From 5b01f18719276b6b75b25c2812bc052a9e88c387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=AA=E5=AD=90?= Date: Thu, 14 May 2026 11:47:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/0003_bookmarks_cache.sql | 57 +++++++ readme.md | 29 +++- src/activitypub.ts | 60 +++++++- src/db.ts | 80 ++++++++++ src/federation.ts | 5 + src/index.ts | 18 ++- src/mastodon.ts | 226 ++++++++++++++++++++++++++-- src/types.ts | 44 ++++++ src/util.ts | 12 ++ worker-configuration.d.ts | 1 + wrangler.jsonc | 9 +- 11 files changed, 512 insertions(+), 29 deletions(-) create mode 100644 migrations/0003_bookmarks_cache.sql diff --git a/migrations/0003_bookmarks_cache.sql b/migrations/0003_bookmarks_cache.sql new file mode 100644 index 0000000..44959da --- /dev/null +++ b/migrations/0003_bookmarks_cache.sql @@ -0,0 +1,57 @@ +-- Bookmarks, pins, remote status cache, OAuth token persistence. + +CREATE TABLE IF NOT EXISTS bookmarks ( + user_id TEXT NOT NULL, + status_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY(user_id, status_id) +); + +CREATE INDEX IF NOT EXISTS idx_bookmarks_user_time ON bookmarks(user_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS pinned_statuses ( + user_id TEXT NOT NULL, + status_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY(user_id, status_id) +); + +CREATE INDEX IF NOT EXISTS idx_pinned_user ON pinned_statuses(user_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS cached_statuses ( + id TEXT PRIMARY KEY, + object_id TEXT NOT NULL UNIQUE, + actor TEXT NOT NULL, + content TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + sensitive INTEGER NOT NULL DEFAULT 0, + language TEXT NOT NULL DEFAULT 'en', + in_reply_to TEXT, + url TEXT NOT NULL, + published TEXT NOT NULL, + cached_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_cached_statuses_actor_time ON cached_statuses(actor, published DESC); +CREATE INDEX IF NOT EXISTS idx_cached_statuses_time ON cached_statuses(published DESC); + +CREATE TABLE IF NOT EXISTS cached_status_attachments ( + cached_status_id TEXT NOT NULL, + position INTEGER NOT NULL, + url TEXT NOT NULL, + preview_url TEXT, + mime_type TEXT NOT NULL, + description TEXT, + PRIMARY KEY(cached_status_id, position) +); + +CREATE TABLE IF NOT EXISTS oauth_tokens ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + app_id TEXT NOT NULL, + scopes TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id, created_at DESC); diff --git a/readme.md b/readme.md index 1967e8b..2496a7f 100644 --- a/readme.md +++ b/readme.md @@ -3,11 +3,11 @@ 一个运行在 Cloudflare Workers 上的单用户联邦宇宙发布软件,使用: - Workers: HTTP API、ActivityPub 路由、Mastodon API 兼容层 -- D1: 用户、OAuth 应用、嘟文、媒体索引、关注、收藏、转发、通知、提及、话题标签、远端 actor 缓存 +- D1: 用户、OAuth 应用/Token、嘟文、媒体索引、关注、收藏、转发、通知、提及、话题标签、收藏夹、置顶、远端 actor 与远端嘟文缓存 - R2: 媒体文件 -- KV: OAuth access token 会话 +- KV: OAuth access token 会话(D1 同步保留,便于管理) -当前目标:让常见 Mastodon App 完成实例发现、创建 OAuth App、登录、上传媒体、发布嘟文、读取公开/家庭时间线、收藏/转发/回复、查看通知、搜索、关注/取关远端账号,同时支持单用户实例与 Fediverse 双向联邦。 +当前目标:让常见 Mastodon App 完成实例发现、创建 OAuth App、登录、上传媒体、发布嘟文、读取公开/家庭/话题时间线、收藏/转发/回复/收藏夹/置顶、查看通知、搜索、关注/取关远端账号,同时支持单用户实例与 Fediverse 双向联邦。 ## 本地运行 @@ -43,6 +43,15 @@ wrangler secret put ADMIN_PASSWORD 一旦开始对外联邦后,不要再改域名,否则远端会把你视为另一个实例身份。 +### 媒体 CDN 加速(可选但推荐) + +默认情况下,客户端拉取上传媒体走 `${PUBLIC_BASE_URL}/media/`,会经过 Worker 反代 R2,消耗 Worker 请求数和 CPU。生产建议: + +1. 在 Cloudflare R2 控制台给 `toot-media` 绑定一个 custom domain(如 `media.social.example.com`),开启公开访问 + CDN。 +2. 把该域名填进 `wrangler.jsonc` 的 `MEDIA_BASE_URL`,例如 `"MEDIA_BASE_URL": "https://media.social.example.com"`。 + +设置后,Mastodon 客户端拿到的 `media_attachments.url` 会直接指向 CDN 域名,不再经过 Worker。Worker 自己的 `/media/` 路径仍然保留,可以作为后备访问入口。 + ## Cloudflare 资源 ```bash @@ -86,11 +95,14 @@ npm run deploy - `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) +- `POST /api/v1/statuses/:id/bookmark`、`/unbookmark`、`/pin`、`/unpin`(本地落库) +- `GET /api/v1/bookmarks`、`GET /api/v1/favourites`(列出本地 bookmark / favourite) 时间线 / 通知 / 媒体 / 搜索 / 其它: -- `GET /api/v1/timelines/public`、`GET /api/v1/timelines/home`(分页支持 `max_id` / `since_id` / `min_id`,响应携带 `Link` 头) +- `GET /api/v1/timelines/public`(分页支持 `max_id` / `since_id` / `min_id`,响应携带 `Link` 头) +- `GET /api/v1/timelines/home`(合并本地嘟文 + 关注的远端账号缓存嘟文,按时间排序) +- `GET /api/v1/timelines/tag/:tag`、`GET /api/v1/tags/:name`(话题时间线 + 话题元数据) - `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:` 查询) @@ -108,7 +120,7 @@ npm run deploy - `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` 用于触发提及通知和回复通知。 +入站 inbox 处理类型:`Follow` / `Undo(Follow)` / `Accept(Follow)` / `Reject(Follow)` / `Like` / `Undo(Like)` / `Announce` / `Undo(Announce)` / `Delete(Note)`(同时清远端嘟文缓存)/ `Delete(Person)`(同时清缓存与关注关系)/ `Update(Person)` / `Update(Note)` / `Create(Note)`(被关注的远端账号公开嘟文会写入 `cached_statuses` 给 home timeline 使用,同时若 mention/回复本地用户会触发通知)。 ## 安全 @@ -128,14 +140,15 @@ npm run deploy - `migrations/0001_initial.sql` — 基础表(users / oauth_apps / oauth_codes / statuses / media / follows / remote_activities) - `migrations/0002_features.sql` — 通知 / 收藏 / 转发 / 提及 / 话题标签 / actor 缓存 / 出站关注 / 删除墓碑 / 嘟文扩展字段(summary / sensitive / language) +- `migrations/0003_bookmarks_cache.sql` — 收藏夹(bookmarks)/ 置顶(pinned_statuses)/ 远端嘟文缓存(cached_statuses)/ OAuth Token 持久表 ## 重要限制 这是一个单用户可运行实现,不是完整 Mastodon 服务端: - 只支持单管理员账号自动初始化,不开放注册 -- 通知 / 收藏 / 转发都已实现,但内容审核、屏蔽、过滤、列表、自定义表情、推送通知仍是 stub -- 没有处理远端嘟文缓存(收到 `Create(Note)` 不会存,仅触发提及通知)。意味着客户端的 home timeline 仍只能看到本地嘟文 +- 远端嘟文缓存只在被本地账号关注的 actor 发出的 `Create(Note)` 时写入,不抓取历史 outbox +- `media_attachments` 已缓存(URL 指向远端原始域名),但 `mentions`、`tags` 在远端缓存嘟文中是空数组 - 私信(direct visibility)的检索没有按收信人过滤,目前所有客户端都能在公开时间线之外读到自己的嘟文,不应当作私信使用 - 没有实现轮询(poll)、列表(list)、推送(push)、未来嘟文(scheduled)等 diff --git a/src/activitypub.ts b/src/activitypub.ts index e031a55..dbaaa73 100644 --- a/src/activitypub.ts +++ b/src/activitypub.ts @@ -1,17 +1,21 @@ import { deleteActorFromCache, + deleteCachedStatus, exportUserPublicKeyPem, findFavourite, findReblog, + getCachedStatusByObjectId, getStatus, getStatusByObjectId, getUserByUsername, recordNotification, - upsertActorCache + upsertActorCache, + upsertCachedStatus } from "./db"; import { deliverToInboxes, isDuplicateActivity, + isFollowedByAnyLocalUser, notifyForLocalStatus, objectAsJson, objectIdString, @@ -360,11 +364,12 @@ async function handleDelete(ctx: InboxContext): Promise { 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 env.DB.prepare("DELETE FROM cached_statuses 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(); + await deleteCachedStatus(env, target); return new Response(null, { status: 202 }); } @@ -374,6 +379,13 @@ async function handleUpdate(ctx: InboxContext): Promise { 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 }); + } + if (String(obj.type ?? "") === "Note" && typeof obj.id === "string") { + const existing = await getCachedStatusByObjectId(env, obj.id); + if (existing && existing.actor === actorId) { + await cacheRemoteNote(env, actorId, obj); + } } return new Response(null, { status: 202 }); } @@ -381,7 +393,7 @@ async function handleUpdate(ctx: InboxContext): Promise { 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 }); + if (!obj || String(obj.type ?? "") !== "Note") return new Response(null, { status: 202 }); const recipients = collectRecipients(activity.body, obj); const localActorIds = new Set(); @@ -391,6 +403,12 @@ async function handleCreate(ctx: InboxContext): Promise { if (m) localActorIds.add(m[1]); } + const isPublic = recipients.includes(PUBLIC_COLLECTION); + const followed = await isFollowedByAnyLocalUser(env, actorId); + if (followed && (isPublic || localActorIds.size > 0)) { + await cacheRemoteNote(env, actorId, obj); + } + for (const username of localActorIds) { const localUser = await getUserByUsername(env, username); if (!localUser) continue; @@ -405,6 +423,42 @@ async function handleCreate(ctx: InboxContext): Promise { return new Response(null, { status: 202 }); } +async function cacheRemoteNote(env: Env, actorId: string, note: Json): Promise { + if (typeof note.id !== "string") return; + const cachedId = note.id; + const stored = await upsertCachedStatus(env, { + id: cachedId, + object_id: cachedId, + actor: actorId, + content: typeof note.content === "string" ? note.content : "", + summary: typeof note.summary === "string" ? note.summary : "", + sensitive: note.sensitive ? 1 : 0, + language: typeof note.contentMap === "object" && note.contentMap ? Object.keys(note.contentMap as Json)[0] ?? "en" : "en", + in_reply_to: typeof note.inReplyTo === "string" ? note.inReplyTo : null, + url: typeof note.url === "string" ? note.url : cachedId, + published: typeof note.published === "string" ? note.published : new Date().toISOString() + }); + if (!stored) return; + await env.DB.prepare("DELETE FROM cached_status_attachments WHERE cached_status_id = ?").bind(stored.id).run(); + const attachments = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : []; + let position = 0; + for (const raw of attachments) { + if (!raw || typeof raw !== "object") continue; + const att = raw as Json; + const url = typeof att.url === "string" ? att.url + : (att.url && typeof att.url === "object" && typeof (att.url as Json).href === "string") ? String((att.url as Json).href) + : null; + if (!url) continue; + const mime = typeof att.mediaType === "string" ? att.mediaType : "application/octet-stream"; + const description = typeof att.name === "string" ? att.name + : typeof att.summary === "string" ? att.summary : null; + await env.DB.prepare( + "INSERT OR REPLACE INTO cached_status_attachments (cached_status_id, position, url, preview_url, mime_type, description) VALUES (?, ?, ?, ?, ?, ?)" + ).bind(stored.id, position, url, null, mime, description).run(); + position++; + } +} + 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(); diff --git a/src/db.ts b/src/db.ts index 4b74ae6..b2eee93 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,6 +1,8 @@ import { exportSpkiPem, hashPassword } from "./crypto"; import type { ActorCache, + CachedStatus, + CachedStatusAttachment, Favourite, Follow, Media, @@ -194,3 +196,81 @@ export function actorCacheStale(cache: ActorCache): boolean { export async function exportUserPublicKeyPem(user: User): Promise { return exportSpkiPem(JSON.parse(user.public_key_jwk) as JsonWebKey); } + +export async function findBookmark(env: Env, userId: string, statusId: string): Promise { + const row = await env.DB.prepare("SELECT user_id FROM bookmarks WHERE user_id = ? AND status_id = ?").bind(userId, statusId).first<{ user_id: string }>(); + return Boolean(row); +} + +export async function addBookmark(env: Env, userId: string, statusId: string): Promise { + await env.DB.prepare("INSERT OR IGNORE INTO bookmarks (user_id, status_id, created_at) VALUES (?, ?, ?)") + .bind(userId, statusId, new Date().toISOString()).run(); +} + +export async function removeBookmark(env: Env, userId: string, statusId: string): Promise { + await env.DB.prepare("DELETE FROM bookmarks WHERE user_id = ? AND status_id = ?").bind(userId, statusId).run(); +} + +export async function findPin(env: Env, userId: string, statusId: string): Promise { + const row = await env.DB.prepare("SELECT user_id FROM pinned_statuses WHERE user_id = ? AND status_id = ?").bind(userId, statusId).first<{ user_id: string }>(); + return Boolean(row); +} + +export async function addPin(env: Env, userId: string, statusId: string): Promise { + await env.DB.prepare("INSERT OR IGNORE INTO pinned_statuses (user_id, status_id, created_at) VALUES (?, ?, ?)") + .bind(userId, statusId, new Date().toISOString()).run(); +} + +export async function removePin(env: Env, userId: string, statusId: string): Promise { + await env.DB.prepare("DELETE FROM pinned_statuses WHERE user_id = ? AND status_id = ?").bind(userId, statusId).run(); +} + +export async function getCachedStatusByObjectId(env: Env, objectId: string): Promise { + return env.DB.prepare("SELECT * FROM cached_statuses WHERE object_id = ?").bind(objectId).first(); +} + +export async function upsertCachedStatus(env: Env, status: Omit & { cached_at?: string }): Promise { + const now = status.cached_at ?? new Date().toISOString(); + await env.DB.prepare( + `INSERT INTO cached_statuses (id, object_id, actor, content, summary, sensitive, language, in_reply_to, url, published, cached_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(object_id) DO UPDATE SET + content = excluded.content, + summary = excluded.summary, + sensitive = excluded.sensitive, + language = excluded.language, + in_reply_to = excluded.in_reply_to, + url = excluded.url, + published = excluded.published, + cached_at = excluded.cached_at` + ) + .bind(status.id, status.object_id, status.actor, status.content, status.summary, status.sensitive, status.language, status.in_reply_to, status.url, status.published, now) + .run(); + return getCachedStatusByObjectId(env, status.object_id); +} + +export async function deleteCachedStatus(env: Env, objectId: string): Promise { + const row = await env.DB.prepare("SELECT id FROM cached_statuses WHERE object_id = ?").bind(objectId).first<{ id: string }>(); + if (!row) return; + await env.DB.prepare("DELETE FROM cached_status_attachments WHERE cached_status_id = ?").bind(row.id).run(); + await env.DB.prepare("DELETE FROM cached_statuses WHERE id = ?").bind(row.id).run(); +} + +export async function listCachedStatusAttachments(env: Env, cachedStatusId: string): Promise { + const rows = await env.DB.prepare("SELECT * FROM cached_status_attachments WHERE cached_status_id = ? ORDER BY position ASC").bind(cachedStatusId).all(); + return rows.results; +} + +export async function insertOAuthToken(env: Env, token: string, userId: string, appId: string, scopes: string): Promise { + await env.DB.prepare("INSERT OR REPLACE INTO oauth_tokens (token, user_id, app_id, scopes, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, NULL)") + .bind(token, userId, appId, scopes, new Date().toISOString()).run(); +} + +export async function deleteOAuthToken(env: Env, token: string): Promise { + await env.DB.prepare("DELETE FROM oauth_tokens WHERE token = ?").bind(token).run(); +} + +export async function touchOAuthToken(env: Env, token: string): Promise { + await env.DB.prepare("UPDATE oauth_tokens SET last_used_at = ? WHERE token = ?") + .bind(new Date().toISOString(), token).run(); +} diff --git a/src/federation.ts b/src/federation.ts index 6556364..daa41b7 100644 --- a/src/federation.ts +++ b/src/federation.ts @@ -251,6 +251,11 @@ export function mentionAcct(env: Env, actorId: string): string { return parseAcctFromActor(env, actorId); } +export async function isFollowedByAnyLocalUser(env: Env, actorId: string): Promise { + const row = await env.DB.prepare("SELECT 1 AS hit FROM outgoing_follows WHERE target_actor = ? AND accepted = 1 LIMIT 1").bind(actorId).first<{ hit: number }>(); + return Boolean(row); +} + export async function decodeRemoteSignatureBase64(value: string): Promise { return base64Decode(value); } diff --git a/src/index.ts b/src/index.ts index ec07981..7cc9d3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,17 +20,21 @@ import { authorizeFollowRequest, authorizePage, bookmarkStatus, + bookmarksList, createApp, createStatus, customEmojis, deleteStatusEndpoint, favouriteStatus, + favouritesList, filtersV1, followAccount, followRequestsList, getAccount, getRelationships, getStatusEndpoint, + hashtagInfo, + hashtagTimeline, homeTimeline, instance, instanceV2, @@ -38,6 +42,7 @@ import { notificationClear, notificationDismiss, notificationsList, + pinStatus, publicTimeline, pushSubscription, reblogStatus, @@ -48,8 +53,10 @@ import { statusContext, token, trendsTags, + unbookmarkStatus, unfavouriteStatus, unfollowAccount, + unpinStatus, unreblogStatus, updateCredentials, updateMedia, @@ -122,12 +129,17 @@ async function route(request: Request, env: Env): Promise { 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 === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unbookmark$/))) return unbookmarkStatus(request, env, decodeURIComponent(m[1])); + if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/pin$/))) return pinStatus(request, env, decodeURIComponent(m[1])); + if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unpin$/))) return unpinStatus(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 === "GET" && (m = path.match(/^\/api\/v1\/timelines\/tag\/([^/]+)$/))) return hashtagTimeline(request, env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/api\/v1\/tags\/([^/]+)$/))) return hashtagInfo(env, decodeURIComponent(m[1])); + + if (method === "GET" && path === "/api/v1/bookmarks") return bookmarksList(request, env); + if (method === "GET" && path === "/api/v1/favourites") return favouritesList(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])); diff --git a/src/mastodon.ts b/src/mastodon.ts index 268d0e0..b86f420 100644 --- a/src/mastodon.ts +++ b/src/mastodon.ts @@ -10,11 +10,16 @@ import { } from "./activitypub"; import { hashPassword, verifyPassword } from "./crypto"; import { + addBookmark, + addPin, countFollowers, countFollowing, countStatuses, + deleteOAuthToken, + findBookmark, findFavourite, findOutgoingFollow, + findPin, findReblog, getAdminUser, getAppByClientId, @@ -22,7 +27,11 @@ import { getUserById, getUserByIdOrUsername, getUserByUsername, + insertOAuthToken, + listCachedStatusAttachments, recordNotification, + removeBookmark, + removePin, takeOAuthCode } from "./db"; import { @@ -41,6 +50,7 @@ import { readBody } from "./http"; import type { + CachedStatus, Follow, Media, Mention, @@ -59,6 +69,7 @@ import { htmlContent, id, isLocalActor, + mediaUrl, normalizeArray, objectUrl, safeFileName, @@ -252,13 +263,17 @@ export async function token(request: Request, env: Env): Promise { const accessToken = tokenString(48); await env.KV.put(`token:${accessToken}`, JSON.stringify({ userId, appId: app.id, scopes } satisfies Session), { expirationTtl: TOKEN_TTL_SECONDS }); + if (userId) await insertOAuthToken(env, accessToken, userId, app.id, scopes); 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}`); + if (tokenValue) { + await env.KV.delete(`token:${tokenValue}`); + await deleteOAuthToken(env, tokenValue); + } return json({}); } @@ -590,11 +605,64 @@ export async function unreblogStatus(request: Request, env: Env, statusId: strin } export async function bookmarkStatus(request: Request, env: Env, statusId: string): Promise { - await requireUser(request, env); + const user = await requireUser(request, env); const status = await getStatus(env, statusId); if (!status) return json({ error: "Record not found" }, 404); + await addBookmark(env, user.id, status.id); const owner = await getUserById(env, status.user_id); - return json(await statusJson(env, status, owner!, request)); + if (!owner) throw new HttpError(500, "owner_missing"); + return json(await statusJson(env, status, owner, request)); +} + +export async function unbookmarkStatus(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); + await removeBookmark(env, user.id, status.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 pinStatus(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); + await addPin(env, user.id, status.id); + return json(await statusJson(env, status, user, request)); +} + +export async function unpinStatus(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); + await removePin(env, user.id, status.id); + return json(await statusJson(env, status, user, request)); +} + +export async function bookmarksList(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + const url = new URL(request.url); + const limit = clampLimit(url.searchParams.get("limit"), 20, 40); + const rows = await env.DB.prepare( + `SELECT s.* FROM statuses s INNER JOIN bookmarks b ON b.status_id = s.id + WHERE b.user_id = ? ORDER BY b.created_at DESC LIMIT ?` + ).bind(user.id, limit).all(); + const items = await serializeStatuses(env, rows.results, request); + return withPagination(json(items), request, rows.results.map((s) => s.id)); +} + +export async function favouritesList(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + const url = new URL(request.url); + const limit = clampLimit(url.searchParams.get("limit"), 20, 40); + const actor = actorUrl(env, user); + const rows = await env.DB.prepare( + `SELECT s.* FROM statuses s INNER JOIN favourites f ON f.status_id = s.id + WHERE f.actor = ? ORDER BY f.created_at DESC LIMIT ?` + ).bind(actor, limit).all(); + const items = await serializeStatuses(env, rows.results, request); + return withPagination(json(items), request, rows.results.map((s) => s.id)); } export async function publicTimeline(request: Request, env: Env): Promise { @@ -611,8 +679,61 @@ export async function publicTimeline(request: Request, env: Env): Promise { - await requireUser(request, env); - return publicTimeline(request, env); + const user = await requireUser(request, env); + const url = new URL(request.url); + const limit = clampLimit(url.searchParams.get("limit"), 20, 40); + + const localRows = await env.DB.prepare( + "SELECT * FROM statuses WHERE user_id = ? ORDER BY created_at DESC LIMIT ?" + ).bind(user.id, limit).all(); + + const cachedRows = await env.DB.prepare( + `SELECT cs.* FROM cached_statuses cs + INNER JOIN outgoing_follows of ON of.target_actor = cs.actor + WHERE of.local_user_id = ? AND of.accepted = 1 + ORDER BY cs.published DESC LIMIT ?` + ).bind(user.id, limit).all(); + + const localItems = await serializeStatuses(env, localRows.results, request); + const cachedItems = await Promise.all(cachedRows.results.map((row) => cachedStatusToMastodon(env, row))); + + const merged = [...localItems, ...cachedItems].sort((a, b) => { + const at = String(a.created_at ?? ""); + const bt = String(b.created_at ?? ""); + return bt.localeCompare(at); + }).slice(0, limit); + + return json(merged); +} + +export async function hashtagTimeline(request: Request, env: Env, tag: string): Promise { + const url = new URL(request.url); + const limit = clampLimit(url.searchParams.get("limit"), 20, 40); + const where: string[] = ["s.visibility = 'public'", "h.tag = ?"]; + const binds: unknown[] = [tag.toLowerCase()]; + const maxId = url.searchParams.get("max_id"); + if (maxId) { where.push("s.created_at < (SELECT created_at FROM statuses WHERE id = ?)"); binds.push(maxId); } + const sinceId = url.searchParams.get("since_id"); + if (sinceId) { where.push("s.created_at > (SELECT created_at FROM statuses WHERE id = ?)"); binds.push(sinceId); } + const minId = url.searchParams.get("min_id"); + if (minId) { where.push("s.created_at > (SELECT created_at FROM statuses WHERE id = ?)"); binds.push(minId); } + const sql = `SELECT s.* FROM statuses s INNER JOIN hashtags h ON h.status_id = s.id WHERE ${where.join(" AND ")} ORDER BY s.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 hashtagInfo(env: Env, tag: string): Promise { + const normalized = tag.toLowerCase(); + const row = await env.DB.prepare("SELECT COUNT(DISTINCT status_id) AS count FROM hashtags WHERE tag = ?").bind(normalized).first<{ count: number }>(); + return json({ + name: normalized, + url: `${baseUrl(env)}/tags/${encodeURIComponent(normalized)}`, + history: [], + following: false, + statuses_count: row?.count ?? 0 + }); } export async function uploadMedia(request: Request, env: Env): Promise { @@ -851,8 +972,60 @@ type StatusSerializationContext = { reblogCountByStatusId: Map; rebloggedStatusIds: Set; replyCountByStatusId: Map; + bookmarkedStatusIds: Set; + pinnedStatusIds: Set; }; +async function cachedStatusToMastodon(env: Env, row: CachedStatus): Promise> { + const cache = await resolveRemoteActor(env, row.actor); + const account = cache ? remoteAccountJson(cache) : { id: row.actor, acct: row.actor, username: row.actor }; + const attachments = await listCachedStatusAttachments(env, row.id); + return { + id: row.object_id, + uri: row.object_id, + url: row.url, + account, + in_reply_to_id: null, + in_reply_to_account_id: null, + content: row.content, + text: row.content, + created_at: row.published, + edited_at: null, + visibility: "public", + language: row.language, + sensitive: Boolean(row.sensitive), + spoiler_text: row.summary, + media_attachments: attachments.map((att) => ({ + id: `${row.id}:${att.position}`, + type: att.mime_type.startsWith("image/") ? "image" + : att.mime_type.startsWith("video/") ? "video" + : att.mime_type.startsWith("audio/") ? "audio" : "unknown", + url: att.url, + preview_url: att.preview_url ?? att.url, + remote_url: att.url, + text_url: null, + meta: {}, + description: att.description, + blurhash: null + })), + mentions: [], + tags: [], + emojis: [], + reblogs_count: 0, + favourites_count: 0, + replies_count: 0, + reblog: null, + application: null, + favourited: false, + reblogged: false, + muted: false, + bookmarked: false, + pinned: false, + card: null, + poll: null + }; +} + async function statusJson( env: Env, status: Status, @@ -905,8 +1078,8 @@ function statusRecord(env: Env, status: Status, user: User, context: StatusSeria favourited: context.favouritedStatusIds.has(status.id), reblogged: context.rebloggedStatusIds.has(status.id), muted: false, - bookmarked: false, - pinned: false, + bookmarked: context.bookmarkedStatusIds.has(status.id), + pinned: context.pinnedStatusIds.has(status.id), card: null, poll: null }; @@ -940,13 +1113,16 @@ async function buildStatusSerializationContext( } const viewer = await viewerActor(request, env); - const [mediaByStatusId, mentionsByStatusId, hashtagsByStatusId, favouriteSummary, reblogSummary, replyCountByStatusId] = await Promise.all([ + const viewerId = await viewerUserId(request, env); + const [mediaByStatusId, mentionsByStatusId, hashtagsByStatusId, favouriteSummary, reblogSummary, replyCountByStatusId, bookmarkedStatusIds, pinnedStatusIds] = 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) + loadReplyCountByStatusIds(env, statusIds), + viewerId ? loadBookmarkedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set()), + viewerId ? loadPinnedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set()) ]); const accountByUserId = new Map>(); @@ -964,7 +1140,9 @@ async function buildStatusSerializationContext( favouritedStatusIds: favouriteSummary.viewerMatchedStatusIds, reblogCountByStatusId: reblogSummary.countByStatusId, rebloggedStatusIds: reblogSummary.viewerMatchedStatusIds, - replyCountByStatusId + replyCountByStatusId, + bookmarkedStatusIds, + pinnedStatusIds }; } @@ -1029,7 +1207,7 @@ function remoteAccountJson(cache: { id: string; preferred_username: string | nul } function mediaJson(env: Env, media: Media): Record { - const url = `${baseUrl(env)}/media/${encodeURIComponent(media.r2_key)}`; + const url = mediaUrl(env, media.r2_key); return { id: media.id, type: media.mime_type.startsWith("image/") ? "image" : media.mime_type.startsWith("video/") ? "video" : "unknown", @@ -1307,6 +1485,32 @@ async function viewerActor(request: Request, env: Env): Promise { return user ? actorUrl(env, user) : null; } +async function viewerUserId(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"); + return session?.userId ?? null; +} + +async function loadBookmarkedStatusIds(env: Env, userId: string, statusIds: string[]): Promise> { + if (statusIds.length === 0) return new Set(); + const placeholders = statusIds.map(() => "?").join(","); + const rows = await env.DB.prepare( + `SELECT status_id FROM bookmarks WHERE user_id = ? AND status_id IN (${placeholders})` + ).bind(userId, ...statusIds).all<{ status_id: string }>(); + return new Set(rows.results.map((row) => row.status_id)); +} + +async function loadPinnedStatusIds(env: Env, userId: string, statusIds: string[]): Promise> { + if (statusIds.length === 0) return new Set(); + const placeholders = statusIds.map(() => "?").join(","); + const rows = await env.DB.prepare( + `SELECT status_id FROM pinned_statuses WHERE user_id = ? AND status_id IN (${placeholders})` + ).bind(userId, ...statusIds).all<{ status_id: string }>(); + return new Set(rows.results.map((row) => row.status_id)); +} + async function requireUser(request: Request, env: Env): Promise { const auth = request.headers.get("authorization") ?? ""; const token = auth.match(/^Bearer\s+(.+)$/i)?.[1]; diff --git a/src/types.ts b/src/types.ts index b86053a..337123b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -122,6 +122,50 @@ export type Hashtag = { tag: string; }; +export type Bookmark = { + user_id: string; + status_id: string; + created_at: string; +}; + +export type PinnedStatus = { + user_id: string; + status_id: string; + created_at: string; +}; + +export type CachedStatus = { + id: string; + object_id: string; + actor: string; + content: string; + summary: string; + sensitive: number; + language: string; + in_reply_to: string | null; + url: string; + published: string; + cached_at: string; +}; + +export type CachedStatusAttachment = { + cached_status_id: string; + position: number; + url: string; + preview_url: string | null; + mime_type: string; + description: string | null; +}; + +export type OAuthToken = { + token: string; + user_id: string; + app_id: string; + scopes: string; + created_at: string; + last_used_at: string | null; +}; + export type ActorCache = { id: string; inbox: string; diff --git a/src/util.ts b/src/util.ts index 8663b64..df59b6b 100644 --- a/src/util.ts +++ b/src/util.ts @@ -103,6 +103,18 @@ export function activityUrl(env: Env, activityId: string): string { return `${baseUrl(env)}/activities/${activityId}`; } +export function mediaCdnBaseUrl(env: Env): string | null { + const value = (env.MEDIA_BASE_URL ?? "").trim(); + if (!value) return null; + return value.replace(/\/+$/, ""); +} + +export function mediaUrl(env: Env, r2Key: string): string { + const cdn = mediaCdnBaseUrl(env); + if (cdn) return `${cdn}/${r2Key.split("/").map(encodeURIComponent).join("/")}`; + return `${baseUrl(env)}/media/${encodeURIComponent(r2Key)}`; +} + export function isLocalActor(env: Env, actorId: string): boolean { try { return new URL(actorId).host === hostFromBaseUrl(env); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index ce04759..8b30c02 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -3,6 +3,7 @@ interface Env { MEDIA: R2Bucket; KV: KVNamespace; PUBLIC_BASE_URL: string; + MEDIA_BASE_URL: string; INSTANCE_NAME: string; ADMIN_USERNAME: string; ADMIN_PASSWORD: string; diff --git a/wrangler.jsonc b/wrangler.jsonc index 2f45a92..e0785af 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -3,16 +3,17 @@ "main": "src/index.ts", "compatibility_date": "2026-05-13", "vars": { - "PUBLIC_BASE_URL": "https://social.example.com", + "PUBLIC_BASE_URL": "https://zxd.im", + "MEDIA_BASE_URL": "https://toot-media.zxd.im", "INSTANCE_NAME": "Toot Worker", - "ADMIN_USERNAME": "admin", + "ADMIN_USERNAME": "sun", "ADMIN_PASSWORD": "change-me-before-deploy" }, "d1_databases": [ { "binding": "DB", "database_name": "toot_db", - "database_id": "00000000-0000-0000-0000-000000000000" + "database_id": "8e042858-bf5f-4d7a-ad84-3e002b0b2f42" } ], "r2_buckets": [ @@ -24,7 +25,7 @@ "kv_namespaces": [ { "binding": "KV", - "id": "00000000000000000000000000000000" + "id": "0e14d63f7d624358ab6507ef1bac9017" } ] }