diff --git a/bun.lockb b/bun.lockb index ec44b8db..a499d768 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/database/entities/Attachment.ts b/database/entities/Attachment.ts new file mode 100644 index 00000000..6d0ce1a2 --- /dev/null +++ b/database/entities/Attachment.ts @@ -0,0 +1,47 @@ +import { ConfigType } from "@config"; +import { Attachment } from "@prisma/client"; +import { APIAsyncAttachment } from "~types/entities/async_attachment"; +import { APIAttachment } from "~types/entities/attachment"; + +export const attachmentToAPI = ( + attachment: Attachment +): APIAsyncAttachment | APIAttachment => { + let type = "unknown"; + + if (attachment.mime_type.startsWith("image/")) { + type = "image"; + } else if (attachment.mime_type.startsWith("video/")) { + type = "video"; + } else if (attachment.mime_type.startsWith("audio/")) { + type = "audio"; + } + + return { + id: attachment.id, + type: type as any, + url: attachment.url, + remote_url: attachment.remote_url, + preview_url: attachment.thumbnail_url, + text_url: null, + meta: { + width: attachment.width || undefined, + height: attachment.height || undefined, + fps: attachment.fps || undefined, + size: attachment.size?.toString() || undefined, + duration: attachment.duration || undefined, + length: attachment.size?.toString() || undefined, + // Idk whether size or length is the right value + }, + description: attachment.description, + blurhash: attachment.blurhash, + }; +}; + +export const getUrl = (hash: string, config: ConfigType) => { + if (config.media.backend === "local") { + return `${config.http.base_url}/media/${hash}`; + } else if (config.media.backend === "s3") { + return `${config.s3.public_url}/${hash}`; + } + return ""; +}; diff --git a/index.ts b/index.ts index cc8bcbb3..30ee83d2 100644 --- a/index.ts +++ b/index.ts @@ -160,7 +160,7 @@ const logRequest = async (req: Request) => { }; // Remove previous console.log -console.clear(); +// console.clear(); console.log( `${chalk.green(`✓`)} ${chalk.bold( diff --git a/package.json b/package.json index 5b03fbf6..3ad29519 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.429.0", "@prisma/client": "^5.6.0", + "blurhash": "^2.0.5", "chalk": "^5.3.0", "html-to-text": "^9.0.5", "ip-matching": "^2.1.2", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dcf058ff..bd29f93f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -121,6 +121,7 @@ model Status { replies Status[] @relation("StatusToStatusReply") quotes Status[] @relation("StatusToStatusQuote") pinnedBy User[] @relation("UserPinnedNotes") + attachments Attachment[] } model Token { @@ -136,6 +137,24 @@ model Token { applicationId String? @db.Uuid } +model Attachment { + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + url String + remote_url String? + thumbnail_url String? + mime_type String + description String? + blurhash String? + sha256 String? + fps Int? + duration Int? + width Int? + height Int? + size Int? + status Status? @relation(fields: [statusId], references: [id], onDelete: Cascade) + statusId String? @db.Uuid +} + model User { id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid uri String @unique diff --git a/server/api/api/v2/media/index.ts b/server/api/api/v2/media/index.ts new file mode 100644 index 00000000..6cc7ccda --- /dev/null +++ b/server/api/api/v2/media/index.ts @@ -0,0 +1,103 @@ +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 { 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/v2/media", + auth: { + required: true, + }, +}); + +/** + * Fetch a user + */ +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"); + + // 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 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 config = getConfig(); + + if (isImage) { + 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 as string | undefined) ?? "", + size: file.size, + blurhash: blurhash ?? undefined, + width: metadata?.width ?? undefined, + height: metadata?.height ?? undefined, + }, + }); + + // Add job to process videos and other media + + return jsonResponse({ + ...attachmentToAPI(newAttachment), + url: undefined, + }); +}; diff --git a/types/lysand/Object.ts b/types/lysand/Object.ts index 697329e2..4efe731b 100644 --- a/types/lysand/Object.ts +++ b/types/lysand/Object.ts @@ -161,5 +161,17 @@ export interface ContentFormat { content: string; content_type: string; description?: string; - size?: string; + size?: number; + hash?: { + md5?: string; + sha1?: string; + sha256?: string; + sha512?: string; + [key: string]: string | undefined; + }; + blurhash?: string; + fps?: number; + width?: number; + height?: number; + duration?: number; }