all: init twt

This commit is contained in:
Amaan Qureshi
2026-03-01 22:38:26 -05:00
commit 4e1439f27e
122 changed files with 18700 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
[*.rs]
indent_style = space
indent_size = 3
+1
View File
@@ -0,0 +1 @@
use flake
+35
View File
@@ -0,0 +1,35 @@
# Ignore everything
*
# Track project files
!.editorconfig
!.envrc
!.gitignore
!.rustfmt.toml
!.taplo.toml
!Cargo.lock
!Cargo.toml
!README.md
# Config (example only, not real config)
!config/
!config/teapot.example.toml
# Session example (never real sessions)
!sessions.example.jsonl
# Source
!src/
!src/**
# Static assets
!public/
!public/**
# Sass sources
!sass/
!sass/**
# Nix
!flake.lock
!flake.nix
+26
View File
@@ -0,0 +1,26 @@
condense_wildcard_suffixes = true
doc_comment_code_block_width = 100
edition = "2024" # Keep in sync with Cargo.toml.
enum_discrim_align_threshold = 60
float_literal_trailing_zero = "Always"
force_multiline_blocks = true
format_code_in_doc_comments = true
format_macro_matchers = true
format_strings = true
group_imports = "StdExternalCrate"
hex_literal_case = "Upper"
imports_granularity = "Crate"
imports_layout = "Vertical"
inline_attribute_width = 60
match_block_trailing_comma = true
max_width = 100
newline_style = "Unix"
normalize_comments = true
normalize_doc_attributes = true
overflow_delimited_expr = true
struct_field_align_threshold = 60
tab_spaces = 3
unstable_features = true
use_field_init_shorthand = true
use_try_shorthand = true
wrap_comments = true
+13
View File
@@ -0,0 +1,13 @@
[formatting]
align_entries = true
column_width = 100
compact_arrays = false
reorder_inline_tables = true
reorder_keys = true
[[rule]]
include = [ "**/Cargo.toml" ]
keys = [ "package" ]
[rule.formatting]
reorder_keys = false
Generated
+1468
View File
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
[package]
name = "teapot"
version = "0.1.0"
edition = "2024"
authors = [ "amaanq" ]
description = "A privacy-focused Twitter/X frontend written in Rust"
license = "AGPL-3.0"
repository = "https://github.com/amaanq/teapot"
keywords = [ "twitter", "privacy", "frontend", "teapot" ]
categories = [ "web-programming::http-server" ]
[dependencies]
axum = { features = [ "macros" ], version = "0.8.8" }
axum-extra = { features = [ "cookie" ], version = "0.12.5" }
bytes = "1.11.1"
data-encoding = "2.10.0"
eyre = "0.6.12"
flate2 = "1.1.9"
form_urlencoded = "1.2.2"
http-body-util = "0.1.3"
hyper = { features = [ "http1", "client" ], version = "1.8.1" }
hyper-rustls = { default-features = false, features = [
"ring",
"http1",
"tls12",
"logging",
"native-tokio",
], version = "0.27.7" }
hyper-util = { features = [ "client-legacy", "http1", "tokio" ], version = "0.1.20" }
maud = { features = [ "axum" ], version = "0.27.0" }
percent-encoding = "2.3.2"
regex = "1.12.3"
ring = "0.17.14"
rustls = { default-features = false, features = [
"ring",
"logging",
"std",
"tls12",
], version = "0.23.37" }
serde = { features = [ "derive" ], version = "1.0.228" }
serde_json = "1.0.149"
thiserror = "2.0.18"
time = { features = [ "formatting", "parsing", "macros", "serde" ], version = "0.3.47" }
tokio = { features = [
"rt-multi-thread",
"net",
"macros",
"sync",
"fs",
"io-util",
], version = "1.49.0" }
toml = "1.0.3"
tower = "0.5.3"
tower-http = { features = [ "fs", "cors", "compression-gzip" ], version = "0.6.8" }
tracing = "0.1.44"
tracing-subscriber = { features = [ "env-filter" ], version = "0.3.22" }
xitter-txid = { default-features = false, version = "0.1.0" }
[dev-dependencies]
tokio-test = "0.4.5"
[profile.release]
codegen-units = 1
lto = true
panic = "abort"
strip = true
[lints.clippy]
cargo = { level = "warn", priority = -1 }
nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
blanket_clippy_restriction_lints = "allow"
restriction = { level = "warn", priority = -1 }
arbitrary_source_item_ordering = "allow"
arithmetic_side_effects = "allow"
as_conversions = "allow"
default_numeric_fallback = "allow"
else_if_without_else = "allow"
error_impl_error = "allow"
exhaustive_structs = "allow"
expect_used = "allow"
float_arithmetic = "allow"
implicit_return = "allow"
indexing_slicing = "allow"
integer_division = "allow"
integer_division_remainder_used = "allow"
let_underscore_must_use = "allow"
let_underscore_untyped = "allow"
map_err_ignore = "allow"
missing_docs_in_private_items = "allow"
mod_module_files = "allow"
multiple_crate_versions = "allow"
needless_borrowed_reference = "allow"
non_ascii_literal = "allow"
pub_use = "allow"
question_mark_used = "allow"
ref_patterns = "allow"
semicolon_outside_block = "allow"
separated_literal_suffix = "allow"
shadow_reuse = "allow"
single_call_fn = "allow"
single_char_lifetime_names = "allow"
std_instead_of_alloc = "allow"
std_instead_of_core = "allow"
string_slice = "allow"
too_many_lines = "allow"
unimplemented = "allow"
unreachable = "allow"
unwrap_in_result = "allow"
unwrap_used = "allow"
wildcard_enum_match_arm = "allow"
+105
View File
@@ -0,0 +1,105 @@
# teapot
A privacy-focused Twitter/X frontend written in Rust.
## Features
- **Privacy-focused**: No JavaScript required, no tracking, no ads
- **RSS feeds**: Subscribe to any user's tweets
- **FxEmbed-style Discord embeds**:
- Multiple images displayed in carousel on mobile
- Videos play directly in the embed
- Proper image dimensions for better layout
- ActivityPub JSON endpoint for rich embeds
## Requirements
- Rust 1.70+
- Twitter/X session tokens for API access
## Building
```bash
cargo build --release
```
## Configuration
1. Copy the example config:
```bash
cp config/teapot.example.toml config/teapot.toml
```
1. Edit `config/teapot.toml` with your settings.
2. Create a sessions file with your Twitter/X credentials:
```bash
cp sessions.example.jsonl sessions.jsonl
# Edit sessions.jsonl with your auth_token and ct0 from browser cookies
```
## Running
```bash
# Development
cargo run
# Production
./target/release/teapot
```
The server will start on `http://localhost:8080` by default.
## Getting Twitter Sessions
To use the Twitter API, you need session tokens from a logged-in Twitter account:
1. Log into Twitter/X in your browser
2. Open Developer Tools (F12) → Application → Cookies
3. Copy the values of `auth_token` and `ct0`
4. Add them to `sessions.jsonl`
## Project Structure
```
teapot/
├── src/
│ ├── main.rs # Entry point
│ ├── config.rs # Configuration
│ ├── error.rs # Error types
│ ├── types/ # Data structures (User, Tweet, Timeline, etc.)
│ ├── api/ # Twitter API client, OAuth, parsing
│ ├── cache/ # In-process caching
│ ├── routes/ # HTTP route handlers
│ ├── views/ # Maud HTML templates
│ └── utils/ # Utilities (HMAC, formatters)
├── public/ # Static assets (CSS, JS, fonts)
├── config/ # Configuration files
└── Cargo.toml # Dependencies
```
## Embed Improvements
This rewrite includes FxEmbed-style improvements for Discord embeds:
### Multiple Images
Discord can display all images from a tweet in a carousel. This works via an ActivityPub JSON endpoint at `/users/{username}/statuses/{id}` that returns media attachments.
### Video Playback
Videos can be played directly in Discord embeds using `twitter:player` meta tags:
- `twitter:player` - Embed URL
- `twitter:player:stream` - Direct MP4 URL
- `twitter:player:width/height` - Dimensions
### Image Dimensions
All images include `og:image:width` and `og:image:height` meta tags for proper layout.
## License
AGPL-3.0
+56
View File
@@ -0,0 +1,56 @@
# teapot Configuration
[server]
# Domain for generating links - change this to your own domain/ip
hostname = "localhost"
title = "teapot"
address = "0.0.0.0"
port = 8080
https = false # Set to true when using HTTPS
httpMaxConnections = 100
staticDir = "./public"
[cache]
# Cache TTL settings (in-process cache, no external dependencies)
listMinutes = 240 # How long to cache list info
rssMinutes = 10 # How long to cache RSS queries
[config]
# Secret key for cryptographic signing of video URLs
hmacKey = "secretkey"
# Use base64 encoding for proxied media URLs
base64Media = false
# Enable RSS feeds
enableRSS = true
# Enable debug endpoints (/.sessions)
enableDebug = false
# HTTP/HTTPS proxy (SOCKS proxies not supported)
proxy = ""
proxyAuth = ""
# API proxy host, e.g. localhost:7000
apiProxy = ""
# Enable this if cookie-based auth is failing
disableTid = false
# Max requests per session to avoid race conditions
maxConcurrentReqs = 2
[preferences]
# Default theme
theme = "teapot"
# URL replacements for privacy
replaceTwitter = ""
replaceYouTube = ""
replaceReddit = ""
# Media handling
proxyVideos = true
hlsPlayback = false
infiniteScroll = false
Generated
+66
View File
@@ -0,0 +1,66 @@
{
"nodes": {
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1772261909,
"narHash": "sha256-8XbJXrhMFhLgoBrjFIJx5XJi+SD+7/gbvaIXCuqy9Z0=",
"owner": "nix-community",
"repo": "fenix",
"rev": "e4c413b9546d6c9e6426b33b4d6de1a49a375024",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1772173633,
"narHash": "sha256-MOH58F4AIbCkh6qlQcwMycyk5SWvsqnS/TCfnqDlpj4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c0f3d81a7ddbc2b1332be0d8481a672b4f6004d6",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"fenix": "fenix",
"nixpkgs": "nixpkgs"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1772178959,
"narHash": "sha256-DkjUvrEnnhHjOcjMx6aXfYGIZ0PWmcYzvVayhRj1r4M=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "8494a8b3b769c17e8594d811012cc1b0fab090c7",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+373
View File
@@ -0,0 +1,373 @@
{
description = "teapot - A privacy-focused Twitter/X frontend written in Rust";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
nixpkgs,
fenix,
}:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
in
{
packages = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
fenixPkgs = fenix.packages.${system}.latest;
rustPlatform = pkgs.makeRustPlatform {
cargo = fenixPkgs.cargo;
rustc = fenixPkgs.rustc;
};
in
{
default = rustPlatform.buildRustPackage {
pname = "teapot";
version = "0.1.0";
src = pkgs.lib.fileset.toSource {
root = ./.;
fileset = pkgs.lib.fileset.unions [
./Cargo.toml
./Cargo.lock
./src
./sass
./public
./config
];
};
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [
pkgs.pkg-config
pkgs.dart-sass
];
doCheck = false;
postInstall = ''
mkdir -p $out/share/teapot
cp -r public $out/share/teapot/
cp -r config $out/share/teapot/
# Compile SCSS
sass sass/index.scss:$out/share/teapot/public/css/style.css \
--load-path=sass/include --style=compressed --no-source-map
'';
meta = with pkgs.lib; {
description = "A privacy-focused Twitter/X frontend written in Rust";
homepage = "https://github.com/amaanq/teapot";
license = licenses.agpl3Only;
mainProgram = "teapot";
};
};
}
);
devShells = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
toolchain = fenix.packages.${system}.latest.toolchain;
in
{
default = pkgs.mkShell {
buildInputs = [
toolchain
pkgs.rust-analyzer
pkgs.pkg-config
];
shellHook = ''
echo "teapot development shell"
echo ""
echo "Quick start:"
echo " 1. cp config/teapot.example.toml config/teapot.toml"
echo " 2. Create sessions.jsonl with your tokens"
echo " 3. cargo run"
'';
};
}
);
nixosModules.default =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.teapot;
configFile = pkgs.writeText "teapot.toml" ''
${lib.generators.toINI
{
mkKeyValue = lib.generators.mkKeyValueDefault {
mkValueString =
v:
if lib.isString v then
"\"" + (lib.escape [ "\"" ] (toString v)) + "\""
else
lib.generators.mkValueStringDefault { } v;
} " = ";
}
(
lib.recursiveUpdate {
inherit (cfg) cache preferences;
config = cfg.config // {
hmacKey = "@hmac@";
};
server = cfg.server // {
staticDir = "${cfg.package}/share/teapot/public";
};
} cfg.settings
)
}
'';
preStart = pkgs.writers.writePython3 "teapot-prestart" { } ''
import os
import secrets
state_dir = os.environ.get("STATE_DIRECTORY")
if not os.path.isfile(f"{state_dir}/hmac"):
hmac = secrets.token_hex(32)
with open(f"{state_dir}/hmac", "w") as f:
f.write(hmac)
else:
with open(f"{state_dir}/hmac", "r") as f:
hmac = f.read()
configFile = "${configFile}"
with open(configFile, "r") as f_in:
with open(f"{state_dir}/teapot.toml", "w") as f_out:
f_out.write(f_in.read().replace("@hmac@", hmac))
'';
in
{
options.services.teapot = {
enable = lib.mkEnableOption "teapot, a privacy-focused Twitter/X frontend";
package = lib.mkPackageOption self.packages.${pkgs.system} "default" { };
server = {
hostname = lib.mkOption {
type = lib.types.str;
default = "localhost";
example = "teapot.example.com";
description = "Hostname of the instance.";
};
title = lib.mkOption {
type = lib.types.str;
default = "teapot";
description = "Title of the instance.";
};
address = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
example = "127.0.0.1";
description = "The address to listen on.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "The port to listen on.";
};
https = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Set secure attribute on cookies. Enable when using HTTPS.";
};
httpMaxConnections = lib.mkOption {
type = lib.types.int;
default = 100;
description = "Maximum number of HTTP connections.";
};
};
cache = {
listMinutes = lib.mkOption {
type = lib.types.int;
default = 240;
description = "How long to cache list info (minutes).";
};
rssMinutes = lib.mkOption {
type = lib.types.int;
default = 10;
description = "How long to cache RSS queries (minutes).";
};
};
config = {
base64Media = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Use base64 encoding for proxied media URLs.";
};
enableRSS = lib.mkEnableOption "RSS feeds" // {
default = true;
};
enableDebug = lib.mkEnableOption "request logs and debug endpoints";
proxy = lib.mkOption {
type = lib.types.str;
default = "";
description = "URL to a HTTP/HTTPS proxy.";
};
proxyAuth = lib.mkOption {
type = lib.types.str;
default = "";
description = "Credentials for proxy.";
};
apiProxy = lib.mkOption {
type = lib.types.str;
default = "";
description = "API proxy host for requests.";
};
disableTid = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Disable TID for cookie-based auth.";
};
maxConcurrentReqs = lib.mkOption {
type = lib.types.int;
default = 2;
description = "Max concurrent requests per session.";
};
};
preferences = {
theme = lib.mkOption {
type = lib.types.str;
default = "teapot";
description = "Instance theme.";
};
replaceTwitter = lib.mkOption {
type = lib.types.str;
default = "";
description = "Replace Twitter links with links to this instance.";
};
replaceYouTube = lib.mkOption {
type = lib.types.str;
default = "";
description = "Replace YouTube links with this instance.";
};
replaceReddit = lib.mkOption {
type = lib.types.str;
default = "";
description = "Replace Reddit links with this instance.";
};
proxyVideos = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Proxy video streaming through the server.";
};
hlsPlayback = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable HLS video streaming (requires JavaScript).";
};
infiniteScroll = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Infinite scrolling (requires JavaScript).";
};
};
settings = lib.mkOption {
type = lib.types.attrs;
default = { };
description = "Additional settings to override module-generated config.";
};
sessionsFile = lib.mkOption {
type = lib.types.path;
default = "/var/lib/teapot/sessions.jsonl";
description = ''
Path to the session tokens file (JSONL format).
Each line: {"oauth_token":"...","oauth_token_secret":"..."}
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open ports in the firewall for the web interface.";
};
};
config = lib.mkIf cfg.enable {
systemd.services.teapot = {
description = "teapot (privacy-focused Twitter/X frontend)";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
DynamicUser = true;
LoadCredential = "sessionsFile:${cfg.sessionsFile}";
StateDirectory = "teapot";
Environment = [
"TEAPOT_CONF_FILE=/var/lib/teapot/teapot.toml"
"TEAPOT_SESSIONS_FILE=%d/sessionsFile"
];
WorkingDirectory = "${cfg.package}/share/teapot";
ExecStart = "${cfg.package}/bin/teapot";
ExecStartPre = "${preStart}";
AmbientCapabilities = lib.mkIf (cfg.server.port < 1024) [ "CAP_NET_BIND_SERVICE" ];
Restart = "on-failure";
RestartSec = "5s";
# Hardening
CapabilityBoundingSet = if (cfg.server.port < 1024) then [ "CAP_NET_BIND_SERVICE" ] else [ "" ];
DeviceAllow = [ "" ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
PrivateDevices = true;
PrivateUsers = cfg.server.port >= 1024;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
];
UMask = "0077";
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.server.port ];
};
};
};
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<TileColor>#2b5797</TileColor>
</tile>
</msapplication>
</browserconfig>
+138
View File
@@ -0,0 +1,138 @@
@font-face {
font-family: "fontello";
src: url("/fonts/fontello.eot?77185648");
src:
url("/fonts/fontello.eot?77185648#iefix") format("embedded-opentype"),
url("/fonts/fontello.woff2?77185648") format("woff2"),
url("/fonts/fontello.woff?77185648") format("woff"),
url("/fonts/fontello.ttf?77185648") format("truetype"),
url("/fonts/fontello.svg?77185648#fontello") format("svg");
font-weight: normal;
font-style: normal;
}
[class^="icon-"]:before,
[class*=" icon-"]:before {
font-family: "fontello";
font-style: normal;
font-weight: normal;
speak: never;
display: inline-block;
text-decoration: inherit;
width: 1em;
margin-right: 0.2em;
text-align: center;
/* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal;
text-transform: none;
/* fix buttons height, for twitter bootstrap */
line-height: 1em;
/* Font smoothing. That was taken from TWBS */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-views:before {
content: "\e800";
}
/* '' */
.icon-heart:before {
content: "\e801";
}
/* '' */
.icon-quote:before {
content: "\e802";
}
/* '' */
.icon-comment:before {
content: "\e803";
}
/* '' */
.icon-play:before {
content: "\e805";
}
/* '' */
.icon-link:before {
content: "\e806";
}
/* '' */
.icon-calendar:before {
content: "\e807";
}
/* '' */
.icon-location:before {
content: "\e808";
}
/* '' */
.icon-picture:before {
content: "\e809";
}
/* '' */
.icon-lock:before {
content: "\e80a";
}
/* '' */
.icon-down:before {
content: "\e80b";
}
/* '' */
.icon-retweet:before {
content: "\e80c";
}
/* '' */
.icon-search:before {
content: "\e80d";
}
/* '' */
.icon-pin:before {
content: "\e80e";
}
/* '' */
.icon-cog:before {
content: "\e80f";
}
/* '' */
.icon-rss:before {
content: "\e810";
}
/* '' */
.icon-ok:before {
content: "\e811";
}
/* '' */
.icon-circle:before {
content: "\f111";
}
/* '' */
.icon-info:before {
content: "\f128";
}
/* '' */
.icon-bird:before {
content: "\f309";
}
/* '' */
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
@import "teapot.css" (prefers-color-scheme: dark);
@import "twitter.css" (prefers-color-scheme: light);
+2
View File
@@ -0,0 +1,2 @@
@import "twitter_dark.css" (prefers-color-scheme: dark);
@import "twitter.css" (prefers-color-scheme: light);
+37
View File
@@ -0,0 +1,37 @@
body {
--bg_color: #000000;
--fg_color: #FFFFFF;
--fg_faded: #FFFFFFD4;
--fg_dark: var(--accent);
--fg_nav: var(--accent);
--bg_panel: #0C0C0C;
--bg_elements: #000000;
--bg_overlays: var(--bg_panel);
--bg_hover: #131313;
--grey: #E2E2E2;
--dark_grey: #4E4E4E;
--darker_grey: #272727;
--darkest_grey: #212121;
--border_grey: #7D7D7D;
--accent: #FF6C60;
--accent_light: #FFACA0;
--accent_dark: #909090;
--accent_border: #FF6C6091;
--play_button: var(--accent);
--play_button_hover: var(--accent_light);
--more_replies_dots: #A7A7A7;
--error_red: #420A05;
--verified_blue: #1DA1F2;
--icon_text: var(--fg_color);
--tab: var(--fg_color);
--tab_selected: var(--accent);
--profile_stat: var(--fg_color);
}
+41
View File
@@ -0,0 +1,41 @@
body {
--bg_color: #282a36;
--fg_color: #f8f8f2;
--fg_faded: #818eb6;
--fg_dark: var(--fg_faded);
--fg_nav: var(--accent);
--bg_panel: #343746;
--bg_elements: #292b36;
--bg_overlays: #44475a;
--bg_hover: #2f323f;
--grey: var(--fg_faded);
--dark_grey: #44475a;
--darker_grey: #3d4051;
--darkest_grey: #363948;
--border_grey: #44475a;
--accent: #bd93f9;
--accent_light: #caa9fa;
--accent_dark: var(--accent);
--accent_border: #ff79c696;
--play_button: #ffb86c;
--play_button_hover: #ffc689;
--more_replies_dots: #bd93f9;
--error_red: #ff5555;
--verified_blue: var(--accent);
--icon_text: ##F8F8F2;
--tab: #6272a4;
--tab_selected: var(--accent);
--profile_stat: #919cbf;
}
.search-bar > form input::placeholder{
color: var(--fg_faded);
}
+37
View File
@@ -0,0 +1,37 @@
body {
--bg_color: #191B22;
--fg_color: #FFFFFF;
--fg_faded: #F8F8F2CF;
--fg_dark: var(--accent);
--fg_nav: var(--accent);
--bg_panel: #282C37;
--bg_elements: #22252F;
--bg_overlays: var(--bg_panel);
--bg_hover: var(--bg_elements);
--grey: #8595AB;
--dark_grey: #393F4F;
--darker_grey: #1C1E23;
--darkest_grey: #1F2125;
--border_grey: #393F4F;
--accent: #9BAEC8;
--accent_light: #CDDEF5;
--accent_dark: #748294;
--accent_border: var(--accent_dark);
--play_button: var(--accent);
--play_button_hover: var(--accent_light);
--more_replies_dots: #7F8DA0;
--error_red: #420A05;
--verified_blue: #1DA1F2;
--icon_text: var(--fg_color);
--tab: var(--accent);
--tab_selected: #D9E1E8;
--profile_stat: var(--fg_color);
}
+37
View File
@@ -0,0 +1,37 @@
body {
--bg_color: #0A1117;
--fg_color: #B9B9BA;
--fg_faded: #B9B9BAFA;
--fg_dark: var(--accent);
--fg_nav: var(--accent);
--bg_panel: #121A24;
--bg_elements: #182230;
--bg_overlays: var(--bg_elements);
--bg_hover: #1B2735;
--grey: #666A6F;
--dark_grey: #42413D;
--darker_grey: #293442;
--darkest_grey: #202935;
--border_grey: #1C2737;
--accent: #D8A070;
--accent_light: #DEB897;
--accent_dark: #6D533C;
--accent_border: #947050;
--play_button: var(--accent);
--play_button_hover: var(--accent_light);
--more_replies_dots: #886446;
--error_red: #420A05;
--verified_blue: #1DA1F2;
--icon_text: #F8F8F8;
--tab: var(--fg_color);
--tab_selected: var(--accent);
--profile_stat: var(--fg_color);
}
+3
View File
@@ -0,0 +1,3 @@
body {
/* uses default values */
}
+37
View File
@@ -0,0 +1,37 @@
body {
--bg_color: #E6ECF0;
--fg_color: #0F0F0F;
--fg_faded: #657786;
--fg_dark: var(--fg_faded);
--fg_nav: var(--accent);
--bg_panel: #FFFFFF;
--bg_elements: #FDFDFD;
--bg_overlays: #FFFFFF;
--bg_hover: #F5F8FA;
--grey: var(--fg_faded);
--dark_grey: #D6D6D6;
--darker_grey: #CECECE;
--darkest_grey: #ECECEC;
--border_grey: #E6ECF0;
--accent: #1DA1F2;
--accent_light: #A0EDFF;
--accent_dark: var(--accent);
--accent_border: #1DA1F296;
--play_button: #D84D4D;
--play_button_hover: #FF6C60;
--more_replies_dots: #0199F7;
--error_red: #FF7266;
--verified_blue: var(--accent);
--icon_text: #F8F8F2;
--tab: var(--accent);
--tab_selected: #000000;
--profile_stat: var(--fg_dark);
}
+37
View File
@@ -0,0 +1,37 @@
body {
--bg_color: #101821;
--fg_color: #FFFFFF;
--fg_faded: #8899A6;
--fg_dark: var(--fg_faded);
--fg_nav: var(--accent);
--bg_panel: #15202B;
--bg_elements: var(--bg_panel);
--bg_overlays: var(--bg_panel);
--bg_hover: #192734;
--grey: var(--fg_faded);
--dark_grey: #38444D;
--darker_grey: #2A343C;
--darkest_grey:#1B2835;
--border_grey: #38444D;
--accent: #1B95E0;
--accent_light: #80CEFF;
--accent_dark: #2B608A;
--accent_border: #1B95E096;
--play_button: var(--accent);
--play_button_hover: var(--accent_light);
--more_replies_dots: #39719C;
--error_red: #FF7266;
--verified_blue: var(--accent);
--icon_text: var(--fg_color);
--tab: var(--grey);
--tab_selected: var(--accent);
--profile_stat: var(--fg_color);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+48
View File
@@ -0,0 +1,48 @@
Font license info
## Modern Pictograms
Copyright (c) 2012 by John Caserta. All rights reserved.
Author: John Caserta
License: SIL (http://scripts.sil.org/OFL)
Homepage: http://thedesignoffice.org/project/modern-pictograms/
## Entypo
Copyright (C) 2012 by Daniel Bruce
Author: Daniel Bruce
License: SIL (http://scripts.sil.org/OFL)
Homepage: http://www.entypo.com
## Iconic
Copyright (C) 2012 by P.J. Onori
Author: P.J. Onori
License: SIL (http://scripts.sil.org/OFL)
Homepage: http://somerandomdude.com/work/iconic/
## Font Awesome
Copyright (C) 2016 by Dave Gandy
Author: Dave Gandy
License: SIL ()
Homepage: http://fortawesome.github.com/Font-Awesome/
## Elusive
Copyright (C) 2013 by Aristeides Stathopoulos
Author: Aristeides Stathopoulos
License: SIL (http://scripts.sil.org/OFL)
Homepage: http://aristeides.com/
Binary file not shown.
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Copyright (C) 2025 by original authors @ fontello.com</metadata>
<defs>
<font id="fontello" horiz-adv-x="1000" >
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
<missing-glyph horiz-adv-x="1000" />
<glyph glyph-name="views" unicode="&#xe800;" d="M180 516l0-538-180 0 0 538 180 0z m250-138l0-400-180 0 0 400 180 0z m250 344l0-744-180 0 0 744 180 0z" horiz-adv-x="680" />
<glyph glyph-name="heart" unicode="&#xe801;" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" />
<glyph glyph-name="quote" unicode="&#xe802;" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" />
<glyph glyph-name="comment" unicode="&#xe803;" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
<glyph glyph-name="play" unicode="&#xe805;" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
<glyph glyph-name="link" unicode="&#xe806;" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
<glyph glyph-name="calendar" unicode="&#xe807;" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
<glyph glyph-name="location" unicode="&#xe808;" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" />
<glyph glyph-name="picture" unicode="&#xe809;" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
<glyph glyph-name="lock" unicode="&#xe80a;" d="M0 350q0 207 147 354t353 146 354-146 146-354-146-354-354-146-353 146-147 354z m252-271l496 0 0 314-82 0 0 59q0 33-14 66-19 45-62 74t-94 30-92-29-62-75q-16-35-14-125l-76 0 0-314z m176 314l0 59q2 31 19 49t45 21l4 0q29-2 49-22t21-48l0-59-138 0z" horiz-adv-x="1000" />
<glyph glyph-name="down" unicode="&#xe80b;" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
<glyph glyph-name="retweet" unicode="&#xe80c;" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
<glyph glyph-name="search" unicode="&#xe80d;" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" />
<glyph glyph-name="pin" unicode="&#xe80e;" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" />
<glyph glyph-name="cog" unicode="&#xe80f;" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" />
<glyph glyph-name="rss" unicode="&#xe810;" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" />
<glyph glyph-name="ok" unicode="&#xe811;" d="M933 534q0-22-16-38l-404-404-76-76q-16-15-38-15t-38 15l-76 76-202 202q-15 16-15 38t15 38l76 76q16 16 38 16t38-16l164-165 366 367q16 16 38 16t38-16l76-76q16-15 16-38z" horiz-adv-x="1000" />
<glyph glyph-name="circle" unicode="&#xf111;" d="M857 350q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
<glyph glyph-name="info" unicode="&#xf128;" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" />
<glyph glyph-name="bird" unicode="&#xf309;" d="M920 636q-36-54-94-98l0-24q0-130-60-250t-186-203-290-83q-160 0-290 84 14-2 46-2 132 0 234 80-62 2-110 38t-66 94q10-4 34-4 26 0 50 6-66 14-108 66t-42 120l0 2q36-20 84-24-84 58-84 158 0 48 26 94 154-188 390-196-6 18-6 42 0 78 55 133t135 55q82 0 136-58 60 12 120 44-20-66-82-104 56 8 108 30z" horiz-adv-x="920" />
</font>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
File diff suppressed because one or more lines are too long
+25
View File
@@ -0,0 +1,25 @@
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
// SPDX-License-Identifier: AGPL-3.0-only
function playVideo(overlay) {
const video = overlay.parentElement.querySelector('video');
const url = video.getAttribute("data-url");
video.setAttribute("controls", "");
overlay.style.display = "none";
if (Hls.isSupported()) {
var hls = new Hls({autoStartLoad: false});
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function () {
hls.loadLevel = hls.levels.length - 1;
hls.startLoad();
video.play();
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
video.addEventListener('canplay', function() {
video.play();
});
}
}
// @license-end
+77
View File
@@ -0,0 +1,77 @@
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
// SPDX-License-Identifier: AGPL-3.0-only
function insertBeforeLast(node, elem) {
node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
}
function getLoadMore(doc) {
return doc.querySelector(".show-more:not(.timeline-item)");
}
function isDuplicate(item, itemClass) {
const tweet = item.querySelector(".tweet-link");
if (tweet == null) return false;
const href = tweet.getAttribute("href");
return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null;
}
window.onload = function () {
const url = window.location.pathname;
const isTweet = url.indexOf("/status/") !== -1;
const containerClass = isTweet ? ".replies" : ".timeline";
const itemClass = containerClass + " > div:not(.top-ref)";
var html = document.querySelector("html");
var container = document.querySelector(containerClass);
var loading = false;
function handleScroll(failed) {
if (loading) return;
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
loading = true;
var loadMore = getLoadMore(document);
if (loadMore == null) return;
loadMore.children[0].text = "Loading...";
var url = new URL(loadMore.children[0].href);
url.searchParams.append("scroll", "true");
fetch(url.toString()).then(function (response) {
if (response.status === 404) throw "error";
return response.text();
}).then(function (html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, "text/html");
loadMore.remove();
for (var item of doc.querySelectorAll(itemClass)) {
if (item.className == "timeline-item show-more") continue;
if (isDuplicate(item, itemClass)) continue;
if (isTweet || isEngagement) container.appendChild(item);
else insertBeforeLast(container, item);
}
loading = false;
const newLoadMore = getLoadMore(doc);
if (newLoadMore == null) return;
if (isTweet || isEngagement) container.appendChild(newLoadMore);
else insertBeforeLast(container, newLoadMore);
}).catch(function (err) {
console.warn("Something went wrong.", err);
if (failed > 3) {
loadMore.children[0].text = "Error";
return;
}
loading = false;
handleScroll((failed || 0) + 1);
});
}
}
window.addEventListener("scroll", () => handleScroll());
};
// @license-end
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg class="lp" viewBox="0 0 40.6 52.3">
<g transform="matrix(0.83,0,0,0.83,-158,-261)">
<path d="m202.5,366c-3.1 0-5.5-0.4-7.3-1.2-1.8-0.8-3-1.9-3.8-3.3-0.8-1.4-1.1-3-1.1-4.8 0-1.8 0.3-3.7 0.8-5.8l8.3-34.8 10.2-1.6-9.1 37.8c-0.2 0.8-0.3 1.5-0.3 2.2 0 0.7 0.1 1.2 0.4 1.7 0.3 0.5 0.7 0.9 1.3 1.2 0.6 0.3 1.5 0.5 2.7 0.6l-2 8.1"/>
<path d="m239.2 344.3c0 3.2-0.5 6.1-1.6 8.8-1 2.6-2.5 4.9-4.4 6.9-1.9 1.9-4.1 3.4-6.7 4.5-2.6 1.1-5.4 1.6-8.5 1.6-1.5 0-3-0.1-4.5-0.4l-3 11.9h-9.7l10.9-45.4c1.7-0.5 3.7-1 6-1.4 2.3-0.4 4.7-0.6 7.3-0.6 2.4 0 4.6 0.4 6.3 1.1 1.8 0.7 3.2 1.8 4.4 3 1.1 1.3 2 2.8 2.5 4.5 0.5 1.7 0.8 3.6 0.8 5.5m-23.8 13.4c0.7 0.2 1.7 0.3 2.8 0.3 1.7 0 3.3-0.3 4.7-1 1.4-0.6 2.6-1.5 3.6-2.7 1-1.1 1.7-2.5 2.3-4.1 0.5-1.6 0.8-3.4 0.8-5.3 0-1.9-0.4-3.5-1.2-4.8-0.8-1.3-2.3-2-4.3-2-1.4 0-2.7 0.1-3.9 0.4l-4.6 19.1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 847 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>teapot</ShortName>
<Description>Search Twitter via teapot</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image width="16" height="16" type="image/x-icon">/public/favicon.ico</Image>
<Url type="text/html" template="/search?q={searchTerms}"/>
<Url type="application/rss+xml" template="/search/rss?q={searchTerms}"/>
</OpenSearchDescription>
+5
View File
@@ -0,0 +1,5 @@
User-agent: *
Disallow: /
Crawl-delay: 1
User-agent: Twitterbot
Disallow:
+1
View File
@@ -0,0 +1 @@
<svg version="1" xmlns="http://www.w3.org/2000/svg" width="666.667" height="666.667" viewBox="0 0 500.000000 500.000000"><path d="M73.6 33.6L71 36.3v427.4l2.6 2.7 2.7 2.6h87.4l2.7-2.6 2.6-2.7V339.9c0-102.6.2-123.9 1.3-123.9.8 0 41.1 56.1 89.7 124.7 48.5 68.7 89.2 125.6 90.4 126.5 1.9 1.6 5.5 1.8 37.7 1.8h35.6l2.7-2.6 2.6-2.7V36.3l-2.6-2.7-2.7-2.6h-87.4l-2.7 2.6-2.6 2.7v123.8c0 102.6-.2 123.9-1.3 123.9-.8 0-41.1-56.1-89.7-124.8-48.5-68.6-89.2-125.5-90.4-126.4-1.9-1.6-5.5-1.8-37.7-1.8H76.3l-2.7 2.6zm158.9 147.1c51.2 72.3 94.4 133.1 96.1 134.9 2.9 3.1 3.6 3.4 9.1 3.4 5.2 0 6.4-.4 8.7-2.6l2.6-2.7V49h62v402l-25.2-.1h-25.3l-93-131.6c-51.1-72.3-94.4-133.1-96.1-134.9-2.9-3.1-3.6-3.4-9.1-3.4-5.2 0-6.4.4-8.7 2.6l-2.6 2.7V451H89V49l25.3.1h25.2l93 131.6z"/></svg>

After

Width:  |  Height:  |  Size: 761 B

+24
View File
@@ -0,0 +1,24 @@
{
"name": "teapot",
"short_name": "teapot",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#333333",
"background_color": "#333333",
"display": "standalone"
}
+40
View File
@@ -0,0 +1,40 @@
@import "_variables";
@import "_mixins";
.panel-container {
margin: auto;
font-size: 130%;
}
.error-panel {
@include center-panel(var(--error_red));
text-align: center;
}
.search-bar > form {
@include center-panel(var(--darkest_grey));
button {
background: var(--bg_elements);
color: var(--fg_color);
border: 0;
border-radius: 3px;
cursor: pointer;
font-weight: bold;
width: 30px;
height: 30px;
padding: 0px 5px 1px 8px;
}
input {
font-size: 16px;
width: 100%;
background: var(--bg_elements);
color: var(--fg_color);
border: 0;
border-radius: 4px;
padding: 4px;
margin-right: 8px;
height: unset;
}
}
+86
View File
@@ -0,0 +1,86 @@
@import '_variables';
@mixin panel($width, $max-width) {
max-width: $max-width;
margin: 0 auto;
float: none;
border-radius: 0;
position: relative;
width: $width;
}
@mixin play-button {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 1;
&:hover {
.overlay-circle {
border-color: var(--play_button_hover);
}
.overlay-triangle {
border-color: transparent transparent transparent var(--play_button_hover);
}
}
}
@mixin breakable {
overflow: hidden;
overflow-wrap: break-word;
}
@mixin ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin center-panel($bg) {
padding: 12px;
border-radius: 4px;
display: flex;
background: $bg;
box-shadow: 0 0 15px $shadow_dark;
margin: auto;
margin-top: -50px;
}
@mixin input-colors {
&:hover {
border-color: var(--accent);
}
&:active {
border-color: var(--accent_light);
}
}
@mixin search-resize($width, $rows) {
@media(max-width: $width) {
.search-toggles {
grid-template-columns: repeat($rows, auto);
}
#search-panel-toggle:checked ~ .search-panel {
max-height: 380px !important;
}
}
}
@mixin create-toggle($elem, $height) {
##{$elem}-toggle {
display: none;
&:checked ~ .#{$elem} {
max-height: $height;
}
&:checked ~ label .icon-down:before {
transform: rotate(180deg) translateY(-1px);
}
}
}
+43
View File
@@ -0,0 +1,43 @@
// colors
$bg_color: #0f0f0f;
$fg_color: #f8f8f2;
$fg_faded: #f8f8f2cf;
$fg_dark: #ff6c60;
$fg_nav: #ff6c60;
$bg_panel: #161616;
$bg_elements: #121212;
$bg_overlays: #1f1f1f;
$bg_hover: #1a1a1a;
$grey: #888889;
$dark_grey: #404040;
$darker_grey: #282828;
$darkest_grey: #222222;
$border_grey: #3e3e35;
$accent: #ff6c60;
$accent_light: #ffaca0;
$accent_dark: #8a3731;
$accent_border: #ff6c6091;
$play_button: #d8574d;
$play_button_hover: #ff6c60;
$more_replies_dots: #ad433b;
$error_red: #420a05;
$verified_blue: #1da1f2;
$verified_business: #fac82b;
$verified_government: #c1b6a4;
$icon_text: $fg_color;
$tab: $fg_color;
$tab_selected: $accent;
$shadow: rgba(0, 0, 0, 0.6);
$shadow_dark: rgba(0, 0, 0, 0.2);
//fonts
$font_0: sans-serif;
$font_1: fontello;
+202
View File
@@ -0,0 +1,202 @@
@import "_variables";
@import "tweet/_base";
@import "profile/_base";
@import "general";
@import "navbar";
@import "inputs";
@import "timeline";
@import "search";
body {
// colors
--bg_color: #{$bg_color};
--fg_color: #{$fg_color};
--fg_faded: #{$fg_faded};
--fg_dark: #{$fg_dark};
--fg_nav: #{$fg_nav};
--bg_panel: #{$bg_panel};
--bg_elements: #{$bg_elements};
--bg_overlays: #{$bg_overlays};
--bg_hover: #{$bg_hover};
--grey: #{$grey};
--dark_grey: #{$dark_grey};
--darker_grey: #{$darker_grey};
--darkest_grey: #{$darkest_grey};
--border_grey: #{$border_grey};
--accent: #{$accent};
--accent_light: #{$accent_light};
--accent_dark: #{$accent_dark};
--accent_border: #{$accent_border};
--play_button: #{$play_button};
--play_button_hover: #{$play_button_hover};
--more_replies_dots: #{$more_replies_dots};
--error_red: #{$error_red};
--verified_blue: #{$verified_blue};
--verified_business: #{$verified_business};
--verified_government: #{$verified_government};
--icon_text: #{$icon_text};
--tab: #{$fg_color};
--tab_selected: #{$accent};
--profile_stat: #{$fg_color};
background-color: var(--bg_color);
color: var(--fg_color);
font-family: $font_0, $font_1;
font-size: 15px;
line-height: 1.3;
margin: 0;
}
* {
outline: unset;
margin: 0;
text-decoration: none;
}
h1 {
display: inline;
}
h2,
h3 {
font-weight: normal;
}
p {
margin: 14px 0;
}
a {
color: var(--accent);
&:hover {
text-decoration: underline;
}
}
fieldset {
border: 0;
padding: 0;
margin-top: -0.6em;
}
legend {
width: 100%;
padding: 0.6em 0 0.3em 0;
border: 0;
font-size: 16px;
font-weight: 600;
border-bottom: 1px solid var(--border_grey);
margin-bottom: 8px;
}
.preferences .note {
border-top: 1px solid var(--border_grey);
border-bottom: 1px solid var(--border_grey);
padding: 6px 0 8px 0;
margin-bottom: 8px;
margin-top: 16px;
}
ul {
padding-left: 1.3em;
}
.container {
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
padding-top: 50px;
margin: auto;
min-height: 100vh;
}
.icon-container {
display: inline;
}
.overlay-panel {
max-width: 600px;
width: 100%;
margin: 0 auto;
margin-top: 10px;
background-color: var(--bg_overlays);
padding: 10px 15px;
align-self: start;
ul {
margin-bottom: 14px;
}
p {
word-break: break-word;
}
}
.verified-icon {
display: inline-block;
width: 14px;
height: 14px;
margin-left: 2px;
.verified-icon-circle {
position: absolute;
font-size: 15px;
}
.verified-icon-check {
position: absolute;
font-size: 9px;
margin: 5px 3px;
}
&.blue {
.verified-icon-circle {
color: var(--verified_blue);
}
.verified-icon-check {
color: var(--icon_text);
}
}
&.business {
.verified-icon-circle {
color: var(--verified_business);
}
.verified-icon-check {
color: var(--bg_panel);
}
}
&.government {
.verified-icon-circle {
color: var(--verified_government);
}
.verified-icon-check {
color: var(--bg_panel);
}
}
}
@media (max-width: 600px) {
.preferences-container {
max-width: 95vw;
}
.nav-item,
.nav-item .icon-container {
font-size: 16px;
}
}
+203
View File
@@ -0,0 +1,203 @@
@import "_variables";
@import "_mixins";
button {
@include input-colors;
background-color: var(--bg_elements);
color: var(--fg_color);
border: 1px solid var(--accent_border);
padding: 3px 6px;
font-size: 14px;
cursor: pointer;
float: right;
}
input[type="text"],
input[type="date"],
input[type="number"],
select {
@include input-colors;
background-color: var(--bg_elements);
padding: 1px 4px;
color: var(--fg_color);
border: 1px solid var(--accent_border);
border-radius: 0;
font-size: 14px;
}
input[type="number"] {
-moz-appearance: textfield;
}
input[type="text"],
input[type="number"] {
height: 16px;
}
select {
height: 20px;
padding: 0 2px;
line-height: 1;
}
input[type="date"]::-webkit-inner-spin-button {
display: none;
}
input[type="number"] {
-moz-appearance: textfield;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
display: none;
-webkit-appearance: none;
margin: 0;
}
input[type="date"]::-webkit-clear-button {
margin-left: 17px;
filter: grayscale(100%);
filter: hue-rotate(120deg);
}
input::-webkit-calendar-picker-indicator {
opacity: 0;
}
input::-webkit-datetime-edit-day-field:focus,
input::-webkit-datetime-edit-month-field:focus,
input::-webkit-datetime-edit-year-field:focus {
background-color: var(--accent);
color: var(--fg_color);
outline: none;
}
.date-range {
.date-input {
display: inline-block;
position: relative;
}
.icon-container {
pointer-events: none;
position: absolute;
top: 2px;
right: 5px;
}
.search-title {
margin: 0 2px;
}
}
.icon-button button {
color: var(--accent);
text-decoration: none;
background: none;
border: none;
float: none;
padding: unset;
padding-left: 4px;
&:hover {
color: var(--accent_light);
}
}
.checkbox {
position: absolute;
top: 1px;
right: 0;
height: 17px;
width: 17px;
background-color: var(--bg_elements);
border: 1px solid var(--accent_border);
&:after {
content: "";
position: absolute;
display: none;
}
}
.checkbox-container {
display: block;
position: relative;
margin-bottom: 5px;
cursor: pointer;
user-select: none;
padding-right: 22px;
input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
&:checked ~ .checkbox:after {
display: block;
}
}
&:hover input ~ .checkbox {
border-color: var(--accent);
}
&:active input ~ .checkbox {
border-color: var(--accent_light);
}
.checkbox:after {
left: 2px;
bottom: 0;
font-size: 13px;
font-family: $font_1;
content: "\e811";
}
}
.pref-group {
display: inline;
}
.preferences {
button {
margin: 6px 0 3px 0;
}
label {
padding-right: 150px;
}
select {
position: absolute;
top: 0;
right: 0;
display: block;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
}
input[type="text"],
input[type="number"] {
position: absolute;
right: 0;
max-width: 140px;
}
.pref-group {
display: block;
}
.pref-input {
position: relative;
margin-bottom: 6px;
}
.pref-reset {
float: left;
}
}
+87
View File
@@ -0,0 +1,87 @@
@import "_variables";
nav {
display: flex;
align-items: center;
position: fixed;
background-color: var(--bg_overlays);
box-shadow: 0 0 4px $shadow;
padding: 0;
width: 100%;
height: 50px;
z-index: 1000;
font-size: 16px;
a,
.icon-button button {
color: var(--fg_nav);
}
}
.inner-nav {
margin: auto;
box-sizing: border-box;
padding: 0 10px;
display: flex;
align-items: center;
flex-basis: 920px;
height: 50px;
}
.site-name {
font-size: 15px;
font-weight: 600;
line-height: 1;
&:hover {
color: var(--accent_light);
text-decoration: unset;
}
}
.site-logo {
display: block;
width: 35px;
height: 35px;
}
.nav-item {
display: flex;
flex: 1;
line-height: 50px;
height: 50px;
overflow: hidden;
flex-wrap: wrap;
align-items: center;
&.right {
text-align: right;
justify-content: flex-end;
}
&.right a:hover {
color: var(--accent_light);
text-decoration: unset;
}
}
.lp {
height: 14px;
display: inline-block;
position: relative;
top: 2px;
fill: var(--fg_nav);
&:hover {
fill: var(--accent_light);
}
}
.icon-info {
margin: 0 -3px;
}
.icon-cog {
font-size: 15px;
padding-left: 0 !important;
}
+83
View File
@@ -0,0 +1,83 @@
@import '_variables';
@import '_mixins';
@import 'card';
@import 'photo-rail';
.profile-tabs {
@include panel(auto, 900px);
.timeline-container {
float: right;
width: 68% !important;
max-width: unset;
}
}
.profile-banner {
margin-bottom: 4px;
background-color: var(--bg_panel);
a {
display: block;
position: relative;
padding: 33.34% 0 0 0;
}
img {
max-width: 100%;
position: absolute;
top: 0;
}
}
.profile-tab {
padding: 0 4px 0 0;
box-sizing: border-box;
display: inline-block;
font-size: 14px;
text-align: left;
vertical-align: top;
max-width: 32%;
top: 50px;
}
.profile-result {
min-height: 54px;
.username {
margin: 0 !important;
}
.tweet-header {
margin-bottom: unset;
}
}
@media(max-width: 700px) {
.profile-tabs {
width: 100vw;
max-width: 600px;
.timeline-container {
width: 100% !important;
.tab-item wide {
flex-grow: 1.4;
}
}
}
.profile-tab {
width: 100%;
max-width: unset;
position: initial !important;
padding: 0;
}
}
@media (min-height: 900px) {
.profile-tab.sticky {
position: sticky;
}
}
+130
View File
@@ -0,0 +1,130 @@
@import '_variables';
@import '_mixins';
.profile-card {
flex-wrap: wrap;
background: var(--bg_panel);
padding: 12px;
display: flex;
}
.profile-card-info {
@include breakable;
width: 100%;
}
.profile-card-tabs-name {
@include breakable;
max-width: 100%;
}
.profile-card-username {
@include breakable;
color: var(--fg_color);
font-size: 14px;
display: block;
}
.profile-card-fullname {
@include breakable;
color: var(--fg_color);
font-size: 16px;
font-weight: bold;
text-shadow: none;
max-width: 100%;
}
.profile-card-avatar {
display: inline-block;
position: relative;
width: 100%;
margin-right: 4px;
margin-bottom: 6px;
&:after {
content: '';
display: block;
margin-top: 100%;
}
img {
box-sizing: border-box;
position: absolute;
width: 100%;
height: 100%;
border: 4px solid var(--darker_grey);
background: var(--bg_panel);
}
}
.profile-card-extra {
display: contents;
flex: 100%;
margin-top: 7px;
.profile-bio {
@include breakable;
width: 100%;
margin: 4px -6px 6px 0;
white-space: pre-wrap;
p {
margin: 0;
}
}
.profile-joindate, .profile-location, .profile-website {
color: var(--fg_faded);
margin: 1px 0;
width: 100%;
}
}
.profile-card-extra-links {
margin-top: 8px;
font-size: 14px;
width: 100%;
}
.profile-statlist {
display: flex;
flex-wrap: wrap;
padding: 0;
width: 100%;
justify-content: space-between;
li {
display: table-cell;
text-align: center;
}
}
.profile-stat-header {
font-weight: bold;
color: var(--profile_stat);
}
.profile-stat-num {
display: block;
color: var(--profile_stat);
}
@media(max-width: 700px) {
.profile-card-info {
display: flex;
}
.profile-card-tabs-name {
flex-shrink: 100;
}
.profile-card-avatar {
width: 80px;
height: 80px;
img {
border-width: 2px;
width: unset;
}
}
}
+96
View File
@@ -0,0 +1,96 @@
@import '_variables';
.photo-rail {
&-card {
float: left;
background: var(--bg_panel);
border-radius: 0 0 4px 4px;
width: 100%;
margin: 5px 0;
}
&-header {
padding: 5px 12px 0;
}
&-header-mobile {
display: none;
box-sizing: border-box;
padding: 5px 12px 0;
width: 100%;
float: unset;
color: var(--accent);
justify-content: space-between;
}
&-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-gap: 3px 3px;
padding: 5px 12px 12px;
a {
position: relative;
border-radius: 5px;
background-color: var(--darker_grey);
&:before {
content: "";
display: block;
padding-top: 100%;
}
}
img {
height: 100%;
width: 100%;
border-radius: 4px;
object-fit: cover;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
}
}
@include create-toggle(photo-rail-grid, 640px);
#photo-rail-grid-toggle:checked ~ .photo-rail-grid {
padding-bottom: 12px;
}
@media(max-width: 700px) {
.photo-rail-header {
display: none;
}
.photo-rail-header-mobile {
display: flex;
}
.photo-rail-grid {
max-height: 0;
padding-bottom: 0;
overflow: hidden;
transition: max-height 0.4s;
}
.photo-rail-grid {
grid-template-columns: repeat(6, 1fr);
}
#photo-rail-grid-toggle:checked ~ .photo-rail-grid {
max-height: 300px;
}
}
@media(max-width: 450px) {
.photo-rail-grid {
grid-template-columns: repeat(4, 1fr);
}
#photo-rail-grid-toggle:checked ~ .photo-rail-grid {
max-height: 450px;
}
}
+120
View File
@@ -0,0 +1,120 @@
@import "_variables";
@import "_mixins";
.search-title {
font-weight: bold;
display: inline-block;
margin-top: 4px;
}
.search-field {
display: flex;
flex-wrap: wrap;
button {
margin: 0 2px 0 0;
padding: 0px 1px 1px 4px;
height: 23px;
display: flex;
align-items: center;
}
.pref-input {
margin: 0 4px 0 0;
flex-grow: 1;
height: 23px;
}
input[type="text"],
input[type="number"] {
height: calc(100% - 4px);
width: calc(100% - 8px);
}
> label {
display: inline;
background-color: var(--bg_elements);
color: var(--fg_color);
border: 1px solid var(--accent_border);
padding: 1px 1px 2px 4px;
font-size: 14px;
cursor: pointer;
margin-bottom: 2px;
@include input-colors;
}
@include create-toggle(search-panel, 380px);
}
.search-panel {
width: 100%;
max-height: 0;
overflow: hidden;
transition: max-height 0.4s;
flex-grow: 1;
font-weight: initial;
text-align: left;
.checkbox-container {
display: inline;
padding-right: unset;
margin-bottom: 5px;
margin-left: 23px;
}
.checkbox {
right: unset;
left: -22px;
line-height: 1.6em;
}
.checkbox-container .checkbox:after {
top: -4px;
}
}
.search-row {
display: flex;
flex-wrap: wrap;
line-height: unset;
> div {
flex-grow: 1;
flex-shrink: 1;
}
input {
height: 21px;
}
.pref-input {
display: block;
padding-bottom: 5px;
input {
height: 21px;
margin-top: 1px;
}
}
}
.search-toggles {
flex-grow: 1;
display: grid;
grid-template-columns: repeat(5, auto);
grid-column-gap: 10px;
}
.profile-tabs {
@include search-resize(820px, 5);
@include search-resize(715px, 4);
@include search-resize(700px, 5);
@include search-resize(485px, 4);
@include search-resize(410px, 3);
}
@include search-resize(700px, 5);
@include search-resize(485px, 4);
@include search-resize(410px, 3);
+162
View File
@@ -0,0 +1,162 @@
@import "_variables";
.timeline-container {
@include panel(100%, 600px);
}
.timeline {
background-color: var(--bg_panel);
> div:not(:first-child) {
border-top: 1px solid var(--border_grey);
}
}
.timeline-header {
width: 100%;
background-color: var(--bg_panel);
text-align: center;
padding: 8px;
display: block;
font-weight: bold;
margin-bottom: 5px;
box-sizing: border-box;
button {
float: unset;
}
}
.timeline-banner img {
width: 100%;
}
.timeline-description {
font-weight: normal;
}
.tab {
align-items: center;
display: flex;
flex-wrap: wrap;
list-style: none;
margin: 0 0 5px 0;
background-color: var(--bg_panel);
padding: 0;
}
.tab-item {
flex: 1 1 0;
text-align: center;
margin-top: 0;
a {
border-bottom: 0.1rem solid transparent;
color: var(--tab);
display: block;
padding: 8px 0;
text-decoration: none;
font-weight: bold;
&:hover {
text-decoration: none;
}
&.active {
border-bottom-color: var(--tab_selected);
color: var(--tab_selected);
}
}
&.active a {
border-bottom-color: var(--tab_selected);
color: var(--tab_selected);
}
&.wide {
flex-grow: 1.2;
flex-basis: 50px;
}
}
.timeline-footer {
background-color: var(--bg_panel);
padding: 6px 0;
}
.timeline-protected {
text-align: center;
p {
margin: 8px 0;
}
h2 {
color: var(--accent);
font-size: 20px;
font-weight: 600;
}
}
.timeline-none {
color: var(--accent);
font-size: 20px;
font-weight: 600;
text-align: center;
}
.timeline-end {
background-color: var(--bg_panel);
color: var(--accent);
font-size: 16px;
font-weight: 600;
text-align: center;
}
.show-more {
background-color: var(--bg_panel);
text-align: center;
padding: 0.75em 0;
display: block !important;
a {
background-color: var(--darkest_grey);
display: inline-block;
height: 2em;
padding: 0 2em;
line-height: 2em;
&:hover {
background-color: var(--darker_grey);
}
}
}
.top-ref {
background-color: var(--bg_color);
border-top: none !important;
.icon-down {
font-size: 20px;
display: flex;
justify-content: center;
text-decoration: none;
&:hover {
color: var(--accent_light);
}
&::before {
transform: rotate(180deg) translateY(-1px);
}
}
}
.timeline-item {
overflow-wrap: break-word;
border-left-width: 0;
min-width: 0;
padding: 0.75em;
display: flex;
position: relative;
}
+244
View File
@@ -0,0 +1,244 @@
@import "_variables";
@import "_mixins";
@import "thread";
@import "media";
@import "video";
@import "embed";
@import "card";
@import "poll";
@import "quote";
.tweet-body {
flex: 1;
min-width: 0;
margin-left: 58px;
pointer-events: none;
z-index: 1;
}
.tweet-content {
line-height: 1.3em;
pointer-events: all;
display: inline;
}
.tweet-bidi {
display: block !important;
}
.tweet-header {
padding: 0;
vertical-align: bottom;
flex-basis: 100%;
margin-bottom: 0.2em;
a {
display: inline-block;
word-break: break-all;
max-width: 100%;
pointer-events: all;
}
}
.tweet-name-row {
padding: 0;
display: flex;
justify-content: space-between;
}
.fullname-and-username {
display: flex;
min-width: 0;
}
.fullname {
@include ellipsis;
flex-shrink: 2;
max-width: 80%;
font-size: 14px;
font-weight: 700;
color: var(--fg_color);
}
.username {
@include ellipsis;
min-width: 1.6em;
margin-left: 0.4em;
word-wrap: normal;
}
.tweet-date {
display: flex;
flex-shrink: 0;
margin-left: 4px;
}
.tweet-date a,
.username,
.show-more a {
color: var(--fg_dark);
}
.tweet-published {
margin: 0;
margin-top: 5px;
color: var(--grey);
pointer-events: all;
}
.tweet-avatar {
display: contents !important;
img {
float: left;
margin-top: 3px;
margin-left: -58px;
width: 48px;
height: 48px;
}
}
.avatar {
&.round {
border-radius: 50%;
-webkit-user-select: none;
}
&.mini {
position: unset;
margin-right: 5px;
margin-top: -1px;
width: 20px;
height: 20px;
}
}
.tweet-embed {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
background-color: var(--bg_panel);
.tweet-content {
font-size: 18px;
}
.tweet-body {
display: flex;
flex-direction: column;
max-height: calc(100vh - 0.75em * 2);
}
.card-image img {
height: auto;
}
.avatar {
position: absolute;
}
}
.attribution {
display: flex;
pointer-events: all;
margin: 5px 0;
strong {
color: var(--fg_color);
}
}
.media-tag-block {
padding-top: 5px;
pointer-events: all;
color: var(--fg_faded);
.icon-container {
padding-right: 2px;
}
.media-tag,
.icon-container {
color: var(--fg_faded);
}
}
.timeline-container .media-tag-block {
font-size: 13px;
}
.tweet-geo {
color: var(--fg_faded);
}
.replying-to {
color: var(--fg_faded);
margin: -2px 0 4px;
a {
pointer-events: all;
}
}
.retweet-header,
.pinned,
.tweet-stats {
align-content: center;
color: var(--grey);
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
font-size: 14px;
font-weight: 600;
line-height: 22px;
span {
@include ellipsis;
}
}
.retweet-header {
margin-top: -5px !important;
}
.tweet-stats {
margin-bottom: -3px;
-webkit-user-select: none;
}
.tweet-stat {
padding-top: 5px;
min-width: 1em;
margin-right: 0.8em;
}
.show-thread {
display: block;
pointer-events: all;
padding-top: 2px;
}
.unavailable-box {
width: 100%;
height: 100%;
padding: 12px;
border: solid 1px var(--dark_grey);
box-sizing: border-box;
border-radius: 10px;
background-color: var(--bg_color);
z-index: 2;
}
.tweet-link {
height: 100%;
width: 100%;
left: 0;
top: 0;
position: absolute;
-webkit-user-select: none;
&:hover {
background-color: var(--bg_hover);
}
}
+119
View File
@@ -0,0 +1,119 @@
@import '_variables';
@import '_mixins';
.card {
margin: 5px 0;
pointer-events: all;
max-height: unset;
}
.card-container {
border-radius: 10px;
border-width: 1px;
border-style: solid;
border-color: var(--dark_grey);
background-color: var(--bg_elements);
overflow: hidden;
color: inherit;
display: flex;
flex-direction: row;
text-decoration: none !important;
&:hover {
border-color: var(--grey);
}
.attachments {
margin: 0;
border-radius: 0;
}
}
.card-content {
padding: 0.5em;
}
.card-title {
@include ellipsis;
white-space: unset;
font-weight: bold;
font-size: 1.1em;
}
.card-description {
margin: 0.3em 0;
white-space: pre-wrap;
}
.card-destination {
@include ellipsis;
color: var(--grey);
display: block;
}
.card-content-container {
color: unset;
overflow: auto;
&:hover {
text-decoration: none;
}
}
.card-image-container {
width: 98px;
flex-shrink: 0;
position: relative;
overflow: hidden;
&:before {
content: "";
display: block;
padding-top: 100%;
}
}
.card-image {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: var(--bg_overlays);
img {
width: 100%;
height: 100%;
max-height: 400px;
display: block;
object-fit: cover;
}
}
.card-overlay {
@include play-button;
opacity: 0.8;
display: flex;
justify-content: center;
align-items: center;
}
.large {
.card-container {
display: block;
}
.card-image-container {
width: unset;
&:before {
display: none;
}
}
.card-image {
position: unset;
border-style: solid;
border-color: var(--dark_grey);
border-width: 0;
border-bottom-width: 1px;
}
}
+17
View File
@@ -0,0 +1,17 @@
@import "_variables";
@import "_mixins";
.embed-video {
.gallery-video {
width: 100%;
height: 100%;
position: absolute;
background-color: black;
top: 0%;
left: 0%;
}
.video-container {
max-height: unset;
}
}
+119
View File
@@ -0,0 +1,119 @@
@import "_variables";
.gallery-row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
overflow: hidden;
flex-grow: 1;
max-height: 379.5px;
max-width: 533px;
pointer-events: all;
.still-image {
width: 100%;
display: flex;
}
}
.attachments {
margin-top: 0.35em;
display: flex;
flex-direction: row;
width: 100%;
max-height: 600px;
border-radius: 7px;
overflow: hidden;
flex-flow: column;
background-color: var(--bg_color);
align-items: center;
pointer-events: all;
.image-attachment {
width: 100%;
}
}
.attachment {
position: relative;
line-height: 0;
overflow: hidden;
margin: 0 0.25em 0 0;
flex-grow: 1;
box-sizing: border-box;
min-width: 2em;
&:last-child {
margin: 0;
max-height: 530px;
}
}
.gallery-gif video {
max-height: 530px;
background-color: #101010;
}
.still-image {
max-height: 379.5px;
max-width: 533px;
justify-content: center;
img {
object-fit: cover;
max-width: 100%;
max-height: 379.5px;
flex-basis: 300px;
flex-grow: 1;
}
}
.image {
display: inline-block;
}
// .single-image {
// display: inline-block;
// width: 100%;
// max-height: 600px;
// .attachments {
// width: unset;
// max-height: unset;
// display: inherit;
// }
// }
.overlay-circle {
border-radius: 50%;
background-color: var(--dark_grey);
width: 40px;
height: 40px;
align-items: center;
display: flex;
border-width: 5px;
border-color: var(--play_button);
border-style: solid;
}
.overlay-triangle {
width: 0;
height: 0;
border-style: solid;
border-width: 12px 0 12px 17px;
border-color: transparent transparent transparent var(--play_button);
margin-left: 14px;
}
.media-gif {
display: table;
background-color: unset;
width: unset;
}
.media-body {
flex: 1;
padding: 0;
white-space: pre-wrap;
}
+42
View File
@@ -0,0 +1,42 @@
@import "_variables";
.poll-meter {
overflow: hidden;
position: relative;
margin: 6px 0;
height: 26px;
background: var(--bg_color);
border-radius: 5px;
display: flex;
align-items: center;
}
.poll-choice-bar {
height: 100%;
position: absolute;
background: var(--dark_grey);
}
.poll-choice-value {
position: relative;
font-weight: bold;
margin-left: 5px;
margin-right: 6px;
min-width: 30px;
text-align: right;
pointer-events: all;
}
.poll-choice-option {
position: relative;
pointer-events: all;
}
.poll-info {
color: var(--grey);
pointer-events: all;
}
.leader .poll-choice-bar {
background: var(--accent_dark);
}
+95
View File
@@ -0,0 +1,95 @@
@import "_variables";
.quote {
margin-top: 10px;
border: solid 1px var(--dark_grey);
border-radius: 10px;
background-color: var(--bg_elements);
overflow: hidden;
pointer-events: all;
position: relative;
width: 100%;
&:hover {
border-color: var(--grey);
}
&.unavailable:hover {
border-color: var(--dark_grey);
}
.tweet-name-row {
padding: 6px 8px;
margin-top: 1px;
}
.quote-text {
overflow: hidden;
white-space: pre-wrap;
word-wrap: break-word;
padding: 0px 8px 8px 8px;
}
.show-thread {
padding: 0px 8px 6px 8px;
margin-top: -6px;
}
.replying-to {
padding: 0px 8px;
margin: unset;
}
}
.unavailable-quote {
padding: 12px;
}
.quote-link {
width: 100%;
height: 100%;
left: 0;
top: 0;
position: absolute;
}
.quote-media-container {
max-height: 300px;
display: flex;
.card {
margin: unset;
}
.attachments {
border-radius: 0;
}
.media-gif {
width: 100%;
display: flex;
justify-content: center;
}
.gallery-gif .attachment {
display: flex;
justify-content: center;
background-color: var(--bg_color);
video {
height: unset;
width: unset;
max-height: 100%;
max-width: 100%;
}
}
.gallery-video,
.gallery-gif {
max-height: 300px;
}
.still-image img {
max-height: 250px;
}
}
+139
View File
@@ -0,0 +1,139 @@
@import "_variables";
@import "_mixins";
.conversation {
@include panel(100%, 600px);
.show-more {
margin-bottom: 10px;
}
}
.main-thread {
margin-bottom: 20px;
background-color: var(--bg_panel);
}
.main-tweet,
.replies {
padding-top: 50px;
margin-top: -50px;
}
.main-tweet .tweet-content {
font-size: 18px;
}
@media (max-width: 600px) {
.main-tweet .tweet-content {
font-size: 16px;
}
}
.reply {
background-color: var(--bg_panel);
margin-bottom: 10px;
}
.thread-line {
.timeline-item::before,
&.timeline-item::before {
background: var(--accent_dark);
content: "";
position: relative;
min-width: 3px;
width: 3px;
left: 26px;
border-radius: 2px;
margin-left: -3px;
margin-bottom: 37px;
top: 56px;
z-index: 1;
pointer-events: none;
}
.with-header:not(:first-child)::after {
background: var(--accent_dark);
content: "";
position: relative;
float: left;
min-width: 3px;
width: 3px;
right: calc(100% - 26px);
border-radius: 2px;
margin-left: -3px;
margin-bottom: 37px;
bottom: 10px;
height: 30px;
z-index: 1;
pointer-events: none;
}
.unavailable::before {
top: 48px;
margin-bottom: 28px;
}
.more-replies::before {
content: "...";
background: unset;
color: var(--more_replies_dots);
font-weight: bold;
font-size: 20px;
line-height: 0.25em;
left: 1.2em;
width: 5px;
top: 2px;
margin-bottom: 0;
margin-left: -2.5px;
}
.earlier-replies {
padding-bottom: 0;
margin-bottom: -5px;
}
}
.timeline-item.thread-last::before {
background: unset;
min-width: unset;
width: 0;
margin: 0;
}
.more-replies {
padding-top: 0.3em !important;
}
.more-replies-text {
@include ellipsis;
display: block;
margin-left: 58px;
padding: 7px 0;
}
.timeline-item.thread.more-replies-thread {
padding: 0 0.75em;
&::before {
top: 40px;
margin-bottom: 31px;
}
.more-replies {
display: flex;
padding-top: unset !important;
margin-top: 8px;
&::before {
display: inline-block;
position: relative;
top: -1px;
line-height: 0.4em;
}
.more-replies-text {
display: inline;
}
}
}
+77
View File
@@ -0,0 +1,77 @@
@import "_variables";
@import "_mixins";
video {
height: 100%;
width: 100%;
}
.gallery-video {
display: flex;
overflow: hidden;
}
.gallery-video.card-container {
flex-direction: column;
width: 100%;
}
.video-container {
min-height: 80px;
min-width: 200px;
max-height: 530px;
margin: 0;
img {
max-height: 100%;
max-width: 100%;
}
}
.video-overlay {
@include play-button;
background-color: $shadow;
p {
position: relative;
z-index: 0;
text-align: center;
top: calc(50% - 20px);
font-size: 20px;
line-height: 1.3;
margin: 0 20px;
}
.overlay-circle {
position: relative;
z-index: 0;
top: calc(50% - 20px);
margin: 0 auto;
width: 40px;
height: 40px;
}
.overlay-duration {
position: absolute;
bottom: 8px;
left: 8px;
background-color: #0000007a;
line-height: 1em;
padding: 4px 6px 4px 6px;
border-radius: 5px;
font-weight: bold;
}
form {
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
display: flex;
}
button {
padding: 5px 8px;
font-size: 16px;
}
}
+1
View File
@@ -0,0 +1 @@
{"id":1,"username":"example","kind":"cookie","auth_token":"YOUR_AUTH_TOKEN_HERE","ct0":"YOUR_CT0_TOKEN_HERE"}
+343
View File
@@ -0,0 +1,343 @@
use std::{
collections::HashMap,
path::Path,
sync::Arc,
time::{
SystemTime,
UNIX_EPOCH,
},
};
use data_encoding::BASE64;
use ring::hmac;
use serde::Serialize;
use time::format_description::well_known::Rfc3339;
use tokio::{
fs,
sync::RwLock,
};
use crate::{
error::{
Error,
Result,
},
types::{
RateLimit,
Session,
SessionCredentials,
SessionKind,
SessionLimits,
},
};
#[derive(Serialize)]
pub struct HealthResponse {
pub sessions: SessionStats,
pub requests: RequestStats,
pub timestamp: String,
}
#[derive(Serialize)]
pub struct SessionStats {
pub total: usize,
pub limited: usize,
pub available: usize,
}
#[derive(Serialize)]
pub struct RequestStats {
pub total: i32,
pub by_api: HashMap<String, i32>,
}
#[derive(Serialize)]
pub struct DebugResponse {
pub sessions: Vec<SessionDetail>,
pub count: usize,
pub timestamp: String,
}
#[derive(Serialize)]
pub struct SessionDetail {
pub id: i64,
pub username: String,
pub limited: bool,
pub limited_at: i64,
pub pending: i32,
pub apis: HashMap<String, RateLimit>,
}
/// Pool of authentication sessions for Twitter API.
#[derive(Clone)]
pub struct SessionPool {
sessions: Vec<Arc<SessionCredentials>>,
limits: Arc<RwLock<HashMap<i64, SessionLimits>>>,
}
impl SessionPool {
/// Load sessions from a JSONL file.
#[expect(
clippy::cognitive_complexity,
reason = "session loading has inherent branching"
)]
pub async fn load(path: &str) -> Result<Self> {
let parsed = if Path::new(path).exists() {
let content = fs::read_to_string(path).await?;
let mut parsed = Vec::new();
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<Session>(line) {
Ok(session) => parsed.push(session),
Err(err) => {
tracing::warn!("Failed to parse session: {err}");
},
}
}
tracing::info!("Loaded {} sessions", parsed.len());
parsed
} else {
tracing::warn!("Sessions file not found: {path}");
Vec::new()
};
let mut sessions = Vec::with_capacity(parsed.len());
let mut limits = HashMap::with_capacity(parsed.len());
for session in parsed {
let (creds, lims) = session.into_credentials_and_limits();
let id = creds.id;
sessions.push(Arc::new(creds));
limits.insert(id, lims);
}
Ok(Self {
sessions,
limits: Arc::new(RwLock::new(limits)),
})
}
/// Get an available session for making API requests.
pub async fn get_session(&self, api: &str) -> Result<Arc<SessionCredentials>> {
if self.sessions.is_empty() {
return Err(Error::NoSessions);
}
let limits = self.limits.read().await;
// Find a session that isn't rate limited for this API
for session in &self.sessions {
if let Some(lim) = limits.get(&session.id) {
if !lim.is_limited(api) {
return Ok(Arc::clone(session));
}
} else {
// No limits recorded yet — session is available
return Ok(Arc::clone(session));
}
}
// If all sessions are limited, return the one with the earliest reset time
let best = self
.sessions
.iter()
.min_by_key(|sess| {
limits
.get(&sess.id)
.and_then(|lim| lim.apis.get(api))
.map_or(i64::MAX, |rate| rate.reset)
})
.ok_or(Error::NoSessions)?;
Ok(Arc::clone(best))
}
/// Update rate limit info for a session.
pub async fn update_session_limit(
&self,
session_id: i64,
api: &str,
limit: i32,
remaining: i32,
reset: i64,
) {
let mut limits = self.limits.write().await;
if let Some(lim) = limits.get_mut(&session_id) {
lim.update_limit(api, limit, remaining, reset);
}
}
/// Mark a session as globally rate limited.
pub async fn mark_limited(&self, session_id: i64) {
let mut limits = self.limits.write().await;
if let Some(lim) = limits.get_mut(&session_id) {
lim.limited = true;
lim.limited_at = time::OffsetDateTime::now_utc().unix_timestamp();
}
}
/// Get the session kind that would be used for a given API.
pub async fn get_session_kind(&self, api: &str) -> SessionKind {
self
.get_session(api)
.await
.map_or_else(|_| SessionKind::default(), |session| session.kind)
}
/// Get session count.
pub const fn len(&self) -> usize {
self.sessions.len()
}
/// Check if pool is empty.
pub const fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
/// Get health statistics about the session pool.
#[expect(
clippy::iter_over_hash_type,
reason = "iteration order irrelevant for aggregation"
)]
pub async fn get_health(&self) -> HealthResponse {
let limits = self.limits.read().await;
let mut limited_count = 0;
let mut total_requests = 0;
let mut by_api = HashMap::<String, i32>::new();
for lim in limits.values() {
if lim.limited {
limited_count += 1;
}
for (api, limit_info) in &lim.apis {
let used = limit_info.limit - limit_info.remaining;
total_requests += used;
*by_api.entry(api.clone()).or_default() += used;
}
}
drop(limits);
HealthResponse {
sessions: SessionStats {
total: self.sessions.len(),
limited: limited_count,
available: self.sessions.len() - limited_count,
},
requests: RequestStats {
total: total_requests,
by_api,
},
timestamp: time::OffsetDateTime::now_utc().format(&Rfc3339).unwrap(),
}
}
/// Get detailed debug info about sessions.
pub async fn get_debug(&self) -> DebugResponse {
let limits = self.limits.read().await;
let sessions = self
.sessions
.iter()
.map(|sess| {
let lim = limits.get(&sess.id);
SessionDetail {
id: sess.id,
username: sess.username.clone(),
limited: lim.is_some_and(|sl| sl.limited),
limited_at: lim.map_or(0, |sl| sl.limited_at),
pending: lim.map_or(0, |sl| sl.pending),
apis: lim.map(|sl| sl.apis.clone()).unwrap_or_default(),
}
})
.collect();
DebugResponse {
sessions,
count: self.sessions.len(),
timestamp: time::OffsetDateTime::now_utc().format(&Rfc3339).unwrap(),
}
}
}
/// Sign a request with `OAuth1`.
pub fn oauth1_sign(
method: &str,
url: &str,
params: &[(&str, &str)],
oauth_token: &str,
oauth_secret: &str,
) -> String {
// OAuth parameters
let timestamp = time::OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce = format!(
"{:032x}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let mut oauth_params = vec![
("oauth_consumer_key", super::endpoints::CONSUMER_KEY),
("oauth_nonce", &nonce),
("oauth_signature_method", "HMAC-SHA1"),
("oauth_timestamp", &timestamp),
("oauth_token", oauth_token),
("oauth_version", "1.0"),
];
// Combine all parameters and sort
let mut all_params: Vec<(&str, &str)> = params.to_vec();
all_params.extend(oauth_params.iter().copied());
all_params.sort_by(|lhs, rhs| lhs.0.cmp(rhs.0));
// Create parameter string
let param_string = all_params
.iter()
.map(|&(key, val)| format!("{}={}", percent_encode(key), percent_encode(val)))
.collect::<Vec<_>>()
.join("&");
// Create signature base string
let base_string = format!(
"{}&{}&{}",
method.to_uppercase(),
percent_encode(url),
percent_encode(&param_string)
);
// Create signing key
let signing_key = format!(
"{}&{}",
percent_encode(super::endpoints::CONSUMER_SECRET),
percent_encode(oauth_secret)
);
// Generate signature
let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, signing_key.as_bytes());
let tag = hmac::sign(&key, base_string.as_bytes());
let signature = BASE64.encode(tag.as_ref());
oauth_params.push(("oauth_signature", &signature));
// Build Authorization header
let auth_header = oauth_params
.iter()
.map(|&(param, val)| format!("{}=\"{}\"", param, percent_encode(val)))
.collect::<Vec<_>>()
.join(", ");
format!("OAuth {auth_header}")
}
fn percent_encode(input: &str) -> String {
percent_encoding::utf8_percent_encode(input, percent_encoding::NON_ALPHANUMERIC).to_string()
}
+620
View File
@@ -0,0 +1,620 @@
use std::sync::Arc;
use axum::http::header;
use serde::{
Deserialize,
de::DeserializeOwned,
};
use super::{
SessionPool,
TidClient,
endpoints,
http::HttpClient,
parser,
};
use crate::{
api::schema::{
ConversationData,
EditHistoryData,
GqlResponse,
ListByIdData,
ListBySlugData,
ListMembersData,
ListTimelineData,
SearchTimelineData,
TweetResultData,
UserResultData,
UserTimelineData,
},
config::Config,
error::{
Error,
Result,
TwitterError,
},
types::{
Conversation,
EditHistory,
GalleryPhoto,
List,
PaginatedResult,
Profile,
SessionKind,
Timeline,
Tweet,
User,
},
};
/// Twitter/X API client.
#[derive(Clone)]
pub struct ApiClient {
client: HttpClient,
sessions: SessionPool,
tid: TidClient,
}
impl ApiClient {
pub fn new(_config: Arc<Config>, sessions: SessionPool) -> Self {
let mut headers = header::HeaderMap::new();
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static(endpoints::USER_AGENT),
);
headers.insert(
header::ACCEPT_LANGUAGE,
header::HeaderValue::from_static("en-US,en;q=0.9"),
);
headers.insert(
header::ACCEPT_ENCODING,
header::HeaderValue::from_static("gzip"),
);
headers.insert(
header::CONNECTION,
header::HeaderValue::from_static("keep-alive"),
);
let client = HttpClient::new().with_default_headers(headers);
let tid = TidClient::new(client.clone());
Self {
client,
sessions,
tid,
}
}
/// Check for API-level errors in the raw response bytes.
fn check_api_errors(bytes: &[u8]) -> Result<()> {
#[derive(Deserialize)]
struct ErrorCheck {
errors: Option<Vec<ApiError>>,
}
#[derive(Deserialize)]
struct ApiError {
#[serde(default)]
code: i64,
#[serde(default)]
message: String,
}
let Ok(check) = serde_json::from_slice::<ErrorCheck>(bytes) else {
return Ok(());
};
let Some(error) = check.errors.as_ref().and_then(|errs| errs.first()) else {
return Ok(());
};
if let Some(twitter_err) = TwitterError::from_code(error.code) {
return match twitter_err {
TwitterError::UserNotFound | TwitterError::NoUserMatches => {
Err(Error::UserNotFound(error.message.clone()))
},
TwitterError::ProtectedUser => Err(Error::ProtectedUser(error.message.clone())),
TwitterError::UserSuspended | TwitterError::Locked => {
Err(Error::UserSuspended(error.message.clone()))
},
TwitterError::RateLimited => Err(Error::RateLimited),
TwitterError::TweetNotFound
| TwitterError::TweetUnavailable
| TwitterError::NoStatusFound
| TwitterError::TweetUnavailable421
| TwitterError::TweetCensored => Err(Error::TweetNotFound(error.message.clone())),
TwitterError::InvalidToken | TwitterError::BadToken => {
Err(Error::TwitterApi(format!(
"Invalid token: {}",
error.message
)))
},
};
}
Err(Error::TwitterApi(format!(
"Error {}: {}",
error.code, error.message
)))
}
/// Make a GraphQL request to the Twitter API.
async fn graphql_request<T: DeserializeOwned>(
&self,
endpoint: &str,
variables: &str,
features: &str,
field_toggles: Option<&str>,
) -> Result<T> {
let session = self.sessions.get_session(endpoint).await?;
let base_url = match session.kind {
SessionKind::OAuth => endpoints::API_URL,
SessionKind::Cookie => endpoints::GRAPHQL_URL,
};
// Build URL with query string (scoped to drop Serializer before await)
let url = {
let mut qs = form_urlencoded::Serializer::new(String::new());
qs.append_pair("variables", variables);
qs.append_pair("features", features);
if let Some(toggles) = field_toggles {
qs.append_pair("fieldToggles", toggles);
}
format!("{base_url}/{endpoint}?{}", qs.finish())
};
// Build auth + extra headers
let mut headers = header::HeaderMap::new();
match session.kind {
SessionKind::OAuth => {
let auth_url = format!("{base_url}/{endpoint}");
let auth = super::oauth1_sign(
"GET",
&auth_url,
&[],
&session.oauth_token,
&session.oauth_secret,
);
headers.insert(header::AUTHORIZATION, auth.parse().unwrap());
},
SessionKind::Cookie => {
let api_path = format!("/i/api/graphql/{endpoint}");
let (bearer, tid) = self
.tid
.generate(&api_path)
.await
.map_or((endpoints::BEARER_TOKEN_NO_TID, None), |tid_val| {
(endpoints::BEARER_TOKEN, Some(tid_val))
});
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_str(bearer).unwrap(),
);
headers.insert(
"x-twitter-auth-type",
header::HeaderValue::from_static("OAuth2Session"),
);
headers.insert("x-csrf-token", session.ct0.parse().unwrap());
headers.insert(
header::COOKIE,
format!("auth_token={}; ct0={}", session.auth_token, session.ct0)
.parse()
.unwrap(),
);
headers.insert(header::ORIGIN, header::HeaderValue::from_static("https://x.com"));
headers.insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/json"),
);
headers.insert(
"sec-ch-ua",
header::HeaderValue::from_static(
r#""Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24""#,
),
);
headers.insert("sec-ch-ua-mobile", header::HeaderValue::from_static("?0"));
headers.insert(
"sec-ch-ua-platform",
header::HeaderValue::from_static("\"Windows\""),
);
headers.insert("sec-fetch-dest", header::HeaderValue::from_static("empty"));
headers.insert("sec-fetch-mode", header::HeaderValue::from_static("cors"));
headers.insert(
"sec-fetch-site",
header::HeaderValue::from_static("same-site"),
);
if let Some(tid) = tid {
headers.insert("x-client-transaction-id", tid.parse().unwrap());
}
},
}
// Common headers
headers.insert(header::ACCEPT, header::HeaderValue::from_static("*/*"));
headers.insert(
"x-twitter-active-user",
header::HeaderValue::from_static("yes"),
);
headers.insert(
"x-twitter-client-language",
header::HeaderValue::from_static("en"),
);
let response = self.client.get_with_headers(&url, &headers).await?;
// Check rate limit headers
if let Some(remaining) = response.headers().get("x-rate-limit-remaining")
&& let Ok(remaining_str) = remaining.to_str()
&& let Ok(remaining_val) = remaining_str.parse::<i32>()
{
let limit = response
.headers()
.get("x-rate-limit-limit")
.and_then(|hv| hv.to_str().ok())
.and_then(|sv| sv.parse().ok())
.unwrap_or(0);
let reset = response
.headers()
.get("x-rate-limit-reset")
.and_then(|hv| hv.to_str().ok())
.and_then(|sv| sv.parse().ok())
.unwrap_or(0);
self
.sessions
.update_session_limit(session.id, endpoint, limit, remaining_val, reset)
.await;
}
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::error!("API request failed: {status} - {body}");
if status.as_u16() == 429 {
self.sessions.mark_limited(session.id).await;
return Err(Error::RateLimited);
}
return Err(Error::TwitterApi(format!("Status {status}: {body}")));
}
let bytes = response.bytes().await?;
// Check for API errors before full deserialization
Self::check_api_errors(&bytes)?;
let resp = serde_json::from_slice::<GqlResponse<T>>(&bytes)
.map_err(|err| Error::Internal(format!("Response parse error: {err}")))?;
Ok(resp.data)
}
/// Get user by screen name.
pub async fn get_user(&self, screen_name: &str) -> Result<User> {
let data = self
.graphql_request::<UserResultData>(
endpoints::GRAPH_USER,
&endpoints::user_by_screen_name_vars(screen_name),
endpoints::GQL_FEATURES,
Some(endpoints::USER_FIELD_TOGGLES),
)
.await?;
super::parse_user(&data)
}
/// Get user by REST ID (numeric user ID).
pub async fn get_user_by_id(&self, user_id: &str) -> Result<User> {
if user_id.is_empty() || !user_id.chars().all(|ch| ch.is_ascii_digit()) {
return Err(Error::UserNotFound("Invalid user ID format".to_owned()));
}
let data = self
.graphql_request::<UserResultData>(
endpoints::GRAPH_USER_BY_ID,
&endpoints::user_by_id_vars(user_id),
endpoints::GQL_FEATURES,
Some(endpoints::USER_FIELD_TOGGLES),
)
.await?;
super::parse_user(&data)
}
/// Get edit history for a tweet.
pub async fn get_edit_history(&self, tweet_id: &str) -> Result<EditHistory> {
let data = self
.graphql_request::<EditHistoryData>(
endpoints::GRAPH_TWEET_EDIT_HISTORY,
&endpoints::tweet_edit_history_vars(tweet_id),
endpoints::GQL_FEATURES,
None,
)
.await?;
parser::edit_history::parse_edit_history(&data)
}
/// Get tweet by ID.
pub async fn get_tweet(&self, tweet_id: &str) -> Result<Tweet> {
let data = self
.graphql_request::<TweetResultData>(
endpoints::GRAPH_TWEET_RESULT,
&endpoints::tweet_vars(tweet_id, None),
endpoints::GQL_FEATURES,
None,
)
.await?;
super::parse_tweet(&data)
}
/// Get conversation/thread for a tweet.
pub async fn get_conversation(
&self,
tweet_id: &str,
cursor: Option<&str>,
) -> Result<Conversation> {
let data = self
.graphql_request::<ConversationData>(
endpoints::GRAPH_TWEET_DETAIL,
&endpoints::tweet_detail_vars(tweet_id, cursor),
endpoints::GQL_FEATURES,
Some(endpoints::TWEET_DETAIL_FIELD_TOGGLES),
)
.await?;
super::parse_conversation(&data, tweet_id)
}
/// Get user's tweets timeline.
pub async fn get_user_tweets(&self, user_id: &str, cursor: Option<&str>) -> Result<Timeline> {
let data = self
.graphql_request::<UserTimelineData>(
endpoints::GRAPH_USER_TWEETS,
&endpoints::user_tweets_vars(user_id, cursor),
endpoints::GQL_FEATURES,
Some(endpoints::USER_TWEETS_FIELD_TOGGLES),
)
.await?;
super::parse_timeline(&data)
}
/// Get user's media timeline.
pub async fn get_user_media(&self, user_id: &str, cursor: Option<&str>) -> Result<Timeline> {
// Use different endpoint/variables based on session type
let session_kind = self
.sessions
.get_session_kind(endpoints::GRAPH_USER_MEDIA)
.await;
let (endpoint, variables) = match session_kind {
SessionKind::OAuth => {
(
endpoints::GRAPH_USER_MEDIA_V2,
endpoints::user_media_v2_vars(user_id, cursor),
)
},
SessionKind::Cookie => {
(
endpoints::GRAPH_USER_MEDIA,
endpoints::user_media_vars(user_id, cursor),
)
},
};
let data = self
.graphql_request::<UserTimelineData>(endpoint, &variables, endpoints::GQL_FEATURES, None)
.await?;
super::parse_timeline(&data)
}
/// Get user's profile with tweets.
pub async fn get_profile(&self, screen_name: &str, cursor: Option<&str>) -> Result<Profile> {
// First get user info
let user = self.get_user(screen_name).await?;
// Fetch tweets and photo rail in parallel (only for first page)
let (tweets, photo_rail) = if cursor.is_none() {
let tweets_future = self.get_user_tweets(&user.id, None);
let photo_rail_future = self.get_photo_rail(&user.id);
let (tweets_result, photo_rail_result) = tokio::join!(tweets_future, photo_rail_future);
(tweets_result?, photo_rail_result.unwrap_or_default())
} else {
(self.get_user_tweets(&user.id, cursor).await?, Vec::new())
};
// Get pinned tweet if present
let pinned = if user.pinned_tweet > 0 {
tweets
.content
.iter()
.flatten()
.find(|tweet| tweet.id == user.pinned_tweet)
.cloned()
} else {
None
};
Ok(Profile {
user,
photo_rail,
pinned,
tweets,
})
}
/// Search tweets.
pub async fn search(&self, query: &str, cursor: Option<&str>) -> Result<Timeline> {
let data = self
.graphql_request::<SearchTimelineData>(
endpoints::GRAPH_SEARCH_TIMELINE,
&endpoints::search_vars(query, cursor, "Latest"),
endpoints::GQL_FEATURES,
None,
)
.await?;
Ok(super::parse_search_timeline(&data))
}
/// Search users.
pub async fn search_users(
&self,
query: &str,
cursor: Option<&str>,
) -> Result<PaginatedResult<User>> {
let data = self
.graphql_request::<SearchTimelineData>(
endpoints::GRAPH_SEARCH_TIMELINE,
&endpoints::search_vars(query, cursor, "People"),
endpoints::GQL_FEATURES,
None,
)
.await?;
Ok(super::parse_user_search(&data))
}
/// Get list by ID.
pub async fn get_list(&self, list_id: &str) -> Result<List> {
let data = self
.graphql_request::<ListByIdData>(
endpoints::GRAPH_LIST_BY_ID,
&endpoints::list_by_id_vars(list_id),
endpoints::GQL_FEATURES,
None,
)
.await?;
let wrapper = data
.list
.as_ref()
.ok_or_else(|| Error::NotFound("List not found".into()))?;
Ok(super::parse_list(wrapper.list_data()))
}
/// Get list by owner username and slug.
pub async fn get_list_by_slug(&self, screen_name: &str, slug: &str) -> Result<List> {
let data = self
.graphql_request::<ListBySlugData>(
endpoints::GRAPH_LIST_BY_SLUG,
&endpoints::list_by_slug_vars(screen_name, slug),
endpoints::GQL_FEATURES,
None,
)
.await?;
data
.user_by_screen_name
.as_ref()
.and_then(|nested| nested.list.as_ref())
.map(|ld| Ok(super::parse_list(ld)))
.ok_or_else(|| Error::NotFound("List not found".into()))?
}
/// Get list tweets.
pub async fn get_list_tweets(&self, list_id: &str, cursor: Option<&str>) -> Result<Timeline> {
let data = self
.graphql_request::<ListTimelineData>(
endpoints::GRAPH_LIST_TWEETS,
&endpoints::list_timeline_vars(list_id, cursor),
endpoints::GQL_FEATURES,
None,
)
.await?;
super::parse_list_timeline(&data)
}
/// Get list members.
pub async fn get_list_members(
&self,
list_id: &str,
cursor: Option<&str>,
) -> Result<PaginatedResult<User>> {
let data = self
.graphql_request::<ListMembersData>(
endpoints::GRAPH_LIST_MEMBERS,
&endpoints::list_members_vars(list_id, cursor),
endpoints::GQL_FEATURES,
None,
)
.await?;
Ok(super::parse_list_members(&data))
}
/// Get user's tweets and replies timeline.
pub async fn get_user_tweets_and_replies(
&self,
user_id: &str,
cursor: Option<&str>,
) -> Result<Timeline> {
let session_kind = self
.sessions
.get_session_kind(endpoints::GRAPH_USER_TWEETS_AND_REPLIES)
.await;
let (endpoint, variables, field_toggles) = match session_kind {
SessionKind::OAuth => {
(
endpoints::GRAPH_USER_TWEETS_AND_REPLIES_V2,
endpoints::user_media_v2_vars(user_id, cursor),
None,
)
},
SessionKind::Cookie => {
(
endpoints::GRAPH_USER_TWEETS_AND_REPLIES,
endpoints::user_tweets_and_replies_vars(user_id, cursor),
Some(endpoints::USER_TWEETS_FIELD_TOGGLES),
)
},
};
let data = self
.graphql_request::<UserTimelineData>(
endpoint,
&variables,
endpoints::GQL_FEATURES,
field_toggles,
)
.await?;
super::parse_timeline(&data)
}
/// Get session pool health statistics.
pub async fn get_session_health(&self) -> super::HealthResponse {
self.sessions.get_health().await
}
/// Get detailed session debug info.
pub async fn get_session_debug(&self) -> super::DebugResponse {
self.sessions.get_debug().await
}
/// Get photo rail (up to 16 recent photos) for a user.
pub async fn get_photo_rail(&self, user_id: &str) -> Result<Vec<GalleryPhoto>> {
let timeline = self.get_user_media(user_id, None).await?;
let mut photos = Vec::new();
for mut tweet in timeline.content.into_iter().flatten() {
// Extract ONE photo per tweet:
// first photo > video thumb > gif thumb > card image
let url = if !tweet.photos.is_empty() {
Some(tweet.photos.swap_remove(0).url)
} else if let Some(video) = tweet.video.take() {
(!video.thumb.is_empty()).then_some(video.thumb)
} else if let Some(gif) = tweet.gif.take() {
(!gif.thumb.is_empty()).then_some(gif.thumb)
} else if let Some(card) = tweet.card.take() {
(!card.image.is_empty()).then_some(card.image)
} else {
None
};
if let Some(url) = url {
photos.push(GalleryPhoto {
url,
tweet_id: tweet.id.to_string(),
color: String::new(),
});
if photos.len() >= 16 {
return Ok(photos);
}
}
}
Ok(photos)
}
}
+142
View File
@@ -0,0 +1,142 @@
/// Twitter/X API constants and endpoints.
use serde_json::json;
pub const CONSUMER_KEY: &str = "3nVuSoBZnx6U4vzUxf5w";
pub const CONSUMER_SECRET: &str = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys";
/// Bearer token that requires x-client-transaction-id (used for cookie sessions
/// with TID).
pub const BEARER_TOKEN: &str =
"Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%\
3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
/// Fallback bearer token that doesn't require x-client-transaction-id.
pub const BEARER_TOKEN_NO_TID: &str =
"Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%\
3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F";
// GraphQL endpoints
pub const GRAPH_USER: &str = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName";
pub const GRAPH_USER_BY_ID: &str = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery";
pub const GRAPH_USER_TWEETS: &str = "oRJs8SLCRNRbQzuZG93_oA/UserTweets";
pub const GRAPH_USER_MEDIA: &str = "36oKqyQ7E_9CmtONGjJRsA/UserMedia";
pub const GRAPH_USER_MEDIA_V2: &str = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2";
pub const GRAPH_TWEET_DETAIL: &str = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail";
pub const GRAPH_TWEET_RESULT: &str = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery";
pub const GRAPH_SEARCH_TIMELINE: &str = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline";
pub const GRAPH_LIST_BY_ID: &str = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId";
pub const GRAPH_LIST_BY_SLUG: &str = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug";
pub const GRAPH_LIST_TWEETS: &str = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline";
pub const GRAPH_LIST_MEMBERS: &str = "BQp2IEYkgxuSxqbTAr1e1g/ListMembers";
pub const GRAPH_USER_TWEETS_AND_REPLIES: &str = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies";
pub const GRAPH_USER_TWEETS_AND_REPLIES_V2: &str =
"BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2";
pub const GRAPH_TWEET_EDIT_HISTORY: &str = "upS9teTSG45aljmP9oTuXA/TweetEditHistory";
pub const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36";
// Base URLs
pub const GRAPHQL_URL: &str = "https://x.com/i/api/graphql";
pub const API_URL: &str = "https://api.x.com/graphql";
/// GraphQL features to include in requests.
pub const GQL_FEATURES: &str = r#"{"android_ad_formats_media_component_render_overlay_enabled":false,"android_graphql_skip_api_media_color_palette":false,"android_professional_link_spotlight_display_enabled":false,"blue_business_profile_image_shape_enabled":false,"commerce_android_shop_module_enabled":false,"creator_subscriptions_subscription_count_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"freedom_of_speech_not_reach_fetch_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"hidden_profile_likes_enabled":false,"highlights_tweets_tab_ui_enabled":false,"interactive_text_enabled":false,"longform_notetweets_consumption_enabled":true,"longform_notetweets_inline_media_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_richtext_consumption_enabled":true,"mobile_app_spotlight_module_enabled":false,"responsive_web_edit_tweet_api_enabled":true,"responsive_web_enhance_cards_enabled":false,"responsive_web_graphql_exclude_directive_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_media_download_video_enabled":false,"responsive_web_text_conversations_enabled":false,"responsive_web_twitter_article_tweet_consumption_enabled":true,"unified_cards_destination_url_params_enabled":false,"responsive_web_twitter_blue_verified_badge_is_enabled":true,"rweb_lists_timeline_redesign_enabled":true,"spaces_2022_h2_clipping":true,"spaces_2022_h2_spaces_communities":true,"standardized_nudges_misinfo":true,"subscriptions_verification_info_enabled":true,"subscriptions_verification_info_reason_enabled":true,"subscriptions_verification_info_verified_since_enabled":true,"super_follow_badge_privacy_enabled":false,"super_follow_exclusive_tweet_notifications_enabled":false,"super_follow_tweet_api_enabled":false,"super_follow_user_api_enabled":false,"tweet_awards_web_tipping_enabled":false,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"tweetypie_unmention_optimization_enabled":false,"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled":false,"verified_phone_label_enabled":false,"vibe_api_enabled":false,"view_counts_everywhere_api_enabled":true,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"responsive_web_jetfuel_frame":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_grok_imagine_annotation_enabled":true,"rweb_tipjar_consumption_enabled":true,"profile_label_improvements_pcf_label_in_post_enabled":true,"creator_subscriptions_quote_tweet_preview_enabled":false,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_post_followups_enabled":true,"rweb_video_timestamps_enabled":false,"responsive_web_grok_share_attachment_enabled":true,"articles_preview_enabled":true,"immersive_video_status_linkable_timestamps":false,"articles_api_enabled":false,"responsive_web_grok_analysis_button_from_backend":true,"rweb_video_screen_enabled":false,"payments_enabled":false,"responsive_web_profile_redirect_enabled":false,"responsive_web_grok_show_grok_translated_post":false,"responsive_web_grok_community_note_auto_translation_is_enabled":false,"profile_label_improvements_pcf_label_in_profile_enabled":false,"grok_android_analyze_trend_fetch_enabled":false,"grok_translations_community_note_auto_translation_is_enabled":false,"grok_translations_post_auto_translation_is_enabled":false,"grok_translations_community_note_translation_is_enabled":false,"grok_translations_timeline_user_bio_auto_translation_is_enabled":false,"subscriptions_feature_can_gift_premium":false,"responsive_web_twitter_article_notes_tab_enabled":false,"subscriptions_verification_info_is_identity_verified_enabled":false,"hidden_profile_subscriptions_enabled":false}"#;
pub const USER_FIELD_TOGGLES: &str = r#"{"withPayments":false,"withAuxiliaryUserLabels":true}"#;
pub const USER_TWEETS_FIELD_TOGGLES: &str = r#"{"withArticlePlainText":false}"#;
pub const TWEET_DETAIL_FIELD_TOGGLES: &str = r#"{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"#;
// ── Helper ──────────────────────────────────────────────────────────────
/// Serialize a JSON value to string, stripping null fields (for optional
/// cursor).
fn vars(mut value: serde_json::Value) -> String {
if let Some(obj) = value.as_object_mut() {
obj.retain(|_, val| !val.is_null());
}
value.to_string()
}
// ── Builder functions ───────────────────────────────────────────────────
pub fn tweet_vars(post_id: &str, cursor: Option<&str>) -> String {
vars(json!({
"postId": post_id, "cursor": cursor,
"includeHasBirdwatchNotes": false, "includePromotedContent": false,
"withBirdwatchNotes": false, "withVoice": false, "withV2Timeline": true,
}))
}
pub fn tweet_detail_vars(focal_tweet_id: &str, cursor: Option<&str>) -> String {
vars(json!({
"focalTweetId": focal_tweet_id, "cursor": cursor,
"referrer": "profile", "withRuxInjections": false, "rankingMode": "Relevance",
"includePromotedContent": true, "withCommunity": true,
"withQuickPromoteEligibilityTweetFields": true,
"withBirdwatchNotes": true, "withVoice": true,
}))
}
pub fn user_tweets_vars(user_id: &str, cursor: Option<&str>) -> String {
vars(json!({
"userId": user_id, "cursor": cursor, "count": 20,
"includePromotedContent": false,
"withQuickPromoteEligibilityTweetFields": true, "withVoice": true,
}))
}
pub fn user_media_vars(user_id: &str, cursor: Option<&str>) -> String {
vars(json!({
"userId": user_id, "cursor": cursor, "count": 20,
"includePromotedContent": false, "withClientEventToken": false,
"withBirdwatchNotes": false, "withVoice": true,
}))
}
pub fn user_media_v2_vars(user_id: &str, cursor: Option<&str>) -> String {
vars(json!({ "rest_id": user_id, "cursor": cursor, "count": 20 }))
}
pub fn user_by_screen_name_vars(screen_name: &str) -> String {
vars(json!({ "screen_name": screen_name, "withSafetyModeUserFields": true }))
}
pub fn user_by_id_vars(rest_id: &str) -> String {
json!({ "rest_id": rest_id }).to_string()
}
pub fn list_members_vars(list_id: &str, cursor: Option<&str>) -> String {
vars(json!({ "listId": list_id, "cursor": cursor, "count": 20 }))
}
pub fn user_tweets_and_replies_vars(user_id: &str, cursor: Option<&str>) -> String {
vars(json!({
"userId": user_id, "cursor": cursor, "count": 20,
"includePromotedContent": false, "withCommunity": true, "withVoice": true,
}))
}
pub fn list_by_slug_vars(screen_name: &str, list_slug: &str) -> String {
json!({ "screenName": screen_name, "listSlug": list_slug }).to_string()
}
pub fn tweet_edit_history_vars(tweet_id: &str) -> String {
json!({ "tweetId": tweet_id, "withQuickPromoteEligibilityTweetFields": true }).to_string()
}
pub fn search_vars(raw_query: &str, cursor: Option<&str>, product: &str) -> String {
vars(json!({
"rawQuery": raw_query, "cursor": cursor, "count": 20,
"querySource": "typedQuery", "product": product,
"withDownvotePerspective": false, "withReactionsMetadata": false,
"withReactionsPerspective": false,
}))
}
pub fn list_by_id_vars(list_id: &str) -> String {
json!({ "listId": list_id }).to_string()
}
pub fn list_timeline_vars(rest_id: &str, cursor: Option<&str>) -> String {
vars(json!({ "rest_id": rest_id, "cursor": cursor, "count": 20 }))
}
+178
View File
@@ -0,0 +1,178 @@
use std::{
io::Read as _,
time::Duration,
};
use axum::http::{
HeaderMap,
Method,
Uri,
header,
};
use bytes::Bytes;
use flate2::read::GzDecoder;
use http_body_util::{
BodyExt as _,
Empty,
};
use hyper::{
StatusCode,
body as hyper_body,
};
use hyper_rustls::HttpsConnectorBuilder;
use hyper_util::{
client::legacy::{
Client,
connect::HttpConnector,
},
rt::TokioExecutor,
};
use serde::de::DeserializeOwned;
use crate::error::{
Error,
Result,
};
type Connector = hyper_rustls::HttpsConnector<HttpConnector>;
/// Lightweight HTTP client wrapping hyper-util's connection-pooling client.
#[derive(Clone)]
#[expect(
clippy::module_name_repetitions,
reason = "HttpClient is clearer than Client"
)]
pub struct HttpClient {
inner: Client<Connector, Empty<Bytes>>,
default_headers: HeaderMap,
}
/// Response wrapper providing convenience methods.
pub struct Response {
status: StatusCode,
headers: HeaderMap,
body: hyper_body::Incoming,
}
impl HttpClient {
pub fn new() -> Self {
let connector = HttpsConnectorBuilder::new()
.with_native_roots()
.expect("native TLS roots")
.https_or_http()
.enable_http1()
.build();
let inner = Client::builder(TokioExecutor::new())
.pool_idle_timeout(Duration::from_secs(90))
.build(connector);
Self {
inner,
default_headers: HeaderMap::new(),
}
}
/// Create a client with default headers applied to every request.
pub fn with_default_headers(mut self, headers: HeaderMap) -> Self {
self.default_headers = headers;
self
}
/// Send a request with a given method and optional extra headers.
async fn send(&self, method: Method, uri: &str, extra_headers: &HeaderMap) -> Result<Response> {
let parsed: Uri = uri
.parse()
.map_err(|err| Error::Internal(format!("invalid URI: {err}")))?;
let mut builder = hyper::Request::builder().method(method).uri(parsed);
for (key, value) in &self.default_headers {
builder = builder.header(key, value);
}
for (key, value) in extra_headers {
builder = builder.header(key, value);
}
let request = builder
.body(Empty::new())
.map_err(|err| Error::Internal(format!("build request: {err}")))?;
let resp = self
.inner
.request(request)
.await
.map_err(|err| Error::Internal(format!("HTTP request failed: {err}")))?;
let (parts, body) = resp.into_parts();
Ok(Response {
status: parts.status,
headers: parts.headers,
body,
})
}
/// Send a GET request.
pub async fn get(&self, uri: &str) -> Result<Response> {
self.send(Method::GET, uri, &HeaderMap::new()).await
}
/// Send a GET request with additional headers.
pub async fn get_with_headers(&self, uri: &str, extra_headers: &HeaderMap) -> Result<Response> {
self.send(Method::GET, uri, extra_headers).await
}
/// Send a HEAD request.
pub async fn head(&self, uri: &str) -> Result<Response> {
self.send(Method::HEAD, uri, &HeaderMap::new()).await
}
}
impl Response {
pub const fn status(&self) -> StatusCode {
self.status
}
pub const fn headers(&self) -> &HeaderMap {
&self.headers
}
/// Collect the response body as bytes, decompressing gzip if needed.
pub async fn bytes(self) -> Result<Bytes> {
let is_gzip = self
.headers
.get(header::CONTENT_ENCODING)
.and_then(|val| val.to_str().ok())
.is_some_and(|val| val.contains("gzip"));
let collected = self
.body
.collect()
.await
.map_err(|err| Error::Internal(format!("read body: {err}")))?
.to_bytes();
if is_gzip {
let mut gz = GzDecoder::new(&*collected);
let mut decoded = Vec::new();
gz.read_to_end(&mut decoded)
.map_err(|err| Error::Internal(format!("gzip decode: {err}")))?;
Ok(Bytes::from(decoded))
} else {
Ok(collected)
}
}
/// Collect the response body as a UTF-8 string.
pub async fn text(self) -> Result<String> {
let data = self.bytes().await?;
String::from_utf8(data.to_vec())
.map_err(|err| Error::Internal(format!("invalid UTF-8: {err}")))
}
/// Deserialize the response body as JSON.
pub async fn json<T: DeserializeOwned>(self) -> Result<T> {
let data = self.bytes().await?;
serde_json::from_slice(&data).map_err(Into::into)
}
}
+13
View File
@@ -0,0 +1,13 @@
mod auth;
mod client;
mod endpoints;
pub mod http;
mod parser;
pub mod schema;
mod tid;
pub use auth::*;
pub use client::*;
pub use http::HttpClient;
pub use parser::*;
pub use tid::*;
+464
View File
@@ -0,0 +1,464 @@
use std::collections::HashMap;
use time::format_description::well_known::Rfc3339;
use crate::{
api::schema::{
BindingValues,
CardData,
Destination,
MediaType,
UnifiedCard,
},
error::{
Error,
Result,
},
types::{
Card,
CardKind,
Poll,
Video,
VideoType,
VideoVariant,
},
};
/// Extract destination URL and vanity from typed destination objects.
fn extract_destination(
dest_id: &str,
destinations: &HashMap<String, Destination>,
) -> (String, String) {
let Some(dest) = destinations.get(dest_id) else {
return (String::new(), String::new());
};
let Some(url_data) = dest
.data
.as_ref()
.and_then(|dest_data| dest_data.url_data.as_ref())
else {
return (String::new(), String::new());
};
let url = url_data.url.as_deref().unwrap_or("").to_owned();
let vanity = url_data.vanity.as_deref().unwrap_or("").to_owned();
(url, vanity)
}
impl TryFrom<&CardData> for Card {
type Error = Error;
fn try_from(card: &CardData) -> Result<Self> {
let name = card.name();
// Strip "poll2choice_" etc. prefix
let kind_name = name.rsplit_once(':').map_or(name, |(_, suffix)| suffix);
let kind = CardKind::from_name(kind_name);
// Handle unified cards specially
if kind == CardKind::Unified {
return Ok(parse_unified_card(card.binding_values()));
}
let bv = card.binding_values();
let url = card.url().to_owned();
let title = bv.string("title").to_owned();
let text = bv.string("description").to_owned();
let dest = {
let vanity = bv.string("vanity_url");
if vanity.is_empty() {
bv.string("domain").to_owned()
} else {
vanity.to_owned()
}
};
// Try multiple image keys in order of preference (with _large suffix)
let image = [
"summary_photo_image_large",
"player_image_large",
"promo_image_large",
"photo_image_full_size_large",
"thumbnail_image_large",
"thumbnail_large",
"event_thumbnail_large",
"image_large",
// Fallbacks without _large suffix
"thumbnail_image",
"player_image",
"summary_photo_image",
"image",
]
.iter()
.map(|key| bv.image(key))
.find(|img| !img.is_empty())
.unwrap_or_default()
.to_owned();
// Card-type-specific logic
let mut video = None;
let mut card_url = url;
let mut card_text = text;
match kind {
CardKind::PromoVideo
| CardKind::PromoVideoConvo
| CardKind::AppPlayer
| CardKind::VideoDirectMessage => {
video = Some(parse_promo_video(bv));
if kind == CardKind::AppPlayer {
let app_cat = bv.string("app_category");
if !app_cat.is_empty() {
app_cat.clone_into(&mut card_text);
}
}
},
CardKind::Broadcast => {
let broadcast_image = bv.image("broadcast_thumbnail_large").to_owned();
return Ok(Self {
kind,
url: bv.string("broadcast_url").to_owned(),
title: bv.string("broadcaster_display_name").to_owned(),
dest,
text: bv.string("broadcast_title").to_owned(),
image: broadcast_image.clone(),
member_count: 0,
video: Some(Video {
thumb: broadcast_image,
available: true,
..Default::default()
}),
});
},
CardKind::LiveEvent => {
bv.string("event_title").clone_into(&mut card_text);
},
CardKind::Player => {
let player_url = bv.string("player_url");
if !player_url.is_empty() {
card_url = player_url.replace("/embed/", "/watch?v=");
}
},
_ => {},
}
// Clear URL for DM cards
if matches!(
kind,
CardKind::VideoDirectMessage | CardKind::ImageDirectMessage
) {
card_url = String::new();
}
Ok(Self {
kind,
url: card_url,
title,
dest,
text: card_text,
image,
video,
member_count: 0,
})
}
}
/// Parse promo video from card binding values.
fn parse_promo_video(bv: &BindingValues) -> Video {
let thumb = bv.image("player_image_large").to_owned();
let duration_secs = bv.string("content_duration_seconds").parse().unwrap_or(0);
let stream_url = ["player_hls_url", "player_stream_url", "amplify_url_vmap"]
.iter()
.map(|k| bv.string(k))
.find(|v| !v.is_empty())
.unwrap_or_default()
.to_owned();
let (content_type, playback_type) = if stream_url.contains("m3u8") {
(VideoType::M3u8, VideoType::M3u8)
} else {
(VideoType::Vmap, VideoType::Vmap)
};
let variant = VideoVariant {
content_type,
url: stream_url.clone(),
bitrate: 0,
resolution: 0,
};
Video {
duration_ms: duration_secs * 1000,
url: stream_url,
thumb,
available: true,
playback_type,
variants: vec![variant],
..Default::default()
}
}
/// Parse unified card (complex card format).
#[expect(
clippy::too_many_lines,
reason = "unified card parsing has inherent branching"
)]
fn parse_unified_card(bv: &BindingValues) -> Card {
let raw_json = bv.string("unified_card");
let unified_card_json = if raw_json.is_empty() { "{}" } else { raw_json };
let card = serde_json::from_str::<UnifiedCard>(unified_card_json).unwrap_or_default();
let mut result = Card {
kind: CardKind::Summary,
url: String::new(),
title: String::new(),
dest: String::new(),
text: String::new(),
image: String::new(),
video: None,
member_count: 0,
};
let destinations = card.destination_objects.unwrap_or_default();
let media_entities = card.media_entities.unwrap_or_default();
let app_store_data = card.app_store_data.unwrap_or_default();
if let Some(components) = card.component_objects {
#[expect(clippy::iter_over_hash_type, reason = "component order is irrelevant")]
for component in components.values() {
let comp_type = component.comp_type.as_deref().unwrap_or("");
let Some(data) = component.data.as_ref() else {
continue;
};
match comp_type {
"details" | "twitter_list_details" | "community_details" => {
if let Some(dest_id) = data.destination.as_deref() {
let (url, vanity) = extract_destination(dest_id, &destinations);
result.url = url;
result.dest = vanity;
}
data
.title
.as_deref()
.or(data.name.as_deref())
.unwrap_or("")
.clone_into(&mut result.title);
match comp_type {
"twitter_list_details" => {
result.member_count = data.member_count;
"List".clone_into(&mut result.dest);
},
"community_details" => {
result.member_count = data.member_count;
"Community".clone_into(&mut result.dest);
},
_ => {},
}
},
"media" | "swipeable_media" => {
let media_id = if comp_type == "swipeable_media" {
data
.media_list
.as_ref()
.and_then(|list| list.first())
.and_then(|media_ref| media_ref.id.as_deref())
} else {
data.id.as_deref()
};
if let Some(mid) = media_id
&& let Some(media) = media_entities.get(mid)
{
if let Some(img_url) = media.media_url_https.as_deref() {
img_url.clone_into(&mut result.image);
}
match media.media_type.unwrap_or_default() {
MediaType::Photo => {
result.kind = CardKind::SummaryLarge;
},
MediaType::Video => {
result.kind = CardKind::PromoVideo;
},
MediaType::Model3d => {
"Unsupported 3D model".clone_into(&mut result.title);
},
_ => {},
}
}
},
"app_store_details" => {
if let Some(app_id) = data.app_id.as_deref()
&& let Some(app_entry) = app_store_data
.get(app_id)
.and_then(|entries| entries.first())
{
let app_type = app_entry.app_type.as_deref().unwrap_or("");
let app_store_id = app_entry.id.as_deref().unwrap_or("");
match app_type {
"android_app" => {
result.url =
format!("http://play.google.com/store/apps/details?id={app_store_id}");
},
"iphone_app" | "ipad_app" => {
result.url = format!("https://itunes.apple.com/app/id{app_store_id}");
},
_ => {},
}
app_entry
.title
.as_deref()
.unwrap_or("")
.clone_into(&mut result.title);
app_entry
.category
.as_deref()
.unwrap_or("")
.clone_into(&mut result.dest);
}
},
"job_details" => {
result.kind = CardKind::JobDetails;
if let Some(dest_id) = data.destination.as_deref() {
let (url, _) = extract_destination(dest_id, &destinations);
result.url = url;
}
data
.title
.as_deref()
.unwrap_or("")
.clone_into(&mut result.title);
data
.short_description_text
.as_deref()
.unwrap_or("")
.clone_into(&mut result.text);
let username = data
.profile_user
.as_ref()
.and_then(|profile| profile.username.as_deref())
.unwrap_or("");
let location = data.location.as_deref().unwrap_or("");
result.dest = format!("@{username} · {location}");
},
"grok_share" => {
result.kind = CardKind::SummaryLarge;
if let Some(dest_id) = data.destination.as_deref() {
let (url, _) = extract_destination(dest_id, &destinations);
result.url = url;
}
"Answer by Grok".clone_into(&mut result.dest);
let truncate = |text: &str, max: usize| {
let truncated: String = text.chars().take(max).collect();
if truncated.len() < text.len() {
format!("{truncated}...")
} else {
truncated
}
};
if let Some(conversation) = data.conversation_preview.as_ref() {
for msg in conversation {
match msg.sender.as_deref() {
Some("USER") => {
result.title = truncate(msg.message.as_deref().unwrap_or(""), 70);
},
Some("AGENT") => {
result.text = truncate(msg.message.as_deref().unwrap_or(""), 500);
},
_ => {},
}
}
}
},
"hidden" => {
result.kind = CardKind::Hidden;
},
"button_group" => {},
_ => {
tracing::debug!("Unknown unified card component type: {comp_type}");
},
}
}
}
result
}
impl TryFrom<&CardData> for Poll {
type Error = Error;
fn try_from(card: &CardData) -> Result<Self> {
let name = card.name();
if !name.contains("poll") {
return Err(Error::Internal("Not a poll card".into()));
}
let bv = card.binding_values();
let mut options = Vec::new();
let mut values = Vec::new();
for idx in 1..=4 {
let label = bv.string(&format!("choice{idx}_label"));
if label.is_empty() {
break;
}
options.push(label.to_owned());
let count = bv
.string(&format!("choice{idx}_count"))
.parse()
.unwrap_or(0);
values.push(count);
}
let votes = values.iter().sum();
#[expect(
clippy::cast_possible_wrap,
reason = "poll option count is always tiny"
)]
let leader = values
.iter()
.enumerate()
.max_by_key(|&(_, val)| val)
.map_or(0, |(idx, _)| idx as i64);
// Parse poll end time from end_datetime_utc (display formatting deferred
// to views via Poll::status_text)
let end_time = time::OffsetDateTime::parse(bv.string("end_datetime_utc"), &Rfc3339).ok();
// Extract poll image if the card name contains "image"
let image = if name.contains("image") {
let img = bv.image("image_large");
if img.is_empty() {
None
} else {
Some(img.to_owned())
}
} else {
None
};
Ok(Self {
options,
values,
votes,
leader,
end_time,
image,
})
}
}
+144
View File
@@ -0,0 +1,144 @@
use super::tweet::parse_tweet_object;
use crate::{
api::schema::{
ConversationData,
InstructionType,
},
error::{
Error,
Result,
},
types::{
Chain,
Conversation,
PaginatedResult,
Tweet,
},
};
/// Parse a conversation from `TweetDetail` response.
pub fn parse_conversation(data: &ConversationData, tweet_id: &str) -> Result<Conversation> {
// Single tweet result path
if let Some(tweet_data) = data
.tweet_result
.as_ref()
.and_then(|nested| nested.result.as_deref())
&& tweet_data.__typename.is_some()
{
let tweet = parse_tweet_object(tweet_data)?;
return Ok(Conversation {
tweet,
..Default::default()
});
}
// Instructions path
let raw_instructions = data
.threaded_conversation_with_injections_v2
.as_ref()
.map(|conv| conv.instructions.as_slice())
.filter(|instr| !instr.is_empty())
.ok_or_else(|| Error::TweetNotFound("No instructions array".to_owned()))?;
let mut main_tweet = Option::<Tweet>::None;
let mut before = Chain::default();
let mut after = Chain::default();
let mut replies = PaginatedResult::<Chain>::default();
// Parse tweet_id for matching
let target_id = tweet_id.parse().unwrap_or(0);
for instruction in raw_instructions {
if instruction.instruction_type != Some(InstructionType::TimelineAddEntries) {
continue;
}
for entry in instruction.entries.as_deref().unwrap_or_default() {
let entry_id = entry.entry_id_str();
if entry_id.starts_with("tweet-") {
// Single tweet entry -- match by ID to determine main vs before
if let Some(tweet) = entry
.tweet_result()
.and_then(|result| parse_tweet_object(result).ok())
{
if tweet.id == target_id {
main_tweet = Some(tweet);
} else {
before.content.push(tweet);
}
}
} else if entry_id.starts_with("tombstone") {
// Tombstone entry -- unavailable tweet in conversation
let tombstone_id = entry_id
.split('-')
.next_back()
.and_then(|id_str| id_str.parse().ok())
.unwrap_or(0);
let tweet = Tweet {
id: tombstone_id,
available: false,
tombstone: "This tweet is unavailable".to_owned(),
..Default::default()
};
if tombstone_id == target_id {
main_tweet = Some(tweet);
} else {
before.content.push(tweet);
}
} else if entry_id.starts_with("conversationthread-") {
// Thread/replies -- check for self-thread (after) vs other replies
let mut chain = Chain::default();
let mut is_self_thread = false;
for item in entry.items() {
// Check for "Show more" cursor in thread
if item.entry_id_str().contains("cursor-showmore") {
let cursor = item.cursor_value().unwrap_or_default();
chain.has_more = true;
chain.cursor = Some(cursor.to_owned());
continue;
}
if let Some(tweet_result) = item.tweet_result()
&& let Ok(tweet) = parse_tweet_object(tweet_result)
{
chain.content.push(tweet);
}
// Check if this is a self-thread
if item.display_type() == Some("SelfThread") {
is_self_thread = true;
}
}
if !chain.content.is_empty() {
if is_self_thread {
after = chain;
} else {
replies.content.push(chain);
}
}
} else if entry_id.starts_with("cursor-") {
// Pagination cursor
if let Some(cursor_value) = entry.cursor_value() {
if entry_id.contains("bottom") {
replies.bottom = Some(cursor_value.to_owned());
} else if entry_id.contains("top") {
replies.top = Some(cursor_value.to_owned());
}
}
}
}
}
let tweet = main_tweet
.ok_or_else(|| Error::TweetNotFound("Main tweet not found in conversation".to_owned()))?;
Ok(Conversation {
tweet,
before,
after,
replies,
})
}
+62
View File
@@ -0,0 +1,62 @@
use super::tweet::parse_tweet_object;
use crate::{
api::schema::{
EditHistoryData,
InstructionType,
},
error::{
Error,
Result,
},
types::EditHistory,
};
/// Parse a `TweetEditHistory` response.
#[expect(
clippy::module_name_repetitions,
reason = "clear naming for public API"
)]
pub fn parse_edit_history(data: &EditHistoryData) -> Result<EditHistory> {
let raw_instructions = data.instructions();
let mut result = EditHistory::default();
for instruction in raw_instructions {
if instruction.instruction_type != Some(InstructionType::TimelineAddEntries) {
continue;
}
for entry in instruction.entries.as_deref().unwrap_or(&[]) {
let entry_id = entry.entry_id_str();
if entry_id == "latestTweet" {
// Latest version is in items[0]
if let Some(tweet) = entry
.items()
.first()
.and_then(|item| item.tweet_result())
.map(parse_tweet_object)
.transpose()?
{
result.latest = tweet;
}
} else if entry_id == "staleTweets" {
// Previous versions
for item in entry.items() {
if let Some(tweet) = item
.tweet_result()
.and_then(|tw_result| parse_tweet_object(tw_result).ok())
{
result.history.push(tweet);
}
}
}
}
}
if result.latest.id == 0 {
return Err(Error::TweetNotFound("No edit history found".to_owned()));
}
Ok(result)
}
+64
View File
@@ -0,0 +1,64 @@
use crate::{
api::schema::{
Entities,
indices,
},
types::{
Entity,
EntityKind,
},
};
/// Parse entities from a typed `Entities` struct.
///
/// Stores only raw API data -- display formatting (`short_url`, hashtag
/// display) is deferred to `entity_expander::expand_entities()`.
pub fn parse_entities_from(raw: &Entities) -> Vec<Entity> {
let mut entities = Vec::new();
for url_ent in &raw.urls {
let (start, end) = indices(&url_ent.indices);
let expanded = url_ent.expanded_url.as_deref().unwrap_or_default();
entities.push(Entity {
indices: (start, end),
kind: EntityKind::Url,
url: expanded.to_owned(),
..Default::default()
});
}
// Hashtag/symbol display and URLs are computed by the expander from the
// original text -- only indices and kind are needed.
for hashtag in &raw.hashtags {
let (start, end) = indices(&hashtag.indices);
entities.push(Entity {
indices: (start, end),
kind: EntityKind::Hashtag,
..Default::default()
});
}
for mention in &raw.user_mentions {
let (start, end) = indices(&mention.indices);
let screen_name = mention.screen_name.as_deref().unwrap_or_default();
let name = mention.name.as_deref().unwrap_or(screen_name);
entities.push(Entity {
indices: (start, end),
kind: EntityKind::Mention,
url: format!("/{screen_name}"),
display: name.to_owned(),
});
}
for symbol in &raw.symbols {
let (start, end) = indices(&symbol.indices);
entities.push(Entity {
indices: (start, end),
kind: EntityKind::Symbol,
..Default::default()
});
}
entities.sort_by_key(|ent| ent.indices.0);
entities
}
+197
View File
@@ -0,0 +1,197 @@
use std::cmp;
use crate::{
api::schema::{
MediaItem,
MediaType,
RawVideoContentType,
TweetLegacy,
},
types::{
Gif,
Photo,
User,
Video,
VideoType,
VideoVariant,
},
};
/// Parsed media result (no side effects on tweet text).
pub struct ParsedMedia {
pub photos: Vec<Photo>,
pub video: Option<Video>,
pub gif: Option<Gif>,
pub attribution: Option<User>,
/// t.co and expanded URLs that should be stripped from tweet text.
pub strip_urls: Vec<String>,
}
/// Parse media from tweet legacy data.
///
/// Returns parsed media plus a list of URLs to strip from the tweet text.
/// Callers apply the stripping -- this function has no side effects.
pub fn parse_media(legacy: &TweetLegacy) -> ParsedMedia {
let mut photos = Vec::new();
let mut video = None;
let mut gif = None;
let mut attribution = None;
let mut strip_urls = Vec::new();
let items = legacy.media_items();
for media in items {
match media.media_type.unwrap_or_default() {
MediaType::Photo => {
if let Some(url) = media.media_url_https.as_deref() {
photos.push(Photo {
url: url.to_owned(),
alt_text: media.ext_alt_text.clone().unwrap_or_default(),
});
}
},
MediaType::Video => {
video = Some(parse_video(media));
// Parse media attribution from additional_media_info.source_user
if let Some(ref info) = media.additional_media_info
&& let Some(ref source_user) = info.source_user
{
attribution = User::try_from(source_user.as_ref()).ok();
}
},
MediaType::AnimatedGif => {
gif = Some(parse_gif(media));
},
_ => {},
}
// Collect URLs that should be stripped from tweet text
let tco_url = media.url.as_deref().unwrap_or_default();
let expanded_url = media.expanded_url.as_deref().unwrap_or(tco_url);
if !tco_url.is_empty() {
strip_urls.push(tco_url.to_owned());
}
if !expanded_url.is_empty() && expanded_url != tco_url {
strip_urls.push(expanded_url.to_owned());
}
}
ParsedMedia {
photos,
video,
gif,
attribution,
strip_urls,
}
}
/// Parse video from typed media item.
pub fn parse_video(media: &MediaItem) -> Video {
let thumb = media
.media_url_https
.as_deref()
.unwrap_or_default()
.to_owned();
let mut variants = Vec::new();
let mut duration_ms = 0_i32;
if let Some(ref video_info) = media.video_info {
duration_ms = video_info.duration_millis;
for variant in &video_info.variants {
let bitrate = variant.bitrate;
let url = variant.url.as_deref().unwrap_or_default().to_owned();
let content_type = match variant.content_type.unwrap_or_default() {
RawVideoContentType::Mp4 => VideoType::Mp4,
RawVideoContentType::M3u8 => VideoType::M3u8,
RawVideoContentType::Other => continue,
};
// Parse resolution from URL (format: /vid/WIDTHxHEIGHT/ or
// /vid/avc1/WIDTHxHEIGHT/)
let resolution = url
.split("/vid/")
.nth(1)
.and_then(|after_vid| {
after_vid
.split('/')
.find(|part| part.contains('x'))
.and_then(|dims| dims.split('x').nth(1))
.and_then(|height| height.parse().ok())
})
.unwrap_or(bitrate / 1000);
variants.push(VideoVariant {
content_type,
url,
bitrate,
resolution,
});
}
}
// Sort variants by resolution (highest first)
variants.sort_by_key(|var| cmp::Reverse(var.resolution));
// Check media availability via ext_media_availability
let (available, reason) = media.ext_media_availability.as_ref().map_or_else(
|| (true, String::new()),
|avail| {
let status = avail.status.as_deref().unwrap_or("");
if !status.is_empty() && !status.eq_ignore_ascii_case("available") {
(false, avail.reason.as_deref().unwrap_or(status).to_owned())
} else {
(true, String::new())
}
},
);
// Parse video title and description from additional_media_info
let title = media
.additional_media_info
.as_ref()
.and_then(|info| info.title.as_deref())
.unwrap_or_default()
.to_owned();
let description = media
.additional_media_info
.as_ref()
.and_then(|info| info.description.as_deref())
.unwrap_or_default()
.to_owned();
Video {
duration_ms,
url: variants
.first()
.map_or_else(String::new, |var| var.url.clone()),
thumb,
available,
reason,
title,
description,
playback_type: VideoType::Mp4,
variants,
}
}
/// Parse GIF from typed media item.
pub fn parse_gif(media: &MediaItem) -> Gif {
let thumb = media
.media_url_https
.as_deref()
.unwrap_or_default()
.to_owned();
let url = media
.video_info
.as_ref()
.and_then(|vi| vi.variants.first())
.and_then(|var| var.url.as_deref())
.unwrap_or_default()
.to_owned();
Gif { url, thumb }
}
+23
View File
@@ -0,0 +1,23 @@
mod card;
mod conversation;
pub mod edit_history;
mod entity;
mod media;
mod search;
mod timeline;
mod tweet;
mod user;
pub use conversation::parse_conversation;
pub use search::{
parse_list,
parse_list_members,
parse_user_search,
};
pub use timeline::{
parse_list_timeline,
parse_search_timeline,
parse_timeline,
};
pub use tweet::parse_tweet;
pub use user::parse_user;
+124
View File
@@ -0,0 +1,124 @@
use super::user::parse_user_object;
use crate::{
api::schema::{
InstructionType,
ListData,
ListMembersData,
SearchTimelineData,
},
types::{
List,
PaginatedResult,
Query,
User,
},
};
/// Parse user search results.
pub fn parse_user_search(data: &SearchTimelineData) -> PaginatedResult<User> {
let raw_instructions = data.instructions();
let mut users = Vec::new();
let mut bottom_cursor = None;
for instruction in raw_instructions {
if instruction.instruction_type != Some(InstructionType::TimelineAddEntries) {
continue;
}
for entry in instruction.entries.as_deref().unwrap_or_default() {
let entry_id = entry.entry_id_str();
if entry_id.starts_with("user-") {
if let Some(user_result) = entry.user_result()
&& let Ok(user) = parse_user_object(user_result)
{
users.push(user);
}
} else if entry_id.starts_with("cursor-bottom-") {
bottom_cursor = entry.cursor_value().map(str::to_owned);
}
}
}
PaginatedResult {
content: users,
top: None,
bottom: bottom_cursor,
beginning: false,
query: Query::default(),
}
}
/// Parse a list from typed `ListData`.
pub fn parse_list(raw: &ListData) -> List {
let id = raw
.id_str
.as_deref()
.or(raw.rest_id.as_deref())
.unwrap_or_default()
.to_owned();
let name = raw.name.clone().unwrap_or_default();
let description = raw.description.clone().unwrap_or_default();
let members = raw.member_count;
// Extract user info from nested user_results
let (user_id, username) = raw
.user_results
.as_ref()
.and_then(|nr| nr.result.as_ref())
.and_then(|user_data| parse_user_object(user_data).ok())
.map(|user| (user.id, user.username))
.unwrap_or_default();
let banner = raw.banner_url.as_deref().unwrap_or_default().to_owned();
List {
id,
name,
user_id,
username,
description,
members,
banner,
}
}
/// Parse list members from API response.
pub fn parse_list_members(data: &ListMembersData) -> PaginatedResult<User> {
let raw_instructions = data.instructions();
let mut users = Vec::new();
let mut top_cursor = None;
let mut bottom_cursor = None;
for instruction in raw_instructions {
if instruction.instruction_type != Some(InstructionType::TimelineAddEntries) {
continue;
}
for entry in instruction.entries.as_deref().unwrap_or_default() {
let entry_id = entry.entry_id_str();
if entry_id.starts_with("user-") {
if let Some(user_result) = entry.user_result()
&& let Ok(user) = parse_user_object(user_result)
{
users.push(user);
}
} else if entry_id.starts_with("cursor-bottom-") {
bottom_cursor = entry.cursor_value().map(str::to_owned);
} else if entry_id.starts_with("cursor-top-") {
top_cursor = entry.cursor_value().map(str::to_owned);
}
}
}
PaginatedResult {
content: users,
top: top_cursor,
bottom: bottom_cursor,
beginning: false,
query: Query::default(),
}
}
+184
View File
@@ -0,0 +1,184 @@
use super::tweet::parse_tweet_object;
use crate::{
api::schema::{
Entry,
Instruction,
InstructionType,
ListTimelineData,
SearchTimelineData,
UserTimelineData,
},
error::{
Error,
Result,
},
types::{
Query,
Timeline,
Tweets,
},
};
/// Parse a user timeline (tweets, media, tweets-and-replies).
pub fn parse_timeline(data: &UserTimelineData) -> Result<Timeline> {
let instructions = data
.user
.as_ref()
.or(data.user_result.as_ref())
.and_then(|nested| nested.result.as_ref())
.map(super::super::schema::TimelineResultData::instructions)
.filter(|instr| !instr.is_empty())
.ok_or_else(|| Error::Internal("Timeline instructions not found".into()))?;
parse_timeline_instructions(instructions)
}
/// Parse a list timeline.
pub fn parse_list_timeline(data: &ListTimelineData) -> Result<Timeline> {
let instructions = data.instructions();
if instructions.is_empty() {
return Err(Error::Internal("Timeline instructions not found".into()));
}
parse_timeline_instructions(instructions)
}
fn parse_timeline_instructions(raw_instructions: &[Instruction]) -> Result<Timeline> {
let mut tweets: Vec<Tweets> = Vec::new();
let mut top_cursor = None;
let mut bottom_cursor = None;
for instruction in raw_instructions {
// Handle moduleItems (grid modules used in media timelines)
if let Some(ref module_items) = instruction.module_items {
let mut module_tweets = Vec::new();
for item in module_items {
if let Some(tweet_result) = item.tweet_result()
&& let Ok(tweet) = parse_tweet_object(tweet_result)
{
module_tweets.push(tweet);
}
}
if !module_tweets.is_empty() {
tweets.push(module_tweets);
}
continue;
}
match instruction.instruction_type.unwrap_or_default() {
InstructionType::TimelineAddEntries => {
for entry in instruction.entries.as_deref().unwrap_or_default() {
let entry_id = entry.entry_id_str();
tracing::trace!("timeline entry: {entry_id}");
if entry_id.starts_with("tweet-")
|| entry_id.contains("-conversation-")
|| entry_id.starts_with("profile-grid-")
{
let entry_tweets = parse_timeline_entry(entry);
if !entry_tweets.is_empty() {
tweets.push(entry_tweets);
}
} else if entry_id.starts_with("cursor-bottom-") {
bottom_cursor = entry.cursor_value().map(str::to_owned);
} else if entry_id.starts_with("cursor-top-") {
top_cursor = entry.cursor_value().map(str::to_owned);
}
}
},
InstructionType::TimelinePinEntry => {
if let Some(mut tweet) = instruction
.entry
.as_ref()
.and_then(|entry| entry.tweet_result())
.map(parse_tweet_object)
.transpose()?
{
tweet.pinned = true;
tweets.insert(0, vec![tweet]);
}
},
_ => {},
}
}
Ok(Timeline {
content: tweets,
top: top_cursor,
bottom: bottom_cursor,
beginning: false,
query: Query::default(),
})
}
/// Parse tweets from a timeline entry.
fn parse_timeline_entry(entry: &Entry) -> Tweets {
let mut tweets = Vec::new();
// Single tweet entry
if let Some(tweet_result) = entry.tweet_result() {
if let Ok(tweet) = parse_tweet_object(tweet_result) {
tweets.push(tweet);
}
return tweets;
}
// Conversation module (thread)
for item in entry.items() {
if let Some(tweet_result) = item.tweet_result()
&& let Ok(tweet) = parse_tweet_object(tweet_result)
{
tweets.push(tweet);
}
}
tweets
}
/// Parse search results.
pub fn parse_search_timeline(data: &SearchTimelineData) -> Timeline {
let raw_instructions = data.instructions();
let mut tweets: Vec<Tweets> = Vec::new();
let mut bottom_cursor = None;
for instruction in raw_instructions {
match instruction.instruction_type.unwrap_or_default() {
InstructionType::TimelineAddEntries => {
for entry in instruction.entries.as_deref().unwrap_or_default() {
let entry_id = entry.entry_id_str();
if entry_id.starts_with("tweet-") {
if let Some(tweet_result) = entry.tweet_result()
&& let Ok(tweet) = parse_tweet_object(tweet_result)
{
tweets.push(vec![tweet]);
}
} else if entry_id.starts_with("cursor-bottom-") {
bottom_cursor = entry.cursor_value().map(str::to_owned);
}
}
},
// TimelineReplaceEntry: replace bottom cursor
InstructionType::TimelineReplaceEntry => {
if let Some(cursor) = instruction
.entry_id_to_replace
.as_deref()
.filter(|id| id.starts_with("cursor-bottom"))
.and(instruction.entry.as_ref())
.and_then(|entry| entry.cursor_value())
{
bottom_cursor = Some(cursor.to_owned());
}
},
_ => {},
}
}
Timeline {
content: tweets,
top: None,
bottom: bottom_cursor,
beginning: false,
query: Query::default(),
}
}
+437
View File
@@ -0,0 +1,437 @@
use super::{
entity::parse_entities_from,
media::parse_media,
user::parse_user_object,
};
use crate::{
api::schema::{
TweetData,
TweetLegacy,
TweetResultData,
indices,
},
error::{
Error,
Result,
},
types::{
Card,
CardKind,
EntityKind,
Photo,
Poll,
Tweet,
TweetStats,
},
};
/// Parse a tweet from typed GraphQL response.
pub fn parse_tweet(data: &TweetResultData) -> Result<Tweet> {
let tweet_data = data
.tweet_result
.as_ref()
.or(data.tweet.as_ref())
.and_then(|nested| nested.result.as_deref())
.ok_or_else(|| Error::TweetNotFound("Tweet data not found in response".into()))?;
parse_tweet_object(tweet_data)
}
/// Parse a tweet object (from `NestedResult` or direct).
pub fn parse_tweet_object(raw: &TweetData) -> Result<Tweet> {
Tweet::try_from(raw)
}
impl TryFrom<&TweetData> for Tweet {
type Error = Error;
#[expect(clippy::option_if_let_else, reason = "readability")]
fn try_from(raw: &TweetData) -> Result<Self> {
// Handle tombstones/unavailable tweets
match raw.__typename.as_deref() {
Some("TweetTombstone") => {
let text = raw
.tombstone
.as_deref()
.unwrap_or("Tweet is unavailable")
.to_owned();
return Ok(Self {
available: false,
tombstone: text,
..Default::default()
});
},
Some("TweetUnavailable") => {
let reason = raw.reason.as_deref().unwrap_or("unavailable").to_owned();
return Ok(Self {
available: false,
tombstone: reason,
..Default::default()
});
},
Some("TweetPreviewDisplay") => {
return Ok(Self {
available: false,
tombstone: "You're unable to view this Tweet because it's only available to the \
Subscribers of the account owner."
.to_owned(),
..Default::default()
});
},
Some("TweetWithVisibilityResults") => {
if let Some(inner) = raw.tweet.as_deref() {
return Self::try_from(inner);
}
},
_ => {},
}
let default_legacy = TweetLegacy::default();
let legacy = raw.legacy.as_ref().unwrap_or(&default_legacy);
let id = legacy
.id_str
.as_deref()
.or(raw.rest_id.as_deref())
.and_then(|id_str| id_str.parse().ok())
.unwrap_or(0_i64);
if id == 0 {
return Err(Error::TweetNotFound("No tweet ID found".into()));
}
let is_withheld = legacy.is_withheld();
// Check for note_tweet (long tweets >280 chars) which provides the full
// untruncated text
let note_tweet = raw.note_tweet.as_ref();
let mut text = note_tweet.map_or_else(
|| legacy.full_text().to_owned(),
|nt| nt.text.clone().unwrap_or_default(),
);
// Parse user (check both user_results and user_result paths)
let user = raw
.core
.as_ref()
.and_then(|core| core.user_value())
.and_then(|user_data| parse_user_object(user_data).ok())
.unwrap_or_default();
// Parse stats
let stats = TweetStats {
replies: legacy.reply_count,
retweets: legacy.retweet_count,
likes: legacy.favorite_count,
views: raw
.views
.as_ref()
.and_then(|views| views.count.as_ref())
.and_then(|count| count.parse().ok())
.unwrap_or(0),
};
// Parse media from typed legacy fields (no side effects on text)
let media = parse_media(legacy);
let mut photos = media.photos;
let video = media.video;
let gif = media.gif;
let media_attribution = media.attribution;
// Strip media URLs from tweet text
for url in &media.strip_urls {
if !url.is_empty() && text.ends_with(url) {
let new_len = text.len() - url.len();
text.truncate(new_len);
#[expect(
clippy::assigning_clones,
reason = "clone_into cannot borrow text mutably while trim_end borrows it"
)]
{
text = text.trim_end().to_owned();
}
break;
}
}
let time = legacy.parse_time();
// Parse reply info (with GraphQL fallbacks)
let mut reply_id = legacy.reply_id();
// Fallback: reply_to_results.rest_id (newer GraphQL path)
if reply_id == 0
&& let Some(ref rtr) = raw.reply_to_results
{
reply_id = rtr
.rest_id
.as_deref()
.and_then(|id_str| id_str.parse().ok())
.unwrap_or(0);
}
let mut reply: Vec<String> = legacy
.in_reply_to_screen_name
.as_ref()
.map(|name| vec![name.clone()])
.unwrap_or_default();
// Fallback: reply_to_user_results for reply username (newer GraphQL path)
if reply.is_empty()
&& let Some(screen_name) = raw.reply_to_screen_name.as_ref()
{
reply = vec![screen_name.clone()];
}
// Parse thread info
let has_thread = legacy.self_thread.is_some();
let thread_id = legacy.thread_id(id);
// Parse quote tweet (check both paths)
let quote = {
let qsr = raw
.quoted_status_result
.as_ref()
.or(raw.quoted_post_results.as_ref());
match qsr {
Some(result) => {
result.result.as_deref().map_or_else(
|| {
// quotedPostResults present but no result -- create stub with just the ID
let quoted_id = legacy
.quoted_status_id_str
.as_deref()
.and_then(|id_str| id_str.parse().ok())
.unwrap_or(0);
(quoted_id != 0).then(|| {
Box::new(Self {
id: quoted_id,
..Default::default()
})
})
},
|quote_data| parse_tweet_object(quote_data).ok().map(Box::new),
)
},
// Fallback: is_quote_status + quoted_status_id_str
None => {
if legacy.is_quote_status.unwrap_or(false) {
let quoted_id = legacy
.quoted_status_id_str
.as_deref()
.and_then(|id_str| id_str.parse().ok())
.unwrap_or(0);
(quoted_id != 0).then(|| {
Box::new(Self {
id: quoted_id,
..Default::default()
})
})
} else {
None
}
},
}
};
// Parse retweet (check legacy path and newer repostedStatusResults)
let retweet = legacy
.retweeted_status_result
.as_ref()
.or(raw.reposted_status_results.as_ref())
.and_then(|nested| nested.result.as_deref())
.and_then(|rt_data| parse_tweet_object(rt_data).ok())
.map(Box::new);
// Parse card, then expand card URL via tweet entities
let mut card = raw
.card
.as_ref()
.and_then(|card_data| Card::try_from(card_data).ok());
if let Some(ref mut card_ref) = card
&& !card_ref.url.is_empty()
&& let Some(expanded) = legacy.expand_card_url(&card_ref.url)
{
card_ref.url = expanded;
}
// Parse poll
let poll = card
.as_ref()
.filter(|card_ref| matches!(card_ref.kind, CardKind::Unknown))
.and(raw.card.as_ref())
.and_then(|card_data| Poll::try_from(card_data).ok());
let location = legacy.location().to_owned();
// Parse entities for text expansion
// Use note_tweet entity_set if available (for long tweets), otherwise use
// legacy entities
let mut entities = note_tweet.map_or_else(
|| {
legacy
.entities
.as_ref()
.map_or_else(Vec::new, parse_entities_from)
},
|nt| {
nt.entity_set
.as_ref()
.map_or_else(Vec::new, parse_entities_from)
},
);
// Apply display_text_range to strip leading @reply mentions
#[expect(
clippy::cast_possible_truncation,
reason = "display_start fits in usize"
)]
let display_start = legacy
.display_text_range
.as_ref()
.and_then(|range| range.first().copied())
.unwrap_or(0) as usize;
// Collect leading @mention entities into the reply field so the "Replying to"
// line shows all recipients, not just in_reply_to_screen_name.
//
// For note_tweets: the note_tweet entity_set often doesn't include
// user_mentions, so fall back to legacy entities for mention collection.
// The note_tweet text is already stripped of the @mention prefix.
if reply_id != 0
&& note_tweet.is_some()
&& let Some(ref ent) = legacy.entities
{
for mention in &ent.user_mentions {
let screen_name = mention.screen_name.as_deref().unwrap_or_default();
let (start, _) = indices(&mention.indices);
if start < display_start
&& !screen_name.is_empty()
&& !reply
.iter()
.any(|existing| existing.eq_ignore_ascii_case(screen_name))
{
reply.push(screen_name.to_owned());
}
}
}
if display_start > 0 && note_tweet.is_none() {
for entity in &entities {
if entity.indices.0 < display_start && entity.kind == EntityKind::Mention {
// url is "/{screen_name}", strip the leading "/"
let screen_name = entity.url.trim_start_matches('/');
if !screen_name.is_empty()
&& !reply
.iter()
.any(|existing| existing.eq_ignore_ascii_case(screen_name))
{
reply.push(screen_name.to_owned());
}
}
}
// Trim the text: skip the first display_start characters (Unicode codepoints)
let char_byte_offset = text
.char_indices()
.nth(display_start)
.map_or(0, |(byte_idx, _)| byte_idx);
if char_byte_offset > 0 {
#[expect(
clippy::assigning_clones,
reason = "clone_into cannot borrow text mutably while slicing borrows it"
)]
{
text = text[char_byte_offset..].trim_start().to_owned();
}
}
// Adjust entity indices and filter out entities before the display range
entities.retain(|ent| ent.indices.0 >= display_start);
for entity in &mut entities {
entity.indices.0 -= display_start;
entity.indices.1 -= display_start;
}
}
// Parse community note (Birdwatch pivot)
let note = raw
.birdwatch_pivot
.as_ref()
.and_then(super::super::schema::BirdwatchPivot::to_note);
// Parse edit history IDs
let history = raw
.edit_control
.as_ref()
.and_then(|ec| ec.tweet_ids())
.map(|ids| {
ids.iter()
.filter_map(|id_str| id_str.parse::<i64>().ok())
.collect()
})
.unwrap_or_default();
// Extract poll image into photos
if let Some(ref poll) = poll
&& let Some(ref img_url) = poll.image
{
photos.push(Photo {
url: img_url.clone(),
alt_text: String::new(),
});
}
// Handle amplify card: sets tweet.video directly
let (card, video) = if card
.as_ref()
.is_some_and(|card_ref| card_ref.kind == CardKind::Amplify)
{
let amplify_video = card.and_then(|card_ref| card_ref.video);
(None, video.or(amplify_video))
} else {
(card, video)
};
// Strip " Learn more." from withheld text
if is_withheld {
#[expect(
clippy::assigning_clones,
reason = "clone_into cannot borrow text mutably while trim borrows it"
)]
{
text = text.trim_end_matches(" Learn more.").to_owned();
}
}
Ok(Self {
id,
thread_id,
reply_id,
user,
text: text.clone(),
time,
reply,
pinned: false,
has_thread,
available: !is_withheld,
tombstone: if is_withheld { text } else { String::new() },
location,
source: String::new(),
stats,
retweet,
attribution: media_attribution,
media_tags: Vec::new(),
quote,
card,
poll,
gif,
video,
photos,
note,
history,
entities,
})
}
}
+224
View File
@@ -0,0 +1,224 @@
use crate::{
api::schema::{
UserData,
UserLegacy,
UserResultData,
},
error::{
Error,
Result,
},
types::{
User,
VerifiedType,
},
utils::formatters::parse_twitter_time,
};
/// Parse a user from typed GraphQL response.
pub fn parse_user(data: &UserResultData) -> Result<User> {
let user_data = data
.user
.as_ref()
.or(data.user_result.as_ref())
.or(data.user_results.as_ref())
.and_then(|nested| nested.result.as_deref())
.ok_or_else(|| Error::UserNotFound("User data not found in response".into()))?;
parse_user_object(user_data)
}
/// Parse a user object (from `NestedResult` or direct).
pub fn parse_user_object(raw: &UserData) -> Result<User> {
User::try_from(raw)
}
impl TryFrom<&UserData> for User {
type Error = Error;
fn try_from(raw: &UserData) -> Result<Self> {
// Handle UserUnavailable
if raw.__typename.as_deref() == Some("UserUnavailable") {
let reason = raw
.reason
.clone()
.unwrap_or_else(|| "unavailable".to_owned());
return Err(Error::UserNotFound(reason));
}
let default_legacy = UserLegacy::default();
let legacy = raw.legacy.as_ref().unwrap_or(&default_legacy);
let id = raw
.rest_id
.as_deref()
.or(legacy.id_str.as_deref())
.unwrap_or_default()
.to_owned();
if id.is_empty() {
return Err(Error::UserNotFound("No user ID found".into()));
}
let username = legacy.screen_name.clone().unwrap_or_default();
let fullname = legacy.name.clone().unwrap_or_default();
let bio = legacy.description.clone().unwrap_or_default();
let location = legacy.location.clone().unwrap_or_default();
let website = legacy
.url_entities
.first()
.and_then(|url_item| url_item.expanded_url.as_deref())
.unwrap_or_default()
.to_owned();
let user_pic = legacy
.profile_image_url_https
.as_deref()
.unwrap_or_default()
.replace("_normal", "_400x400");
let banner = {
let url = legacy.profile_banner_url.as_deref().unwrap_or_default();
if url.is_empty() {
let color = legacy.profile_link_color.as_deref().unwrap_or_default();
if color.is_empty() {
String::new()
} else {
format!("#{color}")
}
} else {
format!("{url}/1500x500")
}
};
let followers = legacy.followers_count;
let following = legacy.friends_count;
let tweets = legacy.statuses_count;
let likes = legacy.favourites_count;
let media = legacy.media_count;
let protected = legacy.protected.unwrap_or(false);
let suspended = raw.unavailable_message;
let is_blue = raw.is_blue_verified.unwrap_or(false);
// Check verified_type first -- Business/Government accounts also have
// is_blue_verified=true
let verified_type = match legacy.verified_type.as_deref() {
Some("Business") => VerifiedType::Business,
Some("Government") => VerifiedType::Government,
_ if is_blue => VerifiedType::Blue,
_ => VerifiedType::None,
};
let join_date = legacy
.created_at
.as_deref()
.or_else(|| {
let core = raw.core.as_ref()?;
core.created_at.as_deref()
})
.and_then(parse_twitter_time);
let pinned_tweet = legacy
.pinned_tweet_ids_str
.as_ref()
.and_then(|ids| ids.first())
.and_then(|id_str| id_str.parse().ok())
.unwrap_or(0);
// Fallback to support newer GraphQL updates where fields moved out of legacy
let (username, fullname, user_pic, bio, location, verified_type) =
if username.is_empty() || user_pic.is_empty() {
let fb_username = raw
.core
.as_ref()
.and_then(|core| core.screen_name.as_deref())
.unwrap_or_default()
.to_owned();
let fb_fullname = raw
.core
.as_ref()
.and_then(|core| core.name.as_deref())
.unwrap_or_default()
.to_owned();
let fb_user_pic = raw
.avatar
.as_ref()
.and_then(|avatar| avatar.image_url.as_deref())
.unwrap_or_default()
.replace("_normal", "_400x400");
let fb_location = raw
.location
.as_ref()
.and_then(|loc| loc.location.as_deref())
.unwrap_or_default()
.to_owned();
let fb_bio = raw
.profile_bio
.as_ref()
.and_then(|bio_data| bio_data.description.as_deref())
.unwrap_or_default()
.to_owned();
// Check verified_type first -- Business/Government override blue
let fb_verified_type = match raw
.verification
.as_ref()
.and_then(|ver| ver.verified_type.as_deref())
{
Some("Business") => VerifiedType::Business,
Some("Government") => VerifiedType::Government,
_ if is_blue => VerifiedType::Blue,
_ => verified_type,
};
(
if fb_username.is_empty() {
username
} else {
fb_username
},
if fb_fullname.is_empty() {
fullname
} else {
fb_fullname
},
if fb_user_pic.is_empty() {
user_pic
} else {
fb_user_pic
},
if fb_bio.is_empty() { bio } else { fb_bio },
if fb_location.is_empty() {
location
} else {
fb_location
},
fb_verified_type,
)
} else {
(username, fullname, user_pic, bio, location, verified_type)
};
Ok(Self {
id,
username,
fullname,
location,
website,
bio,
user_pic,
banner,
pinned_tweet,
following,
followers,
tweets,
likes,
media,
verified_type,
protected,
suspended,
join_date,
})
}
}
+1190
View File
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
//! Transaction ID generation for Twitter/X API requests.
//!
//! Uses the `xitter-txid` crate to generate client transaction IDs
//! matching what the X web app sends. This is required for cookie-based
//! sessions to get full API responses (e.g., conversation-grouped entries
//! in `UserTweetsAndReplies`).
use std::{
sync::Arc,
time::{
Duration,
Instant,
},
};
use axum::http::{
HeaderMap,
header,
};
use tokio::sync::{
Mutex,
RwLock,
};
use xitter_txid::ClientTransaction;
use super::{
endpoints,
http::HttpClient,
};
/// Cached transaction ID client that refreshes periodically.
#[derive(Clone)]
pub struct TidClient {
inner: Arc<RwLock<Option<ClientTransaction>>>,
http: HttpClient,
last_fetch: Arc<Mutex<Instant>>,
}
/// How often to refresh the TID client.
const REFRESH_INTERVAL: Duration = Duration::from_hours(1);
impl TidClient {
pub fn new(http: HttpClient) -> Self {
Self {
inner: Arc::new(RwLock::new(None)),
http,
last_fetch: Arc::new(Mutex::new(
Instant::now().checked_sub(REFRESH_INTERVAL).unwrap(),
)),
}
}
/// Generate a transaction ID for a request path, or [`None`] if TID is
/// unavailable.
pub async fn generate(&self, path: &str) -> Option<String> {
self.ensure_fresh().await;
let guard = self.inner.read().await;
guard
.as_ref()
.map(|ct| ct.generate_transaction_id("GET", path))
}
/// Refresh the TID client if stale. Uses `try_lock` so only one task
/// performs the refresh — concurrent callers skip and use the existing
/// (possibly stale) client.
async fn ensure_fresh(&self) {
let Ok(mut last) = self.last_fetch.try_lock() else {
return; // another task is already refreshing
};
if last.elapsed() < REFRESH_INTERVAL {
return;
}
match self.fetch_client().await {
Ok(ct) => {
*self.inner.write().await = Some(ct);
*last = Instant::now();
tracing::info!("TID client refreshed");
},
Err(err) => {
tracing::warn!("Failed to refresh TID client: {err}");
},
}
}
/// Fetch the x.com homepage and ondemand JS to create a new
/// [`ClientTransaction`].
async fn fetch_client(&self) -> Result<ClientTransaction, String> {
let mut ua_header = HeaderMap::new();
ua_header.insert(header::USER_AGENT, endpoints::USER_AGENT.parse().unwrap());
let home_html = self
.http
.get_with_headers("https://x.com", &ua_header)
.await
.map_err(|err| format!("fetch x.com: {err}"))?
.text()
.await
.map_err(|err| format!("read x.com body: {err}"))?;
let js_url = ClientTransaction::extract_ondemand_url(&home_html)
.map_err(|err| format!("extract ondemand URL: {err}"))?;
let js_text = self
.http
.get_with_headers(&js_url, &ua_header)
.await
.map_err(|err| format!("fetch ondemand JS: {err}"))?
.text()
.await
.map_err(|err| format!("read ondemand JS body: {err}"))?;
ClientTransaction::new(&home_html, &js_text)
.map_err(|err| format!("create TID client: {err}"))
}
}
+152
View File
@@ -0,0 +1,152 @@
use std::{
any::Any,
collections::HashMap,
sync::{
Arc,
RwLock,
},
time::{
Duration,
Instant,
},
};
/// Entry in the cache: type-erased value + expiry.
struct Entry {
value: Arc<dyn Any + Send + Sync>,
expires: Instant,
}
/// In-process cache with TTL-based expiry.
///
/// Uses `std::sync::RwLock` (not tokio) because the lock is never held across
/// `.await` — all operations are plain `HashMap` lookups/inserts.
/// Stores values as `Arc<dyn Any>` to avoid JSON serialization overhead.
#[derive(Clone)]
pub struct Cache {
inner: Arc<RwLock<HashMap<String, Entry>>>,
}
impl Cache {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Get a value from cache, returning `None` if missing, expired, or
/// type-mismatched.
#[expect(clippy::significant_drop_tightening, reason = "entry borrows from map")]
pub fn get<T: Any + Send + Sync + Clone>(&self, key: &str) -> Option<T> {
let map = self.inner.read().ok()?;
let entry = map.get(key)?;
if entry.expires <= Instant::now() {
return None;
}
entry.value.downcast_ref::<T>().cloned()
}
/// Set a value in cache with TTL in seconds.
pub fn set<T: Any + Send + Sync + Clone>(&self, key: &str, value: &T, ttl_seconds: u64) {
let entry = Entry {
value: Arc::new(value.clone()),
expires: Instant::now() + Duration::from_secs(ttl_seconds),
};
if let Ok(mut map) = self.inner.write() {
map.insert(key.to_owned(), entry);
// Lazy eviction: purge expired entries when map grows large
if map.len() > 4096 {
let now = Instant::now();
map.retain(|_, cached| cached.expires > now);
}
}
}
/// Delete a key from cache.
pub fn delete(&self, key: &str) {
if let Ok(mut map) = self.inner.write() {
map.remove(key);
}
}
}
/// Cache key builders.
pub mod keys {
pub fn user(username: &str) -> String {
format!("u:{}", username.to_lowercase())
}
pub fn tweet(id: i64) -> String {
format!("t:{id}")
}
pub fn profile(username: &str) -> String {
format!("p:{}", username.to_lowercase())
}
pub fn timeline(username: &str, kind: &str) -> String {
format!("tl:{}:{kind}", username.to_lowercase())
}
pub fn list(id: &str) -> String {
format!("l:{id}")
}
pub fn list_members(id: &str) -> String {
format!("lm:{id}")
}
pub fn conversation(id: &str) -> String {
format!("conv:{id}")
}
pub fn rss(key: &str) -> String {
format!("rss:{key}")
}
/// User ID to username mapping.
pub fn user_id(id: &str) -> String {
format!("uid:{id}")
}
pub fn rss_user(username: &str) -> String {
rss(&format!("user:{}", username.to_lowercase()))
}
pub fn rss_replies(username: &str) -> String {
rss(&format!("replies:{}", username.to_lowercase()))
}
pub fn rss_media(username: &str) -> String {
rss(&format!("media:{}", username.to_lowercase()))
}
pub fn rss_search(query: &str) -> String {
rss(&format!("search:{}", query.to_lowercase()))
}
pub fn rss_user_search(username: &str, query: &str) -> String {
rss(&format!(
"usersearch:{}:{}",
username.to_lowercase(),
query.to_lowercase()
))
}
pub fn rss_list(id: &str) -> String {
rss(&format!("list:{id}"))
}
pub fn rss_list_slug(username: &str, slug: &str) -> String {
rss(&format!("listslug:{}:{slug}", username.to_lowercase()))
}
}
/// Cache TTL constants (in seconds).
pub mod ttl {
/// Standard TTL for user/profile/tweet data (5 minutes).
pub const DEFAULT: u64 = 300;
/// Long TTL for immutable mappings like user ID -> username (24 hours).
pub const USER_ID_MAPPING: u64 = 86400;
}
+160
View File
@@ -0,0 +1,160 @@
use std::{
fs,
path::Path,
};
use serde::Deserialize;
use crate::error::Result;
#[derive(Debug, Clone, Deserialize)]
#[expect(
clippy::struct_field_names,
reason = "config field in Config mirrors TOML structure"
)]
pub struct Config {
pub server: ServerConfig,
pub cache: CacheConfig,
pub config: AppConfig,
#[serde(default)]
pub preferences: PreferencesConfig,
/// Precomputed `{scheme}://{hostname}` — set in `Config::load`.
#[serde(skip)]
pub url_prefix: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_hostname")]
pub hostname: String,
#[serde(default = "default_title")]
pub title: String,
#[serde(default = "default_address")]
pub address: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
pub https: bool,
#[serde(
default = "default_http_max_connections",
rename = "httpMaxConnections"
)]
pub http_max_connections: u32,
#[serde(default = "default_static_dir", rename = "staticDir")]
pub static_dir: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CacheConfig {
#[serde(default = "default_list_minutes", rename = "listMinutes")]
pub list_minutes: u64,
#[serde(default = "default_rss_minutes", rename = "rssMinutes")]
pub rss_minutes: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[expect(
clippy::struct_excessive_bools,
reason = "mirrors TOML config booleans"
)]
pub struct AppConfig {
#[serde(default = "default_hmac_key", rename = "hmacKey")]
pub hmac_key: String,
#[serde(default, rename = "base64Media")]
pub base64_media: bool,
#[serde(default = "default_true", rename = "enableRSS")]
pub enable_rss: bool,
#[serde(default, rename = "enableDebug")]
pub enable_debug: bool,
#[serde(default)]
pub proxy: String,
#[serde(default, rename = "proxyAuth")]
pub proxy_auth: String,
#[serde(default, rename = "apiProxy")]
pub api_proxy: String,
#[serde(default, rename = "disableTid")]
pub disable_tid: bool,
#[serde(default = "default_max_concurrent_reqs", rename = "maxConcurrentReqs")]
pub max_concurrent_reqs: u32,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct PreferencesConfig {
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default, rename = "replaceTwitter")]
pub replace_twitter: String,
#[serde(default, rename = "replaceYouTube")]
pub replace_youtube: String,
#[serde(default, rename = "replaceReddit")]
pub replace_reddit: String,
#[serde(default = "default_true", rename = "proxyVideos")]
pub proxy_videos: bool,
#[serde(default, rename = "hlsPlayback")]
pub hls_playback: bool,
#[serde(default, rename = "infiniteScroll")]
pub infinite_scroll: bool,
}
// Default value functions
fn default_hostname() -> String {
"localhost".to_owned()
}
fn default_title() -> String {
"teapot".to_owned()
}
fn default_address() -> String {
"0.0.0.0".to_owned()
}
const fn default_port() -> u16 {
8080
}
const fn default_http_max_connections() -> u32 {
100
}
fn default_static_dir() -> String {
"./public".to_owned()
}
const fn default_list_minutes() -> u64 {
120
}
const fn default_rss_minutes() -> u64 {
10
}
fn default_hmac_key() -> String {
"secretkey".to_owned()
}
const fn default_max_concurrent_reqs() -> u32 {
2
}
fn default_theme() -> String {
"teapot".to_owned()
}
const fn default_true() -> bool {
true
}
impl Config {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(path)?;
let mut config = toml::from_str::<Self>(&content)?;
if config.config.hmac_key == "secretkey" {
tracing::warn!(
"Using default HMAC key — video proxy URLs are forgeable. Set config.hmacKey in your \
config"
);
}
let scheme = if config.server.https {
"https"
} else {
"http"
};
config.url_prefix = format!("{scheme}://{}", config.server.hostname);
Ok(config)
}
/// Get the full URL prefix for generating links.
pub fn url_prefix(&self) -> &str {
&self.url_prefix
}
}
+149
View File
@@ -0,0 +1,149 @@
use std::{
io,
result,
};
use axum::{
http::StatusCode,
response::{
Html,
IntoResponse,
Response,
},
};
use thiserror::Error;
use toml::de::Error as TomlError;
use crate::{
utils::html_escape,
views::layout::{
FONTELLO_CSS,
STYLE_CSS,
},
};
pub type Result<T> = result::Result<T, Error>;
#[derive(Error, Debug)]
pub enum Error {
#[error("Configuration error: {0}")]
Config(#[from] TomlError),
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("HTTP request error: {0}")]
Http(String),
#[error("JSON parsing error: {0}")]
Json(#[from] serde_json::Error),
#[error("Twitter API error: {0}")]
TwitterApi(String),
#[error("Rate limited")]
RateLimited,
#[error("Not found: {0}")]
NotFound(String),
#[error("User suspended: {0}")]
UserSuspended(String),
#[error("User not found: {0}")]
UserNotFound(String),
#[error("Tweet not found: {0}")]
TweetNotFound(String),
#[error("Protected user: {0}")]
ProtectedUser(String),
#[error("No sessions available")]
NoSessions,
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[error("HMAC verification failed")]
HmacVerification,
#[error("Internal error: {0}")]
Internal(String),
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
let status = match &self {
&Self::NotFound(_) | &Self::UserNotFound(_) | &Self::TweetNotFound(_) => {
StatusCode::NOT_FOUND
},
&Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
&Self::UserSuspended(_) | &Self::ProtectedUser(_) | &Self::HmacVerification => {
StatusCode::FORBIDDEN
},
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
// Render styled error page
let msg = self.to_string();
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="{STYLE_CSS}">
<link rel="stylesheet" type="text/css" href="{FONTELLO_CSS}">
<title>Error</title>
</head>
<body>
<nav><div class="inner-nav"><a class="site-name" href="/">teapot</a></div></nav>
<div class="container"><div class="panel-container"><div class="error-panel"><span>{}</span></div></div></div>
</body>
</html>"#,
html_escape(&msg)
);
(status, Html(html)).into_response()
}
}
// Twitter API error codes
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TwitterError {
NoUserMatches = 17,
ProtectedUser = 22,
UserNotFound = 50,
UserSuspended = 63,
RateLimited = 88,
InvalidToken = 89,
TweetNotFound = 144,
TweetUnavailable = 179,
NoStatusFound = 220,
BadToken = 239,
Locked = 326,
TweetUnavailable421 = 421,
TweetCensored = 422,
}
impl TwitterError {
pub const fn from_code(code: i64) -> Option<Self> {
match code {
17 => Some(Self::NoUserMatches),
22 => Some(Self::ProtectedUser),
50 => Some(Self::UserNotFound),
63 => Some(Self::UserSuspended),
88 => Some(Self::RateLimited),
89 => Some(Self::InvalidToken),
144 => Some(Self::TweetNotFound),
179 => Some(Self::TweetUnavailable),
220 => Some(Self::NoStatusFound),
239 => Some(Self::BadToken),
326 => Some(Self::Locked),
421 => Some(Self::TweetUnavailable421),
422 => Some(Self::TweetCensored),
_ => None,
}
}
}
+122
View File
@@ -0,0 +1,122 @@
mod api;
mod cache;
mod config;
mod error;
mod routes;
mod types;
mod utils;
mod views;
use std::{
env,
net::SocketAddr,
sync::Arc,
};
use axum::{
Router,
middleware,
};
use tokio::net::TcpListener;
use tower_http::services::{
ServeDir,
ServeFile,
};
use tracing_subscriber::{
fmt,
layer::SubscriberExt as _,
util::SubscriberInitExt as _,
};
use crate::{
api::{
ApiClient,
HttpClient,
SessionPool,
},
cache::Cache,
config::Config,
};
/// Application state shared across all routes.
#[derive(Clone)]
pub struct AppState {
pub config: Arc<Config>,
pub cache: Cache,
pub api: ApiClient,
pub http_client: HttpClient,
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
// Initialize logging
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "teapot=debug,tower_http=debug".into()),
)
.with(fmt::layer())
.init();
// Load configuration
let config_path = env::var("TEAPOT_CONF_FILE").unwrap_or_else(|_| "config/teapot.toml".to_owned());
let config = Config::load(&config_path)?;
let config = Arc::new(config);
tracing::info!(
"Starting {} on {}:{}",
config.server.title,
config.server.address,
config.server.port
);
// Initialize cache
let cache = Cache::new();
// Initialize session pool
let sessions_path =
env::var("TEAPOT_SESSIONS_FILE").unwrap_or_else(|_| "sessions.jsonl".to_owned());
let sessions = SessionPool::load(&sessions_path).await?;
// Initialize API client
let api = ApiClient::new(Arc::clone(&config), sessions);
// Create application state
let state = AppState {
config: Arc::clone(&config),
cache,
api,
http_client: HttpClient::new(),
};
// Build router - order matters: specific routes first, then static files
let static_dir = config.server.static_dir.clone();
let app = Router::new()
.merge(routes::router())
// Serve static files at various paths
.nest_service("/public", ServeDir::new(&static_dir))
.nest_service("/css", ServeDir::new(format!("{static_dir}/css")))
.nest_service("/js", ServeDir::new(format!("{static_dir}/js")))
.nest_service("/fonts", ServeDir::new(format!("{static_dir}/fonts")))
.nest_service("/md", ServeDir::new(format!("{static_dir}/md")))
// Root-level static files
.route_service("/logo.png", ServeFile::new(format!("{static_dir}/logo.png")))
.route_service("/favicon.ico", ServeFile::new(format!("{static_dir}/favicon.ico")))
.route_service("/favicon-16x16.png", ServeFile::new(format!("{static_dir}/favicon-16x16.png")))
.route_service("/favicon-32x32.png", ServeFile::new(format!("{static_dir}/favicon-32x32.png")))
.route_service("/apple-touch-icon.png", ServeFile::new(format!("{static_dir}/apple-touch-icon.png")))
.route_service("/site.webmanifest", ServeFile::new(format!("{static_dir}/site.webmanifest")))
.route_service("/robots.txt", ServeFile::new(format!("{static_dir}/robots.txt")))
.route_service("/opensearch.xml", ServeFile::new(format!("{static_dir}/opensearch.xml")))
.layer(middleware::from_fn(routes::prefs_middleware))
.with_state(state);
// Start server
let addr = SocketAddr::new(config.server.address.parse()?, config.server.port);
let listener = TcpListener::bind(addr).await?;
tracing::info!("Listening on {addr}");
axum::serve(listener, app).await?;
Ok(())
}
+30
View File
@@ -0,0 +1,30 @@
use axum::{
Json,
Router,
extract::State,
routing::get,
};
use crate::{
AppState,
api::{
DebugResponse,
HealthResponse,
},
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/.health", get(health_check))
.route("/.sessions", get(sessions_debug))
}
/// Health check endpoint returning session pool statistics.
async fn health_check(State(state): State<AppState>) -> Json<HealthResponse> {
Json(state.api.get_session_health().await)
}
/// Detailed sessions debug endpoint.
async fn sessions_debug(State(state): State<AppState>) -> Json<DebugResponse> {
Json(state.api.get_session_debug().await)
}
+162
View File
@@ -0,0 +1,162 @@
use axum::{
Router,
extract::{
Path,
Query,
State,
},
http::{
HeaderMap,
header,
},
response::{
Html,
IntoResponse as _,
Json,
Redirect,
Response,
},
routing::get,
};
use serde::Deserialize;
use crate::{
AppState,
error::{
Error,
Result,
},
views::embed as embed_view,
};
#[derive(Debug, Deserialize)]
pub struct OEmbedQuery {
pub text: Option<String>,
pub author: Option<String>,
pub status: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct LegacyEmbedQuery {
pub id: Option<String>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/{username}/status/{id}/embed", get(tweet_embed))
.route("/i/videos/tweet/{id}", get(video_embed))
// Legacy embed URL support (Twitter's old embed format)
.route("/embed/Tweet.html", get(legacy_embed_redirect))
// ActivityPub endpoint for Discord multi-image support
.route("/users/{username}/statuses/{id}", get(activity_pub_status))
// oEmbed endpoint for engagement metrics in Discord embeds
.route("/owoembed", get(oembed))
}
/// Redirect legacy `/embed/Tweet.html?id=XXX` to `/i/status/XXX/embed`.
async fn legacy_embed_redirect(Query(query): Query<LegacyEmbedQuery>) -> Response {
query.id.map_or_else(
|| Redirect::to("/").into_response(),
|id| Redirect::to(&format!("/i/status/{id}/embed")).into_response(),
)
}
/// Tweet embed endpoint - returns HTML with proper OG meta tags for
/// Discord/etc.
async fn tweet_embed(
State(state): State<AppState>,
Path((_username, id)): Path<(String, String)>,
) -> Result<Response> {
// Fetch tweet from API
let tweet = state.api.get_tweet(&id).await?;
let markup = embed_view::render_tweet_embed(&tweet, &state.config);
Ok(Html(markup.into_string()).into_response())
}
/// Video embed endpoint - returns HTML with video player.
async fn video_embed(State(state): State<AppState>, Path(id): Path<String>) -> Result<Response> {
// Fetch tweet from API
let tweet = state.api.get_tweet(&id).await?;
if tweet.video.is_none() && tweet.gif.is_none() {
return Err(Error::NotFound("No video found".into()));
}
let markup = embed_view::render_video_embed(&tweet, &state.config);
Ok(Html(markup.into_string()).into_response())
}
/// `ActivityPub` JSON endpoint for Discord multi-image support.
/// Discord fetches this to get all media attachments for carousel display.
async fn activity_pub_status(
State(state): State<AppState>,
Path((username, id)): Path<(String, String)>,
headers: HeaderMap,
) -> Result<Response> {
let accept = headers
.get(header::ACCEPT)
.and_then(|hv| hv.to_str().ok())
.unwrap_or("");
// Check if client wants ActivityPub JSON
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
// Fetch tweet from API
let tweet = state.api.get_tweet(&id).await?;
// Build ActivityPub JSON
let activity = embed_view::build_activity_pub(&tweet, &state.config);
return Ok((
[(
header::CONTENT_TYPE,
"application/activity+json; charset=utf-8",
)],
Json(activity),
)
.into_response());
}
// Otherwise redirect to normal status page
Ok(Redirect::to(&format!("/{username}/status/{id}")).into_response())
}
/// oEmbed JSON endpoint for engagement metrics in Discord embed footers.
async fn oembed(
State(state): State<AppState>,
Query(query): Query<OEmbedQuery>,
) -> Result<Response> {
#[derive(serde::Serialize)]
struct OEmbedData {
author_name: String,
author_url: String,
provider_name: String,
provider_url: String,
title: &'static str,
#[serde(rename = "type")]
kind: &'static str,
version: &'static str,
}
let text = query.text.unwrap_or_default();
let author = query.author.unwrap_or_default();
let status = query.status.unwrap_or_default();
let author_url = format!("https://x.com/{author}/status/{status}");
let oembed = OEmbedData {
author_name: text,
author_url,
provider_name: state.config.server.title.clone(),
provider_url: state.config.url_prefix().to_owned(),
title: "Post",
kind: "rich",
version: "1.0",
};
Ok((
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
Json(oembed),
)
.into_response())
}
+77
View File
@@ -0,0 +1,77 @@
use axum::{
http::header,
response::{
IntoResponse as _,
Response,
},
};
use crate::{
AppState,
cache::{
keys as cache_keys,
ttl,
},
error::Result,
types::{
Timeline,
Tweet,
Tweets,
User,
},
};
/// Fetch a user, using cache when available.
pub async fn get_cached_user(state: &AppState, username: &str) -> Result<User> {
let cache_key = cache_keys::user(username);
if let Some(cached) = state.cache.get::<User>(&cache_key) {
return Ok(cached);
}
let fetched = state.api.get_user(username).await?;
state.cache.set(&cache_key, &fetched, ttl::DEFAULT);
Ok(fetched)
}
/// Build an RSS response with `Content-Type` and `Min-Id` headers.
pub fn rss_response(rss: String, tweets: &[Tweet]) -> Response {
let min_id = tweets.iter().map(|tweet| tweet.id).min();
let mut response = (
[(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")],
rss,
)
.into_response();
if let Some(id) = min_id {
response.headers_mut().insert(
header::HeaderName::from_static("min-id"),
header::HeaderValue::from(id),
);
}
response
}
/// Check RSS cache and return early if hit.
pub fn check_rss_cache(state: &AppState, key: &str) -> Option<Response> {
let cached = state.cache.get::<String>(key)?;
Some(
(
[(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")],
cached,
)
.into_response(),
)
}
/// Cache an RSS result.
pub fn cache_rss(state: &AppState, key: &str, rss: &str) {
let owned = rss.to_owned();
state
.cache
.set(key, &owned, state.config.cache.rss_minutes * 60);
}
/// Extract tweet groups and cursor from a Timeline.
/// Preserves conversation grouping: each inner [`Vec<Tweet>`] is a conversation
/// thread (parent → reply chain) from a single `profile-conversation-*` entry.
pub fn extract_timeline(timeline: Timeline) -> (Vec<Tweets>, Option<String>) {
(timeline.content, timeline.bottom)
}
+104
View File
@@ -0,0 +1,104 @@
use axum::{
Router,
extract::{
Query,
State,
},
response::{
Html,
IntoResponse as _,
Redirect,
Response,
},
routing::get,
};
use maud::html;
use serde::Deserialize;
use super::unsupported::render_unsupported_feature;
use crate::{
AppState,
error::Result,
views::layout,
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/intent/user", get(intent_user))
.route("/intent/follow", get(intent_follow))
// Catch-all for other intents
.route("/intent/{*path}", get(intent_unsupported))
}
/// Catch-all for unsupported intent handlers.
async fn intent_unsupported(State(state): State<AppState>) -> Response {
render_unsupported_feature(&state, "Intent").into_response()
}
#[derive(Debug, Deserialize)]
pub struct IntentUserQuery {
pub user_id: Option<String>,
pub screen_name: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct IntentFollowQuery {
pub screen_name: Option<String>,
}
/// Handle `/intent/user` - redirect to user profile.
async fn intent_user(
State(state): State<AppState>,
Query(query): Query<IntentUserQuery>,
) -> Result<Response> {
// If screen_name is provided, redirect directly
if let Some(ref screen_name) = query.screen_name
&& !screen_name.is_empty()
{
return Ok(Redirect::to(&format!("/{screen_name}")).into_response());
}
// If user_id is provided, we need to look up the username
if let Some(ref user_id) = query.user_id
&& !user_id.is_empty()
{
// For now, redirect to /i/user/{id} which should handle the lookup
// In a full implementation, we'd look up the user by ID
return Ok(Redirect::to(&format!("/i/user/{user_id}")).into_response());
}
// No valid parameters provided
let content = html! {
div class="error-page" {
h1 { "Invalid Intent" }
p { "Please provide either a screen_name or user_id parameter." }
}
};
let markup = layout::PageLayout::new(&state.config, "Invalid Intent", content).render();
Ok(Html(markup.into_string()).into_response())
}
/// Handle `/intent/follow` - redirect to user profile (following requires login
/// on Twitter).
async fn intent_follow(
State(state): State<AppState>,
Query(query): Query<IntentFollowQuery>,
) -> Result<Response> {
if let Some(ref screen_name) = query.screen_name
&& !screen_name.is_empty()
{
return Ok(Redirect::to(&format!("/{screen_name}")).into_response());
}
// No screen_name provided
let content = html! {
div class="error-page" {
h1 { "Invalid Intent" }
p { "Please provide a screen_name parameter." }
}
};
let markup = layout::PageLayout::new(&state.config, "Invalid Intent", content).render();
Ok(Html(markup.into_string()).into_response())
}
+231
View File
@@ -0,0 +1,231 @@
use axum::{
Router,
extract::{
Path,
Query,
State,
},
http::StatusCode,
response::{
Html,
IntoResponse as _,
Response,
},
routing::get,
};
use axum_extra::extract::CookieJar;
use maud::html;
use serde::Deserialize;
use crate::{
AppState,
cache::{
keys as cache_keys,
ttl,
},
error::{
Error,
Result,
},
types::{
List,
PaginatedResult,
Prefs,
User,
},
views::{
layout,
timeline as timeline_view,
user_list,
},
};
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub cursor: Option<String>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/{username}/lists", get(user_lists_unsupported))
.route("/{username}/lists/", get(user_lists_unsupported))
.route("/{username}/lists/{slug}", get(list_by_slug))
.route("/i/lists/{id}", get(list_by_id))
.route("/i/lists/{id}/members", get(list_members))
}
/// User lists browsing is not supported.
async fn user_lists_unsupported(
State(state): State<AppState>,
jar: CookieJar,
Path(username): Path<String>,
) -> Response {
let prefs = Prefs::from_cookies(&jar, &state.config);
let content = html! {
div class="overlay-panel" {
h1 { "User Lists" }
p {
"Browsing user lists is not currently supported. "
"You can access a specific list directly if you have its URL or slug."
}
p {
a href=(format!("/{username}")) { "Go to @" (username) "'s profile" }
}
}
};
let title = format!("@{username}'s Lists");
let markup = layout::PageLayout::new(&state.config, &title, content)
.prefs(&prefs)
.render();
Html(markup.into_string()).into_response()
}
async fn list_by_slug(
State(state): State<AppState>,
jar: CookieJar,
Path((username, slug)): Path<(String, String)>,
Query(query): Query<ListQuery>,
) -> Result<Response> {
let prefs = Prefs::from_cookies(&jar, &state.config);
// Look up list by owner username and slug
let list = state.api.get_list_by_slug(&username, &slug).await?;
// Fetch list tweets with cursor support
let timeline = state
.api
.get_list_tweets(&list.id, query.cursor.as_deref())
.await?;
let groups = timeline.content;
let cursor = timeline.bottom.as_deref();
let base_url = format!("/{username}/lists/{slug}");
let content = html! {
div class="timeline-container" {
(timeline_view::render_list_header(&list, "tweets", &state.config))
(timeline_view::render_timeline(&groups, &state.config, cursor, Some(&base_url)))
}
};
let title = format!("{} - List by @{}", list.name, username);
let markup = layout::PageLayout::new(&state.config, &title, content)
.description(&list.description)
.prefs(&prefs)
.render();
Ok(Html(markup.into_string()).into_response())
}
async fn list_by_id(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<String>,
Query(query): Query<ListQuery>,
) -> Result<Response> {
let prefs = Prefs::from_cookies(&jar, &state.config);
let (list, timeline) = tokio::join!(
async {
let cache_key = cache_keys::list(&id);
if let Some(cached) = state.cache.get::<List>(&cache_key) {
tracing::debug!("Cache hit for list: {id}");
return Ok::<_, Error>(cached);
}
let fetched = state.api.get_list(&id).await?;
state.cache.set(&cache_key, &fetched, ttl::DEFAULT);
Ok(fetched)
},
state.api.get_list_tweets(&id, query.cursor.as_deref()),
);
let list = list?;
let timeline = timeline?;
let groups = timeline.content;
let cursor = timeline.bottom.as_deref();
let base_url = format!("/i/lists/{id}");
let content = html! {
div class="timeline-container" {
(timeline_view::render_list_header(&list, "tweets", &state.config))
(timeline_view::render_timeline(&groups, &state.config, cursor, Some(&base_url)))
}
};
let title = format!("{} - List", list.name);
let markup = layout::PageLayout::new(&state.config, &title, content)
.description(&list.description)
.prefs(&prefs)
.render();
Ok(Html(markup.into_string()).into_response())
}
async fn list_members(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<String>,
Query(query): Query<ListQuery>,
) -> Result<Response> {
let prefs = Prefs::from_cookies(&jar, &state.config);
let (list, members_result) = tokio::join!(
async {
let cache_key = cache_keys::list(&id);
if let Some(cached) = state.cache.get::<List>(&cache_key) {
return Ok::<_, Error>(cached);
}
let fetched = state.api.get_list(&id).await?;
state.cache.set(&cache_key, &fetched, ttl::DEFAULT);
Ok(fetched)
},
async {
let members_cache_key = cache_keys::list_members(&id);
if query.cursor.is_none() {
if let Some(cached) = state.cache.get::<PaginatedResult<User>>(&members_cache_key) {
tracing::debug!("Cache hit for list members: {id}");
return Ok(cached);
}
let result = state.api.get_list_members(&id, None).await;
if let Ok(ref members) = result {
state.cache.set(&members_cache_key, members, ttl::DEFAULT);
}
result
} else {
state
.api
.get_list_members(&id, query.cursor.as_deref())
.await
}
},
);
let list = list?;
match members_result {
Ok(members) => {
let cursor = members.bottom.as_deref();
let base_url = format!("/i/lists/{id}/members");
let content = html! {
div class="timeline-container" {
(timeline_view::render_list_header(&list, "members", &state.config))
(user_list::render_user_list(&members.content, &state.config, cursor, Some(&base_url), Some(&prefs)))
}
};
let title = format!("{} - Members", list.name);
let markup = layout::PageLayout::new(&state.config, &title, content)
.prefs(&prefs)
.render();
Ok(Html(markup.into_string()).into_response())
},
Err(err) => {
let markup =
layout::render_error(&state.config, "Error loading members", &err.to_string());
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Html(markup.into_string()),
)
.into_response())
},
}
}
// List header rendering lives in views::timeline::render_list_header
+256
View File
@@ -0,0 +1,256 @@
use std::sync::LazyLock;
use axum::{
Router,
body::Body,
extract::{
Path,
State,
},
http::{
StatusCode,
header,
},
response::{
IntoResponse as _,
Response,
},
routing::get,
};
use crate::{
AppState,
error::{
Error,
Result,
},
utils::{
formatters,
hmac,
},
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/pic/{url}", get(pic_proxy))
.route("/pic/enc/{url}", get(pic_proxy_encoded))
.route("/pic/orig/{url}", get(pic_orig_proxy))
.route("/pic/orig/enc/{url}", get(pic_orig_proxy_encoded))
.route("/video/{sig}/{url}", get(video_proxy))
.route("/video/enc/{sig}/{url}", get(video_proxy_encoded))
}
async fn pic_proxy(State(state): State<AppState>, Path(url): Path<String>) -> Result<Response> {
// Reject amplify_video URLs to prevent video thumbnails from being matched
if url.contains("/amplify_video/") {
return Err(Error::InvalidUrl("Not an image URL".into()));
}
proxy_image(&state, &url, false).await
}
async fn pic_proxy_encoded(
State(state): State<AppState>,
Path(url): Path<String>,
) -> Result<Response> {
let decoded = formatters::base64_decode_url(&url)
.ok_or_else(|| Error::InvalidUrl("Invalid base64 encoding".into()))?;
if decoded.contains("/amplify_video/") {
return Err(Error::InvalidUrl("Not an image URL".into()));
}
proxy_image(&state, &decoded, false).await
}
async fn pic_orig_proxy(
State(state): State<AppState>,
Path(url): Path<String>,
) -> Result<Response> {
if url.contains("/amplify_video/") {
return Err(Error::InvalidUrl("Not an image URL".into()));
}
proxy_image(&state, &url, true).await
}
async fn pic_orig_proxy_encoded(
State(state): State<AppState>,
Path(url): Path<String>,
) -> Result<Response> {
let decoded = formatters::base64_decode_url(&url)
.ok_or_else(|| Error::InvalidUrl("Invalid base64 encoding".into()))?;
if decoded.contains("/amplify_video/") {
return Err(Error::InvalidUrl("Not an image URL".into()));
}
proxy_image(&state, &decoded, true).await
}
async fn video_proxy(
State(state): State<AppState>,
Path((sig, url)): Path<(String, String)>,
) -> Result<Response> {
// URL-decode the URL first
let decoded_url = percent_encoding::percent_decode_str(&url)
.decode_utf8()
.map_err(|_| Error::InvalidUrl("Invalid URL encoding".into()))?
.to_string();
// Verify HMAC signature using utility function
if !hmac::verify(&decoded_url, &sig, &state.config.config.hmac_key) {
return Err(Error::HmacVerification);
}
proxy_video(&state, &decoded_url).await
}
async fn video_proxy_encoded(
State(state): State<AppState>,
Path((sig, url)): Path<(String, String)>,
) -> Result<Response> {
let decoded = formatters::base64_decode_url(&url)
.ok_or_else(|| Error::InvalidUrl("Invalid base64 encoding".into()))?;
// Verify HMAC signature using utility function
if !hmac::verify(&decoded, &sig, &state.config.config.hmac_key) {
return Err(Error::HmacVerification);
}
proxy_video(&state, &decoded).await
}
async fn proxy_image(state: &AppState, url: &str, original: bool) -> Result<Response> {
if url.is_empty() || url == "/" {
return Err(Error::InvalidUrl("Empty image URL".into()));
}
let full_url = if url.starts_with("http") {
url.to_owned()
} else {
format!("https://pbs.twimg.com{url}")
};
let full_url = if original {
format!("{full_url}?name=orig")
} else {
full_url
};
let response = state.http_client.get(&full_url).await?;
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|hv| hv.to_str().ok())
.unwrap_or("image/jpeg")
.to_owned();
let bytes = response.bytes().await?;
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, content_type),
(header::CACHE_CONTROL, "max-age=604800".to_owned()),
],
bytes,
)
.into_response())
}
// Extract M3U8 URL from VMAP XML (regex matches url="...m3u8")
static VMAP_M3U8_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"url="([^"]+\.m3u8)"#).unwrap());
async fn proxy_video(state: &AppState, url: &str) -> Result<Response> {
let client = &state.http_client;
// Handle VMAP: fetch XML, extract M3U8 URL, then fetch and proxy the M3U8
if url.contains(".vmap") {
let vmap_response = client.get(url).await?;
if !vmap_response.status().is_success() {
return Err(Error::InvalidUrl("VMAP fetch failed".into()));
}
let vmap_content = vmap_response.text().await?;
let m3u8_url = VMAP_M3U8_RE
.captures(&vmap_content)
.and_then(|caps| caps.get(1))
.map(|mat| mat.as_str());
if let Some(m3u8_url) = m3u8_url {
let m3u8_response = client.get(m3u8_url).await?;
let manifest = m3u8_response.text().await?;
let rewritten = formatters::proxify_m3u8(
&manifest,
&state.config.config.hmac_key,
state.config.config.base64_media,
);
return Ok((
StatusCode::OK,
[
(
header::CONTENT_TYPE,
"application/vnd.apple.mpegurl".to_owned(),
),
(header::CACHE_CONTROL, "max-age=604800".to_owned()),
],
rewritten,
)
.into_response());
}
return Err(Error::InvalidUrl("No M3U8 URL found in VMAP".into()));
}
let response = client.get(url).await?;
if !response.status().is_success() {
return Err(Error::InvalidUrl(format!(
"Video fetch failed: {}",
response.status()
)));
}
// Check if this is an M3U8 playlist that needs rewriting
let is_m3u8 = url.contains(".m3u8");
if is_m3u8 {
// Fetch and rewrite M3U8 manifest
let manifest = response.text().await?;
let rewritten = formatters::proxify_m3u8(
&manifest,
&state.config.config.hmac_key,
state.config.config.base64_media,
);
Ok((
StatusCode::OK,
[
(
header::CONTENT_TYPE,
"application/vnd.apple.mpegurl".to_owned(),
),
(header::CACHE_CONTROL, "max-age=604800".to_owned()),
],
rewritten,
)
.into_response())
} else {
// Regular video/segment file - stream as-is
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|hv| hv.to_str().ok())
.unwrap_or("video/mp4")
.to_owned();
let bytes = response.bytes().await?;
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, content_type),
(header::CACHE_CONTROL, "max-age=604800".to_owned()),
],
bytes,
)
.into_response())
}
}
+234
View File
@@ -0,0 +1,234 @@
mod debug;
mod embed;
pub mod helpers;
mod intent;
mod list;
mod media;
mod preferences;
mod redirect;
mod rss;
mod search;
mod status;
mod timeline;
mod unsupported;
use axum::{
Router,
extract::{
Request,
State,
},
middleware::Next,
response::{
Html,
IntoResponse,
Redirect,
Response,
},
routing::get,
};
use axum_extra::extract::{
CookieJar,
cookie::Cookie,
};
use maud::html;
use time::Duration;
use crate::{
AppState,
types::Prefs,
views::{
layout::PageLayout,
search as search_view,
},
};
pub fn router() -> Router<AppState> {
// ROUTE ORDERING RULES:
//
// Axum matches routes in merge order. The following constraints MUST hold:
//
// 1. embed::router() BEFORE status::router()
// - /users/{username}/statuses/{id} (ActivityPub) must not fall through to
// status handler
//
// 2. status::router() BEFORE timeline::router()
// - /{username}/status/{id} must match before the /{username} catch-all
//
// 3. All specific /i/* routes BEFORE unsupported::i_catchall_router()
// - /i/status/{id}, /i/user/{id} are handled by status::router()
// - /i/redirect is handled by redirect::router()
// - Everything else falls through to the /i/{*path} catch-all
//
// 4. timeline::router() MUST BE LAST
// - /{username} is a greedy catch-all that matches any single-segment path
// - timeline.rs validates against RESERVED_PATHS to reject non-usernames
//
// 5. media::router() BEFORE timeline::router()
// - /pic/* and /video/* must not match as usernames
//
// 6. list::router() BEFORE timeline::router()
// - /{username}/lists must match before /{username} catch-all sub-routes
Router::new()
// ── Fixed routes (no ordering constraints) ──
.route("/", get(home))
.route("/about", get(about))
.route("/explore", get(|| async { Redirect::to("/about") }))
.route("/help", get(|| async { Redirect::to("/about") }))
.merge(unsupported::router())
.merge(debug::router())
.merge(redirect::router())
.merge(intent::router())
.merge(search::router())
.merge(preferences::router())
// ── Order-sensitive routes (see rules above) ──
.merge(embed::router()) // Rule 1: before status
.merge(status::router()) // Rule 2: before timeline
.merge(media::router()) // Rule 5: before timeline
.merge(rss::router()) // Has /{username}/rss, before timeline
.merge(list::router()) // Rule 6: before timeline
.merge(unsupported::i_catchall_router()) // Rule 3: after specific /i/* routes
.merge(timeline::router()) // Rule 4: MUST BE LAST
}
/// Middleware: apply ?prefs= URL parameter overrides.
/// Parses `?prefs=key1=val1,key2=val2` and individual `?key=val`
/// params, sets them as cookies, then redirects to the clean URL.
pub async fn prefs_middleware(request: Request, next: Next) -> Response {
let uri = request.uri().clone();
let query_string = uri.query().unwrap_or("");
// Check if ?prefs= parameter exists
let prefs_param = form_urlencoded::parse(query_string.as_bytes())
.find(|&(ref key, _)| key == "prefs")
.map(|(_, val)| val.to_string());
if let Some(prefs_value) = prefs_param {
// Parse prefs: "key=val,key2=val2"
let mut jar = CookieJar::new();
let pref_names = Prefs::COOKIE_NAMES;
for pair in prefs_value.split(',') {
let (key, value) = match pair.split_once('=') {
Some((pkey, pval)) => (pkey, pval),
None if !pair.is_empty() => (pair, ""),
_ => continue,
};
if pref_names.contains(&key) {
let cookie = Cookie::build((key.to_owned(), value.to_owned()))
.path("/")
.max_age(Duration::days(365))
.http_only(true)
.build();
jar = jar.add(cookie);
}
}
// Rebuild URL without prefs param
let path = uri.path();
let clean_params = form_urlencoded::parse(query_string.as_bytes())
.filter(|&(ref key, _)| key != "prefs")
.map(|(key, val)| {
if val.is_empty() {
key.to_string()
} else {
format!("{key}={val}")
}
})
.collect::<Vec<_>>();
let redirect_url = if clean_params.is_empty() {
path.to_owned()
} else {
format!("{}?{}", path, clean_params.join("&"))
};
return (jar, Redirect::to(&redirect_url)).into_response();
}
// Also handle individual pref params (e.g., ?hlsPlayback=on) for cookieless
// overrides These don't redirect — they just override the cookie jar for
// this request
next.run(request).await
}
async fn home(State(state): State<AppState>, jar: CookieJar) -> impl IntoResponse {
let prefs = Prefs::from_cookies(&jar, &state.config);
let content = search_view::render_search_page();
let markup = PageLayout::new(&state.config, "Home", content)
.description("A privacy-focused Twitter/X frontend")
.prefs(&prefs)
.render();
Html(markup.into_string())
}
async fn about(State(state): State<AppState>, jar: CookieJar) -> impl IntoResponse {
let prefs = Prefs::from_cookies(&jar, &state.config);
let content = html! {
div class="overlay-panel" {
h1 { "About" }
p {
"teapot is a free and open source alternative Twitter front-end focused on privacy and performance. "
"The source is available on GitHub at "
a href="https://github.com/amaanq/teapot" { "https://github.com/amaanq/teapot" }
}
ul {
li { "No JavaScript or ads" }
li { "All requests go through the backend, client never talks to Twitter" }
li { "Prevents Twitter from tracking your IP or JavaScript fingerprint" }
li { "Uses Twitter's unofficial API (no developer account required)" }
li { "Lightweight" }
li { "RSS feeds" }
li { "Themes" }
li { "Mobile support (responsive design)" }
li { "AGPLv3 licensed, no proprietary instances permitted" }
}
p {
"teapot's GitHub wiki contains "
a href="https://github.com/amaanq/teapot/wiki/Instances" { "instances" }
" and "
a href="https://github.com/amaanq/teapot/wiki/Extensions" { "browser extensions" }
" maintained by the community."
}
h2 { "Why use teapot?" }
p {
"It's impossible to use Twitter without JavaScript enabled, and as of 2024 you need to sign up. "
"For privacy-minded folks, preventing JavaScript analytics and IP-based tracking is important, "
"but apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind a VPN and "
"using heavy-duty adblockers, you can get accurately tracked with your "
a href="https://restoreprivacy.com/browser-fingerprinting/" { "browser's fingerprint" }
", "
a href="https://noscriptfingerprint.com/" { "no JavaScript required" }
". This all became particularly important after Twitter "
a href="https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws" { "removed the ability" }
" for users to control whether their data gets sent to advertisers."
}
p {
"Using an instance of teapot (hosted on a VPS for example), you can browse Twitter without "
"JavaScript while retaining your privacy. In addition to respecting your privacy, teapot is on "
"average around 15 times lighter than Twitter, and in most cases serves pages faster "
"(eg. timelines load 2-4x faster)."
}
h2 { "Instance info" }
p {
"Version: teapot"
}
}
};
let markup = PageLayout::new(&state.config, "About", content)
.description("About teapot")
.prefs(&prefs)
.render();
Html(markup.into_string())
}
+368
View File
@@ -0,0 +1,368 @@
use std::{
fs,
path::Path,
};
use axum::{
Form,
Router,
extract::{
Query,
State,
},
http::{
HeaderMap,
header::REFERER,
},
response::{
Html,
IntoResponse as _,
Redirect,
Response,
},
routing::{
get,
post,
},
};
use axum_extra::extract::cookie::{
Cookie,
CookieJar,
};
use serde::Deserialize;
use time::Duration;
use crate::{
AppState,
config::Config,
error::Result,
types::Prefs,
views::{
layout,
preferences as pref_view,
},
};
/// Convert preferences to HTTP cookies for storage.
fn prefs_to_cookies(prefs: &Prefs) -> Vec<Cookie<'static>> {
let max_age = Duration::days(365);
let bool_cookie =
|name: &str, val: bool| make_cookie(name, if val { "on" } else { "" }, max_age);
let str_cookie = |name: &str, val: &str| make_cookie(name, val, max_age);
vec![
str_cookie("theme", &prefs.theme),
bool_cookie("infiniteScroll", prefs.infinite_scroll),
bool_cookie("stickyProfile", prefs.sticky_profile),
bool_cookie("bidiSupport", prefs.bidi_support),
bool_cookie("hlsPlayback", prefs.hls_playback),
bool_cookie("mp4Playback", prefs.mp4_playback),
bool_cookie("proxyVideos", prefs.proxy_videos),
bool_cookie("autoplayGifs", prefs.autoplay_gifs),
bool_cookie("muteVideos", prefs.mute_videos),
bool_cookie("hideTweetStats", prefs.hide_tweet_stats),
bool_cookie("hideBanner", prefs.hide_banner),
bool_cookie("hidePins", prefs.hide_pins),
bool_cookie("hideReplies", prefs.hide_replies),
bool_cookie("hideCommunityNotes", prefs.hide_community_notes),
bool_cookie("squareAvatars", prefs.square_avatars),
bool_cookie("stickyNav", prefs.sticky_nav),
str_cookie("replaceTwitter", &prefs.replace_twitter),
str_cookie("replaceYouTube", &prefs.replace_youtube),
str_cookie("replaceReddit", &prefs.replace_reddit),
]
}
/// Encode non-default prefs as a comma-separated string for URL sharing.
#[expect(
clippy::cognitive_complexity,
reason = "macro-driven repetition, not actual complexity"
)]
fn encode_prefs(prefs: &Prefs, config: &Config) -> String {
let defaults = Prefs::with_defaults(config);
let mut pairs = Vec::new();
macro_rules! enc_checkbox {
($name:expr, $field:ident) => {
if prefs.$field != defaults.$field {
if prefs.$field {
pairs.push(format!("{}=on", $name));
} else {
pairs.push(format!("{}=", $name));
}
}
};
}
macro_rules! enc_string {
($name:expr, $field:ident) => {
if prefs.$field != defaults.$field {
pairs.push(format!("{}={}", $name, prefs.$field));
}
};
}
enc_string!("theme", theme);
enc_checkbox!("infiniteScroll", infinite_scroll);
enc_checkbox!("stickyProfile", sticky_profile);
enc_checkbox!("stickyNav", sticky_nav);
enc_checkbox!("bidiSupport", bidi_support);
enc_checkbox!("hideTweetStats", hide_tweet_stats);
enc_checkbox!("hideBanner", hide_banner);
enc_checkbox!("hidePins", hide_pins);
enc_checkbox!("hideReplies", hide_replies);
enc_checkbox!("hideCommunityNotes", hide_community_notes);
enc_checkbox!("squareAvatars", square_avatars);
enc_checkbox!("mp4Playback", mp4_playback);
enc_checkbox!("hlsPlayback", hls_playback);
enc_checkbox!("proxyVideos", proxy_videos);
enc_checkbox!("muteVideos", mute_videos);
enc_checkbox!("autoplayGifs", autoplay_gifs);
enc_string!("replaceTwitter", replace_twitter);
enc_string!("replaceYouTube", replace_youtube);
enc_string!("replaceReddit", replace_reddit);
pairs.join(",")
}
fn make_cookie(name: &str, value: &str, max_age: Duration) -> Cookie<'static> {
Cookie::build((name.to_owned(), value.to_owned()))
.path("/")
.max_age(max_age)
.http_only(true)
.build()
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/settings", get(settings))
.route("/saveprefs", post(save_prefs))
.route("/resetprefs", post(reset_prefs))
.route("/enablehls", post(enable_hls))
.route("/enablemp4", post(enable_mp4))
}
#[derive(Debug, Deserialize)]
pub struct PrefsForm {
pub referer: Option<String>,
pub theme: Option<String>,
#[serde(rename = "infiniteScroll")]
pub infinite_scroll: Option<String>,
#[serde(rename = "stickyProfile")]
pub sticky_profile: Option<String>,
#[serde(rename = "bidiSupport")]
pub bidi_support: Option<String>,
#[serde(rename = "hlsPlayback")]
pub hls_playback: Option<String>,
#[serde(rename = "mp4Playback")]
pub mp4_playback: Option<String>,
#[serde(rename = "proxyVideos")]
pub proxy_videos: Option<String>,
#[serde(rename = "autoplayGifs")]
pub autoplay_gifs: Option<String>,
#[serde(rename = "muteVideos")]
pub mute_videos: Option<String>,
#[serde(rename = "hideTweetStats")]
pub hide_tweet_stats: Option<String>,
#[serde(rename = "hideBanner")]
pub hide_banner: Option<String>,
#[serde(rename = "hidePins")]
pub hide_pins: Option<String>,
#[serde(rename = "hideReplies")]
pub hide_replies: Option<String>,
#[serde(rename = "hideCommunityNotes")]
pub hide_community_notes: Option<String>,
#[serde(rename = "squareAvatars")]
pub square_avatars: Option<String>,
#[serde(rename = "stickyNav")]
pub sticky_nav: Option<String>,
#[serde(rename = "replaceTwitter")]
pub replace_twitter: Option<String>,
#[serde(rename = "replaceYouTube")]
pub replace_youtube: Option<String>,
#[serde(rename = "replaceReddit")]
pub replace_reddit: Option<String>,
}
impl PrefsForm {
fn to_prefs(&self, config: &Config) -> Prefs {
let defaults = Prefs::with_defaults(config);
let parse_bool =
|field: &Option<String>, default: bool| field.as_ref().map_or(default, |val| val == "on");
let parse_str = |field: &Option<String>, default: String| field.clone().unwrap_or(default);
Prefs {
theme: parse_str(&self.theme, defaults.theme),
infinite_scroll: parse_bool(&self.infinite_scroll, defaults.infinite_scroll),
sticky_profile: parse_bool(&self.sticky_profile, defaults.sticky_profile),
bidi_support: parse_bool(&self.bidi_support, defaults.bidi_support),
hls_playback: parse_bool(&self.hls_playback, defaults.hls_playback),
mp4_playback: parse_bool(&self.mp4_playback, defaults.mp4_playback),
proxy_videos: parse_bool(&self.proxy_videos, defaults.proxy_videos),
autoplay_gifs: parse_bool(&self.autoplay_gifs, defaults.autoplay_gifs),
mute_videos: parse_bool(&self.mute_videos, defaults.mute_videos),
hide_tweet_stats: parse_bool(&self.hide_tweet_stats, defaults.hide_tweet_stats),
hide_banner: parse_bool(&self.hide_banner, defaults.hide_banner),
hide_pins: parse_bool(&self.hide_pins, defaults.hide_pins),
hide_replies: parse_bool(&self.hide_replies, defaults.hide_replies),
hide_community_notes: parse_bool(
&self.hide_community_notes,
defaults.hide_community_notes,
),
square_avatars: parse_bool(&self.square_avatars, defaults.square_avatars),
sticky_nav: parse_bool(&self.sticky_nav, defaults.sticky_nav),
replace_twitter: parse_str(&self.replace_twitter, defaults.replace_twitter),
replace_youtube: parse_str(&self.replace_youtube, defaults.replace_youtube),
replace_reddit: parse_str(&self.replace_reddit, defaults.replace_reddit),
}
}
}
#[derive(Debug, Deserialize)]
pub struct SettingsQuery {
pub referer: Option<String>,
}
async fn settings(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<SettingsQuery>,
) -> Result<Response> {
let prefs = Prefs::from_cookies(&jar, &state.config);
let themes = find_themes(&state.config.server.static_dir);
let referer = query.referer.as_deref().unwrap_or("/");
let prefs_code = encode_prefs(&prefs, &state.config);
let prefs_url = format!("{}/?prefs={}", state.config.url_prefix(), prefs_code);
let content = pref_view::render_preferences_form(&prefs, &themes, referer, &prefs_url);
let markup = layout::PageLayout::new(&state.config, "Settings", content)
.description("Customize your teapot experience")
.prefs(&prefs)
.render();
Ok(Html(markup.into_string()).into_response())
}
async fn save_prefs(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<PrefsForm>,
) -> Result<Response> {
let prefs = form.to_prefs(&state.config);
let cookies = prefs_to_cookies(&prefs);
// Add all cookies to the jar
let mut updated_jar = jar;
for cookie in cookies {
updated_jar = updated_jar.add(cookie);
}
// Redirect back to referer or settings page
let redirect_to = form.referer.as_deref().unwrap_or("/settings");
Ok((updated_jar, Redirect::to(redirect_to)).into_response())
}
#[derive(Debug, Deserialize)]
pub struct ResetPrefsForm {
pub referer: Option<String>,
}
async fn reset_prefs(jar: CookieJar, Form(form): Form<ResetPrefsForm>) -> Result<Response> {
// Remove all preference cookies
let mut updated_jar = jar;
for name in Prefs::COOKIE_NAMES {
let removal = Cookie::build(name.to_string())
.path("/")
.max_age(Duration::seconds(0))
.build();
updated_jar = updated_jar.remove(removal);
}
// Redirect to referer or settings page
let redirect_to = form.referer.as_deref().unwrap_or("/settings");
Ok((updated_jar, Redirect::to(redirect_to)).into_response())
}
async fn enable_hls(jar: CookieJar, headers: HeaderMap) -> Result<Response> {
let cookie = Cookie::build(("hlsPlayback".to_owned(), "on".to_owned()))
.path("/")
.max_age(Duration::days(365))
.http_only(true)
.build();
let updated_jar = jar.add(cookie);
let referer = headers
.get(REFERER)
.and_then(|hv| hv.to_str().ok())
.unwrap_or("/settings");
Ok((updated_jar, Redirect::to(referer)).into_response())
}
async fn enable_mp4(jar: CookieJar, headers: HeaderMap) -> Result<Response> {
let cookie = Cookie::build(("mp4Playback".to_owned(), "on".to_owned()))
.path("/")
.max_age(Duration::days(365))
.http_only(true)
.build();
let updated_jar = jar.add(cookie);
let referer = headers
.get(REFERER)
.and_then(|hv| hv.to_str().ok())
.unwrap_or("/settings");
Ok((updated_jar, Redirect::to(referer)).into_response())
}
/// Discover available themes from CSS files in the static directory.
#[expect(
clippy::absolute_paths,
reason = "crate's Result type shadows std::result::Result"
)]
fn find_themes(static_dir: &str) -> Vec<String> {
let themes_dir = format!("{static_dir}/css/themes");
fs::read_dir(&themes_dir).map_or_else(
|_| {
vec![
"Auto".to_owned(),
"Black".to_owned(),
"Dracula".to_owned(),
"Mastodon".to_owned(),
"teapot".to_owned(),
"Pleroma".to_owned(),
"Twitter".to_owned(),
"Twitter Dark".to_owned(),
]
},
|entries| {
let mut themes = entries
.filter_map(std::result::Result::ok)
.filter_map(|entry| entry.file_name().into_string().ok())
.filter(|name| {
Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("css"))
})
.map(|name| {
name
.trim_end_matches(".css")
.replace('_', " ")
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
chars.next().map_or_else(String::new, |first| {
first.to_uppercase().chain(chars).collect()
})
})
.collect::<Vec<_>>()
.join(" ")
})
.collect::<Vec<_>>();
themes.sort();
themes
},
)
}
+170
View File
@@ -0,0 +1,170 @@
use axum::{
Router,
extract::{
Path,
Query,
State,
},
response::{
IntoResponse as _,
Redirect,
Response,
},
routing::get,
};
use axum_extra::extract::CookieJar;
use serde::Deserialize;
use crate::{
AppState,
config::Config,
error::{
Error,
Result,
},
types::Prefs,
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/i/redirect", get(redirect_handler))
.route("/t.co/{url}", get(tco_resolver))
.route("/cards/{card}/{id}", get(card_resolver))
}
#[derive(Debug, Deserialize)]
pub struct RedirectQuery {
pub url: Option<String>,
}
/// Handle `/i/redirect?url=...` - decode and transform URL before redirecting.
async fn redirect_handler(
State(state): State<AppState>,
Query(query): Query<RedirectQuery>,
jar: CookieJar,
) -> Result<Response> {
let url = query.url.as_deref().unwrap_or("");
if url.is_empty() {
return Err(Error::NotFound("No URL provided".into()));
}
// Decode URL if needed
let decoded_url = percent_decode(url);
// Get user preferences for URL replacements
let prefs = Prefs::from_cookies(&jar, &state.config);
// Apply URL transformations based on preferences
let transformed = apply_url_replacements(&decoded_url, &prefs, &state.config);
Ok(Redirect::to(&transformed).into_response())
}
/// Resolve `t.co` short URLs.
async fn tco_resolver(
State(state): State<AppState>,
Path(url): Path<String>,
jar: CookieJar,
) -> Result<Response> {
let full_url = format!("https://t.co/{url}");
// Resolve the redirect
let resolved = resolve_url(&state, &full_url).await?;
// Get user preferences for URL replacements
let prefs = Prefs::from_cookies(&jar, &state.config);
// Apply URL transformations
let transformed = apply_url_replacements(&resolved, &prefs, &state.config);
Ok(Redirect::to(&transformed).into_response())
}
/// Resolve Twitter card URLs.
async fn card_resolver(
State(state): State<AppState>,
Path((card, id)): Path<(String, String)>,
jar: CookieJar,
) -> Result<Response> {
let full_url = format!("https://cards.twitter.com/cards/{card}/{id}");
// Resolve the redirect
let resolved = resolve_url(&state, &full_url).await?;
// Get user preferences for URL replacements
let prefs = Prefs::from_cookies(&jar, &state.config);
// Apply URL transformations
let transformed = apply_url_replacements(&resolved, &prefs, &state.config);
Ok(Redirect::to(&transformed).into_response())
}
/// Resolve a URL by following redirects and returning the final Location
/// header.
///
/// hyper doesn't follow redirects by default (unlike reqwest), so a simple HEAD
/// request gives us the redirect Location directly.
async fn resolve_url(state: &AppState, url: &str) -> Result<String> {
let response = state
.http_client
.head(url)
.await
.map_err(|err| Error::Internal(format!("Failed to resolve URL: {err}")))?;
// Get Location header for redirect
if let Some(location) = response.headers().get("location") {
let resolved = location
.to_str()
.map_err(|_| Error::Internal("Invalid location header".into()))?
.to_owned();
Ok(resolved)
} else {
// No redirect, return original URL
Ok(url.to_owned())
}
}
/// Apply URL replacements based on user preferences.
fn apply_url_replacements(url: &str, prefs: &Prefs, config: &Config) -> String {
let mut result = url.to_owned();
// Replace Twitter URLs
let twitter_replacement = if prefs.replace_twitter.is_empty() {
&config.server.hostname
} else {
&prefs.replace_twitter
};
result = result
.replace("mobile.twitter.com", twitter_replacement)
.replace("twitter.com", twitter_replacement)
.replace("x.com", twitter_replacement);
// Replace YouTube URLs if configured
if !prefs.replace_youtube.is_empty() {
result = result
.replace("www.youtube.com", &prefs.replace_youtube)
.replace("youtube.com", &prefs.replace_youtube)
.replace("youtu.be", &prefs.replace_youtube);
}
// Replace Reddit URLs if configured
if !prefs.replace_reddit.is_empty() {
result = result
.replace("old.reddit.com", &prefs.replace_reddit)
.replace("www.reddit.com", &prefs.replace_reddit)
.replace("reddit.com", &prefs.replace_reddit);
}
result
}
/// Percent-decode a URL string.
fn percent_decode(url: &str) -> String {
percent_encoding::percent_decode_str(url)
.decode_utf8()
.unwrap_or_else(|_| url.into())
.into_owned()
}
+266
View File
@@ -0,0 +1,266 @@
use axum::{
Router,
extract::{
Path,
Query,
State,
},
response::Response,
routing::get,
};
use serde::Deserialize;
use super::helpers::{
cache_rss,
check_rss_cache,
get_cached_user,
rss_response,
};
use crate::{
AppState,
cache::keys as cache_keys,
error::{
Error,
Result,
},
views::rss as rss_view,
};
#[derive(Debug, Deserialize)]
pub struct RssQuery {
pub cursor: Option<String>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/{username}/rss", get(user_rss))
.route("/{username}/with_replies/rss", get(user_replies_rss))
.route("/{username}/media/rss", get(user_media_rss))
.route("/{username}/search/rss", get(user_search_rss))
.route("/{username}/lists/{slug}/rss", get(list_by_slug_rss))
.route("/search/rss", get(search_rss))
.route("/i/lists/{id}/rss", get(list_rss))
}
/// Check if RSS is enabled; return 404 error if not.
fn check_rss_enabled(state: &AppState) -> Result<()> {
if !state.config.config.enable_rss {
return Err(Error::NotFound("RSS feeds are disabled".to_owned()));
}
Ok(())
}
/// Which user timeline variant to fetch for RSS.
enum UserRssKind {
Tweets,
Replies,
Media,
}
/// Unified user RSS handler -- handles tweets, replies, and media variants.
async fn user_rss_handler(
state: &AppState,
username: &str,
cursor: Option<&str>,
kind: UserRssKind,
) -> Result<Response> {
let (feed_kind, cache_key_fn): (&str, fn(&str) -> String) = match kind {
UserRssKind::Tweets => ("tweets", cache_keys::rss_user),
UserRssKind::Replies => ("replies", cache_keys::rss_replies),
UserRssKind::Media => ("media", cache_keys::rss_media),
};
// Check RSS cache (first page only)
let rss_cache_key = if cursor.is_none() {
let key = cache_key_fn(username);
if let Some(cached) = check_rss_cache(state, &key) {
return Ok(cached);
}
Some(key)
} else {
None
};
let user = get_cached_user(state, username).await?;
let timeline = match kind {
UserRssKind::Tweets | UserRssKind::Replies => {
state.api.get_user_tweets(&user.id, cursor).await?
},
UserRssKind::Media => state.api.get_user_media(&user.id, cursor).await?,
};
let tweets = timeline.content.into_iter().flatten().collect::<Vec<_>>();
let rss = rss_view::render_user_rss(&user, &tweets, &state.config, feed_kind);
if let Some(ref key) = rss_cache_key {
cache_rss(state, key, &rss);
}
Ok(rss_response(rss, &tweets))
}
async fn user_rss(
State(state): State<AppState>,
Path(username): Path<String>,
Query(query): Query<RssQuery>,
) -> Result<Response> {
check_rss_enabled(&state)?;
user_rss_handler(
&state,
&username,
query.cursor.as_deref(),
UserRssKind::Tweets,
)
.await
}
async fn user_replies_rss(
State(state): State<AppState>,
Path(username): Path<String>,
Query(query): Query<RssQuery>,
) -> Result<Response> {
check_rss_enabled(&state)?;
user_rss_handler(
&state,
&username,
query.cursor.as_deref(),
UserRssKind::Replies,
)
.await
}
async fn user_media_rss(
State(state): State<AppState>,
Path(username): Path<String>,
Query(query): Query<RssQuery>,
) -> Result<Response> {
check_rss_enabled(&state)?;
user_rss_handler(
&state,
&username,
query.cursor.as_deref(),
UserRssKind::Media,
)
.await
}
async fn search_rss(
State(state): State<AppState>,
Query(query): Query<super::search::SearchQuery>,
) -> Result<Response> {
check_rss_enabled(&state)?;
let search_query = query.query.as_deref().unwrap_or_default();
if search_query.is_empty() {
return Err(Error::InvalidUrl("Search query is required".into()));
}
let rss_cache_key = cache_keys::rss_search(search_query);
if let Some(cached) = check_rss_cache(&state, &rss_cache_key) {
return Ok(cached);
}
let timeline = state.api.search(search_query, None).await?;
let tweets = timeline.content.into_iter().flatten().collect::<Vec<_>>();
let rss = rss_view::render_search_rss(search_query, &tweets, &state.config);
cache_rss(&state, &rss_cache_key, &rss);
Ok(rss_response(rss, &tweets))
}
/// User search RSS - search tweets from a specific user.
#[derive(Debug, Deserialize)]
pub struct UserSearchRssQuery {
#[serde(rename = "q")]
pub query: Option<String>,
}
async fn user_search_rss(
State(state): State<AppState>,
Path(username): Path<String>,
Query(query): Query<UserSearchRssQuery>,
) -> Result<Response> {
check_rss_enabled(&state)?;
let search_query = query.query.as_deref().unwrap_or_default();
if search_query.is_empty() {
return Err(Error::InvalidUrl("Search query is required".into()));
}
let api_query = format!("from:{username} {search_query}");
let rss_cache_key = cache_keys::rss_user_search(&username, search_query);
if let Some(cached) = check_rss_cache(&state, &rss_cache_key) {
return Ok(cached);
}
let timeline = state.api.search(&api_query, None).await?;
let tweets = timeline.content.into_iter().flatten().collect::<Vec<_>>();
let rss = rss_view::render_search_rss(
&format!("from:{username} {search_query}"),
&tweets,
&state.config,
);
cache_rss(&state, &rss_cache_key, &rss);
Ok(rss_response(rss, &tweets))
}
async fn list_rss(
State(state): State<AppState>,
Path(id): Path<String>,
Query(query): Query<RssQuery>,
) -> Result<Response> {
check_rss_enabled(&state)?;
let rss_cache_key = if query.cursor.is_none() {
let key = cache_keys::rss_list(&id);
if let Some(cached) = check_rss_cache(&state, &key) {
return Ok(cached);
}
Some(key)
} else {
None
};
let (list, timeline) = tokio::join!(
state.api.get_list(&id),
state.api.get_list_tweets(&id, query.cursor.as_deref()),
);
let list = list?;
let timeline = timeline?;
let tweets = timeline.content.into_iter().flatten().collect::<Vec<_>>();
let rss = rss_view::render_list_rss(&list, &tweets, &state.config);
if let Some(ref key) = rss_cache_key {
cache_rss(&state, key, &rss);
}
Ok(rss_response(rss, &tweets))
}
/// List RSS by owner username and slug.
async fn list_by_slug_rss(
State(state): State<AppState>,
Path((username, slug)): Path<(String, String)>,
Query(query): Query<RssQuery>,
) -> Result<Response> {
check_rss_enabled(&state)?;
let rss_cache_key = if query.cursor.is_none() {
let key = cache_keys::rss_list_slug(&username, &slug);
if let Some(cached) = check_rss_cache(&state, &key) {
return Ok(cached);
}
Some(key)
} else {
None
};
let list = state.api.get_list_by_slug(&username, &slug).await?;
let timeline = state
.api
.get_list_tweets(&list.id, query.cursor.as_deref())
.await?;
let tweets = timeline.content.into_iter().flatten().collect::<Vec<_>>();
let rss = rss_view::render_list_rss(&list, &tweets, &state.config);
if let Some(ref key) = rss_cache_key {
cache_rss(&state, key, &rss);
}
Ok(rss_response(rss, &tweets))
}
+395
View File
@@ -0,0 +1,395 @@
use axum::{
Router,
extract::{
Path,
Query as AxumQuery,
RawQuery,
State,
},
http::{
StatusCode,
header::CONTENT_TYPE,
},
response::{
Html,
IntoResponse as _,
Redirect,
Response,
},
routing::get,
};
use axum_extra::extract::CookieJar;
use serde::Deserialize;
use crate::{
AppState,
error::Result,
types::{
Prefs,
Query,
QueryKind,
},
views::{
layout,
search as search_view,
},
};
#[derive(Debug, Default, Deserialize)]
pub struct SearchQuery {
#[serde(rename = "q")]
pub query: Option<String>,
#[serde(rename = "f")]
pub filter: Option<String>,
pub cursor: Option<String>,
// Filter parameters
pub from: Option<String>,
pub since: Option<String>,
pub until: Option<String>,
pub min_faves: Option<String>,
// Filter toggles (f-media=on, e-replies=on, etc.)
#[serde(rename = "f-media")]
pub f_media: Option<String>,
#[serde(rename = "f-images")]
pub f_images: Option<String>,
#[serde(rename = "f-videos")]
pub f_videos: Option<String>,
#[serde(rename = "f-links")]
pub f_links: Option<String>,
#[serde(rename = "f-news")]
pub f_news: Option<String>,
#[serde(rename = "f-quote")]
pub f_quote: Option<String>,
#[serde(rename = "f-verified")]
pub f_verified: Option<String>,
#[serde(rename = "e-replies")]
pub e_replies: Option<String>,
#[serde(rename = "e-retweets")]
pub e_retweets: Option<String>,
}
impl SearchQuery {
/// Convert URL parameters to `SearchFilters` for form rendering.
pub fn to_filters(&self) -> search_view::SearchFilters {
search_view::SearchFilters {
media: self.f_media.as_deref() == Some("on"),
images: self.f_images.as_deref() == Some("on"),
videos: self.f_videos.as_deref() == Some("on"),
links: self.f_links.as_deref() == Some("on"),
news: self.f_news.as_deref() == Some("on"),
quote: self.f_quote.as_deref() == Some("on"),
verified: self.f_verified.as_deref() == Some("on"),
exclude_replies: self.e_replies.as_deref() == Some("on"),
exclude_retweets: self.e_retweets.as_deref() == Some("on"),
since: self.since.clone().unwrap_or_default(),
until: self.until.clone().unwrap_or_default(),
min_faves: self.min_faves.clone().unwrap_or_default(),
}
}
/// Convert URL parameters to `Query` struct.
fn to_query(&self) -> Query {
let raw_query = self.query.as_deref().unwrap_or("");
// Determine query kind from 'f' parameter
let kind = match self.filter.as_deref() {
Some("replies") => QueryKind::Replies,
Some("media") => QueryKind::Media,
Some("users") => QueryKind::Users,
_ => QueryKind::Posts,
};
// Parse the raw query text for inline filters
let mut query = Query::parse(raw_query, kind);
// Add from user if specified as parameter
if let Some(ref from) = self.from {
for user in from.split(',') {
let user = user.trim();
if !user.is_empty() && !query.from_user.contains(&user.to_owned()) {
query.from_user.push(user.to_owned());
}
}
}
// Add date filters from parameters
if let Some(ref since) = self.since
&& query.since.is_empty()
{
query.since.clone_from(since);
}
if let Some(ref until) = self.until
&& query.until.is_empty()
{
query.until.clone_from(until);
}
if let Some(ref min) = self.min_faves
&& query.min_likes.is_empty()
{
query.min_likes.clone_from(min);
}
// Add filter toggles (data-driven to avoid repetition)
let filter_toggles: &[(&Option<String>, &str)] = &[
(&self.f_media, "media"),
(&self.f_images, "images"),
(&self.f_videos, "videos"),
(&self.f_links, "links"),
(&self.f_news, "news"),
(&self.f_quote, "quote"),
(&self.f_verified, "verified"),
];
for &(param, name) in filter_toggles {
if param.as_deref() == Some("on") && !query.filters.iter().any(|filter| filter == name) {
query.filters.push((*name).to_owned());
}
}
// Add exclude toggles
let exclude_toggles: &[(&Option<String>, &str)] =
&[(&self.e_replies, "replies"), (&self.e_retweets, "retweets")];
for &(param, name) in exclude_toggles {
if param.as_deref() == Some("on") && !query.excludes.iter().any(|excl| excl == name) {
query.excludes.push((*name).to_owned());
}
}
query
}
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/search", get(search))
.route("/hashtag/{tag}", get(hashtag))
.route("/opensearch", get(opensearch))
}
async fn search(
State(state): State<AppState>,
jar: CookieJar,
AxumQuery(params): AxumQuery<SearchQuery>,
) -> Result<Response> {
// Extract prefs from cookies
let prefs = Prefs::from_cookies(&jar, &state.config);
let raw_q = params.query.clone().unwrap_or_default();
// Redirect comma-separated usernames to multi-user timeline
if raw_q.contains(',')
&& raw_q.split(',').all(|segment| {
let trimmed = segment.trim();
!trimmed.is_empty()
&& trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '@')
})
{
let cleaned = raw_q
.split(',')
.map(|segment| segment.trim().trim_start_matches('@'))
.collect::<Vec<_>>()
.join(",");
return Ok(Redirect::to(&format!("/{cleaned}")).into_response());
}
// Check if this is a user search
let is_user_search = params.filter.as_deref() == Some("users");
// Handle empty query - show search UI without calling API
if raw_q.is_empty() && params.from.is_none() {
let filters = params.to_filters();
if is_user_search {
let content = search_view::render_user_search_results(
&raw_q,
&[],
&state.config,
None,
None,
Some(&prefs),
);
let markup = layout::PageLayout::new(&state.config, "Search", content)
.prefs(&prefs)
.render();
return Ok(Html(markup.into_string()).into_response());
}
let empty_tweets = Vec::new();
let content = search_view::render_search_results_with_prefs(
&raw_q,
&empty_tweets,
&state.config,
None,
Some(&prefs),
Some(&filters),
None,
);
let markup = layout::PageLayout::new(&state.config, "Search", content)
.prefs(&prefs)
.render();
return Ok(Html(markup.into_string()).into_response());
}
if is_user_search {
// User search
let search_result = state
.api
.search_users(&raw_q, params.cursor.as_deref())
.await;
match search_result {
Ok(result) => {
let cursor = result.bottom.as_deref();
let newer_url = params.cursor.is_some().then(|| {
format!(
"/search?q={}&f=users",
percent_encoding::utf8_percent_encode(&raw_q, percent_encoding::NON_ALPHANUMERIC)
)
});
let content = search_view::render_user_search_results(
&raw_q,
&result.content,
&state.config,
cursor,
newer_url.as_deref(),
Some(&prefs),
);
let title = format!("Search ({raw_q}) | Users");
let canonical = format!(
"https://x.com/search?q={}&src=typed_query",
percent_encoding::utf8_percent_encode(&raw_q, percent_encoding::NON_ALPHANUMERIC)
);
let referer = format!(
"/search?q={}&f=users",
percent_encoding::utf8_percent_encode(&raw_q, percent_encoding::NON_ALPHANUMERIC)
);
let markup = layout::PageLayout::new(&state.config, &title, content)
.prefs(&prefs)
.canonical(&canonical)
.referer(&referer)
.render();
Ok(Html(markup.into_string()).into_response())
},
Err(err) => {
let markup = layout::render_error(&state.config, "Search Error", &err.to_string());
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Html(markup.into_string()),
)
.into_response())
},
}
} else {
// Tweet search
// Parse query with filters
let query = params.to_query();
// Build the actual search query for Twitter API
let api_query = query.build();
// Execute search
let search_result = state.api.search(&api_query, params.cursor.as_deref()).await;
match search_result {
Ok(timeline) => {
let tweets = timeline.content.into_iter().flatten().collect::<Vec<_>>();
let cursor = timeline.bottom.as_deref();
// Display the original user query, not the API query
let display_query = if query.has_filters() {
&api_query
} else {
&raw_q
};
let filters = params.to_filters();
let newer_url = params.cursor.is_some().then(|| {
format!(
"/search?q={}",
percent_encoding::utf8_percent_encode(&raw_q, percent_encoding::NON_ALPHANUMERIC)
)
});
let content = search_view::render_search_results_with_prefs(
display_query,
&tweets,
&state.config,
cursor,
Some(&prefs),
Some(&filters),
newer_url.as_deref(),
);
let title = format!("Search ({raw_q})");
let canonical = format!(
"https://x.com/search?f=live&q={}&src=typed_query",
percent_encoding::utf8_percent_encode(&raw_q, percent_encoding::NON_ALPHANUMERIC)
);
let rss_url = format!(
"/search/rss?f=tweets&q={}",
percent_encoding::utf8_percent_encode(&raw_q, percent_encoding::NON_ALPHANUMERIC)
);
let referer = format!(
"/search?q={}",
percent_encoding::utf8_percent_encode(&raw_q, percent_encoding::NON_ALPHANUMERIC)
);
let markup = layout::PageLayout::new(&state.config, &title, content)
.prefs(&prefs)
.rss(&rss_url)
.canonical(&canonical)
.referer(&referer)
.render();
Ok(Html(markup.into_string()).into_response())
},
Err(err) => {
let markup = layout::render_error(&state.config, "Search Error", &err.to_string());
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Html(markup.into_string()),
)
.into_response())
},
}
}
}
async fn hashtag(
State(state): State<AppState>,
jar: CookieJar,
Path(tag): Path<String>,
AxumQuery(query): AxumQuery<SearchQuery>,
) -> Result<Response> {
// Search for the hashtag
let hashtag_query = format!("#{tag}");
search(
State(state),
jar,
AxumQuery(SearchQuery {
query: Some(hashtag_query),
filter: query.filter,
cursor: query.cursor,
..Default::default()
}),
)
.await
}
async fn opensearch(State(state): State<AppState>) -> Response {
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>{}</ShortName>
<Description>Twitter search via {}</Description>
<InputEncoding>UTF-8</InputEncoding>
<Url type="text/html" template="{}/search?q={{searchTerms}}"/>
</OpenSearchDescription>"#,
state.config.server.title,
state.config.server.hostname,
state.config.url_prefix()
);
(
[(CONTENT_TYPE, "application/opensearchdescription+xml")],
xml,
)
.into_response()
}
+399
View File
@@ -0,0 +1,399 @@
use axum::{
Router,
extract::{
Path,
Query,
State,
},
http::StatusCode,
response::{
Html,
IntoResponse as _,
Redirect,
Response,
},
routing::get,
};
use axum_extra::extract::CookieJar;
use maud::html;
use serde::Deserialize;
use tweet_view::{
TweetRenderer,
render_reply_chains,
thread_context,
};
use crate::{
AppState,
config::Config,
cache::{
keys as cache_keys,
ttl,
},
error::{
Error,
Result,
},
types::{
Prefs,
Tweet,
},
views::{
embed,
layout,
timeline::render_to_top_with_focus,
tweet as tweet_view,
},
};
#[derive(Debug, Deserialize)]
pub struct StatusQuery {
pub cursor: Option<String>,
pub scroll: Option<String>,
}
pub fn router() -> Router<AppState> {
let mut router = Router::new();
for prefix in ["status", "statuses"] {
router = router
.route(&format!("/{{username}}/{prefix}/{{id}}"), get(status))
.route(
&format!("/{{username}}/{prefix}/{{id}}/photo"),
get(status_media_redirect),
)
.route(
&format!("/{{username}}/{prefix}/{{id}}/photo/{{idx}}"),
get(status_media_redirect),
)
.route(
&format!("/{{username}}/{prefix}/{{id}}/video"),
get(status_media_redirect),
)
.route(
&format!("/{{username}}/{prefix}/{{id}}/video/{{idx}}"),
get(status_media_redirect),
)
.route(
&format!("/{{username}}/{prefix}/{{id}}/history"),
get(edit_history),
);
}
router
.route("/{username}/thread/{id}", get(thread_redirect))
.route("/i/status/{id}", get(status_by_id))
.route("/i/web/status/{id}", get(status_by_id))
.route("/i/user/{id}", get(user_by_id))
}
async fn thread_redirect(Path((username, id)): Path<(String, String)>) -> Response {
Redirect::to(&format!("/{username}/status/{id}")).into_response()
}
/// Redirect legacy media URLs (photo/video/history) to the main status page.
async fn status_media_redirect(Path((username, id)): Path<(String, String)>) -> Response {
Redirect::to(&format!("/{username}/status/{id}")).into_response()
}
async fn status(
State(state): State<AppState>,
jar: CookieJar,
Path((username, id)): Path<(String, String)>,
Query(query): Query<StatusQuery>,
) -> Result<Response> {
// Extract prefs from cookies
let prefs = Prefs::from_cookies(&jar, &state.config);
// Fetch conversation (with cache for first page)
let conv_result = if query.cursor.is_none() {
let cache_key = cache_keys::conversation(&id);
if let Some(cached) = state.cache.get(&cache_key) {
tracing::debug!("Cache hit for conversation: {id}");
Ok(cached)
} else {
let result = state.api.get_conversation(&id, None).await;
if let Ok(ref conv) = result {
state.cache.set(&cache_key, conv, ttl::DEFAULT);
}
result
}
} else {
state
.api
.get_conversation(&id, query.cursor.as_deref())
.await
};
match conv_result {
Ok(conversation) => {
let tweet = &conversation.tweet;
// If the main tweet is unavailable (tombstone/withheld), show error
if !tweet.available && tweet.id == 0 {
let msg = if tweet.tombstone.is_empty() {
"Tweet is unavailable"
} else {
&tweet.tombstone
};
let markup = layout::render_error(&state.config, "Tweet not found", msg);
return Ok((StatusCode::NOT_FOUND, Html(markup.into_string())).into_response());
}
let is_scroll = query.scroll.as_ref().is_some_and(|val| val == "true");
// For AJAX scroll requests, return only the replies HTML fragment
if is_scroll {
if conversation.replies.content.is_empty() {
return Ok(StatusCode::NOT_FOUND.into_response());
}
let content = render_reply_chains(
&conversation.replies.content,
conversation.replies.bottom.as_deref().unwrap_or_default(),
&username,
&id,
&state.config,
&prefs,
);
return Ok(Html(content.into_string()).into_response());
}
let content = html! {
div class="conversation" {
div class="main-thread" {
// Before context (parent tweets) with thread line
@if !conversation.before.content.is_empty() {
div class="before-tweet thread-line" {
// "Earlier replies" link when thread doesn't start at root
@let first = &conversation.before.content[0];
@if tweet.thread_id != first.id && (first.reply_id > 0 || !first.available) {
div class="timeline-item more-replies earlier-replies" {
a class="more-replies-text" href=(format!("/{}/status/{}#m", first.user.username, first.id)) {
"earlier replies"
}
}
}
@let before_len = conversation.before.content.len();
@for (idx, tw) in conversation.before.content.iter().enumerate() {
@let ctx = thread_context(idx, before_len, false);
(TweetRenderer::new(tw, &state.config, false).prefs(&prefs).thread_ctx(ctx).render())
}
}
}
// Main tweet (highlighted, larger)
@let has_after = !conversation.after.content.is_empty();
@let after_class = if has_after { "thread thread-line" } else { "" };
div class="main-tweet" id="m" {
(TweetRenderer::new(tweet, &state.config, true).prefs(&prefs).extra_class(after_class).render())
}
// After context (thread continuation) with thread line
@if has_after {
div class="after-tweet thread-line" {
@let after_len = conversation.after.content.len();
@let has_more = conversation.after.has_more;
@for (idx, tw) in conversation.after.content.iter().enumerate() {
@let is_last = idx == after_len - 1 && !has_more;
@let ctx = thread_context(idx, after_len, is_last);
(TweetRenderer::new(tw, &state.config, false).prefs(&prefs).thread_ctx(ctx).render())
}
@if has_more {
@if let Some(last_after) = conversation.after.content.last() {
div class="timeline-item more-replies" {
@if last_after.available {
a class="more-replies-text" href=(format!("/{}/status/{}#m", last_after.user.username, last_after.id)) {
"more replies"
}
} @else {
a class="more-replies-text" { "more replies" }
}
}
}
}
}
}
}
// Replies section
@if !prefs.hide_replies {
// "Load newest" for replies when viewing paginated replies
@if query.cursor.is_some() {
div class="timeline-item show-more" {
a href=(format!("/{username}/status/{id}#r")) { "Load newest" }
}
}
}
@if !prefs.hide_replies && !conversation.replies.content.is_empty() {
div class="replies" id="r" {
(render_reply_chains(
&conversation.replies.content,
conversation.replies.bottom.as_deref().unwrap_or_default(),
&username,
&id,
&state.config,
&prefs,
))
}
}
// Scroll to top button
(render_to_top_with_focus("#m"))
}
};
let markup =
embed::render_status_page(tweet, &content, &prefs, &state.config, &username, &id);
Ok(Html(markup.into_string()).into_response())
},
Err(Error::TweetNotFound(msg)) => {
let markup = layout::render_error(&state.config, "Tweet not found", &msg);
Ok((StatusCode::NOT_FOUND, Html(markup.into_string())).into_response())
},
Err(err) => {
let markup = layout::render_error(&state.config, "Error", &err.to_string());
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Html(markup.into_string()),
)
.into_response())
},
}
}
async fn status_by_id(
State(state): State<AppState>,
Path(id): Path<String>,
Query(_query): Query<StatusQuery>,
) -> Result<Response> {
// Validate tweet ID is numeric
let Ok(tweet_id) = id.parse() else {
return Ok((
StatusCode::NOT_FOUND,
Html(layout::render_error(&state.config, "Not Found", "Invalid tweet ID").into_string()),
)
.into_response());
};
let cache_key = cache_keys::tweet(tweet_id);
let tweet_result = if let Some(cached) = state.cache.get::<Tweet>(&cache_key) {
tracing::debug!("Cache hit for tweet: {id}");
Ok(cached)
} else {
let result = state.api.get_tweet(&id).await;
if let Ok(ref tweet) = result {
state.cache.set(&cache_key, tweet, ttl::DEFAULT);
}
result
};
match tweet_result {
Ok(tweet) => {
let redirect_url = format!("/{}/status/{id}", tweet.user.username);
Ok(Redirect::to(&redirect_url).into_response())
},
Err(err) => {
let markup = layout::render_error(&state.config, "Tweet not found", &err.to_string());
Ok((StatusCode::NOT_FOUND, Html(markup.into_string())).into_response())
},
}
}
/// Lookup user by ID and redirect to their profile.
async fn user_by_id(State(state): State<AppState>, Path(id): Path<String>) -> Result<Response> {
// Check cache first for ID -> username mapping
let cache_key = cache_keys::user_id(&id);
if let Some(username) = state.cache.get::<String>(&cache_key) {
tracing::debug!("Cache hit for user ID: {id} -> {username}");
return Ok(Redirect::to(&format!("/{username}")).into_response());
}
// Fetch user from API by ID
match state.api.get_user_by_id(&id).await {
Ok(user) => {
// Cache the ID -> username mapping (long TTL since IDs don't change)
state
.cache
.set(&cache_key, &user.username, ttl::USER_ID_MAPPING);
Ok(Redirect::to(&format!("/{}", user.username)).into_response())
},
Err(Error::UserNotFound(msg)) => {
let content = html! {
div class="error-page" {
h1 { "User Not Found" }
p { (msg) }
p class="user-id" { "User ID: " (id) }
}
};
let markup = layout::PageLayout::new(&state.config, "User Not Found", content).render();
Ok((StatusCode::NOT_FOUND, Html(markup.into_string())).into_response())
},
Err(err) => {
let markup = layout::render_error(&state.config, "Error", &err.to_string());
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Html(markup.into_string()),
)
.into_response())
},
}
}
/// Edit history page handler.
async fn edit_history(
State(state): State<AppState>,
jar: CookieJar,
Path((username, id)): Path<(String, String)>,
) -> Result<Response> {
// Validate tweet ID
if id.len() > 19 || !id.chars().all(|ch| ch.is_ascii_digit()) {
let markup = layout::render_error(&state.config, "Not Found", "Invalid tweet ID");
return Ok((StatusCode::NOT_FOUND, Html(markup.into_string())).into_response());
}
let prefs = Prefs::from_cookies(&jar, &state.config);
if let Ok(edits) = state.api.get_edit_history(&id).await {
let title = format!(
"Edit History: {} (@{})",
edits.latest.text.chars().take(40).collect::<String>(),
if edits.latest.user.username.is_empty() {
&username
} else {
&edits.latest.user.username
}
);
let content = html! {
div class="edit-history" {
div class="latest-edit" {
div class="edit-history-header" { "Latest post" }
(TweetRenderer::new(&edits.latest, &state.config, false)
.prefs(&prefs)
.render())
}
@if !edits.history.is_empty() {
div class="previous-edits" {
div class="edit-history-header" { "Version history" }
@for tweet in &edits.history {
div class="tweet-edit" {
(TweetRenderer::new(tweet, &state.config, false)
.prefs(&prefs)
.render())
}
}
}
}
}
};
let markup = layout::PageLayout::new(&state.config, &title, content)
.prefs(&prefs)
.render();
Ok(Html(markup.into_string()).into_response())
} else {
let markup = layout::render_error(&state.config, "Not Found", "Tweet edit history not found");
Ok((StatusCode::NOT_FOUND, Html(markup.into_string())).into_response())
}
}
+568
View File
@@ -0,0 +1,568 @@
use std::cmp;
use axum::{
Router,
extract::{
Path,
Query,
State,
},
http::StatusCode,
response::{
Html,
IntoResponse as _,
Response,
},
routing::get,
};
use axum_extra::extract::CookieJar;
use maud::html;
use serde::Deserialize;
use super::helpers::{
extract_timeline,
get_cached_user,
};
use crate::{
AppState,
cache::{
keys as cache_keys,
ttl,
},
error::{
Error,
Result,
},
types::{
GalleryPhoto,
Prefs,
Profile,
Timeline,
TimelineKind,
},
utils::formatters,
views::{
layout,
profile,
search::render_search_panel_with_action,
timeline,
},
};
/// Reserved path names that cannot be usernames.
const RESERVED_PATHS: &[&str] = &[
"pic",
"gif",
"video",
"search",
"settings",
"login",
"intent",
"i",
"about",
"explore",
"help",
"oauth",
"oauth2",
"saveprefs",
"resetprefs",
"enablehls",
"enablemp4",
"public",
"css",
"js",
"fonts",
"embed",
];
/// Validate username contains only valid characters.
fn is_valid_username(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ',')
&& !name.contains('.')
}
/// Check if a username is actually a reserved path.
fn is_reserved_path(name: &str) -> bool {
let lower = name.to_lowercase();
RESERVED_PATHS.iter().any(|&reserved| reserved == lower)
}
/// Split comma-separated usernames and validate each.
fn parse_usernames(name: &str) -> Option<Vec<String>> {
let names = name
.split(',')
.map(|part| part.trim().to_owned())
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if names.is_empty()
|| names
.iter()
.any(|n| !is_valid_username(n) || is_reserved_path(n))
{
return None;
}
Some(names)
}
#[derive(Debug, Deserialize)]
pub struct TimelineQuery {
pub tab: Option<String>,
pub cursor: Option<String>,
/// For AJAX infinite scroll requests - returns only tweet HTML.
pub scroll: Option<String>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/{username}", get(user_timeline))
.route("/{username}/", get(user_timeline))
.route("/{username}/with_replies", get(user_replies))
.route("/{username}/media", get(user_media))
.route("/{username}/search", get(user_search))
}
async fn user_timeline(
State(state): State<AppState>,
jar: CookieJar,
Path(username): Path<String>,
Query(query): Query<TimelineQuery>,
) -> Result<Response> {
// Validate username
if is_reserved_path(&username) {
return Err(Error::InvalidUrl(format!(
"'{username}' is a reserved path",
)));
}
// Check for multi-user search (comma-separated)
if username.contains(',') {
return multi_user_timeline(state, jar, username, query).await;
}
if !is_valid_username(&username) {
return Err(Error::InvalidUrl("Invalid username format".into()));
}
// Get the tab type and validate
let tab = query.tab.as_deref().unwrap_or("tweets");
if !["tweets", "with_replies", "media", "search", ""].contains(&tab) {
return Err(Error::InvalidUrl("Invalid tab parameter".into()));
}
// Check if this is an AJAX scroll request
let is_scroll = query.scroll.as_ref().is_some_and(|val| val == "true");
// Extract prefs from cookies
let prefs = Prefs::from_cookies(&jar, &state.config);
// Check cache first (only for initial page load without cursor)
let cache_key = cache_keys::profile(&username);
let profile_result = if query.cursor.is_none() {
if let Some(cached) = state.cache.get::<Profile>(&cache_key) {
tracing::debug!("Cache hit for profile: {username}");
Ok(cached)
} else {
let result = state.api.get_profile(&username, None).await;
if let Ok(ref profile_data) = result {
state.cache.set(&cache_key, profile_data, ttl::DEFAULT);
}
result
}
} else {
state
.api
.get_profile(&username, query.cursor.as_deref())
.await
};
match profile_result {
Ok(profile_data) => {
// For AJAX scroll requests, return only the tweets HTML
if is_scroll {
let (tweets, cursor) = extract_timeline(profile_data.tweets);
let base_url = format!("/{}", profile_data.user.username);
let content = timeline::render_timeline_with_prefs(
&tweets,
&state.config,
cursor.as_deref(),
Some(&base_url),
&prefs,
None,
);
return Ok(Html(content.into_string()).into_response());
}
let title = format!(
"{} (@{})",
profile_data.user.fullname, profile_data.user.username
);
let desc = format!("The latest tweets from {}", profile_data.user.fullname);
let canonical = format!("https://x.com/{}", profile_data.user.username);
let rss_url = format!("/{}/rss", profile_data.user.username);
let referer = format!("/{}", profile_data.user.username);
let og_image = format!(
"{}{}",
state.config.url_prefix(),
formatters::get_pic_url(
&profile_data.user.user_pic.replace("_normal", "_400x400"),
state.config.config.base64_media,
)
);
let newer = query
.cursor
.is_some()
.then(|| format!("/{}", profile_data.user.username));
let content = profile::render_profile_with_prefs(
&profile_data,
&state.config,
tab,
Some(&prefs),
newer.as_deref(),
);
let markup = layout::PageLayout::new(&state.config, &title, content)
.description(&desc)
.prefs(&prefs)
.rss(&rss_url)
.canonical(&canonical)
.referer(&referer)
.og_image(&og_image)
.og_type("object")
.render();
Ok(Html(markup.into_string()).into_response())
},
Err(Error::UserNotFound(msg)) => {
let markup = layout::render_error(&state.config, "User not found", &msg);
Ok((StatusCode::NOT_FOUND, Html(markup.into_string())).into_response())
},
Err(err) => {
let markup = layout::render_error(&state.config, "Error", &err.to_string());
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Html(markup.into_string()),
)
.into_response())
},
}
}
/// Shared handler for user sub-tab timelines (replies, media).
async fn user_tab_handler(
state: &AppState,
prefs: &Prefs,
username: &str,
cursor: Option<&str>,
tab: TimelineKind,
include_photo_rail: bool,
) -> Result<Response> {
let user = get_cached_user(state, username).await?;
let (cache_kind, tab_str, title_prefix) = match tab {
TimelineKind::Replies => ("replies", "with_replies", "Tweets & replies from"),
TimelineKind::Media => ("media", "media", "Media from"),
_ => unreachable!(),
};
// Fetch timeline (with cache for first page)
let fetch_timeline = async {
let cache_key = cache_keys::timeline(username, cache_kind);
if cursor.is_none() {
if let Some(cached) = state.cache.get::<Timeline>(&cache_key) {
return Ok(cached);
}
let result = match tab {
TimelineKind::Replies => state.api.get_user_tweets_and_replies(&user.id, None).await,
TimelineKind::Media => state.api.get_user_media(&user.id, None).await,
_ => unreachable!(),
};
if let Ok(ref data) = result {
state.cache.set(&cache_key, data, ttl::DEFAULT);
}
result
} else {
match tab {
TimelineKind::Replies => {
state
.api
.get_user_tweets_and_replies(&user.id, cursor)
.await
},
TimelineKind::Media => state.api.get_user_media(&user.id, cursor).await,
_ => unreachable!(),
}
}
};
let (timeline_result, photo_rail) = if include_photo_rail {
let (tl, rail) = tokio::join!(fetch_timeline, fetch_photo_rail(state, &user.id));
(tl, rail)
} else {
(fetch_timeline.await, vec![])
};
let has_request_cursor = cursor.is_some();
match timeline_result {
Ok(data) => {
let (tweets, next_cursor) = extract_timeline(data);
let base_url = format!("/{username}/{tab_str}");
let newer = has_request_cursor.then_some(base_url.as_str());
let timeline_content = timeline::render_timeline_with_prefs(
&tweets,
&state.config,
next_cursor.as_deref(),
Some(&base_url),
prefs,
newer,
);
let content = profile::render_profile_page(
&user,
&photo_rail,
&state.config,
prefs,
tab,
&timeline_content,
);
let title = format!("{title_prefix} @{}", user.username);
let canonical = format!("https://x.com/{username}/{tab_str}");
let rss_url = format!("/{username}/{tab_str}/rss");
let referer = format!("/{username}/{tab_str}");
let markup = layout::PageLayout::new(&state.config, &title, content)
.prefs(prefs)
.rss(&rss_url)
.canonical(&canonical)
.referer(&referer)
.render();
Ok(Html(markup.into_string()).into_response())
},
Err(err) => {
let markup = layout::render_error(&state.config, "Error", &err.to_string());
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Html(markup.into_string()),
)
.into_response())
},
}
}
async fn user_replies(
State(state): State<AppState>,
jar: CookieJar,
Path(username): Path<String>,
Query(query): Query<TimelineQuery>,
) -> Result<Response> {
let prefs = Prefs::from_cookies(&jar, &state.config);
user_tab_handler(
&state,
&prefs,
&username,
query.cursor.as_deref(),
TimelineKind::Replies,
true,
)
.await
}
async fn user_media(
State(state): State<AppState>,
jar: CookieJar,
Path(username): Path<String>,
Query(query): Query<TimelineQuery>,
) -> Result<Response> {
let prefs = Prefs::from_cookies(&jar, &state.config);
user_tab_handler(
&state,
&prefs,
&username,
query.cursor.as_deref(),
TimelineKind::Media,
false,
)
.await
}
#[derive(Debug, serde::Deserialize)]
pub struct UserSearchQuery {
#[serde(rename = "q")]
pub query: Option<String>,
pub cursor: Option<String>,
}
async fn user_search(
State(state): State<AppState>,
jar: CookieJar,
Path(username): Path<String>,
Query(query): Query<UserSearchQuery>,
) -> Result<Response> {
let prefs = Prefs::from_cookies(&jar, &state.config);
let user = get_cached_user(&state, &username).await?;
let search_query = query.query.as_deref().unwrap_or("");
let search_action = format!("/{username}/search");
// When search query is empty, default to showing the user's recent tweets
let api_query = if search_query.is_empty() {
format!("from:{username} include:nativeretweets")
} else {
format!("from:{username} {search_query}")
};
let (photo_rail, search_result) = tokio::join!(
fetch_photo_rail(&state, &user.id),
state.api.search(&api_query, query.cursor.as_deref()),
);
match search_result {
Ok(results) => {
let (tweets, cursor) = extract_timeline(results);
let base_url = if search_query.is_empty() {
format!("/{username}/search")
} else {
format!(
"/{}/search?q={}",
username,
percent_encoding::utf8_percent_encode(
search_query,
percent_encoding::NON_ALPHANUMERIC,
)
)
};
let newer = query.cursor.is_some().then_some(base_url.as_str());
let timeline_content = html! {
div class="timeline-header" {
(render_search_panel_with_action(search_query, None, &search_action))
}
(timeline::render_timeline_with_prefs(&tweets, &state.config, cursor.as_deref(), Some(&base_url), &prefs, newer))
};
let content = profile::render_profile_page(
&user,
&photo_rail,
&state.config,
&prefs,
TimelineKind::Search,
&timeline_content,
);
let title = if search_query.is_empty() {
format!("Search @{username}")
} else {
format!("Search @{username}: {search_query}")
};
let canonical = format!("https://x.com/{username}");
let rss_url = format!("/{username}/search/rss?f=tweets");
let referer = format!("/{username}/search");
let markup = layout::PageLayout::new(&state.config, &title, content)
.prefs(&prefs)
.rss(&rss_url)
.canonical(&canonical)
.referer(&referer)
.render();
Ok(Html(markup.into_string()).into_response())
},
Err(err) => {
let markup = layout::render_error(&state.config, "Search Error", &err.to_string());
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Html(markup.into_string()),
)
.into_response())
},
}
}
/// Fetch photo rail for a user, returning empty vec on error.
async fn fetch_photo_rail(state: &AppState, user_id: &str) -> Vec<GalleryPhoto> {
state.api.get_photo_rail(user_id).await.unwrap_or_default()
}
/// Handle multi-user timeline (comma-separated usernames).
async fn multi_user_timeline(
state: AppState,
jar: CookieJar,
usernames_str: String,
query: TimelineQuery,
) -> Result<Response> {
const PAGE_SIZE: usize = 20;
let prefs = Prefs::from_cookies(&jar, &state.config);
let Some(usernames) = parse_usernames(&usernames_str) else {
return Err(Error::InvalidUrl(
"Invalid username format in multi-user search".into(),
));
};
if usernames.len() > 10 {
return Err(Error::InvalidUrl(
"Maximum 10 users allowed in multi-user search".into(),
));
}
let cursor_id = query
.cursor
.as_ref()
.and_then(|val| val.parse::<i64>().ok());
let mut all_tweets = Vec::new();
let handles: Vec<_> = usernames
.iter()
.map(|username| {
let state = state.clone();
let username = username.clone();
tokio::spawn(async move {
let user = get_cached_user(&state, &username).await.ok()?;
let timeline = state.api.get_user_tweets(&user.id, None).await.ok()?;
Some(timeline.content.into_iter().flatten().collect::<Vec<_>>())
})
})
.collect();
for handle in handles {
if let Ok(Some(tweets)) = handle.await {
all_tweets.extend(tweets);
}
}
all_tweets.sort_by_key(|tweet| cmp::Reverse(tweet.time));
if let Some(cursor) = cursor_id {
all_tweets.retain(|tweet| tweet.id < cursor);
}
let has_more = all_tweets.len() > PAGE_SIZE;
all_tweets.truncate(PAGE_SIZE);
let next_cursor = if has_more {
all_tweets.last().map(|tweet| tweet.id.to_string())
} else {
None
};
let title = format!("Tweets from {}", usernames.join(", "));
let base_url = format!("/{usernames_str}");
let newer = query.cursor.is_some().then_some(base_url.as_str());
let groups = all_tweets
.into_iter()
.map(|tweet| vec![tweet])
.collect::<Vec<Vec<_>>>();
let content = html! {
div class="multi-user-timeline" {
h2 { "Combined timeline: " (usernames.iter().map(|name| format!("@{name}")).collect::<Vec<_>>().join(", ")) }
(timeline::render_timeline_with_prefs(&groups, &state.config, next_cursor.as_deref(), Some(&base_url), &prefs, newer))
}
};
let markup = layout::PageLayout::new(&state.config, &title, content)
.prefs(&prefs)
.render();
Ok(Html(markup.into_string()).into_response())
}
+78
View File
@@ -0,0 +1,78 @@
use axum::{
Router,
extract::State,
response::{
Html,
IntoResponse as _,
Response,
},
routing::get,
};
use maud::html;
use crate::{
AppState,
views::layout,
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/login", get(unsupported_login))
.route("/login/{*path}", get(unsupported_login))
.route("/about/feature", get(unsupported_feature))
}
/// Catch-all router for `/i/*` routes - must be merged AFTER specific `/i/*`
/// routes.
pub fn i_catchall_router() -> Router<AppState> {
Router::new().route("/i/{*path}", get(i_unsupported))
}
/// Catch-all for unsupported `/i/*` routes.
async fn i_unsupported(State(state): State<AppState>) -> Response {
render_unsupported_feature(&state, "Feature").into_response()
}
async fn unsupported_login(State(state): State<AppState>) -> Response {
let content = html! {
div class="overlay-panel" {
h1 { "Login Not Supported" }
p { "teapot does not support logging in to Twitter." }
p { "teapot is a privacy-focused frontend that allows you to browse Twitter without an account." }
p {
a href="/" { "Go to Homepage" }
}
}
};
let markup = layout::PageLayout::new(&state.config, "Login Not Supported", content).render();
Html(markup.into_string()).into_response()
}
async fn unsupported_feature(State(state): State<AppState>) -> Response {
render_unsupported_feature(&state, "Feature").into_response()
}
/// Render the unsupported feature page.
pub fn render_unsupported_feature(state: &AppState, feature_name: &str) -> Response {
let content = html! {
div class="overlay-panel" {
h1 { "Unsupported feature" }
p {
"teapot doesn't support this feature yet, but it might in the future. "
"You can check for an issue and open one if needed here: "
a href="https://github.com/amaanq/teapot/issues" target="_blank" {
"https://github.com/amaanq/teapot/issues"
}
}
p {
"To find out more about the teapot project, see the "
a href="/about" { "About page" }
}
}
};
let title = format!("{feature_name} Not Available");
let markup = layout::PageLayout::new(&state.config, &title, content).render();
Html(markup.into_string()).into_response()
}

Some files were not shown because too many files have changed in this diff Show More