From 01880d39a0d31016bcefd15bf208f223877d48c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=AA=E5=AD=90?= Date: Thu, 14 May 2026 09:59:58 +0800 Subject: [PATCH] Initial toot-worker implementation --- .gitignore | 3 + migrations/0001_initial.sql | 75 ++ migrations/0002_features.sql | 104 ++ migrations/0003_perf_indexes.sql | 3 + package-lock.json | 1575 ++++++++++++++++++++++++++++++ package.json | 18 + readme.md | 150 +++ src/activitypub.ts | 532 ++++++++++ src/crypto.ts | 126 +++ src/db.ts | 196 ++++ src/federation.ts | 260 +++++ src/http.ts | 90 ++ src/index.ts | 157 +++ src/mastodon.ts | 1321 +++++++++++++++++++++++++ src/types.ts | 167 ++++ src/util.ts | 123 +++ tsconfig.json | 13 + worker-configuration.d.ts | 9 + wrangler.jsonc | 30 + 19 files changed, 4952 insertions(+) create mode 100644 .gitignore create mode 100644 migrations/0001_initial.sql create mode 100644 migrations/0002_features.sql create mode 100644 migrations/0003_perf_indexes.sql create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 readme.md create mode 100644 src/activitypub.ts create mode 100644 src/crypto.ts create mode 100644 src/db.ts create mode 100644 src/federation.ts create mode 100644 src/http.ts create mode 100644 src/index.ts create mode 100644 src/mastodon.ts create mode 100644 src/types.ts create mode 100644 src/util.ts create mode 100644 tsconfig.json create mode 100644 worker-configuration.d.ts create mode 100644 wrangler.jsonc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..560d145 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.wrangler/ +.claude/ diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql new file mode 100644 index 0000000..04965e2 --- /dev/null +++ b/migrations/0001_initial.sql @@ -0,0 +1,75 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + private_key_jwk TEXT NOT NULL, + public_key_jwk TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS oauth_apps ( + id TEXT PRIMARY KEY, + client_id TEXT NOT NULL UNIQUE, + client_secret TEXT NOT NULL, + name TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + scopes TEXT NOT NULL, + website TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS oauth_codes ( + code TEXT PRIMARY KEY, + app_id TEXT NOT NULL, + user_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + scopes TEXT NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS statuses ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + content TEXT NOT NULL, + visibility TEXT NOT NULL DEFAULT 'public', + in_reply_to_id TEXT, + activity_id TEXT NOT NULL UNIQUE, + object_id TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + url TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS media ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + status_id TEXT, + r2_key TEXT NOT NULL, + mime_type TEXT NOT NULL, + description TEXT, + size INTEGER NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS follows ( + id TEXT PRIMARY KEY, + follower_actor TEXT NOT NULL, + local_user_id TEXT NOT NULL, + inbox TEXT NOT NULL, + accepted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + UNIQUE(follower_actor, local_user_id) +); + +CREATE TABLE IF NOT EXISTS remote_activities ( + id TEXT PRIMARY KEY, + actor TEXT NOT NULL, + type TEXT NOT NULL, + payload TEXT NOT NULL, + received_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_statuses_created_at ON statuses(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_media_user ON media(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_follows_local_user ON follows(local_user_id); diff --git a/migrations/0002_features.sql b/migrations/0002_features.sql new file mode 100644 index 0000000..083a1d9 --- /dev/null +++ b/migrations/0002_features.sql @@ -0,0 +1,104 @@ +-- Account fields, status enrichment, and federation state for v0.2 -> v0.3. + +ALTER TABLE statuses ADD COLUMN summary TEXT NOT NULL DEFAULT ''; +ALTER TABLE statuses ADD COLUMN sensitive INTEGER NOT NULL DEFAULT 0; +ALTER TABLE statuses ADD COLUMN language TEXT NOT NULL DEFAULT 'en'; + +CREATE TABLE IF NOT EXISTS deleted_statuses ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + object_id TEXT NOT NULL, + url TEXT NOT NULL, + deleted_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + type TEXT NOT NULL, + actor TEXT NOT NULL, + status_id TEXT, + read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_time ON notifications(user_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS favourites ( + id TEXT PRIMARY KEY, + status_id TEXT NOT NULL, + actor TEXT NOT NULL, + activity_id TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(status_id, actor) +); + +CREATE INDEX IF NOT EXISTS idx_favourites_status ON favourites(status_id); + +CREATE TABLE IF NOT EXISTS reblogs ( + id TEXT PRIMARY KEY, + status_id TEXT NOT NULL, + actor TEXT NOT NULL, + activity_id TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(status_id, actor) +); + +CREATE INDEX IF NOT EXISTS idx_reblogs_status ON reblogs(status_id); + +CREATE TABLE IF NOT EXISTS mentions ( + status_id TEXT NOT NULL, + actor TEXT NOT NULL, + acct TEXT NOT NULL, + url TEXT NOT NULL, + PRIMARY KEY(status_id, actor) +); + +CREATE INDEX IF NOT EXISTS idx_mentions_status ON mentions(status_id); + +CREATE TABLE IF NOT EXISTS hashtags ( + status_id TEXT NOT NULL, + tag TEXT NOT NULL, + PRIMARY KEY(status_id, tag) +); + +CREATE INDEX IF NOT EXISTS idx_hashtags_tag ON hashtags(tag); + +CREATE TABLE IF NOT EXISTS actor_cache ( + id TEXT PRIMARY KEY, + inbox TEXT NOT NULL, + shared_inbox TEXT, + preferred_username TEXT, + name TEXT, + summary TEXT, + icon_url TEXT, + public_key_id TEXT, + public_key_pem TEXT, + fetched_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_actor_cache_key_id ON actor_cache(public_key_id); + +CREATE TABLE IF NOT EXISTS outgoing_follows ( + id TEXT PRIMARY KEY, + local_user_id TEXT NOT NULL, + target_actor TEXT NOT NULL, + target_inbox TEXT NOT NULL, + activity_id TEXT NOT NULL, + accepted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + UNIQUE(local_user_id, target_actor) +); + +CREATE INDEX IF NOT EXISTS idx_outgoing_follows_user ON outgoing_follows(local_user_id); + +CREATE TABLE IF NOT EXISTS user_profile_fields ( + user_id TEXT NOT NULL, + position INTEGER NOT NULL, + name TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY(user_id, position) +); + +CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at); +CREATE INDEX IF NOT EXISTS idx_statuses_user_time ON statuses(user_id, created_at DESC); diff --git a/migrations/0003_perf_indexes.sql b/migrations/0003_perf_indexes.sql new file mode 100644 index 0000000..921c10c --- /dev/null +++ b/migrations/0003_perf_indexes.sql @@ -0,0 +1,3 @@ +CREATE INDEX IF NOT EXISTS idx_statuses_visibility_time ON statuses(visibility, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_statuses_reply_time ON statuses(in_reply_to_id, created_at ASC); +CREATE INDEX IF NOT EXISTS idx_media_status_time ON media(status_id, created_at ASC); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..68a5118 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1575 @@ +{ + "name": "toot-worker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "toot-worker", + "version": "0.1.0", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260507.0", + "typescript": "^5.9.3", + "wrangler": "^4.37.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmmirror.com/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260508.1", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260508.1.tgz", + "integrity": "sha512-IT3r6VgiSwIesL4AJbxjgxvIxwWZqM7BKkhYAzOKHl4GF2M0TxeOahUIXd+CYXVZgHX8ceEg+MXbEehPelJyNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260508.1", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260508.1.tgz", + "integrity": "sha512-JTVsisOJPcNKw0qovPjqyBWYahfdhUh7/9NICiG5wxaEQ45PYKdoqNq0hOAAIqvqoxsKZBvTgcPTJREPqk7avA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260508.1", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260508.1.tgz", + "integrity": "sha512-zO38pCc27YlsZiPYcaZnosy0/t7abXrRU3VEO1oKfUvnaCpHgphDG+VsrmHL+kntda6hrtNwg2jLeMAqqIjnjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260508.1", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260508.1.tgz", + "integrity": "sha512-XhJa780Ia6MNIrtxn/ruZHS79b9pu5EKPfRNReaUqxy8erPT2fs93axMfFoS9kIkcaRRj/1TOUKcTeAMoywY7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260508.1", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260508.1.tgz", + "integrity": "sha512-QdDOK3B/Ul1s3QmIwDrFyx9230to6LsNmWcVR8w+TYjNZuRPzqQBgusp78LO7MlqCoEl9dvIcN00jkJnLtBSfw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260511.1", + "resolved": "https://registry.npmmirror.com/@cloudflare/workers-types/-/workers-types-4.20260511.1.tgz", + "integrity": "sha512-FA+si7cOq9i/gtCHhIc0XJL0l1F/ApF+m00752Aj7WZFJrj3ZulT2T8/+rT3BabMT0QEnqFEGIqCgrmqhgEfMg==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmmirror.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmmirror.com/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmmirror.com/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmmirror.com/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260508.0", + "resolved": "https://registry.npmmirror.com/miniflare/-/miniflare-4.20260508.0.tgz", + "integrity": "sha512-h3aG+PA8jEH76V4ZtBAbs3g7kjMfHJUF8hPvxeeajLTKwir+G+dqfBODg5yF9MT29LqrZKCRQRqzfHPWX4kCIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.8", + "workerd": "1.20260508.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmmirror.com/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260508.1", + "resolved": "https://registry.npmmirror.com/workerd/-/workerd-1.20260508.1.tgz", + "integrity": "sha512-VlnjyH3AjVddpSK7J54nsCVgf8i2733pl8GjKttfNi7vN/hEjjAk20d2b1nDToOLKvRQpTewRnVkqaaeGHCaAw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260508.1", + "@cloudflare/workerd-darwin-arm64": "1.20260508.1", + "@cloudflare/workerd-linux-64": "1.20260508.1", + "@cloudflare/workerd-linux-arm64": "1.20260508.1", + "@cloudflare/workerd-windows-64": "1.20260508.1" + } + }, + "node_modules/wrangler": { + "version": "4.90.1", + "resolved": "https://registry.npmmirror.com/wrangler/-/wrangler-4.90.1.tgz", + "integrity": "sha512-u2KrieKSMfRM0toTst/CfDtcRraeoVjmcExcMWgILM/ytq3qcDhuOAULoZSyPHzma43lfLJy1BC544drFyqe1A==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260508.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260508.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260508.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmmirror.com/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmmirror.com/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..571792c --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "toot-worker", + "version": "0.3.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "db:local": "wrangler d1 migrations apply toot_db --local", + "db:remote": "wrangler d1 migrations apply toot_db --remote" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260507.0", + "typescript": "^5.9.3", + "wrangler": "^4.37.0" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..1967e8b --- /dev/null +++ b/readme.md @@ -0,0 +1,150 @@ +# Toot Worker + +一个运行在 Cloudflare Workers 上的单用户联邦宇宙发布软件,使用: + +- Workers: HTTP API、ActivityPub 路由、Mastodon API 兼容层 +- D1: 用户、OAuth 应用、嘟文、媒体索引、关注、收藏、转发、通知、提及、话题标签、远端 actor 缓存 +- R2: 媒体文件 +- KV: OAuth access token 会话 + +当前目标:让常见 Mastodon App 完成实例发现、创建 OAuth App、登录、上传媒体、发布嘟文、读取公开/家庭时间线、收藏/转发/回复、查看通知、搜索、关注/取关远端账号,同时支持单用户实例与 Fediverse 双向联邦。 + +## 本地运行 + +```bash +npm install +npm run db:local +npm run dev +``` + +默认管理员账号来自 `wrangler.jsonc`: + +- 用户名:`admin` +- 密码:`change-me-before-deploy` + +部署前必须改掉 `ADMIN_PASSWORD`,更推荐用 Cloudflare secret 管理密码: + +```bash +wrangler secret put ADMIN_PASSWORD +``` + +`PUBLIC_BASE_URL` 必须在首次正式部署前改成你的稳定实例域名,例如: + +```json +"PUBLIC_BASE_URL": "https://social.example.com" +``` + +这个值会进入: + +- Actor ID +- Object ID +- WebFinger 返回值 +- 媒体和头像 URL + +一旦开始对外联邦后,不要再改域名,否则远端会把你视为另一个实例身份。 + +## Cloudflare 资源 + +```bash +wrangler d1 create toot_db +wrangler r2 bucket create toot-media +wrangler kv namespace create KV +``` + +把返回的 ID 填回 `wrangler.jsonc`,然后应用迁移并部署: + +```bash +npm run db:remote +npm run deploy +``` + +## 已实现接口 + +### Mastodon API 兼容 + +实例 / 应用 / 鉴权: + +- `GET /api/v1/instance`、`GET /api/v2/instance` +- `POST /api/v1/apps`、`GET /api/v1/apps/verify_credentials` +- `GET /oauth/authorize`、`POST /oauth/authorize` +- `POST /oauth/token`、`POST /oauth/revoke` + +账号: + +- `GET /api/v1/accounts/verify_credentials` +- `PATCH /api/v1/accounts/update_credentials`(自动联邦 Update Person) +- `GET /api/v1/accounts/relationships` +- `GET /api/v1/accounts/:id` +- `GET /api/v1/accounts/:id/statuses` +- `POST /api/v1/accounts/:id/follow`、`POST /api/v1/accounts/:id/unfollow`(向远端发送 Follow / Undo) +- `GET /api/v1/follow_requests`、`POST /api/v1/follow_requests/:id/authorize`、`/reject`(stub,默认全自动接受) + +嘟文: + +- `POST /api/v1/statuses`(支持 `media_ids`、`spoiler_text`、`sensitive`、`in_reply_to_id`、`visibility`、`language`,自动解析 `@user`/`@user@host` 提及和 `#hashtag`,投递 Create 给 followers 与 mention) +- `GET /api/v1/statuses/:id`、`DELETE /api/v1/statuses/:id`(联邦 Delete 出站) +- `GET /api/v1/statuses/:id/context` +- `POST /api/v1/statuses/:id/favourite`、`/unfavourite`(联邦 Like / Undo Like) +- `POST /api/v1/statuses/:id/reblog`、`/unreblog`(联邦 Announce / Undo Announce) +- `POST /api/v1/statuses/:id/bookmark`、`/unbookmark`、`/pin`、`/unpin`(本地 stub) + +时间线 / 通知 / 媒体 / 搜索 / 其它: + +- `GET /api/v1/timelines/public`、`GET /api/v1/timelines/home`(分页支持 `max_id` / `since_id` / `min_id`,响应携带 `Link` 头) +- `GET /api/v1/notifications`、`POST /api/v1/notifications/clear`、`POST /api/v1/notifications/:id/dismiss` +- `POST /api/v1/media`、`POST /api/v2/media`、`PUT /api/v1/media/:id` +- `GET /api/v2/search`、`GET /api/v1/search`(本地账号 / 嘟文 / 话题标签 + 跨站 WebFinger 解析 `acct:` 查询) +- `GET /api/v1/custom_emojis`、`GET /api/v1/filters`、`GET /api/v1/trends/tags`、`GET /api/v1/markers`(stub) +- `POST /api/v1/push/subscription`(返回 422,目前不支持推送) + +### ActivityPub / 发现 + +- `GET /.well-known/webfinger?resource=acct:user@example.com` +- `GET /.well-known/nodeinfo`、`GET /.well-known/host-meta` +- `GET /nodeinfo/2.0` +- `GET /users/:username`(含 `endpoints.sharedInbox`、`icon`、`image`、`publicKey`) +- `GET /users/:username/followers`、`/following` +- `GET /users/:username/outbox`(支持 `?page=true` 翻页) +- `POST /users/:username/inbox`、`POST /inbox`(共享 inbox) +- `GET /objects/:id`(嘟文已删除时返回 `Tombstone`,HTTP 410) + +入站 inbox 处理类型:`Follow` / `Undo(Follow)` / `Accept(Follow)` / `Reject(Follow)` / `Like` / `Undo(Like)` / `Announce` / `Undo(Announce)` / `Delete(Note)` / `Delete(Person)` / `Update(Person)` / `Create(Note)`(只用于触发提及通知)。`Create` 用于触发提及通知和回复通知。 + +## 安全 + +- 密码哈希使用 **PBKDF2-SHA256 / 100000 iterations**,带每用户随机 16 字节 salt,旧版 `salt.hash` 哈希也能继续验证(便于无缝升级)。 +- HTTP Signature(rsa-sha256)出站:签名 `(request-target)`、`host`、`date`、`digest`,自动计算 SHA-256 digest。 +- HTTP Signature 入站验证: + - 强制要求 `Date` 头,允许 ±12 小时时钟偏移 + - `POST` 必须带 `Digest`,且与 body 哈希一致 + - 通过签名头里的 `keyId` 取得远端 actor 公钥(并在 `actor_cache` 表中缓存),拒绝 `body.actor` 与 actor cache 不一致的请求 + - 验证 `host` 头匹配请求 URL +- 远端 actor 公钥与 inbox 写入 `actor_cache`,默认 24 小时 TTL,远端 `Delete(Person)` 会清理缓存与所有相关关注 / 收藏 / 转发 / 通知。 +- 重放保护:已处理过的 `activity.id` 会写入 `remote_activities`,重复投递被静默丢弃(返回 202)。 + +## 数据库结构 + +迁移文件: + +- `migrations/0001_initial.sql` — 基础表(users / oauth_apps / oauth_codes / statuses / media / follows / remote_activities) +- `migrations/0002_features.sql` — 通知 / 收藏 / 转发 / 提及 / 话题标签 / actor 缓存 / 出站关注 / 删除墓碑 / 嘟文扩展字段(summary / sensitive / language) + +## 重要限制 + +这是一个单用户可运行实现,不是完整 Mastodon 服务端: + +- 只支持单管理员账号自动初始化,不开放注册 +- 通知 / 收藏 / 转发都已实现,但内容审核、屏蔽、过滤、列表、自定义表情、推送通知仍是 stub +- 没有处理远端嘟文缓存(收到 `Create(Note)` 不会存,仅触发提及通知)。意味着客户端的 home timeline 仍只能看到本地嘟文 +- 私信(direct visibility)的检索没有按收信人过滤,目前所有客户端都能在公开时间线之外读到自己的嘟文,不应当作私信使用 +- 没有实现轮询(poll)、列表(list)、推送(push)、未来嘟文(scheduled)等 + +## 参考 + +- Cloudflare Workers 绑定:https://developers.cloudflare.com/workers/runtime-apis/bindings/ +- Cloudflare D1:https://developers.cloudflare.com/d1/ +- Cloudflare R2:https://developers.cloudflare.com/r2/ +- Cloudflare KV:https://developers.cloudflare.com/kv/ +- Mastodon API:https://docs.joinmastodon.org/methods/ +- ActivityPub:https://www.w3.org/TR/activitypub/ +- HTTP Signatures:https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12 diff --git a/src/activitypub.ts b/src/activitypub.ts new file mode 100644 index 0000000..e031a55 --- /dev/null +++ b/src/activitypub.ts @@ -0,0 +1,532 @@ +import { + deleteActorFromCache, + exportUserPublicKeyPem, + findFavourite, + findReblog, + getStatus, + getStatusByObjectId, + getUserByUsername, + recordNotification, + upsertActorCache +} from "./db"; +import { + deliverToInboxes, + isDuplicateActivity, + notifyForLocalStatus, + objectAsJson, + objectIdString, + parseActivity, + recordRemoteActivity, + resolveDeliveryInboxes, + resolveRemoteActor, + sendSignedActivity, + verifyInboundSignature +} from "./federation"; +import { activityJson, json } from "./http"; +import { + ACTIVITY_CONTEXT, + AVATAR_SVG, + HEADER_SVG, + PUBLIC_COLLECTION, + SECURITY_CONTEXT +} from "./types"; +import type { ActorCache, Json, RemoteActor, Status, User } from "./types"; +import { + actorUrl, + activityUrl, + baseUrl, + clampLimit, + hostFromBaseUrl, + id, + objectUrl +} from "./util"; + +export async function webFinger(request: Request, env: Env): Promise { + const resource = new URL(request.url).searchParams.get("resource") ?? ""; + const match = resource.match(/^acct:([^@]+)@(.+)$/); + if (!match) return json({ error: "not_found" }, 404); + if (match[2].toLowerCase() !== hostFromBaseUrl(env).toLowerCase()) return json({ error: "not_found" }, 404); + const user = await getUserByUsername(env, match[1]); + if (!user) return json({ error: "not_found" }, 404); + + return json({ + subject: `acct:${user.username}@${hostFromBaseUrl(env)}`, + aliases: [actorUrl(env, user)], + links: [ + { rel: "self", type: "application/activity+json", href: actorUrl(env, user) }, + { rel: "http://webfinger.net/rel/profile-page", type: "text/html", href: actorUrl(env, user) } + ] + }, 200, { "content-type": "application/jrd+json; charset=utf-8" }); +} + +export async function hostMeta(env: Env): Promise { + const xml = ` + + +`; + return new Response(xml, { headers: { "content-type": "application/xrd+xml; charset=utf-8" } }); +} + +export function nodeInfoLinks(env: Env): Response { + return json({ links: [{ rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", href: `${baseUrl(env)}/nodeinfo/2.0` }] }); +} + +export async function nodeInfo(env: Env): Promise { + const users = await env.DB.prepare("SELECT COUNT(*) AS count FROM users").first<{ count: number }>(); + const posts = await env.DB.prepare("SELECT COUNT(*) AS count FROM statuses").first<{ count: number }>(); + return json({ + version: "2.0", + software: { name: "toot-worker", version: "0.3.0" }, + protocols: ["activitypub"], + services: { inbound: [], outbound: [] }, + usage: { users: { total: users?.count ?? 0 }, localPosts: posts?.count ?? 0 }, + openRegistrations: false, + metadata: { nodeName: env.INSTANCE_NAME, singleUserMode: true } + }); +} + +export async function actor(env: Env, username: string): Promise { + const user = await getUserByUsername(env, username); + if (!user) return json({ error: "not_found" }, 404); + return activityJson(await actorDocument(env, user)); +} + +export async function actorDocument(env: Env, user: User): Promise { + const url = actorUrl(env, user); + return { + "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT, { manuallyApprovesFollowers: "as:manuallyApprovesFollowers" }], + id: url, + type: "Person", + preferredUsername: user.username, + name: user.display_name, + summary: user.note, + url, + inbox: `${url}/inbox`, + outbox: `${url}/outbox`, + followers: `${url}/followers`, + following: `${url}/following`, + endpoints: { sharedInbox: `${baseUrl(env)}/inbox` }, + icon: { type: "Image", mediaType: "image/svg+xml", url: `${baseUrl(env)}/avatar.png` }, + image: { type: "Image", mediaType: "image/svg+xml", url: `${baseUrl(env)}/header.png` }, + manuallyApprovesFollowers: false, + discoverable: true, + publicKey: { + id: `${url}#main-key`, + owner: url, + publicKeyPem: await exportUserPublicKeyPem(user) + } + }; +} + +export async function outbox(request: Request, env: Env, username: string): Promise { + const user = await getUserByUsername(env, username); + if (!user) return json({ error: "not_found" }, 404); + const url = new URL(request.url); + const wantsPage = url.searchParams.has("page"); + const totalRow = await env.DB.prepare("SELECT COUNT(*) AS count FROM statuses WHERE user_id = ?").bind(user.id).first<{ count: number }>(); + const totalItems = totalRow?.count ?? 0; + const base = `${actorUrl(env, user)}/outbox`; + + if (!wantsPage) { + return activityJson({ + "@context": ACTIVITY_CONTEXT, + id: base, + type: "OrderedCollection", + totalItems, + first: `${base}?page=true` + }); + } + + const limit = clampLimit(url.searchParams.get("limit"), 20, 40); + const rows = await env.DB.prepare("SELECT * FROM statuses WHERE user_id = ? ORDER BY created_at DESC LIMIT ?").bind(user.id, limit).all(); + const items = rows.results.map((status) => createActivity(env, user, status)); + return activityJson({ + "@context": ACTIVITY_CONTEXT, + id: `${base}?page=true`, + partOf: base, + type: "OrderedCollectionPage", + orderedItems: items, + totalItems + }); +} + +export async function followersCollection(env: Env, username: string): Promise { + const user = await getUserByUsername(env, username); + if (!user) return json({ error: "not_found" }, 404); + const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM follows WHERE local_user_id = ? AND accepted = 1").bind(user.id).first<{ count: number }>(); + return activityJson({ + "@context": ACTIVITY_CONTEXT, + id: `${actorUrl(env, user)}/followers`, + type: "Collection", + totalItems: row?.count ?? 0 + }); +} + +export async function followingCollection(env: Env, username: string): Promise { + const user = await getUserByUsername(env, username); + if (!user) return json({ error: "not_found" }, 404); + const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM outgoing_follows WHERE local_user_id = ? AND accepted = 1").bind(user.id).first<{ count: number }>(); + return activityJson({ + "@context": ACTIVITY_CONTEXT, + id: `${actorUrl(env, user)}/following`, + type: "Collection", + totalItems: row?.count ?? 0 + }); +} + +export async function activityObject(env: Env, objectId: string): Promise { + const status = await getStatus(env, objectId); + if (status) { + const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(status.user_id).first(); + if (!user) return json({ error: "not_found" }, 404); + return activityJson(noteObject(env, user, status)); + } + const tomb = await env.DB.prepare("SELECT * FROM deleted_statuses WHERE id = ?").bind(objectId).first<{ id: string; deleted_at: string }>(); + if (tomb) { + return activityJson({ + "@context": ACTIVITY_CONTEXT, + id: `${baseUrl(env)}/objects/${tomb.id}`, + type: "Tombstone", + deleted: tomb.deleted_at + }, 410); + } + return json({ error: "not_found" }, 404); +} + +export async function inboxHandler(request: Request, env: Env, username: string | null): Promise { + const localUser = username ? await getUserByUsername(env, username) : null; + if (username && !localUser) return json({ error: "not_found" }, 404); + + const bodyText = await request.text(); + const activity = parseActivity(bodyText); + if (!activity || !activity.type) return json({ error: "invalid_activity" }, 400); + if (activity.activityId && await isDuplicateActivity(env, activity.activityId)) { + return new Response(null, { status: 202 }); + } + + const verified = await verifyInboundSignature(request, bodyText, env); + if (!verified) return json({ error: "invalid_signature" }, 401); + if (activity.actor && verified.actor.id !== activity.actor) { + return json({ error: "actor_signature_mismatch" }, 401); + } + await recordRemoteActivity(env, activity, true); + + const ctx: InboxContext = { + env, + activity, + actorId: verified.actor.id, + actorCache: verified.actor, + localUser + }; + + switch (activity.type) { + case "Follow": + return handleFollow(ctx); + case "Undo": + return handleUndo(ctx); + case "Accept": + return handleAccept(ctx); + case "Reject": + return handleReject(ctx); + case "Like": + return handleLike(ctx); + case "Announce": + return handleAnnounce(ctx); + case "Delete": + return handleDelete(ctx); + case "Update": + return handleUpdate(ctx); + case "Create": + return handleCreate(ctx); + default: + return new Response(null, { status: 202 }); + } +} + +type InboxContext = { + env: Env; + activity: NonNullable>; + actorId: string; + actorCache: ActorCache; + localUser: User | null; +}; + +async function handleFollow(ctx: InboxContext): Promise { + const { env, activity, actorId, actorCache } = ctx; + const object = objectIdString(activity.body.object); + const localUser = await localUserFromTarget(env, object) ?? ctx.localUser; + if (!localUser) return json({ error: "unknown_local_user" }, 404); + + const inbox = actorCache.shared_inbox ?? actorCache.inbox; + await env.DB.prepare( + "INSERT OR REPLACE INTO follows (id, follower_actor, local_user_id, inbox, accepted, created_at) VALUES (?, ?, ?, ?, 1, ?)" + ).bind(id(), actorId, localUser.id, inbox, new Date().toISOString()).run(); + + await recordNotification(env, localUser.id, "follow", actorId, null); + + await sendSignedActivity(env, localUser, actorCache.inbox, { + "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], + id: activityUrl(env, id()), + type: "Accept", + actor: actorUrl(env, localUser), + object: activity.body + }); + return new Response(null, { status: 202 }); +} + +async function handleUndo(ctx: InboxContext): Promise { + const { env, activity, actorId } = ctx; + const inner = objectAsJson(activity.body.object); + if (!inner) return new Response(null, { status: 202 }); + const innerType = String(inner.type ?? ""); + const localUser = ctx.localUser ?? await localUserFromTarget(env, objectIdString(inner.object)); + if (!localUser) return new Response(null, { status: 202 }); + + if (innerType === "Follow") { + await env.DB.prepare("DELETE FROM follows WHERE follower_actor = ? AND local_user_id = ?").bind(actorId, localUser.id).run(); + } else if (innerType === "Like") { + const target = objectIdString(inner.object); + if (target) { + const status = await getStatusByObjectId(env, target); + if (status) await env.DB.prepare("DELETE FROM favourites WHERE status_id = ? AND actor = ?").bind(status.id, actorId).run(); + } + } else if (innerType === "Announce") { + const target = objectIdString(inner.object); + if (target) { + const status = await getStatusByObjectId(env, target); + if (status) await env.DB.prepare("DELETE FROM reblogs WHERE status_id = ? AND actor = ?").bind(status.id, actorId).run(); + } + } + return new Response(null, { status: 202 }); +} + +async function handleAccept(ctx: InboxContext): Promise { + const { env, activity, actorId } = ctx; + const inner = objectAsJson(activity.body.object); + if (!inner || String(inner.type ?? "") !== "Follow") return new Response(null, { status: 202 }); + const innerActor = typeof inner.actor === "string" ? inner.actor : String((inner.actor as Json | undefined)?.id ?? ""); + const localUser = await localUserFromTarget(env, innerActor); + if (!localUser) return new Response(null, { status: 202 }); + await env.DB.prepare( + "UPDATE outgoing_follows SET accepted = 1 WHERE local_user_id = ? AND target_actor = ?" + ).bind(localUser.id, actorId).run(); + return new Response(null, { status: 202 }); +} + +async function handleReject(ctx: InboxContext): Promise { + const { env, actorId } = ctx; + await env.DB.prepare("DELETE FROM outgoing_follows WHERE target_actor = ?").bind(actorId).run(); + return new Response(null, { status: 202 }); +} + +async function handleLike(ctx: InboxContext): Promise { + const { env, activity, actorId } = ctx; + const target = objectIdString(activity.body.object); + if (!target) return new Response(null, { status: 202 }); + const status = await getStatusByObjectId(env, target); + if (!status) return new Response(null, { status: 202 }); + const existing = await findFavourite(env, status.id, actorId); + if (existing) return new Response(null, { status: 202 }); + await env.DB.prepare( + "INSERT INTO favourites (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)" + ).bind(id(), status.id, actorId, activity.activityId || id(), new Date().toISOString()).run(); + await notifyForLocalStatus(env, status, "favourite", actorId); + return new Response(null, { status: 202 }); +} + +async function handleAnnounce(ctx: InboxContext): Promise { + const { env, activity, actorId } = ctx; + const target = objectIdString(activity.body.object); + if (!target) return new Response(null, { status: 202 }); + const status = await getStatusByObjectId(env, target); + if (!status) return new Response(null, { status: 202 }); + const existing = await findReblog(env, status.id, actorId); + if (existing) return new Response(null, { status: 202 }); + await env.DB.prepare( + "INSERT INTO reblogs (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)" + ).bind(id(), status.id, actorId, activity.activityId || id(), new Date().toISOString()).run(); + await notifyForLocalStatus(env, status, "reblog", actorId); + return new Response(null, { status: 202 }); +} + +async function handleDelete(ctx: InboxContext): Promise { + const { env, activity, actorId } = ctx; + const target = objectIdString(activity.body.object); + if (!target) return new Response(null, { status: 202 }); + + if (target === actorId) { + await env.DB.prepare("DELETE FROM follows WHERE follower_actor = ?").bind(actorId).run(); + await env.DB.prepare("DELETE FROM outgoing_follows WHERE target_actor = ?").bind(actorId).run(); + await env.DB.prepare("DELETE FROM favourites WHERE actor = ?").bind(actorId).run(); + await env.DB.prepare("DELETE FROM reblogs WHERE actor = ?").bind(actorId).run(); + await env.DB.prepare("DELETE FROM notifications WHERE actor = ?").bind(actorId).run(); + await deleteActorFromCache(env, actorId); + return new Response(null, { status: 202 }); + } + + await env.DB.prepare("DELETE FROM favourites WHERE actor = ? AND activity_id LIKE ?").bind(actorId, `%${target}%`).run(); + return new Response(null, { status: 202 }); +} + +async function handleUpdate(ctx: InboxContext): Promise { + const { env, activity, actorId } = ctx; + const obj = objectAsJson(activity.body.object); + if (!obj) return new Response(null, { status: 202 }); + if (String(obj.type ?? "") === "Person" && obj.id === actorId) { + await upsertActorCache(env, obj as unknown as RemoteActor); + } + return new Response(null, { status: 202 }); +} + +async function handleCreate(ctx: InboxContext): Promise { + const { env, activity, actorId } = ctx; + const obj = objectAsJson(activity.body.object); + if (!obj) return new Response(null, { status: 202 }); + + const recipients = collectRecipients(activity.body, obj); + const localActorIds = new Set(); + for (const target of recipients) { + if (!target.startsWith(baseUrl(env))) continue; + const m = target.match(/\/users\/([^/?#]+)$/); + if (m) localActorIds.add(m[1]); + } + + for (const username of localActorIds) { + const localUser = await getUserByUsername(env, username); + if (!localUser) continue; + const inReplyTo = typeof obj.inReplyTo === "string" ? obj.inReplyTo : null; + let statusId: string | null = null; + if (inReplyTo) { + const parent = await getStatusByObjectId(env, inReplyTo); + if (parent) statusId = parent.id; + } + await recordNotification(env, localUser.id, "mention", actorId, statusId); + } + return new Response(null, { status: 202 }); +} + +function collectRecipients(activity: Json, object: Json): string[] { + const fields: unknown[] = [activity.to, activity.cc, activity.bto, activity.bcc, object.to, object.cc]; + const out = new Set(); + for (const field of fields) { + if (Array.isArray(field)) { + for (const value of field) { + if (typeof value === "string") out.add(value); + } + } else if (typeof field === "string") { + out.add(field); + } + } + return [...out]; +} + +async function localUserFromTarget(env: Env, actorId: string | null): Promise { + if (!actorId) return null; + if (!actorId.startsWith(baseUrl(env))) return null; + const match = actorId.match(/\/users\/([^/?#]+)$/); + if (!match) return null; + return getUserByUsername(env, match[1]); +} + +export function createActivity(env: Env, user: User, status: Status, extra: { to?: string[]; cc?: string[] } = {}): Json { + const to = extra.to ?? [PUBLIC_COLLECTION]; + const cc = extra.cc ?? [`${actorUrl(env, user)}/followers`]; + return { + "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], + id: status.activity_id, + type: "Create", + actor: actorUrl(env, user), + published: status.created_at, + to, + cc, + object: noteObject(env, user, status, { to, cc }) + }; +} + +export function deleteActivity(env: Env, user: User, status: Status): Json { + return { + "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], + id: activityUrl(env, id()), + type: "Delete", + actor: actorUrl(env, user), + to: [PUBLIC_COLLECTION], + object: { + id: status.object_id, + type: "Tombstone" + } + }; +} + +export function followActivity(env: Env, user: User, target: string, activityId: string): Json { + return { + "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], + id: activityId, + type: "Follow", + actor: actorUrl(env, user), + object: target + }; +} + +export function undoActivity(env: Env, user: User, inner: Json): Json { + return { + "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], + id: activityUrl(env, id()), + type: "Undo", + actor: actorUrl(env, user), + object: inner + }; +} + +export function likeActivity(env: Env, user: User, target: string, activityId: string): Json { + return { + "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], + id: activityId, + type: "Like", + actor: actorUrl(env, user), + object: target + }; +} + +export function announceActivity(env: Env, user: User, target: string, activityId: string): Json { + return { + "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], + id: activityId, + type: "Announce", + actor: actorUrl(env, user), + published: new Date().toISOString(), + to: [PUBLIC_COLLECTION], + cc: [`${actorUrl(env, user)}/followers`], + object: target + }; +} + +export function updatePersonActivity(env: Env, user: User, doc: Json): Json { + return { + "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], + id: activityUrl(env, id()), + type: "Update", + actor: actorUrl(env, user), + to: [PUBLIC_COLLECTION], + object: doc + }; +} + +export function noteObject(env: Env, user: User, status: Status, opts: { to?: string[]; cc?: string[]; attachments?: Json[]; tag?: Json[] } = {}): Json { + return { + id: status.object_id, + type: "Note", + summary: status.summary || null, + sensitive: Boolean(status.sensitive), + inReplyTo: status.in_reply_to_id ? objectUrl(env, status.in_reply_to_id) : null, + attributedTo: actorUrl(env, user), + content: status.content, + published: status.created_at, + url: status.url, + to: opts.to ?? [PUBLIC_COLLECTION], + cc: opts.cc ?? [`${actorUrl(env, user)}/followers`], + attachment: opts.attachments ?? [], + tag: opts.tag ?? [] + }; +} + +export { AVATAR_SVG, HEADER_SVG }; diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..1017c82 --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,126 @@ +import { base64, base64Decode, base64Url, base64UrlDecode, bytesToArrayBuffer, concatBytes, encoder } from "./util"; + +const PBKDF2_ITERATIONS = 100_000; + +export async function hashPassword(password: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const hash = await pbkdf2(password, salt, PBKDF2_ITERATIONS); + return `pbkdf2$${PBKDF2_ITERATIONS}$${base64Url(salt)}$${base64Url(hash)}`; +} + +export async function verifyPassword(password: string, stored: string): Promise { + if (stored.startsWith("pbkdf2$")) { + const [, itersText, saltPart, hashPart] = stored.split("$"); + const iters = Number(itersText); + if (!Number.isFinite(iters) || iters <= 0) return false; + const salt = base64UrlDecode(saltPart); + const hash = await pbkdf2(password, salt, iters); + return constantTimeEqual(base64Url(hash), hashPart); + } + const [saltPart, hashPart] = stored.split("."); + if (!saltPart || !hashPart) return false; + const salt = base64UrlDecode(saltPart); + const digest = await crypto.subtle.digest("SHA-256", bytesToArrayBuffer(concatBytes(salt, encoder.encode(password)))); + return constantTimeEqual(base64Url(new Uint8Array(digest)), hashPart); +} + +export function passwordNeedsUpgrade(stored: string): boolean { + return !stored.startsWith("pbkdf2$"); +} + +async function pbkdf2(password: string, salt: Uint8Array, iters: number): Promise { + const key = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: bytesToArrayBuffer(salt), iterations: iters }, key, 32 * 8); + return new Uint8Array(bits); +} + +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +export async function digestBase64(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); + return base64(new Uint8Array(digest)); +} + +export async function signString(input: string, jwk: JsonWebKey): Promise { + const key = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, encoder.encode(input)); + return base64(new Uint8Array(signature)); +} + +export async function verifyWithPem(pem: string, data: string, signatureBase64: string): Promise { + const key = await importSpkiPem(pem); + return await crypto.subtle.verify( + { name: "RSASSA-PKCS1-v1_5" }, + key, + bytesToArrayBuffer(base64Decode(signatureBase64)), + encoder.encode(data) + ); +} + +export async function exportSpkiPem(jwk: JsonWebKey): Promise { + const key = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + true, + ["verify"] + ); + const spki = await crypto.subtle.exportKey("spki", key); + return toPem("PUBLIC KEY", spki); +} + +export async function importSpkiPem(pem: string): Promise { + return await crypto.subtle.importKey( + "spki", + pemToArrayBuffer(pem), + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + true, + ["verify"] + ); +} + +function toPem(label: string, key: ArrayBuffer): string { + const b64 = base64(new Uint8Array(key)); + const lines = b64.match(/.{1,64}/g)?.join("\n") ?? b64; + return `-----BEGIN ${label}-----\n${lines}\n-----END ${label}-----`; +} + +function pemToArrayBuffer(pem: string): ArrayBuffer { + const b64 = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, ""); + return bytesToArrayBuffer(base64Decode(b64)); +} + +export type ParsedSignature = { + keyId: string; + algorithm: string | null; + headers: string[]; + signature: string; +}; + +export function parseSignatureHeader(value: string): ParsedSignature | null { + const fields = new Map(); + const re = /(\w+)="([^"]*)"/g; + let match: RegExpExecArray | null; + while ((match = re.exec(value)) !== null) fields.set(match[1].toLowerCase(), match[2]); + const keyId = fields.get("keyid"); + const headers = fields.get("headers"); + const signature = fields.get("signature"); + if (!keyId || !signature) return null; + return { + keyId, + algorithm: fields.get("algorithm") ?? null, + headers: (headers ?? "(created)").split(/\s+/).map((item) => item.toLowerCase()).filter(Boolean), + signature + }; +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..4b74ae6 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,196 @@ +import { exportSpkiPem, hashPassword } from "./crypto"; +import type { + ActorCache, + Favourite, + Follow, + Media, + Notification, + OAuthApp, + OAuthCode, + OutgoingFollow, + Reblog, + RemoteActor, + Status, + User +} from "./types"; +import { ACTOR_CACHE_TTL_MS } from "./types"; +import { id } from "./util"; + +export async function ensureAdminUser(env: Env): Promise { + const existing = await env.DB.prepare("SELECT id FROM users WHERE username = ?").bind(env.ADMIN_USERNAME).first<{ id: string }>(); + if (existing) return; + + const keyPair = await crypto.subtle.generateKey( + { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + true, + ["sign", "verify"] + ) as CryptoKeyPair; + const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey); + const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + const now = new Date().toISOString(); + + await env.DB.prepare( + "INSERT OR IGNORE INTO users (id, username, display_name, note, password_hash, private_key_jwk, public_key_jwk, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind(id(), env.ADMIN_USERNAME, env.ADMIN_USERNAME, "", await hashPassword(env.ADMIN_PASSWORD), JSON.stringify(privateKey), JSON.stringify(publicKey), now) + .run(); +} + +export async function getUserById(env: Env, userId: string): Promise { + return env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(userId).first(); +} + +export async function getUserByUsername(env: Env, username: string): Promise { + return env.DB.prepare("SELECT * FROM users WHERE username = ?").bind(username).first(); +} + +export async function getUserByIdOrUsername(env: Env, key: string): Promise { + return env.DB.prepare("SELECT * FROM users WHERE id = ? OR username = ?").bind(key, key).first(); +} + +export async function getAdminUser(env: Env): Promise { + const user = await getUserByUsername(env, env.ADMIN_USERNAME); + if (!user) throw new Error("admin_user_missing"); + return user; +} + +export async function getAppByClientId(env: Env, clientId: string): Promise { + return env.DB.prepare("SELECT * FROM oauth_apps WHERE client_id = ?").bind(clientId).first(); +} + +export async function takeOAuthCode(env: Env, code: string): Promise { + const row = await env.DB.prepare("SELECT * FROM oauth_codes WHERE code = ?").bind(code).first(); + if (!row) return null; + await env.DB.prepare("DELETE FROM oauth_codes WHERE code = ?").bind(code).run(); + if (row.expires_at < Math.floor(Date.now() / 1000)) return null; + return row; +} + +export async function getStatus(env: Env, statusId: string): Promise { + return env.DB.prepare("SELECT * FROM statuses WHERE id = ?").bind(statusId).first(); +} + +export async function getStatusByObjectId(env: Env, objectId: string): Promise { + return env.DB.prepare("SELECT * FROM statuses WHERE object_id = ?").bind(objectId).first(); +} + +export async function listMediaForStatus(env: Env, statusId: string): Promise { + const rows = await env.DB.prepare("SELECT * FROM media WHERE status_id = ? ORDER BY created_at ASC").bind(statusId).all(); + return rows.results; +} + +export async function listFollowers(env: Env, userId: string): Promise { + const rows = await env.DB.prepare("SELECT * FROM follows WHERE local_user_id = ? AND accepted = 1").bind(userId).all(); + return rows.results; +} + +export async function countFollowers(env: Env, userId: string): Promise { + const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM follows WHERE local_user_id = ? AND accepted = 1").bind(userId).first<{ count: number }>(); + return row?.count ?? 0; +} + +export async function countFollowing(env: Env, userId: string): Promise { + const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM outgoing_follows WHERE local_user_id = ? AND accepted = 1").bind(userId).first<{ count: number }>(); + return row?.count ?? 0; +} + +export async function countStatuses(env: Env, userId: string): Promise { + const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM statuses WHERE user_id = ?").bind(userId).first<{ count: number }>(); + return row?.count ?? 0; +} + +export async function listFavouritesForStatus(env: Env, statusId: string): Promise { + const rows = await env.DB.prepare("SELECT * FROM favourites WHERE status_id = ?").bind(statusId).all(); + return rows.results; +} + +export async function listReblogsForStatus(env: Env, statusId: string): Promise { + const rows = await env.DB.prepare("SELECT * FROM reblogs WHERE status_id = ?").bind(statusId).all(); + return rows.results; +} + +export async function findFavourite(env: Env, statusId: string, actor: string): Promise { + return env.DB.prepare("SELECT * FROM favourites WHERE status_id = ? AND actor = ?").bind(statusId, actor).first(); +} + +export async function findReblog(env: Env, statusId: string, actor: string): Promise { + return env.DB.prepare("SELECT * FROM reblogs WHERE status_id = ? AND actor = ?").bind(statusId, actor).first(); +} + +export async function findOutgoingFollow(env: Env, userId: string, target: string): Promise { + return env.DB.prepare("SELECT * FROM outgoing_follows WHERE local_user_id = ? AND target_actor = ?").bind(userId, target).first(); +} + +export async function recordNotification(env: Env, userId: string, type: string, actor: string, statusId: string | null): Promise { + if (actor === userId) return null; + const notificationId = id(); + const now = new Date().toISOString(); + try { + await env.DB.prepare( + "INSERT INTO notifications (id, user_id, type, actor, status_id, read, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)" + ) + .bind(notificationId, userId, type, actor, statusId, now) + .run(); + } catch { + return null; + } + return env.DB.prepare("SELECT * FROM notifications WHERE id = ?").bind(notificationId).first(); +} + +export async function getActorFromCache(env: Env, actorId: string): Promise { + return env.DB.prepare("SELECT * FROM actor_cache WHERE id = ?").bind(actorId).first(); +} + +export async function getActorByKeyId(env: Env, keyId: string): Promise { + return env.DB.prepare("SELECT * FROM actor_cache WHERE public_key_id = ?").bind(keyId).first(); +} + +export async function upsertActorCache(env: Env, actor: RemoteActor): Promise { + if (!actor.id) return null; + const inbox = actor.inbox ?? actor.id; + const sharedInbox = actor.endpoints?.sharedInbox ?? null; + const iconUrl = typeof actor.icon === "string" ? actor.icon : actor.icon?.url ?? null; + const now = new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO actor_cache (id, inbox, shared_inbox, preferred_username, name, summary, icon_url, public_key_id, public_key_pem, fetched_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + inbox = excluded.inbox, + shared_inbox = excluded.shared_inbox, + preferred_username = excluded.preferred_username, + name = excluded.name, + summary = excluded.summary, + icon_url = excluded.icon_url, + public_key_id = excluded.public_key_id, + public_key_pem = excluded.public_key_pem, + fetched_at = excluded.fetched_at` + ) + .bind( + actor.id, + inbox, + sharedInbox, + actor.preferredUsername ?? null, + actor.name ?? null, + actor.summary ?? null, + iconUrl, + actor.publicKey?.id ?? null, + actor.publicKey?.publicKeyPem ?? null, + now + ) + .run(); + return getActorFromCache(env, actor.id); +} + +export async function deleteActorFromCache(env: Env, actorId: string): Promise { + await env.DB.prepare("DELETE FROM actor_cache WHERE id = ?").bind(actorId).run(); +} + +export function actorCacheStale(cache: ActorCache): boolean { + const fetched = Date.parse(cache.fetched_at); + if (!Number.isFinite(fetched)) return true; + return Date.now() - fetched > ACTOR_CACHE_TTL_MS; +} + +export async function exportUserPublicKeyPem(user: User): Promise { + return exportSpkiPem(JSON.parse(user.public_key_jwk) as JsonWebKey); +} diff --git a/src/federation.ts b/src/federation.ts new file mode 100644 index 0000000..6556364 --- /dev/null +++ b/src/federation.ts @@ -0,0 +1,260 @@ +import { + digestBase64, + parseSignatureHeader, + signString, + verifyWithPem +} from "./crypto"; +import { + actorCacheStale, + deleteActorFromCache, + getActorByKeyId, + getActorFromCache, + recordNotification, + upsertActorCache +} from "./db"; +import type { ActorCache, Json, RemoteActor, Status, User } from "./types"; +import { SIGNATURE_MAX_SKEW_MS } from "./types"; +import { actorUrl, base64Decode, encoder, hostFromBaseUrl, parseAcctFromActor } from "./util"; + +const ACTIVITY_HEADERS = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; + +export async function resolveRemoteActor(env: Env, actorId: string, opts: { force?: boolean } = {}): Promise { + if (!actorId) return null; + const cached = await getActorFromCache(env, actorId); + if (cached && !opts.force && !actorCacheStale(cached)) return cached; + const fetched = await fetchRemoteActor(actorId); + if (!fetched) return cached; + return upsertActorCache(env, fetched); +} + +export async function fetchRemoteActor(actorId: string): Promise { + try { + const response = await fetch(actorId, { + headers: { accept: ACTIVITY_HEADERS }, + cf: { cacheTtl: 60 } + }); + if (!response.ok) return null; + const data = await response.json() as RemoteActor; + if (!data || !data.id) return null; + return data; + } catch { + return null; + } +} + +export async function discoverActorByKeyId(env: Env, keyId: string): Promise { + const cached = await getActorByKeyId(env, keyId); + if (cached && !actorCacheStale(cached)) return cached; + const actorIdGuess = keyId.split("#")[0]; + const refreshed = await resolveRemoteActor(env, actorIdGuess, { force: true }); + if (refreshed?.public_key_id === keyId) return refreshed; + return cached; +} + +export type VerifiedSignature = { + actor: ActorCache; + keyId: string; +}; + +export async function verifyInboundSignature(request: Request, body: string, env: Env): Promise { + const sigHeader = request.headers.get("signature"); + if (!sigHeader) return null; + const parsed = parseSignatureHeader(sigHeader); + if (!parsed || !parsed.headers.includes("(request-target)")) return null; + + const dateHeader = request.headers.get("date"); + if (dateHeader) { + const stamp = Date.parse(dateHeader); + if (!Number.isFinite(stamp)) return null; + if (Math.abs(Date.now() - stamp) > SIGNATURE_MAX_SKEW_MS) return null; + } else if (parsed.headers.includes("date")) { + return null; + } + + const digestHeader = request.headers.get("digest"); + if (digestHeader) { + const expected = `SHA-256=${await digestBase64(body)}`; + if (digestHeader !== expected) return null; + } else if (request.method.toUpperCase() === "POST" && body) { + return null; + } + + const url = new URL(request.url); + const hostHeader = request.headers.get("host"); + if (parsed.headers.includes("host") && hostHeader && hostHeader.toLowerCase() !== url.host.toLowerCase()) { + return null; + } + + const lines: string[] = []; + for (const headerName of parsed.headers) { + if (headerName === "(request-target)") { + lines.push(`(request-target): ${request.method.toLowerCase()} ${url.pathname}${url.search}`); + continue; + } + const value = request.headers.get(headerName); + if (value === null) return null; + lines.push(`${headerName}: ${value}`); + } + + const actor = await discoverActorByKeyId(env, parsed.keyId); + if (!actor || !actor.public_key_pem) return null; + + try { + const ok = await verifyWithPem(actor.public_key_pem, lines.join("\n"), parsed.signature); + if (!ok) return null; + } catch { + return null; + } + return { actor, keyId: parsed.keyId }; +} + +export async function sendSignedActivity(env: Env, user: User, inboxUrl: string, activity: Json): Promise<{ ok: boolean; status: number; text: string }> { + const target = new URL(inboxUrl); + const body = JSON.stringify(activity); + const digest = `SHA-256=${await digestBase64(body)}`; + const date = new Date().toUTCString(); + const host = target.host; + const path = `${target.pathname}${target.search}`; + const signingString = [ + `(request-target): post ${path}`, + `host: ${host}`, + `date: ${date}`, + `digest: ${digest}` + ].join("\n"); + const signature = await signString(signingString, JSON.parse(user.private_key_jwk) as JsonWebKey); + const headerValue = [ + `keyId="${actorUrl(env, user)}#main-key"`, + `algorithm="rsa-sha256"`, + `headers="(request-target) host date digest"`, + `signature="${signature}"` + ].join(","); + + try { + const response = await fetch(inboxUrl, { + method: "POST", + headers: { + accept: "application/activity+json", + "content-type": "application/activity+json", + date, + digest, + signature: headerValue, + "user-agent": `toot-worker (+https://${hostFromBaseUrl(env)})` + }, + body + }); + const text = response.ok ? "" : await response.text().catch(() => ""); + if (!response.ok) console.warn("signed-delivery", inboxUrl, response.status, text.slice(0, 200)); + return { ok: response.ok, status: response.status, text }; + } catch (error) { + console.warn("signed-delivery-error", inboxUrl, String(error)); + return { ok: false, status: 0, text: String(error) }; + } +} + +export async function deliverToInboxes(env: Env, user: User, inboxes: Iterable, activity: Json): Promise { + const unique = new Set(); + for (const inbox of inboxes) { + if (inbox) unique.add(inbox); + } + await Promise.allSettled([...unique].map((inbox) => sendSignedActivity(env, user, inbox, activity))); +} + +export async function gatherFollowerInboxes(env: Env, userId: string): Promise { + const rows = await env.DB.prepare( + "SELECT inbox FROM follows WHERE local_user_id = ? AND accepted = 1" + ).bind(userId).all<{ inbox: string }>(); + const inboxes = new Set(); + for (const row of rows.results) { + if (row.inbox) { + const actor = await getActorFromCache(env, row.inbox); + const shared = (await getActorByKeyId(env, row.inbox))?.shared_inbox; + inboxes.add(actor?.shared_inbox ?? shared ?? row.inbox); + } + } + return [...inboxes]; +} + +export async function resolveDeliveryInboxes(env: Env, actorIds: Iterable): Promise { + const inboxes = new Set(); + for (const actorId of actorIds) { + const actor = await resolveRemoteActor(env, actorId); + if (!actor) continue; + inboxes.add(actor.shared_inbox ?? actor.inbox); + } + return [...inboxes]; +} + +export type InboundActivity = { + body: Json; + bodyText: string; + activityId: string; + type: string; + actor: string; +}; + +export function parseActivity(bodyText: string): InboundActivity | null { + try { + const body = JSON.parse(bodyText) as Json; + return { + body, + bodyText, + activityId: String(body.id ?? ""), + type: String(body.type ?? ""), + actor: typeof body.actor === "string" ? body.actor : String((body.actor as Json | undefined)?.id ?? "") + }; + } catch { + return null; + } +} + +export function activityObjectField(activity: InboundActivity, field: string): unknown { + const object = activity.body[field]; + return object; +} + +export function objectAsJson(value: unknown): Json | null { + return value && typeof value === "object" ? value as Json : null; +} + +export function objectIdString(value: unknown): string | null { + if (typeof value === "string") return value; + const obj = objectAsJson(value); + if (obj && typeof obj.id === "string") return obj.id; + return null; +} + +export async function recordRemoteActivity(env: Env, activity: InboundActivity, verified: boolean): Promise { + await env.DB.prepare( + "INSERT OR IGNORE INTO remote_activities (id, actor, type, payload, received_at) VALUES (?, ?, ?, ?, ?)" + ) + .bind( + activity.activityId || crypto.randomUUID(), + activity.actor, + activity.type, + JSON.stringify({ ...activity.body, signature_verified: verified }), + new Date().toISOString() + ) + .run(); +} + +export async function isDuplicateActivity(env: Env, activityId: string): Promise { + if (!activityId) return false; + const row = await env.DB.prepare("SELECT id FROM remote_activities WHERE id = ?").bind(activityId).first<{ id: string }>(); + return Boolean(row); +} + +export async function notifyForLocalStatus(env: Env, status: Status, type: string, actor: string): Promise { + await recordNotification(env, status.user_id, type, actor, status.id); +} + +export function mentionAcct(env: Env, actorId: string): string { + return parseAcctFromActor(env, actorId); +} + +export async function decodeRemoteSignatureBase64(value: string): Promise { + return base64Decode(value); +} + +export function activitySignableData(input: string): Uint8Array { + return encoder.encode(input); +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..c325521 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,90 @@ +export class HttpError extends Error { + constructor(readonly status: number, message: string) { + super(message); + } +} + +export function json(data: unknown, status = 200, headers: HeadersInit = {}): Response { + return cors(new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json; charset=utf-8", ...headers } + })); +} + +export function activityJson(data: unknown, status = 200): Response { + return cors(new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/activity+json; charset=utf-8" } + })); +} + +export function html(body: string, status = 200): Response { + return cors(new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8" } })); +} + +export function svgResponse(svg: string): Response { + return cors(new Response(svg, { headers: { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "public, max-age=3600" } })); +} + +export function cors(response: Response): Response { + const headers = new Headers(response.headers); + headers.set("access-control-allow-origin", "*"); + headers.set("access-control-allow-methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); + headers.set("access-control-allow-headers", "authorization,content-type,accept,digest,signature,date,idempotency-key"); + headers.set("access-control-expose-headers", "link,x-ratelimit-remaining,x-ratelimit-reset"); + headers.set("x-content-type-options", "nosniff"); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +export type ParsedBody = Record; + +export async function readBody(request: Request): Promise { + const contentType = (request.headers.get("content-type") ?? "").toLowerCase(); + if (contentType.includes("application/json")) { + try { + const value = (await request.json()) as Record; + const out: ParsedBody = {}; + for (const [key, raw] of Object.entries(value)) { + if (Array.isArray(raw)) out[key] = raw.map(String); + else if (raw !== null && raw !== undefined) out[key] = String(raw); + } + return out; + } catch { + throw new HttpError(400, "invalid_json"); + } + } + const form = await request.formData(); + const data: ParsedBody = {}; + for (const [key, value] of form) { + const cleanKey = key.endsWith("[]") ? key.slice(0, -2) : key; + const normalized = value instanceof File ? value : String(value); + const existing = data[cleanKey]; + if (existing === undefined) { + data[cleanKey] = key.endsWith("[]") ? [normalized as string] : normalized; + } else if (Array.isArray(existing)) { + existing.push(normalized as string); + } else { + data[cleanKey] = [existing as string, normalized as string]; + } + } + return data; +} + +export function bodyString(body: ParsedBody, key: string, fallback = ""): string { + const value = body[key]; + if (typeof value === "string") return value; + if (Array.isArray(value) && value.length > 0) return value[0]; + return fallback; +} + +export function bodyArray(body: ParsedBody, key: string): string[] { + const value = body[key]; + if (Array.isArray(value)) return value; + if (typeof value === "string" && value) return [value]; + return []; +} + +export function bodyFile(body: ParsedBody, key: string): File | null { + const value = body[key]; + return value instanceof File ? value : null; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ec07981 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,157 @@ +import { + AVATAR_SVG, + HEADER_SVG, + activityObject, + actor, + followersCollection, + followingCollection, + hostMeta, + inboxHandler, + nodeInfo, + nodeInfoLinks, + outbox, + webFinger +} from "./activitypub"; +import { ensureAdminUser } from "./db"; +import { HttpError, cors, json, svgResponse } from "./http"; +import { + accountStatuses, + authorize, + authorizeFollowRequest, + authorizePage, + bookmarkStatus, + createApp, + createStatus, + customEmojis, + deleteStatusEndpoint, + favouriteStatus, + filtersV1, + followAccount, + followRequestsList, + getAccount, + getRelationships, + getStatusEndpoint, + homeTimeline, + instance, + instanceV2, + markersList, + notificationClear, + notificationDismiss, + notificationsList, + publicTimeline, + pushSubscription, + reblogStatus, + rejectFollowRequest, + revoke, + search, + serveMedia, + statusContext, + token, + trendsTags, + unfavouriteStatus, + unfollowAccount, + unreblogStatus, + updateCredentials, + updateMedia, + uploadMedia, + verifyAppCredentials, + verifyCredentials +} from "./mastodon"; + +export default { + async fetch(request: Request, env: Env): Promise { + try { + await ensureAdminUser(env); + return await route(request, env); + } catch (error) { + if (error instanceof HttpError) return json({ error: error.message }, error.status); + console.error("unhandled", error); + return json({ error: "internal_server_error" }, 500); + } + } +}; + +async function route(request: Request, env: Env): Promise { + if (request.method === "OPTIONS") return cors(new Response(null, { status: 204 })); + + const url = new URL(request.url); + const path = url.pathname.replace(/\/+$/, "") || "/"; + const method = request.method.toUpperCase(); + + if (method === "GET" && path === "/") return nodeInfo(env); + if (method === "GET" && path === "/avatar.png") return svgResponse(AVATAR_SVG); + if (method === "GET" && path === "/header.png") return svgResponse(HEADER_SVG); + + if (method === "GET" && path === "/.well-known/webfinger") return webFinger(request, env); + if (method === "GET" && path === "/.well-known/nodeinfo") return nodeInfoLinks(env); + if (method === "GET" && path === "/.well-known/host-meta") return hostMeta(env); + if (method === "GET" && path === "/nodeinfo/2.0") return nodeInfo(env); + + if (method === "GET" && path === "/api/v1/instance") return instance(env); + if (method === "GET" && path === "/api/v2/instance") return instanceV2(env); + if (method === "POST" && path === "/api/v1/apps") return createApp(request, env); + if (method === "GET" && path === "/api/v1/apps/verify_credentials") return verifyAppCredentials(request, env); + + if (method === "GET" && path === "/oauth/authorize") return authorizePage(request, env); + if (method === "POST" && path === "/oauth/authorize") return authorize(request, env); + if (method === "POST" && path === "/oauth/token") return token(request, env); + if (method === "POST" && path === "/oauth/revoke") return revoke(request, env); + + if (method === "GET" && path === "/api/v1/accounts/verify_credentials") return verifyCredentials(request, env); + if ((method === "PATCH" || method === "POST") && path === "/api/v1/accounts/update_credentials") return updateCredentials(request, env); + if (method === "GET" && path === "/api/v1/accounts/relationships") return getRelationships(request, env); + if (method === "GET" && path === "/api/v1/accounts/search") return search(request, env); + + let m: RegExpMatchArray | null; + + if (method === "GET" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)$/))) return getAccount(env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/statuses$/))) return accountStatuses(request, env, decodeURIComponent(m[1])); + if (method === "POST" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/follow$/))) return followAccount(request, env, decodeURIComponent(m[1])); + if (method === "POST" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/unfollow$/))) return unfollowAccount(request, env, decodeURIComponent(m[1])); + + if (method === "GET" && path === "/api/v1/follow_requests") return followRequestsList(request, env); + if (method === "POST" && (m = path.match(/^\/api\/v1\/follow_requests\/([^/]+)\/authorize$/))) return authorizeFollowRequest(request, env, decodeURIComponent(m[1])); + if (method === "POST" && (m = path.match(/^\/api\/v1\/follow_requests\/([^/]+)\/reject$/))) return rejectFollowRequest(request, env, decodeURIComponent(m[1])); + + if (method === "POST" && path === "/api/v1/statuses") return createStatus(request, env); + if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return getStatusEndpoint(request, env, decodeURIComponent(m[1])); + if (method === "DELETE" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return deleteStatusEndpoint(request, env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/context$/))) return statusContext(env, decodeURIComponent(m[1]), request); + if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/favourite$/))) return favouriteStatus(request, env, decodeURIComponent(m[1])); + if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unfavourite$/))) return unfavouriteStatus(request, env, decodeURIComponent(m[1])); + if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/reblog$/))) return reblogStatus(request, env, decodeURIComponent(m[1])); + if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unreblog$/))) return unreblogStatus(request, env, decodeURIComponent(m[1])); + if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/bookmark$/))) return bookmarkStatus(request, env, decodeURIComponent(m[1])); + if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unbookmark$/))) return bookmarkStatus(request, env, decodeURIComponent(m[1])); + if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/pin$/))) return bookmarkStatus(request, env, decodeURIComponent(m[1])); + if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unpin$/))) return bookmarkStatus(request, env, decodeURIComponent(m[1])); + + if (method === "GET" && path === "/api/v1/timelines/public") return publicTimeline(request, env); + if (method === "GET" && path === "/api/v1/timelines/home") return homeTimeline(request, env); + + if (method === "POST" && (path === "/api/v1/media" || path === "/api/v2/media")) return uploadMedia(request, env); + if (method === "PUT" && (m = path.match(/^\/api\/v1\/media\/([^/]+)$/))) return updateMedia(request, env, decodeURIComponent(m[1])); + + if (method === "GET" && path === "/api/v1/notifications") return notificationsList(request, env); + if (method === "POST" && path === "/api/v1/notifications/clear") return notificationClear(request, env); + if (method === "POST" && (m = path.match(/^\/api\/v1\/notifications\/([^/]+)\/dismiss$/))) return notificationDismiss(request, env, decodeURIComponent(m[1])); + + if (method === "GET" && (path === "/api/v2/search" || path === "/api/v1/search")) return search(request, env); + if (method === "GET" && path === "/api/v1/custom_emojis") return customEmojis(env); + if (method === "GET" && path === "/api/v1/filters") return filtersV1(request, env); + if (method === "GET" && path === "/api/v1/trends/tags") return trendsTags(env); + if (method === "GET" && path === "/api/v1/markers") return markersList(request, env); + if (method === "POST" && path === "/api/v1/push/subscription") return pushSubscription(); + + if (method === "GET" && (m = path.match(/^\/media\/(.+)$/))) return serveMedia(env, m[1]); + + if (method === "GET" && (m = path.match(/^\/users\/([^/]+)$/))) return actor(env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/outbox$/))) return outbox(request, env, decodeURIComponent(m[1])); + if (method === "POST" && (m = path.match(/^\/users\/([^/]+)\/inbox$/))) return inboxHandler(request, env, decodeURIComponent(m[1])); + if (method === "POST" && path === "/inbox") return inboxHandler(request, env, null); + if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/followers$/))) return followersCollection(env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/following$/))) return followingCollection(env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/objects\/([^/]+)$/))) return activityObject(env, decodeURIComponent(m[1])); + + return json({ error: "not_found" }, 404); +} diff --git a/src/mastodon.ts b/src/mastodon.ts new file mode 100644 index 0000000..268d0e0 --- /dev/null +++ b/src/mastodon.ts @@ -0,0 +1,1321 @@ +import { + actorDocument, + announceActivity, + createActivity, + deleteActivity, + followActivity, + likeActivity, + undoActivity, + updatePersonActivity +} from "./activitypub"; +import { hashPassword, verifyPassword } from "./crypto"; +import { + countFollowers, + countFollowing, + countStatuses, + findFavourite, + findOutgoingFollow, + findReblog, + getAdminUser, + getAppByClientId, + getStatus, + getUserById, + getUserByIdOrUsername, + getUserByUsername, + recordNotification, + takeOAuthCode +} from "./db"; +import { + deliverToInboxes, + gatherFollowerInboxes, + resolveDeliveryInboxes, + resolveRemoteActor +} from "./federation"; +import { + bodyArray, + bodyString, + cors, + HttpError, + html, + json, + readBody +} from "./http"; +import type { + Follow, + Media, + Mention, + Notification, + Session, + Status, + User +} from "./types"; +import { + actorUrl, + activityUrl, + baseUrl, + clampLimit, + escapeHtml, + hostFromBaseUrl, + htmlContent, + id, + isLocalActor, + normalizeArray, + objectUrl, + safeFileName, + tokenString +} from "./util"; + +const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90; +const MAX_STATUS_CHARS = 5000; +const MAX_MEDIA_ATTACHMENTS = 4; +const MAX_MEDIA_BYTES = 10 * 1024 * 1024; + +const SUPPORTED_MIME = ["image/jpeg", "image/png", "image/gif", "image/webp"]; + +function parseRedirectUris(value: string): string[] { + return value.split(/\s+/).map((item) => item.trim()).filter(Boolean); +} + +function selectRedirectUri(app: { redirect_uri: string }, requested: string | null | undefined): string | null { + const allowed = parseRedirectUris(app.redirect_uri); + const fallback = allowed[0] ?? "urn:ietf:wg:oauth:2.0:oob"; + const candidate = (requested ?? "").trim() || fallback; + return allowed.includes(candidate) ? candidate : null; +} + +export async function instance(env: Env): Promise { + const userCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM users").first<{ count: number }>(); + const statusCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM statuses").first<{ count: number }>(); + const admin = await getAdminUser(env); + return json({ + uri: hostFromBaseUrl(env), + title: env.INSTANCE_NAME, + short_description: "A single-user ActivityPub server on Cloudflare Workers.", + description: "A single-user ActivityPub server on Cloudflare Workers.", + email: "", + version: "4.2.0-compatible (toot-worker)", + urls: { streaming_api: `wss://${hostFromBaseUrl(env)}` }, + stats: { user_count: userCount?.count ?? 0, status_count: statusCount?.count ?? 0, domain_count: 0 }, + languages: ["en"], + registrations: false, + approval_required: false, + invites_enabled: false, + configuration: { + statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: MAX_MEDIA_ATTACHMENTS, characters_reserved_per_url: 23 }, + media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 }, + polls: { max_options: 4, max_characters_per_option: 50, min_expiration: 300, max_expiration: 2629746 } + }, + contact_account: await accountJson(env, admin), + rules: [] + }); +} + +export async function instanceV2(env: Env): Promise { + const admin = await getAdminUser(env); + return json({ + domain: hostFromBaseUrl(env), + title: env.INSTANCE_NAME, + version: "4.2.0-compatible (toot-worker)", + source_url: "https://example.com", + description: "A single-user ActivityPub server on Cloudflare Workers.", + usage: { users: { active_month: 1 } }, + thumbnail: { url: `${baseUrl(env)}/header.png` }, + languages: ["en"], + configuration: { + urls: { streaming: `wss://${hostFromBaseUrl(env)}` }, + accounts: { max_featured_tags: 0 }, + statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: MAX_MEDIA_ATTACHMENTS, characters_reserved_per_url: 23 }, + media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 } + }, + registrations: { enabled: false, approval_required: false, message: null }, + contact: { email: "", account: await accountJson(env, admin) }, + rules: [] + }); +} + +export async function createApp(request: Request, env: Env): Promise { + const body = await readBody(request); + const now = new Date().toISOString(); + const redirectUri = bodyString(body, "redirect_uris", bodyString(body, "redirect_uri", "urn:ietf:wg:oauth:2.0:oob")); + const app = { + id: id(), + client_id: tokenString(32), + client_secret: tokenString(48), + name: bodyString(body, "client_name", bodyString(body, "name", "Mastodon App")), + redirect_uri: redirectUri, + scopes: bodyString(body, "scopes", "read write follow"), + website: bodyString(body, "website", "") || null, + created_at: now + }; + await env.DB.prepare( + "INSERT INTO oauth_apps (id, client_id, client_secret, name, redirect_uri, scopes, website, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind(app.id, app.client_id, app.client_secret, app.name, app.redirect_uri, app.scopes, app.website, app.created_at) + .run(); + return json({ + id: app.id, + name: app.name, + website: app.website, + redirect_uri: app.redirect_uri, + client_id: app.client_id, + client_secret: app.client_secret, + vapid_key: "" + }); +} + +export async function verifyAppCredentials(request: Request, env: Env): Promise { + const auth = request.headers.get("authorization") ?? ""; + const token = auth.match(/^Bearer\s+(.+)$/i)?.[1]; + if (!token) throw new HttpError(401, "The access token is invalid"); + const session = await env.KV.get(`token:${token}`, "json"); + if (!session) throw new HttpError(401, "The access token is invalid"); + const app = await env.DB.prepare("SELECT * FROM oauth_apps WHERE id = ?").bind(session.appId).first<{ name: string; website: string | null }>(); + return json({ name: app?.name ?? "Mastodon App", website: app?.website ?? null, vapid_key: "" }); +} + +export async function authorizePage(request: Request, env: Env): Promise { + const url = new URL(request.url); + const clientId = url.searchParams.get("client_id"); + const app = clientId ? await getAppByClientId(env, clientId) : null; + if (!app) return html("Unknown OAuth application", 400); + const redirectUri = selectRedirectUri(app, url.searchParams.get("redirect_uri")); + if (!redirectUri) return html("Invalid redirect URI", 400); + + return html(` +Authorize + +

${escapeHtml(env.INSTANCE_NAME)}

+

Authorize ${escapeHtml(app.name)} to access your account.

+
+ + + + +

+

+ +
`); +} + +export async function authorize(request: Request, env: Env): Promise { + const body = await readBody(request); + const app = await getAppByClientId(env, bodyString(body, "client_id")); + if (!app) return json({ error: "invalid_client" }, 400); + const redirectUri = selectRedirectUri(app, bodyString(body, "redirect_uri")); + if (!redirectUri) return json({ error: "invalid_request" }, 400); + + const user = await getUserByUsername(env, bodyString(body, "username")); + if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) { + return html("Invalid username or password", 401); + } + + const code = tokenString(32); + const scope = bodyString(body, "scope", app.scopes); + await env.DB.prepare("INSERT INTO oauth_codes (code, app_id, user_id, redirect_uri, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)") + .bind(code, app.id, user.id, redirectUri, scope, Math.floor(Date.now() / 1000) + 600) + .run(); + + if (redirectUri === "urn:ietf:wg:oauth:2.0:oob") return html(`

Authorization code:

${code}`); + const url = new URL(redirectUri); + url.searchParams.set("code", code); + const state = bodyString(body, "state"); + if (state) url.searchParams.set("state", state); + return Response.redirect(url.toString(), 302); +} + +export async function token(request: Request, env: Env): Promise { + const body = await readBody(request); + const app = await getAppByClientId(env, bodyString(body, "client_id")); + if (!app || app.client_secret !== bodyString(body, "client_secret")) return json({ error: "invalid_client" }, 401); + + const grantType = bodyString(body, "grant_type", "authorization_code"); + let userId = ""; + let scopes = app.scopes; + + if (grantType === "password") { + const user = await getUserByUsername(env, bodyString(body, "username")); + if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) return json({ error: "invalid_grant" }, 400); + userId = user.id; + scopes = bodyString(body, "scope", app.scopes); + } else if (grantType === "client_credentials") { + scopes = bodyString(body, "scope", "read"); + } else { + const row = await takeOAuthCode(env, bodyString(body, "code")); + if (!row || row.app_id !== app.id) return json({ error: "invalid_grant" }, 400); + const redirectUri = bodyString(body, "redirect_uri", row.redirect_uri); + if (redirectUri !== row.redirect_uri || !parseRedirectUris(app.redirect_uri).includes(row.redirect_uri)) { + return json({ error: "invalid_grant" }, 400); + } + userId = row.user_id; + scopes = row.scopes; + } + + const accessToken = tokenString(48); + await env.KV.put(`token:${accessToken}`, JSON.stringify({ userId, appId: app.id, scopes } satisfies Session), { expirationTtl: TOKEN_TTL_SECONDS }); + return json({ access_token: accessToken, token_type: "Bearer", scope: scopes, created_at: Math.floor(Date.now() / 1000) }); +} + +export async function revoke(request: Request, env: Env): Promise { + const body = await readBody(request); + const tokenValue = bodyString(body, "token"); + if (tokenValue) await env.KV.delete(`token:${tokenValue}`); + return json({}); +} + +export async function verifyCredentials(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + const account = await accountJson(env, user) as Record; + account.source = { + privacy: "public", + sensitive: false, + language: "en", + note: user.note, + fields: [] + }; + return json(account); +} + +export async function updateCredentials(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + const body = await readBody(request); + const displayName = bodyString(body, "display_name", user.display_name); + const note = bodyString(body, "note", user.note); + await env.DB.prepare("UPDATE users SET display_name = ?, note = ? WHERE id = ?").bind(displayName, note, user.id).run(); + const password = bodyString(body, "password"); + if (password) { + const hash = await hashPassword(password); + await env.DB.prepare("UPDATE users SET password_hash = ? WHERE id = ?").bind(hash, user.id).run(); + } + const refreshed = await getUserById(env, user.id); + if (!refreshed) throw new HttpError(500, "user_missing"); + const followerInboxes = await gatherFollowerInboxes(env, user.id); + if (followerInboxes.length > 0) { + await deliverToInboxes(env, refreshed, followerInboxes, updatePersonActivity(env, refreshed, await actorDocument(env, refreshed))); + } + return json(await accountJson(env, refreshed)); +} + +export async function getAccount(env: Env, accountId: string): Promise { + const user = await getUserByIdOrUsername(env, accountId); + if (!user) return json({ error: "Record not found" }, 404); + return json(await accountJson(env, user)); +} + +export async function accountStatuses(request: Request, env: Env, accountId: string): Promise { + const user = await getUserByIdOrUsername(env, accountId); + if (!user) return json({ error: "Record not found" }, 404); + const url = new URL(request.url); + const limit = clampLimit(url.searchParams.get("limit"), 20, 40); + const excludeReplies = url.searchParams.get("exclude_replies") === "true"; + const where: string[] = ["user_id = ?"]; + const binds: unknown[] = [user.id]; + if (excludeReplies) where.push("in_reply_to_id IS NULL"); + pagedAppend(where, binds, url); + const sql = `SELECT * FROM statuses WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`; + binds.push(limit); + const rows = await env.DB.prepare(sql).bind(...binds).all(); + const items = await serializeStatuses(env, rows.results, request, new Map([[user.id, user]])); + return withPagination(json(items), request, rows.results.map((row) => row.id)); +} + +export async function createStatus(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + const body = await readBody(request); + const statusText = bodyString(body, "status").trim(); + if (!statusText) return json({ error: "status can't be blank" }, 422); + if (statusText.length > MAX_STATUS_CHARS) return json({ error: "status too long" }, 422); + + const summary = bodyString(body, "spoiler_text"); + const sensitive = bodyString(body, "sensitive") === "true"; + const visibility = bodyString(body, "visibility", "public"); + const inReplyTo = bodyString(body, "in_reply_to_id"); + const language = bodyString(body, "language", "en"); + + const mediaIds = bodyArray(body, "media_ids"); + if (mediaIds.length > MAX_MEDIA_ATTACHMENTS) return json({ error: "too_many_attachments" }, 422); + + const now = new Date().toISOString(); + const statusId = id(); + const objectId = objectUrl(env, statusId); + const activityId = activityUrl(env, statusId); + + const mentionsAcct = extractMentions(statusText); + const hashtags = extractHashtags(statusText); + + const resolvedMentions: { acct: string; actorId: string; url: string }[] = []; + for (const acct of mentionsAcct) { + const resolved = await resolveAcct(env, acct); + if (resolved) resolvedMentions.push(resolved); + } + + const renderedContent = htmlContent(statusText, resolvedMentions.map(({ acct, url }) => ({ acct, url })), hashtags); + + await env.DB.prepare( + "INSERT INTO statuses (id, user_id, content, summary, sensitive, language, visibility, in_reply_to_id, activity_id, object_id, created_at, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + statusId, + user.id, + renderedContent, + summary, + sensitive ? 1 : 0, + language, + visibility, + inReplyTo || null, + activityId, + objectId, + now, + objectId + ) + .run(); + + for (const mediaId of mediaIds) { + await env.DB.prepare("UPDATE media SET status_id = ? WHERE id = ? AND user_id = ?").bind(statusId, mediaId, user.id).run(); + } + + for (const mention of resolvedMentions) { + await env.DB.prepare("INSERT OR IGNORE INTO mentions (status_id, actor, acct, url) VALUES (?, ?, ?, ?)") + .bind(statusId, mention.actorId, mention.acct, mention.url).run(); + } + + for (const tag of hashtags) { + await env.DB.prepare("INSERT OR IGNORE INTO hashtags (status_id, tag) VALUES (?, ?)").bind(statusId, tag).run(); + } + + let replyParent: Status | null = null; + if (inReplyTo) { + replyParent = await getStatus(env, inReplyTo); + if (replyParent) { + const parentUser = await getUserById(env, replyParent.user_id); + if (parentUser && parentUser.id !== user.id) { + await recordNotification(env, parentUser.id, "mention", actorUrl(env, user), statusId); + } + } + } + + for (const mention of resolvedMentions) { + if (mention.actorId.startsWith(baseUrl(env))) { + const mentionedUser = await getUserByUsername(env, mention.acct.split("@")[0]); + if (mentionedUser && mentionedUser.id !== user.id) { + await recordNotification(env, mentionedUser.id, "mention", actorUrl(env, user), statusId); + } + } + } + + const status = await getStatus(env, statusId); + if (!status) throw new HttpError(500, "status_not_found"); + + if (visibility === "public" || visibility === "unlisted") { + const inboxes = new Set(await gatherFollowerInboxes(env, user.id)); + for (const mention of resolvedMentions) { + if (!mention.actorId.startsWith(baseUrl(env))) { + const cache = await resolveRemoteActor(env, mention.actorId); + if (cache) inboxes.add(cache.shared_inbox ?? cache.inbox); + } + } + const to = visibility === "public" ? ["https://www.w3.org/ns/activitystreams#Public"] : [`${actorUrl(env, user)}/followers`]; + const cc = visibility === "public" ? [`${actorUrl(env, user)}/followers`, ...resolvedMentions.map((m) => m.actorId)] : ["https://www.w3.org/ns/activitystreams#Public", ...resolvedMentions.map((m) => m.actorId)]; + const activity = createActivity(env, user, status, { to, cc }); + await deliverToInboxes(env, user, inboxes, activity); + } else if (visibility === "direct") { + const inboxes = new Set(); + for (const mention of resolvedMentions) { + if (!mention.actorId.startsWith(baseUrl(env))) { + const cache = await resolveRemoteActor(env, mention.actorId); + if (cache) inboxes.add(cache.shared_inbox ?? cache.inbox); + } + } + const activity = createActivity(env, user, status, { to: resolvedMentions.map((m) => m.actorId), cc: [] }); + await deliverToInboxes(env, user, inboxes, activity); + } + + return json(await statusJson(env, status, user, request)); +} + +export async function getStatusEndpoint(request: Request, env: Env, statusId: string): Promise { + const status = await getStatus(env, statusId); + if (!status) return json({ error: "Record not found" }, 404); + const user = await getUserById(env, status.user_id); + if (!user) return json({ error: "Record not found" }, 404); + return json(await statusJson(env, status, user, request)); +} + +export async function deleteStatusEndpoint(request: Request, env: Env, statusId: string): Promise { + const user = await requireUser(request, env); + const status = await getStatus(env, statusId); + if (!status || status.user_id !== user.id) return json({ error: "Record not found" }, 404); + + const serialized = await statusJson(env, status, user, request); + + const inboxes = new Set(await gatherFollowerInboxes(env, user.id)); + const mentions = await listMentionsForStatus(env, status.id); + for (const mention of mentions) { + if (!mention.actor.startsWith(baseUrl(env))) { + const cache = await resolveRemoteActor(env, mention.actor); + if (cache) inboxes.add(cache.shared_inbox ?? cache.inbox); + } + } + const media = await env.DB.prepare("SELECT r2_key FROM media WHERE status_id = ?").bind(status.id).all<{ r2_key: string }>(); + const deleteResults = await Promise.allSettled(media.results.map((item) => env.MEDIA.delete(item.r2_key))); + for (const result of deleteResults) { + if (result.status === "rejected") console.warn("media-delete-failed", status.id, String(result.reason)); + } + + await env.DB.prepare( + "INSERT INTO deleted_statuses (id, user_id, object_id, url, deleted_at) VALUES (?, ?, ?, ?, ?)" + ).bind(status.id, user.id, status.object_id, status.url, new Date().toISOString()).run(); + await env.DB.prepare("DELETE FROM statuses WHERE id = ?").bind(status.id).run(); + await env.DB.prepare("DELETE FROM media WHERE status_id = ?").bind(status.id).run(); + await env.DB.prepare("DELETE FROM mentions WHERE status_id = ?").bind(status.id).run(); + await env.DB.prepare("DELETE FROM hashtags WHERE status_id = ?").bind(status.id).run(); + await env.DB.prepare("DELETE FROM favourites WHERE status_id = ?").bind(status.id).run(); + await env.DB.prepare("DELETE FROM reblogs WHERE status_id = ?").bind(status.id).run(); + await env.DB.prepare("DELETE FROM notifications WHERE status_id = ?").bind(status.id).run(); + + await deliverToInboxes(env, user, inboxes, deleteActivity(env, user, status)); + return json(serialized); +} + +export async function statusContext(env: Env, statusId: string, request: Request): Promise { + const status = await getStatus(env, statusId); + if (!status) return json({ error: "Record not found" }, 404); + const ancestors: Status[] = []; + let cursor = status.in_reply_to_id; + while (cursor) { + const parent = await getStatus(env, cursor); + if (!parent) break; + ancestors.unshift(parent); + cursor = parent.in_reply_to_id; + } + const descRows = await env.DB.prepare("SELECT * FROM statuses WHERE in_reply_to_id = ? ORDER BY created_at ASC LIMIT 40").bind(statusId).all(); + const serialized = await serializeStatuses(env, [...ancestors, ...descRows.results], request); + const byId = new Map(serialized.map((item) => [String(item.id), item])); + return json({ + ancestors: ancestors.map((item) => byId.get(item.id)).filter(Boolean), + descendants: descRows.results.map((item) => byId.get(item.id)).filter(Boolean) + }); +} + +export async function favouriteStatus(request: Request, env: Env, statusId: string): Promise { + const user = await requireUser(request, env); + const status = await getStatus(env, statusId); + if (!status) return json({ error: "Record not found" }, 404); + + const actor = actorUrl(env, user); + const existing = await findFavourite(env, status.id, actor); + if (!existing) { + const activityId = activityUrl(env, id()); + await env.DB.prepare( + "INSERT INTO favourites (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)" + ).bind(id(), status.id, actor, activityId, new Date().toISOString()).run(); + + const owner = await getUserById(env, status.user_id); + if (owner && owner.id !== user.id) { + await recordNotification(env, owner.id, "favourite", actor, status.id); + } + if (!isLocalActor(env, status.object_id)) { + const inboxes = await resolveDeliveryInboxes(env, [status.object_id]); + await deliverToInboxes(env, user, inboxes, likeActivity(env, user, status.object_id, activityId)); + } + } + const owner = await getUserById(env, status.user_id); + if (!owner) throw new HttpError(500, "owner_missing"); + return json(await statusJson(env, status, owner, request)); +} + +export async function unfavouriteStatus(request: Request, env: Env, statusId: string): Promise { + const user = await requireUser(request, env); + const status = await getStatus(env, statusId); + if (!status) return json({ error: "Record not found" }, 404); + + const actor = actorUrl(env, user); + const existing = await findFavourite(env, status.id, actor); + if (existing) { + await env.DB.prepare("DELETE FROM favourites WHERE id = ?").bind(existing.id).run(); + if (!isLocalActor(env, status.object_id)) { + const inboxes = await resolveDeliveryInboxes(env, [status.object_id]); + await deliverToInboxes(env, user, inboxes, undoActivity(env, user, likeActivity(env, user, status.object_id, existing.activity_id))); + } + } + const owner = await getUserById(env, status.user_id); + if (!owner) throw new HttpError(500, "owner_missing"); + return json(await statusJson(env, status, owner, request)); +} + +export async function reblogStatus(request: Request, env: Env, statusId: string): Promise { + const user = await requireUser(request, env); + const status = await getStatus(env, statusId); + if (!status) return json({ error: "Record not found" }, 404); + + const actor = actorUrl(env, user); + const existing = await findReblog(env, status.id, actor); + if (!existing) { + const activityId = activityUrl(env, id()); + await env.DB.prepare( + "INSERT INTO reblogs (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)" + ).bind(id(), status.id, actor, activityId, new Date().toISOString()).run(); + const owner = await getUserById(env, status.user_id); + if (owner && owner.id !== user.id) { + await recordNotification(env, owner.id, "reblog", actor, status.id); + } + const inboxes = new Set(await gatherFollowerInboxes(env, user.id)); + if (!isLocalActor(env, status.object_id)) { + for (const inbox of await resolveDeliveryInboxes(env, [status.object_id])) inboxes.add(inbox); + } + await deliverToInboxes(env, user, inboxes, announceActivity(env, user, status.object_id, activityId)); + } + const owner = await getUserById(env, status.user_id); + if (!owner) throw new HttpError(500, "owner_missing"); + return json(await statusJson(env, status, owner, request)); +} + +export async function unreblogStatus(request: Request, env: Env, statusId: string): Promise { + const user = await requireUser(request, env); + const status = await getStatus(env, statusId); + if (!status) return json({ error: "Record not found" }, 404); + + const actor = actorUrl(env, user); + const existing = await findReblog(env, status.id, actor); + if (existing) { + await env.DB.prepare("DELETE FROM reblogs WHERE id = ?").bind(existing.id).run(); + const inboxes = new Set(await gatherFollowerInboxes(env, user.id)); + if (!isLocalActor(env, status.object_id)) { + for (const inbox of await resolveDeliveryInboxes(env, [status.object_id])) inboxes.add(inbox); + } + await deliverToInboxes(env, user, inboxes, undoActivity(env, user, announceActivity(env, user, status.object_id, existing.activity_id))); + } + const owner = await getUserById(env, status.user_id); + if (!owner) throw new HttpError(500, "owner_missing"); + return json(await statusJson(env, status, owner, request)); +} + +export async function bookmarkStatus(request: Request, env: Env, statusId: string): Promise { + await requireUser(request, env); + const status = await getStatus(env, statusId); + if (!status) return json({ error: "Record not found" }, 404); + const owner = await getUserById(env, status.user_id); + return json(await statusJson(env, status, owner!, request)); +} + +export async function publicTimeline(request: Request, env: Env): Promise { + const url = new URL(request.url); + const limit = clampLimit(url.searchParams.get("limit"), 20, 40); + const where: string[] = ["visibility = 'public'"]; + const binds: unknown[] = []; + pagedAppend(where, binds, url); + const sql = `SELECT * FROM statuses WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`; + binds.push(limit); + const rows = await env.DB.prepare(sql).bind(...binds).all(); + const items = await serializeStatuses(env, rows.results, request); + return withPagination(json(items), request, rows.results.map((s) => s.id)); +} + +export async function homeTimeline(request: Request, env: Env): Promise { + await requireUser(request, env); + return publicTimeline(request, env); +} + +export async function uploadMedia(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + const form = await request.formData(); + const file = form.get("file"); + if (!(file instanceof File)) return json({ error: "file is required" }, 422); + if (file.size > MAX_MEDIA_BYTES) return json({ error: "file too large" }, 413); + + const mediaId = id(); + const key = `${user.id}/${mediaId}/${safeFileName(file.name || "upload")}`; + await env.MEDIA.put(key, file.stream(), { httpMetadata: { contentType: file.type || "application/octet-stream" } }); + await env.DB.prepare( + "INSERT INTO media (id, user_id, status_id, r2_key, mime_type, description, size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind(mediaId, user.id, null, key, file.type || "application/octet-stream", form.get("description")?.toString() ?? null, file.size, new Date().toISOString()) + .run(); + + const media = await env.DB.prepare("SELECT * FROM media WHERE id = ?").bind(mediaId).first(); + return json(mediaJson(env, media!), 200); +} + +export async function updateMedia(request: Request, env: Env, mediaId: string): Promise { + const user = await requireUser(request, env); + const body = await readBody(request); + const description = bodyString(body, "description", ""); + await env.DB.prepare("UPDATE media SET description = ? WHERE id = ? AND user_id = ?").bind(description, mediaId, user.id).run(); + const media = await env.DB.prepare("SELECT * FROM media WHERE id = ?").bind(mediaId).first(); + if (!media) return json({ error: "Record not found" }, 404); + return json(mediaJson(env, media)); +} + +export async function serveMedia(env: Env, key: string): Promise { + const object = await env.MEDIA.get(decodeURIComponent(key)); + if (!object) return new Response("Not found", { status: 404 }); + return cors(new Response(object.body, { + headers: { + "content-type": object.httpMetadata?.contentType ?? "application/octet-stream", + "cache-control": "public, max-age=86400" + } + })); +} + +export async function notificationsList(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + const url = new URL(request.url); + const limit = clampLimit(url.searchParams.get("limit"), 15, 80); + const types = normalizeArray(url.searchParams.getAll("types[]")); + const excludeTypes = normalizeArray(url.searchParams.getAll("exclude_types[]")); + + const where: string[] = ["user_id = ?"]; + const binds: unknown[] = [user.id]; + if (types.length > 0) { + where.push(`type IN (${types.map(() => "?").join(",")})`); + binds.push(...types); + } + if (excludeTypes.length > 0) { + where.push(`type NOT IN (${excludeTypes.map(() => "?").join(",")})`); + binds.push(...excludeTypes); + } + + const maxId = url.searchParams.get("max_id"); + if (maxId) { + where.push("created_at < (SELECT created_at FROM notifications WHERE id = ?)"); + binds.push(maxId); + } + const sinceId = url.searchParams.get("since_id"); + if (sinceId) { + where.push("created_at > (SELECT created_at FROM notifications WHERE id = ?)"); + binds.push(sinceId); + } + + const sql = `SELECT * FROM notifications WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`; + binds.push(limit); + + const rows = await env.DB.prepare(sql).bind(...binds).all(); + const out = await serializeNotifications(env, rows.results, request); + return withPagination(json(out), request, rows.results.map((n) => n.id)); +} + +export async function notificationClear(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + await env.DB.prepare("DELETE FROM notifications WHERE user_id = ?").bind(user.id).run(); + return json({}); +} + +export async function notificationDismiss(request: Request, env: Env, notificationId: string): Promise { + const user = await requireUser(request, env); + await env.DB.prepare("DELETE FROM notifications WHERE id = ? AND user_id = ?").bind(notificationId, user.id).run(); + return json({}); +} + +export async function getRelationships(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + const url = new URL(request.url); + const ids = url.searchParams.getAll("id[]").concat(url.searchParams.getAll("id")); + const out = []; + for (const target of ids) { + out.push(await relationshipFor(env, user, target)); + } + return json(out); +} + +export async function followAccount(request: Request, env: Env, accountId: string): Promise { + const user = await requireUser(request, env); + const target = await resolveAccountTarget(env, accountId); + if (!target) return json({ error: "Record not found" }, 404); + + if (target.kind === "local") { + await env.DB.prepare( + "INSERT OR REPLACE INTO outgoing_follows (id, local_user_id, target_actor, target_inbox, activity_id, accepted, created_at) VALUES (?, ?, ?, ?, ?, 1, ?)" + ).bind(id(), user.id, target.actorId, `${target.actorId}/inbox`, "", new Date().toISOString()).run(); + await env.DB.prepare( + "INSERT OR REPLACE INTO follows (id, follower_actor, local_user_id, inbox, accepted, created_at) VALUES (?, ?, ?, ?, 1, ?)" + ).bind(id(), actorUrl(env, user), target.userId, `${actorUrl(env, user)}/inbox`, new Date().toISOString()).run(); + } else { + const activityId = activityUrl(env, id()); + const cache = await resolveRemoteActor(env, target.actorId); + if (!cache) return json({ error: "remote_actor_unreachable" }, 502); + await env.DB.prepare( + "INSERT OR REPLACE INTO outgoing_follows (id, local_user_id, target_actor, target_inbox, activity_id, accepted, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)" + ).bind(id(), user.id, cache.id, cache.inbox, activityId, new Date().toISOString()).run(); + await deliverToInboxes(env, user, [cache.inbox], followActivity(env, user, cache.id, activityId)); + } + return json(await relationshipFor(env, user, accountId)); +} + +export async function unfollowAccount(request: Request, env: Env, accountId: string): Promise { + const user = await requireUser(request, env); + const target = await resolveAccountTarget(env, accountId); + if (!target) return json({ error: "Record not found" }, 404); + + const existing = await findOutgoingFollow(env, user.id, target.actorId); + await env.DB.prepare("DELETE FROM outgoing_follows WHERE local_user_id = ? AND target_actor = ?").bind(user.id, target.actorId).run(); + + if (target.kind === "local") { + await env.DB.prepare("DELETE FROM follows WHERE follower_actor = ? AND local_user_id = ?").bind(actorUrl(env, user), target.userId).run(); + } else if (existing) { + const cache = await resolveRemoteActor(env, target.actorId); + if (cache) { + await deliverToInboxes(env, user, [cache.inbox], undoActivity(env, user, followActivity(env, user, target.actorId, existing.activity_id))); + } + } + return json(await relationshipFor(env, user, accountId)); +} + +export async function followRequestsList(request: Request, env: Env): Promise { + await requireUser(request, env); + return json([]); +} + +export async function authorizeFollowRequest(request: Request, env: Env, _accountId: string): Promise { + await requireUser(request, env); + return json({ id: _accountId, following: true, requested: false }); +} + +export async function rejectFollowRequest(request: Request, env: Env, _accountId: string): Promise { + await requireUser(request, env); + return json({ id: _accountId, following: false, requested: false }); +} + +export async function search(request: Request, env: Env): Promise { + const url = new URL(request.url); + const q = (url.searchParams.get("q") ?? "").trim(); + const type = url.searchParams.get("type"); + const accounts: unknown[] = []; + const statuses: unknown[] = []; + const hashtags: unknown[] = []; + if (!q) return json({ accounts, statuses, hashtags }); + + if (!type || type === "accounts") { + if (q.startsWith("@") || q.includes("@")) { + const acct = q.replace(/^@/, ""); + const resolved = await resolveAcct(env, acct); + if (resolved) { + if (resolved.actorId.startsWith(baseUrl(env))) { + const local = await getUserByUsername(env, resolved.acct.split("@")[0]); + if (local) accounts.push(await accountJson(env, local)); + } else { + const cache = await resolveRemoteActor(env, resolved.actorId); + if (cache) accounts.push(remoteAccountJson(cache)); + } + } + } else { + const rows = await env.DB.prepare("SELECT * FROM users WHERE username LIKE ? LIMIT 20").bind(`%${q}%`).all(); + for (const row of rows.results) accounts.push(await accountJson(env, row)); + } + } + + if (!type || type === "statuses") { + const rows = await env.DB.prepare("SELECT * FROM statuses WHERE content LIKE ? ORDER BY created_at DESC LIMIT 20").bind(`%${escapeHtml(q)}%`).all(); + statuses.push(...await serializeStatuses(env, rows.results, request)); + } + + if (!type || type === "hashtags") { + const tag = q.replace(/^#/, ""); + const rows = await env.DB.prepare("SELECT tag, COUNT(*) AS count FROM hashtags WHERE tag LIKE ? GROUP BY tag LIMIT 20").bind(`%${tag}%`).all<{ tag: string; count: number }>(); + for (const row of rows.results) hashtags.push({ name: row.tag, url: `${baseUrl(env)}/tags/${encodeURIComponent(row.tag)}`, history: [] }); + } + + return json({ accounts, statuses, hashtags }); +} + +export async function customEmojis(env: Env): Promise { + void env; + return json([]); +} + +export async function filtersV1(_request: Request, env: Env): Promise { + void env; + return json([]); +} + +export async function trendsTags(env: Env): Promise { + void env; + return json([]); +} + +export async function pushSubscription(): Promise { + return json({ error: "push subscriptions not supported" }, 422); +} + +export async function markersList(request: Request, env: Env): Promise { + void request; void env; + return json({}); +} + +type StatusSerializationContext = { + usersById: Map; + accountByUserId: Map>; + mediaByStatusId: Map; + mentionsByStatusId: Map; + hashtagsByStatusId: Map; + favouriteCountByStatusId: Map; + favouritedStatusIds: Set; + reblogCountByStatusId: Map; + rebloggedStatusIds: Set; + replyCountByStatusId: Map; +}; + +async function statusJson( + env: Env, + status: Status, + user: User, + request: Request, + context?: StatusSerializationContext +): Promise> { + const resolvedContext = context ?? await buildStatusSerializationContext(env, [status], request, new Map([[user.id, user]])); + return statusRecord(env, status, user, resolvedContext); +} + +function statusRecord(env: Env, status: Status, user: User, context: StatusSerializationContext): Record { + const media = context.mediaByStatusId.get(status.id) ?? []; + const mentions = context.mentionsByStatusId.get(status.id) ?? []; + const tags = context.hashtagsByStatusId.get(status.id) ?? []; + return { + id: status.id, + uri: status.object_id, + url: status.url, + account: context.accountByUserId.get(user.id) ?? { + id: user.id, + username: user.username, + acct: user.username, + display_name: user.display_name + }, + in_reply_to_id: status.in_reply_to_id, + in_reply_to_account_id: null, + content: status.content, + text: status.content, + created_at: status.created_at, + edited_at: null, + visibility: status.visibility, + language: status.language, + sensitive: Boolean(status.sensitive), + spoiler_text: status.summary, + media_attachments: media.map((item) => mediaJson(env, item)), + mentions: mentions.map((mention) => ({ + id: mention.actor, + username: mention.acct.split("@")[0], + acct: mention.acct, + url: mention.url + })), + tags: tags.map((tag) => ({ name: tag, url: `${baseUrl(env)}/tags/${encodeURIComponent(tag)}` })), + emojis: [], + reblogs_count: context.reblogCountByStatusId.get(status.id) ?? 0, + favourites_count: context.favouriteCountByStatusId.get(status.id) ?? 0, + replies_count: context.replyCountByStatusId.get(status.id) ?? 0, + reblog: null, + application: { name: "Toot Worker", website: null }, + favourited: context.favouritedStatusIds.has(status.id), + reblogged: context.rebloggedStatusIds.has(status.id), + muted: false, + bookmarked: false, + pinned: false, + card: null, + poll: null + }; +} + +async function serializeStatuses( + env: Env, + statuses: Status[], + request: Request, + usersById?: Map +): Promise[]> { + if (statuses.length === 0) return []; + const context = await buildStatusSerializationContext(env, statuses, request, usersById); + return statuses.flatMap((status) => { + const user = context.usersById.get(status.user_id); + return user ? [statusRecord(env, status, user, context)] : []; + }); +} + +async function buildStatusSerializationContext( + env: Env, + statuses: Status[], + request: Request, + initialUsersById: Map = new Map() +): Promise { + const statusIds = uniqueStrings(statuses.map((status) => status.id)); + const usersById = new Map(initialUsersById); + const missingUserIds = uniqueStrings(statuses.map((status) => status.user_id).filter((userId) => !usersById.has(userId))); + if (missingUserIds.length > 0) { + for (const user of await loadUsersByIds(env, missingUserIds)) usersById.set(user.id, user); + } + + const viewer = await viewerActor(request, env); + const [mediaByStatusId, mentionsByStatusId, hashtagsByStatusId, favouriteSummary, reblogSummary, replyCountByStatusId] = await Promise.all([ + loadMediaByStatusIds(env, statusIds), + loadMentionsByStatusIds(env, statusIds), + loadHashtagsByStatusIds(env, statusIds), + loadStatusInteractionSummary(env, "favourites", statusIds, viewer), + loadStatusInteractionSummary(env, "reblogs", statusIds, viewer), + loadReplyCountByStatusIds(env, statusIds) + ]); + + const accountByUserId = new Map>(); + for (const user of usersById.values()) { + accountByUserId.set(user.id, await accountJson(env, user)); + } + + return { + usersById, + accountByUserId, + mediaByStatusId, + mentionsByStatusId, + hashtagsByStatusId, + favouriteCountByStatusId: favouriteSummary.countByStatusId, + favouritedStatusIds: favouriteSummary.viewerMatchedStatusIds, + reblogCountByStatusId: reblogSummary.countByStatusId, + rebloggedStatusIds: reblogSummary.viewerMatchedStatusIds, + replyCountByStatusId + }; +} + +async function accountJson(env: Env, user: User): Promise> { + const [followersCount, followingCount, statusesCount] = await Promise.all([ + countFollowers(env, user.id), + countFollowing(env, user.id), + countStatuses(env, user.id) + ]); + const acct = `${user.username}`; + return { + id: user.id, + username: user.username, + acct, + display_name: user.display_name, + locked: false, + bot: false, + discoverable: true, + group: false, + created_at: user.created_at, + note: user.note, + url: actorUrl(env, user), + avatar: `${baseUrl(env)}/avatar.png`, + avatar_static: `${baseUrl(env)}/avatar.png`, + header: `${baseUrl(env)}/header.png`, + header_static: `${baseUrl(env)}/header.png`, + followers_count: followersCount, + following_count: followingCount, + statuses_count: statusesCount, + last_status_at: null, + emojis: [], + fields: [] + }; +} + +function remoteAccountJson(cache: { id: string; preferred_username: string | null; name: string | null; summary: string | null; icon_url: string | null; fetched_at: string }): Record { + const host = (() => { try { return new URL(cache.id).host; } catch { return "remote"; } })(); + const username = cache.preferred_username ?? cache.id.split("/").pop() ?? "user"; + return { + id: cache.id, + username, + acct: `${username}@${host}`, + display_name: cache.name ?? username, + locked: false, + bot: false, + discoverable: true, + group: false, + created_at: cache.fetched_at, + note: cache.summary ?? "", + url: cache.id, + avatar: cache.icon_url ?? "", + avatar_static: cache.icon_url ?? "", + header: "", + header_static: "", + followers_count: 0, + following_count: 0, + statuses_count: 0, + last_status_at: null, + emojis: [], + fields: [] + }; +} + +function mediaJson(env: Env, media: Media): Record { + const url = `${baseUrl(env)}/media/${encodeURIComponent(media.r2_key)}`; + return { + id: media.id, + type: media.mime_type.startsWith("image/") ? "image" : media.mime_type.startsWith("video/") ? "video" : "unknown", + url, + preview_url: url, + remote_url: null, + text_url: null, + meta: {}, + description: media.description, + blurhash: null + }; +} + +async function relationshipFor(env: Env, user: User, target: string): Promise> { + const resolved = await resolveAccountTarget(env, target); + const actorId = resolved?.actorId ?? target; + const outgoing = await findOutgoingFollow(env, user.id, actorId); + const incoming = await env.DB.prepare("SELECT * FROM follows WHERE follower_actor = ? AND local_user_id = ?").bind(actorId, user.id).first(); + return { + id: target, + following: Boolean(outgoing && outgoing.accepted), + showing_reblogs: true, + notifying: false, + languages: null, + followed_by: Boolean(incoming), + blocking: false, + blocked_by: false, + muting: false, + muting_notifications: false, + requested: Boolean(outgoing && !outgoing.accepted), + domain_blocking: false, + endorsed: false, + note: "" + }; +} + +type AccountTarget = { kind: "local"; userId: string; actorId: string } | { kind: "remote"; actorId: string }; + +async function resolveAccountTarget(env: Env, key: string): Promise { + const local = await getUserByIdOrUsername(env, key); + if (local) return { kind: "local", userId: local.id, actorId: actorUrl(env, local) }; + if (key.startsWith("http://") || key.startsWith("https://")) return { kind: "remote", actorId: key }; + if (key.includes("@")) { + const resolved = await resolveAcct(env, key); + if (!resolved) return null; + if (resolved.actorId.startsWith(baseUrl(env))) { + const match = resolved.actorId.match(/\/users\/([^/?#]+)$/); + const localUser = match ? await getUserByUsername(env, match[1]) : null; + if (localUser) return { kind: "local", userId: localUser.id, actorId: resolved.actorId }; + } + return { kind: "remote", actorId: resolved.actorId }; + } + return null; +} + +async function resolveAcct(env: Env, acct: string): Promise<{ acct: string; actorId: string; url: string } | null> { + const trimmed = acct.replace(/^@/, ""); + const [name, host] = trimmed.split("@"); + if (!name) return null; + const targetHost = host ?? hostFromBaseUrl(env); + if (targetHost.toLowerCase() === hostFromBaseUrl(env).toLowerCase()) { + const user = await getUserByUsername(env, name); + if (!user) return null; + return { acct: name, actorId: actorUrl(env, user), url: actorUrl(env, user) }; + } + try { + const wf = await fetch(`https://${targetHost}/.well-known/webfinger?resource=acct:${name}@${targetHost}`, { + headers: { accept: "application/jrd+json, application/json" } + }); + if (!wf.ok) return null; + const doc = await wf.json() as { links?: { rel: string; type?: string; href: string }[] }; + const self = doc.links?.find((link) => link.rel === "self" && (link.type ?? "").includes("activity+json")); + if (!self?.href) return null; + return { acct: `${name}@${targetHost}`, actorId: self.href, url: self.href }; + } catch { + return null; + } +} + +async function listMentionsForStatus(env: Env, statusId: string): Promise { + const rows = await env.DB.prepare("SELECT * FROM mentions WHERE status_id = ?").bind(statusId).all(); + return rows.results; +} + +async function listHashtagsForStatus(env: Env, statusId: string): Promise { + const rows = await env.DB.prepare("SELECT tag FROM hashtags WHERE status_id = ?").bind(statusId).all<{ tag: string }>(); + return rows.results.map((row) => row.tag); +} + +function extractMentions(text: string): string[] { + const re = /@([A-Za-z0-9._-]+(?:@[A-Za-z0-9.-]+\.[A-Za-z]{2,})?)/g; + const out = new Set(); + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) out.add(match[1]); + return [...out]; +} + +function extractHashtags(text: string): string[] { + const re = /(?:^|\s)#([\p{L}\p{N}_]{1,64})/gu; + const out = new Set(); + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) out.add(match[1].toLowerCase()); + return [...out]; +} + +function uniqueStrings(values: Array): string[] { + return [...new Set(values.filter((value): value is string => Boolean(value)))]; +} + +function placeholders(count: number): string { + return Array.from({ length: count }, () => "?").join(","); +} + +async function loadUsersByIds(env: Env, userIds: string[]): Promise { + if (userIds.length === 0) return []; + const rows = await env.DB.prepare(`SELECT * FROM users WHERE id IN (${placeholders(userIds.length)})`).bind(...userIds).all(); + return rows.results; +} + +async function loadStatusesByIds(env: Env, statusIds: string[]): Promise { + if (statusIds.length === 0) return []; + const rows = await env.DB.prepare(`SELECT * FROM statuses WHERE id IN (${placeholders(statusIds.length)})`).bind(...statusIds).all(); + return rows.results; +} + +async function loadMediaByStatusIds(env: Env, statusIds: string[]): Promise> { + const grouped = new Map(); + if (statusIds.length === 0) return grouped; + const rows = await env.DB.prepare( + `SELECT * FROM media WHERE status_id IN (${placeholders(statusIds.length)}) ORDER BY created_at ASC` + ).bind(...statusIds).all(); + for (const row of rows.results) { + if (!row.status_id) continue; + const bucket = grouped.get(row.status_id); + if (bucket) bucket.push(row); + else grouped.set(row.status_id, [row]); + } + return grouped; +} + +async function loadMentionsByStatusIds(env: Env, statusIds: string[]): Promise> { + const grouped = new Map(); + if (statusIds.length === 0) return grouped; + const rows = await env.DB.prepare( + `SELECT * FROM mentions WHERE status_id IN (${placeholders(statusIds.length)})` + ).bind(...statusIds).all(); + for (const row of rows.results) { + const bucket = grouped.get(row.status_id); + if (bucket) bucket.push(row); + else grouped.set(row.status_id, [row]); + } + return grouped; +} + +async function loadHashtagsByStatusIds(env: Env, statusIds: string[]): Promise> { + const grouped = new Map(); + if (statusIds.length === 0) return grouped; + const rows = await env.DB.prepare( + `SELECT status_id, tag FROM hashtags WHERE status_id IN (${placeholders(statusIds.length)})` + ).bind(...statusIds).all<{ status_id: string; tag: string }>(); + for (const row of rows.results) { + const bucket = grouped.get(row.status_id); + if (bucket) bucket.push(row.tag); + else grouped.set(row.status_id, [row.tag]); + } + return grouped; +} + +async function loadStatusInteractionSummary( + env: Env, + table: "favourites" | "reblogs", + statusIds: string[], + viewer: string | null +): Promise<{ countByStatusId: Map; viewerMatchedStatusIds: Set }> { + const countByStatusId = new Map(); + const viewerMatchedStatusIds = new Set(); + if (statusIds.length === 0) return { countByStatusId, viewerMatchedStatusIds }; + + const viewerSql = viewer ? ", MAX(CASE WHEN actor = ? THEN 1 ELSE 0 END) AS viewer_match" : ""; + const sql = `SELECT status_id, COUNT(*) AS count${viewerSql} FROM ${table} WHERE status_id IN (${placeholders(statusIds.length)}) GROUP BY status_id`; + const binds = viewer ? [viewer, ...statusIds] : statusIds; + const rows = await env.DB.prepare(sql).bind(...binds).all<{ status_id: string; count: number; viewer_match?: number }>(); + for (const row of rows.results) { + countByStatusId.set(row.status_id, row.count); + if (row.viewer_match) viewerMatchedStatusIds.add(row.status_id); + } + return { countByStatusId, viewerMatchedStatusIds }; +} + +async function loadReplyCountByStatusIds(env: Env, statusIds: string[]): Promise> { + const counts = new Map(); + if (statusIds.length === 0) return counts; + const rows = await env.DB.prepare( + `SELECT in_reply_to_id AS status_id, COUNT(*) AS count FROM statuses WHERE in_reply_to_id IN (${placeholders(statusIds.length)}) GROUP BY in_reply_to_id` + ).bind(...statusIds).all<{ status_id: string; count: number }>(); + for (const row of rows.results) counts.set(row.status_id, row.count); + return counts; +} + +async function serializeNotifications(env: Env, notifications: Notification[], request: Request): Promise[]> { + if (notifications.length === 0) return []; + + const statuses = await loadStatusesByIds(env, uniqueStrings(notifications.map((notification) => notification.status_id))); + const serializedStatuses = await serializeStatuses(env, statuses, request); + const serializedStatusById = new Map(serializedStatuses.map((item) => [String(item.id), item])); + + const remoteActorIds = uniqueStrings( + notifications.map((notification) => notification.actor).filter((actorId) => !actorId.startsWith(baseUrl(env))) + ); + const remoteAccounts = new Map>(); + const remoteResults = await Promise.all(remoteActorIds.map(async (actorId) => [actorId, await resolveRemoteActor(env, actorId)] as const)); + for (const [actorId, actorCache] of remoteResults) { + remoteAccounts.set(actorId, actorCache ? remoteAccountJson(actorCache) : { id: actorId, acct: actorId, username: actorId }); + } + + const localAccounts = new Map>(); + const out: Record[] = []; + for (const notification of notifications) { + let account = localAccounts.get(notification.actor) ?? remoteAccounts.get(notification.actor); + if (!account) { + const match = notification.actor.match(/\/users\/([^/?#]+)$/); + const localUser = match ? await getUserByUsername(env, match[1]) : null; + account = localUser ? await accountJson(env, localUser) : { id: notification.actor, acct: notification.actor }; + localAccounts.set(notification.actor, account); + } + out.push({ + id: notification.id, + type: notification.type, + created_at: notification.created_at, + account, + status: notification.status_id ? serializedStatusById.get(notification.status_id) ?? null : null + }); + } + return out; +} + +function pagedAppend(where: string[], binds: unknown[], url: URL): void { + const maxId = url.searchParams.get("max_id"); + if (maxId) { + where.push("created_at < (SELECT created_at FROM statuses WHERE id = ?)"); + binds.push(maxId); + } + const sinceId = url.searchParams.get("since_id"); + if (sinceId) { + where.push("created_at > (SELECT created_at FROM statuses WHERE id = ?)"); + binds.push(sinceId); + } + const minId = url.searchParams.get("min_id"); + if (minId) { + where.push("created_at > (SELECT created_at FROM statuses WHERE id = ?)"); + binds.push(minId); + } +} + +function withPagination(response: Response, request: Request, ids: string[]): Response { + if (ids.length === 0) return response; + const url = new URL(request.url); + const nextUrl = new URL(url); + nextUrl.searchParams.set("max_id", ids[ids.length - 1]); + const prevUrl = new URL(url); + prevUrl.searchParams.set("since_id", ids[0]); + const link = `<${nextUrl}>; rel="next", <${prevUrl}>; rel="prev"`; + const headers = new Headers(response.headers); + headers.set("link", link); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +async function viewerActor(request: Request, env: Env): Promise { + const auth = request.headers.get("authorization") ?? ""; + const token = auth.match(/^Bearer\s+(.+)$/i)?.[1]; + if (!token) return null; + const session = await env.KV.get(`token:${token}`, "json"); + if (!session) return null; + const user = await getUserById(env, session.userId); + return user ? actorUrl(env, user) : null; +} + +async function requireUser(request: Request, env: Env): Promise { + const auth = request.headers.get("authorization") ?? ""; + const token = auth.match(/^Bearer\s+(.+)$/i)?.[1]; + if (!token) throw new HttpError(401, "The access token is invalid"); + const session = await env.KV.get(`token:${token}`, "json"); + if (!session) throw new HttpError(401, "The access token is invalid"); + const user = await getUserById(env, session.userId); + if (!user) throw new HttpError(401, "The access token is invalid"); + return user; +} + +export { requireUser }; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..b86053a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,167 @@ +export type Json = Record; + +export type User = { + id: string; + username: string; + display_name: string; + note: string; + password_hash: string; + private_key_jwk: string; + public_key_jwk: string; + created_at: string; +}; + +export type OAuthApp = { + id: string; + client_id: string; + client_secret: string; + name: string; + redirect_uri: string; + scopes: string; + website: string | null; + created_at: string; +}; + +export type OAuthCode = { + code: string; + app_id: string; + user_id: string; + redirect_uri: string; + scopes: string; + expires_at: number; +}; + +export type Status = { + id: string; + user_id: string; + content: string; + summary: string; + sensitive: number; + language: string; + visibility: string; + in_reply_to_id: string | null; + activity_id: string; + object_id: string; + created_at: string; + url: string; +}; + +export type DeletedStatus = { + id: string; + user_id: string; + object_id: string; + url: string; + deleted_at: string; +}; + +export type Media = { + id: string; + user_id: string; + status_id: string | null; + r2_key: string; + mime_type: string; + description: string | null; + size: number; + created_at: string; +}; + +export type Follow = { + id: string; + follower_actor: string; + local_user_id: string; + inbox: string; + accepted: number; + created_at: string; +}; + +export type OutgoingFollow = { + id: string; + local_user_id: string; + target_actor: string; + target_inbox: string; + activity_id: string; + accepted: number; + created_at: string; +}; + +export type Notification = { + id: string; + user_id: string; + type: string; + actor: string; + status_id: string | null; + read: number; + created_at: string; +}; + +export type Favourite = { + id: string; + status_id: string; + actor: string; + activity_id: string; + created_at: string; +}; + +export type Reblog = { + id: string; + status_id: string; + actor: string; + activity_id: string; + created_at: string; +}; + +export type Mention = { + status_id: string; + actor: string; + acct: string; + url: string; +}; + +export type Hashtag = { + status_id: string; + tag: string; +}; + +export type ActorCache = { + id: string; + inbox: string; + shared_inbox: string | null; + preferred_username: string | null; + name: string | null; + summary: string | null; + icon_url: string | null; + public_key_id: string | null; + public_key_pem: string | null; + fetched_at: string; +}; + +export type RemoteActor = { + id: string; + type?: string; + inbox?: string; + endpoints?: { sharedInbox?: string }; + preferredUsername?: string; + name?: string; + summary?: string; + icon?: { url?: string } | string; + publicKey?: { + id?: string; + owner?: string; + publicKeyPem?: string; + }; +}; + +export type Session = { + userId: string; + appId: string; + scopes: string; +}; + +export const ACTIVITY_CONTEXT = "https://www.w3.org/ns/activitystreams"; +export const SECURITY_CONTEXT = "https://w3id.org/security/v1"; +export const PUBLIC_COLLECTION = "https://www.w3.org/ns/activitystreams#Public"; +export const ACTOR_CACHE_TTL_MS = 1000 * 60 * 60 * 24; +export const SIGNATURE_MAX_SKEW_MS = 1000 * 60 * 60 * 12; + +export const AVATAR_SVG = ``; +export const HEADER_SVG = ``; diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..8663b64 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,123 @@ +import type { User } from "./types"; + +export const encoder = new TextEncoder(); +export const decoder = new TextDecoder(); + +export function id(): string { + return crypto.randomUUID(); +} + +export function tokenString(bytes: number): string { + return base64Url(crypto.getRandomValues(new Uint8Array(bytes))); +} + +export function concatBytes(...parts: Uint8Array[]): Uint8Array { + let total = 0; + for (const part of parts) total += part.length; + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +export function base64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +export function base64Decode(value: string): Uint8Array { + const binary = atob(value); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +export function base64Url(bytes: Uint8Array): string { + return base64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +export function base64UrlDecode(value: string): Uint8Array { + const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (value.length % 4)) % 4); + return base64Decode(padded); +} + +export function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +export function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!); +} + +export function safeFileName(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120) || "upload"; +} + +export function htmlContent(text: string, mentions: { acct: string; url: string }[] = [], hashtags: string[] = []): string { + let escaped = escapeHtml(text); + for (const mention of mentions) { + const at = escapeHtml(`@${mention.acct}`); + const url = escapeHtml(mention.url); + const localName = mention.acct.split("@")[0]; + const span = `@${escapeHtml(localName)}`; + escaped = escaped.replaceAll(at, span); + } + for (const tag of hashtags) { + const pattern = new RegExp(`#${escapeHtml(tag)}\\b`, "g"); + escaped = escaped.replace(pattern, ``); + } + return `

${escaped.replace(/\n{2,}/g, "

").replace(/\n/g, "
")}

`; +} + +export function normalizeArray(value: unknown): string[] { + if (Array.isArray(value)) return value.map(String); + if (typeof value === "string" && value) return [value]; + return []; +} + +export function clampLimit(value: unknown, fallback: number, max: number): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(Math.floor(parsed), max); +} + +export function baseUrl(env: Env): string { + return env.PUBLIC_BASE_URL.replace(/\/+$/, ""); +} + +export function hostFromBaseUrl(env: Env): string { + return new URL(baseUrl(env)).host; +} + +export function actorUrl(env: Env, user: User): string { + return `${baseUrl(env)}/users/${user.username}`; +} + +export function objectUrl(env: Env, statusId: string): string { + return `${baseUrl(env)}/objects/${statusId}`; +} + +export function activityUrl(env: Env, activityId: string): string { + return `${baseUrl(env)}/activities/${activityId}`; +} + +export function isLocalActor(env: Env, actorId: string): boolean { + try { + return new URL(actorId).host === hostFromBaseUrl(env); + } catch { + return false; + } +} + +export function parseAcctFromActor(env: Env, actorId: string): string { + try { + const url = new URL(actorId); + const name = url.pathname.split("/").filter(Boolean).pop() ?? actorId; + if (url.host === hostFromBaseUrl(env)) return name; + return `${name}@${url.host}`; + } catch { + return actorId; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6879c86 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "worker-configuration.d.ts"] +} diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts new file mode 100644 index 0000000..ce04759 --- /dev/null +++ b/worker-configuration.d.ts @@ -0,0 +1,9 @@ +interface Env { + DB: D1Database; + MEDIA: R2Bucket; + KV: KVNamespace; + PUBLIC_BASE_URL: string; + INSTANCE_NAME: string; + ADMIN_USERNAME: string; + ADMIN_PASSWORD: string; +} diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..2f45a92 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,30 @@ +{ + "name": "toot-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-13", + "vars": { + "PUBLIC_BASE_URL": "https://social.example.com", + "INSTANCE_NAME": "Toot Worker", + "ADMIN_USERNAME": "admin", + "ADMIN_PASSWORD": "change-me-before-deploy" + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "toot_db", + "database_id": "00000000-0000-0000-0000-000000000000" + } + ], + "r2_buckets": [ + { + "binding": "MEDIA", + "bucket_name": "toot-media" + } + ], + "kv_namespaces": [ + { + "binding": "KV", + "id": "00000000000000000000000000000000" + } + ] +}