This commit is contained in:
浪子
2026-05-14 11:47:25 +08:00
parent 01880d39a0
commit 5b01f18719
11 changed files with 512 additions and 29 deletions
+57
View File
@@ -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);
+21 -8
View File
@@ -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/<key>`,会经过 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/<key>` 路径仍然保留,可以作为后备访问入口。
## 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)等
+57 -3
View File
@@ -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<Response> {
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<Response> {
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<Response> {
async function handleCreate(ctx: InboxContext): Promise<Response> {
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<string>();
@@ -391,6 +403,12 @@ async function handleCreate(ctx: InboxContext): Promise<Response> {
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<Response> {
return new Response(null, { status: 202 });
}
async function cacheRemoteNote(env: Env, actorId: string, note: Json): Promise<void> {
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<string>();
+80
View File
@@ -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<string> {
return exportSpkiPem(JSON.parse(user.public_key_jwk) as JsonWebKey);
}
export async function findBookmark(env: Env, userId: string, statusId: string): Promise<boolean> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<void> {
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<CachedStatus | null> {
return env.DB.prepare("SELECT * FROM cached_statuses WHERE object_id = ?").bind(objectId).first<CachedStatus>();
}
export async function upsertCachedStatus(env: Env, status: Omit<CachedStatus, "cached_at"> & { cached_at?: string }): Promise<CachedStatus | null> {
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<void> {
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<CachedStatusAttachment[]> {
const rows = await env.DB.prepare("SELECT * FROM cached_status_attachments WHERE cached_status_id = ? ORDER BY position ASC").bind(cachedStatusId).all<CachedStatusAttachment>();
return rows.results;
}
export async function insertOAuthToken(env: Env, token: string, userId: string, appId: string, scopes: string): Promise<void> {
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<void> {
await env.DB.prepare("DELETE FROM oauth_tokens WHERE token = ?").bind(token).run();
}
export async function touchOAuthToken(env: Env, token: string): Promise<void> {
await env.DB.prepare("UPDATE oauth_tokens SET last_used_at = ? WHERE token = ?")
.bind(new Date().toISOString(), token).run();
}
+5
View File
@@ -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<boolean> {
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<Uint8Array> {
return base64Decode(value);
}
+15 -3
View File
@@ -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<Response> {
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]));
+215 -11
View File
@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Status>();
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<Response> {
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<Status>();
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<Response> {
@@ -611,8 +679,61 @@ export async function publicTimeline(request: Request, env: Env): Promise<Respon
}
export async function homeTimeline(request: Request, env: Env): Promise<Response> {
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<Status>();
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<CachedStatus>();
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<Response> {
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<Status>();
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<Response> {
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<Response> {
@@ -851,8 +972,60 @@ type StatusSerializationContext = {
reblogCountByStatusId: Map<string, number>;
rebloggedStatusIds: Set<string>;
replyCountByStatusId: Map<string, number>;
bookmarkedStatusIds: Set<string>;
pinnedStatusIds: Set<string>;
};
async function cachedStatusToMastodon(env: Env, row: CachedStatus): Promise<Record<string, unknown>> {
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<string>()),
viewerId ? loadPinnedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set<string>())
]);
const accountByUserId = new Map<string, Record<string, unknown>>();
@@ -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<string, unknown> {
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<string | null> {
return user ? actorUrl(env, user) : null;
}
async function viewerUserId(request: Request, env: Env): Promise<string | null> {
const auth = request.headers.get("authorization") ?? "";
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
if (!token) return null;
const session = await env.KV.get<Session>(`token:${token}`, "json");
return session?.userId ?? null;
}
async function loadBookmarkedStatusIds(env: Env, userId: string, statusIds: string[]): Promise<Set<string>> {
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<Set<string>> {
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<User> {
const auth = request.headers.get("authorization") ?? "";
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
+44
View File
@@ -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;
+12
View File
@@ -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);
+1
View File
@@ -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;
+5 -4
View File
@@ -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"
}
]
}