diff --git a/config/sql/files.ts b/config/sql/files.ts index 438111c..da08db5 100644 --- a/config/sql/files.ts +++ b/config/sql/files.ts @@ -21,10 +21,11 @@ export async function createTable(reservation?: ReservedSQL): Promise { name VARCHAR(255) NOT NULL, original_name VARCHAR(255), mime_type VARCHAR(255) NOT NULL, + extension VARCHAR(255) NOT NULL, size BIGINT NOT NULL, views INTEGER DEFAULT 0, - max_views INTEGER DEFAULT 1, + max_views INTEGER DEFAULT NULL, password TEXT DEFAULT NULL, favorite BOOLEAN DEFAULT FALSE, tags TEXT[] DEFAULT ARRAY[]::TEXT[], diff --git a/config/sql/settings.ts b/config/sql/settings.ts index d9b80f4..17e6cb9 100644 --- a/config/sql/settings.ts +++ b/config/sql/settings.ts @@ -11,6 +11,8 @@ const defaultSettings: Setting[] = [ { key: "enable_invitations", value: "true" }, { key: "allow_user_invites", value: "false" }, { key: "require_email_verification", value: "false" }, + { key: "date_format", value: "yyyy-MM-dd_HH-mm-ss" }, + { key: "random_name_length", value: "8" }, ]; export async function createTable(reservation?: ReservedSQL): Promise { diff --git a/src/helpers/char.ts b/src/helpers/char.ts index 2e239b4..218e318 100644 --- a/src/helpers/char.ts +++ b/src/helpers/char.ts @@ -100,3 +100,23 @@ export function generateRandomString(length?: number): string { return result; } + +export function getBaseUrl(request: Request): string { + const url: URL = new URL(request.url); + const protocol: string = url.protocol.slice(0, -1); + const portSegment: string = url.port ? `:${url.port}` : ""; + + return `${protocol}://${url.hostname}${portSegment}`; +} + +// * File Specific Helpers +export function getExtension(fileName: string): string | null { + return fileName.split(".").length > 1 && fileName.split(".").pop() !== "" + ? (fileName.split(".").pop() ?? null) + : null; +} + +export function nameWithoutExtension(fileName: string): string { + const extension: string | null = getExtension(fileName); + return extension ? fileName.slice(0, -extension.length - 1) : fileName; +} diff --git a/src/routes/api/auth/register.ts b/src/routes/api/auth/register.ts index 26b625d..ca14995 100644 --- a/src/routes/api/auth/register.ts +++ b/src/routes/api/auth/register.ts @@ -7,6 +7,7 @@ import { } from "@config/sql/users"; import { password as bunPassword, type ReservedSQL, sql } from "bun"; +import { isValidTimezone } from "@/helpers/char"; import { logger } from "@/helpers/logger"; import { sessionManager } from "@/helpers/sessions"; @@ -21,11 +22,12 @@ async function handler( request: ExtendedRequest, requestBody: unknown, ): Promise { - const { username, email, password, invite } = requestBody as { + const { username, email, password, invite, timezone } = requestBody as { username: string; email: string; password: string; invite?: string; + timezone?: string; }; if (!username || !email || !password) { @@ -102,7 +104,7 @@ async function handler( errors.push("Username or email already exists"); } - if (invite) { + if (invite && !firstUser) { const result: Invite[] = await reservation`SELECT * FROM invites WHERE id = ${invite};`; @@ -132,13 +134,15 @@ async function handler( const hashedPassword: string = await bunPassword.hash(password, { algorithm: "argon2id", }); - const defaultTimezone: string = - (await getSetting("default_timezone", reservation)) || "UTC"; + const setTimezone: string = + timezone && isValidTimezone(timezone) + ? timezone + : (await getSetting("default_timezone", reservation)) || "UTC"; try { const result: User[] = await reservation` INSERT INTO users (username, email, password, invited_by, roles, timezone) - VALUES (${normalizedUsername}, ${email}, ${hashedPassword}, ${inviteData?.created_by}, ARRAY[${roles.join(",")}]::TEXT[], ${defaultTimezone}) + VALUES (${normalizedUsername}, ${email}, ${hashedPassword}, ${inviteData?.created_by}, ARRAY[${roles.join(",")}]::TEXT[], ${setTimezone}) RETURNING *; `; @@ -197,17 +201,19 @@ async function handler( reservation.release(); } + const userSession: UserSession = { + id: user.id, + username: user.username, + email: user.email, + email_verified: user.email_verified, + roles: user.roles[0].split(","), + avatar: user.avatar, + timezone: user.timezone, + authorization_token: user.authorization_token, + }; + const sessionCookie: string = await sessionManager.createSession( - { - id: user.id, - username: user.username, - email: user.email, - email_verified: user.email_verified, - roles: user.roles[0].split(","), - avatar: user.avatar, - timezone: user.timezone, - authorization_token: user.authorization_token, - }, + userSession, request.headers.get("User-Agent") || "", ); @@ -216,7 +222,7 @@ async function handler( success: true, code: 201, message: "User Registered", - id: user.id, + user: userSession, }, { status: 201, headers: { "Set-Cookie": sessionCookie } }, ); diff --git a/src/routes/api/files/upload.ts b/src/routes/api/files/upload.ts index e69de29..b000b53 100644 --- a/src/routes/api/files/upload.ts +++ b/src/routes/api/files/upload.ts @@ -0,0 +1,242 @@ +import { getSetting } from "@config/sql/settings"; +import { password as bunPassword, randomUUIDv7 } from "bun"; +import { DateTime } from "luxon"; + +import { + generateRandomString, + getBaseUrl, + getExtension, + getNewTimeUTC, + nameWithoutExtension, +} from "@/helpers/char"; + +const routeDef: RouteDef = { + method: "POST", + accepts: ["multipart/form-data", "text/plain", "application/json"], + 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 }, + ); + } + + const session: UserSession | ApiUserSession = request.session; + + 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") ?? "", + }; + + 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: string[] = []; + + formData.forEach( + async (file: FormDataEntryValue, key: string): Promise => { + if (!(file instanceof File)) { + failedFiles.push({ + reason: "Invalid file", + file: key, + }); + return; + } + + if (!file.type || file.type === "") { + failedFiles.push({ + reason: "Cannot determine file type", + file: key, + }); + return; + } + + if (!file.size || file.size === 0) { + failedFiles.push({ + reason: "Empty file", + file: key, + }); + return; + } + + if (!file.name || file.name === "") { + failedFiles.push({ + reason: "Missing file name", + file: key, + }); + return; + } + + 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 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({ + success: true, + files: successfulFiles, + failed: failedFiles, + }); +} + +export { handler, routeDef }; diff --git a/src/routes/api/invite/create.ts b/src/routes/api/invite/create.ts index 4226e58..23f9bd5 100644 --- a/src/routes/api/invite/create.ts +++ b/src/routes/api/invite/create.ts @@ -19,10 +19,10 @@ async function handler( return Response.json( { success: false, - code: 403, + code: 401, error: "Unauthorized", }, - { status: 403 }, + { status: 401 }, ); } diff --git a/src/routes/api/invite/delete[invite].ts b/src/routes/api/invite/delete[invite].ts index 34b670e..bf5617f 100644 --- a/src/routes/api/invite/delete[invite].ts +++ b/src/routes/api/invite/delete[invite].ts @@ -14,10 +14,10 @@ async function handler(request: ExtendedRequest): Promise { return Response.json( { success: false, - code: 403, + code: 401, error: "Unauthorized", }, - { status: 403 }, + { status: 401 }, ); } diff --git a/src/routes/api/settings/set.ts b/src/routes/api/settings/set.ts index 2b0a742..5176416 100644 --- a/src/routes/api/settings/set.ts +++ b/src/routes/api/settings/set.ts @@ -19,10 +19,10 @@ async function handler( return Response.json( { success: false, - code: 403, + code: 401, error: "Unauthorized", }, - { status: 403 }, + { status: 401 }, ); } diff --git a/src/routes/api/user/info[query].ts b/src/routes/api/user/info[query].ts index a9f4851..6172e86 100644 --- a/src/routes/api/user/info[query].ts +++ b/src/routes/api/user/info[query].ts @@ -63,7 +63,10 @@ async function handler(request: ExtendedRequest): Promise { user = result[0]; isSelf = request.session ? user.id === request.session.id : false; - if (showInvites === "true" && (isAdmin || isSelf)) { + if ( + (showInvites === "true" || showInvites === "1") && + (isAdmin || isSelf) + ) { const invites: Invite[] = await sql`SELECT * FROM invites WHERE created_by = ${user.id}`; user.invites = invites; diff --git a/src/server.ts b/src/server.ts index 0c3b4b2..6f6381e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -141,35 +141,61 @@ class ServerHandler { } } - if (routeModule.routeDef.method !== request.method) { + if ( + (Array.isArray(routeModule.routeDef.method) && + !routeModule.routeDef.method.includes( + request.method, + )) || + (!Array.isArray(routeModule.routeDef.method) && + routeModule.routeDef.method !== request.method) + ) { response = Response.json( { success: false, code: 405, - error: `Method ${request.method} Not Allowed, expected ${routeModule.routeDef.method}`, + error: `Method ${request.method} Not Allowed, expected ${ + Array.isArray(routeModule.routeDef.method) + ? routeModule.routeDef.method.join(", ") + : routeModule.routeDef.method + }`, }, { status: 405 }, ); } else { - const expectedContentType: string | null = + const expectedContentType: string | string[] | null = routeModule.routeDef.accepts; - const matchesAccepts: boolean = - expectedContentType === "*/*" || - actualContentType === expectedContentType; + let matchesAccepts: boolean; + + if (Array.isArray(expectedContentType)) { + matchesAccepts = + expectedContentType.includes("*/*") || + expectedContentType.includes( + actualContentType || "", + ); + } else { + matchesAccepts = + expectedContentType === "*/*" || + actualContentType === expectedContentType; + } if (!matchesAccepts) { response = Response.json( { success: false, code: 406, - error: `Content-Type ${contentType} Not Acceptable, expected ${expectedContentType}`, + error: `Content-Type ${actualContentType} Not Acceptable, expected ${ + Array.isArray(expectedContentType) + ? expectedContentType.join(", ") + : expectedContentType + }`, }, { status: 406 }, ); } else { request.params = params; request.query = query; + request.actualContentType = actualContentType; request.session = (await authByToken(request)) || diff --git a/types/bun.d.ts b/types/bun.d.ts index 3e80e42..77ae163 100644 --- a/types/bun.d.ts +++ b/types/bun.d.ts @@ -11,5 +11,6 @@ declare global { query: Query; params: Params; session: UserSession | ApiUserSession | null; + actualContentType: string | null; } } diff --git a/types/char.d.ts b/types/char.d.ts index e536f24..b4e7f93 100644 --- a/types/char.d.ts +++ b/types/char.d.ts @@ -9,3 +9,4 @@ type DurationObject = { }; type UUID = `${string}-${string}-${string}-${string}-${string}`; +type PartialExcept = Partial & Pick; diff --git a/types/file.d.ts b/types/file.d.ts index eae4c2a..e836201 100644 --- a/types/file.d.ts +++ b/types/file.d.ts @@ -1,4 +1,4 @@ -type File = { +type FileEntry = { id: UUID; owner: UUID; folder?: UUID | null; @@ -6,20 +6,23 @@ type File = { name: string; original_name?: string | null; mime_type: string; + extension?: string | null; size: number; views: number; - max_views: number; + max_views: number | null; password?: string | null; favorite: boolean; tags: string[]; thumbnail: boolean; - created_at: Date; - updated_at: Date; - expires_at?: Date | null; + created_at: string; + updated_at: string; + expires_at?: string | null; }; +type FileUpload = Partial; + type Folder = { id: UUID; owner: UUID; @@ -28,6 +31,6 @@ type Folder = { public: boolean; allow_uploads: boolean; - created_at: Date; - updated_at: Date; + created_at: string; + updated_at: string; }; diff --git a/types/routes.d.ts b/types/routes.d.ts index eb67a3c..5517e0d 100644 --- a/types/routes.d.ts +++ b/types/routes.d.ts @@ -1,6 +1,6 @@ type RouteDef = { - method: string; - accepts: string | null; + method: string | string[]; + accepts: string | null | string[]; returns: string; needsBody?: "multipart" | "json"; };