backend/src/routes/api/files/upload.ts

449 lines
11 KiB
TypeScript

import { dataType } from "@config/environment";
import { getSetting } from "@config/sql/settings";
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,
getBaseUrl,
getExtension,
getNewTimeUTC,
nameWithoutExtension,
supportsExif,
} from "@/helpers/char";
import { logger } from "@/helpers/logger";
const routeDef: RouteDef = {
method: "POST",
accepts: ["multipart/form-data", "text/plain", "application/json"],
returns: "application/json",
};
async function removeExifData(
fileBuffer: ArrayBuffer,
extension: string,
): Promise<ArrayBuffer> {
const tempInputPath: string = resolve(
"temp",
`${generateRandomString(5)}.${extension}`,
);
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 {
max_views: user_provided_max_views,
password: user_provided_password,
expires: delete_short_string,
tags: user_provided_tags,
format: name_format = "original", // Supports original,date,random,uuid,
folder: folder_identifier,
favorite: user_wants_favorite,
} = request.query as {
max_views: string;
password: string;
expires: string;
tags: string;
format: string;
folder: string;
favorite: string;
};
const userHeaderOptions: { domain: string; clearExif: string } = {
domain: ((): string => {
const domainsList: string[] =
request.headers
.get("X-Override-Domains")
?.split(",")
.map((domain: string): string => domain.trim()) ?? [];
return domainsList.length > 0
? domainsList[Math.floor(Math.random() * domainsList.length)]
: getBaseUrl(request);
})(),
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;
if (request.actualContentType === "multipart/form-data") {
try {
requestBody = await request.formData();
} catch {
return Response.json(
{
success: false,
code: 400,
error: "Invalid form data",
},
{ status: 400 },
);
}
} else if (
request.actualContentType === "text/plain" ||
request.actualContentType === "application/json"
) {
const body: string = await request.text();
requestBody = new FormData();
requestBody.append(
"file",
new Blob([body], { type: request.actualContentType }),
request.actualContentType === "text/plain"
? "file.txt"
: "file.json",
);
} else {
return Response.json(
{
success: false,
code: 400,
error: "Invalid content type",
},
{ status: 400 },
);
}
const formData: FormData | null = requestBody as FormData;
if (!formData || !(requestBody instanceof FormData)) {
return Response.json(
{
success: false,
code: 400,
error: "Missing form data",
},
{ status: 400 },
);
}
const failedFiles: { reason: string; file: string }[] = [];
const successfulFiles: FileUpload[] = [];
const files: { key: string; file: File }[] = [];
formData.forEach((value: FormDataEntryValue, key: string): void => {
if (value instanceof File) {
files.push({ key, file: value });
}
});
for (const { key, file } of files) {
if (!file.type || file.type === "") {
failedFiles.push({
reason: "Cannot determine file type",
file: key,
});
continue;
}
if (!file.size || file.size === 0) {
failedFiles.push({
reason: "Empty file",
file: key,
});
continue;
}
if (!file.name || file.name === "") {
failedFiles.push({
reason: "Missing file name",
file: key,
});
continue;
}
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,
});
}
export { handler, routeDef };