feat: (BREAKING!) migrate hono #19
					 23 changed files with 1042 additions and 1113 deletions
				
			
		| 
						 | 
				
			
			@ -17,7 +17,7 @@
 | 
			
		|||
    "typescript": "^5.4.3"
 | 
			
		||||
  },
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "zod": "^3.23.8"
 | 
			
		||||
  "peerDependencies": {
 | 
			
		||||
    "zod": "^4.1.12"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,12 @@
 | 
			
		|||
import { z } from "zod";
 | 
			
		||||
export { ZodError } from "zod";
 | 
			
		||||
 | 
			
		||||
export const PERMISSIONS = ["ModifyTag", "QueryContent", "ModifyTagDesc"] as const;
 | 
			
		||||
 | 
			
		||||
export const PermissionNameSchema = z.enum(PERMISSIONS);
 | 
			
		||||
 | 
			
		||||
export type PermissionName = z.infer<typeof PermissionNameSchema>;
 | 
			
		||||
 | 
			
		||||
export const DocumentBodySchema = z.object({
 | 
			
		||||
	title: z.string(),
 | 
			
		||||
	content_type: z.string(),
 | 
			
		||||
| 
						 | 
				
			
			@ -8,7 +14,7 @@ export const DocumentBodySchema = z.object({
 | 
			
		|||
	filename: z.string(),
 | 
			
		||||
	modified_at: z.number(),
 | 
			
		||||
	content_hash: z.string(),
 | 
			
		||||
	additional: z.record(z.unknown()),
 | 
			
		||||
	additional: z.record(z.string(), z.unknown()),
 | 
			
		||||
	tags: z.array(z.string()),
 | 
			
		||||
	pagenum: z.number().int(),
 | 
			
		||||
	gid: z.number().nullable(),
 | 
			
		||||
| 
						 | 
				
			
			@ -40,7 +46,7 @@ export type TagRelation = z.infer<typeof TagRelationSchema>;
 | 
			
		|||
 | 
			
		||||
export const PermissionSchema = z.object({
 | 
			
		||||
	username: z.string(),
 | 
			
		||||
	name: z.string(),
 | 
			
		||||
	name: PermissionNameSchema,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export type Permission = z.infer<typeof PermissionSchema>;
 | 
			
		||||
| 
						 | 
				
			
			@ -103,7 +109,7 @@ export const ServerPersistedSettingSchema = z.object({
 | 
			
		|||
	secure: z.boolean(),
 | 
			
		||||
	cli: z.boolean(),
 | 
			
		||||
	forbid_remote_admin_login: z.boolean(),
 | 
			
		||||
	guest: z.array(z.string()),
 | 
			
		||||
	guest: z.array(PermissionNameSchema),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export type ServerPersistedSetting = z.infer<typeof ServerPersistedSettingSchema>;
 | 
			
		||||
| 
						 | 
				
			
			@ -115,7 +121,7 @@ export const ServerSettingResponseSchema = z.object({
 | 
			
		|||
		mode: z.enum(["development", "production"]),
 | 
			
		||||
	}),
 | 
			
		||||
	persisted: ServerPersistedSettingSchema,
 | 
			
		||||
	permissions: z.array(z.string()),
 | 
			
		||||
	permissions: z.array(PermissionNameSchema),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export type ServerSettingResponse = z.infer<typeof ServerSettingResponseSchema>;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,21 +14,22 @@
 | 
			
		|||
	"author": "",
 | 
			
		||||
	"license": "ISC",
 | 
			
		||||
	"dependencies": {
 | 
			
		||||
		"@elysiajs/cors": "^1.3.3",
 | 
			
		||||
		"@elysiajs/html": "^1.3.1",
 | 
			
		||||
		"@elysiajs/node": "^1.4.1",
 | 
			
		||||
		"@elysiajs/openapi": "^1.4.11",
 | 
			
		||||
		"@hono/node-server": "^1.19.6",
 | 
			
		||||
		"@hono/standard-validator": "^0.1.5",
 | 
			
		||||
		"@hono/zod-openapi": "^1.1.4",
 | 
			
		||||
		"@hono/zod-validator": "^0.7.4",
 | 
			
		||||
		"@std/async": "npm:@jsr/std__async@^1.0.13",
 | 
			
		||||
		"@zip.js/zip.js": "^2.7.62",
 | 
			
		||||
		"better-sqlite3": "^9.6.0",
 | 
			
		||||
		"chokidar": "^3.6.0",
 | 
			
		||||
		"dbtype": "workspace:dbtype",
 | 
			
		||||
		"dotenv": "^16.5.0",
 | 
			
		||||
		"elysia": "^1.4.9",
 | 
			
		||||
		"hono": "^4.10.4",
 | 
			
		||||
		"jose": "^5.10.0",
 | 
			
		||||
		"kysely": "^0.27.6",
 | 
			
		||||
		"natural-orderby": "^2.0.3",
 | 
			
		||||
		"tiny-async-pool": "^1.3.0"
 | 
			
		||||
		"tiny-async-pool": "^1.3.0",
 | 
			
		||||
		"zod": "^4.1.12"
 | 
			
		||||
	},
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@types/better-sqlite3": "^7.6.13",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
import { existsSync, readFileSync } from "node:fs";
 | 
			
		||||
import type { Kysely } from "kysely";
 | 
			
		||||
import type { db } from "dbtype";
 | 
			
		||||
import { Permission } from "./permission/permission.ts";
 | 
			
		||||
import { type Permission, normalizePermissions } from "./permission/permission.ts";
 | 
			
		||||
import { getAppConfig, upsertAppConfig } from "./db/config.ts";
 | 
			
		||||
 | 
			
		||||
export interface SettingConfig {
 | 
			
		||||
| 
						 | 
				
			
			@ -130,14 +130,8 @@ const loadPersistedSetting = async (db: Kysely<db.DB>): Promise<PersistedSetting
 | 
			
		|||
};
 | 
			
		||||
 | 
			
		||||
const mergePersisted = (input: Partial<PersistedSetting>): PersistedSetting => {
 | 
			
		||||
	const validPermissions = new Set<Permission>(Object.values(Permission));
 | 
			
		||||
	const guest = Array.isArray(input.guest)
 | 
			
		||||
		? Array.from(
 | 
			
		||||
			new Set(
 | 
			
		||||
				input.guest.filter((value): value is Permission => validPermissions.has(value as Permission)),
 | 
			
		||||
			),
 | 
			
		||||
		)
 | 
			
		||||
		: persistedDefault.guest;
 | 
			
		||||
	const guestSource = Array.isArray(input.guest) ? input.guest : persistedDefault.guest;
 | 
			
		||||
	const guest = normalizePermissions(guestSource);
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		secure: input.secure ?? persistedDefault.secure,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,3 @@
 | 
			
		|||
import Elysia from "elysia";
 | 
			
		||||
import { connectDB } from "./database.ts";
 | 
			
		||||
import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
import { getKysely } from "./kysely.ts";
 | 
			
		||||
import { type IUser, IUserSettings, Password, type UserAccessor, type UserCreateInput } from "../model/user.ts";
 | 
			
		||||
import { normalizePermissions } from "../permission/permission.ts";
 | 
			
		||||
 | 
			
		||||
class SqliteUser implements IUser {
 | 
			
		||||
	readonly username: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -23,7 +24,7 @@ class SqliteUser implements IUser {
 | 
			
		|||
			.selectAll()
 | 
			
		||||
			.where("username", "=", this.username)
 | 
			
		||||
			.execute();
 | 
			
		||||
		return permissions.map((x) => x.name);
 | 
			
		||||
		return normalizePermissions(permissions.map((x) => x.name));
 | 
			
		||||
	}
 | 
			
		||||
	async add(name: string) {
 | 
			
		||||
		const result = await this.kysely
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,57 +1,63 @@
 | 
			
		|||
import { Elysia, t } from "elysia";
 | 
			
		||||
import { Hono } from "hono";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
import { sValidator } from "@hono/standard-validator";
 | 
			
		||||
import type { DiffManager } from "./diff.ts";
 | 
			
		||||
import type { ContentFile } from "../content/mod.ts";
 | 
			
		||||
import type { AppEnv } from "../login.ts";
 | 
			
		||||
import { AdminOnly } from "../permission/permission.ts";
 | 
			
		||||
import { sendError } from "../route/error_handler.ts";
 | 
			
		||||
 | 
			
		||||
const toSerializableContent = (file: ContentFile) => ({ path: file.path, type: file.type });
 | 
			
		||||
 | 
			
		||||
const CommitEntrySchema = t.Array(t.Object({
 | 
			
		||||
	type: t.String(),
 | 
			
		||||
	path: t.String(),
 | 
			
		||||
const commitEntrySchema = z.array(z.object({
 | 
			
		||||
	type: z.string(),
 | 
			
		||||
	path: z.string(),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
const CommitAllSchema = t.Object({
 | 
			
		||||
	type: t.String(),
 | 
			
		||||
const commitAllSchema = z.object({
 | 
			
		||||
	type: z.string(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const createDiffRouter = (diffmgr: DiffManager) =>
 | 
			
		||||
	new Elysia({ name: "diff-router" })
 | 
			
		||||
		.group("/diff", (app) =>
 | 
			
		||||
			app
 | 
			
		||||
				.get("/list", () => {
 | 
			
		||||
					return diffmgr.getAdded().map((entry) => ({
 | 
			
		||||
						type: entry.type,
 | 
			
		||||
						value: entry.value.map(toSerializableContent),
 | 
			
		||||
					}));
 | 
			
		||||
				}, {
 | 
			
		||||
					beforeHandle: AdminOnly,
 | 
			
		||||
				})
 | 
			
		||||
				.post("/commit", async ({ body }) => {
 | 
			
		||||
					if (body.length === 0) {
 | 
			
		||||
						return { ok: true, docs: [] as number[] };
 | 
			
		||||
					}
 | 
			
		||||
					const results = await Promise.all(body.map(({ type, path }) => diffmgr.commit(type, path)));
 | 
			
		||||
					return {
 | 
			
		||||
						ok: true,
 | 
			
		||||
						docs: results,
 | 
			
		||||
					};
 | 
			
		||||
				}, {
 | 
			
		||||
					beforeHandle: AdminOnly,
 | 
			
		||||
					body: CommitEntrySchema,
 | 
			
		||||
				})
 | 
			
		||||
				.post("/commitall", async ({ body }) => {
 | 
			
		||||
					const { type } = body;
 | 
			
		||||
					if (!type) {
 | 
			
		||||
						sendError(400, 'format exception: there is no "type"');
 | 
			
		||||
					}
 | 
			
		||||
					await diffmgr.commitAll(type);
 | 
			
		||||
					return { ok: true };
 | 
			
		||||
				}, {
 | 
			
		||||
					beforeHandle: AdminOnly,
 | 
			
		||||
					body: CommitAllSchema,
 | 
			
		||||
				})
 | 
			
		||||
				.get("/*", () => {
 | 
			
		||||
					sendError(404);
 | 
			
		||||
				})
 | 
			
		||||
			);
 | 
			
		||||
 | 
			
		||||
export const createDiffRouter = (diffmgr: DiffManager) => {
 | 
			
		||||
	const router = new Hono<AppEnv>();
 | 
			
		||||
 | 
			
		||||
	router.get("/list", AdminOnly, (c) =>
 | 
			
		||||
		c.json(
 | 
			
		||||
			diffmgr.getAdded().map((entry) => ({
 | 
			
		||||
				type: entry.type,
 | 
			
		||||
				value: entry.value.map(toSerializableContent),
 | 
			
		||||
			})),
 | 
			
		||||
		),
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	router.post(
 | 
			
		||||
		"/commit",
 | 
			
		||||
		AdminOnly,
 | 
			
		||||
		sValidator("json", commitEntrySchema),
 | 
			
		||||
		async (c) => {
 | 
			
		||||
			const entries = c.req.valid("json");
 | 
			
		||||
			if (entries.length === 0) {
 | 
			
		||||
				return c.json({ ok: true, docs: [] as number[] });
 | 
			
		||||
			}
 | 
			
		||||
			const results = await Promise.all(entries.map(({ type, path }) => diffmgr.commit(type, path)));
 | 
			
		||||
			return c.json({ ok: true, docs: results });
 | 
			
		||||
		},
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	router.post(
 | 
			
		||||
		"/commitall",
 | 
			
		||||
		AdminOnly,
 | 
			
		||||
		sValidator("json", commitAllSchema),
 | 
			
		||||
		async (c) => {
 | 
			
		||||
			const { type } = c.req.valid("json");
 | 
			
		||||
			if (!type) {
 | 
			
		||||
				sendError(400, 'format exception: there is no "type"');
 | 
			
		||||
			}
 | 
			
		||||
			await diffmgr.commitAll(type);
 | 
			
		||||
			return c.json({ ok: true });
 | 
			
		||||
		},
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	return router;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,24 +1,35 @@
 | 
			
		|||
import { Elysia, t, type Context } from "elysia";
 | 
			
		||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
 | 
			
		||||
import { Hono, type Context, type MiddlewareHandler } from "hono";
 | 
			
		||||
import { SignJWT, jwtVerify, errors } from "jose";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
import { sValidator } from "@hono/standard-validator";
 | 
			
		||||
import type { IUser, UserAccessor } from "./model/mod.ts";
 | 
			
		||||
import { ClientRequestError } from "./route/error_handler.ts";
 | 
			
		||||
import { get_setting } from "./SettingConfig.ts";
 | 
			
		||||
import { normalizePermissions } from "./permission/permission.ts";
 | 
			
		||||
import type { Permission } from "./permission/permission.ts";
 | 
			
		||||
 | 
			
		||||
type PayloadInfo = {
 | 
			
		||||
export type PayloadInfo = {
 | 
			
		||||
	username: string;
 | 
			
		||||
	permission: string[];
 | 
			
		||||
	permission: Permission[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type UserState = {
 | 
			
		||||
	user: PayloadInfo;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type AuthStore = {
 | 
			
		||||
export type AuthStore = {
 | 
			
		||||
	user: PayloadInfo;
 | 
			
		||||
	refreshed: boolean;
 | 
			
		||||
	authenticated: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type AppEnv = {
 | 
			
		||||
	Variables: {
 | 
			
		||||
		auth: AuthStore;
 | 
			
		||||
	};
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type LoginResponse = {
 | 
			
		||||
	accessExpired: number;
 | 
			
		||||
} & PayloadInfo;
 | 
			
		||||
| 
						 | 
				
			
			@ -30,20 +41,18 @@ type RefreshResponse = {
 | 
			
		|||
 | 
			
		||||
type RefreshPayloadInfo = { username: string };
 | 
			
		||||
 | 
			
		||||
type CookieJar = Context["cookie"];
 | 
			
		||||
 | 
			
		||||
const LoginBodySchema = t.Object({
 | 
			
		||||
	username: t.String(),
 | 
			
		||||
	password: t.String(),
 | 
			
		||||
const LoginBodySchema = z.object({
 | 
			
		||||
	username: z.string(),
 | 
			
		||||
	password: z.string(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const ResetBodySchema = t.Object({
 | 
			
		||||
	username: t.String(),
 | 
			
		||||
	oldpassword: t.String(),
 | 
			
		||||
	newpassword: t.String(),
 | 
			
		||||
const ResetBodySchema = z.object({
 | 
			
		||||
	username: z.string(),
 | 
			
		||||
	oldpassword: z.string(),
 | 
			
		||||
	newpassword: z.string(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const SettingsBodySchema = t.Record(t.String(), t.Unknown());
 | 
			
		||||
const SettingsBodySchema = z.record(z.string(), z.unknown());
 | 
			
		||||
 | 
			
		||||
const accessExpiredTime = 60 * 60 * 2 * 1000; // 2 hours
 | 
			
		||||
const refreshExpiredTime = 60 * 60 * 24 * 14 * 1000; // 14 days
 | 
			
		||||
| 
						 | 
				
			
			@ -83,30 +92,36 @@ async function verifyToken<T>(token: string, secret: string): Promise<T> {
 | 
			
		|||
export const accessTokenName = "access_token";
 | 
			
		||||
export const refreshTokenName = "refresh_token";
 | 
			
		||||
 | 
			
		||||
function setToken(cookie: CookieJar, token_name: string, token_payload: string | null, expiredMilliseconds: number) {
 | 
			
		||||
	if (token_payload === null) {
 | 
			
		||||
		cookie[token_name]?.remove();
 | 
			
		||||
const setToken = (c: Context<AppEnv>, tokenName: string, tokenPayload: string | null, expiresMs: number) => {
 | 
			
		||||
	const setting = get_setting();
 | 
			
		||||
	if (tokenPayload === null) {
 | 
			
		||||
		deleteCookie(c, tokenName, {
 | 
			
		||||
			path: "/",
 | 
			
		||||
			secure: setting.secure,
 | 
			
		||||
			sameSite: "Strict",
 | 
			
		||||
		});
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	const setting = get_setting();
 | 
			
		||||
	cookie[token_name].set({
 | 
			
		||||
		value: token_payload,
 | 
			
		||||
	setCookie(c, tokenName, tokenPayload, {
 | 
			
		||||
		path: "/",
 | 
			
		||||
		httpOnly: true,
 | 
			
		||||
		secure: setting.secure,
 | 
			
		||||
		sameSite: "strict",
 | 
			
		||||
		expires: new Date(Date.now() + expiredMilliseconds),
 | 
			
		||||
		sameSite: "Strict",
 | 
			
		||||
		expires: new Date(Date.now() + expiresMs),
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function removeToken(cookie: CookieJar, token_name: string) {
 | 
			
		||||
	cookie[token_name]?.remove();
 | 
			
		||||
}
 | 
			
		||||
type RawPayloadInfo = {
 | 
			
		||||
	username: string;
 | 
			
		||||
	permission: unknown[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const isUserState = (obj: unknown): obj is PayloadInfo => {
 | 
			
		||||
const isUserState = (obj: unknown): obj is RawPayloadInfo => {
 | 
			
		||||
	if (typeof obj !== "object" || obj === null) {
 | 
			
		||||
		return false;
 | 
			
		||||
	}
 | 
			
		||||
	return "username" in obj && "permission" in obj && Array.isArray((obj as { permission: unknown }).permission);
 | 
			
		||||
	const candidate = obj as { username?: unknown; permission?: unknown };
 | 
			
		||||
	return typeof candidate.username === "string" && Array.isArray(candidate.permission);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const isRefreshToken = (obj: unknown): obj is RefreshPayloadInfo => {
 | 
			
		||||
| 
						 | 
				
			
			@ -123,25 +138,23 @@ type AuthResult = {
 | 
			
		|||
};
 | 
			
		||||
 | 
			
		||||
async function authenticate(
 | 
			
		||||
	cookie: CookieJar,
 | 
			
		||||
	c: Context<AppEnv>,
 | 
			
		||||
	userController: UserAccessor,
 | 
			
		||||
	options: { forceRefresh?: boolean } = {},
 | 
			
		||||
): Promise<AuthResult> {
 | 
			
		||||
	const setting = get_setting();
 | 
			
		||||
	const secretKey = setting.jwt_secretkey;
 | 
			
		||||
	const accessCookie = cookie[accessTokenName];
 | 
			
		||||
	const refreshCookie = cookie[refreshTokenName];
 | 
			
		||||
	const accessValue = typeof accessCookie?.value === 'string' ? accessCookie.value : undefined;
 | 
			
		||||
	const refreshValue = typeof refreshCookie?.value === 'string' ? refreshCookie.value : undefined;
 | 
			
		||||
	const accessValue = getCookie(c, accessTokenName);
 | 
			
		||||
	const refreshValue = getCookie(c, refreshTokenName);
 | 
			
		||||
 | 
			
		||||
	const guestUser: PayloadInfo = {
 | 
			
		||||
		username: "",
 | 
			
		||||
		permission: setting.guest,
 | 
			
		||||
		permission: [...setting.guest],
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const setGuest = (): AuthResult => {
 | 
			
		||||
		accessCookie?.remove();
 | 
			
		||||
		refreshCookie?.remove();
 | 
			
		||||
		setToken(c, accessTokenName, null, 0);
 | 
			
		||||
		setToken(c, refreshTokenName, null, 0);
 | 
			
		||||
		return { user: guestUser, refreshed: false, success: false };
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -150,13 +163,13 @@ async function authenticate(
 | 
			
		|||
		if (!account) {
 | 
			
		||||
			return setGuest();
 | 
			
		||||
		}
 | 
			
		||||
		const permissions = await account.get_permissions();
 | 
			
		||||
		const permissions = normalizePermissions(await account.get_permissions());
 | 
			
		||||
		const payload: PayloadInfo = {
 | 
			
		||||
			username: account.username,
 | 
			
		||||
			permission: permissions,
 | 
			
		||||
		};
 | 
			
		||||
		const accessToken = await createAccessToken(payload, secretKey);
 | 
			
		||||
		setToken(cookie, accessTokenName, accessToken, accessExpiredTime);
 | 
			
		||||
		setToken(c, accessTokenName, accessToken, accessExpiredTime);
 | 
			
		||||
		return { user: payload, refreshed: true, success: true };
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -178,11 +191,15 @@ async function authenticate(
 | 
			
		|||
	if (options.forceRefresh) {
 | 
			
		||||
		if (accessValue) {
 | 
			
		||||
			try {
 | 
			
		||||
				const payload = await verifyToken<PayloadInfo>(accessValue, secretKey);
 | 
			
		||||
				const payload = await verifyToken<RawPayloadInfo>(accessValue, secretKey);
 | 
			
		||||
				if (isUserState(payload)) {
 | 
			
		||||
					const accessToken = await createAccessToken(payload, secretKey);
 | 
			
		||||
					setToken(cookie, accessTokenName, accessToken, accessExpiredTime);
 | 
			
		||||
					return { user: payload, refreshed: true, success: true };
 | 
			
		||||
					const normalized: PayloadInfo = {
 | 
			
		||||
						username: payload.username,
 | 
			
		||||
						permission: normalizePermissions(payload.permission),
 | 
			
		||||
					};
 | 
			
		||||
					const accessToken = await createAccessToken(normalized, secretKey);
 | 
			
		||||
					setToken(c, accessTokenName, accessToken, accessExpiredTime);
 | 
			
		||||
					return { user: normalized, refreshed: true, success: true };
 | 
			
		||||
				}
 | 
			
		||||
				return setGuest();
 | 
			
		||||
			} catch (error) {
 | 
			
		||||
| 
						 | 
				
			
			@ -196,9 +213,16 @@ async function authenticate(
 | 
			
		|||
 | 
			
		||||
	if (accessValue) {
 | 
			
		||||
		try {
 | 
			
		||||
			const payload = await verifyToken<PayloadInfo>(accessValue, secretKey);
 | 
			
		||||
			const payload = await verifyToken<RawPayloadInfo>(accessValue, secretKey);
 | 
			
		||||
			if (isUserState(payload)) {
 | 
			
		||||
				return { user: payload, refreshed: false, success: true };
 | 
			
		||||
				return {
 | 
			
		||||
					user: {
 | 
			
		||||
						username: payload.username,
 | 
			
		||||
						permission: normalizePermissions(payload.permission),
 | 
			
		||||
					},
 | 
			
		||||
					refreshed: false,
 | 
			
		||||
					success: true,
 | 
			
		||||
				};
 | 
			
		||||
			}
 | 
			
		||||
			return setGuest();
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
| 
						 | 
				
			
			@ -212,116 +236,120 @@ async function authenticate(
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
export const createLoginRouter = (userController: UserAccessor) => {
 | 
			
		||||
	return new Elysia({ name: "login-router" })
 | 
			
		||||
		.group("/user", (app) =>
 | 
			
		||||
			app
 | 
			
		||||
				.post("/login", async ({ body, cookie, set }) => {
 | 
			
		||||
					const setting = get_setting();
 | 
			
		||||
					const secretKey = setting.jwt_secretkey;
 | 
			
		||||
					const { username, password } = body;
 | 
			
		||||
	const router = new Hono<AppEnv>();
 | 
			
		||||
 | 
			
		||||
					if (username === "admin" && setting.forbid_remote_admin_login) {
 | 
			
		||||
						throw new ClientRequestError(403, "forbidden remote admin login");
 | 
			
		||||
					}
 | 
			
		||||
	router.post(
 | 
			
		||||
		"/login",
 | 
			
		||||
		sValidator("json", LoginBodySchema),
 | 
			
		||||
		async (c) => {
 | 
			
		||||
			const setting = get_setting();
 | 
			
		||||
			const secretKey = setting.jwt_secretkey;
 | 
			
		||||
			const { username, password } = c.req.valid("json");
 | 
			
		||||
 | 
			
		||||
					const user = await userController.findUser(username);
 | 
			
		||||
					if (!user || !user.password.check_password(password)) {
 | 
			
		||||
						throw new ClientRequestError(401, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
			if (username === "admin" && setting.forbid_remote_admin_login) {
 | 
			
		||||
				throw new ClientRequestError(403, "forbidden remote admin login");
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
					const permission = await user.get_permissions();
 | 
			
		||||
					const accessToken = await createAccessToken({ username: user.username, permission }, secretKey);
 | 
			
		||||
					const refreshToken = await createRefreshToken({ username: user.username }, secretKey);
 | 
			
		||||
			const user = await userController.findUser(username);
 | 
			
		||||
			if (!user || !user.password.check_password(password)) {
 | 
			
		||||
				throw new ClientRequestError(401, "not authorized");
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
					setToken(cookie, accessTokenName, accessToken, accessExpiredTime);
 | 
			
		||||
					setToken(cookie, refreshTokenName, refreshToken, refreshExpiredTime);
 | 
			
		||||
			const permission = normalizePermissions(await user.get_permissions());
 | 
			
		||||
			const accessToken = await createAccessToken({ username: user.username, permission }, secretKey);
 | 
			
		||||
			const refreshToken = await createRefreshToken({ username: user.username }, secretKey);
 | 
			
		||||
 | 
			
		||||
					set.status = 200;
 | 
			
		||||
			setToken(c, accessTokenName, accessToken, accessExpiredTime);
 | 
			
		||||
			setToken(c, refreshTokenName, refreshToken, refreshExpiredTime);
 | 
			
		||||
 | 
			
		||||
					return {
 | 
			
		||||
						username: user.username,
 | 
			
		||||
						permission,
 | 
			
		||||
						accessExpired: Math.floor(Date.now() ) + accessExpiredTime,
 | 
			
		||||
					} satisfies LoginResponse;
 | 
			
		||||
				}, {
 | 
			
		||||
					body: LoginBodySchema,
 | 
			
		||||
				})
 | 
			
		||||
				.post("/logout", ({ cookie, set }) => {
 | 
			
		||||
					const setting = get_setting();
 | 
			
		||||
					removeToken(cookie, accessTokenName);
 | 
			
		||||
					removeToken(cookie, refreshTokenName);
 | 
			
		||||
					set.status = 200;
 | 
			
		||||
					return {
 | 
			
		||||
						ok: true,
 | 
			
		||||
						username: "",
 | 
			
		||||
						permission: setting.guest,
 | 
			
		||||
					};
 | 
			
		||||
				})
 | 
			
		||||
				.post("/refresh", async ({ cookie }) => {
 | 
			
		||||
					const auth = await authenticate(cookie, userController, { forceRefresh: true });
 | 
			
		||||
					if (!auth.success) {
 | 
			
		||||
						throw new ClientRequestError(401, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
					return {
 | 
			
		||||
						...auth.user,
 | 
			
		||||
						refresh: true,
 | 
			
		||||
						accessExpired: Math.floor(Date.now()) + accessExpiredTime,
 | 
			
		||||
					} satisfies RefreshResponse;
 | 
			
		||||
				})
 | 
			
		||||
				.post("/reset", async ({ body }) => {
 | 
			
		||||
					const { username, oldpassword, newpassword } = body;
 | 
			
		||||
					const account = await userController.findUser(username);
 | 
			
		||||
					if (!account || !account.password.check_password(oldpassword)) {
 | 
			
		||||
						throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
					await account.reset_password(newpassword);
 | 
			
		||||
					return { ok: true };
 | 
			
		||||
				}, {
 | 
			
		||||
					body: ResetBodySchema,
 | 
			
		||||
				})
 | 
			
		||||
				.get("/settings", async ({ store }) => {
 | 
			
		||||
					const { user } = store as AuthStore;
 | 
			
		||||
					if (!user.username) {
 | 
			
		||||
						throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
					const account = await userController.findUser(user.username);
 | 
			
		||||
					if (!account) {
 | 
			
		||||
						throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
					return (await account.get_settings()) ?? {};
 | 
			
		||||
				})
 | 
			
		||||
				.post("/settings", async ({ body, store }) => {
 | 
			
		||||
					const { user } = store as AuthStore;
 | 
			
		||||
					if (!user.username) {
 | 
			
		||||
						throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
					const account = await userController.findUser(user.username);
 | 
			
		||||
					if (!account) {
 | 
			
		||||
						throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
					await account.set_settings(body as Record<string, unknown>);
 | 
			
		||||
					return { ok: true };
 | 
			
		||||
				}, {
 | 
			
		||||
					body: SettingsBodySchema,
 | 
			
		||||
				}),
 | 
			
		||||
		);
 | 
			
		||||
};
 | 
			
		||||
			return c.json({
 | 
			
		||||
				username: user.username,
 | 
			
		||||
				permission,
 | 
			
		||||
				accessExpired: Math.floor(Date.now()) + accessExpiredTime,
 | 
			
		||||
			} satisfies LoginResponse);
 | 
			
		||||
		},
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
export const createUserHandler = (userController: UserAccessor) => {
 | 
			
		||||
	return new Elysia({
 | 
			
		||||
		name: "user-handler",
 | 
			
		||||
		seed: "UserAccess",
 | 
			
		||||
	})
 | 
			
		||||
		.derive({ as: "scoped" }, async ({ cookie }) => {
 | 
			
		||||
			const auth = await authenticate(cookie, userController);
 | 
			
		||||
			return {
 | 
			
		||||
				user: auth.user,
 | 
			
		||||
				refreshed: auth.refreshed,
 | 
			
		||||
				authenticated: auth.success,
 | 
			
		||||
			};
 | 
			
		||||
	router.post("/logout", (c) => {
 | 
			
		||||
		const setting = get_setting();
 | 
			
		||||
		setToken(c, accessTokenName, null, 0);
 | 
			
		||||
		setToken(c, refreshTokenName, null, 0);
 | 
			
		||||
		return c.json({
 | 
			
		||||
			ok: true,
 | 
			
		||||
			username: "",
 | 
			
		||||
			permission: setting.guest,
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	router.post("/refresh", async (c) => {
 | 
			
		||||
		const auth = await authenticate(c, userController, { forceRefresh: true });
 | 
			
		||||
		if (!auth.success) {
 | 
			
		||||
			throw new ClientRequestError(401, "not authorized");
 | 
			
		||||
		}
 | 
			
		||||
		return c.json({
 | 
			
		||||
			...auth.user,
 | 
			
		||||
			refresh: true,
 | 
			
		||||
			accessExpired: Math.floor(Date.now()) + accessExpiredTime,
 | 
			
		||||
		} satisfies RefreshResponse);
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	router.post(
 | 
			
		||||
		"/reset",
 | 
			
		||||
		sValidator("json", ResetBodySchema),
 | 
			
		||||
		async (c) => {
 | 
			
		||||
			const { username, oldpassword, newpassword } = c.req.valid("json");
 | 
			
		||||
			const account = await userController.findUser(username);
 | 
			
		||||
			if (!account || !account.password.check_password(oldpassword)) {
 | 
			
		||||
				throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
			}
 | 
			
		||||
			await account.reset_password(newpassword);
 | 
			
		||||
			return c.json({ ok: true });
 | 
			
		||||
		},
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	router.get("/settings", async (c) => {
 | 
			
		||||
		const auth = c.get("auth");
 | 
			
		||||
		if (!auth.user.username) {
 | 
			
		||||
			throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
		}
 | 
			
		||||
		const account = await userController.findUser(auth.user.username);
 | 
			
		||||
		if (!account) {
 | 
			
		||||
			throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
		}
 | 
			
		||||
		return c.json((await account.get_settings()) ?? {});
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	router.post(
 | 
			
		||||
		"/settings",
 | 
			
		||||
		sValidator("json", SettingsBodySchema),
 | 
			
		||||
		async (c) => {
 | 
			
		||||
			const auth = c.get("auth");
 | 
			
		||||
			if (!auth.user.username) {
 | 
			
		||||
				throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
			}
 | 
			
		||||
			const account = await userController.findUser(auth.user.username);
 | 
			
		||||
			if (!account) {
 | 
			
		||||
				throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
			}
 | 
			
		||||
			await account.set_settings(c.req.valid("json"));
 | 
			
		||||
			return c.json({ ok: true });
 | 
			
		||||
		},
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	return router;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createUserHandler = (userController: UserAccessor): MiddlewareHandler<AppEnv> =>
 | 
			
		||||
	async (c, next) => {
 | 
			
		||||
		const auth = await authenticate(c, userController);
 | 
			
		||||
		c.set("auth", {
 | 
			
		||||
			user: auth.user,
 | 
			
		||||
			refreshed: auth.refreshed,
 | 
			
		||||
			authenticated: auth.success,
 | 
			
		||||
		});
 | 
			
		||||
		await next();
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
export const getAdmin = async (cntr: UserAccessor) => {
 | 
			
		||||
	const admin = await cntr.findUser("admin");
 | 
			
		||||
	if (admin === undefined) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { UserSetting } from "dbtype";
 | 
			
		||||
import type { Permission } from "../permission/permission.ts";
 | 
			
		||||
import { createHmac, randomBytes } from "node:crypto";
 | 
			
		||||
 | 
			
		||||
function hashForPassword(salt: string, password: string) {
 | 
			
		||||
| 
						 | 
				
			
			@ -50,7 +51,7 @@ export interface IUser {
 | 
			
		|||
	/**
 | 
			
		||||
	 * return user's permission list.
 | 
			
		||||
	 */
 | 
			
		||||
	get_permissions(): Promise<string[]>;
 | 
			
		||||
	get_permissions(): Promise<Permission[]>;
 | 
			
		||||
	/**
 | 
			
		||||
	 * add permission
 | 
			
		||||
	 * @param name permission name to add
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,68 +1,66 @@
 | 
			
		|||
import { PERMISSIONS as SHARED_PERMISSIONS, type PermissionName } from "dbtype";
 | 
			
		||||
import type { Context, MiddlewareHandler } from "hono";
 | 
			
		||||
import type { AppEnv, PayloadInfo } from "../login.ts";
 | 
			
		||||
import { sendError } from "../route/error_handler.ts";
 | 
			
		||||
import type { UserState } from "../login.ts";
 | 
			
		||||
 | 
			
		||||
export enum Permission {
 | 
			
		||||
	// ========
 | 
			
		||||
	// not implemented
 | 
			
		||||
	// admin only
 | 
			
		||||
	/** remove document */
 | 
			
		||||
	// removeContent = 'removeContent',
 | 
			
		||||
export const PERMISSIONS = SHARED_PERMISSIONS;
 | 
			
		||||
 | 
			
		||||
	/** upload document */
 | 
			
		||||
	// uploadContent = 'uploadContent',
 | 
			
		||||
export type Permission = PermissionName;
 | 
			
		||||
 | 
			
		||||
	/** modify document except base path, filename, content_hash. but admin can modify all. */
 | 
			
		||||
	// modifyContent = 'modifyContent',
 | 
			
		||||
export const PERMISSION = {
 | 
			
		||||
	ModifyTag: PERMISSIONS[0],
 | 
			
		||||
	QueryContent: PERMISSIONS[1],
 | 
			
		||||
	ModifyTagDesc: PERMISSIONS[2],
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
	/** add tag into document */
 | 
			
		||||
	// addTagContent = 'addTagContent',
 | 
			
		||||
	/** remove tag from document */
 | 
			
		||||
	// removeTagContent = 'removeTagContent',
 | 
			
		||||
	/** ModifyTagInDoc */
 | 
			
		||||
	ModifyTag = "ModifyTag",
 | 
			
		||||
const PERMISSION_SET = new Set<Permission>(PERMISSIONS);
 | 
			
		||||
 | 
			
		||||
	/** find documents with query */
 | 
			
		||||
	// findAllContent = 'findAllContent',
 | 
			
		||||
	/** find one document. */
 | 
			
		||||
	// findOneContent = 'findOneContent',
 | 
			
		||||
	/** view content*/
 | 
			
		||||
	// viewContent = 'viewContent',
 | 
			
		||||
	QueryContent = "QueryContent",
 | 
			
		||||
export const isPermission = (value: unknown): value is Permission =>
 | 
			
		||||
	typeof value === "string" && PERMISSION_SET.has(value as Permission);
 | 
			
		||||
 | 
			
		||||
	/** modify description about the one tag. */
 | 
			
		||||
	modifyTagDesc = "ModifyTagDesc",
 | 
			
		||||
}
 | 
			
		||||
export const normalizePermissions = (values?: Iterable<unknown>): Permission[] => {
 | 
			
		||||
	if (!values) {
 | 
			
		||||
		return [];
 | 
			
		||||
	}
 | 
			
		||||
	const normalized = new Set<Permission>();
 | 
			
		||||
	for (const value of values) {
 | 
			
		||||
		if (isPermission(value)) {
 | 
			
		||||
			normalized.add(value);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return Array.from(normalized);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type PermissionCheckContext = {
 | 
			
		||||
	user?: UserState["user"];
 | 
			
		||||
	store?: { user?: UserState["user"] };
 | 
			
		||||
} & Record<string, unknown>;
 | 
			
		||||
 | 
			
		||||
const resolveUser = (context: PermissionCheckContext): UserState["user"] => {
 | 
			
		||||
	const user = context.user ?? context.store?.user;
 | 
			
		||||
	if (!user) {
 | 
			
		||||
const resolveUser = (c: Context<AppEnv>): PayloadInfo => {
 | 
			
		||||
	const auth = c.get("auth");
 | 
			
		||||
	if (!auth?.user) {
 | 
			
		||||
		sendError(401, "you are guest. login needed.");
 | 
			
		||||
	}
 | 
			
		||||
	return user as UserState["user"];
 | 
			
		||||
	return auth.user;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createPermissionCheck = (...permissions: string[]) => (context: PermissionCheckContext) => {
 | 
			
		||||
    const user = resolveUser(context);
 | 
			
		||||
    if (user.username === "admin") {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
    const user_permission = user.permission;
 | 
			
		||||
    if (!permissions.every((p) => user_permission.includes(p))) {
 | 
			
		||||
        if (user.username === "") {
 | 
			
		||||
            throw sendError(401, "you are guest. login needed.");
 | 
			
		||||
        }
 | 
			
		||||
        throw sendError(403, "do not have permission");
 | 
			
		||||
    }
 | 
			
		||||
export const createPermissionCheck = (
 | 
			
		||||
	...permissions: Permission[]
 | 
			
		||||
): MiddlewareHandler<AppEnv> => async (c, next) => {
 | 
			
		||||
	const user = resolveUser(c);
 | 
			
		||||
	if (user.username === "admin") {
 | 
			
		||||
		await next();
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	const userPermission = user.permission;
 | 
			
		||||
	if (!permissions.every((p) => userPermission.includes(p))) {
 | 
			
		||||
		if (user.username === "") {
 | 
			
		||||
			throw sendError(401, "you are guest. login needed.");
 | 
			
		||||
		}
 | 
			
		||||
		throw sendError(403, "do not have permission");
 | 
			
		||||
	}
 | 
			
		||||
	await next();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const AdminOnly = (context: PermissionCheckContext) => {
 | 
			
		||||
	const user = resolveUser(context);
 | 
			
		||||
    if (user.username !== "admin") {
 | 
			
		||||
        throw sendError(403, "admin only");
 | 
			
		||||
    }
 | 
			
		||||
export const AdminOnly: MiddlewareHandler<AppEnv> = async (c, next) => {
 | 
			
		||||
	const user = resolveUser(c);
 | 
			
		||||
	if (user.username !== "admin") {
 | 
			
		||||
		throw sendError(403, "admin only");
 | 
			
		||||
	}
 | 
			
		||||
	await next();
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,3 @@
 | 
			
		|||
import type { Context as ElysiaContext } from "elysia";
 | 
			
		||||
import { Readable } from "node:stream";
 | 
			
		||||
import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap.ts";
 | 
			
		||||
import { Entry } from "@zip.js/zip.js";
 | 
			
		||||
| 
						 | 
				
			
			@ -10,21 +9,24 @@ const extensionToMime = (ext: string) => {
 | 
			
		|||
	return `image/${ext}`;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ResponseSet = Pick<ElysiaContext["set"], "status" | "headers">;
 | 
			
		||||
export type ResponseMeta = {
 | 
			
		||||
	status: number;
 | 
			
		||||
	headers: Record<string, string>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type RenderOptions = {
 | 
			
		||||
	path: string;
 | 
			
		||||
	page: number;
 | 
			
		||||
	reqHeaders: Headers;
 | 
			
		||||
	set: ResponseSet;
 | 
			
		||||
	set: ResponseMeta;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
async function setHeadersForEntry(entry: Entry, reqHeaders: Headers, set: ResponseSet) {
 | 
			
		||||
async function setHeadersForEntry(entry: Entry, reqHeaders: Headers, set: ResponseMeta) {
 | 
			
		||||
	const ext = entry.filename.split(".").pop()?.toLowerCase() ?? "jpeg";
 | 
			
		||||
 | 
			
		||||
	set.headers["content-type"] = extensionToMime(ext);
 | 
			
		||||
	if (typeof entry.uncompressedSize === "number") {
 | 
			
		||||
		set.headers["content-length"] = entry.uncompressedSize;
 | 
			
		||||
		set.headers["content-length"] = entry.uncompressedSize.toString();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const lastModified = entry.lastModDate ?? new Date();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,227 +1,316 @@
 | 
			
		|||
import { Elysia, t } from "elysia";
 | 
			
		||||
import { Hono } from "hono";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
import { sValidator } from "@hono/standard-validator";
 | 
			
		||||
import { join } from "node:path";
 | 
			
		||||
import type { Document, QueryListOption } from "dbtype";
 | 
			
		||||
import type { DocumentAccessor } from "../model/doc.ts";
 | 
			
		||||
import { AdminOnly, createPermissionCheck, Permission as Per } from "../permission/permission.ts";
 | 
			
		||||
import type { AppEnv } from "../login.ts";
 | 
			
		||||
import { AdminOnly, createPermissionCheck, PERMISSION as Per } from "../permission/permission.ts";
 | 
			
		||||
import { sendError } from "./error_handler.ts";
 | 
			
		||||
import { oshash } from "src/util/oshash.ts";
 | 
			
		||||
import { headComicPage, renderComicPage } from "./comic.ts";
 | 
			
		||||
import { headComicPage, renderComicPage, type ResponseMeta } from "./comic.ts";
 | 
			
		||||
import { DocumentBodySchema } from "dbtype";
 | 
			
		||||
 | 
			
		||||
const searchQuerySchema = z.object({
 | 
			
		||||
    limit: z.string().optional(),
 | 
			
		||||
    cursor: z.string().optional(),
 | 
			
		||||
    word: z.string().optional(),
 | 
			
		||||
    content_type: z.string().optional(),
 | 
			
		||||
    offset: z.string().optional(),
 | 
			
		||||
    use_offset: z.string().optional(),
 | 
			
		||||
    allow_tag: z.string().optional(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const gidQuerySchema = z.object({
 | 
			
		||||
    gid: z.string(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const idParamSchema = z.object({
 | 
			
		||||
    num: z.coerce.number().int().nonnegative(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const idAndTagParamSchema = z.object({
 | 
			
		||||
    num: z.coerce.number().int().nonnegative(),
 | 
			
		||||
    tag: z.string(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const idAndPageParamSchema = z.object({
 | 
			
		||||
    num: z.coerce.number().int().nonnegative(),
 | 
			
		||||
    page: z.coerce.number().int().nonnegative(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const updateBodySchema = DocumentBodySchema.partial();
 | 
			
		||||
 | 
			
		||||
const ensureFinite = <T extends number>(value: T, message: string) => {
 | 
			
		||||
    if (!Number.isFinite(value)) {
 | 
			
		||||
        throw sendError(400, message);
 | 
			
		||||
    }
 | 
			
		||||
    return value;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const buildResponse = (meta: ResponseMeta, body: BodyInit | null = null) =>
 | 
			
		||||
    new Response(body, { status: meta.status, headers: meta.headers });
 | 
			
		||||
 | 
			
		||||
export const getContentRouter = (controller: DocumentAccessor) => {
 | 
			
		||||
    return new Elysia({
 | 
			
		||||
        name: "content-router",
 | 
			
		||||
        prefix: "/doc",
 | 
			
		||||
    })
 | 
			
		||||
        .get("/search", async ({ query }) => {
 | 
			
		||||
            const limit = Math.min(Number(query.limit ?? 20), 100);
 | 
			
		||||
    const router = new Hono<AppEnv>();
 | 
			
		||||
 | 
			
		||||
    router.get(
 | 
			
		||||
        "/search",
 | 
			
		||||
        createPermissionCheck(Per.QueryContent),
 | 
			
		||||
        sValidator("query", searchQuerySchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const query = c.req.valid("query");
 | 
			
		||||
            const parsedLimit = Number(query.limit ?? 20);
 | 
			
		||||
            const limit = Math.min(Number.isFinite(parsedLimit) ? parsedLimit : 20, 100);
 | 
			
		||||
            const option: QueryListOption = {
 | 
			
		||||
                limit: limit,
 | 
			
		||||
                limit,
 | 
			
		||||
                allow_tag: query.allow_tag?.split(",") ?? [],
 | 
			
		||||
                word: query.word,
 | 
			
		||||
                cursor: query.cursor,
 | 
			
		||||
                cursor: query.cursor ? ensureFinite(Number(query.cursor), "invalid cursor") : undefined,
 | 
			
		||||
                eager_loading: true,
 | 
			
		||||
                offset: Number(query.offset),
 | 
			
		||||
                use_offset: query.use_offset === 'true',
 | 
			
		||||
                offset: query.offset ? ensureFinite(Number(query.offset), "invalid offset") : undefined,
 | 
			
		||||
                use_offset: query.use_offset === "true",
 | 
			
		||||
                content_type: query.content_type,
 | 
			
		||||
            };
 | 
			
		||||
            return await controller.findList(option);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
            query: t.Object({
 | 
			
		||||
                limit: t.Optional(t.String()),
 | 
			
		||||
                cursor: t.Optional(t.Number()),
 | 
			
		||||
                word: t.Optional(t.String()),
 | 
			
		||||
                content_type: t.Optional(t.String()),
 | 
			
		||||
                offset: t.Optional(t.Number()),
 | 
			
		||||
                use_offset: t.Optional(t.String()),
 | 
			
		||||
                allow_tag: t.Optional(t.String()),
 | 
			
		||||
            })
 | 
			
		||||
        })
 | 
			
		||||
        .get("/_gid", async ({ query }) => {
 | 
			
		||||
            const gid_list = query.gid.split(",").map(x => Number.parseInt(x));
 | 
			
		||||
            if (gid_list.some(x => Number.isNaN(x)) || gid_list.length > 100) {
 | 
			
		||||
            return c.json(await controller.findList(option));
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.get(
 | 
			
		||||
        "/_gid",
 | 
			
		||||
        createPermissionCheck(Per.QueryContent),
 | 
			
		||||
        sValidator("query", gidQuerySchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { gid } = c.req.valid("query");
 | 
			
		||||
            const gidList = gid.split(",").map((x) => Number.parseInt(x, 10));
 | 
			
		||||
            if (gidList.some((x) => Number.isNaN(x)) || gidList.length > 100) {
 | 
			
		||||
                throw sendError(400, "Invalid GID list");
 | 
			
		||||
            }
 | 
			
		||||
            return await controller.findByGidList(gid_list);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
            query: t.Object({ gid: t.String() })
 | 
			
		||||
        })
 | 
			
		||||
        .get("/:num", async ({ params: { num } }) => {
 | 
			
		||||
            return c.json(await controller.findByGidList(gidList));
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.get(
 | 
			
		||||
        "/:num",
 | 
			
		||||
        createPermissionCheck(Per.QueryContent),
 | 
			
		||||
        sValidator("param", idParamSchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { num } = c.req.valid("param");
 | 
			
		||||
            const document = await controller.findById(num, true);
 | 
			
		||||
            if (document === undefined) {
 | 
			
		||||
                throw sendError(404, "document does not exist.");
 | 
			
		||||
            }
 | 
			
		||||
            return document;
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
            params: t.Object({ num: t.Numeric() })
 | 
			
		||||
        })
 | 
			
		||||
        .post("/:num", async ({ params: { num }, body }) => {
 | 
			
		||||
            const content_desc: Partial<Document> & { id: number } = {
 | 
			
		||||
            return c.json(document);
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.post(
 | 
			
		||||
        "/:num",
 | 
			
		||||
        AdminOnly,
 | 
			
		||||
        sValidator("param", idParamSchema),
 | 
			
		||||
        sValidator("json", updateBodySchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { num } = c.req.valid("param");
 | 
			
		||||
            const body = c.req.valid("json");
 | 
			
		||||
            const contentDesc: Partial<Document> & { id: number } = {
 | 
			
		||||
                id: num,
 | 
			
		||||
                ...body,
 | 
			
		||||
                ...(body as Record<string, unknown>),
 | 
			
		||||
            };
 | 
			
		||||
            return await controller.update(content_desc);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: AdminOnly,
 | 
			
		||||
            params: t.Object({ num: t.Numeric() }),
 | 
			
		||||
            body: t.Object({}, { additionalProperties: true })
 | 
			
		||||
        })
 | 
			
		||||
        .delete("/:num", async ({ params: { num } }) => {
 | 
			
		||||
            return await controller.del(num);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: AdminOnly,
 | 
			
		||||
            params: t.Object({ num: t.Numeric() })
 | 
			
		||||
        })
 | 
			
		||||
        .get("/:num/similars", async ({ params: { num } }) => {
 | 
			
		||||
            return c.json(await controller.update(contentDesc));
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.delete(
 | 
			
		||||
        "/:num",
 | 
			
		||||
        AdminOnly,
 | 
			
		||||
        sValidator("param", idParamSchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { num } = c.req.valid("param");
 | 
			
		||||
            return c.json(await controller.del(num));
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.get(
 | 
			
		||||
        "/:num/similars",
 | 
			
		||||
        createPermissionCheck(Per.QueryContent),
 | 
			
		||||
        sValidator("param", idParamSchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { num } = c.req.valid("param");
 | 
			
		||||
            const doc = await controller.findById(num, true);
 | 
			
		||||
            if (doc === undefined) {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            return await controller.getSimilarDocument(doc);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
            params: t.Object({ num: t.Numeric() })
 | 
			
		||||
        })
 | 
			
		||||
        .get("/:num/tags", async ({ params: { num } }) => {
 | 
			
		||||
            return c.json(await controller.getSimilarDocument(doc));
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.get(
 | 
			
		||||
        "/:num/tags",
 | 
			
		||||
        createPermissionCheck(Per.QueryContent),
 | 
			
		||||
        sValidator("param", idParamSchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { num } = c.req.valid("param");
 | 
			
		||||
            const document = await controller.findById(num, true);
 | 
			
		||||
            if (document === undefined) {
 | 
			
		||||
                throw sendError(404, "document does not exist.");
 | 
			
		||||
            }
 | 
			
		||||
            return document.tags;
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
            params: t.Object({ num: t.Numeric() })
 | 
			
		||||
        })
 | 
			
		||||
        .post("/:num/tags/:tag", async ({ params: { num, tag } }) => {
 | 
			
		||||
            return c.json(document.tags);
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.post(
 | 
			
		||||
        "/:num/tags/:tag",
 | 
			
		||||
        createPermissionCheck(Per.ModifyTag),
 | 
			
		||||
        sValidator("param", idAndTagParamSchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { num, tag } = c.req.valid("param");
 | 
			
		||||
            const doc = await controller.findById(num);
 | 
			
		||||
            if (doc === undefined) {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            return await controller.addTag(doc, tag);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.ModifyTag),
 | 
			
		||||
            params: t.Object({ num: t.Numeric(), tag: t.String() })
 | 
			
		||||
        })
 | 
			
		||||
        .delete("/:num/tags/:tag", async ({ params: { num, tag } }) => {
 | 
			
		||||
            return c.json(await controller.addTag(doc, tag));
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.delete(
 | 
			
		||||
        "/:num/tags/:tag",
 | 
			
		||||
        createPermissionCheck(Per.ModifyTag),
 | 
			
		||||
        sValidator("param", idAndTagParamSchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { num, tag } = c.req.valid("param");
 | 
			
		||||
            const doc = await controller.findById(num);
 | 
			
		||||
            if (doc === undefined) {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            return await controller.delTag(doc, tag);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.ModifyTag),
 | 
			
		||||
            params: t.Object({ num: t.Numeric(), tag: t.String() })
 | 
			
		||||
        })
 | 
			
		||||
        .post("/:num/_rehash", async ({ params: { num } }) => {
 | 
			
		||||
            return c.json(await controller.delTag(doc, tag));
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.post(
 | 
			
		||||
        "/:num/_rehash",
 | 
			
		||||
        AdminOnly,
 | 
			
		||||
        sValidator("param", idParamSchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { num } = c.req.valid("param");
 | 
			
		||||
            const doc = await controller.findById(num);
 | 
			
		||||
            if (doc === undefined || doc.deleted_at !== null) {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            const filepath = join(doc.basepath, doc.filename);
 | 
			
		||||
            try {
 | 
			
		||||
                const new_hash = (await oshash(filepath)).toString();
 | 
			
		||||
                return await controller.update({ id: num, content_hash: new_hash });
 | 
			
		||||
            } catch (e) {
 | 
			
		||||
                if ((e as NodeJS.ErrnoException).code === "ENOENT") {
 | 
			
		||||
                const newHash = (await oshash(filepath)).toString();
 | 
			
		||||
                return c.json(await controller.update({ id: num, content_hash: newHash }));
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                if ((error as NodeJS.ErrnoException).code === "ENOENT") {
 | 
			
		||||
                    throw sendError(404, "file not found");
 | 
			
		||||
                }
 | 
			
		||||
                throw e;
 | 
			
		||||
                throw error;
 | 
			
		||||
            }
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: AdminOnly,
 | 
			
		||||
            params: t.Object({ num: t.Numeric() })
 | 
			
		||||
        })
 | 
			
		||||
        .post("/:num/_rescan", async ({ params: { num }, set }) => {
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.post(
 | 
			
		||||
        "/:num/_rescan",
 | 
			
		||||
        AdminOnly,
 | 
			
		||||
        sValidator("param", idParamSchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { num } = c.req.valid("param");
 | 
			
		||||
            const doc = await controller.findById(num, true);
 | 
			
		||||
            if (doc === undefined) {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            await controller.rescanDocument(doc);
 | 
			
		||||
            set.status = 204; // No Content
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: AdminOnly,
 | 
			
		||||
            params: t.Object({ num: t.Numeric() })
 | 
			
		||||
        })
 | 
			
		||||
        .group("/:num", (app) =>
 | 
			
		||||
            app
 | 
			
		||||
                .derive(async ({ params: { num } }) => {
 | 
			
		||||
                    const docId = typeof num === "number" ? num : Number.parseInt(String(num));
 | 
			
		||||
                    if (Number.isNaN(docId)) {
 | 
			
		||||
                        throw sendError(400, "invalid document id");
 | 
			
		||||
                    }
 | 
			
		||||
                    const document = await controller.findById(docId, true);
 | 
			
		||||
                    if (document === undefined) {
 | 
			
		||||
                        throw sendError(404, "document does not exist.");
 | 
			
		||||
                    }
 | 
			
		||||
                    return { document, docId };
 | 
			
		||||
                })
 | 
			
		||||
                .head("/comic/thumbnail", async ({ document, request, set }) => {
 | 
			
		||||
                    if (document.content_type !== "comic") {
 | 
			
		||||
                        throw sendError(404);
 | 
			
		||||
                    }
 | 
			
		||||
                    const path = join(document.basepath, document.filename);
 | 
			
		||||
                    await headComicPage({
 | 
			
		||||
                        path,
 | 
			
		||||
                        page: 0,
 | 
			
		||||
                        reqHeaders: request.headers,
 | 
			
		||||
                        set,
 | 
			
		||||
                    });
 | 
			
		||||
                }, {
 | 
			
		||||
                    beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
                    params: t.Object({ num: t.Numeric() }),
 | 
			
		||||
                })
 | 
			
		||||
                .get("/comic/thumbnail", async ({ document, request, set }) => {
 | 
			
		||||
                    if (document.content_type !== "comic") {
 | 
			
		||||
                        throw sendError(404);
 | 
			
		||||
                    }
 | 
			
		||||
                    const path = join(document.basepath, document.filename);
 | 
			
		||||
                    const body = await renderComicPage({
 | 
			
		||||
                        path,
 | 
			
		||||
                        page: 0,
 | 
			
		||||
                        reqHeaders: request.headers,
 | 
			
		||||
                        set,
 | 
			
		||||
                    });
 | 
			
		||||
                    return body ?? undefined;
 | 
			
		||||
                }, {
 | 
			
		||||
                    beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
                    params: t.Object({ num: t.Numeric() }),
 | 
			
		||||
                })
 | 
			
		||||
                .head("/comic/:page", async ({ document, params: { page }, request, set }) => {
 | 
			
		||||
                    if (document.content_type !== "comic") {
 | 
			
		||||
                        throw sendError(404);
 | 
			
		||||
                    }
 | 
			
		||||
                    const pageIndex = page;
 | 
			
		||||
                    const path = join(document.basepath, document.filename);
 | 
			
		||||
                    await headComicPage({
 | 
			
		||||
                        path,
 | 
			
		||||
                        page: pageIndex,
 | 
			
		||||
                        reqHeaders: request.headers,
 | 
			
		||||
                        set,
 | 
			
		||||
                    });
 | 
			
		||||
                }, {
 | 
			
		||||
                    beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
                    params: t.Object({ num: t.Numeric(), page: t.Numeric() }),
 | 
			
		||||
                })
 | 
			
		||||
                .get("/comic/:page", async ({ document, params: { page }, request, set }) => {
 | 
			
		||||
                    if (document.content_type !== "comic") {
 | 
			
		||||
                        throw sendError(404);
 | 
			
		||||
                    }
 | 
			
		||||
                    const pageIndex = page;
 | 
			
		||||
                    const path = join(document.basepath, document.filename);
 | 
			
		||||
                    const body = await renderComicPage({
 | 
			
		||||
                        path,
 | 
			
		||||
                        page: pageIndex,
 | 
			
		||||
                        reqHeaders: request.headers,
 | 
			
		||||
                        set,
 | 
			
		||||
                    });
 | 
			
		||||
                    return body ?? undefined;
 | 
			
		||||
                }, {
 | 
			
		||||
                    beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
                    params: t.Object({ num: t.Numeric(), page: t.Numeric() }),
 | 
			
		||||
                })
 | 
			
		||||
        );
 | 
			
		||||
            return c.body(null, 204);
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.on(
 | 
			
		||||
        "HEAD",
 | 
			
		||||
        "/:num/comic/thumbnail",
 | 
			
		||||
        createPermissionCheck(Per.QueryContent),
 | 
			
		||||
        sValidator("param", idParamSchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { num } = c.req.valid("param");
 | 
			
		||||
            const document = await controller.findById(num, true);
 | 
			
		||||
            if (document === undefined || document.content_type !== "comic") {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            const meta: ResponseMeta = { status: 200, headers: {} };
 | 
			
		||||
            await headComicPage({
 | 
			
		||||
                path: join(document.basepath, document.filename),
 | 
			
		||||
                page: 0,
 | 
			
		||||
                reqHeaders: c.req.raw.headers,
 | 
			
		||||
                set: meta,
 | 
			
		||||
            });
 | 
			
		||||
            return buildResponse(meta);
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.get(
 | 
			
		||||
        "/:num/comic/thumbnail",
 | 
			
		||||
        createPermissionCheck(Per.QueryContent),
 | 
			
		||||
        sValidator("param", idParamSchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { num } = c.req.valid("param");
 | 
			
		||||
            const document = await controller.findById(num, true);
 | 
			
		||||
            if (document === undefined || document.content_type !== "comic") {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            const meta: ResponseMeta = { status: 200, headers: {} };
 | 
			
		||||
            const body = await renderComicPage({
 | 
			
		||||
                path: join(document.basepath, document.filename),
 | 
			
		||||
                page: 0,
 | 
			
		||||
                reqHeaders: c.req.raw.headers,
 | 
			
		||||
                set: meta,
 | 
			
		||||
            });
 | 
			
		||||
            return buildResponse(meta, body ?? null);
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.on(
 | 
			
		||||
        "HEAD",
 | 
			
		||||
        "/:num/comic/:page",
 | 
			
		||||
        createPermissionCheck(Per.QueryContent),
 | 
			
		||||
        sValidator("param", idAndPageParamSchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { num, page } = c.req.valid("param");
 | 
			
		||||
            const document = await controller.findById(num, true);
 | 
			
		||||
            if (document === undefined || document.content_type !== "comic") {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            const meta: ResponseMeta = { status: 200, headers: {} };
 | 
			
		||||
            await headComicPage({
 | 
			
		||||
                path: join(document.basepath, document.filename),
 | 
			
		||||
                page,
 | 
			
		||||
                reqHeaders: c.req.raw.headers,
 | 
			
		||||
                set: meta,
 | 
			
		||||
            });
 | 
			
		||||
            return buildResponse(meta);
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    router.get(
 | 
			
		||||
        "/:num/comic/:page",
 | 
			
		||||
        createPermissionCheck(Per.QueryContent),
 | 
			
		||||
        sValidator("param", idAndPageParamSchema),
 | 
			
		||||
        async (c) => {
 | 
			
		||||
            const { num, page } = c.req.valid("param");
 | 
			
		||||
            const document = await controller.findById(num, true);
 | 
			
		||||
            if (document === undefined || document.content_type !== "comic") {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            const meta: ResponseMeta = { status: 200, headers: {} };
 | 
			
		||||
            const body = await renderComicPage({
 | 
			
		||||
                path: join(document.basepath, document.filename),
 | 
			
		||||
                page,
 | 
			
		||||
                reqHeaders: c.req.raw.headers,
 | 
			
		||||
                set: meta,
 | 
			
		||||
            });
 | 
			
		||||
            return buildResponse(meta, body ?? null);
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return router;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default getContentRouter;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import { ZodError } from "dbtype";
 | 
			
		||||
import { ZodError } from "zod";
 | 
			
		||||
 | 
			
		||||
export interface ErrorFormat {
 | 
			
		||||
	code: number;
 | 
			
		||||
| 
						 | 
				
			
			@ -21,30 +21,36 @@ const code_to_message_table: { [key: number]: string | undefined } = {
 | 
			
		|||
	404: "NotFound",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const error_handler = ({ code, error, set }: { code: string, error: Error, set: { status?: number | string } }) => {
 | 
			
		||||
export const mapErrorToResponse = (error: Error): { status: number; body: ErrorFormat } => {
 | 
			
		||||
    if (error instanceof ClientRequestError) {
 | 
			
		||||
        set.status = error.code;
 | 
			
		||||
        return {
 | 
			
		||||
            code: error.code,
 | 
			
		||||
            message: code_to_message_table[error.code] ?? "",
 | 
			
		||||
            detail: error.message,
 | 
			
		||||
        } satisfies ErrorFormat;
 | 
			
		||||
            status: error.code,
 | 
			
		||||
            body: {
 | 
			
		||||
                code: error.code,
 | 
			
		||||
                message: code_to_message_table[error.code] ?? "",
 | 
			
		||||
                detail: error.message,
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
    if (error instanceof ZodError) {
 | 
			
		||||
        set.status = 400;
 | 
			
		||||
        return {
 | 
			
		||||
            code: 400,
 | 
			
		||||
            message: "BadRequest",
 | 
			
		||||
            detail: error.errors.map((x) => x.message).join(", "),
 | 
			
		||||
        } satisfies ErrorFormat;
 | 
			
		||||
            status: 400,
 | 
			
		||||
            body: {
 | 
			
		||||
                code: 400,
 | 
			
		||||
                message: "BadRequest",
 | 
			
		||||
                detail: error.issues.map((issue) => issue.message).join(", "),
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    set.status = 500;
 | 
			
		||||
    return {
 | 
			
		||||
        code: 500,
 | 
			
		||||
        message: "Internal Server Error",
 | 
			
		||||
        detail: error.message,
 | 
			
		||||
    }
 | 
			
		||||
        status: 500,
 | 
			
		||||
        body: {
 | 
			
		||||
            code: 500,
 | 
			
		||||
            message: "Internal Server Error",
 | 
			
		||||
            detail: error.message,
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const sendError = (code: number, message?: string): never => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,19 +1,23 @@
 | 
			
		|||
import { Elysia, t, type Static } from "elysia";
 | 
			
		||||
import { Hono } from "hono";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
import { sValidator } from "@hono/standard-validator";
 | 
			
		||||
import type { Kysely } from "kysely";
 | 
			
		||||
import type { db } from "dbtype";
 | 
			
		||||
import { AdminOnly, Permission } from "../permission/permission.ts";
 | 
			
		||||
import type { AppEnv } from "../login.ts";
 | 
			
		||||
import { AdminOnly, PERMISSIONS } from "../permission/permission.ts";
 | 
			
		||||
import type { Permission } from "../permission/permission.ts";
 | 
			
		||||
import { get_setting, updatePersistedSetting, type PersistedSettingUpdate } from "../SettingConfig.ts";
 | 
			
		||||
 | 
			
		||||
const permissionOptions = Object.values(Permission).sort() as string[];
 | 
			
		||||
const permissionOptions = [...PERMISSIONS].sort() as Permission[];
 | 
			
		||||
 | 
			
		||||
const updateBodySchema = t.Object({
 | 
			
		||||
	secure: t.Optional(t.Boolean()),
 | 
			
		||||
	cli: t.Optional(t.Boolean()),
 | 
			
		||||
	forbid_remote_admin_login: t.Optional(t.Boolean()),
 | 
			
		||||
	guest: t.Optional(t.Array(t.Enum(Permission))),
 | 
			
		||||
const updateBodySchema = z.object({
 | 
			
		||||
	secure: z.boolean().optional(),
 | 
			
		||||
	cli: z.boolean().optional(),
 | 
			
		||||
	forbid_remote_admin_login: z.boolean().optional(),
 | 
			
		||||
	guest: z.array(z.enum(PERMISSIONS)).optional(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
type UpdateBody = Static<typeof updateBodySchema>;
 | 
			
		||||
type UpdateBody = z.infer<typeof updateBodySchema>;
 | 
			
		||||
 | 
			
		||||
type SettingResponse = {
 | 
			
		||||
	env: {
 | 
			
		||||
| 
						 | 
				
			
			@ -25,9 +29,9 @@ type SettingResponse = {
 | 
			
		|||
		secure: boolean;
 | 
			
		||||
		cli: boolean;
 | 
			
		||||
		forbid_remote_admin_login: boolean;
 | 
			
		||||
		guest: string[];
 | 
			
		||||
		guest: Permission[];
 | 
			
		||||
	};
 | 
			
		||||
	permissions: string[];
 | 
			
		||||
	permissions: Permission[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const buildResponse = (): SettingResponse => {
 | 
			
		||||
| 
						 | 
				
			
			@ -48,14 +52,17 @@ const buildResponse = (): SettingResponse => {
 | 
			
		|||
	};
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createSettingsRouter = (db: Kysely<db.DB>) =>
 | 
			
		||||
	new Elysia({ name: "settings-router" })
 | 
			
		||||
		.get("/settings", () => {
 | 
			
		||||
			return buildResponse()}, {
 | 
			
		||||
			beforeHandle: AdminOnly,
 | 
			
		||||
		})
 | 
			
		||||
		.patch("/settings", async ({ body }) => {
 | 
			
		||||
			const payload = body as UpdateBody;
 | 
			
		||||
export const createSettingsRouter = (db: Kysely<db.DB>) => {
 | 
			
		||||
	const router = new Hono<AppEnv>();
 | 
			
		||||
 | 
			
		||||
	router.get("/settings", AdminOnly, (c) => c.json(buildResponse()));
 | 
			
		||||
 | 
			
		||||
	router.patch(
 | 
			
		||||
		"/settings",
 | 
			
		||||
		AdminOnly,
 | 
			
		||||
		sValidator("json", updateBodySchema),
 | 
			
		||||
		async (c) => {
 | 
			
		||||
			const payload = c.req.valid("json") as UpdateBody;
 | 
			
		||||
			const update: PersistedSettingUpdate = {
 | 
			
		||||
				secure: payload.secure,
 | 
			
		||||
				cli: payload.cli,
 | 
			
		||||
| 
						 | 
				
			
			@ -63,10 +70,11 @@ export const createSettingsRouter = (db: Kysely<db.DB>) =>
 | 
			
		|||
				guest: payload.guest,
 | 
			
		||||
			};
 | 
			
		||||
			await updatePersistedSetting(db, update);
 | 
			
		||||
			return buildResponse();
 | 
			
		||||
		}, {
 | 
			
		||||
			beforeHandle: AdminOnly,
 | 
			
		||||
			body: updateBodySchema,
 | 
			
		||||
		});
 | 
			
		||||
			return c.json(buildResponse());
 | 
			
		||||
		},
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	return router;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default createSettingsRouter;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,33 +1,48 @@
 | 
			
		|||
import { Elysia, t } from "elysia";
 | 
			
		||||
import { Hono } from "hono";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
import { sValidator } from "@hono/standard-validator";
 | 
			
		||||
import type { TagAccessor } from "../model/tag.ts";
 | 
			
		||||
import { createPermissionCheck, Permission } from "../permission/permission.ts";
 | 
			
		||||
import type { AppEnv } from "../login.ts";
 | 
			
		||||
import { createPermissionCheck, PERMISSION } from "../permission/permission.ts";
 | 
			
		||||
import { sendError } from "./error_handler.ts";
 | 
			
		||||
 | 
			
		||||
const tagQuerySchema = z.object({
 | 
			
		||||
	withCount: z.string().optional(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const tagParamSchema = z.object({
 | 
			
		||||
	tag_name: z.string(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export function getTagRounter(tagController: TagAccessor) {
 | 
			
		||||
	return new Elysia({ name: "tags-router",
 | 
			
		||||
		prefix: "/tags",
 | 
			
		||||
	 })
 | 
			
		||||
		.get("/", async ({ query }) => {
 | 
			
		||||
			if (query.withCount !== undefined) {
 | 
			
		||||
				return await tagController.getAllTagCount();
 | 
			
		||||
	const router = new Hono<AppEnv>();
 | 
			
		||||
 | 
			
		||||
	router.get(
 | 
			
		||||
		"/",
 | 
			
		||||
		createPermissionCheck(PERMISSION.QueryContent),
 | 
			
		||||
		sValidator("query", tagQuerySchema),
 | 
			
		||||
		async (c) => {
 | 
			
		||||
			const { withCount } = c.req.valid("query");
 | 
			
		||||
			if (withCount !== undefined) {
 | 
			
		||||
				return c.json(await tagController.getAllTagCount());
 | 
			
		||||
			}
 | 
			
		||||
			return await tagController.getAllTagList();
 | 
			
		||||
		}, {
 | 
			
		||||
			beforeHandle: createPermissionCheck(Permission.QueryContent),
 | 
			
		||||
			query: t.Object({
 | 
			
		||||
				withCount: t.Optional(t.String()),
 | 
			
		||||
			})
 | 
			
		||||
		})
 | 
			
		||||
		.get("/:tag_name", async ({ params: { tag_name } }) => {
 | 
			
		||||
			return c.json(await tagController.getAllTagList());
 | 
			
		||||
		},
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	router.get(
 | 
			
		||||
		"/:tag_name",
 | 
			
		||||
		createPermissionCheck(PERMISSION.QueryContent),
 | 
			
		||||
		sValidator("param", tagParamSchema),
 | 
			
		||||
		async (c) => {
 | 
			
		||||
			const { tag_name } = c.req.valid("param");
 | 
			
		||||
			const tag = await tagController.getTagByName(tag_name);
 | 
			
		||||
			if (!tag) {
 | 
			
		||||
				sendError(404, "tags not found");
 | 
			
		||||
			}
 | 
			
		||||
			return tag;
 | 
			
		||||
		}, {
 | 
			
		||||
			beforeHandle: createPermissionCheck(Permission.QueryContent),
 | 
			
		||||
			params: t.Object({
 | 
			
		||||
				tag_name: t.String(),
 | 
			
		||||
			})
 | 
			
		||||
		});
 | 
			
		||||
			return c.json(tag);
 | 
			
		||||
		},
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	return router;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,158 +1,161 @@
 | 
			
		|||
import { Elysia, t } from "elysia";
 | 
			
		||||
import { cors } from "@elysiajs/cors";
 | 
			
		||||
import { staticPlugin } from "./util/static.ts";
 | 
			
		||||
import { html } from "@elysiajs/html";
 | 
			
		||||
import { Hono } from "hono";
 | 
			
		||||
import { cors } from "hono/cors";
 | 
			
		||||
import { serve } from "@hono/node-server";
 | 
			
		||||
import { readFileSync } from "node:fs";
 | 
			
		||||
import { createInterface as createReadlineInterface } from "node:readline";
 | 
			
		||||
import { config } from "dotenv";
 | 
			
		||||
 | 
			
		||||
import { connectDB } from "./database.ts";
 | 
			
		||||
import { createDiffRouter, DiffManager } from "./diff/mod.ts";
 | 
			
		||||
import { get_setting, initializeSetting } from "./SettingConfig.ts";
 | 
			
		||||
 | 
			
		||||
import { readFileSync } from "node:fs";
 | 
			
		||||
import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts";
 | 
			
		||||
import { createLoginRouter, createUserHandler, getAdmin, isAdminFirst } from "./login.ts";
 | 
			
		||||
import { createLoginRouter, createUserHandler, getAdmin, isAdminFirst, type AppEnv } from "./login.ts";
 | 
			
		||||
import getContentRouter from "./route/contents.ts";
 | 
			
		||||
import { error_handler } from "./route/error_handler.ts";
 | 
			
		||||
import { mapErrorToResponse } from "./route/error_handler.ts";
 | 
			
		||||
import { createSettingsRouter } from "./route/settings.ts";
 | 
			
		||||
 | 
			
		||||
import { createInterface as createReadlineInterface } from "node:readline";
 | 
			
		||||
import { createComicWatcher } from "./diff/watcher/comic_watcher.ts";
 | 
			
		||||
import { loadComicConfig } from "./diff/watcher/ComicConfig.ts";
 | 
			
		||||
import type { DocumentAccessor, TagAccessor, UserAccessor } from "./model/mod.ts";
 | 
			
		||||
import { getTagRounter } from "./route/tags.ts";
 | 
			
		||||
import { node } from "@elysiajs/node";
 | 
			
		||||
import { openapi } from "@elysiajs/openapi";
 | 
			
		||||
import { createStaticRouter } from "./util/static.ts";
 | 
			
		||||
 | 
			
		||||
import { config } from "dotenv";
 | 
			
		||||
config();
 | 
			
		||||
 | 
			
		||||
function createMetaTagContent(key: string, value: string) {
 | 
			
		||||
    return `<meta property="${key}" content="${value}">`;
 | 
			
		||||
	return `<meta property="${key}" content="${value}">`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function createOgTagContent(title: string, description:string, image: string) {
 | 
			
		||||
    return [
 | 
			
		||||
        createMetaTagContent("og:title", title),
 | 
			
		||||
        createMetaTagContent("og:type", "website"),
 | 
			
		||||
        createMetaTagContent("og:description", description),
 | 
			
		||||
        createMetaTagContent("og:image", image),
 | 
			
		||||
        createMetaTagContent("twitter:card", "summary_large_image"),
 | 
			
		||||
        createMetaTagContent("twitter:title", title),
 | 
			
		||||
        createMetaTagContent("twitter:description", description),
 | 
			
		||||
        createMetaTagContent("twitter:image", image),
 | 
			
		||||
    ].join("\n");
 | 
			
		||||
function createOgTagContent(title: string, description: string, image: string) {
 | 
			
		||||
	return [
 | 
			
		||||
		createMetaTagContent("og:title", title),
 | 
			
		||||
		createMetaTagContent("og:type", "website"),
 | 
			
		||||
		createMetaTagContent("og:description", description),
 | 
			
		||||
		createMetaTagContent("og:image", image),
 | 
			
		||||
		createMetaTagContent("twitter:card", "summary_large_image"),
 | 
			
		||||
		createMetaTagContent("twitter:title", title),
 | 
			
		||||
		createMetaTagContent("twitter:description", description),
 | 
			
		||||
		createMetaTagContent("twitter:image", image),
 | 
			
		||||
	].join("\n");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function makeMetaTagInjectedHTML(html: string, tagContent: string) {
 | 
			
		||||
    return html.replace("<!--MetaTag-Outlet-->", tagContent);
 | 
			
		||||
	return html.replace("<!--MetaTag-Outlet-->", tagContent);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const htmlResponse = (content: string, status = 200) =>
 | 
			
		||||
	new Response(content, {
 | 
			
		||||
		status,
 | 
			
		||||
		headers: { "Content-Type": "text/html; charset=utf-8" },
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
const normalizeError = (error: unknown): Error => {
 | 
			
		||||
    if (error instanceof Error) {
 | 
			
		||||
        return error;
 | 
			
		||||
    }
 | 
			
		||||
    if (typeof error === "string") {
 | 
			
		||||
        return new Error(error);
 | 
			
		||||
    }
 | 
			
		||||
    try {
 | 
			
		||||
        return new Error(JSON.stringify(error));
 | 
			
		||||
    } catch (_err) {
 | 
			
		||||
        return new Error("Unknown error");
 | 
			
		||||
    }
 | 
			
		||||
	if (error instanceof Error) {
 | 
			
		||||
		return error;
 | 
			
		||||
	}
 | 
			
		||||
	if (typeof error === "string") {
 | 
			
		||||
		return new Error(error);
 | 
			
		||||
	}
 | 
			
		||||
	try {
 | 
			
		||||
		return new Error(JSON.stringify(error));
 | 
			
		||||
	} catch (_err) {
 | 
			
		||||
		return new Error("Unknown error");
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export async function create_server() {
 | 
			
		||||
    const db = await connectDB();
 | 
			
		||||
    await initializeSetting(db);
 | 
			
		||||
    const setting = get_setting();
 | 
			
		||||
	const db = await connectDB();
 | 
			
		||||
	await initializeSetting(db);
 | 
			
		||||
	const setting = get_setting();
 | 
			
		||||
 | 
			
		||||
    const userController = createSqliteUserController(db);
 | 
			
		||||
    const documentController = createSqliteDocumentAccessor(db);
 | 
			
		||||
    const tagController = createSqliteTagController(db);
 | 
			
		||||
    const diffManger = new DiffManager(documentController);
 | 
			
		||||
    const comicConfig = await loadComicConfig(db);
 | 
			
		||||
    await diffManger.register("comic", createComicWatcher(comicConfig.watch));
 | 
			
		||||
	const userController = createSqliteUserController(db);
 | 
			
		||||
	const documentController = createSqliteDocumentAccessor(db);
 | 
			
		||||
	const tagController = createSqliteTagController(db);
 | 
			
		||||
	const diffManager = new DiffManager(documentController);
 | 
			
		||||
	const comicConfig = await loadComicConfig(db);
 | 
			
		||||
	await diffManager.register("comic", createComicWatcher(comicConfig.watch));
 | 
			
		||||
 | 
			
		||||
    if (setting.cli) {
 | 
			
		||||
        const userAdmin = await getAdmin(userController);
 | 
			
		||||
        if (await isAdminFirst(userAdmin)) {
 | 
			
		||||
            const rl = createReadlineInterface({
 | 
			
		||||
                input: process.stdin,
 | 
			
		||||
                output: process.stdout,
 | 
			
		||||
            });
 | 
			
		||||
            const pw = await new Promise((res: (data: string) => void) => {
 | 
			
		||||
                rl.question("put admin password :", (data) => {
 | 
			
		||||
                    res(data);
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
            rl.close();
 | 
			
		||||
            await userAdmin.reset_password(pw);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
	if (setting.cli) {
 | 
			
		||||
		const userAdmin = await getAdmin(userController);
 | 
			
		||||
		if (await isAdminFirst(userAdmin)) {
 | 
			
		||||
			const rl = createReadlineInterface({
 | 
			
		||||
				input: process.stdin,
 | 
			
		||||
				output: process.stdout,
 | 
			
		||||
			});
 | 
			
		||||
			const pw = await new Promise<string>((resolve) => {
 | 
			
		||||
				rl.question("put admin password :", (data) => {
 | 
			
		||||
					resolve(data);
 | 
			
		||||
				});
 | 
			
		||||
			});
 | 
			
		||||
			rl.close();
 | 
			
		||||
			await userAdmin.reset_password(pw);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
    const index_html = readFileSync("dist/index.html", "utf-8");
 | 
			
		||||
	const indexHtml = readFileSync("dist/index.html", "utf-8");
 | 
			
		||||
 | 
			
		||||
    const app = new Elysia({ 
 | 
			
		||||
        adapter: node(),
 | 
			
		||||
    })
 | 
			
		||||
        .use(cors())
 | 
			
		||||
        .use(staticPlugin({
 | 
			
		||||
            assets: "dist/assets",
 | 
			
		||||
            prefix: "/assets",
 | 
			
		||||
            headers: {
 | 
			
		||||
                "X-Content-Type-Options": "nosniff",
 | 
			
		||||
                "Cache-Control": setting.mode === "development" ? "no-cache" : "public, max-age=3600",
 | 
			
		||||
            }
 | 
			
		||||
        }))
 | 
			
		||||
        .use(openapi())
 | 
			
		||||
        .use(html())
 | 
			
		||||
        .onError((context) => error_handler({
 | 
			
		||||
            code: typeof context.code === "number" ? String(context.code) : context.code,
 | 
			
		||||
            error: normalizeError(context.error),
 | 
			
		||||
            set: context.set,
 | 
			
		||||
        }))
 | 
			
		||||
        .use(createUserHandler(userController))
 | 
			
		||||
        .group("/api", (app) => app
 | 
			
		||||
            .use(createDiffRouter(diffManger))
 | 
			
		||||
            .use(getContentRouter(documentController))
 | 
			
		||||
            .use(getTagRounter(tagController))
 | 
			
		||||
            .use(createSettingsRouter(db))
 | 
			
		||||
            .use(createLoginRouter(userController))
 | 
			
		||||
        )
 | 
			
		||||
        .get("/doc/:id", async ({ params: { id }, set }) => {
 | 
			
		||||
            const docId = Number.parseInt(id, 10);
 | 
			
		||||
            const doc = await documentController.findById(docId, true);
 | 
			
		||||
            let meta;
 | 
			
		||||
            if (doc === undefined) {
 | 
			
		||||
                set.status = 404;
 | 
			
		||||
                meta = createOgTagContent("Not Found Doc", "Not Found", "");
 | 
			
		||||
            } else {
 | 
			
		||||
                set.status = 200;
 | 
			
		||||
                meta = createOgTagContent(
 | 
			
		||||
                    doc.title,
 | 
			
		||||
                    doc.tags.join(", "),
 | 
			
		||||
                    `https://aeolian.monoid.top/api/doc/${docId}/comic/thumbnail`,
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
            return makeMetaTagInjectedHTML(index_html, meta);
 | 
			
		||||
        }, {
 | 
			
		||||
            params: t.Object({ id: t.String() })
 | 
			
		||||
        })
 | 
			
		||||
        .get("/", () => index_html)
 | 
			
		||||
        .get("/doc/*", () => index_html)
 | 
			
		||||
        .get("/search", () => index_html)
 | 
			
		||||
        .get("/login", () => index_html)
 | 
			
		||||
        .get("/profile", () => index_html)
 | 
			
		||||
        .get("/difference", () => index_html)
 | 
			
		||||
        .get("/setting", () => index_html)
 | 
			
		||||
        .get("/tags", () => index_html)
 | 
			
		||||
	const app = new Hono<AppEnv>();
 | 
			
		||||
 | 
			
		||||
    app.listen({
 | 
			
		||||
        port: setting.port,
 | 
			
		||||
        hostname: setting.hostname,
 | 
			
		||||
    });
 | 
			
		||||
	app.use("*", cors());
 | 
			
		||||
	app.use("*", createUserHandler(userController));
 | 
			
		||||
 | 
			
		||||
    console.log(`Server started at http://${setting.hostname}:${setting.port}/`);
 | 
			
		||||
	const staticRouter = createStaticRouter({
 | 
			
		||||
		assets: "dist/assets",
 | 
			
		||||
		prefix: "/assets",
 | 
			
		||||
		headers: {
 | 
			
		||||
			"X-Content-Type-Options": "nosniff",
 | 
			
		||||
			"Cache-Control": setting.mode === "development" ? "no-cache" : "public, max-age=3600",
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
	app.route("/", staticRouter);
 | 
			
		||||
 | 
			
		||||
    return app;
 | 
			
		||||
	app.onError((err, _c) => {
 | 
			
		||||
		const { status, body } = mapErrorToResponse(normalizeError(err));
 | 
			
		||||
		return new Response(JSON.stringify(body), {
 | 
			
		||||
			status,
 | 
			
		||||
			headers: { "Content-Type": "application/json" },
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	const api = new Hono<AppEnv>();
 | 
			
		||||
	api.route("/diff", createDiffRouter(diffManager));
 | 
			
		||||
	api.route("/doc", getContentRouter(documentController));
 | 
			
		||||
	api.route("/tags", getTagRounter(tagController));
 | 
			
		||||
	api.route("/", createSettingsRouter(db));
 | 
			
		||||
	api.route("/user", createLoginRouter(userController));
 | 
			
		||||
 | 
			
		||||
	app.route("/api", api);
 | 
			
		||||
 | 
			
		||||
	app.get("/doc/:id", async (c) => {
 | 
			
		||||
		const param = c.req.param("id");
 | 
			
		||||
		const docId = Number.parseInt(param, 10);
 | 
			
		||||
		if (Number.isNaN(docId)) {
 | 
			
		||||
			const meta = createOgTagContent("Not Found Doc", "Not Found", "");
 | 
			
		||||
			return htmlResponse(makeMetaTagInjectedHTML(indexHtml, meta), 400);
 | 
			
		||||
		}
 | 
			
		||||
		const doc = await documentController.findById(docId, true);
 | 
			
		||||
		if (!doc) {
 | 
			
		||||
			const meta = createOgTagContent("Not Found Doc", "Not Found", "");
 | 
			
		||||
			return htmlResponse(makeMetaTagInjectedHTML(indexHtml, meta), 404);
 | 
			
		||||
		}
 | 
			
		||||
		const meta = createOgTagContent(
 | 
			
		||||
			doc.title,
 | 
			
		||||
			doc.tags.join(", "),
 | 
			
		||||
			`https://aeolian.monoid.top/api/doc/${docId}/comic/thumbnail`,
 | 
			
		||||
		);
 | 
			
		||||
		return htmlResponse(makeMetaTagInjectedHTML(indexHtml, meta));
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	const spaPaths = ["/", "/doc/*", "/search", "/login", "/profile", "/difference", "/setting", "/tags"] as const;
 | 
			
		||||
	for (const path of spaPaths) {
 | 
			
		||||
		app.get(path, () => htmlResponse(indexHtml));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	serve({
 | 
			
		||||
		fetch: app.fetch,
 | 
			
		||||
		hostname: setting.hostname,
 | 
			
		||||
		port: setting.port,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	console.log(`Server started at http://${setting.hostname}:${setting.port}/`);
 | 
			
		||||
 | 
			
		||||
	return app;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +0,0 @@
 | 
			
		|||
import { Elysia } from "elysia";
 | 
			
		||||
import { get_setting } from "./SettingConfig.ts";
 | 
			
		||||
 | 
			
		||||
export const SettingPlugin = new Elysia({
 | 
			
		||||
	name: "setting",
 | 
			
		||||
	seed: "ServerConfig",
 | 
			
		||||
}).derive(() => ({ setting: get_setting() }));
 | 
			
		||||
| 
						 | 
				
			
			@ -1 +0,0 @@
 | 
			
		|||
export {};
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,8 @@
 | 
			
		|||
import { Elysia, NotFoundError, type Context } from "elysia";
 | 
			
		||||
import { Hono } from "hono";
 | 
			
		||||
import type { Context } from "hono";
 | 
			
		||||
import { createReadStream } from "node:fs";
 | 
			
		||||
import { stat } from "node:fs/promises";
 | 
			
		||||
import { Readable } from "node:stream";
 | 
			
		||||
import { extname, resolve } from "node:path";
 | 
			
		||||
 | 
			
		||||
const MIME_TYPES: Record<string, string> = {
 | 
			
		||||
| 
						 | 
				
			
			@ -46,36 +48,44 @@ export type StaticPluginOptions = {
 | 
			
		|||
    headers?: Record<string, string>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const buildResponse = (status: number, headers: Record<string, string>, body: BodyInit | null) =>
 | 
			
		||||
    new Response(body, { status, headers });
 | 
			
		||||
 | 
			
		||||
const resolveWildcard = (context: Context, wildcardParam: string) => {
 | 
			
		||||
    const wildcard = context.req.param(wildcardParam) ?? "";
 | 
			
		||||
    if (wildcard.length === 0) {
 | 
			
		||||
        return undefined;
 | 
			
		||||
    }
 | 
			
		||||
    try {
 | 
			
		||||
        const decoded = decodeURI(wildcard);
 | 
			
		||||
        if (decoded.includes("\0")) {
 | 
			
		||||
            return undefined;
 | 
			
		||||
        }
 | 
			
		||||
        return decoded;
 | 
			
		||||
    } catch {
 | 
			
		||||
        return undefined;
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const handleStaticRequest = async (
 | 
			
		||||
    context: Context,
 | 
			
		||||
    rootDir: string,
 | 
			
		||||
    headersTemplate: Record<string, string>,
 | 
			
		||||
    ctx: Context,
 | 
			
		||||
    sendBody: boolean,
 | 
			
		||||
) => {
 | 
			
		||||
    const wildcard = ctx.params?.["*"] ?? "";
 | 
			
		||||
    if (wildcard.length === 0) {
 | 
			
		||||
        throw new NotFoundError();
 | 
			
		||||
    const pathFragment = resolveWildcard(context, "*");
 | 
			
		||||
    if (!pathFragment) {
 | 
			
		||||
        return buildResponse(404, {}, null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let decoded: string;
 | 
			
		||||
    try {
 | 
			
		||||
        decoded = decodeURI(wildcard);
 | 
			
		||||
    } catch {
 | 
			
		||||
        throw new NotFoundError();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (decoded.includes("\0")) {
 | 
			
		||||
        throw new NotFoundError();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const absolutePath = resolve(rootDir, decoded);
 | 
			
		||||
    const absolutePath = resolve(rootDir, pathFragment);
 | 
			
		||||
    if (!isPathWithinRoot(absolutePath, rootDir)) {
 | 
			
		||||
        throw new NotFoundError();
 | 
			
		||||
        return buildResponse(404, {}, null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const fileStat = await stat(absolutePath).catch(() => undefined);
 | 
			
		||||
    if (!fileStat || fileStat.isDirectory()) {
 | 
			
		||||
        throw new NotFoundError();
 | 
			
		||||
        return buildResponse(404, {}, null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const responseHeaders: Record<string, string> = {
 | 
			
		||||
| 
						 | 
				
			
			@ -86,44 +96,40 @@ const handleStaticRequest = async (
 | 
			
		|||
    const etag = generateETag(fileStat.mtimeMs, fileStat.size);
 | 
			
		||||
    responseHeaders.ETag = etag;
 | 
			
		||||
 | 
			
		||||
    const ifNoneMatch = ctx.request.headers.get("if-none-match");
 | 
			
		||||
    const ifNoneMatch = context.req.header("if-none-match");
 | 
			
		||||
    if (ifNoneMatch && ifNoneMatch === etag) {
 | 
			
		||||
        ctx.set.status = 304;
 | 
			
		||||
        ctx.set.headers = responseHeaders;
 | 
			
		||||
        return undefined;
 | 
			
		||||
        return buildResponse(304, responseHeaders, null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const ifModifiedSince = ctx.request.headers.get("if-modified-since");
 | 
			
		||||
    const ifModifiedSince = context.req.header("if-modified-since");
 | 
			
		||||
    if (ifModifiedSince) {
 | 
			
		||||
        const since = new Date(ifModifiedSince);
 | 
			
		||||
        if (!Number.isNaN(since.getTime()) && fileStat.mtime <= since) {
 | 
			
		||||
            ctx.set.status = 304;
 | 
			
		||||
            ctx.set.headers = responseHeaders;
 | 
			
		||||
            return undefined;
 | 
			
		||||
            return buildResponse(304, responseHeaders, null);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    responseHeaders["Content-Type"] = getMimeType(absolutePath);
 | 
			
		||||
    responseHeaders["Content-Length"] = fileStat.size.toString();
 | 
			
		||||
 | 
			
		||||
    ctx.set.status = 200;
 | 
			
		||||
    ctx.set.headers = responseHeaders;
 | 
			
		||||
 | 
			
		||||
    if (!sendBody) {
 | 
			
		||||
        return undefined;
 | 
			
		||||
        return buildResponse(200, responseHeaders, null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return createReadStream(absolutePath);
 | 
			
		||||
    const nodeStream = createReadStream(absolutePath);
 | 
			
		||||
    const body = Readable.toWeb(nodeStream) as unknown as ReadableStream<Uint8Array>;
 | 
			
		||||
    return buildResponse(200, responseHeaders, body);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const staticPlugin = ({ assets, prefix = "/public", headers = {} }: StaticPluginOptions) => {
 | 
			
		||||
export const createStaticRouter = ({ assets, prefix = "/public", headers = {} }: StaticPluginOptions) => {
 | 
			
		||||
    const trimmedPrefix = prefix.endsWith("/") && prefix !== "/" ? prefix.slice(0, -1) : prefix;
 | 
			
		||||
    const normalizedPrefix = trimmedPrefix.startsWith("/") ? trimmedPrefix : `/${trimmedPrefix}`;
 | 
			
		||||
    const wildcardRoute = normalizedPrefix === "/" ? "/*" : `${normalizedPrefix}/*`;
 | 
			
		||||
    const rootDir = resolve(process.cwd(), assets);
 | 
			
		||||
    const headersTemplate = { ...headers };
 | 
			
		||||
 | 
			
		||||
    return new Elysia({ name: "node-static" })
 | 
			
		||||
        .get(wildcardRoute, async (ctx) => handleStaticRequest(rootDir, headersTemplate, ctx, true))
 | 
			
		||||
        .head(wildcardRoute, async (ctx) => handleStaticRequest(rootDir, headersTemplate, ctx, false));
 | 
			
		||||
    const router = new Hono();
 | 
			
		||||
    router.get(wildcardRoute, (c) => handleStaticRequest(c, rootDir, headersTemplate, true));
 | 
			
		||||
    router.on("HEAD", wildcardRoute, (c) => handleStaticRequest(c, rootDir, headersTemplate, false));
 | 
			
		||||
    return router;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,18 +1,27 @@
 | 
			
		|||
import { beforeEach, describe, expect, it, vi, afterEach } from "vitest";
 | 
			
		||||
import { Elysia } from "elysia";
 | 
			
		||||
import { beforeEach, describe, expect, it, vi } from "vitest";
 | 
			
		||||
import { Hono } from "hono";
 | 
			
		||||
import { createDiffRouter } from "../src/diff/router.ts";
 | 
			
		||||
import type { DiffManager } from "../src/diff/diff.ts";
 | 
			
		||||
import type { AppEnv, AuthStore } from "../src/login.ts";
 | 
			
		||||
 | 
			
		||||
const adminUser = { username: "admin", permission: [] as string[] };
 | 
			
		||||
const adminUser: AuthStore["user"] = { username: "admin", permission: [] };
 | 
			
		||||
 | 
			
		||||
const createTestApp = (diffManager: DiffManager) => {
 | 
			
		||||
	const authPlugin = new Elysia({ name: "test-auth" })
 | 
			
		||||
		.state("user", adminUser)
 | 
			
		||||
		.derive(() => ({ user: adminUser }));
 | 
			
		||||
	const app = new Hono<AppEnv>();
 | 
			
		||||
 | 
			
		||||
	return new Elysia({ name: "diff-test" })
 | 
			
		||||
		.use(authPlugin)
 | 
			
		||||
		.use(createDiffRouter(diffManager));
 | 
			
		||||
	app.use("*", async (c, next) => {
 | 
			
		||||
		const auth: AuthStore = {
 | 
			
		||||
			user: adminUser,
 | 
			
		||||
			refreshed: false,
 | 
			
		||||
			authenticated: true,
 | 
			
		||||
		};
 | 
			
		||||
		c.set("auth", auth);
 | 
			
		||||
		await next();
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	app.route("/diff", createDiffRouter(diffManager));
 | 
			
		||||
 | 
			
		||||
	return app;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
describe("Diff router integration", () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -43,15 +52,8 @@ describe("Diff router integration", () => {
 | 
			
		|||
		app = createTestApp(diffManager);
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	afterEach(async () => {
 | 
			
		||||
		if (app?.server) {
 | 
			
		||||
			await app.stop();
 | 
			
		||||
		}
 | 
			
		||||
		vi.clearAllMocks();
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it("GET /diff/list returns grouped pending items", async () => {
 | 
			
		||||
		const response = await app.handle(new Request("http://localhost/diff/list"));
 | 
			
		||||
		const response = await app.fetch(new Request("http://localhost/diff/list"));
 | 
			
		||||
		expect(response.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
		const payload = await response.json();
 | 
			
		||||
| 
						 | 
				
			
			@ -77,7 +79,7 @@ describe("Diff router integration", () => {
 | 
			
		|||
 | 
			
		||||
		commitMock.mockResolvedValueOnce(555);
 | 
			
		||||
 | 
			
		||||
		const response = await app.handle(request);
 | 
			
		||||
		const response = await app.fetch(request);
 | 
			
		||||
		expect(response.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
		const payload = await response.json();
 | 
			
		||||
| 
						 | 
				
			
			@ -92,7 +94,7 @@ describe("Diff router integration", () => {
 | 
			
		|||
			body: JSON.stringify({ type: "comic" }),
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		const response = await app.handle(request);
 | 
			
		||||
		const response = await app.fetch(request);
 | 
			
		||||
		expect(response.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
		const payload = await response.json();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,20 +1,12 @@
 | 
			
		|||
import { describe, expect, it } from "vitest";
 | 
			
		||||
import { ClientRequestError, error_handler } from "../src/route/error_handler.ts";
 | 
			
		||||
import { DocumentBodySchema } from "dbtype";
 | 
			
		||||
 | 
			
		||||
const createSet = () => ({ status: undefined as number | string | undefined });
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
import { ClientRequestError, mapErrorToResponse } from "../src/route/error_handler.ts";
 | 
			
		||||
 | 
			
		||||
describe("error_handler", () => {
 | 
			
		||||
	it("formats ClientRequestError with provided status", () => {
 | 
			
		||||
		const set = createSet();
 | 
			
		||||
		const result = error_handler({
 | 
			
		||||
			code: "UNKNOWN",
 | 
			
		||||
			error: new ClientRequestError(400, "invalid payload"),
 | 
			
		||||
			set,
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		expect(set.status).toBe(400);
 | 
			
		||||
		expect(result).toEqual({
 | 
			
		||||
		const { status, body } = mapErrorToResponse(new ClientRequestError(400, "invalid payload"));
 | 
			
		||||
		expect(status).toBe(400);
 | 
			
		||||
		expect(body).toEqual({
 | 
			
		||||
			code: 400,
 | 
			
		||||
			message: "BadRequest",
 | 
			
		||||
			detail: "invalid payload",
 | 
			
		||||
| 
						 | 
				
			
			@ -22,35 +14,26 @@ describe("error_handler", () => {
 | 
			
		|||
	});
 | 
			
		||||
 | 
			
		||||
	it("coerces ZodError into a 400 response", () => {
 | 
			
		||||
		const parseResult = DocumentBodySchema.safeParse({});
 | 
			
		||||
		const set = createSet();
 | 
			
		||||
		const schema = z.object({ foo: z.string() });
 | 
			
		||||
		const parseResult = schema.safeParse({});
 | 
			
		||||
 | 
			
		||||
		if (parseResult.success) {
 | 
			
		||||
			throw new Error("Expected validation error");
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const result = error_handler({
 | 
			
		||||
			code: "VALIDATION",
 | 
			
		||||
			error: parseResult.error,
 | 
			
		||||
			set,
 | 
			
		||||
		});
 | 
			
		||||
		const { status, body } = mapErrorToResponse(parseResult.error);
 | 
			
		||||
 | 
			
		||||
		expect(set.status).toBe(400);
 | 
			
		||||
		expect(result.code).toBe(400);
 | 
			
		||||
		expect(result.message).toBe("BadRequest");
 | 
			
		||||
		expect(result.detail).toContain("Required");
 | 
			
		||||
		expect(status).toBe(400);
 | 
			
		||||
		expect(body.code).toBe(400);
 | 
			
		||||
		expect(body.message).toBe("BadRequest");
 | 
			
		||||
		expect(body.detail).toContain("expected string");
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it("defaults to 500 for unexpected errors", () => {
 | 
			
		||||
		const set = createSet();
 | 
			
		||||
		const result = error_handler({
 | 
			
		||||
			code: "INTERNAL_SERVER_ERROR",
 | 
			
		||||
			error: new Error("boom"),
 | 
			
		||||
			set,
 | 
			
		||||
		});
 | 
			
		||||
		const { status, body } = mapErrorToResponse(new Error("boom"));
 | 
			
		||||
 | 
			
		||||
		expect(set.status).toBe(500);
 | 
			
		||||
		expect(result).toEqual({
 | 
			
		||||
		expect(status).toBe(500);
 | 
			
		||||
		expect(body).toEqual({
 | 
			
		||||
			code: 500,
 | 
			
		||||
			message: "Internal Server Error",
 | 
			
		||||
			detail: "boom",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,12 +1,13 @@
 | 
			
		|||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
 | 
			
		||||
import { Elysia } from "elysia";
 | 
			
		||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
 | 
			
		||||
import { Hono } from "hono";
 | 
			
		||||
import { Kysely, SqliteDialect } from "kysely";
 | 
			
		||||
import SqliteDatabase from "better-sqlite3";
 | 
			
		||||
import type { db } from "dbtype";
 | 
			
		||||
import { createSettingsRouter } from "../src/route/settings.ts";
 | 
			
		||||
import { error_handler } from "../src/route/error_handler.ts";
 | 
			
		||||
import { mapErrorToResponse } from "../src/route/error_handler.ts";
 | 
			
		||||
import { get_setting, refreshSetting } from "../src/SettingConfig.ts";
 | 
			
		||||
import { Permission } from "../src/permission/permission.ts";
 | 
			
		||||
import { PERMISSIONS } from "../src/permission/permission.ts";
 | 
			
		||||
import type { AppEnv, AuthStore } from "../src/login.ts";
 | 
			
		||||
 | 
			
		||||
const normalizeError = (error: unknown): Error => {
 | 
			
		||||
	if (error instanceof Error) {
 | 
			
		||||
| 
						 | 
				
			
			@ -56,101 +57,94 @@ describe("settings router", () => {
 | 
			
		|||
	});
 | 
			
		||||
 | 
			
		||||
	const createTestApp = (username: string) => {
 | 
			
		||||
		const user = { username, permission: [] as string[] };
 | 
			
		||||
		return new Elysia({ name: `settings-test-${username}` })
 | 
			
		||||
			.state("user", user)
 | 
			
		||||
			.derive(() => ({ user }))
 | 
			
		||||
			.onError((context) =>
 | 
			
		||||
				error_handler({
 | 
			
		||||
					code: typeof context.code === "number" ? String(context.code) : context.code,
 | 
			
		||||
					error: normalizeError(context.error),
 | 
			
		||||
					set: context.set,
 | 
			
		||||
				}),
 | 
			
		||||
			)
 | 
			
		||||
			.use(createSettingsRouter(database));
 | 
			
		||||
		const app = new Hono<AppEnv>();
 | 
			
		||||
		const auth: AuthStore = {
 | 
			
		||||
			user: { username, permission: [] },
 | 
			
		||||
			refreshed: false,
 | 
			
		||||
			authenticated: true,
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		app.use("*", async (c, next) => {
 | 
			
		||||
			c.set("auth", auth);
 | 
			
		||||
			await next();
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		app.onError((err) => {
 | 
			
		||||
			const { status, body } = mapErrorToResponse(normalizeError(err));
 | 
			
		||||
			return new Response(JSON.stringify(body), {
 | 
			
		||||
				status,
 | 
			
		||||
				headers: { "Content-Type": "application/json" },
 | 
			
		||||
			});
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		app.route("/", createSettingsRouter(database));
 | 
			
		||||
 | 
			
		||||
		return app;
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	it("rejects access for non-admin users", async () => {
 | 
			
		||||
		const app = createTestApp("guest");
 | 
			
		||||
		try {
 | 
			
		||||
			const response = await app.handle(new Request("http://localhost/settings"));
 | 
			
		||||
			expect(response.status).toBe(403);
 | 
			
		||||
		} finally {
 | 
			
		||||
			if (app.server) {
 | 
			
		||||
				await app.stop();
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		const response = await app.fetch(new Request("http://localhost/settings"));
 | 
			
		||||
		expect(response.status).toBe(403);
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it("returns current configuration for admin", async () => {
 | 
			
		||||
		const app = createTestApp("admin");
 | 
			
		||||
		try {
 | 
			
		||||
			const response = await app.handle(new Request("http://localhost/settings"));
 | 
			
		||||
			expect(response.status).toBe(200);
 | 
			
		||||
		const response = await app.fetch(new Request("http://localhost/settings"));
 | 
			
		||||
		expect(response.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
			const payload = await response.json();
 | 
			
		||||
			const expected = get_setting();
 | 
			
		||||
			expect(payload).toMatchObject({
 | 
			
		||||
				persisted: {
 | 
			
		||||
					secure: expected.secure,
 | 
			
		||||
					cli: expected.cli,
 | 
			
		||||
					forbid_remote_admin_login: expected.forbid_remote_admin_login,
 | 
			
		||||
					guest: expected.guest,
 | 
			
		||||
				},
 | 
			
		||||
				env: {
 | 
			
		||||
					hostname: expected.hostname,
 | 
			
		||||
					port: expected.port,
 | 
			
		||||
					mode: expected.mode,
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
			expect(Array.isArray(payload.permissions)).toBe(true);
 | 
			
		||||
			expect(new Set(payload.permissions)).toEqual(new Set(Object.values(Permission)));
 | 
			
		||||
		} finally {
 | 
			
		||||
			if (app.server) {
 | 
			
		||||
				await app.stop();
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		const payload = await response.json();
 | 
			
		||||
		const expected = get_setting();
 | 
			
		||||
		expect(payload).toMatchObject({
 | 
			
		||||
			persisted: {
 | 
			
		||||
				secure: expected.secure,
 | 
			
		||||
				cli: expected.cli,
 | 
			
		||||
				forbid_remote_admin_login: expected.forbid_remote_admin_login,
 | 
			
		||||
				guest: expected.guest,
 | 
			
		||||
			},
 | 
			
		||||
			env: {
 | 
			
		||||
				hostname: expected.hostname,
 | 
			
		||||
				port: expected.port,
 | 
			
		||||
				mode: expected.mode,
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
		expect(Array.isArray(payload.permissions)).toBe(true);
 | 
			
		||||
		expect(new Set(payload.permissions)).toEqual(new Set(PERMISSIONS));
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it("updates persisted settings and returns the new state", async () => {
 | 
			
		||||
		const app = createTestApp("admin");
 | 
			
		||||
		try {
 | 
			
		||||
			const request = new Request("http://localhost/settings", {
 | 
			
		||||
				method: "PATCH",
 | 
			
		||||
				headers: { "content-type": "application/json" },
 | 
			
		||||
				body: JSON.stringify({
 | 
			
		||||
					secure: false,
 | 
			
		||||
					cli: true,
 | 
			
		||||
					guest: ["QueryContent"],
 | 
			
		||||
					forbid_remote_admin_login: false,
 | 
			
		||||
				}),
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			const response = await app.handle(request);
 | 
			
		||||
			expect(response.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
			const payload = await response.json();
 | 
			
		||||
			expect(payload.persisted).toEqual({
 | 
			
		||||
		const request = new Request("http://localhost/settings", {
 | 
			
		||||
			method: "PATCH",
 | 
			
		||||
			headers: { "content-type": "application/json" },
 | 
			
		||||
			body: JSON.stringify({
 | 
			
		||||
				secure: false,
 | 
			
		||||
				cli: true,
 | 
			
		||||
				forbid_remote_admin_login: false,
 | 
			
		||||
				guest: ["QueryContent"],
 | 
			
		||||
			});
 | 
			
		||||
				forbid_remote_admin_login: false,
 | 
			
		||||
			}),
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
			// A follow-up GET should reflect the updated values
 | 
			
		||||
			const followUp = await app.handle(new Request("http://localhost/settings"));
 | 
			
		||||
			expect(followUp.status).toBe(200);
 | 
			
		||||
			const followUpPayload = await followUp.json();
 | 
			
		||||
			expect(followUpPayload.persisted).toEqual({
 | 
			
		||||
				secure: false,
 | 
			
		||||
				cli: true,
 | 
			
		||||
				forbid_remote_admin_login: false,
 | 
			
		||||
				guest: ["QueryContent"],
 | 
			
		||||
			});
 | 
			
		||||
		} finally {
 | 
			
		||||
			if (app.server) {
 | 
			
		||||
				await app.stop();
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		const response = await app.fetch(request);
 | 
			
		||||
		expect(response.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
		const payload = await response.json();
 | 
			
		||||
		expect(payload.persisted).toEqual({
 | 
			
		||||
			secure: false,
 | 
			
		||||
			cli: true,
 | 
			
		||||
			forbid_remote_admin_login: false,
 | 
			
		||||
			guest: ["QueryContent"],
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		// A follow-up GET should reflect the updated values
 | 
			
		||||
		const followUp = await app.fetch(new Request("http://localhost/settings"));
 | 
			
		||||
		expect(followUp.status).toBe(200);
 | 
			
		||||
		const followUpPayload = await followUp.json();
 | 
			
		||||
		expect(followUpPayload.persisted).toEqual({
 | 
			
		||||
			secure: false,
 | 
			
		||||
			cli: true,
 | 
			
		||||
			forbid_remote_admin_login: false,
 | 
			
		||||
			guest: ["QueryContent"],
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										397
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										397
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -148,8 +148,8 @@ importers:
 | 
			
		|||
  packages/dbtype:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      zod:
 | 
			
		||||
        specifier: ^3.23.8
 | 
			
		||||
        version: 3.23.8
 | 
			
		||||
        specifier: ^4.1.12
 | 
			
		||||
        version: 4.1.12
 | 
			
		||||
    devDependencies:
 | 
			
		||||
      '@types/better-sqlite3':
 | 
			
		||||
        specifier: ^7.6.9
 | 
			
		||||
| 
						 | 
				
			
			@ -169,18 +169,18 @@ importers:
 | 
			
		|||
 | 
			
		||||
  packages/server:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@elysiajs/cors':
 | 
			
		||||
        specifier: ^1.3.3
 | 
			
		||||
        version: 1.3.3(elysia@1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3))
 | 
			
		||||
      '@elysiajs/html':
 | 
			
		||||
        specifier: ^1.3.1
 | 
			
		||||
        version: 1.3.1(elysia@1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3))(typescript@5.8.3)
 | 
			
		||||
      '@elysiajs/node':
 | 
			
		||||
        specifier: ^1.4.1
 | 
			
		||||
        version: 1.4.1(elysia@1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3))
 | 
			
		||||
      '@elysiajs/openapi':
 | 
			
		||||
        specifier: ^1.4.11
 | 
			
		||||
        version: 1.4.11(elysia@1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3))
 | 
			
		||||
      '@hono/node-server':
 | 
			
		||||
        specifier: ^1.19.6
 | 
			
		||||
        version: 1.19.6(hono@4.10.4)
 | 
			
		||||
      '@hono/standard-validator':
 | 
			
		||||
        specifier: ^0.1.5
 | 
			
		||||
        version: 0.1.5(@standard-schema/spec@1.0.0)(hono@4.10.4)
 | 
			
		||||
      '@hono/zod-openapi':
 | 
			
		||||
        specifier: ^1.1.4
 | 
			
		||||
        version: 1.1.4(hono@4.10.4)(zod@4.1.12)
 | 
			
		||||
      '@hono/zod-validator':
 | 
			
		||||
        specifier: ^0.7.4
 | 
			
		||||
        version: 0.7.4(hono@4.10.4)(zod@4.1.12)
 | 
			
		||||
      '@std/async':
 | 
			
		||||
        specifier: npm:@jsr/std__async@^1.0.13
 | 
			
		||||
        version: '@jsr/std__async@1.0.13'
 | 
			
		||||
| 
						 | 
				
			
			@ -199,9 +199,9 @@ importers:
 | 
			
		|||
      dotenv:
 | 
			
		||||
        specifier: ^16.5.0
 | 
			
		||||
        version: 16.5.0
 | 
			
		||||
      elysia:
 | 
			
		||||
        specifier: ^1.4.9
 | 
			
		||||
        version: 1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3)
 | 
			
		||||
      hono:
 | 
			
		||||
        specifier: ^4.10.4
 | 
			
		||||
        version: 4.10.4
 | 
			
		||||
      jose:
 | 
			
		||||
        specifier: ^5.10.0
 | 
			
		||||
        version: 5.10.0
 | 
			
		||||
| 
						 | 
				
			
			@ -214,6 +214,9 @@ importers:
 | 
			
		|||
      tiny-async-pool:
 | 
			
		||||
        specifier: ^1.3.0
 | 
			
		||||
        version: 1.3.0
 | 
			
		||||
      zod:
 | 
			
		||||
        specifier: ^4.1.12
 | 
			
		||||
        version: 4.1.12
 | 
			
		||||
    devDependencies:
 | 
			
		||||
      '@types/better-sqlite3':
 | 
			
		||||
        specifier: ^7.6.13
 | 
			
		||||
| 
						 | 
				
			
			@ -248,6 +251,11 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-2aDL3WUv8hMJb2L3r/PIQWsTLyq7RQr3v9xD16fiz6O8ys1xEyLhhTOv8gxtZvJiTzjTF5pHoArvRdesGL1DMQ==}
 | 
			
		||||
    hasBin: true
 | 
			
		||||
 | 
			
		||||
  '@asteasolutions/zod-to-openapi@8.1.0':
 | 
			
		||||
    resolution: {integrity: sha512-tQFxVs05J/6QXXqIzj6rTRk3nj1HFs4pe+uThwE95jL5II2JfpVXkK+CqkO7aT0Do5AYqO6LDrKpleLUFXgY+g==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      zod: ^4.0.0
 | 
			
		||||
 | 
			
		||||
  '@babel/code-frame@7.27.1':
 | 
			
		||||
    resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
 | 
			
		||||
    engines: {node: '>=6.9.0'}
 | 
			
		||||
| 
						 | 
				
			
			@ -412,29 +420,6 @@ packages:
 | 
			
		|||
    cpu: [x64]
 | 
			
		||||
    os: [win32]
 | 
			
		||||
 | 
			
		||||
  '@borewit/text-codec@0.1.1':
 | 
			
		||||
    resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==}
 | 
			
		||||
 | 
			
		||||
  '@elysiajs/cors@1.3.3':
 | 
			
		||||
    resolution: {integrity: sha512-mYIU6PyMM6xIJuj7d27Vt0/wuzVKIEnFPjcvlkyd7t/m9xspAG37cwNjFxVOnyvY43oOd2I/oW2DB85utXpA2Q==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      elysia: '>= 1.3.0'
 | 
			
		||||
 | 
			
		||||
  '@elysiajs/html@1.3.1':
 | 
			
		||||
    resolution: {integrity: sha512-jOWUfvL9vZ2Gs3uCx2w4Po+jxOwRD/sXW3JgvOAD3rEjX0NuygwcvixtbONSzAH8lFhaDBbHAtmCfpue46X9IQ==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      elysia: '>= 1.3.0'
 | 
			
		||||
 | 
			
		||||
  '@elysiajs/node@1.4.1':
 | 
			
		||||
    resolution: {integrity: sha512-2wAALwHK3IYi1XJPnxfp1xJsvps5FqqcQqe+QXjYlGQvsmSG+vI5wNDIuvIlB+6p9NE/laLbqV0aFromf3X7yg==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      elysia: '>= 1.4.0'
 | 
			
		||||
 | 
			
		||||
  '@elysiajs/openapi@1.4.11':
 | 
			
		||||
    resolution: {integrity: sha512-d75bMxYJpN6qSDi/z9L1S7SLk1S/8Px+cTb3W2lrYzU8uQ5E0kXdy1oOMJEfTyVsz3OA19NP9KNxE7ztSbLBLg==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      elysia: '>= 1.4.0'
 | 
			
		||||
 | 
			
		||||
  '@esbuild/aix-ppc64@0.21.5':
 | 
			
		||||
    resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
 | 
			
		||||
    engines: {node: '>=12'}
 | 
			
		||||
| 
						 | 
				
			
			@ -756,6 +741,31 @@ packages:
 | 
			
		|||
  '@floating-ui/utils@0.2.9':
 | 
			
		||||
    resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
 | 
			
		||||
 | 
			
		||||
  '@hono/node-server@1.19.6':
 | 
			
		||||
    resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==}
 | 
			
		||||
    engines: {node: '>=18.14.1'}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      hono: ^4
 | 
			
		||||
 | 
			
		||||
  '@hono/standard-validator@0.1.5':
 | 
			
		||||
    resolution: {integrity: sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@standard-schema/spec': 1.0.0
 | 
			
		||||
      hono: '>=3.9.0'
 | 
			
		||||
 | 
			
		||||
  '@hono/zod-openapi@1.1.4':
 | 
			
		||||
    resolution: {integrity: sha512-4BbOtd6oKg20yo6HLluVbEycBLLIfdKX5o/gUSoKZ2uBmeP4Og/VDfIX3k9pbNEX5W3fRkuPeVjGA+zaQDVY1A==}
 | 
			
		||||
    engines: {node: '>=16.0.0'}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      hono: '>=4.3.6'
 | 
			
		||||
      zod: ^4.0.0
 | 
			
		||||
 | 
			
		||||
  '@hono/zod-validator@0.7.4':
 | 
			
		||||
    resolution: {integrity: sha512-biKGn3BRJVaftZlIPMyK+HCe/UHAjJ6sH0UyXe3+v0OcgVr9xfImDROTJFLtn9e3XEEAHGZIM9U6evu85abm8Q==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      hono: '>=3.9.0'
 | 
			
		||||
      zod: ^3.25.0 || ^4.0.0
 | 
			
		||||
 | 
			
		||||
  '@humanwhocodes/config-array@0.13.0':
 | 
			
		||||
    resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
 | 
			
		||||
    engines: {node: '>=10.10.0'}
 | 
			
		||||
| 
						 | 
				
			
			@ -794,17 +804,6 @@ packages:
 | 
			
		|||
  '@jsr/std__async@1.0.13':
 | 
			
		||||
    resolution: {integrity: sha512-GEApyNtzauJ0kEZ/GxebSkdEN0t29qJtkw+WEvzYTwkL6fHX8cq3YWzRjCqHu+4jMl+rpHiwyr/lfitNInntzA==, tarball: https://npm.jsr.io/~/11/@jsr/std__async/1.0.13.tgz}
 | 
			
		||||
 | 
			
		||||
  '@kitajs/html@4.2.9':
 | 
			
		||||
    resolution: {integrity: sha512-FDHHf5Mi5nR0D+Btq86IV1O9XfsePVCiC5rwU4PXjw2aHja16FmIiwLZBO0CS16rJxKkibjMldyRLAW2ni2mzA==}
 | 
			
		||||
    engines: {node: '>=12'}
 | 
			
		||||
 | 
			
		||||
  '@kitajs/ts-html-plugin@4.1.2':
 | 
			
		||||
    resolution: {integrity: sha512-XE9iIe93TELBdQSvNC3xxXOPDhkcK7on4Oi2HUKhln3jAc5hzn1o33uzjHCYhLeW36r/LXCT70beoXRCFcuTxQ==}
 | 
			
		||||
    hasBin: true
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@kitajs/html': ^4.2.5
 | 
			
		||||
      typescript: ^5.6.2
 | 
			
		||||
 | 
			
		||||
  '@nodelib/fs.scandir@2.1.5':
 | 
			
		||||
    resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
 | 
			
		||||
    engines: {node: '>= 8'}
 | 
			
		||||
| 
						 | 
				
			
			@ -1337,8 +1336,8 @@ packages:
 | 
			
		|||
    cpu: [x64]
 | 
			
		||||
    os: [win32]
 | 
			
		||||
 | 
			
		||||
  '@sinclair/typebox@0.34.41':
 | 
			
		||||
    resolution: {integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==}
 | 
			
		||||
  '@standard-schema/spec@1.0.0':
 | 
			
		||||
    resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
 | 
			
		||||
 | 
			
		||||
  '@swc/core-darwin-arm64@1.11.31':
 | 
			
		||||
    resolution: {integrity: sha512-NTEaYOts0OGSbJZc0O74xsji+64JrF1stmBii6D5EevWEtrY4wlZhm8SiP/qPrOB+HqtAihxWIukWkP2aSdGSQ==}
 | 
			
		||||
| 
						 | 
				
			
			@ -1424,13 +1423,6 @@ packages:
 | 
			
		|||
  '@tanstack/virtual-core@3.13.9':
 | 
			
		||||
    resolution: {integrity: sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ==}
 | 
			
		||||
 | 
			
		||||
  '@tokenizer/inflate@0.2.7':
 | 
			
		||||
    resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==}
 | 
			
		||||
    engines: {node: '>=18'}
 | 
			
		||||
 | 
			
		||||
  '@tokenizer/token@0.3.0':
 | 
			
		||||
    resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
 | 
			
		||||
 | 
			
		||||
  '@ts-morph/common@0.19.0':
 | 
			
		||||
    resolution: {integrity: sha512-Unz/WHmd4pGax91rdIKWi51wnVUW11QttMEPpBiBgIewnc9UQIX7UDLxr5vRlqeByXCwhkF6VabSsI0raWcyAQ==}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1767,10 +1759,6 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
 | 
			
		||||
    engines: {node: '>=6'}
 | 
			
		||||
 | 
			
		||||
  cliui@8.0.1:
 | 
			
		||||
    resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
 | 
			
		||||
    engines: {node: '>=12'}
 | 
			
		||||
 | 
			
		||||
  clone@1.0.4:
 | 
			
		||||
    resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
 | 
			
		||||
    engines: {node: '>=0.8'}
 | 
			
		||||
| 
						 | 
				
			
			@ -1809,13 +1797,6 @@ packages:
 | 
			
		|||
  convert-source-map@2.0.0:
 | 
			
		||||
    resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
 | 
			
		||||
 | 
			
		||||
  cookie-es@2.0.0:
 | 
			
		||||
    resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==}
 | 
			
		||||
 | 
			
		||||
  cookie@1.0.2:
 | 
			
		||||
    resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
 | 
			
		||||
    engines: {node: '>=18'}
 | 
			
		||||
 | 
			
		||||
  cosmiconfig@8.3.6:
 | 
			
		||||
    resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==}
 | 
			
		||||
    engines: {node: '>=14'}
 | 
			
		||||
| 
						 | 
				
			
			@ -1829,14 +1810,6 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
 | 
			
		||||
    engines: {node: '>= 8'}
 | 
			
		||||
 | 
			
		||||
  crossws@0.4.1:
 | 
			
		||||
    resolution: {integrity: sha512-E7WKBcHVhAVrY6JYD5kteNqVq1GSZxqGrdSiwXR9at+XHi43HJoCQKXcCczR5LBnBquFZPsB3o7HklulKoBU5w==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      srvx: '>=0.7.1'
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      srvx:
 | 
			
		||||
        optional: true
 | 
			
		||||
 | 
			
		||||
  cssesc@3.0.0:
 | 
			
		||||
    resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
 | 
			
		||||
    engines: {node: '>=4'}
 | 
			
		||||
| 
						 | 
				
			
			@ -1902,15 +1875,6 @@ packages:
 | 
			
		|||
      supports-color:
 | 
			
		||||
        optional: true
 | 
			
		||||
 | 
			
		||||
  debug@4.4.3:
 | 
			
		||||
    resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
 | 
			
		||||
    engines: {node: '>=6.0'}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      supports-color: '*'
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      supports-color:
 | 
			
		||||
        optional: true
 | 
			
		||||
 | 
			
		||||
  decimal.js-light@2.5.1:
 | 
			
		||||
    resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1986,20 +1950,6 @@ packages:
 | 
			
		|||
  electron-to-chromium@1.5.165:
 | 
			
		||||
    resolution: {integrity: sha512-naiMx1Z6Nb2TxPU6fiFrUrDTjyPMLdTtaOd2oLmG8zVSg2hCWGkhPyxwk+qRmZ1ytwVqUv0u7ZcDA5+ALhaUtw==}
 | 
			
		||||
 | 
			
		||||
  elysia@1.4.9:
 | 
			
		||||
    resolution: {integrity: sha512-BWNhA8DoKQvlQTjAUkMAmNeso24U+ibZxY/8LN96qSDK/6eevaX59r3GISow699JPxSnFY3gLMUzJzCLYVtbvg==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@sinclair/typebox': '>= 0.34.0 < 1'
 | 
			
		||||
      exact-mirror: '>= 0.0.9'
 | 
			
		||||
      file-type: '>= 20.0.0'
 | 
			
		||||
      openapi-types: '>= 12.0.0'
 | 
			
		||||
      typescript: '>= 5.0.0'
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      file-type:
 | 
			
		||||
        optional: true
 | 
			
		||||
      typescript:
 | 
			
		||||
        optional: true
 | 
			
		||||
 | 
			
		||||
  emoji-regex@8.0.0:
 | 
			
		||||
    resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -2096,14 +2046,6 @@ packages:
 | 
			
		|||
  eventemitter3@4.0.7:
 | 
			
		||||
    resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
 | 
			
		||||
 | 
			
		||||
  exact-mirror@0.2.0:
 | 
			
		||||
    resolution: {integrity: sha512-XnP8M3gIk6vLnpZY4A/RsAXwQLyqj7lCRJhiCZMt3NaIIXHsfzpJRsvG5DMSSYYrjm2xTBGCrPbG4Z9JublGBg==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@sinclair/typebox': ^0.34.15
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      '@sinclair/typebox':
 | 
			
		||||
        optional: true
 | 
			
		||||
 | 
			
		||||
  execa@7.2.0:
 | 
			
		||||
    resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==}
 | 
			
		||||
    engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0}
 | 
			
		||||
| 
						 | 
				
			
			@ -2116,9 +2058,6 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==}
 | 
			
		||||
    engines: {node: '>=12.0.0'}
 | 
			
		||||
 | 
			
		||||
  fast-decode-uri-component@1.0.1:
 | 
			
		||||
    resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
 | 
			
		||||
 | 
			
		||||
  fast-deep-equal@3.1.3:
 | 
			
		||||
    resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -2143,17 +2082,10 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
 | 
			
		||||
    engines: {node: ^12.20 || >= 14.13}
 | 
			
		||||
 | 
			
		||||
  fflate@0.8.2:
 | 
			
		||||
    resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
 | 
			
		||||
 | 
			
		||||
  file-entry-cache@6.0.1:
 | 
			
		||||
    resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
 | 
			
		||||
    engines: {node: ^10.12.0 || >=12.0.0}
 | 
			
		||||
 | 
			
		||||
  file-type@21.0.0:
 | 
			
		||||
    resolution: {integrity: sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg==}
 | 
			
		||||
    engines: {node: '>=20'}
 | 
			
		||||
 | 
			
		||||
  file-uri-to-path@1.0.0:
 | 
			
		||||
    resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -2205,10 +2137,6 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
 | 
			
		||||
    engines: {node: '>=6.9.0'}
 | 
			
		||||
 | 
			
		||||
  get-caller-file@2.0.5:
 | 
			
		||||
    resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
 | 
			
		||||
    engines: {node: 6.* || 8.* || >= 10.*}
 | 
			
		||||
 | 
			
		||||
  get-nonce@1.0.1:
 | 
			
		||||
    resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
 | 
			
		||||
    engines: {node: '>=6'}
 | 
			
		||||
| 
						 | 
				
			
			@ -2273,6 +2201,10 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
 | 
			
		||||
    engines: {node: '>= 0.4'}
 | 
			
		||||
 | 
			
		||||
  hono@4.10.4:
 | 
			
		||||
    resolution: {integrity: sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg==}
 | 
			
		||||
    engines: {node: '>=16.9.0'}
 | 
			
		||||
 | 
			
		||||
  https-proxy-agent@6.2.1:
 | 
			
		||||
    resolution: {integrity: sha512-ONsE3+yfZF2caH5+bJlcddtWqNI3Gvs5A38+ngvljxaBiRXRswym2c7yf8UAeFpRFKjFNHIFEHqR/OLAWJzyiA==}
 | 
			
		||||
    engines: {node: '>= 14'}
 | 
			
		||||
| 
						 | 
				
			
			@ -2653,8 +2585,8 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
 | 
			
		||||
    engines: {node: '>=12'}
 | 
			
		||||
 | 
			
		||||
  openapi-types@12.1.3:
 | 
			
		||||
    resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
 | 
			
		||||
  openapi3-ts@4.5.0:
 | 
			
		||||
    resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==}
 | 
			
		||||
 | 
			
		||||
  optionator@0.9.4:
 | 
			
		||||
    resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
 | 
			
		||||
| 
						 | 
				
			
			@ -2910,10 +2842,6 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==}
 | 
			
		||||
    engines: {node: '>=8'}
 | 
			
		||||
 | 
			
		||||
  require-directory@2.1.1:
 | 
			
		||||
    resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
 | 
			
		||||
    engines: {node: '>=0.10.0'}
 | 
			
		||||
 | 
			
		||||
  resolve-from@4.0.0:
 | 
			
		||||
    resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
 | 
			
		||||
    engines: {node: '>=4'}
 | 
			
		||||
| 
						 | 
				
			
			@ -3018,11 +2946,6 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
 | 
			
		||||
    engines: {node: '>=0.10.0'}
 | 
			
		||||
 | 
			
		||||
  srvx@0.8.9:
 | 
			
		||||
    resolution: {integrity: sha512-wYc3VLZHRzwYrWJhkEqkhLb31TI0SOkfYZDkUhXdp3NoCnNS0FqajiQszZZjfow/VYEuc6Q5sZh9nM6kPy2NBQ==}
 | 
			
		||||
    engines: {node: '>=20.16.0'}
 | 
			
		||||
    hasBin: true
 | 
			
		||||
 | 
			
		||||
  stackback@0.0.2:
 | 
			
		||||
    resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -3068,10 +2991,6 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
 | 
			
		||||
    engines: {node: '>=8'}
 | 
			
		||||
 | 
			
		||||
  strtok3@10.3.4:
 | 
			
		||||
    resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
 | 
			
		||||
    engines: {node: '>=18'}
 | 
			
		||||
 | 
			
		||||
  sucrase@3.35.0:
 | 
			
		||||
    resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==}
 | 
			
		||||
    engines: {node: '>=16 || 14 >=14.17'}
 | 
			
		||||
| 
						 | 
				
			
			@ -3155,10 +3074,6 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
 | 
			
		||||
    engines: {node: '>=8.0'}
 | 
			
		||||
 | 
			
		||||
  token-types@6.1.1:
 | 
			
		||||
    resolution: {integrity: sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==}
 | 
			
		||||
    engines: {node: '>=14.16'}
 | 
			
		||||
 | 
			
		||||
  ts-api-utils@1.4.3:
 | 
			
		||||
    resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==}
 | 
			
		||||
    engines: {node: '>=16'}
 | 
			
		||||
| 
						 | 
				
			
			@ -3204,10 +3119,6 @@ packages:
 | 
			
		|||
    engines: {node: '>=14.17'}
 | 
			
		||||
    hasBin: true
 | 
			
		||||
 | 
			
		||||
  uint8array-extras@1.5.0:
 | 
			
		||||
    resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
 | 
			
		||||
    engines: {node: '>=18'}
 | 
			
		||||
 | 
			
		||||
  undici-types@6.21.0:
 | 
			
		||||
    resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -3362,10 +3273,6 @@ packages:
 | 
			
		|||
  wrappy@1.0.2:
 | 
			
		||||
    resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
 | 
			
		||||
 | 
			
		||||
  y18n@5.0.8:
 | 
			
		||||
    resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
 | 
			
		||||
    engines: {node: '>=10'}
 | 
			
		||||
 | 
			
		||||
  yallist@3.1.1:
 | 
			
		||||
    resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -3374,24 +3281,16 @@ packages:
 | 
			
		|||
    engines: {node: '>= 14.6'}
 | 
			
		||||
    hasBin: true
 | 
			
		||||
 | 
			
		||||
  yargs-parser@21.1.1:
 | 
			
		||||
    resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
 | 
			
		||||
    engines: {node: '>=12'}
 | 
			
		||||
 | 
			
		||||
  yargs@17.7.2:
 | 
			
		||||
    resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
 | 
			
		||||
    engines: {node: '>=12'}
 | 
			
		||||
 | 
			
		||||
  yocto-queue@0.1.0:
 | 
			
		||||
    resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
 | 
			
		||||
    engines: {node: '>=10'}
 | 
			
		||||
 | 
			
		||||
  zod@3.23.8:
 | 
			
		||||
    resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
 | 
			
		||||
 | 
			
		||||
  zod@3.25.56:
 | 
			
		||||
    resolution: {integrity: sha512-rd6eEF3BTNvQnR2e2wwolfTmUTnp70aUTqr0oaGbHifzC3BKJsoV+Gat8vxUMR1hwOKBs6El+qWehrHbCpW6SQ==}
 | 
			
		||||
 | 
			
		||||
  zod@4.1.12:
 | 
			
		||||
    resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
 | 
			
		||||
 | 
			
		||||
snapshots:
 | 
			
		||||
 | 
			
		||||
  '@alloc/quick-lru@5.2.0': {}
 | 
			
		||||
| 
						 | 
				
			
			@ -3403,6 +3302,11 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  '@antfu/ni@0.21.12': {}
 | 
			
		||||
 | 
			
		||||
  '@asteasolutions/zod-to-openapi@8.1.0(zod@4.1.12)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      openapi3-ts: 4.5.0
 | 
			
		||||
      zod: 4.1.12
 | 
			
		||||
 | 
			
		||||
  '@babel/code-frame@7.27.1':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@babel/helper-validator-identifier': 7.27.1
 | 
			
		||||
| 
						 | 
				
			
			@ -3600,31 +3504,6 @@ snapshots:
 | 
			
		|||
  '@biomejs/cli-win32-x64@1.6.3':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@borewit/text-codec@0.1.1':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@elysiajs/cors@1.3.3(elysia@1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3))':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      elysia: 1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3)
 | 
			
		||||
 | 
			
		||||
  '@elysiajs/html@1.3.1(elysia@1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3))(typescript@5.8.3)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@kitajs/html': 4.2.9
 | 
			
		||||
      '@kitajs/ts-html-plugin': 4.1.2(@kitajs/html@4.2.9)(typescript@5.8.3)
 | 
			
		||||
      elysia: 1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3)
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - typescript
 | 
			
		||||
 | 
			
		||||
  '@elysiajs/node@1.4.1(elysia@1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3))':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      crossws: 0.4.1(srvx@0.8.9)
 | 
			
		||||
      elysia: 1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3)
 | 
			
		||||
      srvx: 0.8.9
 | 
			
		||||
 | 
			
		||||
  '@elysiajs/openapi@1.4.11(elysia@1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3))':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      elysia: 1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3)
 | 
			
		||||
 | 
			
		||||
  '@esbuild/aix-ppc64@0.21.5':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -3809,6 +3688,28 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  '@floating-ui/utils@0.2.9': {}
 | 
			
		||||
 | 
			
		||||
  '@hono/node-server@1.19.6(hono@4.10.4)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      hono: 4.10.4
 | 
			
		||||
 | 
			
		||||
  '@hono/standard-validator@0.1.5(@standard-schema/spec@1.0.0)(hono@4.10.4)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@standard-schema/spec': 1.0.0
 | 
			
		||||
      hono: 4.10.4
 | 
			
		||||
 | 
			
		||||
  '@hono/zod-openapi@1.1.4(hono@4.10.4)(zod@4.1.12)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@asteasolutions/zod-to-openapi': 8.1.0(zod@4.1.12)
 | 
			
		||||
      '@hono/zod-validator': 0.7.4(hono@4.10.4)(zod@4.1.12)
 | 
			
		||||
      hono: 4.10.4
 | 
			
		||||
      openapi3-ts: 4.5.0
 | 
			
		||||
      zod: 4.1.12
 | 
			
		||||
 | 
			
		||||
  '@hono/zod-validator@0.7.4(hono@4.10.4)(zod@4.1.12)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      hono: 4.10.4
 | 
			
		||||
      zod: 4.1.12
 | 
			
		||||
 | 
			
		||||
  '@humanwhocodes/config-array@0.13.0':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@humanwhocodes/object-schema': 2.0.3
 | 
			
		||||
| 
						 | 
				
			
			@ -3849,18 +3750,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  '@jsr/std__async@1.0.13': {}
 | 
			
		||||
 | 
			
		||||
  '@kitajs/html@4.2.9':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      csstype: 3.1.3
 | 
			
		||||
 | 
			
		||||
  '@kitajs/ts-html-plugin@4.1.2(@kitajs/html@4.2.9)(typescript@5.8.3)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@kitajs/html': 4.2.9
 | 
			
		||||
      chalk: 4.1.2
 | 
			
		||||
      tslib: 2.8.1
 | 
			
		||||
      typescript: 5.8.3
 | 
			
		||||
      yargs: 17.7.2
 | 
			
		||||
 | 
			
		||||
  '@nodelib/fs.scandir@2.1.5':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@nodelib/fs.stat': 2.0.5
 | 
			
		||||
| 
						 | 
				
			
			@ -4350,7 +4239,7 @@ snapshots:
 | 
			
		|||
  '@rollup/rollup-win32-x64-msvc@4.42.0':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@sinclair/typebox@0.34.41': {}
 | 
			
		||||
  '@standard-schema/spec@1.0.0': {}
 | 
			
		||||
 | 
			
		||||
  '@swc/core-darwin-arm64@1.11.31':
 | 
			
		||||
    optional: true
 | 
			
		||||
| 
						 | 
				
			
			@ -4412,18 +4301,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  '@tanstack/virtual-core@3.13.9': {}
 | 
			
		||||
 | 
			
		||||
  '@tokenizer/inflate@0.2.7':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      debug: 4.4.3
 | 
			
		||||
      fflate: 0.8.2
 | 
			
		||||
      token-types: 6.1.1
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - supports-color
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@tokenizer/token@0.3.0':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@ts-morph/common@0.19.0':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      fast-glob: 3.3.3
 | 
			
		||||
| 
						 | 
				
			
			@ -4812,12 +4689,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  cli-spinners@2.9.2: {}
 | 
			
		||||
 | 
			
		||||
  cliui@8.0.1:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      string-width: 4.2.3
 | 
			
		||||
      strip-ansi: 6.0.1
 | 
			
		||||
      wrap-ansi: 7.0.0
 | 
			
		||||
 | 
			
		||||
  clone@1.0.4: {}
 | 
			
		||||
 | 
			
		||||
  clsx@2.1.1: {}
 | 
			
		||||
| 
						 | 
				
			
			@ -4844,10 +4715,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  convert-source-map@2.0.0: {}
 | 
			
		||||
 | 
			
		||||
  cookie-es@2.0.0: {}
 | 
			
		||||
 | 
			
		||||
  cookie@1.0.2: {}
 | 
			
		||||
 | 
			
		||||
  cosmiconfig@8.3.6(typescript@5.8.3):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      import-fresh: 3.3.1
 | 
			
		||||
| 
						 | 
				
			
			@ -4863,10 +4730,6 @@ snapshots:
 | 
			
		|||
      shebang-command: 2.0.0
 | 
			
		||||
      which: 2.0.2
 | 
			
		||||
 | 
			
		||||
  crossws@0.4.1(srvx@0.8.9):
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      srvx: 0.8.9
 | 
			
		||||
 | 
			
		||||
  cssesc@3.0.0: {}
 | 
			
		||||
 | 
			
		||||
  csstype@3.1.3: {}
 | 
			
		||||
| 
						 | 
				
			
			@ -4915,11 +4778,6 @@ snapshots:
 | 
			
		|||
    dependencies:
 | 
			
		||||
      ms: 2.1.3
 | 
			
		||||
 | 
			
		||||
  debug@4.4.3:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      ms: 2.1.3
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  decimal.js-light@2.5.1: {}
 | 
			
		||||
 | 
			
		||||
  decompress-response@6.0.0:
 | 
			
		||||
| 
						 | 
				
			
			@ -4975,17 +4833,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  electron-to-chromium@1.5.165: {}
 | 
			
		||||
 | 
			
		||||
  elysia@1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@sinclair/typebox': 0.34.41
 | 
			
		||||
      cookie: 1.0.2
 | 
			
		||||
      exact-mirror: 0.2.0(@sinclair/typebox@0.34.41)
 | 
			
		||||
      fast-decode-uri-component: 1.0.1
 | 
			
		||||
      openapi-types: 12.1.3
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      file-type: 21.0.0
 | 
			
		||||
      typescript: 5.8.3
 | 
			
		||||
 | 
			
		||||
  emoji-regex@8.0.0: {}
 | 
			
		||||
 | 
			
		||||
  emoji-regex@9.2.2: {}
 | 
			
		||||
| 
						 | 
				
			
			@ -5148,10 +4995,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  eventemitter3@4.0.7: {}
 | 
			
		||||
 | 
			
		||||
  exact-mirror@0.2.0(@sinclair/typebox@0.34.41):
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      '@sinclair/typebox': 0.34.41
 | 
			
		||||
 | 
			
		||||
  execa@7.2.0:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      cross-spawn: 7.0.6
 | 
			
		||||
| 
						 | 
				
			
			@ -5168,8 +5011,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  expect-type@1.2.1: {}
 | 
			
		||||
 | 
			
		||||
  fast-decode-uri-component@1.0.1: {}
 | 
			
		||||
 | 
			
		||||
  fast-deep-equal@3.1.3: {}
 | 
			
		||||
 | 
			
		||||
  fast-equals@5.2.2: {}
 | 
			
		||||
| 
						 | 
				
			
			@ -5195,23 +5036,10 @@ snapshots:
 | 
			
		|||
      node-domexception: 1.0.0
 | 
			
		||||
      web-streams-polyfill: 3.3.3
 | 
			
		||||
 | 
			
		||||
  fflate@0.8.2:
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  file-entry-cache@6.0.1:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      flat-cache: 3.2.0
 | 
			
		||||
 | 
			
		||||
  file-type@21.0.0:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@tokenizer/inflate': 0.2.7
 | 
			
		||||
      strtok3: 10.3.4
 | 
			
		||||
      token-types: 6.1.1
 | 
			
		||||
      uint8array-extras: 1.5.0
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - supports-color
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  file-uri-to-path@1.0.0: {}
 | 
			
		||||
 | 
			
		||||
  fill-range@7.1.1:
 | 
			
		||||
| 
						 | 
				
			
			@ -5259,8 +5087,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  gensync@1.0.0-beta.2: {}
 | 
			
		||||
 | 
			
		||||
  get-caller-file@2.0.5: {}
 | 
			
		||||
 | 
			
		||||
  get-nonce@1.0.1: {}
 | 
			
		||||
 | 
			
		||||
  get-stream@6.0.1: {}
 | 
			
		||||
| 
						 | 
				
			
			@ -5332,6 +5158,8 @@ snapshots:
 | 
			
		|||
    dependencies:
 | 
			
		||||
      function-bind: 1.1.2
 | 
			
		||||
 | 
			
		||||
  hono@4.10.4: {}
 | 
			
		||||
 | 
			
		||||
  https-proxy-agent@6.2.1:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      agent-base: 7.1.3
 | 
			
		||||
| 
						 | 
				
			
			@ -5615,7 +5443,9 @@ snapshots:
 | 
			
		|||
    dependencies:
 | 
			
		||||
      mimic-fn: 4.0.0
 | 
			
		||||
 | 
			
		||||
  openapi-types@12.1.3: {}
 | 
			
		||||
  openapi3-ts@4.5.0:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      yaml: 2.8.0
 | 
			
		||||
 | 
			
		||||
  optionator@0.9.4:
 | 
			
		||||
    dependencies:
 | 
			
		||||
| 
						 | 
				
			
			@ -5899,8 +5729,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  regexparam@3.0.0: {}
 | 
			
		||||
 | 
			
		||||
  require-directory@2.1.1: {}
 | 
			
		||||
 | 
			
		||||
  resolve-from@4.0.0: {}
 | 
			
		||||
 | 
			
		||||
  resolve-pkg-maps@1.0.0: {}
 | 
			
		||||
| 
						 | 
				
			
			@ -6026,10 +5854,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  source-map@0.6.1: {}
 | 
			
		||||
 | 
			
		||||
  srvx@0.8.9:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      cookie-es: 2.0.0
 | 
			
		||||
 | 
			
		||||
  stackback@0.0.2: {}
 | 
			
		||||
 | 
			
		||||
  std-env@3.9.0: {}
 | 
			
		||||
| 
						 | 
				
			
			@ -6070,11 +5894,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  strip-json-comments@3.1.1: {}
 | 
			
		||||
 | 
			
		||||
  strtok3@10.3.4:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@tokenizer/token': 0.3.0
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  sucrase@3.35.0:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@jridgewell/gen-mapping': 0.3.8
 | 
			
		||||
| 
						 | 
				
			
			@ -6186,13 +6005,6 @@ snapshots:
 | 
			
		|||
    dependencies:
 | 
			
		||||
      is-number: 7.0.0
 | 
			
		||||
 | 
			
		||||
  token-types@6.1.1:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@borewit/text-codec': 0.1.1
 | 
			
		||||
      '@tokenizer/token': 0.3.0
 | 
			
		||||
      ieee754: 1.2.1
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  ts-api-utils@1.4.3(typescript@5.8.3):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      typescript: 5.8.3
 | 
			
		||||
| 
						 | 
				
			
			@ -6233,9 +6045,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  typescript@5.8.3: {}
 | 
			
		||||
 | 
			
		||||
  uint8array-extras@1.5.0:
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  undici-types@6.21.0: {}
 | 
			
		||||
 | 
			
		||||
  undici-types@7.8.0: {}
 | 
			
		||||
| 
						 | 
				
			
			@ -6457,26 +6266,12 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  wrappy@1.0.2: {}
 | 
			
		||||
 | 
			
		||||
  y18n@5.0.8: {}
 | 
			
		||||
 | 
			
		||||
  yallist@3.1.1: {}
 | 
			
		||||
 | 
			
		||||
  yaml@2.8.0: {}
 | 
			
		||||
 | 
			
		||||
  yargs-parser@21.1.1: {}
 | 
			
		||||
 | 
			
		||||
  yargs@17.7.2:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      cliui: 8.0.1
 | 
			
		||||
      escalade: 3.2.0
 | 
			
		||||
      get-caller-file: 2.0.5
 | 
			
		||||
      require-directory: 2.1.1
 | 
			
		||||
      string-width: 4.2.3
 | 
			
		||||
      y18n: 5.0.8
 | 
			
		||||
      yargs-parser: 21.1.1
 | 
			
		||||
 | 
			
		||||
  yocto-queue@0.1.0: {}
 | 
			
		||||
 | 
			
		||||
  zod@3.23.8: {}
 | 
			
		||||
 | 
			
		||||
  zod@3.25.56: {}
 | 
			
		||||
 | 
			
		||||
  zod@4.1.12: {}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue