投票支持
This commit is contained in:
+46
-2
@@ -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
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user