First Commit. Duplicate bunext-sqlite preset

This commit is contained in:
2026-10-18 16:47:11 +01:00
commit 81e4a8aecc
718 changed files with 41695 additions and 0 deletions
@@ -0,0 +1,77 @@
import { describe, expect, test } from "bun:test";
import checkUserAccess from "./check-user-access";
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
function makeUserTypes(...types: string[]): BUN_SQLITE_BSQLITE_USER_TYPES[] {
return types.map((user_type) => ({
user_id: 1,
user_type,
}));
}
describe("checkUserAccess", () => {
test("fails with null, undefined or empty user_types", () => {
expect(
checkUserAccess({ user_types: null }).success,
).toBe(false);
expect(
checkUserAccess({ user_types: undefined }).success,
).toBe(false);
expect(checkUserAccess({ user_types: [] }).success).toBe(false);
});
test("super_admin always passes", () => {
expect(
checkUserAccess({
user_types: makeUserTypes("super_admin"),
includes: ["admin"],
}).success,
).toBe(true);
expect(
checkUserAccess({
user_types: makeUserTypes("super_admin", "revoked"),
excludes: ["revoked"],
}).success,
).toBe(true);
});
test("excludes take priority over includes", () => {
const res = checkUserAccess({
user_types: makeUserTypes("admin"),
includes: ["admin"],
excludes: ["admin"],
});
expect(res.success).toBe(false);
expect(res.msg).toBe(`User Excluded`);
});
test("includes require a match", () => {
const res = checkUserAccess({
user_types: makeUserTypes("board_member"),
includes: ["admin"],
});
expect(res.success).toBe(false);
expect(res.msg).toBe(`User not included`);
});
test("includes match with OR semantics", () => {
expect(
checkUserAccess({
user_types: makeUserTypes("president"),
includes: ["admin", "president"],
}).success,
).toBe(true);
});
test("fails when no includes or excludes are provided", () => {
const res = checkUserAccess({
user_types: makeUserTypes("admin"),
});
expect(res.success).toBe(false);
expect(res.msg).toBe(`No includes or excludes provided`);
});
});
@@ -0,0 +1,66 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { UserType } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
type Params = {
includes?: UserType[];
excludes?: UserType[];
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
};
export default function checkUserAccess({
user_types,
excludes,
includes,
}: Params): APIResponseObject {
if (!user_types?.[0]) {
return {
success: false,
msg: `No User Types Provided`,
};
}
if (user_types.find((t) => t.user_type === "super_admin")) {
return {
success: true,
};
}
const userTypeSet = new Set(user_types.map((t) => t.user_type));
if (excludes) {
for (let i = 0; i < excludes.length; i++) {
const exclude = excludes[i] as any;
if (userTypeSet.has(exclude)) {
return {
success: false,
msg: `User Excluded`,
};
}
}
}
if (includes) {
const is_user_included = includes.find((i) =>
userTypeSet.has(i as any),
);
if (!is_user_included) {
return {
success: false,
msg: `User not included`,
};
}
}
if (!includes?.[0] && !excludes?.[0]) {
return {
success: false,
msg: `No includes or excludes provided`,
};
}
return {
success: true,
};
}
@@ -0,0 +1,70 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_USERS,
BUN_SQLITE_BSQLITE_USER_TYPES,
BunSQLiteTables,
} from "@/db/types/db";
import AppError from "@/src/utils/app-error";
type Params = {
first_name: string;
last_name?: string;
email: string;
username: string;
};
/**
* Creates a self-signup account with the default `read_only` user type
* and returns its id.
*/
export default async function createSignupUser({
first_name,
last_name,
email,
username,
}: Params) {
const new_user_res = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
data: [
{
first_name,
last_name: last_name || "",
email,
username,
},
],
});
/**
* bun-sqlite inserts with `RETURNING *` by default, so the new row
* comes back as `singleRes` (with `insertId` as a fallback).
*/
const new_user_id =
new_user_res.singleRes?.id || new_user_res.postInsertReturn?.insertId;
if (!new_user_id) {
throw new AppError(`Couldn't create your account. Please try again.`);
}
const type_res = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_USER_TYPES,
(typeof BunSQLiteTables)[number]
>({
table: "user_types",
data: [
{
user_id: new_user_id,
user_type: "read_only",
},
],
});
if (!type_res.success) {
throw new AppError(`Couldn't create your account. Please try again.`);
}
return new_user_id;
}
@@ -0,0 +1,25 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
BunSQLiteTables,
} from "@/db/types/db";
type Params = {
user_id: string | number;
};
export default async function deleteUserSSOCodes({ user_id }: Params) {
return await BunSQLite.delete<
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
(typeof BunSQLiteTables)[number]
>({
table: "sso_login_codes",
query: {
query: {
user_id: {
value: user_id,
},
},
},
});
}
@@ -0,0 +1,31 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_BSQLITE_USERS, BunSQLiteTables } from "@/db/types/db";
type Params = {
identifier: string;
};
/**
* Looks a user up by email or username.
*/
export default async function findUserByIdentifier({ identifier }: Params) {
return (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
query: {
query: {
email: {
value: identifier,
},
username: {
value: identifier,
},
},
searchOperator: "OR",
},
})
)?.singleRes;
}
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test";
import generateCSRF from "./gen-csrf";
describe("generateCSRF", () => {
test("returns a non-empty string", () => {
expect(generateCSRF().length).toBeGreaterThan(0);
});
test("is unique across calls", () => {
const a = generateCSRF();
const b = generateCSRF();
expect(a).not.toBe(b);
});
test("produces url-safe base64 characters only", () => {
expect(generateCSRF()).toMatch(/^[A-Za-z0-9_-]+$/);
});
});
+11
View File
@@ -0,0 +1,11 @@
export default function generateCSRF() {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
@@ -0,0 +1,47 @@
import { randomInt } from "node:crypto";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_BSQLITE_USERS, BunSQLiteTables } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
type Params = {
email: string;
};
/**
* Derives a free username from an email's local part, adding a numeric
* suffix when the plain version is taken.
*/
export default async function generateUniqueUsername({ email }: Params) {
const base = (email.split("@")[0] || "user")
.toLowerCase()
.replace(/[^a-z0-9._-]/g, "")
.slice(0, 24)
.padEnd(3, "0");
for (let attempt = 0; attempt < 6; attempt++) {
const candidate =
attempt == 0 ? base : `${base}${randomInt(1000, 100000)}`;
const existing = (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
query: {
query: {
username: {
value: candidate,
},
},
},
})
)?.singleRes;
if (!existing?.id) {
return candidate;
}
}
throw new AppError(`Couldn't pick a username. Please sign up with the form.`);
}
@@ -0,0 +1,8 @@
import parseLoginMethod from "@/src/utils/parse-login-method";
/**
* Server-side source of truth for the site-wide login method.
*/
export default function grabLoginMethod() {
return parseLoginMethod({ value: process.env.AUTH_LOGIN_METHOD });
}
@@ -0,0 +1,50 @@
import type {
BUN_SQLITE_BSQLITE_USER_TYPES,
BunSQLiteTables,
} from "@/db/types/db";
import type { UserAuthReturn } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
user_id: number | string;
};
export default async function grabUserAuthTypes({
user_id,
}: Params): Promise<UserAuthReturn> {
try {
const user_types =
(
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USER_TYPES,
(typeof BunSQLiteTables)[number]
>({
table: "user_types",
query: {
query: {
user_id: {
value: user_id,
},
},
},
})
).payload || [];
if (user_types.find((ty) => ty.user_type == "revoked")) {
return {
success: false,
msg: "User Access Revoked",
};
}
return {
success: true,
user_types,
};
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
}
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
BunSQLiteTables,
} from "@/db/types/db";
type Params = {
user_id: string | number;
};
/**
* Server-only. Password hashes live in their own table so that no user
* query can ever return them.
*/
export default async function grabUserPasswordHash({ user_id }: Params) {
const record = (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
(typeof BunSQLiteTables)[number]
>({
table: "user_passwords",
query: {
query: {
user_id: {
value: user_id,
},
},
},
})
).singleRes;
return record?.password_hash || undefined;
}
+48
View File
@@ -0,0 +1,48 @@
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import _ from "lodash";
import userAuth from "./user-auth";
import checkUserAccess from "./check-user-access";
import grabUsers from "../db/users/grab-users";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
query,
}) => {
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status) {
return {
success: false,
msg: `Unauthorized`,
};
}
try {
const is_admin = checkUserAccess({
user_types,
includes: ["admin"],
});
if (
!is_admin.success &&
(!query?.user_id || query.user_id !== user.id)
) {
return {
success: false,
msg: `Unauthorized`,
};
}
return await grabUsers({
query,
});
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
};
@@ -0,0 +1,12 @@
import { AppData } from "@/src/data/app-data";
type Params = {
password: string;
};
export default async function hashPassword({ password }: Params) {
return await Bun.password.hash(password, {
algorithm: "bcrypt",
cost: AppData["HashCost"],
});
}
@@ -0,0 +1,142 @@
import { randomInt } from "node:crypto";
import { AppData } from "@/src/data/app-data";
import { SiteData } from "@/src/data/site-data";
import sendEmail from "../email/send-email";
import resolveEmailRecipient from "../email/resolve-email-recipient";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { SSOAuth, SSOMethod } from "@/src/types";
import { setCookies } from "@/src/utils/cookies";
import { encrypt } from "@/src/utils/crypt";
import EJSON from "@/src/utils/ejson";
import type {
APIResponseObject,
BunextAPIRouteHandlerObjectReturn,
} from "@moduletrace/bunext/types";
import type {
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
BunSQLiteTables,
} from "@/db/types/db";
type Params = {
user_id: number;
email: string;
method: SSOMethod;
/**
* Carried over on resends so the pending login can't be extended
* indefinitely. Defaults to now.
*/
first_step_at?: number;
};
export default async function issueSSOLoginCode({
user_id,
email,
method,
first_step_at,
}: Params): Promise<BunextAPIRouteHandlerObjectReturn<APIResponseObject>> {
const sso_code = randomInt(0, 1000000).toString().padStart(6, "0");
const record_sso = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
(typeof BunSQLiteTables)[number]
>({
data: [
{
code: sso_code,
user_id,
},
],
table: "sso_login_codes",
update_on_duplicate: true,
});
if (!record_sso.success) {
return {
success: false,
msg: `Couldn't issue a login code. Please try again.`,
};
}
if (process.env.NODE_ENV != "production") {
console.log(
`SSO login code for ${email} (user #${user_id}): ${sso_code}\n` +
`Code expires in ${AppData["SSOCodeExpiryMinutes"]} minutes.`,
);
}
const is_reset = method == "reset";
const email_component = (
<div>
<div>
{is_reset
? "Use this code to reset your password"
: "Use this Code to Complete your login"}
</div>
<h1>{sso_code}</h1>
<div>
Code expires in {AppData["SSOCodeExpiryMinutes"]} minutes.
</div>
</div>
);
const recipient = resolveEmailRecipient({ to: email });
if (recipient) {
await sendEmail({
content: email_component,
to: recipient,
subject: is_reset
? `Use this code to reset your password.`
: `Use one-time-code to complete your login.`,
text: is_reset
? `Reset your ${SiteData["SiteName"]} password`
: `Complete your login to the ${SiteData["SiteName"]} portal`,
title: `${SiteData["SiteName"]} SSO`,
options: {
priority: "high",
},
})
.then((mail_res) => {
if (!mail_res.success) {
console.log(
`SSO email to user #${user_id} failed:\n${mail_res.msg}`,
);
}
})
.catch((error) => {
console.log(
`SSO email to user #${user_id} errored:\n${error.message}`,
);
});
}
const sso_auth: SSOAuth = {
user_id,
sso_code,
email,
method,
first_step_at: first_step_at || Date.now(),
};
const sso_auth_string = EJSON.stringify(sso_auth);
if (!sso_auth_string) {
throw new Error(`Couldn't Stringify SSO Auth`);
}
const encrypted_sso_auth_string = await encrypt(sso_auth_string);
return {
success: true,
bunext_api_route_res_transform_fn(res: Response) {
const new_res = res.clone();
setCookies(new_res, [
{
name: AppData["SSOAuthCookieName"],
value: encrypted_sso_auth_string,
},
]);
return new_res;
},
};
}
+154
View File
@@ -0,0 +1,154 @@
import { AppData } from "@/src/data/app-data";
import { setCookies } from "@/src/utils/cookies";
import { encrypt } from "@/src/utils/crypt";
import grabUsers from "../db/users/grab-users";
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_MEDIA,
BUN_SQLITE_BSQLITE_USER_TYPES,
BunSQLiteTables,
} from "@/db/types/db";
import generateCSRF from "./gen-csrf";
import type { User } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import grabMediaURL from "../media/grab-media-url";
import logEvent, { grabRequestIP } from "../logger";
import deleteUserSSOCodes from "./delete-user-sso-codes";
import AppError from "@/src/utils/app-error";
type Params = {
user_id: string | number;
reauth?: boolean;
req?: Request;
};
export default async function loginUser({
user_id,
reauth,
req,
}: Params): Promise<Response> {
try {
const now = Date.now();
const target_user_res = await grabUsers({
query: { user_id },
});
const target_user = target_user_res.singleRes;
if (!target_user?.id) {
throw new AppError(
`Couldn't find your account. Please start again.`,
);
}
const target_user_types = (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USER_TYPES,
(typeof BunSQLiteTables)[number]
>({
table: "user_types",
query: { query: { user_id: { value: target_user.id } } },
})
).payload;
if (!target_user_types?.[0]) {
throw new AppError(`No User types found for this user.`);
}
if (target_user_types.find((ty) => ty.user_type == "revoked")) {
throw new AppError(`User access Revoked`);
}
let csrf_key = generateCSRF();
const profile_media = target_user.profile_media_id
? (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_MEDIA,
(typeof BunSQLiteTables)[number]
>({
table: "media",
targetId: target_user.profile_media_id,
})
).singleRes
: undefined;
const { media_url, media_thumbnail_url } = profile_media?.id
? grabMediaURL({ media: profile_media })
: {};
const new_logged_in_user: User = {
id: target_user.id,
csrf: csrf_key,
date: now,
first_name: target_user.first_name || "",
last_name: target_user.last_name || "",
email: target_user.email || "",
image: media_url,
image_thumbnail: media_thumbnail_url,
verification_status: 1,
logged_in_status: true,
roles: target_user.user_types || "none",
};
const user_payload_json = JSON.stringify(new_logged_in_user);
const encrypted_key = await encrypt(user_payload_json);
const res_obj: APIResponseObject = {
success: true,
singleRes: new_logged_in_user,
};
const res = new Response(JSON.stringify(res_obj), {
headers: {
"Content-Type": "application/json",
},
});
const maxAge = AppData["AuthExpiryDays"] * 24 * 60 * 60;
setCookies(res, [
{
name: AppData["AuthKeyCookieName"],
value: encrypted_key,
maxAge,
},
{
name: AppData["AuthCSRFCookieName"],
value: csrf_key,
maxAge,
},
]);
await deleteUserSSOCodes({ user_id: new_logged_in_user.id });
if (!reauth) {
await logEvent({
title: "User logged in",
category: "auth",
user_id: new_logged_in_user.id,
plain_text: `User #${new_logged_in_user.id} [${new_logged_in_user.first_name} ${new_logged_in_user.last_name}] logged in successfully.`,
metadata: {
user_id: new_logged_in_user.id,
reauth: false,
},
ip_address: grabRequestIP(req),
});
}
return res;
} catch (error: any) {
await logEvent({
title: "Login failed",
category: "auth",
plain_text: `Login failed for user_id ${user_id}: ${error.message}`,
metadata: {
user_id,
},
ip_address: grabRequestIP(req),
});
throw error;
}
}
@@ -0,0 +1,42 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
BunSQLiteTables,
} from "@/db/types/db";
import hashPassword from "./hash-password";
import deleteUserSSOCodes from "./delete-user-sso-codes";
import AppError from "@/src/utils/app-error";
type Params = {
user_id: number;
password: string;
};
/**
* Creates or replaces the user's password. Outstanding login codes are
* revoked, since a code issued before the change would skip the new
* password.
*/
export default async function setUserPassword({ user_id, password }: Params) {
const password_hash = await hashPassword({ password });
const res = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
(typeof BunSQLiteTables)[number]
>({
table: "user_passwords",
data: [
{
user_id,
password_hash,
},
],
update_on_duplicate: true,
});
if (!res.success) {
throw new AppError(`Couldn't save your password. Please try again.`);
}
await deleteUserSSOCodes({ user_id });
}
+62
View File
@@ -0,0 +1,62 @@
import { AppData } from "@/src/data/app-data";
import { getCookie } from "@/src/utils/cookies";
import { decrypt } from "@/src/utils/crypt";
import { safeCompare } from "@/src/utils/safe-compare";
import grabUserAuthTypes from "./grab-user-auth-types";
import type { User, UserAuthReturn } from "@/src/types";
type Params = {
req: Request;
};
export default async function userAuth({
req,
}: Params): Promise<UserAuthReturn> {
try {
const auth_key_cookie = getCookie(req, AppData["AuthKeyCookieName"]);
const auth_csrf_cookie = getCookie(req, AppData["AuthCSRFCookieName"]);
const now = Date.now();
if (!auth_key_cookie) throw new Error(`Auth Key not found!`);
if (!auth_csrf_cookie) throw new Error(`Auth CSRF not found!`);
const decrypted_key = await decrypt(auth_key_cookie);
const decrypted_user_payload = JSON.parse(decrypted_key) as User;
if (!decrypted_user_payload?.id) throw new Error(`Invalid Key`);
if (!decrypted_user_payload.date) throw new Error(`Key has no date!`);
if (!decrypted_user_payload.csrf) throw new Error(`Key has no CSRF!`);
if (!safeCompare(decrypted_user_payload.csrf, auth_csrf_cookie)) {
throw new Error(`CSRF Mismatch`);
}
const time_elapsed = now - decrypted_user_payload.date;
const expiry_time = AppData["AuthExpiryDays"] * 24 * 60 * 60 * 1000;
if (time_elapsed > expiry_time) {
throw new Error(`Key Expired`);
}
const user_auth_types = await grabUserAuthTypes({
user_id: decrypted_user_payload.id,
});
if (!user_auth_types.success) {
return user_auth_types;
}
return {
success: true,
user: decrypted_user_payload,
user_types: user_auth_types.user_types,
};
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
}
@@ -0,0 +1,199 @@
import AppError from "@/src/utils/app-error";
type GoogleJWTHeader = {
alg?: string;
kid?: string;
typ?: string;
};
type GoogleJWKS = {
keys?: GoogleJWK[];
};
type GoogleJWK = JsonWebKey & {
kid?: string;
};
type GoogleIDTokenPayload = {
aud?: string | string[];
email?: string;
email_verified?: boolean | string;
exp?: number | string;
family_name?: string;
given_name?: string;
iss?: string;
name?: string;
picture?: string;
sub?: string;
};
type Params = {
clientId: string;
idToken: string;
};
type GoogleIDTokenUser = Required<Pick<GoogleIDTokenPayload, "email" | "sub">> &
Pick<
GoogleIDTokenPayload,
"name" | "given_name" | "family_name" | "picture"
>;
const GoogleJWKSUrl = "https://www.googleapis.com/oauth2/v3/certs";
let cachedGoogleKeys: {
expiresAt: number;
keys: GoogleJWK[];
} | null = null;
function decodeBase64Url(value: string) {
const normalizedValue = value.replace(/-/g, "+").replace(/_/g, "/");
const padding = normalizedValue.length % 4;
const paddedValue =
padding === 0
? normalizedValue
: normalizedValue.padEnd(
normalizedValue.length + (4 - padding),
"=",
);
return Buffer.from(paddedValue, "base64");
}
function parseGoogleTokenPart<T>(value: string): T {
return JSON.parse(decodeBase64Url(value).toString("utf8")) as T;
}
async function getGoogleSigningKeys() {
if (cachedGoogleKeys && cachedGoogleKeys.expiresAt > Date.now()) {
return cachedGoogleKeys.keys;
}
const response = await fetch(GoogleJWKSUrl);
if (!response.ok) {
throw new Error(`Couldn't fetch Google's signing keys`);
}
const data = (await response.json()) as GoogleJWKS;
if (!data.keys?.length) {
throw new Error(`Google didn't return any signing keys`);
}
const maxAge =
Number(
response.headers.get("cache-control")?.match(/max-age=(\d+)/)?.[1],
) || 300;
cachedGoogleKeys = {
expiresAt: Date.now() + maxAge * 1000,
keys: data.keys,
};
return data.keys;
}
function hasMatchingAudience(
aud: GoogleIDTokenPayload["aud"],
clientId: string,
) {
if (Array.isArray(aud)) {
return aud.includes(clientId);
}
return aud === clientId;
}
export default async function verifyGoogleIdToken({
clientId,
idToken,
}: Params): Promise<GoogleIDTokenUser> {
const tokenParts = idToken.split(".");
if (tokenParts.length !== 3) {
throw new AppError(`Invalid Google token`);
}
const [encodedHeader, encodedPayload, encodedSignature] = tokenParts;
if (!encodedHeader) {
throw new Error(`No encodedHeader`);
}
if (!encodedPayload) {
throw new Error(`No encodedPayload`);
}
if (!encodedSignature) {
throw new Error(`No encodedSignature`);
}
const header = parseGoogleTokenPart<GoogleJWTHeader>(encodedHeader);
const payload = parseGoogleTokenPart<GoogleIDTokenPayload>(encodedPayload);
if (header.alg !== "RS256" || !header.kid) {
throw new Error(`Unsupported Google token signature`);
}
const signingKeys = await getGoogleSigningKeys();
const signingKey = signingKeys.find((key) => key.kid === header.kid);
if (!signingKey) {
throw new Error(`Unknown Google signing key`);
}
const importedKey = await crypto.subtle.importKey(
"jwk",
signingKey,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
false,
["verify"],
);
const isValidSignature = await crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
importedKey,
decodeBase64Url(encodedSignature),
new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`),
);
if (!isValidSignature) {
throw new Error(`Invalid Google token signature`);
}
if (!hasMatchingAudience(payload.aud, clientId)) {
throw new AppError(`Google token was issued for a different app`);
}
if (
payload.iss !== "https://accounts.google.com" &&
payload.iss !== "accounts.google.com"
) {
throw new AppError(`Invalid Google token issuer`);
}
const expiresAt = Number(payload.exp || 0) * 1000;
if (!expiresAt || expiresAt <= Date.now()) {
throw new AppError(`Google token has expired`);
}
const emailVerified =
payload.email_verified === true || payload.email_verified === "true";
if (!payload.email || !emailVerified || !payload.sub) {
throw new AppError(`Google account email is not verified`);
}
return {
email: payload.email,
sub: payload.sub,
name: payload.name,
given_name: payload.given_name,
family_name: payload.family_name,
picture: payload.picture,
};
}
@@ -0,0 +1,105 @@
import type {
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { SSOAuth, SSOMethod } from "@/src/types";
import { getCookie } from "@/src/utils/cookies";
import { decrypt } from "@/src/utils/crypt";
import EJSON from "@/src/utils/ejson";
import { safeCompare } from "@/src/utils/safe-compare";
import rateLimit, { resetRateLimit } from "@/src/utils/rate-limit";
import BunSQLite from "@moduletrace/bun-sqlite";
import deleteUserSSOCodes from "./delete-user-sso-codes";
import AppError from "@/src/utils/app-error";
type Params = {
req: Request;
sso_code?: string | number;
/**
* The code's issuing method must match, e.g. a reset code can't
* complete a login.
*/
method: SSOMethod;
};
/**
* Checks an emailed code against the pending login in the SSO cookie.
* Returns the user id on success, throws a user-facing error otherwise.
*/
export default async function verifySSOCode({
req,
sso_code,
method,
}: Params) {
const sso_cookie = getCookie(req, AppData["SSOAuthCookieName"]);
if (!sso_cookie) {
throw new AppError(`No login in progress. Please start again.`);
}
if (!sso_code) {
throw new AppError(`Please enter your code.`);
}
const sso_auth = EJSON.parse(await decrypt(sso_cookie)) as SSOAuth;
if (!sso_auth?.user_id) {
throw new AppError(`Invalid login session. Please start again.`);
}
const user_id = sso_auth.user_id;
if (sso_auth.method != method) {
await deleteUserSSOCodes({ user_id });
throw new AppError(`This code can't be used here. Please start again.`);
}
const attempts_key = `sso-login:${user_id}`;
const attempts = rateLimit({
key: attempts_key,
limit: AppData["SSOMaxCodeAttempts"],
window_ms: AppData["SSOCodeExpiryMinutes"] * 60 * 1000,
});
if (!attempts.allowed) {
await deleteUserSSOCodes({ user_id });
throw new AppError(`Too many attempts. Please request a new code.`);
}
const target_sso = (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
(typeof BunSQLiteTables)[number]
>({
table: "sso_login_codes",
query: {
query: {
user_id: {
value: user_id,
},
},
},
})
).singleRes;
if (
!target_sso?.code ||
!safeCompare(String(target_sso.code), String(sso_code))
) {
throw new AppError(`Invalid Code!`);
}
const time_elapsed = Date.now() - Number(target_sso.updated_at);
const expiry_time = AppData["SSOCodeExpiryMinutes"] * 60 * 1000;
if (time_elapsed > expiry_time) {
await deleteUserSSOCodes({ user_id });
throw new AppError(`Code Expired. Please start again.`);
}
resetRateLimit({ key: attempts_key });
return user_id;
}
@@ -0,0 +1,31 @@
import grabUserPasswordHash from "./grab-user-password-hash";
import hashPassword from "./hash-password";
type Params = {
user_id?: string | number;
password: string;
};
let dummy_hash: Promise<string> | undefined;
/**
* Checks a password against the user's stored hash. Unknown users and
* users without a password still pay for a full hash comparison, so
* response timing doesn't reveal which accounts exist.
*/
export default async function verifyUserPassword({
user_id,
password,
}: Params) {
const password_hash = user_id
? await grabUserPasswordHash({ user_id })
: undefined;
if (!password_hash) {
dummy_hash ||= hashPassword({ password: crypto.randomUUID() });
await Bun.password.verify(password, await dummy_hash);
return false;
}
return await Bun.password.verify(password, password_hash);
}
@@ -0,0 +1,9 @@
import runBunSQLiteCommand from "./run-bun-sqlite-command";
/**
* Writes a new database snapshot via `bun-sqlite backup`. bun-sqlite prunes
* old snapshots down to `max_backups`.
*/
export default async function createDbBackup() {
return await runBunSQLiteCommand({ args: ["backup"] });
}
@@ -0,0 +1,14 @@
import path from "path";
import BunSQLiteConfig from "@/bun-sqlite.config";
/**
* Absolute path of the bun-sqlite backups directory. Mirrors bun-sqlite's
* own resolution: `{db_dir}/{db_backup_dir || ".backups"}`.
*/
export default function grabDbBackupDir() {
return path.resolve(
process.cwd(),
BunSQLiteConfig.db_dir || "",
BunSQLiteConfig.db_backup_dir || ".backups",
);
}
@@ -0,0 +1,40 @@
import fs from "fs";
import path from "path";
import BunSQLiteConfig from "@/bun-sqlite.config";
import type { DbBackup } from "@/src/types";
import grabDbBackupDir from "./grab-db-backup-dir";
/**
* Lists the `{db_name}-{timestamp}` snapshots written by `bun-sqlite
* backup`. Newest first.
*/
export default function grabDbBackups(): DbBackup[] {
const backup_dir = grabDbBackupDir();
const backup_name_regex = new RegExp(
`^${BunSQLiteConfig.db_name}-\\d+$`,
);
if (!fs.existsSync(backup_dir)) {
return [];
}
return fs
.readdirSync(backup_dir)
.filter((name) => backup_name_regex.test(name))
.map((name) => {
const stat = fs.statSync(path.join(backup_dir, name));
const timestamp = Number(name.split("-").pop());
return {
name,
is_file: stat.isFile(),
created_at: Number.isFinite(timestamp)
? timestamp
: stat.mtimeMs,
size: stat.size,
};
})
.filter((backup) => backup.is_file)
.map(({ is_file, ...backup }) => backup)
.sort((a, b) => b.created_at - a.created_at);
}
@@ -0,0 +1,26 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
type Params = {
req: Request;
};
/**
* Returns the session user only when they are a super admin.
*/
export default async function grabSuperAdminUser({ req }: Params) {
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status) {
return null;
}
/**
* No includes/excludes: only the `super_admin` bypass passes.
*/
if (!checkUserAccess({ user_types }).success) {
return null;
}
return user;
}
@@ -0,0 +1,21 @@
import path from "path";
import grabDbBackupDir from "./grab-db-backup-dir";
import grabDbBackups from "./grab-db-backups";
type Params = {
backup_name?: string;
};
/**
* Resolves a backup file name from the request to its absolute path. Only
* names of existing backups are accepted, so arbitrary paths can't be read.
*/
export default function resolveDbBackupPath({ backup_name }: Params) {
const backup = grabDbBackups().find((b) => b.name === backup_name);
if (!backup) {
throw new Error(`Backup not found`);
}
return path.join(grabDbBackupDir(), backup.name);
}
@@ -0,0 +1,17 @@
import resolveDbBackupPath from "./resolve-db-backup-path";
import runBunSQLiteCommand from "./run-bun-sqlite-command";
type Params = {
backup_name?: string;
};
/**
* Restores a database snapshot from the backups directory. `bun-sqlite
* restore` is an interactive picker, so this uses its non-interactive
* equivalent, `bun-sqlite import <file>`.
*/
export default async function restoreDbBackup({ backup_name }: Params) {
const backup_path = resolveDbBackupPath({ backup_name });
return await runBunSQLiteCommand({ args: ["import", backup_path] });
}
@@ -0,0 +1,45 @@
type Params = {
args: string[];
};
let is_running = false;
/**
* Runs a `bun-sqlite` CLI command (same as `bunx bun-sqlite ...`). Only one
* command runs at a time so a backup and a restore never overlap.
*/
export default async function runBunSQLiteCommand({ args }: Params) {
if (is_running) {
throw new Error(`Another backup or restore is already running`);
}
is_running = true;
try {
const proc = Bun.spawn([process.execPath, "x", "bun-sqlite", ...args], {
cwd: process.cwd(),
env: process.env,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exit_code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exit_code !== 0) {
throw new Error(
stderr.trim() ||
stdout.trim() ||
`bun-sqlite ${args[0]} failed (exit ${exit_code})`,
);
}
return { stdout };
} finally {
is_running = false;
}
}
@@ -0,0 +1,39 @@
import fs from "fs";
import path from "path";
import BunSQLiteConfig from "@/bun-sqlite.config";
import grabDbBackupDir from "./grab-db-backup-dir";
type Params = {
file: File;
};
const SQLITE_HEADER = "SQLite format 3\0";
/**
* Saves an uploaded SQLite database file into the backups directory under
* the same `{db_name}-{timestamp}` name `bun-sqlite backup` uses, so it is
* listed and pruned like any other backup. Returns the saved file name.
*/
export default async function saveUploadedDbBackup({ file }: Params) {
if (!file.size) {
throw new Error(`The uploaded backup file is empty`);
}
const header = await file.slice(0, SQLITE_HEADER.length).text();
if (header !== SQLITE_HEADER) {
throw new Error(`Only SQLite database files can be restored`);
}
const backup_dir = grabDbBackupDir();
if (!fs.existsSync(backup_dir)) {
fs.mkdirSync(backup_dir, { recursive: true });
}
const backup_name = `${BunSQLiteConfig.db_name}-${Date.now()}`;
await Bun.write(path.join(backup_dir, backup_name), file);
return backup_name;
}
@@ -0,0 +1,69 @@
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
import type { TableType } from "@/src/types";
import uniqueStringArr from "@/src/utils/unique-string-arr";
import type { ServerQueryParam } from "@moduletrace/bun-sqlite";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import _ from "lodash";
type Params = {
/**
* Allowed joins
*/
joins: TableType[];
query: ServerQueryParam<any>;
/**
* Allowed fields from joins
*/
include_fields?: (keyof BUN_SQLITE_BSQLITE_ALL_TYPEDEFS)[];
/**
* Fields to exclude from joins
*/
exclude_fields?: (keyof BUN_SQLITE_BSQLITE_ALL_TYPEDEFS)[];
};
export default function checkAllowedJoins({
joins,
query,
include_fields,
exclude_fields,
}: Params): APIResponseObject {
const all_joins = BunSQLite.utils.grab_join_fields_from_query_object({
query,
});
const all_joins_str_arr = uniqueStringArr(
all_joins
.filter((j) => Boolean(j.table))
.map((j) => j.table) as string[],
);
const are_joins_allowed =
_.intersection(joins, all_joins_str_arr).length >=
all_joins_str_arr.length;
if (!are_joins_allowed) {
return {
success: false,
msg: `Can't Join all tables: \`${all_joins_str_arr.join(", ")}\`. Tables allowed are \`${joins.join(", ")}\``,
};
}
if (include_fields?.[0]) {
for (let i = 0; i < all_joins.length; i++) {
const join = all_joins[i];
if (!join) continue;
const join_field = join.field as keyof BUN_SQLITE_BSQLITE_ALL_TYPEDEFS;
if (!include_fields.includes(join_field)) {
return {
success: false,
msg: `Can't Join field: \`${join_field}\` in \`${join.table}\` table`,
};
}
}
}
return {
success: true,
};
}
@@ -0,0 +1,41 @@
import type { BUN_SQLITE_BSQLITE_EMAIL_ATTACHMENTS } from "@/db/types/db";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
import BunSQLite from "@moduletrace/bun-sqlite";
export type EmailAttachmentRecord = BUN_SQLITE_BSQLITE_EMAIL_ATTACHMENTS & {
letter_title?: string;
invoice_number?: string;
invoice_title?: string;
quote_number?: string;
quote_title?: string;
media_write_path?: string;
media_mime_type?: string;
};
export default async function grabEmailAttachments({
email_id,
}: {
email_id: number | string;
}): Promise<APIResponseObject<EmailAttachmentRecord>> {
return await BunSQLite.sql<EmailAttachmentRecord>({
sql: `
SELECT
ea.*,
l.title AS letter_title,
i.invoice_number,
i.title AS invoice_title,
q.quote_number,
q.title AS quote_title,
m.media_write_path,
m.mime_type AS media_mime_type
FROM email_attachments ea
LEFT JOIN letters l ON l.id = ea.letter_id
LEFT JOIN invoices i ON i.id = ea.invoice_id
LEFT JOIN quotes q ON q.id = ea.quote_id
LEFT JOIN media m ON m.id = ea.media_id
WHERE ea.email_id = ?
ORDER BY ea.id ASC
`,
values: [email_id],
});
}
@@ -0,0 +1,14 @@
import type { BUN_SQLITE_BSQLITE_EMAILS } from "@/db/types/db";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
import BunSQLite from "@moduletrace/bun-sqlite";
export default async function grabEmail({
id,
}: {
id: number | string;
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_EMAILS>> {
return await BunSQLite.select<BUN_SQLITE_BSQLITE_EMAILS>({
table: "emails",
targetId: id,
});
}
@@ -0,0 +1,14 @@
import type { BUN_SQLITE_BSQLITE_LETTERS } from "@/db/types/db";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
import BunSQLite from "@moduletrace/bun-sqlite";
export default async function grabLetter({
id,
}: {
id: number | string;
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_LETTERS>> {
return await BunSQLite.select<BUN_SQLITE_BSQLITE_LETTERS>({
table: "letters",
targetId: id,
});
}
@@ -0,0 +1,102 @@
import { Database } from "bun:sqlite";
import { beforeEach, describe, expect, test } from "bun:test";
import grabNextDocumentSequence, {
type DocumentNumberSqlRunner,
} from "./grab-next-document-sequence";
let db: Database;
let run_sql: DocumentNumberSqlRunner;
beforeEach(() => {
db = new Database(":memory:");
db.run("CREATE TABLE invoices (invoice_number TEXT UNIQUE)");
run_sql = async ({ sql, values }) => {
const row = db.query(sql).get(...values) as
| { max_seq: number | null }
| null;
return { singleRes: row };
};
});
function insertNumbers(numbers: string[]) {
const insert = db.prepare("INSERT INTO invoices (invoice_number) VALUES (?)");
for (const number of numbers) {
insert.run(number);
}
}
describe("grabNextDocumentSequence", () => {
test("starts at 1 for an empty table", async () => {
const next = await grabNextDocumentSequence({
table: "invoices",
column: "invoice_number",
prefix: "BSQLITE-202610",
run_sql,
});
expect(next).toBe(1);
});
test("returns max + 1 for existing numbers", async () => {
insertNumbers([
"BSQLITE-202610-001",
"BSQLITE-202610-002",
"BSQLITE-202610-003",
]);
const next = await grabNextDocumentSequence({
table: "invoices",
column: "invoice_number",
prefix: "BSQLITE-202610",
run_sql,
});
expect(next).toBe(4);
});
test("does not reuse a number after a deletion", async () => {
insertNumbers(["BSQLITE-202610-001", "BSQLITE-202610-003"]);
const next = await grabNextDocumentSequence({
table: "invoices",
column: "invoice_number",
prefix: "BSQLITE-202610",
run_sql,
});
expect(next).toBe(4);
});
test("ignores numbers from other prefixes", async () => {
insertNumbers([
"BSQLITE-202609-099",
"BSQLITEQ-202610-050",
"BSQLITE-202610-002",
]);
const next = await grabNextDocumentSequence({
table: "invoices",
column: "invoice_number",
prefix: "BSQLITE-202610",
run_sql,
});
expect(next).toBe(3);
});
test("handles sequences beyond three digits", async () => {
insertNumbers(["BSQLITE-202610-009", "BSQLITE-202610-010"]);
const next = await grabNextDocumentSequence({
table: "invoices",
column: "invoice_number",
prefix: "BSQLITE-202610",
run_sql,
});
expect(next).toBe(11);
});
});
@@ -0,0 +1,41 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type DocumentNumberTarget = {
table: "invoices" | "quotes";
column: "invoice_number" | "quote_number";
};
export type DocumentNumberSqlRunner = (args: {
sql: string;
values: (string | number)[];
}) => Promise<{ singleRes?: { max_seq?: number | string | null } | null }>;
type Params = DocumentNumberTarget & {
prefix: string;
/**
* Injectable query runner, used by tests to target an in-memory DB.
*/
run_sql?: DocumentNumberSqlRunner;
};
/**
* # Grab the next document sequence.
* Reads the highest existing numeric suffix for the prefix instead of
* counting rows, so deleting a document can never hand out a number that
* already exists.
*/
export default async function grabNextDocumentSequence({
table,
column,
prefix,
run_sql,
}: Params): Promise<number> {
const sql = `SELECT MAX(CAST(SUBSTR(${column}, ?) AS INTEGER)) AS max_seq FROM ${table} WHERE ${column} LIKE ?`;
const values = [prefix.length + 2, `${prefix}-%`];
const res = run_sql
? await run_sql({ sql, values })
: await BunSQLite.sql({ sql, values });
return Number(res.singleRes?.max_seq || 0) + 1;
}
@@ -0,0 +1,64 @@
import type {
BUN_SQLITE_BSQLITE_INVOICE_ITEMS,
BUN_SQLITE_BSQLITE_INVOICE_QUOTES,
BUN_SQLITE_BSQLITE_INVOICES,
} from "@/db/types/db";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
import BunSQLite from "@moduletrace/bun-sqlite";
export default async function grabInvoice({
id,
}: {
id: number | string;
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_INVOICES>> {
return await BunSQLite.select<BUN_SQLITE_BSQLITE_INVOICES>({
table: "invoices",
targetId: id,
});
}
export async function grabInvoiceItems({
invoice_id,
}: {
invoice_id: number | string;
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_INVOICE_ITEMS>> {
return await BunSQLite.select<BUN_SQLITE_BSQLITE_INVOICE_ITEMS>({
table: "invoice_items",
query: {
query: {
invoice_id: {
value: Number(invoice_id),
},
},
order: [
{ field: "sort_order", strategy: "ASC" },
{ field: "id", strategy: "ASC" },
],
},
});
}
export type InvoiceQuoteLinkRecord = BUN_SQLITE_BSQLITE_INVOICE_QUOTES & {
quote_number?: string;
quote_title?: string;
};
export async function grabInvoiceQuoteLinks({
invoice_id,
}: {
invoice_id: number | string;
}): Promise<APIResponseObject<InvoiceQuoteLinkRecord>> {
return await BunSQLite.sql<InvoiceQuoteLinkRecord>({
sql: `
SELECT
iq.*,
q.quote_number,
q.title AS quote_title
FROM invoice_quotes iq
LEFT JOIN quotes q ON q.id = iq.quote_id
WHERE iq.invoice_id = ?
ORDER BY iq.id ASC
`,
values: [invoice_id],
});
}
@@ -0,0 +1,55 @@
import type { BUN_SQLITE_BSQLITE_MEDIA_JOIN } from "@/src/types/sql-joins";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
import _ from "lodash";
import grabUsers from "../users/grab-users";
type Params = {
media_res: APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_JOIN>;
};
export default async function appendUsersToMedia({
media_res,
}: Params): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_JOIN>> {
try {
let new_media_res = _.cloneDeep(media_res);
const media = new_media_res.payload;
const users_ids = media
?.map((m) => m.user_id)
.filter((n) => !_.isUndefined(n) && _.isNumber(n));
const unique_users_ids = _.uniq(users_ids);
if (users_ids?.[0] && media?.[0]) {
const users_res = await grabUsers({
query: { ids: unique_users_ids },
});
const users = users_res.payload || [];
for (let i = 0; i < media.length; i++) {
const single_media = media[i];
const target_user = users.find(
(u) => u.id == single_media?.user_id,
);
if (!target_user || !single_media?.user_id) {
continue;
}
new_media_res.payload![i]!.user = target_user;
if (i == 0) {
new_media_res.singleRes = target_user;
}
}
}
return new_media_res;
} catch (error: any) {
console.log("Append users to media ERROR =>", error.message);
return media_res;
}
}
@@ -0,0 +1,113 @@
import _ from "lodash";
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
import grabMediaFromParadigm from "@/src/functions/backend/db/media/grab-media-from-paraidigm";
import type { MediaParadigmType, TableType, User } from "@/src/types";
import grabDirNames from "@/src/utils/grab-dir-names";
import BunSQLite from "@moduletrace/bun-sqlite";
import { rmSync } from "fs";
import path from "path";
type Params = {
id?: string | number;
ids?: (string | number)[];
media_paradigm?: MediaParadigmType;
/**
* ID of the user to be assigned to this media.
* Different from logged in user id
*/
user_id?: string | number | null;
can_delete_all_media?: boolean;
};
export default async function deleteMedia(params: Params) {
const { id, ids, media_paradigm, user_id, can_delete_all_media } = params;
const { DATA_DIR } = grabDirNames();
let media_to_delete = new Set<number>();
if (media_paradigm && user_id) {
const media_res = (
await grabMediaFromParadigm({ media_paradigm, user_id })
).payload;
if (media_res) {
for (let i = 0; i < media_res.length; i++) {
const media = media_res[i];
if (!media?.id) continue;
media_to_delete.add(media.id);
}
}
}
let media_ids_to_del: (number | string)[] = [];
if (id) {
media_ids_to_del.push(id);
}
if (ids) {
media_ids_to_del.push(...ids);
}
if (media_ids_to_del[0]) {
for (let i = 0; i < media_ids_to_del.length; i++) {
const media_id_to_delete = media_ids_to_del[i];
const target_media = (
await BunSQLite.select<BUN_SQLITE_BSQLITE_MEDIA>({
table: "media",
targetId: media_id_to_delete,
})
).singleRes;
if (target_media?.id) {
if (target_media.user_id !== user_id && !can_delete_all_media) {
throw new Error(`Can't delete this media!`);
}
media_to_delete.add(target_media.id);
}
}
}
const media_to_delete_array = Array.from(media_to_delete);
for (let i = 0; i < media_to_delete_array.length; i++) {
const media_id = media_to_delete_array[i];
const target_media = (
await BunSQLite.select<BUN_SQLITE_BSQLITE_MEDIA>({
table: "media",
targetId: media_id,
})
).singleRes;
if (!target_media?.id) {
continue;
}
if (target_media.media_write_path) {
rmSync(
path.join(DATA_DIR, target_media.media_write_path),
{ force: true },
);
}
if (target_media.media_thumbnail_write_path) {
rmSync(
path.join(DATA_DIR, target_media.media_thumbnail_write_path),
{ force: true },
);
}
await BunSQLite.delete<BUN_SQLITE_BSQLITE_MEDIA, TableType>({
table: "media",
targetId: target_media.id,
});
}
return {
success: true,
};
}
@@ -0,0 +1,80 @@
import type { BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS } from "@/db/types/db";
import type { MediaParadigmType, TableType } from "@/src/types";
import type { BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS_JOIN } from "@/src/types/sql-joins";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import _ from "lodash";
type Params = {
media_paradigm: MediaParadigmType;
user_id?: string | number;
};
export default async function grabMediaFromParadigm({
media_paradigm,
user_id,
}: Params): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS_JOIN>> {
try {
const media_paradigms_res = await BunSQLite.select<
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
TableType
>({
table: "media_paradigms",
query: {
query: {
media_paradigm: {
value: media_paradigm,
},
},
join: [
{
joinType: "INNER JOIN",
tableName: "media",
match: [
{
source: "media_id",
target: "id",
},
],
selectFields: ["user_id"],
},
{
joinType: "INNER JOIN",
tableName: "users",
match: [
{
source: {
fieldName: "user_id",
tableName: "media",
},
target: "id",
},
...(user_id
? [
{
source: {
fieldName: "id",
tableName: "users",
},
targetLiteral: user_id,
},
]
: []),
],
selectFields: ["first_name", "last_name"],
},
],
},
});
return {
...media_paradigms_res,
singleRes: media_paradigms_res.singleRes,
};
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
}
@@ -0,0 +1,134 @@
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { ApiReqParams, User } from "@/src/types";
import type { BUN_SQLITE_BSQLITE_MEDIA_JOIN } from "@/src/types/sql-joins";
import BunSQLite, { type APIResponseObject } from "@moduletrace/bun-sqlite";
import type { SQLInsertGenValueType } from "@moduletrace/bun-sqlite";
import _ from "lodash";
import appendUsersToMedia from "./append-users-to-media";
type Params = {
query?: ApiReqParams;
user?: User;
};
export default async function grabMedia(
params?: Params,
): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_JOIN>> {
const { query, user } = params || {};
try {
const LIMIT = AppData["DefaultQueryLimit"];
const values: SQLInsertGenValueType[] = [];
const hasParadigms = Boolean(query?.media_paradigms?.length);
let sql = `SELECT\n`;
sql += ` m.*,\n`;
sql += ` GROUP_CONCAT(DISTINCT mp.media_paradigm) AS media_paradigms\n`;
sql += `FROM media m\n`;
sql += `LEFT JOIN media_paradigms mp ON m.id = mp.media_id\n`;
sql += `WHERE COALESCE(m.archived, 0) <> 1\n`;
if (hasParadigms && query?.media_paradigms) {
const placeholders = query.media_paradigms.map(() => "?").join(",");
sql += ` AND m.id IN (\n`;
sql += ` SELECT sub_mp.media_id\n`;
sql += ` FROM media_paradigms sub_mp\n`;
sql += ` WHERE sub_mp.media_paradigm IN (${placeholders})\n`;
sql += ` GROUP BY sub_mp.media_id\n`;
sql += ` HAVING COUNT(DISTINCT sub_mp.media_paradigm) = ?\n`;
sql += ` )\n`;
values.push(...query.media_paradigms, query.media_paradigms.length);
}
if (query?.is_media_private) {
sql += ` AND m.is_private = 1\n`;
if (!user?.id) {
throw new Error(`Private media requires authenticated user`);
} else {
sql += ` AND m.user_id = ?\n`;
values.push(user.id);
}
} else {
sql += ` AND COALESCE(m.is_private, 0) <> 1\n`;
}
if (query?.id) {
sql += ` AND m.id = ?\n`;
values.push(query.id);
}
if (query?.is_media_primary) {
sql += ` AND m.is_primary = ?\n`;
values.push(1);
}
if (query?.user_id) {
sql += ` AND m.user_id = ?\n`;
values.push(query.user_id);
}
// 4. Parameterized Search Terms
if (query?.search_terms?.length) {
const fields_to_search: (keyof BUN_SQLITE_BSQLITE_MEDIA)[] = [
"title",
"description",
"summary",
"text_content",
];
const searchConditions: string[] = [];
for (const search_term of query.search_terms) {
const termConditions: string[] = [];
for (const field of fields_to_search) {
termConditions.push(`m.${field} LIKE ?`);
values.push(`%${search_term}%`);
}
searchConditions.push(`(${termConditions.join(" OR ")})`);
}
sql += ` AND (${searchConditions.join(" OR ")})\n`;
}
sql += `GROUP BY m.id\n`;
sql += `ORDER BY m.id DESC\n`;
let sql_limit = `LIMIT ?`;
const limitValues: SQLInsertGenValueType[] = [LIMIT];
if (query?.page && query.page > 1) {
const offset = LIMIT * (query.page - 1);
sql_limit += ` OFFSET ?`;
limitValues.push(offset);
}
const final_sql = `${sql}\n${sql_limit}`;
const final_values = [...values, ...limitValues];
const res = await BunSQLite.sql<BUN_SQLITE_BSQLITE_MEDIA_JOIN>({
sql: final_sql,
values: final_values,
});
// Count Query
if (query?.count) {
const count_sql = `SELECT COUNT(*) AS count FROM (${sql}) AS subquery_count`;
const count_res = await BunSQLite.sql<{ count: number }>({
sql: count_sql,
values,
});
res.count = count_res.singleRes?.count ?? 0;
}
return await appendUsersToMedia({ media_res: res });
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
}
@@ -0,0 +1,38 @@
import type {
BUN_SQLITE_BSQLITE_QUOTE_ITEMS,
BUN_SQLITE_BSQLITE_QUOTES,
} from "@/db/types/db";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
import BunSQLite from "@moduletrace/bun-sqlite";
export default async function grabQuote({
id,
}: {
id: number | string;
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_QUOTES>> {
return await BunSQLite.select<BUN_SQLITE_BSQLITE_QUOTES>({
table: "quotes",
targetId: id,
});
}
export async function grabQuoteItems({
quote_id,
}: {
quote_id: number | string;
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_QUOTE_ITEMS>> {
return await BunSQLite.select<BUN_SQLITE_BSQLITE_QUOTE_ITEMS>({
table: "quote_items",
query: {
query: {
quote_id: {
value: Number(quote_id),
},
},
order: [
{ field: "sort_order", strategy: "ASC" },
{ field: "id", strategy: "ASC" },
],
},
});
}
@@ -0,0 +1,32 @@
import type {
BUN_SQLITE_BSQLITE_USER_TYPES,
BunSQLiteTables,
} from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
user_id: number | string;
};
/**
* All `user_types` rows for a user (including `revoked`).
*/
export default async function grabUserTypes({
user_id,
}: Params): Promise<BUN_SQLITE_BSQLITE_USER_TYPES[]> {
const res = await BunSQLite.select<
BUN_SQLITE_BSQLITE_USER_TYPES,
(typeof BunSQLiteTables)[number]
>({
table: "user_types",
query: {
query: {
user_id: {
value: user_id,
},
},
},
});
return res.payload || [];
}
@@ -0,0 +1,147 @@
import { AppData } from "@/src/data/app-data";
import type { ApiReqParams, SQLInsertGenValueType } from "@/src/types";
import type { BUN_SQLITE_BSQLITE_USERS_JOIN } from "@/src/types/sql-joins";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import _ from "lodash";
type Params = {
query?: ApiReqParams;
/**
* For search terms (ie `search_term_*`) parameters.
* This forces all searches to `=` instead of `LIKE`
*/
exact_match?: boolean;
user_id?: string | number;
};
export default async function grabUsers(
params?: Params,
): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_USERS_JOIN>> {
const { query } = params || {};
try {
const LIMIT = AppData["DefaultQueryLimit"];
let sql = ``;
let values: SQLInsertGenValueType[] = [];
sql += `SELECT\n`;
sql += ` u.*,\n`;
sql += ` ut_agg.user_types,\n`;
sql += ` m.id AS profile_media_id\n`;
sql += `FROM users u\n`;
sql += `LEFT JOIN (\n`;
sql += ` SELECT user_id, GROUP_CONCAT(DISTINCT user_type) AS user_types\n`;
sql += ` FROM user_types\n`;
sql += ` GROUP BY user_id\n`;
sql += `) ut_agg ON u.id = ut_agg.user_id\n`;
sql += `LEFT JOIN media m ON (\n`;
sql += ` u.id = m.user_id\n`;
sql += ` AND m.is_primary = 1\n`;
sql += `)\n`;
sql += `LEFT JOIN media_paradigms mp ON (\n`;
sql += ` m.id = mp.media_id\n`;
sql += ` AND mp.media_paradigm = 'user-profile-image'\n`;
sql += `)\n`;
sql += `WHERE COALESCE(u.archived, 0) <> 1\n`;
const target_user_id = params?.user_id || query?.user_id;
if (target_user_id) {
sql += ` AND u.id=?\n`;
values.push(target_user_id);
}
if (query?.search_term_user_type) {
sql += ` AND EXISTS (\n`;
sql += ` SELECT 1 FROM user_types WHERE user_id = u.id AND user_type LIKE`;
if (Array.isArray(query.search_term_user_type)) {
sql += `(`;
for (let i = 0; i < query.search_term_user_type.length; i++) {
const ut = query.search_term_user_type[i];
sql += ` ?`;
values.push(`%${ut}%`);
}
sql += `)`;
} else {
sql += ` ?`;
values.push(`%${query.search_term_user_type}%`);
}
sql += ` )\n`;
} else if (query?.target_user_type) {
sql += ` AND EXISTS (
SELECT 1 FROM user_types WHERE user_id = u.id AND user_type = ?
)\n`;
values.push(query.target_user_type);
}
let search_terms = [
{ field: "first_name", value: query?.search_term_first_name },
{ field: "last_name", value: query?.search_term_last_name },
{ field: "email", value: query?.search_term_email },
{ field: "id", value: query?.search_term_id },
] as const;
for (const term of search_terms) {
if (term.value) {
const isId = term.field === "id";
const match_value = params?.exact_match ? "=" : "LIKE";
if (Array.isArray(term.value)) {
sql += ` AND (`;
for (let i = 0; i < term.value.length; i++) {
const term_value = term.value[i];
if (!term_value) continue;
const match_value_param = params?.exact_match
? term_value
: `%${term_value.replace(/ /g, "%")}%`;
sql += ` u.${term.field} ${isId ? "=" : match_value} ?`;
if (i < term.value.length - 1) {
sql += ` OR`;
}
values.push(isId ? term_value : match_value_param);
}
sql += ` )\n`;
} else {
const match_value_param = params?.exact_match
? term.value
: `%${term.value.replace(/ /g, "%")}%`;
sql += ` AND u.${term.field} ${isId ? "=" : match_value} ?\n`;
values.push(isId ? term.value : match_value_param);
}
}
}
let sql_limit = ``;
sql_limit += `LIMIT ${LIMIT}\n`;
if (query?.page) {
const offset = LIMIT * (query.page - 1);
sql_limit += `OFFSET ${offset}\n`;
}
const res = await BunSQLite.sql<BUN_SQLITE_BSQLITE_USERS_JOIN>({
sql: `${sql}\n${sql_limit}`,
values,
});
if (query?.count) {
const count_sql = `SELECT COUNT(*) AS count FROM (${sql}) x`;
// const count_sql = `SELECT COUNT(*) AS count FROM users u WHERE u.archived IS NOT 1 ${
// query?.user_id ? "AND u.id = ?" : ""
// }`;
const count_res = await BunSQLite.sql({ sql: count_sql, values });
res.count = count_res.singleRes?.count;
}
return res;
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
}
@@ -0,0 +1,84 @@
import TurndownService from "turndown";
import { extractText, getDocumentProxy } from "unpdf";
import mammoth from "mammoth";
// import * as XLSX from "xlsx";
import bufferFromUrl from "@/src/utils/buffer-from-url";
const turndownService = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
});
type Params = {
file_stream?: Buffer<ArrayBuffer>;
file_path?: string;
/**
* File extension, e.g. `pdf`, `docx`, `xlsx`
*/
file_type?: string;
url?: string;
};
export default async function fileToText({
file_type,
file_stream,
file_path,
url,
}: Params): Promise<string | undefined> {
let buffer: Buffer | undefined;
let ext: string;
if (url) {
const result = await bufferFromUrl(url);
buffer = result.buffer;
ext = result.ext;
} else {
buffer = file_path
? Buffer.from(await Bun.file(file_path).arrayBuffer())
: file_stream;
ext = (file_type || file_path?.split(".").pop() || "txt")
.toString()
.toLowerCase();
}
if (!buffer) return undefined;
switch (ext) {
case "docx": {
const { value: html } = await mammoth.convertToHtml({ buffer });
return turndownService.turndown(html);
}
case "pdf": {
try {
const pdfUintBuffer = new Uint8Array(buffer);
const pdfDocProxy = await getDocumentProxy(pdfUintBuffer);
const { text } = await extractText(pdfDocProxy, {
mergePages: true,
});
return text;
} catch (err: any) {
console.error(
`PDF text extraction failed: ${err?.message ?? err}`,
);
throw new Error(
`Failed to extract text from PDF: ${err?.message ?? "unknown error"}`,
);
}
}
// case "xlsx":
// case "csv": {
// const workbook = XLSX.read(buffer, { type: "buffer" });
// return workbook.SheetNames.map((name) => {
// const sheet = workbook.Sheets[name];
// return `### Sheet: ${name}\n\n${XLSX.utils.sheet_to_csv(sheet!)}`;
// }).join("\n\n");
// }
case "md":
case "txt":
default:
return buffer.toString("utf-8");
}
}
@@ -0,0 +1,27 @@
import type { MediaDataType } from "@/src/types";
export default async function MediaDataToArrayBuffer(
body: MediaDataType,
): Promise<ArrayBuffer> {
if (body == null) {
return new ArrayBuffer(0);
}
if (body instanceof ArrayBuffer) {
return body;
}
if (body instanceof SharedArrayBuffer) {
const copy = new ArrayBuffer(body.byteLength);
new Uint8Array(copy).set(new Uint8Array(body));
return copy;
}
if (typeof body === "string") {
return new TextEncoder().encode(body).buffer as ArrayBuffer;
}
if (body instanceof ReadableStream) {
return Bun.readableStreamToArrayBuffer(body);
}
// Blob, BunFile, Request, Response — wrap in Response and use Bun's reader
return new Response(body as BodyInit)
.bytes()
.then((b) => b.buffer as ArrayBuffer);
}
@@ -0,0 +1,24 @@
import type { MediaDataType } from "@/src/types";
export default async function mediaDataToSharpInput(
body: MediaDataType,
): Promise<Buffer> {
if (body instanceof Buffer) return body;
if (body instanceof SharedArrayBuffer)
return Buffer.from(new Uint8Array(body));
if (body instanceof ArrayBuffer) return Buffer.from(body);
if (body instanceof Uint8Array) return Buffer.from(body);
if (body instanceof Blob || body instanceof File)
return Buffer.from(await body.arrayBuffer());
if (body instanceof Response || body instanceof Request)
return Buffer.from(await body.arrayBuffer());
if (typeof body === "string") return Buffer.from(body, "base64");
if (ArrayBuffer.isView(body))
return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
if ("arrayBuffer" in body && typeof body.arrayBuffer === "function") {
return Buffer.from(await (body as any).arrayBuffer());
}
throw new Error(`Unsupported body type: ${typeof body}`);
}
@@ -0,0 +1,50 @@
import type { MediaDataType } from "@/src/types";
import MediaDataToArrayBuffer from "./media-data-to-array-buffer";
export default async function sniffMediaExtension(
data: MediaDataType,
): Promise<string | undefined> {
try {
const bytes = new Uint8Array(await MediaDataToArrayBuffer(data));
if (bytes.length < 12) return undefined;
const has = (...signature: number[]) =>
signature.every((byte, i) => bytes[i] === byte);
if (has(0xff, 0xd8, 0xff)) return "jpg";
if (has(0x89, 0x50, 0x4e, 0x47)) return "png";
if (has(0x47, 0x49, 0x46, 0x38)) return "gif";
if (has(0x25, 0x50, 0x44, 0x46)) return "pdf";
if (has(0x50, 0x4b, 0x03, 0x04)) return "zip";
if (has(0x49, 0x44, 0x33)) return "mp3";
if (has(0x4f, 0x67, 0x67, 0x53)) return "ogg";
if (has(0x52, 0x49, 0x46, 0x46)) {
const riff_type = String.fromCharCode(
bytes[8]!,
bytes[9]!,
bytes[10]!,
bytes[11]!,
);
if (riff_type === "WEBP") return "webp";
if (riff_type === "WAVE") return "wav";
}
if (
String.fromCharCode(bytes[4]!, bytes[5]!, bytes[6]!, bytes[7]!) ===
"ftyp"
) {
return "mp4";
}
const head = String.fromCharCode(...bytes.subarray(0, 8)).trimStart();
if (head.startsWith("<svg") || head.startsWith("<?xml")) return "svg";
return undefined;
} catch (error) {
return undefined;
}
}
@@ -0,0 +1,300 @@
import type {
BUN_SQLITE_BSQLITE_MEDIA,
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
BUN_SQLITE_BSQLITE_USERS,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type {
MediaDataType,
MediaParadigm,
MediaType,
TableType,
User,
} from "@/src/types";
import grabDirNames from "@/src/utils/grab-dir-names";
import BunSQLite, { type APIResponseObject } from "@moduletrace/bun-sqlite";
import { existsSync, mkdirSync } from "fs";
import path from "path";
import sharp from "sharp";
import fileToText from "./file-to-text";
import MediaDataToArrayBuffer from "./media-data-to-array-buffer";
import sniffMediaExtension from "./sniff-media-ext";
import mediaDataToSharpInput from "./meida-data-to-sharp-input";
import deleteMedia from "../../media/delete-media";
type Params = {
media_paradigms?: MediaParadigm[];
media_type?: MediaType;
user: BUN_SQLITE_BSQLITE_USERS | User;
data?: MediaDataType;
is_primary?: boolean;
update_on_duplicate?: boolean;
extract_media_text_content?: boolean;
media?: BUN_SQLITE_BSQLITE_MEDIA;
media_name?: string;
media_mime_type?: string;
is_private?: boolean;
image_width?: number;
image_height?: number;
thumbnail_width?: number;
existing_media_id?: string | number;
media_text_content?: string;
};
export default async function uploadAndRecordMedia({
media_paradigms,
user,
data,
media_type,
is_primary,
update_on_duplicate,
extract_media_text_content,
media,
media_mime_type,
media_name,
is_private,
image_width,
image_height,
thumbnail_width,
existing_media_id,
media_text_content: passed_media_text_content,
}: Params): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA>> {
const now = Date.now();
let target_file_name = `${media_name || now}`;
let target_file_thumbnail_name = `${media_name || now}-thumb`;
const {
USER_MEDIA_PRIVATE_RELATIVE_DIR,
USER_MEDIA_PUBLIC_RELATIVE_DIR,
DATA_DIR,
} = grabDirNames({
user,
});
const MEDIA_RELATIVE_DIR = is_private
? USER_MEDIA_PRIVATE_RELATIVE_DIR
: USER_MEDIA_PUBLIC_RELATIVE_DIR;
if (!MEDIA_RELATIVE_DIR) {
throw new Error(`No target media directory found!`);
}
let media_written_to_disk = false;
let media_text_content = passed_media_text_content;
let media_ext = media_mime_type
? media_mime_type.replace(/\./g, "")
: undefined;
if (!media_ext && data) {
if (typeof data === "string" && data.startsWith("data:")) {
const base64_mime = data.match(/^data:([^;]+);base64,/)?.[1];
if (base64_mime) {
media_ext = base64_mime.split("/").pop() ?? undefined;
}
} else if (data instanceof File && data.name.includes(".")) {
media_ext = data.name.split(".").pop();
} else if (
data instanceof Blob &&
data.type &&
!data.type.includes("octet-stream")
) {
media_ext = data.type.split("/").pop();
} else if (media_type === "image") {
media_ext = "jpeg";
} else if (typeof data !== "string") {
media_ext = await sniffMediaExtension(data);
}
}
const media_write_path = path.join(
MEDIA_RELATIVE_DIR,
`${target_file_name}.${media_ext}`,
);
const media_thumbnail_write_path = path.join(
MEDIA_RELATIVE_DIR,
`${target_file_thumbnail_name}.${media_ext}`,
);
const media_write_path_full = path.join(DATA_DIR, media_write_path);
const media_thumbnail_write_path_full = path.join(
DATA_DIR,
media_thumbnail_write_path,
);
mkdirSync(path.dirname(media_write_path_full), { recursive: true });
if (data && media_type == "image" && media_ext) {
const buffer = await mediaDataToSharpInput(data);
const is_profile_image =
media_paradigms?.includes("user-profile-image");
const thumb_width = thumbnail_width ?? AppData["ImageThumbnailSize"];
let full_width: number | undefined;
let full_height: number | undefined;
let full_options: {
fit?: "cover" | "inside";
withoutEnlargement?: boolean;
} = {};
if (is_profile_image) {
full_width = AppData["MainImageSize"];
full_height = AppData["MainImageSize"];
full_options = { fit: "cover" };
} else {
const meta = await sharp(buffer).metadata();
const source_width = image_width ?? meta.width;
const source_height = image_height ?? meta.height;
const max = AppData["MaxImageWidth"];
full_width =
source_width != null
? source_width > max
? max
: source_width
: max;
full_height =
image_height != null
? source_height != null && source_height > max
? max
: source_height
: undefined;
full_options = { fit: "inside", withoutEnlargement: true };
}
const [full, thumb] = await Promise.all([
sharp(buffer)
.resize(full_width, full_height, full_options)
.jpeg({ quality: AppData["ImageQuality"] })
.toBuffer(),
sharp(buffer)
.resize(
thumb_width,
is_profile_image ? thumb_width : undefined,
is_profile_image
? { fit: "cover" }
: { fit: "inside", withoutEnlargement: true },
)
.jpeg({ quality: AppData["ImageThumbnailQuality"] })
.toBuffer(),
]);
await Bun.write(media_write_path_full, full);
await Bun.write(media_thumbnail_write_path_full, thumb);
} else if (data) {
const media_array_buffer = await MediaDataToArrayBuffer(data);
await Bun.write(media_write_path_full, media_array_buffer, {
mode: 1,
});
if (extract_media_text_content) {
const media_text = await fileToText({
file_type: media_ext,
file_stream: Buffer.from(media_array_buffer),
});
if (media_text) {
media_text_content = media_text;
}
}
}
if (existsSync(media_write_path)) {
media_written_to_disk = true;
}
if (is_primary) {
const existing_user_primary_media = await BunSQLite.select<
BUN_SQLITE_BSQLITE_MEDIA,
TableType
>({
table: "media",
query: {
query: {
user_id: { value: user.id },
is_primary: { value: 1 },
},
},
});
if (existing_user_primary_media.payload) {
await deleteMedia({
can_delete_all_media: true,
ids: existing_user_primary_media.payload.map((m) => m.id!),
});
}
}
const media_data: BUN_SQLITE_BSQLITE_MEDIA = {
...media,
user_id: Number(user.id),
is_primary: is_primary ? 1 : 0,
media_write_path: media_write_path,
media_thumbnail_write_path:
media_type === "image" ? media_thumbnail_write_path : undefined,
media_type: media_type as any,
mime_type: media_type == "image" ? "jpeg" : media_ext,
text_content: media_text_content,
};
let media_record: BUN_SQLITE_BSQLITE_MEDIA | undefined = undefined;
if (existing_media_id) {
const update_media_record = await BunSQLite.update<
BUN_SQLITE_BSQLITE_MEDIA,
TableType
>({
data: media_data,
table: "media",
targetId: existing_media_id,
});
media_record = update_media_record.singleRes || undefined;
} else {
const new_media_record = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_MEDIA,
TableType
>({
data: [media_data],
table: "media",
update_on_duplicate,
});
media_record = new_media_record.singleRes || undefined;
}
if (!media_record) {
throw new Error(`Couldn't record media!`);
}
if (media_paradigms?.[0] && media_record?.id) {
const delete_existing_paradigms = await BunSQLite.delete<
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
(typeof BunSQLiteTables)[number]
>({
table: "media_paradigms",
query: {
query: { media_id: { value: media_record.id } },
},
});
await BunSQLite.insert<
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
(typeof BunSQLiteTables)[number]
>({
data: media_paradigms.map((mp) => ({
media_id: media_record.id,
media_paradigm: mp,
})),
table: "media_paradigms",
update_on_duplicate,
});
}
return {
success: true,
singleRes: media_record,
};
}
@@ -0,0 +1,28 @@
import sanitizeRichTextHtml from "@/src/utils/sanitize-rich-text-html";
type UserWithBio = {
bio?: string | null;
};
type Params<T extends UserWithBio> = {
user: T;
};
/**
* # Sanitize a user's `bio` HTML.
* The `bio` column is editable by every logged-in user (including
* `read_only`) and rendered with `dangerouslySetInnerHTML`, so it must be
* sanitized on write and before render.
*/
export default function sanitizeUserBio<T extends UserWithBio>({
user,
}: Params<T>): T {
if (typeof user.bio !== "string" || !user.bio) {
return user;
}
return {
...user,
bio: sanitizeRichTextHtml({ html: user.bio }),
} as T;
}
@@ -0,0 +1,351 @@
import { formatInvoiceDate } from "@/src/functions/backend/pdf/letter-date";
import { CompanyLetterheadDefaults } from "@/src/dict/company-dict";
import { formatNaira } from "@/src/utils/money";
import { ThemeColors } from "@/src/functions/backend/theme/theme-colors";
const letter_divider = (
<hr
style={{
border: "none",
borderTop: `1px solid ${ThemeColors.hairline}`,
}}
/>
);
type InvoiceEmailContentProps = {
invoice: {
invoice_number?: string;
client_name?: string;
issue_date?: number | "";
due_date?: number | "";
status?: string;
notes?: string;
};
items: {
description?: string;
quantity?: number | string | "";
unit_price_kobo?: number | string | "";
}[];
};
type QuoteEmailContentProps = {
quote: {
quote_number?: string;
client_name?: string;
issue_date?: number | "";
valid_until?: number | "";
status?: string;
notes?: string;
};
items: {
description?: string;
quantity?: number | string | "";
unit_price_kobo?: number | string | "";
}[];
};
export function InvoiceEmailContent({
invoice,
items,
}: InvoiceEmailContentProps) {
const total_kobo = items.reduce((acc, item) => {
return (
acc +
(Number(item.quantity) || 0) * (Number(item.unit_price_kobo) || 0)
);
}, 0);
return (
<div>
<div
style={{
fontSize: "13px",
fontWeight: "600",
color: ThemeColors.ink,
}}
>
{CompanyLetterheadDefaults.name}
</div>
{letter_divider}
<h1
style={{
fontSize: "18px",
margin: "0 0 6px",
}}
>
Invoice {invoice.invoice_number || ""}
</h1>
<div
style={{
fontSize: "13px",
lineHeight: "1.7",
color: ThemeColors.body,
}}
>
Hello {invoice.client_name || "there"},
<br />
Please find attached invoice {invoice.invoice_number || ""} for
your records.
</div>
{letter_divider}
<div style={{ fontSize: "14px", lineHeight: "1.6" }}>
<table
width="100%"
cellPadding="0"
cellSpacing="0"
style={{ fontSize: "13px", borderCollapse: "collapse" }}
>
<tr
style={{
background: ThemeColors.ink,
color: ThemeColors.surface,
fontWeight: "600",
}}
>
<td style={{ padding: "8px 10px" }}>Description</td>
<td align="right" style={{ padding: "8px 10px" }}>
Qty
</td>
<td align="right" style={{ padding: "8px 10px" }}>
Amount
</td>
</tr>
{items.map((item, index) => (
<tr
key={index}
style={
index % 2 == 0
? { background: ThemeColors.faint }
: undefined
}
>
<td style={{ padding: "7px 10px" }}>
{item.description}
</td>
<td align="right" style={{ padding: "7px 10px" }}>
{Number(item.quantity) || 0}
</td>
<td align="right" style={{ padding: "7px 10px" }}>
{formatNaira(
(Number(item.quantity) || 0) *
(Number(item.unit_price_kobo) || 0),
)}
</td>
</tr>
))}
<tr>
<td
colSpan={2}
align="right"
style={{
padding: "9px 10px",
fontWeight: "600",
}}
>
Total (NGN)
</td>
<td
align="right"
style={{
padding: "9px 10px",
fontWeight: "600",
}}
>
{formatNaira(total_kobo)}
</td>
</tr>
</table>
</div>
{letter_divider}
<div
style={{
fontSize: "13px",
lineHeight: "1.7",
color: ThemeColors.body,
}}
>
Invoice {invoice.invoice_number || ""} was issued on{" "}
{formatInvoiceDate(invoice.issue_date || Date.now())}
{invoice.due_date
? ` and is due by ${formatInvoiceDate(invoice.due_date)}`
: ""}
.
<br />
<br />
Thank you for your business.
</div>
<div
style={{
fontSize: "12px",
color: ThemeColors.muted,
borderTop: `1px solid ${ThemeColors.hairline}`,
paddingTop: "12px",
}}
>
{CompanyLetterheadDefaults.name} -{" "}
{CompanyLetterheadDefaults.address_lines.join(", ")} |{" "}
{CompanyLetterheadDefaults.phone} |{" "}
{CompanyLetterheadDefaults.email} |{" "}
{CompanyLetterheadDefaults.website}
</div>
</div>
);
}
export function QuoteEmailContent({ quote, items }: QuoteEmailContentProps) {
const total_kobo = items.reduce((acc, item) => {
return (
acc +
(Number(item.quantity) || 0) * (Number(item.unit_price_kobo) || 0)
);
}, 0);
return (
<div>
<div
style={{
fontSize: "13px",
fontWeight: "600",
color: ThemeColors.ink,
}}
>
{CompanyLetterheadDefaults.name}
</div>
{letter_divider}
<h1
style={{
fontSize: "18px",
margin: "0 0 6px",
}}
>
Quotation {quote.quote_number || ""}
</h1>
<div
style={{
fontSize: "13px",
lineHeight: "1.7",
color: ThemeColors.body,
}}
>
Hello {quote.client_name || "there"},
<br />
Please find attached quotation {quote.quote_number || ""} for
our proposed works.
</div>
{letter_divider}
<div style={{ fontSize: "14px", lineHeight: "1.6" }}>
<table
width="100%"
cellPadding="0"
cellSpacing="0"
style={{ fontSize: "13px", borderCollapse: "collapse" }}
>
<tr
style={{
background: ThemeColors.ink,
color: ThemeColors.surface,
fontWeight: "600",
}}
>
<td style={{ padding: "8px 10px" }}>Description</td>
<td align="right" style={{ padding: "8px 10px" }}>
Qty
</td>
<td align="right" style={{ padding: "8px 10px" }}>
Amount
</td>
</tr>
{items.map((item, index) => (
<tr
key={index}
style={
index % 2 == 0
? { background: ThemeColors.faint }
: undefined
}
>
<td style={{ padding: "7px 10px" }}>
{item.description}
</td>
<td align="right" style={{ padding: "7px 10px" }}>
{Number(item.quantity) || 0}
</td>
<td align="right" style={{ padding: "7px 10px" }}>
{formatNaira(
(Number(item.quantity) || 0) *
(Number(item.unit_price_kobo) || 0),
)}
</td>
</tr>
))}
<tr>
<td
colSpan={2}
align="right"
style={{
padding: "9px 10px",
fontWeight: "600",
}}
>
Total (NGN)
</td>
<td
align="right"
style={{
padding: "9px 10px",
fontWeight: "600",
}}
>
{formatNaira(total_kobo)}
</td>
</tr>
</table>
</div>
{letter_divider}
<div
style={{
fontSize: "13px",
lineHeight: "1.7",
color: ThemeColors.body,
}}
>
Quotation {quote.quote_number || ""} was issued on{" "}
{formatInvoiceDate(quote.issue_date || Date.now())}
{quote.valid_until
? ` and is valid until ${formatInvoiceDate(quote.valid_until)}`
: ""}
.
<br />
<br />
We look forward to working with you.
</div>
<div
style={{
fontSize: "12px",
color: ThemeColors.muted,
borderTop: `1px solid ${ThemeColors.hairline}`,
paddingTop: "12px",
}}
>
{CompanyLetterheadDefaults.name} -{" "}
{CompanyLetterheadDefaults.address_lines.join(", ")} |{" "}
{CompanyLetterheadDefaults.phone} |{" "}
{CompanyLetterheadDefaults.email} |{" "}
{CompanyLetterheadDefaults.website}
</div>
</div>
);
}
@@ -0,0 +1,17 @@
import nodemailer from "nodemailer";
const SMTPTimeoutMilliseconds = 15 * 1000;
const EmailClient = nodemailer.createTransport({
host: process.env.EMAIL_DOMAIN,
port: 465,
secure: true,
connectionTimeout: SMTPTimeoutMilliseconds,
greetingTimeout: SMTPTimeoutMilliseconds,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});
export default EmailClient;
@@ -0,0 +1,22 @@
type Params = {
/**
* The real recipient, used in production
*/
to: string;
};
/**
* Outside production, all outgoing mail is redirected to
* `DEV_EMAIL_RECIPIENT` so real users never get test emails. Returns
* `undefined` when no dev recipient is configured - callers should skip
* sending in that case.
*/
export default function resolveEmailRecipient({
to,
}: Params): string | undefined {
if (process.env.NODE_ENV == "production") {
return to;
}
return process.env.DEV_EMAIL_RECIPIENT || undefined;
}
@@ -0,0 +1,56 @@
import { renderToString } from "react-dom/server";
import path from "path";
import type Mail from "nodemailer/lib/mailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import type { FC, ReactNode } from "react";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import EmailClient from "./email-client";
import { SiteData } from "@/src/data/site-data";
type Params = {
options?: Mail.Options & Partial<SMTPTransport.Options>;
to: string;
content: ReactNode;
subject: string;
text: string;
title?: string;
};
export default async function sendEmail({
options,
to,
content,
subject,
text,
title,
}: Params): Promise<APIResponseObject> {
try {
const now = Date.now();
const EmailRoot = (
await import(path.join(process.cwd(), `src/email/root?t=${now}`))
).default as FC<any>;
const final_component = <EmailRoot>{content}</EmailRoot>;
const html = renderToString(final_component);
const mail = await EmailClient.sendMail({
from: `"${title || `${SiteData["SiteName"]} Mailer`}" <${process.env.EMAIL_USER}>`,
to,
subject,
text,
html,
sender: `${process.env.EMAIL_USER}`,
...options,
});
return {
success: Boolean(mail.accepted?.[0]),
singleRes: mail,
};
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
}
+157
View File
@@ -0,0 +1,157 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { LogCategories } from "@/src/dict/log-categories";
export type LogCategory = (typeof LogCategories)[number]["value"];
export function grabRequestIP(req?: Request): string | undefined {
if (!req) {
return undefined;
}
const forwarded = req.headers.get("x-forwarded-for");
if (forwarded) {
return forwarded.split(",").shift()?.trim();
}
return req.headers.get("x-real-ip") || undefined;
}
export type LogDetail = {
label: string;
value: unknown;
};
export type LogEventParams = {
title: string;
category: LogCategory;
user_id?: number;
plain_text: string;
markdown?: string;
html?: string;
details?: LogDetail[];
metadata?: Record<string, unknown>;
ip_address?: string;
};
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
export function buildLogFormats({
plain_text,
details = [],
}: {
plain_text: string;
details?: LogDetail[];
}): { markdown: string; html: string } {
let markdown = plain_text;
if (details.length) {
markdown +=
`\n\n| Field | Value |\n| --- | --- |\n` +
details
.map(
(detail) =>
`| ${detail.label} | \`${String(detail.value)}\` |`,
)
.join("\n");
}
const html_details = details.length
? `<table><tbody>` +
details
.map(
(detail) =>
`<tr><td><strong>${escapeHtml(detail.label)}</strong></td><td><code>${escapeHtml(
String(detail.value),
)}</code></td></tr>`,
)
.join("") +
`</tbody></table>`
: "";
const html =
`<p>${escapeHtml(plain_text).replaceAll("\n", "<br/>")}</p>` +
html_details;
return { markdown, html };
}
export default async function logEvent({
title,
category,
user_id,
plain_text,
markdown,
html,
details,
metadata,
ip_address,
}: LogEventParams) {
try {
const formats = buildLogFormats({ plain_text, details });
return await BunSQLite.insert({
table: "logs",
data: [
{
title: title.slice(0, 255),
category,
user_id,
plain_text,
markdown: markdown || formats.markdown,
html: html || formats.html,
metadata: metadata ? JSON.stringify(metadata) : undefined,
ip_address,
},
],
});
} catch (error) {
return { success: false };
}
}
export async function logError({
title,
category = "error",
error,
user_id,
details,
metadata,
ip_address,
}: {
title: string;
category?: LogCategory;
error: unknown;
user_id?: number;
details?: LogDetail[];
metadata?: Record<string, unknown>;
ip_address?: string;
}) {
const error_message =
error instanceof Error ? error.message : String(error);
const error_stack =
error instanceof Error && error.stack ? error.stack : undefined;
return await logEvent({
title,
category,
user_id,
plain_text: `${title}: ${error_message}`,
details: [
...(details || []),
{ label: "Error", value: error_message },
],
metadata: {
...metadata,
error_stack,
},
ip_address,
});
}
@@ -0,0 +1,36 @@
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
import type { BUN_SQLITE_BSQLITE_USERS_JOIN } from "@/src/types/sql-joins";
type Params = {
media?: BUN_SQLITE_BSQLITE_MEDIA;
user?: BUN_SQLITE_BSQLITE_USERS_JOIN;
};
export default function grabMediaURL({ media: passed_media, user }: Params) {
const media = passed_media || {};
if (user?.profile_media_id) {
media.id = user.profile_media_id;
}
let media_url = `/media`;
if (media.is_private) {
media_url += `/private`;
} else {
media_url += `/public`;
}
if (!media.id) {
return {};
}
media_url += `/${media.id}`;
const media_thumbnail_url =
media.media_type == "image" || user?.id
? `${media_url}/thumbnail`
: undefined;
return { media_url, media_thumbnail_url };
}
@@ -0,0 +1,93 @@
import { expect, test } from "bun:test";
import htmlToTextBlocks from "./html-blocks";
test("parses paragraphs and inline styles", () => {
const blocks = htmlToTextBlocks(
`<p>Hello <strong>world</strong> and <em>friends</em>.</p><p>Second <u>underlined</u> paragraph.</p>`,
);
expect(blocks).toHaveLength(2);
expect(blocks[0]!.type).toBe("paragraph");
expect((blocks[0] as any).segments).toEqual([
{ text: "Hello " },
{ text: "world", bold: true },
{ text: " and " },
{ text: "friends", italic: true },
{ text: "." },
]);
expect((blocks[1] as any).segments).toEqual([
{ text: "Second " },
{ text: "underlined", underline: true },
{ text: " paragraph." },
]);
});
test("parses lists with ordering", () => {
const blocks = htmlToTextBlocks(
`<ul><li>One</li><li>Two</li></ul><ol><li>First</li></ol>`,
);
expect(blocks).toHaveLength(3);
expect(blocks[0]!).toEqual({
type: "list-item",
ordered: false,
index: 0,
segments: [{ text: "One" }],
});
expect(blocks[1]!).toEqual({
type: "list-item",
ordered: false,
index: 1,
segments: [{ text: "Two" }],
});
expect(blocks[2]!).toEqual({
type: "list-item",
ordered: true,
index: 0,
segments: [{ text: "First" }],
});
});
test("parses headings, blockquote, hr and br", () => {
const blocks = htmlToTextBlocks(
`<h2>Heading</h2><blockquote>Quoted</blockquote><hr><p>Line one<br>Line two</p>`,
);
expect(blocks[0]!.type).toBe("heading");
expect((blocks[0] as any).level).toBe(2);
expect(blocks[1]!.type).toBe("blockquote");
expect(blocks[2]!).toEqual({ type: "divider" });
// <br> splits into separate paragraphs
expect(blocks).toHaveLength(5);
expect((blocks[3] as any).segments.map((s: any) => s.text).join("")).toBe(
"Line one",
);
expect((blocks[4] as any).segments.map((s: any) => s.text).join("")).toBe(
"Line two",
);
});
test("decodes HTML entities", () => {
const blocks = htmlToTextBlocks(
`<p>Fee &amp; charges &mdash; &#163;500 &gt; 400</p>`,
);
expect((blocks[0] as any).segments[0].text).toBe(
"Fee & charges \u2014 \u00a3500 > 400",
);
});
test("uses img alt text and ignores unknown tags", () => {
const blocks = htmlToTextBlocks(
`<p>See <img src="x.png" alt="the chart" /> below.</p><svg><circle></circle></svg>`,
);
expect((blocks[0] as any).segments.map((s: any) => s.text).join("")).toBe(
"See the chart below.",
);
});
test("returns empty for empty input", () => {
expect(htmlToTextBlocks(undefined)).toEqual([]);
expect(htmlToTextBlocks("")).toEqual([]);
});
+262
View File
@@ -0,0 +1,262 @@
export type TextSegment = {
text: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
};
export type TextBlock =
| { type: "paragraph"; segments: TextSegment[] }
| { type: "heading"; level: number; segments: TextSegment[] }
| { type: "list-item"; ordered: boolean; index: number; segments: TextSegment[] }
| { type: "blockquote"; segments: TextSegment[] }
| { type: "divider" };
const named_entities: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
ensp: " ",
emsp: " ",
ndash: "\u2013",
mdash: "\u2014",
hellip: "\u2026",
lsquo: "\u2018",
rsquo: "\u2019",
ldquo: "\u201c",
rdquo: "\u201d",
bull: "\u2022",
middot: "\u00b7",
times: "\u00d7",
copy: "\u00a9",
reg: "\u00ae",
trade: "\u2122",
deg: "\u00b0",
sect: "\u00a7",
para: "\u00b6",
euro: "\u20ac",
pound: "\u00a3",
minus: "\u2212",
radic: "\u221a",
frac12: "\u00bd",
frac14: "\u00bc",
frac34: "\u00be",
};
function decodeEntities(text: string): string {
return text
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) =>
String.fromCodePoint(parseInt(hex, 16)),
)
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
.replace(
/&([a-zA-Z]+);/g,
(match, name: string) => named_entities[name.toLowerCase()] || match,
);
}
const INLINE_STYLE_OPEN = ["b", "strong", "i", "em", "u", "ins"];
const INLINE_STYLE_CLOSE = ["b", "strong", "i", "em", "u", "ins"];
const HEADING_LEVELS: Record<string, number> = {
h1: 1,
h2: 2,
h3: 3,
h4: 4,
h5: 5,
h6: 6,
};
/**
* # Convert an HTML fragment (TinyMCE output) into simple text blocks
* suitable for rendering in a PDF. Supports: p, div, br, h1-h6, ul/ol/li,
* blockquote, hr, b/strong, i/em, u/ins, a, span, img (alt text) and
* basic table cells (degraded to inline text separated by spaces).
*/
export default function htmlToTextBlocks(html?: string): TextBlock[] {
const source = html || "";
const blocks: TextBlock[] = [];
let pending_text = "";
const style = { bold: false, italic: false, underline: false };
let current: TextBlock | undefined;
const list_stack: { ordered: boolean; index: number }[] = [];
const appendText = (raw: string) => {
if (!raw) return;
const decoded = decodeEntities(raw);
if (!decoded) return;
pending_text += decoded;
};
const flushText = () => {
if (!pending_text) return;
if (!current) {
current = { type: "paragraph", segments: [] };
} else if (current.type == "divider") {
current = { type: "paragraph", segments: [] };
}
current.segments.push({
text: pending_text,
...(style.bold ? { bold: true } : {}),
...(style.italic ? { italic: true } : {}),
...(style.underline ? { underline: true } : {}),
});
pending_text = "";
};
const flushBlock = () => {
flushText();
if (current) {
blocks.push(current);
current = undefined;
}
};
const startBlock = (block: TextBlock) => {
flushBlock();
current = block;
};
const pattern = /<(\/?)([a-zA-Z0-9]+)([^>]*)>/g;
let last = 0;
let match;
while ((match = pattern.exec(source)) !== null) {
appendText(source.slice(last, match.index));
last = match.index + (match[0] || "").length;
const closing = Boolean(match[1]);
const tag = (match[2] || "").toLowerCase();
const attrs = match[3] || "";
if (INLINE_STYLE_OPEN.includes(tag) && !closing) {
if (["b", "strong"].includes(tag)) {
flushText();
style.bold = true;
}
if (["i", "em"].includes(tag)) {
flushText();
style.italic = true;
}
if (["u", "ins"].includes(tag)) {
flushText();
style.underline = true;
}
continue;
}
if (INLINE_STYLE_CLOSE.includes(tag) && closing) {
if (["b", "strong"].includes(tag)) {
flushText();
style.bold = false;
}
if (["i", "em"].includes(tag)) {
flushText();
style.italic = false;
}
if (["u", "ins"].includes(tag)) {
flushText();
style.underline = false;
}
continue;
}
if (["p", "div"].includes(tag)) {
if (closing) {
flushBlock();
} else {
startBlock({ type: "paragraph", segments: [] });
}
continue;
}
if (HEADING_LEVELS[tag]) {
if (closing) {
flushBlock();
} else {
startBlock({
type: "heading",
level: HEADING_LEVELS[tag],
segments: [],
});
}
continue;
}
if (tag == "br") {
flushBlock();
continue;
}
if (tag == "hr") {
flushBlock();
blocks.push({ type: "divider" });
continue;
}
if (["ul", "ol"].includes(tag)) {
if (closing) {
flushBlock();
list_stack.pop();
} else {
flushBlock();
list_stack.push({ ordered: tag == "ol", index: 0 });
}
continue;
}
if (tag == "li") {
if (closing) {
flushBlock();
} else {
const list = list_stack[list_stack.length - 1] || {
ordered: false,
index: 0,
};
startBlock({
type: "list-item",
ordered: list.ordered,
index: list.index,
segments: [],
});
list.index += 1;
}
continue;
}
if (tag == "blockquote") {
if (closing) {
flushBlock();
} else {
startBlock({ type: "blockquote", segments: [] });
}
continue;
}
if (tag == "img" && !closing) {
const alt = attrs.match(/alt\s*=\s*["']([^"']*)["']/i);
if (alt?.[1]) appendText(` ${alt[1]} `);
continue;
}
if (["td", "th", "p", "div", "li"].includes(tag) && !closing) {
appendText(" ");
continue;
}
if (["table", "thead", "tbody", "tr"].includes(tag)) {
if (!closing && ["tr"].includes(tag)) {
// no-op, cells add their own spacing
}
continue;
}
}
appendText(source.slice(last));
flushBlock();
return blocks;
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
import dayjs from "dayjs";
function ordinal(n: number): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 13) {
return "th";
}
switch (n % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
}
/**
* # Format a timestamp as a letter date, e.g. "22nd September, 2026".
*/
export function formatLetterDate(timestamp: number): string {
const date = dayjs(timestamp);
return `${date.date()}${ordinal(date.date())} ${date.format("MMMM, YYYY")}`;
}
/**
* # Format a timestamp as a short invoice date, e.g. "22 Sep 2026".
*/
export function formatInvoiceDate(
timestamp?: number | string | "",
): string {
if (!timestamp) {
return "\u2014";
}
return dayjs(Number(timestamp)).format("DD MMM YYYY");
}
+233
View File
@@ -0,0 +1,233 @@
import { PDFDocument } from "pdf-lib";
import {
CompanyLetterheadDefaults,
type CompanyLetterhead,
} from "@/src/dict/company-dict";
import {
PdfColors,
PdfMargin,
PdfPageHeight,
PdfPageWidth,
drawBrandFooterOnAllPages,
drawBrandHeader,
drawTextAt,
embedBrandLogo,
embedPdfFonts,
type PdfFonts,
} from "./pdf-helpers";
import htmlToTextBlocks from "./html-blocks";
import { formatLetterDate } from "./letter-date";
import {
ensureSpace,
renderBlocks,
type RenderContext,
} from "./render-blocks";
type TemplateOverview = {
title?: string;
to_name?: string;
to_address?: string;
from_name?: string;
from_address?: string;
salutation?: string;
body_html?: string;
closing?: string;
signature_name?: string;
signature_title?: string;
};
export type LetterPdfParams = {
template: TemplateOverview;
/** Overrides the template's default recipient at send-time. */
to_name?: string;
to_address?: string;
/** Unix timestamp in ms. Defaults to now. */
letter_date?: number;
company?: CompanyLetterhead;
};
const BODY_SIZE = 11;
const BODY_LINE_GAP = 15.5;
export default async function generateLetterPdf({
template,
to_name,
to_address,
letter_date,
company,
}: LetterPdfParams): Promise<Uint8Array> {
const pdfDoc = await PDFDocument.create();
const fonts = await embedPdfFonts(pdfDoc);
const logo = await embedBrandLogo(pdfDoc);
const company_block = {
...CompanyLetterheadDefaults,
...(company || {}),
};
let page = pdfDoc.addPage([PdfPageWidth, PdfPageHeight]);
const ctx: RenderContext = {
pdfDoc,
page,
fonts,
y: drawBrandHeader({
page,
fonts,
options: { logo, ...company_block },
}),
};
const from_name = template.from_name?.trim();
const from_address = template.from_address?.trim();
if (from_name || from_address) {
ensureSpace(ctx, 80);
ctx.y += 6;
drawTextAt(ctx.page, "From:", {
x: PdfMargin,
y_from_top: ctx.y,
size: 9,
font: fonts.bold,
color: PdfColors.darkGray,
});
ctx.y += 14;
if (from_name) {
drawTextAt(ctx.page, from_name, {
x: PdfMargin,
y_from_top: ctx.y,
size: 10.5,
font: fonts.bold,
color: PdfColors.black,
});
ctx.y += 13.5;
}
if (from_address) {
for (const line of from_address.split(/\r?\n/)) {
drawTextAt(ctx.page, line, {
x: PdfMargin,
y_from_top: ctx.y,
size: 10.5,
font: fonts.regular,
color: PdfColors.black,
});
ctx.y += 13;
}
}
ctx.y += 10;
}
const date_text = formatLetterDate(letter_date || Date.now());
drawTextAt(ctx.page, date_text, {
x: PdfPageWidth - PdfMargin,
y_from_top: ctx.y,
size: 10.5,
font: fonts.regular,
color: PdfColors.black,
align: "right",
});
ctx.y += 24;
const final_to_name = (to_name || template.to_name || "").trim();
const final_to_address = (to_address || template.to_address || "")
.trim();
if (final_to_name) {
drawTextAt(ctx.page, final_to_name, {
x: PdfMargin,
y_from_top: ctx.y,
size: 11,
font: fonts.bold,
color: PdfColors.black,
});
ctx.y += 14.5;
}
if (final_to_address) {
for (const line of final_to_address.split(/\r?\n/)) {
drawTextAt(ctx.page, line, {
x: PdfMargin,
y_from_top: ctx.y,
size: 10.5,
font: fonts.regular,
color: PdfColors.black,
});
ctx.y += 13;
}
}
ctx.y += 18;
const salutation = template.salutation?.trim();
if (salutation) {
drawTextAt(ctx.page, salutation, {
x: PdfMargin,
y_from_top: ctx.y,
size: BODY_SIZE,
font: fonts.regular,
color: PdfColors.black,
});
ctx.y += BODY_LINE_GAP + 6;
}
const blocks = htmlToTextBlocks(template.body_html);
if (blocks[0]) {
renderBlocks({ blocks, fonts, ctx });
}
const closing = template.closing?.trim();
const signature_name = template.signature_name?.trim();
const signature_title = template.signature_title?.trim();
if (closing || signature_name) {
ensureSpace(ctx, 120);
ctx.y += 28;
if (closing) {
drawTextAt(ctx.page, closing, {
x: PdfMargin,
y_from_top: ctx.y,
size: BODY_SIZE,
font: fonts.regular,
color: PdfColors.black,
});
ctx.y += 30;
}
if (signature_name) {
drawTextAt(ctx.page, signature_name, {
x: PdfMargin,
y_from_top: ctx.y,
size: 11.5,
font: fonts.bold,
color: PdfColors.black,
});
ctx.y += 15;
}
if (signature_title) {
drawTextAt(ctx.page, signature_title, {
x: PdfMargin,
y_from_top: ctx.y,
size: 9.5,
font: fonts.regular,
color: PdfColors.gray,
});
ctx.y += 13;
}
}
drawBrandFooterOnAllPages({
pdfDoc,
fonts,
options: { ...company_block },
});
return await pdfDoc.save();
}
File diff suppressed because it is too large Load Diff
+257
View File
@@ -0,0 +1,257 @@
import { PDFDocument, type PDFPage } from "pdf-lib";
import {
PdfColors,
PdfMargin,
PdfPageHeight,
PdfPageWidth,
drawBrandHeader,
drawStyledLine,
drawTextAt,
wrapStyledSegments,
type PdfColor,
type PdfFonts,
type StyledLine,
} from "./pdf-helpers";
import type { TextBlock } from "./html-blocks";
const FOOTER_SPACE = 140;
const BODY_AVAILABLE_WIDTH = PdfPageWidth - PdfMargin * 2;
export type RenderContext = {
pdfDoc: PDFDocument;
page: PDFPage;
y: number;
fonts: PdfFonts;
};
export type BlockRenderStyle = {
/** Body font size in pt. */
size: number;
/** Vertical gap between lines within a block. */
line_gap: number;
/** Body text color. */
color: PdfColor;
blockquote_size: number;
blockquote_indent: number;
/** Extra space after a paragraph/blockquote block. */
paragraph_after: number;
/** Space before a heading block. */
heading_level_gap: number;
/** Extra space after a heading block. */
heading_after: number;
/** Extra space after a list item. */
list_after: number;
divider_before: number;
divider_after: number;
};
const DefaultBlockStyle: BlockRenderStyle = {
size: 11,
line_gap: 12.5,
color: PdfColors.black,
blockquote_size: 10,
blockquote_indent: 26,
paragraph_after: 7,
heading_level_gap: 8,
heading_after: 6,
list_after: 4,
divider_before: 8,
divider_after: 14,
};
/**
* # Compact block style for tight content like invoice/quote notes.
* Mirrors the ACCOUNT DETAILS section: small font, subdued color,
* minimal margins.
*/
export const NotesBlockStyle: BlockRenderStyle = {
size: 9.5,
line_gap: 12.5,
color: PdfColors.darkGray,
blockquote_size: 9,
blockquote_indent: 16,
paragraph_after: 3,
heading_level_gap: 5,
heading_after: 4,
list_after: 2,
divider_before: 5,
divider_after: 8,
};
/**
* # Ensure `amount` points of vertical space remain on the current page.
* Starts a fresh page (with letterhead) when there isn't enough room.
*/
export function ensureSpace(ctx: RenderContext, amount: number) {
if (ctx.y + amount <= PdfPageHeight - FOOTER_SPACE) {
return;
}
const page = ctx.pdfDoc.addPage([PdfPageWidth, PdfPageHeight]);
ctx.page = page;
ctx.y = drawBrandHeader({ page, fonts: ctx.fonts });
}
/**
* # Render HTML-derived text blocks onto a PDF document.
* Shared by letter bodies and invoice/quote notes.
*/
export function renderBlocks({
blocks,
fonts,
ctx,
style = DefaultBlockStyle,
}: {
blocks: TextBlock[];
fonts: PdfFonts;
ctx: RenderContext;
style?: BlockRenderStyle;
}): RenderContext {
// Standard WinAnsi fonts cannot encode the ₦ glyph; PDFs use "NGN" instead.
for (const block of blocks) {
if (block.type == "divider") {
continue;
}
for (const segment of block.segments) {
segment.text = segment.text.replace(/\u20a6/g, "NGN ");
}
}
for (const block of blocks) {
switch (block.type) {
case "paragraph":
case "blockquote": {
if (block.segments.length == 0) {
ctx.y += style.line_gap;
continue;
}
const is_blockquote = block.type == "blockquote";
const indent = is_blockquote ? style.blockquote_indent : 0;
const size = is_blockquote ? style.blockquote_size : style.size;
const lines = wrapStyledSegments({
segments: block.segments,
fonts,
size,
max_width: BODY_AVAILABLE_WIDTH - indent,
});
for (const line of lines) {
ensureSpace(ctx, style.line_gap);
drawStyledLine(ctx.page, line, {
x: PdfMargin + indent,
y_from_top: ctx.y,
size,
color: style.color,
});
ctx.y += style.line_gap;
}
ctx.y += style.paragraph_after;
break;
}
case "heading": {
const size = style.size + (block.level <= 2 ? 3 : 1.5);
const lines = wrapStyledSegments({
segments: block.segments,
fonts,
size,
max_width: BODY_AVAILABLE_WIDTH,
});
ensureSpace(ctx, lines.length * (size + 8) + 24);
ctx.y += style.heading_level_gap;
for (const line of lines) {
ensureSpace(ctx, size + 7);
const bold_line: StyledLine = {
words: line.words.map((word) => ({
...word,
font:
word.font == fonts.italic
? fonts.boldItalic
: fonts.bold,
})),
width: line.width,
};
drawStyledLine(ctx.page, bold_line, {
x: PdfMargin,
y_from_top: ctx.y,
size,
color: style.color,
});
ctx.y += size + 7;
}
ctx.y += style.heading_after;
break;
}
case "list-item": {
const marker = block.ordered ? `${block.index + 1}.` : "\u2022";
const marker_text = `${marker} `;
const marker_width = fonts.regular.widthOfTextAtSize(
marker_text,
style.size,
);
const hang_indent = 18;
const indent = PdfMargin + hang_indent;
const lines = wrapStyledSegments({
segments: block.segments,
fonts,
size: style.size,
max_width:
BODY_AVAILABLE_WIDTH - hang_indent - marker_width,
});
for (const [line_index, line] of lines.entries()) {
ensureSpace(ctx, style.line_gap);
if (line_index == 0) {
drawTextAt(ctx.page, marker, {
x: PdfMargin,
y_from_top: ctx.y,
size: style.size,
font: fonts.regular,
color: style.color,
});
}
drawStyledLine(ctx.page, line, {
x: indent + (line_index == 0 ? marker_width : 0),
y_from_top: ctx.y,
size: style.size,
color: style.color,
});
ctx.y += style.line_gap;
}
ctx.y += style.list_after;
break;
}
case "divider": {
ensureSpace(ctx, 30);
ctx.y += style.divider_before;
ctx.page.drawRectangle({
x: PdfMargin,
y: ctx.page.getHeight() - ctx.y - 1,
width: BODY_AVAILABLE_WIDTH,
height: 1,
color: PdfColors.gray,
});
ctx.y += style.divider_after;
break;
}
}
}
return ctx;
}
@@ -0,0 +1,47 @@
import fs from "fs";
import path from "path";
import parseThemeColors from "@/src/utils/parse-theme-colors";
/**
* Site theme colors for server-rendered output (emails, PDFs) that cannot use
* Tailwind classes. Read from the `@theme inline` block in
* `src/styles/main.css`, so theme changes there flow through automatically.
*/
function grabCssThemeColors(): Record<string, string> {
try {
const css = fs.readFileSync(
path.join(process.cwd(), "src/styles/main.css"),
"utf-8",
);
return parseThemeColors({ css });
} catch {
return {};
}
}
const css_colors = grabCssThemeColors();
function themeColor({ name, fallback }: { name: string; fallback: string }) {
return css_colors[name] || fallback;
}
export const ThemeColors = {
/** Main text / ink */
ink: themeColor({ name: "primary", fallback: "#101828" }),
/** Brand emphasis (bars, highlights, accents) */
accent: themeColor({ name: "accent", fallback: "#5b5bd6" }),
/** Body copy */
body: themeColor({ name: "slate-700", fallback: "#344054" }),
/** Secondary/muted copy */
muted: themeColor({ name: "slate-500", fallback: "#667085" }),
/** Borders and dividers */
hairline: themeColor({ name: "slate-200", fallback: "#e4e7ec" }),
/** Table header / subtle fills */
subtle: themeColor({ name: "slate-100", fallback: "#f2f4f7" }),
/** Zebra rows / faint fills */
faint: themeColor({ name: "slate-50", fallback: "#f9fafb" }),
/** Page canvas */
page: themeColor({ name: "light", fallback: "#f7f8fa" }),
/** Cards / paper */
surface: themeColor({ name: "background-light", fallback: "#ffffff" }),
} as const;
@@ -0,0 +1,60 @@
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { ApiReqParams, ClientAdminCrudParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
export default async function adminCrudHandler<
T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
>(params: ClientAdminCrudParams<T>) {
const {
action,
table,
count,
insert_data,
sql_insert_params,
sql_query,
sql_select_params,
update_data,
update_on_duplicate,
id,
} = params;
let url = `/api/admin/crud/${table}`;
if (id) {
url += `/${id}`;
}
let query: ApiReqParams<T> = {
count,
insert_data,
sql_insert_params,
sql_query,
sql_select_params,
update_data,
update_on_duplicate,
...params.request_query,
...params.request_body,
};
const res = await fetchApi<ApiReqParams<T>, APIResponseObject<T>>(url, {
method:
action == "update"
? "PUT"
: action == "delete"
? "DELETE"
: action == "insert"
? "POST"
: "GET",
body: action == "get" ? undefined : query,
query: action == "get" ? query : undefined,
});
if (!res.debug) {
res.debug = {};
}
res.debug.url = url;
res.debug.query = query;
return res;
}
@@ -0,0 +1,26 @@
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { AdminFetchParams, ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
export default async function adminFetchHandler<
T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
>({ url: passed_url, method = "GET", query, body }: AdminFetchParams<T>) {
let url = passed_url;
const res = await fetchApi<ApiReqParams<T>, APIResponseObject<T>>(url, {
method,
body,
query,
});
if (!res.debug) {
res.debug = {};
}
res.debug.url = url;
res.debug.query = query;
res.debug.body = body;
return res;
}
@@ -0,0 +1,15 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
export default async function clientDeleteMediaHandler(params: ApiReqParams) {
const res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/admin/delete-media`,
{
method: "DELETE",
body: params,
},
);
return res;
}
@@ -0,0 +1,16 @@
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
export default async function clientUploadMediaHandler(params: ApiReqParams) {
const res = await fetchApi<
ApiReqParams,
APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA>
>(`/api/admin/upload-media`, {
method: "POST",
body: params,
});
return res;
}
+121
View File
@@ -0,0 +1,121 @@
/**
* # Client-side actions for document/invoice generation & emailing.
* PDF endpoints return binary PDFs (not JSON), so plain fetch is used
* here instead of `fetchApi`.
*/
async function postAdminAction({
url,
body,
}: {
url: string;
body: any;
}): Promise<{ success: boolean; msg?: string }> {
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body || {}),
});
const content_type = res.headers.get("Content-Type") || "";
if (content_type.includes("application/json")) {
const json = await res.json();
return {
success: Boolean(json.success),
msg: json.msg,
};
}
if (!res.ok) {
throw new Error(`Request failed with status ${res.status}`);
}
return {
success: true,
};
} catch (error: any) {
return {
success: false,
msg: error.message || "Request failed",
};
}
}
export async function downloadGeneratedPdf({
url,
body,
fallback_filename,
}: {
url: string;
body: any;
fallback_filename?: string;
}): Promise<{ success: boolean; msg?: string }> {
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body || {}),
});
const content_type = res.headers.get("Content-Type") || "";
if (content_type.includes("application/json")) {
const json = await res.json();
return {
success: Boolean(json.success),
msg: json.msg,
};
}
if (!res.ok) {
throw new Error(`Request failed with status ${res.status}`);
}
const blob = await res.blob();
if (blob.size == 0) {
throw new Error(`Received an empty PDF`);
}
const disposition = res.headers.get("Content-Disposition") || "";
const filename_match = disposition.match(/filename="?([^";]+)"?/i);
const filename =
filename_match?.[1] || fallback_filename || "document.pdf";
const object_url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = object_url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
setTimeout(() => {
URL.revokeObjectURL(object_url);
}, 1000);
return { success: true };
} catch (error: any) {
return {
success: false,
msg: error.message || "Couldn't download document",
};
}
}
export async function sendEmailRecord({
url,
body,
}: {
url: string;
body: any;
}) {
return await postAdminAction({ url, body });
}
@@ -0,0 +1,17 @@
import { AppData } from "@/src/data/app-data";
import type { SSOPendingLogin } from "@/src/types";
import EJSON from "@/src/utils/ejson";
export default function readSSOPendingLogin(): SSOPendingLogin | undefined {
try {
const stored = sessionStorage.getItem(
AppData["SSOPendingLoginStorageKey"],
);
return stored
? (EJSON.parse(stored) as SSOPendingLogin | undefined)
: undefined;
} catch {
return undefined;
}
}
@@ -0,0 +1,34 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type useFormInit from "@/src/hooks/use-form-init";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
type Params = Pick<ReturnType<typeof useFormInit>, "setLoading" | "setStatus">;
export default async function resendLoginCode({
setLoading,
setStatus,
}: Params) {
setLoading(true);
const res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/auth/resend-login-code`,
{
method: "POST",
body: {},
},
);
setStatus(
res.success
? {
success: true,
msg: "A new code is on its way. Previous codes no longer work.",
}
: {
error: true,
msg: res.msg || "Couldn't resend the code. Please try again.",
},
);
setLoading(false);
}
@@ -0,0 +1,16 @@
import { AppData } from "@/src/data/app-data";
import type { SSOPendingLogin } from "@/src/types";
import EJSON from "@/src/utils/ejson";
/**
* Remembers which login is waiting for a code, so the SSO page can word
* itself to match. Best effort only.
*/
export default function saveSSOPendingLogin(pending: SSOPendingLogin) {
try {
sessionStorage.setItem(
AppData["SSOPendingLoginStorageKey"],
EJSON.stringify(pending) || "",
);
} catch {}
}
@@ -0,0 +1,100 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_BSQLITE_MEDIA, BunSQLiteTables } from "@/db/types/db";
import grabDirNames from "@/src/utils/grab-dir-names";
import { existsSync } from "node:fs";
import path from "node:path";
import userAuth from "../../backend/auth/user-auth";
const { DATA_DIR } = grabDirNames();
type Params = {
req: Request;
};
export default async function handleMediaServer({
req,
}: Params): Promise<Response> {
const url = new URL(req.url);
const [root, privacy, media_id, thumbnail] = url.pathname
.split("/")
.filter((p) => p.match(/./));
if (!privacy) {
throw new Error(`No media privacy params passed`);
}
let media: BUN_SQLITE_BSQLITE_MEDIA | undefined = undefined;
if (privacy == "private") {
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status) {
throw new Error(`Not logged in`);
}
const media_res = await BunSQLite.select<
BUN_SQLITE_BSQLITE_MEDIA,
(typeof BunSQLiteTables)[number]
>({
table: "media",
query: {
query: {
user_id: {
value: user.id,
},
},
},
targetId: media_id,
});
media = media_res.singleRes || undefined;
} else {
const media_res = await BunSQLite.select<
BUN_SQLITE_BSQLITE_MEDIA,
(typeof BunSQLiteTables)[number]
>({
table: "media",
targetId: media_id,
});
media = media_res.singleRes || undefined;
}
if (!media?.id) {
throw new Error(`Media record not found!`);
}
const media_location = thumbnail
? media.media_thumbnail_write_path
: media.media_write_path;
if (!media_location) {
throw new Error(`No media location found!`);
}
const full_media_location = path.join(DATA_DIR, media_location);
if (!existsSync(full_media_location)) {
throw new Error(`Media does not exist!`);
}
const file = Bun.file(full_media_location);
const etag = `"${file.size}-${file.lastModified}"`;
const cache_control =
privacy == "private" ? "private, no-cache" : "public, no-cache";
const headers = new Headers({
ETag: etag,
"Cache-Control": cache_control,
});
if (req.headers.get("if-none-match") === etag) {
return new Response(null, {
status: 304,
headers,
});
}
return new Response(file, { headers });
}