Compare commits
10 commits
7ad7a00500
...
251718d014
Author | SHA1 | Date | |
---|---|---|---|
251718d014 | |||
fa72aca9f4 | |||
0be89bfa23 | |||
cb6d03458f | |||
7f829b32d4 | |||
8047b93ffc | |||
26b55be260 | |||
c5d15240bc | |||
d28c255d21 | |||
018e2e998b |
49 changed files with 2792 additions and 2417 deletions
118
.github/copilot-instructions.md
vendored
Normal file
118
.github/copilot-instructions.md
vendored
Normal file
|
@ -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
|
|
@ -16,7 +16,6 @@ import SettingPage from "@/page/settingPage.tsx";
|
||||||
import ComicPage from "@/page/reader/comicPage.tsx";
|
import ComicPage from "@/page/reader/comicPage.tsx";
|
||||||
import DifferencePage from "./page/differencePage.tsx";
|
import DifferencePage from "./page/differencePage.tsx";
|
||||||
import TagsPage from "./page/tagsPage.tsx";
|
import TagsPage from "./page/tagsPage.tsx";
|
||||||
import TaskQueuePage from "./page/taskQueuePage.tsx";
|
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const { isDarkMode } = useTernaryDarkMode();
|
const { isDarkMode } = useTernaryDarkMode();
|
||||||
|
@ -43,7 +42,6 @@ const App = () => {
|
||||||
<Route path="/doc/:id/reader" component={ComicPage}/>
|
<Route path="/doc/:id/reader" component={ComicPage}/>
|
||||||
<Route path="/difference" component={DifferencePage}/>
|
<Route path="/difference" component={DifferencePage}/>
|
||||||
<Route path="/tags" component={TagsPage}/>
|
<Route path="/tags" component={TagsPage}/>
|
||||||
<Route path="/queue" component={TaskQueuePage} />
|
|
||||||
<Route component={NotFoundPage} />
|
<Route component={NotFoundPage} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
93
packages/client/src/components/AppearanceCard.tsx
Normal file
93
packages/client/src/components/AppearanceCard.tsx
Normal file
|
@ -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 <div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
|
||||||
|
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
|
||||||
|
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
|
||||||
|
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
|
||||||
|
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
||||||
|
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||||
|
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
||||||
|
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||||
|
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DarkModeView() {
|
||||||
|
return <div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
|
||||||
|
<div className="space-y-2 rounded-sm bg-slate-950 p-2">
|
||||||
|
<div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
||||||
|
<div className="h-2 w-[80px] rounded-lg bg-slate-400" />
|
||||||
|
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
||||||
|
<div className="h-4 w-4 rounded-full bg-slate-400" />
|
||||||
|
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
||||||
|
<div className="h-4 w-4 rounded-full bg-slate-400" />
|
||||||
|
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppearanceCard() {
|
||||||
|
const { setTernaryDarkMode, ternaryDarkMode } = useTernaryDarkMode();
|
||||||
|
const isSystemDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Appearance</CardTitle>
|
||||||
|
<span className="text-muted-foreground text-sm">Dark mode</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RadioGroup
|
||||||
|
value={ternaryDarkMode}
|
||||||
|
onValueChange={(v) => setTernaryDarkMode(v as TernaryDarkMode)}
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<RadioGroupItem id="dark" value="dark" className="sr-only" />
|
||||||
|
<Label htmlFor="dark" className="cursor-pointer">
|
||||||
|
<div className="grid place-items-center space-y-2 p-2 rounded-lg hover:bg-accent/50 transition-colors">
|
||||||
|
<DarkModeView />
|
||||||
|
<span className="text-sm font-medium">Dark Mode</span>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<RadioGroupItem id="light" value="light" className="sr-only" />
|
||||||
|
<Label htmlFor="light" className="cursor-pointer">
|
||||||
|
<div className="grid place-items-center space-y-2 p-2 rounded-lg hover:bg-accent/50 transition-colors">
|
||||||
|
<LightModeView />
|
||||||
|
<span className="text-sm font-medium">Light Mode</span>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<RadioGroupItem id="system" value="system" className="sr-only" />
|
||||||
|
<Label htmlFor="system" className="cursor-pointer">
|
||||||
|
<div className="grid place-items-center space-y-2 p-2 rounded-lg hover:bg-accent/50 transition-colors">
|
||||||
|
{isSystemDarkMode ? <DarkModeView /> : <LightModeView />}
|
||||||
|
<span className="text-sm font-medium">System Mode</span>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppearanceCard;
|
279
packages/client/src/components/ServerSettingCard.tsx
Normal file
279
packages/client/src/components/ServerSettingCard.tsx
Normal file
|
@ -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<typeof createSnapshot>;
|
||||||
|
|
||||||
|
type FeedbackState = { type: "success" | "error"; message: string } | null;
|
||||||
|
|
||||||
|
type ServerSettingCardProps = {
|
||||||
|
isAdmin: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: unknown;
|
||||||
|
setting?: ServerSettingResponse;
|
||||||
|
onSave: (payload: ServerSettingUpdate) => Promise<ServerSettingResponse>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<FormState>(() => (setting ? createSnapshot(setting.persisted) : defaultState));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [feedback, setFeedback] = useState<FeedbackState>(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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">Server Settings</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!isAdmin ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
관리자 계정만 서버 설정을 확인하고 수정할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading && !setting ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Spinner className="text-lg" />
|
||||||
|
<span>서버 설정을 불러오는 중…</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{setting && (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<p className="text-xs uppercase text-muted-foreground">Hostname</p>
|
||||||
|
<p className="text-sm font-medium">{setting.env.hostname}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<p className="text-xs uppercase text-muted-foreground">Port</p>
|
||||||
|
<p className="text-sm font-medium">{setting.env.port}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<p className="text-xs uppercase text-muted-foreground">Mode</p>
|
||||||
|
<p className="text-sm font-medium capitalize">{setting.env.mode}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center justify-between rounded-md border p-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Secure cookies</p>
|
||||||
|
<p className="text-sm text-muted-foreground">HTTPS 환경에서만 세션 쿠키를 발급하도록 강제합니다.</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-primary"
|
||||||
|
checked={formState.secure}
|
||||||
|
onChange={(event) => onChangeBoolean("secure")(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between rounded-md border p-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">CLI bootstrap</p>
|
||||||
|
<p className="text-sm text-muted-foreground">초기 관리자 비밀번호를 터미널에서 직접 입력하도록 요청합니다.</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-primary"
|
||||||
|
checked={formState.cli}
|
||||||
|
onChange={(event) => onChangeBoolean("cli")(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between rounded-md border p-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">원격 관리자 로그인 차단</p>
|
||||||
|
<p className="text-sm text-muted-foreground">원격 클라이언트에서 관리자 계정 로그인을 차단합니다.</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-primary"
|
||||||
|
checked={formState.forbid_remote_admin_login}
|
||||||
|
onChange={(event) => onChangeBoolean("forbid_remote_admin_login")(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">게스트 권한</p>
|
||||||
|
<p className="text-sm text-muted-foreground">로그인하지 않은 방문자에게 허용할 권한을 선택하세요.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{permissionOptions.map((permission) => {
|
||||||
|
const active = formState.guest.includes(permission);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={permission}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={active ? "default" : "outline"}
|
||||||
|
onClick={() => toggleGuestPermission(permission)}
|
||||||
|
>
|
||||||
|
{permission}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{permissionOptions.length === 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">정의된 권한이 없습니다.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{formState.guest.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">게스트는 로그인 페이지 외 접근 권한이 없습니다.</span>
|
||||||
|
) : (
|
||||||
|
formState.guest.map((permission) => (
|
||||||
|
<Badge key={`guest-${permission}`} variant="secondary">
|
||||||
|
{permission}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{feedback && (
|
||||||
|
<div
|
||||||
|
className={`text-sm ${feedback.type === "error" ? "text-destructive" : "text-emerald-600"}`}
|
||||||
|
>
|
||||||
|
{feedback.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={saving || !hasChanges}>
|
||||||
|
{saving ? "저장 중…" : "변경 사항 저장"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServerSettingCard;
|
|
@ -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<Task[]>([])
|
|
||||||
|
|
||||||
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 ? (
|
|
||||||
<p className="text-gray-500 p-4">No tasks</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-4 p-4">
|
|
||||||
{filteredTasks.map(task => (
|
|
||||||
<li key={task.id} className="border p-4 rounded-md shadow-sm">
|
|
||||||
<h4 className="font-medium text-primary">{task.title}</h4>
|
|
||||||
<p className="text-sm text-gray-600">{task.description}</p>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">Created: {task.createdAt.toLocaleString()}</p>
|
|
||||||
<div className="mt-2">
|
|
||||||
<Progress value={task.expectedProgress * 100} className="h-2" />
|
|
||||||
<p className="text-xs text-right mt-1">{Math.round(task.expectedProgress * 100)}%</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm mt-1 text-gray-700">{task.currentJobDescription}</p>
|
|
||||||
{status === "Queued" && (
|
|
||||||
<Button
|
|
||||||
onClick={() => updateTaskStatus(task.id, "Processing")}
|
|
||||||
className="mt-2"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Start Processing
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{status === "Processing" && (
|
|
||||||
<Button
|
|
||||||
onClick={() => updateTaskStatus(task.id, "Processed")}
|
|
||||||
className="mt-2"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Mark as Processed
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Total Processed</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-4xl font-bold">{totalProcessed}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Average Progress</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-4xl font-bold">{(averageProgress * 100).toFixed(2)}%</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Processed vs Remaining Tasks</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<BarChart data={chartData}>
|
|
||||||
<XAxis dataKey="name" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip />
|
|
||||||
<Bar dataKey="value" fill="#8884d8" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto p-4 max-w-4xl">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Dynamic Work Queue</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Tabs defaultValue="Queued" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-5">
|
|
||||||
<TabsTrigger value="Queued">Queued ({tasks.filter(t => t.status === "Queued").length})</TabsTrigger>
|
|
||||||
<TabsTrigger value="Processing">Processing ({tasks.filter(t => t.status === "Processing").length})</TabsTrigger>
|
|
||||||
<TabsTrigger value="Processed">Processed ({tasks.filter(t => t.status === "Processed").length})</TabsTrigger>
|
|
||||||
<TabsTrigger value="Exception">Exception ({tasks.filter(t => t.status === "Exception").length})</TabsTrigger>
|
|
||||||
<TabsTrigger value="Summary">Summary</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="Queued">
|
|
||||||
{renderTaskList("Queued")}
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="Processing">
|
|
||||||
{renderTaskList("Processing")}
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="Processed">
|
|
||||||
{renderTaskList("Processed")}
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="Exception">
|
|
||||||
{renderTaskList("Exception")}
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="Summary">
|
|
||||||
{renderProcessedTaskSummary()}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,49 +1,48 @@
|
||||||
import { useLayoutEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "../ui/resizable";
|
import { SidebarNav, BottomNav, SidebarToggle } from "./nav";
|
||||||
import { NavList } from "./nav";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({ children }: LayoutProps) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
const MIN_SIZE_IN_PIXELS = 70;
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||||
const [minSize, setMinSize] = useState(MIN_SIZE_IN_PIXELS);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
const toggleSidebar = () => {
|
||||||
const panelGroup = document.querySelector('[data-panel-group-id="main"]');
|
setIsSidebarOpen(!isSidebarOpen);
|
||||||
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();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizablePanelGroup direction="horizontal" id="main">
|
<div className="flex flex-col md:flex-row relative">
|
||||||
<ResizablePanel minSize={minSize} collapsible maxSize={minSize}>
|
{/* Desktop Sidebar - 데스크탑에서만 보이는 사이드바 */}
|
||||||
<NavList />
|
<aside className={cn("hidden md:flex md:flex-col",
|
||||||
</ResizablePanel>
|
"transition-all duration-300 ease-in-out",
|
||||||
<ResizableHandle withHandle className="z-20" />
|
"border-r bg-background sticky top-0 h-screen",
|
||||||
<ResizablePanel >
|
isSidebarOpen ? 'w-64' : 'w-16')}>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<h2 className="text-lg font-semibold">Ionian</h2>
|
||||||
|
)}
|
||||||
|
<SidebarToggle
|
||||||
|
isOpen={isSidebarOpen}
|
||||||
|
onToggle={toggleSidebar}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<SidebarNav isCollapsed={!isSidebarOpen} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 flex flex-col min-h-0 pb-16 md:pb-0 pt-0 md:pt-0">
|
||||||
{children}
|
{children}
|
||||||
</ResizablePanel>
|
</main>
|
||||||
</ResizablePanelGroup>
|
|
||||||
|
{/* Mobile Bottom Navigation - 모바일에서만 보이는 하단 네비게이션 */}
|
||||||
|
<div className="md:hidden fixed bottom-0 left-0 right-0 z-30">
|
||||||
|
<BottomNav />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -1,27 +1,30 @@
|
||||||
import { Link } from "wouter"
|
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 { Button, buttonVariants } from "@/components/ui/button.tsx"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"
|
||||||
import { useLogin } from "@/state/user.ts";
|
import { useLogin } from "@/state/user.ts";
|
||||||
import { useNavItems } from "./navAtom";
|
import { useNavItems } from "./navAtom";
|
||||||
import { Separator } from "../ui/separator";
|
import { Separator } from "../ui/separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface NavItemProps {
|
interface NavItemProps {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
to: string;
|
to: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavItem({
|
export function NavItem({
|
||||||
icon,
|
icon,
|
||||||
to,
|
to,
|
||||||
name
|
name,
|
||||||
|
className
|
||||||
}: NavItemProps) {
|
}: NavItemProps) {
|
||||||
return <Tooltip>
|
return <Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Link
|
<Link
|
||||||
href={to}
|
href={to}
|
||||||
className={buttonVariants({ variant: "ghost" })}
|
className={buttonVariants({ variant: "ghost", className })}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span className="sr-only">{name}</span>
|
<span className="sr-only">{name}</span>
|
||||||
|
@ -65,15 +68,241 @@ export function NavList() {
|
||||||
|
|
||||||
return <aside className="h-dvh flex flex-col">
|
return <aside className="h-dvh flex flex-col">
|
||||||
<nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1">
|
<nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1">
|
||||||
{navItems && <>{navItems} <Separator/> </>}
|
{navItems && <>{navItems} <Separator /> </>}
|
||||||
<NavItem icon={<SearchIcon className="h-5 w-5" />} to="/search" name="Search" />
|
<NavItem icon={<SearchIcon className="h-5 w-5" />} to="/search" name="Search" />
|
||||||
<NavItem icon={<TagsIcon className="h-5 w-5" />} to="/tags" name="Tags" />
|
<NavItem icon={<TagsIcon className="h-5 w-5" />} to="/tags" name="Tags" />
|
||||||
<NavItem icon={<ArchiveIcon className="h-5 w-5" />} to="/difference" name="Difference" />
|
<NavItem icon={<ArchiveIcon className="h-5 w-5" />} to="/difference" name="Difference" />
|
||||||
<NavItem icon={<LayoutListIcon className="h-5 w-5" />} to="/queue" name="Task Queue" />
|
|
||||||
</nav>
|
</nav>
|
||||||
<nav className="mt-auto flex flex-col items-center gap-4 px-2 sm:py-5 flex-grow-0">
|
<nav className="mt-auto flex flex-col items-center gap-4 px-2 sm:py-5 flex-grow-0">
|
||||||
<NavItem icon={<UserIcon className="h-5 w-5" />} to={ loginInfo ? "/profile" : "/login"} name={ loginInfo ? "Profiles" : "Login"} />
|
<NavItem icon={<UserIcon className="h-5 w-5" />} to={loginInfo ? "/profile" : "/login"} name={loginInfo ? "Profiles" : "Login"} />
|
||||||
<NavItem icon={<SettingsIcon className="h-5 w-5" />} to="/setting" name="Settings" />
|
<NavItem icon={<SettingsIcon className="h-5 w-5" />} to="/setting" name="Settings" />
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사이드바 토글 버튼
|
||||||
|
export function SidebarToggle({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<PanelLeftCloseIcon className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<PanelLeftIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">{isOpen ? "Close sidebar" : "Open sidebar"}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
{isOpen ? "Close sidebar" : "Open sidebar"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모바일용 사이드바 토글 버튼
|
||||||
|
export function MobileSidebarToggle({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<XIcon className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<MenuIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">{isOpen ? "Close menu" : "Open menu"}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데스크탑용 사이드바 네비게이션
|
||||||
|
export function SidebarNav({ isCollapsed, onNavigate }: { isCollapsed: boolean; onNavigate?: () => void }) {
|
||||||
|
const loginInfo = useLogin();
|
||||||
|
const navItems = useNavItems();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<nav className="flex flex-col gap-2 p-3 flex-1 min-h-0">
|
||||||
|
{navItems && (
|
||||||
|
<>
|
||||||
|
<div className={cn("space-y-2", isCollapsed && "items-center")}>
|
||||||
|
{navItems}
|
||||||
|
</div>
|
||||||
|
<Separator className="my-3" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<SidebarNavItem
|
||||||
|
icon={<SearchIcon className="h-5 w-5" />}
|
||||||
|
to="/search"
|
||||||
|
name="Search"
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
<SidebarNavItem
|
||||||
|
icon={<TagsIcon className="h-5 w-5" />}
|
||||||
|
to="/tags"
|
||||||
|
name="Tags"
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
<SidebarNavItem
|
||||||
|
icon={<ArchiveIcon className="h-5 w-5" />}
|
||||||
|
to="/difference"
|
||||||
|
name="Difference"
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
<SidebarNavItem
|
||||||
|
icon={<LayoutListIcon className="h-5 w-5" />}
|
||||||
|
to="/queue"
|
||||||
|
name="Task Queue"
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t p-3 flex flex-col gap-2 flex-shrink-0">
|
||||||
|
<SidebarNavItem
|
||||||
|
icon={<UserIcon className="h-5 w-5" />}
|
||||||
|
to={loginInfo ? "/profile" : "/login"}
|
||||||
|
name={loginInfo ? "Profiles" : "Login"}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
<SidebarNavItem
|
||||||
|
icon={<SettingsIcon className="h-5 w-5" />}
|
||||||
|
to="/setting"
|
||||||
|
name="Settings"
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사이드바 네비게이션 아이템
|
||||||
|
interface SidebarNavItemProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
to: string;
|
||||||
|
name: string;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarNavItem({ icon, to, name, isCollapsed, onNavigate }: SidebarNavItemProps) {
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
href={to}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "ghost", size: "sm" }),
|
||||||
|
"justify-center h-10 w-10 p-0"
|
||||||
|
)}
|
||||||
|
onClick={onNavigate}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="sr-only">{name}</span>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{name}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={to}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "ghost", size: "sm" }),
|
||||||
|
"justify-start gap-3 h-10 px-3"
|
||||||
|
)}
|
||||||
|
onClick={onNavigate}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{name}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모바일용 하단 네비게이션
|
||||||
|
export function BottomNav() {
|
||||||
|
const loginInfo = useLogin();
|
||||||
|
const navItems = useNavItems();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="mb-1">
|
||||||
|
<div className="flex justify-around items-center max-w-md mx-auto
|
||||||
|
overflow-hidden
|
||||||
|
bg-background/50 backdrop-blur-md border rounded-full">
|
||||||
|
<BottomNavItem
|
||||||
|
icon={<SearchIcon className="h-5 w-5" />}
|
||||||
|
to="/search"
|
||||||
|
name="Search"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{navItems ? navItems : <>
|
||||||
|
<BottomNavItem
|
||||||
|
icon={<TagsIcon className="h-5 w-5" />}
|
||||||
|
to="/tags"
|
||||||
|
name="Tags"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<BottomNavItem
|
||||||
|
icon={<ArchiveIcon className="h-5 w-5" />}
|
||||||
|
to="/difference"
|
||||||
|
name="Diff"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<BottomNavItem
|
||||||
|
icon={<UserIcon className="h-5 w-5" />}
|
||||||
|
to={loginInfo ? "/profile" : "/login"}
|
||||||
|
name={loginInfo ? "Profile" : "Login"}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<BottomNavItem
|
||||||
|
icon={<SettingsIcon className="h-5 w-5" />}
|
||||||
|
to="/setting"
|
||||||
|
name="Settings"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 하단 네비게이션 아이템
|
||||||
|
interface BottomNavItemProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
to: string;
|
||||||
|
name: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BottomNavItem({ icon, to, name, className }: BottomNavItemProps) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={to}
|
||||||
|
className={cn("flex flex-col items-center gap-1 p-2 hover:bg-accent text-xs min-w-0", className)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="text-xs truncate leading-normal">{name}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
}
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import { atom, useAtomValue, setAtomValue, getAtomState } from "@/lib/atom";
|
import { atom, useAtomValue, useSetAtom } from "@/lib/atom";
|
||||||
import { useLayoutEffect } from "react";
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
|
||||||
const NavItems = atom<React.ReactNode>("NavItems", null);
|
const NavItems = atom<React.ReactNode>(null);
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export function useNavItems() {
|
export function useNavItems() {
|
||||||
|
@ -9,14 +9,19 @@ export function useNavItems() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageNavItem({items, children}:{items: React.ReactNode, children: React.ReactNode}) {
|
export function PageNavItem({items, children}:{items: React.ReactNode, children: React.ReactNode}) {
|
||||||
|
const currentNavItems = useAtomValue(NavItems);
|
||||||
|
const setNavItems = useSetAtom(NavItems);
|
||||||
|
const prevValueRef = useRef<React.ReactNode>(null);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const prev = getAtomState(NavItems).value;
|
// Store current value before setting new one
|
||||||
const setter = setAtomValue(NavItems);
|
prevValueRef.current = currentNavItems;
|
||||||
setter(items);
|
setNavItems(items);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
setter(prev);
|
setNavItems(prevValueRef.current);
|
||||||
};
|
};
|
||||||
}, [items]);
|
}, [items, currentNavItems, setNavItems]);
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
10
packages/client/src/hook/useServerSettings.ts
Normal file
10
packages/client/src/hook/useServerSettings.ts
Normal file
|
@ -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<ServerSettingResponse>(
|
||||||
|
enabled ? "/api/settings" : null,
|
||||||
|
(url: string) => fetcher(url, { credentials: "include" }),
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,70 +1,2 @@
|
||||||
import { useEffect, useReducer, useState } from "react";
|
// Re-export jotai functions to maintain compatibility
|
||||||
|
export { atom, useAtom, useAtomValue, useSetAtom, useSetAtom as setAtomValue } from 'jotai';
|
||||||
interface AtomState<T> {
|
|
||||||
value: T;
|
|
||||||
listeners: Set<() => void>;
|
|
||||||
}
|
|
||||||
interface Atom<T> {
|
|
||||||
key: string;
|
|
||||||
default: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
const atomStateMap = new WeakMap<Atom<unknown>, AtomState<unknown>>();
|
|
||||||
|
|
||||||
export function atom<T>(key: string, defaultVal: T): Atom<T> {
|
|
||||||
return { key, default: defaultVal };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAtomState<T>(atom: Atom<T>): AtomState<T> {
|
|
||||||
let atomState = atomStateMap.get(atom);
|
|
||||||
if (!atomState) {
|
|
||||||
atomState = {
|
|
||||||
value: atom.default,
|
|
||||||
listeners: new Set(),
|
|
||||||
};
|
|
||||||
atomStateMap.set(atom, atomState);
|
|
||||||
}
|
|
||||||
return atomState as AtomState<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAtom<T>(atom: Atom<T>): [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<T>(atom: Atom<T>): 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<T>(atom: Atom<T>): (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());
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -24,18 +24,14 @@ export interface ContentInfoPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const [pathname] = useLocation();
|
const [pathname] = useLocation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current) {
|
document.scrollingElement?.scrollTo({
|
||||||
ref.current.scrollTo({
|
top: 0,
|
||||||
top: 0,
|
});
|
||||||
left: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
return <div className="p-4 overflow-auto h-dvh" ref={ref}>
|
return <div className="p-4">
|
||||||
{children}
|
{children}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -146,12 +146,20 @@ export default function ComicPage({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNavItem items={<>
|
<PageNavItem items={<>
|
||||||
<NavItem to={`/doc/${params.id}`} name="Back" icon={<ExitIcon />} />
|
<NavItem
|
||||||
<NavItemButton name={isFullScreen ? "Exit Fullscreen" : "Enter Fullscreen"} icon={isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />} onClick={() => {
|
className="flex-1"
|
||||||
toggleFullScreen();
|
to={`/doc/${params.id}`} name="Back" icon={<ExitIcon />} />
|
||||||
}} />
|
<NavItemButton
|
||||||
|
className="flex-1"
|
||||||
|
name={isFullScreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
||||||
|
icon={isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
toggleFullScreen();
|
||||||
|
}} />
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
<span className="text-sm text-ellipsis" >{curPage + 1}/{data.additional.page as number}</span>
|
<span className="text-sm text-ellipsis" >{curPage + 1}/{data.additional.page as number}</span>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-28">
|
<PopoverContent className="w-28">
|
||||||
|
|
|
@ -1,91 +1,43 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { useCallback } from "react";
|
||||||
import { type TernaryDarkMode, useTernaryDarkMode, useMediaQuery } from "usehooks-ts";
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import BuildInfoCard from "@/components/BuildInfoCard";
|
import 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 <div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
|
|
||||||
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
|
|
||||||
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
|
|
||||||
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DarkModeView() {
|
|
||||||
return <div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
|
|
||||||
<div className="space-y-2 rounded-sm bg-slate-950 p-2">
|
|
||||||
<div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
|
||||||
<div className="h-2 w-[80px] rounded-lg bg-slate-400" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-slate-400" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-slate-400" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingPage() {
|
export function SettingPage() {
|
||||||
const { setTernaryDarkMode, ternaryDarkMode } = useTernaryDarkMode();
|
const login = useLogin();
|
||||||
const isSystemDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
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 (
|
return (
|
||||||
<div className="p-4 space-y-2">
|
<div className="p-4 space-y-4">
|
||||||
<Card>
|
<h1 className="text-3xl font-bold">Settings</h1>
|
||||||
<CardHeader>
|
<AppearanceCard />
|
||||||
<CardTitle className="text-2xl">Settings</CardTitle>
|
<ServerSettingCard
|
||||||
</CardHeader>
|
isAdmin={isAdmin}
|
||||||
<CardContent>
|
loading={serverLoading}
|
||||||
<div className="grid gap-4">
|
error={serverError}
|
||||||
<div>
|
setting={serverSetting}
|
||||||
<h3 className="text-lg">Appearance</h3>
|
onSave={handleSave}
|
||||||
<span className="text-muted-foreground text-sm">Dark mode</span>
|
/>
|
||||||
</div>
|
|
||||||
<RadioGroup value={ternaryDarkMode} onValueChange={(v) => setTernaryDarkMode(v as TernaryDarkMode)}
|
|
||||||
className="flex space-x-2 items-center"
|
|
||||||
>
|
|
||||||
<RadioGroupItem id="dark" value="dark" className="sr-only" />
|
|
||||||
<Label htmlFor="dark">
|
|
||||||
<div className="grid place-items-center">
|
|
||||||
<DarkModeView />
|
|
||||||
<span>Dark Mode</span>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
<RadioGroupItem id="light" value="light" className="sr-only" />
|
|
||||||
<Label htmlFor="light">
|
|
||||||
<div className="grid place-items-center">
|
|
||||||
<LightModeView />
|
|
||||||
<span>Light Mode</span>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
<RadioGroupItem id="system" value="system" className="sr-only" />
|
|
||||||
<Label htmlFor="system">
|
|
||||||
<div className="grid place-items-center">
|
|
||||||
{isSystemDarkMode ? <DarkModeView /> : <LightModeView />}
|
|
||||||
<span>System Mode</span>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<BuildInfoCard />
|
<BuildInfoCard />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
import { lazy, Suspense } from 'react';
|
|
||||||
|
|
||||||
const DynamicWorkQueue = lazy(() => import('@/components/gallery/WorkQueue'));
|
|
||||||
|
|
||||||
export default function TaskQueuePage() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
|
||||||
<DynamicWorkQueue />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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 { LoginRequest } from "dbtype/mod.ts";
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
|
@ -9,6 +10,9 @@ import {
|
||||||
resetPasswordService,
|
resetPasswordService,
|
||||||
} from "./api.ts";
|
} from "./api.ts";
|
||||||
|
|
||||||
|
// Create a store for setting atom values outside components
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
let localObj: LoginResponse | null = null;
|
let localObj: LoginResponse | null = null;
|
||||||
function getUserSessions() {
|
function getUserSessions() {
|
||||||
if (localObj === null) {
|
if (localObj === null) {
|
||||||
|
@ -55,7 +59,6 @@ export async function refresh() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const doLogout = async () => {
|
export const doLogout = async () => {
|
||||||
const setVal = setAtomValue(userLoginStateAtom);
|
|
||||||
try {
|
try {
|
||||||
const res = await logoutService();
|
const res = await logoutService();
|
||||||
localObj = {
|
localObj = {
|
||||||
|
@ -64,7 +67,7 @@ export const doLogout = async () => {
|
||||||
permission: res.permission,
|
permission: res.permission,
|
||||||
};
|
};
|
||||||
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
||||||
setVal(localObj);
|
store.set(userLoginStateAtom, localObj);
|
||||||
return {
|
return {
|
||||||
username: localObj.username,
|
username: localObj.username,
|
||||||
permission: localObj.permission,
|
permission: localObj.permission,
|
||||||
|
@ -74,7 +77,7 @@ export const doLogout = async () => {
|
||||||
// Even if logout fails, clear client-side session
|
// Even if logout fails, clear client-side session
|
||||||
localObj = { accessExpired: 0, username: "", permission: [] };
|
localObj = { accessExpired: 0, username: "", permission: [] };
|
||||||
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
||||||
setVal(localObj);
|
store.set(userLoginStateAtom, localObj);
|
||||||
return {
|
return {
|
||||||
username: "",
|
username: "",
|
||||||
permission: [],
|
permission: [],
|
||||||
|
@ -85,9 +88,8 @@ export const doLogout = async () => {
|
||||||
export const doLogin = async (userLoginInfo: LoginRequest): Promise<string | LoginResponse> => {
|
export const doLogin = async (userLoginInfo: LoginRequest): Promise<string | LoginResponse> => {
|
||||||
try {
|
try {
|
||||||
const b = await loginService(userLoginInfo);
|
const b = await loginService(userLoginInfo);
|
||||||
const setVal = setAtomValue(userLoginStateAtom);
|
|
||||||
localObj = b;
|
localObj = b;
|
||||||
setVal(b);
|
store.set(userLoginStateAtom, b);
|
||||||
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
||||||
return b;
|
return b;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -123,7 +125,7 @@ export async function getInitialValue() {
|
||||||
return refresh();
|
return refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userLoginStateAtom = atom("userLoginState", getUserSessions());
|
export const userLoginStateAtom = atom(getUserSessions());
|
||||||
|
|
||||||
export function useLogin() {
|
export function useLogin() {
|
||||||
const val = useAtomValue(userLoginStateAtom);
|
const val = useAtomValue(userLoginStateAtom);
|
||||||
|
|
|
@ -97,4 +97,29 @@ export const LoginResetRequestSchema = z.object({
|
||||||
newpassword: z.string(),
|
newpassword: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LoginResetRequest = z.infer<typeof LoginResetRequestSchema>;
|
export type LoginResetRequest = z.infer<typeof LoginResetRequestSchema>;
|
||||||
|
|
||||||
|
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<typeof ServerPersistedSettingSchema>;
|
||||||
|
|
||||||
|
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<typeof ServerSettingResponseSchema>;
|
||||||
|
|
||||||
|
export const ServerSettingUpdateSchema = ServerPersistedSettingSchema.partial();
|
||||||
|
|
||||||
|
export type ServerSettingUpdate = z.infer<typeof ServerSettingUpdateSchema>;
|
|
@ -50,6 +50,11 @@ export interface UserSettings {
|
||||||
settings: string | null;
|
settings: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DB {
|
export interface DB {
|
||||||
doc_tag_relation: DocTagRelation;
|
doc_tag_relation: DocTagRelation;
|
||||||
document: Document;
|
document: Document;
|
||||||
|
@ -58,4 +63,5 @@ export interface DB {
|
||||||
tags: Tags;
|
tags: Tags;
|
||||||
users: Users;
|
users: Users;
|
||||||
user_settings: UserSettings;
|
user_settings: UserSettings;
|
||||||
|
app_config: AppConfig;
|
||||||
}
|
}
|
||||||
|
|
22
packages/server/migrations/2025-09-30.ts
Normal file
22
packages/server/migrations/2025-09-30.ts
Normal file
|
@ -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<any>) {
|
||||||
|
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<any>) {
|
||||||
|
throw new Error("Downward migrations are not supported. Restore from backup.");
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "followed",
|
"name": "server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "build/app.js",
|
"main": "build/app.js",
|
||||||
|
@ -7,36 +7,36 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
"start": "tsx 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": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"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",
|
"@std/async": "npm:@jsr/std__async@^1.0.13",
|
||||||
"@zip.js/zip.js": "^2.7.62",
|
"@zip.js/zip.js": "^2.7.62",
|
||||||
"better-sqlite3": "^9.6.0",
|
"better-sqlite3": "^9.6.0",
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"dbtype": "workspace:dbtype",
|
"dbtype": "workspace:dbtype",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
|
"elysia": "^1.4.9",
|
||||||
"jose": "^5.10.0",
|
"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",
|
"kysely": "^0.27.6",
|
||||||
"natural-orderby": "^2.0.3",
|
"natural-orderby": "^2.0.3",
|
||||||
"tiny-async-pool": "^1.3.0"
|
"tiny-async-pool": "^1.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@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/node": "^22.15.33",
|
||||||
"@types/tiny-async-pool": "^1.0.5",
|
"@types/tiny-async-pool": "^1.0.5",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^2.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,80 +1,160 @@
|
||||||
import { randomBytes } from "node:crypto";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
import type { Kysely } from "kysely";
|
||||||
import type { Permission } from "./permission/permission.ts";
|
import type { db } from "dbtype";
|
||||||
|
import { Permission } from "./permission/permission.ts";
|
||||||
|
import { getAppConfig, upsertAppConfig } from "./db/config.ts";
|
||||||
|
|
||||||
export interface SettingConfig {
|
export interface SettingConfig {
|
||||||
/**
|
hostname: string;
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
port: number;
|
port: number;
|
||||||
|
|
||||||
mode: "development" | "production";
|
mode: "development" | "production";
|
||||||
/**
|
secure: boolean;
|
||||||
* if true, do not show 'electron' window and show terminal only.
|
guest: Permission[];
|
||||||
*/
|
jwt_secretkey: string;
|
||||||
cli: boolean;
|
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;
|
forbid_remote_admin_login: boolean;
|
||||||
}
|
}
|
||||||
const default_setting: SettingConfig = {
|
|
||||||
localmode: true,
|
export type PersistedSetting = Pick<SettingConfig, "secure" | "guest" | "cli" | "forbid_remote_admin_login">;
|
||||||
|
type EnvSetting = Pick<SettingConfig, "hostname" | "port" | "mode" | "jwt_secretkey">;
|
||||||
|
|
||||||
|
const CONFIG_KEY = "server.settings";
|
||||||
|
const LEGACY_SETTINGS_PATH = "settings.json";
|
||||||
|
|
||||||
|
const persistedDefault: PersistedSetting = {
|
||||||
secure: true,
|
secure: true,
|
||||||
guest: [],
|
guest: [],
|
||||||
jwt_secretkey: "itsRandom",
|
|
||||||
port: 8080,
|
|
||||||
mode: "production",
|
|
||||||
cli: false,
|
cli: false,
|
||||||
forbid_remote_admin_login: true,
|
forbid_remote_admin_login: true,
|
||||||
};
|
};
|
||||||
let setting: null | SettingConfig = null;
|
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
let cachedSetting: SettingConfig | null = null;
|
||||||
const setEmptyToDefault = (target: any, default_table: SettingConfig) => {
|
|
||||||
let diff_occur = false;
|
export const initializeSetting = async (db: Kysely<db.DB>): Promise<SettingConfig> => {
|
||||||
for (const key in default_table) {
|
if (cachedSetting) {
|
||||||
if (key === undefined || key in target) {
|
return cachedSetting;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
target[key] = default_table[key as keyof SettingConfig];
|
|
||||||
diff_occur = true;
|
|
||||||
}
|
}
|
||||||
return diff_occur;
|
|
||||||
|
const persisted = await loadPersistedSetting(db);
|
||||||
|
const envSetting = loadEnvSetting();
|
||||||
|
|
||||||
|
cachedSetting = {
|
||||||
|
...persisted,
|
||||||
|
...envSetting,
|
||||||
|
};
|
||||||
|
|
||||||
|
return cachedSetting;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const read_setting_from_file = () => {
|
export const refreshSetting = async (db: Kysely<db.DB>): Promise<SettingConfig> => {
|
||||||
const ret = existsSync("settings.json") ? JSON.parse(readFileSync("settings.json", { encoding: "utf8" })) : {};
|
cachedSetting = null;
|
||||||
const partial_occur = setEmptyToDefault(ret, default_setting);
|
return initializeSetting(db);
|
||||||
if (partial_occur) {
|
|
||||||
writeFileSync("settings.json", JSON.stringify(ret));
|
|
||||||
}
|
|
||||||
return ret as SettingConfig;
|
|
||||||
};
|
};
|
||||||
export function get_setting(): SettingConfig {
|
|
||||||
if (setting === null) {
|
export type PersistedSettingUpdate = Partial<PersistedSetting>;
|
||||||
setting = read_setting_from_file();
|
|
||||||
const env = process.env.NODE_ENV;
|
export const updatePersistedSetting = async (
|
||||||
if (env !== undefined && env !== "production" && env !== "development") {
|
db: Kysely<db.DB>,
|
||||||
throw new Error('process unknown value in NODE_ENV: must be either "development" or "production"');
|
patch: PersistedSettingUpdate,
|
||||||
}
|
): Promise<SettingConfig> => {
|
||||||
setting.mode = env ?? setting.mode;
|
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<db.DB>): Promise<PersistedSetting> => {
|
||||||
|
const stored = await getAppConfig<Partial<PersistedSetting>>(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>): PersistedSetting => {
|
||||||
|
const validPermissions = new Set<Permission>(Object.values(Permission));
|
||||||
|
const guest = Array.isArray(input.guest)
|
||||||
|
? Array.from(
|
||||||
|
new Set(
|
||||||
|
input.guest.filter((value): value is Permission => validPermissions.has(value as Permission)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: persistedDefault.guest;
|
||||||
|
|
||||||
|
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<PersistedSetting> | undefined => {
|
||||||
|
if (!existsSync(LEGACY_SETTINGS_PATH)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(LEGACY_SETTINGS_PATH, { encoding: "utf8" })) as Partial<PersistedSetting>;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[config] Failed to parse settings.json", error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { create_server } from "./server.ts";
|
import { create_server } from "./server.ts";
|
||||||
|
|
||||||
create_server().then((server) => {
|
create_server()
|
||||||
server.start_server();
|
.catch((err) => {
|
||||||
}).catch((err) => {
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
});
|
});
|
18
packages/server/src/controller.ts
Normal file
18
packages/server/src/controller.ts
Normal file
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
35
packages/server/src/db/config.ts
Normal file
35
packages/server/src/db/config.ts
Normal file
|
@ -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 <T>(db: Kysely<DB>, key: string): Promise<T | undefined> => {
|
||||||
|
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 <T>(db: Kysely<DB>, key: string, value: T): Promise<void> => {
|
||||||
|
const payload = JSON.stringify(value);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insertInto(TABLE)
|
||||||
|
.values({ key, value: payload })
|
||||||
|
.onConflict((oc) => oc.column("key").doUpdateSet({ value: payload }))
|
||||||
|
.execute();
|
||||||
|
};
|
|
@ -1,3 +1,4 @@
|
||||||
export * from "./doc.ts";
|
export * from "./doc.ts";
|
||||||
export * from "./tag.ts";
|
export * from "./tag.ts";
|
||||||
export * from "./user.ts";
|
export * from "./user.ts";
|
||||||
|
export * from "./config.ts";
|
||||||
|
|
|
@ -1,85 +1,57 @@
|
||||||
import type Koa from "koa";
|
import { Elysia, t } from "elysia";
|
||||||
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 type { DiffManager } from "./diff.ts";
|
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) {
|
const toSerializableContent = (file: ContentFile) => ({ path: file.path, type: file.type });
|
||||||
return { path: x.path, type: x.type };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getAdded = (diffmgr: DiffManager) => (ctx: Koa.Context, next: Koa.Next) => {
|
const CommitEntrySchema = t.Array(t.Object({
|
||||||
const ret = diffmgr.getAdded();
|
type: t.String(),
|
||||||
ctx.body = ret.map((x) => ({
|
path: t.String(),
|
||||||
type: x.type,
|
}));
|
||||||
value: x.value.map((x) => ({ path: x.path, type: x.type })),
|
|
||||||
}));
|
|
||||||
ctx.type = "json";
|
|
||||||
};
|
|
||||||
|
|
||||||
type PostAddedBody = {
|
const CommitAllSchema = t.Object({
|
||||||
type: string;
|
type: t.String(),
|
||||||
path: string;
|
});
|
||||||
}[];
|
|
||||||
|
|
||||||
function checkPostAddedBody(body: unknown): body is PostAddedBody {
|
export const createDiffRouter = (diffmgr: DiffManager) =>
|
||||||
if (Array.isArray(body)) {
|
new Elysia({ name: "diff-router" })
|
||||||
return body.map((x) => "type" in x && "path" in x).every((x) => x);
|
.group("/diff", (app) =>
|
||||||
}
|
app
|
||||||
return false;
|
.get("/list", () => {
|
||||||
}
|
return diffmgr.getAdded().map((entry) => ({
|
||||||
|
type: entry.type,
|
||||||
export const postAdded = (diffmgr: DiffManager) => async (ctx: Router.IRouterContext, next: Koa.Next) => {
|
value: entry.value.map(toSerializableContent),
|
||||||
const reqbody = ctx.request.body;
|
}));
|
||||||
if (!checkPostAddedBody(reqbody)) {
|
}, {
|
||||||
sendError(400, "format exception");
|
beforeHandle: AdminOnly,
|
||||||
return;
|
})
|
||||||
}
|
.post("/commit", async ({ body }) => {
|
||||||
const allWork = reqbody.map((op) => diffmgr.commit(op.type, op.path));
|
if (body.length === 0) {
|
||||||
const results = await Promise.all(allWork);
|
return { ok: true, docs: [] as number[] };
|
||||||
ctx.body = {
|
}
|
||||||
ok: true,
|
const results = await Promise.all(body.map(({ type, path }) => diffmgr.commit(type, path)));
|
||||||
docs: results,
|
return {
|
||||||
};
|
ok: true,
|
||||||
ctx.type = "json";
|
docs: results,
|
||||||
await next();
|
};
|
||||||
};
|
}, {
|
||||||
export const postAddedAll = (diffmgr: DiffManager) => async (ctx: Router.IRouterContext, next: Koa.Next) => {
|
beforeHandle: AdminOnly,
|
||||||
if (!ctx.is("json")) {
|
body: CommitEntrySchema,
|
||||||
sendError(400, "format exception");
|
})
|
||||||
return;
|
.post("/commitall", async ({ body }) => {
|
||||||
}
|
const { type } = body;
|
||||||
const reqbody = ctx.request.body as Record<string, unknown>;
|
if (!type) {
|
||||||
if (!("type" in reqbody)) {
|
sendError(400, 'format exception: there is no "type"');
|
||||||
sendError(400, 'format exception: there is no "type"');
|
}
|
||||||
return;
|
await diffmgr.commitAll(type);
|
||||||
}
|
return { ok: true };
|
||||||
const t = reqbody.type;
|
}, {
|
||||||
if (typeof t !== "string") {
|
beforeHandle: AdminOnly,
|
||||||
sendError(400, 'format exception: invalid type of "type"');
|
body: CommitAllSchema,
|
||||||
return;
|
})
|
||||||
}
|
.get("/*", () => {
|
||||||
await diffmgr.commitAll(t);
|
sendError(404);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,7 +1,58 @@
|
||||||
import { ConfigManager } from "../../util/configRW.ts";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import ComicSchema from "./ComicConfig.schema.json" assert { type: "json" };
|
import type { Kysely } from "kysely";
|
||||||
|
import type { db } from "dbtype";
|
||||||
|
import { getAppConfig, upsertAppConfig } from "../../db/config.ts";
|
||||||
|
|
||||||
export interface ComicConfig {
|
export interface ComicConfig {
|
||||||
watch: string[];
|
watch: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ComicConfig = new ConfigManager<ComicConfig>("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<ComicConfig> | 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<ComicConfig> | 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<db.DB>): Promise<ComicConfig> => {
|
||||||
|
if (cache) {
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = await getAppConfig<ComicConfig>(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<db.DB>, config: ComicConfig): Promise<void> => {
|
||||||
|
cache = normalize(config);
|
||||||
|
await upsertAppConfig(db, CONFIG_KEY, cache);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearComicConfigCache = () => {
|
||||||
|
cache = null;
|
||||||
|
};
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { ComicConfig } from "./ComicConfig.ts";
|
|
||||||
import { WatcherCompositer } from "./compositer.ts";
|
import { WatcherCompositer } from "./compositer.ts";
|
||||||
import { RecursiveWatcher } from "./recursive_watcher.ts";
|
import { RecursiveWatcher } from "./recursive_watcher.ts";
|
||||||
import { WatcherFilter } from "./watcher_filter.ts";
|
import { WatcherFilter } from "./watcher_filter.ts";
|
||||||
|
@ -6,8 +5,11 @@ import { WatcherFilter } from "./watcher_filter.ts";
|
||||||
const createComicWatcherBase = (path: string) => {
|
const createComicWatcherBase = (path: string) => {
|
||||||
return new WatcherFilter(new RecursiveWatcher(path), (x) => x.endsWith(".zip"));
|
return new WatcherFilter(new RecursiveWatcher(path), (x) => x.endsWith(".zip"));
|
||||||
};
|
};
|
||||||
export const createComicWatcher = () => {
|
|
||||||
const file = ComicConfig.get_config_file();
|
export const createComicWatcher = (paths: string[]) => {
|
||||||
console.log(`register comic ${file.watch.join(",")}`);
|
const uniquePaths = [...new Set(paths)].filter((path) => path.length > 0);
|
||||||
return new WatcherCompositer(file.watch.map((path) => createComicWatcherBase(path)));
|
if (uniquePaths.length === 0) {
|
||||||
|
console.warn("[diff] No comic watch paths configured");
|
||||||
|
}
|
||||||
|
return new WatcherCompositer(uniquePaths.map((path) => createComicWatcherBase(path)));
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,19 +1,8 @@
|
||||||
|
import { Elysia, t, type Context } from "elysia";
|
||||||
import { SignJWT, jwtVerify, errors } from "jose";
|
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 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 { get_setting } from "./SettingConfig.ts";
|
||||||
import { LoginRequestSchema, LoginResetRequestSchema } from "dbtype";
|
|
||||||
|
|
||||||
type LoginResponse = {
|
|
||||||
accessExpired: number;
|
|
||||||
} & PayloadInfo;
|
|
||||||
|
|
||||||
type RefreshResponse = {
|
|
||||||
accessExpired: number;
|
|
||||||
refresh: boolean;
|
|
||||||
} & PayloadInfo;
|
|
||||||
|
|
||||||
type PayloadInfo = {
|
type PayloadInfo = {
|
||||||
username: string;
|
username: string;
|
||||||
|
@ -24,18 +13,41 @@ export type UserState = {
|
||||||
user: PayloadInfo;
|
user: PayloadInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isUserState = (obj: object | string): obj is PayloadInfo => {
|
type AuthStore = {
|
||||||
if (typeof obj === "string") return false;
|
user: PayloadInfo;
|
||||||
return "username" in obj && "permission" in obj && Array.isArray((obj as { permission: unknown }).permission);
|
refreshed: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LoginResponse = {
|
||||||
|
accessExpired: number;
|
||||||
|
} & PayloadInfo;
|
||||||
|
|
||||||
|
type RefreshResponse = {
|
||||||
|
accessExpired: number;
|
||||||
|
refresh: boolean;
|
||||||
|
} & PayloadInfo;
|
||||||
|
|
||||||
type RefreshPayloadInfo = { username: string };
|
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) {
|
async function createAccessToken(payload: PayloadInfo, secret: string) {
|
||||||
return await new SignJWT(payload)
|
return await new SignJWT(payload)
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
@ -43,7 +55,6 @@ async function createAccessToken(payload: PayloadInfo, secret: string) {
|
||||||
.sign(new TextEncoder().encode(secret));
|
.sign(new TextEncoder().encode(secret));
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 day;
|
|
||||||
async function createRefreshToken(payload: RefreshPayloadInfo, secret: string) {
|
async function createRefreshToken(payload: RefreshPayloadInfo, secret: string) {
|
||||||
return await new SignJWT(payload)
|
return await new SignJWT(payload)
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
@ -57,10 +68,10 @@ class TokenExpiredError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyToken(token: string, secret: string) {
|
async function verifyToken<T>(token: string, secret: string): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret));
|
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret));
|
||||||
return payload as PayloadInfo;
|
return payload as T;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof errors.JWTExpired) {
|
if (error instanceof errors.JWTExpired) {
|
||||||
throw new TokenExpiredError();
|
throw new TokenExpiredError();
|
||||||
|
@ -72,241 +83,245 @@ async function verifyToken(token: string, secret: string) {
|
||||||
export const accessTokenName = "access_token";
|
export const accessTokenName = "access_token";
|
||||||
export const refreshTokenName = "refresh_token";
|
export const refreshTokenName = "refresh_token";
|
||||||
|
|
||||||
function setToken(ctx: Koa.Context, token_name: string, token_payload: string | null, expiredtime: number) {
|
function setToken(cookie: CookieJar, token_name: string, token_payload: string | null, expiredSeconds: number) {
|
||||||
const setting = get_setting();
|
if (token_payload === null) {
|
||||||
if (token_payload === null && !ctx.cookies.get(token_name)) {
|
cookie[token_name]?.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.cookies.set(token_name, token_payload, {
|
const setting = get_setting();
|
||||||
|
cookie[token_name].set({
|
||||||
|
value: token_payload,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: setting.secure,
|
secure: setting.secure,
|
||||||
sameSite: "strict",
|
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 isUserState = (obj: unknown): obj is PayloadInfo => {
|
||||||
const setting = get_setting();
|
if (typeof obj !== "object" || obj === null) {
|
||||||
const secretKey = setting.jwt_secretkey;
|
return false;
|
||||||
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 user = await userController.findUser(username);
|
return "username" in obj && "permission" in obj && Array.isArray((obj as { permission: unknown }).permission);
|
||||||
// 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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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<AuthResult> {
|
||||||
const setting = get_setting();
|
const setting = get_setting();
|
||||||
ctx.cookies.set(accessTokenName, null);
|
const secretKey = setting.jwt_secretkey;
|
||||||
ctx.cookies.set(refreshTokenName, null);
|
const accessCookie = cookie[accessTokenName];
|
||||||
ctx.body = {
|
const refreshCookie = cookie[refreshTokenName];
|
||||||
ok: true,
|
const accessValue = typeof accessCookie?.value === 'string' ? accessCookie.value : undefined;
|
||||||
|
const refreshValue = typeof refreshCookie?.value === 'string' ? refreshCookie.value : undefined;
|
||||||
|
|
||||||
|
const guestUser: PayloadInfo = {
|
||||||
username: "",
|
username: "",
|
||||||
permission: setting.guest,
|
permission: setting.guest,
|
||||||
};
|
};
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createUserHandler =
|
const setGuest = (): AuthResult => {
|
||||||
(userController: UserAccessor) => async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
|
accessCookie?.remove();
|
||||||
const refreshToken = makeRefreshToken(userController);
|
refreshCookie?.remove();
|
||||||
const setting = get_setting();
|
return { user: guestUser, refreshed: false, success: false };
|
||||||
const setGuest = async () => {
|
};
|
||||||
setToken(ctx, accessTokenName, null, 0);
|
|
||||||
setToken(ctx, refreshTokenName, null, 0);
|
const issueAccessForUser = async (username: string): Promise<AuthResult> => {
|
||||||
ctx.state.user = { username: "", permission: setting.guest };
|
const account = await userController.findUser(username);
|
||||||
return await next();
|
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 tryRefresh = async (): Promise<AuthResult> => {
|
||||||
const accessPayload = ctx.cookies.get(accessTokenName);
|
if (!refreshValue) {
|
||||||
const setting = get_setting();
|
return setGuest();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
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 {
|
try {
|
||||||
const payload = await verifyToken(refreshPayload, secretKey);
|
const payload = await verifyToken<RefreshPayloadInfo>(refreshValue, secretKey);
|
||||||
if (isRefreshToken(payload)) {
|
if (!isRefreshToken(payload)) {
|
||||||
const user = await cntr.findUser(payload.username);
|
return setGuest();
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
return await issueAccessForUser(payload.username);
|
||||||
|
} catch {
|
||||||
|
return setGuest();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.forceRefresh) {
|
||||||
|
if (accessValue) {
|
||||||
|
try {
|
||||||
|
const payload = await verifyToken<PayloadInfo>(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<PayloadInfo>(accessValue, secretKey);
|
||||||
|
if (isUserState(payload)) {
|
||||||
|
return { user: payload, refreshed: false, success: true };
|
||||||
|
}
|
||||||
|
return setGuest();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TokenExpiredError) {
|
if (!(error instanceof TokenExpiredError)) {
|
||||||
// Refresh token is expired
|
return setGuest();
|
||||||
return await fail();
|
|
||||||
}
|
}
|
||||||
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";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
return await tryRefresh();
|
||||||
|
|
||||||
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<UserState>, 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<UserState>, 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<string, unknown>;
|
|
||||||
await user.set_settings(settings);
|
|
||||||
ctx.body = { ok: true };
|
|
||||||
ctx.type = "json";
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLoginRouter(userController: UserAccessor) {
|
export const createLoginRouter = (userController: UserAccessor) => {
|
||||||
const router = new Router();
|
return new Elysia({ name: "login-router" })
|
||||||
router.post("/login", createLoginHandler(userController));
|
.group("/user", (app) =>
|
||||||
router.post("/logout", LogoutHandler);
|
app
|
||||||
router.post("/refresh", createRefreshTokenMiddleware(userController));
|
.post("/login", async ({ body, cookie, set }) => {
|
||||||
router.post("/reset", resetPasswordMiddleware(userController));
|
const setting = get_setting();
|
||||||
router.get("/settings", getUserSettingHandler(userController));
|
const secretKey = setting.jwt_secretkey;
|
||||||
router.post("/settings", setUserSettingHandler(userController));
|
const { username, password } = body;
|
||||||
return router;
|
|
||||||
}
|
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<string, unknown>);
|
||||||
|
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) => {
|
export const getAdmin = async (cntr: UserAccessor) => {
|
||||||
const admin = await cntr.findUser("admin");
|
const admin = await cntr.findUser("admin");
|
||||||
if (admin === undefined) {
|
if (admin === undefined) {
|
||||||
throw new Error("initial process failed!"); // ???
|
throw new Error("initial process failed!");
|
||||||
}
|
}
|
||||||
return admin;
|
return admin;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { check_type } from "../util/type_check.ts";
|
|
||||||
import type {
|
import type {
|
||||||
DocumentBody,
|
DocumentBody,
|
||||||
QueryListOption,
|
QueryListOption,
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import type Koa from "koa";
|
|
||||||
import type { UserState } from "../login.ts";
|
|
||||||
import { sendError } from "../route/error_handler.ts";
|
import { sendError } from "../route/error_handler.ts";
|
||||||
|
import type { UserState } from "../login.ts";
|
||||||
|
|
||||||
export enum Permission {
|
export enum Permission {
|
||||||
// ========
|
// ========
|
||||||
|
@ -34,27 +33,36 @@ export enum Permission {
|
||||||
modifyTagDesc = "ModifyTagDesc",
|
modifyTagDesc = "ModifyTagDesc",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createPermissionCheckMiddleware =
|
type PermissionCheckContext = {
|
||||||
(...permissions: string[]) =>
|
user?: UserState["user"];
|
||||||
async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
|
store?: { user?: UserState["user"] };
|
||||||
const user = ctx.state.user;
|
} & Record<string, unknown>;
|
||||||
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();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
|
const resolveUser = (context: PermissionCheckContext): UserState["user"] => {
|
||||||
const user = ctx.state.user;
|
const user = context.user ?? context.store?.user;
|
||||||
if (user.username !== "admin") {
|
if (!user) {
|
||||||
return sendError(403, "admin only");
|
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");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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<ContentContext, DefaultContext>, 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: <explanation>
|
|
||||||
const chain = result.pathAndMethod.reduce((combination: Middleware<any & DefaultContext, any>[], 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<ContentContext> {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,72 +1,104 @@
|
||||||
import type { Context } from "koa";
|
import type { Context as ElysiaContext } from "elysia";
|
||||||
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 { Readable } from "node:stream";
|
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 extensionToMime = (ext: string) => {
|
||||||
const image_ext = ["gif", "png", "jpeg", "bmp", "webp", "jpg", "avif"];
|
if (ext === "jpg") return "image/jpeg";
|
||||||
|
return `image/${ext}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResponseSet = Pick<ElysiaContext["set"], "status" | "headers">;
|
||||||
|
|
||||||
|
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 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<string, string | number>);
|
||||||
|
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) {
|
write(chunk) {
|
||||||
nodeReadableStream.push(chunk);
|
nodeReadable.push(chunk);
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
nodeReadableStream.push(null);
|
nodeReadable.push(null);
|
||||||
},
|
},
|
||||||
}));
|
abort(err) {
|
||||||
nodeReadableStream.on("error", (err) => {
|
nodeReadable.destroy(err);
|
||||||
console.error("readalbe stream error",err);
|
},
|
||||||
ctx.status = 500;
|
})).catch((err) => {
|
||||||
ctx.body = "Internal Server Error";
|
nodeReadable.destroy(err);
|
||||||
zip.reader.close();
|
|
||||||
return;
|
|
||||||
});
|
|
||||||
nodeReadableStream.on("close", () => {
|
|
||||||
zip.reader.close();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = nodeReadableStream;
|
nodeReadable.on("close", () => {
|
||||||
ctx.response.length = entry.uncompressedSize;
|
closeZip().catch(console.error);
|
||||||
ctx.response.type = entry.filename.split(".").pop() as string;
|
});
|
||||||
ctx.status = 200;
|
nodeReadable.on("error", () => {
|
||||||
ctx.set("Date", new Date().toUTCString());
|
closeZip().catch(console.error);
|
||||||
ctx.set("Last-Modified", last_modified.toUTCString());
|
});
|
||||||
} else {
|
nodeReadable.on("end", () => {
|
||||||
ctx.status = 404;
|
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<ContentContext> {
|
|
||||||
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;
|
|
||||||
|
|
|
@ -1,248 +1,195 @@
|
||||||
import type { Context, Next } from "koa";
|
import { Elysia, t } from "elysia";
|
||||||
import Router from "koa-router";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type {
|
import type { Document, QueryListOption } from "dbtype";
|
||||||
Document,
|
|
||||||
QueryListOption,
|
|
||||||
} from "dbtype";
|
|
||||||
import type { DocumentAccessor } from "../model/doc.ts";
|
import type { DocumentAccessor } from "../model/doc.ts";
|
||||||
import {
|
import { AdminOnly, createPermissionCheck, Permission as Per } from "../permission/permission.ts";
|
||||||
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 { sendError } from "./error_handler.ts";
|
import { sendError } from "./error_handler.ts";
|
||||||
import { ParseQueryArgString, ParseQueryArray, ParseQueryBoolean, ParseQueryNumber } from "./util.ts";
|
|
||||||
import { oshash } from "src/util/oshash.ts";
|
import { oshash } from "src/util/oshash.ts";
|
||||||
|
import { renderComicPage } from "./comic.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<Document> & { 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";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getContentRouter = (controller: DocumentAccessor) => {
|
export const getContentRouter = (controller: DocumentAccessor) => {
|
||||||
const ret = new Router();
|
return new Elysia({ name: "content-router",
|
||||||
ret.get("/search", PerCheck(Per.QueryContent), ContentQueryHandler(controller));
|
prefix: "/doc",
|
||||||
ret.get("/_gid", PerCheck(Per.QueryContent), ContentGidListHandler(controller));
|
})
|
||||||
ret.get("/:num(\\d+)", PerCheck(Per.QueryContent), ContentIDHandler(controller));
|
.get("/search", async ({ query }) => {
|
||||||
ret.all("/:num(\\d+)/(.*)", PerCheck(Per.QueryContent), ContentHandler(controller));
|
const limit = Math.min(Number(query.limit ?? 20), 100);
|
||||||
ret.post("/:num(\\d+)", AdminOnly, UpdateContentHandler(controller));
|
const option: QueryListOption = {
|
||||||
ret.del("/:num(\\d+)", AdminOnly, DeleteContentHandler(controller));
|
limit: limit,
|
||||||
// ret.post("/",AdminOnly,CreateContentHandler(controller));
|
allow_tag: query.allow_tag?.split(",") ?? [],
|
||||||
ret.get("/:num(\\d+)/similars", PerCheck(Per.QueryContent), getSimilarDocumentHandler(controller));
|
word: query.word,
|
||||||
ret.get("/:num(\\d+)/tags", PerCheck(Per.QueryContent), ContentTagIDHandler(controller));
|
cursor: query.cursor,
|
||||||
ret.post("/:num(\\d+)/tags/:tag", PerCheck(Per.ModifyTag), AddTagHandler(controller));
|
eager_loading: true,
|
||||||
ret.del("/:num(\\d+)/tags/:tag", PerCheck(Per.ModifyTag), DelTagHandler(controller));
|
offset: Number(query.offset),
|
||||||
ret.use("/:num(\\d+)", PerCheck(Per.QueryContent), new AllContentRouter().routes());
|
use_offset: query.use_offset === 'true',
|
||||||
ret.post("/:num(\\d+)/_rehash", AdminOnly, RehashContentHandler(controller));
|
content_type: query.content_type,
|
||||||
ret.post("/:num(\\d+)/_rescan", AdminOnly, getRescanDocumentHandler(controller));
|
};
|
||||||
return ret;
|
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<Document> & { 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;
|
export default getContentRouter;
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { ZodError } from "dbtype";
|
import { ZodError } from "dbtype";
|
||||||
import type { Context, Next } from "koa";
|
|
||||||
|
|
||||||
export interface ErrorFormat {
|
export interface ErrorFormat {
|
||||||
code: number;
|
code: number;
|
||||||
|
@ -7,15 +6,12 @@ export interface ErrorFormat {
|
||||||
detail?: string;
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ClientRequestError implements Error {
|
export class ClientRequestError extends Error {
|
||||||
name: string;
|
|
||||||
message: string;
|
|
||||||
stack?: string | undefined;
|
|
||||||
code: number;
|
code: number;
|
||||||
|
|
||||||
constructor(code: number, message: string) {
|
constructor(code: number, message: string) {
|
||||||
|
super(message);
|
||||||
this.name = "client request error";
|
this.name = "client request error";
|
||||||
this.message = message;
|
|
||||||
this.code = code;
|
this.code = code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,36 +21,32 @@ const code_to_message_table: { [key: number]: string | undefined } = {
|
||||||
404: "NotFound",
|
404: "NotFound",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const error_handler = async (ctx: Context, next: Next) => {
|
export const error_handler = ({ code, error, set }: { code: string, error: Error, set: { status?: number | string } }) => {
|
||||||
try {
|
if (error instanceof ClientRequestError) {
|
||||||
await next();
|
set.status = error.code;
|
||||||
} catch (err) {
|
return {
|
||||||
if (err instanceof ClientRequestError) {
|
code: error.code,
|
||||||
const body: ErrorFormat = {
|
message: code_to_message_table[error.code] ?? "",
|
||||||
code: err.code,
|
detail: error.message,
|
||||||
message: code_to_message_table[err.code] ?? "",
|
} satisfies ErrorFormat;
|
||||||
detail: err.message,
|
}
|
||||||
};
|
if (error instanceof ZodError) {
|
||||||
ctx.status = err.code;
|
set.status = 400;
|
||||||
ctx.body = body;
|
return {
|
||||||
}
|
code: 400,
|
||||||
else if (err instanceof ZodError) {
|
message: "BadRequest",
|
||||||
const body: ErrorFormat = {
|
detail: error.errors.map((x) => x.message).join(", "),
|
||||||
code: 400,
|
} satisfies ErrorFormat;
|
||||||
message: "BadRequest",
|
}
|
||||||
detail: err.errors.map((x) => x.message).join(", "),
|
|
||||||
};
|
set.status = 500;
|
||||||
ctx.status = 400;
|
return {
|
||||||
ctx.body = body;
|
code: 500,
|
||||||
}
|
message: "Internal Server Error",
|
||||||
else {
|
detail: error.message,
|
||||||
throw err;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendError = (code: number, message?: string) => {
|
export const sendError = (code: number, message?: string): never => {
|
||||||
throw new ClientRequestError(code, message ?? "");
|
throw new ClientRequestError(code, message ?? "");
|
||||||
};
|
};
|
||||||
|
|
||||||
export default error_handler;
|
|
||||||
|
|
72
packages/server/src/route/settings.ts
Normal file
72
packages/server/src/route/settings.ts
Normal file
|
@ -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<typeof updateBodySchema>;
|
||||||
|
|
||||||
|
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<db.DB>) =>
|
||||||
|
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;
|
|
@ -1,29 +1,33 @@
|
||||||
import { type Context, Next } from "koa";
|
import { Elysia, t } from "elysia";
|
||||||
import Router, { type RouterContext } from "koa-router";
|
|
||||||
import type { TagAccessor } from "../model/tag.ts";
|
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";
|
import { sendError } from "./error_handler.ts";
|
||||||
|
|
||||||
export function getTagRounter(tagController: TagAccessor) {
|
export function getTagRounter(tagController: TagAccessor) {
|
||||||
const router = new Router();
|
return new Elysia({ name: "tags-router",
|
||||||
router.get("/", PerCheck(Permission.QueryContent), async (ctx: Context) => {
|
prefix: "/tags",
|
||||||
if (ctx.query.withCount) {
|
})
|
||||||
const c = await tagController.getAllTagCount();
|
.get("/", async ({ query }) => {
|
||||||
ctx.body = c;
|
if (query.withCount !== undefined) {
|
||||||
} else {
|
return await tagController.getAllTagCount();
|
||||||
const c = await tagController.getAllTagList();
|
}
|
||||||
ctx.body = c;
|
return await tagController.getAllTagList();
|
||||||
}
|
}, {
|
||||||
ctx.type = "json";
|
beforeHandle: createPermissionCheck(Permission.QueryContent),
|
||||||
});
|
query: t.Object({
|
||||||
router.get("/:tag_name", PerCheck(Permission.QueryContent), async (ctx: RouterContext) => {
|
withCount: t.Optional(t.String()),
|
||||||
const tag_name = ctx.params.tag_name;
|
})
|
||||||
const c = await tagController.getTagByName(tag_name);
|
})
|
||||||
if (!c) {
|
.get("/:tag_name", async ({ params: { tag_name } }) => {
|
||||||
sendError(404, "tags not found");
|
const tag = await tagController.getTagByName(tag_name);
|
||||||
}
|
if (!tag) {
|
||||||
ctx.body = c;
|
sendError(404, "tags not found");
|
||||||
ctx.type = "json";
|
}
|
||||||
});
|
return tag;
|
||||||
return router;
|
}, {
|
||||||
|
beforeHandle: createPermissionCheck(Permission.QueryContent),
|
||||||
|
params: t.Object({
|
||||||
|
tag_name: t.String(),
|
||||||
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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<ContentContext> {
|
|
||||||
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;
|
|
|
@ -1,255 +1,158 @@
|
||||||
import Koa from "koa";
|
import { Elysia, t } from "elysia";
|
||||||
import Router from "koa-router";
|
import { cors } from "@elysiajs/cors";
|
||||||
|
import { staticPlugin } from "@elysiajs/static";
|
||||||
|
import { html } from "@elysiajs/html";
|
||||||
|
|
||||||
import { connectDB } from "./database.ts";
|
import { connectDB } from "./database.ts";
|
||||||
import { createDiffRouter, DiffManager } from "./diff/mod.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 { readFileSync } from "node:fs";
|
||||||
import bodyparser from "koa-bodyparser";
|
|
||||||
import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts";
|
import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts";
|
||||||
import { createLoginRouter, createUserHandler, getAdmin, isAdminFirst } from "./login.ts";
|
import { createLoginRouter, createUserHandler, getAdmin, isAdminFirst } from "./login.ts";
|
||||||
import getContentRouter from "./route/contents.ts";
|
import getContentRouter from "./route/contents.ts";
|
||||||
import { error_handler } from "./route/error_handler.ts";
|
import { error_handler } from "./route/error_handler.ts";
|
||||||
|
import { createSettingsRouter } from "./route/settings.ts";
|
||||||
|
|
||||||
import { createInterface as createReadlineInterface } from "node:readline";
|
import { createInterface as createReadlineInterface } from "node:readline";
|
||||||
import { createComicWatcher } from "./diff/watcher/comic_watcher.ts";
|
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 type { DocumentAccessor, TagAccessor, UserAccessor } from "./model/mod.ts";
|
||||||
import { getTagRounter } from "./route/tags.ts";
|
import { getTagRounter } from "./route/tags.ts";
|
||||||
|
import { node } from "@elysiajs/node";
|
||||||
|
import { openapi } from "@elysiajs/openapi";
|
||||||
|
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
import { extname, join } from "node:path";
|
|
||||||
config();
|
config();
|
||||||
|
|
||||||
class ServerApplication {
|
function createMetaTagContent(key: string, value: string) {
|
||||||
readonly userController: UserAccessor;
|
return `<meta property="${key}" content="${value}">`;
|
||||||
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: <explanation>
|
|
||||||
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("<!--MetaTag-Outlet-->", 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 `<meta property="${key}" content="${value}">`;
|
|
||||||
}
|
|
||||||
function createOgTagContent(title: string, description: string, image: string) {
|
|
||||||
return [
|
|
||||||
createMetaTagContent("og:title", title),
|
|
||||||
createMetaTagContent("og:type", "website"),
|
|
||||||
createMetaTagContent("og:description", description),
|
|
||||||
createMetaTagContent("og:image", image),
|
|
||||||
// createMetaTagContent("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 createOgTagContent(title: string, description:string, image: string) {
|
||||||
|
return [
|
||||||
|
createMetaTagContent("og:title", title),
|
||||||
|
createMetaTagContent("og:type", "website"),
|
||||||
|
createMetaTagContent("og:description", description),
|
||||||
|
createMetaTagContent("og:image", image),
|
||||||
|
createMetaTagContent("twitter:card", "summary_large_image"),
|
||||||
|
createMetaTagContent("twitter:title", title),
|
||||||
|
createMetaTagContent("twitter:description", description),
|
||||||
|
createMetaTagContent("twitter:image", image),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMetaTagInjectedHTML(html: string, tagContent: string) {
|
||||||
|
return html.replace("<!--MetaTag-Outlet-->", tagContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
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;
|
||||||
|
}
|
7
packages/server/src/setting.ts
Normal file
7
packages/server/src/setting.ts
Normal file
|
@ -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() }));
|
|
@ -1,46 +1 @@
|
||||||
import { existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs";
|
export {};
|
||||||
|
|
||||||
export class ConfigManager<T extends object> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
export function check_type<T>(obj: unknown, check_proto: Record<string, string | undefined>): 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<string, unknown>)[it])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// biome-ignore lint/suspicious/useValidTypeof: <explanation>
|
|
||||||
} else if (defined !== typeof (obj as Record<string, unknown>)[it]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { type FileHandle, open } from "node:fs/promises";
|
import { type FileHandle, open } from "node:fs/promises";
|
||||||
import { orderBy } from "natural-orderby";
|
import { orderBy } from "natural-orderby";
|
||||||
import { ZipReader, Reader, type Entry, ZipReaderConstructorOptions } from "@zip.js/zip.js";
|
import { ZipReader, Reader, type Entry, ZipReaderConstructorOptions } from "@zip.js/zip.js";
|
||||||
import EventEmitter from "node:events";
|
|
||||||
|
|
||||||
class FileReader extends Reader<string> {
|
class FileReader extends Reader<string> {
|
||||||
private fd?: FileHandle;
|
private fd?: FileHandle;
|
||||||
private path: string;
|
private path: string;
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
constructor(path: string) {
|
constructor(path: string) {
|
||||||
super(path);
|
super(path);
|
||||||
|
@ -14,21 +14,29 @@ class FileReader extends Reader<string> {
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
await super.init?.();
|
await super.init?.();
|
||||||
|
if (this.closed) return;
|
||||||
|
|
||||||
const fd = await open(this.path, "r");
|
const fd = await open(this.path, "r");
|
||||||
const stat = await fd.stat();
|
const stat = await fd.stat();
|
||||||
this.fd = fd;
|
this.fd = fd;
|
||||||
this.size = stat.size;
|
this.size = stat.size;
|
||||||
// not implemented yet
|
|
||||||
(this.fd as unknown as EventEmitter).on("close", () => {
|
|
||||||
this.fd?.close();
|
|
||||||
this.fd = undefined;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
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<Uint8Array> {
|
async readUint8Array(index: number, length: number): Promise<Uint8Array> {
|
||||||
|
if (this.closed) {
|
||||||
|
throw new Error("FileReader is closed");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = new Uint8Array(length);
|
const buffer = new Uint8Array(length);
|
||||||
if (this.fd === undefined) {
|
if (this.fd === undefined) {
|
||||||
|
@ -49,18 +57,29 @@ class FileReader extends Reader<string> {
|
||||||
return buffer;
|
return buffer;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("read error", error);
|
console.error("read error", error);
|
||||||
|
// 에러 발생 시 파일 핸들 정리
|
||||||
|
await this.close();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FileZipReader extends ZipReader<FileHandle> {
|
class FileZipReader extends ZipReader<FileHandle> {
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
constructor(private reader: FileReader, options?: ZipReaderConstructorOptions) {
|
constructor(private reader: FileReader, options?: ZipReaderConstructorOptions) {
|
||||||
super(reader, options);
|
super(reader, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
override async close(): Promise<void> {
|
override async close(): Promise<void> {
|
||||||
super.close();
|
if (this.closed) return;
|
||||||
await this.reader.close();
|
this.closed = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await super.close();
|
||||||
|
} finally {
|
||||||
|
await this.reader.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
102
packages/server/tests/diff-router.integration.test.ts
Normal file
102
packages/server/tests/diff-router.integration.test.ts
Normal file
|
@ -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<typeof createTestApp>
|
||||||
|
let diffManager: DiffManager;
|
||||||
|
let getAddedMock: ReturnType<typeof vi.fn>;
|
||||||
|
let commitMock: ReturnType<typeof vi.fn>;
|
||||||
|
let commitAllMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
59
packages/server/tests/error_handler.test.ts
Normal file
59
packages/server/tests/error_handler.test.ts
Normal file
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
156
packages/server/tests/settings-router.integration.test.ts
Normal file
156
packages/server/tests/settings-router.integration.test.ts
Normal file
|
@ -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<typeof SqliteDatabase>;
|
||||||
|
let database: Kysely<db.DB>;
|
||||||
|
|
||||||
|
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<db.DB>({ 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
9
packages/server/tsconfig.vitest.json
Normal file
9
packages/server/tsconfig.vitest.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"types": ["vitest/globals"],
|
||||||
|
"outDir": "./dist-vitest"
|
||||||
|
},
|
||||||
|
"include": ["src", "tests"]
|
||||||
|
}
|
9
packages/server/vitest.config.ts
Normal file
9
packages/server/vitest.config.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
globals: true,
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
1114
pnpm-lock.yaml
generated
1114
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue