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 -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);