From f3b720a07c12044d2cc1e796fcfe62698c21bd85 Mon Sep 17 00:00:00 2001 From: monoid Date: Fri, 31 Oct 2025 23:23:48 +0900 Subject: [PATCH] feat: (BREAKING!) migrate hono --- packages/dbtype/package.json | 4 +- packages/dbtype/src/api.ts | 14 +- packages/server/package.json | 13 +- packages/server/src/SettingConfig.ts | 12 +- packages/server/src/controller.ts | 1 - packages/server/src/db/user.ts | 3 +- packages/server/src/diff/router.ts | 98 ++-- packages/server/src/login.ts | 316 +++++++------ packages/server/src/model/user.ts | 3 +- packages/server/src/permission/permission.ts | 104 ++--- packages/server/src/route/comic.ts | 12 +- packages/server/src/route/contents.ts | 435 +++++++++++------- packages/server/src/route/error_handler.ts | 40 +- packages/server/src/route/settings.ts | 56 ++- packages/server/src/route/tags.ts | 61 ++- packages/server/src/server.ts | 253 +++++----- packages/server/src/setting.ts | 7 - packages/server/src/util/configRW.ts | 1 - packages/server/src/util/static.ts | 78 ++-- .../tests/diff-router.integration.test.ts | 40 +- packages/server/tests/error_handler.test.ts | 47 +- .../tests/settings-router.integration.test.ts | 160 ++++--- pnpm-lock.yaml | 397 ++++------------ 23 files changed, 1042 insertions(+), 1113 deletions(-) delete mode 100644 packages/server/src/setting.ts delete mode 100644 packages/server/src/util/configRW.ts diff --git a/packages/dbtype/package.json b/packages/dbtype/package.json index 69bbcb6..4b95665 100644 --- a/packages/dbtype/package.json +++ b/packages/dbtype/package.json @@ -17,7 +17,7 @@ "typescript": "^5.4.3" }, "type": "module", - "dependencies": { - "zod": "^3.23.8" + "peerDependencies": { + "zod": "^4.1.12" } } diff --git a/packages/dbtype/src/api.ts b/packages/dbtype/src/api.ts index a5f2d1f..b3b5940 100644 --- a/packages/dbtype/src/api.ts +++ b/packages/dbtype/src/api.ts @@ -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; + 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; export const PermissionSchema = z.object({ username: z.string(), - name: z.string(), + name: PermissionNameSchema, }); export type Permission = z.infer; @@ -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; @@ -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; diff --git a/packages/server/package.json b/packages/server/package.json index 06ba9f5..de6e11e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/SettingConfig.ts b/packages/server/src/SettingConfig.ts index 637d489..e57fe3a 100644 --- a/packages/server/src/SettingConfig.ts +++ b/packages/server/src/SettingConfig.ts @@ -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): Promise): PersistedSetting => { - const validPermissions = new Set(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, diff --git a/packages/server/src/controller.ts b/packages/server/src/controller.ts index 422edc2..466a001 100644 --- a/packages/server/src/controller.ts +++ b/packages/server/src/controller.ts @@ -1,4 +1,3 @@ -import Elysia from "elysia"; import { connectDB } from "./database.ts"; import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts"; diff --git a/packages/server/src/db/user.ts b/packages/server/src/db/user.ts index d73cf2e..4c71334 100644 --- a/packages/server/src/db/user.ts +++ b/packages/server/src/db/user.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 diff --git a/packages/server/src/diff/router.ts b/packages/server/src/diff/router.ts index bf76284..1531c59 100644 --- a/packages/server/src/diff/router.ts +++ b/packages/server/src/diff/router.ts @@ -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(); + + 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; +}; diff --git a/packages/server/src/login.ts b/packages/server/src/login.ts index 9e3fc3e..87b6ef4 100644 --- a/packages/server/src/login.ts +++ b/packages/server/src/login.ts @@ -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(token: string, secret: string): Promise { 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, 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, userController: UserAccessor, options: { forceRefresh?: boolean } = {}, ): Promise { 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(accessValue, secretKey); + const payload = await verifyToken(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(accessValue, secretKey); + const payload = await verifyToken(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(); - 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); - 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 => + 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) { diff --git a/packages/server/src/model/user.ts b/packages/server/src/model/user.ts index b0adc1f..99398d0 100644 --- a/packages/server/src/model/user.ts +++ b/packages/server/src/model/user.ts @@ -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; + get_permissions(): Promise; /** * add permission * @param name permission name to add diff --git a/packages/server/src/permission/permission.ts b/packages/server/src/permission/permission.ts index ce79ef0..1db6dc0 100644 --- a/packages/server/src/permission/permission.ts +++ b/packages/server/src/permission/permission.ts @@ -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(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): Permission[] => { + if (!values) { + return []; + } + const normalized = new Set(); + 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; - -const resolveUser = (context: PermissionCheckContext): UserState["user"] => { - const user = context.user ?? context.store?.user; - if (!user) { +const resolveUser = (c: Context): 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 => 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 = async (c, next) => { + const user = resolveUser(c); + if (user.username !== "admin") { + throw sendError(403, "admin only"); + } + await next(); }; diff --git a/packages/server/src/route/comic.ts b/packages/server/src/route/comic.ts index e6dad92..3c3227c 100644 --- a/packages/server/src/route/comic.ts +++ b/packages/server/src/route/comic.ts @@ -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; +export type ResponseMeta = { + status: number; + headers: Record; +}; 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(); diff --git a/packages/server/src/route/contents.ts b/packages/server/src/route/contents.ts index 347f119..849c6dd 100644 --- a/packages/server/src/route/contents.ts +++ b/packages/server/src/route/contents.ts @@ -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 = (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(); + + 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 & { 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 & { id: number } = { id: num, - ...body, + ...(body as Record), }; - 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; diff --git a/packages/server/src/route/error_handler.ts b/packages/server/src/route/error_handler.ts index 499f028..9fc46c7 100644 --- a/packages/server/src/route/error_handler.ts +++ b/packages/server/src/route/error_handler.ts @@ -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 => { diff --git a/packages/server/src/route/settings.ts b/packages/server/src/route/settings.ts index 6e1ce2e..e61b782 100644 --- a/packages/server/src/route/settings.ts +++ b/packages/server/src/route/settings.ts @@ -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; +type UpdateBody = z.infer; 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) => - 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) => { + const router = new Hono(); + + 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) => guest: payload.guest, }; await updatePersistedSetting(db, update); - return buildResponse(); - }, { - beforeHandle: AdminOnly, - body: updateBodySchema, - }); + return c.json(buildResponse()); + }, + ); + + return router; +}; export default createSettingsRouter; diff --git a/packages/server/src/route/tags.ts b/packages/server/src/route/tags.ts index 03212ac..50e3a7c 100644 --- a/packages/server/src/route/tags.ts +++ b/packages/server/src/route/tags.ts @@ -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(); + + 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; } diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 1bcaa7a..96c0585 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -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 ``; + return ``; } -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("", tagContent); + return html.replace("", 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((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(); - 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(); + 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; } \ No newline at end of file diff --git a/packages/server/src/setting.ts b/packages/server/src/setting.ts deleted file mode 100644 index ddcf004..0000000 --- a/packages/server/src/setting.ts +++ /dev/null @@ -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() })); \ No newline at end of file diff --git a/packages/server/src/util/configRW.ts b/packages/server/src/util/configRW.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/packages/server/src/util/configRW.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/server/src/util/static.ts b/packages/server/src/util/static.ts index 0ef3d87..d423c2e 100644 --- a/packages/server/src/util/static.ts +++ b/packages/server/src/util/static.ts @@ -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 = { @@ -46,36 +48,44 @@ export type StaticPluginOptions = { headers?: Record; }; +const buildResponse = (status: number, headers: Record, 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, - 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 = { @@ -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; + 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; }; diff --git a/packages/server/tests/diff-router.integration.test.ts b/packages/server/tests/diff-router.integration.test.ts index 7f8e20a..69bedc6 100644 --- a/packages/server/tests/diff-router.integration.test.ts +++ b/packages/server/tests/diff-router.integration.test.ts @@ -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(); - 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(); diff --git a/packages/server/tests/error_handler.test.ts b/packages/server/tests/error_handler.test.ts index bcd24ab..aa2f5da 100644 --- a/packages/server/tests/error_handler.test.ts +++ b/packages/server/tests/error_handler.test.ts @@ -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", diff --git a/packages/server/tests/settings-router.integration.test.ts b/packages/server/tests/settings-router.integration.test.ts index 55be397..94037ea 100644 --- a/packages/server/tests/settings-router.integration.test.ts +++ b/packages/server/tests/settings-router.integration.test.ts @@ -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(); + 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"], + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 866e2c8..9991a6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} -- 2.39.5