First Commit. Duplicate bunext-sqlite preset
This commit is contained in:
@@ -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_-]+$/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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 & charges — £500 > 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([]);
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
Reference in New Issue
Block a user