提交
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
-- Bookmarks, pins, remote status cache, OAuth token persistence.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
user_id TEXT NOT NULL,
|
||||
status_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, status_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bookmarks_user_time ON bookmarks(user_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pinned_statuses (
|
||||
user_id TEXT NOT NULL,
|
||||
status_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, status_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pinned_user ON pinned_statuses(user_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cached_statuses (
|
||||
id TEXT PRIMARY KEY,
|
||||
object_id TEXT NOT NULL UNIQUE,
|
||||
actor TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
sensitive INTEGER NOT NULL DEFAULT 0,
|
||||
language TEXT NOT NULL DEFAULT 'en',
|
||||
in_reply_to TEXT,
|
||||
url TEXT NOT NULL,
|
||||
published TEXT NOT NULL,
|
||||
cached_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cached_statuses_actor_time ON cached_statuses(actor, published DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_cached_statuses_time ON cached_statuses(published DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cached_status_attachments (
|
||||
cached_status_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
preview_url TEXT,
|
||||
mime_type TEXT NOT NULL,
|
||||
description TEXT,
|
||||
PRIMARY KEY(cached_status_id, position)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
app_id TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id, created_at DESC);
|
||||
@@ -3,11 +3,11 @@
|
||||
一个运行在 Cloudflare Workers 上的单用户联邦宇宙发布软件,使用:
|
||||
|
||||
- Workers: HTTP API、ActivityPub 路由、Mastodon API 兼容层
|
||||
- D1: 用户、OAuth 应用、嘟文、媒体索引、关注、收藏、转发、通知、提及、话题标签、远端 actor 缓存
|
||||
- D1: 用户、OAuth 应用/Token、嘟文、媒体索引、关注、收藏、转发、通知、提及、话题标签、收藏夹、置顶、远端 actor 与远端嘟文缓存
|
||||
- R2: 媒体文件
|
||||
- KV: OAuth access token 会话
|
||||
- KV: OAuth access token 会话(D1 同步保留,便于管理)
|
||||
|
||||
当前目标:让常见 Mastodon App 完成实例发现、创建 OAuth App、登录、上传媒体、发布嘟文、读取公开/家庭时间线、收藏/转发/回复、查看通知、搜索、关注/取关远端账号,同时支持单用户实例与 Fediverse 双向联邦。
|
||||
当前目标:让常见 Mastodon App 完成实例发现、创建 OAuth App、登录、上传媒体、发布嘟文、读取公开/家庭/话题时间线、收藏/转发/回复/收藏夹/置顶、查看通知、搜索、关注/取关远端账号,同时支持单用户实例与 Fediverse 双向联邦。
|
||||
|
||||
## 本地运行
|
||||
|
||||
@@ -43,6 +43,15 @@ wrangler secret put ADMIN_PASSWORD
|
||||
|
||||
一旦开始对外联邦后,不要再改域名,否则远端会把你视为另一个实例身份。
|
||||
|
||||
### 媒体 CDN 加速(可选但推荐)
|
||||
|
||||
默认情况下,客户端拉取上传媒体走 `${PUBLIC_BASE_URL}/media/<key>`,会经过 Worker 反代 R2,消耗 Worker 请求数和 CPU。生产建议:
|
||||
|
||||
1. 在 Cloudflare R2 控制台给 `toot-media` 绑定一个 custom domain(如 `media.social.example.com`),开启公开访问 + CDN。
|
||||
2. 把该域名填进 `wrangler.jsonc` 的 `MEDIA_BASE_URL`,例如 `"MEDIA_BASE_URL": "https://media.social.example.com"`。
|
||||
|
||||
设置后,Mastodon 客户端拿到的 `media_attachments.url` 会直接指向 CDN 域名,不再经过 Worker。Worker 自己的 `/media/<key>` 路径仍然保留,可以作为后备访问入口。
|
||||
|
||||
## Cloudflare 资源
|
||||
|
||||
```bash
|
||||
@@ -86,11 +95,14 @@ npm run deploy
|
||||
- `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)
|
||||
- `POST /api/v1/statuses/:id/bookmark`、`/unbookmark`、`/pin`、`/unpin`(本地落库)
|
||||
- `GET /api/v1/bookmarks`、`GET /api/v1/favourites`(列出本地 bookmark / favourite)
|
||||
|
||||
时间线 / 通知 / 媒体 / 搜索 / 其它:
|
||||
|
||||
- `GET /api/v1/timelines/public`、`GET /api/v1/timelines/home`(分页支持 `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/tag/:tag`、`GET /api/v1/tags/:name`(话题时间线 + 话题元数据)
|
||||
- `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:` 查询)
|
||||
@@ -108,7 +120,7 @@ npm run deploy
|
||||
- `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` 用于触发提及通知和回复通知。
|
||||
入站 inbox 处理类型:`Follow` / `Undo(Follow)` / `Accept(Follow)` / `Reject(Follow)` / `Like` / `Undo(Like)` / `Announce` / `Undo(Announce)` / `Delete(Note)`(同时清远端嘟文缓存)/ `Delete(Person)`(同时清缓存与关注关系)/ `Update(Person)` / `Update(Note)` / `Create(Note)`(被关注的远端账号公开嘟文会写入 `cached_statuses` 给 home timeline 使用,同时若 mention/回复本地用户会触发通知)。
|
||||
|
||||
## 安全
|
||||
|
||||
@@ -128,14 +140,15 @@ npm run deploy
|
||||
|
||||
- `migrations/0001_initial.sql` — 基础表(users / oauth_apps / oauth_codes / statuses / media / follows / remote_activities)
|
||||
- `migrations/0002_features.sql` — 通知 / 收藏 / 转发 / 提及 / 话题标签 / actor 缓存 / 出站关注 / 删除墓碑 / 嘟文扩展字段(summary / sensitive / language)
|
||||
- `migrations/0003_bookmarks_cache.sql` — 收藏夹(bookmarks)/ 置顶(pinned_statuses)/ 远端嘟文缓存(cached_statuses)/ OAuth Token 持久表
|
||||
|
||||
## 重要限制
|
||||
|
||||
这是一个单用户可运行实现,不是完整 Mastodon 服务端:
|
||||
|
||||
- 只支持单管理员账号自动初始化,不开放注册
|
||||
- 通知 / 收藏 / 转发都已实现,但内容审核、屏蔽、过滤、列表、自定义表情、推送通知仍是 stub
|
||||
- 没有处理远端嘟文缓存(收到 `Create(Note)` 不会存,仅触发提及通知)。意味着客户端的 home timeline 仍只能看到本地嘟文
|
||||
- 远端嘟文缓存只在被本地账号关注的 actor 发出的 `Create(Note)` 时写入,不抓取历史 outbox
|
||||
- `media_attachments` 已缓存(URL 指向远端原始域名),但 `mentions`、`tags` 在远端缓存嘟文中是空数组
|
||||
- 私信(direct visibility)的检索没有按收信人过滤,目前所有客户端都能在公开时间线之外读到自己的嘟文,不应当作私信使用
|
||||
- 没有实现轮询(poll)、列表(list)、推送(push)、未来嘟文(scheduled)等
|
||||
|
||||
|
||||
+57
-3
@@ -1,17 +1,21 @@
|
||||
import {
|
||||
deleteActorFromCache,
|
||||
deleteCachedStatus,
|
||||
exportUserPublicKeyPem,
|
||||
findFavourite,
|
||||
findReblog,
|
||||
getCachedStatusByObjectId,
|
||||
getStatus,
|
||||
getStatusByObjectId,
|
||||
getUserByUsername,
|
||||
recordNotification,
|
||||
upsertActorCache
|
||||
upsertActorCache,
|
||||
upsertCachedStatus
|
||||
} from "./db";
|
||||
import {
|
||||
deliverToInboxes,
|
||||
isDuplicateActivity,
|
||||
isFollowedByAnyLocalUser,
|
||||
notifyForLocalStatus,
|
||||
objectAsJson,
|
||||
objectIdString,
|
||||
@@ -360,11 +364,12 @@ async function handleDelete(ctx: InboxContext): Promise<Response> {
|
||||
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 env.DB.prepare("DELETE FROM cached_statuses 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();
|
||||
await deleteCachedStatus(env, target);
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
@@ -374,6 +379,13 @@ async function handleUpdate(ctx: InboxContext): Promise<Response> {
|
||||
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 });
|
||||
}
|
||||
if (String(obj.type ?? "") === "Note" && typeof obj.id === "string") {
|
||||
const existing = await getCachedStatusByObjectId(env, obj.id);
|
||||
if (existing && existing.actor === actorId) {
|
||||
await cacheRemoteNote(env, actorId, obj);
|
||||
}
|
||||
}
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
@@ -381,7 +393,7 @@ async function handleUpdate(ctx: InboxContext): Promise<Response> {
|
||||
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 });
|
||||
if (!obj || String(obj.type ?? "") !== "Note") return new Response(null, { status: 202 });
|
||||
|
||||
const recipients = collectRecipients(activity.body, obj);
|
||||
const localActorIds = new Set<string>();
|
||||
@@ -391,6 +403,12 @@ async function handleCreate(ctx: InboxContext): Promise<Response> {
|
||||
if (m) localActorIds.add(m[1]);
|
||||
}
|
||||
|
||||
const isPublic = recipients.includes(PUBLIC_COLLECTION);
|
||||
const followed = await isFollowedByAnyLocalUser(env, actorId);
|
||||
if (followed && (isPublic || localActorIds.size > 0)) {
|
||||
await cacheRemoteNote(env, actorId, obj);
|
||||
}
|
||||
|
||||
for (const username of localActorIds) {
|
||||
const localUser = await getUserByUsername(env, username);
|
||||
if (!localUser) continue;
|
||||
@@ -405,6 +423,42 @@ async function handleCreate(ctx: InboxContext): Promise<Response> {
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
async function cacheRemoteNote(env: Env, actorId: string, note: Json): Promise<void> {
|
||||
if (typeof note.id !== "string") return;
|
||||
const cachedId = note.id;
|
||||
const stored = await upsertCachedStatus(env, {
|
||||
id: cachedId,
|
||||
object_id: cachedId,
|
||||
actor: actorId,
|
||||
content: typeof note.content === "string" ? note.content : "",
|
||||
summary: typeof note.summary === "string" ? note.summary : "",
|
||||
sensitive: note.sensitive ? 1 : 0,
|
||||
language: typeof note.contentMap === "object" && note.contentMap ? Object.keys(note.contentMap as Json)[0] ?? "en" : "en",
|
||||
in_reply_to: typeof note.inReplyTo === "string" ? note.inReplyTo : null,
|
||||
url: typeof note.url === "string" ? note.url : cachedId,
|
||||
published: typeof note.published === "string" ? note.published : new Date().toISOString()
|
||||
});
|
||||
if (!stored) return;
|
||||
await env.DB.prepare("DELETE FROM cached_status_attachments WHERE cached_status_id = ?").bind(stored.id).run();
|
||||
const attachments = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : [];
|
||||
let position = 0;
|
||||
for (const raw of attachments) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const att = raw as Json;
|
||||
const url = typeof att.url === "string" ? att.url
|
||||
: (att.url && typeof att.url === "object" && typeof (att.url as Json).href === "string") ? String((att.url as Json).href)
|
||||
: null;
|
||||
if (!url) continue;
|
||||
const mime = typeof att.mediaType === "string" ? att.mediaType : "application/octet-stream";
|
||||
const description = typeof att.name === "string" ? att.name
|
||||
: typeof att.summary === "string" ? att.summary : null;
|
||||
await env.DB.prepare(
|
||||
"INSERT OR REPLACE INTO cached_status_attachments (cached_status_id, position, url, preview_url, mime_type, description) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
).bind(stored.id, position, url, null, mime, description).run();
|
||||
position++;
|
||||
}
|
||||
}
|
||||
|
||||
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>();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { exportSpkiPem, hashPassword } from "./crypto";
|
||||
import type {
|
||||
ActorCache,
|
||||
CachedStatus,
|
||||
CachedStatusAttachment,
|
||||
Favourite,
|
||||
Follow,
|
||||
Media,
|
||||
@@ -194,3 +196,81 @@ export function actorCacheStale(cache: ActorCache): boolean {
|
||||
export async function exportUserPublicKeyPem(user: User): Promise<string> {
|
||||
return exportSpkiPem(JSON.parse(user.public_key_jwk) as JsonWebKey);
|
||||
}
|
||||
|
||||
export async function findBookmark(env: Env, userId: string, statusId: string): Promise<boolean> {
|
||||
const row = await env.DB.prepare("SELECT user_id FROM bookmarks WHERE user_id = ? AND status_id = ?").bind(userId, statusId).first<{ user_id: string }>();
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
export async function addBookmark(env: Env, userId: string, statusId: string): Promise<void> {
|
||||
await env.DB.prepare("INSERT OR IGNORE INTO bookmarks (user_id, status_id, created_at) VALUES (?, ?, ?)")
|
||||
.bind(userId, statusId, new Date().toISOString()).run();
|
||||
}
|
||||
|
||||
export async function removeBookmark(env: Env, userId: string, statusId: string): Promise<void> {
|
||||
await env.DB.prepare("DELETE FROM bookmarks WHERE user_id = ? AND status_id = ?").bind(userId, statusId).run();
|
||||
}
|
||||
|
||||
export async function findPin(env: Env, userId: string, statusId: string): Promise<boolean> {
|
||||
const row = await env.DB.prepare("SELECT user_id FROM pinned_statuses WHERE user_id = ? AND status_id = ?").bind(userId, statusId).first<{ user_id: string }>();
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
export async function addPin(env: Env, userId: string, statusId: string): Promise<void> {
|
||||
await env.DB.prepare("INSERT OR IGNORE INTO pinned_statuses (user_id, status_id, created_at) VALUES (?, ?, ?)")
|
||||
.bind(userId, statusId, new Date().toISOString()).run();
|
||||
}
|
||||
|
||||
export async function removePin(env: Env, userId: string, statusId: string): Promise<void> {
|
||||
await env.DB.prepare("DELETE FROM pinned_statuses WHERE user_id = ? AND status_id = ?").bind(userId, statusId).run();
|
||||
}
|
||||
|
||||
export async function getCachedStatusByObjectId(env: Env, objectId: string): Promise<CachedStatus | null> {
|
||||
return env.DB.prepare("SELECT * FROM cached_statuses WHERE object_id = ?").bind(objectId).first<CachedStatus>();
|
||||
}
|
||||
|
||||
export async function upsertCachedStatus(env: Env, status: Omit<CachedStatus, "cached_at"> & { cached_at?: string }): Promise<CachedStatus | null> {
|
||||
const now = status.cached_at ?? new Date().toISOString();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO cached_statuses (id, object_id, actor, content, summary, sensitive, language, in_reply_to, url, published, cached_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(object_id) DO UPDATE SET
|
||||
content = excluded.content,
|
||||
summary = excluded.summary,
|
||||
sensitive = excluded.sensitive,
|
||||
language = excluded.language,
|
||||
in_reply_to = excluded.in_reply_to,
|
||||
url = excluded.url,
|
||||
published = excluded.published,
|
||||
cached_at = excluded.cached_at`
|
||||
)
|
||||
.bind(status.id, status.object_id, status.actor, status.content, status.summary, status.sensitive, status.language, status.in_reply_to, status.url, status.published, now)
|
||||
.run();
|
||||
return getCachedStatusByObjectId(env, status.object_id);
|
||||
}
|
||||
|
||||
export async function deleteCachedStatus(env: Env, objectId: string): Promise<void> {
|
||||
const row = await env.DB.prepare("SELECT id FROM cached_statuses WHERE object_id = ?").bind(objectId).first<{ id: string }>();
|
||||
if (!row) return;
|
||||
await env.DB.prepare("DELETE FROM cached_status_attachments WHERE cached_status_id = ?").bind(row.id).run();
|
||||
await env.DB.prepare("DELETE FROM cached_statuses WHERE id = ?").bind(row.id).run();
|
||||
}
|
||||
|
||||
export async function listCachedStatusAttachments(env: Env, cachedStatusId: string): Promise<CachedStatusAttachment[]> {
|
||||
const rows = await env.DB.prepare("SELECT * FROM cached_status_attachments WHERE cached_status_id = ? ORDER BY position ASC").bind(cachedStatusId).all<CachedStatusAttachment>();
|
||||
return rows.results;
|
||||
}
|
||||
|
||||
export async function insertOAuthToken(env: Env, token: string, userId: string, appId: string, scopes: string): Promise<void> {
|
||||
await env.DB.prepare("INSERT OR REPLACE INTO oauth_tokens (token, user_id, app_id, scopes, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, NULL)")
|
||||
.bind(token, userId, appId, scopes, new Date().toISOString()).run();
|
||||
}
|
||||
|
||||
export async function deleteOAuthToken(env: Env, token: string): Promise<void> {
|
||||
await env.DB.prepare("DELETE FROM oauth_tokens WHERE token = ?").bind(token).run();
|
||||
}
|
||||
|
||||
export async function touchOAuthToken(env: Env, token: string): Promise<void> {
|
||||
await env.DB.prepare("UPDATE oauth_tokens SET last_used_at = ? WHERE token = ?")
|
||||
.bind(new Date().toISOString(), token).run();
|
||||
}
|
||||
|
||||
@@ -251,6 +251,11 @@ export function mentionAcct(env: Env, actorId: string): string {
|
||||
return parseAcctFromActor(env, actorId);
|
||||
}
|
||||
|
||||
export async function isFollowedByAnyLocalUser(env: Env, actorId: string): Promise<boolean> {
|
||||
const row = await env.DB.prepare("SELECT 1 AS hit FROM outgoing_follows WHERE target_actor = ? AND accepted = 1 LIMIT 1").bind(actorId).first<{ hit: number }>();
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
export async function decodeRemoteSignatureBase64(value: string): Promise<Uint8Array> {
|
||||
return base64Decode(value);
|
||||
}
|
||||
|
||||
+15
-3
@@ -20,17 +20,21 @@ import {
|
||||
authorizeFollowRequest,
|
||||
authorizePage,
|
||||
bookmarkStatus,
|
||||
bookmarksList,
|
||||
createApp,
|
||||
createStatus,
|
||||
customEmojis,
|
||||
deleteStatusEndpoint,
|
||||
favouriteStatus,
|
||||
favouritesList,
|
||||
filtersV1,
|
||||
followAccount,
|
||||
followRequestsList,
|
||||
getAccount,
|
||||
getRelationships,
|
||||
getStatusEndpoint,
|
||||
hashtagInfo,
|
||||
hashtagTimeline,
|
||||
homeTimeline,
|
||||
instance,
|
||||
instanceV2,
|
||||
@@ -38,6 +42,7 @@ import {
|
||||
notificationClear,
|
||||
notificationDismiss,
|
||||
notificationsList,
|
||||
pinStatus,
|
||||
publicTimeline,
|
||||
pushSubscription,
|
||||
reblogStatus,
|
||||
@@ -48,8 +53,10 @@ import {
|
||||
statusContext,
|
||||
token,
|
||||
trendsTags,
|
||||
unbookmarkStatus,
|
||||
unfavouriteStatus,
|
||||
unfollowAccount,
|
||||
unpinStatus,
|
||||
unreblogStatus,
|
||||
updateCredentials,
|
||||
updateMedia,
|
||||
@@ -122,12 +129,17 @@ async function route(request: Request, env: Env): Promise<Response> {
|
||||
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 === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unbookmark$/))) return unbookmarkStatus(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/pin$/))) return pinStatus(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unpin$/))) return unpinStatus(request, env, decodeURIComponent(m[1]));
|
||||
|
||||
if (method === "GET" && path === "/api/v1/timelines/public") return publicTimeline(request, env);
|
||||
if (method === "GET" && path === "/api/v1/timelines/home") return homeTimeline(request, env);
|
||||
if (method === "GET" && (m = path.match(/^\/api\/v1\/timelines\/tag\/([^/]+)$/))) return hashtagTimeline(request, env, decodeURIComponent(m[1]));
|
||||
if (method === "GET" && (m = path.match(/^\/api\/v1\/tags\/([^/]+)$/))) return hashtagInfo(env, decodeURIComponent(m[1]));
|
||||
|
||||
if (method === "GET" && path === "/api/v1/bookmarks") return bookmarksList(request, env);
|
||||
if (method === "GET" && path === "/api/v1/favourites") return favouritesList(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]));
|
||||
|
||||
+215
-11
@@ -10,11 +10,16 @@ import {
|
||||
} from "./activitypub";
|
||||
import { hashPassword, verifyPassword } from "./crypto";
|
||||
import {
|
||||
addBookmark,
|
||||
addPin,
|
||||
countFollowers,
|
||||
countFollowing,
|
||||
countStatuses,
|
||||
deleteOAuthToken,
|
||||
findBookmark,
|
||||
findFavourite,
|
||||
findOutgoingFollow,
|
||||
findPin,
|
||||
findReblog,
|
||||
getAdminUser,
|
||||
getAppByClientId,
|
||||
@@ -22,7 +27,11 @@ import {
|
||||
getUserById,
|
||||
getUserByIdOrUsername,
|
||||
getUserByUsername,
|
||||
insertOAuthToken,
|
||||
listCachedStatusAttachments,
|
||||
recordNotification,
|
||||
removeBookmark,
|
||||
removePin,
|
||||
takeOAuthCode
|
||||
} from "./db";
|
||||
import {
|
||||
@@ -41,6 +50,7 @@ import {
|
||||
readBody
|
||||
} from "./http";
|
||||
import type {
|
||||
CachedStatus,
|
||||
Follow,
|
||||
Media,
|
||||
Mention,
|
||||
@@ -59,6 +69,7 @@ import {
|
||||
htmlContent,
|
||||
id,
|
||||
isLocalActor,
|
||||
mediaUrl,
|
||||
normalizeArray,
|
||||
objectUrl,
|
||||
safeFileName,
|
||||
@@ -252,13 +263,17 @@ export async function token(request: Request, env: Env): Promise<Response> {
|
||||
|
||||
const accessToken = tokenString(48);
|
||||
await env.KV.put(`token:${accessToken}`, JSON.stringify({ userId, appId: app.id, scopes } satisfies Session), { expirationTtl: TOKEN_TTL_SECONDS });
|
||||
if (userId) await insertOAuthToken(env, accessToken, userId, app.id, scopes);
|
||||
return json({ access_token: accessToken, token_type: "Bearer", scope: scopes, created_at: Math.floor(Date.now() / 1000) });
|
||||
}
|
||||
|
||||
export async function revoke(request: Request, env: Env): Promise<Response> {
|
||||
const body = await readBody(request);
|
||||
const tokenValue = bodyString(body, "token");
|
||||
if (tokenValue) await env.KV.delete(`token:${tokenValue}`);
|
||||
if (tokenValue) {
|
||||
await env.KV.delete(`token:${tokenValue}`);
|
||||
await deleteOAuthToken(env, tokenValue);
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
|
||||
@@ -590,11 +605,64 @@ export async function unreblogStatus(request: Request, env: Env, statusId: strin
|
||||
}
|
||||
|
||||
export async function bookmarkStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||
await requireUser(request, env);
|
||||
const user = await requireUser(request, env);
|
||||
const status = await getStatus(env, statusId);
|
||||
if (!status) return json({ error: "Record not found" }, 404);
|
||||
await addBookmark(env, user.id, status.id);
|
||||
const owner = await getUserById(env, status.user_id);
|
||||
return json(await statusJson(env, status, owner!, request));
|
||||
if (!owner) throw new HttpError(500, "owner_missing");
|
||||
return json(await statusJson(env, status, owner, request));
|
||||
}
|
||||
|
||||
export async function unbookmarkStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||
const user = await requireUser(request, env);
|
||||
const status = await getStatus(env, statusId);
|
||||
if (!status) return json({ error: "Record not found" }, 404);
|
||||
await removeBookmark(env, user.id, status.id);
|
||||
const owner = await getUserById(env, status.user_id);
|
||||
if (!owner) throw new HttpError(500, "owner_missing");
|
||||
return json(await statusJson(env, status, owner, request));
|
||||
}
|
||||
|
||||
export async function pinStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||
const user = await requireUser(request, env);
|
||||
const status = await getStatus(env, statusId);
|
||||
if (!status || status.user_id !== user.id) return json({ error: "Record not found" }, 404);
|
||||
await addPin(env, user.id, status.id);
|
||||
return json(await statusJson(env, status, user, request));
|
||||
}
|
||||
|
||||
export async function unpinStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||
const user = await requireUser(request, env);
|
||||
const status = await getStatus(env, statusId);
|
||||
if (!status || status.user_id !== user.id) return json({ error: "Record not found" }, 404);
|
||||
await removePin(env, user.id, status.id);
|
||||
return json(await statusJson(env, status, user, request));
|
||||
}
|
||||
|
||||
export async function bookmarksList(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, 40);
|
||||
const rows = await env.DB.prepare(
|
||||
`SELECT s.* FROM statuses s INNER JOIN bookmarks b ON b.status_id = s.id
|
||||
WHERE b.user_id = ? ORDER BY b.created_at DESC LIMIT ?`
|
||||
).bind(user.id, limit).all<Status>();
|
||||
const items = await serializeStatuses(env, rows.results, request);
|
||||
return withPagination(json(items), request, rows.results.map((s) => s.id));
|
||||
}
|
||||
|
||||
export async function favouritesList(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, 40);
|
||||
const actor = actorUrl(env, user);
|
||||
const rows = await env.DB.prepare(
|
||||
`SELECT s.* FROM statuses s INNER JOIN favourites f ON f.status_id = s.id
|
||||
WHERE f.actor = ? ORDER BY f.created_at DESC LIMIT ?`
|
||||
).bind(actor, limit).all<Status>();
|
||||
const items = await serializeStatuses(env, rows.results, request);
|
||||
return withPagination(json(items), request, rows.results.map((s) => s.id));
|
||||
}
|
||||
|
||||
export async function publicTimeline(request: Request, env: Env): Promise<Response> {
|
||||
@@ -611,8 +679,61 @@ export async function publicTimeline(request: Request, env: Env): Promise<Respon
|
||||
}
|
||||
|
||||
export async function homeTimeline(request: Request, env: Env): Promise<Response> {
|
||||
await requireUser(request, env);
|
||||
return publicTimeline(request, env);
|
||||
const user = await requireUser(request, env);
|
||||
const url = new URL(request.url);
|
||||
const limit = clampLimit(url.searchParams.get("limit"), 20, 40);
|
||||
|
||||
const localRows = await env.DB.prepare(
|
||||
"SELECT * FROM statuses WHERE user_id = ? ORDER BY created_at DESC LIMIT ?"
|
||||
).bind(user.id, limit).all<Status>();
|
||||
|
||||
const cachedRows = await env.DB.prepare(
|
||||
`SELECT cs.* FROM cached_statuses cs
|
||||
INNER JOIN outgoing_follows of ON of.target_actor = cs.actor
|
||||
WHERE of.local_user_id = ? AND of.accepted = 1
|
||||
ORDER BY cs.published DESC LIMIT ?`
|
||||
).bind(user.id, limit).all<CachedStatus>();
|
||||
|
||||
const localItems = await serializeStatuses(env, localRows.results, request);
|
||||
const cachedItems = await Promise.all(cachedRows.results.map((row) => cachedStatusToMastodon(env, row)));
|
||||
|
||||
const merged = [...localItems, ...cachedItems].sort((a, b) => {
|
||||
const at = String(a.created_at ?? "");
|
||||
const bt = String(b.created_at ?? "");
|
||||
return bt.localeCompare(at);
|
||||
}).slice(0, limit);
|
||||
|
||||
return json(merged);
|
||||
}
|
||||
|
||||
export async function hashtagTimeline(request: Request, env: Env, tag: string): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const limit = clampLimit(url.searchParams.get("limit"), 20, 40);
|
||||
const where: string[] = ["s.visibility = 'public'", "h.tag = ?"];
|
||||
const binds: unknown[] = [tag.toLowerCase()];
|
||||
const maxId = url.searchParams.get("max_id");
|
||||
if (maxId) { where.push("s.created_at < (SELECT created_at FROM statuses WHERE id = ?)"); binds.push(maxId); }
|
||||
const sinceId = url.searchParams.get("since_id");
|
||||
if (sinceId) { where.push("s.created_at > (SELECT created_at FROM statuses WHERE id = ?)"); binds.push(sinceId); }
|
||||
const minId = url.searchParams.get("min_id");
|
||||
if (minId) { where.push("s.created_at > (SELECT created_at FROM statuses WHERE id = ?)"); binds.push(minId); }
|
||||
const sql = `SELECT s.* FROM statuses s INNER JOIN hashtags h ON h.status_id = s.id WHERE ${where.join(" AND ")} ORDER BY s.created_at DESC LIMIT ?`;
|
||||
binds.push(limit);
|
||||
const rows = await env.DB.prepare(sql).bind(...binds).all<Status>();
|
||||
const items = await serializeStatuses(env, rows.results, request);
|
||||
return withPagination(json(items), request, rows.results.map((s) => s.id));
|
||||
}
|
||||
|
||||
export async function hashtagInfo(env: Env, tag: string): Promise<Response> {
|
||||
const normalized = tag.toLowerCase();
|
||||
const row = await env.DB.prepare("SELECT COUNT(DISTINCT status_id) AS count FROM hashtags WHERE tag = ?").bind(normalized).first<{ count: number }>();
|
||||
return json({
|
||||
name: normalized,
|
||||
url: `${baseUrl(env)}/tags/${encodeURIComponent(normalized)}`,
|
||||
history: [],
|
||||
following: false,
|
||||
statuses_count: row?.count ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadMedia(request: Request, env: Env): Promise<Response> {
|
||||
@@ -851,8 +972,60 @@ type StatusSerializationContext = {
|
||||
reblogCountByStatusId: Map<string, number>;
|
||||
rebloggedStatusIds: Set<string>;
|
||||
replyCountByStatusId: Map<string, number>;
|
||||
bookmarkedStatusIds: Set<string>;
|
||||
pinnedStatusIds: Set<string>;
|
||||
};
|
||||
|
||||
async function cachedStatusToMastodon(env: Env, row: CachedStatus): Promise<Record<string, unknown>> {
|
||||
const cache = await resolveRemoteActor(env, row.actor);
|
||||
const account = cache ? remoteAccountJson(cache) : { id: row.actor, acct: row.actor, username: row.actor };
|
||||
const attachments = await listCachedStatusAttachments(env, row.id);
|
||||
return {
|
||||
id: row.object_id,
|
||||
uri: row.object_id,
|
||||
url: row.url,
|
||||
account,
|
||||
in_reply_to_id: null,
|
||||
in_reply_to_account_id: null,
|
||||
content: row.content,
|
||||
text: row.content,
|
||||
created_at: row.published,
|
||||
edited_at: null,
|
||||
visibility: "public",
|
||||
language: row.language,
|
||||
sensitive: Boolean(row.sensitive),
|
||||
spoiler_text: row.summary,
|
||||
media_attachments: attachments.map((att) => ({
|
||||
id: `${row.id}:${att.position}`,
|
||||
type: att.mime_type.startsWith("image/") ? "image"
|
||||
: att.mime_type.startsWith("video/") ? "video"
|
||||
: att.mime_type.startsWith("audio/") ? "audio" : "unknown",
|
||||
url: att.url,
|
||||
preview_url: att.preview_url ?? att.url,
|
||||
remote_url: att.url,
|
||||
text_url: null,
|
||||
meta: {},
|
||||
description: att.description,
|
||||
blurhash: null
|
||||
})),
|
||||
mentions: [],
|
||||
tags: [],
|
||||
emojis: [],
|
||||
reblogs_count: 0,
|
||||
favourites_count: 0,
|
||||
replies_count: 0,
|
||||
reblog: null,
|
||||
application: null,
|
||||
favourited: false,
|
||||
reblogged: false,
|
||||
muted: false,
|
||||
bookmarked: false,
|
||||
pinned: false,
|
||||
card: null,
|
||||
poll: null
|
||||
};
|
||||
}
|
||||
|
||||
async function statusJson(
|
||||
env: Env,
|
||||
status: Status,
|
||||
@@ -905,8 +1078,8 @@ function statusRecord(env: Env, status: Status, user: User, context: StatusSeria
|
||||
favourited: context.favouritedStatusIds.has(status.id),
|
||||
reblogged: context.rebloggedStatusIds.has(status.id),
|
||||
muted: false,
|
||||
bookmarked: false,
|
||||
pinned: false,
|
||||
bookmarked: context.bookmarkedStatusIds.has(status.id),
|
||||
pinned: context.pinnedStatusIds.has(status.id),
|
||||
card: null,
|
||||
poll: null
|
||||
};
|
||||
@@ -940,13 +1113,16 @@ async function buildStatusSerializationContext(
|
||||
}
|
||||
|
||||
const viewer = await viewerActor(request, env);
|
||||
const [mediaByStatusId, mentionsByStatusId, hashtagsByStatusId, favouriteSummary, reblogSummary, replyCountByStatusId] = await Promise.all([
|
||||
const viewerId = await viewerUserId(request, env);
|
||||
const [mediaByStatusId, mentionsByStatusId, hashtagsByStatusId, favouriteSummary, reblogSummary, replyCountByStatusId, bookmarkedStatusIds, pinnedStatusIds] = await Promise.all([
|
||||
loadMediaByStatusIds(env, statusIds),
|
||||
loadMentionsByStatusIds(env, statusIds),
|
||||
loadHashtagsByStatusIds(env, statusIds),
|
||||
loadStatusInteractionSummary(env, "favourites", statusIds, viewer),
|
||||
loadStatusInteractionSummary(env, "reblogs", statusIds, viewer),
|
||||
loadReplyCountByStatusIds(env, statusIds)
|
||||
loadReplyCountByStatusIds(env, statusIds),
|
||||
viewerId ? loadBookmarkedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set<string>()),
|
||||
viewerId ? loadPinnedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set<string>())
|
||||
]);
|
||||
|
||||
const accountByUserId = new Map<string, Record<string, unknown>>();
|
||||
@@ -964,7 +1140,9 @@ async function buildStatusSerializationContext(
|
||||
favouritedStatusIds: favouriteSummary.viewerMatchedStatusIds,
|
||||
reblogCountByStatusId: reblogSummary.countByStatusId,
|
||||
rebloggedStatusIds: reblogSummary.viewerMatchedStatusIds,
|
||||
replyCountByStatusId
|
||||
replyCountByStatusId,
|
||||
bookmarkedStatusIds,
|
||||
pinnedStatusIds
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1029,7 +1207,7 @@ function remoteAccountJson(cache: { id: string; preferred_username: string | nul
|
||||
}
|
||||
|
||||
function mediaJson(env: Env, media: Media): Record<string, unknown> {
|
||||
const url = `${baseUrl(env)}/media/${encodeURIComponent(media.r2_key)}`;
|
||||
const url = mediaUrl(env, media.r2_key);
|
||||
return {
|
||||
id: media.id,
|
||||
type: media.mime_type.startsWith("image/") ? "image" : media.mime_type.startsWith("video/") ? "video" : "unknown",
|
||||
@@ -1307,6 +1485,32 @@ async function viewerActor(request: Request, env: Env): Promise<string | null> {
|
||||
return user ? actorUrl(env, user) : null;
|
||||
}
|
||||
|
||||
async function viewerUserId(request: Request, env: Env): Promise<string | null> {
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
|
||||
if (!token) return null;
|
||||
const session = await env.KV.get<Session>(`token:${token}`, "json");
|
||||
return session?.userId ?? null;
|
||||
}
|
||||
|
||||
async function loadBookmarkedStatusIds(env: Env, userId: string, statusIds: string[]): Promise<Set<string>> {
|
||||
if (statusIds.length === 0) return new Set();
|
||||
const placeholders = statusIds.map(() => "?").join(",");
|
||||
const rows = await env.DB.prepare(
|
||||
`SELECT status_id FROM bookmarks WHERE user_id = ? AND status_id IN (${placeholders})`
|
||||
).bind(userId, ...statusIds).all<{ status_id: string }>();
|
||||
return new Set(rows.results.map((row) => row.status_id));
|
||||
}
|
||||
|
||||
async function loadPinnedStatusIds(env: Env, userId: string, statusIds: string[]): Promise<Set<string>> {
|
||||
if (statusIds.length === 0) return new Set();
|
||||
const placeholders = statusIds.map(() => "?").join(",");
|
||||
const rows = await env.DB.prepare(
|
||||
`SELECT status_id FROM pinned_statuses WHERE user_id = ? AND status_id IN (${placeholders})`
|
||||
).bind(userId, ...statusIds).all<{ status_id: string }>();
|
||||
return new Set(rows.results.map((row) => row.status_id));
|
||||
}
|
||||
|
||||
async function requireUser(request: Request, env: Env): Promise<User> {
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
|
||||
|
||||
@@ -122,6 +122,50 @@ export type Hashtag = {
|
||||
tag: string;
|
||||
};
|
||||
|
||||
export type Bookmark = {
|
||||
user_id: string;
|
||||
status_id: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type PinnedStatus = {
|
||||
user_id: string;
|
||||
status_id: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CachedStatus = {
|
||||
id: string;
|
||||
object_id: string;
|
||||
actor: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
sensitive: number;
|
||||
language: string;
|
||||
in_reply_to: string | null;
|
||||
url: string;
|
||||
published: string;
|
||||
cached_at: string;
|
||||
};
|
||||
|
||||
export type CachedStatusAttachment = {
|
||||
cached_status_id: string;
|
||||
position: number;
|
||||
url: string;
|
||||
preview_url: string | null;
|
||||
mime_type: string;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
export type OAuthToken = {
|
||||
token: string;
|
||||
user_id: string;
|
||||
app_id: string;
|
||||
scopes: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
};
|
||||
|
||||
export type ActorCache = {
|
||||
id: string;
|
||||
inbox: string;
|
||||
|
||||
+12
@@ -103,6 +103,18 @@ export function activityUrl(env: Env, activityId: string): string {
|
||||
return `${baseUrl(env)}/activities/${activityId}`;
|
||||
}
|
||||
|
||||
export function mediaCdnBaseUrl(env: Env): string | null {
|
||||
const value = (env.MEDIA_BASE_URL ?? "").trim();
|
||||
if (!value) return null;
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function mediaUrl(env: Env, r2Key: string): string {
|
||||
const cdn = mediaCdnBaseUrl(env);
|
||||
if (cdn) return `${cdn}/${r2Key.split("/").map(encodeURIComponent).join("/")}`;
|
||||
return `${baseUrl(env)}/media/${encodeURIComponent(r2Key)}`;
|
||||
}
|
||||
|
||||
export function isLocalActor(env: Env, actorId: string): boolean {
|
||||
try {
|
||||
return new URL(actorId).host === hostFromBaseUrl(env);
|
||||
|
||||
Vendored
+1
@@ -3,6 +3,7 @@ interface Env {
|
||||
MEDIA: R2Bucket;
|
||||
KV: KVNamespace;
|
||||
PUBLIC_BASE_URL: string;
|
||||
MEDIA_BASE_URL: string;
|
||||
INSTANCE_NAME: string;
|
||||
ADMIN_USERNAME: string;
|
||||
ADMIN_PASSWORD: string;
|
||||
|
||||
+5
-4
@@ -3,16 +3,17 @@
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-05-13",
|
||||
"vars": {
|
||||
"PUBLIC_BASE_URL": "https://social.example.com",
|
||||
"PUBLIC_BASE_URL": "https://zxd.im",
|
||||
"MEDIA_BASE_URL": "https://toot-media.zxd.im",
|
||||
"INSTANCE_NAME": "Toot Worker",
|
||||
"ADMIN_USERNAME": "admin",
|
||||
"ADMIN_USERNAME": "sun",
|
||||
"ADMIN_PASSWORD": "change-me-before-deploy"
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "toot_db",
|
||||
"database_id": "00000000-0000-0000-0000-000000000000"
|
||||
"database_id": "8e042858-bf5f-4d7a-ad84-3e002b0b2f42"
|
||||
}
|
||||
],
|
||||
"r2_buckets": [
|
||||
@@ -24,7 +25,7 @@
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "KV",
|
||||
"id": "00000000000000000000000000000000"
|
||||
"id": "0e14d63f7d624358ab6507ef1bac9017"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user