diff --git a/config/config.example.toml b/config/config.example.toml index 33b13faf..067bbd21 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -62,28 +62,97 @@ max_bio_size = 160 max_note_size = 5000 max_avatar_size = 5_000_000 max_header_size = 5_000_000 -max_media_size = 40_000_000 # MEDIA NOT IMPLEMENTED -max_media_attachments = 4 # MEDIA NOT IMPLEMENTED -max_media_description_size = 1000 # MEDIA NOT IMPLEMENTED +max_media_size = 40_000_000 +max_media_attachments = 10 +max_media_description_size = 1000 +max_poll_options = 20 +max_poll_option_size = 500 +min_poll_duration = 60 +max_poll_duration = 1893456000 max_username_size = 30 # An array of strings, defaults are from Akkoma -username_blacklist = [ ".well-known", "~", "about", "activities" , "api", - "auth", "dev", "inbox", "internal", "main", "media", "nodeinfo", "notice", - "oauth", "objects", "proxy", "push", "registration", "relay", "settings", - "status", "tag", "users", "web", "search", "mfa" ] +username_blacklist = [ + ".well-known", + "~", + "about", + "activities", + "api", + "auth", + "dev", + "inbox", + "internal", + "main", + "media", + "nodeinfo", + "notice", + "oauth", + "objects", + "proxy", + "push", + "registration", + "relay", + "settings", + "status", + "tag", + "users", + "web", + "search", + "mfa", +] # Whether to blacklist known temporary email providers blacklist_tempmail = false # Additional email providers to blacklist email_blacklist = [] # Valid URL schemes, otherwise the URL is parsed as text -url_scheme_whitelist = [ "http", "https", "ftp", "dat", "dweb", "gopher", "hyper", - "ipfs", "ipns", "irc", "xmpp", "ircs", "magnet", "mailto", "mumble", "ssb", - "gemini" ] # NOT IMPLEMENTED -allowed_mime_types = [ "image/jpeg", "image/png", "image/gif", "image/heic", "image/heif", - "image/webp", "image/avif", "video/webm", "video/mp4", "video/quicktime", "video/ogg", - "audio/wave", "audio/wav", "audio/x-wav", "audio/x-pn-wave", "audio/vnd.wave", - "audio/ogg", "audio/vorbis", "audio/mpeg", "audio/mp3", "audio/webm", "audio/flac", - "audio/aac", "audio/m4a", "audio/x-m4a", "audio/mp4", "audio/3gpp", "video/x-ms-asf" ] # MEDIA NOT IMPLEMENTED +url_scheme_whitelist = [ + "http", + "https", + "ftp", + "dat", + "dweb", + "gopher", + "hyper", + "ipfs", + "ipns", + "irc", + "xmpp", + "ircs", + "magnet", + "mailto", + "mumble", + "ssb", + "gemini", +] # NOT IMPLEMENTED +allowed_mime_types = [ + "image/jpeg", + "image/png", + "image/gif", + "image/heic", + "image/heif", + "image/webp", + "image/avif", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/vnd.wave", + "audio/ogg", + "audio/vorbis", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf", +] # MEDIA NOT IMPLEMENTED [defaults] # Default visibility for new notes @@ -168,4 +237,4 @@ max_coeff = 1.0 [custom_ratelimits] # Add in any API route in this style here -"/api/v1/timelines/public" = { duration = 60, max = 200 } \ No newline at end of file +"/api/v1/timelines/public" = { duration = 60, max = 200 } diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 6a37f894..552a29c2 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -351,6 +351,7 @@ export const createNewStatus = async (data: { content_type?: string; uri?: string; mentions?: User[]; + media_attachments?: string[]; reply?: { status: Status; user: User; @@ -397,6 +398,15 @@ export const createNewStatus = async (data: { }; }), }, + attachments: data.media_attachments + ? { + connect: data.media_attachments.map(attachment => { + return { + id: attachment, + }; + }), + } + : undefined, inReplyToPostId: data.reply?.status.id, quotingPostId: data.quote?.id, instanceId: data.account.instanceId || undefined, diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index 5c9f81ff..589dfd50 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -78,26 +78,10 @@ export default async ( }>(req); // Validate status - if (!status) { - return errorResponse("Status is required", 422); - } - - let sanitizedStatus: string; - - if (content_type === "text/markdown") { - sanitizedStatus = await sanitizeHtml(parse(status)); - } else if (content_type === "text/x.misskeymarkdown") { - // Parse as MFM - // TODO: Parse as MFM - sanitizedStatus = await sanitizeHtml(parse(status)); - } else { - sanitizedStatus = await sanitizeHtml(status); - } - - if (sanitizedStatus.length > config.validation.max_note_size) { + if (!status && !(media_ids && media_ids.length > 0)) { return errorResponse( - `Status must be less than ${config.validation.max_note_size} characters`, - 400 + "Status is required unless media is attached", + 422 ); } @@ -115,14 +99,74 @@ export default async ( return errorResponse("Poll options must be less than 5", 422); } - // Validate poll expires_in - if (expires_in && (expires_in < 60 || expires_in > 604800)) { + if (media_ids && media_ids.length > 0) { + // Disallow poll + if (options) { + return errorResponse("Cannot attach poll to media", 422); + } + if (media_ids.length > 4) { + return errorResponse("Media IDs must be less than 5", 422); + } + } + + if (options && options.length > config.validation.max_poll_options) { return errorResponse( - "Poll expires_in must be between 60 and 604800", + `Poll options must be less than ${config.validation.max_poll_options}`, 422 ); } + if ( + options && + options.some( + option => option.length > config.validation.max_poll_option_size + ) + ) { + return errorResponse( + `Poll options must be less than ${config.validation.max_poll_option_size} characters`, + 422 + ); + } + + if (expires_in && expires_in < config.validation.min_poll_duration) { + return errorResponse( + `Poll duration must be greater than ${config.validation.min_poll_duration} seconds`, + 422 + ); + } + + if (expires_in && expires_in > config.validation.max_poll_duration) { + return errorResponse( + `Poll duration must be less than ${config.validation.max_poll_duration} seconds`, + 422 + ); + } + + if (scheduled_at) { + if (new Date(scheduled_at).getTime() < Date.now()) { + return errorResponse("Scheduled time must be in the future", 422); + } + } + + let sanitizedStatus: string; + + if (content_type === "text/markdown") { + sanitizedStatus = await sanitizeHtml(parse(status ?? "")); + } else if (content_type === "text/x.misskeymarkdown") { + // Parse as MFM + // TODO: Parse as MFM + sanitizedStatus = await sanitizeHtml(parse(status ?? "")); + } else { + sanitizedStatus = await sanitizeHtml(status ?? ""); + } + + if (sanitizedStatus.length > config.validation.max_note_size) { + return errorResponse( + `Status must be less than ${config.validation.max_note_size} characters`, + 400 + ); + } + // Validate visibility if ( visibility && @@ -145,10 +189,24 @@ export default async ( } // Check if status body doesnt match filters - if (config.filters.note_filters.some(filter => status.match(filter))) { + if (config.filters.note_filters.some(filter => status?.match(filter))) { return errorResponse("Status contains blocked words", 422); } + // Check if media attachments are all valid + + const foundAttachments = await client.attachment.findMany({ + where: { + id: { + in: media_ids ?? [], + }, + }, + }); + + if (foundAttachments.length !== (media_ids ?? []).length) { + return errorResponse("Invalid media IDs", 422); + } + const newStatus = await createNewStatus({ account: user, application, @@ -163,6 +221,7 @@ export default async ( sensitive: sensitive || false, spoiler_text: spoiler_text || "", emojis: [], + media_attachments: media_ids, reply: replyStatus && replyUser ? { diff --git a/server/api/api/v2/media/index.ts b/server/api/api/v2/media/index.ts index ba26cdfd..8a9f4b31 100644 --- a/server/api/api/v2/media/index.ts +++ b/server/api/api/v2/media/index.ts @@ -36,7 +36,7 @@ export default async (req: Request): Promise => { const file = form.get("file") as unknown as File | undefined; const thumbnail = form.get("thumbnail"); - const description = form.get("description"); + const description = form.get("description") as string | undefined; // Floating point numbers from -1.0 to 1.0, comma delimited // const focus = form.get("focus"); @@ -45,6 +45,29 @@ export default async (req: Request): Promise => { return errorResponse("No file provided", 400); } + const config = getConfig(); + + if (file.size > config.validation.max_media_size) { + return errorResponse( + `File too large, max size is ${config.validation.max_media_size} bytes`, + 413 + ); + } + + if (!config.validation.allowed_mime_types.includes(file.type)) { + return errorResponse("Invalid file type", 415); + } + + if ( + description && + description.length > config.validation.max_media_description_size + ) { + return errorResponse( + `Description too long, max length is ${config.validation.max_media_description_size} characters`, + 413 + ); + } + const sha256 = new Bun.SHA256(); const isImage = file.type.startsWith("image/"); @@ -65,8 +88,6 @@ export default async (req: Request): Promise => { let url = ""; - const config = getConfig(); - if (isImage) { const hash = await uploadFile(file, config); @@ -87,7 +108,7 @@ export default async (req: Request): Promise => { thumbnail_url: thumbnailUrl, sha256: sha256.update(await file.arrayBuffer()).digest("hex"), mime_type: file.type, - description: (description as string | undefined) ?? "", + description: description ?? "", size: file.size, blurhash: blurhash ?? undefined, width: metadata?.width ?? undefined, diff --git a/utils/config.ts b/utils/config.ts index fb3d7179..6d84b30a 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -41,6 +41,10 @@ export interface ConfigType { max_media_size: number; max_media_attachments: number; max_media_description_size: number; + max_poll_options: number; + max_poll_option_size: number; + min_poll_duration: number; + max_poll_duration: number; username_blacklist: string[]; blacklist_tempmail: boolean; @@ -181,8 +185,12 @@ export const configDefaults: ConfigType = { max_avatar_size: 5_000_000, max_header_size: 5_000_000, max_media_size: 40_000_000, - max_media_attachments: 4, + max_media_attachments: 10, max_media_description_size: 1000, + max_poll_options: 20, + max_poll_option_size: 500, + min_poll_duration: 60, + max_poll_duration: 1893456000, max_username_size: 30, username_blacklist: [