diff --git a/drizzle/0098_unified_medium.sql b/drizzle/0098_unified_medium.sql new file mode 100644 index 000000000..ceca2c2b4 --- /dev/null +++ b/drizzle/0098_unified_medium.sql @@ -0,0 +1,301 @@ +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 +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") + 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" +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), + "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 +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" = 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" = 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 +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/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/0098_snapshot.json b/drizzle/meta/0098_snapshot.json new file mode 100644 index 000000000..473c711f6 --- /dev/null +++ b/drizzle/meta/0098_snapshot.json @@ -0,0 +1,4207 @@ +{ + "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": { + "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", + "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": { + "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", + "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": { + "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", + "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": { + "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", + "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/drizzle/meta/0099_snapshot.json b/drizzle/meta/0099_snapshot.json new file mode 100644 index 000000000..702648cb0 --- /dev/null +++ b/drizzle/meta/0099_snapshot.json @@ -0,0 +1,4198 @@ +{ + "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": { + "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", + "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": { + "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", + "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": { + "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", + "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": { + "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", + "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 ea70e21a6..607ce9738 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -687,6 +687,20 @@ "when": 1777642390574, "tag": "0097_clear_oversized_summaries", "breakpoints": true + }, + { + "idx": 98, + "version": "7", + "when": 1778025600000, + "tag": "0098_unified_medium", + "breakpoints": true + }, + { + "idx": 99, + "version": "7", + "when": 1778025700000, + "tag": "0099_drop_note_source_medium_unique", + "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..bd99f5b24 100644 --- a/federation/objects.ts +++ b/federation/objects.ts @@ -7,16 +7,21 @@ 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, Actor, ArticleContent, ArticleSource, + Medium, Mention, - NoteMedium, NoteSource, + NoteSourceMedium, Post, PostVisibility, Reaction, @@ -32,6 +37,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) @@ -39,13 +58,24 @@ 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 missingMediumLabel = getMissingArticleMediumLabel( + content.language, + ); + const rendered = await renderMarkup(ctx, content.content, { docId: articleSource.id, kv: ctx.data.kv, - })), - ...content, - })), + mediumUrls, + missingMediumLabel, + }); + return { + ...content, + ...rendered, + content: resolveMediumUrls(content.content, mediumUrls, { + missingMediumLabel, + }), + }; + }), ); const hashtags = contents.flatMap((c) => c.hashtags); contents.sort((a, b) => a.published.valueOf() - b.published.valueOf()); @@ -150,7 +180,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 +198,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 +281,7 @@ builder const note = await ctx.data.db.query.noteSourceTable.findFirst({ with: { account: true, - media: true, + media: { with: { medium: true }, orderBy: { index: "asc" } }, post: { with: { replyTarget: true, quotedPost: true } }, }, where: { id: values.id }, diff --git a/graphql/account.test.ts b/graphql/account.test.ts index f86526398..373b74727 100644 --- a/graphql/account.test.ts +++ b/graphql/account.test.ts @@ -3,12 +3,17 @@ 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"; import { schema } from "./mod.ts"; import { putProfileOgImage } from "./og.ts"; import { createFedCtx, + createTestDisk, insertAccountWithActor, makeGuestContext, makeUserContext, @@ -22,6 +27,7 @@ const viewerQuery = parse(` username name handle + avatarMediumId } } `); @@ -149,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, }, }, ); @@ -172,9 +179,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", }); @@ -382,6 +396,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 ff0f5882c..f0d13633c 100644 --- a/graphql/account.ts +++ b/graphql/account.ts @@ -7,8 +7,9 @@ import { assertNever } from "@std/assert/unstable-never"; import DataLoader from "dataloader"; import { and, desc, eq, gt, inArray, lt, sql } from "drizzle-orm"; import { + createAvatarMediumFromMedium, + createAvatarMediumFromUrl, getAvatarUrl, - transformAvatar, updateAccount, } from "@hackerspub/models/account"; import { syncActorFromAccount } from "@hackerspub/models/actor"; @@ -65,13 +66,20 @@ 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", + deprecationReason: "Use avatarMediumId instead.", select: { columns: { - avatarKey: true, + avatarMediumId: true, }, with: { + avatarMedium: true, emails: true, }, }, @@ -85,7 +93,7 @@ export const Account = builder.drizzleNode("accountTable", { complexity: profileOgImageComplexity, select: { columns: { - avatarKey: true, + avatarMediumId: true, bio: true, id: true, name: true, @@ -93,6 +101,7 @@ export const Account = builder.drizzleNode("accountTable", { username: true, }, with: { + avatarMedium: true, actor: { columns: { handleHost: true, @@ -108,7 +117,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 +573,7 @@ builder.queryField("invitationTree", (t) => const accounts = await ctx.db.query.accountTable.findMany({ with: { actor: true, + avatarMedium: true, emails: true, }, }); @@ -590,13 +600,6 @@ const AccountLinkInput = builder.inputType("AccountLinkInput", { }), }); -const SUPPORTED_AVATAR_TYPES = [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", -]; - builder.relayMutationField( "updateAccount", { @@ -605,7 +608,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 +642,37 @@ 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 createAvatarMediumFromUrl( + 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(), + 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."); + const avatarMedium = await createAvatarMediumFromMedium( + ctx.db, + ctx.disk, + medium, ); - const key = `avatars/${crypto.randomUUID()}.${ - format === "jpeg" ? "jpg" : format - }`; - promises.push(disk.put(key, buffer)); - avatarKey = key; + if (avatarMedium == null) { + throw new Error("Avatar medium must point to an image."); + } + avatarMediumId = avatarMedium.id; } const result = await updateAccount( ctx.fedCtx, @@ -668,7 +681,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 +697,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/admin.test.ts b/graphql/admin.test.ts index fdd774cb8..666cab9a8 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 @@ -788,7 +821,7 @@ Deno.test({ const invitationRegenStatusQuery = parse(` query InvitationRegenerationStatus { invitationRegenerationStatus { - lastRegeneratedAt + lastRegenerated cutoffDate eligibleAccountsCount topThirdCount @@ -849,7 +882,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 +898,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 +926,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 +1007,9 @@ const regenerateMutation = parse(` __typename ... on RegenerateInvitationsPayload { accountsAffected - regeneratedAt + regenerated status { - lastRegeneratedAt + lastRegenerated cutoffDate eligibleAccountsCount topThirdCount @@ -1094,15 +1127,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 +1175,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); }); }, @@ -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 + failedStorageDeletes + 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; + failedStorageDeletes: number; + status: { orphanMediaCount: number }; + }; + }).deleteOrphanMedia; + assertEquals(payload.__typename, "DeleteOrphanMediaPayload"); + assertEquals(payload.deletedCount, 1); + assertEquals(payload.failedStorageDeletes, 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 e1c060bdb..d587d924f 100644 --- a/graphql/admin.ts +++ b/graphql/admin.ts @@ -1,5 +1,8 @@ import { + deleteOrphanMedia, getInvitationRegenerationStatus, + getOrphanMediaStatus, + type InvitationRegenerationStatus as ModelInvitationRegenerationStatus, regenerateInvitations, } from "@hackerspub/models/admin"; import { accountTable, actorTable, postTable } from "@hackerspub/models/schema"; @@ -342,9 +345,17 @@ 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) => ({ + lastRegenerated: t.field({ + type: "DateTime", + nullable: true, + description: + "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.", @@ -353,7 +364,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 +380,26 @@ const InvitationRegenerationStatus = builder.simpleObject( }, ); +interface InvitationRegenerationStatusShape { + lastRegenerated: Date | null; + lastRegeneratedAt: Date | null; + cutoffDate: Date; + eligibleAccountsCount: number; + topThirdCount: number; +} + +function toInvitationRegenerationStatusShape( + status: ModelInvitationRegenerationStatus, +): InvitationRegenerationStatusShape { + return { + lastRegenerated: status.lastRegeneratedAt, + lastRegeneratedAt: status.lastRegeneratedAt, + cutoffDate: status.cutoffDate, + eligibleAccountsCount: status.eligibleAccountsCount, + topThirdCount: status.topThirdCount, + }; +} + builder.queryField("invitationRegenerationStatus", (t) => t.field({ type: InvitationRegenerationStatus, @@ -380,7 +411,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,8 +422,13 @@ const RegenerateInvitationsPayload = builder.simpleObject( { description: "The result of a successful invitations regeneration.", fields: (t) => ({ + regenerated: t.field({ + type: "DateTime", + description: "When the regeneration ran.", + }), regeneratedAt: t.field({ type: "DateTime", + deprecationReason: "Use regenerated", description: "When the regeneration ran.", }), accountsAffected: t.int({ @@ -429,8 +467,85 @@ builder.mutationField("regenerateInvitations", (t) => // pay one aggregate query and report the actual numbers. const status = await getInvitationRegenerationStatus(ctx.db, ctx.kv); return { + regenerated: result.regeneratedAt, regeneratedAt: result.regeneratedAt, accountsAffected: result.accountsAffected, + status: toInvitationRegenerationStatusShape(status), + }; + }, + })); + +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.", + }), + failedStorageDeletes: t.int({ + description: + "Number of stored media objects that could not be deleted.", + }), + 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, + failedStorageDeletes: result.failedDiskDeletes, status, }; }, diff --git a/graphql/builder.ts b/graphql/builder.ts index 1d4c7d5dc..f4277652d 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; @@ -112,6 +118,10 @@ export interface PothosTypes { Input: string; Output: string; }; + Sha256: { + Input: string; + Output: string; + }; URITemplate: { Input: string; Output: string; @@ -336,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/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.test.ts b/graphql/medium-upload.test.ts new file mode 100644 index 000000000..0b7b04634 --- /dev/null +++ b/graphql/medium-upload.test.ts @@ -0,0 +1,146 @@ +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); +}); + +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)); +}); + +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/graphql/medium-upload.ts b/graphql/medium-upload.ts new file mode 100644 index 000000000..913271299 --- /dev/null +++ b/graphql/medium-upload.ts @@ -0,0 +1,152 @@ +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)); +} + +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, + 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 || !/^\d+$/.test(contentLength)) { + return new Response("Length Required", { status: 411 }); + } + const length = Number(contentLength); + if ( + !Number.isSafeInteger(length) || + length !== session.contentLength || + length > MAX_STREAMING_MEDIUM_IMAGE_SIZE + ) { + return new Response("Payload Too Large", { status: 413 }); + } + 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 + ) { + 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..0863b3d0e 100644 --- a/graphql/post.more.test.ts +++ b/graphql/post.more.test.ts @@ -4,18 +4,26 @@ import { encodeGlobalID } from "@pothos/plugin-relay"; import { eq } from "drizzle-orm"; import { execute, parse } from "graphql"; import type { UserContext } from "./builder.ts"; +import { createArticle, updateArticleDraft } from "@hackerspub/models/article"; import { accountTable, articleContentTable, articleDraftTable, articleSourceTable, + mediumTable, type NewPost, 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, @@ -36,6 +44,9 @@ const saveArticleDraftMutation = parse(` tags } } + ... on InvalidInputError { + inputPath + } } } `); @@ -134,6 +145,112 @@ const articleContentOgImageBulkByLanguageQuery = parse(` } `); +const createMediumMutation = parse(` + mutation CreateMedium($input: CreateMediumInput!) { + createMedium(input: $input) { + __typename + ... on CreateMediumPayload { + medium { + uuid + url + type + contentHash + width + height + } + } + ... on InvalidInputError { + inputPath + } + ... on NotAuthenticatedError { + notAuthenticated + } + } + } +`); + +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) { + __typename + ... on AttachArticleDraftMediumPayload { + key + medium { + uuid + } + } + ... on InvalidInputError { + inputPath + } + ... on NotAuthenticatedError { + notAuthenticated + } + } + } +`); + +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) { + __typename + ... on FinishMediumUploadPayload { + medium { + uuid + } + } + ... on InvalidInputError { + inputPath + } + ... on NotAuthenticatedError { + notAuthenticated + } + } + } +`); + const articleContentOgImageCollisionQuery = parse(` query ArticleContentOgImageCollision( $handle: String! @@ -168,6 +285,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 }) { @@ -324,6 +452,306 @@ 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, + 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, { + 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; + 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$/); + 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("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, { + 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("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, { @@ -401,8 +829,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 +957,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(); @@ -884,6 +1326,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 9b579913c..d5e07575e 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"; @@ -6,6 +7,8 @@ import { getAvatarUrl } from "@hackerspub/models/account"; import { createArticle, deleteArticleDraft, + getArticleDraftMediumUrls, + getArticleSourceMediumUrls, getOriginalArticleContent, LanguageChangeWithTranslationsError, startArticleContentTranslation, @@ -20,7 +23,17 @@ 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, + MAX_STREAMING_MEDIUM_IMAGE_SIZE, + SUPPORTED_MEDIUM_IMAGE_TYPES, + UnsafeMediumUrlError, +} from "@hackerspub/models/medium"; import { createNote } from "@hackerspub/models/note"; import { arePostsPinnedBy, @@ -38,17 +51,18 @@ import { import { react, undoReaction } from "@hackerspub/models/reaction"; import { articleContentTable, + articleDraftMediumTable, articleDraftTable, - articleMediumTable, } 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"; @@ -60,6 +74,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) { @@ -391,7 +406,16 @@ export const ArticleDraft = builder.drizzleNode("articleDraftTable", { }, }, 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, + ), + missingMediumLabel: getMissingArticleMediumLabel( + ctx.account?.locales?.[0], + ), + }); return addExternalLinkTargets( rendered.html, new URL(ctx.fedCtx.canonicalOrigin), @@ -431,6 +455,7 @@ export const ArticleContent = builder.drizzleNode("articleContentTable", { select: { columns: { content: true, + language: true, }, with: { source: { @@ -447,6 +472,12 @@ 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, + ), + missingMediumLabel: getMissingArticleMediumLabel(content.language), }); return addExternalLinkTargets( renderCustomEmojis(html.html, content.source.post.emojis), @@ -468,11 +499,17 @@ export const ArticleContent = builder.drizzleNode("articleContentTable", { type: "JSON", description: "Table of contents for the article content.", select: { - columns: { content: true }, + columns: { content: true, language: 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, + ), + missingMediumLabel: getMissingArticleMediumLabel(content.language), }); return rendered.toc; }, @@ -510,6 +547,7 @@ export const ArticleContent = builder.drizzleNode("articleContentTable", { handleHost: true, }, }, + avatarMedium: true, emails: true, }, }, @@ -521,11 +559,17 @@ 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, + ), + missingMediumLabel: getMissingArticleMediumLabel(content.language), }); 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 +687,41 @@ 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.expose("contentHash", { + type: "Sha256", + 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 +765,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 +786,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 +814,15 @@ 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 ?? []; let replyTarget: schema.Post & { actor: schema.Actor } | undefined; if (replyTargetId != null) { replyTarget = await ctx.db.query.postTable.findFirst({ @@ -739,6 +844,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, { @@ -759,7 +878,7 @@ builder.relayMutationField( ), content, language: language.baseName, - media: [], // TODO + media: noteMedia, }, { replyTarget, quotedPost }, ); @@ -787,6 +906,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,14 +929,20 @@ 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, tags, }); + if (draft == null) { + throw new InvalidInputError(args.input.uuid == null ? "id" : "uuid"); + } return draft; }, @@ -973,6 +1103,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(), @@ -982,6 +1116,7 @@ builder.relayMutationField( title: draft.title, content: draft.content, language: language.baseName, + media, }); }); @@ -989,11 +1124,6 @@ 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)); - // Delete draft after successful publish await deleteArticleDraft(ctx.db, session.accountId, draft.id); @@ -1738,6 +1868,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", { @@ -1748,6 +1893,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.", + }), }), }, { @@ -1778,6 +1929,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, { @@ -1786,6 +1952,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) { @@ -1930,18 +2097,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 +2121,275 @@ builder.relayMutationField( if (session == null) { throw new NotAuthenticatedError(); } - const response = await fetch(args.input.mediaUrl); - if (response.status !== 200) { - throw new InvalidInputError("mediaUrl"); + let medium: schema.Medium | undefined; + try { + medium = await createMediumFromUrl( + ctx.db, + ctx.disk, + args.input.url, + { userAgentUrl: new URL(ctx.fedCtx.canonicalOrigin) }, + ); + } catch (error) { + if (!(error instanceof UnsafeMediumUrlError)) throw error; + } + if (medium == null) { + throw new InvalidInputError("url"); } - const contentType = response.headers.get("Content-Type")?.split(";")[0] - ?.trim(); + return medium; + }, + }, + { + outputFields: (t) => ({ + medium: t.field({ + type: Medium, + resolve(result) { + return result; + }, + }), + }), + }, +); + +interface MediumUploadStart { + uploadId: Uuid; + uploadUrl: URL; + method: string; + headers: { name: string; value: string }[]; + expires: 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 ( - contentType == null || !SUPPORTED_IMAGE_TYPES.includes(contentType) + !SUPPORTED_MEDIUM_IMAGE_TYPES.includes( + args.input.contentType as typeof SUPPORTED_MEDIUM_IMAGE_TYPES[number], + ) ) { - throw new InvalidInputError("mediaUrl"); + throw new InvalidInputError("contentType"); } - const blob = await response.blob(); - if (blob.size > MAX_IMAGE_SIZE) { - throw new InvalidInputError("mediaUrl"); + if ( + args.input.contentLength < 1 || + args.input.contentLength > MAX_STREAMING_MEDIUM_IMAGE_SIZE + ) { + throw new InvalidInputError("contentLength"); } + const upload = await createMediumUploadSession( + ctx.kv, + session.accountId, + args.input.contentType, + args.input.contentLength, + ); + let uploadUrl: URL; 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, - }, - }); - return result; + uploadUrl = new URL( + await ctx.disk.getSignedUploadUrl(upload.key, { + contentType: upload.contentType, + contentSize: upload.contentLength, + contentLength: upload.contentLength, + expiresIn: "30mins", + }), + ); } catch { - throw new InvalidInputError("mediaUrl"); + uploadUrl = new URL(`/medium-uploads/${upload.id}`, ctx.request.url); + uploadUrl.searchParams.set("token", upload.token); } + return { + uploadId: upload.id, + uploadUrl, + method: "PUT", + headers: [{ name: "Content-Type", value: upload.contentType }], + expires: new Date(Date.now() + MEDIUM_UPLOAD_TTL_MS), + } satisfies MediumUploadStart; }, }, { outputFields: (t) => ({ - url: t.field({ + uploadId: t.field({ + type: "UUID", + resolve: (result) => result.uploadId, + }), + uploadUrl: t.field({ type: "URL", - resolve(result: UploadMediaResult) { - return new URL(result.url); - }, + resolve: (result) => result.uploadUrl, + }), + method: t.string({ resolve: (result) => result.method }), + headers: t.field({ + type: [MediumUploadHeader], + resolve: (result) => result.headers, }), - width: t.int({ - resolve(result: UploadMediaResult) { - return result.width; + expires: t.field({ + type: "DateTime", + resolve: (result) => result.expires, + }), + }), + }, +); + +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 { + 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, + }); + 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, + }); + } + } + }, + }, + { + outputFields: (t) => ({ + medium: t.field({ + type: Medium, + resolve(result) { + return result; }, }), - height: t.int({ - resolve(result: UploadMediaResult) { - return result.height; + }), + }, +); + +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.", + }), + }), + }, + { + 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..348134641 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -1,7 +1,10 @@ type Account implements Node { actor: Actor! articleDrafts(after: String, before: String, first: Int, last: Int): AccountArticleDraftsConnection! - avatarUrl: URL! + + """UUID of the medium used as this account's avatar.""" + avatarMediumId: UUID + avatarUrl: URL! @deprecated(reason: "Use avatarMediumId instead.") bio: Markdown! created: DateTime! defaultNoteVisibility: PostVisibility! @@ -390,6 +393,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 +437,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! @@ -470,6 +515,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.""" + failedStorageDeletes: Int! + + """The orphan media status after the deletion attempt.""" + status: OrphanMediaStatus! +} + +union DeleteOrphanMediaResult = DeleteOrphanMediaPayload | NotAuthenticatedError | NotAuthorizedError + input DeletePostInput { clientMutationId: ID id: ID! @@ -509,6 +568,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 @@ -589,7 +660,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! @@ -599,7 +670,12 @@ type InvitationRegenerationStatus { """ When the regeneration was last triggered, or null if it has never been run. """ - lastRegeneratedAt: DateTime + 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)). @@ -674,6 +750,27 @@ scalar Markdown scalar MediaType +type Medium implements Node { + """SHA-256 hash of the normalized stored content, if known.""" + contentHash: Sha256 + 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 +782,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 +805,17 @@ 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! + + """ + 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! getPasskeyAuthenticationOptions( """Temporary session ID for passkey authentication.""" @@ -791,6 +896,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 +906,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! } @@ -876,6 +981,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! @@ -1161,6 +1277,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( @@ -1357,7 +1478,10 @@ type RegenerateInvitationsPayload { accountsAffected: Int! """When the regeneration ran.""" - regeneratedAt: DateTime! + regenerated: DateTime! + + """When the regeneration ran.""" + regeneratedAt: DateTime! @deprecated(reason: "Use regenerated") """The updated regeneration status reflecting the just-recorded run.""" status: InvitationRegenerationStatus! @@ -1461,6 +1585,9 @@ input SaveArticleDraftInput { id: ID tags: [String!]! title: String! + + """Draft UUID to use when creating a new draft.""" + uuid: UUID } type SaveArticleDraftPayload { @@ -1493,6 +1620,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! @@ -1563,6 +1693,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 + expires: DateTime! + headers: [MediumUploadHeader!]! + method: String! + uploadId: UUID! + uploadUrl: URL! +} + +union StartMediumUploadResult = InvalidInputError | NotAuthenticatedError | StartMediumUploadPayload + scalar URITemplate """ @@ -1670,7 +1821,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 @@ -1696,31 +1848,29 @@ 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 } -type UpdateArticlePayload { - article: Article! - clientMutationId: ID -} - -union UpdateArticleResult = InvalidInputError | NotAuthenticatedError | UpdateArticlePayload +input UpdateArticleMediumInput { + """Key used in article markdown as hp-medium:KEY. Defaults to mediumId.""" + key: String -input UploadMediaInput { - clientMutationId: ID - draftId: UUID - mediaUrl: URL! + """UUID of a Medium to make available to the article source.""" + mediumId: UUID! } -type UploadMediaPayload { +type UpdateArticlePayload { + article: Article! clientMutationId: ID - height: Int! - url: URL! - width: Int! } -union UploadMediaResult = InvalidInputError | NotAuthenticatedError | UploadMediaPayload +union UpdateArticleResult = InvalidInputError | NotAuthenticatedError | UpdateArticlePayload input VoteOnPollInput { clientMutationId: ID 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/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..8c7dfe35e 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, @@ -23,6 +28,7 @@ import { accountLinkTable, accountTable, type Actor, + type Medium, type NewAccount, } from "./schema.ts"; import { compactUrl } from "./url.ts"; @@ -32,9 +38,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); @@ -52,12 +63,61 @@ 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, ): Promise< | Account & { actor: Actor & { successor: Actor | null }; + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; } @@ -66,6 +126,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 +136,7 @@ export async function getAccountByUsername( return await db.query.accountTable.findFirst({ with: { actor: { with: { successor: true } }, + avatarMedium: true, emails: true, links: { orderBy: { index: "asc" } }, }, @@ -565,5 +627,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/actor.ts b/models/actor.ts index 050f5fe6e..1dc34da51 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/admin.test.ts b/models/admin.test.ts index a1627f566..c241cfdaa 100644 --- a/models/admin.test.ts +++ b/models/admin.test.ts @@ -2,18 +2,65 @@ import { assert } from "@std/assert/assert"; import { assertEquals } from "@std/assert/equals"; import { eq } from "drizzle-orm"; import { + createTestDisk, createTestKv, insertAccountWithActor, insertNotePost, 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, + articleContentTable, + articleDraftMediumTable, + articleDraftTable, + articleSourceMediumTable, + articleSourceTable, + mediumTable, + noteSourceMediumTable, + noteSourceTable, +} from "./schema.ts"; +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, + }; +} + +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 +311,216 @@ 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/prefix.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 directDraftMediumId = await insertTestMedium( + tx, + "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/direct-draft.webp) ![fs](/media/media/direct-fs-draft.webp) ![prefix](/media/media/prefix.webp-extra)`, + }); + + 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 directSourceMediumId = await insertTestMedium( + tx, + "media/direct-source.webp", + old, + ); + await tx.insert(articleContentTable).values({ + sourceId, + language: "en", + title: "Direct source", + 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, 2); + assert( + await tx.query.mediumTable.findFirst({ + where: { id: directDraftMediumId }, + }) != null, + ); + assert( + await tx.query.mediumTable.findFirst({ + where: { id: directFsDraftMediumId }, + }) != null, + ); + assert( + await tx.query.mediumTable.findFirst({ + where: { id: directSourceMediumId }, + }) != null, + ); + }); + }, +}); + +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: "deleteOrphanMedia reports disk failures after deleting rows", + 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, 2); + assertEquals(result.failedDiskDeletes, 1); + assertEquals(disk.deleteKeys.toSorted(), [ + "media/orphan-delete-fail.webp", + "media/orphan-delete-ok.webp", + ]); + assertEquals( + await tx.query.mediumTable.findFirst({ where: { id: failedId } }), + undefined, + ); + 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 6490b2dc0..8c2474b63 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,12 @@ import { accountTable, actorTable, adminStateTable, + articleContentTable, + articleDraftMediumTable, + articleDraftTable, + articleSourceMediumTable, + mediumTable, + noteSourceMediumTable, postTable, } from "./schema.ts"; import { type Uuid, validateUuid } from "./uuid.ts"; @@ -36,6 +44,10 @@ 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; +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; } @@ -58,6 +70,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 +257,141 @@ 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`; + 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}`; + const keyPathMediumReferencePattern = + sql`'/' || ${mediumKeyPattern} || ${mediumReferenceBoundary}`; + 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} + ) AND + NOT EXISTS ( + SELECT 1 FROM ${articleDraftTable} + WHERE + ${articleDraftTable.content} ~ (${hpMediumReferencePattern}) OR + ${articleDraftTable.content} ~ (${directMediumReferencePattern}) OR + ${articleDraftTable.content} ~ (${keyPathMediumReferencePattern}) + ) AND + NOT EXISTS ( + SELECT 1 FROM ${articleContentTable} + WHERE + ${articleContentTable.content} ~ (${hpMediumReferencePattern}) OR + ${articleContentTable.content} ~ (${directMediumReferencePattern}) OR + ${articleContentTable.content} ~ (${keyPathMediumReferencePattern}) + ) + `; +} + +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 = {}, +): 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 runDeletion = async (tx: Database | Transaction) => { + const orphanMedia = await tx + .select({ id: mediumTable.id, key: mediumTable.key }) + .from(mediumTable) + .where(orphanMediaWhere(cutoffDate)) + .orderBy(mediumTable.created) + .limit(ORPHAN_MEDIA_DELETE_BATCH_SIZE) + .for("update"); + const candidateIds = orphanMedia.map((medium) => medium.id); + return candidateIds.length < 1 ? [] : await tx + .delete(mediumTable) + .where(and( + inArray(mediumTable.id, candidateIds), + orphanMediaWhere(cutoffDate), + )) + .returning({ key: mediumTable.key }); + }; + 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, + }; +} diff --git a/models/article.lifecycle.test.ts b/models/article.lifecycle.test.ts index 067a31eb2..3af82068f 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,96 @@ 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 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, + publishedYear: 2026, + slug: "create-article-media", + tags: [], + allowLlmTranslation: false, + title: "Article with media", + content: "![Hero](hp-medium:hero)", + language: "en", + media: [ + { key: "hero", mediumId }, + { key: "her", mediumId: prefixMediumId }, + ], + }); + + 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); + const prefixMedia = await tx.query.articleSourceMediumTable.findFirst({ + where: { articleSourceId: article.articleSource.id, key: "her" }, + }); + assert.equal(prefixMedia, undefined); + }); +}); + +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); @@ -103,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 31992f85e..7773f9a35 100644 --- a/models/article.ts +++ b/models/article.ts @@ -10,10 +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, @@ -25,6 +35,7 @@ import { type ArticleDraft, articleDraftTable, type ArticleSource, + articleSourceMediumTable, articleSourceTable, type Blocking, type Following, @@ -40,6 +51,127 @@ 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, + 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 @@ -159,7 +291,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" }, @@ -232,6 +364,10 @@ export async function createArticle( title: string; content: string; language: string; + media?: readonly { + key: string; + mediumId: Uuid; + }[]; }, ): Promise< Post & { @@ -246,15 +382,34 @@ export async function createArticle( } | undefined > { const { db } = fedCtx.data; + const { media: sourceMedia, ...articleSourceInput } = source; + const referencedMediumKeys = extractArticleMediumKeys(source.content); + 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, - source, + articleSourceInput, ); if (articleSource == null) return undefined; + const media = sourceMedia + ?.filter((medium) => referencedMediumKeys.has(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: { emails: true, links: true }, + with: { avatarMedium: true, emails: true, links: true }, }); if (account == undefined) return undefined; const post = await syncPostFromArticleSource(fedCtx, { @@ -329,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 @@ -429,6 +609,7 @@ export async function updateArticle( title?: string; content?: string; language?: string; + media?: readonly ArticleMediumInput[]; }, ): Promise< Post & { @@ -455,7 +636,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.test.ts b/models/markup.test.ts index 8aa97c75c..9c78cebcf 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,94 @@ 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"), + }); + + 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 0b8493952..5368d54fc 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 }) @@ -170,6 +181,16 @@ export interface RenderMarkupOptions { kv?: Keyv | null; docId?: string | null; refresh?: boolean; + mediumUrls?: Record; + 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( @@ -177,12 +198,17 @@ export async function renderMarkup( markup: string, options: RenderMarkupOptions = {}, ): Promise { + 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${markup}`, + `${JSON.stringify(options.docId ?? null)}\n${ + canonicalizeMediumUrls(mediumUrls) + }\n${JSON.stringify(missingMediumLabel)}\n${markup}`, ), ); cacheKey = `${KV_NAMESPACE}/${KV_CACHE_VERSION}/markup/${ @@ -225,9 +251,12 @@ export async function renderMarkup( ' "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">', "", ); - 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, @@ -244,6 +273,126 @@ export async function renderMarkup( return rendered; } +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] ?? 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/medium.test.ts b/models/medium.test.ts index b892f9cdc..e46f63a33 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,115 @@ 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("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 = { + 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("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 94feba2b4..0bee86612 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,285 @@ 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 REMOTE_MEDIUM_FETCH_TIMEOUT_MS = 30_000; + +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}`); + 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 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; + } + 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(""); +} + +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, + bytes: Uint8Array | ArrayBuffer, + options: { + maxSize?: number; + contentType?: string | null; + preprocess?: MediumPreprocess; + } = {}, +): Promise { + 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 ( + 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; + } + 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; preprocess?: MediumPreprocess } = {}, +): 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; + preprocess?: MediumPreprocess; + } = {}, +): 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 bytes = await readResponseBytes(response, maxSize); + if (bytes == null) return undefined; + return await createMediumFromBytes(db, disk, bytes, { + maxSize, + contentType, + preprocess: options.preprocess, + }); +} + +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.lifecycle.test.ts b/models/note.lifecycle.test.ts index 0c80bd58b..563b47a38 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,74 @@ 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() 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.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..a5e7d2e45 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({ @@ -129,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: { @@ -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,15 +325,22 @@ 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); - if (m != null) media.push(m); + const m = await createNoteSourceMedium( + db, + disk, + noteSource.id, + index, + medium, + ); + if (m == null) return undefined; + media.push(m); index++; } 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, { @@ -470,7 +478,7 @@ export async function updateNote( }; noteSource: NoteSource & { account: Account & { emails: AccountEmail[]; links: AccountLink[] }; - media: NoteMedium[]; + media: NoteSourceMediumWithMedium[]; }; mentions: Mention[]; media: PostMedium[]; @@ -484,10 +492,12 @@ 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.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..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,8 +131,8 @@ 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 } }, - media: true, + account: { with: { avatarMedium: true, emails: true, links: true } }, + media: { with: { medium: true } }, }, }); assert.ok(noteSource != null); @@ -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,8 +162,8 @@ 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 } }, - media: true, + account: { with: { avatarMedium: true, emails: true, links: true } }, + media: { with: { medium: true } }, }, }); assert.ok(updatedSource != null); diff --git a/models/post.ts b/models/post.ts index f5eaeaaec..21bc8e4d6 100644 --- a/models/post.ts +++ b/models/post.ts @@ -40,12 +40,15 @@ 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"; import { extractExternalLinks } from "./html.ts"; -import { renderMarkup } from "./markup.ts"; +import { getMissingArticleMediumLabel, renderMarkup } from "./markup.ts"; import { persistPostMedium } from "./medium.ts"; import { createShareNotification, @@ -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,8 @@ export async function syncPostFromArticleSource( const rendered = await renderMarkup(fedCtx, content.content, { docId: articleSource.id, kv, + mediumUrls: await getArticleSourceMediumUrls(db, disk, articleSource.id), + missingMediumLabel: getMissingArticleMediumLabel(content.language), }); const url = `${fedCtx.origin}/@${articleSource.account.username}/${articleSource.publishedYear}/${ @@ -221,8 +241,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 +255,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; @@ -319,11 +351,11 @@ export async function syncPostFromNoteSource( await Promise.all(noteSource.media.map(async (medium) => ({ postId: post.id, index: medium.index, - type: "image/webp" as const, - url: await disk.getUrl(medium.key), + type: medium.medium.type, + 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() : []; @@ -882,6 +914,7 @@ async function getOriginalSharedPost( export async function sharePost( fedCtx: Context, account: Account & { + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }, @@ -966,6 +999,7 @@ export async function sharePost( export async function unsharePost( fedCtx: Context, account: Account & { + avatarMedium: Medium | null; emails: AccountEmail[]; links: AccountLink[]; }, 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..1dae63f58 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), @@ -80,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}$'`, @@ -566,26 +569,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] }), + index("note_source_medium_medium_id_idx").on(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 +1306,56 @@ 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] }), + index("article_draft_medium_medium_id_idx").on(table.mediumId), + ], +); + +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] }), + index("article_source_medium_medium_id_idx").on(table.mediumId), + ], ); -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/test/postgres.ts b/test/postgres.ts index dc3dfd95a..608cac6ae 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, }, @@ -305,14 +306,32 @@ 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); + }, + 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); }, - delete() { + delete(key: string) { + files.delete(key); return Promise.resolve(undefined); }, } as unknown as ContextData["disk"]; 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-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/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/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/en-US/messages.po b/web-next/src/locales/en-US/messages.po index 2572000f2..a11e151ef 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.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.}}" + #. 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: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.}}" + #. 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:171 msgid "Cutoff:" msgstr "Cutoff:" @@ -536,12 +553,18 @@ msgstr "Delete" msgid "Delete draft" msgstr "Delete draft" +#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:194 +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:194 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:159 +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: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." + #: 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/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/ja-JP/messages.po b/web-next/src/locales/ja-JP/messages.po index 49bf11a06..55223e948 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.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 {#個のディスクオブジェクトを削除できませんでした。}}" + #. 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:179 +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:171 msgid "Cutoff:" msgstr "カットオフ:" @@ -536,12 +553,18 @@ msgstr "削除" msgid "Delete draft" msgstr "下書きを削除" +#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:194 +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:194 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:159 +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:165 +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/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/ko-KR/messages.po b/web-next/src/locales/ko-KR/messages.po index 6c2d4fd92..ac888dbdf 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.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 {디스크 객체 #개를 삭제하지 못했습니다.}}" + #. 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:179 +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:171 msgid "Cutoff:" msgstr "기준 시각:" @@ -536,12 +553,18 @@ msgstr "삭제" msgid "Delete draft" msgstr "임시 보관 삭제" +#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:194 +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:194 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:159 +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:165 +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/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-CN/messages.po b/web-next/src/locales/zh-CN/messages.po index fde55f42f..149ada530 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.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 {# 个磁盘对象无法删除。}}" + #. 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:179 +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:171 msgid "Cutoff:" msgstr "截止时间:" @@ -536,12 +553,18 @@ msgstr "删除" msgid "Delete draft" msgstr "删除草稿" +#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:194 +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:194 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:159 +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:165 +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/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/locales/zh-TW/messages.po b/web-next/src/locales/zh-TW/messages.po index 6b5385831..1a5fc6236 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.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 {# 個磁碟物件無法刪除。}}" + #. 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:179 +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:171 msgid "Cutoff:" msgstr "截止時間:" @@ -536,12 +553,18 @@ msgstr "刪除" msgid "Delete draft" msgstr "刪除草稿" +#: src/routes/(root)/admin/media.tsx:163 +#: src/routes/(root)/admin/media.tsx:194 +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:194 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:159 +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:165 +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,13 +2013,13 @@ 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/你的使用者名稱 。" +msgstr "該連結的 URL,例如 https://github.com/你的使用者名稱。" #: src/components/PostActionMenu.tsx:351 msgid "This action cannot be undone. This will permanently delete this post." @@ -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)。" +msgstr "你的使用者名稱將用於建立你的個人資料 URL 和你的聯邦宇宙識別碼。" diff --git a/web-next/src/routes/(root)/[handle]/settings/index.tsx b/web-next/src/routes/(root)/[handle]/settings/index.tsx index ead42b701..9c1e8fb1e 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,8 +263,9 @@ function SettingsForm(props: SettingsFormProps) { } const [save] = createMutation(settingsMutation); const [saving, setSaving] = createSignal(false); - function onSubmit(event: SubmitEvent) { + async function onSubmit(event: SubmitEvent) { event.preventDefault(); + if (saving()) return; const id = account()?.id; const usernameChanged = account()?.usernameChanged; if ( @@ -274,6 +276,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 +314,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-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..6be0f0aa1 --- /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 failedStorageDeletes: 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": "failedStorageDeletes", + "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": "c8b140a298ff0a8c9b5093b190e70ed5", + "id": null, + "metadata": {}, + "name": "mediaDeleteOrphanMediaMutation", + "operationKind": "mutation", + "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 = "cc7784dd692929a263e9631ff1e07e91"; + +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/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`} 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..c6fcc526c --- /dev/null +++ b/web-next/src/routes/(root)/admin/media.tsx @@ -0,0 +1,206 @@ +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 + failedStorageDeletes + 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.failedStorageDeletes! > 0 + ? i18n._( + msg`${ + plural(result.failedStorageDeletes!, { + one: "# disk object could not be deleted.", + other: "# disk objects could not be deleted.", + }) + }`, + ) + : undefined, + variant: result.failedStorageDeletes! > 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/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}`", 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/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]/[idOrYear]/[slug]/index.tsx b/web/routes/@[username]/[idOrYear]/[slug]/index.tsx index 307c4e0ae..6a6a0447d 100644 --- a/web/routes/@[username]/[idOrYear]/[slug]/index.tsx +++ b/web/routes/@[username]/[idOrYear]/[slug]/index.tsx @@ -3,12 +3,17 @@ import { type FreshContext, page } from "@fresh/core"; import { getAvatarUrl } from "@hackerspub/models/account"; import { getArticleSource, + getArticleSourceMediumUrls, getOriginalArticleContent, startArticleContentSummary, 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 { @@ -132,12 +137,15 @@ 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), + missingMediumLabel: getMissingArticleMediumLabel(content.language), refresh: ctx.url.searchParams.has("refresh") && ctx.state.account?.moderator, }, @@ -228,7 +236,6 @@ export async function handleArticle( where: { replyTargetId: article.post.id }, orderBy: { published: "asc" }, }); - const disk = drive.use(); return { article, content, 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/[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( 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/@[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..9ef139265 100644 --- a/web/routes/@[username]/settings/index.tsx +++ b/web/routes/@[username]/settings/index.tsx @@ -1,7 +1,7 @@ import { page } from "@fresh/core"; import { + createAvatarMediumFromBlob, getAvatarUrl, - transformAvatar, updateAccount, } from "@hackerspub/models/account"; import { syncActorFromAccount } from "@hackerspub/models/actor"; @@ -37,7 +37,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,9 +60,15 @@ 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(); + 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(); @@ -124,20 +134,26 @@ 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 medium = await createAvatarMediumFromBlob(db, disk, avatar, { + maxSize: MAX_AVATAR_SIZE, + }); + 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, + }); } - 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; + values.avatarMediumId = medium.id; } const updatedAccount = await updateAccount(ctx.state.fedCtx, values); if (updatedAccount == null) { @@ -147,15 +163,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..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"; @@ -70,12 +71,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?: 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/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. +

+ +
+ +
+
+
+ ); + }, +); 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, }); 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: { 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[]; };