diff --git a/packages/client/src/components/ServerSettingCard.tsx b/packages/client/src/components/ServerSettingCard.tsx new file mode 100644 index 0000000..5861358 --- /dev/null +++ b/packages/client/src/components/ServerSettingCard.tsx @@ -0,0 +1,279 @@ +import { useEffect, useMemo, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Spinner } from "@/components/Spinner"; +import type { ServerSettingResponse, ServerSettingUpdate } from "dbtype/mod.ts"; +import { ApiError as FetchApiError } from "@/hook/fetcher"; + +const createSnapshot = (setting: ServerSettingResponse["persisted"]) => ({ + secure: setting.secure, + cli: setting.cli, + forbid_remote_admin_login: setting.forbid_remote_admin_login, + guest: [...setting.guest], +}); + +type FormState = ReturnType; + +type FeedbackState = { type: "success" | "error"; message: string } | null; + +type ServerSettingCardProps = { + isAdmin: boolean; + loading: boolean; + error: unknown; + setting?: ServerSettingResponse; + onSave: (payload: ServerSettingUpdate) => Promise; +}; + +const defaultState: FormState = { + secure: true, + cli: false, + forbid_remote_admin_login: true, + guest: [], +}; + +const areArraysEqual = (a: string[], b: string[]) => { + if (a.length !== b.length) { + return false; + } + return a.every((value, index) => value === b[index]); +}; + +export function ServerSettingCard({ isAdmin, loading, error, setting, onSave }: ServerSettingCardProps) { + const [formState, setFormState] = useState(() => (setting ? createSnapshot(setting.persisted) : defaultState)); + const [saving, setSaving] = useState(false); + const [feedback, setFeedback] = useState(null); + + useEffect(() => { + if (setting) { + setFormState(createSnapshot(setting.persisted)); + setFeedback(null); + } + }, [setting]); + + const permissionOptions = useMemo(() => setting?.permissions ?? [], [setting]); + + const baselinePersisted = useMemo(() => setting ? createSnapshot(setting.persisted) : defaultState, [setting]); + const sortedGuest = useMemo(() => [...formState.guest].sort(), [formState.guest]); + const baselineGuest = useMemo(() => [...baselinePersisted.guest].sort(), [baselinePersisted.guest]); + + const guestChanged = !areArraysEqual(sortedGuest, baselineGuest); + + const hasChanges = setting + ? formState.secure !== baselinePersisted.secure + || formState.cli !== baselinePersisted.cli + || formState.forbid_remote_admin_login !== baselinePersisted.forbid_remote_admin_login + || guestChanged + : false; + + const errorMessage = useMemo(() => { + if (!error) { + return null; + } + if (error instanceof FetchApiError) { + return `(${error.status}) ${error.message}`; + } + if (error instanceof Error) { + return error.message; + } + return String(error); + }, [error]); + + const toggleGuestPermission = (permission: string) => { + setFormState((prev) => ({ + ...prev, + guest: prev.guest.includes(permission) + ? prev.guest.filter((value) => value !== permission) + : [...prev.guest, permission], + })); + setFeedback(null); + }; + + const onChangeBoolean = (key: keyof FormState) => (value: boolean) => { + setFormState((prev) => ({ + ...prev, + [key]: value, + })); + setFeedback(null); + }; + + const handleSave = async () => { + if (!setting) { + return; + } + + const payload: ServerSettingUpdate = {}; + if (formState.secure !== baselinePersisted.secure) { + payload.secure = formState.secure; + } + if (formState.cli !== baselinePersisted.cli) { + payload.cli = formState.cli; + } + if (formState.forbid_remote_admin_login !== baselinePersisted.forbid_remote_admin_login) { + payload.forbid_remote_admin_login = formState.forbid_remote_admin_login; + } + if (guestChanged) { + payload.guest = sortedGuest; + } + + if (Object.keys(payload).length === 0) { + setFeedback({ type: "success", message: "변경 사항이 없습니다." }); + return; + } + + try { + setSaving(true); + const updated = await onSave(payload); + setFormState(createSnapshot(updated.persisted)); + setFeedback({ type: "success", message: "서버 설정을 저장했습니다." }); + } catch (err) { + const message = err instanceof FetchApiError + ? `(${err.status}) ${err.message}` + : err instanceof Error + ? err.message + : "설정 저장 중 오류가 발생했습니다."; + setFeedback({ type: "error", message }); + } finally { + setSaving(false); + } + }; + + return ( + + + Server Settings + + + {!isAdmin ? ( +

+ 관리자 계정만 서버 설정을 확인하고 수정할 수 있습니다. +

+ ) : ( +
+ {errorMessage && ( +
+ {errorMessage} +
+ )} + {loading && !setting ? ( +
+ + 서버 설정을 불러오는 중… +
+ ) : null} + {setting && ( + <> +
+
+

Hostname

+

{setting.env.hostname}

+
+
+

Port

+

{setting.env.port}

+
+
+

Mode

+

{setting.env.mode}

+
+
+ +
+ + + +
+ +
+
+

게스트 권한

+

로그인하지 않은 방문자에게 허용할 권한을 선택하세요.

+
+
+ {permissionOptions.map((permission) => { + const active = formState.guest.includes(permission); + return ( + + ); + })} + {permissionOptions.length === 0 && ( + 정의된 권한이 없습니다. + )} +
+
+ {formState.guest.length === 0 ? ( + 게스트는 로그인 페이지 외 접근 권한이 없습니다. + ) : ( + formState.guest.map((permission) => ( + + {permission} + + )) + )} +
+
+ + {feedback && ( +
+ {feedback.message} +
+ )} + +
+ +
+ + )} +
+ )} +
+
+ ); +} + +export default ServerSettingCard; diff --git a/packages/client/src/hook/useServerSettings.ts b/packages/client/src/hook/useServerSettings.ts new file mode 100644 index 0000000..f4f3f2a --- /dev/null +++ b/packages/client/src/hook/useServerSettings.ts @@ -0,0 +1,10 @@ +import useSWR from "swr"; +import { fetcher } from "./fetcher"; +import type { ServerSettingResponse } from "dbtype/mod.ts"; + +export function useServerSettings(enabled: boolean) { + return useSWR( + enabled ? "/api/settings" : null, + (url: string) => fetcher(url, { credentials: "include" }), + ); +} diff --git a/packages/client/src/page/settingPage.tsx b/packages/client/src/page/settingPage.tsx index c66cdeb..9a55dcc 100644 --- a/packages/client/src/page/settingPage.tsx +++ b/packages/client/src/page/settingPage.tsx @@ -1,8 +1,14 @@ +import { useCallback } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { type TernaryDarkMode, useTernaryDarkMode, useMediaQuery } from "usehooks-ts"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Label } from "@/components/ui/label"; import BuildInfoCard from "@/components/BuildInfoCard"; +import { useServerSettings } from "@/hook/useServerSettings"; +import { useLogin } from "@/state/user"; +import { fetcher } from "@/hook/fetcher"; +import type { ServerSettingResponse, ServerSettingUpdate } from "dbtype/mod.ts"; +import { ServerSettingCard } from "@/components/ServerSettingCard"; function LightModeView() { return
@@ -45,9 +51,25 @@ function DarkModeView() { export function SettingPage() { const { setTernaryDarkMode, ternaryDarkMode } = useTernaryDarkMode(); const isSystemDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); + const login = useLogin(); + const isAdmin = login?.username === "admin"; + + const { data: serverSetting, error: serverError, isLoading: serverLoading, mutate } = useServerSettings(isAdmin); + + const handleSave = useCallback(async (payload: ServerSettingUpdate) => { + const response = await fetcher("/api/settings", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + credentials: "include", + }); + const updated = response as ServerSettingResponse; + await mutate(updated, false); + return updated; + }, [mutate]); return ( -
+
Settings @@ -86,6 +108,13 @@ export function SettingPage() {
+
) diff --git a/packages/dbtype/src/api.ts b/packages/dbtype/src/api.ts index 6ee2075..a5f2d1f 100644 --- a/packages/dbtype/src/api.ts +++ b/packages/dbtype/src/api.ts @@ -97,4 +97,29 @@ export const LoginResetRequestSchema = z.object({ newpassword: z.string(), }); -export type LoginResetRequest = z.infer; \ No newline at end of file +export type LoginResetRequest = z.infer; + +export const ServerPersistedSettingSchema = z.object({ + secure: z.boolean(), + cli: z.boolean(), + forbid_remote_admin_login: z.boolean(), + guest: z.array(z.string()), +}); + +export type ServerPersistedSetting = z.infer; + +export const ServerSettingResponseSchema = z.object({ + env: z.object({ + hostname: z.string(), + port: z.number(), + mode: z.enum(["development", "production"]), + }), + persisted: ServerPersistedSettingSchema, + permissions: z.array(z.string()), +}); + +export type ServerSettingResponse = z.infer; + +export const ServerSettingUpdateSchema = ServerPersistedSettingSchema.partial(); + +export type ServerSettingUpdate = z.infer; \ No newline at end of file diff --git a/packages/dbtype/src/types.ts b/packages/dbtype/src/types.ts index d7bd3d2..86ab377 100644 --- a/packages/dbtype/src/types.ts +++ b/packages/dbtype/src/types.ts @@ -50,6 +50,11 @@ export interface UserSettings { settings: string | null; } +export interface AppConfig { + key: string; + value: string; +} + export interface DB { doc_tag_relation: DocTagRelation; document: Document; @@ -58,4 +63,5 @@ export interface DB { tags: Tags; users: Users; user_settings: UserSettings; + app_config: AppConfig; } diff --git a/packages/server/migrations/2025-09-30.ts b/packages/server/migrations/2025-09-30.ts new file mode 100644 index 0000000..e2ab26f --- /dev/null +++ b/packages/server/migrations/2025-09-30.ts @@ -0,0 +1,22 @@ +import { Kysely } from "kysely"; + +const CONFIG_TABLE = "app_config"; +const SCHEMA_VERSION = "2025-09-30"; + +export async function up(db: Kysely) { + await db.schema + .createTable(CONFIG_TABLE) + .ifNotExists() + .addColumn("key", "varchar", (col) => col.notNull().primaryKey()) + .addColumn("value", "text", (col) => col.notNull()) + .execute(); + + await db + .updateTable("schema_migration") + .set({ version: SCHEMA_VERSION, dirty: 0 }) + .execute(); +} + +export async function down(_db: Kysely) { + throw new Error("Downward migrations are not supported. Restore from backup."); +} diff --git a/packages/server/package.json b/packages/server/package.json index ee9a1ef..9e02fd3 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -7,7 +7,9 @@ "scripts": { "dev": "tsx watch src/app.ts", "start": "tsx src/app.ts", - "migrate": "tsx tools/migration.ts" + "migrate": "tsx tools/migration.ts", + "test": "vitest run", + "test:watch": "vitest" }, "author": "", "license": "ISC", @@ -29,10 +31,10 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", - "@types/koa-compose": "^3.2.8", "@types/node": "^22.15.33", "@types/tiny-async-pool": "^1.0.5", "tsx": "^4.20.3", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^2.1.3" } } diff --git a/packages/server/src/SettingConfig.ts b/packages/server/src/SettingConfig.ts index fe08b33..77611fc 100644 --- a/packages/server/src/SettingConfig.ts +++ b/packages/server/src/SettingConfig.ts @@ -1,80 +1,160 @@ -import { randomBytes } from "node:crypto"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import type { Permission } from "./permission/permission.ts"; +import { existsSync, readFileSync } from "node:fs"; +import type { Kysely } from "kysely"; +import type { db } from "dbtype"; +import { Permission } from "./permission/permission.ts"; +import { getAppConfig, upsertAppConfig } from "./db/config.ts"; export interface SettingConfig { - /** - * if true, server will bind on '127.0.0.1' rather than '0.0.0.0' - */ - localmode: boolean; - /** - * secure only - */ - secure: boolean; - - /** - * guest permission - */ - guest: Permission[]; - /** - * JWT secret key. if you change its value, all access tokens are invalidated. - */ - jwt_secretkey: string; - /** - * the port which running server is binding on. - */ + hostname: string; port: number; - mode: "development" | "production"; - /** - * if true, do not show 'electron' window and show terminal only. - */ + secure: boolean; + guest: Permission[]; + jwt_secretkey: string; cli: boolean; - /** forbid to login admin from remote client. but, it do not invalidate access token. - * if you want to invalidate access token, change 'jwt_secretkey'. */ forbid_remote_admin_login: boolean; } -const default_setting: SettingConfig = { - localmode: true, + +export type PersistedSetting = Pick; +type EnvSetting = Pick; + +const CONFIG_KEY = "server.settings"; +const LEGACY_SETTINGS_PATH = "settings.json"; + +const persistedDefault: PersistedSetting = { secure: true, guest: [], - jwt_secretkey: "itsRandom", - port: 8080, - mode: "production", cli: false, forbid_remote_admin_login: true, }; -let setting: null | SettingConfig = null; -// biome-ignore lint/suspicious/noExplicitAny: -const setEmptyToDefault = (target: any, default_table: SettingConfig) => { - let diff_occur = false; - for (const key in default_table) { - if (key === undefined || key in target) { - continue; - } - target[key] = default_table[key as keyof SettingConfig]; - diff_occur = true; +let cachedSetting: SettingConfig | null = null; + +export const initializeSetting = async (db: Kysely): Promise => { + if (cachedSetting) { + return cachedSetting; } - return diff_occur; + + const persisted = await loadPersistedSetting(db); + const envSetting = loadEnvSetting(); + + cachedSetting = { + ...persisted, + ...envSetting, + }; + + return cachedSetting; }; -export const read_setting_from_file = () => { - const ret = existsSync("settings.json") ? JSON.parse(readFileSync("settings.json", { encoding: "utf8" })) : {}; - const partial_occur = setEmptyToDefault(ret, default_setting); - if (partial_occur) { - writeFileSync("settings.json", JSON.stringify(ret)); - } - return ret as SettingConfig; +export const refreshSetting = async (db: Kysely): Promise => { + cachedSetting = null; + return initializeSetting(db); }; -export function get_setting(): SettingConfig { - if (setting === null) { - setting = read_setting_from_file(); - const env = process.env.NODE_ENV; - if (env !== undefined && env !== "production" && env !== "development") { - throw new Error('process unknown value in NODE_ENV: must be either "development" or "production"'); - } - setting.mode = env ?? setting.mode; + +export type PersistedSettingUpdate = Partial; + +export const updatePersistedSetting = async ( + db: Kysely, + patch: PersistedSettingUpdate, +): Promise => { + const current = await initializeSetting(db); + const basePersisted: PersistedSetting = { + secure: current.secure, + guest: current.guest, + cli: current.cli, + forbid_remote_admin_login: current.forbid_remote_admin_login, + }; + const nextPersisted = mergePersisted({ ...basePersisted, ...patch }); + await upsertAppConfig(db, CONFIG_KEY, nextPersisted); + cachedSetting = { + ...current, + ...nextPersisted, + }; + return cachedSetting; +}; + +export const get_setting = (): SettingConfig => { + if (!cachedSetting) { + throw new Error("Settings have not been initialized. Call initializeSetting first."); } - return setting; -} + return cachedSetting; +}; + +const loadEnvSetting = (): EnvSetting => { + const host = process.env.SERVER_HOST ?? process.env.HOST; + if (!host) { + throw new Error("SERVER_HOST environment variable is required"); + } + + const portString = process.env.SERVER_PORT; + if (!portString) { + throw new Error("SERVER_PORT environment variable is required"); + } + + const port = Number.parseInt(portString, 10); + if (!Number.isFinite(port)) { + throw new Error("SERVER_PORT must be a valid integer"); + } + + const modeValue = process.env.SERVER_MODE ?? process.env.NODE_ENV; + if (!modeValue) { + throw new Error("SERVER_MODE or NODE_ENV environment variable is required"); + } + if (modeValue !== "development" && modeValue !== "production") { + throw new Error('SERVER_MODE / NODE_ENV must be either "development" or "production"'); + } + + const jwtSecret = process.env.JWT_SECRET_KEY ?? process.env.JWT_SECRET; + if (!jwtSecret) { + throw new Error("JWT_SECRET_KEY environment variable is required"); + } + + return { + hostname: host, + port, + mode: modeValue, + jwt_secretkey: jwtSecret, + }; +}; + +const loadPersistedSetting = async (db: Kysely): Promise => { + const stored = await getAppConfig>(db, CONFIG_KEY); + if (stored) { + return mergePersisted(stored); + } + + const legacy = readLegacySettings(); + const mergedLegacy = mergePersisted(legacy ?? {}); + await upsertAppConfig(db, CONFIG_KEY, mergedLegacy); + return mergedLegacy; +}; + +const mergePersisted = (input: Partial): 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; + + return { + secure: input.secure ?? persistedDefault.secure, + guest, + cli: input.cli ?? persistedDefault.cli, + forbid_remote_admin_login: input.forbid_remote_admin_login ?? persistedDefault.forbid_remote_admin_login, + }; +}; + +const readLegacySettings = (): Partial | undefined => { + if (!existsSync(LEGACY_SETTINGS_PATH)) { + return undefined; + } + try { + return JSON.parse(readFileSync(LEGACY_SETTINGS_PATH, { encoding: "utf8" })) as Partial; + } catch (error) { + console.error("[config] Failed to parse settings.json", error); + return undefined; + } +}; diff --git a/packages/server/src/db/config.ts b/packages/server/src/db/config.ts new file mode 100644 index 0000000..2949fc7 --- /dev/null +++ b/packages/server/src/db/config.ts @@ -0,0 +1,35 @@ +import type { Kysely } from "kysely"; +import type { db } from "dbtype"; + +type DB = db.DB; + +const TABLE = "app_config"; + +export const getAppConfig = async (db: Kysely, key: string): Promise => { + const row = await db + .selectFrom(TABLE) + .select("value") + .where("key", "=", key) + .executeTakeFirst(); + + if (!row) { + return undefined; + } + + try { + return JSON.parse(row.value) as T; + } catch (error) { + console.error(`[config] Failed to parse value for key ${key}:`, error); + return undefined; + } +}; + +export const upsertAppConfig = async (db: Kysely, key: string, value: T): Promise => { + const payload = JSON.stringify(value); + + await db + .insertInto(TABLE) + .values({ key, value: payload }) + .onConflict((oc) => oc.column("key").doUpdateSet({ value: payload })) + .execute(); +}; diff --git a/packages/server/src/db/mod.ts b/packages/server/src/db/mod.ts index 4debc71..43a3844 100644 --- a/packages/server/src/db/mod.ts +++ b/packages/server/src/db/mod.ts @@ -1,3 +1,4 @@ export * from "./doc.ts"; export * from "./tag.ts"; export * from "./user.ts"; +export * from "./config.ts"; diff --git a/packages/server/src/diff/watcher/ComicConfig.ts b/packages/server/src/diff/watcher/ComicConfig.ts index d2157b0..6afb908 100644 --- a/packages/server/src/diff/watcher/ComicConfig.ts +++ b/packages/server/src/diff/watcher/ComicConfig.ts @@ -1,7 +1,58 @@ -import { ConfigManager } from "../../util/configRW.ts"; -import ComicSchema from "./ComicConfig.schema.json" with { type: "json" }; +import { existsSync, readFileSync } from "node:fs"; +import type { Kysely } from "kysely"; +import type { db } from "dbtype"; +import { getAppConfig, upsertAppConfig } from "../../db/config.ts"; + export interface ComicConfig { watch: string[]; } -export const ComicConfig = new ConfigManager("comic_config.json", { watch: [] }, ComicSchema); +const CONFIG_KEY = "diff.comic.watch"; +const LEGACY_PATH = "comic_config.json"; + +let cache: ComicConfig | null = null; + +const normalize = (input: Partial | undefined): ComicConfig => { + const watch = Array.isArray(input?.watch) + ? input!.watch.filter((item): item is string => typeof item === "string" && item.length > 0) + : []; + return { watch }; +}; + +const readLegacyConfig = (): Partial | undefined => { + if (!existsSync(LEGACY_PATH)) { + return undefined; + } + try { + return JSON.parse(readFileSync(LEGACY_PATH, { encoding: "utf8" })); + } catch (error) { + console.error("[config] Failed to parse comic_config.json", error); + return undefined; + } +}; + +export const loadComicConfig = async (db: Kysely): Promise => { + if (cache) { + return cache; + } + + const stored = await getAppConfig(db, CONFIG_KEY); + if (stored) { + cache = normalize(stored); + return cache; + } + + const legacy = normalize(readLegacyConfig()); + await upsertAppConfig(db, CONFIG_KEY, legacy); + cache = legacy; + return cache; +}; + +export const updateComicConfig = async (db: Kysely, config: ComicConfig): Promise => { + cache = normalize(config); + await upsertAppConfig(db, CONFIG_KEY, cache); +}; + +export const clearComicConfigCache = () => { + cache = null; +}; diff --git a/packages/server/src/diff/watcher/comic_watcher.ts b/packages/server/src/diff/watcher/comic_watcher.ts index cf06bf2..7c786c3 100644 --- a/packages/server/src/diff/watcher/comic_watcher.ts +++ b/packages/server/src/diff/watcher/comic_watcher.ts @@ -1,4 +1,3 @@ -import { ComicConfig } from "./ComicConfig.ts"; import { WatcherCompositer } from "./compositer.ts"; import { RecursiveWatcher } from "./recursive_watcher.ts"; import { WatcherFilter } from "./watcher_filter.ts"; @@ -6,8 +5,11 @@ import { WatcherFilter } from "./watcher_filter.ts"; const createComicWatcherBase = (path: string) => { return new WatcherFilter(new RecursiveWatcher(path), (x) => x.endsWith(".zip")); }; -export const createComicWatcher = () => { - const file = ComicConfig.get_config_file(); - console.log(`register comic ${file.watch.join(",")}`); - return new WatcherCompositer(file.watch.map((path) => createComicWatcherBase(path))); + +export const createComicWatcher = (paths: string[]) => { + const uniquePaths = [...new Set(paths)].filter((path) => path.length > 0); + if (uniquePaths.length === 0) { + console.warn("[diff] No comic watch paths configured"); + } + return new WatcherCompositer(uniquePaths.map((path) => createComicWatcherBase(path))); }; diff --git a/packages/server/src/route/settings.ts b/packages/server/src/route/settings.ts new file mode 100644 index 0000000..6bee557 --- /dev/null +++ b/packages/server/src/route/settings.ts @@ -0,0 +1,71 @@ +import { Elysia, t, type Static } from "elysia"; +import type { Kysely } from "kysely"; +import type { db } from "dbtype"; +import { AdminOnly, Permission } from "../permission/permission.ts"; +import { get_setting, updatePersistedSetting, type PersistedSettingUpdate } from "../SettingConfig.ts"; + +const permissionOptions = Object.values(Permission).sort() as string[]; + +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))), +}); + +type UpdateBody = Static; + +type SettingResponse = { + env: { + hostname: string; + port: number; + mode: "development" | "production"; + }; + persisted: { + secure: boolean; + cli: boolean; + forbid_remote_admin_login: boolean; + guest: string[]; + }; + permissions: string[]; +}; + +const buildResponse = (): SettingResponse => { + const setting = get_setting(); + return { + env: { + hostname: setting.hostname, + port: setting.port, + mode: setting.mode, + }, + persisted: { + secure: setting.secure, + cli: setting.cli, + forbid_remote_admin_login: setting.forbid_remote_admin_login, + guest: [...setting.guest], + }, + permissions: [...permissionOptions], + }; +}; + +export const createSettingsRouter = (db: Kysely) => + new Elysia({ name: "settings-router" }) + .get("/settings", () => buildResponse(), { + beforeHandle: AdminOnly, + }) + .patch("/settings", async ({ body }) => { + const payload = body as UpdateBody; + const update: PersistedSettingUpdate = { + secure: payload.secure, + cli: payload.cli, + forbid_remote_admin_login: payload.forbid_remote_admin_login, + guest: payload.guest, + }; + await updatePersistedSetting(db, update); + return buildResponse(); + }, { + beforeHandle: AdminOnly, + body: updateBodySchema, + }); + +export default createSettingsRouter; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index af8647b..63786e8 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -5,16 +5,18 @@ import { html } from "@elysiajs/html"; import { connectDB } from "./database.ts"; import { createDiffRouter, DiffManager } from "./diff/mod.ts"; -import { get_setting } from "./SettingConfig.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 getContentRouter from "./route/contents.ts"; import { error_handler } 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"; @@ -58,14 +60,16 @@ const normalizeError = (error: unknown): Error => { export async function create_server() { - 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); - diffManger.register("comic", createComicWatcher()); + const comicConfig = await loadComicConfig(db); + await diffManger.register("comic", createComicWatcher(comicConfig.watch)); if (setting.cli) { const userAdmin = await getAdmin(userController); @@ -107,6 +111,7 @@ export async function create_server() { .use(createDiffRouter(diffManger)) .use(getContentRouter(documentController)) .use(getTagRounter(tagController)) + .use(createSettingsRouter(db)) .use(createLoginRouter(userController)) ) .get("/doc/:id", async ({ params: { id }, set }) => { @@ -137,7 +142,7 @@ export async function create_server() { .get("/setting", () => index_html) .get("/tags", () => index_html) .listen({ - hostname: setting.localmode ? "127.0.0.1" : "0.0.0.0", + hostname: setting.hostname, port: setting.port, }); diff --git a/packages/server/src/setting.ts b/packages/server/src/setting.ts index 9c89322..ddcf004 100644 --- a/packages/server/src/setting.ts +++ b/packages/server/src/setting.ts @@ -1,11 +1,7 @@ import { Elysia } from "elysia"; import { get_setting } from "./SettingConfig.ts"; -import { connectDB } from "./database.ts"; -import type { Kysely } from "kysely"; -import type { DB } from "dbtype/src/types.ts"; export const SettingPlugin = new Elysia({ - name: "setting", - seed: "ServerConfig" -}) - .state("setting", get_setting()); \ No newline at end of file + 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 index dd873ad..cb0ff5c 100644 --- a/packages/server/src/util/configRW.ts +++ b/packages/server/src/util/configRW.ts @@ -1,46 +1 @@ -import { existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs"; - -export class ConfigManager { - path: string; - default_config: T; - config: T | null; - schema: object; - constructor(path: string, default_config: T, schema: object) { - this.path = path; - this.default_config = default_config; - this.config = null; - this.schema = schema; - } - get_config_file(): T { - if (this.config !== null) return this.config; - this.config = { ...this.read_config_file() }; - return this.config; - } - private emptyToDefault(target: T) { - let occur = false; - for (const key in this.default_config) { - if (key === undefined || key in target) { - continue; - } - target[key] = this.default_config[key]; - occur = true; - } - return occur; - } - read_config_file(): T { - if (!existsSync(this.path)) { - writeFileSync(this.path, JSON.stringify(this.default_config)); - return this.default_config; - } - const ret = JSON.parse(readFileSync(this.path, { encoding: "utf8" })); - if (this.emptyToDefault(ret)) { - writeFileSync(this.path, JSON.stringify(ret)); - } - return ret; - } - async write_config_file(new_config: T) { - this.config = new_config; - await fs.writeFile(`${this.path}.temp`, JSON.stringify(new_config)); - await fs.rename(`${this.path}.temp`, this.path); - } -} +export {}; diff --git a/packages/server/tests/diff-router.integration.test.ts b/packages/server/tests/diff-router.integration.test.ts new file mode 100644 index 0000000..7f8e20a --- /dev/null +++ b/packages/server/tests/diff-router.integration.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi, afterEach } from "vitest"; +import { Elysia } from "elysia"; +import { createDiffRouter } from "../src/diff/router.ts"; +import type { DiffManager } from "../src/diff/diff.ts"; + +const adminUser = { username: "admin", permission: [] as string[] }; + +const createTestApp = (diffManager: DiffManager) => { + const authPlugin = new Elysia({ name: "test-auth" }) + .state("user", adminUser) + .derive(() => ({ user: adminUser })); + + return new Elysia({ name: "diff-test" }) + .use(authPlugin) + .use(createDiffRouter(diffManager)); +}; + +describe("Diff router integration", () => { + let app: ReturnType + let diffManager: DiffManager; + let getAddedMock: ReturnType; + let commitMock: ReturnType; + let commitAllMock: ReturnType; + + beforeEach(() => { + getAddedMock = vi.fn(() => [ + { + type: "comic", + value: [ + { path: "alpha.zip", type: "archive" }, + ], + }, + ]); + commitMock = vi.fn(async () => 101); + commitAllMock = vi.fn(async () => [201, 202]); + + diffManager = { + getAdded: getAddedMock, + commit: commitMock, + commitAll: commitAllMock, + } as unknown as DiffManager; + + 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")); + expect(response.status).toBe(200); + + const payload = await response.json(); + expect(payload).toEqual([ + { + type: "comic", + value: [ + { path: "alpha.zip", type: "archive" }, + ], + }, + ]); + expect(getAddedMock).toHaveBeenCalledTimes(1); + }); + + it("POST /diff/commit commits each queued item", async () => { + const request = new Request("http://localhost/diff/commit", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify([ + { type: "comic", path: "alpha.zip" }, + ]), + }); + + commitMock.mockResolvedValueOnce(555); + + const response = await app.handle(request); + expect(response.status).toBe(200); + + const payload = await response.json(); + expect(payload).toEqual({ ok: true, docs: [555] }); + expect(commitMock).toHaveBeenCalledWith("comic", "alpha.zip"); + }); + + it("POST /diff/commitall flushes all entries for the type", async () => { + const request = new Request("http://localhost/diff/commitall", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "comic" }), + }); + + const response = await app.handle(request); + expect(response.status).toBe(200); + + const payload = await response.json(); + expect(payload).toEqual({ ok: true }); + expect(commitAllMock).toHaveBeenCalledWith("comic"); + }); +}); diff --git a/packages/server/tests/error_handler.test.ts b/packages/server/tests/error_handler.test.ts new file mode 100644 index 0000000..bcd24ab --- /dev/null +++ b/packages/server/tests/error_handler.test.ts @@ -0,0 +1,59 @@ +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 }); + +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({ + code: 400, + message: "BadRequest", + detail: "invalid payload", + }); + }); + + it("coerces ZodError into a 400 response", () => { + const parseResult = DocumentBodySchema.safeParse({}); + const set = createSet(); + + if (parseResult.success) { + throw new Error("Expected validation error"); + } + + const result = error_handler({ + code: "VALIDATION", + error: parseResult.error, + set, + }); + + expect(set.status).toBe(400); + expect(result.code).toBe(400); + expect(result.message).toBe("BadRequest"); + expect(result.detail).toContain("Required"); + }); + + it("defaults to 500 for unexpected errors", () => { + const set = createSet(); + const result = error_handler({ + code: "INTERNAL_SERVER_ERROR", + error: new Error("boom"), + set, + }); + + expect(set.status).toBe(500); + expect(result).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 new file mode 100644 index 0000000..55be397 --- /dev/null +++ b/packages/server/tests/settings-router.integration.test.ts @@ -0,0 +1,156 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { Elysia } from "elysia"; +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 { get_setting, refreshSetting } from "../src/SettingConfig.ts"; +import { Permission } from "../src/permission/permission.ts"; + +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"); + } +}; + +describe("settings router", () => { + let sqlite: InstanceType; + let database: Kysely; + + beforeAll(async () => { + process.env.SERVER_HOST = "127.0.0.1"; + process.env.SERVER_PORT = "3000"; + process.env.SERVER_MODE = "development"; + process.env.JWT_SECRET_KEY = "test-secret"; + + sqlite = new SqliteDatabase(":memory:"); + const dialect = new SqliteDialect({ database: sqlite }); + database = new Kysely({ dialect }); + + await database.schema + .createTable("app_config") + .addColumn("key", "text", (col) => col.primaryKey()) + .addColumn("value", "text") + .execute(); + + await refreshSetting(database); + }); + + afterAll(async () => { + await database.destroy(); + sqlite.close(); + }); + + beforeEach(async () => { + await database.deleteFrom("app_config").execute(); + await refreshSetting(database); + }); + + 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)); + }; + + 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(); + } + } + }); + + 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 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(); + } + } + }); + + 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({ + secure: false, + cli: true, + forbid_remote_admin_login: false, + guest: ["QueryContent"], + }); + + // 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(); + } + } + }); +}); diff --git a/packages/server/tsconfig.vitest.json b/packages/server/tsconfig.vitest.json new file mode 100644 index 0000000..28c78a8 --- /dev/null +++ b/packages/server/tsconfig.vitest.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "types": ["vitest/globals"], + "outDir": "./dist-vitest" + }, + "include": ["src", "tests"] +} diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts new file mode 100644 index 0000000..4ab1e5a --- /dev/null +++ b/packages/server/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 058b00b..df6b7ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,9 +215,6 @@ importers: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 - '@types/koa-compose': - specifier: ^3.2.8 - version: 3.2.8 '@types/node': specifier: ^22.15.33 version: 22.15.33 @@ -230,6 +227,9 @@ importers: typescript: specifier: ^5.8.3 version: 5.8.3 + vitest: + specifier: ^2.1.3 + version: 2.1.9(@types/node@22.15.33) packages: @@ -1426,27 +1426,12 @@ packages: '@ts-morph/common@0.19.0': resolution: {integrity: sha512-Unz/WHmd4pGax91rdIKWi51wnVUW11QttMEPpBiBgIewnc9UQIX7UDLxr5vRlqeByXCwhkF6VabSsI0raWcyAQ==} - '@types/accepts@1.3.7': - resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} - '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} '@types/better-sqlite3@7.6.9': resolution: {integrity: sha512-FvktcujPDj9XKMJQWFcl2vVl7OdRIqsSRX9b0acWwTmwLK9CF2eqo/FRcmMLNpugKoX/avA6pb7TorDLmpgTnQ==} - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - - '@types/content-disposition@0.5.9': - resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==} - - '@types/cookies@0.9.1': - resolution: {integrity: sha512-E/DPgzifH4sM1UMadJMWd6mO2jOd4g1Ejwzx8/uRCDpJis1IrlyQEcGAYEomtAqRYmD5ORbNXMeI9U0RiVGZbg==} - '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -1480,30 +1465,6 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/express-serve-static-core@5.0.6': - resolution: {integrity: sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==} - - '@types/express@5.0.3': - resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==} - - '@types/http-assert@1.5.6': - resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==} - - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - - '@types/keygrip@1.0.6': - resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} - - '@types/koa-compose@3.2.8': - resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==} - - '@types/koa@2.15.0': - resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==} - - '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/node@22.15.30': resolution: {integrity: sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==} @@ -1516,12 +1477,6 @@ packages: '@types/prop-types@15.7.14': resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==} - '@types/qs@6.14.0': - resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} - - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -1530,12 +1485,6 @@ packages: '@types/react@18.3.23': resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==} - '@types/send@0.17.5': - resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} - - '@types/serve-static@1.15.8': - resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==} - '@types/tiny-async-pool@1.0.5': resolution: {integrity: sha512-8hqr+s4rBthBtb+k02NXejl7BGVbj7CD/ZB2rMSvoSjXO52aXbtm9q/JEey5uDjzADs/zXEo7bU9iX+M6glAUA==} @@ -4443,10 +4392,6 @@ snapshots: mkdirp: 2.1.6 path-browserify: 1.0.1 - '@types/accepts@1.3.7': - dependencies: - '@types/node': 22.15.33 - '@types/better-sqlite3@7.6.13': dependencies: '@types/node': 22.15.33 @@ -4455,24 +4400,6 @@ snapshots: dependencies: '@types/node': 24.0.4 - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 22.15.33 - - '@types/connect@3.4.38': - dependencies: - '@types/node': 22.15.33 - - '@types/content-disposition@0.5.9': {} - - '@types/cookies@0.9.1': - dependencies: - '@types/connect': 3.4.38 - '@types/express': 5.0.3 - '@types/keygrip': 1.0.6 - '@types/node': 22.15.33 - '@types/d3-array@3.2.1': {} '@types/d3-color@3.1.3': {} @@ -4501,42 +4428,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/express-serve-static-core@5.0.6': - dependencies: - '@types/node': 22.15.33 - '@types/qs': 6.14.0 - '@types/range-parser': 1.2.7 - '@types/send': 0.17.5 - - '@types/express@5.0.3': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 5.0.6 - '@types/serve-static': 1.15.8 - - '@types/http-assert@1.5.6': {} - - '@types/http-errors@2.0.5': {} - - '@types/keygrip@1.0.6': {} - - '@types/koa-compose@3.2.8': - dependencies: - '@types/koa': 2.15.0 - - '@types/koa@2.15.0': - dependencies: - '@types/accepts': 1.3.7 - '@types/content-disposition': 0.5.9 - '@types/cookies': 0.9.1 - '@types/http-assert': 1.5.6 - '@types/http-errors': 2.0.5 - '@types/keygrip': 1.0.6 - '@types/koa-compose': 3.2.8 - '@types/node': 22.15.33 - - '@types/mime@1.3.5': {} - '@types/node@22.15.30': dependencies: undici-types: 6.21.0 @@ -4551,10 +4442,6 @@ snapshots: '@types/prop-types@15.7.14': {} - '@types/qs@6.14.0': {} - - '@types/range-parser@1.2.7': {} - '@types/react-dom@18.3.7(@types/react@18.3.23)': dependencies: '@types/react': 18.3.23 @@ -4564,17 +4451,6 @@ snapshots: '@types/prop-types': 15.7.14 csstype: 3.1.3 - '@types/send@0.17.5': - dependencies: - '@types/mime': 1.3.5 - '@types/node': 22.15.33 - - '@types/serve-static@1.15.8': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 22.15.33 - '@types/send': 0.17.5 - '@types/tiny-async-pool@1.0.5': {} '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3)': @@ -4683,6 +4559,14 @@ snapshots: optionalDependencies: vite: 5.4.19(@types/node@22.15.30) + '@vitest/mocker@2.1.9(vite@5.4.19(@types/node@22.15.33))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 5.4.19(@types/node@22.15.33) + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -6377,6 +6261,24 @@ snapshots: - supports-color - terser + vite-node@2.1.9(@types/node@22.15.33): + dependencies: + cac: 6.7.14 + debug: 4.4.1 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.19(@types/node@22.15.33) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite@5.4.19(@types/node@22.15.30): dependencies: esbuild: 0.21.5 @@ -6386,6 +6288,15 @@ snapshots: '@types/node': 22.15.30 fsevents: 2.3.3 + vite@5.4.19(@types/node@22.15.33): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.4 + rollup: 4.42.0 + optionalDependencies: + '@types/node': 22.15.33 + fsevents: 2.3.3 + vitest@2.1.9(@types/node@22.15.30): dependencies: '@vitest/expect': 2.1.9 @@ -6421,6 +6332,41 @@ snapshots: - supports-color - terser + vitest@2.1.9(@types/node@22.15.33): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.19(@types/node@22.15.33)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.2.0 + debug: 4.4.1 + expect-type: 1.2.1 + magic-string: 0.30.17 + pathe: 1.1.2 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.0 + tinyrainbow: 1.2.0 + vite: 5.4.19(@types/node@22.15.33) + vite-node: 2.1.9(@types/node@22.15.33) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.15.33 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + wcwidth@1.0.1: dependencies: defaults: 1.0.4