diff --git a/config/sql/users.ts b/config/sql/users.ts index 385ddf4..560b906 100644 --- a/config/sql/users.ts +++ b/config/sql/users.ts @@ -39,7 +39,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { // * Validation functions -// ? should support non english characters but wont mess up the url +// ? should support non english characters but won't mess up the url export const userNameRestrictions: { length: { min: number; max: number }; regex: RegExp; diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts index e95ac5d..1a3d91f 100644 --- a/src/helpers/auth.ts +++ b/src/helpers/auth.ts @@ -8,11 +8,6 @@ export async function authByToken( ): Promise { let selfReservation: boolean = false; - if (!reservation) { - reservation = await sql.reserve(); - selfReservation = true; - } - const authorizationHeader: string | null = request.headers.get("Authorization"); @@ -22,6 +17,11 @@ export async function authByToken( const authorizationToken: string = authorizationHeader.slice(7).trim(); if (!authorizationToken || !isUUID(authorizationToken)) return null; + if (!reservation) { + reservation = await sql.reserve(); + selfReservation = true; + } + try { const result: User[] = await reservation`SELECT * FROM users WHERE authorization_token = ${authorizationToken};`; diff --git a/src/helpers/char.ts b/src/helpers/char.ts index 9563c0a..21aa15c 100644 --- a/src/helpers/char.ts +++ b/src/helpers/char.ts @@ -109,16 +109,72 @@ export function getBaseUrl(request: Request): string { return `${protocol}://${url.hostname}${portSegment}`; } -// * File Specific Helpers +const knownExtensions: Set = new Set([ + "mp4", + "txt", + "pdf", + "gz", + "tar", + "zip", + "7z", + "rar", + "png", + "jpg", + "jpeg", + "webp", + "gif", + "js", + "ts", + "tsx", + "json", + "html", + "css", + "md", + "log", + "db", + "db3", + "sqlite", + "csv", + "xml", + "yaml", + "yml", + "toml", + "ini", + "cfg", + "conf", + "env", + "sh", + "bash", + "zsh", + "fish", + "ps1", + "bat", + "cmd", + "py", + "pyc", + "pyo", + "pyd", + "pyw", + "pyz", + "pyzw", +]); + export function getExtension(fileName: string): string | null { - return fileName.split(".").length > 1 && fileName.split(".").pop() !== "" - ? (fileName.split(".").pop() ?? null) - : null; + const lastDotIndex: number = fileName.lastIndexOf("."); + if (lastDotIndex <= 0) return null; + + const ext: string = fileName.slice(lastDotIndex + 1).toLowerCase(); + return knownExtensions.has(ext) ? ext : null; } export function nameWithoutExtension(fileName: string): string { - const extension: string | null = getExtension(fileName); - return extension ? fileName.slice(0, -extension.length - 1) : fileName; + const lastDotIndex: number = fileName.lastIndexOf("."); + if (lastDotIndex <= 0) return fileName; + + const ext: string = fileName.slice(lastDotIndex + 1).toLowerCase(); + return knownExtensions.has(ext) + ? fileName.slice(0, lastDotIndex) + : fileName; } export function supportsExif(mimeType: string, extension: string): boolean { diff --git a/src/routes/api/files/upload.ts b/src/routes/api/files/upload.ts index 9623dad..0d8b3d5 100644 --- a/src/routes/api/files/upload.ts +++ b/src/routes/api/files/upload.ts @@ -224,17 +224,20 @@ async function processFile( .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-zA-Z0-9._-]/g, "_") .toLowerCase(); - if (sanitizedFileName.length > 255) - uploadEntry.name = sanitizedFileName.substring(0, 255); + + uploadEntry.name = + sanitizedFileName.length > 255 + ? sanitizedFileName.substring(0, 255) + : sanitizedFileName; try { const existingFile: FileEntry[] = await sql` - SELECT * FROM files WHERE owner = ${session.id} AND name = ${uploadEntry.name}; - `; + 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)}`; + uploadEntry.name = `${uploadEntry.name.substring(0, maxBaseLength)}_${generateRandomString(5)}`; } } catch (error) { logger.error(["Error checking for existing file:", error as Error]); @@ -322,8 +325,10 @@ async function processFile( return; } - uploadEntry.url = `${userHeaderOptions.domain}/f/${uploadEntry.name}`; - successfulFiles.push(uploadEntry); // ? should i remove the password from the response + if (uploadEntry.password) delete uploadEntry.password; + + uploadEntry.url = `${userHeaderOptions.domain}/raw/${uploadEntry.name}`; + successfulFiles.push(uploadEntry); } async function handler(request: ExtendedRequest): Promise { diff --git a/src/routes/raw/[query].ts b/src/routes/raw/[query].ts new file mode 100644 index 0000000..be23740 --- /dev/null +++ b/src/routes/raw/[query].ts @@ -0,0 +1,183 @@ +import { dataType } from "@config/environment"; +import { type BunFile, type ReservedSQL, sql } from "bun"; +import { resolve } from "path"; + +import { isUUID, nameWithoutExtension } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "*/*", +}; + +async function handler(request: ExtendedRequest): Promise { + const { query: file } = request.params as { query: string }; + const { + password, + download: downloadFile, + json, + thumbnail, + } = request.query as { + password: string; + download: string; + json: string; + thumbnail: string; + }; + + const isAdmin: boolean = request.session + ? request.session.roles.includes("admin") + : false; + + if (!file) { + return Response.json( + { + success: false, + code: 400, + error: "No file specified", + }, + { status: 400 }, + ); + } + + const reservation: ReservedSQL = await sql.reserve(); + + const rawName: string = nameWithoutExtension(file); + const isID: boolean = isUUID(rawName); + let fileData: FileEntry | null = null; + + try { + let result: FileEntry[] = []; + if (isID) { + result = await reservation` + SELECT * FROM files WHERE id = ${rawName} + `; + } else { + result = await reservation` + SELECT * FROM files WHERE name = ${rawName} + `; + } + + if (result.length === 0) { + return Response.json( + { + success: false, + code: 404, + error: "File not found", + }, + { status: 404 }, + ); + } + + fileData = result[0]; + } catch (error) { + logger.error(["Failed to fetch file data", error as Error]); + return Response.json( + { + success: false, + code: 500, + error: "Failed to fetch file data", + }, + { status: 500 }, + ); + } finally { + reservation.release(); + } + + if ( + !isAdmin && + fileData.owner !== request.session?.id && + fileData.password && + !password + ) { + return Response.json( + { + success: false, + code: 403, + error: "Password required", + }, + { status: 403 }, + ); + } + + if ( + fileData.password && + (await Bun.password.verify(password, fileData.password)) !== true + ) { + return Response.json( + { + success: false, + code: 403, + error: "Invalid password", + }, + { status: 403 }, + ); + } + + if (json === "true" || json === "1") { + delete fileData.password; + fileData.tags = fileData.tags = fileData.tags[0]?.trim() + ? fileData.tags[0].split(",").filter((tag: string) => tag.trim()) + : []; + + return Response.json( + { + success: true, + code: 200, + file: fileData, + }, + { status: 200 }, + ); + } + + const shouldShowThumbnail: boolean = + thumbnail === "true" || thumbnail === "1"; + let path: string; + if (dataType.type === "local" && dataType.path) { + if (shouldShowThumbnail) { + path = resolve(dataType.path, "thumbnails", `${fileData.id}.jpg`); + } else { + path = resolve( + dataType.path, + `${fileData.id}${ + fileData.extension ? `.${fileData.extension}` : "" + }`, + ); + } + } else { + if (shouldShowThumbnail) { + path = `thumbnails/${fileData.id}.jpg`; + } else { + path = `uploads/${fileData.id}${fileData.extension ? `.${fileData.extension}` : ""}`; + } + } + + try { + const bunStream: BunFile = Bun.file(path); + + return new Response(bunStream, { + headers: { + "Content-Type": shouldShowThumbnail + ? "image/jpeg" + : fileData.mime_type, + "Content-Disposition": + downloadFile === "true" || downloadFile === "1" + ? `attachment; filename="${fileData.original_name || fileData.name}"` + : `inline; filename="${fileData.original_name || fileData.name}"`, + }, + status: 200, + }); + } catch (error) { + logger.error(["Failed to fetch file", error as Error]); + return Response.json( + { + success: false, + code: 500, + error: "Failed to fetch file", + }, + { status: 500 }, + ); + } +} + +export { handler, routeDef }; diff --git a/src/server.ts b/src/server.ts index 6f6381e..fc40125 100644 --- a/src/server.ts +++ b/src/server.ts @@ -38,6 +38,7 @@ class ServerHandler { message: webSocketHandler.handleMessage.bind(webSocketHandler), close: webSocketHandler.handleClose.bind(webSocketHandler), }, + maxRequestBodySize: 10 * 1024 * 1024 * 1024, // 10GB ? will be changed to env var soon }); logger.info(