投票支持
This commit is contained in:
@@ -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);
|
||||||
@@ -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`、`DELETE /api/v1/statuses/:id`(联邦 Delete 出站)
|
||||||
- `GET /api/v1/statuses/:id/context`
|
- `GET /api/v1/statuses/:id/context`
|
||||||
- `POST /api/v1/statuses/:id/favourite`、`/unfavourite`(联邦 Like / Undo Like)
|
- `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/reblog`、`/unreblog`(联邦 Announce / Undo Announce)
|
||||||
- `POST /api/v1/statuses/:id/bookmark`、`/unbookmark`、`/pin`、`/unpin`(本地落库)
|
- `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/bookmarks`、`GET /api/v1/favourites`(列出本地 bookmark / favourite)
|
||||||
|
|
||||||
时间线 / 通知 / 媒体 / 搜索 / 其它:
|
时间线 / 通知 / 媒体 / 搜索 / 其它:
|
||||||
|
|
||||||
- `GET /api/v1/timelines/public`(分页支持 `max_id` / `since_id` / `min_id`,响应携带 `Link` 头)
|
- `GET /api/v1/timelines/public`(分页支持 `max_id` / `since_id` / `min_id`,响应携带 `Link` 头)
|
||||||
- `GET /api/v1/timelines/home`(合并本地嘟文 + 关注的远端账号缓存嘟文,按时间排序)
|
- `GET /api/v1/timelines/home`(合并本地嘟文 + 关注的远端账号缓存嘟文,按时间排序)
|
||||||
|
- `GET /api/v1/timelines/list/:id`
|
||||||
- `GET /api/v1/timelines/tag/:tag`、`GET /api/v1/tags/:name`(话题时间线 + 话题元数据)
|
- `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`
|
- `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`
|
- `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/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)
|
- `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 / 发现
|
### ActivityPub / 发现
|
||||||
|
|
||||||
@@ -142,6 +146,7 @@ npm run deploy
|
|||||||
- `migrations/0002_features.sql` — 通知 / 收藏 / 转发 / 提及 / 话题标签 / actor 缓存 / 出站关注 / 删除墓碑 / 嘟文扩展字段(summary / sensitive / language)
|
- `migrations/0002_features.sql` — 通知 / 收藏 / 转发 / 提及 / 话题标签 / actor 缓存 / 出站关注 / 删除墓碑 / 嘟文扩展字段(summary / sensitive / language)
|
||||||
- `migrations/0003_bookmarks_cache.sql` — 收藏夹(bookmarks)/ 置顶(pinned_statuses)/ 远端嘟文缓存(cached_statuses)/ OAuth Token 持久表
|
- `migrations/0003_bookmarks_cache.sql` — 收藏夹(bookmarks)/ 置顶(pinned_statuses)/ 远端嘟文缓存(cached_statuses)/ OAuth Token 持久表
|
||||||
- `migrations/0006_cached_status_metadata.sql` — 远端缓存嘟文的可见性 / mentions / tags / 本地收件人元数据
|
- `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` 仍没有完整受众表,本地读取保守限制为作者可见,不应当作为完整私信系统使用
|
- `direct` 仍没有完整受众表,本地读取保守限制为作者可见,不应当作为完整私信系统使用
|
||||||
- 远端嘟文缓存只从入站 `Create(Note)` 和已缓存嘟文的 `Update(Note)` 写入,不抓取历史 outbox
|
- 远端嘟文缓存只从入站 `Create(Note)` 和已缓存嘟文的 `Update(Note)` 写入,不抓取历史 outbox
|
||||||
- 远端缓存嘟文会保留正文、CW、语言、可见性、mentions、tags、本地收件人和附件; 互动计数、poll、card 等扩展信息不会完整恢复
|
- 远端缓存嘟文会保留正文、CW、语言、可见性、mentions、tags、本地收件人和附件; 互动计数、poll、card 等扩展信息不会完整恢复
|
||||||
|
- Web Push 目前实现订阅存储和 API 兼容,尚未实现 VAPID 加密投递通知
|
||||||
|
- Poll 当前只在本地 Mastodon API 中序列化和投票,不会联邦成 ActivityPub Question
|
||||||
- 媒体上传只支持 `image/jpeg`、`image/png`、`image/gif`、`image/webp`,单文件 10MB,单条状态的附件数量不做服务端限制; 头像和封面同样只按图片路径处理
|
- 媒体上传只支持 `image/jpeg`、`image/png`、`image/gif`、`image/webp`,单文件 10MB,单条状态的附件数量不做服务端限制; 头像和封面同样只按图片路径处理
|
||||||
- 没有实现接口级限流、反滥用或审核流; `follow_requests` 相关接口仍是 stub
|
- 没有实现接口级限流、反滥用或审核流; `follow_requests` 相关接口仍是 stub
|
||||||
- 没有实现轮询(poll)、列表(list)、推送(push)、未来嘟文(scheduled)等
|
|
||||||
|
|
||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
|
|||||||
+46
-2
@@ -18,14 +18,20 @@ import {
|
|||||||
accountStatuses,
|
accountStatuses,
|
||||||
accountFollowers,
|
accountFollowers,
|
||||||
accountFollowing,
|
accountFollowing,
|
||||||
|
addListAccounts,
|
||||||
authorize,
|
authorize,
|
||||||
authorizeFollowRequest,
|
authorizeFollowRequest,
|
||||||
authorizePage,
|
authorizePage,
|
||||||
bookmarkStatus,
|
bookmarkStatus,
|
||||||
bookmarksList,
|
bookmarksList,
|
||||||
createApp,
|
createApp,
|
||||||
|
createList,
|
||||||
|
createPushSubscription,
|
||||||
createStatus,
|
createStatus,
|
||||||
customEmojis,
|
customEmojis,
|
||||||
|
deleteList,
|
||||||
|
deletePushSubscription,
|
||||||
|
deleteScheduledStatus,
|
||||||
deleteStatusEndpoint,
|
deleteStatusEndpoint,
|
||||||
favouriteStatus,
|
favouriteStatus,
|
||||||
favouritesList,
|
favouritesList,
|
||||||
@@ -33,23 +39,32 @@ import {
|
|||||||
followAccount,
|
followAccount,
|
||||||
followRequestsList,
|
followRequestsList,
|
||||||
getAccount,
|
getAccount,
|
||||||
|
getList,
|
||||||
|
getPoll,
|
||||||
|
getPushSubscription,
|
||||||
getRelationships,
|
getRelationships,
|
||||||
|
getScheduledStatus,
|
||||||
getStatusEndpoint,
|
getStatusEndpoint,
|
||||||
hashtagInfo,
|
hashtagInfo,
|
||||||
hashtagTimeline,
|
hashtagTimeline,
|
||||||
homeTimeline,
|
homeTimeline,
|
||||||
instance,
|
instance,
|
||||||
instanceV2,
|
instanceV2,
|
||||||
|
listAccounts,
|
||||||
|
listScheduledStatuses,
|
||||||
|
listTimeline,
|
||||||
|
listsList,
|
||||||
lookupAccount,
|
lookupAccount,
|
||||||
markersList,
|
markersList,
|
||||||
notificationClear,
|
notificationClear,
|
||||||
notificationDismiss,
|
notificationDismiss,
|
||||||
notificationsList,
|
notificationsList,
|
||||||
pinStatus,
|
pinStatus,
|
||||||
|
publishDueScheduledStatuses,
|
||||||
publicTimeline,
|
publicTimeline,
|
||||||
pushSubscription,
|
|
||||||
reblogStatus,
|
reblogStatus,
|
||||||
rejectFollowRequest,
|
rejectFollowRequest,
|
||||||
|
removeListAccounts,
|
||||||
revoke,
|
revoke,
|
||||||
search,
|
search,
|
||||||
serveMedia,
|
serveMedia,
|
||||||
@@ -61,9 +76,13 @@ import {
|
|||||||
unfollowAccount,
|
unfollowAccount,
|
||||||
unpinStatus,
|
unpinStatus,
|
||||||
unreblogStatus,
|
unreblogStatus,
|
||||||
|
updateList,
|
||||||
|
updatePushSubscription,
|
||||||
|
updateScheduledStatus,
|
||||||
updateCredentials,
|
updateCredentials,
|
||||||
updateMedia,
|
updateMedia,
|
||||||
uploadMedia,
|
uploadMedia,
|
||||||
|
votePoll,
|
||||||
verifyAppCredentials,
|
verifyAppCredentials,
|
||||||
verifyCredentials
|
verifyCredentials
|
||||||
} from "./mastodon";
|
} from "./mastodon";
|
||||||
@@ -72,12 +91,17 @@ export default {
|
|||||||
async fetch(request: Request, env: Env): Promise<Response> {
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
await ensureAdminUser(env);
|
await ensureAdminUser(env);
|
||||||
|
await publishDueScheduledStatuses(env);
|
||||||
return await route(request, env);
|
return await route(request, env);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpError) return json({ error: error.message }, error.status);
|
if (error instanceof HttpError) return json({ error: error.message }, error.status);
|
||||||
console.error("unhandled", error);
|
console.error("unhandled", error);
|
||||||
return json({ error: "internal_server_error" }, 500);
|
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\/([^/]+)\/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\/([^/]+)\/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 === "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/public") return publicTimeline(request, env);
|
||||||
if (method === "GET" && path === "/api/v1/timelines/home") return homeTimeline(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\/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" && (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/bookmarks") return bookmarksList(request, env);
|
||||||
if (method === "GET" && path === "/api/v1/favourites") return favouritesList(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/filters") return filtersV1(request, env);
|
||||||
if (method === "GET" && path === "/api/v1/trends/tags") return trendsTags(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 === "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]);
|
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 { ParsedBody } from "./http";
|
||||||
import type {
|
import type {
|
||||||
ActorCache,
|
ActorCache,
|
||||||
|
AccountList,
|
||||||
CachedStatus,
|
CachedStatus,
|
||||||
CachedStatusMention,
|
CachedStatusMention,
|
||||||
CachedStatusTag,
|
CachedStatusTag,
|
||||||
Follow,
|
Follow,
|
||||||
|
Json,
|
||||||
Media,
|
Media,
|
||||||
Mention,
|
Mention,
|
||||||
Notification,
|
Notification,
|
||||||
|
Poll,
|
||||||
|
PollOption,
|
||||||
|
PushSubscription,
|
||||||
|
ScheduledStatus,
|
||||||
Session,
|
Session,
|
||||||
Status,
|
Status,
|
||||||
User
|
User
|
||||||
@@ -93,6 +99,11 @@ const MAX_MEDIA_BYTES = 10 * 1024 * 1024;
|
|||||||
|
|
||||||
const SUPPORTED_MIME = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
const SUPPORTED_MIME = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||||
const VALID_STATUS_VISIBILITIES = new Set(["public", "unlisted", "private", "direct"]);
|
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 StatusVisibility = "public" | "unlisted" | "private" | "direct";
|
||||||
type StatusViewer = {
|
type StatusViewer = {
|
||||||
@@ -102,6 +113,20 @@ type StatusViewer = {
|
|||||||
remoteFollowsByActorId: Map<string, boolean>;
|
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[] {
|
function parseRedirectUris(value: string): string[] {
|
||||||
return value.split(/\s+/).map((item) => item.trim()).filter(Boolean);
|
return value.split(/\s+/).map((item) => item.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -133,7 +158,7 @@ export async function instance(env: Env): Promise<Response> {
|
|||||||
configuration: {
|
configuration: {
|
||||||
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: REPORTED_MEDIA_ATTACHMENTS_LIMIT, characters_reserved_per_url: 23 },
|
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: 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: 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),
|
contact_account: await accountJson(env, admin),
|
||||||
rules: []
|
rules: []
|
||||||
@@ -155,7 +180,8 @@ export async function instanceV2(env: Env): Promise<Response> {
|
|||||||
urls: { streaming: `wss://${hostFromBaseUrl(env)}` },
|
urls: { streaming: `wss://${hostFromBaseUrl(env)}` },
|
||||||
accounts: { max_featured_tags: 0 },
|
accounts: { max_featured_tags: 0 },
|
||||||
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: REPORTED_MEDIA_ATTACHMENTS_LIMIT, characters_reserved_per_url: 23 },
|
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: 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 },
|
registrations: { enabled: false, approval_required: false, message: null },
|
||||||
contact: { email: "", account: await accountJson(env, admin) },
|
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> {
|
export async function createStatus(request: Request, env: Env): Promise<Response> {
|
||||||
const user = await requireUser(request, env);
|
const user = await requireUser(request, env);
|
||||||
const body = await readBody(request);
|
const body = await readBody(request);
|
||||||
const statusText = bodyString(body, "status").trim();
|
const input = parseStatusCreateInput(body);
|
||||||
if (!statusText) return json({ error: "status can't be blank" }, 422);
|
const scheduledAt = bodyString(body, "scheduled_at");
|
||||||
if (statusText.length > MAX_STATUS_CHARS) return json({ error: "status too long" }, 422);
|
if (scheduledAt) return scheduleStatus(env, user, input, scheduledAt);
|
||||||
|
|
||||||
const summary = bodyString(body, "spoiler_text");
|
const status = await publishStatus(env, user, input);
|
||||||
const sensitive = bodyString(body, "sensitive") === "true";
|
return json(await statusJson(env, status, user, request));
|
||||||
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");
|
|
||||||
|
|
||||||
|
async function publishStatus(env: Env, user: User, input: StatusCreateInput): Promise<Status> {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const statusId = id();
|
const statusId = id();
|
||||||
const objectId = objectUrl(env, statusId);
|
const objectId = objectUrl(env, statusId);
|
||||||
const activityId = activityUrl(env, statusId);
|
const activityId = activityUrl(env, statusId);
|
||||||
|
|
||||||
const mentionsAcct = extractMentions(statusText);
|
const mentionsAcct = extractMentions(input.statusText);
|
||||||
const hashtags = extractHashtags(statusText);
|
const hashtags = extractHashtags(input.statusText);
|
||||||
|
|
||||||
const resolvedMentions: { acct: string; actorId: string; url: string }[] = [];
|
const resolvedMentions: { acct: string; actorId: string; url: string }[] = [];
|
||||||
for (const acct of mentionsAcct) {
|
for (const acct of mentionsAcct) {
|
||||||
@@ -579,7 +601,7 @@ export async function createStatus(request: Request, env: Env): Promise<Response
|
|||||||
if (resolved) resolvedMentions.push(resolved);
|
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(
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
"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,
|
statusId,
|
||||||
user.id,
|
user.id,
|
||||||
renderedContent,
|
renderedContent,
|
||||||
summary,
|
input.summary,
|
||||||
sensitive ? 1 : 0,
|
input.sensitive ? 1 : 0,
|
||||||
language,
|
input.language,
|
||||||
visibility,
|
input.visibility,
|
||||||
inReplyTo || null,
|
input.inReplyTo || null,
|
||||||
activityId,
|
activityId,
|
||||||
objectId,
|
objectId,
|
||||||
now,
|
now,
|
||||||
@@ -600,10 +622,22 @@ export async function createStatus(request: Request, env: Env): Promise<Response
|
|||||||
)
|
)
|
||||||
.run();
|
.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();
|
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) {
|
for (const mention of resolvedMentions) {
|
||||||
await env.DB.prepare("INSERT OR IGNORE INTO mentions (status_id, actor, acct, url) VALUES (?, ?, ?, ?)")
|
await env.DB.prepare("INSERT OR IGNORE INTO mentions (status_id, actor, acct, url) VALUES (?, ?, ?, ?)")
|
||||||
.bind(statusId, mention.actorId, mention.acct, mention.url).run();
|
.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;
|
let replyParent: Status | null = null;
|
||||||
if (inReplyTo) {
|
if (input.inReplyTo) {
|
||||||
replyParent = await getStatus(env, inReplyTo);
|
replyParent = await getStatus(env, input.inReplyTo);
|
||||||
if (replyParent) {
|
if (replyParent) {
|
||||||
const parentUser = await getUserById(env, replyParent.user_id);
|
const parentUser = await getUserById(env, replyParent.user_id);
|
||||||
if (parentUser && parentUser.id !== 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);
|
const status = await getStatus(env, statusId);
|
||||||
if (!status) throw new HttpError(500, "status_not_found");
|
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));
|
const inboxes = new Set<string>(await gatherFollowerInboxes(env, user.id));
|
||||||
for (const mention of resolvedMentions) {
|
for (const mention of resolvedMentions) {
|
||||||
if (!mention.actorId.startsWith(baseUrl(env))) {
|
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 mentionActors = resolvedMentions.map((m) => m.actorId);
|
||||||
const to = visibility === "public"
|
const to = input.visibility === "public"
|
||||||
? ["https://www.w3.org/ns/activitystreams#Public"]
|
? ["https://www.w3.org/ns/activitystreams#Public"]
|
||||||
: visibility === "unlisted"
|
: input.visibility === "unlisted"
|
||||||
? [`${actorUrl(env, user)}/followers`]
|
? [`${actorUrl(env, user)}/followers`]
|
||||||
: [`${actorUrl(env, user)}/followers`, ...mentionActors];
|
: [`${actorUrl(env, user)}/followers`, ...mentionActors];
|
||||||
const cc = visibility === "public"
|
const cc = input.visibility === "public"
|
||||||
? [`${actorUrl(env, user)}/followers`, ...mentionActors]
|
? [`${actorUrl(env, user)}/followers`, ...mentionActors]
|
||||||
: visibility === "unlisted"
|
: input.visibility === "unlisted"
|
||||||
? ["https://www.w3.org/ns/activitystreams#Public", ...mentionActors]
|
? ["https://www.w3.org/ns/activitystreams#Public", ...mentionActors]
|
||||||
: [];
|
: [];
|
||||||
const activity = createActivity(env, user, status, { to, cc });
|
const activity = createActivity(env, user, status, { to, cc });
|
||||||
await deliverToInboxes(env, user, inboxes, activity);
|
await deliverToInboxes(env, user, inboxes, activity);
|
||||||
} else if (visibility === "direct") {
|
} else if (input.visibility === "direct") {
|
||||||
const inboxes = new Set<string>();
|
const inboxes = new Set<string>();
|
||||||
for (const mention of resolvedMentions) {
|
for (const mention of resolvedMentions) {
|
||||||
if (!mention.actorId.startsWith(baseUrl(env))) {
|
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);
|
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> {
|
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));
|
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> {
|
export async function deleteStatusEndpoint(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||||
const user = await requireUser(request, env);
|
const user = await requireUser(request, env);
|
||||||
const status = await getStatus(env, statusId);
|
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 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 reblogs WHERE status_id = ?").bind(status.id).run();
|
||||||
await env.DB.prepare("DELETE FROM notifications 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 mentionActors = mentions.map((mention) => mention.actor);
|
||||||
const deleteAudience = status.visibility === "direct"
|
const deleteAudience = status.visibility === "direct"
|
||||||
@@ -1084,6 +1333,135 @@ export async function getRelationships(request: Request, env: Env): Promise<Resp
|
|||||||
return json(out);
|
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> {
|
export async function followAccount(request: Request, env: Env, accountId: string): Promise<Response> {
|
||||||
const user = await requireUser(request, env);
|
const user = await requireUser(request, env);
|
||||||
const target = await resolveAccountTarget(env, accountId);
|
const target = await resolveAccountTarget(env, accountId);
|
||||||
@@ -1200,8 +1578,57 @@ export async function trendsTags(env: Env): Promise<Response> {
|
|||||||
return json([]);
|
return json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pushSubscription(): Promise<Response> {
|
export async function getPushSubscription(request: Request, env: Env): Promise<Response> {
|
||||||
return json({ error: "push subscriptions not supported" }, 422);
|
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> {
|
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({});
|
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 = {
|
type StatusSerializationContext = {
|
||||||
usersById: Map<string, User>;
|
usersById: Map<string, User>;
|
||||||
accountByUserId: Map<string, Record<string, unknown>>;
|
accountByUserId: Map<string, Record<string, unknown>>;
|
||||||
@@ -1222,6 +1678,11 @@ type StatusSerializationContext = {
|
|||||||
replyCountByStatusId: Map<string, number>;
|
replyCountByStatusId: Map<string, number>;
|
||||||
bookmarkedStatusIds: Set<string>;
|
bookmarkedStatusIds: Set<string>;
|
||||||
pinnedStatusIds: 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>> {
|
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),
|
bookmarked: context.bookmarkedStatusIds.has(status.id),
|
||||||
pinned: context.pinnedStatusIds.has(status.id),
|
pinned: context.pinnedStatusIds.has(status.id),
|
||||||
card: null,
|
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(
|
async function serializeStatuses(
|
||||||
env: Env,
|
env: Env,
|
||||||
statuses: Status[],
|
statuses: Status[],
|
||||||
@@ -1379,7 +1900,7 @@ async function buildStatusSerializationContext(
|
|||||||
const viewerUserForContext = await viewerUser(request, env);
|
const viewerUserForContext = await viewerUser(request, env);
|
||||||
const viewer = viewerUserForContext ? actorUrl(env, viewerUserForContext) : null;
|
const viewer = viewerUserForContext ? actorUrl(env, viewerUserForContext) : null;
|
||||||
const viewerId = viewerUserForContext?.id ?? 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),
|
loadMediaByStatusIds(env, statusIds),
|
||||||
loadMentionsByStatusIds(env, statusIds),
|
loadMentionsByStatusIds(env, statusIds),
|
||||||
loadHashtagsByStatusIds(env, statusIds),
|
loadHashtagsByStatusIds(env, statusIds),
|
||||||
@@ -1387,7 +1908,8 @@ async function buildStatusSerializationContext(
|
|||||||
loadStatusInteractionSummary(env, "reblogs", statusIds, viewer),
|
loadStatusInteractionSummary(env, "reblogs", statusIds, viewer),
|
||||||
loadReplyCountByStatusIds(env, statusIds),
|
loadReplyCountByStatusIds(env, statusIds),
|
||||||
viewerId ? loadBookmarkedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set<string>()),
|
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>>();
|
const accountByUserId = new Map<string, Record<string, unknown>>();
|
||||||
@@ -1407,7 +1929,12 @@ async function buildStatusSerializationContext(
|
|||||||
rebloggedStatusIds: reblogSummary.viewerMatchedStatusIds,
|
rebloggedStatusIds: reblogSummary.viewerMatchedStatusIds,
|
||||||
replyCountByStatusId,
|
replyCountByStatusId,
|
||||||
bookmarkedStatusIds,
|
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 };
|
type AccountTarget = { kind: "local"; userId: string; actorId: string } | { kind: "remote"; actorId: string };
|
||||||
|
|
||||||
async function resolveAccountTarget(env: Env, key: string): Promise<AccountTarget | null> {
|
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)))];
|
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 {
|
function placeholders(count: number): string {
|
||||||
return Array.from({ length: count }, () => "?").join(",");
|
return Array.from({ length: count }, () => "?").join(",");
|
||||||
}
|
}
|
||||||
@@ -1694,6 +2238,63 @@ async function loadReplyCountByStatusIds(env: Env, statusIds: string[]): Promise
|
|||||||
return counts;
|
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>[]> {
|
async function serializeNotifications(env: Env, notifications: Notification[], request: Request): Promise<Record<string, unknown>[]> {
|
||||||
if (notifications.length === 0) return [];
|
if (notifications.length === 0) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,59 @@ export type CachedStatusAttachment = {
|
|||||||
description: string | null;
|
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 = {
|
export type OAuthToken = {
|
||||||
token: string;
|
token: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user