diff --git a/.gitignore b/.gitignore index d23d9c1..379fb8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /node_modules bun.lock .env +/uploads diff --git a/package.json b/package.json index 8570105..20ba07c 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,13 @@ "cleanup": "rm -rf logs node_modules bun.lockdb" }, "devDependencies": { - "@eslint/js": "^9.21.0", + "@eslint/js": "^9.22.0", "@types/bun": "^1.2.4", "@types/ejs": "^3.1.5", "@types/luxon": "^3.4.2", - "@typescript-eslint/eslint-plugin": "^8.26.0", - "@typescript-eslint/parser": "^8.26.0", - "eslint": "^9.21.0", + "@typescript-eslint/eslint-plugin": "^8.26.1", + "@typescript-eslint/parser": "^8.26.1", + "eslint": "^9.22.0", "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-promise": "^7.2.1", "eslint-plugin-simple-import-sort": "^12.1.1", @@ -30,6 +30,7 @@ }, "dependencies": { "ejs": "^3.1.10", + "exiftool-vendored": "^29.1.0", "fast-jwt": "^5.0.5", "luxon": "^3.5.0", "redis": "^4.7.0" diff --git a/src/helpers/char.ts b/src/helpers/char.ts index 218e318..8394623 100644 --- a/src/helpers/char.ts +++ b/src/helpers/char.ts @@ -120,3 +120,32 @@ export function nameWithoutExtension(fileName: string): string { const extension: string | null = getExtension(fileName); return extension ? fileName.slice(0, -extension.length - 1) : fileName; } + +export function supportsExif(mimeType: string, extension: string): boolean { + const supportedMimeTypes: string[] = [ + "image/jpeg", + "image/tiff", + "image/png", + "image/webp", + "image/heif", + "image/heic", + ]; + const supportedExtensions: string[] = [ + "jpg", + "jpeg", + "tiff", + "png", + "webp", + "heif", + "heic", + ]; + + return ( + supportedMimeTypes.includes(mimeType.toLowerCase()) && + supportedExtensions.includes(extension.toLowerCase()) + ); +} + +export function supportsThumbnails(mimeType: string): boolean { + return /^(image\/(?!svg+xml)|video\/)/i.test(mimeType); +} diff --git a/src/routes/api/files/delete[query].ts b/src/routes/api/files/delete[query].ts new file mode 100644 index 0000000..ba24c42 --- /dev/null +++ b/src/routes/api/files/delete[query].ts @@ -0,0 +1,169 @@ +import { dataType } from "@config/environment"; +import { s3, sql, type SQLQuery } from "bun"; +import { resolve } from "path"; + +import { isUUID } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; + +const routeDef: RouteDef = { + method: "DELETE", + accepts: "*/*", + returns: "application/json", + needsBody: "json", +}; + +async function processFile( + request: ExtendedRequest, + file: string, + isAdmin: boolean, + failedFiles: { reason: string; file: string }[], + successfulFiles: string[], +): Promise { + if (!file) { + failedFiles.push({ + reason: "File not provided", + file, + }); + return; + } + + const isID: boolean = isUUID(file); + let fileData: FileEntry | null = null; + + try { + let query: SQLQuery; + if (isID) { + query = sql` + SELECT * FROM files WHERE id = ${file} + `; + } else { + query = sql` + SELECT * FROM files WHERE name = ${file} + `; + } + + const result: FileEntry[] = await query; + + if (result.length === 0) { + failedFiles.push({ + reason: "File not found", + file, + }); + return; + } + + fileData = result[0]; + } catch (error) { + logger.error(["Failed to fetch file data", error as Error]); + failedFiles.push({ + reason: "Failed to fetch file data", + file, + }); + return; + } + + if (!isAdmin && fileData.owner !== request.session?.id) { + failedFiles.push({ + reason: "Forbidden", + file, + }); + } + + // ? Unsure if this is necessary + // if(fileData.password && !password) { + // return Response.json( + // { + // success: false, + // code: 403, + // error: "Password required", + // }, + // { status: 403 }, + // ); + // } + + try { + if (dataType.type === "local" && dataType.path) { + const filePath: string = await resolve( + dataType.path, + `${fileData.id}${fileData.extension ? `.${fileData.extension}` : ""}`, + ); + logger.info(["Deleting file", filePath]); + await Bun.file(filePath).unlink(); + } else { + const filePath: string = `uploads/${fileData.id}${fileData.extension ? `.${fileData.extension}` : ""}`; + await s3.delete(filePath); + } + + await sql` + DELETE FROM files WHERE id = ${fileData.id} + `; + } catch (error) { + logger.error(["Failed to delete file", error as Error]); + failedFiles.push({ + reason: "Failed to delete file", + file, + }); + } + + successfulFiles.push(file); + return; +} + +async function handler( + request: ExtendedRequest, + requestBody: unknown, +): Promise { + if (!request.session) { + return Response.json( + { + success: false, + code: 401, + error: "Unauthorized", + }, + { status: 401 }, + ); + } + + const isAdmin: boolean = request.session.roles.includes("admin"); + const { file } = request.params as { file: string }; + let { files } = requestBody as { files: string[] | string }; + // const { password } = request.query as { password: string }; + + const failedFiles: { reason: string; file: string }[] = []; + const successfulFiles: string[] = []; + + try { + if (file) { + await processFile( + request, + file, + isAdmin, + failedFiles, + successfulFiles, + ); + } else if (files) { + files = Array.isArray(files) + ? files + : files.split(/[, ]+/).filter(Boolean); + } + } catch (error) { + logger.error(["Unexpected error", error as Error]); + return Response.json( + { + success: false, + code: 500, + error: "Unexpected error", + }, + { status: 500 }, + ); + } + + return Response.json({ + success: true, + code: 200, + files: successfulFiles, + failed: failedFiles, + }); +} + +export { handler, routeDef }; diff --git a/src/routes/api/files/upload.ts b/src/routes/api/files/upload.ts index b000b53..663057f 100644 --- a/src/routes/api/files/upload.ts +++ b/src/routes/api/files/upload.ts @@ -1,6 +1,15 @@ +import { dataType } from "@config/environment"; import { getSetting } from "@config/sql/settings"; -import { password as bunPassword, randomUUIDv7 } from "bun"; +import { + password as bunPassword, + randomUUIDv7, + s3, + sql, + type SQLQuery, +} from "bun"; +import { exiftool } from "exiftool-vendored"; import { DateTime } from "luxon"; +import { resolve } from "path"; import { generateRandomString, @@ -8,7 +17,9 @@ import { getExtension, getNewTimeUTC, nameWithoutExtension, + supportsExif, } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; const routeDef: RouteDef = { method: "POST", @@ -16,19 +27,108 @@ const routeDef: RouteDef = { returns: "application/json", }; -async function handler(request: ExtendedRequest): Promise { - if (!request.session || request.session === null) { - return Response.json( - { - success: false, - code: 401, - error: "Unauthorized", - }, - { status: 401 }, - ); - } +async function removeExifData( + fileBuffer: ArrayBuffer, + extension: string, +): Promise { + const tempInputPath: string = resolve( + "temp", + `${generateRandomString(5)}.${extension}`, + ); - const session: UserSession | ApiUserSession = request.session; + try { + await Bun.write(tempInputPath, fileBuffer, { createPath: true }); + + const tagsToRemove: Record = { + GPSAltitude: null, + GPSAltitudeRef: null, + GPSAreaInformation: null, + GPSCoordinates: null, + GPSDateStamp: null, + GPSDestBearing: null, + GPSDateTime: null, + GPSDestBearingRef: null, + GPSDestDistance: null, + GPSDestDistanceRef: null, + GPSDestLatitude: null, + GPSDestLatitudeRef: null, + GPSDestLongitude: null, + GPSDestLongitudeRef: null, + GPSDifferential: null, + GPSDOP: null, + GPSHPositioningError: null, + GPSImgDirection: null, + GPSImgDirectionRef: null, + GPSLatitude: null, + GPSLatitudeRef: null, + GPSLongitude: null, + GPSLongitudeRef: null, + GPSMapDatum: null, + GPSMeasureMode: null, + GPSPosition: null, + GPSProcessingMethod: null, + GPSSatellites: null, + GPSSpeed: null, + GPSSpeedRef: null, + GPSStatus: null, + GPSTimeStamp: null, + GPSTrack: null, + GPSTrackRef: null, + GPSValid: null, + GPSVersionID: null, + GeolocationBearing: null, + GeolocationCity: null, + GeolocationCountry: null, + GeolocationCountryCode: null, + GeolocationDistance: null, + GeolocationFeatureCode: null, + GeolocationFeatureType: null, + GeolocationPopulation: null, + GeolocationPosition: null, + GeolocationRegion: null, + GeolocationSubregion: null, + GeolocationTimeZone: null, + GeolocationWarning: null, + Location: null, + LocationAccuracyHorizontal: null, + LocationAreaCode: null, + LocationInfoVersion: null, + LocationName: null, + }; + + await exiftool.write(tempInputPath, tagsToRemove, [ + "-overwrite_original", + ]); + + const modifiedBuffer: ArrayBuffer = + await Bun.file(tempInputPath).arrayBuffer(); + + return modifiedBuffer; + } catch (error) { + logger.error(["Error modifying EXIF data:", error as Error]); + return fileBuffer; + } finally { + try { + await Bun.file(tempInputPath).unlink(); + } catch (cleanupError) { + logger.error([ + "Error cleaning up temp EXIF data file:", + cleanupError as Error, + ]); + } + } +} + +async function processFile( + file: File, + key: string, + failedFiles: { reason: string; file: string }[], + successfulFiles: FileUpload[], + request: ExtendedRequest, +): Promise { + const session: UserSession | ApiUserSession = request.session as + | UserSession + | ApiUserSession; const { max_views: user_provided_max_views, @@ -62,6 +162,184 @@ async function handler(request: ExtendedRequest): Promise { clearExif: request.headers.get("X-Clear-Exif") ?? "", }; + const extension: string | null = getExtension(file.name); + let rawName: string | null = nameWithoutExtension(file.name); + const maxViews: number | null = + parseInt(user_provided_max_views, 10) || null; + + if (!rawName) { + failedFiles.push({ + reason: "Invalid file name", + file: key, + }); + return; + } + + let hashedPassword: string | null = null; + + if (user_provided_password) { + try { + hashedPassword = await bunPassword.hash(user_provided_password, { + algorithm: "argon2id", + }); + } catch (error) { + throw error; + } + } + + const randomUUID: string = randomUUIDv7(); + const tags: string[] = Array.isArray(user_provided_tags) + ? user_provided_tags + : (user_provided_tags?.split(/[, ]+/).filter(Boolean) ?? []); + + let uploadEntry: FileUpload = { + id: randomUUID as UUID, + owner: session.id as UUID, + name: rawName, + mime_type: file.type, + extension: extension, + size: file.size, + max_views: maxViews, + password: hashedPassword, + favorite: user_wants_favorite === "true" || user_wants_favorite === "1", + tags: tags, + expires_at: delete_short_string + ? getNewTimeUTC(delete_short_string) + : null, + }; + + if (name_format === "date") { + const setTimezone: string = + session.timezone || (await getSetting("default_timezone")) || "UTC"; + const date: DateTime = DateTime.local().setZone(setTimezone); + uploadEntry.name = `${date.toFormat((await getSetting("date_format")) || "yyyy-MM-dd_HH-mm-ss")}`; + } else if (name_format === "random") { + uploadEntry.name = generateRandomString( + Number(await getSetting("random_name_length")) || 8, + ); + } else if (name_format === "uuid") { + uploadEntry.name = randomUUID; + } else { + // ? Should work not sure about non-english characters + const sanitizedFileName: string = rawName + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-zA-Z0-9._-]/g, "_") + .toLowerCase(); + if (sanitizedFileName.length > 255) + uploadEntry.name = sanitizedFileName.substring(0, 255); + + try { + const existingFile: FileEntry[] = await sql` + SELECT * FROM files WHERE owner = ${session.id} AND name = ${uploadEntry.name}; + `; + + if (existingFile.length > 0) { + const maxBaseLength: number = 255 - 6; + uploadEntry.name = `${uploadEntry.name?.substring(0, maxBaseLength)}_${generateRandomString(5)}`; + } + } catch (error) { + logger.error(["Error checking for existing file:", error as Error]); + + failedFiles.push({ + reason: "Failed to check for existing file", + file: key, + }); + return; + } + } + + if (uploadEntry.name !== rawName) uploadEntry.original_name = rawName; + + let fileBuffer: ArrayBuffer = await file.arrayBuffer(); + + if ( + supportsExif(file.type, extension ?? "") && + (userHeaderOptions.clearExif === "true" || + userHeaderOptions.clearExif === "1") + ) { + fileBuffer = await removeExifData(fileBuffer, extension ?? ""); + } + + const uuidWithExtension: string = `${uploadEntry.id}${extension ? `.${extension}` : ""}`; + + let path: string; + if (dataType.type === "local" && dataType.path) { + path = resolve(dataType.path, uuidWithExtension); + + try { + await Bun.write(path, fileBuffer, { createPath: true }); + } catch (error) { + logger.error(["Error writing file to disk:", error as Error]); + + failedFiles.push({ + reason: "Failed to write file", + file: key, + }); + return; + } + } else { + path = "/uploads/" + uuidWithExtension; + + try { + await s3.write(path, fileBuffer); + } catch (error) { + logger.error(["Error writing file to S3:", error as Error]); + + failedFiles.push({ + reason: "Failed to write file", + file: key, + }); + return; + } + } + + try { + const result: FileUpload[] = await sql` + INSERT INTO files ( id, owner, folder, name, original_name, mime_type, extension, size, max_views, password, favorite, tags, expires_at ) + VALUES ( + ${uploadEntry.id}, ${uploadEntry.owner}, ${folder_identifier}, ${uploadEntry.name}, + ${uploadEntry.original_name}, ${uploadEntry.mime_type}, ${uploadEntry.extension}, + ${uploadEntry.size}, ${uploadEntry.max_views}, ${uploadEntry.password}, + ${uploadEntry.favorite}, ARRAY[${(uploadEntry.tags ?? []).map((tag: string): SQLQuery => sql`${tag}`)}]::TEXT[], + ${uploadEntry.expires_at} + ) + RETURNING id; + `; + + if (result.length === 0) { + failedFiles.push({ + reason: "Failed to create file entry", + file: key, + }); + return; + } + } catch (error) { + logger.error(["Error creating file entry:", error as Error]); + + failedFiles.push({ + reason: "Failed to create file entry", + file: key, + }); + return; + } + + uploadEntry.url = `${userHeaderOptions.domain}/f/${uploadEntry.name}`; + successfulFiles.push(uploadEntry); // ? should i remove the password from the response +} + +async function handler(request: ExtendedRequest): Promise { + if (!request.session) { + return Response.json( + { + success: false, + code: 401, + error: "Unauthorized", + }, + { status: 401 }, + ); + } + let requestBody: FormData | null; if (request.actualContentType === "multipart/form-data") { try { @@ -114,126 +392,55 @@ async function handler(request: ExtendedRequest): Promise { } const failedFiles: { reason: string; file: string }[] = []; - const successfulFiles: string[] = []; + const successfulFiles: FileUpload[] = []; - formData.forEach( - async (file: FormDataEntryValue, key: string): Promise => { - if (!(file instanceof File)) { - failedFiles.push({ - reason: "Invalid file", - file: key, - }); - return; - } + const files: { key: string; file: File }[] = []; - if (!file.type || file.type === "") { - failedFiles.push({ - reason: "Cannot determine file type", - file: key, - }); - return; - } + formData.forEach((value: FormDataEntryValue, key: string): void => { + if (value instanceof File) { + files.push({ key, file: value }); + } + }); - if (!file.size || file.size === 0) { - failedFiles.push({ - reason: "Empty file", - file: key, - }); - return; - } + for (const { key, file } of files) { + if (!file.type || file.type === "") { + failedFiles.push({ + reason: "Cannot determine file type", + file: key, + }); + continue; + } - if (!file.name || file.name === "") { - failedFiles.push({ - reason: "Missing file name", - file: key, - }); - return; - } + if (!file.size || file.size === 0) { + failedFiles.push({ + reason: "Empty file", + file: key, + }); + continue; + } - const extension: string | null = getExtension(file.name); - let rawName: string | null = nameWithoutExtension(file.name); - const maxViews: number | null = - parseInt(user_provided_max_views, 10) || null; + if (!file.name || file.name === "") { + failedFiles.push({ + reason: "Missing file name", + file: key, + }); + continue; + } - if (!rawName) { - failedFiles.push({ - reason: "Invalid file name", - file: key, - }); - return; - } - - let hashedPassword: string | null = null; - - if (user_provided_password) { - try { - hashedPassword = await bunPassword.hash( - user_provided_password, - { - algorithm: "argon2id", - }, - ); - } catch (error) { - throw error; - } - } - - const tags: string[] = Array.isArray(user_provided_tags) - ? user_provided_tags - : (user_provided_tags?.split(/[, ]+/).filter(Boolean) ?? []); - - let uploadEntry: FileUpload = { - owner: session.id as UUID, - name: rawName, - mime_type: file.type, - extension: extension, - size: file.size, - max_views: maxViews, - password: hashedPassword, - favorite: - user_wants_favorite === "true" || - user_wants_favorite === "1", - tags: tags, - expires_at: delete_short_string - ? getNewTimeUTC(delete_short_string) - : null, - }; - - if (name_format === "date") { - const setTimezone: string = - session.timezone || - (await getSetting("default_timezone")) || - "UTC"; - const date: DateTime = DateTime.local().setZone(setTimezone); - uploadEntry.name = `${date.toFormat((await getSetting("date_format")) || "yyyy-MM-dd_HH-mm-ss")}`; - } else if (name_format === "random") { - uploadEntry.name = generateRandomString( - Number(await getSetting("random_name_length")) || 8, - ); - } else if (name_format === "uuid") { - const randomUUID: string = randomUUIDv7(); - uploadEntry.name = randomUUID; - uploadEntry.id = randomUUID as UUID; - } else { - // ? Should work not sure about non-english characters - const sanitizedFileName: string = rawName - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-zA-Z0-9._-]/g, "_") - .toLowerCase(); - if (sanitizedFileName.length > 255) - uploadEntry.name = sanitizedFileName.substring(0, 255); - } - - if (uploadEntry.name !== rawName) - uploadEntry.original_name = rawName; - - // let fileBuffer: ArrayBuffer = await file.arrayBuffer(); - }, - ); + try { + await processFile(file, key, failedFiles, successfulFiles, request); + } catch (error) { + logger.error(["Error processing file:", error as Error]); + failedFiles.push({ + reason: "Unexpected error processing file", + file: key, + }); + } + } return Response.json({ success: true, + code: 200, files: successfulFiles, failed: failedFiles, }); diff --git a/types/file.d.ts b/types/file.d.ts index e836201..eac9b7f 100644 --- a/types/file.d.ts +++ b/types/file.d.ts @@ -21,7 +21,9 @@ type FileEntry = { expires_at?: string | null; }; -type FileUpload = Partial; +type FileUpload = Partial & { + url?: string; +}; type Folder = { id: UUID;