From 9e5e330eab5a46693adddf2cb6910a37a4206571 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 02:26:26 +0900 Subject: [PATCH 01/32] Unify local media storage Add the shared Medium model and migrate note, article, and account avatar media to relation tables backed by FlyDrive keys. Keep post media for remote ActivityPub attachments separate. Replace the old uploadMedia API with createMedium, streaming upload session mutations, and article draft media attachment. Update legacy web and web-next avatar/article upload paths to use the new GraphQL/model APIs. Assisted-by: Codex:gpt-5.5 --- drizzle/0098_unified_medium.sql | 214 +++++++++ drizzle/meta/_journal.json | 9 +- federation/actor.ts | 1 + federation/objects.ts | 33 +- graphql/account.test.ts | 11 +- graphql/account.ts | 83 ++-- graphql/deno.json | 3 +- graphql/main.ts | 3 + graphql/medium-upload.ts | 116 +++++ graphql/post.more.test.ts | 137 +++++- graphql/post.ts | 427 +++++++++++++++--- graphql/schema.graphql | 122 ++++- models/account.more.test.ts | 4 +- models/account.ts | 12 +- models/actor.ts | 13 +- models/article.ts | 45 +- models/markup.ts | 20 +- models/medium.test.ts | 74 ++- models/medium.ts | 216 ++++++++- models/note.test.ts | 19 +- models/note.ts | 61 +-- models/post.sync.test.ts | 4 +- models/post.ts | 59 ++- models/relations.ts | 53 ++- models/schema.ts | 113 ++++- .../article-composer/ArticleComposer.tsx | 2 +- .../ArticleComposerContext.tsx | 10 +- web-next/src/lib/uploadImage.ts | 157 +++++-- .../routes/(root)/[handle]/settings/index.tsx | 30 +- web/main.ts | 9 +- web/routes/@[username]/feed.xml.ts | 2 +- web/routes/@[username]/invite/[id]/index.tsx | 7 +- web/routes/@[username]/og.ts | 2 +- web/routes/@[username]/settings/index.tsx | 48 +- web/routes/_app.tsx | 9 +- web/routes/admin/index.tsx | 10 +- web/routes/api/media.ts | 26 +- 37 files changed, 1836 insertions(+), 328 deletions(-) create mode 100644 drizzle/0098_unified_medium.sql create mode 100644 graphql/medium-upload.ts diff --git a/drizzle/0098_unified_medium.sql b/drizzle/0098_unified_medium.sql new file mode 100644 index 000000000..e1679d772 --- /dev/null +++ b/drizzle/0098_unified_medium.sql @@ -0,0 +1,214 @@ +DO $$ BEGIN + CREATE TYPE "public"."medium_type" AS ENUM( + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp' + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "medium" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "type" "medium_type" NOT NULL, + "content_hash" text, + "width" integer, + "height" integer, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "medium_key_unique" UNIQUE("key"), + CONSTRAINT "medium_content_hash_unique" UNIQUE("content_hash"), + CONSTRAINT "medium_width_height_check" CHECK ( + CASE + WHEN "width" IS NULL THEN "height" IS NULL + ELSE "height" IS NOT NULL AND "width" > 0 AND "height" > 0 + END + ) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "note_source_medium" ( + "note_source_id" uuid NOT NULL, + "index" smallint NOT NULL, + "medium_id" uuid NOT NULL, + "alt" text NOT NULL, + CONSTRAINT "note_source_medium_note_source_id_index_pk" + PRIMARY KEY("note_source_id","index"), + CONSTRAINT "note_source_medium_note_source_id_medium_id_unique" + UNIQUE("note_source_id","medium_id"), + CONSTRAINT "note_source_medium_index_check" CHECK ("index" >= 0) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "article_draft_medium" ( + "article_draft_id" uuid NOT NULL, + "key" text NOT NULL, + "medium_id" uuid NOT NULL, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "article_draft_medium_article_draft_id_key_pk" + PRIMARY KEY("article_draft_id","key") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "article_source_medium" ( + "article_source_id" uuid NOT NULL, + "key" text NOT NULL, + "medium_id" uuid NOT NULL, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "article_source_medium_article_source_id_key_pk" + PRIMARY KEY("article_source_id","key") +); +--> statement-breakpoint +ALTER TABLE "account" ADD COLUMN "avatar_medium_id" uuid; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "account" ADD CONSTRAINT "account_avatar_medium_id_medium_id_fk" + FOREIGN KEY ("avatar_medium_id") REFERENCES "public"."medium"("id") + ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "note_source_medium" ADD CONSTRAINT + "note_source_medium_note_source_id_note_source_id_fk" + FOREIGN KEY ("note_source_id") REFERENCES "public"."note_source"("id") + ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "note_source_medium" ADD CONSTRAINT + "note_source_medium_medium_id_medium_id_fk" + FOREIGN KEY ("medium_id") REFERENCES "public"."medium"("id") + ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "article_draft_medium" ADD CONSTRAINT + "article_draft_medium_article_draft_id_article_draft_id_fk" + FOREIGN KEY ("article_draft_id") REFERENCES "public"."article_draft"("id") + ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "article_draft_medium" ADD CONSTRAINT + "article_draft_medium_medium_id_medium_id_fk" + FOREIGN KEY ("medium_id") REFERENCES "public"."medium"("id") + ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "article_source_medium" ADD CONSTRAINT + "article_source_medium_article_source_id_article_source_id_fk" + FOREIGN KEY ("article_source_id") REFERENCES "public"."article_source"("id") + ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "article_source_medium" ADD CONSTRAINT + "article_source_medium_medium_id_medium_id_fk" + FOREIGN KEY ("medium_id") REFERENCES "public"."medium"("id") + ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +INSERT INTO "medium" ("key", "type", "content_hash", "width", "height") +SELECT DISTINCT ON ("key") + "key", + 'image/webp'::"medium_type", + NULL::text, + "width"::integer, + "height"::integer +FROM "note_medium" +ON CONFLICT ("key") DO NOTHING; +--> statement-breakpoint +INSERT INTO "medium" ("key", "type", "content_hash", "width", "height", "created") +SELECT DISTINCT ON ("key") + "key", + 'image/webp'::"medium_type", + CASE + WHEN "key" ~ '^media/[0-9a-f]{64}\.webp$' THEN substring("key" from 7 for 64) + ELSE NULL::text + END, + "width"::integer, + "height"::integer, + "created" +FROM "article_medium" +ON CONFLICT ("key") DO UPDATE SET + "content_hash" = COALESCE("medium"."content_hash", EXCLUDED."content_hash"), + "width" = COALESCE("medium"."width", EXCLUDED."width"::integer), + "height" = COALESCE("medium"."height", EXCLUDED."height"::integer); +--> statement-breakpoint +INSERT INTO "medium" ("key", "type", "content_hash", "width", "height") +SELECT DISTINCT + "avatar_key", + CASE + WHEN lower("avatar_key") LIKE '%.gif' THEN 'image/gif'::"medium_type" + WHEN lower("avatar_key") LIKE '%.png' THEN 'image/png'::"medium_type" + WHEN lower("avatar_key") LIKE '%.webp' THEN 'image/webp'::"medium_type" + ELSE 'image/jpeg'::"medium_type" + END, + NULL::text, + NULL::integer, + NULL::integer +FROM "account" +WHERE "avatar_key" IS NOT NULL +ON CONFLICT ("key") DO NOTHING; +--> statement-breakpoint +INSERT INTO "note_source_medium" ( + "note_source_id", + "index", + "medium_id", + "alt" +) +SELECT nm."note_source_id", nm."index", m."id", nm."alt" +FROM "note_medium" nm +JOIN "medium" m ON m."key" = nm."key" +ON CONFLICT ("note_source_id", "index") DO NOTHING; +--> statement-breakpoint +INSERT INTO "article_draft_medium" ("article_draft_id", "key", "medium_id", "created") +SELECT am."article_draft_id", am."key", m."id", am."created" +FROM "article_medium" am +JOIN "medium" m ON m."key" = am."key" +WHERE am."article_draft_id" IS NOT NULL +ON CONFLICT ("article_draft_id", "key") DO NOTHING; +--> statement-breakpoint +INSERT INTO "article_source_medium" ("article_source_id", "key", "medium_id", "created") +SELECT am."article_source_id", am."key", m."id", am."created" +FROM "article_medium" am +JOIN "medium" m ON m."key" = am."key" +WHERE am."article_source_id" IS NOT NULL +ON CONFLICT ("article_source_id", "key") DO NOTHING; +--> statement-breakpoint +UPDATE "account" a +SET "avatar_medium_id" = m."id" +FROM "medium" m +WHERE a."avatar_key" = m."key"; +--> statement-breakpoint +UPDATE "article_draft" ad +SET "content" = replace(ad."content", am."url", 'hp-medium:' || am."key") +FROM "article_medium" am +WHERE am."article_draft_id" = ad."id"; +--> statement-breakpoint +UPDATE "article_content" ac +SET "content" = replace(ac."content", am."url", 'hp-medium:' || am."key") +FROM "article_source" src +JOIN "article_medium" am ON am."article_source_id" = src."id" +WHERE ac."source_id" = src."id"; +--> statement-breakpoint +DROP TABLE "note_medium"; +--> statement-breakpoint +DROP TABLE "article_medium"; +--> statement-breakpoint +ALTER TABLE "account" DROP CONSTRAINT IF EXISTS "account_avatar_key_unique"; +--> statement-breakpoint +ALTER TABLE "account" DROP COLUMN "avatar_key"; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ea70e21a6..9b8725129 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -687,6 +687,13 @@ "when": 1777642390574, "tag": "0097_clear_oversized_summaries", "breakpoints": true + }, + { + "idx": 98, + "version": "7", + "when": 1778025600000, + "tag": "0098_unified_medium", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/federation/actor.ts b/federation/actor.ts index b5e9cf9c6..31760d173 100644 --- a/federation/actor.ts +++ b/federation/actor.ts @@ -59,6 +59,7 @@ builder const account = await ctx.data.db.query.accountTable.findFirst({ where: { id: identifier }, with: { + avatarMedium: true, emails: true, links: { orderBy: { index: "asc" } }, }, diff --git a/federation/objects.ts b/federation/objects.ts index 390839a33..28aa0481b 100644 --- a/federation/objects.ts +++ b/federation/objects.ts @@ -14,9 +14,10 @@ import type { Actor, ArticleContent, ArticleSource, + Medium, Mention, - NoteMedium, NoteSource, + NoteSourceMedium, Post, PostVisibility, Reaction, @@ -32,6 +33,20 @@ export async function getArticle( contents: ArticleContent[]; }, ): Promise { + const sourceMedia = await ctx.data.db.query.articleSourceMediumTable.findMany( + { + where: { articleSourceId: articleSource.id }, + with: { medium: true }, + }, + ); + const mediumUrls = Object.fromEntries( + await Promise.all( + sourceMedia.map(async (relation) => [ + relation.key, + await ctx.data.disk.getUrl(relation.medium.key), + ]), + ), + ); const url = new URL( `/@${articleSource.account.username}/${articleSource.publishedYear}/${ encodeURIComponent(articleSource.slug) @@ -43,6 +58,7 @@ export async function getArticle( ...(await renderMarkup(ctx, content.content, { docId: articleSource.id, kv: ctx.data.kv, + mediumUrls, })), ...content, })), @@ -150,7 +166,10 @@ export function getPostRecipients( export async function getNote( ctx: Context, - note: NoteSource & { account: Account; media: NoteMedium[] }, + note: NoteSource & { + account: Account; + media: (NoteSourceMedium & { medium: Medium })[]; + }, relations: { replyTargetId?: URL; quotedPost?: Post; @@ -165,11 +184,11 @@ export async function getNote( for (const medium of note.media) { attachments.push( new vocab.Document({ - mediaType: "image/webp", - url: new URL(await disk.getUrl(medium.key)), + mediaType: medium.medium.type, + url: new URL(await disk.getUrl(medium.medium.key)), name: medium.alt, - width: medium.width, - height: medium.height, + width: medium.medium.width ?? undefined, + height: medium.medium.height ?? undefined, }), ); } @@ -248,7 +267,7 @@ builder const note = await ctx.data.db.query.noteSourceTable.findFirst({ with: { account: true, - media: true, + media: { with: { medium: true } }, post: { with: { replyTarget: true, quotedPost: true } }, }, where: { id: values.id }, diff --git a/graphql/account.test.ts b/graphql/account.test.ts index f86526398..fd562dfa6 100644 --- a/graphql/account.test.ts +++ b/graphql/account.test.ts @@ -4,6 +4,8 @@ import { encodeGlobalID } from "@pothos/plugin-relay"; import * as vocab from "@fedify/vocab"; import { execute, parse } from "graphql"; import { updateAccountData } from "@hackerspub/models/account"; +import { mediumTable } from "@hackerspub/models/schema"; +import { generateUuidV7 } from "@hackerspub/models/uuid"; import type { UserContext } from "./builder.ts"; import { schema } from "./mod.ts"; import { putProfileOgImage } from "./og.ts"; @@ -172,9 +174,16 @@ test("Account.ogImageUrl renders and reuses a cached profile image", async () => name: "Profile OG GraphQL", email: "profileoggraphql@example.com", }); + const [avatarMedium] = await tx.insert(mediumTable).values({ + id: generateUuidV7(), + key: "avatar-og-test", + type: "image/webp", + width: null, + height: null, + }).returning(); const updated = await updateAccountData(tx, { id: account.account.id, - avatarKey: "avatar-og-test", + avatarMediumId: avatarMedium.id, bio: "Mixed script bio: Hello, 안녕하세요, こんにちは, 你好, 😀", ogImageKey: "og/v2/stale-profile.png", }); diff --git a/graphql/account.ts b/graphql/account.ts index ff0f5882c..8c081d9e4 100644 --- a/graphql/account.ts +++ b/graphql/account.ts @@ -6,14 +6,11 @@ import { import { assertNever } from "@std/assert/unstable-never"; import DataLoader from "dataloader"; import { and, desc, eq, gt, inArray, lt, sql } from "drizzle-orm"; -import { - getAvatarUrl, - transformAvatar, - updateAccount, -} from "@hackerspub/models/account"; +import { getAvatarUrl, updateAccount } from "@hackerspub/models/account"; import { syncActorFromAccount } from "@hackerspub/models/actor"; import type { Locale } from "@hackerspub/models/i18n"; import { renderMarkup } from "@hackerspub/models/markup"; +import { createMediumFromUrl } from "@hackerspub/models/medium"; import { accountTable, actorTable, @@ -69,9 +66,10 @@ export const Account = builder.drizzleNode("accountTable", { type: "URL", select: { columns: { - avatarKey: true, + avatarMediumId: true, }, with: { + avatarMedium: true, emails: true, }, }, @@ -85,7 +83,7 @@ export const Account = builder.drizzleNode("accountTable", { complexity: profileOgImageComplexity, select: { columns: { - avatarKey: true, + avatarMediumId: true, bio: true, id: true, name: true, @@ -93,6 +91,7 @@ export const Account = builder.drizzleNode("accountTable", { username: true, }, with: { + avatarMedium: true, actor: { columns: { handleHost: true, @@ -108,7 +107,7 @@ export const Account = builder.drizzleNode("accountTable", { }); const handle = `@${account.username}@${account.actor.handleHost}`; const key = await putProfileOgImage(ctx.disk, account.ogImageKey, { - avatarKey: account.avatarKey ?? avatarUrl, + avatarKey: account.avatarMedium?.key ?? avatarUrl, avatarUrl, bio: bio.text, displayName: account.name, @@ -564,6 +563,7 @@ builder.queryField("invitationTree", (t) => const accounts = await ctx.db.query.accountTable.findMany({ with: { actor: true, + avatarMedium: true, emails: true, }, }); @@ -590,13 +590,6 @@ const AccountLinkInput = builder.inputType("AccountLinkInput", { }), }); -const SUPPORTED_AVATAR_TYPES = [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", -]; - builder.relayMutationField( "updateAccount", { @@ -605,7 +598,11 @@ builder.relayMutationField( username: t.string(), name: t.string(), bio: t.string(), - avatarUrl: t.field({ type: "URL" }), + avatarUrl: t.field({ + type: "URL", + deprecationReason: "Use avatarMediumId instead.", + }), + avatarMediumId: t.field({ type: "UUID" }), locales: t.field({ type: ["Locale"] }), hideFromInvitationTree: t.boolean(), hideForeignLanguages: t.boolean(), @@ -635,31 +632,29 @@ builder.relayMutationField( "Username cannot be changed after it has been changed.", ); } - let avatarKey: string | undefined; - const promises: Promise[] = []; + let avatarMediumId: Uuid | undefined; if (args.input.avatarUrl != null) { - const response = await fetch(args.input.avatarUrl); - if (response.status !== 200) { - throw new Error("Failed to fetch the avatar URL."); + if (args.input.avatarMediumId != null) { + throw new Error( + "avatarUrl and avatarMediumId are mutually exclusive.", + ); } - const contentType = response.headers.get("Content-Type"); - if ( - contentType == null || !SUPPORTED_AVATAR_TYPES.includes(contentType) - ) { + const medium = await createMediumFromUrl( + ctx.db, + ctx.disk, + args.input.avatarUrl, + { userAgentUrl: new URL(ctx.fedCtx.canonicalOrigin) }, + ); + if (medium == null) { throw new Error("Avatar URL must point to an image."); } - const disk = ctx.disk; - if (account.avatarKey != null) { - promises.push(disk.delete(account.avatarKey)); - } - const { buffer, format } = await transformAvatar( - await response.arrayBuffer(), - ); - const key = `avatars/${crypto.randomUUID()}.${ - format === "jpeg" ? "jpg" : format - }`; - promises.push(disk.put(key, buffer)); - avatarKey = key; + avatarMediumId = medium.id; + } else if (args.input.avatarMediumId != null) { + const medium = await ctx.db.query.mediumTable.findFirst({ + where: { id: args.input.avatarMediumId }, + }); + if (medium == null) throw new Error("Medium not found."); + avatarMediumId = medium.id; } const result = await updateAccount( ctx.fedCtx, @@ -668,7 +663,7 @@ builder.relayMutationField( username: args.input.username ?? undefined, name: args.input.name ?? undefined, bio: args.input.bio ?? undefined, - avatarKey, + avatarMediumId, locales: args.input.locales?.map((loc) => loc.baseName as Locale) ?? undefined, hideFromInvitationTree: args.input.hideFromInvitationTree ?? @@ -684,12 +679,20 @@ builder.relayMutationField( links: args.input.links ?? undefined, }, ); - await Promise.all(promises); if (result == null) throw new Error("Account not found"); const emails = await ctx.db.query.accountEmailTable.findMany({ where: { accountId: result.id }, }); - await syncActorFromAccount(ctx.fedCtx, { ...result, emails }); + const avatarMedium = result.avatarMediumId == null + ? null + : await ctx.db.query.mediumTable.findFirst({ + where: { id: result.avatarMediumId }, + }) ?? null; + await syncActorFromAccount(ctx.fedCtx, { + ...result, + emails, + avatarMedium, + }); return result; }, }, diff --git a/graphql/deno.json b/graphql/deno.json index 4785b293e..1749917eb 100644 --- a/graphql/deno.json +++ b/graphql/deno.json @@ -2,7 +2,8 @@ "name": "@hackerspub/graphql", "version": "0.2.0", "exports": { - ".": "./mod.ts" + ".": "./mod.ts", + "./medium-upload": "./medium-upload.ts" }, "fmt": { "exclude": [ diff --git a/graphql/main.ts b/graphql/main.ts index 532634100..a4775ce98 100644 --- a/graphql/main.ts +++ b/graphql/main.ts @@ -8,6 +8,7 @@ import { transport as email } from "./email.ts"; import { federation } from "./federation.ts"; import { kv } from "./kv.ts"; import { createYogaServer } from "./mod.ts"; +import { handleMediumUploadProxy } from "./medium-upload.ts"; import assetlinks from "./static/.well-known/assetlinks.json" with { type: "json", }; @@ -20,6 +21,8 @@ const yogaServer = createYogaServer(); Deno.serve({ port: 8080 }, async (req, info) => { const url = new URL(req.url); const disk = drive.use(); + const uploadResponse = await handleMediumUploadProxy(req, kv, disk); + if (uploadResponse != null) return uploadResponse; if (url.pathname === "/.well-known/assetlinks.json") { return new Response(JSON.stringify(assetlinks), { headers: { "content-type": "application/json" }, diff --git a/graphql/medium-upload.ts b/graphql/medium-upload.ts new file mode 100644 index 000000000..7372f3ffc --- /dev/null +++ b/graphql/medium-upload.ts @@ -0,0 +1,116 @@ +import type { Disk } from "flydrive"; +import type Keyv from "keyv"; +import { + MAX_STREAMING_MEDIUM_IMAGE_SIZE, + SUPPORTED_MEDIUM_IMAGE_TYPES, +} from "@hackerspub/models/medium"; +import type { Uuid } from "@hackerspub/models/uuid"; +import { validateUuid } from "@hackerspub/models/uuid"; + +const KV_NAMESPACE = "medium-upload"; +export const MEDIUM_UPLOAD_TTL_MS = 30 * 60 * 1000; + +export interface MediumUploadSession { + id: Uuid; + accountId: Uuid; + key: string; + token: string; + contentType: string; + contentLength: number; + created: string; +} + +export function getMediumUploadSessionKey(id: Uuid): string { + return `${KV_NAMESPACE}/${id}`; +} + +export async function createMediumUploadSession( + kv: Keyv, + accountId: Uuid, + contentType: string, + contentLength: number, +): Promise { + const id = crypto.randomUUID() as Uuid; + const tokenBytes = new Uint8Array(32); + crypto.getRandomValues(tokenBytes); + const token = [...tokenBytes] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + const session: MediumUploadSession = { + id, + accountId, + key: `medium-uploads/${accountId}/${id}`, + token, + contentType, + contentLength, + created: new Date().toISOString(), + }; + await kv.set(getMediumUploadSessionKey(id), session, MEDIUM_UPLOAD_TTL_MS); + return session; +} + +export async function getMediumUploadSession( + kv: Keyv, + id: Uuid, +): Promise { + return await kv.get(getMediumUploadSessionKey(id)); +} + +export async function deleteMediumUploadSession( + kv: Keyv, + id: Uuid, +): Promise { + await kv.delete(getMediumUploadSessionKey(id)); +} + +export async function handleMediumUploadProxy( + request: Request, + kv: Keyv, + disk: Disk, +): Promise { + const url = new URL(request.url); + const match = url.pathname.match(/^\/medium-uploads\/([^/]+)$/); + if (match == null) return undefined; + if (request.method !== "PUT") { + return new Response("Method Not Allowed", { + status: 405, + }); + } + const uploadId = match[1]; + if (!validateUuid(uploadId)) { + return new Response("Not Found", { + status: 404, + }); + } + const session = await getMediumUploadSession(kv, uploadId); + if (session == null || url.searchParams.get("token") !== session.token) { + return new Response("Forbidden", { status: 403 }); + } + const contentType = request.headers.get("Content-Type")?.split(";")[0] + .trim(); + if ( + contentType == null || + contentType !== session.contentType || + !SUPPORTED_MEDIUM_IMAGE_TYPES.includes( + contentType as typeof SUPPORTED_MEDIUM_IMAGE_TYPES[number], + ) + ) { + return new Response("Unsupported Media Type", { status: 415 }); + } + const contentLength = request.headers.get("Content-Length"); + if ( + contentLength != null && + Number(contentLength) > MAX_STREAMING_MEDIUM_IMAGE_SIZE + ) { + return new Response("Payload Too Large", { status: 413 }); + } + const bytes = new Uint8Array(await request.arrayBuffer()); + if ( + bytes.byteLength !== session.contentLength || + bytes.byteLength > MAX_STREAMING_MEDIUM_IMAGE_SIZE + ) { + return new Response("Payload Too Large", { status: 413 }); + } + await disk.put(session.key, bytes, { contentType: session.contentType }); + return new Response(null, { status: 204 }); +} diff --git a/graphql/post.more.test.ts b/graphql/post.more.test.ts index 513bc6f4a..076920888 100644 --- a/graphql/post.more.test.ts +++ b/graphql/post.more.test.ts @@ -9,6 +9,7 @@ import { articleContentTable, articleDraftTable, articleSourceTable, + mediumTable, type NewPost, postTable, } from "@hackerspub/models/schema"; @@ -134,6 +135,49 @@ const articleContentOgImageBulkByLanguageQuery = parse(` } `); +const createMediumMutation = parse(` + mutation CreateMedium($input: CreateMediumInput!) { + createMedium(input: $input) { + __typename + ... on CreateMediumPayload { + medium { + uuid + url + type + width + height + } + } + ... on InvalidInputError { + inputPath + } + ... on NotAuthenticatedError { + notAuthenticated + } + } + } +`); + +const attachArticleDraftMediumMutation = parse(` + mutation AttachArticleDraftMedium($input: AttachArticleDraftMediumInput!) { + attachArticleDraftMedium(input: $input) { + __typename + ... on AttachArticleDraftMediumPayload { + key + medium { + uuid + } + } + ... on InvalidInputError { + inputPath + } + ... on NotAuthenticatedError { + notAuthenticated + } + } + } +`); + const articleContentOgImageCollisionQuery = parse(` query ArticleContentOgImageCollision( $handle: String! @@ -324,6 +368,81 @@ test("saveArticleDraft, articleDraft, and deleteArticleDraft round-trip a draft" }); }); +test("createMedium and attachArticleDraftMedium create draft media relations", async () => { + await withRollback(async (tx) => { + const account = await insertAccountWithActor(tx, { + username: "mediumgraphql", + name: "Medium GraphQL", + email: "mediumgraphql@example.com", + }); + const disk = createOgTestDisk(); + + const createResult = await execute({ + schema, + document: createMediumMutation, + variableValues: { input: { url: smallPngDataUrl } }, + contextValue: makeUserContext(tx, account.account, { disk: disk.disk }), + onError: "NO_PROPAGATE", + }); + + assert.equal(createResult.errors, undefined); + const medium = (toPlainJson(createResult.data) as { + createMedium: { + __typename: string; + medium: { + uuid: string; + url: string; + type: string; + width: number; + height: number; + }; + }; + }).createMedium.medium; + assert.equal(medium.type, "image/webp"); + assert.equal(medium.width, 1); + assert.equal(medium.height, 1); + assert.match(medium.url, /^http:\/\/localhost\/media\/media\/.+\.webp$/); + assert.equal(disk.putKeys.length, 1); + + const draftId = generateUuidV7(); + const attachResult = await execute({ + schema, + document: attachArticleDraftMediumMutation, + variableValues: { + input: { + draftId, + mediumId: medium.uuid, + }, + }, + contextValue: makeUserContext(tx, account.account), + onError: "NO_PROPAGATE", + }); + + assert.equal(attachResult.errors, undefined); + const attached = (toPlainJson(attachResult.data) as { + attachArticleDraftMedium: { + __typename: string; + key: string; + medium: { uuid: string }; + }; + }).attachArticleDraftMedium; + assert.equal(attached.__typename, "AttachArticleDraftMediumPayload"); + assert.equal(attached.key, medium.uuid); + assert.equal(attached.medium.uuid, medium.uuid); + + const relation = await tx.query.articleDraftMediumTable.findFirst({ + where: { articleDraftId: draftId }, + }); + assert.equal(relation?.mediumId, medium.uuid); + assert.equal(relation?.key, medium.uuid); + const draft = await tx.query.articleDraftTable.findFirst({ + where: { id: draftId }, + }); + assert.equal(draft?.title, ""); + assert.equal(draft?.content, ""); + }); +}); + test("publishArticleDraft publishes an article and removes the draft", async () => { await withRollback(async (tx) => { const account = await insertAccountWithActor(tx, { @@ -401,8 +520,15 @@ test("ArticleContent.ogImageUrl keys do not collide across articles", async () = name: "Article OG Collision", email: "articleogcollision@example.com", }); + const [avatarMedium] = await tx.insert(mediumTable).values({ + id: generateUuidV7(), + key: "article-avatar-og-test", + type: "image/webp", + width: null, + height: null, + }).returning(); await tx.update(accountTable) - .set({ avatarKey: "article-avatar-og-test" }) + .set({ avatarMediumId: avatarMedium.id }) .where(eq(accountTable.id, author.account.id)); const published = new Date("2026-04-15T00:00:00.000Z"); @@ -522,8 +648,15 @@ test("ArticleContent.ogImageUrl renders per-language article images", async () = name: "Article OG GraphQL", email: "articleoggraphql@example.com", }); + const [avatarMedium] = await tx.insert(mediumTable).values({ + id: generateUuidV7(), + key: "article-avatar-og-test", + type: "image/webp", + width: null, + height: null, + }).returning(); await tx.update(accountTable) - .set({ avatarKey: "article-avatar-og-test" }) + .set({ avatarMediumId: avatarMedium.id }) .where(eq(accountTable.id, author.account.id)); const sourceId = generateUuidV7(); const postId = generateUuidV7(); diff --git a/graphql/post.ts b/graphql/post.ts index 9b579913c..3fb3dc723 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -6,6 +6,8 @@ import { getAvatarUrl } from "@hackerspub/models/account"; import { createArticle, deleteArticleDraft, + getArticleDraftMediumUrls, + getArticleSourceMediumUrls, getOriginalArticleContent, LanguageChangeWithTranslationsError, startArticleContentTranslation, @@ -21,6 +23,12 @@ import { isReactionEmoji, renderCustomEmojis } from "@hackerspub/models/emoji"; import { addExternalLinkTargets, stripHtml } from "@hackerspub/models/html"; import { negotiateLocale, normalizeLocale } from "@hackerspub/models/i18n"; import { renderMarkup } from "@hackerspub/models/markup"; +import { + createMediumFromBytes, + createMediumFromUrl, + MAX_STREAMING_MEDIUM_IMAGE_SIZE, + SUPPORTED_MEDIUM_IMAGE_TYPES, +} from "@hackerspub/models/medium"; import { createNote } from "@hackerspub/models/note"; import { arePostsPinnedBy, @@ -38,17 +46,19 @@ import { import { react, undoReaction } from "@hackerspub/models/reaction"; import { articleContentTable, + articleDraftMediumTable, articleDraftTable, - articleMediumTable, + articleSourceMediumTable, } from "@hackerspub/models/schema"; import type * as schema from "@hackerspub/models/schema"; import { withTransaction } from "@hackerspub/models/tx"; -import { - MAX_IMAGE_SIZE, - SUPPORTED_IMAGE_TYPES, - uploadImage, -} from "@hackerspub/models/upload"; import { generateUuidV7, type Uuid } from "@hackerspub/models/uuid"; +import { + createMediumUploadSession, + deleteMediumUploadSession, + getMediumUploadSession, + MEDIUM_UPLOAD_TTL_MS, +} from "./medium-upload.ts"; import { Account } from "./account.ts"; import { Actor } from "./actor.ts"; import { builder, Node, type UserContext } from "./builder.ts"; @@ -388,10 +398,17 @@ export const ArticleDraft = builder.drizzleNode("articleDraftTable", { select: { columns: { content: true, + sourceId: true, }, }, async resolve(draft, _, ctx) { - const rendered = await renderMarkup(ctx.fedCtx, draft.content); + const rendered = await renderMarkup(ctx.fedCtx, draft.content, { + mediumUrls: await getArticleDraftMediumUrls( + ctx.db, + ctx.disk, + draft.id, + ), + }); return addExternalLinkTargets( rendered.html, new URL(ctx.fedCtx.canonicalOrigin), @@ -447,6 +464,11 @@ export const ArticleContent = builder.drizzleNode("articleContentTable", { async resolve(content, _, ctx) { const html = await renderMarkup(ctx.fedCtx, content.content, { kv: ctx.kv, + mediumUrls: await getArticleSourceMediumUrls( + ctx.db, + ctx.disk, + content.sourceId, + ), }); return addExternalLinkTargets( renderCustomEmojis(html.html, content.source.post.emojis), @@ -468,11 +490,16 @@ export const ArticleContent = builder.drizzleNode("articleContentTable", { type: "JSON", description: "Table of contents for the article content.", select: { - columns: { content: true }, + columns: { content: true, sourceId: true }, }, async resolve(content, _, ctx) { const rendered = await renderMarkup(ctx.fedCtx, content.content, { kv: ctx.kv, + mediumUrls: await getArticleSourceMediumUrls( + ctx.db, + ctx.disk, + content.sourceId, + ), }); return rendered.toc; }, @@ -510,6 +537,7 @@ export const ArticleContent = builder.drizzleNode("articleContentTable", { handleHost: true, }, }, + avatarMedium: true, emails: true, }, }, @@ -521,11 +549,16 @@ export const ArticleContent = builder.drizzleNode("articleContentTable", { const account = content.source.account; const rendered = await renderMarkup(ctx.fedCtx, content.content, { kv: ctx.kv, + mediumUrls: await getArticleSourceMediumUrls( + ctx.db, + ctx.disk, + content.sourceId, + ), }); const avatarUrl = await getAvatarUrl(ctx.disk, account); const key = await putArticleOgImage(ctx.disk, content.ogImageKey, { authorName: account.name, - avatarKey: account.avatarKey ?? avatarUrl, + avatarKey: account.avatarMedium?.key ?? avatarUrl, avatarUrl, excerpt: content.summary ?? rendered.text, handle: `@${account.username}@${account.actor.handleHost}`, @@ -643,6 +676,40 @@ builder.drizzleNode("postMediumTable", { }), }); +export const Medium = builder.drizzleNode("mediumTable", { + name: "Medium", + id: { + column: (medium) => medium.id, + }, + fields: (t) => ({ + uuid: t.expose("id", { type: "UUID" }), + url: t.field({ + type: "URL", + description: "Public URL for the stored medium.", + resolve: async (medium, _, ctx) => + new URL(await ctx.disk.getUrl(medium.key)), + }), + type: t.expose("type", { + type: "MediaType", + description: "The medium's media type. Local uploads are stored as WebP.", + }), + contentHash: t.exposeString("contentHash", { + nullable: true, + description: "SHA-256 hash of the normalized stored content, if known.", + }), + width: t.exposeInt("width", { nullable: true }), + height: t.exposeInt("height", { nullable: true }), + created: t.expose("created", { type: "DateTime" }), + }), +}); + +const MediumUploadHeader = builder.simpleObject("MediumUploadHeader", { + fields: (t) => ({ + name: t.string(), + value: t.string(), + }), +}); + const PostLink = builder.drizzleNode("postLinkTable", { variant: "PostLink", id: { @@ -686,6 +753,20 @@ const PostLinkImage = builder.drizzleObject("postLinkTable", { builder.drizzleObjectField(PostLinkImage, "post", (t) => t.variant(PostLink)); +const CreateNoteMediumInput = builder.inputType("CreateNoteMediumInput", { + fields: (t) => ({ + mediumId: t.field({ + type: "UUID", + required: true, + description: "UUID of a Medium to attach to the note.", + }), + alt: t.string({ + required: true, + description: "Alternative text for this note's use of the medium.", + }), + }), +}); + builder.relayMutationField( "createNote", { @@ -693,7 +774,12 @@ builder.relayMutationField( visibility: t.field({ type: PostVisibility, required: true }), content: t.field({ type: "Markdown", required: true }), language: t.field({ type: "Locale", required: true }), - // TODO: media + media: t.field({ + type: [CreateNoteMediumInput], + required: false, + defaultValue: [], + description: "Media to attach to the note, in display order.", + }), replyTargetId: t.globalID({ for: [Note, Article, Question], required: false, @@ -716,8 +802,24 @@ builder.relayMutationField( if (session == null) { throw new NotAuthenticatedError(); } - const { visibility, content, language, replyTargetId, quotedPostId } = - args.input; + const { + visibility, + content, + language, + media, + replyTargetId, + quotedPostId, + } = args.input; + const attachedMedia = media ?? []; + for (let i = 0; i < attachedMedia.length; i++) { + if (attachedMedia[i].alt.trim() === "") { + throw new InvalidInputError(`media.${i}.alt`); + } + const medium = await ctx.db.query.mediumTable.findFirst({ + where: { id: attachedMedia[i].mediumId }, + }); + if (medium == null) throw new InvalidInputError(`media.${i}.mediumId`); + } let replyTarget: schema.Post & { actor: schema.Actor } | undefined; if (replyTargetId != null) { replyTarget = await ctx.db.query.postTable.findFirst({ @@ -759,7 +861,10 @@ builder.relayMutationField( ), content, language: language.baseName, - media: [], // TODO + media: attachedMedia.map((medium) => ({ + mediumId: medium.mediumId, + alt: medium.alt.trim(), + })), }, { replyTarget, quotedPost }, ); @@ -787,6 +892,11 @@ builder.relayMutationField( { inputFields: (t) => ({ id: t.globalID({ for: [ArticleDraft], required: false }), + uuid: t.field({ + type: "UUID", + required: false, + description: "Draft UUID to use when creating a new draft.", + }), title: t.string({ required: true }), content: t.field({ type: "Markdown", required: true }), tags: t.stringList({ required: true }), @@ -805,9 +915,12 @@ builder.relayMutationField( throw new NotAuthenticatedError(); } const { id, title, content, tags } = args.input; + if (id != null && args.input.uuid != null) { + throw new InvalidInputError("uuid"); + } const draft = await updateArticleDraft(ctx.db, { - id: id?.id ?? generateUuidV7(), + id: id?.id ?? args.input.uuid ?? generateUuidV7(), accountId: session.accountId, title, content, @@ -989,10 +1102,21 @@ builder.relayMutationField( throw new Error("Failed to publish article"); } - // Migrate media tracking from draft to published article - await ctx.db.update(articleMediumTable) - .set({ articleSourceId: article.articleSource.id }) - .where(eq(articleMediumTable.articleDraftId, draft.id)); + // Migrate media tracking from draft to published article. + const draftMedia = await ctx.db.query.articleDraftMediumTable.findMany({ + where: { articleDraftId: draft.id }, + }); + const publishedMedia = draftMedia + .filter((medium) => draft.content.includes(`hp-medium:${medium.key}`)) + .map((medium) => ({ + articleSourceId: article.articleSource.id, + key: medium.key, + mediumId: medium.mediumId, + })); + if (publishedMedia.length > 0) { + await ctx.db.insert(articleSourceMediumTable).values(publishedMedia) + .onConflictDoNothing(); + } // Delete draft after successful publish await deleteArticleDraft(ctx.db, session.accountId, draft.id); @@ -1930,18 +2054,16 @@ builder.relayMutationField( }, ); -interface UploadMediaResult { - url: string; - width: number; - height: number; -} - builder.relayMutationField( - "uploadMedia", + "createMedium", { inputFields: (t) => ({ - mediaUrl: t.field({ type: "URL", required: true }), - draftId: t.field({ type: "UUID", required: false }), + url: t.field({ + type: "URL", + required: true, + description: + "Image URL to import. Data URLs, HTTP, and HTTPS are supported.", + }), }), }, { @@ -1956,62 +2078,235 @@ builder.relayMutationField( if (session == null) { throw new NotAuthenticatedError(); } - const response = await fetch(args.input.mediaUrl); - if (response.status !== 200) { - throw new InvalidInputError("mediaUrl"); + try { + const medium = await createMediumFromUrl( + ctx.db, + ctx.disk, + args.input.url, + { userAgentUrl: new URL(ctx.fedCtx.canonicalOrigin) }, + ); + if (medium == null) throw new InvalidInputError("url"); + return medium; + } catch { + throw new InvalidInputError("url"); + } + }, + }, + { + outputFields: (t) => ({ + medium: t.field({ + type: Medium, + resolve(result) { + return result; + }, + }), + }), + }, +); + +interface MediumUploadStart { + uploadId: Uuid; + uploadUrl: URL; + method: string; + headers: { name: string; value: string }[]; + expiresAt: Date; +} + +builder.relayMutationField( + "startMediumUpload", + { + inputFields: (t) => ({ + contentType: t.field({ + type: "MediaType", + required: true, + description: "Original image content type.", + }), + contentLength: t.int({ + required: true, + description: "Exact number of bytes the client will upload.", + }), + }), + }, + { + errors: { types: [NotAuthenticatedError, InvalidInputError] }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null) throw new NotAuthenticatedError(); + if ( + !SUPPORTED_MEDIUM_IMAGE_TYPES.includes( + args.input.contentType as typeof SUPPORTED_MEDIUM_IMAGE_TYPES[number], + ) + ) { + throw new InvalidInputError("contentType"); } - const contentType = response.headers.get("Content-Type")?.split(";")[0] - ?.trim(); if ( - contentType == null || !SUPPORTED_IMAGE_TYPES.includes(contentType) + args.input.contentLength < 1 || + args.input.contentLength > MAX_STREAMING_MEDIUM_IMAGE_SIZE ) { - throw new InvalidInputError("mediaUrl"); + throw new InvalidInputError("contentLength"); + } + const upload = await createMediumUploadSession( + ctx.kv, + session.accountId, + args.input.contentType, + args.input.contentLength, + ); + let uploadUrl: URL; + try { + uploadUrl = new URL( + await ctx.disk.getSignedUploadUrl(upload.key, { + contentType: upload.contentType, + contentSize: upload.contentLength, + expiresIn: "30mins", + }), + ); + } catch { + uploadUrl = new URL(`/medium-uploads/${upload.id}`, ctx.request.url); + uploadUrl.searchParams.set("token", upload.token); } - const blob = await response.blob(); - if (blob.size > MAX_IMAGE_SIZE) { - throw new InvalidInputError("mediaUrl"); + return { + uploadId: upload.id, + uploadUrl, + method: "PUT", + headers: [{ name: "Content-Type", value: upload.contentType }], + expiresAt: new Date(Date.now() + MEDIUM_UPLOAD_TTL_MS), + } satisfies MediumUploadStart; + }, + }, + { + outputFields: (t) => ({ + uploadId: t.field({ + type: "UUID", + resolve: (result) => result.uploadId, + }), + uploadUrl: t.field({ + type: "URL", + resolve: (result) => result.uploadUrl, + }), + method: t.string({ resolve: (result) => result.method }), + headers: t.field({ + type: [MediumUploadHeader], + resolve: (result) => result.headers, + }), + expiresAt: t.field({ + type: "DateTime", + resolve: (result) => result.expiresAt, + }), + }), + }, +); + +builder.relayMutationField( + "finishMediumUpload", + { + inputFields: (t) => ({ + uploadId: t.field({ type: "UUID", required: true }), + }), + }, + { + errors: { types: [NotAuthenticatedError, InvalidInputError] }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null) throw new NotAuthenticatedError(); + const upload = await getMediumUploadSession(ctx.kv, args.input.uploadId); + if (upload == null || upload.accountId !== session.accountId) { + throw new InvalidInputError("uploadId"); } try { - const result = await uploadImage(ctx.disk, blob); - if (result == null) { - throw new InvalidInputError("mediaUrl"); - } - await ctx.db.insert(articleMediumTable).values({ - key: result.key, - accountId: session.accountId, - articleDraftId: args.input.draftId ?? undefined, - url: result.url, - width: result.width, - height: result.height, - }).onConflictDoUpdate({ - target: articleMediumTable.key, - set: { - articleDraftId: args.input.draftId ?? undefined, - }, + const bytes = await ctx.disk.getBytes(upload.key); + const medium = await createMediumFromBytes(ctx.db, ctx.disk, bytes, { + maxSize: MAX_STREAMING_MEDIUM_IMAGE_SIZE, + contentType: upload.contentType, }); - return result; + if (medium == null) throw new InvalidInputError("uploadId"); + await ctx.disk.delete(upload.key); + await deleteMediumUploadSession(ctx.kv, upload.id); + return medium; } catch { - throw new InvalidInputError("mediaUrl"); + throw new InvalidInputError("uploadId"); } }, }, { outputFields: (t) => ({ - url: t.field({ - type: "URL", - resolve(result: UploadMediaResult) { - return new URL(result.url); + medium: t.field({ + type: Medium, + resolve(result) { + return result; }, }), - width: t.int({ - resolve(result: UploadMediaResult) { - return result.width; - }, + }), + }, +); + +interface AttachedArticleDraftMedium { + key: string; + medium: schema.Medium; +} + +builder.relayMutationField( + "attachArticleDraftMedium", + { + inputFields: (t) => ({ + draftId: t.field({ type: "UUID", required: true }), + mediumId: t.field({ type: "UUID", required: true }), + key: t.string({ + required: false, + description: + "Key used in article markdown as hp-medium:KEY. Defaults to mediumId.", }), - height: t.int({ - resolve(result: UploadMediaResult) { - return result.height; + }), + }, + { + errors: { types: [NotAuthenticatedError, InvalidInputError] }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null) throw new NotAuthenticatedError(); + let draft = await ctx.db.query.articleDraftTable.findFirst({ + where: { + id: args.input.draftId, + accountId: session.accountId, }, + }); + if (draft == null) { + const inserted = await ctx.db.insert(articleDraftTable).values({ + id: args.input.draftId, + accountId: session.accountId, + title: "", + content: "", + tags: [], + }).onConflictDoNothing().returning(); + draft = inserted[0]; + } + if (draft == null) throw new InvalidInputError("draftId"); + const medium = await ctx.db.query.mediumTable.findFirst({ + where: { id: args.input.mediumId }, + }); + if (medium == null) throw new InvalidInputError("mediumId"); + const key = args.input.key?.trim() || medium.id; + if (!key.match(/^[A-Za-z0-9._:/-]+$/)) { + throw new InvalidInputError("key"); + } + await ctx.db.insert(articleDraftMediumTable).values({ + articleDraftId: draft.id, + key, + mediumId: medium.id, + }).onConflictDoUpdate({ + target: [ + articleDraftMediumTable.articleDraftId, + articleDraftMediumTable.key, + ], + set: { mediumId: medium.id }, + }); + return { key, medium } satisfies AttachedArticleDraftMedium; + }, + }, + { + outputFields: (t) => ({ + key: t.string({ resolve: (result) => result.key }), + medium: t.field({ + type: Medium, + resolve: (result) => result.medium, }), }), }, diff --git a/graphql/schema.graphql b/graphql/schema.graphql index a3f52045d..0ef5dea5b 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -390,6 +390,23 @@ type ArticleDraft implements Node { uuid: UUID! } +input AttachArticleDraftMediumInput { + clientMutationId: ID + draftId: UUID! + + """Key used in article markdown as hp-medium:KEY. Defaults to mediumId.""" + key: String + mediumId: UUID! +} + +type AttachArticleDraftMediumPayload { + clientMutationId: ID + key: String! + medium: Medium! +} + +union AttachArticleDraftMediumResult = AttachArticleDraftMediumPayload | InvalidInputError | NotAuthenticatedError + input BlockActorInput { actorId: ID! clientMutationId: ID @@ -417,15 +434,40 @@ union BookmarkPostResult = BookmarkPostPayload | InvalidInputError | NotAuthenti union CreateInvitationLinkResult = InvalidInputError | InvitationLinkPayload | NotAuthenticatedError +input CreateMediumInput { + clientMutationId: ID + + """Image URL to import. Data URLs, HTTP, and HTTPS are supported.""" + url: URL! +} + +type CreateMediumPayload { + clientMutationId: ID + medium: Medium! +} + +union CreateMediumResult = CreateMediumPayload | InvalidInputError | NotAuthenticatedError + input CreateNoteInput { clientMutationId: ID content: Markdown! language: Locale! + + """Media to attach to the note, in display order.""" + media: [CreateNoteMediumInput!] = [] quotedPostId: ID replyTargetId: ID visibility: PostVisibility! } +input CreateNoteMediumInput { + """Alternative text for this note's use of the medium.""" + alt: String! + + """UUID of a Medium to attach to the note.""" + mediumId: UUID! +} + type CreateNotePayload { clientMutationId: ID note: Note! @@ -509,6 +551,18 @@ type EmptySearchQueryError { message: String! } +input FinishMediumUploadInput { + clientMutationId: ID + uploadId: UUID! +} + +type FinishMediumUploadPayload { + clientMutationId: ID + medium: Medium! +} + +union FinishMediumUploadResult = FinishMediumUploadPayload | InvalidInputError | NotAuthenticatedError + input FollowActorInput { actorId: ID! clientMutationId: ID @@ -674,6 +728,27 @@ scalar Markdown scalar MediaType +type Medium implements Node { + """SHA-256 hash of the normalized stored content, if known.""" + contentHash: String + created: DateTime! + height: Int + id: ID! + + """The medium's media type. Local uploads are stored as WebP.""" + type: MediaType! + + """Public URL for the stored medium.""" + url: URL! + uuid: UUID! + width: Int +} + +type MediumUploadHeader { + name: String! + value: String! +} + type MentionNotification implements Node & Notification { account: Account! actors(after: String, before: String, first: Int, last: Int): NotificationActorsConnection! @@ -685,6 +760,7 @@ type MentionNotification implements Node & Notification { type Mutation { addReactionToPost(input: AddReactionToPostInput!): AddReactionToPostResult! + attachArticleDraftMedium(input: AttachArticleDraftMediumInput!): AttachArticleDraftMediumResult! blockActor(input: BlockActorInput!): BlockActorResult! bookmarkPost(input: BookmarkPostInput!): BookmarkPostResult! completeLoginChallenge( @@ -707,10 +783,12 @@ type Mutation { token: UUID! ): SignupResult! createInvitationLink(expires: String, invitationsLeft: Int!, message: Markdown): CreateInvitationLinkResult! + createMedium(input: CreateMediumInput!): CreateMediumResult! createNote(input: CreateNoteInput!): CreateNoteResult! deleteArticleDraft(input: DeleteArticleDraftInput!): DeleteArticleDraftResult! deleteInvitationLink(id: UUID!): DeleteInvitationLinkResult! deletePost(input: DeletePostInput!): DeletePostResult! + finishMediumUpload(input: FinishMediumUploadInput!): FinishMediumUploadResult! followActor(input: FollowActorInput!): FollowActorResult! getPasskeyAuthenticationOptions( """Temporary session ID for passkey authentication.""" @@ -791,6 +869,7 @@ type Mutation { ): Session saveArticleDraft(input: SaveArticleDraftInput!): SaveArticleDraftResult! sharePost(input: SharePostInput!): SharePostResult! + startMediumUpload(input: StartMediumUploadInput!): StartMediumUploadResult! unblockActor(input: UnblockActorInput!): UnblockActorResult! unbookmarkPost(input: UnbookmarkPostInput!): UnbookmarkPostResult! unfollowActor(input: UnfollowActorInput!): UnfollowActorResult! @@ -800,7 +879,6 @@ type Mutation { unsharePost(input: UnsharePostInput!): UnsharePostResult! updateAccount(input: UpdateAccountInput!): UpdateAccountPayload! updateArticle(input: UpdateArticleInput!): UpdateArticleResult! - uploadMedia(input: UploadMediaInput!): UploadMediaResult! verifyPasskeyRegistration(accountId: ID!, name: String!, platform: String = "web", registrationResponse: JSON!): PasskeyRegistrationResult! voteOnPoll(input: VoteOnPollInput!): VoteOnPollResult! } @@ -1461,6 +1539,9 @@ input SaveArticleDraftInput { id: ID tags: [String!]! title: String! + + """Draft UUID to use when creating a new draft.""" + uuid: UUID } type SaveArticleDraftPayload { @@ -1563,6 +1644,27 @@ type StandardEmoji { raw: String! } +input StartMediumUploadInput { + clientMutationId: ID + + """Exact number of bytes the client will upload.""" + contentLength: Int! + + """Original image content type.""" + contentType: MediaType! +} + +type StartMediumUploadPayload { + clientMutationId: ID + expiresAt: DateTime! + headers: [MediumUploadHeader!]! + method: String! + uploadId: UUID! + uploadUrl: URL! +} + +union StartMediumUploadResult = InvalidInputError | NotAuthenticatedError | StartMediumUploadPayload + scalar URITemplate """ @@ -1670,7 +1772,8 @@ type UnsharePostPayload { union UnsharePostResult = InvalidInputError | NotAuthenticatedError | UnsharePostPayload input UpdateAccountInput { - avatarUrl: URL + avatarMediumId: UUID + avatarUrl: URL @deprecated(reason: "Use avatarMediumId instead.") bio: String clientMutationId: ID defaultNoteVisibility: PostVisibility @@ -1707,21 +1810,6 @@ type UpdateArticlePayload { union UpdateArticleResult = InvalidInputError | NotAuthenticatedError | UpdateArticlePayload -input UploadMediaInput { - clientMutationId: ID - draftId: UUID - mediaUrl: URL! -} - -type UploadMediaPayload { - clientMutationId: ID - height: Int! - url: URL! - width: Int! -} - -union UploadMediaResult = InvalidInputError | NotAuthenticatedError | UploadMediaPayload - input VoteOnPollInput { clientMutationId: ID optionIndices: [Int!]! diff --git a/models/account.more.test.ts b/models/account.more.test.ts index cf57257c2..dd956928f 100644 --- a/models/account.more.test.ts +++ b/models/account.more.test.ts @@ -21,13 +21,13 @@ test("getAvatarUrl() prefers stored avatars and falls back to gravatar defaults" }; const stored = await getAvatarUrl(disk as never, { - avatarKey: "avatars/existing.webp", + avatarMedium: { key: "avatars/existing.webp" }, emails: [], } as never); assert.equal(stored, "http://localhost/media/avatars/existing.webp"); const fallback = await getAvatarUrl(disk as never, { - avatarKey: null, + avatarMedium: null, emails: [], } as never); assert.equal(fallback, "https://gravatar.com/avatar/?d=mp&s=128"); diff --git a/models/account.ts b/models/account.ts index be0d0040b..0e1eb3db9 100644 --- a/models/account.ts +++ b/models/account.ts @@ -23,6 +23,7 @@ import { accountLinkTable, accountTable, type Actor, + type Medium, type NewAccount, } from "./schema.ts"; import { compactUrl } from "./url.ts"; @@ -32,9 +33,14 @@ const logger = getLogger(["hackerspub", "models", "account"]); export async function getAvatarUrl( disk: Disk, - account: Account & { emails: AccountEmail[] }, + account: Account & { + emails: AccountEmail[]; + avatarMedium?: Medium | null; + }, ): Promise { - if (account.avatarKey != null) return await disk.getUrl(account.avatarKey); + if (account.avatarMedium != null) { + return await disk.getUrl(account.avatarMedium.key); + } const emails = account.emails .filter((e) => e.verified != null); emails.sort((a, b) => a.public ? 1 : b.public ? -1 : 0); @@ -66,6 +72,7 @@ export async function getAccountByUsername( const account = await db.query.accountTable.findFirst({ with: { actor: { with: { successor: true } }, + avatarMedium: true, emails: true, links: { orderBy: { index: "asc" } }, }, @@ -75,6 +82,7 @@ export async function getAccountByUsername( return await db.query.accountTable.findFirst({ with: { actor: { with: { successor: true } }, + avatarMedium: true, emails: true, links: { orderBy: { index: "asc" } }, }, diff --git a/models/actor.ts b/models/actor.ts index 050f5fe6e..881828d42 100644 --- a/models/actor.ts +++ b/models/actor.ts @@ -43,6 +43,7 @@ import { followingTable, type Instance, instanceTable, + type Medium, type NewActor, type NewInstance, pinTable, @@ -78,10 +79,18 @@ async function mapWithConcurrencyLimit( export async function syncActorFromAccount( fedCtx: Context, - account: Account & { emails: AccountEmail[]; links: AccountLink[] }, + account: Account & { + avatarMedium?: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }, ): Promise< Actor & { - account: Account & { emails: AccountEmail[]; links: AccountLink[] }; + account: Account & { + avatarMedium?: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }; instance: Instance; } > { diff --git a/models/article.ts b/models/article.ts index 31992f85e..407f32ed7 100644 --- a/models/article.ts +++ b/models/article.ts @@ -11,6 +11,7 @@ import { getLogger } from "@logtape/logtape"; import { minBy } from "@std/collections/min-by"; import type { LanguageModel } from "ai"; import { and, eq, isNotNull, isNull, lt, or, sql } from "drizzle-orm"; +import type { Disk } from "flydrive"; import postgres from "postgres"; import type { ContextData, Models } from "./context.ts"; import type { Database } from "./db.ts"; @@ -41,6 +42,44 @@ import { generateUuidV7, type Uuid } from "./uuid.ts"; const logger = getLogger(["hackerspub", "models", "article"]); +export async function getArticleDraftMediumUrls( + db: Database, + disk: Disk, + draftId: Uuid, +): Promise> { + const media = await db.query.articleDraftMediumTable.findMany({ + where: { articleDraftId: draftId }, + with: { medium: true }, + }); + return Object.fromEntries( + await Promise.all( + media.map(async (relation) => [ + relation.key, + await disk.getUrl(relation.medium.key), + ]), + ), + ); +} + +export async function getArticleSourceMediumUrls( + db: Database, + disk: Disk, + sourceId: Uuid, +): Promise> { + const media = await db.query.articleSourceMediumTable.findMany({ + where: { articleSourceId: sourceId }, + with: { medium: true }, + }); + return Object.fromEntries( + await Promise.all( + media.map(async (relation) => [ + relation.key, + await disk.getUrl(relation.medium.key), + ]), + ), + ); +} + /** * Counts the number of user-perceived characters (extended grapheme * clusters) in a string. @@ -159,7 +198,7 @@ export async function getArticleSource( return await db.query.articleSourceTable.findFirst({ with: { account: { - with: { emails: true, links: true }, + with: { avatarMedium: true, emails: true, links: true }, }, contents: { orderBy: { published: "asc" }, @@ -254,7 +293,7 @@ export async function createArticle( if (articleSource == null) return undefined; const account = await db.query.accountTable.findFirst({ where: { id: source.accountId }, - with: { emails: true, links: true }, + with: { avatarMedium: true, emails: true, links: true }, }); if (account == undefined) return undefined; const post = await syncPostFromArticleSource(fedCtx, { @@ -455,7 +494,7 @@ export async function updateArticle( const { source: articleSource, originalContentChanged } = updateResult; const account = await db.query.accountTable.findFirst({ where: { id: articleSource.accountId }, - with: { emails: true, links: true }, + with: { avatarMedium: true, emails: true, links: true }, }); if (account == null) return undefined; const post = await syncPostFromArticleSource(fedCtx, { diff --git a/models/markup.ts b/models/markup.ts index 0b8493952..7b7cf90f7 100644 --- a/models/markup.ts +++ b/models/markup.ts @@ -170,6 +170,7 @@ export interface RenderMarkupOptions { kv?: Keyv | null; docId?: string | null; refresh?: boolean; + mediumUrls?: Record; } export async function renderMarkup( @@ -177,12 +178,15 @@ export async function renderMarkup( markup: string, options: RenderMarkupOptions = {}, ): Promise { + const resolvedMarkup = resolveMediumUrls(markup, options.mediumUrls ?? {}); let cacheKey: string | undefined; if (options.kv != null) { const digest = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode( - `${JSON.stringify(options.docId ?? null)}\n${markup}`, + `${JSON.stringify(options.docId ?? null)}\n${ + JSON.stringify(options.mediumUrls ?? {}) + }\n${resolvedMarkup}`, ), ); cacheKey = `${KV_NAMESPACE}/${KV_CACHE_VERSION}/markup/${ @@ -202,7 +206,7 @@ export async function renderMarkup( }, }); const tmpEnv: { mentions: string[] } = { mentions: [] }; - await tmpMd.renderAsync(markup, tmpEnv); + await tmpMd.renderAsync(resolvedMarkup, tmpEnv); const mentions = new Set(tmpEnv.mentions); logger.trace("Mentions: {mentions}", { mentions }); const mentionedActors = fedCtx == null @@ -218,7 +222,7 @@ export async function renderMarkup( hashtags: [], macros: {}, }; - const rawHtml = (await md.renderAsync(markup, env)) + const rawHtml = (await md.renderAsync(resolvedMarkup, env)) .replaceAll('', "") .replaceAll( ', +): string { + return markup.replaceAll( + /hp-medium:([A-Za-z0-9._:/-]+)/g, + (matched, key: string) => mediumUrls[key] ?? matched, + ); +} + function slugifyTitle(title: string, docId?: string | null): string { return (docId == null ? "" : docId + "--") + slugify(title, { strip: ASCII_DIACRITICS_REGEXP }); diff --git a/models/medium.test.ts b/models/medium.test.ts index b892f9cdc..ed2897e03 100644 --- a/models/medium.test.ts +++ b/models/medium.test.ts @@ -1,7 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; import * as vocab from "@fedify/vocab"; -import { persistPostMedium } from "./medium.ts"; +import sharp from "sharp"; +import { + createMediumFromBytes, + createMediumFromUrl, + persistPostMedium, + UnsafeMediumUrlError, +} from "./medium.ts"; import { createFedCtx, insertAccountWithActor, @@ -10,6 +16,72 @@ import { withRollback, } from "../test/postgres.ts"; +test("createMediumFromBytes() stores webp media once by content hash", async () => { + await withRollback(async (tx) => { + const putKeys: string[] = []; + const disk = { + put(key: string) { + putKeys.push(key); + return Promise.resolve(); + }, + getUrl(key: string) { + return Promise.resolve(`http://localhost/media/${key}`); + }, + }; + const input = await sharp({ + create: { + width: 2, + height: 2, + channels: 4, + background: { r: 255, g: 0, b: 0, alpha: 1 }, + }, + }).png().toBuffer(); + + const first = await createMediumFromBytes(tx, disk as never, input, { + contentType: "image/png", + }); + const second = await createMediumFromBytes(tx, disk as never, input, { + contentType: "image/png", + }); + + assert.ok(first != null); + assert.ok(second != null); + assert.equal(second.id, first.id); + assert.equal(first.type, "image/webp"); + assert.equal(first.width, 2); + assert.equal(first.height, 2); + assert.equal(putKeys.length, 1); + }); +}); + +test("createMediumFromUrl() rejects redirects to unsafe network targets", async () => { + await withRollback(async (tx) => { + const disk = { + put() { + return Promise.resolve(); + }, + }; + await withMockFetch((_input) => { + return Promise.resolve( + new Response(null, { + status: 302, + headers: { Location: "http://127.0.0.1/image.png" }, + }), + ); + }, async () => { + await assert.rejects( + () => + createMediumFromUrl( + tx, + disk as never, + new URL("https://example.com/image.png"), + ), + UnsafeMediumUrlError, + ); + }); + }); +}); + test("persistPostMedium() stores image attachments and infers media type from content-type", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); diff --git a/models/medium.ts b/models/medium.ts index 94feba2b4..5e45746a5 100644 --- a/models/medium.ts +++ b/models/medium.ts @@ -4,16 +4,24 @@ import { join } from "node:path"; import { type Context, getUserAgent } from "@fedify/fedify"; import * as vocab from "@fedify/vocab"; import ffmpeg from "fluent-ffmpeg"; +import type { Disk } from "flydrive"; +import sharp from "sharp"; +import { isSSRFSafeURL } from "ssrfcheck"; import type { ContextData } from "./context.ts"; +import type { Database } from "./db.ts"; import metadata from "./deno.json" with { type: "json" }; import { isPostMediumType, + type Medium, + mediumTable, + type MediumType, + type NewMedium, type NewPostMedium, type PostMedium, postMediumTable, type PostMediumType, } from "./schema.ts"; -import type { Uuid } from "./uuid.ts"; +import { generateUuidV7, type Uuid } from "./uuid.ts"; const mediaTypes: Record = { "gif": "image/gif", @@ -29,6 +37,212 @@ const mediaTypes: Record = { "qt": "video/quicktime", }; +export const SUPPORTED_MEDIUM_IMAGE_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +] as const; + +export const MAX_MEDIUM_IMAGE_SIZE = 10 * 1024 * 1024; +export const MAX_STREAMING_MEDIUM_IMAGE_SIZE = 50 * 1024 * 1024; + +const localMediumType: MediumType = "image/webp"; + +export class UnsafeMediumUrlError extends Error { + constructor(url: string) { + super(`Unsafe medium URL: ${url}`); + this.name = "UnsafeMediumUrlError"; + } +} + +function isSupportedMediumImageType(value: string | null): boolean { + return value != null && + SUPPORTED_MEDIUM_IMAGE_TYPES.includes( + value.split(";")[0].trim() as typeof SUPPORTED_MEDIUM_IMAGE_TYPES[number], + ); +} + +function assertSafeRemoteMediumUrl(url: URL): void { + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new UnsafeMediumUrlError(url.href); + } + if (!isSSRFSafeURL(url.href, { autoPrependProtocol: false })) { + throw new UnsafeMediumUrlError(url.href); + } +} + +async function fetchMediumUrl( + url: URL, + userAgentUrl: URL | undefined, +): Promise { + let current = url; + for (let redirects = 0; redirects < 6; redirects++) { + assertSafeRemoteMediumUrl(current); + const response = await fetch(current, { + headers: { + "User-Agent": getUserAgent({ + software: `HackersPub/${metadata.version}`, + url: userAgentUrl ?? new URL("https://hackers.pub/"), + }), + }, + redirect: "manual", + }); + if (![301, 302, 303, 307, 308].includes(response.status)) { + return response; + } + const location = response.headers.get("Location"); + if (location == null) return response; + current = new URL(location, current); + } + return new Response(null, { status: 508 }); +} + +async function sha256Hex(data: Uint8Array): Promise { + const digestInput = new Uint8Array(data.byteLength); + digestInput.set(data); + const hashBuffer = await crypto.subtle.digest("SHA-256", digestInput.buffer); + return [...new Uint8Array(hashBuffer)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export async function createMediumFromBytes( + db: Database, + disk: Disk, + bytes: Uint8Array | ArrayBuffer, + options: { maxSize?: number; contentType?: string | null } = {}, +): Promise { + const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + if (input.byteLength > (options.maxSize ?? MAX_MEDIUM_IMAGE_SIZE)) { + return undefined; + } + if ( + options.contentType != null && + !isSupportedMediumImageType(options.contentType) + ) { + return undefined; + } + const { data, info } = await sharp(input, { animated: true }) + .rotate() + .webp() + .toBuffer({ resolveWithObject: true }); + const { width, height } = info; + if (width == null || height == null) return undefined; + const contentHash = await sha256Hex(new Uint8Array(data)); + const existing = await db.query.mediumTable.findFirst({ + where: { contentHash }, + }); + if (existing != null) return existing; + const key = `media/${contentHash}.webp`; + await disk.put(key, new Uint8Array(data), { contentType: localMediumType }); + const rows = await db.insert(mediumTable).values( + { + id: generateUuidV7(), + key, + type: localMediumType, + contentHash, + width, + height, + } satisfies NewMedium, + ).onConflictDoUpdate({ + target: mediumTable.key, + set: { + contentHash, + width, + height, + type: localMediumType, + }, + }).returning(); + return rows[0]; +} + +export async function createMediumFromBlob( + db: Database, + disk: Disk, + blob: Blob, + options: { maxSize?: number } = {}, +): Promise { + if (!isSupportedMediumImageType(blob.type)) return undefined; + return await createMediumFromBytes(db, disk, await blob.arrayBuffer(), { + ...options, + contentType: blob.type, + }); +} + +export async function createMediumFromUrl( + db: Database, + disk: Disk, + url: URL, + options: { maxSize?: number; userAgentUrl?: URL } = {}, +): Promise { + if ( + url.protocol !== "data:" && url.protocol !== "http:" && + url.protocol !== "https:" + ) { + return undefined; + } + const response = url.protocol === "data:" + ? await fetch(url) + : await fetchMediumUrl(url, options.userAgentUrl); + if (!response.ok) return undefined; + const contentType = response.headers.get("Content-Type"); + if (!isSupportedMediumImageType(contentType)) return undefined; + const contentLength = response.headers.get("Content-Length"); + const maxSize = options.maxSize ?? MAX_MEDIUM_IMAGE_SIZE; + if (contentLength != null && Number(contentLength) > maxSize) { + return undefined; + } + const blob = await response.blob(); + if (blob.size > maxSize) return undefined; + return await createMediumFromBytes(db, disk, await blob.arrayBuffer(), { + maxSize, + contentType, + }); +} + +export async function createMediumForExistingKey( + db: Database, + values: { + key: string; + type?: MediumType; + contentHash?: string | null; + width?: number | null; + height?: number | null; + }, +): Promise { + const existing = await db.query.mediumTable.findFirst({ + where: { key: values.key }, + }); + if (existing != null) return existing; + const rows = await db.insert(mediumTable).values( + { + id: generateUuidV7(), + key: values.key, + type: values.type ?? localMediumType, + contentHash: values.contentHash, + width: values.width, + height: values.height, + } satisfies NewMedium, + ).onConflictDoUpdate({ + target: mediumTable.key, + set: { + type: values.type ?? localMediumType, + contentHash: values.contentHash, + width: values.width, + height: values.height, + }, + }).returning(); + return rows[0]; +} + +export async function getMediumUrl( + disk: Disk, + medium: Pick, +): Promise { + return await disk.getUrl(medium.key); +} + export async function persistPostMedium( fedCtx: Context, document: vocab.Document, diff --git a/models/note.test.ts b/models/note.test.ts index 26e7987a5..2ac807bb9 100644 --- a/models/note.test.ts +++ b/models/note.test.ts @@ -2,7 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { updateAccountData } from "./account.ts"; import { createNoteSource, getNoteSource, updateNoteSource } from "./note.ts"; -import { noteMediumTable } from "./schema.ts"; +import { mediumTable, noteSourceMediumTable } from "./schema.ts"; +import { generateUuidV7 } from "./uuid.ts"; import { insertAccountWithActor, insertNotePost, @@ -61,13 +62,19 @@ test("getNoteSource() resolves renamed accounts and loads media relations", asyn content: "Readable note source", }); - await tx.insert(noteMediumTable).values({ - sourceId: noteSourceId, - index: 0, + const [medium] = await tx.insert(mediumTable).values({ + id: generateUuidV7(), key: "note-media/test.webp", - alt: "Readable alt text", + type: "image/webp", width: 320, height: 180, + }).returning(); + + await tx.insert(noteSourceMediumTable).values({ + sourceId: noteSourceId, + index: 0, + mediumId: medium.id, + alt: "Readable alt text", }); const renamed = await updateAccountData(tx, { @@ -90,7 +97,7 @@ test("getNoteSource() resolves renamed accounts and loads media relations", asyn assert.equal(source.post.id, post.id); assert.equal(source.post.actor.id, author.actor.id); assert.equal(source.media.length, 1); - assert.equal(source.media[0].key, "note-media/test.webp"); + assert.equal(source.media[0].medium.key, "note-media/test.webp"); assert.equal(source.media[0].alt, "Readable alt text"); }); }); diff --git a/models/note.ts b/models/note.ts index e4f37f1ca..8d092695b 100644 --- a/models/note.ts +++ b/models/note.ts @@ -5,7 +5,6 @@ import { getNote } from "@hackerspub/federation/objects"; import { sendTagsPubRelayActivity } from "@hackerspub/federation/tags-pub"; import { eq, sql } from "drizzle-orm"; import type { Disk } from "flydrive"; -import sharp from "sharp"; import type { ContextData } from "./context.ts"; import type { Database, Transaction } from "./db.ts"; import { @@ -22,11 +21,12 @@ import { type Blocking, type Following, type Instance, + type Medium, type Mention, type NewNoteSource, - type NoteMedium, - noteMediumTable, type NoteSource, + type NoteSourceMedium, + noteSourceMediumTable, noteSourceTable, type Post, type PostLink, @@ -36,6 +36,11 @@ import { } from "./schema.ts"; import { addPostToTimeline } from "./timeline.ts"; import { generateUuidV7, type Uuid } from "./uuid.ts"; +import { createMediumFromBlob } from "./medium.ts"; + +export type NoteSourceMediumWithMedium = NoteSourceMedium & { + medium: Medium; +}; export async function createNoteSource( db: Database, @@ -110,7 +115,7 @@ export async function getNoteSource( shares: Post[]; reactions: Reaction[]; }; - media: NoteMedium[]; + media: NoteSourceMediumWithMedium[]; } | undefined > { let account = await db.query.accountTable.findFirst({ @@ -267,41 +272,37 @@ export async function getNoteSource( }, }, }, - media: true, + media: { with: { medium: true }, orderBy: { index: "asc" } }, }, where: { id, accountId: account.id }, }); } -export async function createNoteMedium( +export async function createNoteSourceMedium( db: Database, disk: Disk, sourceId: Uuid, index: number, - medium: { blob: Blob; alt: string }, -): Promise { - const image = sharp(await medium.blob.arrayBuffer()).rotate(); - const { width, height } = await image.metadata(); - if (width == null || height == null) return undefined; - const buffer = await image.webp().toBuffer(); - const key = `note-media/${crypto.randomUUID()}.webp`; - await disk.put(key, new Uint8Array(buffer)); - const result = await db.insert(noteMediumTable).values({ + input: { blob: Blob; alt: string } | { mediumId: Uuid; alt: string }, +): Promise { + const medium = "blob" in input + ? await createMediumFromBlob(db, disk, input.blob) + : await db.query.mediumTable.findFirst({ where: { id: input.mediumId } }); + if (medium == null) return undefined; + const result = await db.insert(noteSourceMediumTable).values({ sourceId, index, - key, - alt: medium.alt, - width, - height, + mediumId: medium.id, + alt: input.alt, }).returning(); - return result.length > 0 ? result[0] : undefined; + return result.length > 0 ? { ...result[0], medium } : undefined; } export async function createNote( fedCtx: Context>, source: Omit & { id?: Uuid; - media: { blob: Blob; alt: string }[]; + media: ({ blob: Blob; alt: string } | { mediumId: Uuid; alt: string })[]; }, relations: { replyTarget?: Post & { actor: Actor }; @@ -315,7 +316,7 @@ export async function createNote( }; noteSource: NoteSource & { account: Account & { emails: AccountEmail[]; links: AccountLink[] }; - media: NoteMedium[]; + media: NoteSourceMediumWithMedium[]; }; media: PostMedium[]; } | undefined @@ -324,9 +325,15 @@ export async function createNote( const noteSource = await createNoteSource(db, source); if (noteSource == null) return undefined; let index = 0; - const media = []; + const media: NoteSourceMediumWithMedium[] = []; for (const medium of source.media) { - const m = await createNoteMedium(db, disk, noteSource.id, index, medium); + const m = await createNoteSourceMedium( + db, + disk, + noteSource.id, + index, + medium, + ); if (m != null) media.push(m); index++; } @@ -470,7 +477,7 @@ export async function updateNote( }; noteSource: NoteSource & { account: Account & { emails: AccountEmail[]; links: AccountLink[] }; - media: NoteMedium[]; + media: NoteSourceMediumWithMedium[]; }; mentions: Mention[]; media: PostMedium[]; @@ -486,8 +493,10 @@ export async function updateNote( where: { id: noteSource.accountId }, with: { emails: true, links: true }, }); - const media = await db.query.noteMediumTable.findMany({ + const media = await db.query.noteSourceMediumTable.findMany({ where: { sourceId: noteSourceId }, + with: { medium: true }, + orderBy: { index: "asc" }, }); if (account == null) return undefined; const post = await syncPostFromNoteSource(fedCtx, { diff --git a/models/post.sync.test.ts b/models/post.sync.test.ts index 5bbc95292..5b791ddd8 100644 --- a/models/post.sync.test.ts +++ b/models/post.sync.test.ts @@ -119,7 +119,7 @@ test("syncPostFromNoteSource() upserts note posts and updates quote counts", asy where: { id: noteSourceId }, with: { account: { with: { emails: true, links: true } }, - media: true, + media: { with: { medium: true } }, }, }); assert.ok(noteSource != null); @@ -146,7 +146,7 @@ test("syncPostFromNoteSource() upserts note posts and updates quote counts", asy where: { id: noteSourceId }, with: { account: { with: { emails: true, links: true } }, - media: true, + media: { with: { medium: true } }, }, }); assert.ok(updatedSource != null); diff --git a/models/post.ts b/models/post.ts index f5eaeaaec..eba16ddc5 100644 --- a/models/post.ts +++ b/models/post.ts @@ -40,7 +40,10 @@ import { syncActorFromAccount, toRecipient, } from "./actor.ts"; -import { getOriginalArticleContent } from "./article.ts"; +import { + getArticleSourceMediumUrls, + getOriginalArticleContent, +} from "./article.ts"; import type { ContextData } from "./context.ts"; import { toDate } from "./date.ts"; import type { Database, RelationsFilter } from "./db.ts"; @@ -64,12 +67,13 @@ import { type Blocking, type Following, type Instance, + type Medium, type Mention, mentionTable, type NewPost, type NewPostLink, - type NoteMedium, type NoteSource, + type NoteSourceMedium, noteSourceTable, type Poll, type Post, @@ -94,6 +98,8 @@ const SCRAPE_IMAGE_METADATA_BYTES_LIMIT = 128 * 1024; export type PostObject = vocab.Article | vocab.Note | vocab.Question; +type NoteSourceMediumWithMedium = NoteSourceMedium & { medium: Medium }; + export function isPostObject(object: unknown): object is PostObject { return object instanceof vocab.Article || object instanceof vocab.Note || object instanceof vocab.Question; @@ -144,23 +150,35 @@ async function readResponseBytesAtMost( export async function syncPostFromArticleSource( fedCtx: Context, articleSource: ArticleSource & { - account: Account & { emails: AccountEmail[]; links: AccountLink[] }; + account: Account & { + avatarMedium?: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }; contents: ArticleContent[]; }, ): Promise< Post & { actor: Actor & { - account: Account & { emails: AccountEmail[]; links: AccountLink[] }; + account: Account & { + avatarMedium?: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }; instance: Instance; }; articleSource: ArticleSource & { - account: Account & { emails: AccountEmail[]; links: AccountLink[] }; + account: Account & { + avatarMedium?: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }; contents: ArticleContent[]; }; mentions: Mention[]; } > { - const { db, kv } = fedCtx.data; + const { db, kv, disk } = fedCtx.data; const actor = await syncActorFromAccount(fedCtx, articleSource.account); const content = getOriginalArticleContent(articleSource); if (content == null) { @@ -169,6 +187,7 @@ export async function syncPostFromArticleSource( const rendered = await renderMarkup(fedCtx, content.content, { docId: articleSource.id, kv, + mediumUrls: await getArticleSourceMediumUrls(db, disk, articleSource.id), }); const url = `${fedCtx.origin}/@${articleSource.account.username}/${articleSource.publishedYear}/${ @@ -221,8 +240,12 @@ export async function syncPostFromArticleSource( export async function syncPostFromNoteSource( fedCtx: Context, noteSource: NoteSource & { - account: Account & { emails: AccountEmail[]; links: AccountLink[] }; - media: NoteMedium[]; + account: Account & { + avatarMedium?: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }; + media: NoteSourceMediumWithMedium[]; }, relations: { replyTarget?: Post & { actor: Actor }; @@ -231,12 +254,20 @@ export async function syncPostFromNoteSource( ): Promise< Post & { actor: Actor & { - account: Account & { emails: AccountEmail[]; links: AccountLink[] }; + account: Account & { + avatarMedium?: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }; instance: Instance; }; noteSource: NoteSource & { - account: Account & { emails: AccountEmail[]; links: AccountLink[] }; - media: NoteMedium[]; + account: Account & { + avatarMedium?: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }; + media: NoteSourceMediumWithMedium[]; }; replyTarget: Post & { actor: Actor } | null; quotedPost: Post & { actor: Actor } | null; @@ -320,10 +351,10 @@ export async function syncPostFromNoteSource( postId: post.id, index: medium.index, type: "image/webp" as const, - url: await disk.getUrl(medium.key), + url: await disk.getUrl(medium.medium.key), alt: medium.alt, - width: medium.width, - height: medium.height, + width: medium.medium.width, + height: medium.medium.height, }))), ).returning() : []; diff --git a/models/relations.ts b/models/relations.ts index d8b740c3f..fd072086d 100644 --- a/models/relations.ts +++ b/models/relations.ts @@ -26,7 +26,10 @@ export const relations = defineRelations(schema, (r) => ({ notifications: r.many.notificationTable(), apnsDeviceTokens: r.many.apnsDeviceTokenTable(), fcmDeviceTokens: r.many.fcmDeviceTokenTable(), - uploadedMedia: r.many.articleMediumTable(), + avatarMedium: r.one.mediumTable({ + from: r.accountTable.avatarMediumId, + to: r.mediumTable.id, + }), bookmarks: r.many.bookmarkTable(), }, accountEmailTable: { @@ -115,7 +118,7 @@ export const relations = defineRelations(schema, (r) => ({ to: r.accountTable.id, optional: false, }), - uploadedMedia: r.many.articleMediumTable(), + media: r.many.articleDraftMediumTable(), }, articleSourceTable: { account: r.one.accountTable({ @@ -129,7 +132,7 @@ export const relations = defineRelations(schema, (r) => ({ optional: false, }), contents: r.many.articleContentTable(), - uploadedMedia: r.many.articleMediumTable(), + media: r.many.articleSourceMediumTable(), }, articleContentTable: { source: r.one.articleSourceTable({ @@ -167,13 +170,24 @@ export const relations = defineRelations(schema, (r) => ({ to: r.postTable.noteSourceId, optional: false, }), - media: r.many.noteMediumTable(), + media: r.many.noteSourceMediumTable(), + }, + mediumTable: { + avatarAccounts: r.many.accountTable(), + noteSources: r.many.noteSourceMediumTable(), + articleDrafts: r.many.articleDraftMediumTable(), + articleSources: r.many.articleSourceMediumTable(), }, - noteMediumTable: { + noteSourceMediumTable: { source: r.one.noteSourceTable({ - from: r.noteMediumTable.sourceId, + from: r.noteSourceMediumTable.sourceId, to: r.noteSourceTable.id, }), + medium: r.one.mediumTable({ + from: r.noteSourceMediumTable.mediumId, + to: r.mediumTable.id, + optional: false, + }), }, postTable: { actor: r.one.actorTable({ @@ -370,21 +384,28 @@ export const relations = defineRelations(schema, (r) => ({ optional: true, }), }, - articleMediumTable: { - account: r.one.accountTable({ - from: r.articleMediumTable.accountId, - to: r.accountTable.id, - optional: false, - }), + articleDraftMediumTable: { articleDraft: r.one.articleDraftTable({ - from: r.articleMediumTable.articleDraftId, + from: r.articleDraftMediumTable.articleDraftId, to: r.articleDraftTable.id, - optional: true, + optional: false, + }), + medium: r.one.mediumTable({ + from: r.articleDraftMediumTable.mediumId, + to: r.mediumTable.id, + optional: false, }), + }, + articleSourceMediumTable: { articleSource: r.one.articleSourceTable({ - from: r.articleMediumTable.articleSourceId, + from: r.articleSourceMediumTable.articleSourceId, to: r.articleSourceTable.id, - optional: true, + optional: false, + }), + medium: r.one.mediumTable({ + from: r.articleSourceMediumTable.mediumId, + to: r.mediumTable.id, + optional: false, }), }, invitationLinkTable: { diff --git a/models/schema.ts b/models/schema.ts index 203f5d678..a5ad95c9e 100644 --- a/models/schema.ts +++ b/models/schema.ts @@ -47,7 +47,9 @@ export const accountTable = pgTable( usernameChanged: timestamp("username_changed", { withTimezone: true }), name: varchar({ length: 50 }).notNull(), bio: text().notNull(), - avatarKey: text("avatar_key").unique(), + avatarMediumId: uuid("avatar_medium_id") + .$type() + .references((): AnyPgColumn => mediumTable.id, { onDelete: "set null" }), ogImageKey: text("og_image_key").unique(), locales: varchar().array().$type(), moderator: boolean().notNull().default(false), @@ -566,26 +568,72 @@ export const noteSourceTable = pgTable("note_source", { export type NoteSource = typeof noteSourceTable.$inferSelect; export type NewNoteSource = typeof noteSourceTable.$inferInsert; -export const noteMediumTable = pgTable( - "note_medium", +export const mediumTypeEnum = pgEnum("medium_type", [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]); + +export type MediumType = (typeof mediumTypeEnum.enumValues)[number]; + +export function isMediumType(value: unknown): value is MediumType { + return mediumTypeEnum.enumValues.includes(value as MediumType); +} + +export const mediumTable = pgTable( + "medium", + { + id: uuid().$type().primaryKey(), + key: text().notNull().unique(), + type: mediumTypeEnum().notNull(), + contentHash: text("content_hash").unique(), + width: integer(), + height: integer(), + created: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp), + }, + (table) => [ + check( + "medium_width_height_check", + sql` + CASE + WHEN ${table.width} IS NULL THEN ${table.height} IS NULL + ELSE ${table.height} IS NOT NULL AND + ${table.width} > 0 AND ${table.height} > 0 + END + `, + ), + ], +); + +export type Medium = typeof mediumTable.$inferSelect; +export type NewMedium = typeof mediumTable.$inferInsert; + +export const noteSourceMediumTable = pgTable( + "note_source_medium", { sourceId: uuid("note_source_id") .$type() .notNull() .references(() => noteSourceTable.id, { onDelete: "cascade" }), index: smallint().notNull(), - key: text().notNull().unique(), + mediumId: uuid("medium_id") + .$type() + .notNull() + .references(() => mediumTable.id, { onDelete: "restrict" }), alt: text().notNull(), - width: integer().notNull(), - height: integer().notNull(), }, (table) => [ primaryKey({ columns: [table.sourceId, table.index] }), + unique().on(table.sourceId, table.mediumId), + check("note_source_medium_index_check", sql`${table.index} >= 0`), ], ); -export type NoteMedium = typeof noteMediumTable.$inferSelect; -export type NewNoteMedium = typeof noteMediumTable.$inferInsert; +export type NoteSourceMedium = typeof noteSourceMediumTable.$inferSelect; +export type NewNoteSourceMedium = typeof noteSourceMediumTable.$inferInsert; export const postTypeEnum = pgEnum("post_type", [ "Article", @@ -1257,31 +1305,54 @@ export const invitationLinkTable = pgTable( export type InvitationLink = typeof invitationLinkTable.$inferSelect; export type NewInvitationLink = typeof invitationLinkTable.$inferInsert; -export const articleMediumTable = pgTable( - "article_medium", +export const articleDraftMediumTable = pgTable( + "article_draft_medium", { - key: text().primaryKey(), - accountId: uuid("account_id") + articleDraftId: uuid("article_draft_id") .$type() .notNull() - .references(() => accountTable.id, { onDelete: "cascade" }), - articleDraftId: uuid("article_draft_id") + .references(() => articleDraftTable.id, { onDelete: "cascade" }), + key: text().notNull(), + mediumId: uuid("medium_id") .$type() - .references(() => articleDraftTable.id, { onDelete: "set null" }), + .notNull() + .references(() => mediumTable.id, { onDelete: "restrict" }), + created: timestamp({ withTimezone: true }) + .notNull() + .default(currentTimestamp), + }, + (table) => [ + primaryKey({ columns: [table.articleDraftId, table.key] }), + ], +); + +export type ArticleDraftMedium = typeof articleDraftMediumTable.$inferSelect; +export type NewArticleDraftMedium = typeof articleDraftMediumTable.$inferInsert; + +export const articleSourceMediumTable = pgTable( + "article_source_medium", + { articleSourceId: uuid("article_source_id") .$type() - .references(() => articleSourceTable.id, { onDelete: "set null" }), - url: text().notNull(), - width: integer().notNull(), - height: integer().notNull(), + .notNull() + .references(() => articleSourceTable.id, { onDelete: "cascade" }), + key: text().notNull(), + mediumId: uuid("medium_id") + .$type() + .notNull() + .references(() => mediumTable.id, { onDelete: "restrict" }), created: timestamp({ withTimezone: true }) .notNull() .default(currentTimestamp), }, + (table) => [ + primaryKey({ columns: [table.articleSourceId, table.key] }), + ], ); -export type ArticleMedium = typeof articleMediumTable.$inferSelect; -export type NewArticleMedium = typeof articleMediumTable.$inferInsert; +export type ArticleSourceMedium = typeof articleSourceMediumTable.$inferSelect; +export type NewArticleSourceMedium = + typeof articleSourceMediumTable.$inferInsert; export const adminStateTable = pgTable("admin_state", { key: text().primaryKey(), diff --git a/web-next/src/components/article-composer/ArticleComposer.tsx b/web-next/src/components/article-composer/ArticleComposer.tsx index baa1e92d1..2ae60295b 100644 --- a/web-next/src/components/article-composer/ArticleComposer.tsx +++ b/web-next/src/components/article-composer/ArticleComposer.tsx @@ -33,7 +33,7 @@ function ArticleComposerInner() { } > {t`Draft not found`} diff --git a/web-next/src/components/article-composer/ArticleComposerContext.tsx b/web-next/src/components/article-composer/ArticleComposerContext.tsx index b807fdb2b..2aaa3ee04 100644 --- a/web-next/src/components/article-composer/ArticleComposerContext.tsx +++ b/web-next/src/components/article-composer/ArticleComposerContext.tsx @@ -122,7 +122,8 @@ export interface ArticleComposerProps { export interface ArticleComposerContextValue { // Draft data - draftUuid: string | undefined; + draftUuid: string; + isExistingDraft: boolean; draftDataLoaded: Accessor; draft: Accessor< | { @@ -176,6 +177,9 @@ export const ArticleComposerProvider: ParentComponent = ( const { t, i18n } = useLingui(); const navigate = useNavigate(); const env = useRelayEnvironment(); + const draftUuid = (props.draftUuid ?? + crypto + .randomUUID()) as `${string}-${string}-${string}-${string}-${string}`; // Draft loading const draftData = props.draftUuid @@ -265,6 +269,7 @@ export const ArticleComposerProvider: ParentComponent = ( variables: { input: { id: draft()?.id, + uuid: draft()?.id == null ? draftUuid : undefined, title: title().trim(), content: content().trim(), tags: tags(), @@ -521,7 +526,8 @@ export const ArticleComposerProvider: ParentComponent = ( // --- Context value --- const contextValue: ArticleComposerContextValue = { - draftUuid: props.draftUuid, + draftUuid, + isExistingDraft: props.draftUuid != null, draftDataLoaded, draft, diff --git a/web-next/src/lib/uploadImage.ts b/web-next/src/lib/uploadImage.ts index 388b563d4..e87145e34 100644 --- a/web-next/src/lib/uploadImage.ts +++ b/web-next/src/lib/uploadImage.ts @@ -2,6 +2,7 @@ import { getRequestEvent } from "solid-js/web"; import { getApiUrl } from "~/lib/env.ts"; export interface ImageUploadResult { + uuid: string; url: string; width: number; height: number; @@ -30,15 +31,14 @@ function readSessionCookie(request: Request | undefined): string | null { return null; } -async function uploadMediaOnServer( - mediaUrl: string, - draftId?: string, -): Promise { +async function graphqlRequest( + query: string, + variables: Record, +): Promise { "use server"; const event = getRequestEvent(); const sessionId = readSessionCookie(event?.request); - const response = await fetch(getApiUrl(), { method: "POST", headers: { @@ -47,61 +47,125 @@ async function uploadMediaOnServer( ...(sessionId == null ? {} : { Authorization: `Bearer ${sessionId}` }), }, body: JSON.stringify({ - query: ` - mutation uploadMedia($input: UploadMediaInput!) { - uploadMedia(input: $input) { - __typename - ... on UploadMediaPayload { - url - width - height - } - ... on InvalidInputError { - inputPath - } - ... on NotAuthenticatedError { - notAuthenticated - } - } - } - `, - variables: { - input: { - mediaUrl, - ...(draftId == null ? {} : { draftId }), - }, - }, + query, + variables, }), }); - const result = await response.json() as { + const result = await response.json() as T & { errors?: { message: string }[]; + }; + if (result.errors) { + throw new Error(result.errors[0]?.message || "Upload failed"); + } + return result; +} + +export async function createMediumFromDataUrl( + url: string, +): Promise { + "use server"; + + const result = await graphqlRequest<{ data?: { - uploadMedia: { + createMedium: { __typename: string; - url?: string; - width?: number; - height?: number; + medium?: { + uuid: string; + url: string; + width: number | null; + height: number | null; + }; inputPath?: string; }; }; - }; - - if (result.errors) { - throw new Error(result.errors[0]?.message || "Upload failed"); - } + errors?: { message: string }[]; + }>( + ` + mutation createMedium($input: CreateMediumInput!) { + createMedium(input: $input) { + __typename + ... on CreateMediumPayload { + medium { + uuid + url + width + height + } + } + ... on InvalidInputError { + inputPath + } + ... on NotAuthenticatedError { + notAuthenticated + } + } + } + `, + { input: { url } }, + ); - const data = result.data?.uploadMedia; + const data = result.data?.createMedium; if (data == null) { throw new Error("Upload failed"); } - - if (data.__typename === "UploadMediaPayload") { - return { url: data.url!, width: data.width!, height: data.height! }; - } else if (data.__typename === "NotAuthenticatedError") { + if (data.__typename === "CreateMediumPayload" && data.medium != null) { + return { + uuid: data.medium.uuid, + url: data.medium.url, + width: data.medium.width ?? 0, + height: data.medium.height ?? 0, + }; + } + if (data.__typename === "NotAuthenticatedError") { throw new Error("Not authenticated"); } + throw new Error("Upload failed"); +} +async function attachArticleDraftMediumOnServer( + draftId: string, + mediumId: string, +): Promise { + "use server"; + + const result = await graphqlRequest<{ + data?: { + attachArticleDraftMedium: { + __typename: string; + key?: string; + inputPath?: string; + }; + }; + errors?: { message: string }[]; + }>( + ` + mutation attachArticleDraftMedium($input: AttachArticleDraftMediumInput!) { + attachArticleDraftMedium(input: $input) { + __typename + ... on AttachArticleDraftMediumPayload { + key + } + ... on InvalidInputError { + inputPath + } + ... on NotAuthenticatedError { + notAuthenticated + } + } + } + `, + { input: { draftId, mediumId } }, + ); + + const data = result.data?.attachArticleDraftMedium; + if (data == null) throw new Error("Upload failed"); + if (data.__typename === "AttachArticleDraftMediumPayload" && data.key) { + return data.key; + } + if (data.__typename === "NotAuthenticatedError") { + throw new Error("Not authenticated"); + } throw new Error("Upload failed"); } @@ -110,5 +174,8 @@ export async function uploadImage( draftId?: string, ): Promise { const dataUrl = await fileToDataUrl(file); - return uploadMediaOnServer(dataUrl, draftId); + const medium = await createMediumFromDataUrl(dataUrl); + if (draftId == null) return medium; + const key = await attachArticleDraftMediumOnServer(draftId, medium.uuid); + return { ...medium, url: `hp-medium:${key}` }; } diff --git a/web-next/src/routes/(root)/[handle]/settings/index.tsx b/web-next/src/routes/(root)/[handle]/settings/index.tsx index ead42b701..1b8d080ba 100644 --- a/web-next/src/routes/(root)/[handle]/settings/index.tsx +++ b/web-next/src/routes/(root)/[handle]/settings/index.tsx @@ -38,6 +38,7 @@ import { } from "~/components/ui/text-field.tsx"; import { showToast } from "~/components/ui/toast.tsx"; import { useLingui } from "~/lib/i18n/macro.d.ts"; +import { createMediumFromDataUrl } from "~/lib/uploadImage.ts"; import { SettingsCardPage } from "~/components/SettingsCardPage.tsx"; import { SettingsOwnerGuard } from "~/components/SettingsOwnerGuard.tsx"; import type { settingsForm_account$key } from "./__generated__/settingsForm_account.graphql.ts"; @@ -77,13 +78,13 @@ const loadPageQuery = query( ); const settingsMutation = graphql` - mutation settingsMutation($id: ID!, $username: String, $name: String!, $bio: String!, $avatarUrl: URL, $links: [AccountLinkInput!]!) { + mutation settingsMutation($id: ID!, $username: String, $name: String!, $bio: String!, $avatarMediumId: UUID, $links: [AccountLinkInput!]!) { updateAccount(input: { id: $id, username: $username, name: $name, bio: $bio, - avatarUrl: $avatarUrl, + avatarMediumId: $avatarMediumId, links: $links, }) { account { @@ -262,7 +263,7 @@ function SettingsForm(props: SettingsFormProps) { } const [save] = createMutation(settingsMutation); const [saving, setSaving] = createSignal(false); - function onSubmit(event: SubmitEvent) { + async function onSubmit(event: SubmitEvent) { event.preventDefault(); const id = account()?.id; const usernameChanged = account()?.usernameChanged; @@ -274,6 +275,27 @@ function SettingsForm(props: SettingsFormProps) { const username = usernameInput.value; const name = nameInput.value; const bio = bioInput.value; + let avatarMediumId: + | `${string}-${string}-${string}-${string}-${string}` + | undefined; + const pendingAvatarUrl = avatarUrl(); + if (pendingAvatarUrl != null) { + try { + avatarMediumId = (await createMediumFromDataUrl(pendingAvatarUrl)) + .uuid as `${string}-${string}-${string}-${string}-${string}`; + } catch (error) { + console.error(error); + showToast({ + title: t`Failed to save settings`, + description: error instanceof Error + ? error.message + : t`An error occurred while saving your settings. Please try again, or contact support if the problem persists.`, + variant: "error", + }); + setSaving(false); + return; + } + } setLinks((links) => { const newLinks = links.links.filter((l) => l.name.trim() !== "" && l.url.trim() !== "" @@ -291,7 +313,7 @@ function SettingsForm(props: SettingsFormProps) { username: usernameChanged == null ? username : undefined, name, bio, - avatarUrl: avatarUrl(), + avatarMediumId, links: links.links.filter((l) => l.name.trim() !== "" && l.url.trim() !== "" ).map((l) => ({ name: l.name, url: l.url })), diff --git a/web/main.ts b/web/main.ts index e07eaf1a8..74d6e7d1f 100644 --- a/web/main.ts +++ b/web/main.ts @@ -7,6 +7,7 @@ import { trailingSlashes, } from "@fresh/core"; import { type Context, createYogaServer } from "@hackerspub/graphql"; +import { handleMediumUploadProxy } from "@hackerspub/graphql/medium-upload"; import { getSession } from "@hackerspub/models/session"; import { type Uuid, validateUuid } from "@hackerspub/models/uuid"; import { getXForwardedRequest } from "@hongminhee/x-forwarded-fetch"; @@ -153,7 +154,7 @@ app.use((ctx) => { if (session == null) return { account: undefined, session: undefined }; const account = await db.query.accountTable.findFirst({ where: { id: session.accountId }, - with: { actor: true, emails: true, links: true }, + with: { actor: true, avatarMedium: true, emails: true, links: true }, }); return { account, @@ -166,6 +167,12 @@ app.use((ctx) => { }); app.use(async (ctx) => { + const uploadResponse = await handleMediumUploadProxy( + ctx.req, + kv, + drive.use(), + ); + if (uploadResponse != null) return uploadResponse; if ( ctx.url.pathname.startsWith("/.well-known/") && ctx.url.pathname !== "/.well-known/assetlinks.json" && diff --git a/web/routes/@[username]/feed.xml.ts b/web/routes/@[username]/feed.xml.ts index 2e49818e5..04153f1fe 100644 --- a/web/routes/@[username]/feed.xml.ts +++ b/web/routes/@[username]/feed.xml.ts @@ -11,7 +11,7 @@ export const handler = define.handlers(async (ctx) => { const { username } = ctx.params; if (username.includes("@")) return ctx.next(); const account = await db.query.accountTable.findFirst({ - with: { actor: true, emails: true }, + with: { actor: true, avatarMedium: true, emails: true }, where: { username }, }); if (account == null) return ctx.next(); diff --git a/web/routes/@[username]/invite/[id]/index.tsx b/web/routes/@[username]/invite/[id]/index.tsx index a7ad51d89..efd4c37b2 100644 --- a/web/routes/@[username]/invite/[id]/index.tsx +++ b/web/routes/@[username]/invite/[id]/index.tsx @@ -7,6 +7,7 @@ import { type AccountEmail, type InvitationLink, invitationLinkTable, + type Medium, } from "@hackerspub/models/schema"; import { createSignupToken } from "@hackerspub/models/signup"; import { validateUuid } from "@hackerspub/models/uuid"; @@ -29,7 +30,7 @@ export const handler = define.handlers({ const { id } = ctx.params; if (!validateUuid(id)) return ctx.next(); const invitationLink = await db.query.invitationLinkTable.findFirst({ - with: { inviter: { with: { emails: true } } }, + with: { inviter: { with: { avatarMedium: true, emails: true } } }, where: { id }, }); if (invitationLink == null) return ctx.next(); @@ -46,7 +47,7 @@ export const handler = define.handlers({ const { id } = ctx.params; if (!validateUuid(id)) return ctx.next(); const invitationLink = await db.query.invitationLinkTable.findFirst({ - with: { inviter: { with: { emails: true } } }, + with: { inviter: { with: { avatarMedium: true, emails: true } } }, where: { id }, }); if (invitationLink == null) return ctx.next(); @@ -140,7 +141,7 @@ export const handler = define.handlers({ }); interface InvitationLinkPageProps { - inviter: Account & { emails: AccountEmail[] }; + inviter: Account & { avatarMedium: Medium | null; emails: AccountEmail[] }; invitationLink: InvitationLink; result?: | { duplicateEmail: string } diff --git a/web/routes/@[username]/og.ts b/web/routes/@[username]/og.ts index 57a279a80..52a929915 100644 --- a/web/routes/@[username]/og.ts +++ b/web/routes/@[username]/og.ts @@ -12,7 +12,7 @@ import { define } from "../../utils.ts"; export const handler = define.handlers({ async GET(ctx) { const account = await db.query.accountTable.findFirst({ - with: { emails: true }, + with: { avatarMedium: true, emails: true }, where: { username: ctx.params.username }, }); if (account == null) return ctx.next(); diff --git a/web/routes/@[username]/settings/index.tsx b/web/routes/@[username]/settings/index.tsx index a6cbe21e0..4e58e99b8 100644 --- a/web/routes/@[username]/settings/index.tsx +++ b/web/routes/@[username]/settings/index.tsx @@ -1,10 +1,7 @@ import { page } from "@fresh/core"; -import { - getAvatarUrl, - transformAvatar, - updateAccount, -} from "@hackerspub/models/account"; +import { getAvatarUrl, updateAccount } from "@hackerspub/models/account"; import { syncActorFromAccount } from "@hackerspub/models/actor"; +import { createMediumFromBlob } from "@hackerspub/models/medium"; import { getLogger } from "@logtape/logtape"; import { zip } from "@std/collections/zip"; import { Button } from "../../../components/Button.tsx"; @@ -37,7 +34,11 @@ export const handler = define.handlers({ if (ctx.state.session == null) return ctx.next(); const account = await db.query.accountTable.findFirst({ where: { username: ctx.params.username }, - with: { emails: true, links: { orderBy: { index: "asc" } } }, + with: { + avatarMedium: true, + emails: true, + links: { orderBy: { index: "asc" } }, + }, }); if (account?.id !== ctx.state.session.accountId) return ctx.next(); ctx.state.title = ctx.state.t("settings.profile.title"); @@ -56,7 +57,7 @@ export const handler = define.handlers({ const disk = drive.use(); const account = await db.query.accountTable.findFirst({ where: { username: ctx.params.username }, - with: { emails: true, links: true }, + with: { avatarMedium: true, emails: true, links: true }, }); if (account == null) return ctx.next(); const form = await ctx.req.formData(); @@ -124,20 +125,9 @@ export const handler = define.handlers({ bio, links, }; - const promises: Promise[] = []; if (avatar instanceof File) { - const disk = drive.use(); - if (account.avatarKey != null) { - promises.push(disk.delete(account.avatarKey)); - } - const { buffer, format } = await transformAvatar( - await avatar.arrayBuffer(), - ); - const key = `avatars/${crypto.randomUUID()}.${ - format === "jpeg" ? "jpg" : format - }`; - promises.push(disk.put(key, buffer)); - values.avatarKey = key; + const medium = await createMediumFromBlob(db, disk, avatar); + if (medium != null) values.avatarMediumId = medium.id; } const updatedAccount = await updateAccount(ctx.state.fedCtx, values); if (updatedAccount == null) { @@ -147,15 +137,27 @@ export const handler = define.handlers({ const emails = await db.query.accountEmailTable.findMany({ where: { accountId: updatedAccount.id }, }); - await syncActorFromAccount(ctx.state.fedCtx, { ...updatedAccount, emails }); - await Promise.all(promises); + const avatarMedium = updatedAccount.avatarMediumId == null + ? null + : await db.query.mediumTable.findFirst({ + where: { id: updatedAccount.avatarMediumId }, + }) ?? null; + await syncActorFromAccount(ctx.state.fedCtx, { + ...updatedAccount, + emails, + avatarMedium, + }); if (account.username !== updatedAccount.username) { return Response.redirect( new URL(`/@${updatedAccount.username}/settings`, ctx.url), ); } return page({ - avatarUrl: await getAvatarUrl(disk, { ...updatedAccount, emails }), + avatarUrl: await getAvatarUrl(disk, { + ...updatedAccount, + emails, + avatarMedium, + }), usernameChanged: updatedAccount.usernameChanged, values: updatedAccount, links: updatedAccount.links, diff --git a/web/routes/_app.tsx b/web/routes/_app.tsx index 69bd18ea8..f25ef0552 100644 --- a/web/routes/_app.tsx +++ b/web/routes/_app.tsx @@ -70,12 +70,17 @@ const APPLE_STARTUP_IMAGE_LINKS = APPLE_STARTUP_CONFIGS.flatMap(( export default async function App( { Component, state, url }: PageProps, ) { - let account: Account & { emails: AccountEmail[] } | undefined = undefined; + let account: + | Account & { + emails: AccountEmail[]; + avatarMedium?: import("@hackerspub/models/schema").Medium | null; + } + | undefined = undefined; let drafts = 0; let avatarUrl: string | undefined = undefined; if (state.session != null) { account = await db.query.accountTable.findFirst({ - with: { emails: true }, + with: { avatarMedium: true, emails: true }, where: { id: state.session.accountId }, }); drafts = (await db.select({ cnt: count() }) diff --git a/web/routes/admin/index.tsx b/web/routes/admin/index.tsx index a4217de2a..2bfed8070 100644 --- a/web/routes/admin/index.tsx +++ b/web/routes/admin/index.tsx @@ -5,6 +5,7 @@ import { type AccountEmail, type Actor, actorTable, + type Medium, postTable, } from "@hackerspub/models/schema"; import type { Uuid } from "@hackerspub/models/uuid"; @@ -18,7 +19,13 @@ import { define } from "../../utils.ts"; export const handler = define.handlers({ async GET(_ctx) { const accounts = await db.query.accountTable.findMany({ - with: { emails: true, actor: true, inviter: true, invitees: true }, + with: { + emails: true, + avatarMedium: true, + actor: true, + inviter: true, + invitees: true, + }, orderBy: { created: "desc" }, }); const postsMetadata: Record< @@ -60,6 +67,7 @@ export const handler = define.handlers({ interface AccountListProps { accounts: (Account & { actor: Actor; + avatarMedium: Medium | null; emails: AccountEmail[]; inviter: Account | null; invitees: Account[]; diff --git a/web/routes/api/media.ts b/web/routes/api/media.ts index ddd17564f..939af1a81 100644 --- a/web/routes/api/media.ts +++ b/web/routes/api/media.ts @@ -1,8 +1,9 @@ import { - MAX_IMAGE_SIZE, - SUPPORTED_IMAGE_TYPES, - uploadImage, -} from "@hackerspub/models/upload"; + createMediumFromBlob, + MAX_MEDIUM_IMAGE_SIZE, + SUPPORTED_MEDIUM_IMAGE_TYPES, +} from "@hackerspub/models/medium"; +import { db } from "../../db.ts"; import { drive } from "../../drive.ts"; import { define } from "../../utils.ts"; @@ -31,28 +32,33 @@ export const handler = define.handlers({ return jsonResponse({ error: "No file provided" }, 400); } - if (!SUPPORTED_IMAGE_TYPES.includes(file.type)) { + if ( + !SUPPORTED_MEDIUM_IMAGE_TYPES.includes( + file.type as typeof SUPPORTED_MEDIUM_IMAGE_TYPES[number], + ) + ) { return jsonResponse({ error: "Unsupported image type", - supported: SUPPORTED_IMAGE_TYPES, + supported: SUPPORTED_MEDIUM_IMAGE_TYPES, }, 400); } - if (file.size > MAX_IMAGE_SIZE) { + if (file.size > MAX_MEDIUM_IMAGE_SIZE) { return jsonResponse( - { error: "File too large", maxSize: MAX_IMAGE_SIZE }, + { error: "File too large", maxSize: MAX_MEDIUM_IMAGE_SIZE }, 400, ); } const disk = drive.use(); - const result = await uploadImage(disk, file); + const result = await createMediumFromBlob(db, disk, file); if (result == null) { return jsonResponse({ error: "Failed to process image" }, 500); } return jsonResponse({ - url: result.url, + mediumId: result.id, + url: await disk.getUrl(result.key), width: result.width, height: result.height, }); From 28133efe8151070e20ecd3affebebd5296d2d5f0 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 02:27:54 +0900 Subject: [PATCH 02/32] Fix draft medium preview query Remove an invalid ArticleDraft column selection from the contentHtml resolver. The preview only needs the draft content and medium relation data to render hp-medium references. Assisted-by: Codex:gpt-5.5 --- graphql/post.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/graphql/post.ts b/graphql/post.ts index 3fb3dc723..b20471c4c 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -398,7 +398,6 @@ export const ArticleDraft = builder.drizzleNode("articleDraftTable", { select: { columns: { content: true, - sourceId: true, }, }, async resolve(draft, _, ctx) { From 16845248a0175372a29620859e39c357816403a5 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 07:13:48 +0900 Subject: [PATCH 03/32] Resolve legacy article media Pass article source medium URLs into the legacy article renderer so migrated hp-medium placeholders render as public image URLs. Assisted-by: Codex:gpt-5.5 --- web/routes/@[username]/[idOrYear]/[slug]/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/routes/@[username]/[idOrYear]/[slug]/index.tsx b/web/routes/@[username]/[idOrYear]/[slug]/index.tsx index 307c4e0ae..4b19fc7cc 100644 --- a/web/routes/@[username]/[idOrYear]/[slug]/index.tsx +++ b/web/routes/@[username]/[idOrYear]/[slug]/index.tsx @@ -3,6 +3,7 @@ import { type FreshContext, page } from "@fresh/core"; import { getAvatarUrl } from "@hackerspub/models/account"; import { getArticleSource, + getArticleSourceMediumUrls, getOriginalArticleContent, startArticleContentSummary, updateArticle, @@ -132,12 +133,14 @@ export async function handleArticle( content: ArticleContent, permalink: URL, ): Promise { + const disk = drive.use(); const rendered = await renderMarkup( ctx.state.fedCtx, content.content, { docId: article.id, kv, + mediumUrls: await getArticleSourceMediumUrls(db, disk, article.id), refresh: ctx.url.searchParams.has("refresh") && ctx.state.account?.moderator, }, @@ -228,7 +231,6 @@ export async function handleArticle( where: { replyTargetId: article.post.id }, orderBy: { published: "asc" }, }); - const disk = drive.use(); return { article, content, From 499b293e127ae140fc55055067025757e88f55e5 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 07:16:46 +0900 Subject: [PATCH 04/32] Copy article media before publish Let article creation persist referenced medium mappings before rendering the post or sending ActivityPub Create. Both GraphQL and legacy draft publish now pass draft media through the shared model path. Assisted-by: Codex:gpt-5.5 --- graphql/post.ts | 22 ++------- models/article.lifecycle.test.ts | 48 ++++++++++++++++++- models/article.ts | 19 +++++++- .../@[username]/drafts/[draftId]/publish.ts | 4 ++ 4 files changed, 74 insertions(+), 19 deletions(-) diff --git a/graphql/post.ts b/graphql/post.ts index b20471c4c..39cbb36ec 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -48,7 +48,6 @@ import { articleContentTable, articleDraftMediumTable, articleDraftTable, - articleSourceMediumTable, } from "@hackerspub/models/schema"; import type * as schema from "@hackerspub/models/schema"; import { withTransaction } from "@hackerspub/models/tx"; @@ -1085,6 +1084,10 @@ builder.relayMutationField( // Create article from draft const article = await withTransaction(ctx.fedCtx, async (context) => { + const media = await context.data.db.query.articleDraftMediumTable + .findMany({ + where: { articleDraftId: draft.id }, + }); return await createArticle(context, { accountId: session.accountId, publishedYear: new Date().getFullYear(), @@ -1094,6 +1097,7 @@ builder.relayMutationField( title: draft.title, content: draft.content, language: language.baseName, + media, }); }); @@ -1101,22 +1105,6 @@ builder.relayMutationField( throw new Error("Failed to publish article"); } - // Migrate media tracking from draft to published article. - const draftMedia = await ctx.db.query.articleDraftMediumTable.findMany({ - where: { articleDraftId: draft.id }, - }); - const publishedMedia = draftMedia - .filter((medium) => draft.content.includes(`hp-medium:${medium.key}`)) - .map((medium) => ({ - articleSourceId: article.articleSource.id, - key: medium.key, - mediumId: medium.mediumId, - })); - if (publishedMedia.length > 0) { - await ctx.db.insert(articleSourceMediumTable).values(publishedMedia) - .onConflictDoNothing(); - } - // Delete draft after successful publish await deleteArticleDraft(ctx.db, session.accountId, draft.id); diff --git a/models/article.lifecycle.test.ts b/models/article.lifecycle.test.ts index 067a31eb2..858719477 100644 --- a/models/article.lifecycle.test.ts +++ b/models/article.lifecycle.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createArticle, updateArticle } from "./article.ts"; -import { articleContentTable } from "./schema.ts"; +import { articleContentTable, mediumTable } from "./schema.ts"; +import { generateUuidV7 } from "./uuid.ts"; import { createFedCtx, insertAccountWithActor, @@ -56,6 +57,51 @@ test("createArticle() creates a post and timeline entry for the author", async ( }); }); +test("createArticle() copies source media before rendering the post", async () => { + await withRollback(async (tx) => { + const fedCtx = createFedCtx(tx); + fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + const author = await insertAccountWithActor(tx, { + username: "createarticlemediaauthor", + name: "Create Article Media Author", + email: "createarticlemediaauthor@example.com", + }); + const mediumId = generateUuidV7(); + await tx.insert(mediumTable).values({ + id: mediumId, + key: "media/create-article-media.webp", + type: "image/webp", + width: 2, + height: 2, + }); + + const article = await createArticle(fedCtx, { + accountId: author.account.id, + publishedYear: 2026, + slug: "create-article-media", + tags: [], + allowLlmTranslation: false, + title: "Article with media", + content: "![Hero](hp-medium:hero)", + language: "en", + media: [{ key: "hero", mediumId }], + }); + + assert.ok(article != null); + assert.match( + article.contentHtml, + /http:\/\/localhost\/media\/media\/create-article-media\.webp/, + ); + assert.doesNotMatch(article.contentHtml, /hp-medium:hero/); + + const media = await tx.query.articleSourceMediumTable.findFirst({ + where: { articleSourceId: article.articleSource.id, key: "hero" }, + }); + assert.ok(media != null); + assert.equal(media.mediumId, mediumId); + }); +}); + test("updateArticle() rewrites the persisted article post", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); diff --git a/models/article.ts b/models/article.ts index 407f32ed7..aa2fa5176 100644 --- a/models/article.ts +++ b/models/article.ts @@ -26,6 +26,7 @@ import { type ArticleDraft, articleDraftTable, type ArticleSource, + articleSourceMediumTable, articleSourceTable, type Blocking, type Following, @@ -271,6 +272,10 @@ export async function createArticle( title: string; content: string; language: string; + media?: readonly { + key: string; + mediumId: Uuid; + }[]; }, ): Promise< Post & { @@ -285,12 +290,24 @@ export async function createArticle( } | undefined > { const { db } = fedCtx.data; + const { media: sourceMedia, ...articleSourceInput } = source; const articleSource = await createArticleSource( db, fedCtx.data.models, - source, + articleSourceInput, ); if (articleSource == null) return undefined; + const media = sourceMedia + ?.filter((medium) => source.content.includes(`hp-medium:${medium.key}`)) + .map((medium) => ({ + articleSourceId: articleSource.id, + key: medium.key, + mediumId: medium.mediumId, + })) ?? []; + if (media.length > 0) { + await db.insert(articleSourceMediumTable).values(media) + .onConflictDoNothing(); + } const account = await db.query.accountTable.findFirst({ where: { id: source.accountId }, with: { avatarMedium: true, emails: true, links: true }, diff --git a/web/routes/@[username]/drafts/[draftId]/publish.ts b/web/routes/@[username]/drafts/[draftId]/publish.ts index 1b1b202fc..9a38e1c4c 100644 --- a/web/routes/@[username]/drafts/[draftId]/publish.ts +++ b/web/routes/@[username]/drafts/[draftId]/publish.ts @@ -34,6 +34,9 @@ export const handler = define.handlers({ { status: 400, headers: { "Content-Type": "application/json" } }, ); } + const media = await db.query.articleDraftMediumTable.findMany({ + where: { articleDraftId: draft.id }, + }); const post = await createArticle(ctx.state.fedCtx, { accountId: ctx.state.session.accountId, title: draft.title, @@ -42,6 +45,7 @@ export const handler = define.handlers({ slug: result.output.slug, language: result.output.language, allowLlmTranslation: result.output.allowLlmTranslation, + media, }); if (post == null) { return new Response( From f2ba4b85880455599f43abc5b6a549221ace62e3 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 07:19:52 +0900 Subject: [PATCH 05/32] Require avatar media for actor sync Make avatarMedium a required nullable relation for actor sync inputs and load it at note and session call sites. This prevents note refresh paths from replacing uploaded avatar URLs with fallback images. Assisted-by: Codex:gpt-5.5 --- graphql/builder.ts | 8 +++++++- graphql/server.ts | 3 +++ graphql/signup.ts | 1 + models/actor.ts | 4 ++-- models/note.ts | 6 +++--- models/post.sync.test.ts | 25 +++++++++++++++++++++---- models/post.ts | 14 ++++++++------ test/postgres.ts | 1 + web/routes/sign/in/[token].tsx | 1 + web/routes/sign/up/[token].tsx | 1 + web/utils.ts | 3 +++ 11 files changed, 51 insertions(+), 16 deletions(-) diff --git a/graphql/builder.ts b/graphql/builder.ts index 1d4c7d5dc..5089a4da5 100644 --- a/graphql/builder.ts +++ b/graphql/builder.ts @@ -33,6 +33,7 @@ import type { AccountEmail, AccountLink, Actor, + Medium, } from "@hackerspub/models/schema"; import type { Session } from "@hackerspub/models/session"; import type { Uuid } from "@hackerspub/models/uuid"; @@ -58,7 +59,12 @@ export interface AdminAccountStats { export interface UserContext extends ServerContext { session: Session | undefined; account: - | Account & { actor: Actor; emails: AccountEmail[]; links: AccountLink[] } + | Account & { + actor: Actor; + avatarMedium: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + } | undefined; pollViewerVotes?: Map>>; adminAccountStatsLoader?: DataLoader; diff --git a/graphql/server.ts b/graphql/server.ts index 52936254c..020c24862 100644 --- a/graphql/server.ts +++ b/graphql/server.ts @@ -4,6 +4,7 @@ import type { AccountEmail, AccountLink, Actor, + Medium, } from "@hackerspub/models/schema"; import { getSession } from "@hackerspub/models/session"; import { type Uuid, validateUuid } from "@hackerspub/models/uuid"; @@ -44,6 +45,7 @@ export function createYogaServer(): YogaServerInstance< let account: | Account & { actor: Actor; + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; } @@ -54,6 +56,7 @@ export function createYogaServer(): YogaServerInstance< where: { id: session.accountId }, with: { actor: true, + avatarMedium: true, emails: true, links: true, }, diff --git a/graphql/signup.ts b/graphql/signup.ts index f8503f250..4ea3db0be 100644 --- a/graphql/signup.ts +++ b/graphql/signup.ts @@ -276,6 +276,7 @@ builder.mutationFields((t) => ({ const actor = await syncActorFromAccount(ctx.fedCtx, { ...account, + avatarMedium: null, links: [], }); diff --git a/models/actor.ts b/models/actor.ts index 881828d42..1dc34da51 100644 --- a/models/actor.ts +++ b/models/actor.ts @@ -80,14 +80,14 @@ async function mapWithConcurrencyLimit( export async function syncActorFromAccount( fedCtx: Context, account: Account & { - avatarMedium?: Medium | null; + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }, ): Promise< Actor & { account: Account & { - avatarMedium?: Medium | null; + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }; diff --git a/models/note.ts b/models/note.ts index 8d092695b..4a2c5d6f7 100644 --- a/models/note.ts +++ b/models/note.ts @@ -134,7 +134,7 @@ export async function getNoteSource( return await db.query.noteSourceTable.findFirst({ with: { account: { - with: { emails: true, links: true }, + with: { avatarMedium: true, emails: true, links: true }, }, post: { with: { @@ -339,7 +339,7 @@ export async function createNote( } const account = await db.query.accountTable.findFirst({ where: { id: source.accountId }, - with: { emails: true, links: true }, + with: { avatarMedium: true, emails: true, links: true }, }); if (account == undefined) return undefined; const post = await syncPostFromNoteSource(fedCtx, { @@ -491,7 +491,7 @@ export async function updateNote( if (noteSource == null) return undefined; const account = await db.query.accountTable.findFirst({ where: { id: noteSource.accountId }, - with: { emails: true, links: true }, + with: { avatarMedium: true, emails: true, links: true }, }); const media = await db.query.noteSourceMediumTable.findMany({ where: { sourceId: noteSourceId }, diff --git a/models/post.sync.test.ts b/models/post.sync.test.ts index 5b791ddd8..9fad584af 100644 --- a/models/post.sync.test.ts +++ b/models/post.sync.test.ts @@ -2,8 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { eq } from "drizzle-orm"; import { + accountTable, articleContentTable, articleSourceTable, + mediumTable, noteSourceTable, } from "./schema.ts"; import { syncPostFromArticleSource, syncPostFromNoteSource } from "./post.ts"; @@ -48,7 +50,7 @@ test("syncPostFromArticleSource() upserts the post when source content changes", const source = await tx.query.articleSourceTable.findFirst({ where: { id: sourceId }, with: { - account: { with: { emails: true, links: true } }, + account: { with: { avatarMedium: true, emails: true, links: true } }, contents: true, }, }); @@ -71,7 +73,7 @@ test("syncPostFromArticleSource() upserts the post when source content changes", const updatedSource = await tx.query.articleSourceTable.findFirst({ where: { id: sourceId }, with: { - account: { with: { emails: true, links: true } }, + account: { with: { avatarMedium: true, emails: true, links: true } }, contents: true, }, }); @@ -102,6 +104,17 @@ test("syncPostFromNoteSource() upserts note posts and updates quote counts", asy account: quotedAuthor.account, content: "Quoted target", }); + const avatarMediumId = generateUuidV7(); + await tx.insert(mediumTable).values({ + id: avatarMediumId, + key: "avatars/sync-note-owner.webp", + type: "image/webp", + width: 2, + height: 2, + }); + await tx.update(accountTable) + .set({ avatarMediumId }) + .where(eq(accountTable.id, author.account.id)); const noteSourceId = generateUuidV7(); const published = new Date("2026-04-15T00:00:00.000Z"); @@ -118,7 +131,7 @@ test("syncPostFromNoteSource() upserts note posts and updates quote counts", asy const noteSource = await tx.query.noteSourceTable.findFirst({ where: { id: noteSourceId }, with: { - account: { with: { emails: true, links: true } }, + account: { with: { avatarMedium: true, emails: true, links: true } }, media: { with: { medium: true } }, }, }); @@ -130,6 +143,10 @@ test("syncPostFromNoteSource() upserts note posts and updates quote counts", asy assert.equal(created.noteSourceId, noteSourceId); assert.equal(created.quotedPost?.id, quotedPost.id); + assert.equal( + created.actor.avatarUrl, + "http://localhost/media/avatars/sync-note-owner.webp", + ); assert.ok("hackerspub" in created.tags); const quotedAfterCreate = await tx.query.postTable.findFirst({ @@ -145,7 +162,7 @@ test("syncPostFromNoteSource() upserts note posts and updates quote counts", asy const updatedSource = await tx.query.noteSourceTable.findFirst({ where: { id: noteSourceId }, with: { - account: { with: { emails: true, links: true } }, + account: { with: { avatarMedium: true, emails: true, links: true } }, media: { with: { medium: true } }, }, }); diff --git a/models/post.ts b/models/post.ts index eba16ddc5..fa3539481 100644 --- a/models/post.ts +++ b/models/post.ts @@ -151,7 +151,7 @@ export async function syncPostFromArticleSource( fedCtx: Context, articleSource: ArticleSource & { account: Account & { - avatarMedium?: Medium | null; + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }; @@ -161,7 +161,7 @@ export async function syncPostFromArticleSource( Post & { actor: Actor & { account: Account & { - avatarMedium?: Medium | null; + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }; @@ -169,7 +169,7 @@ export async function syncPostFromArticleSource( }; articleSource: ArticleSource & { account: Account & { - avatarMedium?: Medium | null; + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }; @@ -241,7 +241,7 @@ export async function syncPostFromNoteSource( fedCtx: Context, noteSource: NoteSource & { account: Account & { - avatarMedium?: Medium | null; + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }; @@ -255,7 +255,7 @@ export async function syncPostFromNoteSource( Post & { actor: Actor & { account: Account & { - avatarMedium?: Medium | null; + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }; @@ -263,7 +263,7 @@ export async function syncPostFromNoteSource( }; noteSource: NoteSource & { account: Account & { - avatarMedium?: Medium | null; + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }; @@ -913,6 +913,7 @@ async function getOriginalSharedPost( export async function sharePost( fedCtx: Context, account: Account & { + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }, @@ -997,6 +998,7 @@ export async function sharePost( export async function unsharePost( fedCtx: Context, account: Account & { + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }, diff --git a/test/postgres.ts b/test/postgres.ts index dc3dfd95a..739bcc6a0 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -119,6 +119,7 @@ export async function insertAccountWithActor( where: { id: accountId }, with: { actor: true, + avatarMedium: true, emails: true, links: true, }, diff --git a/web/routes/sign/in/[token].tsx b/web/routes/sign/in/[token].tsx index deebd4cdd..24ae24d71 100644 --- a/web/routes/sign/in/[token].tsx +++ b/web/routes/sign/in/[token].tsx @@ -37,6 +37,7 @@ export const handler = define.handlers( const account = await db.query.accountTable.findFirst({ where: { id: token.accountId }, with: { + avatarMedium: true, emails: true, links: { orderBy: { index: "asc" } }, }, diff --git a/web/routes/sign/up/[token].tsx b/web/routes/sign/up/[token].tsx index 15f306d97..1e1cf66e6 100644 --- a/web/routes/sign/up/[token].tsx +++ b/web/routes/sign/up/[token].tsx @@ -99,6 +99,7 @@ export const handler = define.handlers({ } const actor = await syncActorFromAccount(ctx.state.fedCtx, { ...account, + avatarMedium: null, links: [], }); await deleteSignupToken(kv, token.token); diff --git a/web/utils.ts b/web/utils.ts index 02324e96c..488e821b8 100644 --- a/web/utils.ts +++ b/web/utils.ts @@ -8,6 +8,7 @@ import type { AccountEmail, AccountLink, Actor, + Medium, } from "@hackerspub/models/schema"; import type { Session } from "@hackerspub/models/session"; import type { QueryGraphQL } from "./graphql/gql.ts"; @@ -38,6 +39,7 @@ export interface State { account: | (Account & { actor: Actor; + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }) @@ -46,6 +48,7 @@ export interface State { session?: Session; account?: Account & { actor: Actor; + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }; From 4b095a581fd2cf087fbbcdf29b66ffea2c74d71d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 11:43:01 +0900 Subject: [PATCH 06/32] Rewrite every migrated article image Apply article media URL replacements recursively during the unified medium migration. This converts every matching URL for multi-image drafts and article contents instead of relying on UPDATE ... FROM row selection. Assisted-by: Codex:gpt-5.5 --- drizzle/0098_unified_medium.sql | 88 ++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/drizzle/0098_unified_medium.sql b/drizzle/0098_unified_medium.sql index e1679d772..c5ffac2cc 100644 --- a/drizzle/0098_unified_medium.sql +++ b/drizzle/0098_unified_medium.sql @@ -194,16 +194,90 @@ SET "avatar_medium_id" = m."id" FROM "medium" m WHERE a."avatar_key" = m."key"; --> statement-breakpoint +WITH RECURSIVE "article_draft_medium_replacements" AS ( + SELECT + am."article_draft_id", + am."url", + am."key", + row_number() OVER ( + PARTITION BY am."article_draft_id" + ORDER BY am."created", am."key" + ) AS "index" + FROM "article_medium" am + WHERE am."article_draft_id" IS NOT NULL AND am."url" IS NOT NULL +), +"rewritten_article_draft" AS ( + SELECT ad."id", ad."content", 0::bigint AS "index" + FROM "article_draft" ad + WHERE EXISTS ( + SELECT 1 + FROM "article_draft_medium_replacements" r + WHERE r."article_draft_id" = ad."id" + ) + UNION ALL + SELECT + draft."id", + replace(draft."content", r."url", 'hp-medium:' || r."key"), + r."index" + FROM "rewritten_article_draft" draft + JOIN "article_draft_medium_replacements" r + ON r."article_draft_id" = draft."id" AND + r."index" = draft."index" + 1 +), +"final_article_draft" AS ( + SELECT DISTINCT ON ("id") "id", "content" + FROM "rewritten_article_draft" + ORDER BY "id", "index" DESC +) UPDATE "article_draft" ad -SET "content" = replace(ad."content", am."url", 'hp-medium:' || am."key") -FROM "article_medium" am -WHERE am."article_draft_id" = ad."id"; +SET "content" = f."content" +FROM "final_article_draft" f +WHERE f."id" = ad."id"; --> statement-breakpoint +WITH RECURSIVE "article_content_medium_replacements" AS ( + SELECT + am."article_source_id", + am."url", + am."key", + row_number() OVER ( + PARTITION BY am."article_source_id" + ORDER BY am."created", am."key" + ) AS "index" + FROM "article_medium" am + WHERE am."article_source_id" IS NOT NULL AND am."url" IS NOT NULL +), +"rewritten_article_content" AS ( + SELECT + ac."source_id", + ac."language", + ac."content", + 0::bigint AS "index" + FROM "article_content" ac + WHERE EXISTS ( + SELECT 1 + FROM "article_content_medium_replacements" r + WHERE r."article_source_id" = ac."source_id" + ) + UNION ALL + SELECT + content."source_id", + content."language", + replace(content."content", r."url", 'hp-medium:' || r."key"), + r."index" + FROM "rewritten_article_content" content + JOIN "article_content_medium_replacements" r + ON r."article_source_id" = content."source_id" AND + r."index" = content."index" + 1 +), +"final_article_content" AS ( + SELECT DISTINCT ON ("source_id", "language") "source_id", "language", "content" + FROM "rewritten_article_content" + ORDER BY "source_id", "language", "index" DESC +) UPDATE "article_content" ac -SET "content" = replace(ac."content", am."url", 'hp-medium:' || am."key") -FROM "article_source" src -JOIN "article_medium" am ON am."article_source_id" = src."id" -WHERE ac."source_id" = src."id"; +SET "content" = f."content" +FROM "final_article_content" f +WHERE f."source_id" = ac."source_id" AND f."language" = ac."language"; --> statement-breakpoint DROP TABLE "note_medium"; --> statement-breakpoint From ae132e4b47688af5ec359e8a7c4eda2a381dbef1 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 12:17:36 +0900 Subject: [PATCH 07/32] Preserve avatar media transforms Apply the existing avatar crop and size cap before storing avatar media from legacy uploads, deprecated avatarUrl updates, and avatarMediumId assignments. Add the missing Drizzle snapshot for the unified medium migration so future schema diffs have the right baseline. Assisted-by: Codex:gpt-5.5 --- drizzle/meta/0098_snapshot.json | 4143 +++++++++++++++++++++ graphql/account.test.ts | 109 + graphql/account.ts | 20 +- models/account.ts | 55 +- models/medium.ts | 37 +- test/postgres.ts | 9 +- web/routes/@[username]/settings/index.tsx | 11 +- 7 files changed, 4369 insertions(+), 15 deletions(-) create mode 100644 drizzle/meta/0098_snapshot.json diff --git a/drizzle/meta/0098_snapshot.json b/drizzle/meta/0098_snapshot.json new file mode 100644 index 000000000..7c2127e5e --- /dev/null +++ b/drizzle/meta/0098_snapshot.json @@ -0,0 +1,4143 @@ +{ + "id": "4126d275-1618-4c02-81ec-75bd6741a271", + "prevId": "b3b97f99-efd6-4823-b04f-2054c3d05cbc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_email": { + "name": "account_email", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified": { + "name": "verified", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_account_email_lower_email": { + "name": "idx_account_email_lower_email", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_email_account_id_account_id_fk": { + "name": "account_email_account_id_account_id_fk", + "tableFrom": "account_email", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_key": { + "name": "account_key", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private": { + "name": "private", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "account_key_account_id_account_id_fk": { + "name": "account_key_account_id_account_id_fk", + "tableFrom": "account_key", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_key_account_id_type_pk": { + "name": "account_key_account_id_type_pk", + "columns": [ + "account_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "account_key_public_check": { + "name": "account_key_public_check", + "value": "\"account_key\".\"public\" IS JSON OBJECT" + }, + "account_key_private_check": { + "name": "account_key_private_check", + "value": "\"account_key\".\"private\" IS JSON OBJECT" + } + }, + "isRLSEnabled": false + }, + "public.account_link": { + "name": "account_link", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "index": { + "name": "index", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "account_link_icon", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "verified": { + "name": "verified", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "account_link_account_id_account_id_fk": { + "name": "account_link_account_id_account_id_fk", + "tableFrom": "account_link", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_link_account_id_index_pk": { + "name": "account_link_account_id_index_pk", + "columns": [ + "account_id", + "index" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "account_link_name_check": { + "name": "account_link_name_check", + "value": "\n char_length(\"account_link\".\"name\") <= 50 AND\n \"account_link\".\"name\" !~ '^[[:space:]]' AND\n \"account_link\".\"name\" !~ '[[:space:]]$'\n " + } + }, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "old_username": { + "name": "old_username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "username_changed": { + "name": "username_changed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_medium_id": { + "name": "avatar_medium_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "og_image_key": { + "name": "og_image_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locales": { + "name": "locales", + "type": "varchar[]", + "primaryKey": false, + "notNull": false + }, + "moderator": { + "name": "moderator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notification_read": { + "name": "notification_read", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "left_invitations": { + "name": "left_invitations", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "hide_from_invitation_tree": { + "name": "hide_from_invitation_tree", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hide_foreign_languages": { + "name": "hide_foreign_languages", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "prefer_ai_summary": { + "name": "prefer_ai_summary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "note_visibility": { + "name": "note_visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "share_visibility": { + "name": "share_visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "account_avatar_medium_id_medium_id_fk": { + "name": "account_avatar_medium_id_medium_id_fk", + "tableFrom": "account", + "tableTo": "medium", + "columnsFrom": [ + "avatar_medium_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "account_inviter_id_account_id_fk": { + "name": "account_inviter_id_account_id_fk", + "tableFrom": "account", + "tableTo": "account", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_username_unique": { + "name": "account_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "account_og_image_key_unique": { + "name": "account_og_image_key_unique", + "nullsNotDistinct": false, + "columns": [ + "og_image_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "account_username_check": { + "name": "account_username_check", + "value": "\"account\".\"username\" ~ '^[a-z0-9_]{1,50}$'" + }, + "account_name_check": { + "name": "account_name_check", + "value": "\n char_length(\"account\".\"name\") <= 50 AND\n \"account\".\"name\" !~ '^[[:space:]]' AND\n \"account\".\"name\" !~ '[[:space:]]$'\n " + } + }, + "isRLSEnabled": false + }, + "public.actor": { + "name": "actor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_host": { + "name": "instance_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle_host": { + "name": "handle_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "'@' || \"actor\".\"username\" || '@' || \"actor\".\"handle_host\"", + "type": "stored" + } + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio_html": { + "name": "bio_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "automatically_approves_followers": { + "name": "automatically_approves_followers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "header_url": { + "name": "header_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_url": { + "name": "inbox_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_inbox_url": { + "name": "shared_inbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "followers_url": { + "name": "followers_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "featured_url": { + "name": "featured_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "field_htmls": { + "name": "field_htmls", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "emojis": { + "name": "emojis", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "successor_id": { + "name": "successor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "(ARRAY[]::text[])" + }, + "followees_count": { + "name": "followees_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "followers_count": { + "name": "followers_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "posts_count": { + "name": "posts_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "actor_instance_host_instance_host_fk": { + "name": "actor_instance_host_instance_host_fk", + "tableFrom": "actor", + "tableTo": "instance", + "columnsFrom": [ + "instance_host" + ], + "columnsTo": [ + "host" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "actor_account_id_account_id_fk": { + "name": "actor_account_id_account_id_fk", + "tableFrom": "actor", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "actor_successor_id_actor_id_fk": { + "name": "actor_successor_id_actor_id_fk", + "tableFrom": "actor", + "tableTo": "actor", + "columnsFrom": [ + "successor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "actor_iri_unique": { + "name": "actor_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + }, + "actor_account_id_unique": { + "name": "actor_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id" + ] + }, + "actor_username_instance_host_unique": { + "name": "actor_username_instance_host_unique", + "nullsNotDistinct": false, + "columns": [ + "username", + "instance_host" + ] + } + }, + "policies": {}, + "checkConstraints": { + "actor_username_check": { + "name": "actor_username_check", + "value": "\"actor\".\"username\" NOT LIKE '%@%'" + } + }, + "isRLSEnabled": false + }, + "public.admin_state": { + "name": "admin_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apns_device_token": { + "name": "apns_device_token", + "schema": "", + "columns": { + "device_token": { + "name": "device_token", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "apns_device_token_account_id_index": { + "name": "apns_device_token_account_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apns_device_token_account_id_account_id_fk": { + "name": "apns_device_token_account_id_account_id_fk", + "tableFrom": "apns_device_token", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "apns_device_token_device_token_check": { + "name": "apns_device_token_device_token_check", + "value": "\"apns_device_token\".\"device_token\" ~ '^[0-9a-f]{64}$'" + } + }, + "isRLSEnabled": false + }, + "public.article_content": { + "name": "article_content", + "schema": "", + "columns": { + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_started": { + "name": "summary_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "summary_unnecessary": { + "name": "summary_unnecessary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "og_image_key": { + "name": "og_image_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_language": { + "name": "original_language", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "translator_id": { + "name": "translator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "translation_requester_id": { + "name": "translation_requester_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "being_translated": { + "name": "being_translated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "article_content_source_id_article_source_id_fk": { + "name": "article_content_source_id_article_source_id_fk", + "tableFrom": "article_content", + "tableTo": "article_source", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "article_content_translator_id_account_id_fk": { + "name": "article_content_translator_id_account_id_fk", + "tableFrom": "article_content", + "tableTo": "account", + "columnsFrom": [ + "translator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "article_content_translation_requester_id_account_id_fk": { + "name": "article_content_translation_requester_id_account_id_fk", + "tableFrom": "article_content", + "tableTo": "account", + "columnsFrom": [ + "translation_requester_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "article_content_source_id_original_language_article_content_source_id_language_fk": { + "name": "article_content_source_id_original_language_article_content_source_id_language_fk", + "tableFrom": "article_content", + "tableTo": "article_content", + "columnsFrom": [ + "source_id", + "original_language" + ], + "columnsTo": [ + "source_id", + "language" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "article_content_source_id_language_pk": { + "name": "article_content_source_id_language_pk", + "columns": [ + "source_id", + "language" + ] + } + }, + "uniqueConstraints": { + "article_content_og_image_key_unique": { + "name": "article_content_og_image_key_unique", + "nullsNotDistinct": false, + "columns": [ + "og_image_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "article_content_original_language_check": { + "name": "article_content_original_language_check", + "value": "(\n \"article_content\".\"translator_id\" IS NULL AND\n \"article_content\".\"translation_requester_id\" IS NULL\n ) = (\"article_content\".\"original_language\" IS NULL)" + }, + "article_content_translator_translation_requester_id_check": { + "name": "article_content_translator_translation_requester_id_check", + "value": "\"article_content\".\"translator_id\" IS NULL OR \"article_content\".\"translation_requester_id\" IS NULL" + }, + "article_content_being_translated_check": { + "name": "article_content_being_translated_check", + "value": "NOT \"article_content\".\"being_translated\" OR (\"article_content\".\"original_language\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.article_draft_medium": { + "name": "article_draft_medium", + "schema": "", + "columns": { + "article_draft_id": { + "name": "article_draft_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "medium_id": { + "name": "medium_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "article_draft_medium_article_draft_id_article_draft_id_fk": { + "name": "article_draft_medium_article_draft_id_article_draft_id_fk", + "tableFrom": "article_draft_medium", + "tableTo": "article_draft", + "columnsFrom": [ + "article_draft_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "article_draft_medium_medium_id_medium_id_fk": { + "name": "article_draft_medium_medium_id_medium_id_fk", + "tableFrom": "article_draft_medium", + "tableTo": "medium", + "columnsFrom": [ + "medium_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "article_draft_medium_article_draft_id_key_pk": { + "name": "article_draft_medium_article_draft_id_key_pk", + "columns": [ + "article_draft_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.article_draft": { + "name": "article_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "article_source_id": { + "name": "article_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "(ARRAY[]::text[])" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "article_draft_account_id_account_id_fk": { + "name": "article_draft_account_id_account_id_fk", + "tableFrom": "article_draft", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "article_draft_article_source_id_article_source_id_fk": { + "name": "article_draft_article_source_id_article_source_id_fk", + "tableFrom": "article_draft", + "tableTo": "article_source", + "columnsFrom": [ + "article_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.article_source_medium": { + "name": "article_source_medium", + "schema": "", + "columns": { + "article_source_id": { + "name": "article_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "medium_id": { + "name": "medium_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "article_source_medium_article_source_id_article_source_id_fk": { + "name": "article_source_medium_article_source_id_article_source_id_fk", + "tableFrom": "article_source_medium", + "tableTo": "article_source", + "columnsFrom": [ + "article_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "article_source_medium_medium_id_medium_id_fk": { + "name": "article_source_medium_medium_id_medium_id_fk", + "tableFrom": "article_source_medium", + "tableTo": "medium", + "columnsFrom": [ + "medium_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "article_source_medium_article_source_id_key_pk": { + "name": "article_source_medium_article_source_id_key_pk", + "columns": [ + "article_source_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.article_source": { + "name": "article_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "published_year": { + "name": "published_year", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(year FROM CURRENT_TIMESTAMP)" + }, + "slug": { + "name": "slug", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "(ARRAY[]::text[])" + }, + "allow_llm_translation": { + "name": "allow_llm_translation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "article_source_account_id_account_id_fk": { + "name": "article_source_account_id_account_id_fk", + "tableFrom": "article_source", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "article_source_account_id_published_year_slug_unique": { + "name": "article_source_account_id_published_year_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id", + "published_year", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "article_source_published_year_check": { + "name": "article_source_published_year_check", + "value": "\"article_source\".\"published_year\" = EXTRACT(year FROM \"article_source\".\"published\")" + } + }, + "isRLSEnabled": false + }, + "public.blocking": { + "name": "blocking", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blocker_id": { + "name": "blocker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blockee_id": { + "name": "blockee_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "blocking_blocker_id_actor_id_fk": { + "name": "blocking_blocker_id_actor_id_fk", + "tableFrom": "blocking", + "tableTo": "actor", + "columnsFrom": [ + "blocker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocking_blockee_id_actor_id_fk": { + "name": "blocking_blockee_id_actor_id_fk", + "tableFrom": "blocking", + "tableTo": "actor", + "columnsFrom": [ + "blockee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blocking_iri_unique": { + "name": "blocking_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + }, + "blocking_blocker_id_blockee_id_unique": { + "name": "blocking_blocker_id_blockee_id_unique", + "nullsNotDistinct": false, + "columns": [ + "blocker_id", + "blockee_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "blocking_blocker_blockee_check": { + "name": "blocking_blocker_blockee_check", + "value": "\"blocking\".\"blocker_id\" != \"blocking\".\"blockee_id\"" + } + }, + "isRLSEnabled": false + }, + "public.bookmark": { + "name": "bookmark", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_bookmark_account_created": { + "name": "idx_bookmark_account_created", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"post_id\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookmark_post_id_index": { + "name": "bookmark_post_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookmark_account_id_account_id_fk": { + "name": "bookmark_account_id_account_id_fk", + "tableFrom": "bookmark", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmark_post_id_post_id_fk": { + "name": "bookmark_post_id_post_id_fk", + "tableFrom": "bookmark", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmark_account_id_post_id_pk": { + "name": "bookmark_account_id_post_id_pk", + "columns": [ + "account_id", + "post_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_emoji": { + "name": "custom_emoji", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_type": { + "name": "image_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_emoji_iri_unique": { + "name": "custom_emoji_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + } + }, + "policies": {}, + "checkConstraints": { + "custom_emoji_name_check": { + "name": "custom_emoji_name_check", + "value": "\"custom_emoji\".\"name\" ~ '^:[^:[:space:]]+:$'" + }, + "custom_emoji_image_type_check": { + "name": "custom_emoji_image_type_check", + "value": "\n CASE\n WHEN \"custom_emoji\".\"image_type\" IS NULL THEN true\n ELSE \"custom_emoji\".\"image_type\" ~ '^image/'\n END\n " + }, + "custom_emoji_image_url_check": { + "name": "custom_emoji_image_url_check", + "value": "\"custom_emoji\".\"image_url\" ~ '^https?://'" + } + }, + "isRLSEnabled": false + }, + "public.fcm_device_token": { + "name": "fcm_device_token", + "schema": "", + "columns": { + "device_token": { + "name": "device_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "fcm_device_token_account_id_index": { + "name": "fcm_device_token_account_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fcm_device_token_account_id_account_id_fk": { + "name": "fcm_device_token_account_id_account_id_fk", + "tableFrom": "fcm_device_token", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.following": { + "name": "following", + "schema": "", + "columns": { + "iri": { + "name": "iri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "followee_id": { + "name": "followee_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accepted": { + "name": "accepted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "following_follower_id_index": { + "name": "following_follower_id_index", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "following_follower_id_actor_id_fk": { + "name": "following_follower_id_actor_id_fk", + "tableFrom": "following", + "tableTo": "actor", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "following_followee_id_actor_id_fk": { + "name": "following_followee_id_actor_id_fk", + "tableFrom": "following", + "tableTo": "actor", + "columnsFrom": [ + "followee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "following_follower_id_followee_id_unique": { + "name": "following_follower_id_followee_id_unique", + "nullsNotDistinct": false, + "columns": [ + "follower_id", + "followee_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance": { + "name": "instance", + "schema": "", + "columns": { + "host": { + "name": "host", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "software": { + "name": "software", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "instance_host_check": { + "name": "instance_host_check", + "value": "\"instance\".\"host\" NOT LIKE '%@%'" + } + }, + "isRLSEnabled": false + }, + "public.invitation_link": { + "name": "invitation_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invitations_left": { + "name": "invitations_left", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_link_inviter_id_account_id_fk": { + "name": "invitation_link_inviter_id_account_id_fk", + "tableFrom": "invitation_link", + "tableTo": "account", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.medium": { + "name": "medium", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "medium_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "medium_key_unique": { + "name": "medium_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + }, + "medium_content_hash_unique": { + "name": "medium_content_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "content_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "medium_width_height_check": { + "name": "medium_width_height_check", + "value": "\n CASE\n WHEN \"medium\".\"width\" IS NULL THEN \"medium\".\"height\" IS NULL\n ELSE \"medium\".\"height\" IS NOT NULL AND\n \"medium\".\"width\" > 0 AND \"medium\".\"height\" > 0\n END\n " + } + }, + "isRLSEnabled": false + }, + "public.mention": { + "name": "mention", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mention_actor_id_index": { + "name": "mention_actor_id_index", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mention_post_id_post_id_fk": { + "name": "mention_post_id_post_id_fk", + "tableFrom": "mention", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mention_actor_id_actor_id_fk": { + "name": "mention_actor_id_actor_id_fk", + "tableFrom": "mention", + "tableTo": "actor", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mention_post_id_actor_id_pk": { + "name": "mention_post_id_actor_id_pk", + "columns": [ + "post_id", + "actor_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.note_source_medium": { + "name": "note_source_medium", + "schema": "", + "columns": { + "note_source_id": { + "name": "note_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "index": { + "name": "index", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "medium_id": { + "name": "medium_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "note_source_medium_note_source_id_note_source_id_fk": { + "name": "note_source_medium_note_source_id_note_source_id_fk", + "tableFrom": "note_source_medium", + "tableTo": "note_source", + "columnsFrom": [ + "note_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "note_source_medium_medium_id_medium_id_fk": { + "name": "note_source_medium_medium_id_medium_id_fk", + "tableFrom": "note_source_medium", + "tableTo": "medium", + "columnsFrom": [ + "medium_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "note_source_medium_note_source_id_index_pk": { + "name": "note_source_medium_note_source_id_index_pk", + "columns": [ + "note_source_id", + "index" + ] + } + }, + "uniqueConstraints": { + "note_source_medium_note_source_id_medium_id_unique": { + "name": "note_source_medium_note_source_id_medium_id_unique", + "nullsNotDistinct": false, + "columns": [ + "note_source_id", + "medium_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "note_source_medium_index_check": { + "name": "note_source_medium_index_check", + "value": "\"note_source_medium\".\"index\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.note_source": { + "name": "note_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "note_source_account_id_account_id_fk": { + "name": "note_source_account_id_account_id_fk", + "tableFrom": "note_source", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_ids": { + "name": "actor_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "(ARRAY[]::uuid[])" + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_emoji_id": { + "name": "custom_emoji_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_notification_account_id_created": { + "name": "idx_notification_account_id_created", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_post_id_index": { + "name": "notification_post_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"notification\".\"post_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_account_id_actor_ids_index": { + "name": "notification_account_id_actor_ids_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notification\".\"type\" = 'follow'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_account_id_post_id_index": { + "name": "notification_account_id_post_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notification\".\"type\" NOT IN ('follow', 'react')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_account_id_post_id_emoji_index": { + "name": "notification_account_id_post_id_emoji_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emoji", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notification\".\"type\" = 'react' AND \"notification\".\"custom_emoji_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_account_id_post_id_custom_emoji_id_index": { + "name": "notification_account_id_post_id_custom_emoji_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "custom_emoji_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notification\".\"type\" = 'react' AND \"notification\".\"emoji\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_account_id_account_id_fk": { + "name": "notification_account_id_account_id_fk", + "tableFrom": "notification", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_post_id_post_id_fk": { + "name": "notification_post_id_post_id_fk", + "tableFrom": "notification", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_custom_emoji_id_custom_emoji_id_fk": { + "name": "notification_custom_emoji_id_custom_emoji_id_fk", + "tableFrom": "notification", + "tableTo": "custom_emoji", + "columnsFrom": [ + "custom_emoji_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "notification_post_id_check": { + "name": "notification_post_id_check", + "value": "\n CASE \"notification\".\"type\"\n WHEN 'follow' THEN \"notification\".\"post_id\" IS NULL\n ELSE \"notification\".\"post_id\" IS NOT NULL\n END\n " + }, + "notification_emoji_check": { + "name": "notification_emoji_check", + "value": "\n CASE \"notification\".\"type\"\n WHEN 'react'\n THEN \"notification\".\"emoji\" IS NOT NULL AND \"notification\".\"custom_emoji_id\" IS NULL\n OR \"notification\".\"emoji\" IS NULL AND \"notification\".\"custom_emoji_id\" IS NOT NULL\n ELSE \"notification\".\"emoji\" IS NULL AND \"notification\".\"custom_emoji_id\" IS NULL\n END\n " + } + }, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "webauthn_user_id": { + "name": "webauthn_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "passkey_device_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "passkey_transport[]", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "passkey_account_id_index": { + "name": "passkey_account_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_webauthn_user_id_index": { + "name": "passkey_webauthn_user_id_index", + "columns": [ + { + "expression": "webauthn_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_account_id_account_id_fk": { + "name": "passkey_account_id_account_id_fk", + "tableFrom": "passkey", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_account_id_webauthn_user_id_unique": { + "name": "passkey_account_id_webauthn_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id", + "webauthn_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "passkey_name_check": { + "name": "passkey_name_check", + "value": "\"passkey\".\"name\" !~ '^[[:space:]]*$'" + } + }, + "isRLSEnabled": false + }, + "public.pin": { + "name": "pin", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "pin_actor_id_index": { + "name": "pin_actor_id_index", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pin_actor_id_actor_id_fk": { + "name": "pin_actor_id_actor_id_fk", + "tableFrom": "pin", + "tableTo": "actor", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pin_post_id_actor_id_post_id_actor_id_fk": { + "name": "pin_post_id_actor_id_post_id_actor_id_fk", + "tableFrom": "pin", + "tableTo": "post", + "columnsFrom": [ + "post_id", + "actor_id" + ], + "columnsTo": [ + "id", + "actor_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pin_post_id_actor_id_pk": { + "name": "pin_post_id_actor_id_pk", + "columns": [ + "post_id", + "actor_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_option": { + "name": "poll_option", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "index": { + "name": "index", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "votes_count": { + "name": "votes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "poll_option_post_id_poll_post_id_fk": { + "name": "poll_option_post_id_poll_post_id_fk", + "tableFrom": "poll_option", + "tableTo": "poll", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "post_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "poll_option_post_id_index_pk": { + "name": "poll_option_post_id_index_pk", + "columns": [ + "post_id", + "index" + ] + } + }, + "uniqueConstraints": { + "poll_option_post_id_title_unique": { + "name": "poll_option_post_id_title_unique", + "nullsNotDistinct": false, + "columns": [ + "post_id", + "title" + ] + } + }, + "policies": {}, + "checkConstraints": { + "poll_option_index_check": { + "name": "poll_option_index_check", + "value": "\"poll_option\".\"index\" >= 0" + }, + "poll_option_votes_count_check": { + "name": "poll_option_votes_count_check", + "value": "\"poll_option\".\"votes_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.poll": { + "name": "poll", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "multiple": { + "name": "multiple", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "voters_count": { + "name": "voters_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ends": { + "name": "ends", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "poll_post_id_post_id_fk": { + "name": "poll_post_id_post_id_fk", + "tableFrom": "poll", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "poll_voters_count_check": { + "name": "poll_voters_count_check", + "value": "\"poll\".\"voters_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.poll_vote": { + "name": "poll_vote", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "option_index": { + "name": "option_index", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "poll_vote_post_id_poll_post_id_fk": { + "name": "poll_vote_post_id_poll_post_id_fk", + "tableFrom": "poll_vote", + "tableTo": "poll", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "post_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "poll_vote_actor_id_actor_id_fk": { + "name": "poll_vote_actor_id_actor_id_fk", + "tableFrom": "poll_vote", + "tableTo": "actor", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "poll_vote_post_id_option_index_poll_option_post_id_index_fk": { + "name": "poll_vote_post_id_option_index_poll_option_post_id_index_fk", + "tableFrom": "poll_vote", + "tableTo": "poll_option", + "columnsFrom": [ + "post_id", + "option_index" + ], + "columnsTo": [ + "post_id", + "index" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "poll_vote_post_id_option_index_actor_id_pk": { + "name": "poll_vote_post_id_option_index_actor_id_pk", + "columns": [ + "post_id", + "option_index", + "actor_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_link": { + "name": "post_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "site_name": { + "name": "site_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_alt": { + "name": "image_alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_type": { + "name": "image_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_width": { + "name": "image_width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_height": { + "name": "image_height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "scraped": { + "name": "scraped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "post_link_creator_id_index": { + "name": "post_link_creator_id_index", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_link_creator_id_actor_id_fk": { + "name": "post_link_creator_id_actor_id_fk", + "tableFrom": "post_link", + "tableTo": "actor", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_link_url_unique": { + "name": "post_link_url_unique", + "nullsNotDistinct": false, + "columns": [ + "url" + ] + } + }, + "policies": {}, + "checkConstraints": { + "post_link_url_check": { + "name": "post_link_url_check", + "value": "\"post_link\".\"url\" ~ '^https?://'" + }, + "post_link_image_url_check": { + "name": "post_link_image_url_check", + "value": "\"post_link\".\"image_url\" ~ '^https?://'" + }, + "post_link_image_alt_check": { + "name": "post_link_image_alt_check", + "value": "\"post_link\".\"image_alt\" IS NULL OR \"post_link\".\"image_url\" IS NOT NULL" + }, + "post_link_image_type_check": { + "name": "post_link_image_type_check", + "value": "\n CASE\n WHEN \"post_link\".\"image_type\" IS NULL THEN true\n ELSE \"post_link\".\"image_type\" ~ '^image/' AND\n \"post_link\".\"image_url\" IS NOT NULL\n END\n " + }, + "post_link_image_width_height_check": { + "name": "post_link_image_width_height_check", + "value": "\n CASE\n WHEN \"post_link\".\"image_width\" IS NOT NULL\n THEN \"post_link\".\"image_url\" IS NOT NULL AND\n \"post_link\".\"image_height\" IS NOT NULL AND\n \"post_link\".\"image_width\" > 0 AND\n \"post_link\".\"image_height\" > 0\n WHEN \"post_link\".\"image_height\" IS NOT NULL\n THEN \"post_link\".\"image_url\" IS NOT NULL AND\n \"post_link\".\"image_width\" IS NOT NULL AND\n \"post_link\".\"image_width\" > 0 AND\n \"post_link\".\"image_height\" > 0\n ELSE true\n END\n " + } + }, + "isRLSEnabled": false + }, + "public.post_medium": { + "name": "post_medium", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "index": { + "name": "index", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "post_medium_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "post_medium_post_id_post_id_fk": { + "name": "post_medium_post_id_post_id_fk", + "tableFrom": "post_medium", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_medium_post_id_index_pk": { + "name": "post_medium_post_id_index_pk", + "columns": [ + "post_id", + "index" + ] + } + }, + "uniqueConstraints": { + "post_medium_thumbnail_key_unique": { + "name": "post_medium_thumbnail_key_unique", + "nullsNotDistinct": false, + "columns": [ + "thumbnail_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "post_medium_index_check": { + "name": "post_medium_index_check", + "value": "\"post_medium\".\"index\" >= 0" + }, + "post_medium_url_check": { + "name": "post_medium_url_check", + "value": "\"post_medium\".\"url\" ~ '^https?://'" + }, + "post_medium_width_height_check": { + "name": "post_medium_width_height_check", + "value": "\n CASE\n WHEN \"post_medium\".\"width\" IS NULL THEN \"post_medium\".\"height\" IS NULL\n ELSE \"post_medium\".\"height\" IS NOT NULL AND\n \"post_medium\".\"width\" > 0 AND \"post_medium\".\"height\" > 0\n END\n " + } + }, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unlisted'" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "article_source_id": { + "name": "article_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "note_source_id": { + "name": "note_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "shared_post_id": { + "name": "shared_post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reply_target_id": { + "name": "reply_target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quoted_post_id": { + "name": "quoted_post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_html": { + "name": "content_html", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "relayed_tags": { + "name": "relayed_tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "(ARRAY[]::text[])" + }, + "emojis": { + "name": "emojis", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "replies_count": { + "name": "replies_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "shares_count": { + "name": "shares_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quotes_count": { + "name": "quotes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reactions_counts": { + "name": "reactions_counts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "reactions_count": { + "name": "reactions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "json_sum_object_values(\"post\".\"reactions_counts\")", + "type": "stored" + } + }, + "link_id": { + "name": "link_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_post_visibility_published": { + "name": "idx_post_visibility_published", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"published\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_post_actor_id_published": { + "name": "idx_post_actor_id_published", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"published\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_reply_target_id_index": { + "name": "post_reply_target_id_index", + "columns": [ + { + "expression": "reply_target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_shared_post_id_index": { + "name": "post_shared_post_id_index", + "columns": [ + { + "expression": "shared_post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"post\".\"shared_post_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_quoted_post_id_index": { + "name": "post_quoted_post_id_index", + "columns": [ + { + "expression": "quoted_post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"post\".\"quoted_post_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_post_note_source_published": { + "name": "idx_post_note_source_published", + "columns": [ + { + "expression": "\"published\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"post\".\"note_source_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_post_article_source_published": { + "name": "idx_post_article_source_published", + "columns": [ + { + "expression": "\"published\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"post\".\"article_source_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_actor_id_actor_id_fk": { + "name": "post_actor_id_actor_id_fk", + "tableFrom": "post", + "tableTo": "actor", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_article_source_id_article_source_id_fk": { + "name": "post_article_source_id_article_source_id_fk", + "tableFrom": "post", + "tableTo": "article_source", + "columnsFrom": [ + "article_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_note_source_id_note_source_id_fk": { + "name": "post_note_source_id_note_source_id_fk", + "tableFrom": "post", + "tableTo": "note_source", + "columnsFrom": [ + "note_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_shared_post_id_post_id_fk": { + "name": "post_shared_post_id_post_id_fk", + "tableFrom": "post", + "tableTo": "post", + "columnsFrom": [ + "shared_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_reply_target_id_post_id_fk": { + "name": "post_reply_target_id_post_id_fk", + "tableFrom": "post", + "tableTo": "post", + "columnsFrom": [ + "reply_target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_quoted_post_id_post_id_fk": { + "name": "post_quoted_post_id_post_id_fk", + "tableFrom": "post", + "tableTo": "post", + "columnsFrom": [ + "quoted_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_link_id_post_link_id_fk": { + "name": "post_link_id_post_link_id_fk", + "tableFrom": "post", + "tableTo": "post_link", + "columnsFrom": [ + "link_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_iri_unique": { + "name": "post_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + }, + "post_article_source_id_unique": { + "name": "post_article_source_id_unique", + "nullsNotDistinct": false, + "columns": [ + "article_source_id" + ] + }, + "post_note_source_id_unique": { + "name": "post_note_source_id_unique", + "nullsNotDistinct": false, + "columns": [ + "note_source_id" + ] + }, + "post_id_actor_id_unique": { + "name": "post_id_actor_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "actor_id" + ] + }, + "post_actor_id_shared_post_id_unique": { + "name": "post_actor_id_shared_post_id_unique", + "nullsNotDistinct": false, + "columns": [ + "actor_id", + "shared_post_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "post_article_source_id_check": { + "name": "post_article_source_id_check", + "value": "\"post\".\"type\" = 'Article' OR \"post\".\"article_source_id\" IS NULL" + }, + "post_note_source_id_check": { + "name": "post_note_source_id_check", + "value": "\"post\".\"type\" = 'Note' OR \"post\".\"note_source_id\" IS NULL" + }, + "post_shared_post_id_reply_target_id_check": { + "name": "post_shared_post_id_reply_target_id_check", + "value": "\"post\".\"shared_post_id\" IS NULL OR \"post\".\"reply_target_id\" IS NULL" + }, + "post_reactions_acounts_check": { + "name": "post_reactions_acounts_check", + "value": "\"post\".\"reactions_counts\" IS JSON OBJECT" + }, + "post_link_id_check": { + "name": "post_link_id_check", + "value": "(\"post\".\"link_id\" IS NULL) = (\"post\".\"link_url\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.reaction": { + "name": "reaction", + "schema": "", + "columns": { + "iri": { + "name": "iri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_emoji_id": { + "name": "custom_emoji_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "reaction_post_id_actor_id_emoji_index": { + "name": "reaction_post_id_actor_id_emoji_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emoji", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"reaction\".\"custom_emoji_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "reaction_post_id_actor_id_custom_emoji_id_index": { + "name": "reaction_post_id_actor_id_custom_emoji_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "custom_emoji_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"reaction\".\"emoji\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "reaction_post_id_index": { + "name": "reaction_post_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reaction_post_id_post_id_fk": { + "name": "reaction_post_id_post_id_fk", + "tableFrom": "reaction", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reaction_actor_id_actor_id_fk": { + "name": "reaction_actor_id_actor_id_fk", + "tableFrom": "reaction", + "tableTo": "actor", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reaction_custom_emoji_id_custom_emoji_id_fk": { + "name": "reaction_custom_emoji_id_custom_emoji_id_fk", + "tableFrom": "reaction", + "tableTo": "custom_emoji", + "columnsFrom": [ + "custom_emoji_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "reaction_emoji_check": { + "name": "reaction_emoji_check", + "value": "\n \"reaction\".\"emoji\" IS NOT NULL\n AND length(\"reaction\".\"emoji\") > 0\n AND \"reaction\".\"emoji\" !~ '^[[:space:]:]+|[[:space:]:]+$'\n AND \"reaction\".\"custom_emoji_id\" IS NULL\n OR\n \"reaction\".\"emoji\" IS NULL AND \"reaction\".\"custom_emoji_id\" IS NOT NULL\n " + } + }, + "isRLSEnabled": false + }, + "public.timeline_item": { + "name": "timeline_item", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "original_author_id": { + "name": "original_author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_sharer_id": { + "name": "last_sharer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sharers_count": { + "name": "sharers_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "added": { + "name": "added", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "appended": { + "name": "appended", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_timeline_item_account_id_added": { + "name": "idx_timeline_item_account_id_added", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"added\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_timeline_item_account_id_appended": { + "name": "idx_timeline_item_account_id_appended", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"appended\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_item_post_id_index": { + "name": "timeline_item_post_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "timeline_item_account_id_account_id_fk": { + "name": "timeline_item_account_id_account_id_fk", + "tableFrom": "timeline_item", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "timeline_item_post_id_post_id_fk": { + "name": "timeline_item_post_id_post_id_fk", + "tableFrom": "timeline_item", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "timeline_item_original_author_id_actor_id_fk": { + "name": "timeline_item_original_author_id_actor_id_fk", + "tableFrom": "timeline_item", + "tableTo": "actor", + "columnsFrom": [ + "original_author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "timeline_item_last_sharer_id_actor_id_fk": { + "name": "timeline_item_last_sharer_id_actor_id_fk", + "tableFrom": "timeline_item", + "tableTo": "actor", + "columnsFrom": [ + "last_sharer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "timeline_item_account_id_post_id_pk": { + "name": "timeline_item_account_id_post_id_pk", + "columns": [ + "account_id", + "post_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_key_type": { + "name": "account_key_type", + "schema": "public", + "values": [ + "Ed25519", + "RSASSA-PKCS1-v1_5" + ] + }, + "public.account_link_icon": { + "name": "account_link_icon", + "schema": "public", + "values": [ + "activitypub", + "akkoma", + "bluesky", + "codeberg", + "dev", + "discord", + "facebook", + "github", + "gitlab", + "hackernews", + "hollo", + "instagram", + "keybase", + "lemmy", + "linkedin", + "lobsters", + "mastodon", + "matrix", + "misskey", + "pixelfed", + "pleroma", + "qiita", + "reddit", + "sourcehut", + "threads", + "velog", + "web", + "wikipedia", + "x", + "zenn" + ] + }, + "public.actor_type": { + "name": "actor_type", + "schema": "public", + "values": [ + "Application", + "Group", + "Organization", + "Person", + "Service" + ] + }, + "public.medium_type": { + "name": "medium_type", + "schema": "public", + "values": [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "follow", + "mention", + "reply", + "share", + "quote", + "react" + ] + }, + "public.passkey_device_type": { + "name": "passkey_device_type", + "schema": "public", + "values": [ + "singleDevice", + "multiDevice" + ] + }, + "public.passkey_transport": { + "name": "passkey_transport", + "schema": "public", + "values": [ + "ble", + "cable", + "hybrid", + "internal", + "nfc", + "smart-card", + "usb" + ] + }, + "public.post_medium_type": { + "name": "post_medium_type", + "schema": "public", + "values": [ + "image/gif", + "image/jpeg", + "image/png", + "image/svg+xml", + "image/webp", + "video/mp4", + "video/webm", + "video/quicktime" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": [ + "Article", + "Note", + "Question" + ] + }, + "public.post_visibility": { + "name": "post_visibility", + "schema": "public", + "values": [ + "public", + "unlisted", + "followers", + "direct", + "none" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/graphql/account.test.ts b/graphql/account.test.ts index fd562dfa6..26c1ad3d6 100644 --- a/graphql/account.test.ts +++ b/graphql/account.test.ts @@ -3,7 +3,9 @@ import test from "node:test"; import { encodeGlobalID } from "@pothos/plugin-relay"; import * as vocab from "@fedify/vocab"; import { execute, parse } from "graphql"; +import sharp from "sharp"; import { updateAccountData } from "@hackerspub/models/account"; +import { createMediumFromBytes } from "@hackerspub/models/medium"; import { mediumTable } from "@hackerspub/models/schema"; import { generateUuidV7 } from "@hackerspub/models/uuid"; import type { UserContext } from "./builder.ts"; @@ -11,6 +13,7 @@ import { schema } from "./mod.ts"; import { putProfileOgImage } from "./og.ts"; import { createFedCtx, + createTestDisk, insertAccountWithActor, makeGuestContext, makeUserContext, @@ -391,6 +394,112 @@ test("updateAccount updates profile preferences for the signed-in account", asyn }); }); +test("updateAccount transforms avatarUrl before assigning a medium", async () => { + await withRollback(async (tx) => { + const account = await insertAccountWithActor(tx, { + username: "updateaccountavatarurl", + name: "Update Account Avatar URL", + email: "updateaccountavatarurl@example.com", + }); + const input = await sharp({ + create: { + width: 200, + height: 100, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }).png().toBuffer(); + const avatarUrl = `data:image/png;base64,${input.toString("base64")}`; + const disk = createTestDisk(); + const fedCtx = createFedCtx(tx); + fedCtx.data.disk = disk; + fedCtx.getActor = (identifier: string) => + Promise.resolve( + new vocab.Person({ + id: fedCtx.getActorUri(identifier), + }), + ); + + const result = await execute({ + schema, + document: updateAccountMutation, + variableValues: { + input: { + id: encodeGlobalID("Account", account.account.id), + avatarUrl, + }, + }, + contextValue: makeUserContext(tx, account.account, { disk, fedCtx }), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + const updated = await tx.query.accountTable.findFirst({ + where: { id: account.account.id }, + with: { avatarMedium: true }, + }); + assert.ok(updated?.avatarMedium != null); + assert.equal(updated.avatarMedium.width, 100); + assert.equal(updated.avatarMedium.height, 100); + }); +}); + +test("updateAccount transforms avatarMediumId before assigning it", async () => { + await withRollback(async (tx) => { + const account = await insertAccountWithActor(tx, { + username: "updateaccountavatarid", + name: "Update Account Avatar ID", + email: "updateaccountavatarid@example.com", + }); + const input = await sharp({ + create: { + width: 200, + height: 100, + channels: 3, + background: { r: 0, g: 255, b: 0 }, + }, + }).png().toBuffer(); + const disk = createTestDisk(); + const genericMedium = await createMediumFromBytes(tx, disk, input, { + contentType: "image/png", + }); + assert.ok(genericMedium != null); + assert.equal(genericMedium.width, 200); + assert.equal(genericMedium.height, 100); + const fedCtx = createFedCtx(tx); + fedCtx.data.disk = disk; + fedCtx.getActor = (identifier: string) => + Promise.resolve( + new vocab.Person({ + id: fedCtx.getActorUri(identifier), + }), + ); + + const result = await execute({ + schema, + document: updateAccountMutation, + variableValues: { + input: { + id: encodeGlobalID("Account", account.account.id), + avatarMediumId: genericMedium.id, + }, + }, + contextValue: makeUserContext(tx, account.account, { disk, fedCtx }), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + const updated = await tx.query.accountTable.findFirst({ + where: { id: account.account.id }, + with: { avatarMedium: true }, + }); + assert.ok(updated?.avatarMedium != null); + assert.notEqual(updated.avatarMedium.id, genericMedium.id); + assert.equal(updated.avatarMedium.width, 100); + assert.equal(updated.avatarMedium.height, 100); + }); +}); + test("updateAccount rejects a second username change", async () => { await withRollback(async (tx) => { const account = await insertAccountWithActor(tx, { diff --git a/graphql/account.ts b/graphql/account.ts index 8c081d9e4..0a3690942 100644 --- a/graphql/account.ts +++ b/graphql/account.ts @@ -6,11 +6,15 @@ import { import { assertNever } from "@std/assert/unstable-never"; import DataLoader from "dataloader"; import { and, desc, eq, gt, inArray, lt, sql } from "drizzle-orm"; -import { getAvatarUrl, updateAccount } from "@hackerspub/models/account"; +import { + createAvatarMediumFromMedium, + createAvatarMediumFromUrl, + getAvatarUrl, + updateAccount, +} from "@hackerspub/models/account"; import { syncActorFromAccount } from "@hackerspub/models/actor"; import type { Locale } from "@hackerspub/models/i18n"; import { renderMarkup } from "@hackerspub/models/markup"; -import { createMediumFromUrl } from "@hackerspub/models/medium"; import { accountTable, actorTable, @@ -639,7 +643,7 @@ builder.relayMutationField( "avatarUrl and avatarMediumId are mutually exclusive.", ); } - const medium = await createMediumFromUrl( + const medium = await createAvatarMediumFromUrl( ctx.db, ctx.disk, args.input.avatarUrl, @@ -654,7 +658,15 @@ builder.relayMutationField( where: { id: args.input.avatarMediumId }, }); if (medium == null) throw new Error("Medium not found."); - avatarMediumId = medium.id; + const avatarMedium = await createAvatarMediumFromMedium( + ctx.db, + ctx.disk, + medium, + ); + if (avatarMedium == null) { + throw new Error("Avatar medium must point to an image."); + } + avatarMediumId = avatarMedium.id; } const result = await updateAccount( ctx.fedCtx, diff --git a/models/account.ts b/models/account.ts index 0e1eb3db9..d9ed6174b 100644 --- a/models/account.ts +++ b/models/account.ts @@ -15,6 +15,11 @@ import type { Disk } from "flydrive"; import sharp from "sharp"; import type { ContextData } from "./context.ts"; import type { Database } from "./db.ts"; +import { + createMediumFromBlob, + createMediumFromBytes, + createMediumFromUrl, +} from "./medium.ts"; import { type Account, type AccountEmail, @@ -58,6 +63,54 @@ export async function getAvatarUrl( return url == "mp" ? "https://gravatar.com/avatar/?d=mp&s=128" : url; } +async function preprocessAvatarMedium( + bytes: Uint8Array, +): Promise<{ bytes: Uint8Array; contentType: string }> { + const { buffer, format } = await transformAvatar(bytes); + return { + bytes: buffer, + contentType: `image/${format}`, + }; +} + +export async function createAvatarMediumFromBlob( + db: Database, + disk: Disk, + blob: Blob, + options: { maxSize?: number } = {}, +): Promise { + return await createMediumFromBlob(db, disk, blob, { + ...options, + preprocess: preprocessAvatarMedium, + }); +} + +export async function createAvatarMediumFromUrl( + db: Database, + disk: Disk, + url: URL, + options: { maxSize?: number; userAgentUrl?: URL } = {}, +): Promise { + return await createMediumFromUrl(db, disk, url, { + ...options, + preprocess: preprocessAvatarMedium, + }); +} + +export async function createAvatarMediumFromMedium( + db: Database, + disk: Disk, + medium: Medium, + options: { maxSize?: number } = {}, +): Promise { + const bytes = await disk.getBytes(medium.key); + return await createMediumFromBytes(db, disk, bytes, { + ...options, + contentType: medium.type, + preprocess: preprocessAvatarMedium, + }); +} + export async function getAccountByUsername( db: Database, username: string, @@ -573,5 +626,5 @@ export async function transformAvatar( format = "jpeg"; } const buffer = await image.toBuffer(); - return { buffer: new Uint8Array(buffer.buffer), format }; + return { buffer: new Uint8Array(buffer), format }; } diff --git a/models/medium.ts b/models/medium.ts index 5e45746a5..f8cba8cf4 100644 --- a/models/medium.ts +++ b/models/medium.ts @@ -49,6 +49,10 @@ export const MAX_STREAMING_MEDIUM_IMAGE_SIZE = 50 * 1024 * 1024; const localMediumType: MediumType = "image/webp"; +type MediumPreprocess = ( + bytes: Uint8Array, +) => Promise<{ bytes: Uint8Array; contentType?: string | null }>; + export class UnsafeMediumUrlError extends Error { constructor(url: string) { super(`Unsafe medium URL: ${url}`); @@ -111,18 +115,34 @@ export async function createMediumFromBytes( db: Database, disk: Disk, bytes: Uint8Array | ArrayBuffer, - options: { maxSize?: number; contentType?: string | null } = {}, + options: { + maxSize?: number; + contentType?: string | null; + preprocess?: MediumPreprocess; + } = {}, ): Promise { - const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let contentType = options.contentType; if (input.byteLength > (options.maxSize ?? MAX_MEDIUM_IMAGE_SIZE)) { return undefined; } if ( - options.contentType != null && - !isSupportedMediumImageType(options.contentType) + contentType != null && + !isSupportedMediumImageType(contentType) ) { return undefined; } + if (options.preprocess != null) { + const processed = await options.preprocess(input); + input = processed.bytes; + contentType = processed.contentType ?? contentType; + if (input.byteLength > (options.maxSize ?? MAX_MEDIUM_IMAGE_SIZE)) { + return undefined; + } + if (contentType != null && !isSupportedMediumImageType(contentType)) { + return undefined; + } + } const { data, info } = await sharp(input, { animated: true }) .rotate() .webp() @@ -161,7 +181,7 @@ export async function createMediumFromBlob( db: Database, disk: Disk, blob: Blob, - options: { maxSize?: number } = {}, + options: { maxSize?: number; preprocess?: MediumPreprocess } = {}, ): Promise { if (!isSupportedMediumImageType(blob.type)) return undefined; return await createMediumFromBytes(db, disk, await blob.arrayBuffer(), { @@ -174,7 +194,11 @@ export async function createMediumFromUrl( db: Database, disk: Disk, url: URL, - options: { maxSize?: number; userAgentUrl?: URL } = {}, + options: { + maxSize?: number; + userAgentUrl?: URL; + preprocess?: MediumPreprocess; + } = {}, ): Promise { if ( url.protocol !== "data:" && url.protocol !== "http:" && @@ -198,6 +222,7 @@ export async function createMediumFromUrl( return await createMediumFromBytes(db, disk, await blob.arrayBuffer(), { maxSize, contentType, + preprocess: options.preprocess, }); } diff --git a/test/postgres.ts b/test/postgres.ts index 739bcc6a0..a79e39840 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -306,11 +306,18 @@ export function toPlainJson(value: T): T { } export function createTestDisk(): ContextData["disk"] { + const files = new Map(); return { getUrl(key: string) { return Promise.resolve(`http://localhost/media/${key}`); }, - put() { + getBytes(key: string) { + const bytes = files.get(key); + if (bytes == null) throw new Error(`No test disk file for key: ${key}`); + return Promise.resolve(bytes); + }, + put(key: string, contents: Uint8Array) { + files.set(key, contents); return Promise.resolve(undefined); }, delete() { diff --git a/web/routes/@[username]/settings/index.tsx b/web/routes/@[username]/settings/index.tsx index 4e58e99b8..c48e523f2 100644 --- a/web/routes/@[username]/settings/index.tsx +++ b/web/routes/@[username]/settings/index.tsx @@ -1,7 +1,10 @@ import { page } from "@fresh/core"; -import { getAvatarUrl, updateAccount } from "@hackerspub/models/account"; +import { + createAvatarMediumFromBlob, + getAvatarUrl, + updateAccount, +} from "@hackerspub/models/account"; import { syncActorFromAccount } from "@hackerspub/models/actor"; -import { createMediumFromBlob } from "@hackerspub/models/medium"; import { getLogger } from "@logtape/logtape"; import { zip } from "@std/collections/zip"; import { Button } from "../../../components/Button.tsx"; @@ -126,7 +129,9 @@ export const handler = define.handlers({ links, }; if (avatar instanceof File) { - const medium = await createMediumFromBlob(db, disk, avatar); + const medium = await createAvatarMediumFromBlob(db, disk, avatar, { + maxSize: MAX_AVATAR_SIZE, + }); if (medium != null) values.avatarMediumId = medium.id; } const updatedAccount = await updateAccount(ctx.state.fedCtx, values); From fc76427bbb33b8e0f3e735b97fcf76eff095017d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 15:36:56 +0900 Subject: [PATCH 08/32] Allow repeated note media Drop the per-note medium uniqueness constraint so the same deduplicated Medium can appear at multiple indexes with distinct alt text. Add a migration, snapshot, and regression test for repeated note attachments. Assisted-by: Codex:gpt-5.5 --- .../0099_drop_note_source_medium_unique.sql | 2 + drizzle/meta/0099_snapshot.json | 4134 +++++++++++++++++ drizzle/meta/_journal.json | 7 + models/note.lifecycle.test.ts | 44 + models/schema.ts | 1 - 5 files changed, 4187 insertions(+), 1 deletion(-) create mode 100644 drizzle/0099_drop_note_source_medium_unique.sql create mode 100644 drizzle/meta/0099_snapshot.json diff --git a/drizzle/0099_drop_note_source_medium_unique.sql b/drizzle/0099_drop_note_source_medium_unique.sql new file mode 100644 index 000000000..c3992fe4c --- /dev/null +++ b/drizzle/0099_drop_note_source_medium_unique.sql @@ -0,0 +1,2 @@ +ALTER TABLE "note_source_medium" +DROP CONSTRAINT IF EXISTS "note_source_medium_note_source_id_medium_id_unique"; diff --git a/drizzle/meta/0099_snapshot.json b/drizzle/meta/0099_snapshot.json new file mode 100644 index 000000000..ea9419feb --- /dev/null +++ b/drizzle/meta/0099_snapshot.json @@ -0,0 +1,4134 @@ +{ + "id": "9ff2027a-5023-46bd-a163-96e27755f399", + "prevId": "4126d275-1618-4c02-81ec-75bd6741a271", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_email": { + "name": "account_email", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified": { + "name": "verified", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_account_email_lower_email": { + "name": "idx_account_email_lower_email", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_email_account_id_account_id_fk": { + "name": "account_email_account_id_account_id_fk", + "tableFrom": "account_email", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_key": { + "name": "account_key", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private": { + "name": "private", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "account_key_account_id_account_id_fk": { + "name": "account_key_account_id_account_id_fk", + "tableFrom": "account_key", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_key_account_id_type_pk": { + "name": "account_key_account_id_type_pk", + "columns": [ + "account_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "account_key_public_check": { + "name": "account_key_public_check", + "value": "\"account_key\".\"public\" IS JSON OBJECT" + }, + "account_key_private_check": { + "name": "account_key_private_check", + "value": "\"account_key\".\"private\" IS JSON OBJECT" + } + }, + "isRLSEnabled": false + }, + "public.account_link": { + "name": "account_link", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "index": { + "name": "index", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "account_link_icon", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "verified": { + "name": "verified", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "account_link_account_id_account_id_fk": { + "name": "account_link_account_id_account_id_fk", + "tableFrom": "account_link", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_link_account_id_index_pk": { + "name": "account_link_account_id_index_pk", + "columns": [ + "account_id", + "index" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "account_link_name_check": { + "name": "account_link_name_check", + "value": "\n char_length(\"account_link\".\"name\") <= 50 AND\n \"account_link\".\"name\" !~ '^[[:space:]]' AND\n \"account_link\".\"name\" !~ '[[:space:]]$'\n " + } + }, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "old_username": { + "name": "old_username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "username_changed": { + "name": "username_changed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_medium_id": { + "name": "avatar_medium_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "og_image_key": { + "name": "og_image_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locales": { + "name": "locales", + "type": "varchar[]", + "primaryKey": false, + "notNull": false + }, + "moderator": { + "name": "moderator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notification_read": { + "name": "notification_read", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "left_invitations": { + "name": "left_invitations", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "hide_from_invitation_tree": { + "name": "hide_from_invitation_tree", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hide_foreign_languages": { + "name": "hide_foreign_languages", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "prefer_ai_summary": { + "name": "prefer_ai_summary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "note_visibility": { + "name": "note_visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "share_visibility": { + "name": "share_visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "account_avatar_medium_id_medium_id_fk": { + "name": "account_avatar_medium_id_medium_id_fk", + "tableFrom": "account", + "tableTo": "medium", + "columnsFrom": [ + "avatar_medium_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "account_inviter_id_account_id_fk": { + "name": "account_inviter_id_account_id_fk", + "tableFrom": "account", + "tableTo": "account", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_username_unique": { + "name": "account_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "account_og_image_key_unique": { + "name": "account_og_image_key_unique", + "nullsNotDistinct": false, + "columns": [ + "og_image_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "account_username_check": { + "name": "account_username_check", + "value": "\"account\".\"username\" ~ '^[a-z0-9_]{1,50}$'" + }, + "account_name_check": { + "name": "account_name_check", + "value": "\n char_length(\"account\".\"name\") <= 50 AND\n \"account\".\"name\" !~ '^[[:space:]]' AND\n \"account\".\"name\" !~ '[[:space:]]$'\n " + } + }, + "isRLSEnabled": false + }, + "public.actor": { + "name": "actor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_host": { + "name": "instance_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle_host": { + "name": "handle_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "'@' || \"actor\".\"username\" || '@' || \"actor\".\"handle_host\"", + "type": "stored" + } + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio_html": { + "name": "bio_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "automatically_approves_followers": { + "name": "automatically_approves_followers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "header_url": { + "name": "header_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_url": { + "name": "inbox_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_inbox_url": { + "name": "shared_inbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "followers_url": { + "name": "followers_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "featured_url": { + "name": "featured_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "field_htmls": { + "name": "field_htmls", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "emojis": { + "name": "emojis", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "successor_id": { + "name": "successor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "(ARRAY[]::text[])" + }, + "followees_count": { + "name": "followees_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "followers_count": { + "name": "followers_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "posts_count": { + "name": "posts_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "actor_instance_host_instance_host_fk": { + "name": "actor_instance_host_instance_host_fk", + "tableFrom": "actor", + "tableTo": "instance", + "columnsFrom": [ + "instance_host" + ], + "columnsTo": [ + "host" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "actor_account_id_account_id_fk": { + "name": "actor_account_id_account_id_fk", + "tableFrom": "actor", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "actor_successor_id_actor_id_fk": { + "name": "actor_successor_id_actor_id_fk", + "tableFrom": "actor", + "tableTo": "actor", + "columnsFrom": [ + "successor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "actor_iri_unique": { + "name": "actor_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + }, + "actor_account_id_unique": { + "name": "actor_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id" + ] + }, + "actor_username_instance_host_unique": { + "name": "actor_username_instance_host_unique", + "nullsNotDistinct": false, + "columns": [ + "username", + "instance_host" + ] + } + }, + "policies": {}, + "checkConstraints": { + "actor_username_check": { + "name": "actor_username_check", + "value": "\"actor\".\"username\" NOT LIKE '%@%'" + } + }, + "isRLSEnabled": false + }, + "public.admin_state": { + "name": "admin_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apns_device_token": { + "name": "apns_device_token", + "schema": "", + "columns": { + "device_token": { + "name": "device_token", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "apns_device_token_account_id_index": { + "name": "apns_device_token_account_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apns_device_token_account_id_account_id_fk": { + "name": "apns_device_token_account_id_account_id_fk", + "tableFrom": "apns_device_token", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "apns_device_token_device_token_check": { + "name": "apns_device_token_device_token_check", + "value": "\"apns_device_token\".\"device_token\" ~ '^[0-9a-f]{64}$'" + } + }, + "isRLSEnabled": false + }, + "public.article_content": { + "name": "article_content", + "schema": "", + "columns": { + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_started": { + "name": "summary_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "summary_unnecessary": { + "name": "summary_unnecessary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "og_image_key": { + "name": "og_image_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_language": { + "name": "original_language", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "translator_id": { + "name": "translator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "translation_requester_id": { + "name": "translation_requester_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "being_translated": { + "name": "being_translated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "article_content_source_id_article_source_id_fk": { + "name": "article_content_source_id_article_source_id_fk", + "tableFrom": "article_content", + "tableTo": "article_source", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "article_content_translator_id_account_id_fk": { + "name": "article_content_translator_id_account_id_fk", + "tableFrom": "article_content", + "tableTo": "account", + "columnsFrom": [ + "translator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "article_content_translation_requester_id_account_id_fk": { + "name": "article_content_translation_requester_id_account_id_fk", + "tableFrom": "article_content", + "tableTo": "account", + "columnsFrom": [ + "translation_requester_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "article_content_source_id_original_language_article_content_source_id_language_fk": { + "name": "article_content_source_id_original_language_article_content_source_id_language_fk", + "tableFrom": "article_content", + "tableTo": "article_content", + "columnsFrom": [ + "source_id", + "original_language" + ], + "columnsTo": [ + "source_id", + "language" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "article_content_source_id_language_pk": { + "name": "article_content_source_id_language_pk", + "columns": [ + "source_id", + "language" + ] + } + }, + "uniqueConstraints": { + "article_content_og_image_key_unique": { + "name": "article_content_og_image_key_unique", + "nullsNotDistinct": false, + "columns": [ + "og_image_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "article_content_original_language_check": { + "name": "article_content_original_language_check", + "value": "(\n \"article_content\".\"translator_id\" IS NULL AND\n \"article_content\".\"translation_requester_id\" IS NULL\n ) = (\"article_content\".\"original_language\" IS NULL)" + }, + "article_content_translator_translation_requester_id_check": { + "name": "article_content_translator_translation_requester_id_check", + "value": "\"article_content\".\"translator_id\" IS NULL OR \"article_content\".\"translation_requester_id\" IS NULL" + }, + "article_content_being_translated_check": { + "name": "article_content_being_translated_check", + "value": "NOT \"article_content\".\"being_translated\" OR (\"article_content\".\"original_language\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.article_draft_medium": { + "name": "article_draft_medium", + "schema": "", + "columns": { + "article_draft_id": { + "name": "article_draft_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "medium_id": { + "name": "medium_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "article_draft_medium_article_draft_id_article_draft_id_fk": { + "name": "article_draft_medium_article_draft_id_article_draft_id_fk", + "tableFrom": "article_draft_medium", + "tableTo": "article_draft", + "columnsFrom": [ + "article_draft_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "article_draft_medium_medium_id_medium_id_fk": { + "name": "article_draft_medium_medium_id_medium_id_fk", + "tableFrom": "article_draft_medium", + "tableTo": "medium", + "columnsFrom": [ + "medium_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "article_draft_medium_article_draft_id_key_pk": { + "name": "article_draft_medium_article_draft_id_key_pk", + "columns": [ + "article_draft_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.article_draft": { + "name": "article_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "article_source_id": { + "name": "article_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "(ARRAY[]::text[])" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "article_draft_account_id_account_id_fk": { + "name": "article_draft_account_id_account_id_fk", + "tableFrom": "article_draft", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "article_draft_article_source_id_article_source_id_fk": { + "name": "article_draft_article_source_id_article_source_id_fk", + "tableFrom": "article_draft", + "tableTo": "article_source", + "columnsFrom": [ + "article_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.article_source_medium": { + "name": "article_source_medium", + "schema": "", + "columns": { + "article_source_id": { + "name": "article_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "medium_id": { + "name": "medium_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "article_source_medium_article_source_id_article_source_id_fk": { + "name": "article_source_medium_article_source_id_article_source_id_fk", + "tableFrom": "article_source_medium", + "tableTo": "article_source", + "columnsFrom": [ + "article_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "article_source_medium_medium_id_medium_id_fk": { + "name": "article_source_medium_medium_id_medium_id_fk", + "tableFrom": "article_source_medium", + "tableTo": "medium", + "columnsFrom": [ + "medium_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "article_source_medium_article_source_id_key_pk": { + "name": "article_source_medium_article_source_id_key_pk", + "columns": [ + "article_source_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.article_source": { + "name": "article_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "published_year": { + "name": "published_year", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(year FROM CURRENT_TIMESTAMP)" + }, + "slug": { + "name": "slug", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "(ARRAY[]::text[])" + }, + "allow_llm_translation": { + "name": "allow_llm_translation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "article_source_account_id_account_id_fk": { + "name": "article_source_account_id_account_id_fk", + "tableFrom": "article_source", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "article_source_account_id_published_year_slug_unique": { + "name": "article_source_account_id_published_year_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id", + "published_year", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "article_source_published_year_check": { + "name": "article_source_published_year_check", + "value": "\"article_source\".\"published_year\" = EXTRACT(year FROM \"article_source\".\"published\")" + } + }, + "isRLSEnabled": false + }, + "public.blocking": { + "name": "blocking", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blocker_id": { + "name": "blocker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blockee_id": { + "name": "blockee_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "blocking_blocker_id_actor_id_fk": { + "name": "blocking_blocker_id_actor_id_fk", + "tableFrom": "blocking", + "tableTo": "actor", + "columnsFrom": [ + "blocker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocking_blockee_id_actor_id_fk": { + "name": "blocking_blockee_id_actor_id_fk", + "tableFrom": "blocking", + "tableTo": "actor", + "columnsFrom": [ + "blockee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blocking_iri_unique": { + "name": "blocking_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + }, + "blocking_blocker_id_blockee_id_unique": { + "name": "blocking_blocker_id_blockee_id_unique", + "nullsNotDistinct": false, + "columns": [ + "blocker_id", + "blockee_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "blocking_blocker_blockee_check": { + "name": "blocking_blocker_blockee_check", + "value": "\"blocking\".\"blocker_id\" != \"blocking\".\"blockee_id\"" + } + }, + "isRLSEnabled": false + }, + "public.bookmark": { + "name": "bookmark", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_bookmark_account_created": { + "name": "idx_bookmark_account_created", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"post_id\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookmark_post_id_index": { + "name": "bookmark_post_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookmark_account_id_account_id_fk": { + "name": "bookmark_account_id_account_id_fk", + "tableFrom": "bookmark", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmark_post_id_post_id_fk": { + "name": "bookmark_post_id_post_id_fk", + "tableFrom": "bookmark", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmark_account_id_post_id_pk": { + "name": "bookmark_account_id_post_id_pk", + "columns": [ + "account_id", + "post_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_emoji": { + "name": "custom_emoji", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_type": { + "name": "image_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_emoji_iri_unique": { + "name": "custom_emoji_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + } + }, + "policies": {}, + "checkConstraints": { + "custom_emoji_name_check": { + "name": "custom_emoji_name_check", + "value": "\"custom_emoji\".\"name\" ~ '^:[^:[:space:]]+:$'" + }, + "custom_emoji_image_type_check": { + "name": "custom_emoji_image_type_check", + "value": "\n CASE\n WHEN \"custom_emoji\".\"image_type\" IS NULL THEN true\n ELSE \"custom_emoji\".\"image_type\" ~ '^image/'\n END\n " + }, + "custom_emoji_image_url_check": { + "name": "custom_emoji_image_url_check", + "value": "\"custom_emoji\".\"image_url\" ~ '^https?://'" + } + }, + "isRLSEnabled": false + }, + "public.fcm_device_token": { + "name": "fcm_device_token", + "schema": "", + "columns": { + "device_token": { + "name": "device_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "fcm_device_token_account_id_index": { + "name": "fcm_device_token_account_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fcm_device_token_account_id_account_id_fk": { + "name": "fcm_device_token_account_id_account_id_fk", + "tableFrom": "fcm_device_token", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.following": { + "name": "following", + "schema": "", + "columns": { + "iri": { + "name": "iri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "followee_id": { + "name": "followee_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accepted": { + "name": "accepted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "following_follower_id_index": { + "name": "following_follower_id_index", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "following_follower_id_actor_id_fk": { + "name": "following_follower_id_actor_id_fk", + "tableFrom": "following", + "tableTo": "actor", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "following_followee_id_actor_id_fk": { + "name": "following_followee_id_actor_id_fk", + "tableFrom": "following", + "tableTo": "actor", + "columnsFrom": [ + "followee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "following_follower_id_followee_id_unique": { + "name": "following_follower_id_followee_id_unique", + "nullsNotDistinct": false, + "columns": [ + "follower_id", + "followee_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance": { + "name": "instance", + "schema": "", + "columns": { + "host": { + "name": "host", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "software": { + "name": "software", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "instance_host_check": { + "name": "instance_host_check", + "value": "\"instance\".\"host\" NOT LIKE '%@%'" + } + }, + "isRLSEnabled": false + }, + "public.invitation_link": { + "name": "invitation_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invitations_left": { + "name": "invitations_left", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_link_inviter_id_account_id_fk": { + "name": "invitation_link_inviter_id_account_id_fk", + "tableFrom": "invitation_link", + "tableTo": "account", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.medium": { + "name": "medium", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "medium_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "medium_key_unique": { + "name": "medium_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + }, + "medium_content_hash_unique": { + "name": "medium_content_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "content_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "medium_width_height_check": { + "name": "medium_width_height_check", + "value": "\n CASE\n WHEN \"medium\".\"width\" IS NULL THEN \"medium\".\"height\" IS NULL\n ELSE \"medium\".\"height\" IS NOT NULL AND\n \"medium\".\"width\" > 0 AND \"medium\".\"height\" > 0\n END\n " + } + }, + "isRLSEnabled": false + }, + "public.mention": { + "name": "mention", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mention_actor_id_index": { + "name": "mention_actor_id_index", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mention_post_id_post_id_fk": { + "name": "mention_post_id_post_id_fk", + "tableFrom": "mention", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mention_actor_id_actor_id_fk": { + "name": "mention_actor_id_actor_id_fk", + "tableFrom": "mention", + "tableTo": "actor", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mention_post_id_actor_id_pk": { + "name": "mention_post_id_actor_id_pk", + "columns": [ + "post_id", + "actor_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.note_source_medium": { + "name": "note_source_medium", + "schema": "", + "columns": { + "note_source_id": { + "name": "note_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "index": { + "name": "index", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "medium_id": { + "name": "medium_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "note_source_medium_note_source_id_note_source_id_fk": { + "name": "note_source_medium_note_source_id_note_source_id_fk", + "tableFrom": "note_source_medium", + "tableTo": "note_source", + "columnsFrom": [ + "note_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "note_source_medium_medium_id_medium_id_fk": { + "name": "note_source_medium_medium_id_medium_id_fk", + "tableFrom": "note_source_medium", + "tableTo": "medium", + "columnsFrom": [ + "medium_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "note_source_medium_note_source_id_index_pk": { + "name": "note_source_medium_note_source_id_index_pk", + "columns": [ + "note_source_id", + "index" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "note_source_medium_index_check": { + "name": "note_source_medium_index_check", + "value": "\"note_source_medium\".\"index\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.note_source": { + "name": "note_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "note_source_account_id_account_id_fk": { + "name": "note_source_account_id_account_id_fk", + "tableFrom": "note_source", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_ids": { + "name": "actor_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "(ARRAY[]::uuid[])" + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_emoji_id": { + "name": "custom_emoji_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_notification_account_id_created": { + "name": "idx_notification_account_id_created", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_post_id_index": { + "name": "notification_post_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"notification\".\"post_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_account_id_actor_ids_index": { + "name": "notification_account_id_actor_ids_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notification\".\"type\" = 'follow'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_account_id_post_id_index": { + "name": "notification_account_id_post_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notification\".\"type\" NOT IN ('follow', 'react')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_account_id_post_id_emoji_index": { + "name": "notification_account_id_post_id_emoji_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emoji", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notification\".\"type\" = 'react' AND \"notification\".\"custom_emoji_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_account_id_post_id_custom_emoji_id_index": { + "name": "notification_account_id_post_id_custom_emoji_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "custom_emoji_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notification\".\"type\" = 'react' AND \"notification\".\"emoji\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_account_id_account_id_fk": { + "name": "notification_account_id_account_id_fk", + "tableFrom": "notification", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_post_id_post_id_fk": { + "name": "notification_post_id_post_id_fk", + "tableFrom": "notification", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_custom_emoji_id_custom_emoji_id_fk": { + "name": "notification_custom_emoji_id_custom_emoji_id_fk", + "tableFrom": "notification", + "tableTo": "custom_emoji", + "columnsFrom": [ + "custom_emoji_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "notification_post_id_check": { + "name": "notification_post_id_check", + "value": "\n CASE \"notification\".\"type\"\n WHEN 'follow' THEN \"notification\".\"post_id\" IS NULL\n ELSE \"notification\".\"post_id\" IS NOT NULL\n END\n " + }, + "notification_emoji_check": { + "name": "notification_emoji_check", + "value": "\n CASE \"notification\".\"type\"\n WHEN 'react'\n THEN \"notification\".\"emoji\" IS NOT NULL AND \"notification\".\"custom_emoji_id\" IS NULL\n OR \"notification\".\"emoji\" IS NULL AND \"notification\".\"custom_emoji_id\" IS NOT NULL\n ELSE \"notification\".\"emoji\" IS NULL AND \"notification\".\"custom_emoji_id\" IS NULL\n END\n " + } + }, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "webauthn_user_id": { + "name": "webauthn_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "passkey_device_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "passkey_transport[]", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "passkey_account_id_index": { + "name": "passkey_account_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_webauthn_user_id_index": { + "name": "passkey_webauthn_user_id_index", + "columns": [ + { + "expression": "webauthn_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_account_id_account_id_fk": { + "name": "passkey_account_id_account_id_fk", + "tableFrom": "passkey", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_account_id_webauthn_user_id_unique": { + "name": "passkey_account_id_webauthn_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id", + "webauthn_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "passkey_name_check": { + "name": "passkey_name_check", + "value": "\"passkey\".\"name\" !~ '^[[:space:]]*$'" + } + }, + "isRLSEnabled": false + }, + "public.pin": { + "name": "pin", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "pin_actor_id_index": { + "name": "pin_actor_id_index", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pin_actor_id_actor_id_fk": { + "name": "pin_actor_id_actor_id_fk", + "tableFrom": "pin", + "tableTo": "actor", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pin_post_id_actor_id_post_id_actor_id_fk": { + "name": "pin_post_id_actor_id_post_id_actor_id_fk", + "tableFrom": "pin", + "tableTo": "post", + "columnsFrom": [ + "post_id", + "actor_id" + ], + "columnsTo": [ + "id", + "actor_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pin_post_id_actor_id_pk": { + "name": "pin_post_id_actor_id_pk", + "columns": [ + "post_id", + "actor_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_option": { + "name": "poll_option", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "index": { + "name": "index", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "votes_count": { + "name": "votes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "poll_option_post_id_poll_post_id_fk": { + "name": "poll_option_post_id_poll_post_id_fk", + "tableFrom": "poll_option", + "tableTo": "poll", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "post_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "poll_option_post_id_index_pk": { + "name": "poll_option_post_id_index_pk", + "columns": [ + "post_id", + "index" + ] + } + }, + "uniqueConstraints": { + "poll_option_post_id_title_unique": { + "name": "poll_option_post_id_title_unique", + "nullsNotDistinct": false, + "columns": [ + "post_id", + "title" + ] + } + }, + "policies": {}, + "checkConstraints": { + "poll_option_index_check": { + "name": "poll_option_index_check", + "value": "\"poll_option\".\"index\" >= 0" + }, + "poll_option_votes_count_check": { + "name": "poll_option_votes_count_check", + "value": "\"poll_option\".\"votes_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.poll": { + "name": "poll", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "multiple": { + "name": "multiple", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "voters_count": { + "name": "voters_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ends": { + "name": "ends", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "poll_post_id_post_id_fk": { + "name": "poll_post_id_post_id_fk", + "tableFrom": "poll", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "poll_voters_count_check": { + "name": "poll_voters_count_check", + "value": "\"poll\".\"voters_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.poll_vote": { + "name": "poll_vote", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "option_index": { + "name": "option_index", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "poll_vote_post_id_poll_post_id_fk": { + "name": "poll_vote_post_id_poll_post_id_fk", + "tableFrom": "poll_vote", + "tableTo": "poll", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "post_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "poll_vote_actor_id_actor_id_fk": { + "name": "poll_vote_actor_id_actor_id_fk", + "tableFrom": "poll_vote", + "tableTo": "actor", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "poll_vote_post_id_option_index_poll_option_post_id_index_fk": { + "name": "poll_vote_post_id_option_index_poll_option_post_id_index_fk", + "tableFrom": "poll_vote", + "tableTo": "poll_option", + "columnsFrom": [ + "post_id", + "option_index" + ], + "columnsTo": [ + "post_id", + "index" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "poll_vote_post_id_option_index_actor_id_pk": { + "name": "poll_vote_post_id_option_index_actor_id_pk", + "columns": [ + "post_id", + "option_index", + "actor_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_link": { + "name": "post_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "site_name": { + "name": "site_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_alt": { + "name": "image_alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_type": { + "name": "image_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_width": { + "name": "image_width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_height": { + "name": "image_height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "scraped": { + "name": "scraped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "post_link_creator_id_index": { + "name": "post_link_creator_id_index", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_link_creator_id_actor_id_fk": { + "name": "post_link_creator_id_actor_id_fk", + "tableFrom": "post_link", + "tableTo": "actor", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_link_url_unique": { + "name": "post_link_url_unique", + "nullsNotDistinct": false, + "columns": [ + "url" + ] + } + }, + "policies": {}, + "checkConstraints": { + "post_link_url_check": { + "name": "post_link_url_check", + "value": "\"post_link\".\"url\" ~ '^https?://'" + }, + "post_link_image_url_check": { + "name": "post_link_image_url_check", + "value": "\"post_link\".\"image_url\" ~ '^https?://'" + }, + "post_link_image_alt_check": { + "name": "post_link_image_alt_check", + "value": "\"post_link\".\"image_alt\" IS NULL OR \"post_link\".\"image_url\" IS NOT NULL" + }, + "post_link_image_type_check": { + "name": "post_link_image_type_check", + "value": "\n CASE\n WHEN \"post_link\".\"image_type\" IS NULL THEN true\n ELSE \"post_link\".\"image_type\" ~ '^image/' AND\n \"post_link\".\"image_url\" IS NOT NULL\n END\n " + }, + "post_link_image_width_height_check": { + "name": "post_link_image_width_height_check", + "value": "\n CASE\n WHEN \"post_link\".\"image_width\" IS NOT NULL\n THEN \"post_link\".\"image_url\" IS NOT NULL AND\n \"post_link\".\"image_height\" IS NOT NULL AND\n \"post_link\".\"image_width\" > 0 AND\n \"post_link\".\"image_height\" > 0\n WHEN \"post_link\".\"image_height\" IS NOT NULL\n THEN \"post_link\".\"image_url\" IS NOT NULL AND\n \"post_link\".\"image_width\" IS NOT NULL AND\n \"post_link\".\"image_width\" > 0 AND\n \"post_link\".\"image_height\" > 0\n ELSE true\n END\n " + } + }, + "isRLSEnabled": false + }, + "public.post_medium": { + "name": "post_medium", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "index": { + "name": "index", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "post_medium_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "post_medium_post_id_post_id_fk": { + "name": "post_medium_post_id_post_id_fk", + "tableFrom": "post_medium", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_medium_post_id_index_pk": { + "name": "post_medium_post_id_index_pk", + "columns": [ + "post_id", + "index" + ] + } + }, + "uniqueConstraints": { + "post_medium_thumbnail_key_unique": { + "name": "post_medium_thumbnail_key_unique", + "nullsNotDistinct": false, + "columns": [ + "thumbnail_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "post_medium_index_check": { + "name": "post_medium_index_check", + "value": "\"post_medium\".\"index\" >= 0" + }, + "post_medium_url_check": { + "name": "post_medium_url_check", + "value": "\"post_medium\".\"url\" ~ '^https?://'" + }, + "post_medium_width_height_check": { + "name": "post_medium_width_height_check", + "value": "\n CASE\n WHEN \"post_medium\".\"width\" IS NULL THEN \"post_medium\".\"height\" IS NULL\n ELSE \"post_medium\".\"height\" IS NOT NULL AND\n \"post_medium\".\"width\" > 0 AND \"post_medium\".\"height\" > 0\n END\n " + } + }, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unlisted'" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "article_source_id": { + "name": "article_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "note_source_id": { + "name": "note_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "shared_post_id": { + "name": "shared_post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reply_target_id": { + "name": "reply_target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quoted_post_id": { + "name": "quoted_post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_html": { + "name": "content_html", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "relayed_tags": { + "name": "relayed_tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "(ARRAY[]::text[])" + }, + "emojis": { + "name": "emojis", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "replies_count": { + "name": "replies_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "shares_count": { + "name": "shares_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quotes_count": { + "name": "quotes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reactions_counts": { + "name": "reactions_counts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "reactions_count": { + "name": "reactions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "json_sum_object_values(\"post\".\"reactions_counts\")", + "type": "stored" + } + }, + "link_id": { + "name": "link_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_post_visibility_published": { + "name": "idx_post_visibility_published", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"published\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_post_actor_id_published": { + "name": "idx_post_actor_id_published", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"published\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_reply_target_id_index": { + "name": "post_reply_target_id_index", + "columns": [ + { + "expression": "reply_target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_shared_post_id_index": { + "name": "post_shared_post_id_index", + "columns": [ + { + "expression": "shared_post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"post\".\"shared_post_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_quoted_post_id_index": { + "name": "post_quoted_post_id_index", + "columns": [ + { + "expression": "quoted_post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"post\".\"quoted_post_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_post_note_source_published": { + "name": "idx_post_note_source_published", + "columns": [ + { + "expression": "\"published\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"post\".\"note_source_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_post_article_source_published": { + "name": "idx_post_article_source_published", + "columns": [ + { + "expression": "\"published\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"post\".\"article_source_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_actor_id_actor_id_fk": { + "name": "post_actor_id_actor_id_fk", + "tableFrom": "post", + "tableTo": "actor", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_article_source_id_article_source_id_fk": { + "name": "post_article_source_id_article_source_id_fk", + "tableFrom": "post", + "tableTo": "article_source", + "columnsFrom": [ + "article_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_note_source_id_note_source_id_fk": { + "name": "post_note_source_id_note_source_id_fk", + "tableFrom": "post", + "tableTo": "note_source", + "columnsFrom": [ + "note_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_shared_post_id_post_id_fk": { + "name": "post_shared_post_id_post_id_fk", + "tableFrom": "post", + "tableTo": "post", + "columnsFrom": [ + "shared_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_reply_target_id_post_id_fk": { + "name": "post_reply_target_id_post_id_fk", + "tableFrom": "post", + "tableTo": "post", + "columnsFrom": [ + "reply_target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_quoted_post_id_post_id_fk": { + "name": "post_quoted_post_id_post_id_fk", + "tableFrom": "post", + "tableTo": "post", + "columnsFrom": [ + "quoted_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_link_id_post_link_id_fk": { + "name": "post_link_id_post_link_id_fk", + "tableFrom": "post", + "tableTo": "post_link", + "columnsFrom": [ + "link_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_iri_unique": { + "name": "post_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + }, + "post_article_source_id_unique": { + "name": "post_article_source_id_unique", + "nullsNotDistinct": false, + "columns": [ + "article_source_id" + ] + }, + "post_note_source_id_unique": { + "name": "post_note_source_id_unique", + "nullsNotDistinct": false, + "columns": [ + "note_source_id" + ] + }, + "post_id_actor_id_unique": { + "name": "post_id_actor_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "actor_id" + ] + }, + "post_actor_id_shared_post_id_unique": { + "name": "post_actor_id_shared_post_id_unique", + "nullsNotDistinct": false, + "columns": [ + "actor_id", + "shared_post_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "post_article_source_id_check": { + "name": "post_article_source_id_check", + "value": "\"post\".\"type\" = 'Article' OR \"post\".\"article_source_id\" IS NULL" + }, + "post_note_source_id_check": { + "name": "post_note_source_id_check", + "value": "\"post\".\"type\" = 'Note' OR \"post\".\"note_source_id\" IS NULL" + }, + "post_shared_post_id_reply_target_id_check": { + "name": "post_shared_post_id_reply_target_id_check", + "value": "\"post\".\"shared_post_id\" IS NULL OR \"post\".\"reply_target_id\" IS NULL" + }, + "post_reactions_acounts_check": { + "name": "post_reactions_acounts_check", + "value": "\"post\".\"reactions_counts\" IS JSON OBJECT" + }, + "post_link_id_check": { + "name": "post_link_id_check", + "value": "(\"post\".\"link_id\" IS NULL) = (\"post\".\"link_url\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.reaction": { + "name": "reaction", + "schema": "", + "columns": { + "iri": { + "name": "iri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_emoji_id": { + "name": "custom_emoji_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "reaction_post_id_actor_id_emoji_index": { + "name": "reaction_post_id_actor_id_emoji_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emoji", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"reaction\".\"custom_emoji_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "reaction_post_id_actor_id_custom_emoji_id_index": { + "name": "reaction_post_id_actor_id_custom_emoji_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "custom_emoji_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"reaction\".\"emoji\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "reaction_post_id_index": { + "name": "reaction_post_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reaction_post_id_post_id_fk": { + "name": "reaction_post_id_post_id_fk", + "tableFrom": "reaction", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reaction_actor_id_actor_id_fk": { + "name": "reaction_actor_id_actor_id_fk", + "tableFrom": "reaction", + "tableTo": "actor", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reaction_custom_emoji_id_custom_emoji_id_fk": { + "name": "reaction_custom_emoji_id_custom_emoji_id_fk", + "tableFrom": "reaction", + "tableTo": "custom_emoji", + "columnsFrom": [ + "custom_emoji_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "reaction_emoji_check": { + "name": "reaction_emoji_check", + "value": "\n \"reaction\".\"emoji\" IS NOT NULL\n AND length(\"reaction\".\"emoji\") > 0\n AND \"reaction\".\"emoji\" !~ '^[[:space:]:]+|[[:space:]:]+$'\n AND \"reaction\".\"custom_emoji_id\" IS NULL\n OR\n \"reaction\".\"emoji\" IS NULL AND \"reaction\".\"custom_emoji_id\" IS NOT NULL\n " + } + }, + "isRLSEnabled": false + }, + "public.timeline_item": { + "name": "timeline_item", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "original_author_id": { + "name": "original_author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_sharer_id": { + "name": "last_sharer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sharers_count": { + "name": "sharers_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "added": { + "name": "added", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "appended": { + "name": "appended", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_timeline_item_account_id_added": { + "name": "idx_timeline_item_account_id_added", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"added\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_timeline_item_account_id_appended": { + "name": "idx_timeline_item_account_id_appended", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"appended\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_item_post_id_index": { + "name": "timeline_item_post_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "timeline_item_account_id_account_id_fk": { + "name": "timeline_item_account_id_account_id_fk", + "tableFrom": "timeline_item", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "timeline_item_post_id_post_id_fk": { + "name": "timeline_item_post_id_post_id_fk", + "tableFrom": "timeline_item", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "timeline_item_original_author_id_actor_id_fk": { + "name": "timeline_item_original_author_id_actor_id_fk", + "tableFrom": "timeline_item", + "tableTo": "actor", + "columnsFrom": [ + "original_author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "timeline_item_last_sharer_id_actor_id_fk": { + "name": "timeline_item_last_sharer_id_actor_id_fk", + "tableFrom": "timeline_item", + "tableTo": "actor", + "columnsFrom": [ + "last_sharer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "timeline_item_account_id_post_id_pk": { + "name": "timeline_item_account_id_post_id_pk", + "columns": [ + "account_id", + "post_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_key_type": { + "name": "account_key_type", + "schema": "public", + "values": [ + "Ed25519", + "RSASSA-PKCS1-v1_5" + ] + }, + "public.account_link_icon": { + "name": "account_link_icon", + "schema": "public", + "values": [ + "activitypub", + "akkoma", + "bluesky", + "codeberg", + "dev", + "discord", + "facebook", + "github", + "gitlab", + "hackernews", + "hollo", + "instagram", + "keybase", + "lemmy", + "linkedin", + "lobsters", + "mastodon", + "matrix", + "misskey", + "pixelfed", + "pleroma", + "qiita", + "reddit", + "sourcehut", + "threads", + "velog", + "web", + "wikipedia", + "x", + "zenn" + ] + }, + "public.actor_type": { + "name": "actor_type", + "schema": "public", + "values": [ + "Application", + "Group", + "Organization", + "Person", + "Service" + ] + }, + "public.medium_type": { + "name": "medium_type", + "schema": "public", + "values": [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "follow", + "mention", + "reply", + "share", + "quote", + "react" + ] + }, + "public.passkey_device_type": { + "name": "passkey_device_type", + "schema": "public", + "values": [ + "singleDevice", + "multiDevice" + ] + }, + "public.passkey_transport": { + "name": "passkey_transport", + "schema": "public", + "values": [ + "ble", + "cable", + "hybrid", + "internal", + "nfc", + "smart-card", + "usb" + ] + }, + "public.post_medium_type": { + "name": "post_medium_type", + "schema": "public", + "values": [ + "image/gif", + "image/jpeg", + "image/png", + "image/svg+xml", + "image/webp", + "video/mp4", + "video/webm", + "video/quicktime" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": [ + "Article", + "Note", + "Question" + ] + }, + "public.post_visibility": { + "name": "post_visibility", + "schema": "public", + "values": [ + "public", + "unlisted", + "followers", + "direct", + "none" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9b8725129..607ce9738 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -694,6 +694,13 @@ "when": 1778025600000, "tag": "0098_unified_medium", "breakpoints": true + }, + { + "idx": 99, + "version": "7", + "when": 1778025700000, + "tag": "0099_drop_note_source_medium_unique", + "breakpoints": true } ] } diff --git a/models/note.lifecycle.test.ts b/models/note.lifecycle.test.ts index 0c80bd58b..6ef502f86 100644 --- a/models/note.lifecycle.test.ts +++ b/models/note.lifecycle.test.ts @@ -5,6 +5,8 @@ import type { Context } from "@fedify/fedify"; import type { ContextData } from "./context.ts"; import type { Transaction } from "./db.ts"; import { createNote, updateNote } from "./note.ts"; +import { mediumTable } from "./schema.ts"; +import { generateUuidV7 } from "./uuid.ts"; import { createFedCtx, insertAccountWithActor, @@ -55,6 +57,48 @@ test("createNote() creates a post and timeline entry for the author", async () = }); }); +test("createNote() allows the same medium at multiple indexes", async () => { + await withRollback(async (tx) => { + const fedCtx = createFedCtx(tx); + const author = await insertAccountWithActor(tx, { + username: "duplicatenotemedia", + name: "Duplicate Note Media", + email: "duplicatenotemedia@example.com", + }); + const [medium] = await tx.insert(mediumTable).values({ + id: generateUuidV7(), + key: "note-media/duplicate.webp", + type: "image/webp", + width: 320, + height: 180, + }).returning(); + + const note = await createNote( + fedCtx as unknown as Context>, + { + accountId: author.account.id, + visibility: "public", + content: "Same image twice", + language: "en", + media: [ + { mediumId: medium.id, alt: "First occurrence" }, + { mediumId: medium.id, alt: "Second occurrence" }, + ], + }, + ); + + assert.ok(note != null); + assert.equal(note.noteSource.media.length, 2); + assert.equal(note.noteSource.media[0].index, 0); + assert.equal(note.noteSource.media[0].mediumId, medium.id); + assert.equal(note.noteSource.media[0].alt, "First occurrence"); + assert.equal(note.noteSource.media[1].index, 1); + assert.equal(note.noteSource.media[1].mediumId, medium.id); + assert.equal(note.noteSource.media[1].alt, "Second occurrence"); + assert.equal(note.media.length, 2); + }); +}); + test("createNote() stores tags relayed to tags.pub only for public posts", async () => { await withTagsPubRelayEnabled(async () => { await withRollback(async (tx) => { diff --git a/models/schema.ts b/models/schema.ts index a5ad95c9e..23ab72ece 100644 --- a/models/schema.ts +++ b/models/schema.ts @@ -627,7 +627,6 @@ export const noteSourceMediumTable = pgTable( }, (table) => [ primaryKey({ columns: [table.sourceId, table.index] }), - unique().on(table.sourceId, table.mediumId), check("note_source_medium_index_check", sql`${table.index} >= 0`), ], ); From 80c0b2922762b9ce051bd7c2d2b3cf641543079e Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 15:40:33 +0900 Subject: [PATCH 09/32] Require upload content length Validate proxy upload Content-Length before reading the request body so fallback uploads cannot buffer unbounded chunked data. Cover both the missing-length rejection path and a valid exact-length upload. Assisted-by: Codex:gpt-5.5 --- graphql/medium-upload.test.ts | 74 +++++++++++++++++++++++++++++++++++ graphql/medium-upload.ts | 9 ++++- 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 graphql/medium-upload.test.ts diff --git a/graphql/medium-upload.test.ts b/graphql/medium-upload.test.ts new file mode 100644 index 000000000..dc7be9a9b --- /dev/null +++ b/graphql/medium-upload.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Uuid } from "@hackerspub/models/uuid"; +import { + createMediumUploadSession, + handleMediumUploadProxy, +} from "./medium-upload.ts"; +import { createTestDisk, createTestKv } from "../test/postgres.ts"; + +test("handleMediumUploadProxy rejects missing content length before reading", async () => { + const { kv } = createTestKv(); + const disk = createTestDisk(); + const accountId = crypto.randomUUID() as Uuid; + const session = await createMediumUploadSession( + kv, + accountId, + "image/png", + 4, + ); + const body = new ReadableStream({ + pull() { + throw new Error("request body should not be read"); + }, + }); + + const response = await handleMediumUploadProxy( + new Request( + `http://localhost/medium-uploads/${session.id}?token=${session.token}`, + { + method: "PUT", + headers: { "Content-Type": "image/png" }, + body, + }, + ), + kv, + disk, + ); + + assert.ok(response != null); + assert.equal(response.status, 411); +}); + +test("handleMediumUploadProxy accepts exact content length", async () => { + const { kv } = createTestKv(); + const disk = createTestDisk(); + const accountId = crypto.randomUUID() as Uuid; + const session = await createMediumUploadSession( + kv, + accountId, + "image/png", + 4, + ); + const bytes = new Uint8Array([1, 2, 3, 4]); + + const response = await handleMediumUploadProxy( + new Request( + `http://localhost/medium-uploads/${session.id}?token=${session.token}`, + { + method: "PUT", + headers: { + "Content-Type": "image/png", + "Content-Length": String(bytes.byteLength), + }, + body: bytes, + }, + ), + kv, + disk, + ); + + assert.ok(response != null); + assert.equal(response.status, 204); + assert.deepEqual(await disk.getBytes(session.key), bytes); +}); diff --git a/graphql/medium-upload.ts b/graphql/medium-upload.ts index 7372f3ffc..91de00fcb 100644 --- a/graphql/medium-upload.ts +++ b/graphql/medium-upload.ts @@ -98,9 +98,14 @@ export async function handleMediumUploadProxy( return new Response("Unsupported Media Type", { status: 415 }); } const contentLength = request.headers.get("Content-Length"); + if (contentLength == null || !/^\d+$/.test(contentLength)) { + return new Response("Length Required", { status: 411 }); + } + const length = Number(contentLength); if ( - contentLength != null && - Number(contentLength) > MAX_STREAMING_MEDIUM_IMAGE_SIZE + !Number.isSafeInteger(length) || + length !== session.contentLength || + length > MAX_STREAMING_MEDIUM_IMAGE_SIZE ) { return new Response("Payload Too Large", { status: 413 }); } From ef7478141e16f630424ef6f988c02f89a7f404ca Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 16:19:38 +0900 Subject: [PATCH 10/32] Drop At suffix from DateTime fields Rename GraphQL DateTime fields that used an At suffix to match the schema convention. Keep the internal model property names unchanged and map them at the GraphQL boundary. Assisted-by: Codex:gpt-5.5 --- graphql/admin.test.ts | 44 +++++++++---------- graphql/admin.ts | 33 +++++++++++--- graphql/post.ts | 8 ++-- graphql/schema.graphql | 8 ++-- .../src/routes/(root)/admin/invitations.tsx | 8 ++-- 5 files changed, 61 insertions(+), 40 deletions(-) diff --git a/graphql/admin.test.ts b/graphql/admin.test.ts index fdd774cb8..49da09483 100644 --- a/graphql/admin.test.ts +++ b/graphql/admin.test.ts @@ -788,7 +788,7 @@ Deno.test({ const invitationRegenStatusQuery = parse(` query InvitationRegenerationStatus { invitationRegenerationStatus { - lastRegeneratedAt + lastRegenerated cutoffDate eligibleAccountsCount topThirdCount @@ -849,7 +849,7 @@ Deno.test({ Deno.test({ name: - "invitationRegenerationStatus returns null lastRegeneratedAt when KV empty", + "invitationRegenerationStatus returns null lastRegenerated when KV empty", sanitizeOps: false, sanitizeResources: false, async fn() { @@ -865,11 +865,11 @@ Deno.test({ assertEquals(result.errors, undefined); const status = (result.data as { invitationRegenerationStatus: { - lastRegeneratedAt: unknown; + lastRegenerated: unknown; } | null; }).invitationRegenerationStatus; assert(status != null); - assertEquals(status.lastRegeneratedAt, null); + assertEquals(status.lastRegenerated, null); }); }, }); @@ -893,15 +893,15 @@ Deno.test({ assertEquals(result.errors, undefined); const status = (result.data as { invitationRegenerationStatus: { - lastRegeneratedAt: Date | string | null; + lastRegenerated: Date | string | null; cutoffDate: Date | string; } | null; }).invitationRegenerationStatus; assert(status != null); - assert(status.lastRegeneratedAt != null); - const lastIso = status.lastRegeneratedAt instanceof Date - ? status.lastRegeneratedAt.toISOString() - : status.lastRegeneratedAt; + assert(status.lastRegenerated != null); + const lastIso = status.lastRegenerated instanceof Date + ? status.lastRegenerated.toISOString() + : status.lastRegenerated; assertEquals(lastIso, stored.toISOString()); const cutoffIso = status.cutoffDate instanceof Date ? status.cutoffDate.toISOString() @@ -974,9 +974,9 @@ const regenerateMutation = parse(` __typename ... on RegenerateInvitationsPayload { accountsAffected - regeneratedAt + regenerated status { - lastRegeneratedAt + lastRegenerated cutoffDate eligibleAccountsCount topThirdCount @@ -1094,15 +1094,15 @@ Deno.test({ regenerateInvitations: { __typename: string; accountsAffected: number; - regeneratedAt: Date | string; + regenerated: Date | string; status: { - lastRegeneratedAt: Date | string | null; + lastRegenerated: Date | string | null; }; }; }).regenerateInvitations; assertEquals(payload.__typename, "RegenerateInvitationsPayload"); assertEquals(payload.accountsAffected, 1); - assert(payload.status.lastRegeneratedAt != null); + assert(payload.status.lastRegenerated != null); // KV is updated. assert(typeof store.get(INVITATIONS_LAST_REGEN_KEY) === "string"); @@ -1142,18 +1142,18 @@ Deno.test({ assertEquals(result.errors, undefined); const payload = (result.data as { regenerateInvitations: { - regeneratedAt: Date | string; + regenerated: Date | string; status: { - lastRegeneratedAt: Date | string | null; + lastRegenerated: Date | string | null; }; }; }).regenerateInvitations; - const regenIso = payload.regeneratedAt instanceof Date - ? payload.regeneratedAt.toISOString() - : payload.regeneratedAt; - const lastIso = payload.status.lastRegeneratedAt instanceof Date - ? payload.status.lastRegeneratedAt.toISOString() - : payload.status.lastRegeneratedAt; + const regenIso = payload.regenerated instanceof Date + ? payload.regenerated.toISOString() + : payload.regenerated; + const lastIso = payload.status.lastRegenerated instanceof Date + ? payload.status.lastRegenerated.toISOString() + : payload.status.lastRegenerated; assertEquals(regenIso, lastIso); }); }, diff --git a/graphql/admin.ts b/graphql/admin.ts index e1c060bdb..799d10b87 100644 --- a/graphql/admin.ts +++ b/graphql/admin.ts @@ -1,5 +1,6 @@ import { getInvitationRegenerationStatus, + type InvitationRegenerationStatus as ModelInvitationRegenerationStatus, regenerateInvitations, } from "@hackerspub/models/admin"; import { accountTable, actorTable, postTable } from "@hackerspub/models/schema"; @@ -342,7 +343,7 @@ const InvitationRegenerationStatus = builder.simpleObject( "A snapshot of the invitation-regeneration state used by the admin UI " + "to preview a regeneration before triggering it.", fields: (t) => ({ - lastRegeneratedAt: t.field({ + lastRegenerated: t.field({ type: "DateTime", nullable: true, description: @@ -353,7 +354,7 @@ const InvitationRegenerationStatus = builder.simpleObject( type: "DateTime", description: "The earliest `published` timestamp a post must have to count " + - "an account as eligible. Equals `lastRegeneratedAt` once a " + + "an account as eligible. Equals `lastRegenerated` once a " + "regeneration has been recorded; otherwise defaults to one " + "week before now.", }), @@ -369,6 +370,24 @@ const InvitationRegenerationStatus = builder.simpleObject( }, ); +interface InvitationRegenerationStatusShape { + lastRegenerated: Date | null; + cutoffDate: Date; + eligibleAccountsCount: number; + topThirdCount: number; +} + +function toInvitationRegenerationStatusShape( + status: ModelInvitationRegenerationStatus, +): InvitationRegenerationStatusShape { + return { + lastRegenerated: status.lastRegeneratedAt, + cutoffDate: status.cutoffDate, + eligibleAccountsCount: status.eligibleAccountsCount, + topThirdCount: status.topThirdCount, + }; +} + builder.queryField("invitationRegenerationStatus", (t) => t.field({ type: InvitationRegenerationStatus, @@ -380,7 +399,9 @@ builder.queryField("invitationRegenerationStatus", (t) => async resolve(_root, _args, ctx) { if (ctx.session == null) return null; if (!ctx.account?.moderator) return null; - return await getInvitationRegenerationStatus(ctx.db, ctx.kv); + return toInvitationRegenerationStatusShape( + await getInvitationRegenerationStatus(ctx.db, ctx.kv), + ); }, })); @@ -389,7 +410,7 @@ const RegenerateInvitationsPayload = builder.simpleObject( { description: "The result of a successful invitations regeneration.", fields: (t) => ({ - regeneratedAt: t.field({ + regenerated: t.field({ type: "DateTime", description: "When the regeneration ran.", }), @@ -429,9 +450,9 @@ builder.mutationField("regenerateInvitations", (t) => // pay one aggregate query and report the actual numbers. const status = await getInvitationRegenerationStatus(ctx.db, ctx.kv); return { - regeneratedAt: result.regeneratedAt, + regenerated: result.regeneratedAt, accountsAffected: result.accountsAffected, - status, + status: toInvitationRegenerationStatusShape(status), }; }, })); diff --git a/graphql/post.ts b/graphql/post.ts index 39cbb36ec..a4003eafb 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -2096,7 +2096,7 @@ interface MediumUploadStart { uploadUrl: URL; method: string; headers: { name: string; value: string }[]; - expiresAt: Date; + expires: Date; } builder.relayMutationField( @@ -2156,7 +2156,7 @@ builder.relayMutationField( uploadUrl, method: "PUT", headers: [{ name: "Content-Type", value: upload.contentType }], - expiresAt: new Date(Date.now() + MEDIUM_UPLOAD_TTL_MS), + expires: new Date(Date.now() + MEDIUM_UPLOAD_TTL_MS), } satisfies MediumUploadStart; }, }, @@ -2175,9 +2175,9 @@ builder.relayMutationField( type: [MediumUploadHeader], resolve: (result) => result.headers, }), - expiresAt: t.field({ + expires: t.field({ type: "DateTime", - resolve: (result) => result.expiresAt, + resolve: (result) => result.expires, }), }), }, diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 0ef5dea5b..3038f6f87 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -643,7 +643,7 @@ A snapshot of the invitation-regeneration state used by the admin UI to preview """ type InvitationRegenerationStatus { """ - The earliest `published` timestamp a post must have to count an account as eligible. Equals `lastRegeneratedAt` once a regeneration has been recorded; otherwise defaults to one week before now. + The earliest `published` timestamp a post must have to count an account as eligible. Equals `lastRegenerated` once a regeneration has been recorded; otherwise defaults to one week before now. """ cutoffDate: DateTime! @@ -653,7 +653,7 @@ type InvitationRegenerationStatus { """ When the regeneration was last triggered, or null if it has never been run. """ - lastRegeneratedAt: DateTime + lastRegenerated: DateTime """ Number of accounts that would receive an invitation if a regeneration were triggered now (ceil(eligible / 3)). @@ -1435,7 +1435,7 @@ type RegenerateInvitationsPayload { accountsAffected: Int! """When the regeneration ran.""" - regeneratedAt: DateTime! + regenerated: DateTime! """The updated regeneration status reflecting the just-recorded run.""" status: InvitationRegenerationStatus! @@ -1656,7 +1656,7 @@ input StartMediumUploadInput { type StartMediumUploadPayload { clientMutationId: ID - expiresAt: DateTime! + expires: DateTime! headers: [MediumUploadHeader!]! method: String! uploadId: UUID! diff --git a/web-next/src/routes/(root)/admin/invitations.tsx b/web-next/src/routes/(root)/admin/invitations.tsx index c2e7597a6..b991de749 100644 --- a/web-next/src/routes/(root)/admin/invitations.tsx +++ b/web-next/src/routes/(root)/admin/invitations.tsx @@ -30,7 +30,7 @@ const invitationsPageQuery = graphql` moderator } invitationRegenerationStatus { - lastRegeneratedAt + lastRegenerated cutoffDate eligibleAccountsCount topThirdCount @@ -61,9 +61,9 @@ const invitationsRegenerateMutation = graphql` __typename ... on RegenerateInvitationsPayload { accountsAffected - regeneratedAt + regenerated status { - lastRegeneratedAt + lastRegenerated cutoffDate eligibleAccountsCount topThirdCount @@ -168,7 +168,7 @@ export default function AdminInvitationsPage() { {" "} {t`Never`} From b06cf9fa529ab46ee4e6d8684b49f968704e3aec Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 16:35:16 +0900 Subject: [PATCH 11/32] Expose content hashes as Sha256 Add a Sha256 GraphQL scalar for lowercase hex-encoded SHA-256 digests and expose Medium.contentHash through it instead of String. Update both GraphQL codegen configurations so clients continue to generate cleanly. Assisted-by: Codex:gpt-5.5 --- graphql/builder.ts | 41 ++++++++++++++++++++++++ graphql/post.more.test.ts | 64 ++++++++++++++++++++++++++++++++++++++ graphql/post.ts | 3 +- graphql/schema.graphql | 5 ++- web-next/relay.config.json | 1 + web/codegen.ts | 1 + 6 files changed, 113 insertions(+), 2 deletions(-) diff --git a/graphql/builder.ts b/graphql/builder.ts index 5089a4da5..f4277652d 100644 --- a/graphql/builder.ts +++ b/graphql/builder.ts @@ -118,6 +118,10 @@ export interface PothosTypes { Input: string; Output: string; }; + Sha256: { + Input: string; + Output: string; + }; URITemplate: { Input: string; Output: string; @@ -342,6 +346,43 @@ builder.scalarType("MediaType", { parseValue: (v) => String(v), }); +const sha256Pattern = /^[0-9a-f]{64}$/; + +function normalizeSha256(value: unknown): string { + if (typeof value !== "string" || !sha256Pattern.test(value)) { + throw createGraphQLError( + "Expected a lowercase hex-encoded SHA-256 digest.", + ); + } + return value; +} + +builder.addScalarType( + "Sha256", + new GraphQLScalarType({ + name: "Sha256", + description: "A lowercase hex-encoded SHA-256 digest.", + serialize: normalizeSha256, + parseValue: normalizeSha256, + parseLiteral(ast) { + if (ast.kind !== Kind.STRING) { + throw createGraphQLError( + `Can only validate strings as SHA-256 digests but got a: ${ast.kind}`, + { nodes: ast }, + ); + } + return normalizeSha256(ast.value); + }, + extensions: { + codegenScalarType: "string", + jsonSchema: { + type: "string", + pattern: "^[0-9a-f]{64}$", + }, + }, + }), +); + builder.queryType({}); builder.mutationType({}); diff --git a/graphql/post.more.test.ts b/graphql/post.more.test.ts index 076920888..94c82574e 100644 --- a/graphql/post.more.test.ts +++ b/graphql/post.more.test.ts @@ -144,6 +144,7 @@ const createMediumMutation = parse(` uuid url type + contentHash width height } @@ -158,6 +159,29 @@ const createMediumMutation = parse(` } `); +const mediumContentHashTypeQuery = parse(` + query MediumContentHashType { + medium: __type(name: "Medium") { + fields { + name + type { + kind + name + ofType { + kind + name + } + } + } + } + sha256: __type(name: "Sha256") { + kind + name + description + } + } +`); + const attachArticleDraftMediumMutation = parse(` mutation AttachArticleDraftMedium($input: AttachArticleDraftMediumInput!) { attachArticleDraftMedium(input: $input) { @@ -368,6 +392,44 @@ test("saveArticleDraft, articleDraft, and deleteArticleDraft round-trip a draft" }); }); +test("Medium.contentHash is exposed as Sha256", async () => { + const result = await execute({ + schema, + document: mediumContentHashTypeQuery, + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + const data = toPlainJson(result.data) as { + medium: { + fields: { + name: string; + type: { + kind: string; + name: string | null; + ofType: { kind: string; name: string | null } | null; + }; + }[]; + }; + sha256: { + kind: string; + name: string; + description: string; + }; + }; + assert.equal(data.sha256.kind, "SCALAR"); + assert.equal(data.sha256.name, "Sha256"); + assert.match(data.sha256.description, /SHA-256/); + const contentHash = data.medium.fields.find((field) => + field.name === "contentHash" + ); + assert.deepEqual(contentHash?.type, { + kind: "SCALAR", + name: "Sha256", + ofType: null, + }); +}); + test("createMedium and attachArticleDraftMedium create draft media relations", async () => { await withRollback(async (tx) => { const account = await insertAccountWithActor(tx, { @@ -393,12 +455,14 @@ test("createMedium and attachArticleDraftMedium create draft media relations", a uuid: string; url: string; type: string; + contentHash: string; width: number; height: number; }; }; }).createMedium.medium; assert.equal(medium.type, "image/webp"); + assert.match(medium.contentHash, /^[0-9a-f]{64}$/); assert.equal(medium.width, 1); assert.equal(medium.height, 1); assert.match(medium.url, /^http:\/\/localhost\/media\/media\/.+\.webp$/); diff --git a/graphql/post.ts b/graphql/post.ts index a4003eafb..a26a8c9a0 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -691,7 +691,8 @@ export const Medium = builder.drizzleNode("mediumTable", { type: "MediaType", description: "The medium's media type. Local uploads are stored as WebP.", }), - contentHash: t.exposeString("contentHash", { + contentHash: t.expose("contentHash", { + type: "Sha256", nullable: true, description: "SHA-256 hash of the normalized stored content, if known.", }), diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 3038f6f87..06c05c3f0 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -730,7 +730,7 @@ scalar MediaType type Medium implements Node { """SHA-256 hash of the normalized stored content, if known.""" - contentHash: String + contentHash: Sha256 created: DateTime! height: Int id: ID! @@ -1574,6 +1574,9 @@ type Session { userAgent: String } +"""A lowercase hex-encoded SHA-256 digest.""" +scalar Sha256 + type ShareNotification implements Node & Notification { account: Account! actors(after: String, before: String, first: Int, last: Int): NotificationActorsConnection! diff --git a/web-next/relay.config.json b/web-next/relay.config.json index 4aa0689ae..6c5157f7e 100644 --- a/web-next/relay.config.json +++ b/web-next/relay.config.json @@ -14,6 +14,7 @@ "Locale": "string", "Markdown": "string", "MediaType": "string", + "Sha256": "string", "URL": "string", "URITemplate": "string", "UUID": "`${string}-${string}-${string}-${string}-${string}`" diff --git a/web/codegen.ts b/web/codegen.ts index 5fbb812a7..1ba98539e 100644 --- a/web/codegen.ts +++ b/web/codegen.ts @@ -22,6 +22,7 @@ const config: CodegenConfig = { Locale: "Intl.Locale | string", Markdown: "string", MediaType: "string", + Sha256: "string", URL: "URL", URITemplate: "string", UUID: "`${string}-${string}-${string}-${string}-${string}`", From 348edb5ccefe0587d54b02b8a46c42438b7beba1 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 17:20:31 +0900 Subject: [PATCH 12/32] Add orphan media cleanup Add moderator-only orphan media status and deletion APIs, plus manual cleanup pages in both web stacks. The cleanup keeps a 24-hour grace period and only deletes media that are no longer referenced by accounts, notes, article drafts, or article sources. Assisted-by: Codex:gpt-5.5 --- graphql/admin.test.ts | 195 ++++++++++++++++- graphql/admin.ts | 78 +++++++ graphql/schema.graphql | 35 +++ models/admin.test.ts | 168 +++++++++++++- models/admin.ts | 100 +++++++++ web-next/src/components/AppSidebar.tsx | 23 ++ web-next/src/locales/en-US/messages.po | 207 +++++++++++------- web-next/src/locales/ja-JP/messages.po | 207 +++++++++++------- web-next/src/locales/ko-KR/messages.po | 207 +++++++++++------- web-next/src/locales/zh-CN/messages.po | 207 +++++++++++------- web-next/src/locales/zh-TW/messages.po | 207 +++++++++++------- .../mediaDeleteOrphanMediaMutation.graphql.ts | 164 ++++++++++++++ .../__generated__/mediaPageQuery.graphql.ts | 125 +++++++++++ web-next/src/routes/(root)/admin/media.tsx | 204 +++++++++++++++++ web/components/AdminNav.tsx | 5 +- web/routes/admin/media.tsx | 82 +++++++ 16 files changed, 1806 insertions(+), 408 deletions(-) create mode 100644 web-next/src/routes/(root)/admin/__generated__/mediaDeleteOrphanMediaMutation.graphql.ts create mode 100644 web-next/src/routes/(root)/admin/__generated__/mediaPageQuery.graphql.ts create mode 100644 web-next/src/routes/(root)/admin/media.tsx create mode 100644 web/routes/admin/media.tsx diff --git a/graphql/admin.test.ts b/graphql/admin.test.ts index 49da09483..791f295ff 100644 --- a/graphql/admin.test.ts +++ b/graphql/admin.test.ts @@ -1,9 +1,11 @@ import { assert } from "@std/assert/assert"; import { assertEquals } from "@std/assert/equals"; import { INVITATIONS_LAST_REGEN_KEY } from "@hackerspub/models/admin"; -import { accountTable } from "@hackerspub/models/schema"; +import { accountTable, mediumTable } from "@hackerspub/models/schema"; +import { generateUuidV7, type Uuid } from "@hackerspub/models/uuid"; import { eq, inArray, sql } from "drizzle-orm"; import { execute, parse } from "graphql"; +import type { UserContext } from "./builder.ts"; import { schema } from "./mod.ts"; import { createTestKv, @@ -14,6 +16,37 @@ import { withRollback, } from "../test/postgres.ts"; +function createTrackingDisk() { + const deleteKeys: string[] = []; + return { + deleteKeys, + disk: { + delete(key: string) { + deleteKeys.push(key); + return Promise.resolve(undefined); + }, + } as unknown as UserContext["disk"], + }; +} + +async function insertTestMedium( + tx: Parameters[0]>[0], + key: string, + created: Date, +): Promise { + const id = generateUuidV7(); + await tx.insert(mediumTable).values({ + id, + key, + type: "image/webp", + contentHash: null, + width: 1, + height: 1, + created, + }); + return id; +} + const adminAccountsQuery = parse(` query AdminAccounts( $first: Int @@ -1232,6 +1265,166 @@ Deno.test({ }, }); +const orphanMediaStatusQuery = parse(` + query OrphanMediaStatus { + orphanMediaStatus { + cutoffDate + orphanMediaCount + } + } +`); + +Deno.test({ + name: "orphanMediaStatus returns null for guest", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const result = await execute({ + schema, + document: orphanMediaStatusQuery, + contextValue: makeGuestContext(tx), + onError: "NO_PROPAGATE", + }); + assertEquals(result.errors, undefined); + assertEquals( + (result.data as { orphanMediaStatus: unknown }).orphanMediaStatus, + null, + ); + }); + }, +}); + +Deno.test({ + name: "orphanMediaStatus counts old unreferenced media for moderators", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const mod = await makeModerator(tx, "orphanstatusmod"); + await insertTestMedium( + tx, + "media/graphql-orphan-status.webp", + new Date("2020-01-01T00:00:00.000Z"), + ); + await insertTestMedium( + tx, + "media/graphql-recent-status.webp", + new Date(), + ); + + const result = await execute({ + schema, + document: orphanMediaStatusQuery, + contextValue: makeUserContext(tx, mod.account), + onError: "NO_PROPAGATE", + }); + + assertEquals(result.errors, undefined); + const status = (result.data as { + orphanMediaStatus: { + orphanMediaCount: number; + cutoffDate: Date | string; + } | null; + }).orphanMediaStatus; + assert(status != null); + assertEquals(status.orphanMediaCount, 1); + }); + }, +}); + +const deleteOrphanMediaMutation = parse(` + mutation DeleteOrphanMedia { + deleteOrphanMedia { + __typename + ... on DeleteOrphanMediaPayload { + deletedCount + failedDiskDeletes + status { + orphanMediaCount + } + } + ... on NotAuthenticatedError { notAuthenticated } + ... on NotAuthorizedError { notAuthorized } + } + } +`); + +Deno.test({ + name: "deleteOrphanMedia returns NotAuthenticatedError for guest", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const result = await execute({ + schema, + document: deleteOrphanMediaMutation, + contextValue: makeGuestContext(tx), + onError: "NO_PROPAGATE", + }); + assertEquals(result.errors, undefined); + assertEquals( + (result.data as { + deleteOrphanMedia: { __typename: string }; + }).deleteOrphanMedia.__typename, + "NotAuthenticatedError", + ); + }); + }, +}); + +Deno.test({ + name: "deleteOrphanMedia deletes old unreferenced media for moderators", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const mod = await makeModerator(tx, "orphanmutmod"); + const orphanId = await insertTestMedium( + tx, + "media/graphql-orphan-delete.webp", + new Date("2020-01-01T00:00:00.000Z"), + ); + const recentId = await insertTestMedium( + tx, + "media/graphql-recent-keep.webp", + new Date(), + ); + const disk = createTrackingDisk(); + + const result = await execute({ + schema, + document: deleteOrphanMediaMutation, + contextValue: makeUserContext(tx, mod.account, { disk: disk.disk }), + onError: "NO_PROPAGATE", + }); + + assertEquals(result.errors, undefined); + const payload = (result.data as { + deleteOrphanMedia: { + __typename: string; + deletedCount: number; + failedDiskDeletes: number; + status: { orphanMediaCount: number }; + }; + }).deleteOrphanMedia; + assertEquals(payload.__typename, "DeleteOrphanMediaPayload"); + assertEquals(payload.deletedCount, 1); + assertEquals(payload.failedDiskDeletes, 0); + assertEquals(payload.status.orphanMediaCount, 0); + assertEquals(disk.deleteKeys, ["media/graphql-orphan-delete.webp"]); + assertEquals( + await tx.query.mediumTable.findFirst({ where: { id: orphanId } }), + undefined, + ); + assert( + await tx.query.mediumTable.findFirst({ where: { id: recentId } }) != + null, + ); + }); + }, +}); + Deno.test({ name: "regenerateInvitations called twice in immediate succession returns 0 affected on second", diff --git a/graphql/admin.ts b/graphql/admin.ts index 799d10b87..c175a10e8 100644 --- a/graphql/admin.ts +++ b/graphql/admin.ts @@ -1,5 +1,7 @@ import { + deleteOrphanMedia, getInvitationRegenerationStatus, + getOrphanMediaStatus, type InvitationRegenerationStatus as ModelInvitationRegenerationStatus, regenerateInvitations, } from "@hackerspub/models/admin"; @@ -456,3 +458,79 @@ builder.mutationField("regenerateInvitations", (t) => }; }, })); + +const OrphanMediaStatus = builder.simpleObject( + "OrphanMediaStatus", + { + description: + "A snapshot of media objects old enough to delete and not referenced " + + "by accounts, notes, article drafts, or article sources.", + fields: (t) => ({ + cutoffDate: t.field({ + type: "DateTime", + description: + "Only unreferenced media created before this timestamp are counted.", + }), + orphanMediaCount: t.int({ + description: + "Number of unreferenced media objects older than the cutoff.", + }), + }), + }, +); + +builder.queryField("orphanMediaStatus", (t) => + t.field({ + type: OrphanMediaStatus, + nullable: true, + description: + "Moderator-only orphan media preview. Returns null when the viewer " + + "is not a moderator.", + async resolve(_root, _args, ctx) { + if (ctx.session == null) return null; + if (!ctx.account?.moderator) return null; + return await getOrphanMediaStatus(ctx.db); + }, + })); + +const DeleteOrphanMediaPayload = builder.simpleObject( + "DeleteOrphanMediaPayload", + { + description: "The result of deleting orphan media.", + fields: (t) => ({ + deletedCount: t.int({ + description: "Number of orphan media database rows deleted.", + }), + failedDiskDeletes: t.int({ + description: + "Number of stored media objects that could not be deleted from disk.", + }), + status: t.field({ + type: OrphanMediaStatus, + description: "The orphan media status after the deletion attempt.", + }), + }), + }, +); + +builder.mutationField("deleteOrphanMedia", (t) => + t.field({ + type: DeleteOrphanMediaPayload, + description: + "Delete unreferenced media older than the grace period. Requires a " + + "moderator account.", + errors: { + types: [NotAuthenticatedError, NotAuthorizedError], + }, + async resolve(_root, _args, ctx) { + if (ctx.session == null) throw new NotAuthenticatedError(); + if (!ctx.account?.moderator) throw new NotAuthorizedError(); + const result = await deleteOrphanMedia(ctx.db, ctx.disk); + const status = await getOrphanMediaStatus(ctx.db); + return { + deletedCount: result.deletedCount, + failedDiskDeletes: result.failedDiskDeletes, + status, + }; + }, + })); diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 06c05c3f0..491337e7c 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -512,6 +512,20 @@ union DeleteArticleDraftResult = DeleteArticleDraftPayload | InvalidInputError | union DeleteInvitationLinkResult = InvitationLinkNotFoundError | InvitationLinkPayload | NotAuthenticatedError +"""The result of deleting orphan media.""" +type DeleteOrphanMediaPayload { + """Number of orphan media database rows deleted.""" + deletedCount: Int! + + """Number of stored media objects that could not be deleted from disk.""" + failedDiskDeletes: Int! + + """The orphan media status after the deletion attempt.""" + status: OrphanMediaStatus! +} + +union DeleteOrphanMediaResult = DeleteOrphanMediaPayload | NotAuthenticatedError | NotAuthorizedError + input DeletePostInput { clientMutationId: ID id: ID! @@ -787,6 +801,11 @@ type Mutation { createNote(input: CreateNoteInput!): CreateNoteResult! deleteArticleDraft(input: DeleteArticleDraftInput!): DeleteArticleDraftResult! deleteInvitationLink(id: UUID!): DeleteInvitationLinkResult! + + """ + Delete unreferenced media older than the grace period. Requires a moderator account. + """ + deleteOrphanMedia: DeleteOrphanMediaResult! deletePost(input: DeletePostInput!): DeletePostResult! finishMediumUpload(input: FinishMediumUploadInput!): FinishMediumUploadResult! followActor(input: FollowActorInput!): FollowActorResult! @@ -954,6 +973,17 @@ enum NotificationType { SHARE } +""" +A snapshot of media objects old enough to delete and not referenced by accounts, notes, article drafts, or article sources. +""" +type OrphanMediaStatus { + """Only unreferenced media created before this timestamp are counted.""" + cutoffDate: DateTime! + + """Number of unreferenced media objects older than the cutoff.""" + orphanMediaCount: Int! +} + type PageInfo { endCursor: String hasNextPage: Boolean! @@ -1239,6 +1269,11 @@ type Query { ): Document! node(id: ID!): Node nodes(ids: [ID!]!): [Node]! + + """ + Moderator-only orphan media preview. Returns null when the viewer is not a moderator. + """ + orphanMediaStatus: OrphanMediaStatus personalTimeline(after: String, before: String, first: Int, last: Int, local: Boolean = false, postType: PostType, withoutShares: Boolean = false): QueryPersonalTimelineConnection! postByUrl(url: String!): Post privacyPolicy( diff --git a/models/admin.test.ts b/models/admin.test.ts index a1627f566..5a3f630b9 100644 --- a/models/admin.test.ts +++ b/models/admin.test.ts @@ -8,12 +8,56 @@ import { withRollback, } from "../test/postgres.ts"; import { + deleteOrphanMedia, getInvitationRegenerationStatus, getInvitationsLastRegen, + getOrphanMediaStatus, INVITATIONS_LAST_REGEN_KEY, regenerateInvitations, } from "./admin.ts"; -import { accountTable, adminStateTable } from "./schema.ts"; +import { + accountTable, + adminStateTable, + articleDraftMediumTable, + articleDraftTable, + articleSourceMediumTable, + articleSourceTable, + mediumTable, + noteSourceMediumTable, + noteSourceTable, +} from "./schema.ts"; +import { generateUuidV7, type Uuid } from "./uuid.ts"; + +function createTrackingDisk() { + const deleteKeys: string[] = []; + return { + deleteKeys, + disk: { + delete(key: string) { + deleteKeys.push(key); + return Promise.resolve(undefined); + }, + } as unknown as Parameters[1], + }; +} + +async function insertTestMedium( + tx: Parameters[0]>[0], + key: string, + created: Date, +): Promise { + const id = generateUuidV7(); + await tx.insert(mediumTable).values({ + id, + key, + type: "image/webp", + contentHash: null, + width: 1, + height: 1, + created, + }); + return id; +} Deno.test({ name: "getInvitationsLastRegen returns null when DB and KV are empty", @@ -264,6 +308,128 @@ Deno.test({ }, }); +Deno.test({ + name: "getOrphanMediaStatus counts only old unreferenced media", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const account = await insertAccountWithActor(tx, { + username: "orphanstatus", + name: "Orphan Status", + email: "orphanstatus@example.com", + }); + const now = new Date("2026-04-15T00:00:00.000Z"); + const old = new Date("2026-04-13T00:00:00.000Z"); + const cutoff = new Date("2026-04-14T00:00:00.000Z"); + const recent = new Date("2026-04-14T12:00:00.000Z"); + + await insertTestMedium(tx, "media/orphan.webp", old); + await insertTestMedium(tx, "media/recent.webp", recent); + + const avatarMediumId = await insertTestMedium( + tx, + "media/avatar.webp", + old, + ); + await tx.update(accountTable).set({ avatarMediumId }).where( + eq(accountTable.id, account.account.id), + ); + + const noteMediumId = await insertTestMedium(tx, "media/note.webp", old); + const noteSourceId = generateUuidV7(); + await tx.insert(noteSourceTable).values({ + id: noteSourceId, + accountId: account.account.id, + content: "note", + language: "en", + }); + await tx.insert(noteSourceMediumTable).values({ + sourceId: noteSourceId, + index: 0, + mediumId: noteMediumId, + alt: "", + }); + + const draftMediumId = await insertTestMedium(tx, "media/draft.webp", old); + const draftId = generateUuidV7(); + await tx.insert(articleDraftTable).values({ + id: draftId, + accountId: account.account.id, + title: "Draft", + content: "draft", + }); + await tx.insert(articleDraftMediumTable).values({ + articleDraftId: draftId, + key: "draft-key", + mediumId: draftMediumId, + }); + + const sourceMediumId = await insertTestMedium( + tx, + "media/source.webp", + old, + ); + const sourceId = generateUuidV7(); + await tx.insert(articleSourceTable).values({ + id: sourceId, + accountId: account.account.id, + slug: "source", + published: new Date("2026-04-15T00:00:00.000Z"), + }); + await tx.insert(articleSourceMediumTable).values({ + articleSourceId: sourceId, + key: "source-key", + mediumId: sourceMediumId, + }); + + const status = await getOrphanMediaStatus(tx, { now }); + assertEquals(status.cutoffDate.toISOString(), cutoff.toISOString()); + assertEquals(status.orphanMediaCount, 1); + }); + }, +}); + +Deno.test({ + name: "deleteOrphanMedia removes old unreferenced rows and disk objects", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const now = new Date("2026-04-15T00:00:00.000Z"); + const old = new Date("2026-04-13T00:00:00.000Z"); + const cutoff = new Date("2026-04-14T00:00:00.000Z"); + const recent = new Date("2026-04-14T12:00:00.000Z"); + const orphanId = await insertTestMedium( + tx, + "media/orphan-delete.webp", + old, + ); + const recentId = await insertTestMedium( + tx, + "media/recent-keep.webp", + recent, + ); + const disk = createTrackingDisk(); + + const result = await deleteOrphanMedia(tx, disk.disk, { now }); + + assertEquals(result.cutoffDate.toISOString(), cutoff.toISOString()); + assertEquals(result.deletedCount, 1); + assertEquals(result.failedDiskDeletes, 0); + assertEquals(disk.deleteKeys, ["media/orphan-delete.webp"]); + assertEquals( + await tx.query.mediumTable.findFirst({ where: { id: orphanId } }), + undefined, + ); + assert( + await tx.query.mediumTable.findFirst({ where: { id: recentId } }) != + null, + ); + }); + }, +}); + Deno.test({ name: "regenerateInvitations falls back to one-week cutoff when KV key absent", diff --git a/models/admin.ts b/models/admin.ts index 6490b2dc0..9a4eb83d1 100644 --- a/models/admin.ts +++ b/models/admin.ts @@ -9,8 +9,10 @@ import { inArray, isNotNull, lte, + type SQL, sql, } from "drizzle-orm"; +import type { Disk } from "flydrive"; import type Keyv from "keyv"; const logger = getLogger(["hackerspub", "models", "admin"]); @@ -19,6 +21,10 @@ import { accountTable, actorTable, adminStateTable, + articleDraftMediumTable, + articleSourceMediumTable, + mediumTable, + noteSourceMediumTable, postTable, } from "./schema.ts"; import { type Uuid, validateUuid } from "./uuid.ts"; @@ -36,6 +42,8 @@ const INVITATIONS_REGEN_LOCK_KEY = 0x69_6e_76_72; export const DEFAULT_REGEN_CUTOFF_DURATION: Temporal.Duration = Temporal .Duration.from({ days: 7 }); +export const DEFAULT_ORPHAN_MEDIA_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000; + function isTransaction(db: Database): db is Transaction { return "rollback" in db; } @@ -58,6 +66,22 @@ export interface RegenerateOptions { defaultCutoffDuration?: Temporal.Duration; } +export interface OrphanMediaStatus { + cutoffDate: Date; + orphanMediaCount: number; +} + +export interface OrphanMediaOptions { + now?: Date; + gracePeriodMs?: number; +} + +export interface DeleteOrphanMediaResult { + cutoffDate: Date; + deletedCount: number; + failedDiskDeletes: number; +} + export async function getInvitationsLastRegen( db: Database, kv?: Keyv, @@ -229,3 +253,79 @@ export async function regenerateInvitations( } return result; } + +function resolveOrphanMediaCutoff(options: OrphanMediaOptions): Date { + const now = options.now ?? new Date(); + const gracePeriodMs = options.gracePeriodMs ?? + DEFAULT_ORPHAN_MEDIA_GRACE_PERIOD_MS; + return new Date(now.getTime() - gracePeriodMs); +} + +function orphanMediaWhere(cutoffDate: Date): SQL { + const cutoffDateSql = sql`${cutoffDate.toISOString()}::timestamptz`; + return sql` + ${mediumTable.created} < ${cutoffDateSql} AND + NOT EXISTS ( + SELECT 1 FROM ${accountTable} + WHERE ${accountTable.avatarMediumId} = ${mediumTable.id} + ) AND + NOT EXISTS ( + SELECT 1 FROM ${noteSourceMediumTable} + WHERE ${noteSourceMediumTable.mediumId} = ${mediumTable.id} + ) AND + NOT EXISTS ( + SELECT 1 FROM ${articleDraftMediumTable} + WHERE ${articleDraftMediumTable.mediumId} = ${mediumTable.id} + ) AND + NOT EXISTS ( + SELECT 1 FROM ${articleSourceMediumTable} + WHERE ${articleSourceMediumTable.mediumId} = ${mediumTable.id} + ) + `; +} + +export async function getOrphanMediaStatus( + db: Database, + options: OrphanMediaOptions = {}, +): Promise { + const cutoffDate = resolveOrphanMediaCutoff(options); + const [row] = await db + .select({ count: count() }) + .from(mediumTable) + .where(orphanMediaWhere(cutoffDate)); + return { + cutoffDate, + orphanMediaCount: Number(row?.count ?? 0), + }; +} + +export async function deleteOrphanMedia( + db: Database, + disk: Disk, + options: OrphanMediaOptions = {}, +): Promise { + const cutoffDate = resolveOrphanMediaCutoff(options); + const deleted = await db + .delete(mediumTable) + .where(orphanMediaWhere(cutoffDate)) + .returning({ key: mediumTable.key }); + + let failedDiskDeletes = 0; + for (const { key } of deleted) { + try { + await disk.delete(key); + } catch (error) { + failedDiskDeletes++; + logger.warn( + "Failed to delete orphan medium object {key}: {error}", + { key, error }, + ); + } + } + + return { + cutoffDate, + deletedCount: deleted.length, + failedDiskDeletes, + }; +} diff --git a/web-next/src/components/AppSidebar.tsx b/web-next/src/components/AppSidebar.tsx index f7996823c..a39c66c9a 100644 --- a/web-next/src/components/AppSidebar.tsx +++ b/web-next/src/components/AppSidebar.tsx @@ -631,6 +631,29 @@ function AdminSection(props: AdminSectionProps) { {t`Invitations`} + + + + + + {t`Media`} + + diff --git a/web-next/src/locales/en-US/messages.po b/web-next/src/locales/en-US/messages.po index 2572000f2..070a48f07 100644 --- a/web-next/src/locales/en-US/messages.po +++ b/web-next/src/locales/en-US/messages.po @@ -18,6 +18,11 @@ msgstr "" msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}" +#. placeholder {0}: result.failedDiskDeletes! +#: src/routes/(root)/admin/media.tsx:109 +msgid "{0, plural, one {# disk object could not be deleted.} other {# disk objects could not be deleted.}}" +msgstr "{0, plural, one {# disk object could not be deleted.} other {# disk objects could not be deleted.}}" + #. placeholder {0}: status()?.eligibleAccountsCount ?? 0 #. placeholder {1}: status()?.topThirdCount ?? 0 #: src/routes/(root)/admin/invitations.tsx:191 @@ -44,6 +49,11 @@ msgstr "{0, plural, one {# following} other {# following}}" msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, one {# invitation left} other {# invitations left}}" +#. placeholder {0}: count() +#: src/routes/(root)/admin/media.tsx:177 +msgid "{0, plural, one {# orphan medium can be deleted.} other {# orphan media can be deleted.}}" +msgstr "{0, plural, one {# orphan medium can be deleted.} other {# orphan media can be deleted.}}" + #. placeholder {0}: votes() #: src/components/QuestionCard.tsx:484 msgid "{0, plural, one {# vote} other {# votes}}" @@ -59,6 +69,11 @@ msgstr "{0, plural, one {# voter} other {# voters}}" msgid "{0, plural, one {+1 more} other {+# more}}" msgstr "{0, plural, one {+1 more} other {+# more}}" +#. placeholder {0}: result.deletedCount! +#: src/routes/(root)/admin/media.tsx:100 +msgid "{0, plural, one {Deleted # orphan medium.} other {Deleted # orphan media.}}" +msgstr "{0, plural, one {Deleted # orphan medium.} other {Deleted # orphan media.}}" + #. placeholder {0}: account.invitationsLeft #: src/routes/(root)/[handle]/settings/invite.tsx:306 msgid "{0, plural, one {Invite your friends to Hackers' Pub. You can invite up to # person.} other {Invite your friends to Hackers' Pub. You can invite up to # people.}}" @@ -167,7 +182,7 @@ msgstr "{0} shared your post" #. placeholder {1}: post.excerpt #. placeholder {1}: title() #: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:238 -#: src/routes/(root)/[handle]/[noteId].tsx:221 +#: src/routes/(root)/[handle]/[noteId].tsx:226 msgid "{0}: {1}" msgstr "{0}: {1}" @@ -201,7 +216,7 @@ msgstr "{0}'s notes" msgid "{0}'s shares" msgstr "{0}'s shares" -#: src/routes/(root)/[handle]/settings/index.tsx:534 +#: src/routes/(root)/[handle]/settings/index.tsx:556 msgid "A name for the link that will be displayed on your profile, e.g., GitHub." msgstr "A name for the link that will be displayed on your profile, e.g., GitHub." @@ -265,7 +280,8 @@ msgstr "An error occurred while saving your preferences. Please try again, or co msgid "An error occurred while saving your preferred languages. Please try again, or contact support if the problem persists." msgstr "An error occurred while saving your preferred languages. Please try again, or contact support if the problem persists." -#: src/routes/(root)/[handle]/settings/index.tsx:311 +#: src/routes/(root)/[handle]/settings/index.tsx:292 +#: src/routes/(root)/[handle]/settings/index.tsx:333 msgid "An error occurred while saving your settings. Please try again, or contact support if the problem persists." msgstr "An error occurred while saving your settings. Please try again, or contact support if the problem persists." @@ -286,7 +302,7 @@ msgstr "Are you sure you want to block {0} ({1})? They won't be able to follow y msgid "Are you sure you want to delete \"{draftTitle}\"? This action cannot be undone." msgstr "Are you sure you want to delete \"{draftTitle}\"? This action cannot be undone." -#: src/components/article-composer/ArticleComposerContext.tsx:408 +#: src/components/article-composer/ArticleComposerContext.tsx:413 msgid "Are you sure you want to delete this draft? This action cannot be undone." msgstr "Are you sure you want to delete this draft? This action cannot be undone." @@ -306,7 +322,7 @@ msgstr "Are you sure you want to unblock {0} ({1})? They will be able to follow msgid "Article drafts" msgstr "Article drafts" -#: src/components/article-composer/ArticleComposerContext.tsx:364 +#: src/components/article-composer/ArticleComposerContext.tsx:369 msgid "Article published" msgstr "Article published" @@ -328,7 +344,7 @@ msgid "Articles only" msgstr "Articles only" #. placeholder {0}: "CHANGED" -#: src/routes/(root)/[handle]/settings/index.tsx:409 +#: src/routes/(root)/[handle]/settings/index.tsx:431 msgid "As you have already changed it {0}, you can't change it again." msgstr "As you have already changed it {0}, you can't change it again." @@ -336,11 +352,11 @@ msgstr "As you have already changed it {0}, you can't change it again." msgid "Authenticating…" msgstr "Authenticating…" -#: src/routes/(root)/[handle]/settings/index.tsx:325 +#: src/routes/(root)/[handle]/settings/index.tsx:347 msgid "Avatar" msgstr "Avatar" -#: src/routes/(root)/[handle]/settings/index.tsx:438 +#: src/routes/(root)/[handle]/settings/index.tsx:460 #: src/routes/(root)/sign/up/[token].tsx:410 msgid "Bio" msgstr "Bio" @@ -380,7 +396,7 @@ msgstr "Bookmarks" #: src/components/RemoteFollowButton.tsx:218 #: src/components/RemoteFollowButton.tsx:280 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:339 -#: src/routes/(root)/[handle]/settings/index.tsx:372 +#: src/routes/(root)/[handle]/settings/index.tsx:394 #: src/routes/(root)/[handle]/settings/passkeys.tsx:521 #: src/routes/(root)/authorize_interaction.tsx:190 msgid "Cancel" @@ -414,7 +430,7 @@ msgstr "Closed" msgid "Code" msgstr "Code" -#: src/components/AppSidebar.tsx:811 +#: src/components/AppSidebar.tsx:834 #: src/routes/(root)/coc.tsx:48 #: src/routes/(root)/sign/up/[token].tsx:457 msgid "Code of conduct" @@ -424,7 +440,7 @@ msgstr "Code of conduct" #~ msgid "Comments ({0})" #~ msgstr "Comments ({0})" -#: src/components/AppSidebar.tsx:654 +#: src/components/AppSidebar.tsx:677 #: src/components/FloatingComposeButton.tsx:61 msgid "Compose" msgstr "Compose" @@ -467,7 +483,7 @@ msgstr "Could not load profile." msgid "Could not vote on this poll" msgstr "Could not vote on this poll" -#: src/components/AppSidebar.tsx:700 +#: src/components/AppSidebar.tsx:723 #: src/components/FloatingComposeButton.tsx:123 msgid "Create article" msgstr "Create article" @@ -476,7 +492,7 @@ msgstr "Create article" msgid "Create invitation link" msgstr "Create invitation link" -#: src/components/AppSidebar.tsx:676 +#: src/components/AppSidebar.tsx:699 #: src/components/FloatingComposeButton.tsx:99 #: src/components/NoteComposeModal.tsx:26 #: src/components/NoteComposer.tsx:452 @@ -505,15 +521,16 @@ msgstr "Creating account…" msgid "Creating…" msgstr "Creating…" -#: src/routes/(root)/[handle]/settings/index.tsx:379 +#: src/routes/(root)/[handle]/settings/index.tsx:401 msgid "Crop" msgstr "Crop" -#: src/routes/(root)/[handle]/settings/index.tsx:352 +#: src/routes/(root)/[handle]/settings/index.tsx:374 msgid "Crop your new avatar" msgstr "Crop your new avatar" #: src/routes/(root)/admin/invitations.tsx:183 +#: src/routes/(root)/admin/media.tsx:169 msgid "Cutoff:" msgstr "Cutoff:" @@ -536,12 +553,18 @@ msgstr "Delete" msgid "Delete draft" msgstr "Delete draft" +#: src/routes/(root)/admin/media.tsx:161 +#: src/routes/(root)/admin/media.tsx:192 +msgid "Delete orphan media" +msgstr "Delete orphan media" + #: src/components/PostActionMenu.tsx:349 msgid "Delete post?" msgstr "Delete post?" #: src/components/article-composer/ArticleComposerActions.tsx:20 #: src/routes/(root)/[handle]/settings/invite.tsx:654 +#: src/routes/(root)/admin/media.tsx:192 msgid "Deleting…" msgstr "Deleting…" @@ -549,7 +572,7 @@ msgstr "Deleting…" msgid "Discard unsaved changes - are you sure?" msgstr "Discard unsaved changes - are you sure?" -#: src/routes/(root)/[handle]/settings/index.tsx:422 +#: src/routes/(root)/[handle]/settings/index.tsx:444 #: src/routes/(root)/sign/up/[token].tsx:384 msgid "Display name" msgstr "Display name" @@ -562,12 +585,12 @@ msgstr "Do you need an account? Hackers' Pub is invite-only—please ask a frien msgid "Do you want to quote this link?" msgstr "Do you want to quote this link?" -#: src/components/article-composer/ArticleComposerContext.tsx:429 +#: src/components/article-composer/ArticleComposerContext.tsx:434 #: src/routes/(root)/[handle]/drafts/index.tsx:178 msgid "Draft deleted" msgstr "Draft deleted" -#: src/components/article-composer/ArticleComposerContext.tsx:339 +#: src/components/article-composer/ArticleComposerContext.tsx:344 msgid "Draft must be saved before publishing" msgstr "Draft must be saved before publishing" @@ -575,11 +598,11 @@ msgstr "Draft must be saved before publishing" msgid "Draft not found" msgstr "Draft not found" -#: src/components/article-composer/ArticleComposerContext.tsx:291 +#: src/components/article-composer/ArticleComposerContext.tsx:296 msgid "Draft saved" msgstr "Draft saved" -#: src/routes/(root)/[handle]/settings/index.tsx:355 +#: src/routes/(root)/[handle]/settings/index.tsx:377 msgid "Drag to select the area you want to keep, then click “Crop” to update your avatar." msgstr "Drag to select the area you want to keep, then click “Crop” to update your avatar." @@ -630,19 +653,19 @@ msgstr "Enter your email address below to get started." msgid "Enter your email or username below to sign in." msgstr "Enter your email or username below to sign in." -#: src/components/article-composer/ArticleComposerContext.tsx:257 -#: src/components/article-composer/ArticleComposerContext.tsx:299 -#: src/components/article-composer/ArticleComposerContext.tsx:308 -#: src/components/article-composer/ArticleComposerContext.tsx:316 -#: src/components/article-composer/ArticleComposerContext.tsx:329 -#: src/components/article-composer/ArticleComposerContext.tsx:338 -#: src/components/article-composer/ArticleComposerContext.tsx:371 -#: src/components/article-composer/ArticleComposerContext.tsx:380 -#: src/components/article-composer/ArticleComposerContext.tsx:388 -#: src/components/article-composer/ArticleComposerContext.tsx:399 -#: src/components/article-composer/ArticleComposerContext.tsx:436 -#: src/components/article-composer/ArticleComposerContext.tsx:445 -#: src/components/article-composer/ArticleComposerContext.tsx:453 +#: src/components/article-composer/ArticleComposerContext.tsx:261 +#: src/components/article-composer/ArticleComposerContext.tsx:304 +#: src/components/article-composer/ArticleComposerContext.tsx:313 +#: src/components/article-composer/ArticleComposerContext.tsx:321 +#: src/components/article-composer/ArticleComposerContext.tsx:334 +#: src/components/article-composer/ArticleComposerContext.tsx:343 +#: src/components/article-composer/ArticleComposerContext.tsx:376 +#: src/components/article-composer/ArticleComposerContext.tsx:385 +#: src/components/article-composer/ArticleComposerContext.tsx:393 +#: src/components/article-composer/ArticleComposerContext.tsx:404 +#: src/components/article-composer/ArticleComposerContext.tsx:441 +#: src/components/article-composer/ArticleComposerContext.tsx:450 +#: src/components/article-composer/ArticleComposerContext.tsx:458 #: src/components/article-composer/ArticleComposerForm.tsx:26 #: src/components/NoteComposer.tsx:203 #: src/components/NoteComposer.tsx:252 @@ -699,6 +722,10 @@ msgstr "Failed to create invitation link" msgid "Failed to delete invitation link" msgstr "Failed to delete invitation link" +#: src/routes/(root)/admin/media.tsx:133 +msgid "Failed to delete orphan media." +msgstr "Failed to delete orphan media." + #: src/components/PostActionMenu.tsx:289 #: src/components/PostActionMenu.tsx:296 msgid "Failed to delete post" @@ -756,7 +783,7 @@ msgstr "Failed to load more passkeys; click to retry" msgid "Failed to load more posts; click to retry" msgstr "Failed to load more posts; click to retry" -#: src/routes/(root)/[handle]/[noteId].tsx:480 +#: src/routes/(root)/[handle]/[noteId].tsx:485 msgid "Failed to load more replies; click to retry" msgstr "Failed to load more replies; click to retry" @@ -806,7 +833,8 @@ msgstr "Failed to save language preferences" msgid "Failed to save preferences" msgstr "Failed to save preferences" -#: src/routes/(root)/[handle]/settings/index.tsx:309 +#: src/routes/(root)/[handle]/settings/index.tsx:289 +#: src/routes/(root)/[handle]/settings/index.tsx:331 msgid "Failed to save settings" msgstr "Failed to save settings" @@ -905,7 +933,7 @@ msgstr "Following you" msgid "Formatting" msgstr "Formatting" -#: src/components/AppSidebar.tsx:849 +#: src/components/AppSidebar.tsx:872 msgid "GitHub repository" msgstr "GitHub repository" @@ -916,7 +944,7 @@ msgstr "GitHub repository" msgid "Go back" msgstr "Go back" -#: src/routes/[...404].tsx:47 +#: src/components/NotFoundPage.tsx:48 msgid "Go home" msgstr "Go home" @@ -947,8 +975,8 @@ msgstr "Grants one extra invitation to the most active accounts (the top third b msgid "Hackers' Pub" msgstr "Hackers' Pub" +#: src/components/NotFoundPage.tsx:18 #: src/routes/(root).tsx:83 -#: src/routes/[...404].tsx:17 msgid "Hackers' Pub home" msgstr "Hackers' Pub home" @@ -964,6 +992,10 @@ msgstr "Hackers' Pub: Admin · Accounts" msgid "Hackers' Pub: Admin · Invitations" msgstr "Hackers' Pub: Admin · Invitations" +#: src/routes/(root)/admin/media.tsx:143 +msgid "Hackers' Pub: Admin · Media" +msgstr "Hackers' Pub: Admin · Media" + #: src/routes/(root)/notifications.tsx:44 msgid "Hackers' Pub: Notifications" msgstr "Hackers' Pub: Notifications" @@ -1002,12 +1034,12 @@ msgid "If you have a fediverse account, you can reply to this article from your msgstr "If you have a fediverse account, you can reply to this article from your own instance. Search {0} on your instance and reply to it." #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[noteId].tsx:308 +#: src/routes/(root)/[handle]/[noteId].tsx:313 msgid "If you have a fediverse account, you can reply to this note from your own instance. Search {0} on your instance and reply to it." msgstr "If you have a fediverse account, you can reply to this note from your own instance. Search {0} on your instance and reply to it." #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[noteId].tsx:363 +#: src/routes/(root)/[handle]/[noteId].tsx:368 msgid "If you have a fediverse account, you can reply to this post from your own instance. Search {0} on your instance and reply to it." msgstr "If you have a fediverse account, you can reply to this post from your own instance. Search {0} on your instance and reply to it." @@ -1023,9 +1055,9 @@ msgstr "Invalid Fediverse handle format." #. placeholder {0}: response.createNote.inputPath #. placeholder {0}: response.deleteArticleDraft.inputPath #. placeholder {0}: response.publishArticleDraft.inputPath -#: src/components/article-composer/ArticleComposerContext.tsx:301 -#: src/components/article-composer/ArticleComposerContext.tsx:373 -#: src/components/article-composer/ArticleComposerContext.tsx:438 +#: src/components/article-composer/ArticleComposerContext.tsx:306 +#: src/components/article-composer/ArticleComposerContext.tsx:378 +#: src/components/article-composer/ArticleComposerContext.tsx:443 #: src/components/NoteComposer.tsx:281 #: src/routes/(root)/[handle]/drafts/index.tsx:187 msgid "Invalid input: {0}" @@ -1088,7 +1120,7 @@ msgstr "Invited by" msgid "Italic" msgstr "Italic" -#: src/routes/(root)/[handle]/settings/index.tsx:429 +#: src/routes/(root)/[handle]/settings/index.tsx:451 msgid "John Doe" msgstr "John Doe" @@ -1143,7 +1175,7 @@ msgstr "Link author:" msgid "Link expired" msgstr "Link expired" -#: src/routes/(root)/[handle]/settings/index.tsx:515 +#: src/routes/(root)/[handle]/settings/index.tsx:537 msgid "Link name" msgstr "Link name" @@ -1199,7 +1231,7 @@ msgstr "Load more passkeys" msgid "Load more posts" msgstr "Load more posts" -#: src/routes/(root)/[handle]/[noteId].tsx:483 +#: src/routes/(root)/[handle]/[noteId].tsx:488 msgid "Load more replies" msgstr "Load more replies" @@ -1251,7 +1283,7 @@ msgstr "Loading more passkeys…" msgid "Loading more posts…" msgstr "Loading more posts…" -#: src/routes/(root)/[handle]/[noteId].tsx:477 +#: src/routes/(root)/[handle]/[noteId].tsx:482 msgid "Loading more replies…" msgstr "Loading more replies…" @@ -1282,6 +1314,11 @@ msgstr "Markdown guide" msgid "Markdown supported" msgstr "Markdown supported" +#: src/components/AppSidebar.tsx:654 +#: src/routes/(root)/admin/media.tsx:157 +msgid "Media" +msgstr "Media" + #: src/components/PostVisibilitySelect.tsx:83 #: src/components/VisibilityTag.tsx:98 msgid "Mentioned only" @@ -1345,7 +1382,7 @@ msgstr "New article" msgid "No bookmarks yet" msgstr "No bookmarks yet" -#: src/components/article-composer/ArticleComposerContext.tsx:400 +#: src/components/article-composer/ArticleComposerContext.tsx:405 msgid "No draft to delete" msgstr "No draft to delete" @@ -1395,6 +1432,10 @@ msgstr "No such account in Hackers' Pub—please try again." msgid "No user URI provided." msgstr "No user URI provided." +#: src/routes/(root)/admin/media.tsx:124 +msgid "Not authorized to delete orphan media." +msgstr "Not authorized to delete orphan media." + #: src/routes/(root)/admin/invitations.tsx:123 msgid "Not authorized to regenerate invitations." msgstr "Not authorized to regenerate invitations." @@ -1417,7 +1458,7 @@ msgid "Note created successfully" msgstr "Note created successfully" #. placeholder {0}: "REL_ME_ATTR" -#: src/routes/(root)/[handle]/settings/index.tsx:478 +#: src/routes/(root)/[handle]/settings/index.tsx:500 msgid "Note that you can verify your links belong to you by making the linked pages also link to your Hackers' Pub profile with {0} attribute." msgstr "Note that you can verify your links belong to you by making the linked pages also link to your Hackers' Pub profile with {0} attribute." @@ -1458,7 +1499,7 @@ msgstr "Or enter the code from the email" msgid "Other languages" msgstr "Other languages" -#: src/routes/[...404].tsx:40 +#: src/components/NotFoundPage.tsx:41 msgid "Page not found" msgstr "Page not found" @@ -1502,7 +1543,7 @@ msgstr "Pin to profile" msgid "Pinned posts" msgstr "Pinned posts" -#: src/routes/(root)/[handle]/settings/index.tsx:179 +#: src/routes/(root)/[handle]/settings/index.tsx:180 msgid "Please choose an image file smaller than 5 MiB." msgstr "Please choose an image file smaller than 5 MiB." @@ -1578,7 +1619,7 @@ msgstr "Preferred languages" msgid "Priority" msgstr "Priority" -#: src/components/AppSidebar.tsx:816 +#: src/components/AppSidebar.tsx:839 #: src/routes/(root)/privacy.tsx:48 msgid "Privacy policy" msgstr "Privacy policy" @@ -1588,8 +1629,8 @@ msgid "Profile actions" msgstr "Profile actions" #: src/components/SettingsTabs.tsx:44 -#: src/routes/(root)/[handle]/settings/index.tsx:123 #: src/routes/(root)/[handle]/settings/index.tsx:124 +#: src/routes/(root)/[handle]/settings/index.tsx:125 msgid "Profile settings" msgstr "Profile settings" @@ -1647,7 +1688,7 @@ msgstr "Read full article" msgid "Read the full Code of conduct" msgstr "Read the full Code of conduct" -#: src/components/AppSidebar.tsx:735 +#: src/components/AppSidebar.tsx:758 msgid "Recent drafts" msgstr "Recent drafts" @@ -1711,6 +1752,10 @@ msgstr "Remove bookmark" msgid "Remove quote" msgstr "Remove quote" +#: src/routes/(root)/admin/media.tsx:163 +msgid "Removes stored media that are old enough and no longer attached to an avatar, note, article draft, or article." +msgstr "Removes stored media that are old enough and no longer attached to an avatar, note, article draft, or article." + #: src/components/PostControls.tsx:207 msgid "Reply" msgstr "Reply" @@ -1724,7 +1769,7 @@ msgstr "Revoke" msgid "Revoke passkey" msgstr "Revoke passkey" -#: src/routes/(root)/[handle]/settings/index.tsx:490 +#: src/routes/(root)/[handle]/settings/index.tsx:512 #: src/routes/(root)/[handle]/settings/language.tsx:200 #: src/routes/(root)/[handle]/settings/preferences.tsx:206 msgid "Save" @@ -1744,7 +1789,7 @@ msgstr "Save draft to see preview" #: src/components/article-composer/ArticleComposerActions.tsx:32 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:345 -#: src/routes/(root)/[handle]/settings/index.tsx:490 +#: src/routes/(root)/[handle]/settings/index.tsx:512 #: src/routes/(root)/[handle]/settings/language.tsx:200 #: src/routes/(root)/[handle]/settings/preferences.tsx:206 msgid "Saving…" @@ -1826,7 +1871,7 @@ msgstr "Sign in to vote" msgid "Sign in with passkey" msgstr "Sign in with passkey" -#: src/components/AppSidebar.tsx:798 +#: src/components/AppSidebar.tsx:821 msgid "Sign out" msgstr "Sign out" @@ -1859,7 +1904,7 @@ msgstr "Single choice" msgid "Slug (URL)" msgstr "Slug (URL)" -#: src/components/article-composer/ArticleComposerContext.tsx:330 +#: src/components/article-composer/ArticleComposerContext.tsx:335 msgid "Slug cannot be empty" msgstr "Slug cannot be empty" @@ -1867,9 +1912,9 @@ msgstr "Slug cannot be empty" msgid "Something went wrong—please try again." msgstr "Something went wrong—please try again." -#: src/components/article-composer/ArticleComposerContext.tsx:290 -#: src/components/article-composer/ArticleComposerContext.tsx:363 -#: src/components/article-composer/ArticleComposerContext.tsx:428 +#: src/components/article-composer/ArticleComposerContext.tsx:295 +#: src/components/article-composer/ArticleComposerContext.tsx:368 +#: src/components/article-composer/ArticleComposerContext.tsx:433 #: src/components/NoteComposer.tsx:272 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:189 #: src/routes/(root)/[handle]/drafts/index.tsx:177 @@ -1884,7 +1929,7 @@ msgstr "Successfully saved language preferences" msgid "Successfully saved preferences" msgstr "Successfully saved preferences" -#: src/routes/(root)/[handle]/settings/index.tsx:302 +#: src/routes/(root)/[handle]/settings/index.tsx:324 msgid "Successfully saved settings" msgstr "Successfully saved settings" @@ -1959,7 +2004,7 @@ msgstr "The invitation link has been created successfully." msgid "The invitation link has been deleted successfully." msgstr "The invitation link has been deleted successfully." -#: src/routes/[...404].tsx:43 +#: src/components/NotFoundPage.tsx:44 msgid "The page you're looking for doesn't exist or has been moved." msgstr "The page you're looking for doesn't exist or has been moved." @@ -1973,11 +2018,11 @@ msgstr "The sign-up link is invalid. Please make sure you're using the correct l #. placeholder {0}: "GITHUB_REPOSITORY" #. placeholder {1}: "AGPL-3.0" -#: src/components/AppSidebar.tsx:840 +#: src/components/AppSidebar.tsx:863 msgid "The source code of this website is available on {0} under the {1} license." msgstr "The source code of this website is available on {0} under the {1} license." -#: src/routes/(root)/[handle]/settings/index.tsx:558 +#: src/routes/(root)/[handle]/settings/index.tsx:580 msgid "The URL of the link, e.g., https://github.com/yourhandle." msgstr "The URL of the link, e.g., https://github.com/yourhandle." @@ -2031,7 +2076,7 @@ msgstr "Timeline" msgid "Title" msgstr "Title" -#: src/components/article-composer/ArticleComposerContext.tsx:258 +#: src/components/article-composer/ArticleComposerContext.tsx:262 msgid "Title cannot be empty" msgstr "Title cannot be empty" @@ -2123,7 +2168,7 @@ msgstr "Unpin from profile" msgid "Unshare" msgstr "Unshare" -#: src/routes/(root)/[handle]/settings/index.tsx:125 +#: src/routes/(root)/[handle]/settings/index.tsx:126 msgid "Update your profile information, including your avatar, username, display name, bio, and links." msgstr "Update your profile information, including your avatar, username, display name, bio, and links." @@ -2132,7 +2177,7 @@ msgstr "Update your profile information, including your avatar, username, displa msgid "Updated {0}" msgstr "Updated {0}" -#: src/routes/(root)/[handle]/settings/index.tsx:540 +#: src/routes/(root)/[handle]/settings/index.tsx:562 msgid "URL" msgstr "URL" @@ -2148,7 +2193,7 @@ msgstr "User not found." msgid "User unblocked" msgstr "User unblocked" -#: src/routes/(root)/[handle]/settings/index.tsx:387 +#: src/routes/(root)/[handle]/settings/index.tsx:409 #: src/routes/(root)/sign/up/[token].tsx:357 msgid "Username" msgstr "Username" @@ -2183,7 +2228,7 @@ msgstr "Verified that this link is owned by {0} {1}" msgid "Verifying your invitation…" msgstr "Verifying your invitation…" -#: src/components/AppSidebar.tsx:761 +#: src/components/AppSidebar.tsx:784 msgid "View all drafts →" msgstr "View all drafts →" @@ -2227,7 +2272,7 @@ msgstr "Voting…" msgid "We couldn't reach the translation service. Try again, or come back in a few minutes." msgstr "We couldn't reach the translation service. Try again, or come back in a few minutes." -#: src/routes/(root)/[handle]/settings/index.tsx:521 +#: src/routes/(root)/[handle]/settings/index.tsx:543 msgid "Website" msgstr "Website" @@ -2272,11 +2317,11 @@ msgstr "You are blocked by this user. You can't follow them or see their posts." msgid "You are blocking this user. They can't follow you or see your posts." msgstr "You are blocking this user. They can't follow you or see your posts." -#: src/routes/(root)/[handle]/settings/index.tsx:403 +#: src/routes/(root)/[handle]/settings/index.tsx:425 msgid "You can change it only once, and the old username will become available to others." msgstr "You can change it only once, and the old username will become available to others." -#: src/routes/(root)/[handle]/settings/index.tsx:569 +#: src/routes/(root)/[handle]/settings/index.tsx:591 msgid "You can leave this empty to remove the link." msgstr "You can leave this empty to remove the link." @@ -2320,7 +2365,7 @@ msgstr "You must be signed in" msgid "You must be signed in to create a note" msgstr "You must be signed in to create a note" -#: src/components/article-composer/ArticleComposerContext.tsx:446 +#: src/components/article-composer/ArticleComposerContext.tsx:451 #: src/routes/(root)/[handle]/drafts/index.tsx:195 msgid "You must be signed in to delete a draft" msgstr "You must be signed in to delete a draft" @@ -2329,11 +2374,11 @@ msgstr "You must be signed in to delete a draft" msgid "You must be signed in to edit an article" msgstr "You must be signed in to edit an article" -#: src/components/article-composer/ArticleComposerContext.tsx:381 +#: src/components/article-composer/ArticleComposerContext.tsx:386 msgid "You must be signed in to publish an article" msgstr "You must be signed in to publish an article" -#: src/components/article-composer/ArticleComposerContext.tsx:309 +#: src/components/article-composer/ArticleComposerContext.tsx:314 msgid "You must be signed in to save a draft" msgstr "You must be signed in to save a draft" @@ -2349,11 +2394,11 @@ msgstr "You'll automatically follow each other when you sign up." msgid "You've been invited to Hackers' Pub" msgstr "You've been invited to Hackers' Pub" -#: src/routes/(root)/[handle]/settings/index.tsx:327 +#: src/routes/(root)/[handle]/settings/index.tsx:349 msgid "Your avatar will be displayed on your profile and in your posts. You can upload a PNG, JPEG, GIF, or WebP image up to 5 MiB in size." msgstr "Your avatar will be displayed on your profile and in your posts. You can upload a PNG, JPEG, GIF, or WebP image up to 5 MiB in size." -#: src/routes/(root)/[handle]/settings/index.tsx:447 +#: src/routes/(root)/[handle]/settings/index.tsx:469 msgid "Your bio will be displayed on your profile. You can use Markdown to format it." msgstr "Your bio will be displayed on your profile. You can use Markdown to format it." @@ -2369,7 +2414,7 @@ msgstr "Your email address will be used to sign in to your account." msgid "Your friend will see this message in the invitation email." msgstr "Your friend will see this message in the invitation email." -#: src/routes/(root)/[handle]/settings/index.tsx:433 +#: src/routes/(root)/[handle]/settings/index.tsx:455 #: src/routes/(root)/sign/up/[token].tsx:400 msgid "Your name will be displayed on your profile and in your posts." msgstr "Your name will be displayed on your profile and in your posts." @@ -2386,11 +2431,11 @@ msgstr "Your preferences have been updated successfully." msgid "Your preferred languages have been updated." msgstr "Your preferred languages have been updated." -#: src/routes/(root)/[handle]/settings/index.tsx:303 +#: src/routes/(root)/[handle]/settings/index.tsx:325 msgid "Your profile settings have been updated successfully." msgstr "Your profile settings have been updated successfully." -#: src/routes/(root)/[handle]/settings/index.tsx:400 +#: src/routes/(root)/[handle]/settings/index.tsx:422 #: src/routes/(root)/sign/up/[token].tsx:374 msgid "Your username will be used to create your profile URL and your fediverse handle." msgstr "Your username will be used to create your profile URL and your fediverse handle." diff --git a/web-next/src/locales/ja-JP/messages.po b/web-next/src/locales/ja-JP/messages.po index 49bf11a06..378cbbdbe 100644 --- a/web-next/src/locales/ja-JP/messages.po +++ b/web-next/src/locales/ja-JP/messages.po @@ -18,6 +18,11 @@ msgstr "" msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, other {#コメント}}" +#. placeholder {0}: result.failedDiskDeletes! +#: src/routes/(root)/admin/media.tsx:109 +msgid "{0, plural, one {# disk object could not be deleted.} other {# disk objects could not be deleted.}}" +msgstr "{0, plural, other {#個のディスクオブジェクトを削除できませんでした。}}" + #. placeholder {0}: status()?.eligibleAccountsCount ?? 0 #. placeholder {1}: status()?.topThirdCount ?? 0 #: src/routes/(root)/admin/invitations.tsx:191 @@ -44,6 +49,11 @@ msgstr "{0, plural, other {#フォロー}}" msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, other {残り#件の招待}}" +#. placeholder {0}: count() +#: src/routes/(root)/admin/media.tsx:177 +msgid "{0, plural, one {# orphan medium can be deleted.} other {# orphan media can be deleted.}}" +msgstr "{0, plural, other {#件の孤立したメディアを削除できます。}}" + #. placeholder {0}: votes() #: src/components/QuestionCard.tsx:484 msgid "{0, plural, one {# vote} other {# votes}}" @@ -59,6 +69,11 @@ msgstr "{0, plural, other {投票者 #人}}" msgid "{0, plural, one {+1 more} other {+# more}}" msgstr "{0, plural, other {他#件}}" +#. placeholder {0}: result.deletedCount! +#: src/routes/(root)/admin/media.tsx:100 +msgid "{0, plural, one {Deleted # orphan medium.} other {Deleted # orphan media.}}" +msgstr "{0, plural, other {#件の孤立したメディアを削除しました。}}" + #. placeholder {0}: account.invitationsLeft #: src/routes/(root)/[handle]/settings/invite.tsx:306 msgid "{0, plural, one {Invite your friends to Hackers' Pub. You can invite up to # person.} other {Invite your friends to Hackers' Pub. You can invite up to # people.}}" @@ -167,7 +182,7 @@ msgstr "{0}さんがあなたのコンテンツを共有しました" #. placeholder {1}: post.excerpt #. placeholder {1}: title() #: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:238 -#: src/routes/(root)/[handle]/[noteId].tsx:221 +#: src/routes/(root)/[handle]/[noteId].tsx:226 msgid "{0}: {1}" msgstr "{0}:{1}" @@ -201,7 +216,7 @@ msgstr "{0}さんの投稿" msgid "{0}'s shares" msgstr "{0}さんの共有" -#: src/routes/(root)/[handle]/settings/index.tsx:534 +#: src/routes/(root)/[handle]/settings/index.tsx:556 msgid "A name for the link that will be displayed on your profile, e.g., GitHub." msgstr "プロフィールに表示されるリンクの名前。(例:GitHub)" @@ -265,7 +280,8 @@ msgstr "環境設定の保存中にエラーが発生しました。再度お試 msgid "An error occurred while saving your preferred languages. Please try again, or contact support if the problem persists." msgstr "言語設定の保存中にエラーが発生しました。再度お試しいただくか、問題が解決しない場合はサポートにお問い合わせください。" -#: src/routes/(root)/[handle]/settings/index.tsx:311 +#: src/routes/(root)/[handle]/settings/index.tsx:292 +#: src/routes/(root)/[handle]/settings/index.tsx:333 msgid "An error occurred while saving your settings. Please try again, or contact support if the problem persists." msgstr "設定の保存中にエラーが発生しました。再度お試しいただくか、問題が解決しない場合はサポートにお問い合わせください。" @@ -286,7 +302,7 @@ msgstr "{0}さん({1})をブロックしますか?このユーザーはあ msgid "Are you sure you want to delete \"{draftTitle}\"? This action cannot be undone." msgstr "「{draftTitle}」を削除してもよろしいですか?この操作は元に戻せません。" -#: src/components/article-composer/ArticleComposerContext.tsx:408 +#: src/components/article-composer/ArticleComposerContext.tsx:413 msgid "Are you sure you want to delete this draft? This action cannot be undone." msgstr "この下書きを削除してもよろしいですか?この操作は元に戻せません。" @@ -306,7 +322,7 @@ msgstr "{0}さん({1})のブロックを解除しますか?このユーザ msgid "Article drafts" msgstr "記事の下書き" -#: src/components/article-composer/ArticleComposerContext.tsx:364 +#: src/components/article-composer/ArticleComposerContext.tsx:369 msgid "Article published" msgstr "記事を公開しました" @@ -328,7 +344,7 @@ msgid "Articles only" msgstr "記事のみ" #. placeholder {0}: "CHANGED" -#: src/routes/(root)/[handle]/settings/index.tsx:409 +#: src/routes/(root)/[handle]/settings/index.tsx:431 msgid "As you have already changed it {0}, you can't change it again." msgstr "すでに{0}に変更済みのため、再度変更することはできません。" @@ -336,11 +352,11 @@ msgstr "すでに{0}に変更済みのため、再度変更することはでき msgid "Authenticating…" msgstr "認証中…" -#: src/routes/(root)/[handle]/settings/index.tsx:325 +#: src/routes/(root)/[handle]/settings/index.tsx:347 msgid "Avatar" msgstr "アイコン" -#: src/routes/(root)/[handle]/settings/index.tsx:438 +#: src/routes/(root)/[handle]/settings/index.tsx:460 #: src/routes/(root)/sign/up/[token].tsx:410 msgid "Bio" msgstr "自己紹介" @@ -380,7 +396,7 @@ msgstr "ブックマーク" #: src/components/RemoteFollowButton.tsx:218 #: src/components/RemoteFollowButton.tsx:280 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:339 -#: src/routes/(root)/[handle]/settings/index.tsx:372 +#: src/routes/(root)/[handle]/settings/index.tsx:394 #: src/routes/(root)/[handle]/settings/passkeys.tsx:521 #: src/routes/(root)/authorize_interaction.tsx:190 msgid "Cancel" @@ -414,7 +430,7 @@ msgstr "終了" msgid "Code" msgstr "コード" -#: src/components/AppSidebar.tsx:811 +#: src/components/AppSidebar.tsx:834 #: src/routes/(root)/coc.tsx:48 #: src/routes/(root)/sign/up/[token].tsx:457 msgid "Code of conduct" @@ -424,7 +440,7 @@ msgstr "行動規範" #~ msgid "Comments ({0})" #~ msgstr "コメント({0})" -#: src/components/AppSidebar.tsx:654 +#: src/components/AppSidebar.tsx:677 #: src/components/FloatingComposeButton.tsx:61 msgid "Compose" msgstr "作成" @@ -467,7 +483,7 @@ msgstr "プロフィールを読み込めませんでした。" msgid "Could not vote on this poll" msgstr "このアンケートに投票できませんでした" -#: src/components/AppSidebar.tsx:700 +#: src/components/AppSidebar.tsx:723 #: src/components/FloatingComposeButton.tsx:123 msgid "Create article" msgstr "記事を作成" @@ -476,7 +492,7 @@ msgstr "記事を作成" msgid "Create invitation link" msgstr "招待リンクを作成" -#: src/components/AppSidebar.tsx:676 +#: src/components/AppSidebar.tsx:699 #: src/components/FloatingComposeButton.tsx:99 #: src/components/NoteComposeModal.tsx:26 #: src/components/NoteComposer.tsx:452 @@ -505,15 +521,16 @@ msgstr "アカウントを作成中…" msgid "Creating…" msgstr "作成中…" -#: src/routes/(root)/[handle]/settings/index.tsx:379 +#: src/routes/(root)/[handle]/settings/index.tsx:401 msgid "Crop" msgstr "切り抜き" -#: src/routes/(root)/[handle]/settings/index.tsx:352 +#: src/routes/(root)/[handle]/settings/index.tsx:374 msgid "Crop your new avatar" msgstr "新しいアイコンを切り抜く" #: src/routes/(root)/admin/invitations.tsx:183 +#: src/routes/(root)/admin/media.tsx:169 msgid "Cutoff:" msgstr "カットオフ:" @@ -536,12 +553,18 @@ msgstr "削除" msgid "Delete draft" msgstr "下書きを削除" +#: src/routes/(root)/admin/media.tsx:161 +#: src/routes/(root)/admin/media.tsx:192 +msgid "Delete orphan media" +msgstr "孤立したメディアを削除" + #: src/components/PostActionMenu.tsx:349 msgid "Delete post?" msgstr "コンテンツを削除しますか?" #: src/components/article-composer/ArticleComposerActions.tsx:20 #: src/routes/(root)/[handle]/settings/invite.tsx:654 +#: src/routes/(root)/admin/media.tsx:192 msgid "Deleting…" msgstr "削除中…" @@ -549,7 +572,7 @@ msgstr "削除中…" msgid "Discard unsaved changes - are you sure?" msgstr "未保存の変更を破棄してもよろしいですか?" -#: src/routes/(root)/[handle]/settings/index.tsx:422 +#: src/routes/(root)/[handle]/settings/index.tsx:444 #: src/routes/(root)/sign/up/[token].tsx:384 msgid "Display name" msgstr "名前" @@ -562,12 +585,12 @@ msgstr "アカウントが必要ですか?Hackers' Pubは招待制ですので msgid "Do you want to quote this link?" msgstr "このリンクを引用しますか?" -#: src/components/article-composer/ArticleComposerContext.tsx:429 +#: src/components/article-composer/ArticleComposerContext.tsx:434 #: src/routes/(root)/[handle]/drafts/index.tsx:178 msgid "Draft deleted" msgstr "下書きを削除しました" -#: src/components/article-composer/ArticleComposerContext.tsx:339 +#: src/components/article-composer/ArticleComposerContext.tsx:344 msgid "Draft must be saved before publishing" msgstr "公開する前に下書きを保存する必要があります" @@ -575,11 +598,11 @@ msgstr "公開する前に下書きを保存する必要があります" msgid "Draft not found" msgstr "下書きが見つかりません" -#: src/components/article-composer/ArticleComposerContext.tsx:291 +#: src/components/article-composer/ArticleComposerContext.tsx:296 msgid "Draft saved" msgstr "下書きを保存しました" -#: src/routes/(root)/[handle]/settings/index.tsx:355 +#: src/routes/(root)/[handle]/settings/index.tsx:377 msgid "Drag to select the area you want to keep, then click “Crop” to update your avatar." msgstr "保持したい領域をドラッグして選択し、「切り抜き」をクリックしてアイコンを更新してください。" @@ -626,19 +649,19 @@ msgstr "以下にメールアドレスを入力して始めましょう。" msgid "Enter your email or username below to sign in." msgstr "以下にメールアドレスまたはユーザー名を入力してログインしてください。" -#: src/components/article-composer/ArticleComposerContext.tsx:257 -#: src/components/article-composer/ArticleComposerContext.tsx:299 -#: src/components/article-composer/ArticleComposerContext.tsx:308 -#: src/components/article-composer/ArticleComposerContext.tsx:316 -#: src/components/article-composer/ArticleComposerContext.tsx:329 -#: src/components/article-composer/ArticleComposerContext.tsx:338 -#: src/components/article-composer/ArticleComposerContext.tsx:371 -#: src/components/article-composer/ArticleComposerContext.tsx:380 -#: src/components/article-composer/ArticleComposerContext.tsx:388 -#: src/components/article-composer/ArticleComposerContext.tsx:399 -#: src/components/article-composer/ArticleComposerContext.tsx:436 -#: src/components/article-composer/ArticleComposerContext.tsx:445 -#: src/components/article-composer/ArticleComposerContext.tsx:453 +#: src/components/article-composer/ArticleComposerContext.tsx:261 +#: src/components/article-composer/ArticleComposerContext.tsx:304 +#: src/components/article-composer/ArticleComposerContext.tsx:313 +#: src/components/article-composer/ArticleComposerContext.tsx:321 +#: src/components/article-composer/ArticleComposerContext.tsx:334 +#: src/components/article-composer/ArticleComposerContext.tsx:343 +#: src/components/article-composer/ArticleComposerContext.tsx:376 +#: src/components/article-composer/ArticleComposerContext.tsx:385 +#: src/components/article-composer/ArticleComposerContext.tsx:393 +#: src/components/article-composer/ArticleComposerContext.tsx:404 +#: src/components/article-composer/ArticleComposerContext.tsx:441 +#: src/components/article-composer/ArticleComposerContext.tsx:450 +#: src/components/article-composer/ArticleComposerContext.tsx:458 #: src/components/article-composer/ArticleComposerForm.tsx:26 #: src/components/NoteComposer.tsx:203 #: src/components/NoteComposer.tsx:252 @@ -695,6 +718,10 @@ msgstr "招待リンクの作成に失敗しました" msgid "Failed to delete invitation link" msgstr "招待リンクの削除に失敗しました" +#: src/routes/(root)/admin/media.tsx:133 +msgid "Failed to delete orphan media." +msgstr "孤立したメディアの削除に失敗しました。" + #: src/components/PostActionMenu.tsx:289 #: src/components/PostActionMenu.tsx:296 msgid "Failed to delete post" @@ -752,7 +779,7 @@ msgstr "パスキーの読み込みに失敗しました。クリックして再 msgid "Failed to load more posts; click to retry" msgstr "コンテンツの読み込みに失敗しました。クリックして再試行してください" -#: src/routes/(root)/[handle]/[noteId].tsx:480 +#: src/routes/(root)/[handle]/[noteId].tsx:485 msgid "Failed to load more replies; click to retry" msgstr "返信の読み込みに失敗しました。クリックして再試行してください" @@ -802,7 +829,8 @@ msgstr "言語設定の保存に失敗しました" msgid "Failed to save preferences" msgstr "環境設定の保存に失敗しました" -#: src/routes/(root)/[handle]/settings/index.tsx:309 +#: src/routes/(root)/[handle]/settings/index.tsx:289 +#: src/routes/(root)/[handle]/settings/index.tsx:331 msgid "Failed to save settings" msgstr "設定の保存に失敗しました" @@ -901,7 +929,7 @@ msgstr "あなたをフォロー中" msgid "Formatting" msgstr "書式設定" -#: src/components/AppSidebar.tsx:849 +#: src/components/AppSidebar.tsx:872 msgid "GitHub repository" msgstr "GitHubリポジトリ" @@ -912,7 +940,7 @@ msgstr "GitHubリポジトリ" msgid "Go back" msgstr "戻る" -#: src/routes/[...404].tsx:47 +#: src/components/NotFoundPage.tsx:48 msgid "Go home" msgstr "ホームに戻る" @@ -943,8 +971,8 @@ msgstr "前回の再付与カットオフ以降、最も活発なアカウント msgid "Hackers' Pub" msgstr "Hackers' Pub" +#: src/components/NotFoundPage.tsx:18 #: src/routes/(root).tsx:83 -#: src/routes/[...404].tsx:17 msgid "Hackers' Pub home" msgstr "Hackers' Pubホーム" @@ -960,6 +988,10 @@ msgstr "Hackers' Pub:管理 · アカウント" msgid "Hackers' Pub: Admin · Invitations" msgstr "Hackers' Pub:管理 · 招待" +#: src/routes/(root)/admin/media.tsx:143 +msgid "Hackers' Pub: Admin · Media" +msgstr "Hackers' Pub:管理 · メディア" + #: src/routes/(root)/notifications.tsx:44 msgid "Hackers' Pub: Notifications" msgstr "Hackers' Pub:通知" @@ -998,12 +1030,12 @@ msgid "If you have a fediverse account, you can reply to this article from your msgstr "フェディバース(fediverse)アカウントをお持ちの場合、この記事に返信することができます。ご利用のインスタンスの検索バーに{0}を検索し、該当記事に返信してください。" #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[noteId].tsx:308 +#: src/routes/(root)/[handle]/[noteId].tsx:313 msgid "If you have a fediverse account, you can reply to this note from your own instance. Search {0} on your instance and reply to it." msgstr "フェディバース(fediverse)アカウントをお持ちの場合、この投稿に返信することができます。ご利用のインスタンスの検索バーに{0}を検索し、該当投稿に返信してください。" #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[noteId].tsx:363 +#: src/routes/(root)/[handle]/[noteId].tsx:368 msgid "If you have a fediverse account, you can reply to this post from your own instance. Search {0} on your instance and reply to it." msgstr "フェディバースのアカウントをお持ちの場合、自分のインスタンスからこのコンテンツに返信できます。お使いのインスタンスで{0}を検索して返信してください。" @@ -1019,9 +1051,9 @@ msgstr "無効なフェディバースのハンドルの形式です。" #. placeholder {0}: response.createNote.inputPath #. placeholder {0}: response.deleteArticleDraft.inputPath #. placeholder {0}: response.publishArticleDraft.inputPath -#: src/components/article-composer/ArticleComposerContext.tsx:301 -#: src/components/article-composer/ArticleComposerContext.tsx:373 -#: src/components/article-composer/ArticleComposerContext.tsx:438 +#: src/components/article-composer/ArticleComposerContext.tsx:306 +#: src/components/article-composer/ArticleComposerContext.tsx:378 +#: src/components/article-composer/ArticleComposerContext.tsx:443 #: src/components/NoteComposer.tsx:281 #: src/routes/(root)/[handle]/drafts/index.tsx:187 msgid "Invalid input: {0}" @@ -1084,7 +1116,7 @@ msgstr "招待者" msgid "Italic" msgstr "斜体" -#: src/routes/(root)/[handle]/settings/index.tsx:429 +#: src/routes/(root)/[handle]/settings/index.tsx:451 msgid "John Doe" msgstr "田中太郎" @@ -1139,7 +1171,7 @@ msgstr "リンクの著者:" msgid "Link expired" msgstr "リンクの有効期限が切れています" -#: src/routes/(root)/[handle]/settings/index.tsx:515 +#: src/routes/(root)/[handle]/settings/index.tsx:537 msgid "Link name" msgstr "リンク名" @@ -1195,7 +1227,7 @@ msgstr "パスキーを読み込む" msgid "Load more posts" msgstr "コンテンツをもっと読み込む" -#: src/routes/(root)/[handle]/[noteId].tsx:483 +#: src/routes/(root)/[handle]/[noteId].tsx:488 msgid "Load more replies" msgstr "返信をもっと読み込む" @@ -1247,7 +1279,7 @@ msgstr "パスキーを読み込み中…" msgid "Loading more posts…" msgstr "コンテンツを読み込み中…" -#: src/routes/(root)/[handle]/[noteId].tsx:477 +#: src/routes/(root)/[handle]/[noteId].tsx:482 msgid "Loading more replies…" msgstr "返信を読み込み中…" @@ -1278,6 +1310,11 @@ msgstr "Markdown ガイド" msgid "Markdown supported" msgstr "Markdown対応" +#: src/components/AppSidebar.tsx:654 +#: src/routes/(root)/admin/media.tsx:157 +msgid "Media" +msgstr "メディア" + #: src/components/PostVisibilitySelect.tsx:83 #: src/components/VisibilityTag.tsx:98 msgid "Mentioned only" @@ -1341,7 +1378,7 @@ msgstr "新しい記事" msgid "No bookmarks yet" msgstr "ブックマークはまだありません" -#: src/components/article-composer/ArticleComposerContext.tsx:400 +#: src/components/article-composer/ArticleComposerContext.tsx:405 msgid "No draft to delete" msgstr "削除する下書きがありません" @@ -1391,6 +1428,10 @@ msgstr "Hackers' Pubにそのようなアカウントはありません。もう msgid "No user URI provided." msgstr "ユーザーURIが提供されていません。" +#: src/routes/(root)/admin/media.tsx:124 +msgid "Not authorized to delete orphan media." +msgstr "孤立したメディアを削除する権限がありません。" + #: src/routes/(root)/admin/invitations.tsx:123 msgid "Not authorized to regenerate invitations." msgstr "招待状を再付与する権限がありません。" @@ -1413,7 +1454,7 @@ msgid "Note created successfully" msgstr "投稿が作成されました" #. placeholder {0}: "REL_ME_ATTR" -#: src/routes/(root)/[handle]/settings/index.tsx:478 +#: src/routes/(root)/[handle]/settings/index.tsx:500 msgid "Note that you can verify your links belong to you by making the linked pages also link to your Hackers' Pub profile with {0} attribute." msgstr "リンクが実際にあなたのものであることを認証できます。リンク先のページでも{0}属性を使用してあなたのHackers' Pubプロフィールへリンクしてください。" @@ -1453,7 +1494,7 @@ msgstr "またはメールのコードを入力してください" msgid "Other languages" msgstr "他の言語" -#: src/routes/[...404].tsx:40 +#: src/components/NotFoundPage.tsx:41 msgid "Page not found" msgstr "ページが見つかりません" @@ -1497,7 +1538,7 @@ msgstr "プロフィールに固定" msgid "Pinned posts" msgstr "固定されたコンテンツ" -#: src/routes/(root)/[handle]/settings/index.tsx:179 +#: src/routes/(root)/[handle]/settings/index.tsx:180 msgid "Please choose an image file smaller than 5 MiB." msgstr "5 MiB未満の画像ファイルを選択してください。" @@ -1573,7 +1614,7 @@ msgstr "優先言語" msgid "Priority" msgstr "優先度" -#: src/components/AppSidebar.tsx:816 +#: src/components/AppSidebar.tsx:839 #: src/routes/(root)/privacy.tsx:48 msgid "Privacy policy" msgstr "プライバシーポリシー" @@ -1583,8 +1624,8 @@ msgid "Profile actions" msgstr "プロフィール操作" #: src/components/SettingsTabs.tsx:44 -#: src/routes/(root)/[handle]/settings/index.tsx:123 #: src/routes/(root)/[handle]/settings/index.tsx:124 +#: src/routes/(root)/[handle]/settings/index.tsx:125 msgid "Profile settings" msgstr "プロフィール設定" @@ -1642,7 +1683,7 @@ msgstr "記事全文を読む" msgid "Read the full Code of conduct" msgstr "行動規範の全文を読む" -#: src/components/AppSidebar.tsx:735 +#: src/components/AppSidebar.tsx:758 msgid "Recent drafts" msgstr "最近の下書き" @@ -1706,6 +1747,10 @@ msgstr "ブックマークを削除" msgid "Remove quote" msgstr "引用を削除" +#: src/routes/(root)/admin/media.tsx:163 +msgid "Removes stored media that are old enough and no longer attached to an avatar, note, article draft, or article." +msgstr "作成から十分な時間が経過し、アイコン、投稿、記事の下書き、記事のいずれにも添付されていない保存済みメディアを削除します。" + #: src/components/PostControls.tsx:207 msgid "Reply" msgstr "返信" @@ -1719,7 +1764,7 @@ msgstr "取り消す" msgid "Revoke passkey" msgstr "パスキーを取り消す" -#: src/routes/(root)/[handle]/settings/index.tsx:490 +#: src/routes/(root)/[handle]/settings/index.tsx:512 #: src/routes/(root)/[handle]/settings/language.tsx:200 #: src/routes/(root)/[handle]/settings/preferences.tsx:206 msgid "Save" @@ -1739,7 +1784,7 @@ msgstr "下書きを保存するとプレビューが表示されます" #: src/components/article-composer/ArticleComposerActions.tsx:32 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:345 -#: src/routes/(root)/[handle]/settings/index.tsx:490 +#: src/routes/(root)/[handle]/settings/index.tsx:512 #: src/routes/(root)/[handle]/settings/language.tsx:200 #: src/routes/(root)/[handle]/settings/preferences.tsx:206 msgid "Saving…" @@ -1821,7 +1866,7 @@ msgstr "ログインして投票" msgid "Sign in with passkey" msgstr "パスキーでサインイン" -#: src/components/AppSidebar.tsx:798 +#: src/components/AppSidebar.tsx:821 msgid "Sign out" msgstr "ログアウト" @@ -1854,7 +1899,7 @@ msgstr "単一選択" msgid "Slug (URL)" msgstr "スラッグ(URL)" -#: src/components/article-composer/ArticleComposerContext.tsx:330 +#: src/components/article-composer/ArticleComposerContext.tsx:335 msgid "Slug cannot be empty" msgstr "スラッグは空にできません" @@ -1862,9 +1907,9 @@ msgstr "スラッグは空にできません" msgid "Something went wrong—please try again." msgstr "問題が発生しました。再度お試しください。" -#: src/components/article-composer/ArticleComposerContext.tsx:290 -#: src/components/article-composer/ArticleComposerContext.tsx:363 -#: src/components/article-composer/ArticleComposerContext.tsx:428 +#: src/components/article-composer/ArticleComposerContext.tsx:295 +#: src/components/article-composer/ArticleComposerContext.tsx:368 +#: src/components/article-composer/ArticleComposerContext.tsx:433 #: src/components/NoteComposer.tsx:272 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:189 #: src/routes/(root)/[handle]/drafts/index.tsx:177 @@ -1879,7 +1924,7 @@ msgstr "言語設定を正常に保存しました" msgid "Successfully saved preferences" msgstr "環境設定を正常に保存しました" -#: src/routes/(root)/[handle]/settings/index.tsx:302 +#: src/routes/(root)/[handle]/settings/index.tsx:324 msgid "Successfully saved settings" msgstr "設定を正常に保存しました" @@ -1954,7 +1999,7 @@ msgstr "招待リンクが正常に作成されました。" msgid "The invitation link has been deleted successfully." msgstr "招待リンクが正常に削除されました。" -#: src/routes/[...404].tsx:43 +#: src/components/NotFoundPage.tsx:44 msgid "The page you're looking for doesn't exist or has been moved." msgstr "お探しのページは存在しないか、移動された可能性があります。" @@ -1968,11 +2013,11 @@ msgstr "登録リンクが無効です。受信したメールのリンクを正 #. placeholder {0}: "GITHUB_REPOSITORY" #. placeholder {1}: "AGPL-3.0" -#: src/components/AppSidebar.tsx:840 +#: src/components/AppSidebar.tsx:863 msgid "The source code of this website is available on {0} under the {1} license." msgstr "このウェブサイトのソースコードは{1}ライセンスで{0}で公開されています。" -#: src/routes/(root)/[handle]/settings/index.tsx:558 +#: src/routes/(root)/[handle]/settings/index.tsx:580 msgid "The URL of the link, e.g., https://github.com/yourhandle." msgstr "リンクのURL。(例:https://github.com/yourhandle)" @@ -2026,7 +2071,7 @@ msgstr "タイムライン" msgid "Title" msgstr "タイトル" -#: src/components/article-composer/ArticleComposerContext.tsx:258 +#: src/components/article-composer/ArticleComposerContext.tsx:262 msgid "Title cannot be empty" msgstr "タイトルは空にできません" @@ -2118,7 +2163,7 @@ msgstr "プロフィールから固定解除" msgid "Unshare" msgstr "共有を取り消す" -#: src/routes/(root)/[handle]/settings/index.tsx:125 +#: src/routes/(root)/[handle]/settings/index.tsx:126 msgid "Update your profile information, including your avatar, username, display name, bio, and links." msgstr "アイコン、ユーザー名、名前、自己紹介、リンクなどのプロフィール情報を更新してください。" @@ -2127,7 +2172,7 @@ msgstr "アイコン、ユーザー名、名前、自己紹介、リンクなど msgid "Updated {0}" msgstr "{0}に更新" -#: src/routes/(root)/[handle]/settings/index.tsx:540 +#: src/routes/(root)/[handle]/settings/index.tsx:562 msgid "URL" msgstr "URL" @@ -2143,7 +2188,7 @@ msgstr "ユーザー情報が見つかりません。" msgid "User unblocked" msgstr "ユーザーのブロックを解除しました" -#: src/routes/(root)/[handle]/settings/index.tsx:387 +#: src/routes/(root)/[handle]/settings/index.tsx:409 #: src/routes/(root)/sign/up/[token].tsx:357 msgid "Username" msgstr "ユーザー名" @@ -2178,7 +2223,7 @@ msgstr "{1}に{0}さんがこのリンクの所有者であることを確認済 msgid "Verifying your invitation…" msgstr "招待を確認中…" -#: src/components/AppSidebar.tsx:761 +#: src/components/AppSidebar.tsx:784 msgid "View all drafts →" msgstr "すべての下書きを表示 →" @@ -2222,7 +2267,7 @@ msgstr "投票中…" msgid "We couldn't reach the translation service. Try again, or come back in a few minutes." msgstr "翻訳サービスに接続できませんでした。再試行するか、数分後に再度お試しください。" -#: src/routes/(root)/[handle]/settings/index.tsx:521 +#: src/routes/(root)/[handle]/settings/index.tsx:543 msgid "Website" msgstr "ウェブサイト" @@ -2267,11 +2312,11 @@ msgstr "このユーザーからブロックされています。このユーザ msgid "You are blocking this user. They can't follow you or see your posts." msgstr "このユーザーをブロックしています。このユーザーはあなたをフォローしたり、あなたのコンテンツを見たりできません。" -#: src/routes/(root)/[handle]/settings/index.tsx:403 +#: src/routes/(root)/[handle]/settings/index.tsx:425 msgid "You can change it only once, and the old username will become available to others." msgstr "変更は1回のみ可能で、変更前のユーザー名は他のユーザーが使用できるようになります。" -#: src/routes/(root)/[handle]/settings/index.tsx:569 +#: src/routes/(root)/[handle]/settings/index.tsx:591 msgid "You can leave this empty to remove the link." msgstr "リンクを削除する場合は空にしてください。" @@ -2315,7 +2360,7 @@ msgstr "ログインが必要です" msgid "You must be signed in to create a note" msgstr "投稿を作成するにはログインが必要です" -#: src/components/article-composer/ArticleComposerContext.tsx:446 +#: src/components/article-composer/ArticleComposerContext.tsx:451 #: src/routes/(root)/[handle]/drafts/index.tsx:195 msgid "You must be signed in to delete a draft" msgstr "下書きを削除するにはログインする必要があります" @@ -2324,11 +2369,11 @@ msgstr "下書きを削除するにはログインする必要があります" msgid "You must be signed in to edit an article" msgstr "記事を編集するにはログインする必要があります" -#: src/components/article-composer/ArticleComposerContext.tsx:381 +#: src/components/article-composer/ArticleComposerContext.tsx:386 msgid "You must be signed in to publish an article" msgstr "記事を公開するにはログインする必要があります" -#: src/components/article-composer/ArticleComposerContext.tsx:309 +#: src/components/article-composer/ArticleComposerContext.tsx:314 msgid "You must be signed in to save a draft" msgstr "下書きを保存するにはログインする必要があります" @@ -2344,11 +2389,11 @@ msgstr "登録したら自動的にお互いをフォローします。" msgid "You've been invited to Hackers' Pub" msgstr "Hackers' Pubに招待されました" -#: src/routes/(root)/[handle]/settings/index.tsx:327 +#: src/routes/(root)/[handle]/settings/index.tsx:349 msgid "Your avatar will be displayed on your profile and in your posts. You can upload a PNG, JPEG, GIF, or WebP image up to 5 MiB in size." msgstr "アイコンはプロフィールとコンテンツに表示されます。PNG、JPEG、GIF、WebP形式の画像を5MiBまでアップロードできます。" -#: src/routes/(root)/[handle]/settings/index.tsx:447 +#: src/routes/(root)/[handle]/settings/index.tsx:469 msgid "Your bio will be displayed on your profile. You can use Markdown to format it." msgstr "自己紹介はプロフィールに表示されます。Markdownを使用できます。" @@ -2364,7 +2409,7 @@ msgstr "メールアドレスはアカウントへのログインに使用され msgid "Your friend will see this message in the invitation email." msgstr "友達は招待メールでこのメッセージを見ることができます。" -#: src/routes/(root)/[handle]/settings/index.tsx:433 +#: src/routes/(root)/[handle]/settings/index.tsx:455 #: src/routes/(root)/sign/up/[token].tsx:400 msgid "Your name will be displayed on your profile and in your posts." msgstr "名前はプロフィールとコンテンツに表示されます。" @@ -2381,11 +2426,11 @@ msgstr "環境設定が正常に更新されました。" msgid "Your preferred languages have been updated." msgstr "優先言語が更新されました。" -#: src/routes/(root)/[handle]/settings/index.tsx:303 +#: src/routes/(root)/[handle]/settings/index.tsx:325 msgid "Your profile settings have been updated successfully." msgstr "プロフィール設定が正常に更新されました。" -#: src/routes/(root)/[handle]/settings/index.tsx:400 +#: src/routes/(root)/[handle]/settings/index.tsx:422 #: src/routes/(root)/sign/up/[token].tsx:374 msgid "Your username will be used to create your profile URL and your fediverse handle." msgstr "ユーザー名はプロフィールURLとフェディバースハンドルの作成に使用されます。" diff --git a/web-next/src/locales/ko-KR/messages.po b/web-next/src/locales/ko-KR/messages.po index 6c2d4fd92..6b2621b2c 100644 --- a/web-next/src/locales/ko-KR/messages.po +++ b/web-next/src/locales/ko-KR/messages.po @@ -18,6 +18,11 @@ msgstr "" msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, other {# 댓글}}" +#. placeholder {0}: result.failedDiskDeletes! +#: src/routes/(root)/admin/media.tsx:109 +msgid "{0, plural, one {# disk object could not be deleted.} other {# disk objects could not be deleted.}}" +msgstr "{0, plural, other {디스크 객체 #개를 삭제하지 못했습니다.}}" + #. placeholder {0}: status()?.eligibleAccountsCount ?? 0 #. placeholder {1}: status()?.topThirdCount ?? 0 #: src/routes/(root)/admin/invitations.tsx:191 @@ -44,6 +49,11 @@ msgstr "{0, plural, other {# 팔로잉}}" msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, other {남은 초대 #건}}" +#. placeholder {0}: count() +#: src/routes/(root)/admin/media.tsx:177 +msgid "{0, plural, one {# orphan medium can be deleted.} other {# orphan media can be deleted.}}" +msgstr "{0, plural, other {연결되지 않은 미디어 #개를 삭제할 수 있습니다.}}" + #. placeholder {0}: votes() #: src/components/QuestionCard.tsx:484 msgid "{0, plural, one {# vote} other {# votes}}" @@ -59,6 +69,11 @@ msgstr "{0, plural, other {투표자 #명}}" msgid "{0, plural, one {+1 more} other {+# more}}" msgstr "{0, plural, other {외 #개}}" +#. placeholder {0}: result.deletedCount! +#: src/routes/(root)/admin/media.tsx:100 +msgid "{0, plural, one {Deleted # orphan medium.} other {Deleted # orphan media.}}" +msgstr "{0, plural, other {연결되지 않은 미디어 #개를 삭제했습니다.}}" + #. placeholder {0}: account.invitationsLeft #: src/routes/(root)/[handle]/settings/invite.tsx:306 msgid "{0, plural, one {Invite your friends to Hackers' Pub. You can invite up to # person.} other {Invite your friends to Hackers' Pub. You can invite up to # people.}}" @@ -167,7 +182,7 @@ msgstr "{0} 님이 회원님의 콘텐츠를 공유했습니다" #. placeholder {1}: post.excerpt #. placeholder {1}: title() #: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:238 -#: src/routes/(root)/[handle]/[noteId].tsx:221 +#: src/routes/(root)/[handle]/[noteId].tsx:226 msgid "{0}: {1}" msgstr "{0}: {1}" @@ -201,7 +216,7 @@ msgstr "{0} 님의 단문" msgid "{0}'s shares" msgstr "{0} 님의 공유" -#: src/routes/(root)/[handle]/settings/index.tsx:534 +#: src/routes/(root)/[handle]/settings/index.tsx:556 msgid "A name for the link that will be displayed on your profile, e.g., GitHub." msgstr "프로필에 표시될 링크의 이름. 예: GitHub." @@ -265,7 +280,8 @@ msgstr "환경 설정 저장 중 오류가 발생했습니다. 다시 시도하 msgid "An error occurred while saving your preferred languages. Please try again, or contact support if the problem persists." msgstr "언어 설정 저장 중 오류가 발생했습니다. 다시 시도하거나 문제가 지속되면 지원팀에 문의해 주세요." -#: src/routes/(root)/[handle]/settings/index.tsx:311 +#: src/routes/(root)/[handle]/settings/index.tsx:292 +#: src/routes/(root)/[handle]/settings/index.tsx:333 msgid "An error occurred while saving your settings. Please try again, or contact support if the problem persists." msgstr "설정 저장 중 오류가 발생했습니다. 다시 시도하거나 문제가 지속되면 지원팀에 문의하세요." @@ -286,7 +302,7 @@ msgstr "{0} 님({1})을 차단하시겠습니까? 상대방은 회원님을 팔 msgid "Are you sure you want to delete \"{draftTitle}\"? This action cannot be undone." msgstr "「{draftTitle}」을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다." -#: src/components/article-composer/ArticleComposerContext.tsx:408 +#: src/components/article-composer/ArticleComposerContext.tsx:413 msgid "Are you sure you want to delete this draft? This action cannot be undone." msgstr "이 임시 보관을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다." @@ -306,7 +322,7 @@ msgstr "{0} 님({1})의 차단을 해제하시겠습니까? 상대방은 회원 msgid "Article drafts" msgstr "게시글 임시 보관" -#: src/components/article-composer/ArticleComposerContext.tsx:364 +#: src/components/article-composer/ArticleComposerContext.tsx:369 msgid "Article published" msgstr "게시글을 공개했습니다" @@ -328,7 +344,7 @@ msgid "Articles only" msgstr "게시글만" #. placeholder {0}: "CHANGED" -#: src/routes/(root)/[handle]/settings/index.tsx:409 +#: src/routes/(root)/[handle]/settings/index.tsx:431 msgid "As you have already changed it {0}, you can't change it again." msgstr "이미 {0} 변경하였기 때문에 다시 변경할 수 없습니다." @@ -336,11 +352,11 @@ msgstr "이미 {0} 변경하였기 때문에 다시 변경할 수 없습니다." msgid "Authenticating…" msgstr "인증중…" -#: src/routes/(root)/[handle]/settings/index.tsx:325 +#: src/routes/(root)/[handle]/settings/index.tsx:347 msgid "Avatar" msgstr "프로필 사진" -#: src/routes/(root)/[handle]/settings/index.tsx:438 +#: src/routes/(root)/[handle]/settings/index.tsx:460 #: src/routes/(root)/sign/up/[token].tsx:410 msgid "Bio" msgstr "약력" @@ -380,7 +396,7 @@ msgstr "북마크" #: src/components/RemoteFollowButton.tsx:218 #: src/components/RemoteFollowButton.tsx:280 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:339 -#: src/routes/(root)/[handle]/settings/index.tsx:372 +#: src/routes/(root)/[handle]/settings/index.tsx:394 #: src/routes/(root)/[handle]/settings/passkeys.tsx:521 #: src/routes/(root)/authorize_interaction.tsx:190 msgid "Cancel" @@ -414,7 +430,7 @@ msgstr "닫힘" msgid "Code" msgstr "코드" -#: src/components/AppSidebar.tsx:811 +#: src/components/AppSidebar.tsx:834 #: src/routes/(root)/coc.tsx:48 #: src/routes/(root)/sign/up/[token].tsx:457 msgid "Code of conduct" @@ -424,7 +440,7 @@ msgstr "행동 강령" #~ msgid "Comments ({0})" #~ msgstr "댓글 ({0})" -#: src/components/AppSidebar.tsx:654 +#: src/components/AppSidebar.tsx:677 #: src/components/FloatingComposeButton.tsx:61 msgid "Compose" msgstr "작성" @@ -467,7 +483,7 @@ msgstr "프로필을 불러올 수 없습니다." msgid "Could not vote on this poll" msgstr "이 투표에 참여할 수 없습니다" -#: src/components/AppSidebar.tsx:700 +#: src/components/AppSidebar.tsx:723 #: src/components/FloatingComposeButton.tsx:123 msgid "Create article" msgstr "게시글 작성" @@ -476,7 +492,7 @@ msgstr "게시글 작성" msgid "Create invitation link" msgstr "초대 링크 생성" -#: src/components/AppSidebar.tsx:676 +#: src/components/AppSidebar.tsx:699 #: src/components/FloatingComposeButton.tsx:99 #: src/components/NoteComposeModal.tsx:26 #: src/components/NoteComposer.tsx:452 @@ -505,15 +521,16 @@ msgstr "계정을 생성하는 중…" msgid "Creating…" msgstr "작성 중…" -#: src/routes/(root)/[handle]/settings/index.tsx:379 +#: src/routes/(root)/[handle]/settings/index.tsx:401 msgid "Crop" msgstr "자르기" -#: src/routes/(root)/[handle]/settings/index.tsx:352 +#: src/routes/(root)/[handle]/settings/index.tsx:374 msgid "Crop your new avatar" msgstr "새 프로필 사진 자르기" #: src/routes/(root)/admin/invitations.tsx:183 +#: src/routes/(root)/admin/media.tsx:169 msgid "Cutoff:" msgstr "기준 시각:" @@ -536,12 +553,18 @@ msgstr "삭제" msgid "Delete draft" msgstr "임시 보관 삭제" +#: src/routes/(root)/admin/media.tsx:161 +#: src/routes/(root)/admin/media.tsx:192 +msgid "Delete orphan media" +msgstr "연결되지 않은 미디어 삭제" + #: src/components/PostActionMenu.tsx:349 msgid "Delete post?" msgstr "콘텐츠를 삭제할까요?" #: src/components/article-composer/ArticleComposerActions.tsx:20 #: src/routes/(root)/[handle]/settings/invite.tsx:654 +#: src/routes/(root)/admin/media.tsx:192 msgid "Deleting…" msgstr "삭제 중…" @@ -549,7 +572,7 @@ msgstr "삭제 중…" msgid "Discard unsaved changes - are you sure?" msgstr "저장하지 않은 변경 사항을 삭제하시겠습니까?" -#: src/routes/(root)/[handle]/settings/index.tsx:422 +#: src/routes/(root)/[handle]/settings/index.tsx:444 #: src/routes/(root)/sign/up/[token].tsx:384 msgid "Display name" msgstr "이름" @@ -562,12 +585,12 @@ msgstr "계정이 필요하신가요? Hackers' Pub은 초대 전용입니다. msgid "Do you want to quote this link?" msgstr "이 링크를 인용하시겠습니까?" -#: src/components/article-composer/ArticleComposerContext.tsx:429 +#: src/components/article-composer/ArticleComposerContext.tsx:434 #: src/routes/(root)/[handle]/drafts/index.tsx:178 msgid "Draft deleted" msgstr "임시 보관을 삭제했습니다" -#: src/components/article-composer/ArticleComposerContext.tsx:339 +#: src/components/article-composer/ArticleComposerContext.tsx:344 msgid "Draft must be saved before publishing" msgstr "공개하기 전에 임시 보관해야 합니다" @@ -575,11 +598,11 @@ msgstr "공개하기 전에 임시 보관해야 합니다" msgid "Draft not found" msgstr "임시 보관을 찾을 수 없습니다" -#: src/components/article-composer/ArticleComposerContext.tsx:291 +#: src/components/article-composer/ArticleComposerContext.tsx:296 msgid "Draft saved" msgstr "임시 보관했습니다" -#: src/routes/(root)/[handle]/settings/index.tsx:355 +#: src/routes/(root)/[handle]/settings/index.tsx:377 msgid "Drag to select the area you want to keep, then click “Crop” to update your avatar." msgstr "유지하려는 영역을 드래그하여 선택한 다음 “자르기”를 클릭하여 프로필 사진을 업데이트하세요." @@ -626,19 +649,19 @@ msgstr "아래에 이메일 주소를 입력하여 시작하세요." msgid "Enter your email or username below to sign in." msgstr "로그인하려면 아래에 이메일 또는 아이디를 입력해주세요." -#: src/components/article-composer/ArticleComposerContext.tsx:257 -#: src/components/article-composer/ArticleComposerContext.tsx:299 -#: src/components/article-composer/ArticleComposerContext.tsx:308 -#: src/components/article-composer/ArticleComposerContext.tsx:316 -#: src/components/article-composer/ArticleComposerContext.tsx:329 -#: src/components/article-composer/ArticleComposerContext.tsx:338 -#: src/components/article-composer/ArticleComposerContext.tsx:371 -#: src/components/article-composer/ArticleComposerContext.tsx:380 -#: src/components/article-composer/ArticleComposerContext.tsx:388 -#: src/components/article-composer/ArticleComposerContext.tsx:399 -#: src/components/article-composer/ArticleComposerContext.tsx:436 -#: src/components/article-composer/ArticleComposerContext.tsx:445 -#: src/components/article-composer/ArticleComposerContext.tsx:453 +#: src/components/article-composer/ArticleComposerContext.tsx:261 +#: src/components/article-composer/ArticleComposerContext.tsx:304 +#: src/components/article-composer/ArticleComposerContext.tsx:313 +#: src/components/article-composer/ArticleComposerContext.tsx:321 +#: src/components/article-composer/ArticleComposerContext.tsx:334 +#: src/components/article-composer/ArticleComposerContext.tsx:343 +#: src/components/article-composer/ArticleComposerContext.tsx:376 +#: src/components/article-composer/ArticleComposerContext.tsx:385 +#: src/components/article-composer/ArticleComposerContext.tsx:393 +#: src/components/article-composer/ArticleComposerContext.tsx:404 +#: src/components/article-composer/ArticleComposerContext.tsx:441 +#: src/components/article-composer/ArticleComposerContext.tsx:450 +#: src/components/article-composer/ArticleComposerContext.tsx:458 #: src/components/article-composer/ArticleComposerForm.tsx:26 #: src/components/NoteComposer.tsx:203 #: src/components/NoteComposer.tsx:252 @@ -695,6 +718,10 @@ msgstr "초대 링크 생성에 실패했습니다" msgid "Failed to delete invitation link" msgstr "초대 링크 삭제에 실패했습니다" +#: src/routes/(root)/admin/media.tsx:133 +msgid "Failed to delete orphan media." +msgstr "연결되지 않은 미디어 삭제에 실패했습니다." + #: src/components/PostActionMenu.tsx:289 #: src/components/PostActionMenu.tsx:296 msgid "Failed to delete post" @@ -752,7 +779,7 @@ msgstr "패스키를 더 불러오지 못했습니다. 클릭해서 다시 시 msgid "Failed to load more posts; click to retry" msgstr "콘텐츠 불러오기 실패. 클릭하여 재시도하세요" -#: src/routes/(root)/[handle]/[noteId].tsx:480 +#: src/routes/(root)/[handle]/[noteId].tsx:485 msgid "Failed to load more replies; click to retry" msgstr "댓글을 더 불러오지 못했습니다. 클릭해서 다시 시도하세요" @@ -802,7 +829,8 @@ msgstr "언어 설정 저장 실패" msgid "Failed to save preferences" msgstr "환경 설정 저장 실패" -#: src/routes/(root)/[handle]/settings/index.tsx:309 +#: src/routes/(root)/[handle]/settings/index.tsx:289 +#: src/routes/(root)/[handle]/settings/index.tsx:331 msgid "Failed to save settings" msgstr "설정 저장 실패" @@ -901,7 +929,7 @@ msgstr "당신을 팔로우합니다" msgid "Formatting" msgstr "서식" -#: src/components/AppSidebar.tsx:849 +#: src/components/AppSidebar.tsx:872 msgid "GitHub repository" msgstr "GitHub 저장소" @@ -912,7 +940,7 @@ msgstr "GitHub 저장소" msgid "Go back" msgstr "돌아가기" -#: src/routes/[...404].tsx:47 +#: src/components/NotFoundPage.tsx:48 msgid "Go home" msgstr "홈으로 가기" @@ -943,8 +971,8 @@ msgstr "마지막 재발급 시점 이후 가장 활발한 계정(콘텐츠 수 msgid "Hackers' Pub" msgstr "Hackers' Pub" +#: src/components/NotFoundPage.tsx:18 #: src/routes/(root).tsx:83 -#: src/routes/[...404].tsx:17 msgid "Hackers' Pub home" msgstr "Hackers' Pub 홈" @@ -960,6 +988,10 @@ msgstr "Hackers' Pub: 관리 · 계정" msgid "Hackers' Pub: Admin · Invitations" msgstr "Hackers' Pub: 관리 · 초대" +#: src/routes/(root)/admin/media.tsx:143 +msgid "Hackers' Pub: Admin · Media" +msgstr "Hackers' Pub: 관리 · 미디어" + #: src/routes/(root)/notifications.tsx:44 msgid "Hackers' Pub: Notifications" msgstr "Hackers' Pub: 알림" @@ -998,12 +1030,12 @@ msgid "If you have a fediverse account, you can reply to this article from your msgstr "연합우주(fediverse) 계정이 있으시다면, 이 게시글에 댓글을 달 수 있습니다. 사용하시는 인스턴스의 검색창에 {0}로 검색하신 뒤, 해당 게시글에 댓글을 남기시면 됩니다." #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[noteId].tsx:308 +#: src/routes/(root)/[handle]/[noteId].tsx:313 msgid "If you have a fediverse account, you can reply to this note from your own instance. Search {0} on your instance and reply to it." msgstr "연합우주(fediverse) 계정이 있으시다면, 이 단문에 댓글을 달 수 있습니다. 사용하시는 인스턴스의 검색창에 {0}로 검색하신 뒤, 해당 단문에 댓글을 남기시면 됩니다." #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[noteId].tsx:363 +#: src/routes/(root)/[handle]/[noteId].tsx:368 msgid "If you have a fediverse account, you can reply to this post from your own instance. Search {0} on your instance and reply to it." msgstr "연합우주 계정이 있으시다면, 이 콘텐츠에 댓글을 달 수 있습니다. 사용하시는 인스턴스에서 {0}을 검색한 뒤 댓글을 남기세요." @@ -1019,9 +1051,9 @@ msgstr "올바른 연합우주 핸들 형식이 아닙니다." #. placeholder {0}: response.createNote.inputPath #. placeholder {0}: response.deleteArticleDraft.inputPath #. placeholder {0}: response.publishArticleDraft.inputPath -#: src/components/article-composer/ArticleComposerContext.tsx:301 -#: src/components/article-composer/ArticleComposerContext.tsx:373 -#: src/components/article-composer/ArticleComposerContext.tsx:438 +#: src/components/article-composer/ArticleComposerContext.tsx:306 +#: src/components/article-composer/ArticleComposerContext.tsx:378 +#: src/components/article-composer/ArticleComposerContext.tsx:443 #: src/components/NoteComposer.tsx:281 #: src/routes/(root)/[handle]/drafts/index.tsx:187 msgid "Invalid input: {0}" @@ -1084,7 +1116,7 @@ msgstr "초대한 사람" msgid "Italic" msgstr "기울임" -#: src/routes/(root)/[handle]/settings/index.tsx:429 +#: src/routes/(root)/[handle]/settings/index.tsx:451 msgid "John Doe" msgstr "홍길동" @@ -1139,7 +1171,7 @@ msgstr "링크 저자:" msgid "Link expired" msgstr "링크가 만료되었습니다" -#: src/routes/(root)/[handle]/settings/index.tsx:515 +#: src/routes/(root)/[handle]/settings/index.tsx:537 msgid "Link name" msgstr "링크 이름" @@ -1195,7 +1227,7 @@ msgstr "패스키 더 불러오기" msgid "Load more posts" msgstr "콘텐츠 더 불러오기" -#: src/routes/(root)/[handle]/[noteId].tsx:483 +#: src/routes/(root)/[handle]/[noteId].tsx:488 msgid "Load more replies" msgstr "댓글 더 불러오기" @@ -1247,7 +1279,7 @@ msgstr "패스키를 더 불러오는 중…" msgid "Loading more posts…" msgstr "콘텐츠 불러오는 중…" -#: src/routes/(root)/[handle]/[noteId].tsx:477 +#: src/routes/(root)/[handle]/[noteId].tsx:482 msgid "Loading more replies…" msgstr "댓글을 더 불러오는 중…" @@ -1278,6 +1310,11 @@ msgstr "Markdown 가이드" msgid "Markdown supported" msgstr "Markdown 사용 가능" +#: src/components/AppSidebar.tsx:654 +#: src/routes/(root)/admin/media.tsx:157 +msgid "Media" +msgstr "미디어" + #: src/components/PostVisibilitySelect.tsx:83 #: src/components/VisibilityTag.tsx:98 msgid "Mentioned only" @@ -1341,7 +1378,7 @@ msgstr "새 게시글" msgid "No bookmarks yet" msgstr "아직 북마크가 없습니다" -#: src/components/article-composer/ArticleComposerContext.tsx:400 +#: src/components/article-composer/ArticleComposerContext.tsx:405 msgid "No draft to delete" msgstr "삭제할 임시 보관이 없습니다" @@ -1391,6 +1428,10 @@ msgstr "Hackers' Pub에 해당 계정이 없습니다. 다시 시도해주세요 msgid "No user URI provided." msgstr "사용자 URI가 제공되지 않았습니다." +#: src/routes/(root)/admin/media.tsx:124 +msgid "Not authorized to delete orphan media." +msgstr "연결되지 않은 미디어를 삭제할 권한이 없습니다." + #: src/routes/(root)/admin/invitations.tsx:123 msgid "Not authorized to regenerate invitations." msgstr "초대장을 재발급할 권한이 없습니다." @@ -1413,7 +1454,7 @@ msgid "Note created successfully" msgstr "단문이 작성되었습니다" #. placeholder {0}: "REL_ME_ATTR" -#: src/routes/(root)/[handle]/settings/index.tsx:478 +#: src/routes/(root)/[handle]/settings/index.tsx:500 msgid "Note that you can verify your links belong to you by making the linked pages also link to your Hackers' Pub profile with {0} attribute." msgstr "링크가 실제로 당신의 것인지 인증할 수 있습니다. 링크한 페이지에서도 {0} 속성을 사용해 당신의 Hackers' Pub 프로필로 링크를 걸어 주세요." @@ -1453,7 +1494,7 @@ msgstr "또는 이메일의 코드를 입력하세요" msgid "Other languages" msgstr "다른 언어" -#: src/routes/[...404].tsx:40 +#: src/components/NotFoundPage.tsx:41 msgid "Page not found" msgstr "페이지를 찾을 수 없습니다" @@ -1497,7 +1538,7 @@ msgstr "프로필에 고정" msgid "Pinned posts" msgstr "고정된 콘텐츠" -#: src/routes/(root)/[handle]/settings/index.tsx:179 +#: src/routes/(root)/[handle]/settings/index.tsx:180 msgid "Please choose an image file smaller than 5 MiB." msgstr "5 MiB 미만의 이미지 파일을 선택해주세요." @@ -1573,7 +1614,7 @@ msgstr "선호 언어" msgid "Priority" msgstr "우선순위" -#: src/components/AppSidebar.tsx:816 +#: src/components/AppSidebar.tsx:839 #: src/routes/(root)/privacy.tsx:48 msgid "Privacy policy" msgstr "개인정보 처리방침" @@ -1583,8 +1624,8 @@ msgid "Profile actions" msgstr "프로필 작업" #: src/components/SettingsTabs.tsx:44 -#: src/routes/(root)/[handle]/settings/index.tsx:123 #: src/routes/(root)/[handle]/settings/index.tsx:124 +#: src/routes/(root)/[handle]/settings/index.tsx:125 msgid "Profile settings" msgstr "프로필 설정" @@ -1642,7 +1683,7 @@ msgstr "게시글 전체 읽기" msgid "Read the full Code of conduct" msgstr "행동 강령 전문을 읽으세요." -#: src/components/AppSidebar.tsx:735 +#: src/components/AppSidebar.tsx:758 msgid "Recent drafts" msgstr "최근 임시 보관" @@ -1706,6 +1747,10 @@ msgstr "북마크 해제" msgid "Remove quote" msgstr "인용 삭제" +#: src/routes/(root)/admin/media.tsx:163 +msgid "Removes stored media that are old enough and no longer attached to an avatar, note, article draft, or article." +msgstr "생성된 지 충분히 오래되었고 프로필 사진, 단문, 게시글 임시 보관, 게시글에 더 이상 첨부되어 있지 않은 저장된 미디어를 제거합니다." + #: src/components/PostControls.tsx:207 msgid "Reply" msgstr "댓글" @@ -1719,7 +1764,7 @@ msgstr "취소" msgid "Revoke passkey" msgstr "패스키를 취소" -#: src/routes/(root)/[handle]/settings/index.tsx:490 +#: src/routes/(root)/[handle]/settings/index.tsx:512 #: src/routes/(root)/[handle]/settings/language.tsx:200 #: src/routes/(root)/[handle]/settings/preferences.tsx:206 msgid "Save" @@ -1739,7 +1784,7 @@ msgstr "미리보기를 보려면 임시 보관하세요" #: src/components/article-composer/ArticleComposerActions.tsx:32 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:345 -#: src/routes/(root)/[handle]/settings/index.tsx:490 +#: src/routes/(root)/[handle]/settings/index.tsx:512 #: src/routes/(root)/[handle]/settings/language.tsx:200 #: src/routes/(root)/[handle]/settings/preferences.tsx:206 msgid "Saving…" @@ -1821,7 +1866,7 @@ msgstr "로그인 후 투표" msgid "Sign in with passkey" msgstr "패스키를 사용하여 로그인" -#: src/components/AppSidebar.tsx:798 +#: src/components/AppSidebar.tsx:821 msgid "Sign out" msgstr "로그아웃" @@ -1854,7 +1899,7 @@ msgstr "단일 선택" msgid "Slug (URL)" msgstr "슬러그 (URL)" -#: src/components/article-composer/ArticleComposerContext.tsx:330 +#: src/components/article-composer/ArticleComposerContext.tsx:335 msgid "Slug cannot be empty" msgstr "슬러그는 비워둘 수 없습니다" @@ -1862,9 +1907,9 @@ msgstr "슬러그는 비워둘 수 없습니다" msgid "Something went wrong—please try again." msgstr "문제가 발생했습니다. 다시 시도해주세요." -#: src/components/article-composer/ArticleComposerContext.tsx:290 -#: src/components/article-composer/ArticleComposerContext.tsx:363 -#: src/components/article-composer/ArticleComposerContext.tsx:428 +#: src/components/article-composer/ArticleComposerContext.tsx:295 +#: src/components/article-composer/ArticleComposerContext.tsx:368 +#: src/components/article-composer/ArticleComposerContext.tsx:433 #: src/components/NoteComposer.tsx:272 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:189 #: src/routes/(root)/[handle]/drafts/index.tsx:177 @@ -1879,7 +1924,7 @@ msgstr "언어 설정이 성공적으로 저장되었습니다" msgid "Successfully saved preferences" msgstr "환경 설정이 성공적으로 저장되었습니다" -#: src/routes/(root)/[handle]/settings/index.tsx:302 +#: src/routes/(root)/[handle]/settings/index.tsx:324 msgid "Successfully saved settings" msgstr "설정이 성공적으로 저장되었습니다" @@ -1954,7 +1999,7 @@ msgstr "초대 링크가 성공적으로 생성되었습니다." msgid "The invitation link has been deleted successfully." msgstr "초대 링크가 성공적으로 삭제되었습니다." -#: src/routes/[...404].tsx:43 +#: src/components/NotFoundPage.tsx:44 msgid "The page you're looking for doesn't exist or has been moved." msgstr "찾으시는 페이지가 존재하지 않거나 이동되었습니다." @@ -1968,11 +2013,11 @@ msgstr "가입 링크가 유효하지 않습니다. 이메일로 받은 링크 #. placeholder {0}: "GITHUB_REPOSITORY" #. placeholder {1}: "AGPL-3.0" -#: src/components/AppSidebar.tsx:840 +#: src/components/AppSidebar.tsx:863 msgid "The source code of this website is available on {0} under the {1} license." msgstr "이 웹사이트의 소스 코드는 {1} 라이선스로 {0}에서 배포됩니다." -#: src/routes/(root)/[handle]/settings/index.tsx:558 +#: src/routes/(root)/[handle]/settings/index.tsx:580 msgid "The URL of the link, e.g., https://github.com/yourhandle." msgstr "링크의 URL. 예: https://github.com/yourhandle." @@ -2026,7 +2071,7 @@ msgstr "타임라인" msgid "Title" msgstr "제목" -#: src/components/article-composer/ArticleComposerContext.tsx:258 +#: src/components/article-composer/ArticleComposerContext.tsx:262 msgid "Title cannot be empty" msgstr "제목은 비워둘 수 없습니다" @@ -2118,7 +2163,7 @@ msgstr "프로필에서 고정 해제" msgid "Unshare" msgstr "공유 취소" -#: src/routes/(root)/[handle]/settings/index.tsx:125 +#: src/routes/(root)/[handle]/settings/index.tsx:126 msgid "Update your profile information, including your avatar, username, display name, bio, and links." msgstr "프로필 사진, 아이디, 이름, 약력, 링크 등의 프로필 정보를 업데이트하세요." @@ -2127,7 +2172,7 @@ msgstr "프로필 사진, 아이디, 이름, 약력, 링크 등의 프로필 정 msgid "Updated {0}" msgstr "{0}에 업데이트됨" -#: src/routes/(root)/[handle]/settings/index.tsx:540 +#: src/routes/(root)/[handle]/settings/index.tsx:562 msgid "URL" msgstr "URL" @@ -2143,7 +2188,7 @@ msgstr "사용자 정보를 찾을 수 없습니다." msgid "User unblocked" msgstr "사용자 차단을 해제했습니다" -#: src/routes/(root)/[handle]/settings/index.tsx:387 +#: src/routes/(root)/[handle]/settings/index.tsx:409 #: src/routes/(root)/sign/up/[token].tsx:357 msgid "Username" msgstr "아이디" @@ -2178,7 +2223,7 @@ msgstr "{1}에 {0} 님이 이 링크의 소유자임이 확인됨" msgid "Verifying your invitation…" msgstr "초대장을 확인하고 있습니다…" -#: src/components/AppSidebar.tsx:761 +#: src/components/AppSidebar.tsx:784 msgid "View all drafts →" msgstr "모든 임시 보관 보기 →" @@ -2222,7 +2267,7 @@ msgstr "투표 중…" msgid "We couldn't reach the translation service. Try again, or come back in a few minutes." msgstr "번역 서비스에 연결하지 못했습니다. 다시 시도하거나 몇 분 후에 다시 방문해 주세요." -#: src/routes/(root)/[handle]/settings/index.tsx:521 +#: src/routes/(root)/[handle]/settings/index.tsx:543 msgid "Website" msgstr "웹사이트" @@ -2267,11 +2312,11 @@ msgstr "이 사용자에게서 차단당했습니다. 이 사용자를 팔로하 msgid "You are blocking this user. They can't follow you or see your posts." msgstr "이 사용자를 차단했습니다. 이 사용자는 회원님을 팔로하거나 콘텐츠를 볼 수 없습니다." -#: src/routes/(root)/[handle]/settings/index.tsx:403 +#: src/routes/(root)/[handle]/settings/index.tsx:425 msgid "You can change it only once, and the old username will become available to others." msgstr "아이디는 단 한 번만 변경할 수 있으며, 변경하기 전 아이디는 다른 사람이 사용할 수 있게 됩니다." -#: src/routes/(root)/[handle]/settings/index.tsx:569 +#: src/routes/(root)/[handle]/settings/index.tsx:591 msgid "You can leave this empty to remove the link." msgstr "링크를 삭제하려면 이곳을 비워두세요." @@ -2315,7 +2360,7 @@ msgstr "로그인이 필요합니다" msgid "You must be signed in to create a note" msgstr "단문을 작성하려면 로그인해야 합니다" -#: src/components/article-composer/ArticleComposerContext.tsx:446 +#: src/components/article-composer/ArticleComposerContext.tsx:451 #: src/routes/(root)/[handle]/drafts/index.tsx:195 msgid "You must be signed in to delete a draft" msgstr "임시 보관을 삭제하려면 로그인해야 합니다" @@ -2324,11 +2369,11 @@ msgstr "임시 보관을 삭제하려면 로그인해야 합니다" msgid "You must be signed in to edit an article" msgstr "게시글을 수정하려면 로그인해야 합니다" -#: src/components/article-composer/ArticleComposerContext.tsx:381 +#: src/components/article-composer/ArticleComposerContext.tsx:386 msgid "You must be signed in to publish an article" msgstr "게시글을 공개하려면 로그인해야 합니다" -#: src/components/article-composer/ArticleComposerContext.tsx:309 +#: src/components/article-composer/ArticleComposerContext.tsx:314 msgid "You must be signed in to save a draft" msgstr "임시 보관하려면 로그인해야 합니다" @@ -2344,11 +2389,11 @@ msgstr "가입 시 자동으로 서로 팔로우 하게 됩니다." msgid "You've been invited to Hackers' Pub" msgstr "Hackers' Pub에 초대되었습니다" -#: src/routes/(root)/[handle]/settings/index.tsx:327 +#: src/routes/(root)/[handle]/settings/index.tsx:349 msgid "Your avatar will be displayed on your profile and in your posts. You can upload a PNG, JPEG, GIF, or WebP image up to 5 MiB in size." msgstr "프로필 사진은 프로필과 콘텐츠에 표시됩니다. PNG, JPEG, GIF, WebP 형식의 이미지를 5MiB 이하로 업로드할 수 있습니다." -#: src/routes/(root)/[handle]/settings/index.tsx:447 +#: src/routes/(root)/[handle]/settings/index.tsx:469 msgid "Your bio will be displayed on your profile. You can use Markdown to format it." msgstr "약력은 프로필에 표시됩니다. Markdown을 사용할 수 있습니다." @@ -2364,7 +2409,7 @@ msgstr "이메일 주소는 계정에 로그인할 때 사용됩니다." msgid "Your friend will see this message in the invitation email." msgstr "초대장을 받는 친구가 볼 수 있는 메시지입니다." -#: src/routes/(root)/[handle]/settings/index.tsx:433 +#: src/routes/(root)/[handle]/settings/index.tsx:455 #: src/routes/(root)/sign/up/[token].tsx:400 msgid "Your name will be displayed on your profile and in your posts." msgstr "이름은 프로필과 콘텐츠에 표시됩니다." @@ -2381,11 +2426,11 @@ msgstr "환경 설정이 성공적으로 업데이트되었습니다." msgid "Your preferred languages have been updated." msgstr "선호 언어가 업데이트되었습니다." -#: src/routes/(root)/[handle]/settings/index.tsx:303 +#: src/routes/(root)/[handle]/settings/index.tsx:325 msgid "Your profile settings have been updated successfully." msgstr "프로필 설정이 성공적으로 업데이트되었습니다." -#: src/routes/(root)/[handle]/settings/index.tsx:400 +#: src/routes/(root)/[handle]/settings/index.tsx:422 #: src/routes/(root)/sign/up/[token].tsx:374 msgid "Your username will be used to create your profile URL and your fediverse handle." msgstr "아이디는 프로필 URL과 연합우주(fediverse) 핸들로 사용됩니다." diff --git a/web-next/src/locales/zh-CN/messages.po b/web-next/src/locales/zh-CN/messages.po index fde55f42f..d04a90dcc 100644 --- a/web-next/src/locales/zh-CN/messages.po +++ b/web-next/src/locales/zh-CN/messages.po @@ -18,6 +18,11 @@ msgstr "" msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, other {# 条评论}}" +#. placeholder {0}: result.failedDiskDeletes! +#: src/routes/(root)/admin/media.tsx:109 +msgid "{0, plural, one {# disk object could not be deleted.} other {# disk objects could not be deleted.}}" +msgstr "{0, plural, other {# 个磁盘对象无法删除。}}" + #. placeholder {0}: status()?.eligibleAccountsCount ?? 0 #. placeholder {1}: status()?.topThirdCount ?? 0 #: src/routes/(root)/admin/invitations.tsx:191 @@ -44,6 +49,11 @@ msgstr "{0, plural, other {关注 # 人}}" msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, other {剩余 # 次邀请}}" +#. placeholder {0}: count() +#: src/routes/(root)/admin/media.tsx:177 +msgid "{0, plural, one {# orphan medium can be deleted.} other {# orphan media can be deleted.}}" +msgstr "{0, plural, other {可删除 # 个孤立媒体。}}" + #. placeholder {0}: votes() #: src/components/QuestionCard.tsx:484 msgid "{0, plural, one {# vote} other {# votes}}" @@ -59,6 +69,11 @@ msgstr "{0, plural, other {# 位投票者}}" msgid "{0, plural, one {+1 more} other {+# more}}" msgstr "{0, plural, other {还有#个}}" +#. placeholder {0}: result.deletedCount! +#: src/routes/(root)/admin/media.tsx:100 +msgid "{0, plural, one {Deleted # orphan medium.} other {Deleted # orphan media.}}" +msgstr "{0, plural, other {已删除 # 个孤立媒体。}}" + #. placeholder {0}: account.invitationsLeft #: src/routes/(root)/[handle]/settings/invite.tsx:306 msgid "{0, plural, one {Invite your friends to Hackers' Pub. You can invite up to # person.} other {Invite your friends to Hackers' Pub. You can invite up to # people.}}" @@ -167,7 +182,7 @@ msgstr "{0} 转发了你的内容" #. placeholder {1}: post.excerpt #. placeholder {1}: title() #: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:238 -#: src/routes/(root)/[handle]/[noteId].tsx:221 +#: src/routes/(root)/[handle]/[noteId].tsx:226 msgid "{0}: {1}" msgstr "{0}:{1}" @@ -201,7 +216,7 @@ msgstr "{0}的帖子" msgid "{0}'s shares" msgstr "{0}的转帖" -#: src/routes/(root)/[handle]/settings/index.tsx:534 +#: src/routes/(root)/[handle]/settings/index.tsx:556 msgid "A name for the link that will be displayed on your profile, e.g., GitHub." msgstr "为显示在你个人资料页面的链接命名,例如 GitHub。" @@ -265,7 +280,8 @@ msgstr "保存设置时出现错误。请重试,如果问题持续存在,请 msgid "An error occurred while saving your preferred languages. Please try again, or contact support if the problem persists." msgstr "保存语言偏好时出现错误。请重试,如果问题仍然存在,请联系支持。" -#: src/routes/(root)/[handle]/settings/index.tsx:311 +#: src/routes/(root)/[handle]/settings/index.tsx:292 +#: src/routes/(root)/[handle]/settings/index.tsx:333 msgid "An error occurred while saving your settings. Please try again, or contact support if the problem persists." msgstr "保存设置时发生错误。请重试,如果问题仍然存在,请联系支持。" @@ -286,7 +302,7 @@ msgstr "确定要屏蔽 {0}({1})吗?该用户将无法关注你或查看 msgid "Are you sure you want to delete \"{draftTitle}\"? This action cannot be undone." msgstr "您确定要删除\"{draftTitle}\"吗?此操作无法撤销。" -#: src/components/article-composer/ArticleComposerContext.tsx:408 +#: src/components/article-composer/ArticleComposerContext.tsx:413 msgid "Are you sure you want to delete this draft? This action cannot be undone." msgstr "您确定要删除此草稿吗?此操作无法撤销。" @@ -306,7 +322,7 @@ msgstr "确定要取消屏蔽 {0}({1})吗?该用户将能够关注你并 msgid "Article drafts" msgstr "文章草稿" -#: src/components/article-composer/ArticleComposerContext.tsx:364 +#: src/components/article-composer/ArticleComposerContext.tsx:369 msgid "Article published" msgstr "文章已发布" @@ -328,7 +344,7 @@ msgid "Articles only" msgstr "仅文章" #. placeholder {0}: "CHANGED" -#: src/routes/(root)/[handle]/settings/index.tsx:409 +#: src/routes/(root)/[handle]/settings/index.tsx:431 msgid "As you have already changed it {0}, you can't change it again." msgstr "自打你已经把用户名换成 {0} 了,你再也改不了了。" @@ -336,11 +352,11 @@ msgstr "自打你已经把用户名换成 {0} 了,你再也改不了了。" msgid "Authenticating…" msgstr "正在验证…" -#: src/routes/(root)/[handle]/settings/index.tsx:325 +#: src/routes/(root)/[handle]/settings/index.tsx:347 msgid "Avatar" msgstr "头像" -#: src/routes/(root)/[handle]/settings/index.tsx:438 +#: src/routes/(root)/[handle]/settings/index.tsx:460 #: src/routes/(root)/sign/up/[token].tsx:410 msgid "Bio" msgstr "个人简介" @@ -380,7 +396,7 @@ msgstr "收藏" #: src/components/RemoteFollowButton.tsx:218 #: src/components/RemoteFollowButton.tsx:280 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:339 -#: src/routes/(root)/[handle]/settings/index.tsx:372 +#: src/routes/(root)/[handle]/settings/index.tsx:394 #: src/routes/(root)/[handle]/settings/passkeys.tsx:521 #: src/routes/(root)/authorize_interaction.tsx:190 msgid "Cancel" @@ -414,7 +430,7 @@ msgstr "已结束" msgid "Code" msgstr "代码" -#: src/components/AppSidebar.tsx:811 +#: src/components/AppSidebar.tsx:834 #: src/routes/(root)/coc.tsx:48 #: src/routes/(root)/sign/up/[token].tsx:457 msgid "Code of conduct" @@ -424,7 +440,7 @@ msgstr "行为准则" #~ msgid "Comments ({0})" #~ msgstr "评论({0})" -#: src/components/AppSidebar.tsx:654 +#: src/components/AppSidebar.tsx:677 #: src/components/FloatingComposeButton.tsx:61 msgid "Compose" msgstr "写作" @@ -467,7 +483,7 @@ msgstr "无法加载个人资料。" msgid "Could not vote on this poll" msgstr "无法提交此投票" -#: src/components/AppSidebar.tsx:700 +#: src/components/AppSidebar.tsx:723 #: src/components/FloatingComposeButton.tsx:123 msgid "Create article" msgstr "创建文章" @@ -476,7 +492,7 @@ msgstr "创建文章" msgid "Create invitation link" msgstr "创建邀请链接" -#: src/components/AppSidebar.tsx:676 +#: src/components/AppSidebar.tsx:699 #: src/components/FloatingComposeButton.tsx:99 #: src/components/NoteComposeModal.tsx:26 #: src/components/NoteComposer.tsx:452 @@ -505,15 +521,16 @@ msgstr "正在创建账户…" msgid "Creating…" msgstr "正在创建…" -#: src/routes/(root)/[handle]/settings/index.tsx:379 +#: src/routes/(root)/[handle]/settings/index.tsx:401 msgid "Crop" msgstr "裁剪" -#: src/routes/(root)/[handle]/settings/index.tsx:352 +#: src/routes/(root)/[handle]/settings/index.tsx:374 msgid "Crop your new avatar" msgstr "裁剪你的新头像" #: src/routes/(root)/admin/invitations.tsx:183 +#: src/routes/(root)/admin/media.tsx:169 msgid "Cutoff:" msgstr "截止时间:" @@ -536,12 +553,18 @@ msgstr "删除" msgid "Delete draft" msgstr "删除草稿" +#: src/routes/(root)/admin/media.tsx:161 +#: src/routes/(root)/admin/media.tsx:192 +msgid "Delete orphan media" +msgstr "删除孤立媒体" + #: src/components/PostActionMenu.tsx:349 msgid "Delete post?" msgstr "删除内容?" #: src/components/article-composer/ArticleComposerActions.tsx:20 #: src/routes/(root)/[handle]/settings/invite.tsx:654 +#: src/routes/(root)/admin/media.tsx:192 msgid "Deleting…" msgstr "正在删除…" @@ -549,7 +572,7 @@ msgstr "正在删除…" msgid "Discard unsaved changes - are you sure?" msgstr "放弃未保存的更改 - 您确定吗?" -#: src/routes/(root)/[handle]/settings/index.tsx:422 +#: src/routes/(root)/[handle]/settings/index.tsx:444 #: src/routes/(root)/sign/up/[token].tsx:384 msgid "Display name" msgstr "昵称" @@ -562,12 +585,12 @@ msgstr "需要创建账户吗?Hackers' Pub 仅限邀请,请联系朋友邀 msgid "Do you want to quote this link?" msgstr "要引用此链接吗?" -#: src/components/article-composer/ArticleComposerContext.tsx:429 +#: src/components/article-composer/ArticleComposerContext.tsx:434 #: src/routes/(root)/[handle]/drafts/index.tsx:178 msgid "Draft deleted" msgstr "草稿已删除" -#: src/components/article-composer/ArticleComposerContext.tsx:339 +#: src/components/article-composer/ArticleComposerContext.tsx:344 msgid "Draft must be saved before publishing" msgstr "发布前必须保存草稿" @@ -575,11 +598,11 @@ msgstr "发布前必须保存草稿" msgid "Draft not found" msgstr "未找到草稿" -#: src/components/article-composer/ArticleComposerContext.tsx:291 +#: src/components/article-composer/ArticleComposerContext.tsx:296 msgid "Draft saved" msgstr "草稿已保存" -#: src/routes/(root)/[handle]/settings/index.tsx:355 +#: src/routes/(root)/[handle]/settings/index.tsx:377 msgid "Drag to select the area you want to keep, then click “Crop” to update your avatar." msgstr "拖动选择要保留的区域,然后点击「裁剪」来更新你的头像。" @@ -626,19 +649,19 @@ msgstr "请在下方输入你的电子邮件地址以开始。" msgid "Enter your email or username below to sign in." msgstr "请在下方输入您的邮箱或用户名以登录。" -#: src/components/article-composer/ArticleComposerContext.tsx:257 -#: src/components/article-composer/ArticleComposerContext.tsx:299 -#: src/components/article-composer/ArticleComposerContext.tsx:308 -#: src/components/article-composer/ArticleComposerContext.tsx:316 -#: src/components/article-composer/ArticleComposerContext.tsx:329 -#: src/components/article-composer/ArticleComposerContext.tsx:338 -#: src/components/article-composer/ArticleComposerContext.tsx:371 -#: src/components/article-composer/ArticleComposerContext.tsx:380 -#: src/components/article-composer/ArticleComposerContext.tsx:388 -#: src/components/article-composer/ArticleComposerContext.tsx:399 -#: src/components/article-composer/ArticleComposerContext.tsx:436 -#: src/components/article-composer/ArticleComposerContext.tsx:445 -#: src/components/article-composer/ArticleComposerContext.tsx:453 +#: src/components/article-composer/ArticleComposerContext.tsx:261 +#: src/components/article-composer/ArticleComposerContext.tsx:304 +#: src/components/article-composer/ArticleComposerContext.tsx:313 +#: src/components/article-composer/ArticleComposerContext.tsx:321 +#: src/components/article-composer/ArticleComposerContext.tsx:334 +#: src/components/article-composer/ArticleComposerContext.tsx:343 +#: src/components/article-composer/ArticleComposerContext.tsx:376 +#: src/components/article-composer/ArticleComposerContext.tsx:385 +#: src/components/article-composer/ArticleComposerContext.tsx:393 +#: src/components/article-composer/ArticleComposerContext.tsx:404 +#: src/components/article-composer/ArticleComposerContext.tsx:441 +#: src/components/article-composer/ArticleComposerContext.tsx:450 +#: src/components/article-composer/ArticleComposerContext.tsx:458 #: src/components/article-composer/ArticleComposerForm.tsx:26 #: src/components/NoteComposer.tsx:203 #: src/components/NoteComposer.tsx:252 @@ -695,6 +718,10 @@ msgstr "创建邀请链接失败" msgid "Failed to delete invitation link" msgstr "删除邀请链接失败" +#: src/routes/(root)/admin/media.tsx:133 +msgid "Failed to delete orphan media." +msgstr "删除孤立媒体失败。" + #: src/components/PostActionMenu.tsx:289 #: src/components/PostActionMenu.tsx:296 msgid "Failed to delete post" @@ -752,7 +779,7 @@ msgstr "加载更多通行密钥失败,点击重试" msgid "Failed to load more posts; click to retry" msgstr "加载更多内容失败,点击重试" -#: src/routes/(root)/[handle]/[noteId].tsx:480 +#: src/routes/(root)/[handle]/[noteId].tsx:485 msgid "Failed to load more replies; click to retry" msgstr "加载更多回复失败,点击重试" @@ -802,7 +829,8 @@ msgstr "保存语言偏好失败" msgid "Failed to save preferences" msgstr "保存设置失败" -#: src/routes/(root)/[handle]/settings/index.tsx:309 +#: src/routes/(root)/[handle]/settings/index.tsx:289 +#: src/routes/(root)/[handle]/settings/index.tsx:331 msgid "Failed to save settings" msgstr "保存设置失败" @@ -901,7 +929,7 @@ msgstr "关注了你" msgid "Formatting" msgstr "格式" -#: src/components/AppSidebar.tsx:849 +#: src/components/AppSidebar.tsx:872 msgid "GitHub repository" msgstr "GitHub 仓库" @@ -912,7 +940,7 @@ msgstr "GitHub 仓库" msgid "Go back" msgstr "返回" -#: src/routes/[...404].tsx:47 +#: src/components/NotFoundPage.tsx:48 msgid "Go home" msgstr "返回首页" @@ -943,8 +971,8 @@ msgstr "向自上次重新发放截止时间以来最活跃的账号(按内容 msgid "Hackers' Pub" msgstr "Hackers' Pub" +#: src/components/NotFoundPage.tsx:18 #: src/routes/(root).tsx:83 -#: src/routes/[...404].tsx:17 msgid "Hackers' Pub home" msgstr "Hackers' Pub 首页" @@ -960,6 +988,10 @@ msgstr "Hackers' Pub:管理 · 账号" msgid "Hackers' Pub: Admin · Invitations" msgstr "Hackers' Pub:管理 · 邀请" +#: src/routes/(root)/admin/media.tsx:143 +msgid "Hackers' Pub: Admin · Media" +msgstr "Hackers' Pub:管理 · 媒体" + #: src/routes/(root)/notifications.tsx:44 msgid "Hackers' Pub: Notifications" msgstr "Hackers' Pub:通知" @@ -998,12 +1030,12 @@ msgid "If you have a fediverse account, you can reply to this article from your msgstr "如果你在联邦宇宙有个账户,你可以在你自己的实例里评论此文章。在你的实例搜索 {0} 后回复。" #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[noteId].tsx:308 +#: src/routes/(root)/[handle]/[noteId].tsx:313 msgid "If you have a fediverse account, you can reply to this note from your own instance. Search {0} on your instance and reply to it." msgstr "如果你在联邦宇宙有个账户,你可以在你自己的实例里评论此帖子。在你的实例搜索 {0} 后回复。" #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[noteId].tsx:363 +#: src/routes/(root)/[handle]/[noteId].tsx:368 msgid "If you have a fediverse account, you can reply to this post from your own instance. Search {0} on your instance and reply to it." msgstr "如果你有联邦宇宙账号,可以从自己的实例回复此内容。在你的实例中搜索 {0} 并回复。" @@ -1019,9 +1051,9 @@ msgstr "联邦宇宙用户名格式无效。" #. placeholder {0}: response.createNote.inputPath #. placeholder {0}: response.deleteArticleDraft.inputPath #. placeholder {0}: response.publishArticleDraft.inputPath -#: src/components/article-composer/ArticleComposerContext.tsx:301 -#: src/components/article-composer/ArticleComposerContext.tsx:373 -#: src/components/article-composer/ArticleComposerContext.tsx:438 +#: src/components/article-composer/ArticleComposerContext.tsx:306 +#: src/components/article-composer/ArticleComposerContext.tsx:378 +#: src/components/article-composer/ArticleComposerContext.tsx:443 #: src/components/NoteComposer.tsx:281 #: src/routes/(root)/[handle]/drafts/index.tsx:187 msgid "Invalid input: {0}" @@ -1084,7 +1116,7 @@ msgstr "邀请人" msgid "Italic" msgstr "斜体" -#: src/routes/(root)/[handle]/settings/index.tsx:429 +#: src/routes/(root)/[handle]/settings/index.tsx:451 msgid "John Doe" msgstr "张三" @@ -1139,7 +1171,7 @@ msgstr "链接作者:" msgid "Link expired" msgstr "链接已过期" -#: src/routes/(root)/[handle]/settings/index.tsx:515 +#: src/routes/(root)/[handle]/settings/index.tsx:537 msgid "Link name" msgstr "链接名" @@ -1195,7 +1227,7 @@ msgstr "加载更多通行密钥" msgid "Load more posts" msgstr "加载更多内容" -#: src/routes/(root)/[handle]/[noteId].tsx:483 +#: src/routes/(root)/[handle]/[noteId].tsx:488 msgid "Load more replies" msgstr "加载更多回复" @@ -1247,7 +1279,7 @@ msgstr "正在加载更多通行密钥…" msgid "Loading more posts…" msgstr "加载更多内容中…" -#: src/routes/(root)/[handle]/[noteId].tsx:477 +#: src/routes/(root)/[handle]/[noteId].tsx:482 msgid "Loading more replies…" msgstr "正在加载更多回复…" @@ -1278,6 +1310,11 @@ msgstr "Markdown 指南" msgid "Markdown supported" msgstr "Markdown 可用" +#: src/components/AppSidebar.tsx:654 +#: src/routes/(root)/admin/media.tsx:157 +msgid "Media" +msgstr "媒体" + #: src/components/PostVisibilitySelect.tsx:83 #: src/components/VisibilityTag.tsx:98 msgid "Mentioned only" @@ -1341,7 +1378,7 @@ msgstr "新建文章" msgid "No bookmarks yet" msgstr "暂无收藏" -#: src/components/article-composer/ArticleComposerContext.tsx:400 +#: src/components/article-composer/ArticleComposerContext.tsx:405 msgid "No draft to delete" msgstr "没有要删除的草稿" @@ -1391,6 +1428,10 @@ msgstr "Hackers' Pub 中无此账户,请重试。" msgid "No user URI provided." msgstr "未提供用户 URI。" +#: src/routes/(root)/admin/media.tsx:124 +msgid "Not authorized to delete orphan media." +msgstr "无权删除孤立媒体。" + #: src/routes/(root)/admin/invitations.tsx:123 msgid "Not authorized to regenerate invitations." msgstr "无权重新发放邀请。" @@ -1413,7 +1454,7 @@ msgid "Note created successfully" msgstr "帖子创建成功" #. placeholder {0}: "REL_ME_ATTR" -#: src/routes/(root)/[handle]/settings/index.tsx:478 +#: src/routes/(root)/[handle]/settings/index.tsx:500 msgid "Note that you can verify your links belong to you by making the linked pages also link to your Hackers' Pub profile with {0} attribute." msgstr "你可以验证这些链接确实属于你。在链接的页面上使用 {0} 属性链接回你的 Hackers' Pub 个人资料页。" @@ -1453,7 +1494,7 @@ msgstr "或输入邮件中的验证码" msgid "Other languages" msgstr "其他语言" -#: src/routes/[...404].tsx:40 +#: src/components/NotFoundPage.tsx:41 msgid "Page not found" msgstr "页面未找到" @@ -1497,7 +1538,7 @@ msgstr "置顶到个人资料" msgid "Pinned posts" msgstr "已置顶内容" -#: src/routes/(root)/[handle]/settings/index.tsx:179 +#: src/routes/(root)/[handle]/settings/index.tsx:180 msgid "Please choose an image file smaller than 5 MiB." msgstr "请选择小于 5 MiB 的图片文件。" @@ -1573,7 +1614,7 @@ msgstr "偏好语言" msgid "Priority" msgstr "优先级" -#: src/components/AppSidebar.tsx:816 +#: src/components/AppSidebar.tsx:839 #: src/routes/(root)/privacy.tsx:48 msgid "Privacy policy" msgstr "隐私政策" @@ -1583,8 +1624,8 @@ msgid "Profile actions" msgstr "个人资料操作" #: src/components/SettingsTabs.tsx:44 -#: src/routes/(root)/[handle]/settings/index.tsx:123 #: src/routes/(root)/[handle]/settings/index.tsx:124 +#: src/routes/(root)/[handle]/settings/index.tsx:125 msgid "Profile settings" msgstr "个人资料设置" @@ -1642,7 +1683,7 @@ msgstr "阅读完整文章" msgid "Read the full Code of conduct" msgstr "阅读完整的行为准则" -#: src/components/AppSidebar.tsx:735 +#: src/components/AppSidebar.tsx:758 msgid "Recent drafts" msgstr "最近的草稿" @@ -1706,6 +1747,10 @@ msgstr "取消收藏" msgid "Remove quote" msgstr "移除引用" +#: src/routes/(root)/admin/media.tsx:163 +msgid "Removes stored media that are old enough and no longer attached to an avatar, note, article draft, or article." +msgstr "移除已足够旧且不再附加到头像、帖子、文章草稿或文章的已存储媒体。" + #: src/components/PostControls.tsx:207 msgid "Reply" msgstr "回复" @@ -1719,7 +1764,7 @@ msgstr "撤销" msgid "Revoke passkey" msgstr "撤销通行密钥" -#: src/routes/(root)/[handle]/settings/index.tsx:490 +#: src/routes/(root)/[handle]/settings/index.tsx:512 #: src/routes/(root)/[handle]/settings/language.tsx:200 #: src/routes/(root)/[handle]/settings/preferences.tsx:206 msgid "Save" @@ -1739,7 +1784,7 @@ msgstr "保存草稿以查看预览" #: src/components/article-composer/ArticleComposerActions.tsx:32 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:345 -#: src/routes/(root)/[handle]/settings/index.tsx:490 +#: src/routes/(root)/[handle]/settings/index.tsx:512 #: src/routes/(root)/[handle]/settings/language.tsx:200 #: src/routes/(root)/[handle]/settings/preferences.tsx:206 msgid "Saving…" @@ -1821,7 +1866,7 @@ msgstr "登录后投票" msgid "Sign in with passkey" msgstr "使用通行密钥登录" -#: src/components/AppSidebar.tsx:798 +#: src/components/AppSidebar.tsx:821 msgid "Sign out" msgstr "登出" @@ -1854,7 +1899,7 @@ msgstr "单选" msgid "Slug (URL)" msgstr "URL 别名" -#: src/components/article-composer/ArticleComposerContext.tsx:330 +#: src/components/article-composer/ArticleComposerContext.tsx:335 msgid "Slug cannot be empty" msgstr "URL 别名不能为空" @@ -1862,9 +1907,9 @@ msgstr "URL 别名不能为空" msgid "Something went wrong—please try again." msgstr "出现错误,请重试。" -#: src/components/article-composer/ArticleComposerContext.tsx:290 -#: src/components/article-composer/ArticleComposerContext.tsx:363 -#: src/components/article-composer/ArticleComposerContext.tsx:428 +#: src/components/article-composer/ArticleComposerContext.tsx:295 +#: src/components/article-composer/ArticleComposerContext.tsx:368 +#: src/components/article-composer/ArticleComposerContext.tsx:433 #: src/components/NoteComposer.tsx:272 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:189 #: src/routes/(root)/[handle]/drafts/index.tsx:177 @@ -1879,7 +1924,7 @@ msgstr "成功保存语言偏好" msgid "Successfully saved preferences" msgstr "设置已成功保存" -#: src/routes/(root)/[handle]/settings/index.tsx:302 +#: src/routes/(root)/[handle]/settings/index.tsx:324 msgid "Successfully saved settings" msgstr "设置保存成功" @@ -1954,7 +1999,7 @@ msgstr "邀请链接已成功创建。" msgid "The invitation link has been deleted successfully." msgstr "邀请链接已成功删除。" -#: src/routes/[...404].tsx:43 +#: src/components/NotFoundPage.tsx:44 msgid "The page you're looking for doesn't exist or has been moved." msgstr "您访问的页面不存在或已被移动。" @@ -1968,11 +2013,11 @@ msgstr "注册链接无效。请确保你使用的是你收到的正确邮件链 #. placeholder {0}: "GITHUB_REPOSITORY" #. placeholder {1}: "AGPL-3.0" -#: src/components/AppSidebar.tsx:840 +#: src/components/AppSidebar.tsx:863 msgid "The source code of this website is available on {0} under the {1} license." msgstr "可在 {0} 上以 {1} 许可获取该网站的源代码。" -#: src/routes/(root)/[handle]/settings/index.tsx:558 +#: src/routes/(root)/[handle]/settings/index.tsx:580 msgid "The URL of the link, e.g., https://github.com/yourhandle." msgstr "该链接的 URL,例如 https://github.com/nideyonghuming 。" @@ -2026,7 +2071,7 @@ msgstr "时间线" msgid "Title" msgstr "标题" -#: src/components/article-composer/ArticleComposerContext.tsx:258 +#: src/components/article-composer/ArticleComposerContext.tsx:262 msgid "Title cannot be empty" msgstr "标题不能为空" @@ -2118,7 +2163,7 @@ msgstr "从个人资料取消置顶" msgid "Unshare" msgstr "取消转帖" -#: src/routes/(root)/[handle]/settings/index.tsx:125 +#: src/routes/(root)/[handle]/settings/index.tsx:126 msgid "Update your profile information, including your avatar, username, display name, bio, and links." msgstr "更新您的个人资料信息,包括头像、用户名、昵称、个人简介和链接。" @@ -2127,7 +2172,7 @@ msgstr "更新您的个人资料信息,包括头像、用户名、昵称、个 msgid "Updated {0}" msgstr "更新于 {0}" -#: src/routes/(root)/[handle]/settings/index.tsx:540 +#: src/routes/(root)/[handle]/settings/index.tsx:562 msgid "URL" msgstr "URL" @@ -2143,7 +2188,7 @@ msgstr "未找到用户信息。" msgid "User unblocked" msgstr "已取消屏蔽用户" -#: src/routes/(root)/[handle]/settings/index.tsx:387 +#: src/routes/(root)/[handle]/settings/index.tsx:409 #: src/routes/(root)/sign/up/[token].tsx:357 msgid "Username" msgstr "用户名" @@ -2178,7 +2223,7 @@ msgstr "已于{1}验证此链接归{0}所有" msgid "Verifying your invitation…" msgstr "正在验证您的邀请…" -#: src/components/AppSidebar.tsx:761 +#: src/components/AppSidebar.tsx:784 msgid "View all drafts →" msgstr "查看所有草稿 →" @@ -2222,7 +2267,7 @@ msgstr "正在投票…" msgid "We couldn't reach the translation service. Try again, or come back in a few minutes." msgstr "无法连接到翻译服务。请重试或几分钟后再来。" -#: src/routes/(root)/[handle]/settings/index.tsx:521 +#: src/routes/(root)/[handle]/settings/index.tsx:543 msgid "Website" msgstr "网站" @@ -2267,11 +2312,11 @@ msgstr "你已被此用户屏蔽。你无法关注该用户或查看该用户的 msgid "You are blocking this user. They can't follow you or see your posts." msgstr "你正在屏蔽此用户。该用户无法关注你或查看你的内容。" -#: src/routes/(root)/[handle]/settings/index.tsx:403 +#: src/routes/(root)/[handle]/settings/index.tsx:425 msgid "You can change it only once, and the old username will become available to others." msgstr "你只能更改一次用户名,而旧的用户名会公开为别人使用。" -#: src/routes/(root)/[handle]/settings/index.tsx:569 +#: src/routes/(root)/[handle]/settings/index.tsx:591 msgid "You can leave this empty to remove the link." msgstr "您可以将此处留空以删除链接。" @@ -2315,7 +2360,7 @@ msgstr "请先登录" msgid "You must be signed in to create a note" msgstr "你必须登录才能创建帖子" -#: src/components/article-composer/ArticleComposerContext.tsx:446 +#: src/components/article-composer/ArticleComposerContext.tsx:451 #: src/routes/(root)/[handle]/drafts/index.tsx:195 msgid "You must be signed in to delete a draft" msgstr "您必须登录才能删除草稿" @@ -2324,11 +2369,11 @@ msgstr "您必须登录才能删除草稿" msgid "You must be signed in to edit an article" msgstr "您必须登录才能编辑文章" -#: src/components/article-composer/ArticleComposerContext.tsx:381 +#: src/components/article-composer/ArticleComposerContext.tsx:386 msgid "You must be signed in to publish an article" msgstr "您必须登录才能发布文章" -#: src/components/article-composer/ArticleComposerContext.tsx:309 +#: src/components/article-composer/ArticleComposerContext.tsx:314 msgid "You must be signed in to save a draft" msgstr "您必须登录才能保存草稿" @@ -2344,11 +2389,11 @@ msgstr "您在注册时将自动互相关注。" msgid "You've been invited to Hackers' Pub" msgstr "你被邀请加入 Hackers' Pub" -#: src/routes/(root)/[handle]/settings/index.tsx:327 +#: src/routes/(root)/[handle]/settings/index.tsx:349 msgid "Your avatar will be displayed on your profile and in your posts. You can upload a PNG, JPEG, GIF, or WebP image up to 5 MiB in size." msgstr "你的头像将在你的个人资料页面和你的帖文中显示。可以上传大小不超过 5 MiB 的 PNG、JPEG、GIF 或 WebP 图片。" -#: src/routes/(root)/[handle]/settings/index.tsx:447 +#: src/routes/(root)/[handle]/settings/index.tsx:469 msgid "Your bio will be displayed on your profile. You can use Markdown to format it." msgstr "你的个人简介将在你的个人资料页面显示。你可以用 Markdown 文档格式化。" @@ -2364,7 +2409,7 @@ msgstr "你的电子邮件地址将用于登录。" msgid "Your friend will see this message in the invitation email." msgstr "你的朋友将在邀请邮件中看到此信息。" -#: src/routes/(root)/[handle]/settings/index.tsx:433 +#: src/routes/(root)/[handle]/settings/index.tsx:455 #: src/routes/(root)/sign/up/[token].tsx:400 msgid "Your name will be displayed on your profile and in your posts." msgstr "你的昵称将在你的个人资料页面和你的帖文中显示。" @@ -2381,11 +2426,11 @@ msgstr "您的设置已成功更新。" msgid "Your preferred languages have been updated." msgstr "您的偏好语言已更新。" -#: src/routes/(root)/[handle]/settings/index.tsx:303 +#: src/routes/(root)/[handle]/settings/index.tsx:325 msgid "Your profile settings have been updated successfully." msgstr "个人资料设置已成功更新。" -#: src/routes/(root)/[handle]/settings/index.tsx:400 +#: src/routes/(root)/[handle]/settings/index.tsx:422 #: src/routes/(root)/sign/up/[token].tsx:374 msgid "Your username will be used to create your profile URL and your fediverse handle." msgstr "你的用户名将被用来创建你的个人资料 URL 和你的 Fediverse 用户名(handle)。" diff --git a/web-next/src/locales/zh-TW/messages.po b/web-next/src/locales/zh-TW/messages.po index 6b5385831..86f4bb6f0 100644 --- a/web-next/src/locales/zh-TW/messages.po +++ b/web-next/src/locales/zh-TW/messages.po @@ -18,6 +18,11 @@ msgstr "" msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, other {# 則評論}}" +#. placeholder {0}: result.failedDiskDeletes! +#: src/routes/(root)/admin/media.tsx:109 +msgid "{0, plural, one {# disk object could not be deleted.} other {# disk objects could not be deleted.}}" +msgstr "{0, plural, other {# 個磁碟物件無法刪除。}}" + #. placeholder {0}: status()?.eligibleAccountsCount ?? 0 #. placeholder {1}: status()?.topThirdCount ?? 0 #: src/routes/(root)/admin/invitations.tsx:191 @@ -44,6 +49,11 @@ msgstr "{0, plural, other {關注 # 人}}" msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, other {剩餘 # 次邀請}}" +#. placeholder {0}: count() +#: src/routes/(root)/admin/media.tsx:177 +msgid "{0, plural, one {# orphan medium can be deleted.} other {# orphan media can be deleted.}}" +msgstr "{0, plural, other {可刪除 # 個孤立媒體。}}" + #. placeholder {0}: votes() #: src/components/QuestionCard.tsx:484 msgid "{0, plural, one {# vote} other {# votes}}" @@ -59,6 +69,11 @@ msgstr "{0, plural, other {# 位投票者}}" msgid "{0, plural, one {+1 more} other {+# more}}" msgstr "{0, plural, other {還有#個}}" +#. placeholder {0}: result.deletedCount! +#: src/routes/(root)/admin/media.tsx:100 +msgid "{0, plural, one {Deleted # orphan medium.} other {Deleted # orphan media.}}" +msgstr "{0, plural, other {已刪除 # 個孤立媒體。}}" + #. placeholder {0}: account.invitationsLeft #: src/routes/(root)/[handle]/settings/invite.tsx:306 msgid "{0, plural, one {Invite your friends to Hackers' Pub. You can invite up to # person.} other {Invite your friends to Hackers' Pub. You can invite up to # people.}}" @@ -167,7 +182,7 @@ msgstr "{0} 轉貼了你的內容" #. placeholder {1}: post.excerpt #. placeholder {1}: title() #: src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx:238 -#: src/routes/(root)/[handle]/[noteId].tsx:221 +#: src/routes/(root)/[handle]/[noteId].tsx:226 msgid "{0}: {1}" msgstr "{0}:{1}" @@ -201,7 +216,7 @@ msgstr "{0}的貼文" msgid "{0}'s shares" msgstr "{0}的轉貼" -#: src/routes/(root)/[handle]/settings/index.tsx:534 +#: src/routes/(root)/[handle]/settings/index.tsx:556 msgid "A name for the link that will be displayed on your profile, e.g., GitHub." msgstr "為顯示在你個人資料頁面的連結命名,例如 GitHub。" @@ -265,7 +280,8 @@ msgstr "儲存設定時發生錯誤。請重試,如果問題持續存在,請 msgid "An error occurred while saving your preferred languages. Please try again, or contact support if the problem persists." msgstr "儲存語言偏好時出現錯誤。請重試,如果問題仍然存在,請聯繫支援。" -#: src/routes/(root)/[handle]/settings/index.tsx:311 +#: src/routes/(root)/[handle]/settings/index.tsx:292 +#: src/routes/(root)/[handle]/settings/index.tsx:333 msgid "An error occurred while saving your settings. Please try again, or contact support if the problem persists." msgstr "儲存設定時發生錯誤。請重試,如果問題仍然存在,請聯繫支援。" @@ -286,7 +302,7 @@ msgstr "確定要封鎖 {0}({1})嗎?該使用者將無法關注你或查 msgid "Are you sure you want to delete \"{draftTitle}\"? This action cannot be undone." msgstr "您確定要刪除「{draftTitle}」嗎?此操作無法復原。" -#: src/components/article-composer/ArticleComposerContext.tsx:408 +#: src/components/article-composer/ArticleComposerContext.tsx:413 msgid "Are you sure you want to delete this draft? This action cannot be undone." msgstr "您確定要刪除此草稿嗎?此操作無法復原。" @@ -306,7 +322,7 @@ msgstr "確定要解除封鎖 {0}({1})嗎?該使用者將能夠關注你 msgid "Article drafts" msgstr "文章草稿" -#: src/components/article-composer/ArticleComposerContext.tsx:364 +#: src/components/article-composer/ArticleComposerContext.tsx:369 msgid "Article published" msgstr "文章已發布" @@ -328,7 +344,7 @@ msgid "Articles only" msgstr "僅文章" #. placeholder {0}: "CHANGED" -#: src/routes/(root)/[handle]/settings/index.tsx:409 +#: src/routes/(root)/[handle]/settings/index.tsx:431 msgid "As you have already changed it {0}, you can't change it again." msgstr "自從你已經把使用者名稱換成 {0} 了,你再也改不了了。" @@ -336,11 +352,11 @@ msgstr "自從你已經把使用者名稱換成 {0} 了,你再也改不了了 msgid "Authenticating…" msgstr "驗證中…" -#: src/routes/(root)/[handle]/settings/index.tsx:325 +#: src/routes/(root)/[handle]/settings/index.tsx:347 msgid "Avatar" msgstr "頭像" -#: src/routes/(root)/[handle]/settings/index.tsx:438 +#: src/routes/(root)/[handle]/settings/index.tsx:460 #: src/routes/(root)/sign/up/[token].tsx:410 msgid "Bio" msgstr "個人簡介" @@ -380,7 +396,7 @@ msgstr "收藏" #: src/components/RemoteFollowButton.tsx:218 #: src/components/RemoteFollowButton.tsx:280 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:339 -#: src/routes/(root)/[handle]/settings/index.tsx:372 +#: src/routes/(root)/[handle]/settings/index.tsx:394 #: src/routes/(root)/[handle]/settings/passkeys.tsx:521 #: src/routes/(root)/authorize_interaction.tsx:190 msgid "Cancel" @@ -414,7 +430,7 @@ msgstr "已結束" msgid "Code" msgstr "程式碼" -#: src/components/AppSidebar.tsx:811 +#: src/components/AppSidebar.tsx:834 #: src/routes/(root)/coc.tsx:48 #: src/routes/(root)/sign/up/[token].tsx:457 msgid "Code of conduct" @@ -424,7 +440,7 @@ msgstr "行為準則" #~ msgid "Comments ({0})" #~ msgstr "評論({0})" -#: src/components/AppSidebar.tsx:654 +#: src/components/AppSidebar.tsx:677 #: src/components/FloatingComposeButton.tsx:61 msgid "Compose" msgstr "寫作" @@ -467,7 +483,7 @@ msgstr "無法載入個人資料。" msgid "Could not vote on this poll" msgstr "無法提交此投票" -#: src/components/AppSidebar.tsx:700 +#: src/components/AppSidebar.tsx:723 #: src/components/FloatingComposeButton.tsx:123 msgid "Create article" msgstr "建立文章" @@ -476,7 +492,7 @@ msgstr "建立文章" msgid "Create invitation link" msgstr "建立邀請連結" -#: src/components/AppSidebar.tsx:676 +#: src/components/AppSidebar.tsx:699 #: src/components/FloatingComposeButton.tsx:99 #: src/components/NoteComposeModal.tsx:26 #: src/components/NoteComposer.tsx:452 @@ -505,15 +521,16 @@ msgstr "創建帳戶…" msgid "Creating…" msgstr "正在建立…" -#: src/routes/(root)/[handle]/settings/index.tsx:379 +#: src/routes/(root)/[handle]/settings/index.tsx:401 msgid "Crop" msgstr "裁剪" -#: src/routes/(root)/[handle]/settings/index.tsx:352 +#: src/routes/(root)/[handle]/settings/index.tsx:374 msgid "Crop your new avatar" msgstr "裁剪你的新頭像" #: src/routes/(root)/admin/invitations.tsx:183 +#: src/routes/(root)/admin/media.tsx:169 msgid "Cutoff:" msgstr "截止時間:" @@ -536,12 +553,18 @@ msgstr "刪除" msgid "Delete draft" msgstr "刪除草稿" +#: src/routes/(root)/admin/media.tsx:161 +#: src/routes/(root)/admin/media.tsx:192 +msgid "Delete orphan media" +msgstr "刪除孤立媒體" + #: src/components/PostActionMenu.tsx:349 msgid "Delete post?" msgstr "刪除內容?" #: src/components/article-composer/ArticleComposerActions.tsx:20 #: src/routes/(root)/[handle]/settings/invite.tsx:654 +#: src/routes/(root)/admin/media.tsx:192 msgid "Deleting…" msgstr "正在刪除…" @@ -549,7 +572,7 @@ msgstr "正在刪除…" msgid "Discard unsaved changes - are you sure?" msgstr "放棄未儲存的變更 - 您確定嗎?" -#: src/routes/(root)/[handle]/settings/index.tsx:422 +#: src/routes/(root)/[handle]/settings/index.tsx:444 #: src/routes/(root)/sign/up/[token].tsx:384 msgid "Display name" msgstr "暱稱" @@ -562,12 +585,12 @@ msgstr "需要建立帳戶嗎?Hackers' Pub 僅限邀請,請聯繫朋友邀 msgid "Do you want to quote this link?" msgstr "要引用此連結嗎?" -#: src/components/article-composer/ArticleComposerContext.tsx:429 +#: src/components/article-composer/ArticleComposerContext.tsx:434 #: src/routes/(root)/[handle]/drafts/index.tsx:178 msgid "Draft deleted" msgstr "草稿已刪除" -#: src/components/article-composer/ArticleComposerContext.tsx:339 +#: src/components/article-composer/ArticleComposerContext.tsx:344 msgid "Draft must be saved before publishing" msgstr "發布前必須儲存草稿" @@ -575,11 +598,11 @@ msgstr "發布前必須儲存草稿" msgid "Draft not found" msgstr "未找到草稿" -#: src/components/article-composer/ArticleComposerContext.tsx:291 +#: src/components/article-composer/ArticleComposerContext.tsx:296 msgid "Draft saved" msgstr "草稿已儲存" -#: src/routes/(root)/[handle]/settings/index.tsx:355 +#: src/routes/(root)/[handle]/settings/index.tsx:377 msgid "Drag to select the area you want to keep, then click “Crop” to update your avatar." msgstr "拖動選擇要保留的區域,然後點擊「裁剪」來更新你的頭像。" @@ -626,19 +649,19 @@ msgstr "請在下方輸入你的電子郵件地址以開始。" msgid "Enter your email or username below to sign in." msgstr "請在下方輸入您的電子郵件或使用者名稱以登入。" -#: src/components/article-composer/ArticleComposerContext.tsx:257 -#: src/components/article-composer/ArticleComposerContext.tsx:299 -#: src/components/article-composer/ArticleComposerContext.tsx:308 -#: src/components/article-composer/ArticleComposerContext.tsx:316 -#: src/components/article-composer/ArticleComposerContext.tsx:329 -#: src/components/article-composer/ArticleComposerContext.tsx:338 -#: src/components/article-composer/ArticleComposerContext.tsx:371 -#: src/components/article-composer/ArticleComposerContext.tsx:380 -#: src/components/article-composer/ArticleComposerContext.tsx:388 -#: src/components/article-composer/ArticleComposerContext.tsx:399 -#: src/components/article-composer/ArticleComposerContext.tsx:436 -#: src/components/article-composer/ArticleComposerContext.tsx:445 -#: src/components/article-composer/ArticleComposerContext.tsx:453 +#: src/components/article-composer/ArticleComposerContext.tsx:261 +#: src/components/article-composer/ArticleComposerContext.tsx:304 +#: src/components/article-composer/ArticleComposerContext.tsx:313 +#: src/components/article-composer/ArticleComposerContext.tsx:321 +#: src/components/article-composer/ArticleComposerContext.tsx:334 +#: src/components/article-composer/ArticleComposerContext.tsx:343 +#: src/components/article-composer/ArticleComposerContext.tsx:376 +#: src/components/article-composer/ArticleComposerContext.tsx:385 +#: src/components/article-composer/ArticleComposerContext.tsx:393 +#: src/components/article-composer/ArticleComposerContext.tsx:404 +#: src/components/article-composer/ArticleComposerContext.tsx:441 +#: src/components/article-composer/ArticleComposerContext.tsx:450 +#: src/components/article-composer/ArticleComposerContext.tsx:458 #: src/components/article-composer/ArticleComposerForm.tsx:26 #: src/components/NoteComposer.tsx:203 #: src/components/NoteComposer.tsx:252 @@ -695,6 +718,10 @@ msgstr "建立邀請連結失敗" msgid "Failed to delete invitation link" msgstr "刪除邀請連結失敗" +#: src/routes/(root)/admin/media.tsx:133 +msgid "Failed to delete orphan media." +msgstr "刪除孤立媒體失敗。" + #: src/components/PostActionMenu.tsx:289 #: src/components/PostActionMenu.tsx:296 msgid "Failed to delete post" @@ -752,7 +779,7 @@ msgstr "載入更多通行金鑰失敗;點擊重試" msgid "Failed to load more posts; click to retry" msgstr "載入更多內容失敗,點擊重試" -#: src/routes/(root)/[handle]/[noteId].tsx:480 +#: src/routes/(root)/[handle]/[noteId].tsx:485 msgid "Failed to load more replies; click to retry" msgstr "載入更多回覆失敗,點擊重試" @@ -802,7 +829,8 @@ msgstr "儲存語言偏好失敗" msgid "Failed to save preferences" msgstr "儲存設定失敗" -#: src/routes/(root)/[handle]/settings/index.tsx:309 +#: src/routes/(root)/[handle]/settings/index.tsx:289 +#: src/routes/(root)/[handle]/settings/index.tsx:331 msgid "Failed to save settings" msgstr "儲存設定失敗" @@ -901,7 +929,7 @@ msgstr "關注了你" msgid "Formatting" msgstr "格式" -#: src/components/AppSidebar.tsx:849 +#: src/components/AppSidebar.tsx:872 msgid "GitHub repository" msgstr "GitHub 儲存庫" @@ -912,7 +940,7 @@ msgstr "GitHub 儲存庫" msgid "Go back" msgstr "返回" -#: src/routes/[...404].tsx:47 +#: src/components/NotFoundPage.tsx:48 msgid "Go home" msgstr "返回首頁" @@ -943,8 +971,8 @@ msgstr "向自上次重新發放截止時間以來最活躍的帳號(按內容 msgid "Hackers' Pub" msgstr "Hackers' Pub" +#: src/components/NotFoundPage.tsx:18 #: src/routes/(root).tsx:83 -#: src/routes/[...404].tsx:17 msgid "Hackers' Pub home" msgstr "Hackers' Pub 首頁" @@ -960,6 +988,10 @@ msgstr "Hackers' Pub:管理 · 帳號" msgid "Hackers' Pub: Admin · Invitations" msgstr "Hackers' Pub:管理 · 邀請" +#: src/routes/(root)/admin/media.tsx:143 +msgid "Hackers' Pub: Admin · Media" +msgstr "Hackers' Pub:管理 · 媒體" + #: src/routes/(root)/notifications.tsx:44 msgid "Hackers' Pub: Notifications" msgstr "Hackers' Pub:通知" @@ -998,12 +1030,12 @@ msgid "If you have a fediverse account, you can reply to this article from your msgstr "如果你在聯邦宇宙有個帳戶,你可以在你自己的站台裡評論此文章。在你的站台搜尋 {0} 後回覆。" #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[noteId].tsx:308 +#: src/routes/(root)/[handle]/[noteId].tsx:313 msgid "If you have a fediverse account, you can reply to this note from your own instance. Search {0} on your instance and reply to it." msgstr "如果你在聯邦宇宙有個帳戶,你可以在你自己的實例裡評論此貼文。在你的實例搜尋 {0} 後回覆。" #. placeholder {0}: "ACTIVITYPUB_URI" -#: src/routes/(root)/[handle]/[noteId].tsx:363 +#: src/routes/(root)/[handle]/[noteId].tsx:368 msgid "If you have a fediverse account, you can reply to this post from your own instance. Search {0} on your instance and reply to it." msgstr "如果你有聯邦宇宙帳號,可以從自己的站台回覆此內容。在你的站台中搜尋 {0} 並回覆。" @@ -1019,9 +1051,9 @@ msgstr "聯邦宇宙使用者名稱格式無效。" #. placeholder {0}: response.createNote.inputPath #. placeholder {0}: response.deleteArticleDraft.inputPath #. placeholder {0}: response.publishArticleDraft.inputPath -#: src/components/article-composer/ArticleComposerContext.tsx:301 -#: src/components/article-composer/ArticleComposerContext.tsx:373 -#: src/components/article-composer/ArticleComposerContext.tsx:438 +#: src/components/article-composer/ArticleComposerContext.tsx:306 +#: src/components/article-composer/ArticleComposerContext.tsx:378 +#: src/components/article-composer/ArticleComposerContext.tsx:443 #: src/components/NoteComposer.tsx:281 #: src/routes/(root)/[handle]/drafts/index.tsx:187 msgid "Invalid input: {0}" @@ -1084,7 +1116,7 @@ msgstr "邀請人" msgid "Italic" msgstr "斜體" -#: src/routes/(root)/[handle]/settings/index.tsx:429 +#: src/routes/(root)/[handle]/settings/index.tsx:451 msgid "John Doe" msgstr "張三" @@ -1139,7 +1171,7 @@ msgstr "連結作者:" msgid "Link expired" msgstr "連結已過期" -#: src/routes/(root)/[handle]/settings/index.tsx:515 +#: src/routes/(root)/[handle]/settings/index.tsx:537 msgid "Link name" msgstr "連結名" @@ -1195,7 +1227,7 @@ msgstr "載入更多通行金鑰" msgid "Load more posts" msgstr "載入更多內容" -#: src/routes/(root)/[handle]/[noteId].tsx:483 +#: src/routes/(root)/[handle]/[noteId].tsx:488 msgid "Load more replies" msgstr "載入更多回覆" @@ -1247,7 +1279,7 @@ msgstr "正在載入更多通行金鑰…" msgid "Loading more posts…" msgstr "載入更多內容中…" -#: src/routes/(root)/[handle]/[noteId].tsx:477 +#: src/routes/(root)/[handle]/[noteId].tsx:482 msgid "Loading more replies…" msgstr "正在載入更多回覆…" @@ -1278,6 +1310,11 @@ msgstr "Markdown 指南" msgid "Markdown supported" msgstr "Markdown 可用" +#: src/components/AppSidebar.tsx:654 +#: src/routes/(root)/admin/media.tsx:157 +msgid "Media" +msgstr "媒體" + #: src/components/PostVisibilitySelect.tsx:83 #: src/components/VisibilityTag.tsx:98 msgid "Mentioned only" @@ -1341,7 +1378,7 @@ msgstr "新建文章" msgid "No bookmarks yet" msgstr "暫無收藏" -#: src/components/article-composer/ArticleComposerContext.tsx:400 +#: src/components/article-composer/ArticleComposerContext.tsx:405 msgid "No draft to delete" msgstr "沒有要刪除的草稿" @@ -1391,6 +1428,10 @@ msgstr "Hackers' Pub 中無此帳戶,請重試。" msgid "No user URI provided." msgstr "未提供使用者 URI。" +#: src/routes/(root)/admin/media.tsx:124 +msgid "Not authorized to delete orphan media." +msgstr "無權刪除孤立媒體。" + #: src/routes/(root)/admin/invitations.tsx:123 msgid "Not authorized to regenerate invitations." msgstr "無權重新發放邀請。" @@ -1413,7 +1454,7 @@ msgid "Note created successfully" msgstr "貼文建立成功" #. placeholder {0}: "REL_ME_ATTR" -#: src/routes/(root)/[handle]/settings/index.tsx:478 +#: src/routes/(root)/[handle]/settings/index.tsx:500 msgid "Note that you can verify your links belong to you by making the linked pages also link to your Hackers' Pub profile with {0} attribute." msgstr "你可以驗證這些連結確實屬於你。在連結的頁面上使用 {0} 屬性連結回你的 Hackers' Pub 個人資料頁。" @@ -1453,7 +1494,7 @@ msgstr "或輸入郵件中的驗證碼" msgid "Other languages" msgstr "其他語言" -#: src/routes/[...404].tsx:40 +#: src/components/NotFoundPage.tsx:41 msgid "Page not found" msgstr "頁面未找到" @@ -1497,7 +1538,7 @@ msgstr "釘選到個人資料" msgid "Pinned posts" msgstr "已釘選內容" -#: src/routes/(root)/[handle]/settings/index.tsx:179 +#: src/routes/(root)/[handle]/settings/index.tsx:180 msgid "Please choose an image file smaller than 5 MiB." msgstr "請選擇小於 5 MiB 的圖片檔案。" @@ -1573,7 +1614,7 @@ msgstr "偏好語言" msgid "Priority" msgstr "優先級" -#: src/components/AppSidebar.tsx:816 +#: src/components/AppSidebar.tsx:839 #: src/routes/(root)/privacy.tsx:48 msgid "Privacy policy" msgstr "隱私權政策" @@ -1583,8 +1624,8 @@ msgid "Profile actions" msgstr "個人檔案操作" #: src/components/SettingsTabs.tsx:44 -#: src/routes/(root)/[handle]/settings/index.tsx:123 #: src/routes/(root)/[handle]/settings/index.tsx:124 +#: src/routes/(root)/[handle]/settings/index.tsx:125 msgid "Profile settings" msgstr "個人資料設定" @@ -1642,7 +1683,7 @@ msgstr "閱讀完整文章" msgid "Read the full Code of conduct" msgstr "閱讀完整的行為守則" -#: src/components/AppSidebar.tsx:735 +#: src/components/AppSidebar.tsx:758 msgid "Recent drafts" msgstr "最近的草稿" @@ -1706,6 +1747,10 @@ msgstr "取消收藏" msgid "Remove quote" msgstr "移除引用" +#: src/routes/(root)/admin/media.tsx:163 +msgid "Removes stored media that are old enough and no longer attached to an avatar, note, article draft, or article." +msgstr "移除已足夠舊且不再附加到頭像、貼文、文章草稿或文章的已儲存媒體。" + #: src/components/PostControls.tsx:207 msgid "Reply" msgstr "回覆" @@ -1719,7 +1764,7 @@ msgstr "撤銷" msgid "Revoke passkey" msgstr "撤銷通行金鑰" -#: src/routes/(root)/[handle]/settings/index.tsx:490 +#: src/routes/(root)/[handle]/settings/index.tsx:512 #: src/routes/(root)/[handle]/settings/language.tsx:200 #: src/routes/(root)/[handle]/settings/preferences.tsx:206 msgid "Save" @@ -1739,7 +1784,7 @@ msgstr "儲存草稿以查看預覽" #: src/components/article-composer/ArticleComposerActions.tsx:32 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:345 -#: src/routes/(root)/[handle]/settings/index.tsx:490 +#: src/routes/(root)/[handle]/settings/index.tsx:512 #: src/routes/(root)/[handle]/settings/language.tsx:200 #: src/routes/(root)/[handle]/settings/preferences.tsx:206 msgid "Saving…" @@ -1821,7 +1866,7 @@ msgstr "登入後投票" msgid "Sign in with passkey" msgstr "使用通行金鑰登入" -#: src/components/AppSidebar.tsx:798 +#: src/components/AppSidebar.tsx:821 msgid "Sign out" msgstr "登出" @@ -1854,7 +1899,7 @@ msgstr "單選" msgid "Slug (URL)" msgstr "網址別名" -#: src/components/article-composer/ArticleComposerContext.tsx:330 +#: src/components/article-composer/ArticleComposerContext.tsx:335 msgid "Slug cannot be empty" msgstr "網址別名不能為空" @@ -1862,9 +1907,9 @@ msgstr "網址別名不能為空" msgid "Something went wrong—please try again." msgstr "發生錯誤,請重試。" -#: src/components/article-composer/ArticleComposerContext.tsx:290 -#: src/components/article-composer/ArticleComposerContext.tsx:363 -#: src/components/article-composer/ArticleComposerContext.tsx:428 +#: src/components/article-composer/ArticleComposerContext.tsx:295 +#: src/components/article-composer/ArticleComposerContext.tsx:368 +#: src/components/article-composer/ArticleComposerContext.tsx:433 #: src/components/NoteComposer.tsx:272 #: src/routes/(root)/[handle]/[idOrYear]/[slug]/edit.tsx:189 #: src/routes/(root)/[handle]/drafts/index.tsx:177 @@ -1879,7 +1924,7 @@ msgstr "成功儲存語言偏好" msgid "Successfully saved preferences" msgstr "設定已成功儲存" -#: src/routes/(root)/[handle]/settings/index.tsx:302 +#: src/routes/(root)/[handle]/settings/index.tsx:324 msgid "Successfully saved settings" msgstr "設定儲存成功" @@ -1954,7 +1999,7 @@ msgstr "邀請連結已成功建立。" msgid "The invitation link has been deleted successfully." msgstr "邀請連結已成功刪除。" -#: src/routes/[...404].tsx:43 +#: src/components/NotFoundPage.tsx:44 msgid "The page you're looking for doesn't exist or has been moved." msgstr "您要找的頁面不存在或已移動。" @@ -1968,11 +2013,11 @@ msgstr "註冊連結無效。請確保你使用的是你收到的正確郵件連 #. placeholder {0}: "GITHUB_REPOSITORY" #. placeholder {1}: "AGPL-3.0" -#: src/components/AppSidebar.tsx:840 +#: src/components/AppSidebar.tsx:863 msgid "The source code of this website is available on {0} under the {1} license." msgstr "可在 {0} 上以 {1} 授權取得該網站的原始碼。" -#: src/routes/(root)/[handle]/settings/index.tsx:558 +#: src/routes/(root)/[handle]/settings/index.tsx:580 msgid "The URL of the link, e.g., https://github.com/yourhandle." msgstr "該連結的 URL,例如 https://github.com/你的使用者名稱 。" @@ -2026,7 +2071,7 @@ msgstr "時間軸" msgid "Title" msgstr "標題" -#: src/components/article-composer/ArticleComposerContext.tsx:258 +#: src/components/article-composer/ArticleComposerContext.tsx:262 msgid "Title cannot be empty" msgstr "標題不能為空" @@ -2118,7 +2163,7 @@ msgstr "從個人資料取消釘選" msgid "Unshare" msgstr "取消轉貼" -#: src/routes/(root)/[handle]/settings/index.tsx:125 +#: src/routes/(root)/[handle]/settings/index.tsx:126 msgid "Update your profile information, including your avatar, username, display name, bio, and links." msgstr "更新您的個人資料資訊,包括頭像、使用者名稱、暱稱、個人簡介和連結。" @@ -2127,7 +2172,7 @@ msgstr "更新您的個人資料資訊,包括頭像、使用者名稱、暱稱 msgid "Updated {0}" msgstr "更新於 {0}" -#: src/routes/(root)/[handle]/settings/index.tsx:540 +#: src/routes/(root)/[handle]/settings/index.tsx:562 msgid "URL" msgstr "URL" @@ -2143,7 +2188,7 @@ msgstr "未找到使用者資訊。" msgid "User unblocked" msgstr "已解除封鎖使用者" -#: src/routes/(root)/[handle]/settings/index.tsx:387 +#: src/routes/(root)/[handle]/settings/index.tsx:409 #: src/routes/(root)/sign/up/[token].tsx:357 msgid "Username" msgstr "使用者名稱" @@ -2178,7 +2223,7 @@ msgstr "已於{1}驗證此連結歸{0}所有" msgid "Verifying your invitation…" msgstr "驗證您的邀請…" -#: src/components/AppSidebar.tsx:761 +#: src/components/AppSidebar.tsx:784 msgid "View all drafts →" msgstr "查看所有草稿 →" @@ -2222,7 +2267,7 @@ msgstr "正在投票…" msgid "We couldn't reach the translation service. Try again, or come back in a few minutes." msgstr "無法連接到翻譯服務。請重試或幾分鐘後再來。" -#: src/routes/(root)/[handle]/settings/index.tsx:521 +#: src/routes/(root)/[handle]/settings/index.tsx:543 msgid "Website" msgstr "網站" @@ -2267,11 +2312,11 @@ msgstr "你已被此使用者封鎖。你無法關注該使用者或查看該使 msgid "You are blocking this user. They can't follow you or see your posts." msgstr "你正在封鎖此使用者。該使用者無法關注你或查看你的內容。" -#: src/routes/(root)/[handle]/settings/index.tsx:403 +#: src/routes/(root)/[handle]/settings/index.tsx:425 msgid "You can change it only once, and the old username will become available to others." msgstr "你只能更改一次使用者名稱,而舊的使用者名稱會公開為別人使用。" -#: src/routes/(root)/[handle]/settings/index.tsx:569 +#: src/routes/(root)/[handle]/settings/index.tsx:591 msgid "You can leave this empty to remove the link." msgstr "您可以將此處留空以刪除連結。" @@ -2315,7 +2360,7 @@ msgstr "請先登入" msgid "You must be signed in to create a note" msgstr "你必須登入才能建立貼文" -#: src/components/article-composer/ArticleComposerContext.tsx:446 +#: src/components/article-composer/ArticleComposerContext.tsx:451 #: src/routes/(root)/[handle]/drafts/index.tsx:195 msgid "You must be signed in to delete a draft" msgstr "您必須登入才能刪除草稿" @@ -2324,11 +2369,11 @@ msgstr "您必須登入才能刪除草稿" msgid "You must be signed in to edit an article" msgstr "您必須登入才能編輯文章" -#: src/components/article-composer/ArticleComposerContext.tsx:381 +#: src/components/article-composer/ArticleComposerContext.tsx:386 msgid "You must be signed in to publish an article" msgstr "您必須登入才能發布文章" -#: src/components/article-composer/ArticleComposerContext.tsx:309 +#: src/components/article-composer/ArticleComposerContext.tsx:314 msgid "You must be signed in to save a draft" msgstr "您必須登入才能儲存草稿" @@ -2344,11 +2389,11 @@ msgstr "註冊後您將自動追蹤對方。" msgid "You've been invited to Hackers' Pub" msgstr "你被邀請加入 Hackers' Pub" -#: src/routes/(root)/[handle]/settings/index.tsx:327 +#: src/routes/(root)/[handle]/settings/index.tsx:349 msgid "Your avatar will be displayed on your profile and in your posts. You can upload a PNG, JPEG, GIF, or WebP image up to 5 MiB in size." msgstr "你的頭像將在你的個人資料頁面和你的貼文中顯示。可以上傳大小不超過 5 MiB 的 PNG、JPEG、GIF 或 WebP 圖片。" -#: src/routes/(root)/[handle]/settings/index.tsx:447 +#: src/routes/(root)/[handle]/settings/index.tsx:469 msgid "Your bio will be displayed on your profile. You can use Markdown to format it." msgstr "你的個人簡介將在你的個人資料頁面顯示。你可以用 Markdown 文件格式化。" @@ -2364,7 +2409,7 @@ msgstr "你的電子郵件地址將用於登入。" msgid "Your friend will see this message in the invitation email." msgstr "你的朋友將在邀請郵件中看到此訊息。" -#: src/routes/(root)/[handle]/settings/index.tsx:433 +#: src/routes/(root)/[handle]/settings/index.tsx:455 #: src/routes/(root)/sign/up/[token].tsx:400 msgid "Your name will be displayed on your profile and in your posts." msgstr "你的暱稱將在你的個人資料頁面和你的貼文中顯示。" @@ -2381,11 +2426,11 @@ msgstr "您的設定已成功更新。" msgid "Your preferred languages have been updated." msgstr "您的偏好語言已更新。" -#: src/routes/(root)/[handle]/settings/index.tsx:303 +#: src/routes/(root)/[handle]/settings/index.tsx:325 msgid "Your profile settings have been updated successfully." msgstr "個人資料設定已成功更新。" -#: src/routes/(root)/[handle]/settings/index.tsx:400 +#: src/routes/(root)/[handle]/settings/index.tsx:422 #: src/routes/(root)/sign/up/[token].tsx:374 msgid "Your username will be used to create your profile URL and your fediverse handle." msgstr "你的使用者名稱將被用來創建你的個人資料 URL 和你的 Fediverse 使用者名稱(handle)。" diff --git a/web-next/src/routes/(root)/admin/__generated__/mediaDeleteOrphanMediaMutation.graphql.ts b/web-next/src/routes/(root)/admin/__generated__/mediaDeleteOrphanMediaMutation.graphql.ts new file mode 100644 index 000000000..8665564f6 --- /dev/null +++ b/web-next/src/routes/(root)/admin/__generated__/mediaDeleteOrphanMediaMutation.graphql.ts @@ -0,0 +1,164 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type mediaDeleteOrphanMediaMutation$variables = Record; +export type mediaDeleteOrphanMediaMutation$data = { + readonly deleteOrphanMedia: { + readonly __typename: "DeleteOrphanMediaPayload"; + readonly deletedCount: number; + readonly failedDiskDeletes: number; + readonly status: { + readonly cutoffDate: string; + readonly orphanMediaCount: number; + }; + } | { + readonly __typename: "NotAuthenticatedError"; + readonly notAuthenticated: string; + } | { + readonly __typename: "NotAuthorizedError"; + readonly notAuthorized: string; + } | { + // This will never be '%other', but we need some + // value in case none of the concrete values match. + readonly __typename: "%other"; + }; +}; +export type mediaDeleteOrphanMediaMutation = { + response: mediaDeleteOrphanMediaMutation$data; + variables: mediaDeleteOrphanMediaMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "alias": null, + "args": null, + "concreteType": null, + "kind": "LinkedField", + "name": "deleteOrphanMedia", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + }, + { + "kind": "InlineFragment", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "deletedCount", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "failedDiskDeletes", + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "OrphanMediaStatus", + "kind": "LinkedField", + "name": "status", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cutoffDate", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "orphanMediaCount", + "storageKey": null + } + ], + "storageKey": null + } + ], + "type": "DeleteOrphanMediaPayload", + "abstractKey": null + }, + { + "kind": "InlineFragment", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "notAuthenticated", + "storageKey": null + } + ], + "type": "NotAuthenticatedError", + "abstractKey": null + }, + { + "kind": "InlineFragment", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "notAuthorized", + "storageKey": null + } + ], + "type": "NotAuthorizedError", + "abstractKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "mediaDeleteOrphanMediaMutation", + "selections": (v0/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "mediaDeleteOrphanMediaMutation", + "selections": (v0/*: any*/) + }, + "params": { + "cacheID": "1364fbb899b298804448ce1a4f889d65", + "id": null, + "metadata": {}, + "name": "mediaDeleteOrphanMediaMutation", + "operationKind": "mutation", + "text": "mutation mediaDeleteOrphanMediaMutation {\n deleteOrphanMedia {\n __typename\n ... on DeleteOrphanMediaPayload {\n deletedCount\n failedDiskDeletes\n status {\n cutoffDate\n orphanMediaCount\n }\n }\n ... on NotAuthenticatedError {\n notAuthenticated\n }\n ... on NotAuthorizedError {\n notAuthorized\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "81a2de26df26cc625848d35c9bd884a5"; + +export default node; diff --git a/web-next/src/routes/(root)/admin/__generated__/mediaPageQuery.graphql.ts b/web-next/src/routes/(root)/admin/__generated__/mediaPageQuery.graphql.ts new file mode 100644 index 000000000..2747a0424 --- /dev/null +++ b/web-next/src/routes/(root)/admin/__generated__/mediaPageQuery.graphql.ts @@ -0,0 +1,125 @@ +/** + * @generated SignedSource<<5e87f5442bb1266c72280e12384e85c8>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type mediaPageQuery$variables = Record; +export type mediaPageQuery$data = { + readonly orphanMediaStatus: { + readonly cutoffDate: string; + readonly orphanMediaCount: number; + } | null | undefined; + readonly viewer: { + readonly moderator: boolean; + } | null | undefined; +}; +export type mediaPageQuery = { + response: mediaPageQuery$data; + variables: mediaPageQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "moderator", + "storageKey": null +}, +v1 = { + "alias": null, + "args": null, + "concreteType": "OrphanMediaStatus", + "kind": "LinkedField", + "name": "orphanMediaStatus", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cutoffDate", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "orphanMediaCount", + "storageKey": null + } + ], + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "mediaPageQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Account", + "kind": "LinkedField", + "name": "viewer", + "plural": false, + "selections": [ + (v0/*: any*/) + ], + "storageKey": null + }, + (v1/*: any*/) + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "mediaPageQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Account", + "kind": "LinkedField", + "name": "viewer", + "plural": false, + "selections": [ + (v0/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + } + ], + "storageKey": null + }, + (v1/*: any*/) + ] + }, + "params": { + "cacheID": "3bba66a6c9dc34af6623b5ba9d445450", + "id": null, + "metadata": {}, + "name": "mediaPageQuery", + "operationKind": "query", + "text": "query mediaPageQuery {\n viewer {\n moderator\n id\n }\n orphanMediaStatus {\n cutoffDate\n orphanMediaCount\n }\n}\n" + } +}; +})(); + +(node as any).hash = "1d406e46be173f2d8925fde32de62d4e"; + +export default node; diff --git a/web-next/src/routes/(root)/admin/media.tsx b/web-next/src/routes/(root)/admin/media.tsx new file mode 100644 index 000000000..ea1222f90 --- /dev/null +++ b/web-next/src/routes/(root)/admin/media.tsx @@ -0,0 +1,204 @@ +import { Navigate, query, revalidate, useNavigate } from "@solidjs/router"; +import { graphql } from "relay-runtime"; +import { createSignal, Show } from "solid-js"; +import { + createMutation, + createPreloadedQuery, + loadQuery, + useRelayEnvironment, +} from "solid-relay"; +import { NarrowContainer } from "~/components/NarrowContainer.tsx"; +import { Timestamp } from "~/components/Timestamp.tsx"; +import { Title } from "~/components/Title.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/card.tsx"; +import { showToast } from "~/components/ui/toast.tsx"; +import { msg, plural, useLingui } from "~/lib/i18n/macro.d.ts"; +import type { mediaDeleteOrphanMediaMutation } from "./__generated__/mediaDeleteOrphanMediaMutation.graphql.ts"; +import type { mediaPageQuery } from "./__generated__/mediaPageQuery.graphql.ts"; + +const mediaPageQuery = graphql` + query mediaPageQuery { + viewer { + moderator + } + orphanMediaStatus { + cutoffDate + orphanMediaCount + } + } +`; + +const loadAdminMediaPageQuery = query( + () => + loadQuery( + useRelayEnvironment()(), + mediaPageQuery, + {}, + { fetchPolicy: "network-only" }, + ), + "loadAdminMediaPageQuery", +); + +export const route = { + preload() { + void loadAdminMediaPageQuery(); + }, +}; + +const mediaDeleteOrphanMediaMutation = graphql` + mutation mediaDeleteOrphanMediaMutation { + deleteOrphanMedia { + __typename + ... on DeleteOrphanMediaPayload { + deletedCount + failedDiskDeletes + status { + cutoffDate + orphanMediaCount + } + } + ... on NotAuthenticatedError { + notAuthenticated + } + ... on NotAuthorizedError { + notAuthorized + } + } + } +`; + +export default function AdminMediaPage() { + const { i18n, t } = useLingui(); + const navigate = useNavigate(); + const data = createPreloadedQuery( + mediaPageQuery, + () => loadAdminMediaPageQuery(), + ); + const [deleteOrphans] = createMutation( + mediaDeleteOrphanMediaMutation, + ); + const [submitting, setSubmitting] = createSignal(false); + + function onDeleteOrphans() { + setSubmitting(true); + deleteOrphans({ + variables: {}, + onCompleted(response) { + setSubmitting(false); + const result = response.deleteOrphanMedia; + if (result.__typename === "DeleteOrphanMediaPayload") { + showToast({ + title: i18n._( + msg`${ + plural(result.deletedCount!, { + one: "Deleted # orphan medium.", + other: "Deleted # orphan media.", + }) + }`, + ), + description: result.failedDiskDeletes! > 0 + ? i18n._( + msg`${ + plural(result.failedDiskDeletes!, { + one: "# disk object could not be deleted.", + other: "# disk objects could not be deleted.", + }) + }`, + ) + : undefined, + variant: result.failedDiskDeletes! > 0 ? "error" : undefined, + }); + void revalidate("loadAdminMediaPageQuery"); + } else if (result.__typename === "NotAuthenticatedError") { + navigate("/sign?next=%2Fadmin%2Fmedia", { replace: true }); + } else { + showToast({ + title: t`Not authorized to delete orphan media.`, + variant: "error", + }); + } + }, + onError(error) { + setSubmitting(false); + console.error(error); + showToast({ + title: t`Failed to delete orphan media.`, + description: import.meta.env.DEV ? error.message : undefined, + variant: "error", + }); + }, + }); + } + + return ( + + {t`Hackers' Pub: Admin · Media`} + + {(data) => ( + } + > + {(_) => { + const status = () => data.orphanMediaStatus; + const count = () => status()?.orphanMediaCount ?? 0; + return ( + <> +

+ {t`Media`} +

+ + + {t`Delete orphan media`} + + {t`Removes stored media that are old enough and no longer attached to an avatar, note, article draft, or article.`} + + + +

+ + {t`Cutoff:`} + {" "} + + {(ts) => } + +

+

+ {i18n._( + msg`${ + plural(count(), { + one: "# orphan medium can be deleted.", + other: "# orphan media can be deleted.", + }) + }`, + )} +

+
+ + + +
+ + ); + }} +
+ )} +
+
+ ); +} diff --git a/web/components/AdminNav.tsx b/web/components/AdminNav.tsx index 3d6530ab0..b95b9679e 100644 --- a/web/components/AdminNav.tsx +++ b/web/components/AdminNav.tsx @@ -1,6 +1,6 @@ import { Tab, TabNav } from "./TabNav.tsx"; -export type AdminNavItem = "accounts" | "invitations"; +export type AdminNavItem = "accounts" | "invitations" | "media"; export interface AdminNavProps { active: AdminNavItem; @@ -15,6 +15,9 @@ export function AdminNav({ active }: AdminNavProps) { Invitations + + Media + ); } diff --git a/web/routes/admin/media.tsx b/web/routes/admin/media.tsx new file mode 100644 index 000000000..5e88fa954 --- /dev/null +++ b/web/routes/admin/media.tsx @@ -0,0 +1,82 @@ +import { page } from "@fresh/core"; +import { + deleteOrphanMedia, + getOrphanMediaStatus, +} from "@hackerspub/models/admin"; +import { AdminNav } from "../../components/AdminNav.tsx"; +import { Button } from "../../components/Button.tsx"; +import { db } from "../../db.ts"; +import { drive } from "../../drive.ts"; +import { define } from "../../utils.ts"; + +export const handler = define.handlers({ + async GET(_ctx) { + return page({ + status: await getOrphanMediaStatus(db), + }); + }, + + async POST(_ctx) { + const result = await deleteOrphanMedia(db, drive.use()); + return page({ + status: await getOrphanMediaStatus(db), + deletedCount: result.deletedCount, + failedDiskDeletes: result.failedDiskDeletes, + }); + }, +}); + +interface MediaMaintenanceProps { + status: { + cutoffDate: Date; + orphanMediaCount: number; + }; + deletedCount?: number; + failedDiskDeletes?: number; +} + +export default define.page( + function MediaMaintenance({ state: { language }, data }) { + const { status, deletedCount, failedDiskDeletes } = data; + + return ( +
+ + +
+

+ This removes media created before{" "} + {status.cutoffDate.toLocaleString(language)}{" "} + that are not attached to an avatar, note, article draft, or article. +

+ + {deletedCount != null && ( +
+

+ Deleted {deletedCount.toLocaleString(language)} orphan media. +

+ {failedDiskDeletes != null && failedDiskDeletes > 0 && ( +

+ Failed to delete {failedDiskDeletes.toLocaleString(language)} + {" "} + disk objects. +

+ )} +
+ )} + +

+ {status.orphanMediaCount.toLocaleString(language)}{" "} + orphan media can be deleted. +

+ +
+ +
+
+
+ ); + }, +); From 301d8f02ef265370e83d739f822d9cb8ddbeda31 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 21:13:01 +0900 Subject: [PATCH 13/32] Harden medium upload validation Bound proxy and remote image reads while streaming, return validation failures for corrupt image bytes, and stop reporting successful uploads as invalid when temporary cleanup fails. https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195237489 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195237495 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195237526 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195237542 Assisted-by: Codex:gpt-5.5 --- graphql/medium-upload.test.ts | 39 +++++++++++++++++++ graphql/medium-upload.ts | 33 ++++++++++++++++- graphql/post.ts | 47 ++++++++++++++++------- models/medium.test.ts | 43 +++++++++++++++++++++ models/medium.ts | 70 ++++++++++++++++++++++++++--------- 5 files changed, 201 insertions(+), 31 deletions(-) diff --git a/graphql/medium-upload.test.ts b/graphql/medium-upload.test.ts index dc7be9a9b..a00056425 100644 --- a/graphql/medium-upload.test.ts +++ b/graphql/medium-upload.test.ts @@ -72,3 +72,42 @@ test("handleMediumUploadProxy accepts exact content length", async () => { assert.equal(response.status, 204); assert.deepEqual(await disk.getBytes(session.key), bytes); }); + +test("handleMediumUploadProxy stops reading when body exceeds session length", async () => { + const { kv } = createTestKv(); + const disk = createTestDisk(); + const accountId = crypto.randomUUID() as Uuid; + const session = await createMediumUploadSession( + kv, + accountId, + "image/png", + 4, + ); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3, 4])); + controller.enqueue(new Uint8Array([5])); + controller.close(); + }, + }); + + const response = await handleMediumUploadProxy( + new Request( + `http://localhost/medium-uploads/${session.id}?token=${session.token}`, + { + method: "PUT", + headers: { + "Content-Type": "image/png", + "Content-Length": "4", + }, + body, + }, + ), + kv, + disk, + ); + + assert.ok(response != null); + assert.equal(response.status, 413); + assert.throws(() => disk.getBytes(session.key)); +}); diff --git a/graphql/medium-upload.ts b/graphql/medium-upload.ts index 91de00fcb..913271299 100644 --- a/graphql/medium-upload.ts +++ b/graphql/medium-upload.ts @@ -63,6 +63,33 @@ export async function deleteMediumUploadSession( await kv.delete(getMediumUploadSessionKey(id)); } +async function readRequestBody( + request: Request, + maxSize: number, +): Promise { + const reader = request.body?.getReader(); + if (reader == null) return undefined; + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxSize) { + await reader.cancel(); + return undefined; + } + chunks.push(value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + export async function handleMediumUploadProxy( request: Request, kv: Keyv, @@ -109,8 +136,12 @@ export async function handleMediumUploadProxy( ) { return new Response("Payload Too Large", { status: 413 }); } - const bytes = new Uint8Array(await request.arrayBuffer()); + const bytes = await readRequestBody( + request, + Math.min(session.contentLength, MAX_STREAMING_MEDIUM_IMAGE_SIZE), + ); if ( + bytes == null || bytes.byteLength !== session.contentLength || bytes.byteLength > MAX_STREAMING_MEDIUM_IMAGE_SIZE ) { diff --git a/graphql/post.ts b/graphql/post.ts index a26a8c9a0..19d64dfc0 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -1,3 +1,4 @@ +import { getLogger } from "@logtape/logtape"; import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; import { unreachable } from "@std/assert"; import { assertNever } from "@std/assert/unstable-never"; @@ -28,6 +29,7 @@ import { createMediumFromUrl, MAX_STREAMING_MEDIUM_IMAGE_SIZE, SUPPORTED_MEDIUM_IMAGE_TYPES, + UnsafeMediumUrlError, } from "@hackerspub/models/medium"; import { createNote } from "@hackerspub/models/note"; import { @@ -69,6 +71,7 @@ import { Reactable, Reaction } from "./reactable.ts"; import { NotAuthenticatedError } from "./session.ts"; const articleContentOgImageComplexity = 2_000; +const logger = getLogger(["hackerspub", "graphql", "post"]); class SharedPostDeletionNotAllowedError extends Error { public constructor(public readonly inputPath: string) { @@ -2066,18 +2069,21 @@ builder.relayMutationField( if (session == null) { throw new NotAuthenticatedError(); } + let medium: schema.Medium | undefined; try { - const medium = await createMediumFromUrl( + medium = await createMediumFromUrl( ctx.db, ctx.disk, args.input.url, { userAgentUrl: new URL(ctx.fedCtx.canonicalOrigin) }, ); - if (medium == null) throw new InvalidInputError("url"); - return medium; - } catch { + } catch (error) { + if (!(error instanceof UnsafeMediumUrlError)) throw error; + } + if (medium == null) { throw new InvalidInputError("url"); } + return medium; }, }, { @@ -2200,19 +2206,34 @@ builder.relayMutationField( if (upload == null || upload.accountId !== session.accountId) { throw new InvalidInputError("uploadId"); } + let bytes: Uint8Array; try { - const bytes = await ctx.disk.getBytes(upload.key); - const medium = await createMediumFromBytes(ctx.db, ctx.disk, bytes, { - maxSize: MAX_STREAMING_MEDIUM_IMAGE_SIZE, - contentType: upload.contentType, - }); - if (medium == null) throw new InvalidInputError("uploadId"); - await ctx.disk.delete(upload.key); - await deleteMediumUploadSession(ctx.kv, upload.id); - return medium; + bytes = await ctx.disk.getBytes(upload.key); } catch { throw new InvalidInputError("uploadId"); } + const medium = await createMediumFromBytes(ctx.db, ctx.disk, bytes, { + maxSize: MAX_STREAMING_MEDIUM_IMAGE_SIZE, + contentType: upload.contentType, + }); + if (medium == null) throw new InvalidInputError("uploadId"); + try { + await ctx.disk.delete(upload.key); + } catch (error) { + logger.warn("Failed to delete temporary medium upload {key}: {error}", { + key: upload.key, + error, + }); + } + try { + await deleteMediumUploadSession(ctx.kv, upload.id); + } catch (error) { + logger.warn("Failed to delete medium upload session {id}: {error}", { + id: upload.id, + error, + }); + } + return medium; }, }, { diff --git a/models/medium.test.ts b/models/medium.test.ts index ed2897e03..e46f63a33 100644 --- a/models/medium.test.ts +++ b/models/medium.test.ts @@ -54,6 +54,17 @@ test("createMediumFromBytes() stores webp media once by content hash", async () }); }); +test("createMediumFromBytes() rejects corrupt image bytes", async () => { + const medium = await createMediumFromBytes( + undefined as never, + undefined as never, + new Uint8Array([1, 2, 3, 4]), + { contentType: "image/png" }, + ); + + assert.equal(medium, undefined); +}); + test("createMediumFromUrl() rejects redirects to unsafe network targets", async () => { await withRollback(async (tx) => { const disk = { @@ -82,6 +93,38 @@ test("createMediumFromUrl() rejects redirects to unsafe network targets", async }); }); +test("createMediumFromUrl() stops reading remote bodies over the size limit", async () => { + const disk = { + put() { + throw new Error("oversized media should not be stored"); + }, + }; + await withMockFetch((_input) => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3, 4])); + controller.enqueue(new Uint8Array([5])); + controller.close(); + }, + }); + return Promise.resolve( + new Response(body, { + status: 200, + headers: { "Content-Type": "image/png" }, + }), + ); + }, async () => { + const medium = await createMediumFromUrl( + undefined as never, + disk as never, + new URL("https://example.com/image.png"), + { maxSize: 4 }, + ); + + assert.equal(medium, undefined); + }); +}); + test("persistPostMedium() stores image attachments and infers media type from content-type", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); diff --git a/models/medium.ts b/models/medium.ts index f8cba8cf4..52240ab7b 100644 --- a/models/medium.ts +++ b/models/medium.ts @@ -111,6 +111,33 @@ async function sha256Hex(data: Uint8Array): Promise { .join(""); } +async function readResponseBytes( + response: Response, + maxSize: number, +): Promise { + const reader = response.body?.getReader(); + if (reader == null) return undefined; + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxSize) { + await reader.cancel(); + return undefined; + } + chunks.push(value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + export async function createMediumFromBytes( db: Database, disk: Disk, @@ -132,22 +159,31 @@ export async function createMediumFromBytes( ) { return undefined; } - if (options.preprocess != null) { - const processed = await options.preprocess(input); - input = processed.bytes; - contentType = processed.contentType ?? contentType; - if (input.byteLength > (options.maxSize ?? MAX_MEDIUM_IMAGE_SIZE)) { - return undefined; - } - if (contentType != null && !isSupportedMediumImageType(contentType)) { - return undefined; + let data: Uint8Array; + let width: number | undefined; + let height: number | undefined; + try { + if (options.preprocess != null) { + const processed = await options.preprocess(input); + input = processed.bytes; + contentType = processed.contentType ?? contentType; + if (input.byteLength > (options.maxSize ?? MAX_MEDIUM_IMAGE_SIZE)) { + return undefined; + } + if (contentType != null && !isSupportedMediumImageType(contentType)) { + return undefined; + } } + const result = await sharp(input, { animated: true }) + .rotate() + .webp() + .toBuffer({ resolveWithObject: true }); + data = result.data; + width = result.info.width; + height = result.info.height; + } catch { + return undefined; } - const { data, info } = await sharp(input, { animated: true }) - .rotate() - .webp() - .toBuffer({ resolveWithObject: true }); - const { width, height } = info; if (width == null || height == null) return undefined; const contentHash = await sha256Hex(new Uint8Array(data)); const existing = await db.query.mediumTable.findFirst({ @@ -217,9 +253,9 @@ export async function createMediumFromUrl( if (contentLength != null && Number(contentLength) > maxSize) { return undefined; } - const blob = await response.blob(); - if (blob.size > maxSize) return undefined; - return await createMediumFromBytes(db, disk, await blob.arrayBuffer(), { + const bytes = await readResponseBytes(response, maxSize); + if (bytes == null) return undefined; + return await createMediumFromBytes(db, disk, bytes, { maxSize, contentType, preprocess: options.preprocess, From b8de8a4c2e70d0dd7d2821d7e432a5c30168c247 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 21:13:14 +0900 Subject: [PATCH 14/32] Tighten media relation handling Make migrated article media selection deterministic, keep orphan media rows retryable when disk deletion fails, and avoid silent partial success when note or article media references do not match exactly. https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195237472 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195237508 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195237512 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195237550 Assisted-by: Codex:gpt-5.5 --- drizzle/0098_unified_medium.sql | 1 + models/admin.test.ts | 45 +++++++++++++++++++++++++++++++- models/admin.ts | 18 ++++++++++--- models/article.lifecycle.test.ts | 17 +++++++++++- models/article.ts | 6 ++++- models/note.lifecycle.test.ts | 26 ++++++++++++++++++ models/note.ts | 3 ++- 7 files changed, 108 insertions(+), 8 deletions(-) diff --git a/drizzle/0098_unified_medium.sql b/drizzle/0098_unified_medium.sql index c5ffac2cc..4a4750ed8 100644 --- a/drizzle/0098_unified_medium.sql +++ b/drizzle/0098_unified_medium.sql @@ -143,6 +143,7 @@ SELECT DISTINCT ON ("key") "height"::integer, "created" FROM "article_medium" +ORDER BY "key", "created" DESC ON CONFLICT ("key") DO UPDATE SET "content_hash" = COALESCE("medium"."content_hash", EXCLUDED."content_hash"), "width" = COALESCE("medium"."width", EXCLUDED."width"::integer), diff --git a/models/admin.test.ts b/models/admin.test.ts index 5a3f630b9..b44ff8791 100644 --- a/models/admin.test.ts +++ b/models/admin.test.ts @@ -28,13 +28,14 @@ import { } from "./schema.ts"; import { generateUuidV7, type Uuid } from "./uuid.ts"; -function createTrackingDisk() { +function createTrackingDisk(failingKeys = new Set()) { const deleteKeys: string[] = []; return { deleteKeys, disk: { delete(key: string) { deleteKeys.push(key); + if (failingKeys.has(key)) return Promise.reject(new Error("failed")); return Promise.resolve(undefined); }, } as unknown as Parameters[1], @@ -430,6 +431,48 @@ Deno.test({ }, }); +Deno.test({ + name: "deleteOrphanMedia keeps rows whose disk object failed to delete", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + await withRollback(async (tx) => { + const now = new Date("2026-04-15T00:00:00.000Z"); + const old = new Date("2026-04-13T00:00:00.000Z"); + const failedId = await insertTestMedium( + tx, + "media/orphan-delete-fail.webp", + old, + ); + const deletedId = await insertTestMedium( + tx, + "media/orphan-delete-ok.webp", + old, + ); + const disk = createTrackingDisk( + new Set(["media/orphan-delete-fail.webp"]), + ); + + const result = await deleteOrphanMedia(tx, disk.disk, { now }); + + assertEquals(result.deletedCount, 1); + assertEquals(result.failedDiskDeletes, 1); + assertEquals(disk.deleteKeys.toSorted(), [ + "media/orphan-delete-fail.webp", + "media/orphan-delete-ok.webp", + ]); + assert( + await tx.query.mediumTable.findFirst({ where: { id: failedId } }) != + null, + ); + assertEquals( + await tx.query.mediumTable.findFirst({ where: { id: deletedId } }), + undefined, + ); + }); + }, +}); + Deno.test({ name: "regenerateInvitations falls back to one-week cutoff when KV key absent", diff --git a/models/admin.ts b/models/admin.ts index 9a4eb83d1..ec5e1716c 100644 --- a/models/admin.ts +++ b/models/admin.ts @@ -305,15 +305,18 @@ export async function deleteOrphanMedia( options: OrphanMediaOptions = {}, ): Promise { const cutoffDate = resolveOrphanMediaCutoff(options); - const deleted = await db - .delete(mediumTable) + const orphanMedia = await db + .select({ key: mediumTable.key }) + .from(mediumTable) .where(orphanMediaWhere(cutoffDate)) - .returning({ key: mediumTable.key }); + .orderBy(mediumTable.created); let failedDiskDeletes = 0; - for (const { key } of deleted) { + const deletedKeys: string[] = []; + for (const { key } of orphanMedia) { try { await disk.delete(key); + deletedKeys.push(key); } catch (error) { failedDiskDeletes++; logger.warn( @@ -322,6 +325,13 @@ export async function deleteOrphanMedia( ); } } + const deleted = deletedKeys.length < 1 ? [] : await db + .delete(mediumTable) + .where(and( + inArray(mediumTable.key, deletedKeys), + orphanMediaWhere(cutoffDate), + )) + .returning({ key: mediumTable.key }); return { cutoffDate, diff --git a/models/article.lifecycle.test.ts b/models/article.lifecycle.test.ts index 858719477..33e929845 100644 --- a/models/article.lifecycle.test.ts +++ b/models/article.lifecycle.test.ts @@ -74,6 +74,14 @@ test("createArticle() copies source media before rendering the post", async () = width: 2, height: 2, }); + const prefixMediumId = generateUuidV7(); + await tx.insert(mediumTable).values({ + id: prefixMediumId, + key: "media/create-article-prefix.webp", + type: "image/webp", + width: 2, + height: 2, + }); const article = await createArticle(fedCtx, { accountId: author.account.id, @@ -84,7 +92,10 @@ test("createArticle() copies source media before rendering the post", async () = title: "Article with media", content: "![Hero](hp-medium:hero)", language: "en", - media: [{ key: "hero", mediumId }], + media: [ + { key: "hero", mediumId }, + { key: "her", mediumId: prefixMediumId }, + ], }); assert.ok(article != null); @@ -99,6 +110,10 @@ test("createArticle() copies source media before rendering the post", async () = }); assert.ok(media != null); assert.equal(media.mediumId, mediumId); + const prefixMedia = await tx.query.articleSourceMediumTable.findFirst({ + where: { articleSourceId: article.articleSource.id, key: "her" }, + }); + assert.equal(prefixMedia, undefined); }); }); diff --git a/models/article.ts b/models/article.ts index aa2fa5176..d49a6c7de 100644 --- a/models/article.ts +++ b/models/article.ts @@ -291,6 +291,10 @@ export async function createArticle( > { const { db } = fedCtx.data; const { media: sourceMedia, ...articleSourceInput } = source; + const referencedMediumKeys = new Set( + [...source.content.matchAll(/hp-medium:([A-Za-z0-9._:/-]+)/g)] + .map((match) => match[1]), + ); const articleSource = await createArticleSource( db, fedCtx.data.models, @@ -298,7 +302,7 @@ export async function createArticle( ); if (articleSource == null) return undefined; const media = sourceMedia - ?.filter((medium) => source.content.includes(`hp-medium:${medium.key}`)) + ?.filter((medium) => referencedMediumKeys.has(medium.key)) .map((medium) => ({ articleSourceId: articleSource.id, key: medium.key, diff --git a/models/note.lifecycle.test.ts b/models/note.lifecycle.test.ts index 6ef502f86..563b47a38 100644 --- a/models/note.lifecycle.test.ts +++ b/models/note.lifecycle.test.ts @@ -99,6 +99,32 @@ test("createNote() allows the same medium at multiple indexes", async () => { }); }); +test("createNote() fails when a requested medium cannot be attached", async () => { + await withRollback(async (tx) => { + const fedCtx = createFedCtx(tx); + const author = await insertAccountWithActor(tx, { + username: "missingnotemedia", + name: "Missing Note Media", + email: "missingnotemedia@example.com", + }); + + const note = await createNote( + fedCtx as unknown as Context>, + { + accountId: author.account.id, + visibility: "public", + content: "Missing image", + language: "en", + media: [ + { mediumId: generateUuidV7(), alt: "Missing medium" }, + ], + }, + ); + + assert.equal(note, undefined); + }); +}); + test("createNote() stores tags relayed to tags.pub only for public posts", async () => { await withTagsPubRelayEnabled(async () => { await withRollback(async (tx) => { diff --git a/models/note.ts b/models/note.ts index 4a2c5d6f7..a5e7d2e45 100644 --- a/models/note.ts +++ b/models/note.ts @@ -334,7 +334,8 @@ export async function createNote( index, medium, ); - if (m != null) media.push(m); + if (m == null) return undefined; + media.push(m); index++; } const account = await db.query.accountTable.findFirst({ From 0f9256d8d344c7a91210fcfb3fa812517f12ebd7 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 21:13:24 +0900 Subject: [PATCH 15/32] Polish media admin surfaces Expose avatarMediumId on Account, keep signed-in non-moderators out of the sign-in loop, and add glossary terms for the new orphan media cleanup strings. https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195237498 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195237558 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195237567 Assisted-by: Codex:gpt-5.5 --- graphql/account.test.ts | 2 ++ graphql/account.ts | 5 +++++ graphql/schema.graphql | 3 +++ web-next/src/locales/en-US/glossary.txt | 2 ++ web-next/src/locales/ja-JP/glossary.txt | 2 ++ web-next/src/locales/ko-KR/glossary.txt | 2 ++ web-next/src/locales/zh-CN/glossary.txt | 2 ++ web-next/src/locales/zh-TW/glossary.txt | 2 ++ web-next/src/routes/(root)/admin/media.tsx | 4 +++- 9 files changed, 23 insertions(+), 1 deletion(-) diff --git a/graphql/account.test.ts b/graphql/account.test.ts index 26c1ad3d6..373b74727 100644 --- a/graphql/account.test.ts +++ b/graphql/account.test.ts @@ -27,6 +27,7 @@ const viewerQuery = parse(` username name handle + avatarMediumId } } `); @@ -154,6 +155,7 @@ test("viewer returns the signed-in account and null for guests", async () => { username: "viewerquery", name: "Viewer Query", handle: "@viewerquery@localhost", + avatarMediumId: null, }, }, ); diff --git a/graphql/account.ts b/graphql/account.ts index 0a3690942..172acc91f 100644 --- a/graphql/account.ts +++ b/graphql/account.ts @@ -66,6 +66,11 @@ export const Account = builder.drizzleNode("accountTable", { }), name: t.exposeString("name"), bio: t.expose("bio", { type: "Markdown" }), + avatarMediumId: t.expose("avatarMediumId", { + type: "UUID", + nullable: true, + description: "UUID of the medium used as this account's avatar.", + }), avatarUrl: t.field({ type: "URL", select: { diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 491337e7c..182a15da4 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -1,6 +1,9 @@ type Account implements Node { actor: Actor! articleDrafts(after: String, before: String, first: Int, last: Int): AccountArticleDraftsConnection! + + """UUID of the medium used as this account's avatar.""" + avatarMediumId: UUID avatarUrl: URL! bio: Markdown! created: DateTime! diff --git a/web-next/src/locales/en-US/glossary.txt b/web-next/src/locales/en-US/glossary.txt index dbde7e4d8..425a953be 100644 --- a/web-next/src/locales/en-US/glossary.txt +++ b/web-next/src/locales/en-US/glossary.txt @@ -28,6 +28,8 @@ handle → handle invitation → invitation invitationLink → invitation link medium → medium +disk object → disk object +orphan media → orphan media preferences → preferences notification → notification moderator → moderator diff --git a/web-next/src/locales/ja-JP/glossary.txt b/web-next/src/locales/ja-JP/glossary.txt index d14a83583..0c9a7a432 100644 --- a/web-next/src/locales/ja-JP/glossary.txt +++ b/web-next/src/locales/ja-JP/glossary.txt @@ -28,6 +28,8 @@ handle → ハンドル invitation → 招待 invitationLink → 招待リンク medium → メディア +disk object → ディスクオブジェクト +orphan media → 孤立したメディア preferences → 環境設定 notification → 通知 moderator → モデレーター diff --git a/web-next/src/locales/ko-KR/glossary.txt b/web-next/src/locales/ko-KR/glossary.txt index 1f851905d..91dfe3576 100644 --- a/web-next/src/locales/ko-KR/glossary.txt +++ b/web-next/src/locales/ko-KR/glossary.txt @@ -28,6 +28,8 @@ handle → 핸들 invitation → 초대 invitationLink → 초대 링크 medium → 미디어 +disk object → 디스크 객체 +orphan media → 연결되지 않은 미디어 preferences → 환경 설정 notification → 알림 moderator → 모더레이터 diff --git a/web-next/src/locales/zh-CN/glossary.txt b/web-next/src/locales/zh-CN/glossary.txt index 5070659a9..8828b72e7 100644 --- a/web-next/src/locales/zh-CN/glossary.txt +++ b/web-next/src/locales/zh-CN/glossary.txt @@ -28,6 +28,8 @@ handle → handle invitation → 邀请 invitationLink → 邀请链接 medium → 媒体 +disk object → 磁盘对象 +orphan media → 孤立媒体 preferences → 偏好设置 notification → 通知 moderator → 版主 diff --git a/web-next/src/locales/zh-TW/glossary.txt b/web-next/src/locales/zh-TW/glossary.txt index 5e475f4fc..2b78afbf4 100644 --- a/web-next/src/locales/zh-TW/glossary.txt +++ b/web-next/src/locales/zh-TW/glossary.txt @@ -28,6 +28,8 @@ handle → 識別碼 invitation → 邀請 invitationLink → 邀請連結 medium → 媒體 +disk object → 磁碟物件 +orphan media → 孤立媒體 preferences → 偏好設定 notification → 通知 moderator → 版主 diff --git a/web-next/src/routes/(root)/admin/media.tsx b/web-next/src/routes/(root)/admin/media.tsx index ea1222f90..bd4d8db73 100644 --- a/web-next/src/routes/(root)/admin/media.tsx +++ b/web-next/src/routes/(root)/admin/media.tsx @@ -146,7 +146,9 @@ export default function AdminMediaPage() { } + fallback={data.viewer == null + ? + : } > {(_) => { const status = () => data.orphanMediaStatus; From a687bc3989627c24944ab5d4b79d788d5ff1dc99 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 21:13:31 +0900 Subject: [PATCH 16/32] Guard legacy profile settings posts Require the route account to match the active session before applying legacy profile settings updates. https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195237572 Assisted-by: Codex:gpt-5.5 --- web/routes/@[username]/settings/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/routes/@[username]/settings/index.tsx b/web/routes/@[username]/settings/index.tsx index c48e523f2..93e4fcd4f 100644 --- a/web/routes/@[username]/settings/index.tsx +++ b/web/routes/@[username]/settings/index.tsx @@ -62,7 +62,13 @@ export const handler = define.handlers({ where: { username: ctx.params.username }, with: { avatarMedium: true, emails: true, links: true }, }); - if (account == null) return ctx.next(); + if ( + account == null || + ctx.state.session == null || + account.id !== ctx.state.session.accountId + ) { + return ctx.next(); + } const form = await ctx.req.formData(); const avatar = form.get("avatar"); const username = form.get("username")?.toString()?.trim()?.toLowerCase(); From 98b64788054707ad80d9b949d813c8ed9bb68fd8 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 21:47:11 +0900 Subject: [PATCH 17/32] Index medium relations Add b-tree indexes for the new medium foreign keys so reverse lookups and orphan media cleanup do not have to scan relation tables. Keep the Drizzle schema and snapshots in sync with the migration. Assisted-by: Codex:gpt-5.5 --- drizzle/0098_unified_medium.sql | 12 ++++++ drizzle/meta/0098_snapshot.json | 72 +++++++++++++++++++++++++++++++-- drizzle/meta/0099_snapshot.json | 72 +++++++++++++++++++++++++++++++-- models/schema.ts | 4 ++ 4 files changed, 152 insertions(+), 8 deletions(-) diff --git a/drizzle/0098_unified_medium.sql b/drizzle/0098_unified_medium.sql index 4a4750ed8..ceca2c2b4 100644 --- a/drizzle/0098_unified_medium.sql +++ b/drizzle/0098_unified_medium.sql @@ -57,8 +57,20 @@ CREATE TABLE IF NOT EXISTS "article_source_medium" ( PRIMARY KEY("article_source_id","key") ); --> statement-breakpoint +CREATE INDEX IF NOT EXISTS "note_source_medium_medium_id_idx" +ON "note_source_medium" ("medium_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "article_draft_medium_medium_id_idx" +ON "article_draft_medium" ("medium_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "article_source_medium_medium_id_idx" +ON "article_source_medium" ("medium_id"); +--> statement-breakpoint ALTER TABLE "account" ADD COLUMN "avatar_medium_id" uuid; --> statement-breakpoint +CREATE INDEX IF NOT EXISTS "account_avatar_medium_id_idx" +ON "account" ("avatar_medium_id"); +--> statement-breakpoint DO $$ BEGIN ALTER TABLE "account" ADD CONSTRAINT "account_avatar_medium_id_medium_id_fk" FOREIGN KEY ("avatar_medium_id") REFERENCES "public"."medium"("id") diff --git a/drizzle/meta/0098_snapshot.json b/drizzle/meta/0098_snapshot.json index 7c2127e5e..473c711f6 100644 --- a/drizzle/meta/0098_snapshot.json +++ b/drizzle/meta/0098_snapshot.json @@ -381,7 +381,23 @@ "default": "CURRENT_TIMESTAMP" } }, - "indexes": {}, + "indexes": { + "account_avatar_medium_id_idx": { + "name": "account_avatar_medium_id_idx", + "columns": [ + { + "expression": "avatar_medium_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "account_avatar_medium_id_medium_id_fk": { "name": "account_avatar_medium_id_medium_id_fk", @@ -1038,7 +1054,23 @@ "default": "CURRENT_TIMESTAMP" } }, - "indexes": {}, + "indexes": { + "article_draft_medium_medium_id_idx": { + "name": "article_draft_medium_medium_id_idx", + "columns": [ + { + "expression": "medium_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "article_draft_medium_article_draft_id_article_draft_id_fk": { "name": "article_draft_medium_article_draft_id_article_draft_id_fk", @@ -1202,7 +1234,23 @@ "default": "CURRENT_TIMESTAMP" } }, - "indexes": {}, + "indexes": { + "article_source_medium_medium_id_idx": { + "name": "article_source_medium_medium_id_idx", + "columns": [ + { + "expression": "medium_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "article_source_medium_article_source_id_article_source_id_fk": { "name": "article_source_medium_article_source_id_article_source_id_fk", @@ -2074,7 +2122,23 @@ "notNull": true } }, - "indexes": {}, + "indexes": { + "note_source_medium_medium_id_idx": { + "name": "note_source_medium_medium_id_idx", + "columns": [ + { + "expression": "medium_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "note_source_medium_note_source_id_note_source_id_fk": { "name": "note_source_medium_note_source_id_note_source_id_fk", diff --git a/drizzle/meta/0099_snapshot.json b/drizzle/meta/0099_snapshot.json index ea9419feb..702648cb0 100644 --- a/drizzle/meta/0099_snapshot.json +++ b/drizzle/meta/0099_snapshot.json @@ -381,7 +381,23 @@ "default": "CURRENT_TIMESTAMP" } }, - "indexes": {}, + "indexes": { + "account_avatar_medium_id_idx": { + "name": "account_avatar_medium_id_idx", + "columns": [ + { + "expression": "avatar_medium_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "account_avatar_medium_id_medium_id_fk": { "name": "account_avatar_medium_id_medium_id_fk", @@ -1038,7 +1054,23 @@ "default": "CURRENT_TIMESTAMP" } }, - "indexes": {}, + "indexes": { + "article_draft_medium_medium_id_idx": { + "name": "article_draft_medium_medium_id_idx", + "columns": [ + { + "expression": "medium_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "article_draft_medium_article_draft_id_article_draft_id_fk": { "name": "article_draft_medium_article_draft_id_article_draft_id_fk", @@ -1202,7 +1234,23 @@ "default": "CURRENT_TIMESTAMP" } }, - "indexes": {}, + "indexes": { + "article_source_medium_medium_id_idx": { + "name": "article_source_medium_medium_id_idx", + "columns": [ + { + "expression": "medium_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "article_source_medium_article_source_id_article_source_id_fk": { "name": "article_source_medium_article_source_id_article_source_id_fk", @@ -2074,7 +2122,23 @@ "notNull": true } }, - "indexes": {}, + "indexes": { + "note_source_medium_medium_id_idx": { + "name": "note_source_medium_medium_id_idx", + "columns": [ + { + "expression": "medium_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "note_source_medium_note_source_id_note_source_id_fk": { "name": "note_source_medium_note_source_id_note_source_id_fk", diff --git a/models/schema.ts b/models/schema.ts index 23ab72ece..1dae63f58 100644 --- a/models/schema.ts +++ b/models/schema.ts @@ -82,6 +82,7 @@ export const accountTable = pgTable( .default(currentTimestamp), }, (table) => [ + index("account_avatar_medium_id_idx").on(table.avatarMediumId), check( "account_username_check", sql`${table.username} ~ '^[a-z0-9_]{1,50}$'`, @@ -627,6 +628,7 @@ export const noteSourceMediumTable = pgTable( }, (table) => [ primaryKey({ columns: [table.sourceId, table.index] }), + index("note_source_medium_medium_id_idx").on(table.mediumId), check("note_source_medium_index_check", sql`${table.index} >= 0`), ], ); @@ -1322,6 +1324,7 @@ export const articleDraftMediumTable = pgTable( }, (table) => [ primaryKey({ columns: [table.articleDraftId, table.key] }), + index("article_draft_medium_medium_id_idx").on(table.mediumId), ], ); @@ -1346,6 +1349,7 @@ export const articleSourceMediumTable = pgTable( }, (table) => [ primaryKey({ columns: [table.articleSourceId, table.key] }), + index("article_source_medium_medium_id_idx").on(table.mediumId), ], ); From ea04c8c08b4cfd7afcee90d4059af90ffbc8464b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 21:47:28 +0900 Subject: [PATCH 18/32] Polish media GraphQL API Deprecate legacy avatar and invitation timestamp aliases while keeping old fields available for clients. Rename the orphan-media deletion failure count to a storage-neutral GraphQL field and update Relay, tests, and extracted translations. Assisted-by: Codex:gpt-5.5 --- graphql/account.ts | 1 + graphql/admin.test.ts | 6 ++--- graphql/admin.ts | 22 ++++++++++++++++--- graphql/schema.graphql | 14 +++++++++--- web-next/src/locales/en-US/messages.po | 16 +++++++------- web-next/src/locales/ja-JP/messages.po | 16 +++++++------- web-next/src/locales/ko-KR/messages.po | 16 +++++++------- web-next/src/locales/zh-CN/messages.po | 18 +++++++-------- web-next/src/locales/zh-TW/messages.po | 16 +++++++------- .../mediaDeleteOrphanMediaMutation.graphql.ts | 12 +++++----- web-next/src/routes/(root)/admin/media.tsx | 8 +++---- 11 files changed, 85 insertions(+), 60 deletions(-) diff --git a/graphql/account.ts b/graphql/account.ts index 172acc91f..f0d13633c 100644 --- a/graphql/account.ts +++ b/graphql/account.ts @@ -73,6 +73,7 @@ export const Account = builder.drizzleNode("accountTable", { }), avatarUrl: t.field({ type: "URL", + deprecationReason: "Use avatarMediumId instead.", select: { columns: { avatarMediumId: true, diff --git a/graphql/admin.test.ts b/graphql/admin.test.ts index 791f295ff..666cab9a8 100644 --- a/graphql/admin.test.ts +++ b/graphql/admin.test.ts @@ -1339,7 +1339,7 @@ const deleteOrphanMediaMutation = parse(` __typename ... on DeleteOrphanMediaPayload { deletedCount - failedDiskDeletes + failedStorageDeletes status { orphanMediaCount } @@ -1404,13 +1404,13 @@ Deno.test({ deleteOrphanMedia: { __typename: string; deletedCount: number; - failedDiskDeletes: number; + failedStorageDeletes: number; status: { orphanMediaCount: number }; }; }).deleteOrphanMedia; assertEquals(payload.__typename, "DeleteOrphanMediaPayload"); assertEquals(payload.deletedCount, 1); - assertEquals(payload.failedDiskDeletes, 0); + assertEquals(payload.failedStorageDeletes, 0); assertEquals(payload.status.orphanMediaCount, 0); assertEquals(disk.deleteKeys, ["media/graphql-orphan-delete.webp"]); assertEquals( diff --git a/graphql/admin.ts b/graphql/admin.ts index c175a10e8..d587d924f 100644 --- a/graphql/admin.ts +++ b/graphql/admin.ts @@ -352,6 +352,14 @@ const InvitationRegenerationStatus = builder.simpleObject( "When the regeneration was last triggered, or null if it has " + "never been run.", }), + lastRegeneratedAt: t.field({ + type: "DateTime", + nullable: true, + deprecationReason: "Use lastRegenerated", + description: + "When the regeneration was last triggered, or null if it has " + + "never been run.", + }), cutoffDate: t.field({ type: "DateTime", description: @@ -374,6 +382,7 @@ const InvitationRegenerationStatus = builder.simpleObject( interface InvitationRegenerationStatusShape { lastRegenerated: Date | null; + lastRegeneratedAt: Date | null; cutoffDate: Date; eligibleAccountsCount: number; topThirdCount: number; @@ -384,6 +393,7 @@ function toInvitationRegenerationStatusShape( ): InvitationRegenerationStatusShape { return { lastRegenerated: status.lastRegeneratedAt, + lastRegeneratedAt: status.lastRegeneratedAt, cutoffDate: status.cutoffDate, eligibleAccountsCount: status.eligibleAccountsCount, topThirdCount: status.topThirdCount, @@ -416,6 +426,11 @@ const RegenerateInvitationsPayload = builder.simpleObject( type: "DateTime", description: "When the regeneration ran.", }), + regeneratedAt: t.field({ + type: "DateTime", + deprecationReason: "Use regenerated", + description: "When the regeneration ran.", + }), accountsAffected: t.int({ description: "Number of accounts whose `leftInvitations` was incremented.", @@ -453,6 +468,7 @@ builder.mutationField("regenerateInvitations", (t) => const status = await getInvitationRegenerationStatus(ctx.db, ctx.kv); return { regenerated: result.regeneratedAt, + regeneratedAt: result.regeneratedAt, accountsAffected: result.accountsAffected, status: toInvitationRegenerationStatusShape(status), }; @@ -501,9 +517,9 @@ const DeleteOrphanMediaPayload = builder.simpleObject( deletedCount: t.int({ description: "Number of orphan media database rows deleted.", }), - failedDiskDeletes: t.int({ + failedStorageDeletes: t.int({ description: - "Number of stored media objects that could not be deleted from disk.", + "Number of stored media objects that could not be deleted.", }), status: t.field({ type: OrphanMediaStatus, @@ -529,7 +545,7 @@ builder.mutationField("deleteOrphanMedia", (t) => const status = await getOrphanMediaStatus(ctx.db); return { deletedCount: result.deletedCount, - failedDiskDeletes: result.failedDiskDeletes, + failedStorageDeletes: result.failedDiskDeletes, status, }; }, diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 182a15da4..647ae9a48 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -4,7 +4,7 @@ type Account implements Node { """UUID of the medium used as this account's avatar.""" avatarMediumId: UUID - avatarUrl: URL! + avatarUrl: URL! @deprecated(reason: "Use avatarMediumId instead.") bio: Markdown! created: DateTime! defaultNoteVisibility: PostVisibility! @@ -520,8 +520,8 @@ type DeleteOrphanMediaPayload { """Number of orphan media database rows deleted.""" deletedCount: Int! - """Number of stored media objects that could not be deleted from disk.""" - failedDiskDeletes: Int! + """Number of stored media objects that could not be deleted.""" + failedStorageDeletes: Int! """The orphan media status after the deletion attempt.""" status: OrphanMediaStatus! @@ -672,6 +672,11 @@ type InvitationRegenerationStatus { """ lastRegenerated: DateTime + """ + When the regeneration was last triggered, or null if it has never been run. + """ + lastRegeneratedAt: DateTime @deprecated(reason: "Use lastRegenerated") + """ Number of accounts that would receive an invitation if a regeneration were triggered now (ceil(eligible / 3)). """ @@ -1475,6 +1480,9 @@ type RegenerateInvitationsPayload { """When the regeneration ran.""" regenerated: DateTime! + """When the regeneration ran.""" + regeneratedAt: DateTime! @deprecated(reason: "Use regenerated") + """The updated regeneration status reflecting the just-recorded run.""" status: InvitationRegenerationStatus! } diff --git a/web-next/src/locales/en-US/messages.po b/web-next/src/locales/en-US/messages.po index 070a48f07..a11e151ef 100644 --- a/web-next/src/locales/en-US/messages.po +++ b/web-next/src/locales/en-US/messages.po @@ -18,7 +18,7 @@ msgstr "" msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}" -#. placeholder {0}: result.failedDiskDeletes! +#. placeholder {0}: result.failedStorageDeletes! #: src/routes/(root)/admin/media.tsx:109 msgid "{0, plural, one {# disk object could not be deleted.} other {# disk objects could not be deleted.}}" msgstr "{0, plural, one {# disk object could not be deleted.} other {# disk objects could not be deleted.}}" @@ -50,7 +50,7 @@ msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, one {# invitation left} other {# invitations left}}" #. placeholder {0}: count() -#: src/routes/(root)/admin/media.tsx:177 +#: src/routes/(root)/admin/media.tsx:179 msgid "{0, plural, one {# orphan medium can be deleted.} other {# orphan media can be deleted.}}" msgstr "{0, plural, one {# orphan medium can be deleted.} other {# orphan media can be deleted.}}" @@ -530,7 +530,7 @@ msgid "Crop your new avatar" msgstr "Crop your new avatar" #: src/routes/(root)/admin/invitations.tsx:183 -#: src/routes/(root)/admin/media.tsx:169 +#: src/routes/(root)/admin/media.tsx:171 msgid "Cutoff:" msgstr "Cutoff:" @@ -553,8 +553,8 @@ msgstr "Delete" msgid "Delete draft" msgstr "Delete draft" -#: src/routes/(root)/admin/media.tsx:161 -#: src/routes/(root)/admin/media.tsx:192 +#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:194 msgid "Delete orphan media" msgstr "Delete orphan media" @@ -564,7 +564,7 @@ msgstr "Delete post?" #: src/components/article-composer/ArticleComposerActions.tsx:20 #: src/routes/(root)/[handle]/settings/invite.tsx:654 -#: src/routes/(root)/admin/media.tsx:192 +#: src/routes/(root)/admin/media.tsx:194 msgid "Deleting…" msgstr "Deleting…" @@ -1315,7 +1315,7 @@ msgid "Markdown supported" msgstr "Markdown supported" #: src/components/AppSidebar.tsx:654 -#: src/routes/(root)/admin/media.tsx:157 +#: src/routes/(root)/admin/media.tsx:159 msgid "Media" msgstr "Media" @@ -1752,7 +1752,7 @@ msgstr "Remove bookmark" msgid "Remove quote" msgstr "Remove quote" -#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:165 msgid "Removes stored media that are old enough and no longer attached to an avatar, note, article draft, or article." msgstr "Removes stored media that are old enough and no longer attached to an avatar, note, article draft, or article." diff --git a/web-next/src/locales/ja-JP/messages.po b/web-next/src/locales/ja-JP/messages.po index 378cbbdbe..55223e948 100644 --- a/web-next/src/locales/ja-JP/messages.po +++ b/web-next/src/locales/ja-JP/messages.po @@ -18,7 +18,7 @@ msgstr "" msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, other {#コメント}}" -#. placeholder {0}: result.failedDiskDeletes! +#. placeholder {0}: result.failedStorageDeletes! #: src/routes/(root)/admin/media.tsx:109 msgid "{0, plural, one {# disk object could not be deleted.} other {# disk objects could not be deleted.}}" msgstr "{0, plural, other {#個のディスクオブジェクトを削除できませんでした。}}" @@ -50,7 +50,7 @@ msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, other {残り#件の招待}}" #. placeholder {0}: count() -#: src/routes/(root)/admin/media.tsx:177 +#: src/routes/(root)/admin/media.tsx:179 msgid "{0, plural, one {# orphan medium can be deleted.} other {# orphan media can be deleted.}}" msgstr "{0, plural, other {#件の孤立したメディアを削除できます。}}" @@ -530,7 +530,7 @@ msgid "Crop your new avatar" msgstr "新しいアイコンを切り抜く" #: src/routes/(root)/admin/invitations.tsx:183 -#: src/routes/(root)/admin/media.tsx:169 +#: src/routes/(root)/admin/media.tsx:171 msgid "Cutoff:" msgstr "カットオフ:" @@ -553,8 +553,8 @@ msgstr "削除" msgid "Delete draft" msgstr "下書きを削除" -#: src/routes/(root)/admin/media.tsx:161 -#: src/routes/(root)/admin/media.tsx:192 +#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:194 msgid "Delete orphan media" msgstr "孤立したメディアを削除" @@ -564,7 +564,7 @@ msgstr "コンテンツを削除しますか?" #: src/components/article-composer/ArticleComposerActions.tsx:20 #: src/routes/(root)/[handle]/settings/invite.tsx:654 -#: src/routes/(root)/admin/media.tsx:192 +#: src/routes/(root)/admin/media.tsx:194 msgid "Deleting…" msgstr "削除中…" @@ -1311,7 +1311,7 @@ msgid "Markdown supported" msgstr "Markdown対応" #: src/components/AppSidebar.tsx:654 -#: src/routes/(root)/admin/media.tsx:157 +#: src/routes/(root)/admin/media.tsx:159 msgid "Media" msgstr "メディア" @@ -1747,7 +1747,7 @@ msgstr "ブックマークを削除" msgid "Remove quote" msgstr "引用を削除" -#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:165 msgid "Removes stored media that are old enough and no longer attached to an avatar, note, article draft, or article." msgstr "作成から十分な時間が経過し、アイコン、投稿、記事の下書き、記事のいずれにも添付されていない保存済みメディアを削除します。" diff --git a/web-next/src/locales/ko-KR/messages.po b/web-next/src/locales/ko-KR/messages.po index 6b2621b2c..ac888dbdf 100644 --- a/web-next/src/locales/ko-KR/messages.po +++ b/web-next/src/locales/ko-KR/messages.po @@ -18,7 +18,7 @@ msgstr "" msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, other {# 댓글}}" -#. placeholder {0}: result.failedDiskDeletes! +#. placeholder {0}: result.failedStorageDeletes! #: src/routes/(root)/admin/media.tsx:109 msgid "{0, plural, one {# disk object could not be deleted.} other {# disk objects could not be deleted.}}" msgstr "{0, plural, other {디스크 객체 #개를 삭제하지 못했습니다.}}" @@ -50,7 +50,7 @@ msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, other {남은 초대 #건}}" #. placeholder {0}: count() -#: src/routes/(root)/admin/media.tsx:177 +#: src/routes/(root)/admin/media.tsx:179 msgid "{0, plural, one {# orphan medium can be deleted.} other {# orphan media can be deleted.}}" msgstr "{0, plural, other {연결되지 않은 미디어 #개를 삭제할 수 있습니다.}}" @@ -530,7 +530,7 @@ msgid "Crop your new avatar" msgstr "새 프로필 사진 자르기" #: src/routes/(root)/admin/invitations.tsx:183 -#: src/routes/(root)/admin/media.tsx:169 +#: src/routes/(root)/admin/media.tsx:171 msgid "Cutoff:" msgstr "기준 시각:" @@ -553,8 +553,8 @@ msgstr "삭제" msgid "Delete draft" msgstr "임시 보관 삭제" -#: src/routes/(root)/admin/media.tsx:161 -#: src/routes/(root)/admin/media.tsx:192 +#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:194 msgid "Delete orphan media" msgstr "연결되지 않은 미디어 삭제" @@ -564,7 +564,7 @@ msgstr "콘텐츠를 삭제할까요?" #: src/components/article-composer/ArticleComposerActions.tsx:20 #: src/routes/(root)/[handle]/settings/invite.tsx:654 -#: src/routes/(root)/admin/media.tsx:192 +#: src/routes/(root)/admin/media.tsx:194 msgid "Deleting…" msgstr "삭제 중…" @@ -1311,7 +1311,7 @@ msgid "Markdown supported" msgstr "Markdown 사용 가능" #: src/components/AppSidebar.tsx:654 -#: src/routes/(root)/admin/media.tsx:157 +#: src/routes/(root)/admin/media.tsx:159 msgid "Media" msgstr "미디어" @@ -1747,7 +1747,7 @@ msgstr "북마크 해제" msgid "Remove quote" msgstr "인용 삭제" -#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:165 msgid "Removes stored media that are old enough and no longer attached to an avatar, note, article draft, or article." msgstr "생성된 지 충분히 오래되었고 프로필 사진, 단문, 게시글 임시 보관, 게시글에 더 이상 첨부되어 있지 않은 저장된 미디어를 제거합니다." diff --git a/web-next/src/locales/zh-CN/messages.po b/web-next/src/locales/zh-CN/messages.po index d04a90dcc..149ada530 100644 --- a/web-next/src/locales/zh-CN/messages.po +++ b/web-next/src/locales/zh-CN/messages.po @@ -18,7 +18,7 @@ msgstr "" msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, other {# 条评论}}" -#. placeholder {0}: result.failedDiskDeletes! +#. placeholder {0}: result.failedStorageDeletes! #: src/routes/(root)/admin/media.tsx:109 msgid "{0, plural, one {# disk object could not be deleted.} other {# disk objects could not be deleted.}}" msgstr "{0, plural, other {# 个磁盘对象无法删除。}}" @@ -50,7 +50,7 @@ msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, other {剩余 # 次邀请}}" #. placeholder {0}: count() -#: src/routes/(root)/admin/media.tsx:177 +#: src/routes/(root)/admin/media.tsx:179 msgid "{0, plural, one {# orphan medium can be deleted.} other {# orphan media can be deleted.}}" msgstr "{0, plural, other {可删除 # 个孤立媒体。}}" @@ -530,7 +530,7 @@ msgid "Crop your new avatar" msgstr "裁剪你的新头像" #: src/routes/(root)/admin/invitations.tsx:183 -#: src/routes/(root)/admin/media.tsx:169 +#: src/routes/(root)/admin/media.tsx:171 msgid "Cutoff:" msgstr "截止时间:" @@ -553,8 +553,8 @@ msgstr "删除" msgid "Delete draft" msgstr "删除草稿" -#: src/routes/(root)/admin/media.tsx:161 -#: src/routes/(root)/admin/media.tsx:192 +#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:194 msgid "Delete orphan media" msgstr "删除孤立媒体" @@ -564,7 +564,7 @@ msgstr "删除内容?" #: src/components/article-composer/ArticleComposerActions.tsx:20 #: src/routes/(root)/[handle]/settings/invite.tsx:654 -#: src/routes/(root)/admin/media.tsx:192 +#: src/routes/(root)/admin/media.tsx:194 msgid "Deleting…" msgstr "正在删除…" @@ -1311,7 +1311,7 @@ msgid "Markdown supported" msgstr "Markdown 可用" #: src/components/AppSidebar.tsx:654 -#: src/routes/(root)/admin/media.tsx:157 +#: src/routes/(root)/admin/media.tsx:159 msgid "Media" msgstr "媒体" @@ -1747,9 +1747,9 @@ msgstr "取消收藏" msgid "Remove quote" msgstr "移除引用" -#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:165 msgid "Removes stored media that are old enough and no longer attached to an avatar, note, article draft, or article." -msgstr "移除已足够旧且不再附加到头像、帖子、文章草稿或文章的已存储媒体。" +msgstr "移除已过期且不再附加到头像、帖子、文章草稿或文章的已存储媒体。" #: src/components/PostControls.tsx:207 msgid "Reply" diff --git a/web-next/src/locales/zh-TW/messages.po b/web-next/src/locales/zh-TW/messages.po index 86f4bb6f0..be34835d4 100644 --- a/web-next/src/locales/zh-TW/messages.po +++ b/web-next/src/locales/zh-TW/messages.po @@ -18,7 +18,7 @@ msgstr "" msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, other {# 則評論}}" -#. placeholder {0}: result.failedDiskDeletes! +#. placeholder {0}: result.failedStorageDeletes! #: src/routes/(root)/admin/media.tsx:109 msgid "{0, plural, one {# disk object could not be deleted.} other {# disk objects could not be deleted.}}" msgstr "{0, plural, other {# 個磁碟物件無法刪除。}}" @@ -50,7 +50,7 @@ msgid "{0, plural, one {# invitation left} other {# invitations left}}" msgstr "{0, plural, other {剩餘 # 次邀請}}" #. placeholder {0}: count() -#: src/routes/(root)/admin/media.tsx:177 +#: src/routes/(root)/admin/media.tsx:179 msgid "{0, plural, one {# orphan medium can be deleted.} other {# orphan media can be deleted.}}" msgstr "{0, plural, other {可刪除 # 個孤立媒體。}}" @@ -530,7 +530,7 @@ msgid "Crop your new avatar" msgstr "裁剪你的新頭像" #: src/routes/(root)/admin/invitations.tsx:183 -#: src/routes/(root)/admin/media.tsx:169 +#: src/routes/(root)/admin/media.tsx:171 msgid "Cutoff:" msgstr "截止時間:" @@ -553,8 +553,8 @@ msgstr "刪除" msgid "Delete draft" msgstr "刪除草稿" -#: src/routes/(root)/admin/media.tsx:161 -#: src/routes/(root)/admin/media.tsx:192 +#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:194 msgid "Delete orphan media" msgstr "刪除孤立媒體" @@ -564,7 +564,7 @@ msgstr "刪除內容?" #: src/components/article-composer/ArticleComposerActions.tsx:20 #: src/routes/(root)/[handle]/settings/invite.tsx:654 -#: src/routes/(root)/admin/media.tsx:192 +#: src/routes/(root)/admin/media.tsx:194 msgid "Deleting…" msgstr "正在刪除…" @@ -1311,7 +1311,7 @@ msgid "Markdown supported" msgstr "Markdown 可用" #: src/components/AppSidebar.tsx:654 -#: src/routes/(root)/admin/media.tsx:157 +#: src/routes/(root)/admin/media.tsx:159 msgid "Media" msgstr "媒體" @@ -1747,7 +1747,7 @@ msgstr "取消收藏" msgid "Remove quote" msgstr "移除引用" -#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:165 msgid "Removes stored media that are old enough and no longer attached to an avatar, note, article draft, or article." msgstr "移除已足夠舊且不再附加到頭像、貼文、文章草稿或文章的已儲存媒體。" diff --git a/web-next/src/routes/(root)/admin/__generated__/mediaDeleteOrphanMediaMutation.graphql.ts b/web-next/src/routes/(root)/admin/__generated__/mediaDeleteOrphanMediaMutation.graphql.ts index 8665564f6..6be0f0aa1 100644 --- a/web-next/src/routes/(root)/admin/__generated__/mediaDeleteOrphanMediaMutation.graphql.ts +++ b/web-next/src/routes/(root)/admin/__generated__/mediaDeleteOrphanMediaMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -14,7 +14,7 @@ export type mediaDeleteOrphanMediaMutation$data = { readonly deleteOrphanMedia: { readonly __typename: "DeleteOrphanMediaPayload"; readonly deletedCount: number; - readonly failedDiskDeletes: number; + readonly failedStorageDeletes: number; readonly status: { readonly cutoffDate: string; readonly orphanMediaCount: number; @@ -67,7 +67,7 @@ var v0 = [ "alias": null, "args": null, "kind": "ScalarField", - "name": "failedDiskDeletes", + "name": "failedStorageDeletes", "storageKey": null }, { @@ -149,16 +149,16 @@ return { "selections": (v0/*: any*/) }, "params": { - "cacheID": "1364fbb899b298804448ce1a4f889d65", + "cacheID": "c8b140a298ff0a8c9b5093b190e70ed5", "id": null, "metadata": {}, "name": "mediaDeleteOrphanMediaMutation", "operationKind": "mutation", - "text": "mutation mediaDeleteOrphanMediaMutation {\n deleteOrphanMedia {\n __typename\n ... on DeleteOrphanMediaPayload {\n deletedCount\n failedDiskDeletes\n status {\n cutoffDate\n orphanMediaCount\n }\n }\n ... on NotAuthenticatedError {\n notAuthenticated\n }\n ... on NotAuthorizedError {\n notAuthorized\n }\n }\n}\n" + "text": "mutation mediaDeleteOrphanMediaMutation {\n deleteOrphanMedia {\n __typename\n ... on DeleteOrphanMediaPayload {\n deletedCount\n failedStorageDeletes\n status {\n cutoffDate\n orphanMediaCount\n }\n }\n ... on NotAuthenticatedError {\n notAuthenticated\n }\n ... on NotAuthorizedError {\n notAuthorized\n }\n }\n}\n" } }; })(); -(node as any).hash = "81a2de26df26cc625848d35c9bd884a5"; +(node as any).hash = "cc7784dd692929a263e9631ff1e07e91"; export default node; diff --git a/web-next/src/routes/(root)/admin/media.tsx b/web-next/src/routes/(root)/admin/media.tsx index bd4d8db73..c6fcc526c 100644 --- a/web-next/src/routes/(root)/admin/media.tsx +++ b/web-next/src/routes/(root)/admin/media.tsx @@ -59,7 +59,7 @@ const mediaDeleteOrphanMediaMutation = graphql` __typename ... on DeleteOrphanMediaPayload { deletedCount - failedDiskDeletes + failedStorageDeletes status { cutoffDate orphanMediaCount @@ -104,17 +104,17 @@ export default function AdminMediaPage() { }) }`, ), - description: result.failedDiskDeletes! > 0 + description: result.failedStorageDeletes! > 0 ? i18n._( msg`${ - plural(result.failedDiskDeletes!, { + plural(result.failedStorageDeletes!, { one: "# disk object could not be deleted.", other: "# disk objects could not be deleted.", }) }`, ) : undefined, - variant: result.failedDiskDeletes! > 0 ? "error" : undefined, + variant: result.failedStorageDeletes! > 0 ? "error" : undefined, }); void revalidate("loadAdminMediaPageQuery"); } else if (result.__typename === "NotAuthenticatedError") { From 033963b3d647e7343f046eeb295b8f185e3624d0 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 21:47:39 +0900 Subject: [PATCH 19/32] Preserve medium metadata in sync Resolve article medium placeholders in ActivityPub source markdown and copy the stored medium type when syncing note media. Tighten related account and app types so avatar medium relations are visible to callers. Assisted-by: Codex:gpt-5.5 --- federation/objects.ts | 17 ++++++++++++----- models/account.ts | 1 + models/post.ts | 2 +- web/routes/_app.tsx | 3 ++- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/federation/objects.ts b/federation/objects.ts index 28aa0481b..f20db183b 100644 --- a/federation/objects.ts +++ b/federation/objects.ts @@ -54,14 +54,21 @@ export async function getArticle( ctx.canonicalOrigin, ); const contents = await Promise.all( - articleSource.contents.map(async (content) => ({ - ...(await renderMarkup(ctx, content.content, { + articleSource.contents.map(async (content) => { + const rendered = await renderMarkup(ctx, content.content, { docId: articleSource.id, kv: ctx.data.kv, mediumUrls, - })), - ...content, - })), + }); + return { + ...content, + ...rendered, + content: content.content.replaceAll( + /hp-medium:([A-Za-z0-9._:/-]+)/g, + (matched, key: string) => mediumUrls[key] ?? matched, + ), + }; + }), ); const hashtags = contents.flatMap((c) => c.hashtags); contents.sort((a, b) => a.published.valueOf() - b.published.valueOf()); diff --git a/models/account.ts b/models/account.ts index d9ed6174b..8c7dfe35e 100644 --- a/models/account.ts +++ b/models/account.ts @@ -117,6 +117,7 @@ export async function getAccountByUsername( ): Promise< | Account & { actor: Actor & { successor: Actor | null }; + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; } diff --git a/models/post.ts b/models/post.ts index fa3539481..1bb28142d 100644 --- a/models/post.ts +++ b/models/post.ts @@ -350,7 +350,7 @@ export async function syncPostFromNoteSource( await Promise.all(noteSource.media.map(async (medium) => ({ postId: post.id, index: medium.index, - type: "image/webp" as const, + type: medium.medium.type, url: await disk.getUrl(medium.medium.key), alt: medium.alt, width: medium.medium.width, diff --git a/web/routes/_app.tsx b/web/routes/_app.tsx index f25ef0552..450fce245 100644 --- a/web/routes/_app.tsx +++ b/web/routes/_app.tsx @@ -5,6 +5,7 @@ import { type Account, type AccountEmail, articleDraftTable, + type Medium, } from "@hackerspub/models/schema"; import { dirname } from "@std/path/dirname"; import { join } from "@std/path/join"; @@ -73,7 +74,7 @@ export default async function App( let account: | Account & { emails: AccountEmail[]; - avatarMedium?: import("@hackerspub/models/schema").Medium | null; + avatarMedium?: Medium | null; } | undefined = undefined; let drafts = 0; From 24b9f0ccc63ef638bfd14fd1a2584c73c2557bab Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 21:47:48 +0900 Subject: [PATCH 20/32] Strengthen media cleanup tests Cover upload proxy rejection when the declared content length is longer than the body. Make the test disk delete keys and reuse the full test disk shape for orphan-media deletion tests. Assisted-by: Codex:gpt-5.5 --- graphql/medium-upload.test.ts | 33 +++++++++++++++++++++++++++++++++ models/admin.test.ts | 15 ++++++++------- test/postgres.ts | 3 ++- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/graphql/medium-upload.test.ts b/graphql/medium-upload.test.ts index a00056425..0b7b04634 100644 --- a/graphql/medium-upload.test.ts +++ b/graphql/medium-upload.test.ts @@ -111,3 +111,36 @@ test("handleMediumUploadProxy stops reading when body exceeds session length", a assert.equal(response.status, 413); assert.throws(() => disk.getBytes(session.key)); }); + +test("handleMediumUploadProxy rejects bodies shorter than content length", async () => { + const { kv } = createTestKv(); + const disk = createTestDisk(); + const accountId = crypto.randomUUID() as Uuid; + const session = await createMediumUploadSession( + kv, + accountId, + "image/png", + 4, + ); + const bytes = new Uint8Array([1, 2, 3]); + + const response = await handleMediumUploadProxy( + new Request( + `http://localhost/medium-uploads/${session.id}?token=${session.token}`, + { + method: "PUT", + headers: { + "Content-Type": "image/png", + "Content-Length": "4", + }, + body: bytes, + }, + ), + kv, + disk, + ); + + assert.ok(response != null); + assert.equal(response.status, 413); + assert.throws(() => disk.getBytes(session.key)); +}); diff --git a/models/admin.test.ts b/models/admin.test.ts index b44ff8791..8d13fe8da 100644 --- a/models/admin.test.ts +++ b/models/admin.test.ts @@ -2,6 +2,7 @@ import { assert } from "@std/assert/assert"; import { assertEquals } from "@std/assert/equals"; import { eq } from "drizzle-orm"; import { + createTestDisk, createTestKv, insertAccountWithActor, insertNotePost, @@ -30,15 +31,15 @@ import { generateUuidV7, type Uuid } from "./uuid.ts"; function createTrackingDisk(failingKeys = new Set()) { const deleteKeys: string[] = []; + const disk = createTestDisk(); + disk.delete = (key: string) => { + deleteKeys.push(key); + if (failingKeys.has(key)) return Promise.reject(new Error("failed")); + return Promise.resolve(undefined); + }; return { deleteKeys, - disk: { - delete(key: string) { - deleteKeys.push(key); - if (failingKeys.has(key)) return Promise.reject(new Error("failed")); - return Promise.resolve(undefined); - }, - } as unknown as Parameters[1], + disk, }; } diff --git a/test/postgres.ts b/test/postgres.ts index a79e39840..0ada26503 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -320,7 +320,8 @@ export function createTestDisk(): ContextData["disk"] { files.set(key, contents); return Promise.resolve(undefined); }, - delete() { + delete(key: string) { + files.delete(key); return Promise.resolve(undefined); }, } as unknown as ContextData["disk"]; From 563ff16a4d20c650817ce64e7e0bb075c79db906 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 21:53:35 +0900 Subject: [PATCH 21/32] Clean finalized upload failures Ensure finishMediumUpload removes the temporary uploaded object and the upload session even when image normalization rejects the uploaded bytes. Add a GraphQL regression test for invalid upload bytes. https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195405175 Assisted-by: Codex:gpt-5.5 --- graphql/post.more.test.ts | 62 +++++++++++++++++++++++++++++++++++++++ graphql/post.ts | 56 +++++++++++++++++++---------------- 2 files changed, 93 insertions(+), 25 deletions(-) diff --git a/graphql/post.more.test.ts b/graphql/post.more.test.ts index 94c82574e..06d8b5f94 100644 --- a/graphql/post.more.test.ts +++ b/graphql/post.more.test.ts @@ -14,9 +14,15 @@ import { postTable, } from "@hackerspub/models/schema"; import { generateUuidV7, type Uuid } from "@hackerspub/models/uuid"; +import { + createMediumUploadSession, + getMediumUploadSession, +} from "./medium-upload.ts"; import { schema } from "./mod.ts"; import { createFedCtx, + createTestDisk, + createTestKv, insertAccountWithActor, insertNotePost, makeGuestContext, @@ -202,6 +208,25 @@ const attachArticleDraftMediumMutation = parse(` } `); +const finishMediumUploadMutation = parse(` + mutation FinishMediumUpload($input: FinishMediumUploadInput!) { + finishMediumUpload(input: $input) { + __typename + ... on FinishMediumUploadPayload { + medium { + uuid + } + } + ... on InvalidInputError { + inputPath + } + ... on NotAuthenticatedError { + notAuthenticated + } + } + } +`); + const articleContentOgImageCollisionQuery = parse(` query ArticleContentOgImageCollision( $handle: String! @@ -507,6 +532,43 @@ test("createMedium and attachArticleDraftMedium create draft media relations", a }); }); +test("finishMediumUpload cleans up invalid uploaded bytes", async () => { + await withRollback(async (tx) => { + const account = await insertAccountWithActor(tx, { + username: "invaliduploadgraphql", + name: "Invalid Upload GraphQL", + email: "invaliduploadgraphql@example.com", + }); + const { kv } = createTestKv(); + const disk = createTestDisk(); + const upload = await createMediumUploadSession( + kv, + account.account.id, + "image/png", + 4, + ); + await disk.put(upload.key, new Uint8Array([1, 2, 3, 4])); + + const result = await execute({ + schema, + document: finishMediumUploadMutation, + variableValues: { input: { uploadId: upload.id } }, + contextValue: makeUserContext(tx, account.account, { kv, disk }), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + finishMediumUpload: { + __typename: "InvalidInputError", + inputPath: "uploadId", + }, + }); + assert.throws(() => disk.getBytes(upload.key)); + assert.equal(await getMediumUploadSession(kv, upload.id), undefined); + }); +}); + test("publishArticleDraft publishes an article and removes the draft", async () => { await withRollback(async (tx) => { const account = await insertAccountWithActor(tx, { diff --git a/graphql/post.ts b/graphql/post.ts index 19d64dfc0..b593e5fbd 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -2206,34 +2206,40 @@ builder.relayMutationField( if (upload == null || upload.accountId !== session.accountId) { throw new InvalidInputError("uploadId"); } - let bytes: Uint8Array; try { - bytes = await ctx.disk.getBytes(upload.key); - } catch { - throw new InvalidInputError("uploadId"); - } - const medium = await createMediumFromBytes(ctx.db, ctx.disk, bytes, { - maxSize: MAX_STREAMING_MEDIUM_IMAGE_SIZE, - contentType: upload.contentType, - }); - if (medium == null) throw new InvalidInputError("uploadId"); - try { - await ctx.disk.delete(upload.key); - } catch (error) { - logger.warn("Failed to delete temporary medium upload {key}: {error}", { - key: upload.key, - error, - }); - } - try { - await deleteMediumUploadSession(ctx.kv, upload.id); - } catch (error) { - logger.warn("Failed to delete medium upload session {id}: {error}", { - id: upload.id, - error, + let bytes: Uint8Array; + try { + bytes = await ctx.disk.getBytes(upload.key); + } catch { + throw new InvalidInputError("uploadId"); + } + const medium = await createMediumFromBytes(ctx.db, ctx.disk, bytes, { + maxSize: MAX_STREAMING_MEDIUM_IMAGE_SIZE, + contentType: upload.contentType, }); + if (medium == null) throw new InvalidInputError("uploadId"); + return medium; + } finally { + try { + await ctx.disk.delete(upload.key); + } catch (error) { + logger.warn( + "Failed to delete temporary medium upload {key}: {error}", + { + key: upload.key, + error, + }, + ); + } + try { + await deleteMediumUploadSession(ctx.kv, upload.id); + } catch (error) { + logger.warn("Failed to delete medium upload session {id}: {error}", { + id: upload.id, + error, + }); + } } - return medium; }, }, { From 498331b63d740fc394c6ddaee0e6d8ce0ba5e108 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 22:36:27 +0900 Subject: [PATCH 22/32] Harden medium attachment handling Validate note media inside the creation transaction, reject uploaded objects whose stored size differs from the upload session before reading them, and verify article hp-medium references against the submitted source media. Order note media deterministically for ActivityPub objects and add a timeout to remote medium fetches. https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195608653 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195620790 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195636956 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195636963 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195636970 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195636979 Assisted-by: Codex:gpt-5.5 --- federation/objects.ts | 2 +- graphql/post.more.test.ts | 84 ++++++++++++++++++++++++++++++++ graphql/post.ts | 44 ++++++++++++----- models/article.lifecycle.test.ts | 30 ++++++++++++ models/article.ts | 6 +++ models/medium.ts | 30 ++++++++---- test/postgres.ts | 10 ++++ 7 files changed, 183 insertions(+), 23 deletions(-) diff --git a/federation/objects.ts b/federation/objects.ts index f20db183b..6bb13caa7 100644 --- a/federation/objects.ts +++ b/federation/objects.ts @@ -274,7 +274,7 @@ builder const note = await ctx.data.db.query.noteSourceTable.findFirst({ with: { account: true, - media: { with: { medium: true } }, + media: { with: { medium: true }, orderBy: { index: "asc" } }, post: { with: { replyTarget: true, quotedPost: true } }, }, where: { id: values.id }, diff --git a/graphql/post.more.test.ts b/graphql/post.more.test.ts index 06d8b5f94..862b8ff29 100644 --- a/graphql/post.more.test.ts +++ b/graphql/post.more.test.ts @@ -261,6 +261,17 @@ const createNoteMutation = parse(` } `); +const createNoteWithErrorMutation = parse(` + mutation CreateNoteWithError($input: CreateNoteInput!) { + createNote(input: $input) { + __typename + ... on InvalidInputError { + inputPath + } + } + } +`); + const deletePostMutation = parse(` mutation DeletePost($id: ID!) { deletePost(input: { id: $id }) { @@ -569,6 +580,43 @@ test("finishMediumUpload cleans up invalid uploaded bytes", async () => { }); }); +test("finishMediumUpload rejects unexpected uploaded size before reading", async () => { + await withRollback(async (tx) => { + const account = await insertAccountWithActor(tx, { + username: "mismatcheduploadgraphql", + name: "Mismatched Upload GraphQL", + email: "mismatcheduploadgraphql@example.com", + }); + const { kv } = createTestKv(); + const disk = createTestDisk(); + const upload = await createMediumUploadSession( + kv, + account.account.id, + "image/png", + 4, + ); + await disk.put(upload.key, new Uint8Array([1, 2, 3, 4, 5])); + + const result = await execute({ + schema, + document: finishMediumUploadMutation, + variableValues: { input: { uploadId: upload.id } }, + contextValue: makeUserContext(tx, account.account, { kv, disk }), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + finishMediumUpload: { + __typename: "InvalidInputError", + inputPath: "uploadId", + }, + }); + assert.throws(() => disk.getBytes(upload.key)); + assert.equal(await getMediumUploadSession(kv, upload.id), undefined); + }); +}); + test("publishArticleDraft publishes an article and removes the draft", async () => { await withRollback(async (tx) => { const account = await insertAccountWithActor(tx, { @@ -1143,6 +1191,42 @@ test("createNote creates a note for the signed-in account", async () => { }); }); +test("createNote validates attached media inside the transaction", async () => { + await withRollback(async (tx) => { + const account = await insertAccountWithActor(tx, { + username: "createnotemissingmedia", + name: "Create Note Missing Media", + email: "createnotemissingmedia@example.com", + }); + + const result = await execute({ + schema, + document: createNoteWithErrorMutation, + variableValues: { + input: { + content: "note with missing media", + language: "en", + visibility: "PUBLIC", + media: [{ + mediumId: crypto.randomUUID(), + alt: "Missing image", + }], + }, + }, + contextValue: makeTransactionalUserContext(tx, account.account), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + createNote: { + __typename: "InvalidInputError", + inputPath: "media.0.mediumId", + }, + }); + }); +}); + test("deletePost rejects deleting shared posts and postByUrl resolves owned posts", async () => { await withRollback(async (tx) => { const author = await insertAccountWithActor(tx, { diff --git a/graphql/post.ts b/graphql/post.ts index b593e5fbd..4efd5080d 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -813,15 +813,6 @@ builder.relayMutationField( quotedPostId, } = args.input; const attachedMedia = media ?? []; - for (let i = 0; i < attachedMedia.length; i++) { - if (attachedMedia[i].alt.trim() === "") { - throw new InvalidInputError(`media.${i}.alt`); - } - const medium = await ctx.db.query.mediumTable.findFirst({ - where: { id: attachedMedia[i].mediumId }, - }); - if (medium == null) throw new InvalidInputError(`media.${i}.mediumId`); - } let replyTarget: schema.Post & { actor: schema.Actor } | undefined; if (replyTargetId != null) { replyTarget = await ctx.db.query.postTable.findFirst({ @@ -843,6 +834,20 @@ builder.relayMutationField( } } return await withTransaction(ctx.fedCtx, async (context) => { + const noteMedia = await Promise.all( + attachedMedia.map(async (medium, i) => { + const alt = medium.alt.trim(); + if (alt === "") throw new InvalidInputError(`media.${i}.alt`); + const storedMedium = await context.data.db.query.mediumTable + .findFirst({ + where: { id: medium.mediumId }, + }); + if (storedMedium == null) { + throw new InvalidInputError(`media.${i}.mediumId`); + } + return { mediumId: medium.mediumId, alt }; + }), + ); const note = await createNote( context, { @@ -863,10 +868,7 @@ builder.relayMutationField( ), content, language: language.baseName, - media: attachedMedia.map((medium) => ({ - mediumId: medium.mediumId, - alt: medium.alt.trim(), - })), + media: noteMedia, }, { replyTarget, quotedPost }, ); @@ -2151,6 +2153,7 @@ builder.relayMutationField( await ctx.disk.getSignedUploadUrl(upload.key, { contentType: upload.contentType, contentSize: upload.contentLength, + contentLength: upload.contentLength, expiresIn: "30mins", }), ); @@ -2207,12 +2210,27 @@ builder.relayMutationField( throw new InvalidInputError("uploadId"); } try { + let metadata: { contentLength: number }; + try { + metadata = await ctx.disk.getMetaData(upload.key); + } catch { + throw new InvalidInputError("uploadId"); + } + if ( + metadata.contentLength !== upload.contentLength || + metadata.contentLength > MAX_STREAMING_MEDIUM_IMAGE_SIZE + ) { + throw new InvalidInputError("uploadId"); + } let bytes: Uint8Array; try { bytes = await ctx.disk.getBytes(upload.key); } catch { throw new InvalidInputError("uploadId"); } + if (bytes.byteLength !== upload.contentLength) { + throw new InvalidInputError("uploadId"); + } const medium = await createMediumFromBytes(ctx.db, ctx.disk, bytes, { maxSize: MAX_STREAMING_MEDIUM_IMAGE_SIZE, contentType: upload.contentType, diff --git a/models/article.lifecycle.test.ts b/models/article.lifecycle.test.ts index 33e929845..5d0458c12 100644 --- a/models/article.lifecycle.test.ts +++ b/models/article.lifecycle.test.ts @@ -117,6 +117,36 @@ test("createArticle() copies source media before rendering the post", async () = }); }); +test("createArticle() rejects content with missing source media", async () => { + await withRollback(async (tx) => { + const fedCtx = createFedCtx(tx); + fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + const author = await insertAccountWithActor(tx, { + username: "missingarticlemediaauthor", + name: "Missing Article Media Author", + email: "missingarticlemediaauthor@example.com", + }); + + const article = await createArticle(fedCtx, { + accountId: author.account.id, + publishedYear: 2026, + slug: "missing-article-media", + tags: [], + allowLlmTranslation: false, + title: "Article with missing media", + content: "![Hero](hp-medium:missing)", + language: "en", + media: [], + }); + + assert.equal(article, undefined); + const source = await tx.query.articleSourceTable.findFirst({ + where: { slug: "missing-article-media" }, + }); + assert.equal(source, undefined); + }); +}); + test("updateArticle() rewrites the persisted article post", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); diff --git a/models/article.ts b/models/article.ts index d49a6c7de..f72788330 100644 --- a/models/article.ts +++ b/models/article.ts @@ -295,6 +295,12 @@ export async function createArticle( [...source.content.matchAll(/hp-medium:([A-Za-z0-9._:/-]+)/g)] .map((match) => match[1]), ); + const sourceMediaByKey = new Map( + (sourceMedia ?? []).map((medium) => [medium.key, medium]), + ); + for (const key of referencedMediumKeys) { + if (!sourceMediaByKey.has(key)) return undefined; + } const articleSource = await createArticleSource( db, fedCtx.data.models, diff --git a/models/medium.ts b/models/medium.ts index 52240ab7b..0bee86612 100644 --- a/models/medium.ts +++ b/models/medium.ts @@ -46,6 +46,7 @@ export const SUPPORTED_MEDIUM_IMAGE_TYPES = [ export const MAX_MEDIUM_IMAGE_SIZE = 10 * 1024 * 1024; export const MAX_STREAMING_MEDIUM_IMAGE_SIZE = 50 * 1024 * 1024; +const REMOTE_MEDIUM_FETCH_TIMEOUT_MS = 30_000; const localMediumType: MediumType = "image/webp"; @@ -83,15 +84,26 @@ async function fetchMediumUrl( let current = url; for (let redirects = 0; redirects < 6; redirects++) { assertSafeRemoteMediumUrl(current); - const response = await fetch(current, { - headers: { - "User-Agent": getUserAgent({ - software: `HackersPub/${metadata.version}`, - url: userAgentUrl ?? new URL("https://hackers.pub/"), - }), - }, - redirect: "manual", - }); + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + REMOTE_MEDIUM_FETCH_TIMEOUT_MS, + ); + let response: Response; + try { + response = await fetch(current, { + headers: { + "User-Agent": getUserAgent({ + software: `HackersPub/${metadata.version}`, + url: userAgentUrl ?? new URL("https://hackers.pub/"), + }), + }, + redirect: "manual", + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } if (![301, 302, 303, 307, 308].includes(response.status)) { return response; } diff --git a/test/postgres.ts b/test/postgres.ts index 0ada26503..608cac6ae 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -316,6 +316,16 @@ export function createTestDisk(): ContextData["disk"] { if (bytes == null) throw new Error(`No test disk file for key: ${key}`); return Promise.resolve(bytes); }, + getMetaData(key: string) { + const bytes = files.get(key); + if (bytes == null) throw new Error(`No test disk file for key: ${key}`); + return Promise.resolve({ + contentLength: bytes.byteLength, + contentType: undefined, + etag: `"${key}"`, + lastModified: new Date("2026-04-15T00:00:00.000Z"), + }); + }, put(key: string, contents: Uint8Array) { files.set(key, contents); return Promise.resolve(undefined); From 2c9631403ab8252f20966d83f9746160316672ba Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 22:36:39 +0900 Subject: [PATCH 23/32] Lock orphan medium cleanup Run orphan medium cleanup under a transaction, lock candidate rows while storage deletes are in progress, and delete storage objects in parallel. Rows whose storage delete fails remain in the database so cleanup can retry them. https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195608661 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195636967 Assisted-by: Codex:gpt-5.5 --- models/admin.ts | 69 ++++++++++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 30 deletions(-) diff --git a/models/admin.ts b/models/admin.ts index ec5e1716c..facb40fc8 100644 --- a/models/admin.ts +++ b/models/admin.ts @@ -305,37 +305,46 @@ export async function deleteOrphanMedia( options: OrphanMediaOptions = {}, ): Promise { const cutoffDate = resolveOrphanMediaCutoff(options); - const orphanMedia = await db - .select({ key: mediumTable.key }) - .from(mediumTable) - .where(orphanMediaWhere(cutoffDate)) - .orderBy(mediumTable.created); + const runDeletion = async (tx: Database | Transaction) => { + const orphanMedia = await tx + .select({ key: mediumTable.key }) + .from(mediumTable) + .where(orphanMediaWhere(cutoffDate)) + .orderBy(mediumTable.created) + .for("update"); - let failedDiskDeletes = 0; - const deletedKeys: string[] = []; - for (const { key } of orphanMedia) { - try { - await disk.delete(key); - deletedKeys.push(key); - } catch (error) { - failedDiskDeletes++; - logger.warn( - "Failed to delete orphan medium object {key}: {error}", - { key, error }, - ); - } - } - const deleted = deletedKeys.length < 1 ? [] : await db - .delete(mediumTable) - .where(and( - inArray(mediumTable.key, deletedKeys), - orphanMediaWhere(cutoffDate), - )) - .returning({ key: mediumTable.key }); + const deleteResults = await Promise.all( + orphanMedia.map(async ({ key }) => { + try { + await disk.delete(key); + return { key, deleted: true }; + } catch (error) { + logger.warn( + "Failed to delete orphan medium object {key}: {error}", + { key, error }, + ); + return { key, deleted: false }; + } + }), + ); + const deletedKeys = deleteResults + .filter((result) => result.deleted) + .map((result) => result.key); + const deleted = deletedKeys.length < 1 ? [] : await tx + .delete(mediumTable) + .where(and( + inArray(mediumTable.key, deletedKeys), + orphanMediaWhere(cutoffDate), + )) + .returning({ key: mediumTable.key }); - return { - cutoffDate, - deletedCount: deleted.length, - failedDiskDeletes, + return { + cutoffDate, + deletedCount: deleted.length, + failedDiskDeletes: deleteResults.length - deletedKeys.length, + }; }; + return isTransaction(db) + ? await runDeletion(db) + : await db.transaction(runDeletion); } From 939fb09853c552e84bd47d63703de79bf15ab692 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 22:36:47 +0900 Subject: [PATCH 24/32] Surface avatar upload failures Abort profile updates when avatar media processing fails so the existing settings page shows the avatar validation error instead of silently saving the rest of the form. Also clean up the zh-TW strings called out in review. https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195636986 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195637011 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195637025 Assisted-by: Codex:gpt-5.5 --- web-next/src/locales/zh-TW/messages.po | 4 ++-- web/routes/@[username]/settings/index.tsx | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/web-next/src/locales/zh-TW/messages.po b/web-next/src/locales/zh-TW/messages.po index be34835d4..1a5fc6236 100644 --- a/web-next/src/locales/zh-TW/messages.po +++ b/web-next/src/locales/zh-TW/messages.po @@ -2019,7 +2019,7 @@ msgstr "可在 {0} 上以 {1} 授權取得該網站的原始碼。" #: src/routes/(root)/[handle]/settings/index.tsx:580 msgid "The URL of the link, e.g., https://github.com/yourhandle." -msgstr "該連結的 URL,例如 https://github.com/你的使用者名稱 。" +msgstr "該連結的 URL,例如 https://github.com/你的使用者名稱。" #: src/components/PostActionMenu.tsx:351 msgid "This action cannot be undone. This will permanently delete this post." @@ -2433,4 +2433,4 @@ msgstr "個人資料設定已成功更新。" #: src/routes/(root)/[handle]/settings/index.tsx:422 #: src/routes/(root)/sign/up/[token].tsx:374 msgid "Your username will be used to create your profile URL and your fediverse handle." -msgstr "你的使用者名稱將被用來創建你的個人資料 URL 和你的 Fediverse 使用者名稱(handle)。" +msgstr "你的使用者名稱將用於建立你的個人資料 URL 和你的聯邦宇宙識別碼。" diff --git a/web/routes/@[username]/settings/index.tsx b/web/routes/@[username]/settings/index.tsx index 93e4fcd4f..9ef139265 100644 --- a/web/routes/@[username]/settings/index.tsx +++ b/web/routes/@[username]/settings/index.tsx @@ -138,7 +138,22 @@ export const handler = define.handlers({ const medium = await createAvatarMediumFromBlob(db, disk, avatar, { maxSize: MAX_AVATAR_SIZE, }); - if (medium != null) values.avatarMediumId = medium.id; + if (medium == null) { + errors.avatar = t("settings.profile.avatarInvalid"); + return page({ + avatarUrl: await getAvatarUrl(disk, account), + usernameChanged: account.usernameChanged, + values: { + username, + name, + bio, + leftInvitations: account.leftInvitations, + }, + links, + errors, + }); + } + values.avatarMediumId = medium.id; } const updatedAccount = await updateAccount(ctx.state.fedCtx, values); if (updatedAccount == null) { From 740073ec329c97398afe860beb4751571842e0db Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 23:34:33 +0900 Subject: [PATCH 25/32] Harden orphan medium cleanup Keep media referenced directly from article markdown out of orphan cleanup. Process cleanup candidates in bounded batches, delete database rows before storage objects, and cap storage-delete concurrency to avoid unbounded I/O. https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195878140 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195878144 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195878152 https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195925595 Assisted-by: Codex:gpt-5.5 --- models/admin.test.ts | 44 +++++++++++++++++++--- models/admin.ts | 89 ++++++++++++++++++++++++++++++-------------- 2 files changed, 100 insertions(+), 33 deletions(-) diff --git a/models/admin.test.ts b/models/admin.test.ts index 8d13fe8da..ab9b34d44 100644 --- a/models/admin.test.ts +++ b/models/admin.test.ts @@ -19,6 +19,7 @@ import { import { accountTable, adminStateTable, + articleContentTable, articleDraftMediumTable, articleDraftTable, articleSourceMediumTable, @@ -366,6 +367,18 @@ Deno.test({ key: "draft-key", mediumId: draftMediumId, }); + const directDraftMediumId = await insertTestMedium( + tx, + "media/direct-draft.webp", + old, + ); + const directDraftId = generateUuidV7(); + await tx.insert(articleDraftTable).values({ + id: directDraftId, + accountId: account.account.id, + title: "Direct draft", + content: `![direct](/media/media/direct-draft.webp)`, + }); const sourceMediumId = await insertTestMedium( tx, @@ -384,10 +397,31 @@ Deno.test({ key: "source-key", mediumId: sourceMediumId, }); + const directSourceMediumId = await insertTestMedium( + tx, + "media/direct-source.webp", + old, + ); + await tx.insert(articleContentTable).values({ + sourceId, + language: "en", + title: "Direct source", + content: `![direct](/media/media/direct-source.webp)`, + }); const status = await getOrphanMediaStatus(tx, { now }); assertEquals(status.cutoffDate.toISOString(), cutoff.toISOString()); assertEquals(status.orphanMediaCount, 1); + assert( + await tx.query.mediumTable.findFirst({ + where: { id: directDraftMediumId }, + }) != null, + ); + assert( + await tx.query.mediumTable.findFirst({ + where: { id: directSourceMediumId }, + }) != null, + ); }); }, }); @@ -433,7 +467,7 @@ Deno.test({ }); Deno.test({ - name: "deleteOrphanMedia keeps rows whose disk object failed to delete", + name: "deleteOrphanMedia reports disk failures after deleting rows", sanitizeOps: false, sanitizeResources: false, async fn() { @@ -456,15 +490,15 @@ Deno.test({ const result = await deleteOrphanMedia(tx, disk.disk, { now }); - assertEquals(result.deletedCount, 1); + assertEquals(result.deletedCount, 2); assertEquals(result.failedDiskDeletes, 1); assertEquals(disk.deleteKeys.toSorted(), [ "media/orphan-delete-fail.webp", "media/orphan-delete-ok.webp", ]); - assert( - await tx.query.mediumTable.findFirst({ where: { id: failedId } }) != - null, + assertEquals( + await tx.query.mediumTable.findFirst({ where: { id: failedId } }), + undefined, ); assertEquals( await tx.query.mediumTable.findFirst({ where: { id: deletedId } }), diff --git a/models/admin.ts b/models/admin.ts index facb40fc8..58bbc9f2c 100644 --- a/models/admin.ts +++ b/models/admin.ts @@ -21,7 +21,9 @@ import { accountTable, actorTable, adminStateTable, + articleContentTable, articleDraftMediumTable, + articleDraftTable, articleSourceMediumTable, mediumTable, noteSourceMediumTable, @@ -43,6 +45,8 @@ export const DEFAULT_REGEN_CUTOFF_DURATION: Temporal.Duration = Temporal .Duration.from({ days: 7 }); export const DEFAULT_ORPHAN_MEDIA_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000; +const ORPHAN_MEDIA_DELETE_BATCH_SIZE = 1000; +const ORPHAN_MEDIA_STORAGE_DELETE_CONCURRENCY = 8; function isTransaction(db: Database): db is Transaction { return "rollback" in db; @@ -280,10 +284,38 @@ function orphanMediaWhere(cutoffDate: Date): SQL { NOT EXISTS ( SELECT 1 FROM ${articleSourceMediumTable} WHERE ${articleSourceMediumTable.mediumId} = ${mediumTable.id} + ) AND + NOT EXISTS ( + SELECT 1 FROM ${articleDraftTable} + WHERE strpos(${articleDraftTable.content}, ${mediumTable.key}) > 0 + ) AND + NOT EXISTS ( + SELECT 1 FROM ${articleContentTable} + WHERE strpos(${articleContentTable.content}, ${mediumTable.key}) > 0 ) `; } +async function mapWithConcurrency( + items: readonly T[], + concurrency: number, + mapper: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + const workers = Array.from( + { length: Math.min(concurrency, items.length) }, + async () => { + while (nextIndex < items.length) { + const index = nextIndex++; + results[index] = await mapper(items[index]); + } + }, + ); + await Promise.all(workers); + return results; +} + export async function getOrphanMediaStatus( db: Database, options: OrphanMediaOptions = {}, @@ -307,44 +339,45 @@ export async function deleteOrphanMedia( const cutoffDate = resolveOrphanMediaCutoff(options); const runDeletion = async (tx: Database | Transaction) => { const orphanMedia = await tx - .select({ key: mediumTable.key }) + .select({ id: mediumTable.id, key: mediumTable.key }) .from(mediumTable) .where(orphanMediaWhere(cutoffDate)) .orderBy(mediumTable.created) + .limit(ORPHAN_MEDIA_DELETE_BATCH_SIZE) .for("update"); - - const deleteResults = await Promise.all( - orphanMedia.map(async ({ key }) => { - try { - await disk.delete(key); - return { key, deleted: true }; - } catch (error) { - logger.warn( - "Failed to delete orphan medium object {key}: {error}", - { key, error }, - ); - return { key, deleted: false }; - } - }), - ); - const deletedKeys = deleteResults - .filter((result) => result.deleted) - .map((result) => result.key); - const deleted = deletedKeys.length < 1 ? [] : await tx + const candidateIds = orphanMedia.map((medium) => medium.id); + return candidateIds.length < 1 ? [] : await tx .delete(mediumTable) .where(and( - inArray(mediumTable.key, deletedKeys), + inArray(mediumTable.id, candidateIds), orphanMediaWhere(cutoffDate), )) .returning({ key: mediumTable.key }); - - return { - cutoffDate, - deletedCount: deleted.length, - failedDiskDeletes: deleteResults.length - deletedKeys.length, - }; }; - return isTransaction(db) + const deleted = isTransaction(db) ? await runDeletion(db) : await db.transaction(runDeletion); + + const deleteResults = await mapWithConcurrency( + deleted, + ORPHAN_MEDIA_STORAGE_DELETE_CONCURRENCY, + async ({ key }) => { + try { + await disk.delete(key); + return { key, deleted: true }; + } catch (error) { + logger.warn( + "Failed to delete orphan medium object {key}: {error}", + { key, error }, + ); + return { key, deleted: false }; + } + }, + ); + return { + cutoffDate, + deletedCount: deleted.length, + failedDiskDeletes: deleteResults.filter((result) => !result.deleted) + .length, + }; } From 19d1bf7da63a8877e82e964957aab84caea835fa Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 23:34:42 +0900 Subject: [PATCH 26/32] Guard settings form submission Return early when a profile settings save is already in progress so duplicate submits cannot start overlapping avatar uploads or account mutations. https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3195878158 Assisted-by: Codex:gpt-5.5 --- web-next/src/routes/(root)/[handle]/settings/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web-next/src/routes/(root)/[handle]/settings/index.tsx b/web-next/src/routes/(root)/[handle]/settings/index.tsx index 1b8d080ba..9c1e8fb1e 100644 --- a/web-next/src/routes/(root)/[handle]/settings/index.tsx +++ b/web-next/src/routes/(root)/[handle]/settings/index.tsx @@ -265,6 +265,7 @@ function SettingsForm(props: SettingsFormProps) { const [saving, setSaving] = createSignal(false); async function onSubmit(event: SubmitEvent) { event.preventDefault(); + if (saving()) return; const id = account()?.id; const usernameChanged = account()?.usernameChanged; if ( From 79ff3ad97a8d5c6eccf9bdf28981f17092f150df Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 6 May 2026 23:54:48 +0900 Subject: [PATCH 27/32] Attach media during article updates Add an updateArticle media input so edited article markdown can introduce new hp-medium references. The model now validates referenced keys, updates article_source_medium before post rendering, and rolls back the edit when a referenced medium cannot be resolved. Assisted-by: Codex:gpt-5.5 --- graphql/post.more.test.ts | 86 ++++++++++ graphql/post.ts | 37 +++++ graphql/schema.graphql | 13 ++ models/article.lifecycle.test.ts | 91 +++++++++++ models/article.ts | 263 ++++++++++++++++++++++--------- 5 files changed, 416 insertions(+), 74 deletions(-) diff --git a/graphql/post.more.test.ts b/graphql/post.more.test.ts index 862b8ff29..b92c55597 100644 --- a/graphql/post.more.test.ts +++ b/graphql/post.more.test.ts @@ -4,6 +4,7 @@ import { encodeGlobalID } from "@pothos/plugin-relay"; import { eq } from "drizzle-orm"; import { execute, parse } from "graphql"; import type { UserContext } from "./builder.ts"; +import { createArticle } from "@hackerspub/models/article"; import { accountTable, articleContentTable, @@ -208,6 +209,26 @@ const attachArticleDraftMediumMutation = parse(` } `); +const updateArticleWithMediaMutation = parse(` + mutation UpdateArticleWithMedia($input: UpdateArticleInput!) { + updateArticle(input: $input) { + __typename + ... on UpdateArticlePayload { + article { + id + content + } + } + ... on InvalidInputError { + inputPath + } + ... on NotAuthenticatedError { + notAuthenticated + } + } + } +`); + const finishMediumUploadMutation = parse(` mutation FinishMediumUpload($input: FinishMediumUploadInput!) { finishMediumUpload(input: $input) { @@ -543,6 +564,71 @@ test("createMedium and attachArticleDraftMedium create draft media relations", a }); }); +test("updateArticle accepts media for new article source references", async () => { + await withRollback(async (tx) => { + const account = await insertAccountWithActor(tx, { + username: "updatearticlemediumgraphql", + name: "Update Article Medium GraphQL", + email: "updatearticlemediumgraphql@example.com", + }); + const fedCtx = createFedCtx(tx); + const article = await createArticle(fedCtx, { + accountId: account.account.id, + publishedYear: 2026, + slug: "update-article-medium-graphql", + tags: [], + allowLlmTranslation: false, + published: new Date("2026-04-15T00:00:00.000Z"), + updated: new Date("2026-04-15T00:00:00.000Z"), + title: "Original article", + content: "Original body", + language: "en", + }); + assert.ok(article != null); + const mediumId = generateUuidV7(); + await tx.insert(mediumTable).values({ + id: mediumId, + key: "media/update-article-medium-graphql.webp", + type: "image/webp", + width: 2, + height: 2, + }); + + const result = await execute({ + schema, + document: updateArticleWithMediaMutation, + variableValues: { + input: { + articleId: encodeGlobalID("Article", article.id), + content: "![Hero](hp-medium:hero)", + media: [{ key: "hero", mediumId }], + }, + }, + contextValue: makeUserContext(tx, account.account, { fedCtx }), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + const updated = (toPlainJson(result.data) as { + updateArticle: { + __typename: string; + article: { content: string }; + }; + }).updateArticle; + assert.equal(updated.__typename, "UpdateArticlePayload"); + assert.match( + updated.article.content, + /http:\/\/localhost\/media\/media\/update-article-medium-graphql\.webp/, + ); + assert.doesNotMatch(updated.article.content, /hp-medium:hero/); + + const relation = await tx.query.articleSourceMediumTable.findFirst({ + where: { articleSourceId: article.articleSource.id, key: "hero" }, + }); + assert.equal(relation?.mediumId, mediumId); + }); +}); + test("finishMediumUpload cleans up invalid uploaded bytes", async () => { await withRollback(async (tx) => { const account = await insertAccountWithActor(tx, { diff --git a/graphql/post.ts b/graphql/post.ts index 4efd5080d..e0a1028ac 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -1855,6 +1855,21 @@ builder.queryField("articleByYearAndSlug", (t) => }, })); +const UpdateArticleMediumInput = builder.inputType("UpdateArticleMediumInput", { + fields: (t) => ({ + mediumId: t.field({ + type: "UUID", + required: true, + description: "UUID of a Medium to make available to the article source.", + }), + key: t.string({ + required: false, + description: + "Key used in article markdown as hp-medium:KEY. Defaults to mediumId.", + }), + }), +}); + builder.relayMutationField( "updateArticle", { @@ -1865,6 +1880,12 @@ builder.relayMutationField( tags: t.stringList({ required: false }), language: t.field({ type: "Locale", required: false }), allowLlmTranslation: t.boolean({ required: false }), + media: t.field({ + type: [UpdateArticleMediumInput], + required: false, + description: + "Media to make available to hp-medium:KEY references in the updated article markdown.", + }), }), }, { @@ -1895,6 +1916,21 @@ builder.relayMutationField( throw new InvalidInputError("articleId"); } + const media: { key: string; mediumId: Uuid }[] = []; + for (const [i, mediumInput] of (args.input.media ?? []).entries()) { + const medium = await ctx.db.query.mediumTable.findFirst({ + where: { id: mediumInput.mediumId }, + }); + if (medium == null) { + throw new InvalidInputError(`media.${i}.mediumId`); + } + const key = mediumInput.key?.trim() || medium.id; + if (!key.match(/^[A-Za-z0-9._:/-]+$/)) { + throw new InvalidInputError(`media.${i}.key`); + } + media.push({ key, mediumId: medium.id }); + } + let updated; try { updated = await updateArticle(ctx.fedCtx, post.articleSource.id, { @@ -1903,6 +1939,7 @@ builder.relayMutationField( tags: args.input.tags ?? undefined, language: args.input.language?.baseName ?? undefined, allowLlmTranslation: args.input.allowLlmTranslation ?? undefined, + media: args.input.media == null ? undefined : media, }); } catch (e) { if (e instanceof LanguageChangeWithTranslationsError) { diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 647ae9a48..348134641 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -1848,10 +1848,23 @@ input UpdateArticleInput { clientMutationId: ID content: Markdown language: Locale + + """ + Media to make available to hp-medium:KEY references in the updated article markdown. + """ + media: [UpdateArticleMediumInput!] tags: [String!] title: String } +input UpdateArticleMediumInput { + """Key used in article markdown as hp-medium:KEY. Defaults to mediumId.""" + key: String + + """UUID of a Medium to make available to the article source.""" + mediumId: UUID! +} + type UpdateArticlePayload { article: Article! clientMutationId: ID diff --git a/models/article.lifecycle.test.ts b/models/article.lifecycle.test.ts index 5d0458c12..3af82068f 100644 --- a/models/article.lifecycle.test.ts +++ b/models/article.lifecycle.test.ts @@ -194,6 +194,97 @@ test("updateArticle() rewrites the persisted article post", async () => { }); }); +test("updateArticle() attaches source media before rendering the post", async () => { + await withRollback(async (tx) => { + const fedCtx = createFedCtx(tx); + fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + const author = await insertAccountWithActor(tx, { + username: "updatearticlemediaauthor", + name: "Update Article Media Author", + email: "updatearticlemediaauthor@example.com", + }); + const article = await createArticle(fedCtx, { + accountId: author.account.id, + publishedYear: 2026, + slug: "update-article-media", + tags: [], + allowLlmTranslation: false, + published: new Date("2026-04-15T00:00:00.000Z"), + updated: new Date("2026-04-15T00:00:00.000Z"), + title: "Original article", + content: "Original body", + language: "en", + }); + assert.ok(article != null); + const mediumId = generateUuidV7(); + await tx.insert(mediumTable).values({ + id: mediumId, + key: "media/update-article-media.webp", + type: "image/webp", + width: 2, + height: 2, + }); + + const updated = await updateArticle(fedCtx, article.articleSource.id, { + content: "![Hero](hp-medium:hero)", + media: [{ key: "hero", mediumId }], + }); + + assert.ok(updated != null); + assert.match( + updated.contentHtml, + /http:\/\/localhost\/media\/media\/update-article-media\.webp/, + ); + assert.doesNotMatch(updated.contentHtml, /hp-medium:hero/); + + const relation = await tx.query.articleSourceMediumTable.findFirst({ + where: { articleSourceId: article.articleSource.id, key: "hero" }, + }); + assert.ok(relation != null); + assert.equal(relation.mediumId, mediumId); + }); +}); + +test("updateArticle() rejects missing source media without saving content", async () => { + await withRollback(async (tx) => { + const fedCtx = createFedCtx(tx); + fedCtx.data.models = fakeModels as typeof fedCtx.data.models; + const author = await insertAccountWithActor(tx, { + username: "updatearticlemissingmedia", + name: "Update Article Missing Media", + email: "updatearticlemissingmedia@example.com", + }); + const article = await createArticle(fedCtx, { + accountId: author.account.id, + publishedYear: 2026, + slug: "update-article-missing-media", + tags: [], + allowLlmTranslation: false, + published: new Date("2026-04-15T00:00:00.000Z"), + updated: new Date("2026-04-15T00:00:00.000Z"), + title: "Original article", + content: "Original body", + language: "en", + }); + assert.ok(article != null); + + const updated = await updateArticle(fedCtx, article.articleSource.id, { + content: "![Missing](hp-medium:missing)", + media: [], + }); + + assert.equal(updated, undefined); + const originalContent = await tx.query.articleContentTable.findFirst({ + where: { + sourceId: article.articleSource.id, + originalLanguage: { isNull: true }, + }, + }); + assert.ok(originalContent != null); + assert.equal(originalContent.content, "Original body"); + }); +}); + test("updateArticle() resets existing translation rows when the body changes", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); diff --git a/models/article.ts b/models/article.ts index f72788330..7773f9a35 100644 --- a/models/article.ts +++ b/models/article.ts @@ -10,11 +10,20 @@ import { sendTagsPubRelayActivity } from "@hackerspub/federation/tags-pub"; import { getLogger } from "@logtape/logtape"; import { minBy } from "@std/collections/min-by"; import type { LanguageModel } from "ai"; -import { and, eq, isNotNull, isNull, lt, or, sql } from "drizzle-orm"; +import { + and, + eq, + isNotNull, + isNull, + lt, + notInArray, + or, + sql, +} from "drizzle-orm"; import type { Disk } from "flydrive"; import postgres from "postgres"; import type { ContextData, Models } from "./context.ts"; -import type { Database } from "./db.ts"; +import type { Database, Transaction } from "./db.ts"; import { syncPostFromArticleSource } from "./post.ts"; import { type Account, @@ -42,6 +51,89 @@ import { addPostToTimeline } from "./timeline.ts"; import { generateUuidV7, type Uuid } from "./uuid.ts"; const logger = getLogger(["hackerspub", "models", "article"]); +const articleMediumReferencePattern = /hp-medium:([A-Za-z0-9._:/-]+)/g; +const articleMediumKeyPattern = /^[A-Za-z0-9._:/-]+$/; + +interface ArticleMediumInput { + key: string; + mediumId: Uuid; +} + +class InvalidArticleSourceMediumError extends Error { +} + +function extractArticleMediumKeys(content: string): Set { + return new Set( + [...content.matchAll(articleMediumReferencePattern)].map((match) => + match[1] + ), + ); +} + +async function updateArticleSourceMedia( + db: Database | Transaction, + articleSourceId: Uuid, + content: string, + sourceMedia: readonly ArticleMediumInput[] | undefined, +): Promise { + const referencedMediumKeys = extractArticleMediumKeys(content); + const existingMedia = await db.query.articleSourceMediumTable.findMany({ + where: { articleSourceId }, + }); + const existingMediaByKey = new Map( + existingMedia.map((medium) => [medium.key, medium]), + ); + const sourceMediaByKey = new Map(); + for (const medium of sourceMedia ?? []) { + if (!articleMediumKeyPattern.test(medium.key)) return false; + sourceMediaByKey.set(medium.key, medium); + } + const missingKeys = [...referencedMediumKeys].filter((key) => + !existingMediaByKey.has(key) && !sourceMediaByKey.has(key) + ); + if (missingKeys.length > 0) return false; + const referencedSourceMedia = [...referencedMediumKeys] + .map((key) => sourceMediaByKey.get(key)) + .filter((medium) => medium != null); + const referencedMediumIds = [ + ...new Set( + referencedSourceMedia.map((medium) => medium.mediumId), + ), + ]; + if (referencedMediumIds.length > 0) { + const storedMedia = await db.query.mediumTable.findMany({ + where: { id: { in: referencedMediumIds } }, + columns: { id: true }, + }); + if (storedMedia.length !== referencedMediumIds.length) return false; + } + if (referencedMediumKeys.size < 1) { + await db.delete(articleSourceMediumTable) + .where(eq(articleSourceMediumTable.articleSourceId, articleSourceId)); + } else { + await db.delete(articleSourceMediumTable) + .where(and( + eq(articleSourceMediumTable.articleSourceId, articleSourceId), + notInArray(articleSourceMediumTable.key, [...referencedMediumKeys]), + )); + } + if (referencedSourceMedia.length > 0) { + await db.insert(articleSourceMediumTable).values( + referencedSourceMedia.map((medium) => ({ + articleSourceId, + key: medium.key, + mediumId: medium.mediumId, + })), + ).onConflictDoUpdate({ + target: [ + articleSourceMediumTable.articleSourceId, + articleSourceMediumTable.key, + ], + set: { mediumId: sql`excluded.medium_id` }, + }); + } + return true; +} export async function getArticleDraftMediumUrls( db: Database, @@ -291,10 +383,7 @@ export async function createArticle( > { const { db } = fedCtx.data; const { media: sourceMedia, ...articleSourceInput } = source; - const referencedMediumKeys = new Set( - [...source.content.matchAll(/hp-medium:([A-Za-z0-9._:/-]+)/g)] - .map((match) => match[1]), - ); + const referencedMediumKeys = extractArticleMediumKeys(source.content); const sourceMediaByKey = new Map( (sourceMedia ?? []).map((medium) => [medium.key, medium]), ); @@ -395,89 +484,114 @@ export async function updateArticleSource( title?: string; content?: string; language?: string; + media?: readonly ArticleMediumInput[]; }, models?: Models, ): Promise { + const { media: sourceMedia, ...sourceFields } = source; // Captured inside the transaction and used after it commits so we // can enqueue a fresh summarization for the row whose body or // language just changed. let resummarizeTarget: ArticleContent | undefined; let originalContentChanged = false; - const result = await db.transaction(async (tx) => { - const sources = await tx.update(articleSourceTable) - .set({ ...source, updated: sql`CURRENT_TIMESTAMP` }) - .where(eq(articleSourceTable.id, id)) - .returning(); - if (sources.length < 1) return undefined; - const originalContent = await getOriginalArticleContent(tx, sources[0]); - if (originalContent == null) { - if ( - source.language == null || source.title == null || - source.content == null - ) { - throw new Error("Missing required fields for new article content"); - } - await tx.insert(articleContentTable).values({ - sourceId: id, - language: source.language, - title: source.title, - content: source.content, - }); - } else { - const newContent = source.content ?? originalContent.content; - const newLanguage = source.language ?? originalContent.language; - const contentChanged = newContent !== originalContent.content; - const languageChanged = newLanguage !== originalContent.language; - try { - const updatedRows = await tx.update(articleContentTable) - .set({ - language: newLanguage, - title: source.title ?? originalContent.title, - content: newContent, - updated: sql`CURRENT_TIMESTAMP`, - // When the body or language actually changes, clear the - // previous summary state so a fresh attempt can run with - // the new content/language, including unsticking any - // earlier `summaryUnnecessary` mark and discarding any - // summary that would now be in the wrong language. - ...(contentChanged || languageChanged - ? { - summary: null, - summaryStarted: null, - summaryUnnecessary: false, - } - : {}), - }) - .where( - and( - eq(articleContentTable.sourceId, id), - eq(articleContentTable.language, originalContent.language), - ), - ) - .returning(); + let result: (ArticleSource & { contents: ArticleContent[] }) | undefined; + try { + result = await db.transaction(async (tx) => { + const sources = await tx.update(articleSourceTable) + .set({ ...sourceFields, updated: sql`CURRENT_TIMESTAMP` }) + .where(eq(articleSourceTable.id, id)) + .returning(); + if (sources.length < 1) return undefined; + const originalContent = await getOriginalArticleContent(tx, sources[0]); + if (originalContent == null) { if ( - (contentChanged || languageChanged) && updatedRows.length > 0 + sourceFields.language == null || sourceFields.title == null || + sourceFields.content == null ) { - resummarizeTarget = updatedRows[0]; + throw new Error("Missing required fields for new article content"); } - if (contentChanged && updatedRows.length > 0) { - originalContentChanged = true; + await tx.insert(articleContentTable).values({ + sourceId: id, + language: sourceFields.language, + title: sourceFields.title, + content: sourceFields.content, + }); + } else { + const newContent = sourceFields.content ?? originalContent.content; + const newLanguage = sourceFields.language ?? originalContent.language; + const contentChanged = newContent !== originalContent.content; + const languageChanged = newLanguage !== originalContent.language; + try { + const updatedRows = await tx.update(articleContentTable) + .set({ + language: newLanguage, + title: sourceFields.title ?? originalContent.title, + content: newContent, + updated: sql`CURRENT_TIMESTAMP`, + // When the body or language actually changes, clear the + // previous summary state so a fresh attempt can run with + // the new content/language, including unsticking any + // earlier `summaryUnnecessary` mark and discarding any + // summary that would now be in the wrong language. + ...(contentChanged || languageChanged + ? { + summary: null, + summaryStarted: null, + summaryUnnecessary: false, + } + : {}), + }) + .where( + and( + eq(articleContentTable.sourceId, id), + eq(articleContentTable.language, originalContent.language), + ), + ) + .returning(); + if ( + (contentChanged || languageChanged) && updatedRows.length > 0 + ) { + resummarizeTarget = updatedRows[0]; + } + if (contentChanged && updatedRows.length > 0) { + originalContentChanged = true; + } + } catch (error) { + if ( + error instanceof postgres.PostgresError && error.code === "23503" + ) { + throw new LanguageChangeWithTranslationsError(); + } + throw error; } - } catch (error) { - if ( - error instanceof postgres.PostgresError && error.code === "23503" - ) { - throw new LanguageChangeWithTranslationsError(); + } + const contents = await tx.query.articleContentTable.findMany({ + where: { sourceId: id }, + orderBy: { published: "asc" }, + }); + if (sourceFields.content != null || sourceMedia != null) { + const originalContent = contents.find((content) => + content.originalLanguage == null && + content.translatorId == null && + content.translationRequesterId == null + ); + if (originalContent == null) { + throw new Error("Missing original article content"); } - throw error; + const mediaUpdated = await updateArticleSourceMedia( + tx, + id, + originalContent.content, + sourceMedia, + ); + if (!mediaUpdated) throw new InvalidArticleSourceMediumError(); } - } - const contents = await tx.query.articleContentTable.findMany({ - where: { sourceId: id }, - orderBy: { published: "asc" }, + return { ...sources[0], contents }; }); - return { ...sources[0], contents }; - }); + } catch (error) { + if (error instanceof InvalidArticleSourceMediumError) return undefined; + throw error; + } if (result == null) return undefined; // Queue a fresh summarization outside of the transaction so the // claim is visible to other workers as soon as it is acquired and @@ -495,6 +609,7 @@ export async function updateArticle( title?: string; content?: string; language?: string; + media?: readonly ArticleMediumInput[]; }, ): Promise< Post & { From 24d5b9e325cc8489dff6b97ec44757303ac88fb1 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 7 May 2026 00:09:06 +0900 Subject: [PATCH 28/32] Tighten orphan media reference checks Match article markdown references as full hp-medium tokens or local media URLs instead of searching for a raw key substring. This keeps direct article references protected without treating prefix-like keys as live. https://github.com/hackers-pub/hackerspub/pull/286#discussion_r3196348904 Assisted-by: Codex:gpt-5.5 --- models/admin.test.ts | 9 ++++++--- models/admin.ts | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/models/admin.test.ts b/models/admin.test.ts index ab9b34d44..dec14f7be 100644 --- a/models/admin.test.ts +++ b/models/admin.test.ts @@ -328,6 +328,7 @@ Deno.test({ const recent = new Date("2026-04-14T12:00:00.000Z"); await insertTestMedium(tx, "media/orphan.webp", old); + await insertTestMedium(tx, "media/prefix.webp", old); await insertTestMedium(tx, "media/recent.webp", recent); const avatarMediumId = await insertTestMedium( @@ -377,7 +378,8 @@ Deno.test({ id: directDraftId, accountId: account.account.id, title: "Direct draft", - content: `![direct](/media/media/direct-draft.webp)`, + content: + `![direct](/media/media/direct-draft.webp) ![prefix](/media/media/prefix.webp-extra)`, }); const sourceMediumId = await insertTestMedium( @@ -406,12 +408,13 @@ Deno.test({ sourceId, language: "en", title: "Direct source", - content: `![direct](/media/media/direct-source.webp)`, + content: + `![direct](hp-medium:media/direct-source.webp) ![prefix](hp-medium:media/prefix.webp-extra)`, }); const status = await getOrphanMediaStatus(tx, { now }); assertEquals(status.cutoffDate.toISOString(), cutoff.toISOString()); - assertEquals(status.orphanMediaCount, 1); + assertEquals(status.orphanMediaCount, 2); assert( await tx.query.mediumTable.findFirst({ where: { id: directDraftMediumId }, diff --git a/models/admin.ts b/models/admin.ts index 58bbc9f2c..dd2eaf0b0 100644 --- a/models/admin.ts +++ b/models/admin.ts @@ -267,6 +267,12 @@ function resolveOrphanMediaCutoff(options: OrphanMediaOptions): Date { function orphanMediaWhere(cutoffDate: Date): SQL { const cutoffDateSql = sql`${cutoffDate.toISOString()}::timestamptz`; + const mediumKeyPattern = sql`replace(${mediumTable.key}, '.', '[.]')`; + const mediumReferenceBoundary = sql`'([^A-Za-z0-9._:/-]|$)'`; + const hpMediumReferencePattern = + sql`'hp-medium:' || ${mediumKeyPattern} || ${mediumReferenceBoundary}`; + const directMediumReferencePattern = + sql`'/media/' || ${mediumKeyPattern} || ${mediumReferenceBoundary}`; return sql` ${mediumTable.created} < ${cutoffDateSql} AND NOT EXISTS ( @@ -287,11 +293,15 @@ function orphanMediaWhere(cutoffDate: Date): SQL { ) AND NOT EXISTS ( SELECT 1 FROM ${articleDraftTable} - WHERE strpos(${articleDraftTable.content}, ${mediumTable.key}) > 0 + WHERE + ${articleDraftTable.content} ~ (${hpMediumReferencePattern}) OR + ${articleDraftTable.content} ~ (${directMediumReferencePattern}) ) AND NOT EXISTS ( SELECT 1 FROM ${articleContentTable} - WHERE strpos(${articleContentTable.content}, ${mediumTable.key}) > 0 + WHERE + ${articleContentTable.content} ~ (${hpMediumReferencePattern}) OR + ${articleContentTable.content} ~ (${directMediumReferencePattern}) ) `; } From b2c91a45556bcba4720d9125a5df7daec8cb487c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 7 May 2026 00:10:10 +0900 Subject: [PATCH 29/32] Reject conflicting draft UUIDs Return InvalidInputError when saveArticleDraft cannot upsert a supplied UUID because it already belongs to another account. This avoids surfacing a non-null payload with a missing draft as an internal GraphQL error. https://github.com/hackers-pub/hackerspub/pull/286#issuecomment-4389308646 Assisted-by: Codex:gpt-5.5 --- graphql/post.more.test.ts | 51 ++++++++++++++++++++++++++++++++++++++- graphql/post.ts | 3 +++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/graphql/post.more.test.ts b/graphql/post.more.test.ts index b92c55597..0863b3d0e 100644 --- a/graphql/post.more.test.ts +++ b/graphql/post.more.test.ts @@ -4,7 +4,7 @@ import { encodeGlobalID } from "@pothos/plugin-relay"; import { eq } from "drizzle-orm"; import { execute, parse } from "graphql"; import type { UserContext } from "./builder.ts"; -import { createArticle } from "@hackerspub/models/article"; +import { createArticle, updateArticleDraft } from "@hackerspub/models/article"; import { accountTable, articleContentTable, @@ -44,6 +44,9 @@ const saveArticleDraftMutation = parse(` tags } } + ... on InvalidInputError { + inputPath + } } } `); @@ -449,6 +452,52 @@ test("saveArticleDraft, articleDraft, and deleteArticleDraft round-trip a draft" }); }); +test("saveArticleDraft rejects draft UUIDs owned by another account", async () => { + await withRollback(async (tx) => { + const owner = await insertAccountWithActor(tx, { + username: "draftuuidowner", + name: "Draft UUID Owner", + email: "draftuuidowner@example.com", + }); + const other = await insertAccountWithActor(tx, { + username: "draftuuidother", + name: "Draft UUID Other", + email: "draftuuidother@example.com", + }); + const draftId = generateUuidV7(); + await updateArticleDraft(tx, { + id: draftId, + accountId: owner.account.id, + title: "Owned draft", + content: "Owned content", + tags: [], + }); + + const result = await execute({ + schema, + document: saveArticleDraftMutation, + variableValues: { + input: { + uuid: draftId, + title: "Conflicting draft", + content: "Conflicting content", + tags: [], + }, + }, + contextValue: makeUserContext(tx, other.account), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + saveArticleDraft: { + __typename: "InvalidInputError", + inputPath: "uuid", + }, + }); + }); +}); + test("Medium.contentHash is exposed as Sha256", async () => { const result = await execute({ schema, diff --git a/graphql/post.ts b/graphql/post.ts index e0a1028ac..e8315384e 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -930,6 +930,9 @@ builder.relayMutationField( content, tags, }); + if (draft == null) { + throw new InvalidInputError(args.input.uuid == null ? "id" : "uuid"); + } return draft; }, From 07389e3b993c853b2f9661e1c12643689c2aa7ec Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 7 May 2026 00:30:29 +0900 Subject: [PATCH 30/32] Match medium key paths in cleanup Preserve direct article references that use the stored medium key path as well as local FS URLs and hp-medium placeholders. This covers CDN-style URLs whose path is based directly on the medium key. https://github.com/hackers-pub/hackerspub/pull/286#issuecomment-4389525885 Assisted-by: Codex:gpt-5.5 --- models/admin.test.ts | 12 +++++++++++- models/admin.ts | 8 ++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/models/admin.test.ts b/models/admin.test.ts index dec14f7be..c241cfdaa 100644 --- a/models/admin.test.ts +++ b/models/admin.test.ts @@ -373,13 +373,18 @@ Deno.test({ "media/direct-draft.webp", old, ); + const directFsDraftMediumId = await insertTestMedium( + tx, + "media/direct-fs-draft.webp", + old, + ); const directDraftId = generateUuidV7(); await tx.insert(articleDraftTable).values({ id: directDraftId, accountId: account.account.id, title: "Direct draft", content: - `![direct](/media/media/direct-draft.webp) ![prefix](/media/media/prefix.webp-extra)`, + `![direct](/media/direct-draft.webp) ![fs](/media/media/direct-fs-draft.webp) ![prefix](/media/media/prefix.webp-extra)`, }); const sourceMediumId = await insertTestMedium( @@ -420,6 +425,11 @@ Deno.test({ where: { id: directDraftMediumId }, }) != null, ); + assert( + await tx.query.mediumTable.findFirst({ + where: { id: directFsDraftMediumId }, + }) != null, + ); assert( await tx.query.mediumTable.findFirst({ where: { id: directSourceMediumId }, diff --git a/models/admin.ts b/models/admin.ts index dd2eaf0b0..8c2474b63 100644 --- a/models/admin.ts +++ b/models/admin.ts @@ -273,6 +273,8 @@ function orphanMediaWhere(cutoffDate: Date): SQL { sql`'hp-medium:' || ${mediumKeyPattern} || ${mediumReferenceBoundary}`; const directMediumReferencePattern = sql`'/media/' || ${mediumKeyPattern} || ${mediumReferenceBoundary}`; + const keyPathMediumReferencePattern = + sql`'/' || ${mediumKeyPattern} || ${mediumReferenceBoundary}`; return sql` ${mediumTable.created} < ${cutoffDateSql} AND NOT EXISTS ( @@ -295,13 +297,15 @@ function orphanMediaWhere(cutoffDate: Date): SQL { SELECT 1 FROM ${articleDraftTable} WHERE ${articleDraftTable.content} ~ (${hpMediumReferencePattern}) OR - ${articleDraftTable.content} ~ (${directMediumReferencePattern}) + ${articleDraftTable.content} ~ (${directMediumReferencePattern}) OR + ${articleDraftTable.content} ~ (${keyPathMediumReferencePattern}) ) AND NOT EXISTS ( SELECT 1 FROM ${articleContentTable} WHERE ${articleContentTable.content} ~ (${hpMediumReferencePattern}) OR - ${articleContentTable.content} ~ (${directMediumReferencePattern}) + ${articleContentTable.content} ~ (${directMediumReferencePattern}) OR + ${articleContentTable.content} ~ (${keyPathMediumReferencePattern}) ) `; } From da52ed86b28a6e3fe4b5a4a72b4846b9d6f3abac Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 7 May 2026 01:11:32 +0900 Subject: [PATCH 31/32] Render missing media placeholders Replace unresolved hp-medium references with generated SVG placeholders when rendering article and draft markup. Use article language where it is available, and keep user-authored data URLs blocked by resolving medium references after Markdown rendering. Cover image, link, and srcset references with regression tests. Assisted-by: Codex:gpt-5.5 --- federation/objects.ts | 17 +- graphql/post.ts | 14 +- models/markup.test.ts | 66 +++++++- models/markup.ts | 147 ++++++++++++++++-- models/post.ts | 3 +- .../@[username]/[idOrYear]/[slug]/index.tsx | 7 +- .../@[username]/[idOrYear]/[slug]/ogimage.ts | 10 +- web/routes/@[username]/drafts/index.tsx | 8 +- web/routes/api/preview.ts | 9 +- 9 files changed, 256 insertions(+), 25 deletions(-) diff --git a/federation/objects.ts b/federation/objects.ts index 6bb13caa7..bd99f5b24 100644 --- a/federation/objects.ts +++ b/federation/objects.ts @@ -7,7 +7,11 @@ import { isReactionEmoji, type ReactionEmoji, } from "@hackerspub/models/emoji"; -import { renderMarkup } from "@hackerspub/models/markup"; +import { + getMissingArticleMediumLabel, + renderMarkup, + resolveMediumUrls, +} from "@hackerspub/models/markup"; import { isPostVisibleTo } from "@hackerspub/models/post"; import type { Account, @@ -55,18 +59,21 @@ export async function getArticle( ); const contents = await Promise.all( articleSource.contents.map(async (content) => { + const missingMediumLabel = getMissingArticleMediumLabel( + content.language, + ); const rendered = await renderMarkup(ctx, content.content, { docId: articleSource.id, kv: ctx.data.kv, mediumUrls, + missingMediumLabel, }); return { ...content, ...rendered, - content: content.content.replaceAll( - /hp-medium:([A-Za-z0-9._:/-]+)/g, - (matched, key: string) => mediumUrls[key] ?? matched, - ), + content: resolveMediumUrls(content.content, mediumUrls, { + missingMediumLabel, + }), }; }), ); diff --git a/graphql/post.ts b/graphql/post.ts index e8315384e..d5e07575e 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -23,7 +23,10 @@ import { import { isReactionEmoji, renderCustomEmojis } from "@hackerspub/models/emoji"; import { addExternalLinkTargets, stripHtml } from "@hackerspub/models/html"; import { negotiateLocale, normalizeLocale } from "@hackerspub/models/i18n"; -import { renderMarkup } from "@hackerspub/models/markup"; +import { + getMissingArticleMediumLabel, + renderMarkup, +} from "@hackerspub/models/markup"; import { createMediumFromBytes, createMediumFromUrl, @@ -409,6 +412,9 @@ export const ArticleDraft = builder.drizzleNode("articleDraftTable", { ctx.disk, draft.id, ), + missingMediumLabel: getMissingArticleMediumLabel( + ctx.account?.locales?.[0], + ), }); return addExternalLinkTargets( rendered.html, @@ -449,6 +455,7 @@ export const ArticleContent = builder.drizzleNode("articleContentTable", { select: { columns: { content: true, + language: true, }, with: { source: { @@ -470,6 +477,7 @@ export const ArticleContent = builder.drizzleNode("articleContentTable", { ctx.disk, content.sourceId, ), + missingMediumLabel: getMissingArticleMediumLabel(content.language), }); return addExternalLinkTargets( renderCustomEmojis(html.html, content.source.post.emojis), @@ -491,7 +499,7 @@ export const ArticleContent = builder.drizzleNode("articleContentTable", { type: "JSON", description: "Table of contents for the article content.", select: { - columns: { content: true, sourceId: true }, + columns: { content: true, language: true, sourceId: true }, }, async resolve(content, _, ctx) { const rendered = await renderMarkup(ctx.fedCtx, content.content, { @@ -501,6 +509,7 @@ export const ArticleContent = builder.drizzleNode("articleContentTable", { ctx.disk, content.sourceId, ), + missingMediumLabel: getMissingArticleMediumLabel(content.language), }); return rendered.toc; }, @@ -555,6 +564,7 @@ export const ArticleContent = builder.drizzleNode("articleContentTable", { ctx.disk, content.sourceId, ), + missingMediumLabel: getMissingArticleMediumLabel(content.language), }); const avatarUrl = await getAvatarUrl(ctx.disk, account); const key = await putArticleOgImage(ctx.disk, content.ogImageKey, { diff --git a/models/markup.test.ts b/models/markup.test.ts index 8aa97c75c..bdf9a7344 100644 --- a/models/markup.test.ts +++ b/models/markup.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { extractMentionsFromHtml, renderMarkup } from "./markup.ts"; +import { + extractMentionsFromHtml, + getMissingArticleMediumLabel, + renderMarkup, +} from "./markup.ts"; import { createFedCtx, createTestKv, @@ -36,6 +40,66 @@ Welcome to #HackersPub.`; assert.deepEqual(second, first); }); +test("renderMarkup() renders unresolved medium references as an SVG placeholder", async () => { + const rendered = await renderMarkup(null, "![missing](hp-medium:elsewhere)", { + missingMediumLabel: getMissingArticleMediumLabel("ko-KR"), + }); + + assert.doesNotMatch(rendered.html, /hp-medium:elsewhere/); + const src = rendered.html.match(/\bsrc="([^"]+)"/)?.[1]; + assert.ok(src != null); + assert.ok(src.startsWith("data:image/svg+xml;charset=UTF-8,")); + const svg = decodeURIComponent(src.slice(src.indexOf(",") + 1)); + assert.match(svg, /이 게시글에 첨부된 적 없는 미디어입니다\./); +}); + +test("renderMarkup() renders unresolved medium links as an SVG placeholder", async () => { + const rendered = await renderMarkup(null, "[missing](hp-medium:elsewhere)"); + + assert.doesNotMatch(rendered.html, /hp-medium:elsewhere/); + const href = rendered.html.match(/\bhref="([^"]+)"/)?.[1]; + assert.ok(href != null); + assert.ok(href.startsWith("data:image/svg+xml;charset=UTF-8,")); +}); + +test("renderMarkup() resolves medium references in srcset attributes", async () => { + const rendered = await renderMarkup( + null, + `ok`, + { + mediumUrls: { + small: "https://cdn.example/small.webp", + large: "https://cdn.example/large.webp", + }, + }, + ); + + assert.match( + rendered.html, + /srcset="https:\/\/cdn\.example\/small\.webp 1x, https:\/\/cdn\.example\/large\.webp 2x"/, + ); + assert.match(rendered.html, /src="https:\/\/cdn\.example\/small\.webp"/); +}); + +test("renderMarkup() uses attached medium URLs when a mapping exists", async () => { + const rendered = await renderMarkup(null, "![ok](hp-medium:local-key)", { + mediumUrls: { "local-key": "https://cdn.example/media.webp" }, + }); + + assert.match(rendered.html, /https:\/\/cdn\.example\/media\.webp/); + assert.doesNotMatch(rendered.html, /data:image\/svg\+xml/); +}); + +test("renderMarkup() does not allow user-authored data URL markdown images", async () => { + const rendered = await renderMarkup( + null, + "![bad](data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%221200%22%20height%3D%22675%22%20aria-labelledby%3D%22title%20desc%22%3E%3C/svg%3E)", + ); + + assert.doesNotMatch(rendered.html, /\bsrc="data:image\/svg\+xml/); + assert.match(rendered.html, /!\[bad\]/); +}); + test("extractMentionsFromHtml() resolves persisted actor mentions by href", async () => { await withRollback(async (tx) => { const fedCtx = createFedCtx(tx); diff --git a/models/markup.ts b/models/markup.ts index 7b7cf90f7..72160e26a 100644 --- a/models/markup.ts +++ b/models/markup.ts @@ -37,6 +37,7 @@ import { codeToHtml } from "shiki"; import { persistActor, persistActorsByHandles } from "./actor.ts"; import type { ContextData } from "./context.ts"; import { sanitizeExcerptHtml, sanitizeHtml, stripHtml } from "./html.ts"; +import { negotiateLocale } from "./i18n.ts"; import { type Actor, actorTable } from "./schema.ts"; const logger = getLogger(["hackerspub", "models", "markup"]); @@ -44,6 +45,16 @@ const logger = getLogger(["hackerspub", "models", "markup"]); const KV_NAMESPACE = "markup"; const KV_CACHE_VERSION = "2025-06-08"; +const MISSING_ARTICLE_MEDIUM_LABELS = { + en: "This medium has not been attached to this article.", + ja: "この記事に添付されていないメディアです。", + ko: "이 게시글에 첨부된 적 없는 미디어입니다.", + "zh-CN": "此媒体未附加到这篇文章。", + "zh-TW": "此媒體未附加到這篇文章。", +} as const; + +const DEFAULT_MISSING_ARTICLE_MEDIUM_LABEL = MISSING_ARTICLE_MEDIUM_LABELS.en; + let tocTree: InternalToc = { l: 0, n: "", c: [] }; const md = MarkdownItAsync({ html: true, linkify: true }) @@ -171,6 +182,7 @@ export interface RenderMarkupOptions { docId?: string | null; refresh?: boolean; mediumUrls?: Record; + missingMediumLabel?: string; } export async function renderMarkup( @@ -178,15 +190,17 @@ export async function renderMarkup( markup: string, options: RenderMarkupOptions = {}, ): Promise { - const resolvedMarkup = resolveMediumUrls(markup, options.mediumUrls ?? {}); + const mediumUrls = options.mediumUrls ?? {}; + const missingMediumLabel = options.missingMediumLabel ?? + DEFAULT_MISSING_ARTICLE_MEDIUM_LABEL; let cacheKey: string | undefined; if (options.kv != null) { const digest = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode( `${JSON.stringify(options.docId ?? null)}\n${ - JSON.stringify(options.mediumUrls ?? {}) - }\n${resolvedMarkup}`, + JSON.stringify(mediumUrls) + }\n${JSON.stringify(missingMediumLabel)}\n${markup}`, ), ); cacheKey = `${KV_NAMESPACE}/${KV_CACHE_VERSION}/markup/${ @@ -206,7 +220,7 @@ export async function renderMarkup( }, }); const tmpEnv: { mentions: string[] } = { mentions: [] }; - await tmpMd.renderAsync(resolvedMarkup, tmpEnv); + await tmpMd.renderAsync(markup, tmpEnv); const mentions = new Set(tmpEnv.mentions); logger.trace("Mentions: {mentions}", { mentions }); const mentionedActors = fedCtx == null @@ -222,16 +236,19 @@ export async function renderMarkup( hashtags: [], macros: {}, }; - const rawHtml = (await md.renderAsync(resolvedMarkup, env)) + const rawHtml = (await md.renderAsync(markup, env)) .replaceAll('', "") .replaceAll( '', "", ); - const html = sanitizeHtml(rawHtml); - const excerptHtml = sanitizeExcerptHtml(rawHtml); - const text = stripHtml(rawHtml); + const resolvedHtml = resolveMediumUrlsInHtml(rawHtml, mediumUrls, { + missingMediumLabel, + }); + const html = sanitizeHtml(resolvedHtml); + const excerptHtml = sanitizeExcerptHtml(resolvedHtml); + const text = stripHtml(resolvedHtml); const toc = toToc(tocTree, options.docId); const rendered: RenderedMarkup = { html, @@ -248,16 +265,126 @@ export async function renderMarkup( return rendered; } -function resolveMediumUrls( +export function getMissingArticleMediumLabel( + locale?: Intl.Locale | string | null, +): string { + if (locale == null) return DEFAULT_MISSING_ARTICLE_MEDIUM_LABEL; + try { + const matched = negotiateLocale( + locale, + Object.keys(MISSING_ARTICLE_MEDIUM_LABELS), + )?.baseName as keyof typeof MISSING_ARTICLE_MEDIUM_LABELS | undefined; + return matched == null + ? DEFAULT_MISSING_ARTICLE_MEDIUM_LABEL + : MISSING_ARTICLE_MEDIUM_LABELS[matched]; + } catch { + return DEFAULT_MISSING_ARTICLE_MEDIUM_LABEL; + } +} + +export function resolveMediumUrls( markup: string, mediumUrls: Record, + options: { missingMediumLabel?: string } = {}, ): string { + const missingMediumUrl = createMissingMediumDataUrl( + options.missingMediumLabel ?? DEFAULT_MISSING_ARTICLE_MEDIUM_LABEL, + ); return markup.replaceAll( /hp-medium:([A-Za-z0-9._:/-]+)/g, - (matched, key: string) => mediumUrls[key] ?? matched, + (_matched, key: string) => mediumUrls[key] ?? missingMediumUrl, ); } +function resolveMediumUrlsInHtml( + html: string, + mediumUrls: Record, + options: { missingMediumLabel: string }, +): string { + const missingMediumUrl = createMissingMediumDataUrl( + options.missingMediumLabel, + ); + const $ = load(html, null, false); + // Medium currently only supports images. If audio or video uploads become + // supported, extend this list to media-specific attributes such as + // audio[src], video[src], and video[poster] with type-appropriate fallbacks. + $("a[href]").each((_, el) => { + const $el = $(el); + const href = $el.attr("href"); + if (href == null) return; + $el.attr("href", resolveMediumUrl(href, mediumUrls, missingMediumUrl)); + }); + $("img[src], source[src]").each((_, el) => { + const $el = $(el); + const src = $el.attr("src"); + if (src == null) return; + $el.attr("src", resolveMediumUrl(src, mediumUrls, missingMediumUrl)); + }); + $("img[srcset], source[srcset]").each((_, el) => { + const $el = $(el); + const srcset = $el.attr("srcset"); + if (srcset == null) return; + $el.attr( + "srcset", + resolveMediumSrcset(srcset, mediumUrls, missingMediumUrl), + ); + }); + return $.root().html() ?? ""; +} + +function resolveMediumUrl( + url: string, + mediumUrls: Record, + missingMediumUrl: string, +): string { + const matched = /^hp-medium:([A-Za-z0-9._:/-]+)$/.exec(url); + if (matched == null) return url; + return mediumUrls[matched[1]] ?? missingMediumUrl; +} + +function resolveMediumSrcset( + srcset: string, + mediumUrls: Record, + missingMediumUrl: string, +): string { + return srcset.split(",").map((candidate) => { + const trimmed = candidate.trim(); + if (trimmed === "") return candidate; + const matched = /^(\S+)(.*)$/.exec(trimmed); + if (matched == null) return candidate; + return `${resolveMediumUrl(matched[1], mediumUrls, missingMediumUrl)}${ + matched[2] + }`; + }).join(", "); +} + +function createMissingMediumDataUrl(label: string): string { + const text = escapeSvgText(label); + const svg = + ` + ${text} + ${text} + + + + + + + + + ${text} +`; + return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`; +} + +function escapeSvgText(text: string): string { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + function slugifyTitle(title: string, docId?: string | null): string { return (docId == null ? "" : docId + "--") + slugify(title, { strip: ASCII_DIACRITICS_REGEXP }); diff --git a/models/post.ts b/models/post.ts index 1bb28142d..21bc8e4d6 100644 --- a/models/post.ts +++ b/models/post.ts @@ -48,7 +48,7 @@ import type { ContextData } from "./context.ts"; import { toDate } from "./date.ts"; import type { Database, RelationsFilter } from "./db.ts"; import { extractExternalLinks } from "./html.ts"; -import { renderMarkup } from "./markup.ts"; +import { getMissingArticleMediumLabel, renderMarkup } from "./markup.ts"; import { persistPostMedium } from "./medium.ts"; import { createShareNotification, @@ -188,6 +188,7 @@ export async function syncPostFromArticleSource( docId: articleSource.id, kv, mediumUrls: await getArticleSourceMediumUrls(db, disk, articleSource.id), + missingMediumLabel: getMissingArticleMediumLabel(content.language), }); const url = `${fedCtx.origin}/@${articleSource.account.username}/${articleSource.publishedYear}/${ diff --git a/web/routes/@[username]/[idOrYear]/[slug]/index.tsx b/web/routes/@[username]/[idOrYear]/[slug]/index.tsx index 4b19fc7cc..6a6a0447d 100644 --- a/web/routes/@[username]/[idOrYear]/[slug]/index.tsx +++ b/web/routes/@[username]/[idOrYear]/[slug]/index.tsx @@ -9,7 +9,11 @@ import { updateArticle, } from "@hackerspub/models/article"; import { preprocessContentHtml } from "@hackerspub/models/html"; -import { renderMarkup, type Toc } from "@hackerspub/models/markup"; +import { + getMissingArticleMediumLabel, + renderMarkup, + type Toc, +} from "@hackerspub/models/markup"; import { createNote } from "@hackerspub/models/note"; import { isPostVisibleTo } from "@hackerspub/models/post"; import type { @@ -141,6 +145,7 @@ export async function handleArticle( docId: article.id, kv, mediumUrls: await getArticleSourceMediumUrls(db, disk, article.id), + missingMediumLabel: getMissingArticleMediumLabel(content.language), refresh: ctx.url.searchParams.has("refresh") && ctx.state.account?.moderator, }, diff --git a/web/routes/@[username]/[idOrYear]/[slug]/ogimage.ts b/web/routes/@[username]/[idOrYear]/[slug]/ogimage.ts index bd8b3af14..8804e8f08 100644 --- a/web/routes/@[username]/[idOrYear]/[slug]/ogimage.ts +++ b/web/routes/@[username]/[idOrYear]/[slug]/ogimage.ts @@ -3,7 +3,10 @@ import { getArticleSource, getOriginalArticleContent, } from "@hackerspub/models/article"; -import { renderMarkup } from "@hackerspub/models/markup"; +import { + getMissingArticleMediumLabel, + renderMarkup, +} from "@hackerspub/models/markup"; import { isPostVisibleTo } from "@hackerspub/models/post"; import { type ArticleContent, @@ -47,7 +50,10 @@ export const handler = define.handlers({ const rendered = await renderMarkup( ctx.state.fedCtx, content.content, - { kv }, + { + kv, + missingMediumLabel: getMissingArticleMediumLabel(content.language), + }, ); const ogImageKey = await drawOgImage( disk, diff --git a/web/routes/@[username]/drafts/index.tsx b/web/routes/@[username]/drafts/index.tsx index 4d5f59e10..9a276741d 100644 --- a/web/routes/@[username]/drafts/index.tsx +++ b/web/routes/@[username]/drafts/index.tsx @@ -1,5 +1,8 @@ import { page } from "@fresh/core"; -import { renderMarkup } from "@hackerspub/models/markup"; +import { + getMissingArticleMediumLabel, + renderMarkup, +} from "@hackerspub/models/markup"; import { desc } from "drizzle-orm"; import { Excerpt } from "../../../components/Excerpt.tsx"; import { Msg } from "../../../components/Msg.tsx"; @@ -32,6 +35,9 @@ export const handler = define.handlers({ excerptHtml: (await renderMarkup(ctx.state.fedCtx, draft.content, { docId: draft.id, kv, + missingMediumLabel: getMissingArticleMediumLabel( + ctx.state.language, + ), })).excerptHtml, }))), }); diff --git a/web/routes/api/preview.ts b/web/routes/api/preview.ts index de96b712f..0d164df6c 100644 --- a/web/routes/api/preview.ts +++ b/web/routes/api/preview.ts @@ -1,4 +1,7 @@ -import { renderMarkup } from "@hackerspub/models/markup"; +import { + getMissingArticleMediumLabel, + renderMarkup, +} from "@hackerspub/models/markup"; import { define } from "../../utils.ts"; export const handler = define.handlers({ @@ -6,7 +9,9 @@ export const handler = define.handlers({ if (ctx.state.session == null) return ctx.next(); const nonce = ctx.req.headers.get("Echo-Nonce"); const markup = await ctx.req.text(); - const rendered = await renderMarkup(ctx.state.fedCtx, markup); + const rendered = await renderMarkup(ctx.state.fedCtx, markup, { + missingMediumLabel: getMissingArticleMediumLabel(ctx.state.language), + }); if (ctx.req.headers.get("Accept") === "application/json") { return new Response(JSON.stringify(rendered), { headers: { From 52d54da7c20b14e3c47e5532d54d20e64c12224f Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 7 May 2026 01:14:08 +0900 Subject: [PATCH 32/32] Stabilize medium URL cache keys Canonicalize medium URL mappings before hashing markup cache keys so unordered database results do not cause cache churn for identical article content. https://github.com/hackers-pub/hackerspub/pull/286#issuecomment-4389730367 Assisted-by: Codex:gpt-5.5 --- models/markup.test.ts | 28 ++++++++++++++++++++++++++++ models/markup.ts | 10 +++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/models/markup.test.ts b/models/markup.test.ts index bdf9a7344..9c78cebcf 100644 --- a/models/markup.test.ts +++ b/models/markup.test.ts @@ -40,6 +40,34 @@ Welcome to #HackersPub.`; assert.deepEqual(second, first); }); +test("renderMarkup() canonicalizes medium URLs before caching", async () => { + const { kv, store } = createTestKv(); + const markup = `![a](hp-medium:a) + +![b](hp-medium:b)`; + + const first = await renderMarkup(null, markup, { + kv: kv as never, + docId: "doc-with-media", + mediumUrls: { + a: "https://cdn.example/a.webp", + b: "https://cdn.example/b.webp", + }, + }); + + const second = await renderMarkup(null, markup, { + kv: kv as never, + docId: "doc-with-media", + mediumUrls: { + b: "https://cdn.example/b.webp", + a: "https://cdn.example/a.webp", + }, + }); + + assert.deepEqual(second, first); + assert.equal(store.size, 1); +}); + test("renderMarkup() renders unresolved medium references as an SVG placeholder", async () => { const rendered = await renderMarkup(null, "![missing](hp-medium:elsewhere)", { missingMediumLabel: getMissingArticleMediumLabel("ko-KR"), diff --git a/models/markup.ts b/models/markup.ts index 72160e26a..5368d54fc 100644 --- a/models/markup.ts +++ b/models/markup.ts @@ -185,6 +185,14 @@ export interface RenderMarkupOptions { missingMediumLabel?: string; } +function canonicalizeMediumUrls(mediumUrls: Record): string { + return JSON.stringify(Object.fromEntries( + Object.entries(mediumUrls).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0 + ), + )); +} + export async function renderMarkup( fedCtx: Context | null | undefined, markup: string, @@ -199,7 +207,7 @@ export async function renderMarkup( "SHA-256", new TextEncoder().encode( `${JSON.stringify(options.docId ?? null)}\n${ - JSON.stringify(mediumUrls) + canonicalizeMediumUrls(mediumUrls) }\n${JSON.stringify(missingMediumLabel)}\n${markup}`, ), );