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
+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];