diff --git a/.gitignore b/.gitignore index 379fb8b..36e4935 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ bun.lock .env /uploads +.idea diff --git a/config/sql/settings.ts b/config/sql/settings.ts index 17e6cb9..cd067d3 100644 --- a/config/sql/settings.ts +++ b/config/sql/settings.ts @@ -13,6 +13,7 @@ const defaultSettings: Setting[] = [ { key: "require_email_verification", value: "false" }, { key: "date_format", value: "yyyy-MM-dd_HH-mm-ss" }, { key: "random_name_length", value: "8" }, + { key: "enable_thumbnails", value: "true" }, ]; export async function createTable(reservation?: ReservedSQL): Promise { diff --git a/package.json b/package.json index 20ba07c..2c7da4b 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ }, "devDependencies": { "@eslint/js": "^9.22.0", - "@types/bun": "^1.2.4", + "@types/bun": "^1.2.5", "@types/ejs": "^3.1.5", + "@types/fluent-ffmpeg": "^2.1.27", + "@types/image-thumbnail": "^1.0.4", "@types/luxon": "^3.4.2", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", @@ -32,6 +34,8 @@ "ejs": "^3.1.10", "exiftool-vendored": "^29.1.0", "fast-jwt": "^5.0.5", + "fluent-ffmpeg": "^2.1.3", + "image-thumbnail": "^1.0.17", "luxon": "^3.5.0", "redis": "^4.7.0" } diff --git a/src/helpers/char.ts b/src/helpers/char.ts index 8394623..9563c0a 100644 --- a/src/helpers/char.ts +++ b/src/helpers/char.ts @@ -146,6 +146,6 @@ export function supportsExif(mimeType: string, extension: string): boolean { ); } -export function supportsThumbnails(mimeType: string): boolean { +export function supportsThumbnail(mimeType: string): boolean { return /^(image\/(?!svg+xml)|video\/)/i.test(mimeType); } diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts index 331be1d..16e1076 100644 --- a/src/helpers/logger.ts +++ b/src/helpers/logger.ts @@ -130,17 +130,17 @@ class Logger { } public error( - message: string | Error | (string | Error)[], + message: string | Error | ErrorEvent | (string | Error)[], breakLine: boolean = false, ): void { const stack: string = new Error().stack || ""; const { filename, timestamp } = this.getCallerInfo(stack); - const messages: (string | Error)[] = Array.isArray(message) + const messages: (string | Error | ErrorEvent)[] = Array.isArray(message) ? message : [message]; const joinedMessage: string = messages - .map((msg: string | Error): string => + .map((msg: string | Error | ErrorEvent): string => typeof msg === "string" ? msg : msg.message, ) .join(" "); diff --git a/src/helpers/workers/thumbnails.ts b/src/helpers/workers/thumbnails.ts new file mode 100644 index 0000000..45c9073 --- /dev/null +++ b/src/helpers/workers/thumbnails.ts @@ -0,0 +1,208 @@ +import { dataType } from "@config/environment.ts"; +import { logger } from "@helpers/logger.ts"; +import { type BunFile, s3, sql } from "bun"; +import ffmpeg from "fluent-ffmpeg"; +import imageThumbnail from "image-thumbnail"; +import { join, resolve } from "path"; + +declare var self: Worker; + +async function generateVideoThumbnail( + filePath: string, + thumbnailPath: string, +): Promise { + return new Promise( + ( + resolve: (value: ArrayBuffer) => void, + reject: (reason: Error) => void, + ): void => { + ffmpeg(filePath) + .videoFilters("thumbnail") + .frames(1) + .format("mjpeg") + .output(thumbnailPath) + .on("error", (error: Error) => { + logger.error([ + "failed to generate thumbnail", + error as Error, + ]); + reject(error); + }) + + .on("end", async () => { + const thumbnailFile: BunFile = Bun.file(thumbnailPath); + const file: BunFile = Bun.file(filePath); + const thumbnailArraybuffer: ArrayBuffer = + await thumbnailFile.arrayBuffer(); + + await thumbnailFile.unlink(); + await file.unlink(); + + resolve(thumbnailArraybuffer); + }) + .run(); + }, + ); +} + +async function generateImageThumbnail( + filePath: string, + thumbnailPath: string, +): Promise { + return new Promise( + async ( + resolve: (value: ArrayBuffer) => void, + reject: (reason: Error) => void, + ) => { + try { + const options: { + responseType: "buffer"; + height: number; + jpegOptions: { + force: boolean; + quality: number; + }; + } = { + height: 320, + responseType: "buffer", + jpegOptions: { + force: true, + quality: 60, + }, + }; + + const thumbnailBuffer: Buffer = await imageThumbnail( + filePath, + options, + ); + + await Bun.write(thumbnailPath, thumbnailBuffer.buffer); + resolve(await Bun.file(thumbnailPath).arrayBuffer()); + + await Bun.file(filePath).unlink(); + await Bun.file(thumbnailPath).unlink(); + } catch (error) { + reject(error as Error); + } + }, + ); +} + +async function createThumbnails(files: FileEntry[]): Promise { + const { type, path } = dataType; + + for (const file of files) { + const { id, mime_type } = file; + + let fileArrayBuffer: ArrayBuffer | null = null; + const isVideo: boolean = mime_type.startsWith("video/"); + const fileName: string = `${id}.${file.extension || ""}`; + + if (type === "local") { + const filePath: string | undefined = join(path as string, fileName); + + try { + fileArrayBuffer = await Bun.file(filePath).arrayBuffer(); + } catch { + logger.error([ + "Could not generate thumbnail for file:", + fileName, + ]); + continue; + } + } else { + try { + fileArrayBuffer = await s3.file(fileName).arrayBuffer(); + } catch { + logger.error([ + "Could not generate thumbnail for file:", + fileName, + ]); + continue; + } + } + + if (!fileArrayBuffer) { + logger.error(["Could not generate thumbnail for file:", fileName]); + continue; + } + + const tempFilePath: string = resolve("temp", `${id}.tmp`); + const tempThumbnailPath: string = resolve("temp", `${id}.jpg`); + + try { + await Bun.write(tempFilePath, fileArrayBuffer, { + createPath: true, + }); + } catch (error) { + logger.error([ + "Could not write file to temp path:", + fileName, + error as Error, + ]); + continue; + } + + try { + const thumbnailArrayBuffer: ArrayBuffer | null = isVideo + ? await generateVideoThumbnail(tempFilePath, tempThumbnailPath) + : await generateImageThumbnail(tempFilePath, tempThumbnailPath); + + if (!thumbnailArrayBuffer) { + logger.error([ + "Could not generate thumbnail for file:", + fileName, + ]); + continue; + } + + try { + if (type === "local") { + const thumbnailPath: string = join( + path as string, + "thumbnails", + `${id}.jpg`, + ); + + await Bun.write(thumbnailPath, thumbnailArrayBuffer, { + createPath: true, + }); + } else { + const thumbnailPath: string = `thumbnails/${id}.jpg`; + + await s3.file(thumbnailPath).write(thumbnailArrayBuffer); + } + } catch (error) { + logger.error([ + "Could not write thumbnail to storage:", + fileName, + error as Error, + ]); + } + + try { + await sql`UPDATE files SET thumbnail = true WHERE id = ${id}`; + } catch (error) { + logger.error([ + "Could not update file with thumbnail status:", + fileName, + error as Error, + ]); + } + } catch (error) { + logger.error([ + "An error occurred while generating thumbnail for file:", + fileName, + error as Error, + ]); + } + } +} + +self.onmessage = async (event: MessageEvent): Promise => { + await createThumbnails(event.data.files); +}; + +self.onerror = (error: ErrorEvent): void => { + logger.error(error); +}; diff --git a/src/routes/api/files/delete[query].ts b/src/routes/api/files/delete[query].ts index ba24c42..cd2ccb8 100644 --- a/src/routes/api/files/delete[query].ts +++ b/src/routes/api/files/delete[query].ts @@ -125,7 +125,7 @@ async function handler( } const isAdmin: boolean = request.session.roles.includes("admin"); - const { file } = request.params as { file: string }; + const { query: file } = request.params as { query: string }; let { files } = requestBody as { files: string[] | string }; // const { password } = request.query as { password: string }; @@ -133,7 +133,7 @@ async function handler( const successfulFiles: string[] = []; try { - if (file) { + if (file && !(typeof file === "string" && file.length === 0)) { await processFile( request, file, @@ -145,6 +145,16 @@ async function handler( files = Array.isArray(files) ? files : files.split(/[, ]+/).filter(Boolean); + + for (const file of files) { + await processFile( + request, + file, + isAdmin, + failedFiles, + successfulFiles, + ); + } } } catch (error) { logger.error(["Unexpected error", error as Error]); @@ -161,7 +171,7 @@ async function handler( return Response.json({ success: true, code: 200, - files: successfulFiles, + deleted: successfulFiles, failed: failedFiles, }); } diff --git a/src/routes/api/files/upload.ts b/src/routes/api/files/upload.ts index 663057f..9623dad 100644 --- a/src/routes/api/files/upload.ts +++ b/src/routes/api/files/upload.ts @@ -18,6 +18,7 @@ import { getNewTimeUTC, nameWithoutExtension, supportsExif, + supportsThumbnail, } from "@/helpers/char"; import { logger } from "@/helpers/logger"; @@ -100,10 +101,7 @@ async function removeExifData( "-overwrite_original", ]); - const modifiedBuffer: ArrayBuffer = - await Bun.file(tempInputPath).arrayBuffer(); - - return modifiedBuffer; + return await Bun.file(tempInputPath).arrayBuffer(); } catch (error) { logger.error(["Error modifying EXIF data:", error as Error]); return fileBuffer; @@ -438,6 +436,29 @@ async function handler(request: ExtendedRequest): Promise { } } + const filesThatSupportThumbnails: FileUpload[] = successfulFiles.filter( + (file: FileUpload): boolean => + supportsThumbnail(file.mime_type as string), + ); + if ( + (await getSetting("enable_thumbnails")) === "true" && + filesThatSupportThumbnails.length > 0 + ) { + try { + const worker: Worker = new Worker( + "./src/helpers/workers/thumbnails.ts", + { + type: "module", + }, + ); + worker.postMessage({ + files: filesThatSupportThumbnails, + }); + } catch (error) { + logger.error(["Error starting thumbnail worker:", error as Error]); + } + } + return Response.json({ success: true, code: 200, diff --git a/src/routes/api/user/info[query].ts b/src/routes/api/user/info[query].ts index 6172e86..96fb226 100644 --- a/src/routes/api/user/info[query].ts +++ b/src/routes/api/user/info[query].ts @@ -1,5 +1,5 @@ import { isValidUsername } from "@config/sql/users"; -import { sql } from "bun"; +import { type ReservedSQL, sql } from "bun"; import { isUUID } from "@/helpers/char"; import { logger } from "@/helpers/logger"; @@ -12,7 +12,9 @@ const routeDef: RouteDef = { async function handler(request: ExtendedRequest): Promise { const { query } = request.params as { query: string }; - const { invites: showInvites } = request.query as { invites: string }; + const { invites: showInvites } = request.query as { + invites: string; + }; if (!query) { return Response.json( @@ -44,10 +46,12 @@ async function handler(request: ExtendedRequest): Promise { ); } + const reservation: ReservedSQL = await sql.reserve(); + try { const result: GetUser[] = isId - ? await sql`SELECT * FROM users WHERE id = ${normalized}` - : await sql`SELECT * FROM users WHERE username = ${normalized}`; + ? await reservation`SELECT * FROM users WHERE id = ${normalized}` + : await reservation`SELECT * FROM users WHERE username = ${normalized}`; if (result.length === 0) { return Response.json( @@ -61,15 +65,23 @@ async function handler(request: ExtendedRequest): Promise { } user = result[0]; + isSelf = request.session ? user.id === request.session.id : false; + const files: { count: bigint }[] = + await reservation`SELECT COUNT(*) FROM files WHERE owner = ${user.id}`; + const folders: { count: bigint }[] = + await reservation`SELECT COUNT(*) FROM folders WHERE owner = ${user.id}`; + + if (files) user.files = Number(files[0].count); + if (folders) user.folders = Number(folders[0].count); + if ( (showInvites === "true" || showInvites === "1") && (isAdmin || isSelf) ) { - const invites: Invite[] = - await sql`SELECT * FROM invites WHERE created_by = ${user.id}`; - user.invites = invites; + user.invites = + await reservation`SELECT * FROM invites WHERE created_by = ${user.id}`; } } catch (error) { logger.error([ @@ -85,6 +97,19 @@ async function handler(request: ExtendedRequest): Promise { }, { status: 500 }, ); + } finally { + reservation.release(); + } + + if (!user) { + return Response.json( + { + success: false, + code: 404, + error: "User not found", + }, + { status: 404 }, + ); } delete user.password; diff --git a/types/file.d.ts b/types/file.d.ts index eac9b7f..e620263 100644 --- a/types/file.d.ts +++ b/types/file.d.ts @@ -25,6 +25,11 @@ type FileUpload = Partial & { url?: string; }; +type GetFile = Partial & { + url?: string; + raw_url?: string; +}; + type Folder = { id: UUID; owner: UUID; diff --git a/types/session.d.ts b/types/session.d.ts index ca8f840..acc8c8d 100644 --- a/types/session.d.ts +++ b/types/session.d.ts @@ -52,4 +52,6 @@ type GetUser = { created_at?: Date; last_seen?: Date; invites?: Invite[]; + files: number; + folders: number; };