附件上传限制
This commit is contained in:
@@ -147,12 +147,13 @@ npm run deploy
|
|||||||
这是一个单用户可运行实现,不是完整 Mastodon 服务端:
|
这是一个单用户可运行实现,不是完整 Mastodon 服务端:
|
||||||
|
|
||||||
- 只支持单管理员账号自动初始化,不开放注册
|
- 只支持单管理员账号自动初始化,不开放注册
|
||||||
- 本地状态的可见性语义并不完整:
|
- 本地状态的可见性已做基础控制:
|
||||||
- `visibility=private` 目前只会写入本地库,不会按 followers-only 语义完整投递
|
- `public` / `unlisted` 可公开读取; ActivityPub outbox 只暴露这两类状态
|
||||||
- `unlisted` / `private` / `direct` 没有做完整的读权限控制,`GET /api/v1/accounts/:id/statuses`、`GET /api/v1/statuses/:id`、搜索接口以及 ActivityPub outbox 都可能暴露非公开内容,不应当存放敏感或真正私密的信息
|
- `private` 会按 followers-only 投递,本地读取限作者和本地关注者
|
||||||
|
- `direct` 仍没有完整受众表,本地读取保守限制为作者可见,不应当作为完整私信系统使用
|
||||||
- 远端嘟文缓存只在被本地账号关注的 actor 发出的入站 `Create(Note)` 时写入,不抓取历史 outbox
|
- 远端嘟文缓存只在被本地账号关注的 actor 发出的入站 `Create(Note)` 时写入,不抓取历史 outbox
|
||||||
- 远端缓存嘟文只保留正文、CW、语言和附件; `mentions`、`tags`、互动计数等不会完整恢复,在 home timeline 中统一按 `visibility: public` 返回
|
- 远端缓存嘟文只保留正文、CW、语言和附件; `mentions`、`tags`、互动计数等不会完整恢复,在 home timeline 中统一按 `visibility: public` 返回
|
||||||
- 媒体上传只支持 `image/jpeg`、`image/png`、`image/gif`、`image/webp`,单文件 10MB,单条状态最多 4 个附件; 头像和封面同样只按图片路径处理
|
- 媒体上传只支持 `image/jpeg`、`image/png`、`image/gif`、`image/webp`,单文件 10MB,单条状态的附件数量不做服务端限制; 头像和封面同样只按图片路径处理
|
||||||
- 没有实现接口级限流、反滥用或审核流; `follow_requests` 相关接口仍是 stub
|
- 没有实现接口级限流、反滥用或审核流; `follow_requests` 相关接口仍是 stub
|
||||||
- 没有实现轮询(poll)、列表(list)、推送(push)、未来嘟文(scheduled)等
|
- 没有实现轮询(poll)、列表(list)、推送(push)、未来嘟文(scheduled)等
|
||||||
|
|
||||||
|
|||||||
+30
-8
@@ -157,7 +157,9 @@ export async function outbox(request: Request, env: Env, username: string): Prom
|
|||||||
if (!user) return json({ error: "not_found" }, 404);
|
if (!user) return json({ error: "not_found" }, 404);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const wantsPage = url.searchParams.has("page");
|
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 totalRow = await env.DB.prepare(
|
||||||
|
"SELECT COUNT(*) AS count FROM statuses WHERE user_id = ? AND visibility IN ('public', 'unlisted')"
|
||||||
|
).bind(user.id).first<{ count: number }>();
|
||||||
const totalItems = totalRow?.count ?? 0;
|
const totalItems = totalRow?.count ?? 0;
|
||||||
const base = `${actorUrl(env, user)}/outbox`;
|
const base = `${actorUrl(env, user)}/outbox`;
|
||||||
|
|
||||||
@@ -172,7 +174,9 @@ export async function outbox(request: Request, env: Env, username: string): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
const limit = clampLimit(url.searchParams.get("limit"), 20, 40);
|
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 rows = await env.DB.prepare(
|
||||||
|
"SELECT * FROM statuses WHERE user_id = ? AND visibility IN ('public', 'unlisted') ORDER BY created_at DESC LIMIT ?"
|
||||||
|
).bind(user.id, limit).all<Status>();
|
||||||
const items = rows.results.map((status) => createActivity(env, user, status));
|
const items = rows.results.map((status) => createActivity(env, user, status));
|
||||||
return activityJson({
|
return activityJson({
|
||||||
"@context": ACTIVITY_CONTEXT,
|
"@context": ACTIVITY_CONTEXT,
|
||||||
@@ -211,6 +215,7 @@ export async function followingCollection(env: Env, username: string): Promise<R
|
|||||||
export async function activityObject(env: Env, objectId: string): Promise<Response> {
|
export async function activityObject(env: Env, objectId: string): Promise<Response> {
|
||||||
const status = await getStatus(env, objectId);
|
const status = await getStatus(env, objectId);
|
||||||
if (status) {
|
if (status) {
|
||||||
|
if (status.visibility !== "public" && status.visibility !== "unlisted") return json({ error: "not_found" }, 404);
|
||||||
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(status.user_id).first<User>();
|
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);
|
if (!user) return json({ error: "not_found" }, 404);
|
||||||
return activityJson(noteObject(env, user, status));
|
return activityJson(noteObject(env, user, status));
|
||||||
@@ -513,8 +518,9 @@ async function localUserFromTarget(env: Env, actorId: string | null): Promise<Us
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createActivity(env: Env, user: User, status: Status, extra: { to?: string[]; cc?: string[] } = {}): Json {
|
export function createActivity(env: Env, user: User, status: Status, extra: { to?: string[]; cc?: string[] } = {}): Json {
|
||||||
const to = extra.to ?? [PUBLIC_COLLECTION];
|
const audience = statusAudience(env, user, status);
|
||||||
const cc = extra.cc ?? [`${actorUrl(env, user)}/followers`];
|
const to = extra.to ?? audience.to;
|
||||||
|
const cc = extra.cc ?? audience.cc;
|
||||||
return {
|
return {
|
||||||
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
||||||
id: status.activity_id,
|
id: status.activity_id,
|
||||||
@@ -527,13 +533,28 @@ export function createActivity(env: Env, user: User, status: Status, extra: { to
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteActivity(env: Env, user: User, status: Status): Json {
|
function statusAudience(env: Env, user: User, status: Status): { to: string[]; cc: string[] } {
|
||||||
|
if (status.visibility === "unlisted") {
|
||||||
|
return { to: [`${actorUrl(env, user)}/followers`], cc: [PUBLIC_COLLECTION] };
|
||||||
|
}
|
||||||
|
if (status.visibility === "private") {
|
||||||
|
return { to: [`${actorUrl(env, user)}/followers`], cc: [] };
|
||||||
|
}
|
||||||
|
if (status.visibility === "direct") {
|
||||||
|
return { to: [], cc: [] };
|
||||||
|
}
|
||||||
|
return { to: [PUBLIC_COLLECTION], cc: [`${actorUrl(env, user)}/followers`] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteActivity(env: Env, user: User, status: Status, extra: { to?: string[]; cc?: string[] } = {}): Json {
|
||||||
|
const audience = statusAudience(env, user, status);
|
||||||
return {
|
return {
|
||||||
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
||||||
id: activityUrl(env, id()),
|
id: activityUrl(env, id()),
|
||||||
type: "Delete",
|
type: "Delete",
|
||||||
actor: actorUrl(env, user),
|
actor: actorUrl(env, user),
|
||||||
to: [PUBLIC_COLLECTION],
|
to: extra.to ?? audience.to,
|
||||||
|
cc: extra.cc ?? audience.cc,
|
||||||
object: {
|
object: {
|
||||||
id: status.object_id,
|
id: status.object_id,
|
||||||
type: "Tombstone"
|
type: "Tombstone"
|
||||||
@@ -596,6 +617,7 @@ export function updatePersonActivity(env: Env, user: User, doc: Json): Json {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function noteObject(env: Env, user: User, status: Status, opts: { to?: string[]; cc?: string[]; attachments?: Json[]; tag?: Json[] } = {}): Json {
|
export function noteObject(env: Env, user: User, status: Status, opts: { to?: string[]; cc?: string[]; attachments?: Json[]; tag?: Json[] } = {}): Json {
|
||||||
|
const audience = statusAudience(env, user, status);
|
||||||
return {
|
return {
|
||||||
id: status.object_id,
|
id: status.object_id,
|
||||||
type: "Note",
|
type: "Note",
|
||||||
@@ -606,8 +628,8 @@ export function noteObject(env: Env, user: User, status: Status, opts: { to?: st
|
|||||||
content: status.content,
|
content: status.content,
|
||||||
published: status.created_at,
|
published: status.created_at,
|
||||||
url: status.url,
|
url: status.url,
|
||||||
to: opts.to ?? [PUBLIC_COLLECTION],
|
to: opts.to ?? audience.to,
|
||||||
cc: opts.cc ?? [`${actorUrl(env, user)}/followers`],
|
cc: opts.cc ?? audience.cc,
|
||||||
attachment: opts.attachments ?? [],
|
attachment: opts.attachments ?? [],
|
||||||
tag: opts.tag ?? []
|
tag: opts.tag ?? []
|
||||||
};
|
};
|
||||||
|
|||||||
+126
-38
@@ -86,10 +86,18 @@ import {
|
|||||||
|
|
||||||
const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90;
|
const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90;
|
||||||
const MAX_STATUS_CHARS = 5000;
|
const MAX_STATUS_CHARS = 5000;
|
||||||
const MAX_MEDIA_ATTACHMENTS = 4;
|
const REPORTED_MEDIA_ATTACHMENTS_LIMIT = 9999;
|
||||||
const MAX_MEDIA_BYTES = 10 * 1024 * 1024;
|
const MAX_MEDIA_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
const SUPPORTED_MIME = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
const SUPPORTED_MIME = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||||
|
const VALID_STATUS_VISIBILITIES = new Set(["public", "unlisted", "private", "direct"]);
|
||||||
|
|
||||||
|
type StatusVisibility = "public" | "unlisted" | "private" | "direct";
|
||||||
|
type StatusViewer = {
|
||||||
|
user: User | null;
|
||||||
|
actor: string | null;
|
||||||
|
followsByOwnerId: Map<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
function parseRedirectUris(value: string): string[] {
|
function parseRedirectUris(value: string): string[] {
|
||||||
return value.split(/\s+/).map((item) => item.trim()).filter(Boolean);
|
return value.split(/\s+/).map((item) => item.trim()).filter(Boolean);
|
||||||
@@ -120,7 +128,7 @@ export async function instance(env: Env): Promise<Response> {
|
|||||||
approval_required: false,
|
approval_required: false,
|
||||||
invites_enabled: false,
|
invites_enabled: false,
|
||||||
configuration: {
|
configuration: {
|
||||||
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: MAX_MEDIA_ATTACHMENTS, characters_reserved_per_url: 23 },
|
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: REPORTED_MEDIA_ATTACHMENTS_LIMIT, characters_reserved_per_url: 23 },
|
||||||
media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 },
|
media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 },
|
||||||
polls: { max_options: 4, max_characters_per_option: 50, min_expiration: 300, max_expiration: 2629746 }
|
polls: { max_options: 4, max_characters_per_option: 50, min_expiration: 300, max_expiration: 2629746 }
|
||||||
},
|
},
|
||||||
@@ -143,7 +151,7 @@ export async function instanceV2(env: Env): Promise<Response> {
|
|||||||
configuration: {
|
configuration: {
|
||||||
urls: { streaming: `wss://${hostFromBaseUrl(env)}` },
|
urls: { streaming: `wss://${hostFromBaseUrl(env)}` },
|
||||||
accounts: { max_featured_tags: 0 },
|
accounts: { max_featured_tags: 0 },
|
||||||
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: MAX_MEDIA_ATTACHMENTS, characters_reserved_per_url: 23 },
|
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: REPORTED_MEDIA_ATTACHMENTS_LIMIT, characters_reserved_per_url: 23 },
|
||||||
media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 }
|
media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 }
|
||||||
},
|
},
|
||||||
registrations: { enabled: false, approval_required: false, message: null },
|
registrations: { enabled: false, approval_required: false, message: null },
|
||||||
@@ -454,6 +462,9 @@ export async function accountStatuses(request: Request, env: Env, accountId: str
|
|||||||
const excludeReplies = url.searchParams.get("exclude_replies") === "true";
|
const excludeReplies = url.searchParams.get("exclude_replies") === "true";
|
||||||
const where: string[] = ["user_id = ?"];
|
const where: string[] = ["user_id = ?"];
|
||||||
const binds: unknown[] = [user.id];
|
const binds: unknown[] = [user.id];
|
||||||
|
const viewer = await loadStatusViewer(request, env);
|
||||||
|
const visibilityClause = await visibleStatusWhereForOwner(env, user.id, viewer);
|
||||||
|
if (visibilityClause) where.push(visibilityClause);
|
||||||
if (excludeReplies) where.push("in_reply_to_id IS NULL");
|
if (excludeReplies) where.push("in_reply_to_id IS NULL");
|
||||||
pagedAppend(where, binds, url);
|
pagedAppend(where, binds, url);
|
||||||
const sql = `SELECT * FROM statuses WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`;
|
const sql = `SELECT * FROM statuses WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`;
|
||||||
@@ -542,11 +553,11 @@ export async function createStatus(request: Request, env: Env): Promise<Response
|
|||||||
const summary = bodyString(body, "spoiler_text");
|
const summary = bodyString(body, "spoiler_text");
|
||||||
const sensitive = bodyString(body, "sensitive") === "true";
|
const sensitive = bodyString(body, "sensitive") === "true";
|
||||||
const visibility = bodyString(body, "visibility", "public");
|
const visibility = bodyString(body, "visibility", "public");
|
||||||
|
if (!isStatusVisibility(visibility)) return json({ error: "invalid_visibility" }, 422);
|
||||||
const inReplyTo = bodyString(body, "in_reply_to_id");
|
const inReplyTo = bodyString(body, "in_reply_to_id");
|
||||||
const language = bodyString(body, "language", "en");
|
const language = bodyString(body, "language", "en");
|
||||||
|
|
||||||
const mediaIds = bodyArray(body, "media_ids");
|
const mediaIds = bodyArray(body, "media_ids");
|
||||||
if (mediaIds.length > MAX_MEDIA_ATTACHMENTS) return json({ error: "too_many_attachments" }, 422);
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const statusId = id();
|
const statusId = id();
|
||||||
@@ -619,7 +630,7 @@ export async function createStatus(request: Request, env: Env): Promise<Response
|
|||||||
const status = await getStatus(env, statusId);
|
const status = await getStatus(env, statusId);
|
||||||
if (!status) throw new HttpError(500, "status_not_found");
|
if (!status) throw new HttpError(500, "status_not_found");
|
||||||
|
|
||||||
if (visibility === "public" || visibility === "unlisted") {
|
if (visibility === "public" || visibility === "unlisted" || visibility === "private") {
|
||||||
const inboxes = new Set<string>(await gatherFollowerInboxes(env, user.id));
|
const inboxes = new Set<string>(await gatherFollowerInboxes(env, user.id));
|
||||||
for (const mention of resolvedMentions) {
|
for (const mention of resolvedMentions) {
|
||||||
if (!mention.actorId.startsWith(baseUrl(env))) {
|
if (!mention.actorId.startsWith(baseUrl(env))) {
|
||||||
@@ -627,8 +638,17 @@ export async function createStatus(request: Request, env: Env): Promise<Response
|
|||||||
if (cache) inboxes.add(cache.shared_inbox ?? cache.inbox);
|
if (cache) inboxes.add(cache.shared_inbox ?? cache.inbox);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const to = visibility === "public" ? ["https://www.w3.org/ns/activitystreams#Public"] : [`${actorUrl(env, user)}/followers`];
|
const mentionActors = resolvedMentions.map((m) => m.actorId);
|
||||||
const cc = visibility === "public" ? [`${actorUrl(env, user)}/followers`, ...resolvedMentions.map((m) => m.actorId)] : ["https://www.w3.org/ns/activitystreams#Public", ...resolvedMentions.map((m) => m.actorId)];
|
const to = visibility === "public"
|
||||||
|
? ["https://www.w3.org/ns/activitystreams#Public"]
|
||||||
|
: visibility === "unlisted"
|
||||||
|
? [`${actorUrl(env, user)}/followers`]
|
||||||
|
: [`${actorUrl(env, user)}/followers`, ...mentionActors];
|
||||||
|
const cc = visibility === "public"
|
||||||
|
? [`${actorUrl(env, user)}/followers`, ...mentionActors]
|
||||||
|
: visibility === "unlisted"
|
||||||
|
? ["https://www.w3.org/ns/activitystreams#Public", ...mentionActors]
|
||||||
|
: [];
|
||||||
const activity = createActivity(env, user, status, { to, cc });
|
const activity = createActivity(env, user, status, { to, cc });
|
||||||
await deliverToInboxes(env, user, inboxes, activity);
|
await deliverToInboxes(env, user, inboxes, activity);
|
||||||
} else if (visibility === "direct") {
|
} else if (visibility === "direct") {
|
||||||
@@ -649,6 +669,8 @@ export async function createStatus(request: Request, env: Env): Promise<Response
|
|||||||
export async function getStatusEndpoint(request: Request, env: Env, statusId: string): Promise<Response> {
|
export async function getStatusEndpoint(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||||
const status = await getStatus(env, statusId);
|
const status = await getStatus(env, statusId);
|
||||||
if (!status) return json({ error: "Record not found" }, 404);
|
if (!status) return json({ error: "Record not found" }, 404);
|
||||||
|
const viewer = await loadStatusViewer(request, env);
|
||||||
|
if (!await canViewerViewStatus(env, status, viewer)) return json({ error: "Record not found" }, 404);
|
||||||
const user = await getUserById(env, status.user_id);
|
const user = await getUserById(env, status.user_id);
|
||||||
if (!user) return json({ error: "Record not found" }, 404);
|
if (!user) return json({ error: "Record not found" }, 404);
|
||||||
return json(await statusJson(env, status, user, request));
|
return json(await statusJson(env, status, user, request));
|
||||||
@@ -661,7 +683,10 @@ export async function deleteStatusEndpoint(request: Request, env: Env, statusId:
|
|||||||
|
|
||||||
const serialized = await statusJson(env, status, user, request);
|
const serialized = await statusJson(env, status, user, request);
|
||||||
|
|
||||||
const inboxes = new Set<string>(await gatherFollowerInboxes(env, user.id));
|
const inboxes = new Set<string>();
|
||||||
|
if (status.visibility !== "direct") {
|
||||||
|
for (const inbox of await gatherFollowerInboxes(env, user.id)) inboxes.add(inbox);
|
||||||
|
}
|
||||||
const mentions = await listMentionsForStatus(env, status.id);
|
const mentions = await listMentionsForStatus(env, status.id);
|
||||||
for (const mention of mentions) {
|
for (const mention of mentions) {
|
||||||
if (!mention.actor.startsWith(baseUrl(env))) {
|
if (!mention.actor.startsWith(baseUrl(env))) {
|
||||||
@@ -686,13 +711,22 @@ export async function deleteStatusEndpoint(request: Request, env: Env, statusId:
|
|||||||
await env.DB.prepare("DELETE FROM reblogs WHERE status_id = ?").bind(status.id).run();
|
await env.DB.prepare("DELETE FROM reblogs WHERE status_id = ?").bind(status.id).run();
|
||||||
await env.DB.prepare("DELETE FROM notifications WHERE status_id = ?").bind(status.id).run();
|
await env.DB.prepare("DELETE FROM notifications WHERE status_id = ?").bind(status.id).run();
|
||||||
|
|
||||||
await deliverToInboxes(env, user, inboxes, deleteActivity(env, user, status));
|
const mentionActors = mentions.map((mention) => mention.actor);
|
||||||
|
const deleteAudience = status.visibility === "direct"
|
||||||
|
? { to: mentionActors, cc: [] }
|
||||||
|
: status.visibility === "private"
|
||||||
|
? { to: [`${actorUrl(env, user)}/followers`, ...mentionActors], cc: [] }
|
||||||
|
: {};
|
||||||
|
await deliverToInboxes(env, user, inboxes, deleteActivity(env, user, status, deleteAudience));
|
||||||
return json(serialized);
|
return json(serialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function statusContext(env: Env, statusId: string, request: Request): Promise<Response> {
|
export async function statusContext(env: Env, statusId: string, request: Request): Promise<Response> {
|
||||||
const status = await getStatus(env, statusId);
|
const status = await getStatus(env, statusId);
|
||||||
if (!status) return json({ error: "Record not found" }, 404);
|
if (!status) return json({ error: "Record not found" }, 404);
|
||||||
|
const viewer = await loadStatusViewer(request, env);
|
||||||
|
if (!await canViewerViewStatus(env, status, viewer)) return json({ error: "Record not found" }, 404);
|
||||||
|
|
||||||
const ancestors: Status[] = [];
|
const ancestors: Status[] = [];
|
||||||
let cursor = status.in_reply_to_id;
|
let cursor = status.in_reply_to_id;
|
||||||
while (cursor) {
|
while (cursor) {
|
||||||
@@ -702,17 +736,19 @@ export async function statusContext(env: Env, statusId: string, request: Request
|
|||||||
cursor = parent.in_reply_to_id;
|
cursor = parent.in_reply_to_id;
|
||||||
}
|
}
|
||||||
const descRows = await env.DB.prepare("SELECT * FROM statuses WHERE in_reply_to_id = ? ORDER BY created_at ASC LIMIT 40").bind(statusId).all<Status>();
|
const descRows = await env.DB.prepare("SELECT * FROM statuses WHERE in_reply_to_id = ? ORDER BY created_at ASC LIMIT 40").bind(statusId).all<Status>();
|
||||||
const serialized = await serializeStatuses(env, [...ancestors, ...descRows.results], request);
|
const visibleAncestors = await filterStatusesForViewer(env, ancestors, viewer);
|
||||||
|
const visibleDescendants = await filterStatusesForViewer(env, descRows.results, viewer);
|
||||||
|
const serialized = await serializeStatuses(env, [...visibleAncestors, ...visibleDescendants], request);
|
||||||
const byId = new Map(serialized.map((item) => [String(item.id), item]));
|
const byId = new Map(serialized.map((item) => [String(item.id), item]));
|
||||||
return json({
|
return json({
|
||||||
ancestors: ancestors.map((item) => byId.get(item.id)).filter(Boolean),
|
ancestors: visibleAncestors.map((item) => byId.get(item.id)).filter(Boolean),
|
||||||
descendants: descRows.results.map((item) => byId.get(item.id)).filter(Boolean)
|
descendants: visibleDescendants.map((item) => byId.get(item.id)).filter(Boolean)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function favouriteStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
export async function favouriteStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||||
const user = await requireUser(request, env);
|
const user = await requireUser(request, env);
|
||||||
const status = await getStatus(env, statusId);
|
const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user));
|
||||||
if (!status) return json({ error: "Record not found" }, 404);
|
if (!status) return json({ error: "Record not found" }, 404);
|
||||||
|
|
||||||
const actor = actorUrl(env, user);
|
const actor = actorUrl(env, user);
|
||||||
@@ -739,7 +775,7 @@ export async function favouriteStatus(request: Request, env: Env, statusId: stri
|
|||||||
|
|
||||||
export async function unfavouriteStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
export async function unfavouriteStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||||
const user = await requireUser(request, env);
|
const user = await requireUser(request, env);
|
||||||
const status = await getStatus(env, statusId);
|
const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user));
|
||||||
if (!status) return json({ error: "Record not found" }, 404);
|
if (!status) return json({ error: "Record not found" }, 404);
|
||||||
|
|
||||||
const actor = actorUrl(env, user);
|
const actor = actorUrl(env, user);
|
||||||
@@ -758,8 +794,9 @@ export async function unfavouriteStatus(request: Request, env: Env, statusId: st
|
|||||||
|
|
||||||
export async function reblogStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
export async function reblogStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||||
const user = await requireUser(request, env);
|
const user = await requireUser(request, env);
|
||||||
const status = await getStatus(env, statusId);
|
const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user));
|
||||||
if (!status) return json({ error: "Record not found" }, 404);
|
if (!status) return json({ error: "Record not found" }, 404);
|
||||||
|
if (status.visibility !== "public" && status.visibility !== "unlisted") return json({ error: "status_not_rebloggable" }, 422);
|
||||||
|
|
||||||
const actor = actorUrl(env, user);
|
const actor = actorUrl(env, user);
|
||||||
const existing = await findReblog(env, status.id, actor);
|
const existing = await findReblog(env, status.id, actor);
|
||||||
@@ -785,7 +822,7 @@ export async function reblogStatus(request: Request, env: Env, statusId: string)
|
|||||||
|
|
||||||
export async function unreblogStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
export async function unreblogStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||||
const user = await requireUser(request, env);
|
const user = await requireUser(request, env);
|
||||||
const status = await getStatus(env, statusId);
|
const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user));
|
||||||
if (!status) return json({ error: "Record not found" }, 404);
|
if (!status) return json({ error: "Record not found" }, 404);
|
||||||
|
|
||||||
const actor = actorUrl(env, user);
|
const actor = actorUrl(env, user);
|
||||||
@@ -805,7 +842,7 @@ export async function unreblogStatus(request: Request, env: Env, statusId: strin
|
|||||||
|
|
||||||
export async function bookmarkStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
export async function bookmarkStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||||
const user = await requireUser(request, env);
|
const user = await requireUser(request, env);
|
||||||
const status = await getStatus(env, statusId);
|
const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user));
|
||||||
if (!status) return json({ error: "Record not found" }, 404);
|
if (!status) return json({ error: "Record not found" }, 404);
|
||||||
await addBookmark(env, user.id, status.id);
|
await addBookmark(env, user.id, status.id);
|
||||||
const owner = await getUserById(env, status.user_id);
|
const owner = await getUserById(env, status.user_id);
|
||||||
@@ -815,7 +852,7 @@ export async function bookmarkStatus(request: Request, env: Env, statusId: strin
|
|||||||
|
|
||||||
export async function unbookmarkStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
export async function unbookmarkStatus(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||||
const user = await requireUser(request, env);
|
const user = await requireUser(request, env);
|
||||||
const status = await getStatus(env, statusId);
|
const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user));
|
||||||
if (!status) return json({ error: "Record not found" }, 404);
|
if (!status) return json({ error: "Record not found" }, 404);
|
||||||
await removeBookmark(env, user.id, status.id);
|
await removeBookmark(env, user.id, status.id);
|
||||||
const owner = await getUserById(env, status.user_id);
|
const owner = await getUserById(env, status.user_id);
|
||||||
@@ -847,8 +884,9 @@ export async function bookmarksList(request: Request, env: Env): Promise<Respons
|
|||||||
`SELECT s.* FROM statuses s INNER JOIN bookmarks b ON b.status_id = s.id
|
`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 ?`
|
WHERE b.user_id = ? ORDER BY b.created_at DESC LIMIT ?`
|
||||||
).bind(user.id, limit).all<Status>();
|
).bind(user.id, limit).all<Status>();
|
||||||
const items = await serializeStatuses(env, rows.results, request);
|
const visibleRows = await filterStatusesForViewer(env, rows.results, statusViewerForUser(env, user));
|
||||||
return withPagination(json(items), request, rows.results.map((s) => s.id));
|
const items = await serializeStatuses(env, visibleRows, request);
|
||||||
|
return withPagination(json(items), request, visibleRows.map((s) => s.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function favouritesList(request: Request, env: Env): Promise<Response> {
|
export async function favouritesList(request: Request, env: Env): Promise<Response> {
|
||||||
@@ -860,8 +898,9 @@ export async function favouritesList(request: Request, env: Env): Promise<Respon
|
|||||||
`SELECT s.* FROM statuses s INNER JOIN favourites f ON f.status_id = s.id
|
`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 ?`
|
WHERE f.actor = ? ORDER BY f.created_at DESC LIMIT ?`
|
||||||
).bind(actor, limit).all<Status>();
|
).bind(actor, limit).all<Status>();
|
||||||
const items = await serializeStatuses(env, rows.results, request);
|
const visibleRows = await filterStatusesForViewer(env, rows.results, statusViewerForUser(env, user));
|
||||||
return withPagination(json(items), request, rows.results.map((s) => s.id));
|
const items = await serializeStatuses(env, visibleRows, request);
|
||||||
|
return withPagination(json(items), request, visibleRows.map((s) => s.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publicTimeline(request: Request, env: Env): Promise<Response> {
|
export async function publicTimeline(request: Request, env: Env): Promise<Response> {
|
||||||
@@ -1123,8 +1162,9 @@ export async function search(request: Request, env: Env): Promise<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!type || type === "statuses") {
|
if (!type || type === "statuses") {
|
||||||
const rows = await env.DB.prepare("SELECT * FROM statuses WHERE content LIKE ? ORDER BY created_at DESC LIMIT 20").bind(`%${escapeHtml(q)}%`).all<Status>();
|
const rows = await env.DB.prepare("SELECT * FROM statuses WHERE content LIKE ? ORDER BY created_at DESC LIMIT 100").bind(`%${escapeHtml(q)}%`).all<Status>();
|
||||||
statuses.push(...await serializeStatuses(env, rows.results, request));
|
const visibleRows = await filterStatusesForViewer(env, rows.results, await loadStatusViewer(request, env));
|
||||||
|
statuses.push(...await serializeStatuses(env, visibleRows.slice(0, 20), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!type || type === "hashtags") {
|
if (!type || type === "hashtags") {
|
||||||
@@ -1311,8 +1351,9 @@ async function buildStatusSerializationContext(
|
|||||||
for (const user of await loadUsersByIds(env, missingUserIds)) usersById.set(user.id, user);
|
for (const user of await loadUsersByIds(env, missingUserIds)) usersById.set(user.id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewer = await viewerActor(request, env);
|
const viewerUserForContext = await viewerUser(request, env);
|
||||||
const viewerId = await viewerUserId(request, env);
|
const viewer = viewerUserForContext ? actorUrl(env, viewerUserForContext) : null;
|
||||||
|
const viewerId = viewerUserForContext?.id ?? null;
|
||||||
const [mediaByStatusId, mentionsByStatusId, hashtagsByStatusId, favouriteSummary, reblogSummary, replyCountByStatusId, bookmarkedStatusIds, pinnedStatusIds] = await Promise.all([
|
const [mediaByStatusId, mentionsByStatusId, hashtagsByStatusId, favouriteSummary, reblogSummary, replyCountByStatusId, bookmarkedStatusIds, pinnedStatusIds] = await Promise.all([
|
||||||
loadMediaByStatusIds(env, statusIds),
|
loadMediaByStatusIds(env, statusIds),
|
||||||
loadMentionsByStatusIds(env, statusIds),
|
loadMentionsByStatusIds(env, statusIds),
|
||||||
@@ -1626,7 +1667,8 @@ async function serializeNotifications(env: Env, notifications: Notification[], r
|
|||||||
if (notifications.length === 0) return [];
|
if (notifications.length === 0) return [];
|
||||||
|
|
||||||
const statuses = await loadStatusesByIds(env, uniqueStrings(notifications.map((notification) => notification.status_id)));
|
const statuses = await loadStatusesByIds(env, uniqueStrings(notifications.map((notification) => notification.status_id)));
|
||||||
const serializedStatuses = await serializeStatuses(env, statuses, request);
|
const visibleStatuses = await filterStatusesForViewer(env, statuses, await loadStatusViewer(request, env));
|
||||||
|
const serializedStatuses = await serializeStatuses(env, visibleStatuses, request);
|
||||||
const serializedStatusById = new Map(serializedStatuses.map((item) => [String(item.id), item]));
|
const serializedStatusById = new Map(serializedStatuses.map((item) => [String(item.id), item]));
|
||||||
|
|
||||||
const remoteActorIds = uniqueStrings(
|
const remoteActorIds = uniqueStrings(
|
||||||
@@ -1708,22 +1750,68 @@ function withPagination(response: Response, request: Request, ids: string[]): Re
|
|||||||
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function viewerActor(request: Request, env: Env): Promise<string | null> {
|
function isStatusVisibility(value: string): value is StatusVisibility {
|
||||||
|
return VALID_STATUS_VISIBILITIES.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatusViewer(request: Request, env: Env): Promise<StatusViewer> {
|
||||||
|
return statusViewerForUser(env, await viewerUser(request, env));
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusViewerForUser(env: Env, user: User | null): StatusViewer {
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
actor: user ? actorUrl(env, user) : null,
|
||||||
|
followsByOwnerId: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function visibleStatusWhereForOwner(env: Env, ownerUserId: string, viewer: StatusViewer, column = "visibility"): Promise<string | null> {
|
||||||
|
if (viewer.user?.id === ownerUserId) return null;
|
||||||
|
if (await viewerFollowsOwner(env, viewer, ownerUserId)) return `${column} IN ('public', 'unlisted', 'private')`;
|
||||||
|
return `${column} IN ('public', 'unlisted')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function visibleStatusOrNull(env: Env, statusId: string, viewer: StatusViewer): Promise<Status | null> {
|
||||||
|
const status = await getStatus(env, statusId);
|
||||||
|
if (!status) return null;
|
||||||
|
return await canViewerViewStatus(env, status, viewer) ? status : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterStatusesForViewer(env: Env, statuses: Status[], viewer: StatusViewer): Promise<Status[]> {
|
||||||
|
const visible: Status[] = [];
|
||||||
|
for (const status of statuses) {
|
||||||
|
if (await canViewerViewStatus(env, status, viewer)) visible.push(status);
|
||||||
|
}
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canViewerViewStatus(env: Env, status: Status, viewer: StatusViewer): Promise<boolean> {
|
||||||
|
if (status.visibility === "public" || status.visibility === "unlisted") return true;
|
||||||
|
if (viewer.user?.id === status.user_id) return true;
|
||||||
|
if (status.visibility === "private") return viewerFollowsOwner(env, viewer, status.user_id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewerFollowsOwner(env: Env, viewer: StatusViewer, ownerUserId: string): Promise<boolean> {
|
||||||
|
if (!viewer.actor) return false;
|
||||||
|
const cached = viewer.followsByOwnerId.get(ownerUserId);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const row = await env.DB.prepare(
|
||||||
|
"SELECT 1 AS hit FROM follows WHERE local_user_id = ? AND follower_actor = ? AND accepted = 1 LIMIT 1"
|
||||||
|
).bind(ownerUserId, viewer.actor).first<{ hit: number }>();
|
||||||
|
const follows = Boolean(row?.hit);
|
||||||
|
viewer.followsByOwnerId.set(ownerUserId, follows);
|
||||||
|
return follows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewerUser(request: Request, env: Env): Promise<User | null> {
|
||||||
const auth = request.headers.get("authorization") ?? "";
|
const auth = request.headers.get("authorization") ?? "";
|
||||||
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
|
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
const session = await env.KV.get<Session>(`token:${token}`, "json");
|
const session = await env.KV.get<Session>(`token:${token}`, "json");
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
const user = await getUserById(env, session.userId);
|
return getUserById(env, session.userId);
|
||||||
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>> {
|
async function loadBookmarkedStatusIds(env: Env, userId: string, statusIds: string[]): Promise<Set<string>> {
|
||||||
|
|||||||
Reference in New Issue
Block a user