附件上传限制

This commit is contained in:
浪子
2026-05-14 15:14:10 +08:00
parent 635aad8162
commit 5365a3569f
3 changed files with 161 additions and 50 deletions
+5 -4
View File
@@ -147,12 +147,13 @@ npm run deploy
这是一个单用户可运行实现,不是完整 Mastodon 服务端:
- 只支持单管理员账号自动初始化,不开放注册
- 本地状态的可见性语义并不完整:
- `visibility=private` 目前只会写入本地库,不会按 followers-only 语义完整投递
- `unlisted` / `private` / `direct` 没有做完整的读权限控制,`GET /api/v1/accounts/:id/statuses``GET /api/v1/statuses/:id`、搜索接口以及 ActivityPub outbox 都可能暴露非公开内容,不应当存放敏感或真正私密的信息
- 本地状态的可见性已做基础控制:
- `public` / `unlisted` 可公开读取; ActivityPub outbox 只暴露这两类状态
- `private` 会按 followers-only 投递,本地读取限作者和本地关注者
- `direct` 仍没有完整受众表,本地读取保守限制为作者可见,不应当作为完整私信系统使用
- 远端嘟文缓存只在被本地账号关注的 actor 发出的入站 `Create(Note)` 时写入,不抓取历史 outbox
- 远端缓存嘟文只保留正文、CW、语言和附件; `mentions``tags`、互动计数等不会完整恢复,在 home timeline 中统一按 `visibility: public` 返回
- 媒体上传只支持 `image/jpeg``image/png``image/gif``image/webp`,单文件 10MB,单条状态最多 4 个附件; 头像和封面同样只按图片路径处理
- 媒体上传只支持 `image/jpeg``image/png``image/gif``image/webp`,单文件 10MB,单条状态的附件数量不做服务端限制; 头像和封面同样只按图片路径处理
- 没有实现接口级限流、反滥用或审核流; `follow_requests` 相关接口仍是 stub
- 没有实现轮询(poll)、列表(list)、推送(push)、未来嘟文(scheduled)等
+30 -8
View File
@@ -157,7 +157,9 @@ export async function outbox(request: Request, env: Env, username: string): Prom
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 totalRow = await env.DB.prepare(
"SELECT COUNT(*) AS count FROM statuses WHERE user_id = ? AND visibility IN ('public', 'unlisted')"
).bind(user.id).first<{ count: number }>();
const totalItems = totalRow?.count ?? 0;
const base = `${actorUrl(env, user)}/outbox`;
@@ -172,7 +174,9 @@ export async function outbox(request: Request, env: Env, username: string): Prom
}
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<Status>();
const rows = await env.DB.prepare(
"SELECT * FROM statuses WHERE user_id = ? AND visibility IN ('public', 'unlisted') ORDER BY created_at DESC LIMIT ?"
).bind(user.id, limit).all<Status>();
const items = rows.results.map((status) => createActivity(env, user, status));
return activityJson({
"@context": ACTIVITY_CONTEXT,
@@ -211,6 +215,7 @@ export async function followingCollection(env: Env, username: string): Promise<R
export async function activityObject(env: Env, objectId: string): Promise<Response> {
const status = await getStatus(env, objectId);
if (status) {
if (status.visibility !== "public" && status.visibility !== "unlisted") return json({ error: "not_found" }, 404);
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(status.user_id).first<User>();
if (!user) return json({ error: "not_found" }, 404);
return activityJson(noteObject(env, user, status));
@@ -513,8 +518,9 @@ async function localUserFromTarget(env: Env, actorId: string | null): Promise<Us
}
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`];
const audience = statusAudience(env, user, status);
const to = extra.to ?? audience.to;
const cc = extra.cc ?? audience.cc;
return {
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
id: status.activity_id,
@@ -527,13 +533,28 @@ export function createActivity(env: Env, user: User, status: Status, extra: { to
};
}
export function deleteActivity(env: Env, user: User, status: Status): Json {
function statusAudience(env: Env, user: User, status: Status): { to: string[]; cc: string[] } {
if (status.visibility === "unlisted") {
return { to: [`${actorUrl(env, user)}/followers`], cc: [PUBLIC_COLLECTION] };
}
if (status.visibility === "private") {
return { to: [`${actorUrl(env, user)}/followers`], cc: [] };
}
if (status.visibility === "direct") {
return { to: [], cc: [] };
}
return { to: [PUBLIC_COLLECTION], cc: [`${actorUrl(env, user)}/followers`] };
}
export function deleteActivity(env: Env, user: User, status: Status, extra: { to?: string[]; cc?: string[] } = {}): Json {
const audience = statusAudience(env, user, status);
return {
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
id: activityUrl(env, id()),
type: "Delete",
actor: actorUrl(env, user),
to: [PUBLIC_COLLECTION],
to: extra.to ?? audience.to,
cc: extra.cc ?? audience.cc,
object: {
id: status.object_id,
type: "Tombstone"
@@ -596,6 +617,7 @@ export function updatePersonActivity(env: Env, user: User, doc: Json): Json {
}
export function noteObject(env: Env, user: User, status: Status, opts: { to?: string[]; cc?: string[]; attachments?: Json[]; tag?: Json[] } = {}): Json {
const audience = statusAudience(env, user, status);
return {
id: status.object_id,
type: "Note",
@@ -606,8 +628,8 @@ export function noteObject(env: Env, user: User, status: Status, opts: { to?: st
content: status.content,
published: status.created_at,
url: status.url,
to: opts.to ?? [PUBLIC_COLLECTION],
cc: opts.cc ?? [`${actorUrl(env, user)}/followers`],
to: opts.to ?? audience.to,
cc: opts.cc ?? audience.cc,
attachment: opts.attachments ?? [],
tag: opts.tag ?? []
};
+126 -38
View File
@@ -86,10 +86,18 @@ import {
const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90;
const MAX_STATUS_CHARS = 5000;
const MAX_MEDIA_ATTACHMENTS = 4;
const REPORTED_MEDIA_ATTACHMENTS_LIMIT = 9999;
const MAX_MEDIA_BYTES = 10 * 1024 * 1024;
const SUPPORTED_MIME = ["image/jpeg", "image/png", "image/gif", "image/webp"];
const VALID_STATUS_VISIBILITIES = new Set(["public", "unlisted", "private", "direct"]);
type StatusVisibility = "public" | "unlisted" | "private" | "direct";
type StatusViewer = {
user: User | null;
actor: string | null;
followsByOwnerId: Map<string, boolean>;
};
function parseRedirectUris(value: string): string[] {
return value.split(/\s+/).map((item) => item.trim()).filter(Boolean);
@@ -120,7 +128,7 @@ export async function instance(env: Env): Promise<Response> {
approval_required: false,
invites_enabled: false,
configuration: {
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: MAX_MEDIA_ATTACHMENTS, characters_reserved_per_url: 23 },
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: REPORTED_MEDIA_ATTACHMENTS_LIMIT, 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 }
},
@@ -143,7 +151,7 @@ export async function instanceV2(env: Env): Promise<Response> {
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 },
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: REPORTED_MEDIA_ATTACHMENTS_LIMIT, 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 },
@@ -454,6 +462,9 @@ export async function accountStatuses(request: Request, env: Env, accountId: str
const excludeReplies = url.searchParams.get("exclude_replies") === "true";
const where: string[] = ["user_id = ?"];
const binds: unknown[] = [user.id];
const viewer = await loadStatusViewer(request, env);
const visibilityClause = await visibleStatusWhereForOwner(env, user.id, viewer);
if (visibilityClause) where.push(visibilityClause);
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 ?`;
@@ -542,11 +553,11 @@ export async function createStatus(request: Request, env: Env): Promise<Response
const summary = bodyString(body, "spoiler_text");
const sensitive = bodyString(body, "sensitive") === "true";
const visibility = bodyString(body, "visibility", "public");
if (!isStatusVisibility(visibility)) return json({ error: "invalid_visibility" }, 422);
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();
@@ -619,7 +630,7 @@ export async function createStatus(request: Request, env: Env): Promise<Response
const status = await getStatus(env, statusId);
if (!status) throw new HttpError(500, "status_not_found");
if (visibility === "public" || visibility === "unlisted") {
if (visibility === "public" || visibility === "unlisted" || visibility === "private") {
const inboxes = new Set<string>(await gatherFollowerInboxes(env, user.id));
for (const mention of resolvedMentions) {
if (!mention.actorId.startsWith(baseUrl(env))) {
@@ -627,8 +638,17 @@ export async function createStatus(request: Request, env: Env): Promise<Response
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 mentionActors = resolvedMentions.map((m) => m.actorId);
const to = visibility === "public"
? ["https://www.w3.org/ns/activitystreams#Public"]
: visibility === "unlisted"
? [`${actorUrl(env, user)}/followers`]
: [`${actorUrl(env, user)}/followers`, ...mentionActors];
const cc = visibility === "public"
? [`${actorUrl(env, user)}/followers`, ...mentionActors]
: visibility === "unlisted"
? ["https://www.w3.org/ns/activitystreams#Public", ...mentionActors]
: [];
const activity = createActivity(env, user, status, { to, cc });
await deliverToInboxes(env, user, inboxes, activity);
} else if (visibility === "direct") {
@@ -649,6 +669,8 @@ export async function createStatus(request: Request, env: Env): Promise<Response
export async function getStatusEndpoint(request: Request, env: Env, statusId: string): Promise<Response> {
const status = await getStatus(env, statusId);
if (!status) return json({ error: "Record not found" }, 404);
const viewer = await loadStatusViewer(request, env);
if (!await canViewerViewStatus(env, status, viewer)) 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));
@@ -661,7 +683,10 @@ export async function deleteStatusEndpoint(request: Request, env: Env, statusId:
const serialized = await statusJson(env, status, user, request);
const inboxes = new Set<string>(await gatherFollowerInboxes(env, user.id));
const inboxes = new Set<string>();
if (status.visibility !== "direct") {
for (const inbox of await gatherFollowerInboxes(env, user.id)) inboxes.add(inbox);
}
const mentions = await listMentionsForStatus(env, status.id);
for (const mention of mentions) {
if (!mention.actor.startsWith(baseUrl(env))) {
@@ -686,13 +711,22 @@ export async function deleteStatusEndpoint(request: Request, env: Env, statusId:
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));
const mentionActors = mentions.map((mention) => mention.actor);
const deleteAudience = status.visibility === "direct"
? { to: mentionActors, cc: [] }
: status.visibility === "private"
? { to: [`${actorUrl(env, user)}/followers`, ...mentionActors], cc: [] }
: {};
await deliverToInboxes(env, user, inboxes, deleteActivity(env, user, status, deleteAudience));
return json(serialized);
}
export async function statusContext(env: Env, statusId: string, request: Request): Promise<Response> {
const status = await getStatus(env, statusId);
if (!status) return json({ error: "Record not found" }, 404);
const viewer = await loadStatusViewer(request, env);
if (!await canViewerViewStatus(env, status, viewer)) return json({ error: "Record not found" }, 404);
const ancestors: Status[] = [];
let cursor = status.in_reply_to_id;
while (cursor) {
@@ -702,17 +736,19 @@ export async function statusContext(env: Env, statusId: string, request: Request
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<Status>();
const serialized = await serializeStatuses(env, [...ancestors, ...descRows.results], request);
const visibleAncestors = await filterStatusesForViewer(env, ancestors, viewer);
const visibleDescendants = await filterStatusesForViewer(env, descRows.results, viewer);
const serialized = await serializeStatuses(env, [...visibleAncestors, ...visibleDescendants], 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)
ancestors: visibleAncestors.map((item) => byId.get(item.id)).filter(Boolean),
descendants: visibleDescendants.map((item) => byId.get(item.id)).filter(Boolean)
});
}
export async function favouriteStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user));
if (!status) return json({ error: "Record not found" }, 404);
const actor = actorUrl(env, user);
@@ -739,7 +775,7 @@ export async function favouriteStatus(request: Request, env: Env, statusId: stri
export async function unfavouriteStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user));
if (!status) return json({ error: "Record not found" }, 404);
const actor = actorUrl(env, user);
@@ -758,8 +794,9 @@ export async function unfavouriteStatus(request: Request, env: Env, statusId: st
export async function reblogStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user));
if (!status) return json({ error: "Record not found" }, 404);
if (status.visibility !== "public" && status.visibility !== "unlisted") return json({ error: "status_not_rebloggable" }, 422);
const actor = actorUrl(env, user);
const existing = await findReblog(env, status.id, actor);
@@ -785,7 +822,7 @@ export async function reblogStatus(request: Request, env: Env, statusId: string)
export async function unreblogStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user));
if (!status) return json({ error: "Record not found" }, 404);
const actor = actorUrl(env, user);
@@ -805,7 +842,7 @@ export async function unreblogStatus(request: Request, env: Env, statusId: strin
export async function bookmarkStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user));
if (!status) return json({ error: "Record not found" }, 404);
await addBookmark(env, user.id, status.id);
const owner = await getUserById(env, status.user_id);
@@ -815,7 +852,7 @@ export async function bookmarkStatus(request: Request, env: Env, statusId: strin
export async function unbookmarkStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user));
if (!status) return json({ error: "Record not found" }, 404);
await removeBookmark(env, user.id, status.id);
const owner = await getUserById(env, status.user_id);
@@ -847,8 +884,9 @@ export async function bookmarksList(request: Request, env: Env): Promise<Respons
`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));
const visibleRows = await filterStatusesForViewer(env, rows.results, statusViewerForUser(env, user));
const items = await serializeStatuses(env, visibleRows, request);
return withPagination(json(items), request, visibleRows.map((s) => s.id));
}
export async function favouritesList(request: Request, env: Env): Promise<Response> {
@@ -860,8 +898,9 @@ export async function favouritesList(request: Request, env: Env): Promise<Respon
`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));
const visibleRows = await filterStatusesForViewer(env, rows.results, statusViewerForUser(env, user));
const items = await serializeStatuses(env, visibleRows, request);
return withPagination(json(items), request, visibleRows.map((s) => s.id));
}
export async function publicTimeline(request: Request, env: Env): Promise<Response> {
@@ -1123,8 +1162,9 @@ export async function search(request: Request, env: Env): Promise<Response> {
}
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<Status>();
statuses.push(...await serializeStatuses(env, rows.results, request));
const rows = await env.DB.prepare("SELECT * FROM statuses WHERE content LIKE ? ORDER BY created_at DESC LIMIT 100").bind(`%${escapeHtml(q)}%`).all<Status>();
const visibleRows = await filterStatusesForViewer(env, rows.results, await loadStatusViewer(request, env));
statuses.push(...await serializeStatuses(env, visibleRows.slice(0, 20), request));
}
if (!type || type === "hashtags") {
@@ -1311,8 +1351,9 @@ async function buildStatusSerializationContext(
for (const user of await loadUsersByIds(env, missingUserIds)) usersById.set(user.id, user);
}
const viewer = await viewerActor(request, env);
const viewerId = await viewerUserId(request, env);
const viewerUserForContext = await viewerUser(request, env);
const viewer = viewerUserForContext ? actorUrl(env, viewerUserForContext) : null;
const viewerId = viewerUserForContext?.id ?? null;
const [mediaByStatusId, mentionsByStatusId, hashtagsByStatusId, favouriteSummary, reblogSummary, replyCountByStatusId, bookmarkedStatusIds, pinnedStatusIds] = await Promise.all([
loadMediaByStatusIds(env, statusIds),
loadMentionsByStatusIds(env, statusIds),
@@ -1626,7 +1667,8 @@ async function serializeNotifications(env: Env, notifications: Notification[], r
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 visibleStatuses = await filterStatusesForViewer(env, statuses, await loadStatusViewer(request, env));
const serializedStatuses = await serializeStatuses(env, visibleStatuses, request);
const serializedStatusById = new Map(serializedStatuses.map((item) => [String(item.id), item]));
const remoteActorIds = uniqueStrings(
@@ -1708,22 +1750,68 @@ function withPagination(response: Response, request: Request, ids: string[]): Re
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}
async function viewerActor(request: Request, env: Env): Promise<string | null> {
function isStatusVisibility(value: string): value is StatusVisibility {
return VALID_STATUS_VISIBILITIES.has(value);
}
async function loadStatusViewer(request: Request, env: Env): Promise<StatusViewer> {
return statusViewerForUser(env, await viewerUser(request, env));
}
function statusViewerForUser(env: Env, user: User | null): StatusViewer {
return {
user,
actor: user ? actorUrl(env, user) : null,
followsByOwnerId: new Map()
};
}
async function visibleStatusWhereForOwner(env: Env, ownerUserId: string, viewer: StatusViewer, column = "visibility"): Promise<string | null> {
if (viewer.user?.id === ownerUserId) return null;
if (await viewerFollowsOwner(env, viewer, ownerUserId)) return `${column} IN ('public', 'unlisted', 'private')`;
return `${column} IN ('public', 'unlisted')`;
}
async function visibleStatusOrNull(env: Env, statusId: string, viewer: StatusViewer): Promise<Status | null> {
const status = await getStatus(env, statusId);
if (!status) return null;
return await canViewerViewStatus(env, status, viewer) ? status : null;
}
async function filterStatusesForViewer(env: Env, statuses: Status[], viewer: StatusViewer): Promise<Status[]> {
const visible: Status[] = [];
for (const status of statuses) {
if (await canViewerViewStatus(env, status, viewer)) visible.push(status);
}
return visible;
}
async function canViewerViewStatus(env: Env, status: Status, viewer: StatusViewer): Promise<boolean> {
if (status.visibility === "public" || status.visibility === "unlisted") return true;
if (viewer.user?.id === status.user_id) return true;
if (status.visibility === "private") return viewerFollowsOwner(env, viewer, status.user_id);
return false;
}
async function viewerFollowsOwner(env: Env, viewer: StatusViewer, ownerUserId: string): Promise<boolean> {
if (!viewer.actor) return false;
const cached = viewer.followsByOwnerId.get(ownerUserId);
if (cached !== undefined) return cached;
const row = await env.DB.prepare(
"SELECT 1 AS hit FROM follows WHERE local_user_id = ? AND follower_actor = ? AND accepted = 1 LIMIT 1"
).bind(ownerUserId, viewer.actor).first<{ hit: number }>();
const follows = Boolean(row?.hit);
viewer.followsByOwnerId.set(ownerUserId, follows);
return follows;
}
async function viewerUser(request: Request, env: Env): Promise<User | 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");
if (!session) return null;
const user = await getUserById(env, session.userId);
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;
return getUserById(env, session.userId);
}
async function loadBookmarkedStatusIds(env: Env, userId: string, statusIds: string[]): Promise<Set<string>> {