diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index 0e505c4e..109505db 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -6,9 +6,7 @@ import { config } from "config-manager"; import { and, eq } from "drizzle-orm"; import type { Hono } from "hono"; import ISO6391 from "iso-639-1"; -import { MediaBackendType } from "media-manager"; -import type { MediaBackend } from "media-manager"; -import { LocalMediaBackend, S3MediaBackend } from "media-manager"; +import { MediaBackend } from "media-manager"; import { z } from "zod"; import { getUrl } from "~/database/entities/attachment"; import { parseEmojis } from "~/database/entities/emoji"; @@ -127,19 +125,10 @@ export default (app: Hono) => display_name ?? "", ); - let mediaManager: MediaBackend; - - switch (config.media.backend as MediaBackendType) { - case MediaBackendType.Local: - mediaManager = new LocalMediaBackend(config); - break; - case MediaBackendType.S3: - mediaManager = new S3MediaBackend(config); - break; - default: - // TODO: Replace with logger - throw new Error("Invalid media backend"); - } + const mediaManager = await MediaBackend.fromBackendType( + config.media.backend, + config, + ); if (display_name) { // Check if display name doesnt match filters diff --git a/server/api/api/v1/media/:id/index.ts b/server/api/api/v1/media/:id/index.ts index 5584465b..ee9cf875 100644 --- a/server/api/api/v1/media/:id/index.ts +++ b/server/api/api/v1/media/:id/index.ts @@ -3,9 +3,7 @@ import { errorResponse, jsonResponse, response } from "@/response"; import { zValidator } from "@hono/zod-validator"; import { config } from "config-manager"; import type { Hono } from "hono"; -import type { MediaBackend } from "media-manager"; -import { MediaBackendType } from "media-manager"; -import { LocalMediaBackend, S3MediaBackend } from "media-manager"; +import { MediaBackend } from "media-manager"; import { z } from "zod"; import { getUrl } from "~/database/entities/attachment"; import { RolePermissions } from "~/drizzle/schema"; @@ -74,19 +72,10 @@ export default (app: Hono) => let thumbnailUrl = attachment.data.thumbnailUrl; - let mediaManager: MediaBackend; - - switch (config.media.backend as MediaBackendType) { - case MediaBackendType.Local: - mediaManager = new LocalMediaBackend(config); - break; - case MediaBackendType.S3: - mediaManager = new S3MediaBackend(config); - break; - default: - // TODO: Replace with logger - throw new Error("Invalid media backend"); - } + const mediaManager = await MediaBackend.fromBackendType( + config.media.backend, + config, + ); if (thumbnail) { const { path } = await mediaManager.addFile(thumbnail); diff --git a/server/api/api/v1/media/index.ts b/server/api/api/v1/media/index.ts index 74238fdc..aae9ddfb 100644 --- a/server/api/api/v1/media/index.ts +++ b/server/api/api/v1/media/index.ts @@ -4,9 +4,7 @@ import { zValidator } from "@hono/zod-validator"; import { encode } from "blurhash"; import { config } from "config-manager"; import type { Hono } from "hono"; -import { MediaBackendType } from "media-manager"; -import type { MediaBackend } from "media-manager"; -import { LocalMediaBackend, S3MediaBackend } from "media-manager"; +import { MediaBackend } from "media-manager"; import sharp from "sharp"; import { z } from "zod"; import { getUrl } from "~/database/entities/attachment"; @@ -101,19 +99,10 @@ export default (app: Hono) => let url = ""; - let mediaManager: MediaBackend; - - switch (config.media.backend as MediaBackendType) { - case MediaBackendType.Local: - mediaManager = new LocalMediaBackend(config); - break; - case MediaBackendType.S3: - mediaManager = new S3MediaBackend(config); - break; - default: - // TODO: Replace with logger - throw new Error("Invalid media backend"); - } + const mediaManager = await MediaBackend.fromBackendType( + config.media.backend, + config, + ); const { path } = await mediaManager.addFile(file); diff --git a/server/api/api/v1/statuses/:id/index.ts b/server/api/api/v1/statuses/:id/index.ts index b7150d59..49c79e5c 100644 --- a/server/api/api/v1/statuses/:id/index.ts +++ b/server/api/api/v1/statuses/:id/index.ts @@ -12,8 +12,8 @@ import type { Hono } from "hono"; import ISO6391 from "iso-639-1"; import { z } from "zod"; import { undoFederationRequest } from "~/database/entities/federation"; -import { db } from "~/drizzle/db"; import { RolePermissions } from "~/drizzle/schema"; +import { Attachment } from "~/packages/database-interface/attachment"; import { Note } from "~/packages/database-interface/note"; export const meta = applyConfig({ @@ -40,43 +40,58 @@ export const schemas = { param: z.object({ id: z.string().regex(idValidator), }), - form: z.object({ - status: z.string().max(config.validation.max_note_size).optional(), - content_type: z.string().optional().default("text/plain"), - media_ids: z - .array(z.string().regex(idValidator)) - .max(config.validation.max_media_attachments) - .optional(), - spoiler_text: z.string().max(255).optional(), - sensitive: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - language: z - .enum(ISO6391.getAllCodes() as [string, ...string[]]) - .optional(), - "poll[options]": z - .array(z.string().max(config.validation.max_poll_option_size)) - .max(config.validation.max_poll_options) - .optional(), - "poll[expires_in]": z.coerce - .number() - .int() - .min(config.validation.min_poll_duration) - .max(config.validation.max_poll_duration) - .optional(), - "poll[multiple]": z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - "poll[hide_totals]": z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - }), + form: z + .object({ + status: z + .string() + .max(config.validation.max_note_size) + .refine( + (s) => + !config.filters.note_content.some((filter) => + s.match(filter), + ), + "Status contains blocked words", + ) + .optional(), + content_type: z.string().optional().default("text/plain"), + media_ids: z + .array(z.string().regex(idValidator)) + .max(config.validation.max_media_attachments) + .default([]), + spoiler_text: z.string().max(255).optional(), + sensitive: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .or(z.boolean()) + .optional(), + language: z + .enum(ISO6391.getAllCodes() as [string, ...string[]]) + .optional(), + "poll[options]": z + .array(z.string().max(config.validation.max_poll_option_size)) + .max(config.validation.max_poll_options) + .optional(), + "poll[expires_in]": z.coerce + .number() + .int() + .min(config.validation.min_poll_duration) + .max(config.validation.max_poll_duration) + .optional(), + "poll[multiple]": z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .or(z.boolean()) + .optional(), + "poll[hide_totals]": z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .or(z.boolean()) + .optional(), + }) + .refine( + (obj) => !(obj.media_ids.length > 0 && obj["poll[options]"]), + "Cannot attach poll to media", + ), }; export default (app: Hono) => @@ -91,91 +106,65 @@ export default (app: Hono) => const { id } = context.req.valid("param"); const { user } = context.req.valid("header"); - const foundStatus = await Note.fromId(id, user?.id); - - if (!foundStatus?.isViewableByUser(user)) { - return errorResponse("Record not found", 404); - } - - if (context.req.method === "GET") { - return jsonResponse(await foundStatus.toApi(user)); - } - if (context.req.method === "DELETE") { - if (foundStatus.author.id !== user?.id) { - return errorResponse("Unauthorized", 401); - } - - // TODO: Delete and redraft - - await foundStatus.delete(); - - await user.federateToFollowers( - undoFederationRequest(user, foundStatus.getUri()), - ); - - return jsonResponse(await foundStatus.toApi(user), 200); - } - // TODO: Polls const { status: statusText, content_type, - "poll[options]": options, media_ids, spoiler_text, sensitive, } = context.req.valid("form"); - if (!(statusText || (media_ids && media_ids.length > 0))) { - return errorResponse( - "Status is required unless media is attached", - 422, - ); + const note = await Note.fromId(id, user?.id); + + if (!note?.isViewableByUser(user)) { + return errorResponse("Record not found", 404); } - if (media_ids && media_ids.length > 0 && options) { - return errorResponse( - "Cannot attach poll to post with media", - 422, - ); - } + switch (context.req.method) { + case "GET": { + return jsonResponse(await note.toApi(user)); + } + case "DELETE": { + if (note.author.id !== user?.id) { + return errorResponse("Unauthorized", 401); + } - if ( - config.filters.note_content.some((filter) => - statusText?.match(filter), - ) - ) { - return errorResponse("Status contains blocked words", 422); - } + // TODO: Delete and redraft - if (media_ids && media_ids.length > 0) { - const foundAttachments = await db.query.Attachments.findMany({ - where: (attachment, { inArray }) => - inArray(attachment.id, media_ids), - }); + await note.delete(); - if (foundAttachments.length !== (media_ids ?? []).length) { - return errorResponse("Invalid media IDs", 422); + await user.federateToFollowers( + undoFederationRequest(user, note.getUri()), + ); + + return jsonResponse(await note.toApi(user), 200); + } + case "PUT": { + if (media_ids.length > 0) { + const foundAttachments = + await Attachment.fromIds(media_ids); + + if (foundAttachments.length !== media_ids.length) { + return errorResponse("Invalid media IDs", 422); + } + } + + const newNote = await note.updateFromData({ + content: statusText + ? { + [content_type]: { + content: statusText, + }, + } + : undefined, + isSensitive: sensitive, + spoilerText: spoiler_text, + mediaAttachments: media_ids, + }); + + return jsonResponse(await newNote.toApi(user)); } } - - const newNote = await foundStatus.updateFromData({ - content: statusText - ? { - [content_type]: { - content: statusText, - }, - } - : undefined, - isSensitive: sensitive, - spoilerText: spoiler_text, - mediaAttachments: media_ids, - }); - - if (!newNote) { - return errorResponse("Failed to update status", 500); - } - - return jsonResponse(await newNote.toApi(user)); }, ); diff --git a/server/api/api/v1/statuses/index.test.ts b/server/api/api/v1/statuses/index.test.ts index a34091e9..96713918 100644 --- a/server/api/api/v1/statuses/index.test.ts +++ b/server/api/api/v1/statuses/index.test.ts @@ -28,7 +28,9 @@ describe(meta.route, () => { const response = await sendTestRequest( new Request(new URL(meta.route, config.http.base_url), { method: "POST", - body: new URLSearchParams(), + body: new URLSearchParams({ + status: "Hello, world!", + }), }), ); diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index 9ece9644..bee6abc2 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -6,8 +6,8 @@ import type { Hono } from "hono"; import ISO6391 from "iso-639-1"; import { z } from "zod"; import { federateNote } from "~/database/entities/status"; -import { db } from "~/drizzle/db"; import { RolePermissions } from "~/drizzle/schema"; +import { Attachment } from "~/packages/database-interface/attachment"; import { Note } from "~/packages/database-interface/note"; export const meta = applyConfig({ @@ -26,61 +26,81 @@ export const meta = applyConfig({ }); export const schemas = { - form: z.object({ - status: z - .string() - .max(config.validation.max_note_size) - .trim() - .optional(), - // TODO: Add regex to validate - content_type: z.string().optional().default("text/plain"), - media_ids: z - .array(z.string().uuid()) - .max(config.validation.max_media_attachments) - .optional(), - spoiler_text: z.string().max(255).trim().optional(), - sensitive: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - language: z - .enum(ISO6391.getAllCodes() as [string, ...string[]]) - .optional(), - "poll[options]": z - .array(z.string().max(config.validation.max_poll_option_size)) - .max(config.validation.max_poll_options) - .optional(), - "poll[expires_in]": z.coerce - .number() - .int() - .min(config.validation.min_poll_duration) - .max(config.validation.max_poll_duration) - .optional(), - "poll[multiple]": z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - "poll[hide_totals]": z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - in_reply_to_id: z.string().uuid().optional().nullable(), - quote_id: z.string().uuid().optional().nullable(), - visibility: z - .enum(["public", "unlisted", "private", "direct"]) - .optional() - .default("public"), - scheduled_at: z.string().optional().nullable(), - local_only: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional() - .default(false), - }), + form: z + .object({ + status: z + .string() + .max(config.validation.max_note_size) + .trim() + .refine( + (s) => + !config.filters.note_content.some((filter) => + s.match(filter), + ), + "Status contains blocked words", + ) + .optional(), + // TODO: Add regex to validate + content_type: z.string().optional().default("text/plain"), + media_ids: z + .array(z.string().uuid()) + .max(config.validation.max_media_attachments) + .default([]), + spoiler_text: z.string().max(255).trim().optional(), + sensitive: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .or(z.boolean()) + .optional(), + language: z + .enum(ISO6391.getAllCodes() as [string, ...string[]]) + .optional(), + "poll[options]": z + .array(z.string().max(config.validation.max_poll_option_size)) + .max(config.validation.max_poll_options) + .optional(), + "poll[expires_in]": z.coerce + .number() + .int() + .min(config.validation.min_poll_duration) + .max(config.validation.max_poll_duration) + .optional(), + "poll[multiple]": z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .or(z.boolean()) + .optional(), + "poll[hide_totals]": z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .or(z.boolean()) + .optional(), + in_reply_to_id: z.string().uuid().optional().nullable(), + quote_id: z.string().uuid().optional().nullable(), + visibility: z + .enum(["public", "unlisted", "private", "direct"]) + .optional() + .default("public"), + scheduled_at: z.coerce + .date() + .min(new Date(), "Scheduled time must be in the future") + .optional() + .nullable(), + local_only: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .or(z.boolean()) + .optional() + .default(false), + }) + .refine( + (obj) => obj.status || obj.media_ids.length > 0, + "Status is required unless media is attached", + ) + .refine( + (obj) => !(obj.media_ids.length > 0 && obj["poll[options]"]), + "Cannot attach poll to media", + ), }; export default (app: Hono) => @@ -100,10 +120,8 @@ export default (app: Hono) => const { status, media_ids, - "poll[options]": options, in_reply_to_id, quote_id, - scheduled_at, sensitive, spoiler_text, visibility, @@ -111,68 +129,22 @@ export default (app: Hono) => local_only, } = context.req.valid("form"); - // Validate status - if (!(status || (media_ids && media_ids.length > 0))) { - return errorResponse( - "Status is required unless media is attached", - 422, - ); - } - - if (media_ids && media_ids.length > 0 && options) { - // Disallow poll - return errorResponse("Cannot attach poll to media", 422); - } - - if (scheduled_at) { - if ( - Number.isNaN(new Date(scheduled_at).getTime()) || - new Date(scheduled_at).getTime() < Date.now() - ) { - return errorResponse( - "Scheduled time must be in the future", - 422, - ); - } - } - - // Check if status body doesnt match filters - if ( - config.filters.note_content.some((filter) => - status?.match(filter), - ) - ) { - return errorResponse("Status contains blocked words", 422); - } - // Check if media attachments are all valid - if (media_ids && media_ids.length > 0) { - const foundAttachments = await db.query.Attachments.findMany({ - where: (attachment, { inArray }) => - inArray(attachment.id, media_ids), - }).catch(() => []); + if (media_ids.length > 0) { + const foundAttachments = await Attachment.fromIds(media_ids); - if (foundAttachments.length !== (media_ids ?? []).length) { + if (foundAttachments.length !== media_ids.length) { return errorResponse("Invalid media IDs", 422); } } // Check that in_reply_to_id and quote_id are real posts if provided - if (in_reply_to_id) { - const foundReply = await Note.fromId(in_reply_to_id); - if (!foundReply) { - return errorResponse( - "Invalid in_reply_to_id (not found)", - 422, - ); - } + if (in_reply_to_id && !(await Note.fromId(in_reply_to_id))) { + return errorResponse("Invalid in_reply_to_id (not found)", 422); } - if (quote_id) { - const foundQuote = await Note.fromId(quote_id); - if (!foundQuote) { - return errorResponse("Invalid quote_id (not found)", 422); - } + if (quote_id && !(await Note.fromId(quote_id))) { + return errorResponse("Invalid quote_id (not found)", 422); } const newNote = await Note.fromData({ @@ -191,10 +163,6 @@ export default (app: Hono) => application: application ?? undefined, }); - if (!newNote) { - return errorResponse("Failed to create status", 500); - } - if (!local_only) { await federateNote(newNote); } diff --git a/server/api/api/v2/media/index.ts b/server/api/api/v2/media/index.ts index 744199fb..fa257bdb 100644 --- a/server/api/api/v2/media/index.ts +++ b/server/api/api/v2/media/index.ts @@ -4,9 +4,7 @@ import { zValidator } from "@hono/zod-validator"; import { encode } from "blurhash"; import { config } from "config-manager"; import type { Hono } from "hono"; -import type { MediaBackend } from "media-manager"; -import { MediaBackendType } from "media-manager"; -import { LocalMediaBackend, S3MediaBackend } from "media-manager"; +import { MediaBackend } from "media-manager"; import sharp from "sharp"; import { z } from "zod"; import { getUrl } from "~/database/entities/attachment"; @@ -101,19 +99,10 @@ export default (app: Hono) => let url = ""; - let mediaManager: MediaBackend; - - switch (config.media.backend as MediaBackendType) { - case MediaBackendType.Local: - mediaManager = new LocalMediaBackend(config); - break; - case MediaBackendType.S3: - mediaManager = new S3MediaBackend(config); - break; - default: - // TODO: Replace with logger - throw new Error("Invalid media backend"); - } + const mediaManager = await MediaBackend.fromBackendType( + config.media.backend, + config, + ); const { path } = await mediaManager.addFile(file);