fix
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
-- Keep federation delivery and home timeline lookups indexed as caches grow.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_actor_cache_inbox ON actor_cache(inbox);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_outgoing_follows_target_accepted ON outgoing_follows(target_actor, accepted);
|
||||||
Generated
+210
-257
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -11,8 +11,8 @@
|
|||||||
"db:remote": "wrangler d1 migrations apply toot_db --remote"
|
"db:remote": "wrangler d1 migrations apply toot_db --remote"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20260507.0",
|
"@cloudflare/workers-types": "^4.20260617.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"wrangler": "^4.37.0"
|
"wrangler": "^4.102.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
Marker,
|
Marker,
|
||||||
OutgoingDelivery,
|
OutgoingDelivery,
|
||||||
OutgoingFollow,
|
OutgoingFollow,
|
||||||
|
OAuthToken,
|
||||||
Reblog,
|
Reblog,
|
||||||
RemoteActor,
|
RemoteActor,
|
||||||
Status,
|
Status,
|
||||||
@@ -21,7 +22,17 @@ import type {
|
|||||||
import { ACTOR_CACHE_TTL_MS } from "./types";
|
import { ACTOR_CACHE_TTL_MS } from "./types";
|
||||||
import { id } from "./util";
|
import { id } from "./util";
|
||||||
|
|
||||||
|
let adminUserReady: Promise<void> | null = null;
|
||||||
|
|
||||||
export async function ensureAdminUser(env: Env): Promise<void> {
|
export async function ensureAdminUser(env: Env): Promise<void> {
|
||||||
|
adminUserReady ??= ensureAdminUserOnce(env).catch((error) => {
|
||||||
|
adminUserReady = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
await adminUserReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAdminUserOnce(env: Env): Promise<void> {
|
||||||
const existing = await env.DB.prepare("SELECT id FROM users WHERE username = ?").bind(env.ADMIN_USERNAME).first<{ id: string }>();
|
const existing = await env.DB.prepare("SELECT id FROM users WHERE username = ?").bind(env.ADMIN_USERNAME).first<{ id: string }>();
|
||||||
if (existing) return;
|
if (existing) return;
|
||||||
const adminPassword = env.ADMIN_PASSWORD;
|
const adminPassword = env.ADMIN_PASSWORD;
|
||||||
@@ -306,6 +317,10 @@ export async function insertOAuthToken(env: Env, token: string, userId: string,
|
|||||||
.bind(token, userId, appId, scopes, new Date().toISOString()).run();
|
.bind(token, userId, appId, scopes, new Date().toISOString()).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getOAuthToken(env: Env, token: string): Promise<OAuthToken | null> {
|
||||||
|
return env.DB.prepare("SELECT * FROM oauth_tokens WHERE token = ?").bind(token).first<OAuthToken>();
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteOAuthToken(env: Env, token: string): Promise<void> {
|
export async function deleteOAuthToken(env: Env, token: string): Promise<void> {
|
||||||
await env.DB.prepare("DELETE FROM oauth_tokens WHERE token = ?").bind(token).run();
|
await env.DB.prepare("DELETE FROM oauth_tokens WHERE token = ?").bind(token).run();
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-13
@@ -25,9 +25,11 @@ import { actorUrl, base64Decode, encoder, hostFromBaseUrl, parseAcctFromActor }
|
|||||||
|
|
||||||
const ACTIVITY_HEADERS = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
|
const ACTIVITY_HEADERS = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
|
||||||
const DELIVERY_BATCH_SIZE = 20;
|
const DELIVERY_BATCH_SIZE = 20;
|
||||||
|
const DELIVERY_CONCURRENCY = 4;
|
||||||
const DELIVERY_MAX_ATTEMPTS = 8;
|
const DELIVERY_MAX_ATTEMPTS = 8;
|
||||||
const DELIVERY_LEASE_MS = 60_000;
|
const DELIVERY_LEASE_MS = 60_000;
|
||||||
const DELIVERY_MAX_BACKOFF_SECONDS = 60 * 60;
|
const DELIVERY_MAX_BACKOFF_SECONDS = 60 * 60;
|
||||||
|
const REMOTE_FETCH_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
export async function resolveRemoteActor(env: Env, actorId: string, opts: { force?: boolean } = {}): Promise<ActorCache | null> {
|
export async function resolveRemoteActor(env: Env, actorId: string, opts: { force?: boolean } = {}): Promise<ActorCache | null> {
|
||||||
if (!actorId) return null;
|
if (!actorId) return null;
|
||||||
@@ -42,6 +44,7 @@ export async function fetchRemoteActor(actorId: string): Promise<RemoteActor | n
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(actorId, {
|
const response = await fetch(actorId, {
|
||||||
headers: { accept: ACTIVITY_HEADERS },
|
headers: { accept: ACTIVITY_HEADERS },
|
||||||
|
signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS),
|
||||||
cf: { cacheTtl: 60 }
|
cf: { cacheTtl: 60 }
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
@@ -151,7 +154,8 @@ export async function sendSignedActivity(env: Env, user: User, inboxUrl: string,
|
|||||||
signature: headerValue,
|
signature: headerValue,
|
||||||
"user-agent": `toot-worker (+https://${hostFromBaseUrl(env)})`
|
"user-agent": `toot-worker (+https://${hostFromBaseUrl(env)})`
|
||||||
},
|
},
|
||||||
body
|
body,
|
||||||
|
signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS)
|
||||||
});
|
});
|
||||||
const text = response.ok ? "" : await response.text().catch(() => "");
|
const text = response.ok ? "" : await response.text().catch(() => "");
|
||||||
if (!response.ok) console.warn("signed-delivery", inboxUrl, response.status, text.slice(0, 200));
|
if (!response.ok) console.warn("signed-delivery", inboxUrl, response.status, text.slice(0, 200));
|
||||||
@@ -169,9 +173,18 @@ export async function deliverToInboxes(env: Env, user: User, inboxes: Iterable<s
|
|||||||
export async function processOutgoingDeliveries(env: Env): Promise<void> {
|
export async function processOutgoingDeliveries(env: Env): Promise<void> {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const deliveries = await listDueOutgoingDeliveries(env, now, DELIVERY_BATCH_SIZE);
|
const deliveries = await listDueOutgoingDeliveries(env, now, DELIVERY_BATCH_SIZE);
|
||||||
for (const delivery of deliveries) {
|
await runWithConcurrency(deliveries, DELIVERY_CONCURRENCY, (delivery) => processOutgoingDelivery(env, delivery));
|
||||||
await processOutgoingDelivery(env, delivery);
|
}
|
||||||
|
|
||||||
|
async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
|
||||||
|
let cursor = 0;
|
||||||
|
const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||||
|
while (cursor < items.length) {
|
||||||
|
const item = items[cursor++];
|
||||||
|
await worker(item);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(runners);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processOutgoingDelivery(env: Env, delivery: OutgoingDelivery): Promise<void> {
|
async function processOutgoingDelivery(env: Env, delivery: OutgoingDelivery): Promise<void> {
|
||||||
@@ -218,17 +231,12 @@ function nextDeliveryAttemptAt(attempts: number): string {
|
|||||||
|
|
||||||
export async function gatherFollowerInboxes(env: Env, userId: string): Promise<string[]> {
|
export async function gatherFollowerInboxes(env: Env, userId: string): Promise<string[]> {
|
||||||
const rows = await env.DB.prepare(
|
const rows = await env.DB.prepare(
|
||||||
"SELECT inbox FROM follows WHERE local_user_id = ? AND accepted = 1"
|
`SELECT COALESCE(ac.shared_inbox, f.inbox) AS inbox
|
||||||
|
FROM follows f
|
||||||
|
LEFT JOIN actor_cache ac ON ac.inbox = f.inbox
|
||||||
|
WHERE f.local_user_id = ? AND f.accepted = 1`
|
||||||
).bind(userId).all<{ inbox: string }>();
|
).bind(userId).all<{ inbox: string }>();
|
||||||
const inboxes = new Set<string>();
|
return [...new Set(rows.results.map((row) => row.inbox).filter(Boolean))];
|
||||||
for (const row of rows.results) {
|
|
||||||
if (row.inbox) {
|
|
||||||
const actor = await getActorFromCache(env, row.inbox);
|
|
||||||
const shared = (await getActorByKeyId(env, row.inbox))?.shared_inbox;
|
|
||||||
inboxes.add(actor?.shared_inbox ?? shared ?? row.inbox);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...inboxes];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveDeliveryInboxes(env: Env, actorIds: Iterable<string>): Promise<string[]> {
|
export async function resolveDeliveryInboxes(env: Env, actorIds: Iterable<string>): Promise<string[]> {
|
||||||
|
|||||||
@@ -97,7 +97,9 @@ export default {
|
|||||||
try {
|
try {
|
||||||
await ensureAdminUser(env);
|
await ensureAdminUser(env);
|
||||||
const response = await route(request, env);
|
const response = await route(request, env);
|
||||||
|
if (shouldDrainOutgoingDeliveries(request)) {
|
||||||
ctx.waitUntil(processOutgoingDeliveries(env));
|
ctx.waitUntil(processOutgoingDeliveries(env));
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpError) return json({ error: error.message }, error.status);
|
if (error instanceof HttpError) return json({ error: error.message }, error.status);
|
||||||
@@ -252,3 +254,8 @@ function wantsHtmlDocument(request: Request): boolean {
|
|||||||
const accept = request.headers.get("accept") ?? "";
|
const accept = request.headers.get("accept") ?? "";
|
||||||
return /\btext\/html\b/i.test(accept) && !/(application\/activity\+json|application\/ld\+json)/i.test(accept);
|
return /\btext\/html\b/i.test(accept) && !/(application\/activity\+json|application\/ld\+json)/i.test(accept);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldDrainOutgoingDeliveries(request: Request): boolean {
|
||||||
|
const method = request.method.toUpperCase();
|
||||||
|
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
||||||
|
}
|
||||||
|
|||||||
+98
-23
@@ -27,6 +27,7 @@ import {
|
|||||||
getActorFromCache,
|
getActorFromCache,
|
||||||
getAdminUser,
|
getAdminUser,
|
||||||
getAppByClientId,
|
getAppByClientId,
|
||||||
|
getOAuthToken,
|
||||||
getCachedStatusByObjectId,
|
getCachedStatusByObjectId,
|
||||||
getStatus,
|
getStatus,
|
||||||
getUserById,
|
getUserById,
|
||||||
@@ -43,7 +44,8 @@ import {
|
|||||||
saveMarker,
|
saveMarker,
|
||||||
setUserAvatarKey,
|
setUserAvatarKey,
|
||||||
setUserHeaderKey,
|
setUserHeaderKey,
|
||||||
takeOAuthCode
|
takeOAuthCode,
|
||||||
|
touchOAuthToken
|
||||||
} from "./db";
|
} from "./db";
|
||||||
import {
|
import {
|
||||||
deliverToInboxes,
|
deliverToInboxes,
|
||||||
@@ -103,7 +105,7 @@ import {
|
|||||||
|
|
||||||
const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90;
|
const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90;
|
||||||
const MAX_STATUS_CHARS = 5000;
|
const MAX_STATUS_CHARS = 5000;
|
||||||
const REPORTED_MEDIA_ATTACHMENTS_LIMIT = 9999;
|
const MAX_MEDIA_ATTACHMENTS = 20;
|
||||||
const MAX_MEDIA_BYTES = 10 * 1024 * 1024;
|
const MAX_MEDIA_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
const SUPPORTED_MIME = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
const SUPPORTED_MIME = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||||
@@ -175,7 +177,7 @@ export async function instance(env: Env): Promise<Response> {
|
|||||||
approval_required: false,
|
approval_required: false,
|
||||||
invites_enabled: false,
|
invites_enabled: false,
|
||||||
configuration: {
|
configuration: {
|
||||||
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: REPORTED_MEDIA_ATTACHMENTS_LIMIT, characters_reserved_per_url: 23 },
|
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: MAX_MEDIA_ATTACHMENTS, characters_reserved_per_url: 23 },
|
||||||
media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 },
|
media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 },
|
||||||
polls: { max_options: MAX_POLL_OPTIONS, max_characters_per_option: MAX_POLL_OPTION_CHARS, min_expiration: MIN_POLL_EXPIRATION_SECONDS, max_expiration: MAX_POLL_EXPIRATION_SECONDS }
|
polls: { max_options: MAX_POLL_OPTIONS, max_characters_per_option: MAX_POLL_OPTION_CHARS, min_expiration: MIN_POLL_EXPIRATION_SECONDS, max_expiration: MAX_POLL_EXPIRATION_SECONDS }
|
||||||
},
|
},
|
||||||
@@ -198,7 +200,7 @@ export async function instanceV2(env: Env): Promise<Response> {
|
|||||||
configuration: {
|
configuration: {
|
||||||
urls: { streaming: `wss://${hostFromBaseUrl(env)}` },
|
urls: { streaming: `wss://${hostFromBaseUrl(env)}` },
|
||||||
accounts: { max_featured_tags: 0 },
|
accounts: { max_featured_tags: 0 },
|
||||||
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: REPORTED_MEDIA_ATTACHMENTS_LIMIT, characters_reserved_per_url: 23 },
|
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: MAX_MEDIA_ATTACHMENTS, characters_reserved_per_url: 23 },
|
||||||
media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 },
|
media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 },
|
||||||
polls: { max_options: MAX_POLL_OPTIONS, max_characters_per_option: MAX_POLL_OPTION_CHARS, min_expiration: MIN_POLL_EXPIRATION_SECONDS, max_expiration: MAX_POLL_EXPIRATION_SECONDS }
|
polls: { max_options: MAX_POLL_OPTIONS, max_characters_per_option: MAX_POLL_OPTION_CHARS, min_expiration: MIN_POLL_EXPIRATION_SECONDS, max_expiration: MAX_POLL_EXPIRATION_SECONDS }
|
||||||
},
|
},
|
||||||
@@ -242,8 +244,9 @@ export async function verifyAppCredentials(request: Request, env: Env): Promise<
|
|||||||
const auth = request.headers.get("authorization") ?? "";
|
const auth = request.headers.get("authorization") ?? "";
|
||||||
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
|
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
|
||||||
if (!token) throw new HttpError(401, "The access token is invalid");
|
if (!token) throw new HttpError(401, "The access token is invalid");
|
||||||
const session = await env.KV.get<Session>(`token:${token}`, "json");
|
const session = await loadSession(env, token);
|
||||||
if (!session) throw new HttpError(401, "The access token is invalid");
|
if (!session) throw new HttpError(401, "The access token is invalid");
|
||||||
|
requireScopes(session, ["read"]);
|
||||||
const app = await env.DB.prepare("SELECT * FROM oauth_apps WHERE id = ?").bind(session.appId).first<{ name: string; website: string | null }>();
|
const app = await env.DB.prepare("SELECT * FROM oauth_apps WHERE id = ?").bind(session.appId).first<{ name: string; website: string | null }>();
|
||||||
return json({ name: app?.name ?? "Mastodon App", website: app?.website ?? null, vapid_key: "" });
|
return json({ name: app?.name ?? "Mastodon App", website: app?.website ?? null, vapid_key: "" });
|
||||||
}
|
}
|
||||||
@@ -285,7 +288,7 @@ export async function authorize(request: Request, env: Env): Promise<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const code = tokenString(32);
|
const code = tokenString(32);
|
||||||
const scope = bodyString(body, "scope", app.scopes);
|
const scope = requestedScopesWithinApp(bodyString(body, "scope", app.scopes), app.scopes);
|
||||||
await env.DB.prepare("INSERT INTO oauth_codes (code, app_id, user_id, redirect_uri, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)")
|
await env.DB.prepare("INSERT INTO oauth_codes (code, app_id, user_id, redirect_uri, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)")
|
||||||
.bind(code, app.id, user.id, redirectUri, scope, Math.floor(Date.now() / 1000) + 600)
|
.bind(code, app.id, user.id, redirectUri, scope, Math.floor(Date.now() / 1000) + 600)
|
||||||
.run();
|
.run();
|
||||||
@@ -311,9 +314,9 @@ export async function token(request: Request, env: Env): Promise<Response> {
|
|||||||
const user = await getUserByUsername(env, bodyString(body, "username"));
|
const user = await getUserByUsername(env, bodyString(body, "username"));
|
||||||
if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) return json({ error: "invalid_grant" }, 400);
|
if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) return json({ error: "invalid_grant" }, 400);
|
||||||
userId = user.id;
|
userId = user.id;
|
||||||
scopes = bodyString(body, "scope", app.scopes);
|
scopes = requestedScopesWithinApp(bodyString(body, "scope", app.scopes), app.scopes);
|
||||||
} else if (grantType === "client_credentials") {
|
} else if (grantType === "client_credentials") {
|
||||||
scopes = bodyString(body, "scope", "read");
|
scopes = requestedScopesWithinApp(bodyString(body, "scope", "read"), app.scopes);
|
||||||
} else {
|
} else {
|
||||||
const row = await takeOAuthCode(env, bodyString(body, "code"));
|
const row = await takeOAuthCode(env, bodyString(body, "code"));
|
||||||
if (!row || row.app_id !== app.id) return json({ error: "invalid_grant" }, 400);
|
if (!row || row.app_id !== app.id) return json({ error: "invalid_grant" }, 400);
|
||||||
@@ -407,12 +410,17 @@ export async function updateCredentials(request: Request, env: Env): Promise<Res
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function storeProfileAsset(env: Env, userId: string, kind: "avatar" | "header", file: File): Promise<string> {
|
async function storeProfileAsset(env: Env, userId: string, kind: "avatar" | "header", file: File): Promise<string> {
|
||||||
|
if (!isSupportedImageMime(file.type)) throw new HttpError(415, "unsupported media type");
|
||||||
const ext = mimeExtension(file.type) ?? safeFileName(file.name).split(".").pop() ?? "bin";
|
const ext = mimeExtension(file.type) ?? safeFileName(file.name).split(".").pop() ?? "bin";
|
||||||
const key = `${userId}/${kind}-${id()}.${ext}`;
|
const key = `${userId}/${kind}-${id()}.${ext}`;
|
||||||
await env.MEDIA.put(key, file.stream(), { httpMetadata: { contentType: file.type || "application/octet-stream" } });
|
await env.MEDIA.put(key, file.stream(), { httpMetadata: { contentType: file.type || "application/octet-stream" } });
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSupportedImageMime(mime: string): boolean {
|
||||||
|
return SUPPORTED_MIME.includes(mime) || mime === "image/jpg";
|
||||||
|
}
|
||||||
|
|
||||||
function mimeExtension(mime: string): string | null {
|
function mimeExtension(mime: string): string | null {
|
||||||
switch (mime) {
|
switch (mime) {
|
||||||
case "image/jpeg": case "image/jpg": return "jpg";
|
case "image/jpeg": case "image/jpg": return "jpg";
|
||||||
@@ -740,6 +748,8 @@ function parseStatusCreateInput(body: ParsedBody): StatusCreateInput {
|
|||||||
if (pollOptions.some((option) => option.length > MAX_POLL_OPTION_CHARS)) throw new HttpError(422, "poll_option_too_long");
|
if (pollOptions.some((option) => option.length > MAX_POLL_OPTION_CHARS)) throw new HttpError(422, "poll_option_too_long");
|
||||||
|
|
||||||
const pollExpiresIn = pollOptions.length > 0 ? parsePollExpiresIn(bodyString(body, "poll[expires_in]", String(MIN_POLL_EXPIRATION_SECONDS))) : null;
|
const pollExpiresIn = pollOptions.length > 0 ? parsePollExpiresIn(bodyString(body, "poll[expires_in]", String(MIN_POLL_EXPIRATION_SECONDS))) : null;
|
||||||
|
const mediaIds = bodyArray(body, "media_ids");
|
||||||
|
if (mediaIds.length > MAX_MEDIA_ATTACHMENTS) throw new HttpError(422, "too_many_media_attachments");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusText,
|
statusText,
|
||||||
@@ -748,7 +758,7 @@ function parseStatusCreateInput(body: ParsedBody): StatusCreateInput {
|
|||||||
visibility,
|
visibility,
|
||||||
inReplyTo: bodyString(body, "in_reply_to_id"),
|
inReplyTo: bodyString(body, "in_reply_to_id"),
|
||||||
language: bodyString(body, "language", "en"),
|
language: bodyString(body, "language", "en"),
|
||||||
mediaIds: bodyArray(body, "media_ids"),
|
mediaIds,
|
||||||
pollOptions,
|
pollOptions,
|
||||||
pollExpiresIn,
|
pollExpiresIn,
|
||||||
pollMultiple: bodyString(body, "poll[multiple]") === "true",
|
pollMultiple: bodyString(body, "poll[multiple]") === "true",
|
||||||
@@ -766,6 +776,8 @@ function parseStatusEditInput(body: ParsedBody, existing: Status): StatusEditInp
|
|||||||
|
|
||||||
const visibility = bodyString(body, "visibility", existing.visibility);
|
const visibility = bodyString(body, "visibility", existing.visibility);
|
||||||
if (!isStatusVisibility(visibility)) throw new HttpError(422, "invalid_visibility");
|
if (!isStatusVisibility(visibility)) throw new HttpError(422, "invalid_visibility");
|
||||||
|
const mediaIds = Object.prototype.hasOwnProperty.call(body, "media_ids") ? bodyArray(body, "media_ids") : null;
|
||||||
|
if (mediaIds && mediaIds.length > MAX_MEDIA_ATTACHMENTS) throw new HttpError(422, "too_many_media_attachments");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusText,
|
statusText,
|
||||||
@@ -775,7 +787,7 @@ function parseStatusEditInput(body: ParsedBody, existing: Status): StatusEditInp
|
|||||||
: Boolean(existing.sensitive),
|
: Boolean(existing.sensitive),
|
||||||
visibility,
|
visibility,
|
||||||
language: bodyString(body, "language", existing.language || "en"),
|
language: bodyString(body, "language", existing.language || "en"),
|
||||||
mediaIds: Object.prototype.hasOwnProperty.call(body, "media_ids") ? bodyArray(body, "media_ids") : null
|
mediaIds
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1381,6 +1393,7 @@ export async function uploadMedia(request: Request, env: Env): Promise<Response>
|
|||||||
const file = form.get("file");
|
const file = form.get("file");
|
||||||
if (!(file instanceof File)) return json({ error: "file is required" }, 422);
|
if (!(file instanceof File)) return json({ error: "file is required" }, 422);
|
||||||
if (file.size > MAX_MEDIA_BYTES) return json({ error: "file too large" }, 413);
|
if (file.size > MAX_MEDIA_BYTES) return json({ error: "file too large" }, 413);
|
||||||
|
if (!isSupportedImageMime(file.type)) return json({ error: "unsupported media type" }, 415);
|
||||||
|
|
||||||
const mediaId = id();
|
const mediaId = id();
|
||||||
const key = `${user.id}/${mediaId}/${safeFileName(file.name || "upload")}`;
|
const key = `${user.id}/${mediaId}/${safeFileName(file.name || "upload")}`;
|
||||||
@@ -1606,7 +1619,7 @@ export async function listTimeline(request: Request, env: Env, listId: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function followAccount(request: Request, env: Env, accountId: string): Promise<Response> {
|
export async function followAccount(request: Request, env: Env, accountId: string): Promise<Response> {
|
||||||
const user = await requireUser(request, env);
|
const user = await requireUser(request, env, ["follow"]);
|
||||||
const target = await resolveAccountTarget(env, accountId);
|
const target = await resolveAccountTarget(env, accountId);
|
||||||
if (!target) return json({ error: "Record not found" }, 404);
|
if (!target) return json({ error: "Record not found" }, 404);
|
||||||
|
|
||||||
@@ -1630,7 +1643,7 @@ export async function followAccount(request: Request, env: Env, accountId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function unfollowAccount(request: Request, env: Env, accountId: string): Promise<Response> {
|
export async function unfollowAccount(request: Request, env: Env, accountId: string): Promise<Response> {
|
||||||
const user = await requireUser(request, env);
|
const user = await requireUser(request, env, ["follow"]);
|
||||||
const target = await resolveAccountTarget(env, accountId);
|
const target = await resolveAccountTarget(env, accountId);
|
||||||
if (!target) return json({ error: "Record not found" }, 404);
|
if (!target) return json({ error: "Record not found" }, 404);
|
||||||
|
|
||||||
@@ -1649,23 +1662,23 @@ export async function unfollowAccount(request: Request, env: Env, accountId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function followRequestsList(request: Request, env: Env): Promise<Response> {
|
export async function followRequestsList(request: Request, env: Env): Promise<Response> {
|
||||||
await requireUser(request, env);
|
await requireUser(request, env, ["follow"]);
|
||||||
return json([]);
|
return json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authorizeFollowRequest(request: Request, env: Env, _accountId: string): Promise<Response> {
|
export async function authorizeFollowRequest(request: Request, env: Env, _accountId: string): Promise<Response> {
|
||||||
await requireUser(request, env);
|
await requireUser(request, env, ["follow"]);
|
||||||
return json({ id: _accountId, following: true, requested: false });
|
return json({ id: _accountId, following: true, requested: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rejectFollowRequest(request: Request, env: Env, _accountId: string): Promise<Response> {
|
export async function rejectFollowRequest(request: Request, env: Env, _accountId: string): Promise<Response> {
|
||||||
await requireUser(request, env);
|
await requireUser(request, env, ["follow"]);
|
||||||
return json({ id: _accountId, following: false, requested: false });
|
return json({ id: _accountId, following: false, requested: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function search(request: Request, env: Env): Promise<Response> {
|
export async function search(request: Request, env: Env): Promise<Response> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const q = (url.searchParams.get("q") ?? "").trim();
|
const q = (url.searchParams.get("q") ?? "").trim().slice(0, 128);
|
||||||
const type = url.searchParams.get("type");
|
const type = url.searchParams.get("type");
|
||||||
const accounts: unknown[] = [];
|
const accounts: unknown[] = [];
|
||||||
const statuses: unknown[] = [];
|
const statuses: unknown[] = [];
|
||||||
@@ -1686,7 +1699,7 @@ export async function search(request: Request, env: Env): Promise<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const rows = await env.DB.prepare("SELECT * FROM users WHERE username LIKE ? LIMIT 20").bind(`%${q}%`).all<User>();
|
const rows = await env.DB.prepare("SELECT * FROM users WHERE username LIKE ? ESCAPE '\\' LIMIT 20").bind(likeContains(q)).all<User>();
|
||||||
for (const row of rows.results) accounts.push(await accountJson(env, row));
|
for (const row of rows.results) accounts.push(await accountJson(env, row));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1697,20 +1710,24 @@ export async function search(request: Request, env: Env): Promise<Response> {
|
|||||||
if (remoteStatus && await canViewerViewCachedStatus(env, remoteStatus, viewer)) {
|
if (remoteStatus && await canViewerViewCachedStatus(env, remoteStatus, viewer)) {
|
||||||
statuses.push(await cachedStatusToMastodon(env, remoteStatus));
|
statuses.push(await cachedStatusToMastodon(env, remoteStatus));
|
||||||
}
|
}
|
||||||
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 rows = await env.DB.prepare("SELECT * FROM statuses WHERE content LIKE ? ESCAPE '\\' ORDER BY created_at DESC LIMIT 100").bind(likeContains(q)).all<Status>();
|
||||||
const visibleRows = await filterStatusesForViewer(env, rows.results, viewer);
|
const visibleRows = await filterStatusesForViewer(env, rows.results, viewer);
|
||||||
statuses.push(...await serializeStatuses(env, visibleRows.slice(0, 20), request));
|
statuses.push(...await serializeStatuses(env, visibleRows.slice(0, 20), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!type || type === "hashtags") {
|
if (!type || type === "hashtags") {
|
||||||
const tag = q.replace(/^#/, "");
|
const tag = q.replace(/^#/, "");
|
||||||
const rows = await env.DB.prepare("SELECT tag, COUNT(*) AS count FROM hashtags WHERE tag LIKE ? GROUP BY tag LIMIT 20").bind(`%${tag}%`).all<{ tag: string; count: number }>();
|
const rows = await env.DB.prepare("SELECT tag, COUNT(*) AS count FROM hashtags WHERE tag LIKE ? ESCAPE '\\' GROUP BY tag LIMIT 20").bind(likeContains(tag)).all<{ tag: string; count: number }>();
|
||||||
for (const row of rows.results) hashtags.push({ name: row.tag, url: `${baseUrl(env)}/tags/${encodeURIComponent(row.tag)}`, history: [] });
|
for (const row of rows.results) hashtags.push({ name: row.tag, url: `${baseUrl(env)}/tags/${encodeURIComponent(row.tag)}`, history: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ accounts, statuses, hashtags });
|
return json({ accounts, statuses, hashtags });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function likeContains(value: string): string {
|
||||||
|
return `%${value.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveRemoteStatusSearch(env: Env, q: string): Promise<CachedStatus | null> {
|
async function resolveRemoteStatusSearch(env: Env, q: string): Promise<CachedStatus | null> {
|
||||||
const url = parseSearchUrl(q);
|
const url = parseSearchUrl(q);
|
||||||
if (!url || url.host.toLowerCase() === hostFromBaseUrl(env).toLowerCase()) return null;
|
if (!url || url.host.toLowerCase() === hostFromBaseUrl(env).toLowerCase()) return null;
|
||||||
@@ -2369,7 +2386,8 @@ async function resolveAcct(env: Env, acct: string): Promise<{ acct: string; acto
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const wf = await fetch(`https://${targetHost}/.well-known/webfinger?resource=acct:${name}@${targetHost}`, {
|
const wf = await fetch(`https://${targetHost}/.well-known/webfinger?resource=acct:${name}@${targetHost}`, {
|
||||||
headers: { accept: "application/jrd+json, application/json" }
|
headers: { accept: "application/jrd+json, application/json" },
|
||||||
|
signal: AbortSignal.timeout(10_000)
|
||||||
});
|
});
|
||||||
if (!wf.ok) return null;
|
if (!wf.ok) return null;
|
||||||
const doc = await wf.json() as { links?: { rel: string; type?: string; href: string }[] };
|
const doc = await wf.json() as { links?: { rel: string; type?: string; href: string }[] };
|
||||||
@@ -2750,8 +2768,9 @@ async function viewerUser(request: Request, env: Env): Promise<User | null> {
|
|||||||
const auth = request.headers.get("authorization") ?? "";
|
const auth = request.headers.get("authorization") ?? "";
|
||||||
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
|
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
const session = await env.KV.get<Session>(`token:${token}`, "json");
|
const session = await loadSession(env, token);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
|
if (!hasAnyScope(session, ["read"])) return null;
|
||||||
return getUserById(env, session.userId);
|
return getUserById(env, session.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2773,15 +2792,71 @@ async function loadPinnedStatusIds(env: Env, userId: string, statusIds: string[]
|
|||||||
return new Set(rows.results.map((row) => row.status_id));
|
return new Set(rows.results.map((row) => row.status_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireUser(request: Request, env: Env): Promise<User> {
|
async function requireUser(request: Request, env: Env, scopes = defaultRequiredScopes(request)): Promise<User> {
|
||||||
const auth = request.headers.get("authorization") ?? "";
|
const auth = request.headers.get("authorization") ?? "";
|
||||||
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
|
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
|
||||||
if (!token) throw new HttpError(401, "The access token is invalid");
|
if (!token) throw new HttpError(401, "The access token is invalid");
|
||||||
const session = await env.KV.get<Session>(`token:${token}`, "json");
|
const session = await loadSession(env, token);
|
||||||
if (!session) throw new HttpError(401, "The access token is invalid");
|
if (!session) throw new HttpError(401, "The access token is invalid");
|
||||||
|
requireScopes(session, scopes);
|
||||||
const user = await getUserById(env, session.userId);
|
const user = await getUserById(env, session.userId);
|
||||||
if (!user) throw new HttpError(401, "The access token is invalid");
|
if (!user) throw new HttpError(401, "The access token is invalid");
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSession(env: Env, token: string): Promise<Session | null> {
|
||||||
|
const session = await env.KV.get<Session>(`token:${token}`, "json");
|
||||||
|
if (session) return session;
|
||||||
|
|
||||||
|
const row = await getOAuthToken(env, token);
|
||||||
|
if (!row) return null;
|
||||||
|
const createdAt = Date.parse(row.created_at);
|
||||||
|
if (!Number.isFinite(createdAt) || Date.now() - createdAt > TOKEN_TTL_SECONDS * 1000) {
|
||||||
|
await deleteOAuthToken(env, token).catch(() => undefined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const restored = { userId: row.user_id, appId: row.app_id, scopes: row.scopes } satisfies Session;
|
||||||
|
await Promise.allSettled([
|
||||||
|
env.KV.put(`token:${token}`, JSON.stringify(restored), { expirationTtl: TOKEN_TTL_SECONDS }),
|
||||||
|
touchOAuthToken(env, token)
|
||||||
|
]);
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRequiredScopes(request: Request): string[] {
|
||||||
|
return request.method.toUpperCase() === "GET" ? ["read"] : ["write"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestedScopesWithinApp(requested: string, appScopes: string): string {
|
||||||
|
const requestedScopes = normalizeScopes(requested || appScopes);
|
||||||
|
const allowed = normalizeScopes(appScopes);
|
||||||
|
if (requestedScopes.length === 0) return appScopes;
|
||||||
|
if (requestedScopes.every((scope) => scopeAllowed(scope, allowed))) return requestedScopes.join(" ");
|
||||||
|
throw new HttpError(400, "invalid_scope");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireScopes(session: Session, required: string[]): void {
|
||||||
|
if (!hasAllScopes(session, required)) throw new HttpError(403, "insufficient_scope");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAllScopes(session: Session, required: string[]): boolean {
|
||||||
|
return required.every((scope) => hasAnyScope(session, [scope]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnyScope(session: Session, required: string[]): boolean {
|
||||||
|
const granted = normalizeScopes(session.scopes);
|
||||||
|
return required.some((scope) => scopeAllowed(scope, granted));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeAllowed(required: string, granted: string[]): boolean {
|
||||||
|
if (granted.includes(required)) return true;
|
||||||
|
if (!required.includes(":") && granted.some((scope) => scope.startsWith(`${required}:`))) return true;
|
||||||
|
const root = required.split(":")[0];
|
||||||
|
return granted.includes(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScopes(scopes: string): string[] {
|
||||||
|
return [...new Set(scopes.split(/\s+/).map((scope) => scope.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
export { requireUser };
|
export { requireUser };
|
||||||
|
|||||||
Reference in New Issue
Block a user