From 458fbc80f5bf0202e6a346dfa65186619865574c Mon Sep 17 00:00:00 2001
From: creations <creations@creations.works>
Date: Sat, 8 Mar 2025 13:30:58 -0500
Subject: [PATCH] fix maxviews add extension, added some settings, add multi
 method route support, register timezone, start of upload route, change
 Unauthorized to 401, change date types to string

---
 config/sql/files.ts                     |   3 +-
 config/sql/settings.ts                  |   2 +
 src/helpers/char.ts                     |  20 ++
 src/routes/api/auth/register.ts         |  38 ++--
 src/routes/api/files/upload.ts          | 242 ++++++++++++++++++++++++
 src/routes/api/invite/create.ts         |   4 +-
 src/routes/api/invite/delete[invite].ts |   4 +-
 src/routes/api/settings/set.ts          |   4 +-
 src/routes/api/user/info[query].ts      |   5 +-
 src/server.ts                           |  40 +++-
 types/bun.d.ts                          |   1 +
 types/char.d.ts                         |   1 +
 types/file.d.ts                         |  17 +-
 types/routes.d.ts                       |   4 +-
 14 files changed, 345 insertions(+), 40 deletions(-)

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<void> {
 				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<void> {
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<Response> {
-	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<Response> {
+	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<void> => {
+			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<Response> {
 		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<Response> {
 		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<T, K extends keyof T> = Partial<T> & Pick<T, K>;
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<FileEntry>;
+
 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";
 };