提交
This commit is contained in:
+215
-11
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user