投票支持

This commit is contained in:
浪子
2026-05-14 19:29:34 +08:00
parent a2badc2d4f
commit e55a1a063d
5 changed files with 819 additions and 42 deletions
+46 -2
View File
@@ -18,14 +18,20 @@ import {
accountStatuses,
accountFollowers,
accountFollowing,
addListAccounts,
authorize,
authorizeFollowRequest,
authorizePage,
bookmarkStatus,
bookmarksList,
createApp,
createList,
createPushSubscription,
createStatus,
customEmojis,
deleteList,
deletePushSubscription,
deleteScheduledStatus,
deleteStatusEndpoint,
favouriteStatus,
favouritesList,
@@ -33,23 +39,32 @@ import {
followAccount,
followRequestsList,
getAccount,
getList,
getPoll,
getPushSubscription,
getRelationships,
getScheduledStatus,
getStatusEndpoint,
hashtagInfo,
hashtagTimeline,
homeTimeline,
instance,
instanceV2,
listAccounts,
listScheduledStatuses,
listTimeline,
listsList,
lookupAccount,
markersList,
notificationClear,
notificationDismiss,
notificationsList,
pinStatus,
publishDueScheduledStatuses,
publicTimeline,
pushSubscription,
reblogStatus,
rejectFollowRequest,
removeListAccounts,
revoke,
search,
serveMedia,
@@ -61,9 +76,13 @@ import {
unfollowAccount,
unpinStatus,
unreblogStatus,
updateList,
updatePushSubscription,
updateScheduledStatus,
updateCredentials,
updateMedia,
uploadMedia,
votePoll,
verifyAppCredentials,
verifyCredentials
} from "./mastodon";
@@ -72,12 +91,17 @@ export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
await ensureAdminUser(env);
await publishDueScheduledStatuses(env);
return await route(request, env);
} catch (error) {
if (error instanceof HttpError) return json({ error: error.message }, error.status);
console.error("unhandled", error);
return json({ error: "internal_server_error" }, 500);
}
},
async scheduled(_event: ScheduledEvent, env: Env): Promise<void> {
await ensureAdminUser(env);
await publishDueScheduledStatuses(env);
}
};
@@ -138,12 +162,29 @@ async function route(request: Request, env: Env): Promise<Response> {
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" && (m = path.match(/^\/api\/v1\/polls\/([^/]+)$/))) return getPoll(request, env, decodeURIComponent(m[1]));
if (method === "POST" && (m = path.match(/^\/api\/v1\/polls\/([^/]+)\/votes$/))) return votePoll(request, env, decodeURIComponent(m[1]));
if (method === "GET" && path === "/api/v1/scheduled_statuses") return listScheduledStatuses(request, env);
if (method === "GET" && (m = path.match(/^\/api\/v1\/scheduled_statuses\/([^/]+)$/))) return getScheduledStatus(request, env, decodeURIComponent(m[1]));
if ((method === "PUT" || method === "PATCH") && (m = path.match(/^\/api\/v1\/scheduled_statuses\/([^/]+)$/))) return updateScheduledStatus(request, env, decodeURIComponent(m[1]));
if (method === "DELETE" && (m = path.match(/^\/api\/v1\/scheduled_statuses\/([^/]+)$/))) return deleteScheduledStatus(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\/list\/([^/]+)$/))) return listTimeline(request, env, decodeURIComponent(m[1]));
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/lists") return listsList(request, env);
if (method === "POST" && path === "/api/v1/lists") return createList(request, env);
if (method === "GET" && (m = path.match(/^\/api\/v1\/lists\/([^/]+)$/))) return getList(request, env, decodeURIComponent(m[1]));
if ((method === "PUT" || method === "PATCH") && (m = path.match(/^\/api\/v1\/lists\/([^/]+)$/))) return updateList(request, env, decodeURIComponent(m[1]));
if (method === "DELETE" && (m = path.match(/^\/api\/v1\/lists\/([^/]+)$/))) return deleteList(request, env, decodeURIComponent(m[1]));
if (method === "GET" && (m = path.match(/^\/api\/v1\/lists\/([^/]+)\/accounts$/))) return listAccounts(request, env, decodeURIComponent(m[1]));
if (method === "POST" && (m = path.match(/^\/api\/v1\/lists\/([^/]+)\/accounts$/))) return addListAccounts(request, env, decodeURIComponent(m[1]));
if (method === "DELETE" && (m = path.match(/^\/api\/v1\/lists\/([^/]+)\/accounts$/))) return removeListAccounts(request, 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);
@@ -159,7 +200,10 @@ async function route(request: Request, env: Env): Promise<Response> {
if (method === "GET" && path === "/api/v1/filters") return filtersV1(request, env);
if (method === "GET" && path === "/api/v1/trends/tags") return trendsTags(env);
if (method === "GET" && path === "/api/v1/markers") return markersList(request, env);
if (method === "POST" && path === "/api/v1/push/subscription") return pushSubscription();
if (method === "GET" && path === "/api/v1/push/subscription") return getPushSubscription(request, env);
if (method === "POST" && path === "/api/v1/push/subscription") return createPushSubscription(request, env);
if (method === "PUT" && path === "/api/v1/push/subscription") return updatePushSubscription(request, env);
if (method === "DELETE" && path === "/api/v1/push/subscription") return deletePushSubscription(request, env);
if (method === "GET" && (m = path.match(/^\/media\/(.+)$/))) return serveMedia(env, m[1]);
+638 -37
View File
@@ -58,13 +58,19 @@ import {
import type { ParsedBody } from "./http";
import type {
ActorCache,
AccountList,
CachedStatus,
CachedStatusMention,
CachedStatusTag,
Follow,
Json,
Media,
Mention,
Notification,
Poll,
PollOption,
PushSubscription,
ScheduledStatus,
Session,
Status,
User
@@ -93,6 +99,11 @@ 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"]);
const MAX_POLL_OPTIONS = 4;
const MAX_POLL_OPTION_CHARS = 50;
const MIN_POLL_EXPIRATION_SECONDS = 300;
const MAX_POLL_EXPIRATION_SECONDS = 2629746;
const SCHEDULED_STATUS_MIN_DELAY_SECONDS = 300;
type StatusVisibility = "public" | "unlisted" | "private" | "direct";
type StatusViewer = {
@@ -102,6 +113,20 @@ type StatusViewer = {
remoteFollowsByActorId: Map<string, boolean>;
};
type StatusCreateInput = {
statusText: string;
summary: string;
sensitive: boolean;
visibility: StatusVisibility;
inReplyTo: string;
language: string;
mediaIds: string[];
pollOptions: string[];
pollExpiresIn: number | null;
pollMultiple: boolean;
pollHideTotals: boolean;
};
function parseRedirectUris(value: string): string[] {
return value.split(/\s+/).map((item) => item.trim()).filter(Boolean);
}
@@ -133,7 +158,7 @@ export async function instance(env: Env): Promise<Response> {
configuration: {
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 }
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 }
},
contact_account: await accountJson(env, admin),
rules: []
@@ -155,7 +180,8 @@ export async function instanceV2(env: Env): Promise<Response> {
urls: { streaming: `wss://${hostFromBaseUrl(env)}` },
accounts: { max_featured_tags: 0 },
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 }
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 }
},
registrations: { enabled: false, approval_required: false, message: null },
contact: { email: "", account: await accountJson(env, admin) },
@@ -552,26 +578,22 @@ async function accountFromActorId(env: Env, actorId: string): Promise<Record<str
export async function createStatus(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const body = await readBody(request);
const statusText = bodyString(body, "status").trim();
if (!statusText) return json({ error: "status can't be blank" }, 422);
if (statusText.length > MAX_STATUS_CHARS) return json({ error: "status too long" }, 422);
const input = parseStatusCreateInput(body);
const scheduledAt = bodyString(body, "scheduled_at");
if (scheduledAt) return scheduleStatus(env, user, input, scheduledAt);
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");
const status = await publishStatus(env, user, input);
return json(await statusJson(env, status, user, request));
}
async function publishStatus(env: Env, user: User, input: StatusCreateInput): Promise<Status> {
const now = new Date().toISOString();
const statusId = id();
const objectId = objectUrl(env, statusId);
const activityId = activityUrl(env, statusId);
const mentionsAcct = extractMentions(statusText);
const hashtags = extractHashtags(statusText);
const mentionsAcct = extractMentions(input.statusText);
const hashtags = extractHashtags(input.statusText);
const resolvedMentions: { acct: string; actorId: string; url: string }[] = [];
for (const acct of mentionsAcct) {
@@ -579,7 +601,7 @@ export async function createStatus(request: Request, env: Env): Promise<Response
if (resolved) resolvedMentions.push(resolved);
}
const renderedContent = htmlContent(statusText, resolvedMentions.map(({ acct, url }) => ({ acct, url })), hashtags);
const renderedContent = htmlContent(input.statusText, resolvedMentions.map(({ acct, url }) => ({ acct, url })), hashtags);
await env.DB.prepare(
"INSERT INTO statuses (id, user_id, content, summary, sensitive, language, visibility, in_reply_to_id, activity_id, object_id, created_at, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
@@ -588,11 +610,11 @@ export async function createStatus(request: Request, env: Env): Promise<Response
statusId,
user.id,
renderedContent,
summary,
sensitive ? 1 : 0,
language,
visibility,
inReplyTo || null,
input.summary,
input.sensitive ? 1 : 0,
input.language,
input.visibility,
input.inReplyTo || null,
activityId,
objectId,
now,
@@ -600,10 +622,22 @@ export async function createStatus(request: Request, env: Env): Promise<Response
)
.run();
for (const mediaId of mediaIds) {
for (const mediaId of input.mediaIds) {
await env.DB.prepare("UPDATE media SET status_id = ? WHERE id = ? AND user_id = ?").bind(statusId, mediaId, user.id).run();
}
if (input.pollOptions.length > 0) {
const pollId = id();
const expiresAt = input.pollExpiresIn ? new Date(Date.now() + input.pollExpiresIn * 1000).toISOString() : null;
await env.DB.prepare(
"INSERT INTO polls (id, status_id, user_id, expires_at, multiple, hide_totals, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
).bind(pollId, statusId, user.id, expiresAt, input.pollMultiple ? 1 : 0, input.pollHideTotals ? 1 : 0, now).run();
for (let i = 0; i < input.pollOptions.length; i++) {
await env.DB.prepare("INSERT INTO poll_options (poll_id, position, title) VALUES (?, ?, ?)")
.bind(pollId, i, input.pollOptions[i]).run();
}
}
for (const mention of resolvedMentions) {
await env.DB.prepare("INSERT OR IGNORE INTO mentions (status_id, actor, acct, url) VALUES (?, ?, ?, ?)")
.bind(statusId, mention.actorId, mention.acct, mention.url).run();
@@ -614,8 +648,8 @@ export async function createStatus(request: Request, env: Env): Promise<Response
}
let replyParent: Status | null = null;
if (inReplyTo) {
replyParent = await getStatus(env, inReplyTo);
if (input.inReplyTo) {
replyParent = await getStatus(env, input.inReplyTo);
if (replyParent) {
const parentUser = await getUserById(env, replyParent.user_id);
if (parentUser && parentUser.id !== user.id) {
@@ -636,7 +670,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" || visibility === "private") {
if (input.visibility === "public" || input.visibility === "unlisted" || input.visibility === "private") {
const inboxes = new Set<string>(await gatherFollowerInboxes(env, user.id));
for (const mention of resolvedMentions) {
if (!mention.actorId.startsWith(baseUrl(env))) {
@@ -645,19 +679,19 @@ export async function createStatus(request: Request, env: Env): Promise<Response
}
}
const mentionActors = resolvedMentions.map((m) => m.actorId);
const to = visibility === "public"
const to = input.visibility === "public"
? ["https://www.w3.org/ns/activitystreams#Public"]
: visibility === "unlisted"
: input.visibility === "unlisted"
? [`${actorUrl(env, user)}/followers`]
: [`${actorUrl(env, user)}/followers`, ...mentionActors];
const cc = visibility === "public"
const cc = input.visibility === "public"
? [`${actorUrl(env, user)}/followers`, ...mentionActors]
: visibility === "unlisted"
: input.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") {
} else if (input.visibility === "direct") {
const inboxes = new Set<string>();
for (const mention of resolvedMentions) {
if (!mention.actorId.startsWith(baseUrl(env))) {
@@ -669,7 +703,177 @@ export async function createStatus(request: Request, env: Env): Promise<Response
await deliverToInboxes(env, user, inboxes, activity);
}
return json(await statusJson(env, status, user, request));
return status;
}
function parseStatusCreateInput(body: ParsedBody): StatusCreateInput {
const statusText = bodyString(body, "status").trim();
if (!statusText) throw new HttpError(422, "status can't be blank");
if (statusText.length > MAX_STATUS_CHARS) throw new HttpError(422, "status too long");
const visibility = bodyString(body, "visibility", "public");
if (!isStatusVisibility(visibility)) throw new HttpError(422, "invalid_visibility");
const pollOptions = bodyArray(body, "poll[options]").map((option) => option.trim()).filter(Boolean);
if (pollOptions.length === 1) throw new HttpError(422, "poll needs at least two options");
if (pollOptions.length > MAX_POLL_OPTIONS) throw new HttpError(422, "too_many_poll_options");
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;
return {
statusText,
summary: bodyString(body, "spoiler_text"),
sensitive: bodyString(body, "sensitive") === "true",
visibility,
inReplyTo: bodyString(body, "in_reply_to_id"),
language: bodyString(body, "language", "en"),
mediaIds: bodyArray(body, "media_ids"),
pollOptions,
pollExpiresIn,
pollMultiple: bodyString(body, "poll[multiple]") === "true",
pollHideTotals: bodyString(body, "poll[hide_totals]") === "true"
};
}
function parsePollExpiresIn(value: string): number {
const seconds = Number(value);
if (!Number.isFinite(seconds)) throw new HttpError(422, "invalid_poll_expiration");
const wholeSeconds = Math.floor(seconds);
if (wholeSeconds < MIN_POLL_EXPIRATION_SECONDS) throw new HttpError(422, "poll_expiration_too_short");
if (wholeSeconds > MAX_POLL_EXPIRATION_SECONDS) throw new HttpError(422, "poll_expiration_too_long");
return wholeSeconds;
}
async function scheduleStatus(env: Env, user: User, input: StatusCreateInput, scheduledAtValue: string): Promise<Response> {
const scheduledAt = new Date(scheduledAtValue);
if (!Number.isFinite(scheduledAt.getTime())) return json({ error: "invalid_scheduled_at" }, 422);
if (scheduledAt.getTime() < Date.now() + SCHEDULED_STATUS_MIN_DELAY_SECONDS * 1000) {
return json({ error: "scheduled_at_too_soon" }, 422);
}
const now = new Date().toISOString();
const row: ScheduledStatus = {
id: id(),
user_id: user.id,
params_json: JSON.stringify(input),
media_ids_json: JSON.stringify(input.mediaIds),
scheduled_at: scheduledAt.toISOString(),
created_at: now
};
await env.DB.prepare(
"INSERT INTO scheduled_statuses (id, user_id, params_json, media_ids_json, scheduled_at, created_at) VALUES (?, ?, ?, ?, ?, ?)"
).bind(row.id, row.user_id, row.params_json, row.media_ids_json, row.scheduled_at, row.created_at).run();
return json(await scheduledStatusJson(env, row));
}
export async function listScheduledStatuses(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, 80);
const rows = await env.DB.prepare(
"SELECT * FROM scheduled_statuses WHERE user_id = ? ORDER BY scheduled_at ASC LIMIT ?"
).bind(user.id, limit).all<ScheduledStatus>();
return json(await Promise.all(rows.results.map((row) => scheduledStatusJson(env, row))));
}
export async function getScheduledStatus(request: Request, env: Env, scheduledId: string): Promise<Response> {
const user = await requireUser(request, env);
const row = await env.DB.prepare("SELECT * FROM scheduled_statuses WHERE id = ? AND user_id = ?").bind(scheduledId, user.id).first<ScheduledStatus>();
if (!row) return json({ error: "Record not found" }, 404);
return json(await scheduledStatusJson(env, row));
}
export async function updateScheduledStatus(request: Request, env: Env, scheduledId: string): Promise<Response> {
const user = await requireUser(request, env);
const row = await env.DB.prepare("SELECT * FROM scheduled_statuses WHERE id = ? AND user_id = ?").bind(scheduledId, user.id).first<ScheduledStatus>();
if (!row) return json({ error: "Record not found" }, 404);
const body = await readBody(request);
const scheduledAtValue = bodyString(body, "scheduled_at");
if (!scheduledAtValue) return json({ error: "scheduled_at is required" }, 422);
const scheduledAt = new Date(scheduledAtValue);
if (!Number.isFinite(scheduledAt.getTime())) return json({ error: "invalid_scheduled_at" }, 422);
if (scheduledAt.getTime() < Date.now() + SCHEDULED_STATUS_MIN_DELAY_SECONDS * 1000) {
return json({ error: "scheduled_at_too_soon" }, 422);
}
await env.DB.prepare("UPDATE scheduled_statuses SET scheduled_at = ? WHERE id = ? AND user_id = ?")
.bind(scheduledAt.toISOString(), scheduledId, user.id).run();
const updated = await env.DB.prepare("SELECT * FROM scheduled_statuses WHERE id = ?").bind(scheduledId).first<ScheduledStatus>();
return json(await scheduledStatusJson(env, updated!));
}
export async function deleteScheduledStatus(request: Request, env: Env, scheduledId: string): Promise<Response> {
const user = await requireUser(request, env);
const row = await env.DB.prepare("SELECT * FROM scheduled_statuses WHERE id = ? AND user_id = ?").bind(scheduledId, user.id).first<ScheduledStatus>();
if (!row) return json({ error: "Record not found" }, 404);
await env.DB.prepare("DELETE FROM scheduled_statuses WHERE id = ? AND user_id = ?").bind(scheduledId, user.id).run();
return json({});
}
export async function publishDueScheduledStatuses(env: Env): Promise<void> {
const rows = await env.DB.prepare(
"SELECT * FROM scheduled_statuses WHERE scheduled_at <= ? ORDER BY scheduled_at ASC LIMIT 10"
).bind(new Date().toISOString()).all<ScheduledStatus>();
for (const row of rows.results) {
const user = await getUserById(env, row.user_id);
if (!user) {
await env.DB.prepare("DELETE FROM scheduled_statuses WHERE id = ?").bind(row.id).run();
continue;
}
try {
await publishStatus(env, user, parseScheduledStatusInput(row.params_json));
await env.DB.prepare("DELETE FROM scheduled_statuses WHERE id = ?").bind(row.id).run();
} catch (error) {
console.warn("scheduled-status-publish-failed", row.id, String(error));
}
}
}
async function scheduledStatusJson(env: Env, row: ScheduledStatus): Promise<Record<string, unknown>> {
const input = parseScheduledStatusInput(row.params_json);
const mediaIds = parseCachedJson<string>(row.media_ids_json);
const media = mediaIds.length > 0
? (await env.DB.prepare(`SELECT * FROM media WHERE id IN (${placeholders(mediaIds.length)})`).bind(...mediaIds).all<Media>()).results
: [];
return {
id: row.id,
scheduled_at: row.scheduled_at,
params: {
text: input.statusText,
media_ids: input.mediaIds,
sensitive: input.sensitive,
spoiler_text: input.summary,
visibility: input.visibility,
scheduled_at: row.scheduled_at,
poll: input.pollOptions.length > 0 ? {
options: input.pollOptions,
expires_in: input.pollExpiresIn,
multiple: input.pollMultiple,
hide_totals: input.pollHideTotals
} : null,
idempotency: null,
in_reply_to_id: input.inReplyTo || null,
application_id: null
},
media_attachments: media.map((item) => mediaJson(env, item))
};
}
function parseScheduledStatusInput(value: string): StatusCreateInput {
const parsed = JSON.parse(value) as StatusCreateInput;
if (!isStatusVisibility(parsed.visibility)) throw new Error("invalid_scheduled_visibility");
return {
statusText: String(parsed.statusText ?? ""),
summary: String(parsed.summary ?? ""),
sensitive: Boolean(parsed.sensitive),
visibility: parsed.visibility,
inReplyTo: String(parsed.inReplyTo ?? ""),
language: String(parsed.language ?? "en"),
mediaIds: Array.isArray(parsed.mediaIds) ? parsed.mediaIds.map(String) : [],
pollOptions: Array.isArray(parsed.pollOptions) ? parsed.pollOptions.map(String) : [],
pollExpiresIn: typeof parsed.pollExpiresIn === "number" ? parsed.pollExpiresIn : null,
pollMultiple: Boolean(parsed.pollMultiple),
pollHideTotals: Boolean(parsed.pollHideTotals)
};
}
export async function getStatusEndpoint(request: Request, env: Env, statusId: string): Promise<Response> {
@@ -682,6 +886,45 @@ export async function getStatusEndpoint(request: Request, env: Env, statusId: st
return json(await statusJson(env, status, user, request));
}
export async function getPoll(request: Request, env: Env, pollId: string): Promise<Response> {
const poll = await env.DB.prepare("SELECT * FROM polls WHERE id = ?").bind(pollId).first<Poll>();
if (!poll) return json({ error: "Record not found" }, 404);
const status = await getStatus(env, poll.status_id);
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);
return json(await pollJson(env, poll, viewer.actor));
}
export async function votePoll(request: Request, env: Env, pollId: string): Promise<Response> {
const user = await requireUser(request, env);
const poll = await env.DB.prepare("SELECT * FROM polls WHERE id = ?").bind(pollId).first<Poll>();
if (!poll) return json({ error: "Record not found" }, 404);
const status = await getStatus(env, poll.status_id);
if (!status) return json({ error: "Record not found" }, 404);
const viewer = statusViewerForUser(env, user);
if (!await canViewerViewStatus(env, status, viewer)) return json({ error: "Record not found" }, 404);
if (poll.expires_at && Date.parse(poll.expires_at) <= Date.now()) return json({ error: "poll_expired" }, 422);
const body = await readBody(request);
const choices = uniqueNumbers(bodyArray(body, "choices").map((choice) => Number(choice)));
if (choices.length === 0) return json({ error: "choices can't be blank" }, 422);
if (!poll.multiple && choices.length > 1) return json({ error: "poll_is_single_choice" }, 422);
const options = await env.DB.prepare("SELECT * FROM poll_options WHERE poll_id = ?").bind(poll.id).all<PollOption>();
const validPositions = new Set(options.results.map((option) => option.position));
if (choices.some((choice) => !validPositions.has(choice))) return json({ error: "invalid_choice" }, 422);
const actor = actorUrl(env, user);
const existing = await env.DB.prepare("SELECT 1 AS hit FROM poll_votes WHERE poll_id = ? AND voter_actor = ? LIMIT 1").bind(poll.id, actor).first<{ hit: number }>();
if (existing) return json({ error: "already_voted" }, 422);
const now = new Date().toISOString();
for (const choice of choices) {
await env.DB.prepare("INSERT INTO poll_votes (poll_id, position, voter_actor, created_at) VALUES (?, ?, ?, ?)")
.bind(poll.id, choice, actor, now).run();
}
return json(await pollJson(env, poll, actor));
}
export async function deleteStatusEndpoint(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
@@ -716,6 +959,12 @@ export async function deleteStatusEndpoint(request: Request, env: Env, statusId:
await env.DB.prepare("DELETE FROM favourites WHERE status_id = ?").bind(status.id).run();
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();
const poll = await env.DB.prepare("SELECT id FROM polls WHERE status_id = ?").bind(status.id).first<{ id: string }>();
if (poll) {
await env.DB.prepare("DELETE FROM poll_votes WHERE poll_id = ?").bind(poll.id).run();
await env.DB.prepare("DELETE FROM poll_options WHERE poll_id = ?").bind(poll.id).run();
await env.DB.prepare("DELETE FROM polls WHERE id = ?").bind(poll.id).run();
}
const mentionActors = mentions.map((mention) => mention.actor);
const deleteAudience = status.visibility === "direct"
@@ -1084,6 +1333,135 @@ export async function getRelationships(request: Request, env: Env): Promise<Resp
return json(out);
}
export async function listsList(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const rows = await env.DB.prepare("SELECT * FROM lists WHERE user_id = ? ORDER BY created_at DESC").bind(user.id).all<AccountList>();
return json(rows.results.map(listJson));
}
export async function createList(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const body = await readBody(request);
const title = bodyString(body, "title").trim();
if (!title) return json({ error: "title can't be blank" }, 422);
const row: AccountList = {
id: id(),
user_id: user.id,
title,
replies_policy: bodyString(body, "replies_policy", "list") || "list",
exclusive: bodyString(body, "exclusive") === "true" ? 1 : 0,
created_at: new Date().toISOString()
};
await env.DB.prepare(
"INSERT INTO lists (id, user_id, title, replies_policy, exclusive, created_at) VALUES (?, ?, ?, ?, ?, ?)"
).bind(row.id, row.user_id, row.title, row.replies_policy, row.exclusive, row.created_at).run();
return json(listJson(row));
}
export async function getList(request: Request, env: Env, listId: string): Promise<Response> {
const user = await requireUser(request, env);
const row = await getOwnedList(env, user.id, listId);
if (!row) return json({ error: "Record not found" }, 404);
return json(listJson(row));
}
export async function updateList(request: Request, env: Env, listId: string): Promise<Response> {
const user = await requireUser(request, env);
const existing = await getOwnedList(env, user.id, listId);
if (!existing) return json({ error: "Record not found" }, 404);
const body = await readBody(request);
const title = bodyString(body, "title", existing.title).trim();
if (!title) return json({ error: "title can't be blank" }, 422);
const repliesPolicy = bodyString(body, "replies_policy", existing.replies_policy) || existing.replies_policy;
const exclusiveValue = bodyString(body, "exclusive");
const exclusive = exclusiveValue ? (exclusiveValue === "true" ? 1 : 0) : existing.exclusive;
await env.DB.prepare("UPDATE lists SET title = ?, replies_policy = ?, exclusive = ? WHERE id = ? AND user_id = ?")
.bind(title, repliesPolicy, exclusive, listId, user.id).run();
const updated = await getOwnedList(env, user.id, listId);
return json(listJson(updated!));
}
export async function deleteList(request: Request, env: Env, listId: string): Promise<Response> {
const user = await requireUser(request, env);
const existing = await getOwnedList(env, user.id, listId);
if (!existing) return json({ error: "Record not found" }, 404);
await env.DB.prepare("DELETE FROM list_accounts WHERE list_id = ?").bind(listId).run();
await env.DB.prepare("DELETE FROM lists WHERE id = ? AND user_id = ?").bind(listId, user.id).run();
return json({});
}
export async function listAccounts(request: Request, env: Env, listId: string): Promise<Response> {
const user = await requireUser(request, env);
const existing = await getOwnedList(env, user.id, listId);
if (!existing) return json({ error: "Record not found" }, 404);
const rows = await env.DB.prepare("SELECT account_actor FROM list_accounts WHERE list_id = ? ORDER BY created_at DESC").bind(listId).all<{ account_actor: string }>();
return json(await actorIdsToAccounts(env, rows.results.map((row) => row.account_actor)));
}
export async function addListAccounts(request: Request, env: Env, listId: string): Promise<Response> {
const user = await requireUser(request, env);
const existing = await getOwnedList(env, user.id, listId);
if (!existing) return json({ error: "Record not found" }, 404);
const body = await readBody(request);
const accountIds = bodyArray(body, "account_ids");
if (accountIds.length === 0) return json({ error: "account_ids can't be blank" }, 422);
const now = new Date().toISOString();
for (const accountId of accountIds) {
const target = await resolveAccountTarget(env, accountId);
if (!target) continue;
await env.DB.prepare("INSERT OR IGNORE INTO list_accounts (list_id, account_actor, created_at) VALUES (?, ?, ?)")
.bind(listId, target.actorId, now).run();
}
return json({});
}
export async function removeListAccounts(request: Request, env: Env, listId: string): Promise<Response> {
const user = await requireUser(request, env);
const existing = await getOwnedList(env, user.id, listId);
if (!existing) return json({ error: "Record not found" }, 404);
const body = await readBody(request);
for (const accountId of bodyArray(body, "account_ids")) {
const target = await resolveAccountTarget(env, accountId);
const actorId = target?.actorId ?? accountId;
await env.DB.prepare("DELETE FROM list_accounts WHERE list_id = ? AND account_actor = ?").bind(listId, actorId).run();
}
return json({});
}
export async function listTimeline(request: Request, env: Env, listId: string): Promise<Response> {
const user = await requireUser(request, env);
const existing = await getOwnedList(env, user.id, listId);
if (!existing) return json({ error: "Record not found" }, 404);
const url = new URL(request.url);
const limit = clampLimit(url.searchParams.get("limit"), 20, 40);
const accountRows = await env.DB.prepare("SELECT account_actor FROM list_accounts WHERE list_id = ?").bind(listId).all<{ account_actor: string }>();
const actors = accountRows.results.map((row) => row.account_actor);
const localUserIds: string[] = [];
const remoteActors: string[] = [];
for (const actor of actors) {
if (actor.startsWith(baseUrl(env))) {
const match = actor.match(/\/users\/([^/?#]+)$/);
const local = match ? await getUserByUsername(env, match[1]) : null;
if (local) localUserIds.push(local.id);
} else {
remoteActors.push(actor);
}
}
const viewer = statusViewerForUser(env, user);
const localRows = localUserIds.length > 0
? (await env.DB.prepare(`SELECT * FROM statuses WHERE user_id IN (${placeholders(localUserIds.length)}) ORDER BY created_at DESC LIMIT ?`).bind(...localUserIds, limit * 2).all<Status>()).results
: [];
const remoteRows = remoteActors.length > 0
? (await env.DB.prepare(`SELECT * FROM cached_statuses WHERE actor IN (${placeholders(remoteActors.length)}) ORDER BY published DESC LIMIT ?`).bind(...remoteActors, limit * 2).all<CachedStatus>()).results
: [];
const visibleLocalRows = await filterStatusesForViewer(env, localRows, viewer);
const visibleRemoteRows = await filterCachedStatusesForViewer(env, remoteRows, viewer);
const localItems = await serializeStatuses(env, visibleLocalRows.slice(0, limit), request);
const remoteItems = await Promise.all(visibleRemoteRows.slice(0, limit).map((row) => cachedStatusToMastodon(env, row)));
return json([...localItems, ...remoteItems].sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? ""))).slice(0, limit));
}
export async function followAccount(request: Request, env: Env, accountId: string): Promise<Response> {
const user = await requireUser(request, env);
const target = await resolveAccountTarget(env, accountId);
@@ -1200,8 +1578,57 @@ export async function trendsTags(env: Env): Promise<Response> {
return json([]);
}
export async function pushSubscription(): Promise<Response> {
return json({ error: "push subscriptions not supported" }, 422);
export async function getPushSubscription(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const row = await env.DB.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").bind(user.id).first<PushSubscription>();
if (!row) return json({ error: "Record not found" }, 404);
return json(pushSubscriptionJson(row));
}
export async function createPushSubscription(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const body = await readBody(request);
const endpoint = bodyString(body, "subscription[endpoint]");
const serverKey = bodyString(body, "subscription[keys][p256dh]");
const auth = bodyString(body, "subscription[keys][auth]");
if (!endpoint || !serverKey || !auth) return json({ error: "subscription is incomplete" }, 422);
const now = new Date().toISOString();
const existing = await env.DB.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").bind(user.id).first<PushSubscription>();
const idValue = existing?.id ?? id();
const alerts = pushAlertsFromBody(body, existing?.alerts_json);
const policy = bodyString(body, "data[policy]", existing?.policy ?? "all") || "all";
await env.DB.prepare(
`INSERT INTO push_subscriptions (id, user_id, endpoint, server_key, auth, alerts_json, policy, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
endpoint = excluded.endpoint,
server_key = excluded.server_key,
auth = excluded.auth,
alerts_json = excluded.alerts_json,
policy = excluded.policy,
updated_at = excluded.updated_at`
).bind(idValue, user.id, endpoint, serverKey, auth, JSON.stringify(alerts), policy, existing?.created_at ?? now, now).run();
const row = await env.DB.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").bind(user.id).first<PushSubscription>();
return json(pushSubscriptionJson(row!));
}
export async function updatePushSubscription(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const row = await env.DB.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").bind(user.id).first<PushSubscription>();
if (!row) return json({ error: "Record not found" }, 404);
const body = await readBody(request);
const alerts = pushAlertsFromBody(body, row.alerts_json);
const policy = bodyString(body, "data[policy]", row.policy) || row.policy;
await env.DB.prepare("UPDATE push_subscriptions SET alerts_json = ?, policy = ?, updated_at = ? WHERE user_id = ?")
.bind(JSON.stringify(alerts), policy, new Date().toISOString(), user.id).run();
const updated = await env.DB.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").bind(user.id).first<PushSubscription>();
return json(pushSubscriptionJson(updated!));
}
export async function deletePushSubscription(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
await env.DB.prepare("DELETE FROM push_subscriptions WHERE user_id = ?").bind(user.id).run();
return json({});
}
export async function markersList(request: Request, env: Env): Promise<Response> {
@@ -1209,6 +1636,35 @@ export async function markersList(request: Request, env: Env): Promise<Response>
return json({});
}
function pushSubscriptionJson(row: PushSubscription): Record<string, unknown> {
return {
id: row.id,
endpoint: row.endpoint,
server_key: row.server_key,
alerts: parseObjectJson(row.alerts_json),
policy: row.policy
};
}
function pushAlertsFromBody(body: ParsedBody, fallback?: string): Record<string, boolean> {
const alerts = parseObjectJson(fallback ?? "{}") as Record<string, boolean>;
const keys = ["follow", "favourite", "reblog", "mention", "poll", "status", "update", "admin.sign_up", "admin.report"];
for (const key of keys) {
const value = bodyString(body, `data[alerts][${key}]`);
if (value) alerts[key] = value === "true";
}
return alerts;
}
function parseObjectJson(value: string): Record<string, unknown> {
try {
const parsed = JSON.parse(value) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
} catch {
return {};
}
}
type StatusSerializationContext = {
usersById: Map<string, User>;
accountByUserId: Map<string, Record<string, unknown>>;
@@ -1222,6 +1678,11 @@ type StatusSerializationContext = {
replyCountByStatusId: Map<string, number>;
bookmarkedStatusIds: Set<string>;
pinnedStatusIds: Set<string>;
pollByStatusId: Map<string, Poll>;
pollOptionsByPollId: Map<string, PollOption[]>;
pollVotesByPollId: Map<string, Map<number, number>>;
pollVotersCountByPollId: Map<string, number>;
pollOwnVotesByPollId: Map<string, number[]>;
};
async function cachedStatusToMastodon(env: Env, row: CachedStatus): Promise<Record<string, unknown>> {
@@ -1345,10 +1806,70 @@ function statusRecord(env: Env, status: Status, user: User, context: StatusSeria
bookmarked: context.bookmarkedStatusIds.has(status.id),
pinned: context.pinnedStatusIds.has(status.id),
card: null,
poll: null
poll: pollRecord(status.id, context)
};
}
function pollRecord(statusId: string, context: StatusSerializationContext): Record<string, unknown> | null {
const poll = context.pollByStatusId.get(statusId);
if (!poll) return null;
const options = context.pollOptionsByPollId.get(poll.id) ?? [];
const voteCounts = context.pollVotesByPollId.get(poll.id) ?? new Map<number, number>();
const votersCount = context.pollVotersCountByPollId.get(poll.id) ?? 0;
const ownVotes = context.pollOwnVotesByPollId.get(poll.id) ?? [];
const now = Date.now();
const expiresAt = poll.expires_at ? Date.parse(poll.expires_at) : NaN;
const expired = Number.isFinite(expiresAt) ? expiresAt <= now : false;
const showTotals = !poll.hide_totals || expired || ownVotes.length > 0;
const votesCount = [...voteCounts.values()].reduce((total, count) => total + count, 0);
return {
id: poll.id,
expires_at: poll.expires_at,
expired,
multiple: Boolean(poll.multiple),
votes_count: showTotals ? votesCount : null,
voters_count: showTotals ? votersCount : null,
voted: ownVotes.length > 0,
own_votes: ownVotes,
options: options.map((option) => ({
title: option.title,
votes_count: showTotals ? voteCounts.get(option.position) ?? 0 : null
})),
emojis: []
};
}
async function pollJson(env: Env, poll: Poll, viewer: string | null): Promise<Record<string, unknown>> {
const [options, voteRows, votersRow] = await Promise.all([
env.DB.prepare("SELECT * FROM poll_options WHERE poll_id = ? ORDER BY position ASC").bind(poll.id).all<PollOption>(),
env.DB.prepare("SELECT position, COUNT(*) AS count FROM poll_votes WHERE poll_id = ? GROUP BY position").bind(poll.id).all<{ position: number; count: number }>(),
env.DB.prepare("SELECT COUNT(DISTINCT voter_actor) AS count FROM poll_votes WHERE poll_id = ?").bind(poll.id).first<{ count: number }>()
]);
const ownRows = viewer
? (await env.DB.prepare("SELECT position FROM poll_votes WHERE poll_id = ? AND voter_actor = ? ORDER BY position ASC").bind(poll.id, viewer).all<{ position: number }>()).results
: [];
const context: StatusSerializationContext = {
usersById: new Map(),
accountByUserId: new Map(),
mediaByStatusId: new Map(),
mentionsByStatusId: new Map(),
hashtagsByStatusId: new Map(),
favouriteCountByStatusId: new Map(),
favouritedStatusIds: new Set(),
reblogCountByStatusId: new Map(),
rebloggedStatusIds: new Set(),
replyCountByStatusId: new Map(),
bookmarkedStatusIds: new Set(),
pinnedStatusIds: new Set(),
pollByStatusId: new Map([[poll.status_id, poll]]),
pollOptionsByPollId: new Map([[poll.id, options.results]]),
pollVotesByPollId: new Map([[poll.id, new Map(voteRows.results.map((row) => [row.position, row.count]))]]),
pollVotersCountByPollId: new Map([[poll.id, votersRow?.count ?? 0]]),
pollOwnVotesByPollId: new Map([[poll.id, ownRows.map((row) => row.position)]])
};
return pollRecord(poll.status_id, context)!;
}
async function serializeStatuses(
env: Env,
statuses: Status[],
@@ -1379,7 +1900,7 @@ async function buildStatusSerializationContext(
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([
const [mediaByStatusId, mentionsByStatusId, hashtagsByStatusId, favouriteSummary, reblogSummary, replyCountByStatusId, bookmarkedStatusIds, pinnedStatusIds, pollContext] = await Promise.all([
loadMediaByStatusIds(env, statusIds),
loadMentionsByStatusIds(env, statusIds),
loadHashtagsByStatusIds(env, statusIds),
@@ -1387,7 +1908,8 @@ async function buildStatusSerializationContext(
loadStatusInteractionSummary(env, "reblogs", statusIds, viewer),
loadReplyCountByStatusIds(env, statusIds),
viewerId ? loadBookmarkedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set<string>()),
viewerId ? loadPinnedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set<string>())
viewerId ? loadPinnedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set<string>()),
loadPollSerializationContext(env, statusIds, viewer)
]);
const accountByUserId = new Map<string, Record<string, unknown>>();
@@ -1407,7 +1929,12 @@ async function buildStatusSerializationContext(
rebloggedStatusIds: reblogSummary.viewerMatchedStatusIds,
replyCountByStatusId,
bookmarkedStatusIds,
pinnedStatusIds
pinnedStatusIds,
pollByStatusId: pollContext.pollByStatusId,
pollOptionsByPollId: pollContext.pollOptionsByPollId,
pollVotesByPollId: pollContext.pollVotesByPollId,
pollVotersCountByPollId: pollContext.pollVotersCountByPollId,
pollOwnVotesByPollId: pollContext.pollOwnVotesByPollId
};
}
@@ -1512,6 +2039,19 @@ async function relationshipFor(env: Env, user: User, target: string): Promise<Re
};
}
function listJson(row: AccountList): Record<string, unknown> {
return {
id: row.id,
title: row.title,
replies_policy: row.replies_policy,
exclusive: Boolean(row.exclusive)
};
}
async function getOwnedList(env: Env, userId: string, listId: string): Promise<AccountList | null> {
return env.DB.prepare("SELECT * FROM lists WHERE id = ? AND user_id = ?").bind(listId, userId).first<AccountList>();
}
type AccountTarget = { kind: "local"; userId: string; actorId: string } | { kind: "remote"; actorId: string };
async function resolveAccountTarget(env: Env, key: string): Promise<AccountTarget | null> {
@@ -1598,6 +2138,10 @@ function uniqueStrings(values: Array<string | null | undefined>): string[] {
return [...new Set(values.filter((value): value is string => Boolean(value)))];
}
function uniqueNumbers(values: number[]): number[] {
return [...new Set(values.filter((value) => Number.isInteger(value) && value >= 0))];
}
function placeholders(count: number): string {
return Array.from({ length: count }, () => "?").join(",");
}
@@ -1694,6 +2238,63 @@ async function loadReplyCountByStatusIds(env: Env, statusIds: string[]): Promise
return counts;
}
async function loadPollSerializationContext(
env: Env,
statusIds: string[],
viewer: string | null
): Promise<Pick<StatusSerializationContext, "pollByStatusId" | "pollOptionsByPollId" | "pollVotesByPollId" | "pollVotersCountByPollId" | "pollOwnVotesByPollId">> {
const pollByStatusId = new Map<string, Poll>();
const pollOptionsByPollId = new Map<string, PollOption[]>();
const pollVotesByPollId = new Map<string, Map<number, number>>();
const pollVotersCountByPollId = new Map<string, number>();
const pollOwnVotesByPollId = new Map<string, number[]>();
if (statusIds.length === 0) return { pollByStatusId, pollOptionsByPollId, pollVotesByPollId, pollVotersCountByPollId, pollOwnVotesByPollId };
const polls = await env.DB.prepare(`SELECT * FROM polls WHERE status_id IN (${placeholders(statusIds.length)})`).bind(...statusIds).all<Poll>();
const pollIds = polls.results.map((poll) => poll.id);
for (const poll of polls.results) pollByStatusId.set(poll.status_id, poll);
if (pollIds.length === 0) return { pollByStatusId, pollOptionsByPollId, pollVotesByPollId, pollVotersCountByPollId, pollOwnVotesByPollId };
const optionRows = await env.DB.prepare(
`SELECT * FROM poll_options WHERE poll_id IN (${placeholders(pollIds.length)}) ORDER BY position ASC`
).bind(...pollIds).all<PollOption>();
for (const option of optionRows.results) {
const bucket = pollOptionsByPollId.get(option.poll_id);
if (bucket) bucket.push(option);
else pollOptionsByPollId.set(option.poll_id, [option]);
}
const voteRows = await env.DB.prepare(
`SELECT poll_id, position, COUNT(*) AS count FROM poll_votes WHERE poll_id IN (${placeholders(pollIds.length)}) GROUP BY poll_id, position`
).bind(...pollIds).all<{ poll_id: string; position: number; count: number }>();
for (const row of voteRows.results) {
let bucket = pollVotesByPollId.get(row.poll_id);
if (!bucket) {
bucket = new Map();
pollVotesByPollId.set(row.poll_id, bucket);
}
bucket.set(row.position, row.count);
}
const voterRows = await env.DB.prepare(
`SELECT poll_id, COUNT(DISTINCT voter_actor) AS count FROM poll_votes WHERE poll_id IN (${placeholders(pollIds.length)}) GROUP BY poll_id`
).bind(...pollIds).all<{ poll_id: string; count: number }>();
for (const row of voterRows.results) pollVotersCountByPollId.set(row.poll_id, row.count);
if (viewer) {
const ownRows = await env.DB.prepare(
`SELECT poll_id, position FROM poll_votes WHERE voter_actor = ? AND poll_id IN (${placeholders(pollIds.length)}) ORDER BY position ASC`
).bind(viewer, ...pollIds).all<{ poll_id: string; position: number }>();
for (const row of ownRows.results) {
const bucket = pollOwnVotesByPollId.get(row.poll_id);
if (bucket) bucket.push(row.position);
else pollOwnVotesByPollId.set(row.poll_id, [row.position]);
}
}
return { pollByStatusId, pollOptionsByPollId, pollVotesByPollId, pollVotersCountByPollId, pollOwnVotesByPollId };
}
async function serializeNotifications(env: Env, notifications: Notification[], request: Request): Promise<Record<string, unknown>[]> {
if (notifications.length === 0) return [];
+53
View File
@@ -174,6 +174,59 @@ export type CachedStatusAttachment = {
description: string | null;
};
export type Poll = {
id: string;
status_id: string;
user_id: string;
expires_at: string | null;
multiple: number;
hide_totals: number;
created_at: string;
};
export type PollOption = {
poll_id: string;
position: number;
title: string;
};
export type PollVote = {
poll_id: string;
position: number;
voter_actor: string;
created_at: string;
};
export type AccountList = {
id: string;
user_id: string;
title: string;
replies_policy: string;
exclusive: number;
created_at: string;
};
export type PushSubscription = {
id: string;
user_id: string;
endpoint: string;
server_key: string;
auth: string;
alerts_json: string;
policy: string;
created_at: string;
updated_at: string;
};
export type ScheduledStatus = {
id: string;
user_id: string;
params_json: string;
media_ids_json: string;
scheduled_at: string;
created_at: string;
};
export type OAuthToken = {
token: string;
user_id: string;