From 5a6aa715ba49aec53aaaebac85033276a62599d9 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 15 Jun 2024 01:45:12 +0900 Subject: [PATCH] POST /api/v2/media --- README.md | 2 +- biome.json | 3 + bun.lockb | Bin 125972 -> 137410 bytes drizzle/0022_media.sql | 20 + drizzle/meta/0022_snapshot.json | 1173 +++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 1 + src/api/v1/accounts.ts | 25 +- src/api/v1/index.ts | 52 +- src/api/v1/instance.ts | 13 +- src/api/v1/media.ts | 18 + src/api/v1/notifications.ts | 33 +- src/api/v1/statuses.ts | 262 +------ src/api/v1/timelines.ts | 74 +- src/api/v2/index.ts | 53 +- src/api/v2/instance.ts | 13 +- src/entities/medium.ts | 30 + src/entities/status.ts | 51 +- src/federation/index.ts | 54 +- src/federation/post.ts | 64 +- src/media.ts | 52 ++ src/schema.ts | 27 + 22 files changed, 1558 insertions(+), 469 deletions(-) create mode 100644 drizzle/0022_media.sql create mode 100644 drizzle/meta/0022_snapshot.json create mode 100644 src/api/v1/media.ts create mode 100644 src/entities/medium.ts create mode 100644 src/media.ts diff --git a/README.md b/README.md index b81f2a1..f8448b0 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Current features and roadmap - [ ] Pinned posts - [x] Mentions - [x] Hashtags -- [ ] Images +- [x] Media attachments - [ ] Polls - [x] Likes (favorites) - [x] Shares (reblogs) diff --git a/biome.json b/biome.json index e9cfa69..20e7b24 100644 --- a/biome.json +++ b/biome.json @@ -16,6 +16,9 @@ "recommended": true, "correctness": { "useJsxKeyInIterable": "off" + }, + "style": { + "noNonNullAssertion": "off" } } } diff --git a/bun.lockb b/bun.lockb index 2d19ddc1443dab0bd2180a566ed67230cfce9280..afe1e99853e0c7235c3aa323917b8e4d6a52337e 100755 GIT binary patch delta 31118 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 SwMzWnRyktVkmGW statement-breakpoint +DO $$ BEGIN + ALTER TABLE "media" ADD CONSTRAINT "media_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/drizzle/meta/0022_snapshot.json b/drizzle/meta/0022_snapshot.json new file mode 100644 index 0000000..90ba5b0 --- /dev/null +++ b/drizzle/meta/0022_snapshot.json @@ -0,0 +1,1173 @@ +{ + "id": "aa782e81-6518-493a-9a67-e0e745e68a70", + "prevId": "40f68b26-30fc-4985-aebf-290686632550", + "version": "6", + "dialect": "postgresql", + "tables": { + "public.access_tokens": { + "name": "access_tokens", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "grant_type": { + "name": "grant_type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'authorization_code'" + }, + "scopes": { + "name": "scopes", + "type": "scope[]", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "access_tokens_application_id_applications_id_fk": { + "name": "access_tokens_application_id_applications_id_fk", + "tableFrom": "access_tokens", + "tableTo": "applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_tokens_account_owner_id_account_owners_id_fk": { + "name": "access_tokens_account_owner_id_account_owners_id_fk", + "tableFrom": "access_tokens", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.account_owners": { + "name": "account_owners", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_jwk": { + "name": "private_key_jwk", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "public_key_jwk": { + "name": "public_key_jwk", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "fields": { + "name": "fields", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "followed_tags": { + "name": "followed_tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": [] + }, + "visibility": { + "name": "visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": {}, + "foreignKeys": { + "account_owners_id_accounts_id_fk": { + "name": "account_owners_id_accounts_id_fk", + "tableFrom": "account_owners", + "tableTo": "accounts", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_owners_handle_unique": { + "name": "account_owners_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + } + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio_html": { + "name": "bio_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_url": { + "name": "inbox_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followers_url": { + "name": "followers_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_inbox_url": { + "name": "shared_inbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "following_count": { + "name": "following_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "followers_count": { + "name": "followers_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "posts_count": { + "name": "posts_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "field_htmls": { + "name": "field_htmls", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_iri_unique": { + "name": "accounts_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + }, + "accounts_handle_unique": { + "name": "accounts_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + } + }, + "public.applications": { + "name": "applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "scope[]", + "primaryKey": false, + "notNull": true + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "applications_client_id_unique": { + "name": "applications_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + } + }, + "public.bookmarks": { + "name": "bookmarks", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarks_post_id_posts_id_fk": { + "name": "bookmarks_post_id_posts_id_fk", + "tableFrom": "bookmarks", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarks_account_owner_id_account_owners_id_fk": { + "name": "bookmarks_account_owner_id_account_owners_id_fk", + "tableFrom": "bookmarks", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarks_post_id_account_owner_id_pk": { + "name": "bookmarks_post_id_account_owner_id_pk", + "columns": [ + "post_id", + "account_owner_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "varchar(254)", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shares": { + "name": "shares", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify": { + "name": "notify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "languages": { + "name": "languages", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "approved": { + "name": "approved", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "follows_following_id_accounts_id_fk": { + "name": "follows_following_id_accounts_id_fk", + "tableFrom": "follows", + "tableTo": "accounts", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follows_follower_id_accounts_id_fk": { + "name": "follows_follower_id_accounts_id_fk", + "tableFrom": "follows", + "tableTo": "accounts", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follows_following_id_follower_id_pk": { + "name": "follows_following_id_follower_id_pk", + "columns": [ + "following_id", + "follower_id" + ] + } + }, + "uniqueConstraints": { + "follows_iri_unique": { + "name": "follows_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + } + } + }, + "public.likes": { + "name": "likes", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "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": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "likes_post_id_posts_id_fk": { + "name": "likes_post_id_posts_id_fk", + "tableFrom": "likes", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "likes_account_id_accounts_id_fk": { + "name": "likes_account_id_accounts_id_fk", + "tableFrom": "likes", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "likes_post_id_account_id_pk": { + "name": "likes_post_id_account_id_pk", + "columns": [ + "post_id", + "account_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.markers": { + "name": "markers", + "schema": "", + "columns": { + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "marker_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "last_read_id": { + "name": "last_read_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "markers_account_owner_id_account_owners_id_fk": { + "name": "markers_account_owner_id_account_owners_id_fk", + "tableFrom": "markers", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "markers_account_owner_id_type_pk": { + "name": "markers_account_owner_id_type_pk", + "columns": [ + "account_owner_id", + "type" + ] + } + }, + "uniqueConstraints": {} + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_type": { + "name": "thumbnail_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thumbnail_width": { + "name": "thumbnail_width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "thumbnail_height": { + "name": "thumbnail_height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "media_post_id_posts_id_fk": { + "name": "media_post_id_posts_id_fk", + "tableFrom": "media", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.mentions": { + "name": "mentions", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "mentions_post_id_posts_id_fk": { + "name": "mentions_post_id_posts_id_fk", + "tableFrom": "mentions", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mentions_account_id_accounts_id_fk": { + "name": "mentions_account_id_accounts_id_fk", + "tableFrom": "mentions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mentions_post_id_account_id_pk": { + "name": "mentions_post_id_account_id_pk", + "columns": [ + "post_id", + "account_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.posts": { + "name": "posts", + "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 + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reply_target_id": { + "name": "reply_target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sharing_id": { + "name": "sharing_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "summary_html": { + "name": "summary_html", + "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": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_card": { + "name": "preview_card", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "replies_count": { + "name": "replies_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "shares_count": { + "name": "shares_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "likes_count": { + "name": "likes_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "posts_actor_id_accounts_id_fk": { + "name": "posts_actor_id_accounts_id_fk", + "tableFrom": "posts", + "tableTo": "accounts", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_application_id_applications_id_fk": { + "name": "posts_application_id_applications_id_fk", + "tableFrom": "posts", + "tableTo": "applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "posts_reply_target_id_posts_id_fk": { + "name": "posts_reply_target_id_posts_id_fk", + "tableFrom": "posts", + "tableTo": "posts", + "columnsFrom": [ + "reply_target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "posts_sharing_id_posts_id_fk": { + "name": "posts_sharing_id_posts_id_fk", + "tableFrom": "posts", + "tableTo": "posts", + "columnsFrom": [ + "sharing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "posts_iri_unique": { + "name": "posts_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + } + } + } + }, + "enums": { + "public.account_type": { + "name": "account_type", + "schema": "public", + "values": [ + "Application", + "Group", + "Organization", + "Person", + "Service" + ] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "authorization_code", + "client_credentials" + ] + }, + "public.marker_type": { + "name": "marker_type", + "schema": "public", + "values": [ + "notifications", + "home" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": [ + "Article", + "Note" + ] + }, + "public.post_visibility": { + "name": "post_visibility", + "schema": "public", + "values": [ + "public", + "unlisted", + "private", + "direct" + ] + }, + "public.scope": { + "name": "scope", + "schema": "public", + "values": [ + "read", + "read:accounts", + "read:blocks", + "read:bookmarks", + "read:favourites", + "read:filters", + "read:follows", + "read:lists", + "read:mutes", + "read:notifications", + "read:search", + "read:statuses", + "write", + "write:accounts", + "write:blocks", + "write:bookmarks", + "write:conversations", + "write:favourites", + "write:filters", + "write:follows", + "write:lists", + "write:media", + "write:mutes", + "write:notifications", + "write:reports", + "write:statuses", + "follow", + "push" + ] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d965166..6e30c5d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1716861308732, "tag": "0021_markers", "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1718378766046, + "tag": "0022_media", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index c788f5b..01b4e22 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "markdown-it-replace-link": "^1.2.1", "open-graph-scraper": "^6.5.1", "postgres": "^3.4.4", + "sharp": "^0.33.4", "uuidv7-js": "^1.0.12", "x-forwarded-fetch": "^0.2.0", "xss": "^1.0.15", diff --git a/src/api/v1/accounts.ts b/src/api/v1/accounts.ts index d6ece52..bf9f390 100644 --- a/src/api/v1/accounts.ts +++ b/src/api/v1/accounts.ts @@ -20,7 +20,7 @@ import { serializeAccount, serializeAccountOwner, } from "../../entities/account"; -import { serializePost } from "../../entities/status"; +import { getPostRelations, serializePost } from "../../entities/status"; import { federation } from "../../federation"; import { persistAccount } from "../../federation/account"; import { type Variables, scopeRequired, tokenRequired } from "../../oauth"; @@ -29,9 +29,7 @@ import { type NewFollow, accountOwners, accounts, - bookmarks, follows, - likes, mentions, posts, } from "../../schema"; @@ -483,26 +481,7 @@ app.get( query.max_id == null ? undefined : lte(posts.id, query.max_id), query.min_id == null ? undefined : gte(posts.id, query.min_id), ), - with: { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, tokenOwner.id) }, - shares: { where: eq(posts.accountId, tokenOwner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, tokenOwner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, tokenOwner.id) }, - shares: { where: eq(posts.accountId, tokenOwner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, tokenOwner.id) }, - }, + with: getPostRelations(tokenOwner.id), orderBy: [desc(posts.id)], limit: query.limit ?? 20, }); diff --git a/src/api/v1/index.ts b/src/api/v1/index.ts index be5723c..d545eab 100644 --- a/src/api/v1/index.ts +++ b/src/api/v1/index.ts @@ -3,15 +3,16 @@ import { and, desc, eq, lt } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import { db } from "../../db"; -import { serializePost } from "../../entities/status"; +import { getPostRelations, serializePost } from "../../entities/status"; import { serializeTag } from "../../entities/tag"; import { type Variables, scopeRequired, tokenRequired } from "../../oauth"; -import { bookmarks, likes, posts } from "../../schema"; +import { bookmarks, likes } from "../../schema"; import accounts from "./accounts"; import apps from "./apps"; import follow_requests from "./follow_requests"; import instance from "./instance"; import markers from "./markers"; +import media from "./media"; import notifications from "./notifications"; import statuses from "./statuses"; import tags from "./tags"; @@ -24,6 +25,7 @@ app.route("/accounts", accounts); app.route("/follow_requests", follow_requests); app.route("/instance", instance); app.route("/markers", markers); +app.route("/media", media); app.route("/notifications", notifications); app.route("/statuses", statuses); app.route("/tags", tags); @@ -84,28 +86,7 @@ app.get( : lt(likes.created, new Date(query.before)), ), with: { - post: { - with: { - account: { with: { owner: true } }, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, + post: { with: getPostRelations(owner.id) }, }, orderBy: [desc(likes.created)], limit: query.limit, @@ -160,28 +141,7 @@ app.get( : lt(bookmarks.created, new Date(query.before)), ), with: { - post: { - with: { - account: { with: { owner: true } }, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, + post: { with: getPostRelations(owner.id) }, }, orderBy: [desc(bookmarks.created)], limit: query.limit, diff --git a/src/api/v1/instance.ts b/src/api/v1/instance.ts index 21d9774..0bf904b 100644 --- a/src/api/v1/instance.ts +++ b/src/api/v1/instance.ts @@ -50,14 +50,19 @@ app.get("/", async (c) => { statuses: { // TODO max_characters: 4096, - max_media_attachments: 0, + max_media_attachments: 8, characters_reserved_per_url: 256, }, media_attachments: { + supported_mime_types: [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + ], + image_size_limit: 1024 * 1024 * 32, // 32MiB + image_matrix_limit: 16_777_216, // TODO - supported_mime_types: [], - image_size_limit: 0, - image_matrix_limit: 0, video_size_limit: 0, video_frame_rate_limit: 0, video_matrix_limit: 0, diff --git a/src/api/v1/media.ts b/src/api/v1/media.ts new file mode 100644 index 0000000..4e2ea4c --- /dev/null +++ b/src/api/v1/media.ts @@ -0,0 +1,18 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { db } from "../../db"; +import { serializeMedium } from "../../entities/medium"; +import type { Variables } from "../../oauth"; +import { media } from "../../schema"; + +const app = new Hono<{ Variables: Variables }>(); + +app.get("/:id", async (c) => { + const medium = await db.query.media.findFirst({ + where: eq(media.id, c.req.param("id")), + }); + if (medium == null) return c.json({ error: "Not found" }, 404); + return c.json(serializeMedium(medium)); +}); + +export default app; diff --git a/src/api/v1/notifications.ts b/src/api/v1/notifications.ts index b9f3c4d..b1a32dd 100644 --- a/src/api/v1/notifications.ts +++ b/src/api/v1/notifications.ts @@ -6,16 +6,9 @@ import { serializeAccount, serializeAccountOwner, } from "../../entities/account"; -import { serializePost } from "../../entities/status"; +import { getPostRelations, serializePost } from "../../entities/status"; import { type Variables, scopeRequired, tokenRequired } from "../../oauth"; -import { - accounts, - bookmarks, - follows, - likes, - mentions, - posts, -} from "../../schema"; +import { accounts, follows, likes, mentions, posts } from "../../schema"; const app = new Hono<{ Variables: Variables }>(); @@ -141,7 +134,6 @@ app.get( const accountIds = notifications.map((n) => n.accountId); const postIds = notifications .filter((n) => n.postId != null) - // biome-ignore lint/style/noNonNullAssertion: filtered .map((n) => n.postId!); const accountMap = Object.fromEntries( (accountIds.length > 0 @@ -156,26 +148,7 @@ app.get( (postIds.length > 0 ? await db.query.posts.findMany({ where: inArray(posts.id, postIds), - with: { - account: { with: { owner: true } }, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), }) : [] ).map((p) => [p.id, p]), diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index f7c28ea..97b33d0 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -1,7 +1,7 @@ import { Note, Undo } from "@fedify/fedify"; import * as vocab from "@fedify/fedify/vocab"; import { zValidator } from "@hono/zod-validator"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { Hono } from "hono"; import { uuidv7 } from "uuidv7-js"; import { z } from "zod"; @@ -10,7 +10,7 @@ import { serializeAccount, serializeAccountOwner, } from "../../entities/account"; -import { serializePost } from "../../entities/status"; +import { getPostRelations, serializePost } from "../../entities/status"; import federation from "../../federation"; import { toAnnounce, toCreate } from "../../federation/post"; import { type Variables, scopeRequired, tokenRequired } from "../../oauth"; @@ -22,6 +22,7 @@ import { type NewPost, bookmarks, likes, + media, mentions, posts, } from "../../schema"; @@ -124,6 +125,19 @@ app.post( previewCard, published, }); + if (data.media_ids != null && data.media_ids.length > 0) { + for (const mediaId of data.media_ids) { + const result = await tx + .update(media) + .set({ postId: id }) + .where(and(eq(media.id, mediaId), isNull(media.postId))) + .returning(); + if (result.length < 1) { + tx.rollback(); + return c.json({ error: "Media not found" }, 422); + } + } + } if (mentionedIds.length > 0) { await tx.insert(mentions).values( mentionedIds.map((accountId) => ({ @@ -133,29 +147,9 @@ app.post( ); } }); - // biome-ignore lint/style/noNonNullAssertion: post is never null const post = (await db.query.posts.findFirst({ where: eq(posts.id, id), - with: { - account: { with: { owner: true } }, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), }))!; const activity = toCreate(post, fedCtx); if (post.visibility === "direct") { @@ -255,28 +249,8 @@ app.put( }); const post = await db.query.posts.findFirst({ where: eq(posts.id, id), - with: { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), }); - // biome-ignore lint/style/noNonNullAssertion: never null return c.json(serializePost(post!, owner, c.req.url)); }, ); @@ -289,26 +263,7 @@ app.get("/:id", tokenRequired, scopeRequired(["read:statuses"]), async (c) => { const id = c.req.param("id"); const post = await db.query.posts.findFirst({ where: eq(posts.id, id), - with: { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), }); if (post == null) return c.json({ error: "Record not found" }, 404); return c.json(serializePost(post, owner, c.req.url)); @@ -329,26 +284,7 @@ app.delete( const id = c.req.param("id"); const post = await db.query.posts.findFirst({ where: eq(posts.id, id), - with: { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), }); if (post == null) return c.json({ error: "Record not found" }, 404); await db.delete(posts).where(eq(posts.id, id)); @@ -407,30 +343,9 @@ app.get( ); } const id = c.req.param("id"); - const with_ = { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - replies: true, - } as const; const post = await db.query.posts.findFirst({ where: eq(posts.id, id), - with: with_, + with: getPostRelations(owner.id), }); if (post == null) return c.json({ error: "Record not found" }, 404); const ancestors: (typeof post)[] = []; @@ -438,7 +353,7 @@ app.get( while (p.replyTargetId != null) { p = await db.query.posts.findFirst({ where: eq(posts.id, p.replyTargetId), - with: with_, + with: getPostRelations(owner.id), }); if (p == null) break; ancestors.unshift(p); @@ -450,7 +365,7 @@ app.get( if (p == null) break; const replies = await db.query.posts.findMany({ where: eq(posts.replyTargetId, p.id), - with: with_, + with: getPostRelations(owner.id), }); descendants.push(...replies); ps.push(...replies); @@ -490,26 +405,7 @@ app.post( } const post = await db.query.posts.findFirst({ where: eq(posts.id, postId), - with: { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), }); if (post == null) { return c.json({ error: "Record not found" }, 404); @@ -556,26 +452,7 @@ app.post( const like = result[0]; const post = await db.query.posts.findFirst({ where: eq(posts.id, postId), - with: { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), }); if (post == null) { return c.json({ error: "Record not found" }, 404); @@ -694,38 +571,17 @@ app.post( }); const post = await db.query.posts.findFirst({ where: eq(posts.id, id), - with: { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), }); await fedCtx.sendActivity( { handle: owner.handle }, "followers", - // biome-ignore lint/style/noNonNullAssertion: never null toAnnounce(post!, fedCtx), { preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)], }, ); - // biome-ignore lint/style/noNonNullAssertion: never null return c.json(serializePost(post!, owner, c.req.url)); }, ); @@ -784,28 +640,8 @@ app.post( } const originalPost = await db.query.posts.findFirst({ where: eq(posts.id, originalPostId), - with: { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), }); - // biome-ignore lint/style/noNonNullAssertion: never null return c.json(serializePost(originalPost!, owner, c.req.url)); }, ); @@ -833,28 +669,8 @@ app.post( } const post = await db.query.posts.findFirst({ where: eq(posts.id, postId), - with: { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), }); - // biome-ignore lint/style/noNonNullAssertion: never null return c.json(serializePost(post!, owner, c.req.url)); }, ); @@ -886,28 +702,8 @@ app.post( } const post = await db.query.posts.findFirst({ where: eq(posts.id, postId), - with: { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), }); - // biome-ignore lint/style/noNonNullAssertion: never null return c.json(serializePost(post!, owner, c.req.url)); }, ); diff --git a/src/api/v1/timelines.ts b/src/api/v1/timelines.ts index d84ca18..24a5871 100644 --- a/src/api/v1/timelines.ts +++ b/src/api/v1/timelines.ts @@ -16,16 +16,9 @@ import { import { Hono } from "hono"; import { z } from "zod"; import { db } from "../../db"; -import { serializePost } from "../../entities/status"; +import { getPostRelations, serializePost } from "../../entities/status"; import { type Variables, scopeRequired, tokenRequired } from "../../oauth"; -import { - accountOwners, - bookmarks, - follows, - likes, - mentions, - posts, -} from "../../schema"; +import { accountOwners, follows, mentions, posts } from "../../schema"; const app = new Hono<{ Variables: Variables }>(); @@ -84,26 +77,7 @@ app.get( query.max_id == null ? undefined : lt(posts.id, query.max_id), query.min_id == null ? undefined : gt(posts.id, query.min_id), ), - with: { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), orderBy: [desc(posts.id)], limit: query.limit, }); @@ -192,26 +166,7 @@ app.get( query.max_id == null ? undefined : lt(posts.id, query.max_id), query.min_id == null ? undefined : gt(posts.id, query.min_id), ), - with: { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), orderBy: [desc(posts.id)], limit: query.limit, }); @@ -283,26 +238,7 @@ app.get( query.max_id == null ? undefined : lt(posts.id, query.max_id), query.min_id == null ? undefined : gt(posts.id, query.min_id), ), - with: { - account: true, - application: true, - replyTarget: true, - sharing: { - with: { - account: true, - application: true, - replyTarget: true, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, - }, - mentions: { with: { account: { with: { owner: true } } } }, - likes: { where: eq(likes.accountId, owner.id) }, - shares: { where: eq(posts.accountId, owner.id) }, - bookmarks: { where: eq(bookmarks.accountOwnerId, owner.id) }, - }, + with: getPostRelations(owner.id), orderBy: [desc(posts.id)], limit: query.limit, }); diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index 477a3f8..bde68ae 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -1,8 +1,59 @@ +import { PutObjectCommand } from "@aws-sdk/client-s3"; import { Hono } from "hono"; +import sharp from "sharp"; +import { uuidv7 } from "uuidv7-js"; +import { db } from "../../db"; +import { serializeMedium } from "../../entities/medium"; +import { uploadThumbnail } from "../../media"; +import { type Variables, scopeRequired, tokenRequired } from "../../oauth"; +import { S3_BUCKET, S3_URL_BASE, s3 } from "../../s3"; +import { media } from "../../schema"; import instance from "./instance"; -const app = new Hono(); +const app = new Hono<{ Variables: Variables }>(); app.route("/instance", instance); +app.post("/media", tokenRequired, scopeRequired(["write:media"]), async (c) => { + const owner = c.get("token").accountOwner; + if (owner == null) { + return c.json({ error: "This method requires an authenticated user" }, 422); + } + const form = await c.req.formData(); + const file = form.get("file"); + if (!(file instanceof File)) { + return c.json({ error: "file is required" }, 422); + } + const description = form.get("description")?.toString(); + const id = uuidv7(); + const fileBuffer = await file.arrayBuffer(); + const image = sharp(fileBuffer); + const fileMetadata = await image.metadata(); + await s3.send( + new PutObjectCommand({ + Bucket: S3_BUCKET, + Key: `media/${id}/original`, + Body: new Uint8Array(fileBuffer), + ContentType: file.type, + }), + ); + const url = new URL(`media/${id}/original`, S3_URL_BASE).href; + const result = await db + .insert(media) + .values({ + id, + type: file.type, + url, + width: fileMetadata.width!, + height: fileMetadata.height!, + description, + ...(await uploadThumbnail(id, image)), + }) + .returning(); + if (result.length < 1) { + return c.json({ error: "Failed to insert media" }, 500); + } + return c.json(serializeMedium(result[0])); +}); + export default app; diff --git a/src/api/v2/instance.ts b/src/api/v2/instance.ts index c4eecc9..1958b83 100644 --- a/src/api/v2/instance.ts +++ b/src/api/v2/instance.ts @@ -51,14 +51,19 @@ app.get("/", async (c) => { statuses: { // TODO max_characters: 4096, - max_media_attachments: 0, + max_media_attachments: 8, characters_reserved_per_url: 256, }, media_attachments: { + supported_mime_types: [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + ], + image_size_limit: 1024 * 1024 * 32, // 32MiB + image_matrix_limit: 16_777_216, // TODO - supported_mime_types: [], - image_size_limit: 0, - image_matrix_limit: 0, video_size_limit: 0, video_frame_rate_limit: 0, video_matrix_limit: 0, diff --git a/src/entities/medium.ts b/src/entities/medium.ts new file mode 100644 index 0000000..32ba845 --- /dev/null +++ b/src/entities/medium.ts @@ -0,0 +1,30 @@ +import type { Medium } from "../schema"; + +// biome-ignore lint/suspicious/noExplicitAny: JSON +export function serializeMedium(medium: Medium): Record { + return { + id: medium.id, + type: "image", + url: medium.url, + preview_url: medium.thumbnailUrl, + remote_url: null, + text_url: null, + meta: { + original: { + width: medium.width, + height: medium.height, + size: `${medium.width}x${medium.height}`, + aspect: medium.width / medium.height, + }, + small: { + width: medium.thumbnailWidth, + height: medium.thumbnailHeight, + size: `${medium.thumbnailWidth}x${medium.thumbnailHeight}`, + aspect: medium.thumbnailWidth / medium.thumbnailHeight, + }, + focus: { x: 0, y: 0 }, + }, + description: medium.description, + blurhash: null, + }; +} diff --git a/src/entities/status.ts b/src/entities/status.ts index 8c6219d..d7e872c 100644 --- a/src/entities/status.ts +++ b/src/entities/status.ts @@ -1,15 +1,46 @@ +import { eq } from "drizzle-orm"; import type { PreviewCard } from "../previewcard"; -import type { - Account, - AccountOwner, - Application, - Bookmark, - Like, - Mention, - Post, +import { + type Account, + type AccountOwner, + type Application, + type Bookmark, + type Like, + type Medium, + type Mention, + type Post, + bookmarks, + likes, + posts, } from "../schema"; import { extractText } from "../text"; import { serializeAccount } from "./account"; +import { serializeMedium } from "./medium"; + +export function getPostRelations(ownerId: string) { + return { + account: { with: { owner: true } }, + application: true, + replyTarget: true, + sharing: { + with: { + account: true, + application: true, + replyTarget: true, + media: true, + mentions: { with: { account: { with: { owner: true } } } }, + likes: { where: eq(likes.accountId, ownerId) }, + shares: { where: eq(posts.accountId, ownerId) }, + bookmarks: { where: eq(bookmarks.accountOwnerId, ownerId) }, + }, + }, + media: true, + mentions: { with: { account: { with: { owner: true } } } }, + likes: { where: eq(likes.accountId, ownerId) }, + shares: { where: eq(posts.accountId, ownerId) }, + bookmarks: { where: eq(bookmarks.accountOwnerId, ownerId) }, + } as const; +} export function serializePost( post: Post & { @@ -21,6 +52,7 @@ export function serializePost( account: Account; application: Application | null; replyTarget: Post | null; + media: Medium[]; mentions: (Mention & { account: Account & { owner: AccountOwner | null }; })[]; @@ -29,6 +61,7 @@ export function serializePost( bookmarks: Bookmark[]; }) | null; + media: Medium[]; mentions: (Mention & { account: Account & { owner: AccountOwner | null }; })[]; @@ -81,7 +114,7 @@ export function serializePost( website: post.application.website, }, account: serializeAccount(post.account, baseUrl), - media_attachments: [], // TODO + media_attachments: post.media.map(serializeMedium), mentions: post.mentions.map((mention) => ({ id: mention.accountId, username: mention.account.handle.replaceAll(/(?:^@)|(?:@[^@]+$)/g, ""), diff --git a/src/federation/index.ts b/src/federation/index.ts index 5f30810..98df0e4 100644 --- a/src/federation/index.ts +++ b/src/federation/index.ts @@ -8,15 +8,11 @@ import { Endpoints, Federation, Follow, - Hashtag, Image, InProcessMessageQueue, - LanguageString, Like, MemoryKvStore, - Mention, Note, - PUBLIC_COLLECTION, PropertyValue, Reject, Undo, @@ -40,7 +36,7 @@ import { } from "../schema"; import { persistAccount } from "./account"; import { toTemporalInstant } from "./date"; -import { persistPost, persistSharingPost } from "./post"; +import { persistPost, persistSharingPost, toObject } from "./post"; export const federation = new Federation({ kv: new MemoryKvStore(), @@ -439,7 +435,12 @@ federation.setObjectDispatcher(Note, "/@{handle}/{id}", async (ctx, values) => { if (owner == null) return null; const post = await db.query.posts.findFirst({ where: and(eq(posts.id, values.id), eq(posts.accountId, owner.account.id)), - with: { replyTarget: true, mentions: { with: { account: true } } }, + with: { + account: { with: { owner: true } }, + replyTarget: true, + media: true, + mentions: { with: { account: true } }, + }, }); if (post == null) return null; if (post.visibility === "private") { @@ -459,46 +460,7 @@ federation.setObjectDispatcher(Note, "/@{handle}/{id}", async (ctx, values) => { const found = post.mentions.some((m) => m.account.iri === keyOwnerId.href); if (!found) return null; } - return new Note({ - id: ctx.getObjectUri(Note, values), - attribution: ctx.getActorUri(values.handle), - replyTarget: - post.replyTarget == null ? null : new URL(post.replyTarget.iri), - tos: - post.visibility === "direct" - ? post.mentions.map((m) => new URL(m.account.iri)) - : post.visibility === "public" - ? [PUBLIC_COLLECTION] - : post.visibility === "private" - ? [ctx.getFollowersUri(values.handle)] - : [], - cc: post.visibility === "direct" ? PUBLIC_COLLECTION : null, - summary: - post.summaryHtml == null - ? null - : post.language == null - ? post.summaryHtml - : new LanguageString(post.summaryHtml, post.language), - content: - post.contentHtml == null - ? null - : post.language == null - ? post.contentHtml - : new LanguageString(post.contentHtml, post.language), - tags: [ - ...Object.entries(post.tags).map( - ([name, url]) => new Hashtag({ name: `#${name}`, href: new URL(url) }), - ), - ...post.mentions.map( - (m) => - new Mention({ name: m.account.handle, href: new URL(m.account.iri) }), - ), - ], - sensitive: post.sensitive, - url: post.url ? new URL(post.url) : null, - published: post.published ? toTemporalInstant(post.published) : null, - updated: toTemporalInstant(post.updated), - }); + return toObject(post, ctx); }); federation.setNodeInfoDispatcher("/nodeinfo/2.1", async (_ctx) => { diff --git a/src/federation/post.ts b/src/federation/post.ts index 3c874d5..ca6d9a5 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -16,14 +16,19 @@ import { getLogger } from "@logtape/logtape"; import { type ExtractTablesWithRelations, eq, sql } from "drizzle-orm"; import type { PgDatabase } from "drizzle-orm/pg-core"; import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"; +import sharp from "sharp"; import { uuidv7 } from "uuidv7-js"; +import { uploadThumbnail } from "../media"; import { fetchPreviewCard } from "../previewcard"; import { type Account, type AccountOwner, + type Medium, type Mention, + type NewMedium, type NewPost, type Post, + media, mentions, posts, } from "../schema"; @@ -140,7 +145,7 @@ export async function persistPost( }); if (post == null) return null; await db.delete(mentions).where(eq(mentions.postId, post.id)); - for await (const tag of object.getTags()) { + for await (const tag of object.getTags(options)) { if (tag instanceof vocab.Mention && tag.name != null && tag.href != null) { const account = await persistAccountByIri(db, tag.href.href, options); if (account == null) continue; @@ -150,6 +155,40 @@ export async function persistPost( }); } } + await db.delete(media).where(eq(media.postId, post.id)); + for await (const attachment of object.getAttachments(options)) { + if ( + !( + attachment instanceof vocab.Image || + attachment instanceof vocab.Document + ) + ) { + continue; + } + const url = + attachment.url instanceof Link + ? attachment.url.href?.href + : attachment.url?.href; + if (url == null) continue; + const response = await fetch(url); + const mediaType = + response.headers.get("Content-Type") ?? attachment.mediaType; + if (mediaType == null) continue; + const image = sharp(await response.arrayBuffer()); + const metadata = await image.metadata(); + const id = uuidv7(); + const thumbnail = await uploadThumbnail(id, image); + await db.insert(media).values({ + id, + postId: post.id, + type: mediaType, + url, + description: attachment.name?.toString(), + width: attachment.width ?? metadata.width!, + height: attachment.height ?? metadata.height!, + ...thumbnail, + } satisfies NewMedium); + } return post; } @@ -208,6 +247,7 @@ export function toObject( post: Post & { account: Account & { owner: AccountOwner | null }; replyTarget: Post | null; + media: Medium[]; mentions: (Mention & { account: Account })[]; }, ctx: Context, @@ -248,14 +288,32 @@ export function toObject( ...Object.entries(post.tags).map( ([name, url]) => new vocab.Hashtag({ - name, + name: `#${name}`, href: new URL(url), }), ), ], replyTarget: post.replyTarget == null ? null : new URL(post.replyTarget.iri), + attachments: post.media.map( + (medium) => + new vocab.Image({ + mediaType: medium.type, + url: new URL(medium.url), + name: medium.description, + width: medium.width, + height: medium.height, + }), + ), published: toTemporalInstant(post.published), + url: post.url ? new URL(post.url) : null, + updated: toTemporalInstant( + post.published == null + ? post.updated + : +post.updated === +post.published + ? null + : post.updated, + ), }); } @@ -263,13 +321,13 @@ export function toCreate( post: Post & { account: Account & { owner: AccountOwner | null }; replyTarget: Post | null; + media: Medium[]; mentions: (Mention & { account: Account })[]; }, ctx: Context, ): Create { const object = toObject(post, ctx); return new Create({ - // biome-ignore lint/style/noNonNullAssertion: id is never null id: new URL("#create", object.id!), actor: object.attributionId, tos: object.toIds, diff --git a/src/media.ts b/src/media.ts new file mode 100644 index 0000000..ce8400f --- /dev/null +++ b/src/media.ts @@ -0,0 +1,52 @@ +import { PutObjectCommand } from "@aws-sdk/client-s3"; +import type { Sharp } from "sharp"; +import { S3_BUCKET, S3_URL_BASE, s3 } from "./s3"; + +const DEFAULT_THUMBNAIL_AREA = 230_400; + +export interface Thumbnail { + thumbnailUrl: string; + thumbnailType: string; + thumbnailWidth: number; + thumbnailHeight: number; +} + +export async function uploadThumbnail( + id: string, + original: Sharp, + thumbnailArea = DEFAULT_THUMBNAIL_AREA, +): Promise { + const originalMetadata = await original.metadata(); + const thumbnailSize = calculateThumbnailSize( + originalMetadata.width!, + originalMetadata.height!, + thumbnailArea, + ); + const thumbnail = await original.resize(thumbnailSize).webp().toBuffer(); + await s3.send( + new PutObjectCommand({ + Bucket: S3_BUCKET, + Key: `media/${id}/thumbnail`, + Body: thumbnail.subarray(), + ContentType: "image/webp", + }), + ); + return { + thumbnailUrl: new URL(`media/${id}/thumbnail`, S3_URL_BASE).href, + thumbnailType: "image/webp", + thumbnailWidth: thumbnailSize.width, + thumbnailHeight: thumbnailSize.height, + }; +} + +export function calculateThumbnailSize( + width: number, + height: number, + maxArea: number, +): { width: number; height: number } { + const ratio = width / height; + if (width * height <= maxArea) return { width, height }; + const newHeight = Math.sqrt(maxArea / ratio); + const newWidth = ratio * newHeight; + return { width: Math.round(newWidth), height: Math.round(newHeight) }; +} diff --git a/src/schema.ts b/src/schema.ts index 8625727..188717c 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -3,6 +3,7 @@ import { type AnyPgColumn, bigint, boolean, + integer, json, jsonb, pgEnum, @@ -307,10 +308,36 @@ export const postRelations = relations(posts, ({ one, many }) => ({ shares: many(posts, { relationName: "share", }), + media: many(media), mentions: many(mentions), bookmarks: many(bookmarks), })); +export const media = pgTable("media", { + id: uuid("id").primaryKey(), + postId: uuid("post_id").references(() => posts.id, { onDelete: "cascade" }), + type: text("type").notNull(), + url: text("url").notNull(), + width: integer("width").notNull(), + height: integer("height").notNull(), + description: text("description"), + thumbnailType: text("thumbnail_type").notNull(), + thumbnailUrl: text("thumbnail_url").notNull(), + thumbnailWidth: integer("thumbnail_width").notNull(), + thumbnailHeight: integer("thumbnail_height").notNull(), + created: timestamp("created", { withTimezone: true }).notNull().defaultNow(), +}); + +export type Medium = typeof media.$inferSelect; +export type NewMedium = typeof media.$inferInsert; + +export const mediumRelations = relations(media, ({ one }) => ({ + post: one(posts, { + fields: [media.postId], + references: [posts.id], + }), +})); + export const mentions = pgTable( "mentions", {