Initial toot-worker implementation
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.claude/
|
||||
@@ -0,0 +1,75 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
private_key_jwk TEXT NOT NULL,
|
||||
public_key_jwk TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL UNIQUE,
|
||||
client_secret TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
website TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
app_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS statuses (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
visibility TEXT NOT NULL DEFAULT 'public',
|
||||
in_reply_to_id TEXT,
|
||||
activity_id TEXT NOT NULL UNIQUE,
|
||||
object_id TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
url TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
status_id TEXT,
|
||||
r2_key TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
description TEXT,
|
||||
size INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS follows (
|
||||
id TEXT PRIMARY KEY,
|
||||
follower_actor TEXT NOT NULL,
|
||||
local_user_id TEXT NOT NULL,
|
||||
inbox TEXT NOT NULL,
|
||||
accepted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(follower_actor, local_user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remote_activities (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_statuses_created_at ON statuses(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_user ON media(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_follows_local_user ON follows(local_user_id);
|
||||
@@ -0,0 +1,104 @@
|
||||
-- Account fields, status enrichment, and federation state for v0.2 -> v0.3.
|
||||
|
||||
ALTER TABLE statuses ADD COLUMN summary TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE statuses ADD COLUMN sensitive INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE statuses ADD COLUMN language TEXT NOT NULL DEFAULT 'en';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deleted_statuses (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
object_id TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
deleted_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
status_id TEXT,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_time ON notifications(user_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS favourites (
|
||||
id TEXT PRIMARY KEY,
|
||||
status_id TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
activity_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(status_id, actor)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_favourites_status ON favourites(status_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reblogs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status_id TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
activity_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(status_id, actor)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reblogs_status ON reblogs(status_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mentions (
|
||||
status_id TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
acct TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
PRIMARY KEY(status_id, actor)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mentions_status ON mentions(status_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hashtags (
|
||||
status_id TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY(status_id, tag)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_hashtags_tag ON hashtags(tag);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS actor_cache (
|
||||
id TEXT PRIMARY KEY,
|
||||
inbox TEXT NOT NULL,
|
||||
shared_inbox TEXT,
|
||||
preferred_username TEXT,
|
||||
name TEXT,
|
||||
summary TEXT,
|
||||
icon_url TEXT,
|
||||
public_key_id TEXT,
|
||||
public_key_pem TEXT,
|
||||
fetched_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_actor_cache_key_id ON actor_cache(public_key_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outgoing_follows (
|
||||
id TEXT PRIMARY KEY,
|
||||
local_user_id TEXT NOT NULL,
|
||||
target_actor TEXT NOT NULL,
|
||||
target_inbox TEXT NOT NULL,
|
||||
activity_id TEXT NOT NULL,
|
||||
accepted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(local_user_id, target_actor)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_outgoing_follows_user ON outgoing_follows(local_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profile_fields (
|
||||
user_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, position)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_statuses_user_time ON statuses(user_id, created_at DESC);
|
||||
@@ -0,0 +1,3 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_statuses_visibility_time ON statuses(visibility, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_statuses_reply_time ON statuses(in_reply_to_id, created_at ASC);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_status_time ON media(status_id, created_at ASC);
|
||||
Generated
+1575
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "toot-worker",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:local": "wrangler d1 migrations apply toot_db --local",
|
||||
"db:remote": "wrangler d1 migrations apply toot_db --remote"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260507.0",
|
||||
"typescript": "^5.9.3",
|
||||
"wrangler": "^4.37.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
# Toot Worker
|
||||
|
||||
一个运行在 Cloudflare Workers 上的单用户联邦宇宙发布软件,使用:
|
||||
|
||||
- Workers: HTTP API、ActivityPub 路由、Mastodon API 兼容层
|
||||
- D1: 用户、OAuth 应用、嘟文、媒体索引、关注、收藏、转发、通知、提及、话题标签、远端 actor 缓存
|
||||
- R2: 媒体文件
|
||||
- KV: OAuth access token 会话
|
||||
|
||||
当前目标:让常见 Mastodon App 完成实例发现、创建 OAuth App、登录、上传媒体、发布嘟文、读取公开/家庭时间线、收藏/转发/回复、查看通知、搜索、关注/取关远端账号,同时支持单用户实例与 Fediverse 双向联邦。
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run db:local
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认管理员账号来自 `wrangler.jsonc`:
|
||||
|
||||
- 用户名:`admin`
|
||||
- 密码:`change-me-before-deploy`
|
||||
|
||||
部署前必须改掉 `ADMIN_PASSWORD`,更推荐用 Cloudflare secret 管理密码:
|
||||
|
||||
```bash
|
||||
wrangler secret put ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
`PUBLIC_BASE_URL` 必须在首次正式部署前改成你的稳定实例域名,例如:
|
||||
|
||||
```json
|
||||
"PUBLIC_BASE_URL": "https://social.example.com"
|
||||
```
|
||||
|
||||
这个值会进入:
|
||||
|
||||
- Actor ID
|
||||
- Object ID
|
||||
- WebFinger 返回值
|
||||
- 媒体和头像 URL
|
||||
|
||||
一旦开始对外联邦后,不要再改域名,否则远端会把你视为另一个实例身份。
|
||||
|
||||
## Cloudflare 资源
|
||||
|
||||
```bash
|
||||
wrangler d1 create toot_db
|
||||
wrangler r2 bucket create toot-media
|
||||
wrangler kv namespace create KV
|
||||
```
|
||||
|
||||
把返回的 ID 填回 `wrangler.jsonc`,然后应用迁移并部署:
|
||||
|
||||
```bash
|
||||
npm run db:remote
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
## 已实现接口
|
||||
|
||||
### Mastodon API 兼容
|
||||
|
||||
实例 / 应用 / 鉴权:
|
||||
|
||||
- `GET /api/v1/instance`、`GET /api/v2/instance`
|
||||
- `POST /api/v1/apps`、`GET /api/v1/apps/verify_credentials`
|
||||
- `GET /oauth/authorize`、`POST /oauth/authorize`
|
||||
- `POST /oauth/token`、`POST /oauth/revoke`
|
||||
|
||||
账号:
|
||||
|
||||
- `GET /api/v1/accounts/verify_credentials`
|
||||
- `PATCH /api/v1/accounts/update_credentials`(自动联邦 Update Person)
|
||||
- `GET /api/v1/accounts/relationships`
|
||||
- `GET /api/v1/accounts/:id`
|
||||
- `GET /api/v1/accounts/:id/statuses`
|
||||
- `POST /api/v1/accounts/:id/follow`、`POST /api/v1/accounts/:id/unfollow`(向远端发送 Follow / Undo)
|
||||
- `GET /api/v1/follow_requests`、`POST /api/v1/follow_requests/:id/authorize`、`/reject`(stub,默认全自动接受)
|
||||
|
||||
嘟文:
|
||||
|
||||
- `POST /api/v1/statuses`(支持 `media_ids`、`spoiler_text`、`sensitive`、`in_reply_to_id`、`visibility`、`language`,自动解析 `@user`/`@user@host` 提及和 `#hashtag`,投递 Create 给 followers 与 mention)
|
||||
- `GET /api/v1/statuses/:id`、`DELETE /api/v1/statuses/:id`(联邦 Delete 出站)
|
||||
- `GET /api/v1/statuses/:id/context`
|
||||
- `POST /api/v1/statuses/:id/favourite`、`/unfavourite`(联邦 Like / Undo Like)
|
||||
- `POST /api/v1/statuses/:id/reblog`、`/unreblog`(联邦 Announce / Undo Announce)
|
||||
- `POST /api/v1/statuses/:id/bookmark`、`/unbookmark`、`/pin`、`/unpin`(本地 stub)
|
||||
|
||||
时间线 / 通知 / 媒体 / 搜索 / 其它:
|
||||
|
||||
- `GET /api/v1/timelines/public`、`GET /api/v1/timelines/home`(分页支持 `max_id` / `since_id` / `min_id`,响应携带 `Link` 头)
|
||||
- `GET /api/v1/notifications`、`POST /api/v1/notifications/clear`、`POST /api/v1/notifications/:id/dismiss`
|
||||
- `POST /api/v1/media`、`POST /api/v2/media`、`PUT /api/v1/media/:id`
|
||||
- `GET /api/v2/search`、`GET /api/v1/search`(本地账号 / 嘟文 / 话题标签 + 跨站 WebFinger 解析 `acct:` 查询)
|
||||
- `GET /api/v1/custom_emojis`、`GET /api/v1/filters`、`GET /api/v1/trends/tags`、`GET /api/v1/markers`(stub)
|
||||
- `POST /api/v1/push/subscription`(返回 422,目前不支持推送)
|
||||
|
||||
### ActivityPub / 发现
|
||||
|
||||
- `GET /.well-known/webfinger?resource=acct:user@example.com`
|
||||
- `GET /.well-known/nodeinfo`、`GET /.well-known/host-meta`
|
||||
- `GET /nodeinfo/2.0`
|
||||
- `GET /users/:username`(含 `endpoints.sharedInbox`、`icon`、`image`、`publicKey`)
|
||||
- `GET /users/:username/followers`、`/following`
|
||||
- `GET /users/:username/outbox`(支持 `?page=true` 翻页)
|
||||
- `POST /users/:username/inbox`、`POST /inbox`(共享 inbox)
|
||||
- `GET /objects/:id`(嘟文已删除时返回 `Tombstone`,HTTP 410)
|
||||
|
||||
入站 inbox 处理类型:`Follow` / `Undo(Follow)` / `Accept(Follow)` / `Reject(Follow)` / `Like` / `Undo(Like)` / `Announce` / `Undo(Announce)` / `Delete(Note)` / `Delete(Person)` / `Update(Person)` / `Create(Note)`(只用于触发提及通知)。`Create` 用于触发提及通知和回复通知。
|
||||
|
||||
## 安全
|
||||
|
||||
- 密码哈希使用 **PBKDF2-SHA256 / 100000 iterations**,带每用户随机 16 字节 salt,旧版 `salt.hash` 哈希也能继续验证(便于无缝升级)。
|
||||
- HTTP Signature(rsa-sha256)出站:签名 `(request-target)`、`host`、`date`、`digest`,自动计算 SHA-256 digest。
|
||||
- HTTP Signature 入站验证:
|
||||
- 强制要求 `Date` 头,允许 ±12 小时时钟偏移
|
||||
- `POST` 必须带 `Digest`,且与 body 哈希一致
|
||||
- 通过签名头里的 `keyId` 取得远端 actor 公钥(并在 `actor_cache` 表中缓存),拒绝 `body.actor` 与 actor cache 不一致的请求
|
||||
- 验证 `host` 头匹配请求 URL
|
||||
- 远端 actor 公钥与 inbox 写入 `actor_cache`,默认 24 小时 TTL,远端 `Delete(Person)` 会清理缓存与所有相关关注 / 收藏 / 转发 / 通知。
|
||||
- 重放保护:已处理过的 `activity.id` 会写入 `remote_activities`,重复投递被静默丢弃(返回 202)。
|
||||
|
||||
## 数据库结构
|
||||
|
||||
迁移文件:
|
||||
|
||||
- `migrations/0001_initial.sql` — 基础表(users / oauth_apps / oauth_codes / statuses / media / follows / remote_activities)
|
||||
- `migrations/0002_features.sql` — 通知 / 收藏 / 转发 / 提及 / 话题标签 / actor 缓存 / 出站关注 / 删除墓碑 / 嘟文扩展字段(summary / sensitive / language)
|
||||
|
||||
## 重要限制
|
||||
|
||||
这是一个单用户可运行实现,不是完整 Mastodon 服务端:
|
||||
|
||||
- 只支持单管理员账号自动初始化,不开放注册
|
||||
- 通知 / 收藏 / 转发都已实现,但内容审核、屏蔽、过滤、列表、自定义表情、推送通知仍是 stub
|
||||
- 没有处理远端嘟文缓存(收到 `Create(Note)` 不会存,仅触发提及通知)。意味着客户端的 home timeline 仍只能看到本地嘟文
|
||||
- 私信(direct visibility)的检索没有按收信人过滤,目前所有客户端都能在公开时间线之外读到自己的嘟文,不应当作私信使用
|
||||
- 没有实现轮询(poll)、列表(list)、推送(push)、未来嘟文(scheduled)等
|
||||
|
||||
## 参考
|
||||
|
||||
- Cloudflare Workers 绑定:https://developers.cloudflare.com/workers/runtime-apis/bindings/
|
||||
- Cloudflare D1:https://developers.cloudflare.com/d1/
|
||||
- Cloudflare R2:https://developers.cloudflare.com/r2/
|
||||
- Cloudflare KV:https://developers.cloudflare.com/kv/
|
||||
- Mastodon API:https://docs.joinmastodon.org/methods/
|
||||
- ActivityPub:https://www.w3.org/TR/activitypub/
|
||||
- HTTP Signatures:https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12
|
||||
@@ -0,0 +1,532 @@
|
||||
import {
|
||||
deleteActorFromCache,
|
||||
exportUserPublicKeyPem,
|
||||
findFavourite,
|
||||
findReblog,
|
||||
getStatus,
|
||||
getStatusByObjectId,
|
||||
getUserByUsername,
|
||||
recordNotification,
|
||||
upsertActorCache
|
||||
} from "./db";
|
||||
import {
|
||||
deliverToInboxes,
|
||||
isDuplicateActivity,
|
||||
notifyForLocalStatus,
|
||||
objectAsJson,
|
||||
objectIdString,
|
||||
parseActivity,
|
||||
recordRemoteActivity,
|
||||
resolveDeliveryInboxes,
|
||||
resolveRemoteActor,
|
||||
sendSignedActivity,
|
||||
verifyInboundSignature
|
||||
} from "./federation";
|
||||
import { activityJson, json } from "./http";
|
||||
import {
|
||||
ACTIVITY_CONTEXT,
|
||||
AVATAR_SVG,
|
||||
HEADER_SVG,
|
||||
PUBLIC_COLLECTION,
|
||||
SECURITY_CONTEXT
|
||||
} from "./types";
|
||||
import type { ActorCache, Json, RemoteActor, Status, User } from "./types";
|
||||
import {
|
||||
actorUrl,
|
||||
activityUrl,
|
||||
baseUrl,
|
||||
clampLimit,
|
||||
hostFromBaseUrl,
|
||||
id,
|
||||
objectUrl
|
||||
} from "./util";
|
||||
|
||||
export async function webFinger(request: Request, env: Env): Promise<Response> {
|
||||
const resource = new URL(request.url).searchParams.get("resource") ?? "";
|
||||
const match = resource.match(/^acct:([^@]+)@(.+)$/);
|
||||
if (!match) return json({ error: "not_found" }, 404);
|
||||
if (match[2].toLowerCase() !== hostFromBaseUrl(env).toLowerCase()) return json({ error: "not_found" }, 404);
|
||||
const user = await getUserByUsername(env, match[1]);
|
||||
if (!user) return json({ error: "not_found" }, 404);
|
||||
|
||||
return json({
|
||||
subject: `acct:${user.username}@${hostFromBaseUrl(env)}`,
|
||||
aliases: [actorUrl(env, user)],
|
||||
links: [
|
||||
{ rel: "self", type: "application/activity+json", href: actorUrl(env, user) },
|
||||
{ rel: "http://webfinger.net/rel/profile-page", type: "text/html", href: actorUrl(env, user) }
|
||||
]
|
||||
}, 200, { "content-type": "application/jrd+json; charset=utf-8" });
|
||||
}
|
||||
|
||||
export async function hostMeta(env: Env): Promise<Response> {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
|
||||
<Link rel="lrdd" template="${baseUrl(env)}/.well-known/webfinger?resource={uri}"/>
|
||||
</XRD>`;
|
||||
return new Response(xml, { headers: { "content-type": "application/xrd+xml; charset=utf-8" } });
|
||||
}
|
||||
|
||||
export function nodeInfoLinks(env: Env): Response {
|
||||
return json({ links: [{ rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", href: `${baseUrl(env)}/nodeinfo/2.0` }] });
|
||||
}
|
||||
|
||||
export async function nodeInfo(env: Env): Promise<Response> {
|
||||
const users = await env.DB.prepare("SELECT COUNT(*) AS count FROM users").first<{ count: number }>();
|
||||
const posts = await env.DB.prepare("SELECT COUNT(*) AS count FROM statuses").first<{ count: number }>();
|
||||
return json({
|
||||
version: "2.0",
|
||||
software: { name: "toot-worker", version: "0.3.0" },
|
||||
protocols: ["activitypub"],
|
||||
services: { inbound: [], outbound: [] },
|
||||
usage: { users: { total: users?.count ?? 0 }, localPosts: posts?.count ?? 0 },
|
||||
openRegistrations: false,
|
||||
metadata: { nodeName: env.INSTANCE_NAME, singleUserMode: true }
|
||||
});
|
||||
}
|
||||
|
||||
export async function actor(env: Env, username: string): Promise<Response> {
|
||||
const user = await getUserByUsername(env, username);
|
||||
if (!user) return json({ error: "not_found" }, 404);
|
||||
return activityJson(await actorDocument(env, user));
|
||||
}
|
||||
|
||||
export async function actorDocument(env: Env, user: User): Promise<Json> {
|
||||
const url = actorUrl(env, user);
|
||||
return {
|
||||
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT, { manuallyApprovesFollowers: "as:manuallyApprovesFollowers" }],
|
||||
id: url,
|
||||
type: "Person",
|
||||
preferredUsername: user.username,
|
||||
name: user.display_name,
|
||||
summary: user.note,
|
||||
url,
|
||||
inbox: `${url}/inbox`,
|
||||
outbox: `${url}/outbox`,
|
||||
followers: `${url}/followers`,
|
||||
following: `${url}/following`,
|
||||
endpoints: { sharedInbox: `${baseUrl(env)}/inbox` },
|
||||
icon: { type: "Image", mediaType: "image/svg+xml", url: `${baseUrl(env)}/avatar.png` },
|
||||
image: { type: "Image", mediaType: "image/svg+xml", url: `${baseUrl(env)}/header.png` },
|
||||
manuallyApprovesFollowers: false,
|
||||
discoverable: true,
|
||||
publicKey: {
|
||||
id: `${url}#main-key`,
|
||||
owner: url,
|
||||
publicKeyPem: await exportUserPublicKeyPem(user)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function outbox(request: Request, env: Env, username: string): Promise<Response> {
|
||||
const user = await getUserByUsername(env, username);
|
||||
if (!user) return json({ error: "not_found" }, 404);
|
||||
const url = new URL(request.url);
|
||||
const wantsPage = url.searchParams.has("page");
|
||||
const totalRow = await env.DB.prepare("SELECT COUNT(*) AS count FROM statuses WHERE user_id = ?").bind(user.id).first<{ count: number }>();
|
||||
const totalItems = totalRow?.count ?? 0;
|
||||
const base = `${actorUrl(env, user)}/outbox`;
|
||||
|
||||
if (!wantsPage) {
|
||||
return activityJson({
|
||||
"@context": ACTIVITY_CONTEXT,
|
||||
id: base,
|
||||
type: "OrderedCollection",
|
||||
totalItems,
|
||||
first: `${base}?page=true`
|
||||
});
|
||||
}
|
||||
|
||||
const limit = clampLimit(url.searchParams.get("limit"), 20, 40);
|
||||
const rows = await env.DB.prepare("SELECT * FROM statuses WHERE user_id = ? ORDER BY created_at DESC LIMIT ?").bind(user.id, limit).all<Status>();
|
||||
const items = rows.results.map((status) => createActivity(env, user, status));
|
||||
return activityJson({
|
||||
"@context": ACTIVITY_CONTEXT,
|
||||
id: `${base}?page=true`,
|
||||
partOf: base,
|
||||
type: "OrderedCollectionPage",
|
||||
orderedItems: items,
|
||||
totalItems
|
||||
});
|
||||
}
|
||||
|
||||
export async function followersCollection(env: Env, username: string): Promise<Response> {
|
||||
const user = await getUserByUsername(env, username);
|
||||
if (!user) return json({ error: "not_found" }, 404);
|
||||
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM follows WHERE local_user_id = ? AND accepted = 1").bind(user.id).first<{ count: number }>();
|
||||
return activityJson({
|
||||
"@context": ACTIVITY_CONTEXT,
|
||||
id: `${actorUrl(env, user)}/followers`,
|
||||
type: "Collection",
|
||||
totalItems: row?.count ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
export async function followingCollection(env: Env, username: string): Promise<Response> {
|
||||
const user = await getUserByUsername(env, username);
|
||||
if (!user) return json({ error: "not_found" }, 404);
|
||||
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM outgoing_follows WHERE local_user_id = ? AND accepted = 1").bind(user.id).first<{ count: number }>();
|
||||
return activityJson({
|
||||
"@context": ACTIVITY_CONTEXT,
|
||||
id: `${actorUrl(env, user)}/following`,
|
||||
type: "Collection",
|
||||
totalItems: row?.count ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
export async function activityObject(env: Env, objectId: string): Promise<Response> {
|
||||
const status = await getStatus(env, objectId);
|
||||
if (status) {
|
||||
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(status.user_id).first<User>();
|
||||
if (!user) return json({ error: "not_found" }, 404);
|
||||
return activityJson(noteObject(env, user, status));
|
||||
}
|
||||
const tomb = await env.DB.prepare("SELECT * FROM deleted_statuses WHERE id = ?").bind(objectId).first<{ id: string; deleted_at: string }>();
|
||||
if (tomb) {
|
||||
return activityJson({
|
||||
"@context": ACTIVITY_CONTEXT,
|
||||
id: `${baseUrl(env)}/objects/${tomb.id}`,
|
||||
type: "Tombstone",
|
||||
deleted: tomb.deleted_at
|
||||
}, 410);
|
||||
}
|
||||
return json({ error: "not_found" }, 404);
|
||||
}
|
||||
|
||||
export async function inboxHandler(request: Request, env: Env, username: string | null): Promise<Response> {
|
||||
const localUser = username ? await getUserByUsername(env, username) : null;
|
||||
if (username && !localUser) return json({ error: "not_found" }, 404);
|
||||
|
||||
const bodyText = await request.text();
|
||||
const activity = parseActivity(bodyText);
|
||||
if (!activity || !activity.type) return json({ error: "invalid_activity" }, 400);
|
||||
if (activity.activityId && await isDuplicateActivity(env, activity.activityId)) {
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
const verified = await verifyInboundSignature(request, bodyText, env);
|
||||
if (!verified) return json({ error: "invalid_signature" }, 401);
|
||||
if (activity.actor && verified.actor.id !== activity.actor) {
|
||||
return json({ error: "actor_signature_mismatch" }, 401);
|
||||
}
|
||||
await recordRemoteActivity(env, activity, true);
|
||||
|
||||
const ctx: InboxContext = {
|
||||
env,
|
||||
activity,
|
||||
actorId: verified.actor.id,
|
||||
actorCache: verified.actor,
|
||||
localUser
|
||||
};
|
||||
|
||||
switch (activity.type) {
|
||||
case "Follow":
|
||||
return handleFollow(ctx);
|
||||
case "Undo":
|
||||
return handleUndo(ctx);
|
||||
case "Accept":
|
||||
return handleAccept(ctx);
|
||||
case "Reject":
|
||||
return handleReject(ctx);
|
||||
case "Like":
|
||||
return handleLike(ctx);
|
||||
case "Announce":
|
||||
return handleAnnounce(ctx);
|
||||
case "Delete":
|
||||
return handleDelete(ctx);
|
||||
case "Update":
|
||||
return handleUpdate(ctx);
|
||||
case "Create":
|
||||
return handleCreate(ctx);
|
||||
default:
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
}
|
||||
|
||||
type InboxContext = {
|
||||
env: Env;
|
||||
activity: NonNullable<ReturnType<typeof parseActivity>>;
|
||||
actorId: string;
|
||||
actorCache: ActorCache;
|
||||
localUser: User | null;
|
||||
};
|
||||
|
||||
async function handleFollow(ctx: InboxContext): Promise<Response> {
|
||||
const { env, activity, actorId, actorCache } = ctx;
|
||||
const object = objectIdString(activity.body.object);
|
||||
const localUser = await localUserFromTarget(env, object) ?? ctx.localUser;
|
||||
if (!localUser) return json({ error: "unknown_local_user" }, 404);
|
||||
|
||||
const inbox = actorCache.shared_inbox ?? actorCache.inbox;
|
||||
await env.DB.prepare(
|
||||
"INSERT OR REPLACE INTO follows (id, follower_actor, local_user_id, inbox, accepted, created_at) VALUES (?, ?, ?, ?, 1, ?)"
|
||||
).bind(id(), actorId, localUser.id, inbox, new Date().toISOString()).run();
|
||||
|
||||
await recordNotification(env, localUser.id, "follow", actorId, null);
|
||||
|
||||
await sendSignedActivity(env, localUser, actorCache.inbox, {
|
||||
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
||||
id: activityUrl(env, id()),
|
||||
type: "Accept",
|
||||
actor: actorUrl(env, localUser),
|
||||
object: activity.body
|
||||
});
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
async function handleUndo(ctx: InboxContext): Promise<Response> {
|
||||
const { env, activity, actorId } = ctx;
|
||||
const inner = objectAsJson(activity.body.object);
|
||||
if (!inner) return new Response(null, { status: 202 });
|
||||
const innerType = String(inner.type ?? "");
|
||||
const localUser = ctx.localUser ?? await localUserFromTarget(env, objectIdString(inner.object));
|
||||
if (!localUser) return new Response(null, { status: 202 });
|
||||
|
||||
if (innerType === "Follow") {
|
||||
await env.DB.prepare("DELETE FROM follows WHERE follower_actor = ? AND local_user_id = ?").bind(actorId, localUser.id).run();
|
||||
} else if (innerType === "Like") {
|
||||
const target = objectIdString(inner.object);
|
||||
if (target) {
|
||||
const status = await getStatusByObjectId(env, target);
|
||||
if (status) await env.DB.prepare("DELETE FROM favourites WHERE status_id = ? AND actor = ?").bind(status.id, actorId).run();
|
||||
}
|
||||
} else if (innerType === "Announce") {
|
||||
const target = objectIdString(inner.object);
|
||||
if (target) {
|
||||
const status = await getStatusByObjectId(env, target);
|
||||
if (status) await env.DB.prepare("DELETE FROM reblogs WHERE status_id = ? AND actor = ?").bind(status.id, actorId).run();
|
||||
}
|
||||
}
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
async function handleAccept(ctx: InboxContext): Promise<Response> {
|
||||
const { env, activity, actorId } = ctx;
|
||||
const inner = objectAsJson(activity.body.object);
|
||||
if (!inner || String(inner.type ?? "") !== "Follow") return new Response(null, { status: 202 });
|
||||
const innerActor = typeof inner.actor === "string" ? inner.actor : String((inner.actor as Json | undefined)?.id ?? "");
|
||||
const localUser = await localUserFromTarget(env, innerActor);
|
||||
if (!localUser) return new Response(null, { status: 202 });
|
||||
await env.DB.prepare(
|
||||
"UPDATE outgoing_follows SET accepted = 1 WHERE local_user_id = ? AND target_actor = ?"
|
||||
).bind(localUser.id, actorId).run();
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
async function handleReject(ctx: InboxContext): Promise<Response> {
|
||||
const { env, actorId } = ctx;
|
||||
await env.DB.prepare("DELETE FROM outgoing_follows WHERE target_actor = ?").bind(actorId).run();
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
async function handleLike(ctx: InboxContext): Promise<Response> {
|
||||
const { env, activity, actorId } = ctx;
|
||||
const target = objectIdString(activity.body.object);
|
||||
if (!target) return new Response(null, { status: 202 });
|
||||
const status = await getStatusByObjectId(env, target);
|
||||
if (!status) return new Response(null, { status: 202 });
|
||||
const existing = await findFavourite(env, status.id, actorId);
|
||||
if (existing) return new Response(null, { status: 202 });
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO favourites (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)"
|
||||
).bind(id(), status.id, actorId, activity.activityId || id(), new Date().toISOString()).run();
|
||||
await notifyForLocalStatus(env, status, "favourite", actorId);
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
async function handleAnnounce(ctx: InboxContext): Promise<Response> {
|
||||
const { env, activity, actorId } = ctx;
|
||||
const target = objectIdString(activity.body.object);
|
||||
if (!target) return new Response(null, { status: 202 });
|
||||
const status = await getStatusByObjectId(env, target);
|
||||
if (!status) return new Response(null, { status: 202 });
|
||||
const existing = await findReblog(env, status.id, actorId);
|
||||
if (existing) return new Response(null, { status: 202 });
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO reblogs (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)"
|
||||
).bind(id(), status.id, actorId, activity.activityId || id(), new Date().toISOString()).run();
|
||||
await notifyForLocalStatus(env, status, "reblog", actorId);
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
async function handleDelete(ctx: InboxContext): Promise<Response> {
|
||||
const { env, activity, actorId } = ctx;
|
||||
const target = objectIdString(activity.body.object);
|
||||
if (!target) return new Response(null, { status: 202 });
|
||||
|
||||
if (target === actorId) {
|
||||
await env.DB.prepare("DELETE FROM follows WHERE follower_actor = ?").bind(actorId).run();
|
||||
await env.DB.prepare("DELETE FROM outgoing_follows WHERE target_actor = ?").bind(actorId).run();
|
||||
await env.DB.prepare("DELETE FROM favourites WHERE actor = ?").bind(actorId).run();
|
||||
await env.DB.prepare("DELETE FROM reblogs WHERE actor = ?").bind(actorId).run();
|
||||
await env.DB.prepare("DELETE FROM notifications WHERE actor = ?").bind(actorId).run();
|
||||
await deleteActorFromCache(env, actorId);
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
await env.DB.prepare("DELETE FROM favourites WHERE actor = ? AND activity_id LIKE ?").bind(actorId, `%${target}%`).run();
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
async function handleUpdate(ctx: InboxContext): Promise<Response> {
|
||||
const { env, activity, actorId } = ctx;
|
||||
const obj = objectAsJson(activity.body.object);
|
||||
if (!obj) return new Response(null, { status: 202 });
|
||||
if (String(obj.type ?? "") === "Person" && obj.id === actorId) {
|
||||
await upsertActorCache(env, obj as unknown as RemoteActor);
|
||||
}
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
async function handleCreate(ctx: InboxContext): Promise<Response> {
|
||||
const { env, activity, actorId } = ctx;
|
||||
const obj = objectAsJson(activity.body.object);
|
||||
if (!obj) return new Response(null, { status: 202 });
|
||||
|
||||
const recipients = collectRecipients(activity.body, obj);
|
||||
const localActorIds = new Set<string>();
|
||||
for (const target of recipients) {
|
||||
if (!target.startsWith(baseUrl(env))) continue;
|
||||
const m = target.match(/\/users\/([^/?#]+)$/);
|
||||
if (m) localActorIds.add(m[1]);
|
||||
}
|
||||
|
||||
for (const username of localActorIds) {
|
||||
const localUser = await getUserByUsername(env, username);
|
||||
if (!localUser) continue;
|
||||
const inReplyTo = typeof obj.inReplyTo === "string" ? obj.inReplyTo : null;
|
||||
let statusId: string | null = null;
|
||||
if (inReplyTo) {
|
||||
const parent = await getStatusByObjectId(env, inReplyTo);
|
||||
if (parent) statusId = parent.id;
|
||||
}
|
||||
await recordNotification(env, localUser.id, "mention", actorId, statusId);
|
||||
}
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
function collectRecipients(activity: Json, object: Json): string[] {
|
||||
const fields: unknown[] = [activity.to, activity.cc, activity.bto, activity.bcc, object.to, object.cc];
|
||||
const out = new Set<string>();
|
||||
for (const field of fields) {
|
||||
if (Array.isArray(field)) {
|
||||
for (const value of field) {
|
||||
if (typeof value === "string") out.add(value);
|
||||
}
|
||||
} else if (typeof field === "string") {
|
||||
out.add(field);
|
||||
}
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
async function localUserFromTarget(env: Env, actorId: string | null): Promise<User | null> {
|
||||
if (!actorId) return null;
|
||||
if (!actorId.startsWith(baseUrl(env))) return null;
|
||||
const match = actorId.match(/\/users\/([^/?#]+)$/);
|
||||
if (!match) return null;
|
||||
return getUserByUsername(env, match[1]);
|
||||
}
|
||||
|
||||
export function createActivity(env: Env, user: User, status: Status, extra: { to?: string[]; cc?: string[] } = {}): Json {
|
||||
const to = extra.to ?? [PUBLIC_COLLECTION];
|
||||
const cc = extra.cc ?? [`${actorUrl(env, user)}/followers`];
|
||||
return {
|
||||
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
||||
id: status.activity_id,
|
||||
type: "Create",
|
||||
actor: actorUrl(env, user),
|
||||
published: status.created_at,
|
||||
to,
|
||||
cc,
|
||||
object: noteObject(env, user, status, { to, cc })
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteActivity(env: Env, user: User, status: Status): Json {
|
||||
return {
|
||||
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
||||
id: activityUrl(env, id()),
|
||||
type: "Delete",
|
||||
actor: actorUrl(env, user),
|
||||
to: [PUBLIC_COLLECTION],
|
||||
object: {
|
||||
id: status.object_id,
|
||||
type: "Tombstone"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function followActivity(env: Env, user: User, target: string, activityId: string): Json {
|
||||
return {
|
||||
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
||||
id: activityId,
|
||||
type: "Follow",
|
||||
actor: actorUrl(env, user),
|
||||
object: target
|
||||
};
|
||||
}
|
||||
|
||||
export function undoActivity(env: Env, user: User, inner: Json): Json {
|
||||
return {
|
||||
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
||||
id: activityUrl(env, id()),
|
||||
type: "Undo",
|
||||
actor: actorUrl(env, user),
|
||||
object: inner
|
||||
};
|
||||
}
|
||||
|
||||
export function likeActivity(env: Env, user: User, target: string, activityId: string): Json {
|
||||
return {
|
||||
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
||||
id: activityId,
|
||||
type: "Like",
|
||||
actor: actorUrl(env, user),
|
||||
object: target
|
||||
};
|
||||
}
|
||||
|
||||
export function announceActivity(env: Env, user: User, target: string, activityId: string): Json {
|
||||
return {
|
||||
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
||||
id: activityId,
|
||||
type: "Announce",
|
||||
actor: actorUrl(env, user),
|
||||
published: new Date().toISOString(),
|
||||
to: [PUBLIC_COLLECTION],
|
||||
cc: [`${actorUrl(env, user)}/followers`],
|
||||
object: target
|
||||
};
|
||||
}
|
||||
|
||||
export function updatePersonActivity(env: Env, user: User, doc: Json): Json {
|
||||
return {
|
||||
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
||||
id: activityUrl(env, id()),
|
||||
type: "Update",
|
||||
actor: actorUrl(env, user),
|
||||
to: [PUBLIC_COLLECTION],
|
||||
object: doc
|
||||
};
|
||||
}
|
||||
|
||||
export function noteObject(env: Env, user: User, status: Status, opts: { to?: string[]; cc?: string[]; attachments?: Json[]; tag?: Json[] } = {}): Json {
|
||||
return {
|
||||
id: status.object_id,
|
||||
type: "Note",
|
||||
summary: status.summary || null,
|
||||
sensitive: Boolean(status.sensitive),
|
||||
inReplyTo: status.in_reply_to_id ? objectUrl(env, status.in_reply_to_id) : null,
|
||||
attributedTo: actorUrl(env, user),
|
||||
content: status.content,
|
||||
published: status.created_at,
|
||||
url: status.url,
|
||||
to: opts.to ?? [PUBLIC_COLLECTION],
|
||||
cc: opts.cc ?? [`${actorUrl(env, user)}/followers`],
|
||||
attachment: opts.attachments ?? [],
|
||||
tag: opts.tag ?? []
|
||||
};
|
||||
}
|
||||
|
||||
export { AVATAR_SVG, HEADER_SVG };
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { base64, base64Decode, base64Url, base64UrlDecode, bytesToArrayBuffer, concatBytes, encoder } from "./util";
|
||||
|
||||
const PBKDF2_ITERATIONS = 100_000;
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const hash = await pbkdf2(password, salt, PBKDF2_ITERATIONS);
|
||||
return `pbkdf2$${PBKDF2_ITERATIONS}$${base64Url(salt)}$${base64Url(hash)}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
if (stored.startsWith("pbkdf2$")) {
|
||||
const [, itersText, saltPart, hashPart] = stored.split("$");
|
||||
const iters = Number(itersText);
|
||||
if (!Number.isFinite(iters) || iters <= 0) return false;
|
||||
const salt = base64UrlDecode(saltPart);
|
||||
const hash = await pbkdf2(password, salt, iters);
|
||||
return constantTimeEqual(base64Url(hash), hashPart);
|
||||
}
|
||||
const [saltPart, hashPart] = stored.split(".");
|
||||
if (!saltPart || !hashPart) return false;
|
||||
const salt = base64UrlDecode(saltPart);
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytesToArrayBuffer(concatBytes(salt, encoder.encode(password))));
|
||||
return constantTimeEqual(base64Url(new Uint8Array(digest)), hashPart);
|
||||
}
|
||||
|
||||
export function passwordNeedsUpgrade(stored: string): boolean {
|
||||
return !stored.startsWith("pbkdf2$");
|
||||
}
|
||||
|
||||
async function pbkdf2(password: string, salt: Uint8Array, iters: number): Promise<Uint8Array> {
|
||||
const key = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]);
|
||||
const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: bytesToArrayBuffer(salt), iterations: iters }, key, 32 * 8);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
function constantTimeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export async function digestBase64(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
||||
return base64(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
export async function signString(input: string, jwk: JsonWebKey): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
jwk,
|
||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, encoder.encode(input));
|
||||
return base64(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
export async function verifyWithPem(pem: string, data: string, signatureBase64: string): Promise<boolean> {
|
||||
const key = await importSpkiPem(pem);
|
||||
return await crypto.subtle.verify(
|
||||
{ name: "RSASSA-PKCS1-v1_5" },
|
||||
key,
|
||||
bytesToArrayBuffer(base64Decode(signatureBase64)),
|
||||
encoder.encode(data)
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportSpkiPem(jwk: JsonWebKey): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
jwk,
|
||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
true,
|
||||
["verify"]
|
||||
);
|
||||
const spki = await crypto.subtle.exportKey("spki", key);
|
||||
return toPem("PUBLIC KEY", spki);
|
||||
}
|
||||
|
||||
export async function importSpkiPem(pem: string): Promise<CryptoKey> {
|
||||
return await crypto.subtle.importKey(
|
||||
"spki",
|
||||
pemToArrayBuffer(pem),
|
||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
true,
|
||||
["verify"]
|
||||
);
|
||||
}
|
||||
|
||||
function toPem(label: string, key: ArrayBuffer): string {
|
||||
const b64 = base64(new Uint8Array(key));
|
||||
const lines = b64.match(/.{1,64}/g)?.join("\n") ?? b64;
|
||||
return `-----BEGIN ${label}-----\n${lines}\n-----END ${label}-----`;
|
||||
}
|
||||
|
||||
function pemToArrayBuffer(pem: string): ArrayBuffer {
|
||||
const b64 = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, "");
|
||||
return bytesToArrayBuffer(base64Decode(b64));
|
||||
}
|
||||
|
||||
export type ParsedSignature = {
|
||||
keyId: string;
|
||||
algorithm: string | null;
|
||||
headers: string[];
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export function parseSignatureHeader(value: string): ParsedSignature | null {
|
||||
const fields = new Map<string, string>();
|
||||
const re = /(\w+)="([^"]*)"/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(value)) !== null) fields.set(match[1].toLowerCase(), match[2]);
|
||||
const keyId = fields.get("keyid");
|
||||
const headers = fields.get("headers");
|
||||
const signature = fields.get("signature");
|
||||
if (!keyId || !signature) return null;
|
||||
return {
|
||||
keyId,
|
||||
algorithm: fields.get("algorithm") ?? null,
|
||||
headers: (headers ?? "(created)").split(/\s+/).map((item) => item.toLowerCase()).filter(Boolean),
|
||||
signature
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { exportSpkiPem, hashPassword } from "./crypto";
|
||||
import type {
|
||||
ActorCache,
|
||||
Favourite,
|
||||
Follow,
|
||||
Media,
|
||||
Notification,
|
||||
OAuthApp,
|
||||
OAuthCode,
|
||||
OutgoingFollow,
|
||||
Reblog,
|
||||
RemoteActor,
|
||||
Status,
|
||||
User
|
||||
} from "./types";
|
||||
import { ACTOR_CACHE_TTL_MS } from "./types";
|
||||
import { id } from "./util";
|
||||
|
||||
export async function ensureAdminUser(env: Env): Promise<void> {
|
||||
const existing = await env.DB.prepare("SELECT id FROM users WHERE username = ?").bind(env.ADMIN_USERNAME).first<{ id: string }>();
|
||||
if (existing) return;
|
||||
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
|
||||
true,
|
||||
["sign", "verify"]
|
||||
) as CryptoKeyPair;
|
||||
const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
|
||||
const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await env.DB.prepare(
|
||||
"INSERT OR IGNORE INTO users (id, username, display_name, note, password_hash, private_key_jwk, public_key_jwk, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(id(), env.ADMIN_USERNAME, env.ADMIN_USERNAME, "", await hashPassword(env.ADMIN_PASSWORD), JSON.stringify(privateKey), JSON.stringify(publicKey), now)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function getUserById(env: Env, userId: string): Promise<User | null> {
|
||||
return env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(userId).first<User>();
|
||||
}
|
||||
|
||||
export async function getUserByUsername(env: Env, username: string): Promise<User | null> {
|
||||
return env.DB.prepare("SELECT * FROM users WHERE username = ?").bind(username).first<User>();
|
||||
}
|
||||
|
||||
export async function getUserByIdOrUsername(env: Env, key: string): Promise<User | null> {
|
||||
return env.DB.prepare("SELECT * FROM users WHERE id = ? OR username = ?").bind(key, key).first<User>();
|
||||
}
|
||||
|
||||
export async function getAdminUser(env: Env): Promise<User> {
|
||||
const user = await getUserByUsername(env, env.ADMIN_USERNAME);
|
||||
if (!user) throw new Error("admin_user_missing");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getAppByClientId(env: Env, clientId: string): Promise<OAuthApp | null> {
|
||||
return env.DB.prepare("SELECT * FROM oauth_apps WHERE client_id = ?").bind(clientId).first<OAuthApp>();
|
||||
}
|
||||
|
||||
export async function takeOAuthCode(env: Env, code: string): Promise<OAuthCode | null> {
|
||||
const row = await env.DB.prepare("SELECT * FROM oauth_codes WHERE code = ?").bind(code).first<OAuthCode>();
|
||||
if (!row) return null;
|
||||
await env.DB.prepare("DELETE FROM oauth_codes WHERE code = ?").bind(code).run();
|
||||
if (row.expires_at < Math.floor(Date.now() / 1000)) return null;
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function getStatus(env: Env, statusId: string): Promise<Status | null> {
|
||||
return env.DB.prepare("SELECT * FROM statuses WHERE id = ?").bind(statusId).first<Status>();
|
||||
}
|
||||
|
||||
export async function getStatusByObjectId(env: Env, objectId: string): Promise<Status | null> {
|
||||
return env.DB.prepare("SELECT * FROM statuses WHERE object_id = ?").bind(objectId).first<Status>();
|
||||
}
|
||||
|
||||
export async function listMediaForStatus(env: Env, statusId: string): Promise<Media[]> {
|
||||
const rows = await env.DB.prepare("SELECT * FROM media WHERE status_id = ? ORDER BY created_at ASC").bind(statusId).all<Media>();
|
||||
return rows.results;
|
||||
}
|
||||
|
||||
export async function listFollowers(env: Env, userId: string): Promise<Follow[]> {
|
||||
const rows = await env.DB.prepare("SELECT * FROM follows WHERE local_user_id = ? AND accepted = 1").bind(userId).all<Follow>();
|
||||
return rows.results;
|
||||
}
|
||||
|
||||
export async function countFollowers(env: Env, userId: string): Promise<number> {
|
||||
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM follows WHERE local_user_id = ? AND accepted = 1").bind(userId).first<{ count: number }>();
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
export async function countFollowing(env: Env, userId: string): Promise<number> {
|
||||
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM outgoing_follows WHERE local_user_id = ? AND accepted = 1").bind(userId).first<{ count: number }>();
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
export async function countStatuses(env: Env, userId: string): Promise<number> {
|
||||
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM statuses WHERE user_id = ?").bind(userId).first<{ count: number }>();
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
export async function listFavouritesForStatus(env: Env, statusId: string): Promise<Favourite[]> {
|
||||
const rows = await env.DB.prepare("SELECT * FROM favourites WHERE status_id = ?").bind(statusId).all<Favourite>();
|
||||
return rows.results;
|
||||
}
|
||||
|
||||
export async function listReblogsForStatus(env: Env, statusId: string): Promise<Reblog[]> {
|
||||
const rows = await env.DB.prepare("SELECT * FROM reblogs WHERE status_id = ?").bind(statusId).all<Reblog>();
|
||||
return rows.results;
|
||||
}
|
||||
|
||||
export async function findFavourite(env: Env, statusId: string, actor: string): Promise<Favourite | null> {
|
||||
return env.DB.prepare("SELECT * FROM favourites WHERE status_id = ? AND actor = ?").bind(statusId, actor).first<Favourite>();
|
||||
}
|
||||
|
||||
export async function findReblog(env: Env, statusId: string, actor: string): Promise<Reblog | null> {
|
||||
return env.DB.prepare("SELECT * FROM reblogs WHERE status_id = ? AND actor = ?").bind(statusId, actor).first<Reblog>();
|
||||
}
|
||||
|
||||
export async function findOutgoingFollow(env: Env, userId: string, target: string): Promise<OutgoingFollow | null> {
|
||||
return env.DB.prepare("SELECT * FROM outgoing_follows WHERE local_user_id = ? AND target_actor = ?").bind(userId, target).first<OutgoingFollow>();
|
||||
}
|
||||
|
||||
export async function recordNotification(env: Env, userId: string, type: string, actor: string, statusId: string | null): Promise<Notification | null> {
|
||||
if (actor === userId) return null;
|
||||
const notificationId = id();
|
||||
const now = new Date().toISOString();
|
||||
try {
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO notifications (id, user_id, type, actor, status_id, read, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)"
|
||||
)
|
||||
.bind(notificationId, userId, type, actor, statusId, now)
|
||||
.run();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return env.DB.prepare("SELECT * FROM notifications WHERE id = ?").bind(notificationId).first<Notification>();
|
||||
}
|
||||
|
||||
export async function getActorFromCache(env: Env, actorId: string): Promise<ActorCache | null> {
|
||||
return env.DB.prepare("SELECT * FROM actor_cache WHERE id = ?").bind(actorId).first<ActorCache>();
|
||||
}
|
||||
|
||||
export async function getActorByKeyId(env: Env, keyId: string): Promise<ActorCache | null> {
|
||||
return env.DB.prepare("SELECT * FROM actor_cache WHERE public_key_id = ?").bind(keyId).first<ActorCache>();
|
||||
}
|
||||
|
||||
export async function upsertActorCache(env: Env, actor: RemoteActor): Promise<ActorCache | null> {
|
||||
if (!actor.id) return null;
|
||||
const inbox = actor.inbox ?? actor.id;
|
||||
const sharedInbox = actor.endpoints?.sharedInbox ?? null;
|
||||
const iconUrl = typeof actor.icon === "string" ? actor.icon : actor.icon?.url ?? null;
|
||||
const now = new Date().toISOString();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO actor_cache (id, inbox, shared_inbox, preferred_username, name, summary, icon_url, public_key_id, public_key_pem, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
inbox = excluded.inbox,
|
||||
shared_inbox = excluded.shared_inbox,
|
||||
preferred_username = excluded.preferred_username,
|
||||
name = excluded.name,
|
||||
summary = excluded.summary,
|
||||
icon_url = excluded.icon_url,
|
||||
public_key_id = excluded.public_key_id,
|
||||
public_key_pem = excluded.public_key_pem,
|
||||
fetched_at = excluded.fetched_at`
|
||||
)
|
||||
.bind(
|
||||
actor.id,
|
||||
inbox,
|
||||
sharedInbox,
|
||||
actor.preferredUsername ?? null,
|
||||
actor.name ?? null,
|
||||
actor.summary ?? null,
|
||||
iconUrl,
|
||||
actor.publicKey?.id ?? null,
|
||||
actor.publicKey?.publicKeyPem ?? null,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
return getActorFromCache(env, actor.id);
|
||||
}
|
||||
|
||||
export async function deleteActorFromCache(env: Env, actorId: string): Promise<void> {
|
||||
await env.DB.prepare("DELETE FROM actor_cache WHERE id = ?").bind(actorId).run();
|
||||
}
|
||||
|
||||
export function actorCacheStale(cache: ActorCache): boolean {
|
||||
const fetched = Date.parse(cache.fetched_at);
|
||||
if (!Number.isFinite(fetched)) return true;
|
||||
return Date.now() - fetched > ACTOR_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
export async function exportUserPublicKeyPem(user: User): Promise<string> {
|
||||
return exportSpkiPem(JSON.parse(user.public_key_jwk) as JsonWebKey);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
digestBase64,
|
||||
parseSignatureHeader,
|
||||
signString,
|
||||
verifyWithPem
|
||||
} from "./crypto";
|
||||
import {
|
||||
actorCacheStale,
|
||||
deleteActorFromCache,
|
||||
getActorByKeyId,
|
||||
getActorFromCache,
|
||||
recordNotification,
|
||||
upsertActorCache
|
||||
} from "./db";
|
||||
import type { ActorCache, Json, RemoteActor, Status, User } from "./types";
|
||||
import { SIGNATURE_MAX_SKEW_MS } from "./types";
|
||||
import { actorUrl, base64Decode, encoder, hostFromBaseUrl, parseAcctFromActor } from "./util";
|
||||
|
||||
const ACTIVITY_HEADERS = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
|
||||
|
||||
export async function resolveRemoteActor(env: Env, actorId: string, opts: { force?: boolean } = {}): Promise<ActorCache | null> {
|
||||
if (!actorId) return null;
|
||||
const cached = await getActorFromCache(env, actorId);
|
||||
if (cached && !opts.force && !actorCacheStale(cached)) return cached;
|
||||
const fetched = await fetchRemoteActor(actorId);
|
||||
if (!fetched) return cached;
|
||||
return upsertActorCache(env, fetched);
|
||||
}
|
||||
|
||||
export async function fetchRemoteActor(actorId: string): Promise<RemoteActor | null> {
|
||||
try {
|
||||
const response = await fetch(actorId, {
|
||||
headers: { accept: ACTIVITY_HEADERS },
|
||||
cf: { cacheTtl: 60 }
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json() as RemoteActor;
|
||||
if (!data || !data.id) return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverActorByKeyId(env: Env, keyId: string): Promise<ActorCache | null> {
|
||||
const cached = await getActorByKeyId(env, keyId);
|
||||
if (cached && !actorCacheStale(cached)) return cached;
|
||||
const actorIdGuess = keyId.split("#")[0];
|
||||
const refreshed = await resolveRemoteActor(env, actorIdGuess, { force: true });
|
||||
if (refreshed?.public_key_id === keyId) return refreshed;
|
||||
return cached;
|
||||
}
|
||||
|
||||
export type VerifiedSignature = {
|
||||
actor: ActorCache;
|
||||
keyId: string;
|
||||
};
|
||||
|
||||
export async function verifyInboundSignature(request: Request, body: string, env: Env): Promise<VerifiedSignature | null> {
|
||||
const sigHeader = request.headers.get("signature");
|
||||
if (!sigHeader) return null;
|
||||
const parsed = parseSignatureHeader(sigHeader);
|
||||
if (!parsed || !parsed.headers.includes("(request-target)")) return null;
|
||||
|
||||
const dateHeader = request.headers.get("date");
|
||||
if (dateHeader) {
|
||||
const stamp = Date.parse(dateHeader);
|
||||
if (!Number.isFinite(stamp)) return null;
|
||||
if (Math.abs(Date.now() - stamp) > SIGNATURE_MAX_SKEW_MS) return null;
|
||||
} else if (parsed.headers.includes("date")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const digestHeader = request.headers.get("digest");
|
||||
if (digestHeader) {
|
||||
const expected = `SHA-256=${await digestBase64(body)}`;
|
||||
if (digestHeader !== expected) return null;
|
||||
} else if (request.method.toUpperCase() === "POST" && body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const hostHeader = request.headers.get("host");
|
||||
if (parsed.headers.includes("host") && hostHeader && hostHeader.toLowerCase() !== url.host.toLowerCase()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const headerName of parsed.headers) {
|
||||
if (headerName === "(request-target)") {
|
||||
lines.push(`(request-target): ${request.method.toLowerCase()} ${url.pathname}${url.search}`);
|
||||
continue;
|
||||
}
|
||||
const value = request.headers.get(headerName);
|
||||
if (value === null) return null;
|
||||
lines.push(`${headerName}: ${value}`);
|
||||
}
|
||||
|
||||
const actor = await discoverActorByKeyId(env, parsed.keyId);
|
||||
if (!actor || !actor.public_key_pem) return null;
|
||||
|
||||
try {
|
||||
const ok = await verifyWithPem(actor.public_key_pem, lines.join("\n"), parsed.signature);
|
||||
if (!ok) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return { actor, keyId: parsed.keyId };
|
||||
}
|
||||
|
||||
export async function sendSignedActivity(env: Env, user: User, inboxUrl: string, activity: Json): Promise<{ ok: boolean; status: number; text: string }> {
|
||||
const target = new URL(inboxUrl);
|
||||
const body = JSON.stringify(activity);
|
||||
const digest = `SHA-256=${await digestBase64(body)}`;
|
||||
const date = new Date().toUTCString();
|
||||
const host = target.host;
|
||||
const path = `${target.pathname}${target.search}`;
|
||||
const signingString = [
|
||||
`(request-target): post ${path}`,
|
||||
`host: ${host}`,
|
||||
`date: ${date}`,
|
||||
`digest: ${digest}`
|
||||
].join("\n");
|
||||
const signature = await signString(signingString, JSON.parse(user.private_key_jwk) as JsonWebKey);
|
||||
const headerValue = [
|
||||
`keyId="${actorUrl(env, user)}#main-key"`,
|
||||
`algorithm="rsa-sha256"`,
|
||||
`headers="(request-target) host date digest"`,
|
||||
`signature="${signature}"`
|
||||
].join(",");
|
||||
|
||||
try {
|
||||
const response = await fetch(inboxUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/activity+json",
|
||||
"content-type": "application/activity+json",
|
||||
date,
|
||||
digest,
|
||||
signature: headerValue,
|
||||
"user-agent": `toot-worker (+https://${hostFromBaseUrl(env)})`
|
||||
},
|
||||
body
|
||||
});
|
||||
const text = response.ok ? "" : await response.text().catch(() => "");
|
||||
if (!response.ok) console.warn("signed-delivery", inboxUrl, response.status, text.slice(0, 200));
|
||||
return { ok: response.ok, status: response.status, text };
|
||||
} catch (error) {
|
||||
console.warn("signed-delivery-error", inboxUrl, String(error));
|
||||
return { ok: false, status: 0, text: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deliverToInboxes(env: Env, user: User, inboxes: Iterable<string>, activity: Json): Promise<void> {
|
||||
const unique = new Set<string>();
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox) unique.add(inbox);
|
||||
}
|
||||
await Promise.allSettled([...unique].map((inbox) => sendSignedActivity(env, user, inbox, activity)));
|
||||
}
|
||||
|
||||
export async function gatherFollowerInboxes(env: Env, userId: string): Promise<string[]> {
|
||||
const rows = await env.DB.prepare(
|
||||
"SELECT inbox FROM follows WHERE local_user_id = ? AND accepted = 1"
|
||||
).bind(userId).all<{ inbox: string }>();
|
||||
const inboxes = new Set<string>();
|
||||
for (const row of rows.results) {
|
||||
if (row.inbox) {
|
||||
const actor = await getActorFromCache(env, row.inbox);
|
||||
const shared = (await getActorByKeyId(env, row.inbox))?.shared_inbox;
|
||||
inboxes.add(actor?.shared_inbox ?? shared ?? row.inbox);
|
||||
}
|
||||
}
|
||||
return [...inboxes];
|
||||
}
|
||||
|
||||
export async function resolveDeliveryInboxes(env: Env, actorIds: Iterable<string>): Promise<string[]> {
|
||||
const inboxes = new Set<string>();
|
||||
for (const actorId of actorIds) {
|
||||
const actor = await resolveRemoteActor(env, actorId);
|
||||
if (!actor) continue;
|
||||
inboxes.add(actor.shared_inbox ?? actor.inbox);
|
||||
}
|
||||
return [...inboxes];
|
||||
}
|
||||
|
||||
export type InboundActivity = {
|
||||
body: Json;
|
||||
bodyText: string;
|
||||
activityId: string;
|
||||
type: string;
|
||||
actor: string;
|
||||
};
|
||||
|
||||
export function parseActivity(bodyText: string): InboundActivity | null {
|
||||
try {
|
||||
const body = JSON.parse(bodyText) as Json;
|
||||
return {
|
||||
body,
|
||||
bodyText,
|
||||
activityId: String(body.id ?? ""),
|
||||
type: String(body.type ?? ""),
|
||||
actor: typeof body.actor === "string" ? body.actor : String((body.actor as Json | undefined)?.id ?? "")
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function activityObjectField(activity: InboundActivity, field: string): unknown {
|
||||
const object = activity.body[field];
|
||||
return object;
|
||||
}
|
||||
|
||||
export function objectAsJson(value: unknown): Json | null {
|
||||
return value && typeof value === "object" ? value as Json : null;
|
||||
}
|
||||
|
||||
export function objectIdString(value: unknown): string | null {
|
||||
if (typeof value === "string") return value;
|
||||
const obj = objectAsJson(value);
|
||||
if (obj && typeof obj.id === "string") return obj.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function recordRemoteActivity(env: Env, activity: InboundActivity, verified: boolean): Promise<void> {
|
||||
await env.DB.prepare(
|
||||
"INSERT OR IGNORE INTO remote_activities (id, actor, type, payload, received_at) VALUES (?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(
|
||||
activity.activityId || crypto.randomUUID(),
|
||||
activity.actor,
|
||||
activity.type,
|
||||
JSON.stringify({ ...activity.body, signature_verified: verified }),
|
||||
new Date().toISOString()
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function isDuplicateActivity(env: Env, activityId: string): Promise<boolean> {
|
||||
if (!activityId) return false;
|
||||
const row = await env.DB.prepare("SELECT id FROM remote_activities WHERE id = ?").bind(activityId).first<{ id: string }>();
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
export async function notifyForLocalStatus(env: Env, status: Status, type: string, actor: string): Promise<void> {
|
||||
await recordNotification(env, status.user_id, type, actor, status.id);
|
||||
}
|
||||
|
||||
export function mentionAcct(env: Env, actorId: string): string {
|
||||
return parseAcctFromActor(env, actorId);
|
||||
}
|
||||
|
||||
export async function decodeRemoteSignatureBase64(value: string): Promise<Uint8Array> {
|
||||
return base64Decode(value);
|
||||
}
|
||||
|
||||
export function activitySignableData(input: string): Uint8Array {
|
||||
return encoder.encode(input);
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
export class HttpError extends Error {
|
||||
constructor(readonly status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function json(data: unknown, status = 200, headers: HeadersInit = {}): Response {
|
||||
return cors(new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", ...headers }
|
||||
}));
|
||||
}
|
||||
|
||||
export function activityJson(data: unknown, status = 200): Response {
|
||||
return cors(new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/activity+json; charset=utf-8" }
|
||||
}));
|
||||
}
|
||||
|
||||
export function html(body: string, status = 200): Response {
|
||||
return cors(new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8" } }));
|
||||
}
|
||||
|
||||
export function svgResponse(svg: string): Response {
|
||||
return cors(new Response(svg, { headers: { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "public, max-age=3600" } }));
|
||||
}
|
||||
|
||||
export function cors(response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("access-control-allow-origin", "*");
|
||||
headers.set("access-control-allow-methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
|
||||
headers.set("access-control-allow-headers", "authorization,content-type,accept,digest,signature,date,idempotency-key");
|
||||
headers.set("access-control-expose-headers", "link,x-ratelimit-remaining,x-ratelimit-reset");
|
||||
headers.set("x-content-type-options", "nosniff");
|
||||
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
||||
}
|
||||
|
||||
export type ParsedBody = Record<string, string | string[] | File>;
|
||||
|
||||
export async function readBody(request: Request): Promise<ParsedBody> {
|
||||
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const value = (await request.json()) as Record<string, unknown>;
|
||||
const out: ParsedBody = {};
|
||||
for (const [key, raw] of Object.entries(value)) {
|
||||
if (Array.isArray(raw)) out[key] = raw.map(String);
|
||||
else if (raw !== null && raw !== undefined) out[key] = String(raw);
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
throw new HttpError(400, "invalid_json");
|
||||
}
|
||||
}
|
||||
const form = await request.formData();
|
||||
const data: ParsedBody = {};
|
||||
for (const [key, value] of form) {
|
||||
const cleanKey = key.endsWith("[]") ? key.slice(0, -2) : key;
|
||||
const normalized = value instanceof File ? value : String(value);
|
||||
const existing = data[cleanKey];
|
||||
if (existing === undefined) {
|
||||
data[cleanKey] = key.endsWith("[]") ? [normalized as string] : normalized;
|
||||
} else if (Array.isArray(existing)) {
|
||||
existing.push(normalized as string);
|
||||
} else {
|
||||
data[cleanKey] = [existing as string, normalized as string];
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function bodyString(body: ParsedBody, key: string, fallback = ""): string {
|
||||
const value = body[key];
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value) && value.length > 0) return value[0];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function bodyArray(body: ParsedBody, key: string): string[] {
|
||||
const value = body[key];
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === "string" && value) return [value];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function bodyFile(body: ParsedBody, key: string): File | null {
|
||||
const value = body[key];
|
||||
return value instanceof File ? value : null;
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
AVATAR_SVG,
|
||||
HEADER_SVG,
|
||||
activityObject,
|
||||
actor,
|
||||
followersCollection,
|
||||
followingCollection,
|
||||
hostMeta,
|
||||
inboxHandler,
|
||||
nodeInfo,
|
||||
nodeInfoLinks,
|
||||
outbox,
|
||||
webFinger
|
||||
} from "./activitypub";
|
||||
import { ensureAdminUser } from "./db";
|
||||
import { HttpError, cors, json, svgResponse } from "./http";
|
||||
import {
|
||||
accountStatuses,
|
||||
authorize,
|
||||
authorizeFollowRequest,
|
||||
authorizePage,
|
||||
bookmarkStatus,
|
||||
createApp,
|
||||
createStatus,
|
||||
customEmojis,
|
||||
deleteStatusEndpoint,
|
||||
favouriteStatus,
|
||||
filtersV1,
|
||||
followAccount,
|
||||
followRequestsList,
|
||||
getAccount,
|
||||
getRelationships,
|
||||
getStatusEndpoint,
|
||||
homeTimeline,
|
||||
instance,
|
||||
instanceV2,
|
||||
markersList,
|
||||
notificationClear,
|
||||
notificationDismiss,
|
||||
notificationsList,
|
||||
publicTimeline,
|
||||
pushSubscription,
|
||||
reblogStatus,
|
||||
rejectFollowRequest,
|
||||
revoke,
|
||||
search,
|
||||
serveMedia,
|
||||
statusContext,
|
||||
token,
|
||||
trendsTags,
|
||||
unfavouriteStatus,
|
||||
unfollowAccount,
|
||||
unreblogStatus,
|
||||
updateCredentials,
|
||||
updateMedia,
|
||||
uploadMedia,
|
||||
verifyAppCredentials,
|
||||
verifyCredentials
|
||||
} from "./mastodon";
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
try {
|
||||
await ensureAdminUser(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 function route(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method === "OPTIONS") return cors(new Response(null, { status: 204 }));
|
||||
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname.replace(/\/+$/, "") || "/";
|
||||
const method = request.method.toUpperCase();
|
||||
|
||||
if (method === "GET" && path === "/") return nodeInfo(env);
|
||||
if (method === "GET" && path === "/avatar.png") return svgResponse(AVATAR_SVG);
|
||||
if (method === "GET" && path === "/header.png") return svgResponse(HEADER_SVG);
|
||||
|
||||
if (method === "GET" && path === "/.well-known/webfinger") return webFinger(request, env);
|
||||
if (method === "GET" && path === "/.well-known/nodeinfo") return nodeInfoLinks(env);
|
||||
if (method === "GET" && path === "/.well-known/host-meta") return hostMeta(env);
|
||||
if (method === "GET" && path === "/nodeinfo/2.0") return nodeInfo(env);
|
||||
|
||||
if (method === "GET" && path === "/api/v1/instance") return instance(env);
|
||||
if (method === "GET" && path === "/api/v2/instance") return instanceV2(env);
|
||||
if (method === "POST" && path === "/api/v1/apps") return createApp(request, env);
|
||||
if (method === "GET" && path === "/api/v1/apps/verify_credentials") return verifyAppCredentials(request, env);
|
||||
|
||||
if (method === "GET" && path === "/oauth/authorize") return authorizePage(request, env);
|
||||
if (method === "POST" && path === "/oauth/authorize") return authorize(request, env);
|
||||
if (method === "POST" && path === "/oauth/token") return token(request, env);
|
||||
if (method === "POST" && path === "/oauth/revoke") return revoke(request, env);
|
||||
|
||||
if (method === "GET" && path === "/api/v1/accounts/verify_credentials") return verifyCredentials(request, env);
|
||||
if ((method === "PATCH" || method === "POST") && path === "/api/v1/accounts/update_credentials") return updateCredentials(request, env);
|
||||
if (method === "GET" && path === "/api/v1/accounts/relationships") return getRelationships(request, env);
|
||||
if (method === "GET" && path === "/api/v1/accounts/search") return search(request, env);
|
||||
|
||||
let m: RegExpMatchArray | null;
|
||||
|
||||
if (method === "GET" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)$/))) return getAccount(env, decodeURIComponent(m[1]));
|
||||
if (method === "GET" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/statuses$/))) return accountStatuses(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/follow$/))) return followAccount(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/unfollow$/))) return unfollowAccount(request, env, decodeURIComponent(m[1]));
|
||||
|
||||
if (method === "GET" && path === "/api/v1/follow_requests") return followRequestsList(request, env);
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/follow_requests\/([^/]+)\/authorize$/))) return authorizeFollowRequest(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/follow_requests\/([^/]+)\/reject$/))) return rejectFollowRequest(request, env, decodeURIComponent(m[1]));
|
||||
|
||||
if (method === "POST" && path === "/api/v1/statuses") return createStatus(request, env);
|
||||
if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return getStatusEndpoint(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "DELETE" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return deleteStatusEndpoint(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/context$/))) return statusContext(env, decodeURIComponent(m[1]), request);
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/favourite$/))) return favouriteStatus(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unfavourite$/))) return unfavouriteStatus(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/reblog$/))) return reblogStatus(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unreblog$/))) return unreblogStatus(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/bookmark$/))) return bookmarkStatus(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unbookmark$/))) return bookmarkStatus(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/pin$/))) return bookmarkStatus(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unpin$/))) return bookmarkStatus(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 === "POST" && (path === "/api/v1/media" || path === "/api/v2/media")) return uploadMedia(request, env);
|
||||
if (method === "PUT" && (m = path.match(/^\/api\/v1\/media\/([^/]+)$/))) return updateMedia(request, env, decodeURIComponent(m[1]));
|
||||
|
||||
if (method === "GET" && path === "/api/v1/notifications") return notificationsList(request, env);
|
||||
if (method === "POST" && path === "/api/v1/notifications/clear") return notificationClear(request, env);
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/notifications\/([^/]+)\/dismiss$/))) return notificationDismiss(request, env, decodeURIComponent(m[1]));
|
||||
|
||||
if (method === "GET" && (path === "/api/v2/search" || path === "/api/v1/search")) return search(request, env);
|
||||
if (method === "GET" && path === "/api/v1/custom_emojis") return customEmojis(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/markers") return markersList(request, env);
|
||||
if (method === "POST" && path === "/api/v1/push/subscription") return pushSubscription();
|
||||
|
||||
if (method === "GET" && (m = path.match(/^\/media\/(.+)$/))) return serveMedia(env, m[1]);
|
||||
|
||||
if (method === "GET" && (m = path.match(/^\/users\/([^/]+)$/))) return actor(env, decodeURIComponent(m[1]));
|
||||
if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/outbox$/))) return outbox(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && (m = path.match(/^\/users\/([^/]+)\/inbox$/))) return inboxHandler(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && path === "/inbox") return inboxHandler(request, env, null);
|
||||
if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/followers$/))) return followersCollection(env, decodeURIComponent(m[1]));
|
||||
if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/following$/))) return followingCollection(env, decodeURIComponent(m[1]));
|
||||
if (method === "GET" && (m = path.match(/^\/objects\/([^/]+)$/))) return activityObject(env, decodeURIComponent(m[1]));
|
||||
|
||||
return json({ error: "not_found" }, 404);
|
||||
}
|
||||
+1321
File diff suppressed because it is too large
Load Diff
+167
@@ -0,0 +1,167 @@
|
||||
export type Json = Record<string, unknown>;
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
note: string;
|
||||
password_hash: string;
|
||||
private_key_jwk: string;
|
||||
public_key_jwk: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type OAuthApp = {
|
||||
id: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
name: string;
|
||||
redirect_uri: string;
|
||||
scopes: string;
|
||||
website: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type OAuthCode = {
|
||||
code: string;
|
||||
app_id: string;
|
||||
user_id: string;
|
||||
redirect_uri: string;
|
||||
scopes: string;
|
||||
expires_at: number;
|
||||
};
|
||||
|
||||
export type Status = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
sensitive: number;
|
||||
language: string;
|
||||
visibility: string;
|
||||
in_reply_to_id: string | null;
|
||||
activity_id: string;
|
||||
object_id: string;
|
||||
created_at: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type DeletedStatus = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
object_id: string;
|
||||
url: string;
|
||||
deleted_at: string;
|
||||
};
|
||||
|
||||
export type Media = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
status_id: string | null;
|
||||
r2_key: string;
|
||||
mime_type: string;
|
||||
description: string | null;
|
||||
size: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Follow = {
|
||||
id: string;
|
||||
follower_actor: string;
|
||||
local_user_id: string;
|
||||
inbox: string;
|
||||
accepted: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type OutgoingFollow = {
|
||||
id: string;
|
||||
local_user_id: string;
|
||||
target_actor: string;
|
||||
target_inbox: string;
|
||||
activity_id: string;
|
||||
accepted: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Notification = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: string;
|
||||
actor: string;
|
||||
status_id: string | null;
|
||||
read: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Favourite = {
|
||||
id: string;
|
||||
status_id: string;
|
||||
actor: string;
|
||||
activity_id: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Reblog = {
|
||||
id: string;
|
||||
status_id: string;
|
||||
actor: string;
|
||||
activity_id: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Mention = {
|
||||
status_id: string;
|
||||
actor: string;
|
||||
acct: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type Hashtag = {
|
||||
status_id: string;
|
||||
tag: string;
|
||||
};
|
||||
|
||||
export type ActorCache = {
|
||||
id: string;
|
||||
inbox: string;
|
||||
shared_inbox: string | null;
|
||||
preferred_username: string | null;
|
||||
name: string | null;
|
||||
summary: string | null;
|
||||
icon_url: string | null;
|
||||
public_key_id: string | null;
|
||||
public_key_pem: string | null;
|
||||
fetched_at: string;
|
||||
};
|
||||
|
||||
export type RemoteActor = {
|
||||
id: string;
|
||||
type?: string;
|
||||
inbox?: string;
|
||||
endpoints?: { sharedInbox?: string };
|
||||
preferredUsername?: string;
|
||||
name?: string;
|
||||
summary?: string;
|
||||
icon?: { url?: string } | string;
|
||||
publicKey?: {
|
||||
id?: string;
|
||||
owner?: string;
|
||||
publicKeyPem?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Session = {
|
||||
userId: string;
|
||||
appId: string;
|
||||
scopes: string;
|
||||
};
|
||||
|
||||
export const ACTIVITY_CONTEXT = "https://www.w3.org/ns/activitystreams";
|
||||
export const SECURITY_CONTEXT = "https://w3id.org/security/v1";
|
||||
export const PUBLIC_COLLECTION = "https://www.w3.org/ns/activitystreams#Public";
|
||||
export const ACTOR_CACHE_TTL_MS = 1000 * 60 * 60 * 24;
|
||||
export const SIGNATURE_MAX_SKEW_MS = 1000 * 60 * 60 * 12;
|
||||
|
||||
export const AVATAR_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160"><rect width="160" height="160" fill="#d8e1e8"/><circle cx="80" cy="56" r="28" fill="#6b7c8f"/><path d="M30 136c10-28 34-42 50-42s40 14 50 42" fill="#6b7c8f"/></svg>`;
|
||||
export const HEADER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 500"><defs><linearGradient id="g" x1="0" x2="1"><stop stop-color="#d8e1e8"/><stop offset="1" stop-color="#8aa0b6"/></linearGradient></defs><rect width="1500" height="500" fill="url(#g)"/><circle cx="220" cy="120" r="90" fill="#f7fbff" fill-opacity=".35"/><circle cx="1280" cy="380" r="120" fill="#f7fbff" fill-opacity=".2"/></svg>`;
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import type { User } from "./types";
|
||||
|
||||
export const encoder = new TextEncoder();
|
||||
export const decoder = new TextDecoder();
|
||||
|
||||
export function id(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function tokenString(bytes: number): string {
|
||||
return base64Url(crypto.getRandomValues(new Uint8Array(bytes)));
|
||||
}
|
||||
|
||||
export function concatBytes(...parts: Uint8Array[]): Uint8Array {
|
||||
let total = 0;
|
||||
for (const part of parts) total += part.length;
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function base64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function base64Decode(value: string): Uint8Array {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function base64Url(bytes: Uint8Array): string {
|
||||
return base64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
export function base64UrlDecode(value: string): Uint8Array {
|
||||
const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (value.length % 4)) % 4);
|
||||
return base64Decode(padded);
|
||||
}
|
||||
|
||||
export function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!);
|
||||
}
|
||||
|
||||
export function safeFileName(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120) || "upload";
|
||||
}
|
||||
|
||||
export function htmlContent(text: string, mentions: { acct: string; url: string }[] = [], hashtags: string[] = []): string {
|
||||
let escaped = escapeHtml(text);
|
||||
for (const mention of mentions) {
|
||||
const at = escapeHtml(`@${mention.acct}`);
|
||||
const url = escapeHtml(mention.url);
|
||||
const localName = mention.acct.split("@")[0];
|
||||
const span = `<span class="h-card"><a href="${url}" class="u-url mention">@<span>${escapeHtml(localName)}</span></a></span>`;
|
||||
escaped = escaped.replaceAll(at, span);
|
||||
}
|
||||
for (const tag of hashtags) {
|
||||
const pattern = new RegExp(`#${escapeHtml(tag)}\\b`, "g");
|
||||
escaped = escaped.replace(pattern, `<a href="#" class="mention hashtag" rel="tag">#<span>${escapeHtml(tag)}</span></a>`);
|
||||
}
|
||||
return `<p>${escaped.replace(/\n{2,}/g, "</p><p>").replace(/\n/g, "<br>")}</p>`;
|
||||
}
|
||||
|
||||
export function normalizeArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
if (typeof value === "string" && value) return [value];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function clampLimit(value: unknown, fallback: number, max: number): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.min(Math.floor(parsed), max);
|
||||
}
|
||||
|
||||
export function baseUrl(env: Env): string {
|
||||
return env.PUBLIC_BASE_URL.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function hostFromBaseUrl(env: Env): string {
|
||||
return new URL(baseUrl(env)).host;
|
||||
}
|
||||
|
||||
export function actorUrl(env: Env, user: User): string {
|
||||
return `${baseUrl(env)}/users/${user.username}`;
|
||||
}
|
||||
|
||||
export function objectUrl(env: Env, statusId: string): string {
|
||||
return `${baseUrl(env)}/objects/${statusId}`;
|
||||
}
|
||||
|
||||
export function activityUrl(env: Env, activityId: string): string {
|
||||
return `${baseUrl(env)}/activities/${activityId}`;
|
||||
}
|
||||
|
||||
export function isLocalActor(env: Env, actorId: string): boolean {
|
||||
try {
|
||||
return new URL(actorId).host === hostFromBaseUrl(env);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAcctFromActor(env: Env, actorId: string): string {
|
||||
try {
|
||||
const url = new URL(actorId);
|
||||
const name = url.pathname.split("/").filter(Boolean).pop() ?? actorId;
|
||||
if (url.host === hostFromBaseUrl(env)) return name;
|
||||
return `${name}@${url.host}`;
|
||||
} catch {
|
||||
return actorId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "worker-configuration.d.ts"]
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
interface Env {
|
||||
DB: D1Database;
|
||||
MEDIA: R2Bucket;
|
||||
KV: KVNamespace;
|
||||
PUBLIC_BASE_URL: string;
|
||||
INSTANCE_NAME: string;
|
||||
ADMIN_USERNAME: string;
|
||||
ADMIN_PASSWORD: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "toot-worker",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-05-13",
|
||||
"vars": {
|
||||
"PUBLIC_BASE_URL": "https://social.example.com",
|
||||
"INSTANCE_NAME": "Toot Worker",
|
||||
"ADMIN_USERNAME": "admin",
|
||||
"ADMIN_PASSWORD": "change-me-before-deploy"
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "toot_db",
|
||||
"database_id": "00000000-0000-0000-0000-000000000000"
|
||||
}
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "MEDIA",
|
||||
"bucket_name": "toot-media"
|
||||
}
|
||||
],
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "KV",
|
||||
"id": "00000000000000000000000000000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user