From e55a1a063d79c64db55d5acd6a79cdee3b4f407c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=AA=E5=AD=90?= Date: Thu, 14 May 2026 19:29:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=95=E7=A5=A8=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0007_polls_lists_push_scheduled.sql | 73 ++ readme.md | 12 +- src/index.ts | 48 +- src/mastodon.ts | 675 +++++++++++++++++- src/types.ts | 53 ++ 5 files changed, 819 insertions(+), 42 deletions(-) create mode 100644 migrations/0007_polls_lists_push_scheduled.sql diff --git a/migrations/0007_polls_lists_push_scheduled.sql b/migrations/0007_polls_lists_push_scheduled.sql new file mode 100644 index 0000000..2350995 --- /dev/null +++ b/migrations/0007_polls_lists_push_scheduled.sql @@ -0,0 +1,73 @@ +-- Mastodon API compatibility for polls, lists, push subscriptions, and +-- scheduled statuses. + +CREATE TABLE IF NOT EXISTS polls ( + id TEXT PRIMARY KEY, + status_id TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL, + expires_at TEXT, + multiple INTEGER NOT NULL DEFAULT 0, + hide_totals INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS poll_options ( + poll_id TEXT NOT NULL, + position INTEGER NOT NULL, + title TEXT NOT NULL, + PRIMARY KEY(poll_id, position) +); + +CREATE TABLE IF NOT EXISTS poll_votes ( + poll_id TEXT NOT NULL, + position INTEGER NOT NULL, + voter_actor TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY(poll_id, position, voter_actor) +); + +CREATE INDEX IF NOT EXISTS idx_polls_status ON polls(status_id); +CREATE INDEX IF NOT EXISTS idx_poll_votes_poll ON poll_votes(poll_id); + +CREATE TABLE IF NOT EXISTS lists ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT NOT NULL, + replies_policy TEXT NOT NULL DEFAULT 'list', + exclusive INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS list_accounts ( + list_id TEXT NOT NULL, + account_actor TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY(list_id, account_actor) +); + +CREATE INDEX IF NOT EXISTS idx_lists_user ON lists(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_list_accounts_list ON list_accounts(list_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL UNIQUE, + endpoint TEXT NOT NULL, + server_key TEXT NOT NULL, + auth TEXT NOT NULL, + alerts_json TEXT NOT NULL DEFAULT '{}', + policy TEXT NOT NULL DEFAULT 'all', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS scheduled_statuses ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + params_json TEXT NOT NULL, + media_ids_json TEXT NOT NULL DEFAULT '[]', + scheduled_at TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_scheduled_statuses_user_time ON scheduled_statuses(user_id, scheduled_at ASC); +CREATE INDEX IF NOT EXISTS idx_scheduled_statuses_due ON scheduled_statuses(scheduled_at ASC); diff --git a/readme.md b/readme.md index 8c06f54..cd6afe7 100644 --- a/readme.md +++ b/readme.md @@ -90,24 +90,28 @@ npm run deploy 嘟文: -- `POST /api/v1/statuses`(支持 `media_ids`、`spoiler_text`、`sensitive`、`in_reply_to_id`、`visibility`、`language`,自动解析 `@user`/`@user@host` 提及和 `#hashtag`,投递 Create 给 followers 与 mention) +- `POST /api/v1/statuses`(支持 `media_ids`、`spoiler_text`、`sensitive`、`in_reply_to_id`、`visibility`、`language`、`poll[...]`、`scheduled_at`,自动解析 `@user`/`@user@host` 提及和 `#hashtag`,投递 Create 给 followers 与 mention) - `GET /api/v1/statuses/:id`、`DELETE /api/v1/statuses/:id`(联邦 Delete 出站) - `GET /api/v1/statuses/:id/context` - `POST /api/v1/statuses/:id/favourite`、`/unfavourite`(联邦 Like / Undo Like) - `POST /api/v1/statuses/:id/reblog`、`/unreblog`(联邦 Announce / Undo Announce) - `POST /api/v1/statuses/:id/bookmark`、`/unbookmark`、`/pin`、`/unpin`(本地落库) +- `GET /api/v1/polls/:id`、`POST /api/v1/polls/:id/votes` +- `GET /api/v1/scheduled_statuses`、`GET / PUT / DELETE /api/v1/scheduled_statuses/:id` - `GET /api/v1/bookmarks`、`GET /api/v1/favourites`(列出本地 bookmark / favourite) 时间线 / 通知 / 媒体 / 搜索 / 其它: - `GET /api/v1/timelines/public`(分页支持 `max_id` / `since_id` / `min_id`,响应携带 `Link` 头) - `GET /api/v1/timelines/home`(合并本地嘟文 + 关注的远端账号缓存嘟文,按时间排序) +- `GET /api/v1/timelines/list/:id` - `GET /api/v1/timelines/tag/:tag`、`GET /api/v1/tags/:name`(话题时间线 + 话题元数据) +- `GET / POST /api/v1/lists`、`GET / PUT / DELETE /api/v1/lists/:id`、`GET / POST / DELETE /api/v1/lists/:id/accounts` - `GET /api/v1/notifications`、`POST /api/v1/notifications/clear`、`POST /api/v1/notifications/:id/dismiss` - `POST /api/v1/media`、`POST /api/v2/media`、`PUT /api/v1/media/:id` - `GET /api/v2/search`、`GET /api/v1/search`(本地账号 / 嘟文 / 话题标签 + 跨站 WebFinger 解析 `acct:` 查询) - `GET /api/v1/custom_emojis`、`GET /api/v1/filters`、`GET /api/v1/trends/tags`、`GET /api/v1/markers`(stub) -- `POST /api/v1/push/subscription`(返回 422,目前不支持推送) +- `GET / POST / PUT / DELETE /api/v1/push/subscription`(存储 Web Push 订阅参数;实际推送投递仍需 VAPID/加密发送实现) ### ActivityPub / 发现 @@ -142,6 +146,7 @@ npm run deploy - `migrations/0002_features.sql` — 通知 / 收藏 / 转发 / 提及 / 话题标签 / actor 缓存 / 出站关注 / 删除墓碑 / 嘟文扩展字段(summary / sensitive / language) - `migrations/0003_bookmarks_cache.sql` — 收藏夹(bookmarks)/ 置顶(pinned_statuses)/ 远端嘟文缓存(cached_statuses)/ OAuth Token 持久表 - `migrations/0006_cached_status_metadata.sql` — 远端缓存嘟文的可见性 / mentions / tags / 本地收件人元数据 +- `migrations/0007_polls_lists_push_scheduled.sql` — poll / list / push subscription / scheduled statuses ## 重要限制 @@ -154,9 +159,10 @@ npm run deploy - `direct` 仍没有完整受众表,本地读取保守限制为作者可见,不应当作为完整私信系统使用 - 远端嘟文缓存只从入站 `Create(Note)` 和已缓存嘟文的 `Update(Note)` 写入,不抓取历史 outbox - 远端缓存嘟文会保留正文、CW、语言、可见性、mentions、tags、本地收件人和附件; 互动计数、poll、card 等扩展信息不会完整恢复 +- Web Push 目前实现订阅存储和 API 兼容,尚未实现 VAPID 加密投递通知 +- Poll 当前只在本地 Mastodon API 中序列化和投票,不会联邦成 ActivityPub Question - 媒体上传只支持 `image/jpeg`、`image/png`、`image/gif`、`image/webp`,单文件 10MB,单条状态的附件数量不做服务端限制; 头像和封面同样只按图片路径处理 - 没有实现接口级限流、反滥用或审核流; `follow_requests` 相关接口仍是 stub -- 没有实现轮询(poll)、列表(list)、推送(push)、未来嘟文(scheduled)等 ## 参考 diff --git a/src/index.ts b/src/index.ts index 03dfefa..5a78e6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { 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 { + await ensureAdminUser(env); + await publishDueScheduledStatuses(env); } }; @@ -138,12 +162,29 @@ async function route(request: Request, env: Env): Promise { 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 { 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]); diff --git a/src/mastodon.ts b/src/mastodon.ts index d51d87b..cffd3f9 100644 --- a/src/mastodon.ts +++ b/src/mastodon.ts @@ -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; }; +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 { 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 { 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 { 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 { 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 ({ 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 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(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 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(); for (const mention of resolvedMentions) { if (!mention.actorId.startsWith(baseUrl(env))) { @@ -669,7 +703,177 @@ export async function createStatus(request: Request, env: Env): Promise 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 { + 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 { + 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(); + return json(await Promise.all(rows.results.map((row) => scheduledStatusJson(env, row)))); +} + +export async function getScheduledStatus(request: Request, env: Env, scheduledId: string): Promise { + 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(); + 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 { + 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(); + 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(); + return json(await scheduledStatusJson(env, updated!)); +} + +export async function deleteScheduledStatus(request: Request, env: Env, scheduledId: string): Promise { + 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(); + 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 { + 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(); + 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> { + const input = parseScheduledStatusInput(row.params_json); + const mediaIds = parseCachedJson(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()).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 { @@ -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 { + const poll = await env.DB.prepare("SELECT * FROM polls WHERE id = ?").bind(pollId).first(); + 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 { + const user = await requireUser(request, env); + const poll = await env.DB.prepare("SELECT * FROM polls WHERE id = ?").bind(pollId).first(); + 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(); + 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 { 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 { + 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(); + return json(rows.results.map(listJson)); +} + +export async function createList(request: Request, env: Env): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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()).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()).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 { const user = await requireUser(request, env); const target = await resolveAccountTarget(env, accountId); @@ -1200,8 +1578,57 @@ export async function trendsTags(env: Env): Promise { return json([]); } -export async function pushSubscription(): Promise { - return json({ error: "push subscriptions not supported" }, 422); +export async function getPushSubscription(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + const row = await env.DB.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").bind(user.id).first(); + if (!row) return json({ error: "Record not found" }, 404); + return json(pushSubscriptionJson(row)); +} + +export async function createPushSubscription(request: Request, env: Env): Promise { + 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(); + 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(); + return json(pushSubscriptionJson(row!)); +} + +export async function updatePushSubscription(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + const row = await env.DB.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").bind(user.id).first(); + 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(); + return json(pushSubscriptionJson(updated!)); +} + +export async function deletePushSubscription(request: Request, env: Env): Promise { + 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 { @@ -1209,6 +1636,35 @@ export async function markersList(request: Request, env: Env): Promise return json({}); } +function pushSubscriptionJson(row: PushSubscription): Record { + 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 { + const alerts = parseObjectJson(fallback ?? "{}") as Record; + 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 { + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : {}; + } catch { + return {}; + } +} + type StatusSerializationContext = { usersById: Map; accountByUserId: Map>; @@ -1222,6 +1678,11 @@ type StatusSerializationContext = { replyCountByStatusId: Map; bookmarkedStatusIds: Set; pinnedStatusIds: Set; + pollByStatusId: Map; + pollOptionsByPollId: Map; + pollVotesByPollId: Map>; + pollVotersCountByPollId: Map; + pollOwnVotesByPollId: Map; }; async function cachedStatusToMastodon(env: Env, row: CachedStatus): Promise> { @@ -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 | 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(); + 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> { + 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(), + 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()), - viewerId ? loadPinnedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set()) + viewerId ? loadPinnedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set()), + loadPollSerializationContext(env, statusIds, viewer) ]); const accountByUserId = new Map>(); @@ -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 { + 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 { + return env.DB.prepare("SELECT * FROM lists WHERE id = ? AND user_id = ?").bind(listId, userId).first(); +} + type AccountTarget = { kind: "local"; userId: string; actorId: string } | { kind: "remote"; actorId: string }; async function resolveAccountTarget(env: Env, key: string): Promise { @@ -1598,6 +2138,10 @@ function uniqueStrings(values: Array): 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> { + const pollByStatusId = new Map(); + const pollOptionsByPollId = new Map(); + const pollVotesByPollId = new Map>(); + const pollVotersCountByPollId = new Map(); + const pollOwnVotesByPollId = new Map(); + 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(); + 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(); + 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[]> { if (notifications.length === 0) return []; diff --git a/src/types.ts b/src/types.ts index 087d20b..219cdb3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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;