diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..3a0cbed --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,118 @@ +# Copilot Instructions for Ionian + +## Project Overview +Ionian is a full-stack content file management system focused on comic/media files. It's a pnpm monorepo with 3 packages: `server` (Elysia.js API), `client` (React+Vite SPA), and `dbtype` (shared TypeScript types). + +## Architecture & Key Patterns + +### Monorepo Structure +- Uses pnpm workspaces with `packages/*` configuration +- `dbtype` package provides shared types between client/server +- Server builds to `dist/` directory which client proxies to via Vite dev server + +### Server Architecture (Elysia.js + SQLite) +- **Framework**: Elysia.js with TypeScript, uses Kysely for type-safe SQL queries +- **Database**: SQLite with custom migration system in `migrations/` directory +- **Core Pattern**: Document-centric with content types (`comic`, `video`, etc.) +- **File Watching**: `DiffManager` + content watchers monitor filesystem changes in `src/diff/` +- **Permissions**: Role-based system in `src/permission/permission.ts` +- **Routes**: Organized by domain (`/api/doc/*`, `/api/user/*`, `/api/diff/*`) + +### Client Architecture (React + State Management) +- **Router**: Uses `wouter` (not React Router) for client-side routing +- **State**: Jotai atoms for global state (`src/lib/atom.ts` re-exports jotai) +- **API**: SWR for data fetching with custom hooks in `src/hook/` +- **UI**: shadcn/ui components with Tailwind CSS, dark mode support +- **Build Info**: Vite injects git hash/build time via `__BUILD_*__` constants + +### Critical File Watching System +The diff system monitors content directories: +- `DiffManager` orchestrates multiple content watchers +- `ContentDiffHandler` processes filesystem changes +- Watchers register via `await diffManager.register(type, watcher)` +- Content changes queue in waiting lists before manual commit + +## Development Workflows + +### Build Commands +```bash +# Full deployment build (run from root) +pnpm run app:build # Builds both client and server + +# Individual package builds +pnpm run compile # Server build +pnpm run build # Client build (from packages/client) + +# Development +cd packages/server && pnpm run dev # tsx watch mode +cd packages/client && pnpm run dev # Vite dev server +``` + +### Database Migrations +```bash +cd packages/server && pnpm run migrate +``` +Migrations live in `migrations/YYYY-MM-DD.ts` with manual version tracking. + +### Testing +```bash +cd packages/server && pnpm test # Vitest unit tests +cd packages/server && pnpm test:watch # Watch mode +``` + +## Key Conventions + +### Import Patterns +- Server: Use `.ts` extensions in imports (`./module.ts`) +- Client: Use `@/` alias for `src/` directory +- Cross-package imports: `import { Type } from "dbtype/mod.ts"` + +### API Design +- RESTful routes under `/api/` prefix +- Elysia schema validation with `t.Object()` patterns +- Permission decorators: `beforeHandle: createPermissionCheck(Permission.QueryContent)` +- Error handling via `sendError()` helper and global error handler + +### Component Patterns +- Page components in `src/page/` directory +- Reusable components in `src/components/` with ui/ subdirectory +- Custom hooks in `src/hook/` for API operations +- State atoms in `src/lib/atom.ts` + +### Database Patterns +- Models in `src/model/` define interfaces +- Controllers in `src/db/` implement database operations +- Use Kysely query builder, not raw SQL +- Document-centric design with tag relationships + +## Project-Specific Context + +### Content Types +System supports multiple content types (`comic`, `video`) with extensible architecture. Comic content has special thumbnail/page rendering support. + +### Permission System +Three-tier permissions: Admin, User, Guest. Check `src/permission/permission.ts` for available permissions. + +### Client-Server Communication +- Client proxies `/api/*` to server during development +- Production serves client from server's static middleware +- Authentication via HTTP-only cookies with JWT refresh pattern + +## Common Tasks + +### Adding New Content Type +1. Create watcher in `src/diff/watcher/` +2. Register in `create_server()` +3. Add route handler in `src/route/` +4. Update `dbtype` if new fields needed + +### Adding New API Endpoint +1. Create route in appropriate `src/route/*.ts` file +2. Add to main router in `src/server.ts` +3. Define types in `dbtype` package if needed +4. Create client hook in `packages/client/src/hook/` + +### UI Component Development +1. Use shadcn/ui patterns in `src/components/ui/` +2. Use Jotai atoms for component state sharing +3. Follow existing page structure patterns \ No newline at end of file diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 9c500c1..78e992f 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -16,7 +16,6 @@ import SettingPage from "@/page/settingPage.tsx"; import ComicPage from "@/page/reader/comicPage.tsx"; import DifferencePage from "./page/differencePage.tsx"; import TagsPage from "./page/tagsPage.tsx"; -import TaskQueuePage from "./page/taskQueuePage.tsx"; const App = () => { const { isDarkMode } = useTernaryDarkMode(); @@ -43,7 +42,6 @@ const App = () => { - diff --git a/packages/client/src/components/AppearanceCard.tsx b/packages/client/src/components/AppearanceCard.tsx new file mode 100644 index 0000000..278d4f9 --- /dev/null +++ b/packages/client/src/components/AppearanceCard.tsx @@ -0,0 +1,93 @@ +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"; + +function LightModeView() { + return
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
; +} + +function DarkModeView() { + return
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+} + +export function AppearanceCard() { + const { setTernaryDarkMode, ternaryDarkMode } = useTernaryDarkMode(); + const isSystemDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); + + return ( + + + Appearance + Dark mode + + + setTernaryDarkMode(v as TernaryDarkMode)} + className="grid grid-cols-1 sm:grid-cols-3 gap-4" + > +
+ + +
+
+ + +
+
+ + +
+
+
+
+ ); +} + +export default AppearanceCard; \ No newline at end of file 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/components/gallery/WorkQueue.tsx b/packages/client/src/components/gallery/WorkQueue.tsx deleted file mode 100644 index bb89fb8..0000000 --- a/packages/client/src/components/gallery/WorkQueue.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { useState, useEffect } from 'react' -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Progress } from "@/components/ui/progress" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts' - -interface Task { - id: string; - status: "Processed" | "Processing" | "Queued" | "Exception"; - createdAt: Date; - title: string; - description: string; - expectedProgress: number; - currentJobDescription: string; -} - -const generateMockTasks = (): Task[] => { - const statuses: Task['status'][] = ["Processed", "Processing", "Queued", "Exception"]; - return Array.from({ length: 50 }, (_, i) => ({ - id: `task-${i + 1}`, - status: statuses[Math.floor(Math.random() * statuses.length)], - createdAt: new Date(Date.now() - Math.random() * 10000000000), - title: `Task ${i + 1}`, - description: `This is a description for Task ${i + 1}`, - expectedProgress: Math.random(), - currentJobDescription: `Current job for Task ${i + 1}` - })); -}; - -export default function DynamicWorkQueue() { - const [tasks, setTasks] = useState([]) - - useEffect(() => { - setTasks(generateMockTasks()); - - const intervalId = setInterval(() => { - setTasks(prevTasks => { - let newTasks: Task[] = prevTasks; - // if processed tasks are more than 10, remove the oldest one - if (newTasks.filter(task => task.status === "Processed").length > 10) { - newTasks = newTasks.filter(task => task.status !== "Processed"); - } - // update the progress of each task - newTasks = newTasks.map(task => ({ - ...task, - expectedProgress: Math.min(1, task.expectedProgress + Math.random() * 0.2), - status: task.expectedProgress >= 1 ? "Processed" : task.status - })); - // if there are no queued tasks, add a new one - if (newTasks.filter(task => task.status === "Queued").length === 0) { - newTasks.push({ - id: `task-${newTasks.length + 1}`, - status: "Queued", - createdAt: new Date(), - title: `Task ${newTasks.length + 1}`, - description: `This is a description for Task ${newTasks.length + 1}`, - expectedProgress: 0, - currentJobDescription: `Current job for Task ${newTasks.length + 1}` - }); - } - return newTasks; - }); - }, 5000); - - return () => clearInterval(intervalId); - }, []) - - const updateTaskStatus = (taskId: string, newStatus: Task['status']) => { - setTasks(prevTasks => - prevTasks.map(task => - task.id === taskId ? { ...task, status: newStatus } : task - ) - ); - } - - const renderTaskList = (status: Task['status']) => { - const filteredTasks = tasks.filter(task => task.status === status) - - return ( - <> - {filteredTasks.length === 0 ? ( -

No tasks

- ) : ( -
    - {filteredTasks.map(task => ( -
  • -

    {task.title}

    -

    {task.description}

    -

    Created: {task.createdAt.toLocaleString()}

    -
    - -

    {Math.round(task.expectedProgress * 100)}%

    -
    -

    {task.currentJobDescription}

    - {status === "Queued" && ( - - )} - {status === "Processing" && ( - - )} -
  • - ))} -
- )} - - ) - } - - const renderProcessedTaskSummary = () => { - const processedTasks = tasks.filter(task => task.status === "Processed"); - const totalProcessed = processedTasks.length; - const averageProgress = processedTasks.reduce((sum, task) => sum + task.expectedProgress, 0) / totalProcessed || 0; - - const chartData = [ - { name: 'Processed', value: totalProcessed }, - { name: 'Remaining', value: tasks.length - totalProcessed }, - ]; - - return ( -
-
- - - Total Processed - - -

{totalProcessed}

-
-
- - - Average Progress - - -

{(averageProgress * 100).toFixed(2)}%

-
-
-
- - - Processed vs Remaining Tasks - - - - - - - - - - - - -
- ) - } - - return ( -
- - - Dynamic Work Queue - - - - - Queued ({tasks.filter(t => t.status === "Queued").length}) - Processing ({tasks.filter(t => t.status === "Processing").length}) - Processed ({tasks.filter(t => t.status === "Processed").length}) - Exception ({tasks.filter(t => t.status === "Exception").length}) - Summary - - - {renderTaskList("Queued")} - - - {renderTaskList("Processing")} - - - {renderTaskList("Processed")} - - - {renderTaskList("Exception")} - - - {renderProcessedTaskSummary()} - - - - -
- ) -} diff --git a/packages/client/src/components/layout/layout.tsx b/packages/client/src/components/layout/layout.tsx index 1216fc8..2b0954a 100644 --- a/packages/client/src/components/layout/layout.tsx +++ b/packages/client/src/components/layout/layout.tsx @@ -1,49 +1,48 @@ -import { useLayoutEffect, useState } from "react"; -import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "../ui/resizable"; -import { NavList } from "./nav"; - +import { useState } from "react"; +import { SidebarNav, BottomNav, SidebarToggle } from "./nav"; +import { cn } from "@/lib/utils"; interface LayoutProps { children?: React.ReactNode; } export default function Layout({ children }: LayoutProps) { - const MIN_SIZE_IN_PIXELS = 70; - const [minSize, setMinSize] = useState(MIN_SIZE_IN_PIXELS); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); - useLayoutEffect(() => { - const panelGroup = document.querySelector('[data-panel-group-id="main"]'); - const resizeHandles = document.querySelectorAll( - "[data-panel-resize-handle-id]" - ); - if (!panelGroup || !resizeHandles) return; - const observer = new ResizeObserver(() => { - let width = panelGroup?.clientWidth; - if (!width) return; - width -= [...resizeHandles].reduce((acc, resizeHandle) => acc + resizeHandle.clientWidth, 0); - // Minimum size in pixels is a percentage of the PanelGroup's height, - // less the (fixed) height of the resize handles. - setMinSize((MIN_SIZE_IN_PIXELS / width) * 100); - }); - observer.observe(panelGroup); - for (const resizeHandle of resizeHandles) { - observer.observe(resizeHandle); - } - - return () => { - observer.disconnect(); - }; - }, []); + const toggleSidebar = () => { + setIsSidebarOpen(!isSidebarOpen); + }; return ( - - - - - - +
+ {/* Desktop Sidebar - 데스크탑에서만 보이는 사이드바 */} + + + {/* Main Content */} +
{children} - - +
+ + {/* Mobile Bottom Navigation - 모바일에서만 보이는 하단 네비게이션 */} +
+ +
+
); } \ No newline at end of file diff --git a/packages/client/src/components/layout/nav.tsx b/packages/client/src/components/layout/nav.tsx index 5ea962f..a430846 100644 --- a/packages/client/src/components/layout/nav.tsx +++ b/packages/client/src/components/layout/nav.tsx @@ -1,27 +1,30 @@ import { Link } from "wouter" -import { SearchIcon, SettingsIcon, TagsIcon, ArchiveIcon, UserIcon, LayoutListIcon } from "lucide-react" +import { SearchIcon, SettingsIcon, TagsIcon, ArchiveIcon, UserIcon, LayoutListIcon, PanelLeftIcon, PanelLeftCloseIcon, MenuIcon, XIcon } from "lucide-react" import { Button, buttonVariants } from "@/components/ui/button.tsx" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx" import { useLogin } from "@/state/user.ts"; import { useNavItems } from "./navAtom"; import { Separator } from "../ui/separator"; +import { cn } from "@/lib/utils"; interface NavItemProps { icon: React.ReactNode; to: string; name: string; + className?: string; } export function NavItem({ icon, to, - name + name, + className }: NavItemProps) { return {icon} {name} @@ -65,15 +68,241 @@ export function NavList() { return +} + +// 사이드바 토글 버튼 +export function SidebarToggle({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) { + return ( + + + + + + {isOpen ? "Close sidebar" : "Open sidebar"} + + + ); +} + +// 모바일용 사이드바 토글 버튼 +export function MobileSidebarToggle({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) { + return ( + + ); +} + +// 데스크탑용 사이드바 네비게이션 +export function SidebarNav({ isCollapsed, onNavigate }: { isCollapsed: boolean; onNavigate?: () => void }) { + const loginInfo = useLogin(); + const navItems = useNavItems(); + + return ( +
+ + +
+ } + to={loginInfo ? "/profile" : "/login"} + name={loginInfo ? "Profiles" : "Login"} + isCollapsed={isCollapsed} + onNavigate={onNavigate} + /> + } + to="/setting" + name="Settings" + isCollapsed={isCollapsed} + onNavigate={onNavigate} + /> +
+
+ ); +} + +// 사이드바 네비게이션 아이템 +interface SidebarNavItemProps { + icon: React.ReactNode; + to: string; + name: string; + isCollapsed: boolean; + onNavigate?: () => void; +} + +function SidebarNavItem({ icon, to, name, isCollapsed, onNavigate }: SidebarNavItemProps) { + if (isCollapsed) { + return ( + + + + {icon} + {name} + + + {name} + + ); + } + + return ( + + {icon} + {name} + + ); +} + +// 모바일용 하단 네비게이션 +export function BottomNav() { + const loginInfo = useLogin(); + const navItems = useNavItems(); + + return ( + + ); +} + +// 하단 네비게이션 아이템 +interface BottomNavItemProps { + icon: React.ReactNode; + to: string; + name: string; + className?: string; +} + +function BottomNavItem({ icon, to, name, className }: BottomNavItemProps) { + return ( + + {icon} + {name} + + ); } \ No newline at end of file diff --git a/packages/client/src/components/layout/navAtom.tsx b/packages/client/src/components/layout/navAtom.tsx index f0b23a6..a05b2a8 100644 --- a/packages/client/src/components/layout/navAtom.tsx +++ b/packages/client/src/components/layout/navAtom.tsx @@ -1,7 +1,7 @@ -import { atom, useAtomValue, setAtomValue, getAtomState } from "@/lib/atom"; -import { useLayoutEffect } from "react"; +import { atom, useAtomValue, useSetAtom } from "@/lib/atom"; +import { useLayoutEffect, useRef } from "react"; -const NavItems = atom("NavItems", null); +const NavItems = atom(null); // eslint-disable-next-line react-refresh/only-export-components export function useNavItems() { @@ -9,14 +9,19 @@ export function useNavItems() { } export function PageNavItem({items, children}:{items: React.ReactNode, children: React.ReactNode}) { + const currentNavItems = useAtomValue(NavItems); + const setNavItems = useSetAtom(NavItems); + const prevValueRef = useRef(null); + useLayoutEffect(() => { - const prev = getAtomState(NavItems).value; - const setter = setAtomValue(NavItems); - setter(items); + // Store current value before setting new one + prevValueRef.current = currentNavItems; + setNavItems(items); + return () => { - setter(prev); + setNavItems(prevValueRef.current); }; - }, [items]); + }, [items, currentNavItems, setNavItems]); return children; } 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/lib/atom.ts b/packages/client/src/lib/atom.ts index 526e725..c543526 100644 --- a/packages/client/src/lib/atom.ts +++ b/packages/client/src/lib/atom.ts @@ -1,70 +1,2 @@ -import { useEffect, useReducer, useState } from "react"; - -interface AtomState { - value: T; - listeners: Set<() => void>; -} -interface Atom { - key: string; - default: T; -} - -const atomStateMap = new WeakMap, AtomState>(); - -export function atom(key: string, defaultVal: T): Atom { - return { key, default: defaultVal }; -} - -export function getAtomState(atom: Atom): AtomState { - let atomState = atomStateMap.get(atom); - if (!atomState) { - atomState = { - value: atom.default, - listeners: new Set(), - }; - atomStateMap.set(atom, atomState); - } - return atomState as AtomState; -} - -export function useAtom(atom: Atom): [T, (val: T) => void] { - const state = getAtomState(atom); - const [, setState] = useState(state.value); - useEffect(() => { - const listener = () => setState(state.value); - state.listeners.add(listener); - return () => { - state.listeners.delete(listener); - }; - }, [state]); - return [ - state.value as T, - (val: T) => { - state.value = val; - // biome-ignore lint/complexity/noForEach: forEach is used to call each listener - state.listeners.forEach((listener) => listener()); - }, - ]; -} - -export function useAtomValue(atom: Atom): T { - const state = getAtomState(atom); - const update = useReducer((x) => x + 1, 0)[1]; - useEffect(() => { - const listener = () => update(); - state.listeners.add(listener); - return () => { - state.listeners.delete(listener); - }; - }, [state, update]); - return state.value; -} - -export function setAtomValue(atom: Atom): (val: T) => void { - const state = getAtomState(atom); - return (val: T) => { - state.value = val; - // biome-ignore lint/complexity/noForEach: forEach is used to call each listener - state.listeners.forEach((listener) => listener()); - }; -} \ No newline at end of file +// Re-export jotai functions to maintain compatibility +export { atom, useAtom, useAtomValue, useSetAtom, useSetAtom as setAtomValue } from 'jotai'; \ No newline at end of file diff --git a/packages/client/src/page/contentInfoPage.tsx b/packages/client/src/page/contentInfoPage.tsx index 4b5f291..eadbd35 100644 --- a/packages/client/src/page/contentInfoPage.tsx +++ b/packages/client/src/page/contentInfoPage.tsx @@ -24,18 +24,14 @@ export interface ContentInfoPageProps { } function Wrapper({ children }: { children: React.ReactNode }) { - const ref = useRef(null); const [pathname] = useLocation(); useEffect(() => { - if (ref.current) { - ref.current.scrollTo({ - top: 0, - left: 0, - }); - } + document.scrollingElement?.scrollTo({ + top: 0, + }); }, [pathname]); - return
+ return
{children}
; } diff --git a/packages/client/src/page/reader/comicPage.tsx b/packages/client/src/page/reader/comicPage.tsx index 1257788..797ac53 100644 --- a/packages/client/src/page/reader/comicPage.tsx +++ b/packages/client/src/page/reader/comicPage.tsx @@ -146,12 +146,20 @@ export default function ComicPage({ return ( - } /> - : } onClick={() => { - toggleFullScreen(); - }} /> + } /> + : } + onClick={() => { + toggleFullScreen(); + }} /> - + {curPage + 1}/{data.additional.page as number} diff --git a/packages/client/src/page/settingPage.tsx b/packages/client/src/page/settingPage.tsx index c66cdeb..433bc10 100644 --- a/packages/client/src/page/settingPage.tsx +++ b/packages/client/src/page/settingPage.tsx @@ -1,91 +1,43 @@ -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 { useCallback } from "react"; import BuildInfoCard from "@/components/BuildInfoCard"; +import AppearanceCard from "@/components/AppearanceCard"; +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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
; -} -function DarkModeView() { - return
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-} 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 - - -
-
-

Appearance

- Dark mode -
- setTernaryDarkMode(v as TernaryDarkMode)} - className="flex space-x-2 items-center" - > - - - - - - - -
-
-
+
+

Settings

+ +
) diff --git a/packages/client/src/page/taskQueuePage.tsx b/packages/client/src/page/taskQueuePage.tsx deleted file mode 100644 index da6add8..0000000 --- a/packages/client/src/page/taskQueuePage.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { lazy, Suspense } from 'react'; - -const DynamicWorkQueue = lazy(() => import('@/components/gallery/WorkQueue')); - -export default function TaskQueuePage() { - return ( -
- Loading...
}> - - -
- ); -} \ No newline at end of file diff --git a/packages/client/src/state/user.ts b/packages/client/src/state/user.ts index 88cf097..69ad2bb 100644 --- a/packages/client/src/state/user.ts +++ b/packages/client/src/state/user.ts @@ -1,4 +1,5 @@ -import { atom, useAtomValue, setAtomValue } from "../lib/atom.ts"; +import { atom, useAtomValue } from "../lib/atom.ts"; +import { createStore } from 'jotai'; import { LoginRequest } from "dbtype/mod.ts"; import { ApiError, @@ -9,6 +10,9 @@ import { resetPasswordService, } from "./api.ts"; +// Create a store for setting atom values outside components +const store = createStore(); + let localObj: LoginResponse | null = null; function getUserSessions() { if (localObj === null) { @@ -55,7 +59,6 @@ export async function refresh() { } export const doLogout = async () => { - const setVal = setAtomValue(userLoginStateAtom); try { const res = await logoutService(); localObj = { @@ -64,7 +67,7 @@ export const doLogout = async () => { permission: res.permission, }; window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); - setVal(localObj); + store.set(userLoginStateAtom, localObj); return { username: localObj.username, permission: localObj.permission, @@ -74,7 +77,7 @@ export const doLogout = async () => { // Even if logout fails, clear client-side session localObj = { accessExpired: 0, username: "", permission: [] }; window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); - setVal(localObj); + store.set(userLoginStateAtom, localObj); return { username: "", permission: [], @@ -85,9 +88,8 @@ export const doLogout = async () => { export const doLogin = async (userLoginInfo: LoginRequest): Promise => { try { const b = await loginService(userLoginInfo); - const setVal = setAtomValue(userLoginStateAtom); localObj = b; - setVal(b); + store.set(userLoginStateAtom, b); window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); return b; } catch (e) { @@ -123,7 +125,7 @@ export async function getInitialValue() { return refresh(); } -export const userLoginStateAtom = atom("userLoginState", getUserSessions()); +export const userLoginStateAtom = atom(getUserSessions()); export function useLogin() { const val = useAtomValue(userLoginStateAtom); 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 1086e23..d6f7a2d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,5 +1,5 @@ { - "name": "followed", + "name": "server", "version": "1.0.0", "description": "", "main": "build/app.js", @@ -7,36 +7,36 @@ "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", "dependencies": { + "@elysiajs/cors": "^1.3.3", + "@elysiajs/html": "^1.3.1", + "@elysiajs/node": "^1.4.1", + "@elysiajs/openapi": "^1.4.11", + "@elysiajs/static": "^1.3.0", "@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", "jose": "^5.10.0", - "koa": "^2.16.1", - "koa-bodyparser": "^4.4.1", - "koa-compose": "^4.1.0", - "koa-router": "^12.0.1", "kysely": "^0.27.6", "natural-orderby": "^2.0.3", "tiny-async-pool": "^1.3.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", - "@types/jsonwebtoken": "^8.5.9", - "@types/koa": "^2.15.0", - "@types/koa-bodyparser": "^4.3.12", - "@types/koa-compose": "^3.2.8", - "@types/koa-router": "^7.4.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..637d489 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 ?? process.env.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/app.ts b/packages/server/src/app.ts index 88ebde1..547a5d7 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -1,7 +1,6 @@ import { create_server } from "./server.ts"; -create_server().then((server) => { - server.start_server(); -}).catch((err) => { +create_server() +.catch((err) => { console.error(err); }); \ No newline at end of file diff --git a/packages/server/src/controller.ts b/packages/server/src/controller.ts new file mode 100644 index 0000000..422edc2 --- /dev/null +++ b/packages/server/src/controller.ts @@ -0,0 +1,18 @@ +import Elysia from "elysia"; +import { connectDB } from "./database.ts"; +import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts"; + +export async function createControllers() { + const db = await connectDB(); + + const userController = createSqliteUserController(db); + const documentController = createSqliteDocumentAccessor(db); + const tagController = createSqliteTagController(db); + + return { + userController, + documentController, + tagController + }; +} + 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/router.ts b/packages/server/src/diff/router.ts index c3be4f2..bf76284 100644 --- a/packages/server/src/diff/router.ts +++ b/packages/server/src/diff/router.ts @@ -1,85 +1,57 @@ -import type Koa from "koa"; -import Router from "koa-router"; -import type { ContentFile } from "../content/mod.ts"; -import { AdminOnlyMiddleware } from "../permission/permission.ts"; -import { sendError } from "../route/error_handler.ts"; +import { Elysia, t } from "elysia"; import type { DiffManager } from "./diff.ts"; +import type { ContentFile } from "../content/mod.ts"; +import { AdminOnly } from "../permission/permission.ts"; +import { sendError } from "../route/error_handler.ts"; -function content_file_to_return(x: ContentFile) { - return { path: x.path, type: x.type }; -} +const toSerializableContent = (file: ContentFile) => ({ path: file.path, type: file.type }); -export const getAdded = (diffmgr: DiffManager) => (ctx: Koa.Context, next: Koa.Next) => { - const ret = diffmgr.getAdded(); - ctx.body = ret.map((x) => ({ - type: x.type, - value: x.value.map((x) => ({ path: x.path, type: x.type })), - })); - ctx.type = "json"; -}; +const CommitEntrySchema = t.Array(t.Object({ + type: t.String(), + path: t.String(), +})); -type PostAddedBody = { - type: string; - path: string; -}[]; +const CommitAllSchema = t.Object({ + type: t.String(), +}); -function checkPostAddedBody(body: unknown): body is PostAddedBody { - if (Array.isArray(body)) { - return body.map((x) => "type" in x && "path" in x).every((x) => x); - } - return false; -} - -export const postAdded = (diffmgr: DiffManager) => async (ctx: Router.IRouterContext, next: Koa.Next) => { - const reqbody = ctx.request.body; - if (!checkPostAddedBody(reqbody)) { - sendError(400, "format exception"); - return; - } - const allWork = reqbody.map((op) => diffmgr.commit(op.type, op.path)); - const results = await Promise.all(allWork); - ctx.body = { - ok: true, - docs: results, - }; - ctx.type = "json"; - await next(); -}; -export const postAddedAll = (diffmgr: DiffManager) => async (ctx: Router.IRouterContext, next: Koa.Next) => { - if (!ctx.is("json")) { - sendError(400, "format exception"); - return; - } - const reqbody = ctx.request.body as Record; - if (!("type" in reqbody)) { - sendError(400, 'format exception: there is no "type"'); - return; - } - const t = reqbody.type; - if (typeof t !== "string") { - sendError(400, 'format exception: invalid type of "type"'); - return; - } - await diffmgr.commitAll(t); - ctx.body = { - ok: true, - }; - ctx.type = "json"; - await next(); -}; -/* -export const getNotWatched = (diffmgr : DiffManager)=> (ctx:Router.IRouterContext,next:Koa.Next)=>{ - ctx.body = { - added: diffmgr.added.map(content_file_to_return), - deleted: diffmgr.deleted.map(content_file_to_return), - }; - ctx.type = 'json'; -}*/ - -export function createDiffRouter(diffmgr: DiffManager) { - const ret = new Router(); - ret.get("/list", AdminOnlyMiddleware, getAdded(diffmgr)); - ret.post("/commit", AdminOnlyMiddleware, postAdded(diffmgr)); - ret.post("/commitall", AdminOnlyMiddleware, postAddedAll(diffmgr)); - return ret; -} +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); + }) + ); diff --git a/packages/server/src/diff/watcher/ComicConfig.ts b/packages/server/src/diff/watcher/ComicConfig.ts index da42a85..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" assert { 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/login.ts b/packages/server/src/login.ts index 3374529..5f1fc2e 100644 --- a/packages/server/src/login.ts +++ b/packages/server/src/login.ts @@ -1,19 +1,8 @@ +import { Elysia, t, type Context } from "elysia"; import { SignJWT, jwtVerify, errors } from "jose"; -import type Koa from "koa"; -import Router from "koa-router"; import type { IUser, UserAccessor } from "./model/mod.ts"; -import { sendError } from "./route/error_handler.ts"; +import { ClientRequestError } from "./route/error_handler.ts"; import { get_setting } from "./SettingConfig.ts"; -import { LoginRequestSchema, LoginResetRequestSchema } from "dbtype"; - -type LoginResponse = { - accessExpired: number; -} & PayloadInfo; - -type RefreshResponse = { - accessExpired: number; - refresh: boolean; -} & PayloadInfo; type PayloadInfo = { username: string; @@ -24,18 +13,41 @@ export type UserState = { user: PayloadInfo; }; -const isUserState = (obj: object | string): obj is PayloadInfo => { - if (typeof obj === "string") return false; - return "username" in obj && "permission" in obj && Array.isArray((obj as { permission: unknown }).permission); +type AuthStore = { + user: PayloadInfo; + refreshed: boolean; + authenticated: boolean; }; +type LoginResponse = { + accessExpired: number; +} & PayloadInfo; + +type RefreshResponse = { + accessExpired: number; + refresh: boolean; +} & PayloadInfo; + type RefreshPayloadInfo = { username: string }; -const isRefreshToken = (obj: object | string): obj is RefreshPayloadInfo => { - if (typeof obj === "string") return false; - return "username" in obj && typeof (obj as { username: unknown }).username === "string"; -}; -const accessExpiredTime = 60 * 60 * 2; // 2 hour +type CookieJar = Context["cookie"]; + +const LoginBodySchema = t.Object({ + username: t.String(), + password: t.String(), +}); + +const ResetBodySchema = t.Object({ + username: t.String(), + oldpassword: t.String(), + newpassword: t.String(), +}); + +const SettingsBodySchema = t.Record(t.String(), t.Unknown()); + +const accessExpiredTime = 60 * 60 * 2; // 2 hours +const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 days + async function createAccessToken(payload: PayloadInfo, secret: string) { return await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) @@ -43,7 +55,6 @@ async function createAccessToken(payload: PayloadInfo, secret: string) { .sign(new TextEncoder().encode(secret)); } -const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 day; async function createRefreshToken(payload: RefreshPayloadInfo, secret: string) { return await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) @@ -57,10 +68,10 @@ class TokenExpiredError extends Error { } } -async function verifyToken(token: string, secret: string) { +async function verifyToken(token: string, secret: string): Promise { try { const { payload } = await jwtVerify(token, new TextEncoder().encode(secret)); - return payload as PayloadInfo; + return payload as T; } catch (error) { if (error instanceof errors.JWTExpired) { throw new TokenExpiredError(); @@ -72,241 +83,245 @@ async function verifyToken(token: string, secret: string) { export const accessTokenName = "access_token"; export const refreshTokenName = "refresh_token"; -function setToken(ctx: Koa.Context, token_name: string, token_payload: string | null, expiredtime: number) { - const setting = get_setting(); - if (token_payload === null && !ctx.cookies.get(token_name)) { +function setToken(cookie: CookieJar, token_name: string, token_payload: string | null, expiredSeconds: number) { + if (token_payload === null) { + cookie[token_name]?.remove(); return; } - ctx.cookies.set(token_name, token_payload, { + const setting = get_setting(); + cookie[token_name].set({ + value: token_payload, httpOnly: true, secure: setting.secure, sameSite: "strict", - expires: new Date(Date.now() + expiredtime * 1000), + expires: new Date(Date.now() + expiredSeconds * 1000), }); } -export const createLoginHandler = (userController: UserAccessor) => async (ctx: Koa.Context, _next: Koa.Next) => { - const setting = get_setting(); - const secretKey = setting.jwt_secretkey; - const body = ctx.request.body; - const { - username, - password, - } = LoginRequestSchema.parse(body); - // if admin login is forbidden? - if (username === "admin" && setting.forbid_remote_admin_login) { - return sendError(403, "forbidden remote admin login"); +const isUserState = (obj: unknown): obj is PayloadInfo => { + if (typeof obj !== "object" || obj === null) { + return false; } - const user = await userController.findUser(username); - // username not exist - if (user === undefined) return sendError(401, "not authorized"); - // password not matched - if (!user.password.check_password(password)) { - return sendError(401, "not authorized"); - } - // create token - const userPermission = await user.get_permissions(); - const payload = await createAccessToken({ - username: user.username, - permission: userPermission, - }, secretKey); - const payload2 = await createRefreshToken({ - username: user.username, - }, secretKey); - setToken(ctx, accessTokenName, payload, accessExpiredTime); - setToken(ctx, refreshTokenName, payload2, refreshExpiredTime); - ctx.body = { - username: user.username, - permission: userPermission, - accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime, - } satisfies LoginResponse; - console.log(`${username} logined`); - return; + return "username" in obj && "permission" in obj && Array.isArray((obj as { permission: unknown }).permission); }; -export const LogoutHandler = (ctx: Koa.Context, _next: Koa.Next) => { +const isRefreshToken = (obj: unknown): obj is RefreshPayloadInfo => { + if (typeof obj !== "object" || obj === null) { + return false; + } + return "username" in obj && typeof (obj as { username: unknown }).username === "string"; +}; + +type AuthResult = { + user: PayloadInfo; + refreshed: boolean; + success: boolean; +}; + +async function authenticate( + cookie: CookieJar, + userController: UserAccessor, + options: { forceRefresh?: boolean } = {}, +): Promise { const setting = get_setting(); - ctx.cookies.set(accessTokenName, null); - ctx.cookies.set(refreshTokenName, null); - ctx.body = { - ok: true, + 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 guestUser: PayloadInfo = { username: "", permission: setting.guest, }; - return; -}; -export const createUserHandler = - (userController: UserAccessor) => async (ctx: Koa.ParameterizedContext, next: Koa.Next) => { - const refreshToken = makeRefreshToken(userController); - const setting = get_setting(); - const setGuest = async () => { - setToken(ctx, accessTokenName, null, 0); - setToken(ctx, refreshTokenName, null, 0); - ctx.state.user = { username: "", permission: setting.guest }; - return await next(); + const setGuest = (): AuthResult => { + accessCookie?.remove(); + refreshCookie?.remove(); + return { user: guestUser, refreshed: false, success: false }; + }; + + const issueAccessForUser = async (username: string): Promise => { + const account = await userController.findUser(username); + if (!account) { + return setGuest(); + } + const permissions = await account.get_permissions(); + const payload: PayloadInfo = { + username: account.username, + permission: permissions, }; - return await refreshToken(ctx, setGuest, next); + const accessToken = await createAccessToken(payload, secretKey); + setToken(cookie, accessTokenName, accessToken, accessExpiredTime); + return { user: payload, refreshed: true, success: true }; }; -const makeRefreshToken = (cntr: UserAccessor) => async (ctx: Koa.Context, fail: Koa.Next, next: Koa.Next) => { - const accessPayload = ctx.cookies.get(accessTokenName); - const setting = get_setting(); - const secretKey = setting.jwt_secretkey; - - if (!accessPayload) { - return await checkRefreshAndUpdate(); - } - - try { - const payload = await verifyToken(accessPayload, secretKey); - if (isUserState(payload)) { - ctx.state.user = payload; - return await next(); + const tryRefresh = async (): Promise => { + if (!refreshValue) { + return setGuest(); } - console.error("Invalid token detected"); - throw new Error("Token form invalid"); - } catch (error) { - if (error instanceof TokenExpiredError) { - return await checkRefreshAndUpdate(); - } - throw error; - } - - async function checkRefreshAndUpdate() { - const refreshPayload = ctx.cookies.get(refreshTokenName); - if (!refreshPayload) { - return await fail(); // Refresh token doesn't exist - } - try { - const payload = await verifyToken(refreshPayload, secretKey); - if (isRefreshToken(payload)) { - const user = await cntr.findUser(payload.username); - if (!user) return await fail(); // User does not exist - - const permissions = await user.get_permissions(); - const newAccessToken = await createAccessToken({ - username: user.username, - permission: permissions, - }, secretKey); - - setToken(ctx, accessTokenName, newAccessToken, accessExpiredTime); - ctx.state.user = { username: payload.username, permission: permissions }; - } else { - console.error("Invalid token detected"); - throw new Error("Token form invalid"); + const payload = await verifyToken(refreshValue, secretKey); + if (!isRefreshToken(payload)) { + return setGuest(); } + return await issueAccessForUser(payload.username); + } catch { + return setGuest(); + } + }; + + if (options.forceRefresh) { + if (accessValue) { + try { + 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 }; + } + return setGuest(); + } catch (error) { + if (!(error instanceof TokenExpiredError)) { + return setGuest(); + } + } + } + return await tryRefresh(); + } + + if (accessValue) { + try { + const payload = await verifyToken(accessValue, secretKey); + if (isUserState(payload)) { + return { user: payload, refreshed: false, success: true }; + } + return setGuest(); } catch (error) { - if (error instanceof TokenExpiredError) { - // Refresh token is expired - return await fail(); + if (!(error instanceof TokenExpiredError)) { + return setGuest(); } - throw error; } - - return await next(); } -}; -export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => { - const handler = makeRefreshToken(cntr); - await handler(ctx, fail, success); - async function fail() { - const user = ctx.state.user as PayloadInfo; - ctx.body = { - refresh: false, - accessExpired: 0, - ...user, - } satisfies RefreshResponse; - ctx.type = "json"; - }; - async function success() { - const user = ctx.state.user as PayloadInfo; - ctx.body = { - ...user, - refresh: true, - accessExpired: Math.floor(Date.now() / 1000 + accessExpiredTime), - } satisfies RefreshResponse; - ctx.type = "json"; - } -}; - - -export const resetPasswordMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => { - const body = ctx.request.body; - const { - username, - oldpassword, - newpassword, - } = LoginResetRequestSchema.parse(body); - const user = await cntr.findUser(username); - if (user === undefined) { - return sendError(403, "not authorized"); - } - if (!user.password.check_password(oldpassword)) { - return sendError(403, "not authorized"); - } - user.reset_password(newpassword); - ctx.body = { ok: true }; - ctx.type = "json"; -}; - -export function getUserSettingHandler(userController: UserAccessor) { - return async (ctx: Koa.ParameterizedContext, next: Koa.Next) => { - const username = ctx.state.user.username; - if (!username) { - return sendError(403, "not authorized"); - } - const user = await userController.findUser(username); - if (user === undefined) { - return sendError(403, "not authorized"); - } - const settings = await user.get_settings(); - if (settings === undefined) { - ctx.body = {}; - ctx.type = "json"; - return; - } - ctx.body = settings; - ctx.type = "json"; - await next(); - }; -} -export function setUserSettingHandler(userController: UserAccessor) { - return async (ctx: Koa.ParameterizedContext, next: Koa.Next) => { - const username = ctx.state.user.username; - if (!username) { - return sendError(403, "not authorized"); - } - const user = await userController.findUser(username); - if (user === undefined) { - return sendError(403, "not authorized"); - } - const body = ctx.request.body; - const settings = body as Record; - await user.set_settings(settings); - ctx.body = { ok: true }; - ctx.type = "json"; - await next(); - }; + return await tryRefresh(); } -export function createLoginRouter(userController: UserAccessor) { - const router = new Router(); - router.post("/login", createLoginHandler(userController)); - router.post("/logout", LogoutHandler); - router.post("/refresh", createRefreshTokenMiddleware(userController)); - router.post("/reset", resetPasswordMiddleware(userController)); - router.get("/settings", getUserSettingHandler(userController)); - router.post("/settings", setUserSettingHandler(userController)); - return router; -} +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; + + if (username === "admin" && setting.forbid_remote_admin_login) { + throw new ClientRequestError(403, "forbidden remote admin login"); + } + + const user = await userController.findUser(username); + if (!user || !user.password.check_password(password)) { + throw new ClientRequestError(401, "not authorized"); + } + + const permission = await user.get_permissions(); + const accessToken = await createAccessToken({ username: user.username, permission }, secretKey); + const refreshToken = await createRefreshToken({ username: user.username }, secretKey); + + setToken(cookie, accessTokenName, accessToken, accessExpiredTime); + setToken(cookie, refreshTokenName, refreshToken, refreshExpiredTime); + + set.status = 200; + + return { + username: user.username, + permission, + accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime, + } satisfies LoginResponse; + }, { + body: LoginBodySchema, + }) + .post("/logout", ({ cookie, set }) => { + const setting = get_setting(); + setToken(cookie, accessTokenName, null, 0); + setToken(cookie, refreshTokenName, null, 0); + 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() / 1000) + 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, + }), + ); +}; + +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, + }; + }); +}; export const getAdmin = async (cntr: UserAccessor) => { const admin = await cntr.findUser("admin"); if (admin === undefined) { - throw new Error("initial process failed!"); // ??? + throw new Error("initial process failed!"); } return admin; }; diff --git a/packages/server/src/model/doc.ts b/packages/server/src/model/doc.ts index ec7ed75..47a651e 100644 --- a/packages/server/src/model/doc.ts +++ b/packages/server/src/model/doc.ts @@ -1,4 +1,3 @@ -import { check_type } from "../util/type_check.ts"; import type { DocumentBody, QueryListOption, diff --git a/packages/server/src/permission/permission.ts b/packages/server/src/permission/permission.ts index 04f96c7..ce79ef0 100644 --- a/packages/server/src/permission/permission.ts +++ b/packages/server/src/permission/permission.ts @@ -1,6 +1,5 @@ -import type Koa from "koa"; -import type { UserState } from "../login.ts"; import { sendError } from "../route/error_handler.ts"; +import type { UserState } from "../login.ts"; export enum Permission { // ======== @@ -34,27 +33,36 @@ export enum Permission { modifyTagDesc = "ModifyTagDesc", } -export const createPermissionCheckMiddleware = - (...permissions: string[]) => - async (ctx: Koa.ParameterizedContext, next: Koa.Next) => { - const user = ctx.state.user; - if (user.username === "admin") { - return await next(); - } - const user_permission = user.permission; - // if permissions is not subset of user permission - if (!permissions.map((p) => user_permission.includes(p)).every((x) => x)) { - if (user.username === "") { - return sendError(401, "you are guest. login needed."); - } return sendError(403, "do not have permission"); - } - await next(); - }; +type PermissionCheckContext = { + user?: UserState["user"]; + store?: { user?: UserState["user"] }; +} & Record; -export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext, next: Koa.Next) => { - const user = ctx.state.user; - if (user.username !== "admin") { - return sendError(403, "admin only"); +const resolveUser = (context: PermissionCheckContext): UserState["user"] => { + const user = context.user ?? context.store?.user; + if (!user) { + sendError(401, "you are guest. login needed."); } - await next(); + return user as UserState["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 AdminOnly = (context: PermissionCheckContext) => { + const user = resolveUser(context); + if (user.username !== "admin") { + throw sendError(403, "admin only"); + } }; diff --git a/packages/server/src/route/all.ts b/packages/server/src/route/all.ts deleted file mode 100644 index 34fbd09..0000000 --- a/packages/server/src/route/all.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { DefaultContext, Middleware, Next, ParameterizedContext } from "koa"; -import compose from "koa-compose"; -import Router from "koa-router"; -import ComicRouter from "./comic.ts"; -import type { ContentContext } from "./context.ts"; -import VideoRouter from "./video.ts"; - -const table: { [s: string]: Router | undefined } = { - comic: new ComicRouter(), - video: new VideoRouter(), -}; -const all_middleware = - (cont: string | undefined, restarg: string | undefined) => - async (ctx: ParameterizedContext, next: Next) => { - if (cont === undefined) { - ctx.status = 404; - return; - } - if (ctx.state.location === undefined) { - ctx.status = 404; - return; - } - - if (ctx.state.location.type !== cont) { - console.error("not matched"); - ctx.status = 404; - return; - } - const router = table[cont]; - if (router === undefined) { - ctx.status = 404; - return; - } - const rest = `/${restarg ?? ""}`; - const result = router.match(rest, "GET"); - if (!result.route) { - return await next(); - } - // biome-ignore lint/suspicious/noExplicitAny: - const chain = result.pathAndMethod.reduce((combination: Middleware[], cur) => { - combination.push(async (ctx, next) => { - const captures = cur.captures(rest); - ctx.params = cur.params(rest, captures); - ctx.request.params = ctx.params; - ctx.routerPath = cur.path; - return await next(); - }); - return combination.concat(cur.stack); - }, []); - return await compose(chain)(ctx, next); - }; -export class AllContentRouter extends Router { - constructor() { - super(); - this.get("/:content_type", async (ctx, next) => { - return await all_middleware(ctx.params.content_type, undefined)(ctx, next); - }); - this.get("/:content_type/:rest(.*)", async (ctx, next) => { - const cont = ctx.params.content_type as string; - return await all_middleware(cont, ctx.params.rest)(ctx, next); - }); - } -} diff --git a/packages/server/src/route/comic.ts b/packages/server/src/route/comic.ts index 6b30b0b..30663e5 100644 --- a/packages/server/src/route/comic.ts +++ b/packages/server/src/route/comic.ts @@ -1,72 +1,104 @@ -import type { Context } from "koa"; -import Router from "koa-router"; -import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap.ts"; -import type { ContentContext } from "./context.ts"; -import { since_last_modified } from "./util.ts"; +import type { Context as ElysiaContext } from "elysia"; import { Readable } from "node:stream"; +import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap.ts"; +const imageExtensions = new Set(["gif", "png", "jpeg", "bmp", "webp", "jpg", "avif"]); -async function renderZipImage(ctx: Context, path: string, page: number) { - const image_ext = ["gif", "png", "jpeg", "bmp", "webp", "jpg", "avif"]; +const extensionToMime = (ext: string) => { + if (ext === "jpg") return "image/jpeg"; + return `image/${ext}`; +}; + +type ResponseSet = Pick; + +type RenderOptions = { + path: string; + page: number; + reqHeaders: Headers; + set: ResponseSet; +}; + +export async function renderComicPage({ path, page, reqHeaders, set }: RenderOptions) { const zip = await readZip(path); - const entries = (await entriesByNaturalOrder(zip.reader)).filter((x) => { - const ext = x.filename.split(".").pop(); - return ext !== undefined && image_ext.includes(ext); - }); - if (0 <= page && page < entries.length) { - const entry = entries[page]; - const last_modified = entry.lastModDate; - if (since_last_modified(ctx, last_modified)) { - return; - } - const read_stream = await createReadableStreamFromZip(zip.reader, entry); - const nodeReadableStream = new Readable(); - nodeReadableStream._read = () => { }; - read_stream.pipeTo(new WritableStream({ + try { + const entries = (await entriesByNaturalOrder(zip.reader)).filter((entry) => { + const ext = entry.filename.split(".").pop()?.toLowerCase(); + return ext !== undefined && imageExtensions.has(ext); + }); + + if (page < 0 || page >= entries.length) { + set.status = 404; + await zip.reader.close(); + return null; + } + + const entry = entries[page]; + const lastModified = entry.lastModDate ?? new Date(); + const ifModifiedSince = reqHeaders.get("if-modified-since"); + + const headers = (set.headers ??= {} as Record); + headers["Date"] = new Date().toUTCString(); + headers["Last-Modified"] = lastModified.toUTCString(); + + if (ifModifiedSince) { + const cachedDate = new Date(ifModifiedSince); + if (!Number.isNaN(cachedDate.valueOf()) && lastModified <= cachedDate) { + set.status = 304; + await zip.reader.close(); + return null; + } + } + + const readStream = await createReadableStreamFromZip(zip.reader, entry); + const nodeReadable = new Readable({ + read() { + // noop + }, + }); + + let zipClosed = false; + const closeZip = async () => { + if (!zipClosed) { + zipClosed = true; + await zip.reader.close(); + } + }; + + readStream.pipeTo(new WritableStream({ write(chunk) { - nodeReadableStream.push(chunk); + nodeReadable.push(chunk); }, close() { - nodeReadableStream.push(null); + nodeReadable.push(null); }, - })); - nodeReadableStream.on("error", (err) => { - console.error("readalbe stream error",err); - ctx.status = 500; - ctx.body = "Internal Server Error"; - zip.reader.close(); - return; - }); - nodeReadableStream.on("close", () => { - zip.reader.close(); + abort(err) { + nodeReadable.destroy(err); + }, + })).catch((err) => { + nodeReadable.destroy(err); }); - ctx.body = nodeReadableStream; - ctx.response.length = entry.uncompressedSize; - ctx.response.type = entry.filename.split(".").pop() as string; - ctx.status = 200; - ctx.set("Date", new Date().toUTCString()); - ctx.set("Last-Modified", last_modified.toUTCString()); - } else { - ctx.status = 404; + nodeReadable.on("close", () => { + closeZip().catch(console.error); + }); + nodeReadable.on("error", () => { + closeZip().catch(console.error); + }); + nodeReadable.on("end", () => { + closeZip().catch(console.error); + }); + + const ext = entry.filename.split(".").pop()?.toLowerCase() ?? "jpeg"; + headers["Content-Type"] = extensionToMime(ext); + if (typeof entry.uncompressedSize === "number") { + headers["Content-Length"] = entry.uncompressedSize.toString(); + } + + set.status = 200; + return nodeReadable; + } catch (error) { + await zip.reader.close(); + throw error; } } - -export class ComicRouter extends Router { - constructor() { - super(); - this.get("/", async (ctx, next) => { - await renderZipImage(ctx, ctx.state.location.path, 0); - }); - this.get("/:page(\\d+)", async (ctx, next) => { - const page = Number.parseInt(ctx.params.page); - await renderZipImage(ctx, ctx.state.location.path, page); - }); - this.get("/thumbnail", async (ctx, next) => { - await renderZipImage(ctx, ctx.state.location.path, 0); - }); - } -} - -export default ComicRouter; diff --git a/packages/server/src/route/contents.ts b/packages/server/src/route/contents.ts index 83d03f9..cdfffb7 100644 --- a/packages/server/src/route/contents.ts +++ b/packages/server/src/route/contents.ts @@ -1,248 +1,195 @@ -import type { Context, Next } from "koa"; -import Router from "koa-router"; +import { Elysia, t } from "elysia"; import { join } from "node:path"; -import type { - Document, - QueryListOption, -} from "dbtype"; +import type { Document, QueryListOption } from "dbtype"; import type { DocumentAccessor } from "../model/doc.ts"; -import { - AdminOnlyMiddleware as AdminOnly, - createPermissionCheckMiddleware as PerCheck, - Permission as Per, -} from "../permission/permission.ts"; -import { AllContentRouter } from "./all.ts"; -import type { ContentLocation } from "./context.ts"; +import { AdminOnly, createPermissionCheck, Permission as Per } from "../permission/permission.ts"; import { sendError } from "./error_handler.ts"; -import { ParseQueryArgString, ParseQueryArray, ParseQueryBoolean, ParseQueryNumber } from "./util.ts"; import { oshash } from "src/util/oshash.ts"; - - -const ContentIDHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => { - const num = Number.parseInt(ctx.params.num); - const document = await controller.findById(num, true); - if (document === undefined) { - return sendError(404, "document does not exist."); - } - ctx.body = document; - ctx.type = "json"; -}; -const ContentTagIDHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => { - const num = Number.parseInt(ctx.params.num); - const document = await controller.findById(num, true); - if (document === undefined) { - return sendError(404, "document does not exist."); - } - ctx.body = document.tags; - ctx.type = "json"; -}; -const ContentQueryHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => { - - const query_limit = ctx.query.limit; - const query_cursor = ctx.query.cursor; - const query_word = ctx.query.word; - const query_content_type = ctx.query.content_type; - const query_offset = ctx.query.offset; - const query_use_offset = ctx.query.use_offset; - if ([ - query_limit, - query_cursor, - query_word, - query_content_type, - query_offset, - query_use_offset, - ].some((x) => Array.isArray(x))) { - return sendError(400, "paramter can not be array"); - } - const limit = Math.min(ParseQueryNumber(query_limit) ?? 20, 100); - const cursor = ParseQueryNumber(query_cursor); - const word = ParseQueryArgString(query_word); - const content_type = ParseQueryArgString(query_content_type); - const offset = ParseQueryNumber(query_offset); - if (Number.isNaN(limit) || Number.isNaN(cursor) || Number.isNaN(offset)) { - return sendError(400, "parameter limit, cursor or offset is not a number"); - } - const allow_tag = ParseQueryArray(ctx.query.allow_tag); - const [ok, use_offset] = ParseQueryBoolean(query_use_offset); - if (!ok) { - return sendError(400, "use_offset must be true or false."); - } - const option: QueryListOption = { - limit: limit, - allow_tag: allow_tag, - word: word, - cursor: cursor, - eager_loading: true, - offset: offset, - use_offset: use_offset ?? false, - content_type: content_type, - }; - const document = await controller.findList(option); - ctx.body = document; - ctx.type = "json"; -}; -const UpdateContentHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => { - const num = Number.parseInt(ctx.params.num); - - if (ctx.request.type !== "json") { - return sendError(400, "update fail. invalid document type: it is not json."); - } - if (typeof ctx.request.body !== "object") { - return sendError(400, "update fail. invalid argument: not"); - } - const content_desc: Partial & { id: number } = { - id: num, - ...ctx.request.body, - }; - const success = await controller.update(content_desc); - ctx.body = JSON.stringify(success); - ctx.type = "json"; -}; - -const AddTagHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => { - let tag_name = ctx.params.tag; - const num = Number.parseInt(ctx.params.num); - if (typeof tag_name === "undefined") { - return sendError(400, "??? Unreachable"); - } - tag_name = String(tag_name); - const c = await controller.findById(num); - if (c === undefined) { - return sendError(404); - } - const r = await controller.addTag(c, tag_name); - ctx.body = JSON.stringify(r); - ctx.type = "json"; -}; - -const DelTagHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => { - let tag_name = ctx.params.tag; - const num = Number.parseInt(ctx.params.num); - if (typeof tag_name === "undefined") { - return sendError(400, "?? Unreachable"); - } - tag_name = String(tag_name); - const c = await controller.findById(num); - if (c === undefined) { - return sendError(404); - } - const r = await controller.delTag(c, tag_name); - ctx.body = JSON.stringify(r); - ctx.type = "json"; -}; - -const DeleteContentHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => { - const num = Number.parseInt(ctx.params.num); - const r = await controller.del(num); - ctx.body = JSON.stringify(r); - ctx.type = "json"; -}; - -const ContentHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => { - const num = Number.parseInt(ctx.params.num); - const document = await controller.findById(num, true); - if (document === undefined) { - return sendError(404, "document does not exist."); - } - if (document.deleted_at !== null) { - return sendError(404, "document has been removed."); - } - const path = join(document.basepath, document.filename); - ctx.state.location = { - path: path, - type: document.content_type, - additional: document.additional, - } as ContentLocation; - await next(); -}; - -function RehashContentHandler(controller: DocumentAccessor) { - return async (ctx: Context, next: Next) => { - const num = Number.parseInt(ctx.params.num); - const c = await controller.findById(num); - if (c === undefined || c.deleted_at !== null) { - return sendError(404); - } - const filepath = join(c.basepath, c.filename); - let new_hash: string; - try { - new_hash = (await oshash(filepath)).toString(); - } - catch (e) { - // if file is not found, return 404 - if ( (e as NodeJS.ErrnoException).code === "ENOENT") { - return sendError(404, "file not found"); - } - throw e; - } - const r = await controller.update({ - id: num, - content_hash: new_hash, - }); - ctx.body = JSON.stringify(r); - ctx.type = "json"; - }; -} - -function getSimilarDocumentHandler(controller: DocumentAccessor) { - return async (ctx: Context, next: Next) => { - const num = Number.parseInt(ctx.params.num); - const c = await controller.findById(num, true); - if (c === undefined) { - return sendError(404); - } - const r = await controller.getSimilarDocument(c); - ctx.body = r; - ctx.type = "json"; - }; -} - -function getRescanDocumentHandler(controller: DocumentAccessor) { - return async (ctx: Context, next: Next) => { - const num = Number.parseInt(ctx.params.num); - const c = await controller.findById(num, true); - if (c === undefined) { - return sendError(404); - } - await controller.rescanDocument(c); - // 204 No Content - ctx.status = 204; - }; -} - -function ContentGidListHandler(controller: DocumentAccessor) { - return async (ctx: Context, next: Next) => { - const gid_list = ParseQueryArray(ctx.query.gid).map((x) => Number.parseInt(x)) - if (gid_list.some((x) => Number.isNaN(x))) { - return sendError(400, "gid is not a number"); - } - // size limit - if (gid_list.length > 100) { - return sendError(400, "gid list is too long"); - } - const r = await controller.findByGidList(gid_list); - ctx.body = r; - ctx.type = "json"; - }; -} +import { renderComicPage } from "./comic.ts"; export const getContentRouter = (controller: DocumentAccessor) => { - const ret = new Router(); - ret.get("/search", PerCheck(Per.QueryContent), ContentQueryHandler(controller)); - ret.get("/_gid", PerCheck(Per.QueryContent), ContentGidListHandler(controller)); - ret.get("/:num(\\d+)", PerCheck(Per.QueryContent), ContentIDHandler(controller)); - ret.all("/:num(\\d+)/(.*)", PerCheck(Per.QueryContent), ContentHandler(controller)); - ret.post("/:num(\\d+)", AdminOnly, UpdateContentHandler(controller)); - ret.del("/:num(\\d+)", AdminOnly, DeleteContentHandler(controller)); - // ret.post("/",AdminOnly,CreateContentHandler(controller)); - ret.get("/:num(\\d+)/similars", PerCheck(Per.QueryContent), getSimilarDocumentHandler(controller)); - ret.get("/:num(\\d+)/tags", PerCheck(Per.QueryContent), ContentTagIDHandler(controller)); - ret.post("/:num(\\d+)/tags/:tag", PerCheck(Per.ModifyTag), AddTagHandler(controller)); - ret.del("/:num(\\d+)/tags/:tag", PerCheck(Per.ModifyTag), DelTagHandler(controller)); - ret.use("/:num(\\d+)", PerCheck(Per.QueryContent), new AllContentRouter().routes()); - ret.post("/:num(\\d+)/_rehash", AdminOnly, RehashContentHandler(controller)); - ret.post("/:num(\\d+)/_rescan", AdminOnly, getRescanDocumentHandler(controller)); - return ret; + return new Elysia({ name: "content-router", + prefix: "/doc", + }) + .get("/search", async ({ query }) => { + const limit = Math.min(Number(query.limit ?? 20), 100); + const option: QueryListOption = { + limit: limit, + allow_tag: query.allow_tag?.split(",") ?? [], + word: query.word, + cursor: query.cursor, + eager_loading: true, + offset: Number(query.offset), + 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) { + 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 } }) => { + 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 } = { + id: num, + ...body, + }; + 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 } }) => { + 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 } }) => { + 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 } }) => { + 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 } }) => { + 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 } }) => { + 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") { + throw sendError(404, "file not found"); + } + throw e; + } + }, { + beforeHandle: AdminOnly, + params: t.Object({ num: t.Numeric() }) + }) + .post("/:num/_rescan", async ({ params: { num }, set }) => { + 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 }; + }) + .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() }), + }) + .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() }), + }) + ); }; export default getContentRouter; diff --git a/packages/server/src/route/error_handler.ts b/packages/server/src/route/error_handler.ts index 7677cb1..499f028 100644 --- a/packages/server/src/route/error_handler.ts +++ b/packages/server/src/route/error_handler.ts @@ -1,5 +1,4 @@ import { ZodError } from "dbtype"; -import type { Context, Next } from "koa"; export interface ErrorFormat { code: number; @@ -7,15 +6,12 @@ export interface ErrorFormat { detail?: string; } -class ClientRequestError implements Error { - name: string; - message: string; - stack?: string | undefined; +export class ClientRequestError extends Error { code: number; constructor(code: number, message: string) { + super(message); this.name = "client request error"; - this.message = message; this.code = code; } } @@ -25,36 +21,32 @@ const code_to_message_table: { [key: number]: string | undefined } = { 404: "NotFound", }; -export const error_handler = async (ctx: Context, next: Next) => { - try { - await next(); - } catch (err) { - if (err instanceof ClientRequestError) { - const body: ErrorFormat = { - code: err.code, - message: code_to_message_table[err.code] ?? "", - detail: err.message, - }; - ctx.status = err.code; - ctx.body = body; - } - else if (err instanceof ZodError) { - const body: ErrorFormat = { - code: 400, - message: "BadRequest", - detail: err.errors.map((x) => x.message).join(", "), - }; - ctx.status = 400; - ctx.body = body; - } - else { - throw err; - } - } +export const error_handler = ({ code, error, set }: { code: string, error: Error, set: { status?: number | string } }) => { + if (error instanceof ClientRequestError) { + set.status = error.code; + return { + code: error.code, + message: code_to_message_table[error.code] ?? "", + detail: error.message, + } satisfies ErrorFormat; + } + if (error instanceof ZodError) { + set.status = 400; + return { + code: 400, + message: "BadRequest", + detail: error.errors.map((x) => x.message).join(", "), + } satisfies ErrorFormat; + } + + set.status = 500; + return { + code: 500, + message: "Internal Server Error", + detail: error.message, + } }; -export const sendError = (code: number, message?: string) => { +export const sendError = (code: number, message?: string): never => { throw new ClientRequestError(code, message ?? ""); }; - -export default error_handler; diff --git a/packages/server/src/route/settings.ts b/packages/server/src/route/settings.ts new file mode 100644 index 0000000..6e1ce2e --- /dev/null +++ b/packages/server/src/route/settings.ts @@ -0,0 +1,72 @@ +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", () => { + return 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/route/tags.ts b/packages/server/src/route/tags.ts index 5a1993d..03212ac 100644 --- a/packages/server/src/route/tags.ts +++ b/packages/server/src/route/tags.ts @@ -1,29 +1,33 @@ -import { type Context, Next } from "koa"; -import Router, { type RouterContext } from "koa-router"; +import { Elysia, t } from "elysia"; import type { TagAccessor } from "../model/tag.ts"; -import { createPermissionCheckMiddleware as PerCheck, Permission } from "../permission/permission.ts"; +import { createPermissionCheck, Permission } from "../permission/permission.ts"; import { sendError } from "./error_handler.ts"; export function getTagRounter(tagController: TagAccessor) { - const router = new Router(); - router.get("/", PerCheck(Permission.QueryContent), async (ctx: Context) => { - if (ctx.query.withCount) { - const c = await tagController.getAllTagCount(); - ctx.body = c; - } else { - const c = await tagController.getAllTagList(); - ctx.body = c; - } - ctx.type = "json"; - }); - router.get("/:tag_name", PerCheck(Permission.QueryContent), async (ctx: RouterContext) => { - const tag_name = ctx.params.tag_name; - const c = await tagController.getTagByName(tag_name); - if (!c) { - sendError(404, "tags not found"); - } - ctx.body = c; - ctx.type = "json"; - }); - return router; + return new Elysia({ name: "tags-router", + prefix: "/tags", + }) + .get("/", async ({ query }) => { + if (query.withCount !== undefined) { + return 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 } }) => { + 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(), + }) + }); } diff --git a/packages/server/src/route/util.ts b/packages/server/src/route/util.ts deleted file mode 100644 index 8266aab..0000000 --- a/packages/server/src/route/util.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Context } from "koa"; - -export function ParseQueryNumber(s: string[] | string | undefined): number | undefined { - if (s === undefined) return undefined; - if (typeof s === "object") return undefined; - return Number.parseInt(s); -} -export function ParseQueryArray(s: string[] | string | undefined) { - const input = s ?? []; - const r = Array.isArray(input) ? input : input.split(","); - return r.map((x) => decodeURIComponent(x)); -} -export function ParseQueryArgString(s: string[] | string | undefined) { - if (typeof s === "object") return undefined; - return s === undefined ? s : decodeURIComponent(s); -} -export function ParseQueryBoolean(s: string[] | string | undefined): [boolean, boolean | undefined] { - let value: boolean | undefined; - - if (s === "true") { - value = true; - } else if (s === "false") { - value = false; - } else if (s === undefined) { - value = undefined; - } else return [false, undefined]; - return [true, value]; -} - -export function since_last_modified(ctx: Context, last_modified: Date): boolean { - const con = ctx.get("If-Modified-Since"); - if (con === "") return false; - const mdate = new Date(con); - if (last_modified > mdate) return false; - ctx.status = 304; - return true; -} diff --git a/packages/server/src/route/video.ts b/packages/server/src/route/video.ts deleted file mode 100644 index 65d1c91..0000000 --- a/packages/server/src/route/video.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { createReadStream, promises } from "node:fs"; -import type { Context } from "koa"; -import Router from "koa-router"; -import type { ContentContext } from "./context.ts"; - -export async function renderVideo(ctx: Context, path: string) { - const ext = path.trim().split(".").pop(); - if (ext === undefined) { - // ctx.status = 404; - console.error(`${path}:${ext}`); - return; - } - ctx.response.type = ext; - const range_text = ctx.request.get("range"); - const stat = await promises.stat(path); - let start = 0; - let end = 0; - ctx.set("Last-Modified", new Date(stat.mtime).toUTCString()); - ctx.set("Date", new Date().toUTCString()); - ctx.set("Accept-Ranges", "bytes"); - if (range_text === "") { - end = 1024 * 512; - end = Math.min(end, stat.size - 1); - if (start > end) { - ctx.status = 416; - return; - } - ctx.status = 200; - ctx.length = stat.size; - const stream = createReadStream(path); - ctx.body = stream; - } else { - const m = range_text.match(/^bytes=(\d+)-(\d*)/); - if (m === null) { - ctx.status = 416; - return; - } - start = Number.parseInt(m[1]); - end = m[2].length > 0 ? Number.parseInt(m[2]) : start + 1024 * 1024; - end = Math.min(end, stat.size - 1); - if (start > end) { - ctx.status = 416; - return; - } - ctx.status = 206; - ctx.length = end - start + 1; - ctx.response.set("Content-Range", `bytes ${start}-${end}/${stat.size}`); - ctx.body = createReadStream(path, { - start: start, - end: end, - }); // inclusive range. - } -} - -export class VideoRouter extends Router { - constructor() { - super(); - this.get("/", async (ctx, next) => { - await renderVideo(ctx, ctx.state.location.path); - }); - this.get("/thumbnail", async (ctx, next) => { - await renderVideo(ctx, ctx.state.location.path); - }); - } -} - -export default VideoRouter; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 6ba4037..d51bcd3 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,255 +1,158 @@ -import Koa from "koa"; -import Router from "koa-router"; +import { Elysia, t } from "elysia"; +import { cors } from "@elysiajs/cors"; +import { staticPlugin } from "@elysiajs/static"; +import { html } from "@elysiajs/html"; import { connectDB } from "./database.ts"; import { createDiffRouter, DiffManager } from "./diff/mod.ts"; -import { get_setting, SettingConfig } from "./SettingConfig.ts"; +import { get_setting, initializeSetting } from "./SettingConfig.ts"; -import { createReadStream, readFileSync } from "node:fs"; -import bodyparser from "koa-bodyparser"; +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"; +import { node } from "@elysiajs/node"; +import { openapi } from "@elysiajs/openapi"; import { config } from "dotenv"; -import { extname, join } from "node:path"; config(); -class ServerApplication { - readonly userController: UserAccessor; - readonly documentController: DocumentAccessor; - readonly tagController: TagAccessor; - readonly diffManger: DiffManager; - readonly app: Koa; - private index_html: string; - private constructor(controller: { - userController: UserAccessor; - documentController: DocumentAccessor; - tagController: TagAccessor; - }) { - this.userController = controller.userController; - this.documentController = controller.documentController; - this.tagController = controller.tagController; - - this.diffManger = new DiffManager(this.documentController); - this.app = new Koa(); - this.index_html = readFileSync("dist/index.html", "utf-8"); - } - private async setup() { - const setting = get_setting(); - const app = this.app; - - if (setting.cli) { - const userAdmin = await getAdmin(this.userController); - if (await isAdminFirst(userAdmin)) { - const rl = createReadlineInterface({ - input: process.stdin, - output: process.stdout, - }); - const pw = await new Promise((res: (data: string) => void, err) => { - rl.question("put admin password :", (data) => { - res(data); - }); - }); - rl.close(); - userAdmin.reset_password(pw); - } - } - app.use(bodyparser()); - app.use(error_handler); - app.use(createUserHandler(this.userController)); - - const diff_router = createDiffRouter(this.diffManger); - this.diffManger.register("comic", createComicWatcher()); - - console.log("setup router"); - - const router = new Router(); - router.use("/api/(.*)", async (ctx, next) => { - // For CORS - ctx.res.setHeader("access-control-allow-origin", "*"); - await next(); - }); - - router.use("/api/diff", diff_router.routes()); - router.use("/api/diff", diff_router.allowedMethods()); - - const content_router = getContentRouter(this.documentController); - router.use("/api/doc", content_router.routes()); - router.use("/api/doc", content_router.allowedMethods()); - - const tags_router = getTagRounter(this.tagController); - router.use("/api/tags", tags_router.allowedMethods()); - router.use("/api/tags", tags_router.routes()); - - this.serve_with_meta_index(router); - this.serve_index(router); - this.serve_static_file(router); - - const login_router = createLoginRouter(this.userController); - router.use("/api/user", login_router.routes()); - router.use("/api/user", login_router.allowedMethods()); - - if (setting.mode === "development") { - let mm_count = 0; - app.use(async (ctx, next) => { - console.log(`=== Request No ${mm_count++} \t===`); - const ip = ctx.get("X-Real-IP").length > 0 ? ctx.get("X-Real-IP") : ctx.ip; - const fromClient = ctx.state.user.username === "" ? ip : ctx.state.user.username; - console.log(`${mm_count} ${fromClient} : ${ctx.method} ${ctx.url}`); - const start = Date.now(); - await next(); - const end = Date.now(); - console.log(`${mm_count} ${fromClient} : ${ctx.method} ${ctx.url} ${ctx.status} ${end - start}ms`); - }); - } - app.use(router.routes()); - app.use(router.allowedMethods()); - console.log("setup done"); - } - private serve_index(router: Router) { - const serveindex = (url: string) => { - router.get(url, (ctx) => { - ctx.type = "html"; - ctx.body = this.index_html; - const setting = get_setting(); - ctx.set("x-content-type-options", "no-sniff"); - if (setting.mode === "development") { - ctx.set("cache-control", "no-cache"); - } else { - ctx.set("cache-control", "public, max-age=3600"); - } - }); - }; - serveindex("/"); - serveindex("/doc/:rest(.*)"); - serveindex("/search"); - serveindex("/login"); - serveindex("/profile"); - serveindex("/difference"); - serveindex("/setting"); - serveindex("/tags"); - } - private serve_with_meta_index(router: Router) { - const DocMiddleware = async (ctx: Koa.ParameterizedContext) => { - const docId = Number.parseInt(ctx.params.id); - const doc = await this.documentController.findById(docId, true); - // biome-ignore lint/suspicious/noImplicitAnyLet: - let meta; - if (doc === undefined) { - ctx.status = 404; - meta = NotFoundContent(); - } else { - ctx.status = 200; - meta = createOgTagContent( - doc.title, - doc.tags.join(", "), - `https://aeolian.monoid.top/api/doc/${docId}/comic/thumbnail`, - ); - } - const html = makeMetaTagInjectedHTML(this.index_html, meta); - serveHTML(ctx, html); - }; - router.get("/doc/:id(\\d+)", DocMiddleware); - - function NotFoundContent() { - return createOgTagContent("Not Found Doc", "Not Found", ""); - } - function makeMetaTagInjectedHTML(html: string, tagContent: string) { - return html.replace("", tagContent); - } - function serveHTML(ctx: Koa.Context, file: string) { - ctx.type = "html"; - ctx.body = file; - const setting = get_setting(); - ctx.set("x-content-type-options", "no-sniff"); - if (setting.mode === "development") { - ctx.set("cache-control", "no-cache"); - } else { - ctx.set("cache-control", "public, max-age=3600"); - } - } - - function createMetaTagContent(key: string, value: string) { - 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("og:image:width","480"), - // createMetaTagContent("og:image","480"), - // createMetaTagContent("og:image:type","image/png"), - createMetaTagContent("twitter:card", "summary_large_image"), - createMetaTagContent("twitter:title", title), - createMetaTagContent("twitter:description", description), - createMetaTagContent("twitter:image", image), - ].join("\n"); - } - } - private serve_static_file(router: Router) { - router.get("/assets/(.*)", async (ctx, next) => { - const setting = get_setting(); - const ext = extname(ctx.path); - ctx.type = ext; - ctx.body = createReadStream(join("dist",`.${ctx.path}`)); - ctx.set("x-content-type-options", "no-sniff"); - if (setting.mode === "development") { - ctx.set("cache-control", "no-cache"); - } else { - ctx.set("cache-control", "public, max-age=3600"); - } - }); - // const static_file_server = (path: string, type: string) => { - // router.get(`/${path}`, async (ctx, next) => { - // const setting = get_setting(); - // ctx.type = type; - // ctx.body = createReadStream(path); - // ctx.set("x-content-type-options", "no-sniff"); - // if (setting.mode === "development") { - // ctx.set("cache-control", "no-cache"); - // } else { - // ctx.set("cache-control", "public, max-age=3600"); - // } - // }); - // }; - // const setting = get_setting(); - // static_file_server("dist/bundle.css", "css"); - // static_file_server("dist/bundle.js", "js"); - // if (setting.mode === "development") { - // static_file_server("dist/bundle.js.map", "text"); - // static_file_server("dist/bundle.css.map", "text"); - // } - } - start_server() { - const setting = get_setting(); - // todo : support https - console.log(`listen on http://${setting.localmode ? "localhost" : "0.0.0.0"}:${setting.port}`); - return this.app.listen(setting.port, setting.localmode ? "127.0.0.1" : "0.0.0.0"); - } - static async createServer() { - const db = await connectDB(); - - const app = new ServerApplication({ - userController: createSqliteUserController(db), - documentController: createSqliteDocumentAccessor(db), - tagController: createSqliteTagController(db), - }); - await app.setup(); - return app; - } +function createMetaTagContent(key: string, value: string) { + 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 makeMetaTagInjectedHTML(html: string, tagContent: string) { + return html.replace("", tagContent); +} + +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"); + } +}; + + export async function create_server() { - return await ServerApplication.createServer(); -} + const db = await connectDB(); + await initializeSetting(db); + const setting = get_setting(); -export default { create_server }; + 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)); + + 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); + } + } + + const index_html = 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) + + app.listen({ + port: setting.port, + hostname: setting.hostname, + }); + + 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 new file mode 100644 index 0000000..ddcf004 --- /dev/null +++ b/packages/server/src/setting.ts @@ -0,0 +1,7 @@ +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 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/src/util/type_check.ts b/packages/server/src/util/type_check.ts deleted file mode 100644 index 6ee6d50..0000000 --- a/packages/server/src/util/type_check.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function check_type(obj: unknown, check_proto: Record): obj is T { - if (typeof obj !== "object" || obj === null) return false; - for (const it in check_proto) { - let defined = check_proto[it]; - if (defined === undefined) return false; - defined = defined.trim(); - if (defined.endsWith("[]")) { - if (!Array.isArray((obj as Record)[it])) { - return false; - } - // biome-ignore lint/suspicious/useValidTypeof: - } else if (defined !== typeof (obj as Record)[it]) { - return false; - } - } - return true; -} diff --git a/packages/server/src/util/zipwrap.ts b/packages/server/src/util/zipwrap.ts index 6589e8e..dc7cf7c 100644 --- a/packages/server/src/util/zipwrap.ts +++ b/packages/server/src/util/zipwrap.ts @@ -1,11 +1,11 @@ import { type FileHandle, open } from "node:fs/promises"; import { orderBy } from "natural-orderby"; import { ZipReader, Reader, type Entry, ZipReaderConstructorOptions } from "@zip.js/zip.js"; -import EventEmitter from "node:events"; class FileReader extends Reader { private fd?: FileHandle; private path: string; + private closed = false; constructor(path: string) { super(path); @@ -14,21 +14,29 @@ class FileReader extends Reader { async init(): Promise { await super.init?.(); + if (this.closed) return; + const fd = await open(this.path, "r"); const stat = await fd.stat(); this.fd = fd; this.size = stat.size; - // not implemented yet - (this.fd as unknown as EventEmitter).on("close", () => { - this.fd?.close(); - this.fd = undefined; - }); } + async close(): Promise { - await this.fd?.close(); + if (this.closed) return; + this.closed = true; + + if (this.fd) { + await this.fd.close(); + this.fd = undefined; + } } async readUint8Array(index: number, length: number): Promise { + if (this.closed) { + throw new Error("FileReader is closed"); + } + try { const buffer = new Uint8Array(length); if (this.fd === undefined) { @@ -49,18 +57,29 @@ class FileReader extends Reader { return buffer; } catch (error) { console.error("read error", error); + // 에러 발생 시 파일 핸들 정리 + await this.close(); throw error; } } } class FileZipReader extends ZipReader { + private closed = false; + constructor(private reader: FileReader, options?: ZipReaderConstructorOptions) { super(reader, options); } + override async close(): Promise { - super.close(); - await this.reader.close(); + if (this.closed) return; + this.closed = true; + + try { + await super.close(); + } finally { + await this.reader.close(); + } } } 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 2b06c91..dd65b32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,21 @@ 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)) + '@elysiajs/static': + specifier: ^1.3.0 + version: 1.3.0(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)) '@std/async': specifier: npm:@jsr/std__async@^1.0.13 version: '@jsr/std__async@1.0.13' @@ -187,21 +202,12 @@ 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) jose: specifier: ^5.10.0 version: 5.10.0 - koa: - specifier: ^2.16.1 - version: 2.16.1 - koa-bodyparser: - specifier: ^4.4.1 - version: 4.4.1 - koa-compose: - specifier: ^4.1.0 - version: 4.1.0 - koa-router: - specifier: ^12.0.1 - version: 12.0.1 kysely: specifier: ^0.27.6 version: 0.27.6 @@ -215,21 +221,6 @@ importers: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 - '@types/jsonwebtoken': - specifier: ^8.5.9 - version: 8.5.9 - '@types/koa': - specifier: ^2.15.0 - version: 2.15.0 - '@types/koa-bodyparser': - specifier: ^4.3.12 - version: 4.3.12 - '@types/koa-compose': - specifier: ^3.2.8 - version: 3.2.8 - '@types/koa-router': - specifier: ^7.4.8 - version: 7.4.8 '@types/node': specifier: ^22.15.33 version: 22.15.33 @@ -242,6 +233,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: @@ -421,6 +415,34 @@ 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' + + '@elysiajs/static@1.3.0': + resolution: {integrity: sha512-7mWlj2U/AZvH27IfRKqpUjDP1W9ZRldF9NmdnatFEtx0AOy7YYgyk0rt5hXrH6wPcR//2gO2Qy+k5rwswpEhJA==} + peerDependencies: + elysia: '>= 1.3.0' + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -742,9 +764,6 @@ packages: '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} - '@hapi/bourne@3.0.0': - resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==} - '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -783,6 +802,17 @@ 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'} @@ -1315,6 +1345,9 @@ packages: cpu: [x64] os: [win32] + '@sinclair/typebox@0.34.41': + resolution: {integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==} + '@swc/core-darwin-arm64@1.11.31': resolution: {integrity: sha512-NTEaYOts0OGSbJZc0O74xsji+64JrF1stmBii6D5EevWEtrY4wlZhm8SiP/qPrOB+HqtAihxWIukWkP2aSdGSQ==} engines: {node: '>=10'} @@ -1399,30 +1432,22 @@ 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==} - '@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==} @@ -1456,39 +1481,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/jsonwebtoken@8.5.9': - resolution: {integrity: sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==} - - '@types/keygrip@1.0.6': - resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} - - '@types/koa-bodyparser@4.3.12': - resolution: {integrity: sha512-hKMmRMVP889gPIdLZmmtou/BijaU1tHPyMNmcK7FAHAdATnRcGQQy78EqTTxLH1D4FTsrxIzklAQCso9oGoebQ==} - - '@types/koa-compose@3.2.8': - resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==} - - '@types/koa-router@7.4.8': - resolution: {integrity: sha512-SkWlv4F9f+l3WqYNQHnWjYnyTxYthqt8W9az2RTdQW7Ay8bc00iRZcrb8MC75iEfPqnGcg2csEl8tTG1NQPD4A==} - - '@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==} @@ -1501,12 +1493,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: @@ -1515,12 +1501,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==} @@ -1623,10 +1603,6 @@ packages: resolution: {integrity: sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=16.5.0'} - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1746,26 +1722,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - cache-content-type@1.0.1: - resolution: {integrity: sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==} - engines: {node: '>= 6.0.0'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1815,22 +1775,22 @@ 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'} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - co-body@6.2.0: - resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==} - engines: {node: '>=8.0.0'} - - co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - code-block-writer@12.0.0: resolution: {integrity: sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==} @@ -1858,23 +1818,15 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookies@0.9.1: - resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} - engines: {node: '>= 0.8'} + cookie-es@2.0.0: + resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} - copy-to@2.0.1: - resolution: {integrity: sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==} + 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==} @@ -1889,6 +1841,14 @@ 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'} @@ -1954,6 +1914,15 @@ 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==} @@ -1965,9 +1934,6 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} - deep-equal@1.0.1: - resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} - deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -1978,25 +1944,10 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - delegates@1.0.0: - resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} - - depd@1.1.2: - resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} - engines: {node: '>= 0.6'} - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-libc@2.0.3: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} @@ -2041,29 +1992,32 @@ packages: resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} engines: {node: '>=12'} - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - 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==} emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} - engines: {node: '>= 0.8'} - end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} @@ -2073,21 +2027,9 @@ packages: error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -2102,9 +2044,6 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -2169,6 +2108,14 @@ 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} @@ -2181,6 +2128,9 @@ 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==} @@ -2205,10 +2155,17 @@ 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==} @@ -2238,10 +2195,6 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -2264,18 +2217,14 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} + 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'} - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -2318,10 +2267,6 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2336,30 +2281,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - http-assert@1.5.0: - resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} - engines: {node: '>= 0.8'} - - http-errors@1.8.1: - resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} - engines: {node: '>= 0.6'} - - http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} - engines: {node: '>= 0.8'} - https-proxy-agent@6.2.1: resolution: {integrity: sha512-ONsE3+yfZF2caH5+bJlcddtWqNI3Gvs5A38+ngvljxaBiRXRswym2c7yf8UAeFpRFKjFNHIFEHqR/OLAWJzyiA==} engines: {node: '>= 14'} @@ -2368,10 +2293,6 @@ packages: resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} engines: {node: '>=14.18.0'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2387,10 +2308,6 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inflation@2.1.0: - resolution: {integrity: sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==} - engines: {node: '>= 0.8.0'} - inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -2428,10 +2345,6 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-generator-function@1.1.0: - resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} - engines: {node: '>= 0.4'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2448,10 +2361,6 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2517,10 +2426,6 @@ packages: jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} - keygrip@1.1.0: - resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} - engines: {node: '>= 0.6'} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2528,25 +2433,6 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - koa-bodyparser@4.4.1: - resolution: {integrity: sha512-kBH3IYPMb+iAXnrxIhXnW+gXV8OTzCu8VPDqvcDHW9SQrbkHmqPQtiZwrltNmSq6/lpipHnT7k7PsjlVD7kK0w==} - engines: {node: '>=8.0.0'} - - koa-compose@4.1.0: - resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} - - koa-convert@2.0.0: - resolution: {integrity: sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==} - engines: {node: '>= 10'} - - koa-router@12.0.1: - resolution: {integrity: sha512-gaDdj3GtzoLoeosacd50kBBTnnh3B9AYxDThQUo4sfUyXdOhY6ku1qyZKW88tQCRgc3Sw6ChXYXWZwwgjOxE0w==} - engines: {node: '>= 12'} - - koa@2.16.1: - resolution: {integrity: sha512-umfX9d3iuSxTQP4pnzLOz0HKnPg0FaUUIKcye2lOiz3KPu1Y3M3xlz76dISdFPQs37P9eJz1wUpcTS6KDPn9fA==} - engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4} - kysely-codegen@0.14.1: resolution: {integrity: sha512-W6ULVWYnlBcGalHlKqw3ls0QjPl6n6z1xJ9ixPCh1lxDSbe98s4gjEbKC3hh8ZTCUTZSLSasi+QCii4AL/j7pw==} hasBin: true @@ -2649,14 +2535,6 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -2664,10 +2542,6 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - micromatch@4.0.5: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} engines: {node: '>=8.6'} @@ -2676,14 +2550,6 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -2748,10 +2614,6 @@ packages: natural-orderby@2.0.3: resolution: {integrity: sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==} - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - node-abi@3.68.0: resolution: {integrity: sha512-7vbj10trelExNjFSBm5kTvZXXa7pZyKWx9RCKIyqe6I9Ev3IzGpQoqBP3a+cOdxY+pWj6VkP28n/2wWysBHD/A==} engines: {node: '>=10'} @@ -2760,6 +2622,10 @@ packages: resolution: {integrity: sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==} engines: {node: '>=10'} + node-cache@5.1.2: + resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==} + engines: {node: '>= 8.0.0'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -2792,14 +2658,6 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2811,8 +2669,8 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} - only@0.0.2: - resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} @@ -2841,10 +2699,6 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -2871,9 +2725,6 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -2972,17 +2823,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} - engines: {node: '>=0.6'} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} - engines: {node: '>= 0.8'} - rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -3083,6 +2926,10 @@ 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'} @@ -3119,13 +2966,6 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -3142,9 +2982,6 @@ packages: engines: {node: '>=10'} hasBin: true - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn-ui@0.8.0: resolution: {integrity: sha512-avqRgjJ6PIQQXdfvoCAWQpyLTLk6oHhtU5DQKmLeYcgu1ZIsgMqA9MKWAkr0HpEdCAenCCZvFbvJ2C2m5ZXRiA==} hasBin: true @@ -3166,22 +3003,6 @@ packages: engines: {node: '>=4'} hasBin: true - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -3213,17 +3034,14 @@ 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==} - statuses@1.5.0: - resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} - engines: {node: '>= 0.6'} - - statuses@2.0.1: - resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} - engines: {node: '>= 0.8'} - std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -3266,6 +3084,10 @@ 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'} @@ -3349,9 +3171,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} + 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==} @@ -3372,10 +3194,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsscmp@1.0.6: - resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} - engines: {node: '>=0.6.x'} - tsx@4.20.3: resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==} engines: {node: '>=18.0.0'} @@ -3392,10 +3210,6 @@ packages: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - typescript@5.4.3: resolution: {integrity: sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==} engines: {node: '>=14.17'} @@ -3406,6 +3220,10 @@ 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==} @@ -3416,10 +3234,6 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -3463,10 +3277,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} @@ -3568,6 +3378,10 @@ 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==} @@ -3576,9 +3390,13 @@ packages: engines: {node: '>= 14.6'} hasBin: true - ylru@1.4.0: - resolution: {integrity: sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==} - engines: {node: '>= 4.0.0'} + 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==} @@ -3798,6 +3616,36 @@ 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) + + '@elysiajs/static@1.3.0(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) + node-cache: 5.1.2 + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -3982,8 +3830,6 @@ snapshots: '@floating-ui/utils@0.2.9': {} - '@hapi/bourne@3.0.0': {} - '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -4024,6 +3870,18 @@ 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 @@ -4513,6 +4371,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.42.0': optional: true + '@sinclair/typebox@0.34.41': {} + '@swc/core-darwin-arm64@1.11.31': optional: true @@ -4573,6 +4433,18 @@ 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 @@ -4580,10 +4452,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 @@ -4592,24 +4460,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': {} @@ -4638,54 +4488,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/jsonwebtoken@8.5.9': - dependencies: - '@types/node': 22.15.33 - - '@types/keygrip@1.0.6': {} - - '@types/koa-bodyparser@4.3.12': - dependencies: - '@types/koa': 2.15.0 - - '@types/koa-compose@3.2.8': - dependencies: - '@types/koa': 2.15.0 - - '@types/koa-router@7.4.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 @@ -4700,10 +4502,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 @@ -4713,17 +4511,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)': @@ -4832,6 +4619,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 @@ -4859,11 +4654,6 @@ snapshots: '@zip.js/zip.js@2.7.62': {} - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - acorn-jsx@5.3.2(acorn@8.14.1): dependencies: acorn: 8.14.1 @@ -4988,25 +4778,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bytes@3.1.2: {} - cac@6.7.14: {} - cache-content-type@1.0.1: - dependencies: - mime-types: 2.1.35 - ylru: 1.4.0 - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - callsites@3.1.0: {} camelcase-css@2.0.1: {} @@ -5060,20 +4833,18 @@ 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: {} + clone@2.1.2: {} + clsx@2.1.1: {} - co-body@6.2.0: - dependencies: - '@hapi/bourne': 3.0.0 - inflation: 2.1.0 - qs: 6.14.0 - raw-body: 2.5.2 - type-is: 1.6.18 - - co@4.6.0: {} - code-block-writer@12.0.0: {} color-convert@1.9.3: @@ -5094,20 +4865,11 @@ snapshots: concat-map@0.0.1: {} - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 - - content-type@1.0.5: {} - convert-source-map@2.0.0: {} - cookies@0.9.1: - dependencies: - depd: 2.0.0 - keygrip: 1.1.0 + cookie-es@2.0.0: {} - copy-to@2.0.1: {} + cookie@1.0.2: {} cosmiconfig@8.3.6(typescript@5.8.3): dependencies: @@ -5124,6 +4886,10 @@ 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: {} @@ -5172,6 +4938,11 @@ 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: @@ -5180,8 +4951,6 @@ snapshots: deep-eql@5.0.2: {} - deep-equal@1.0.1: {} - deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -5190,16 +4959,8 @@ snapshots: dependencies: clone: 1.0.4 - delegates@1.0.0: {} - - depd@1.1.2: {} - - depd@2.0.0: {} - dequal@2.0.3: {} - destroy@1.2.0: {} - detect-libc@2.0.3: {} detect-libc@2.0.4: {} @@ -5233,24 +4994,25 @@ snapshots: dotenv@16.5.0: {} - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - eastasianwidth@0.2.0: {} - ee-first@1.1.1: {} - 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: {} - encodeurl@1.0.2: {} - end-of-stream@1.4.4: dependencies: once: 1.4.0 @@ -5263,16 +5025,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -5329,8 +5083,6 @@ snapshots: escalade@3.2.0: {} - escape-html@1.0.3: {} - escape-string-regexp@1.0.5: {} escape-string-regexp@4.0.0: {} @@ -5419,6 +5171,10 @@ 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 @@ -5435,6 +5191,8 @@ snapshots: expect-type@1.2.1: {} + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-equals@5.2.2: {} @@ -5460,10 +5218,23 @@ 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: @@ -5494,8 +5265,6 @@ snapshots: fraction.js@4.3.7: {} - fresh@0.5.2: {} - fs-constants@1.0.0: {} fs-extra@11.3.0: @@ -5513,26 +5282,10 @@ snapshots: gensync@1.0.0-beta.2: {} - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 + get-caller-file@2.0.5: {} get-nonce@1.0.1: {} - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - get-stream@6.0.1: {} get-tsconfig@4.10.1: @@ -5590,8 +5343,6 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 - gopd@1.2.0: {} - graceful-fs@4.2.11: {} graphemer@1.4.0: {} @@ -5600,37 +5351,10 @@ snapshots: has-flag@4.0.0: {} - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - hasown@2.0.2: dependencies: function-bind: 1.1.2 - http-assert@1.5.0: - dependencies: - deep-equal: 1.0.1 - http-errors: 1.8.1 - - http-errors@1.8.1: - dependencies: - depd: 1.1.2 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 1.5.0 - toidentifier: 1.0.1 - - http-errors@2.0.0: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.1 - toidentifier: 1.0.1 - https-proxy-agent@6.2.1: dependencies: agent-base: 7.1.3 @@ -5640,10 +5364,6 @@ snapshots: human-signals@4.3.1: {} - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - ieee754@1.2.1: {} ignore@5.3.2: {} @@ -5655,8 +5375,6 @@ snapshots: imurmurhash@0.1.4: {} - inflation@2.1.0: {} - inflight@1.0.6: dependencies: once: 1.4.0 @@ -5684,13 +5402,6 @@ snapshots: is-fullwidth-code-point@3.0.0: {} - is-generator-function@1.1.0: - dependencies: - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -5701,13 +5412,6 @@ snapshots: is-path-inside@3.0.3: {} - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - is-stream@3.0.0: {} is-unicode-supported@1.3.0: {} @@ -5753,67 +5457,12 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - keygrip@1.1.0: - dependencies: - tsscmp: 1.0.6 - keyv@4.5.4: dependencies: json-buffer: 3.0.1 kleur@3.0.3: {} - koa-bodyparser@4.4.1: - dependencies: - co-body: 6.2.0 - copy-to: 2.0.1 - type-is: 1.6.18 - - koa-compose@4.1.0: {} - - koa-convert@2.0.0: - dependencies: - co: 4.6.0 - koa-compose: 4.1.0 - - koa-router@12.0.1: - dependencies: - debug: 4.4.1 - http-errors: 2.0.0 - koa-compose: 4.1.0 - methods: 1.1.2 - path-to-regexp: 6.3.0 - transitivePeerDependencies: - - supports-color - - koa@2.16.1: - dependencies: - accepts: 1.3.8 - cache-content-type: 1.0.1 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookies: 0.9.1 - debug: 4.4.1 - delegates: 1.0.0 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 1.0.2 - escape-html: 1.0.3 - fresh: 0.5.2 - http-assert: 1.5.0 - http-errors: 1.8.1 - is-generator-function: 1.1.0 - koa-compose: 4.1.0 - koa-convert: 2.0.0 - on-finished: 2.4.1 - only: 0.0.2 - parseurl: 1.3.3 - statuses: 1.5.0 - type-is: 1.6.18 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - kysely-codegen@0.14.1(better-sqlite3@9.4.3)(kysely@0.27.3): dependencies: chalk: 4.1.2 @@ -5887,16 +5536,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 - math-intrinsics@1.1.0: {} - - media-typer@0.3.0: {} - merge-stream@2.0.0: {} merge2@1.4.1: {} - methods@1.1.2: {} - micromatch@4.0.5: dependencies: braces: 3.0.3 @@ -5907,12 +5550,6 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} @@ -5959,8 +5596,6 @@ snapshots: natural-orderby@2.0.3: {} - negotiator@0.6.3: {} - node-abi@3.68.0: dependencies: semver: 7.7.2 @@ -5969,6 +5604,10 @@ snapshots: dependencies: semver: 7.7.2 + node-cache@5.1.2: + dependencies: + clone: 2.1.2 + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -5991,12 +5630,6 @@ snapshots: object-hash@3.0.0: {} - object-inspect@1.13.4: {} - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - once@1.4.0: dependencies: wrappy: 1.0.2 @@ -6009,7 +5642,7 @@ snapshots: dependencies: mimic-fn: 4.0.0 - only@0.0.2: {} + openapi-types@12.1.3: {} optionator@0.9.4: dependencies: @@ -6053,8 +5686,6 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 - parseurl@1.3.3: {} - path-browserify@1.0.1: {} path-exists@4.0.0: {} @@ -6072,8 +5703,6 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-to-regexp@6.3.0: {} - path-type@4.0.0: {} pathe@1.1.2: {} @@ -6180,19 +5809,8 @@ snapshots: punycode@2.3.1: {} - qs@6.14.0: - dependencies: - side-channel: 1.1.0 - queue-microtask@1.2.3: {} - raw-body@2.5.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -6308,6 +5926,8 @@ snapshots: regexparam@3.0.0: {} + require-directory@2.1.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -6361,14 +5981,6 @@ snapshots: safe-buffer@5.2.1: {} - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - - safer-buffer@2.1.2: {} - scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -6379,8 +5991,6 @@ snapshots: semver@7.7.2: {} - setprototypeof@1.2.0: {} - shadcn-ui@0.8.0(typescript@5.8.3): dependencies: '@antfu/ni': 0.21.12 @@ -6421,34 +6031,6 @@ snapshots: interpret: 1.4.0 rechoir: 0.6.2 - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -6471,12 +6053,12 @@ snapshots: source-map@0.6.1: {} + srvx@0.8.9: + dependencies: + cookie-es: 2.0.0 + stackback@0.0.2: {} - statuses@1.5.0: {} - - statuses@2.0.1: {} - std-env@3.9.0: {} stdin-discarder@0.1.0: @@ -6515,6 +6097,11 @@ 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 @@ -6626,7 +6213,12 @@ snapshots: dependencies: is-number: 7.0.0 - toidentifier@1.0.1: {} + 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: @@ -6647,8 +6239,6 @@ snapshots: tslib@2.8.1: {} - tsscmp@1.0.6: {} - tsx@4.20.3: dependencies: esbuild: 0.25.5 @@ -6666,23 +6256,19 @@ snapshots: type-fest@0.20.2: {} - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - typescript@5.4.3: {} typescript@5.8.3: {} + uint8array-extras@1.5.0: + optional: true + undici-types@6.21.0: {} undici-types@7.8.0: {} universalify@2.0.1: {} - unpipe@1.0.0: {} - update-browserslist-db@1.1.3(browserslist@4.25.0): dependencies: browserslist: 4.25.0 @@ -6719,8 +6305,6 @@ snapshots: util-deprecate@1.0.2: {} - vary@1.1.2: {} - victory-vendor@36.9.2: dependencies: '@types/d3-array': 3.2.1 @@ -6756,6 +6340,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 @@ -6765,6 +6367,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 @@ -6800,6 +6411,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 @@ -6838,11 +6484,23 @@ snapshots: wrappy@1.0.2: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yaml@2.8.0: {} - ylru@1.4.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: {}