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

This commit is contained in:
creations 2025-03-08 13:30:58 -05:00
parent 94ba46cc2d
commit 458fbc80f5
Signed by: creations
GPG key ID: 8F553AA4320FC711
14 changed files with 345 additions and 40 deletions

View file

@ -21,10 +21,11 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
original_name VARCHAR(255), original_name VARCHAR(255),
mime_type VARCHAR(255) NOT NULL, mime_type VARCHAR(255) NOT NULL,
extension VARCHAR(255) NOT NULL,
size BIGINT NOT NULL, size BIGINT NOT NULL,
views INTEGER DEFAULT 0, views INTEGER DEFAULT 0,
max_views INTEGER DEFAULT 1, max_views INTEGER DEFAULT NULL,
password TEXT DEFAULT NULL, password TEXT DEFAULT NULL,
favorite BOOLEAN DEFAULT FALSE, favorite BOOLEAN DEFAULT FALSE,
tags TEXT[] DEFAULT ARRAY[]::TEXT[], tags TEXT[] DEFAULT ARRAY[]::TEXT[],

View file

@ -11,6 +11,8 @@ const defaultSettings: Setting[] = [
{ key: "enable_invitations", value: "true" }, { key: "enable_invitations", value: "true" },
{ key: "allow_user_invites", value: "false" }, { key: "allow_user_invites", value: "false" },
{ key: "require_email_verification", 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> { export async function createTable(reservation?: ReservedSQL): Promise<void> {

View file

@ -100,3 +100,23 @@ export function generateRandomString(length?: number): string {
return result; 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;
}

View file

@ -7,6 +7,7 @@ import {
} from "@config/sql/users"; } from "@config/sql/users";
import { password as bunPassword, type ReservedSQL, sql } from "bun"; import { password as bunPassword, type ReservedSQL, sql } from "bun";
import { isValidTimezone } from "@/helpers/char";
import { logger } from "@/helpers/logger"; import { logger } from "@/helpers/logger";
import { sessionManager } from "@/helpers/sessions"; import { sessionManager } from "@/helpers/sessions";
@ -21,11 +22,12 @@ async function handler(
request: ExtendedRequest, request: ExtendedRequest,
requestBody: unknown, requestBody: unknown,
): Promise<Response> { ): Promise<Response> {
const { username, email, password, invite } = requestBody as { const { username, email, password, invite, timezone } = requestBody as {
username: string; username: string;
email: string; email: string;
password: string; password: string;
invite?: string; invite?: string;
timezone?: string;
}; };
if (!username || !email || !password) { if (!username || !email || !password) {
@ -102,7 +104,7 @@ async function handler(
errors.push("Username or email already exists"); errors.push("Username or email already exists");
} }
if (invite) { if (invite && !firstUser) {
const result: Invite[] = const result: Invite[] =
await reservation`SELECT * FROM invites WHERE id = ${invite};`; await reservation`SELECT * FROM invites WHERE id = ${invite};`;
@ -132,13 +134,15 @@ async function handler(
const hashedPassword: string = await bunPassword.hash(password, { const hashedPassword: string = await bunPassword.hash(password, {
algorithm: "argon2id", algorithm: "argon2id",
}); });
const defaultTimezone: string = const setTimezone: string =
(await getSetting("default_timezone", reservation)) || "UTC"; timezone && isValidTimezone(timezone)
? timezone
: (await getSetting("default_timezone", reservation)) || "UTC";
try { try {
const result: User[] = await reservation` const result: User[] = await reservation`
INSERT INTO users (username, email, password, invited_by, roles, timezone) 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 *; RETURNING *;
`; `;
@ -197,17 +201,19 @@ async function handler(
reservation.release(); 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( const sessionCookie: string = await sessionManager.createSession(
{ 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,
},
request.headers.get("User-Agent") || "", request.headers.get("User-Agent") || "",
); );
@ -216,7 +222,7 @@ async function handler(
success: true, success: true,
code: 201, code: 201,
message: "User Registered", message: "User Registered",
id: user.id, user: userSession,
}, },
{ status: 201, headers: { "Set-Cookie": sessionCookie } }, { status: 201, headers: { "Set-Cookie": sessionCookie } },
); );

View file

@ -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 };

View file

@ -19,10 +19,10 @@ async function handler(
return Response.json( return Response.json(
{ {
success: false, success: false,
code: 403, code: 401,
error: "Unauthorized", error: "Unauthorized",
}, },
{ status: 403 }, { status: 401 },
); );
} }

View file

@ -14,10 +14,10 @@ async function handler(request: ExtendedRequest): Promise<Response> {
return Response.json( return Response.json(
{ {
success: false, success: false,
code: 403, code: 401,
error: "Unauthorized", error: "Unauthorized",
}, },
{ status: 403 }, { status: 401 },
); );
} }

View file

@ -19,10 +19,10 @@ async function handler(
return Response.json( return Response.json(
{ {
success: false, success: false,
code: 403, code: 401,
error: "Unauthorized", error: "Unauthorized",
}, },
{ status: 403 }, { status: 401 },
); );
} }

View file

@ -63,7 +63,10 @@ async function handler(request: ExtendedRequest): Promise<Response> {
user = result[0]; user = result[0];
isSelf = request.session ? user.id === request.session.id : false; isSelf = request.session ? user.id === request.session.id : false;
if (showInvites === "true" && (isAdmin || isSelf)) { if (
(showInvites === "true" || showInvites === "1") &&
(isAdmin || isSelf)
) {
const invites: Invite[] = const invites: Invite[] =
await sql`SELECT * FROM invites WHERE created_by = ${user.id}`; await sql`SELECT * FROM invites WHERE created_by = ${user.id}`;
user.invites = invites; user.invites = invites;

View file

@ -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( response = Response.json(
{ {
success: false, success: false,
code: 405, 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 }, { status: 405 },
); );
} else { } else {
const expectedContentType: string | null = const expectedContentType: string | string[] | null =
routeModule.routeDef.accepts; routeModule.routeDef.accepts;
const matchesAccepts: boolean = let matchesAccepts: boolean;
expectedContentType === "*/*" ||
actualContentType === expectedContentType; if (Array.isArray(expectedContentType)) {
matchesAccepts =
expectedContentType.includes("*/*") ||
expectedContentType.includes(
actualContentType || "",
);
} else {
matchesAccepts =
expectedContentType === "*/*" ||
actualContentType === expectedContentType;
}
if (!matchesAccepts) { if (!matchesAccepts) {
response = Response.json( response = Response.json(
{ {
success: false, success: false,
code: 406, 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 }, { status: 406 },
); );
} else { } else {
request.params = params; request.params = params;
request.query = query; request.query = query;
request.actualContentType = actualContentType;
request.session = request.session =
(await authByToken(request)) || (await authByToken(request)) ||

1
types/bun.d.ts vendored
View file

@ -11,5 +11,6 @@ declare global {
query: Query; query: Query;
params: Params; params: Params;
session: UserSession | ApiUserSession | null; session: UserSession | ApiUserSession | null;
actualContentType: string | null;
} }
} }

1
types/char.d.ts vendored
View file

@ -9,3 +9,4 @@ type DurationObject = {
}; };
type UUID = `${string}-${string}-${string}-${string}-${string}`; type UUID = `${string}-${string}-${string}-${string}-${string}`;
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;

17
types/file.d.ts vendored
View file

@ -1,4 +1,4 @@
type File = { type FileEntry = {
id: UUID; id: UUID;
owner: UUID; owner: UUID;
folder?: UUID | null; folder?: UUID | null;
@ -6,20 +6,23 @@ type File = {
name: string; name: string;
original_name?: string | null; original_name?: string | null;
mime_type: string; mime_type: string;
extension?: string | null;
size: number; size: number;
views: number; views: number;
max_views: number; max_views: number | null;
password?: string | null; password?: string | null;
favorite: boolean; favorite: boolean;
tags: string[]; tags: string[];
thumbnail: boolean; thumbnail: boolean;
created_at: Date; created_at: string;
updated_at: Date; updated_at: string;
expires_at?: Date | null; expires_at?: string | null;
}; };
type FileUpload = Partial<FileEntry>;
type Folder = { type Folder = {
id: UUID; id: UUID;
owner: UUID; owner: UUID;
@ -28,6 +31,6 @@ type Folder = {
public: boolean; public: boolean;
allow_uploads: boolean; allow_uploads: boolean;
created_at: Date; created_at: string;
updated_at: Date; updated_at: string;
}; };

4
types/routes.d.ts vendored
View file

@ -1,6 +1,6 @@
type RouteDef = { type RouteDef = {
method: string; method: string | string[];
accepts: string | null; accepts: string | null | string[];
returns: string; returns: string;
needsBody?: "multipart" | "json"; needsBody?: "multipart" | "json";
}; };