First Commit
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import type { BUN_SQLITE_WGUI_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_WGUI_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,7 @@
|
||||
export default function generateCSRF() {
|
||||
return (
|
||||
Math.random().toString(36).substring(2) +
|
||||
"-" +
|
||||
Math.random().toString(36).substring(2)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_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_WGUI_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.msg,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,137 @@
|
||||
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_WGUI_MEDIA,
|
||||
BUN_SQLITE_WGUI_SSO_LOGIN_CODES,
|
||||
BUN_SQLITE_WGUI_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";
|
||||
|
||||
type Params = {
|
||||
user_id: string | number;
|
||||
};
|
||||
|
||||
export default async function loginUser({
|
||||
user_id,
|
||||
}: Params): Promise<Response> {
|
||||
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 Error(`Couldn't Find user with ID ${user_id}`);
|
||||
}
|
||||
|
||||
const target_user_types = (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_USER_TYPES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_types",
|
||||
query: { query: { user_id: { value: target_user.id } } },
|
||||
})
|
||||
).payload;
|
||||
|
||||
if (!target_user_types?.[0]) {
|
||||
throw new Error(`No User types found for this user.`);
|
||||
}
|
||||
|
||||
if (target_user_types.find((ty) => ty.user_type == "revoked")) {
|
||||
throw new Error(`User access Revoked`);
|
||||
}
|
||||
|
||||
let csrf_key = generateCSRF();
|
||||
|
||||
const profile_media = target_user.profile_media_id
|
||||
? (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_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,
|
||||
},
|
||||
]);
|
||||
|
||||
const delete_all_user_sso_codes = await BunSQLite.delete<
|
||||
BUN_SQLITE_WGUI_SSO_LOGIN_CODES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "sso_login_codes",
|
||||
query: {
|
||||
query: {
|
||||
user_id: {
|
||||
value: new_logged_in_user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`User #${new_logged_in_user.id} [${new_logged_in_user.first_name} ${new_logged_in_user.last_name}] logged in successfully.`,
|
||||
);
|
||||
console.log(
|
||||
`Date: ${Date()}\n==============================================================`,
|
||||
);
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { getCookie } from "@/src/utils/cookies";
|
||||
import { decrypt } from "@/src/utils/crypt";
|
||||
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!`);
|
||||
|
||||
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.msg,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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;
|
||||
iss?: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
sub?: string;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
clientId: string;
|
||||
idToken: string;
|
||||
};
|
||||
|
||||
type GoogleIDTokenUser = Required<Pick<GoogleIDTokenPayload, "email" | "sub">> &
|
||||
Pick<GoogleIDTokenPayload, "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 Error(`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 Error(`Google token was issued for a different app`);
|
||||
}
|
||||
|
||||
if (
|
||||
payload.iss !== "https://accounts.google.com" &&
|
||||
payload.iss !== "accounts.google.com"
|
||||
) {
|
||||
throw new Error(`Invalid Google token issuer`);
|
||||
}
|
||||
|
||||
const expiresAt = Number(payload.exp || 0) * 1000;
|
||||
|
||||
if (!expiresAt || expiresAt <= Date.now()) {
|
||||
throw new Error(`Google token has expired`);
|
||||
}
|
||||
|
||||
const emailVerified =
|
||||
payload.email_verified === true || payload.email_verified === "true";
|
||||
|
||||
if (!payload.email || !emailVerified || !payload.sub) {
|
||||
throw new Error(`Google account email is not verified`);
|
||||
}
|
||||
|
||||
return {
|
||||
email: payload.email,
|
||||
sub: payload.sub,
|
||||
name: payload.name,
|
||||
picture: payload.picture,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { BUN_SQLITE_WGUI_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/dist/types";
|
||||
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_WGUI_ALL_TYPEDEFS)[];
|
||||
/**
|
||||
* Fields to exclude from joins
|
||||
*/
|
||||
exclude_fields?: (keyof BUN_SQLITE_WGUI_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_WGUI_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,55 @@
|
||||
import type { BUN_SQLITE_WGUI_MEDIA_JOIN } from "@/src/types/sql-joins";
|
||||
import _ from "lodash";
|
||||
import grabUsers from "../users/grab-users";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
type Params = {
|
||||
media_res: APIResponseObject<BUN_SQLITE_WGUI_MEDIA_JOIN>;
|
||||
};
|
||||
|
||||
export default async function appendUsersToMedia({
|
||||
media_res,
|
||||
}: Params): Promise<APIResponseObject<BUN_SQLITE_WGUI_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,103 @@
|
||||
import _ from "lodash";
|
||||
import type { BUN_SQLITE_WGUI_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 BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { rmSync } from "fs";
|
||||
|
||||
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;
|
||||
|
||||
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_WGUI_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_WGUI_MEDIA>({
|
||||
table: "media",
|
||||
targetId: media_id,
|
||||
})
|
||||
).singleRes;
|
||||
|
||||
if (!target_media?.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target_media.media_write_path) {
|
||||
rmSync(target_media.media_write_path);
|
||||
}
|
||||
|
||||
if (target_media.media_thumbnail_write_path) {
|
||||
rmSync(target_media.media_thumbnail_write_path);
|
||||
}
|
||||
|
||||
await BunSQLite.delete<BUN_SQLITE_WGUI_MEDIA, TableType>({
|
||||
table: "media",
|
||||
targetId: target_media.id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { BUN_SQLITE_WGUI_MEDIA_PARADIGMS } from "@/db/types/db";
|
||||
import type { MediaParadigmType, TableType } from "@/src/types";
|
||||
import type { BUN_SQLITE_WGUI_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_WGUI_MEDIA_PARADIGMS_JOIN>> {
|
||||
try {
|
||||
const media_paradigms_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_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_WGUI_MEDIA } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { ApiReqParams, SQLInsertGenValueType, User } from "@/src/types";
|
||||
import type { BUN_SQLITE_WGUI_MEDIA_JOIN } from "@/src/types/sql-joins";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import _ from "lodash";
|
||||
import appendUsersToMedia from "./append-users-to-media";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
type Params = {
|
||||
query?: ApiReqParams;
|
||||
user?: User;
|
||||
};
|
||||
|
||||
export default async function grabMedia(
|
||||
params?: Params,
|
||||
): Promise<APIResponseObject<BUN_SQLITE_WGUI_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 SEPARATOR ',') 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_WGUI_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_WGUI_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,147 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { ApiReqParams, SQLInsertGenValueType } from "@/src/types";
|
||||
import type { BUN_SQLITE_WGUI_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_WGUI_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 u.archived <> 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_WGUI_USERS_JOIN>({
|
||||
sql: `${sql}\n${sql_limit}`,
|
||||
values,
|
||||
});
|
||||
|
||||
if (query?.count) {
|
||||
const count_sql = `SELECT COUNT(*) AS count FROM (${sql})`;
|
||||
// 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,82 @@
|
||||
import TurndownService from "turndown";
|
||||
import { extractText, getDocumentProxy } from "unpdf";
|
||||
import mammoth from "mammoth";
|
||||
import * as XLSX from "xlsx";
|
||||
import type { FileType } from "onnxruntime-node";
|
||||
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_type?: FileType;
|
||||
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,301 @@
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_MEDIA,
|
||||
BUN_SQLITE_WGUI_MEDIA_PARADIGMS,
|
||||
BUN_SQLITE_WGUI_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 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";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
type Params = {
|
||||
media_paradigms?: MediaParadigm[];
|
||||
media_type?: MediaType;
|
||||
user: BUN_SQLITE_WGUI_USERS | User;
|
||||
data?: MediaDataType;
|
||||
is_primary?: boolean;
|
||||
update_on_duplicate?: boolean;
|
||||
extract_media_text_content?: boolean;
|
||||
media?: BUN_SQLITE_WGUI_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_WGUI_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_WGUI_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_WGUI_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_WGUI_MEDIA | undefined = undefined;
|
||||
|
||||
if (existing_media_id) {
|
||||
const update_media_record = await BunSQLite.update<
|
||||
BUN_SQLITE_WGUI_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_WGUI_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_WGUI_MEDIA_PARADIGMS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "media_paradigms",
|
||||
query: {
|
||||
query: { media_id: { value: media_record.id } },
|
||||
},
|
||||
});
|
||||
|
||||
await BunSQLite.insert<
|
||||
BUN_SQLITE_WGUI_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,13 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
const EmailClient = nodemailer.createTransport({
|
||||
host: process.env.EMAIL_DOMAIN,
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
export default EmailClient;
|
||||
@@ -0,0 +1,55 @@
|
||||
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";
|
||||
|
||||
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 || "SBF 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,36 @@
|
||||
import type { BUN_SQLITE_WGUI_MEDIA } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_WGUI_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
|
||||
type Params = {
|
||||
media?: BUN_SQLITE_WGUI_MEDIA;
|
||||
user?: BUN_SQLITE_WGUI_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,60 @@
|
||||
import type { BUN_SQLITE_WGUI_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
|
||||
import type { ApiReqParams, ClientAdminCrudParams } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
export default async function adminCrudHandler<
|
||||
T extends {} = BUN_SQLITE_WGUI_ALL_TYPEDEFS,
|
||||
>(params: ClientAdminCrudParams<T>) {
|
||||
const {
|
||||
action,
|
||||
table,
|
||||
count,
|
||||
insert_data,
|
||||
sql_insert_params,
|
||||
sql_query,
|
||||
sql_select_params,
|
||||
update_data,
|
||||
update_on_duplicate,
|
||||
id,
|
||||
} = params;
|
||||
|
||||
let url = `/api/admin/crud/${table}`;
|
||||
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
let query: ApiReqParams<T> = {
|
||||
count,
|
||||
insert_data,
|
||||
sql_insert_params,
|
||||
sql_query,
|
||||
sql_select_params,
|
||||
update_data,
|
||||
update_on_duplicate,
|
||||
...params.request_query,
|
||||
...params.request_body,
|
||||
};
|
||||
|
||||
const res = await fetchApi<ApiReqParams<T>, APIResponseObject<T>>(url, {
|
||||
method:
|
||||
action == "update"
|
||||
? "PUT"
|
||||
: action == "delete"
|
||||
? "DELETE"
|
||||
: action == "insert"
|
||||
? "POST"
|
||||
: "GET",
|
||||
body: action == "get" ? undefined : query,
|
||||
query: action == "get" ? query : undefined,
|
||||
});
|
||||
|
||||
if (!res.debug) {
|
||||
res.debug = {};
|
||||
}
|
||||
res.debug.url = url;
|
||||
res.debug.query = query;
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { BUN_SQLITE_WGUI_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
|
||||
import type { AdminFetchParams, ApiReqParams } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
export default async function adminFetchHandler<
|
||||
T extends {} = BUN_SQLITE_WGUI_ALL_TYPEDEFS,
|
||||
>({ url: passed_url, method = "GET", query, body }: AdminFetchParams<T>) {
|
||||
let url = passed_url;
|
||||
|
||||
const res = await fetchApi<ApiReqParams<T>, APIResponseObject<T>>(url, {
|
||||
method,
|
||||
body,
|
||||
query,
|
||||
});
|
||||
|
||||
if (!res.debug) {
|
||||
res.debug = {};
|
||||
}
|
||||
|
||||
res.debug.url = url;
|
||||
res.debug.query = query;
|
||||
res.debug.body = body;
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
|
||||
import type { ApiReqParams } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
export default async function clientDeleteMediaHandler(params: ApiReqParams) {
|
||||
const res = await fetchApi<ApiReqParams, APIResponseObject>(
|
||||
`/api/admin/delete-media`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: params,
|
||||
},
|
||||
);
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { BUN_SQLITE_WGUI_MEDIA } from "@/db/types/db";
|
||||
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
|
||||
import type { ApiReqParams } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
export default async function clientUploadMediaHandler(params: ApiReqParams) {
|
||||
const res = await fetchApi<
|
||||
ApiReqParams,
|
||||
APIResponseObject<BUN_SQLITE_WGUI_MEDIA>
|
||||
>(`/api/admin/upload-media`, {
|
||||
method: "POST",
|
||||
body: params,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_WGUI_MEDIA, BunSQLiteTables } from "@/db/types/db";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import userAuth from "../../backend/auth/user-auth";
|
||||
|
||||
const { DATA_DIR } = grabDirNames();
|
||||
|
||||
type Params = {
|
||||
req: Request;
|
||||
};
|
||||
|
||||
export default async function handleMediaServer({
|
||||
req,
|
||||
}: Params): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
|
||||
const [root, privacy, media_id, thumbnail] = url.pathname
|
||||
.split("/")
|
||||
.filter((p) => p.match(/./));
|
||||
|
||||
if (!privacy) {
|
||||
throw new Error(`No media privacy params passed`);
|
||||
}
|
||||
|
||||
let media: BUN_SQLITE_WGUI_MEDIA | undefined = undefined;
|
||||
|
||||
if (privacy == "private") {
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status) {
|
||||
throw new Error(`Not logged in`);
|
||||
}
|
||||
|
||||
const media_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_MEDIA,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "media",
|
||||
query: {
|
||||
query: {
|
||||
user_id: {
|
||||
value: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
targetId: media_id,
|
||||
});
|
||||
|
||||
media = media_res.singleRes || undefined;
|
||||
} else {
|
||||
const media_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_MEDIA,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "media",
|
||||
targetId: media_id,
|
||||
});
|
||||
|
||||
media = media_res.singleRes || undefined;
|
||||
}
|
||||
|
||||
if (!media?.id) {
|
||||
throw new Error(`Media record not found!`);
|
||||
}
|
||||
|
||||
const media_location = thumbnail
|
||||
? media.media_thumbnail_write_path
|
||||
: media.media_write_path;
|
||||
|
||||
if (!media_location) {
|
||||
throw new Error(`No media location found!`);
|
||||
}
|
||||
|
||||
const full_media_location = path.join(DATA_DIR, media_location);
|
||||
|
||||
if (!existsSync(full_media_location)) {
|
||||
throw new Error(`Media does not exist!`);
|
||||
}
|
||||
|
||||
return new Response(Bun.file(full_media_location));
|
||||
}
|
||||
Reference in New Issue
Block a user