diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 804a6a8a..4b1cd2a8 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -22,6 +22,8 @@ import { import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji"; import type { APIStatus } from "~types/entities/status"; import { applicationToAPI } from "./Application"; +import { attachmentToAPI } from "./Attachment"; +import type { APIAttachment } from "~types/entities/attachment"; const config = getConfig(); @@ -53,6 +55,7 @@ export const statusAndUserRelations: Prisma.StatusInclude = { }, }, }, + reblogs: true, attachments: true, instance: true, mentions: { @@ -438,7 +441,10 @@ export const statusToAPI = async ( ), // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition favourites_count: (status.likes ?? []).length, - media_attachments: [], + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + media_attachments: (status.attachments ?? []).map( + a => attachmentToAPI(a) as APIAttachment + ), // @ts-expect-error Prisma TypeScript types dont include relations mentions: status.mentions.map(mention => userToAPI(mention)), language: null, @@ -458,11 +464,7 @@ export const statusToAPI = async ( reblogId: status.id, }, })), - reblogs_count: await client.status.count({ - where: { - reblogId: status.id, - }, - }), + reblogs_count: status._count.reblogs, replies_count: status._count.replies, sensitive: status.sensitive, spoiler_text: status.spoilerText, diff --git a/server/api/api/v1/media/index.ts b/server/api/api/v1/media/index.ts new file mode 100644 index 00000000..da3019ef --- /dev/null +++ b/server/api/api/v1/media/index.ts @@ -0,0 +1,123 @@ +import { applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; +import { encode } from "blurhash"; +import { getFromRequest } from "~database/entities/User"; +import type { APIRouteMeta } from "~types/api"; +import sharp from "sharp"; +import { uploadFile } from "~classes/media"; +import { getConfig } from "@config"; +import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 10, + duration: 60, + }, + route: "/api/v1/media", + auth: { + required: true, + oauthPermissions: ["write:media"], + }, +}); + +/** + * Upload new media + */ +export default async (req: Request): Promise => { + const { user } = await getFromRequest(req); + + if (!user) { + return errorResponse("Unauthorized", 401); + } + + const form = await req.formData(); + + const file = form.get("file") as unknown as File | undefined; + const thumbnail = form.get("thumbnail"); + const description = form.get("description") as string | undefined; + + // Floating point numbers from -1.0 to 1.0, comma delimited + // const focus = form.get("focus"); + + if (!file) { + 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.enforce_mime_types && + !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/"); + + const metadata = isImage + ? await sharp(await file.arrayBuffer()).metadata() + : null; + + const blurhash = isImage + ? encode( + new Uint8ClampedArray(await file.arrayBuffer()), + metadata?.width ?? 0, + metadata?.height ?? 0, + 4, + 4 + ) + : null; + + let url = ""; + + const hash = await uploadFile(file, config); + + url = hash ? getUrl(hash, config) : ""; + + let thumbnailUrl = ""; + + if (thumbnail) { + const hash = await uploadFile(thumbnail as unknown as File, config); + + thumbnailUrl = hash ? getUrl(hash, config) : ""; + } + + const newAttachment = await client.attachment.create({ + data: { + url, + thumbnail_url: thumbnailUrl, + sha256: sha256.update(await file.arrayBuffer()).digest("hex"), + mime_type: file.type, + description: description ?? "", + size: file.size, + blurhash: blurhash ?? undefined, + width: metadata?.width ?? undefined, + height: metadata?.height ?? undefined, + }, + }); + + // TODO: Add job to process videos and other media + + return jsonResponse(attachmentToAPI(newAttachment)); +}; diff --git a/server/api/api/v1/statuses/[id]/reblog.ts b/server/api/api/v1/statuses/[id]/reblog.ts index 32096db3..dc67ee8d 100644 --- a/server/api/api/v1/statuses/[id]/reblog.ts +++ b/server/api/api/v1/statuses/[id]/reblog.ts @@ -10,7 +10,10 @@ import { statusAndUserRelations, statusToAPI, } from "~database/entities/Status"; -import { getFromRequest } from "~database/entities/User"; +import { + getFromRequest, + type UserWithRelations, +} from "~database/entities/User"; import type { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -84,10 +87,15 @@ export default async ( }); // Create notification for reblog if reblogged user is on the same instance - if (status.reblog?.author.instanceId === user.instanceId) { + if ( + // @ts-expect-error Prisma relations not showing in types + (status.reblog?.author as UserWithRelations).instanceId === + user.instanceId + ) { await client.notification.create({ data: { accountId: user.id, + // @ts-expect-error Prisma relations not showing in types notifiedId: status.reblog.authorId, type: "reblog", statusId: status.reblogId,