forked from atums.world/atums.world
fixed multi upload and added exif removal, added delete file route, supports body or query, update packages
This commit is contained in:
parent
f8538814a1
commit
c5b2d1177a
6 changed files with 538 additions and 129 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
||||||
/node_modules
|
/node_modules
|
||||||
bun.lock
|
bun.lock
|
||||||
.env
|
.env
|
||||||
|
/uploads
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
169
src/routes/api/files/delete[query].ts
Normal file
169
src/routes/api/files/delete[query].ts
Normal 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 };
|
|
@ -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
4
types/file.d.ts
vendored
|
@ -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;
|
||||||
|
|
Loading…
Add table
Reference in a new issue