fixed multi upload and added exif removal, added delete file route, supports body or query, update packages

This commit is contained in:
creations 2025-03-10 18:50:00 -04:00
parent f8538814a1
commit c5b2d1177a
Signed by: creations
GPG key ID: 8F553AA4320FC711
6 changed files with 538 additions and 129 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
/node_modules /node_modules
bun.lock bun.lock
.env .env
/uploads

View file

@ -10,13 +10,13 @@
"cleanup": "rm -rf logs node_modules bun.lockdb" "cleanup": "rm -rf logs node_modules bun.lockdb"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.21.0", "@eslint/js": "^9.22.0",
"@types/bun": "^1.2.4", "@types/bun": "^1.2.4",
"@types/ejs": "^3.1.5", "@types/ejs": "^3.1.5",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@typescript-eslint/eslint-plugin": "^8.26.0", "@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.0", "@typescript-eslint/parser": "^8.26.1",
"eslint": "^9.21.0", "eslint": "^9.22.0",
"eslint-plugin-prettier": "^5.2.3", "eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-promise": "^7.2.1", "eslint-plugin-promise": "^7.2.1",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
@ -30,6 +30,7 @@
}, },
"dependencies": { "dependencies": {
"ejs": "^3.1.10", "ejs": "^3.1.10",
"exiftool-vendored": "^29.1.0",
"fast-jwt": "^5.0.5", "fast-jwt": "^5.0.5",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"redis": "^4.7.0" "redis": "^4.7.0"

View file

@ -120,3 +120,32 @@ export function nameWithoutExtension(fileName: string): string {
const extension: string | null = getExtension(fileName); const extension: string | null = getExtension(fileName);
return extension ? fileName.slice(0, -extension.length - 1) : 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);
}

View file

@ -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<void> {
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<Response> {
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 };

View file

@ -1,6 +1,15 @@
import { dataType } from "@config/environment";
import { getSetting } from "@config/sql/settings"; 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 { DateTime } from "luxon";
import { resolve } from "path";
import { import {
generateRandomString, generateRandomString,
@ -8,7 +17,9 @@ import {
getExtension, getExtension,
getNewTimeUTC, getNewTimeUTC,
nameWithoutExtension, nameWithoutExtension,
supportsExif,
} from "@/helpers/char"; } from "@/helpers/char";
import { logger } from "@/helpers/logger";
const routeDef: RouteDef = { const routeDef: RouteDef = {
method: "POST", method: "POST",
@ -16,19 +27,108 @@ const routeDef: RouteDef = {
returns: "application/json", returns: "application/json",
}; };
async function handler(request: ExtendedRequest): Promise<Response> { async function removeExifData(
if (!request.session || request.session === null) { fileBuffer: ArrayBuffer,
return Response.json( extension: string,
{ ): Promise<ArrayBuffer> {
success: false, const tempInputPath: string = resolve(
code: 401, "temp",
error: "Unauthorized", `${generateRandomString(5)}.${extension}`,
}, );
{ status: 401 },
);
}
const session: UserSession | ApiUserSession = request.session; try {
await Bun.write(tempInputPath, fileBuffer, { createPath: true });
const tagsToRemove: Record<string, null> = {
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<void> {
const session: UserSession | ApiUserSession = request.session as
| UserSession
| ApiUserSession;
const { const {
max_views: user_provided_max_views, max_views: user_provided_max_views,
@ -62,6 +162,184 @@ async function handler(request: ExtendedRequest): Promise<Response> {
clearExif: request.headers.get("X-Clear-Exif") ?? "", 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<Response> {
if (!request.session) {
return Response.json(
{
success: false,
code: 401,
error: "Unauthorized",
},
{ status: 401 },
);
}
let requestBody: FormData | null; let requestBody: FormData | null;
if (request.actualContentType === "multipart/form-data") { if (request.actualContentType === "multipart/form-data") {
try { try {
@ -114,126 +392,55 @@ async function handler(request: ExtendedRequest): Promise<Response> {
} }
const failedFiles: { reason: string; file: string }[] = []; const failedFiles: { reason: string; file: string }[] = [];
const successfulFiles: string[] = []; const successfulFiles: FileUpload[] = [];
formData.forEach( const files: { key: string; file: File }[] = [];
async (file: FormDataEntryValue, key: string): Promise<void> => {
if (!(file instanceof File)) {
failedFiles.push({
reason: "Invalid file",
file: key,
});
return;
}
if (!file.type || file.type === "") { formData.forEach((value: FormDataEntryValue, key: string): void => {
failedFiles.push({ if (value instanceof File) {
reason: "Cannot determine file type", files.push({ key, file: value });
file: key, }
}); });
return;
}
if (!file.size || file.size === 0) { for (const { key, file } of files) {
failedFiles.push({ if (!file.type || file.type === "") {
reason: "Empty file", failedFiles.push({
file: key, reason: "Cannot determine file type",
}); file: key,
return; });
} continue;
}
if (!file.name || file.name === "") { if (!file.size || file.size === 0) {
failedFiles.push({ failedFiles.push({
reason: "Missing file name", reason: "Empty file",
file: key, file: key,
}); });
return; continue;
} }
const extension: string | null = getExtension(file.name); if (!file.name || file.name === "") {
let rawName: string | null = nameWithoutExtension(file.name); failedFiles.push({
const maxViews: number | null = reason: "Missing file name",
parseInt(user_provided_max_views, 10) || null; file: key,
});
continue;
}
if (!rawName) { try {
failedFiles.push({ await processFile(file, key, failedFiles, successfulFiles, request);
reason: "Invalid file name", } catch (error) {
file: key, logger.error(["Error processing file:", error as Error]);
}); failedFiles.push({
return; reason: "Unexpected error processing file",
} file: key,
});
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();
},
);
return Response.json({ return Response.json({
success: true, success: true,
code: 200,
files: successfulFiles, files: successfulFiles,
failed: failedFiles, failed: failedFiles,
}); });

4
types/file.d.ts vendored
View file

@ -21,7 +21,9 @@ type FileEntry = {
expires_at?: string | null; expires_at?: string | null;
}; };
type FileUpload = Partial<FileEntry>; type FileUpload = Partial<FileEntry> & {
url?: string;
};
type Folder = { type Folder = {
id: UUID; id: UUID;