all: init twt
@@ -0,0 +1,3 @@
|
||||
[*.rs]
|
||||
indent_style = space
|
||||
indent_size = 3
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<TileColor>#2b5797</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/* '' */
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "teapot.css" (prefers-color-scheme: dark);
|
||||
@import "twitter.css" (prefers-color-scheme: light);
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "twitter_dark.css" (prefers-color-scheme: dark);
|
||||
@import "twitter.css" (prefers-color-scheme: light);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
/* uses default values */
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
After Width: | Height: | Size: 446 B |
|
After Width: | Height: | Size: 601 B |
|
After Width: | Height: | Size: 15 KiB |
@@ -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/
|
||||
|
||||
|
||||
@@ -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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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 |
@@ -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
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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 |
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
Crawl-delay: 1
|
||||
User-agent: Twitterbot
|
||||
Disallow:
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"id":1,"username":"example","kind":"cookie","auth_token":"YOUR_AUTH_TOKEN_HERE","ct0":"YOUR_CT0_TOKEN_HERE"}
|
||||
@@ -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", ×tamp),
|
||||
("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(¶m_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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||