feat!: use elysia js intead of koa				#18
		
		
	
					 22 changed files with 1109 additions and 259 deletions
				
			
		
							
								
								
									
										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;
 | 
			
		||||
							
								
								
									
										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,8 +1,14 @@
 | 
			
		|||
import { useCallback } from "react";
 | 
			
		||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 | 
			
		||||
import { type TernaryDarkMode, useTernaryDarkMode, useMediaQuery } from "usehooks-ts";
 | 
			
		||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
 | 
			
		||||
import { Label } from "@/components/ui/label";
 | 
			
		||||
import BuildInfoCard from "@/components/BuildInfoCard";
 | 
			
		||||
import { useServerSettings } from "@/hook/useServerSettings";
 | 
			
		||||
import { useLogin } from "@/state/user";
 | 
			
		||||
import { fetcher } from "@/hook/fetcher";
 | 
			
		||||
import type { ServerSettingResponse, ServerSettingUpdate } from "dbtype/mod.ts";
 | 
			
		||||
import { ServerSettingCard } from "@/components/ServerSettingCard";
 | 
			
		||||
 | 
			
		||||
function LightModeView() {
 | 
			
		||||
    return <div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
 | 
			
		||||
| 
						 | 
				
			
			@ -45,9 +51,25 @@ function DarkModeView() {
 | 
			
		|||
export function SettingPage() {
 | 
			
		||||
    const { setTernaryDarkMode, ternaryDarkMode } = useTernaryDarkMode();
 | 
			
		||||
    const isSystemDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
 | 
			
		||||
    const login = useLogin();
 | 
			
		||||
    const isAdmin = login?.username === "admin";
 | 
			
		||||
 | 
			
		||||
    const { data: serverSetting, error: serverError, isLoading: serverLoading, mutate } = useServerSettings(isAdmin);
 | 
			
		||||
 | 
			
		||||
    const handleSave = useCallback(async (payload: ServerSettingUpdate) => {
 | 
			
		||||
        const response = await fetcher("/api/settings", {
 | 
			
		||||
            method: "PATCH",
 | 
			
		||||
            headers: { "content-type": "application/json" },
 | 
			
		||||
            body: JSON.stringify(payload),
 | 
			
		||||
            credentials: "include",
 | 
			
		||||
        });
 | 
			
		||||
        const updated = response as ServerSettingResponse;
 | 
			
		||||
        await mutate(updated, false);
 | 
			
		||||
        return updated;
 | 
			
		||||
    }, [mutate]);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <div className="p-4 space-y-2">
 | 
			
		||||
        <div className="p-4 space-y-4">
 | 
			
		||||
            <Card>
 | 
			
		||||
                <CardHeader>
 | 
			
		||||
                    <CardTitle className="text-2xl">Settings</CardTitle>
 | 
			
		||||
| 
						 | 
				
			
			@ -86,6 +108,13 @@ export function SettingPage() {
 | 
			
		|||
                    </div>
 | 
			
		||||
                </CardContent>
 | 
			
		||||
            </Card>
 | 
			
		||||
            <ServerSettingCard
 | 
			
		||||
                isAdmin={isAdmin}
 | 
			
		||||
                loading={serverLoading}
 | 
			
		||||
                error={serverError}
 | 
			
		||||
                setting={serverSetting}
 | 
			
		||||
                onSave={handleSave}
 | 
			
		||||
            />
 | 
			
		||||
            <BuildInfoCard />
 | 
			
		||||
        </div>
 | 
			
		||||
    )
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -97,4 +97,29 @@ export const LoginResetRequestSchema = z.object({
 | 
			
		|||
	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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface AppConfig {
 | 
			
		||||
  key: string;
 | 
			
		||||
  value: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface DB {
 | 
			
		||||
  doc_tag_relation: DocTagRelation;
 | 
			
		||||
  document: Document;
 | 
			
		||||
| 
						 | 
				
			
			@ -58,4 +63,5 @@ export interface DB {
 | 
			
		|||
  tags: Tags;
 | 
			
		||||
  users: Users;
 | 
			
		||||
  user_settings: UserSettings;
 | 
			
		||||
  app_config: AppConfig;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										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.");
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -7,7 +7,9 @@
 | 
			
		|||
	"scripts": {
 | 
			
		||||
		"dev": "tsx watch src/app.ts",
 | 
			
		||||
		"start": "tsx src/app.ts",
 | 
			
		||||
		"migrate": "tsx tools/migration.ts"
 | 
			
		||||
		"migrate": "tsx tools/migration.ts",
 | 
			
		||||
		"test": "vitest run",
 | 
			
		||||
		"test:watch": "vitest"
 | 
			
		||||
	},
 | 
			
		||||
	"author": "",
 | 
			
		||||
	"license": "ISC",
 | 
			
		||||
| 
						 | 
				
			
			@ -29,10 +31,10 @@
 | 
			
		|||
	},
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@types/better-sqlite3": "^7.6.13",
 | 
			
		||||
		"@types/koa-compose": "^3.2.8",
 | 
			
		||||
		"@types/node": "^22.15.33",
 | 
			
		||||
		"@types/tiny-async-pool": "^1.0.5",
 | 
			
		||||
		"tsx": "^4.20.3",
 | 
			
		||||
		"typescript": "^5.8.3"
 | 
			
		||||
		"typescript": "^5.8.3",
 | 
			
		||||
		"vitest": "^2.1.3"
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,80 +1,160 @@
 | 
			
		|||
import { randomBytes } from "node:crypto";
 | 
			
		||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
 | 
			
		||||
import type { Permission } from "./permission/permission.ts";
 | 
			
		||||
import { existsSync, readFileSync } from "node:fs";
 | 
			
		||||
import type { Kysely } from "kysely";
 | 
			
		||||
import type { db } from "dbtype";
 | 
			
		||||
import { Permission } from "./permission/permission.ts";
 | 
			
		||||
import { getAppConfig, upsertAppConfig } from "./db/config.ts";
 | 
			
		||||
 | 
			
		||||
export interface SettingConfig {
 | 
			
		||||
	/**
 | 
			
		||||
	 * if true, server will bind on '127.0.0.1' rather than '0.0.0.0'
 | 
			
		||||
	 */
 | 
			
		||||
	localmode: boolean;
 | 
			
		||||
	/**
 | 
			
		||||
	 * secure only
 | 
			
		||||
	 */
 | 
			
		||||
	secure: boolean;
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * guest permission
 | 
			
		||||
	 */
 | 
			
		||||
	guest: Permission[];
 | 
			
		||||
	/**
 | 
			
		||||
	 * JWT secret key. if you change its value, all access tokens are invalidated.
 | 
			
		||||
	 */
 | 
			
		||||
	jwt_secretkey: string;
 | 
			
		||||
	/**
 | 
			
		||||
	 * the port which running server is binding on.
 | 
			
		||||
	 */
 | 
			
		||||
	hostname: string;
 | 
			
		||||
	port: number;
 | 
			
		||||
 | 
			
		||||
	mode: "development" | "production";
 | 
			
		||||
	/**
 | 
			
		||||
	 * if true, do not show 'electron' window and show terminal only.
 | 
			
		||||
	 */
 | 
			
		||||
	secure: boolean;
 | 
			
		||||
	guest: Permission[];
 | 
			
		||||
	jwt_secretkey: string;
 | 
			
		||||
	cli: boolean;
 | 
			
		||||
	/** forbid to login admin from remote client. but, it do not invalidate access token.
 | 
			
		||||
	 * if you want to invalidate access token, change 'jwt_secretkey'. */
 | 
			
		||||
	forbid_remote_admin_login: boolean;
 | 
			
		||||
}
 | 
			
		||||
const default_setting: SettingConfig = {
 | 
			
		||||
	localmode: true,
 | 
			
		||||
 | 
			
		||||
export type PersistedSetting = Pick<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,
 | 
			
		||||
	guest: [],
 | 
			
		||||
	jwt_secretkey: "itsRandom",
 | 
			
		||||
	port: 8080,
 | 
			
		||||
	mode: "production",
 | 
			
		||||
	cli: false,
 | 
			
		||||
	forbid_remote_admin_login: true,
 | 
			
		||||
};
 | 
			
		||||
let setting: null | SettingConfig = null;
 | 
			
		||||
 | 
			
		||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
 | 
			
		||||
const setEmptyToDefault = (target: any, default_table: SettingConfig) => {
 | 
			
		||||
	let diff_occur = false;
 | 
			
		||||
	for (const key in default_table) {
 | 
			
		||||
		if (key === undefined || key in target) {
 | 
			
		||||
			continue;
 | 
			
		||||
		}
 | 
			
		||||
		target[key] = default_table[key as keyof SettingConfig];
 | 
			
		||||
		diff_occur = true;
 | 
			
		||||
let cachedSetting: SettingConfig | null = null;
 | 
			
		||||
 | 
			
		||||
export const initializeSetting = async (db: Kysely<db.DB>): Promise<SettingConfig> => {
 | 
			
		||||
	if (cachedSetting) {
 | 
			
		||||
		return cachedSetting;
 | 
			
		||||
	}
 | 
			
		||||
	return diff_occur;
 | 
			
		||||
 | 
			
		||||
	const persisted = await loadPersistedSetting(db);
 | 
			
		||||
	const envSetting = loadEnvSetting();
 | 
			
		||||
 | 
			
		||||
	cachedSetting = {
 | 
			
		||||
		...persisted,
 | 
			
		||||
		...envSetting,
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	return cachedSetting;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const read_setting_from_file = () => {
 | 
			
		||||
	const ret = existsSync("settings.json") ? JSON.parse(readFileSync("settings.json", { encoding: "utf8" })) : {};
 | 
			
		||||
	const partial_occur = setEmptyToDefault(ret, default_setting);
 | 
			
		||||
	if (partial_occur) {
 | 
			
		||||
		writeFileSync("settings.json", JSON.stringify(ret));
 | 
			
		||||
	}
 | 
			
		||||
	return ret as SettingConfig;
 | 
			
		||||
export const refreshSetting = async (db: Kysely<db.DB>): Promise<SettingConfig> => {
 | 
			
		||||
	cachedSetting = null;
 | 
			
		||||
	return initializeSetting(db);
 | 
			
		||||
};
 | 
			
		||||
export function get_setting(): SettingConfig {
 | 
			
		||||
	if (setting === null) {
 | 
			
		||||
		setting = read_setting_from_file();
 | 
			
		||||
		const env = process.env.NODE_ENV;
 | 
			
		||||
		if (env !== undefined && env !== "production" && env !== "development") {
 | 
			
		||||
			throw new Error('process unknown value in NODE_ENV: must be either "development" or "production"');
 | 
			
		||||
		}
 | 
			
		||||
		setting.mode = env ?? setting.mode;
 | 
			
		||||
 | 
			
		||||
export type PersistedSettingUpdate = Partial<PersistedSetting>;
 | 
			
		||||
 | 
			
		||||
export const updatePersistedSetting = async (
 | 
			
		||||
	db: Kysely<db.DB>,
 | 
			
		||||
	patch: PersistedSettingUpdate,
 | 
			
		||||
): Promise<SettingConfig> => {
 | 
			
		||||
	const current = await initializeSetting(db);
 | 
			
		||||
	const basePersisted: PersistedSetting = {
 | 
			
		||||
		secure: current.secure,
 | 
			
		||||
		guest: current.guest,
 | 
			
		||||
		cli: current.cli,
 | 
			
		||||
		forbid_remote_admin_login: current.forbid_remote_admin_login,
 | 
			
		||||
	};
 | 
			
		||||
	const nextPersisted = mergePersisted({ ...basePersisted, ...patch });
 | 
			
		||||
	await upsertAppConfig(db, CONFIG_KEY, nextPersisted);
 | 
			
		||||
	cachedSetting = {
 | 
			
		||||
		...current,
 | 
			
		||||
		...nextPersisted,
 | 
			
		||||
	};
 | 
			
		||||
	return cachedSetting;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const get_setting = (): SettingConfig => {
 | 
			
		||||
	if (!cachedSetting) {
 | 
			
		||||
		throw new Error("Settings have not been initialized. Call initializeSetting first.");
 | 
			
		||||
	}
 | 
			
		||||
	return setting;
 | 
			
		||||
}
 | 
			
		||||
	return cachedSetting;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const loadEnvSetting = (): EnvSetting => {
 | 
			
		||||
	const host = process.env.SERVER_HOST ?? process.env.HOST;
 | 
			
		||||
	if (!host) {
 | 
			
		||||
		throw new Error("SERVER_HOST environment variable is required");
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const portString = process.env.SERVER_PORT;
 | 
			
		||||
	if (!portString) {
 | 
			
		||||
		throw new Error("SERVER_PORT environment variable is required");
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const port = Number.parseInt(portString, 10);
 | 
			
		||||
	if (!Number.isFinite(port)) {
 | 
			
		||||
		throw new Error("SERVER_PORT must be a valid integer");
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const modeValue = process.env.SERVER_MODE ?? process.env.NODE_ENV;
 | 
			
		||||
	if (!modeValue) {
 | 
			
		||||
		throw new Error("SERVER_MODE or NODE_ENV environment variable is required");
 | 
			
		||||
	}
 | 
			
		||||
	if (modeValue !== "development" && modeValue !== "production") {
 | 
			
		||||
		throw new Error('SERVER_MODE / NODE_ENV must be either "development" or "production"');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const jwtSecret = process.env.JWT_SECRET_KEY ?? process.env.JWT_SECRET;
 | 
			
		||||
	if (!jwtSecret) {
 | 
			
		||||
		throw new Error("JWT_SECRET_KEY environment variable is required");
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		hostname: host,
 | 
			
		||||
		port,
 | 
			
		||||
		mode: modeValue,
 | 
			
		||||
		jwt_secretkey: jwtSecret,
 | 
			
		||||
	};
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const loadPersistedSetting = async (db: Kysely<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;
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										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 "./tag.ts";
 | 
			
		||||
export * from "./user.ts";
 | 
			
		||||
export * from "./config.ts";
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,58 @@
 | 
			
		|||
import { ConfigManager } from "../../util/configRW.ts";
 | 
			
		||||
import ComicSchema from "./ComicConfig.schema.json" with { type: "json" };
 | 
			
		||||
import { existsSync, readFileSync } from "node:fs";
 | 
			
		||||
import type { Kysely } from "kysely";
 | 
			
		||||
import type { db } from "dbtype";
 | 
			
		||||
import { getAppConfig, upsertAppConfig } from "../../db/config.ts";
 | 
			
		||||
 | 
			
		||||
export interface ComicConfig {
 | 
			
		||||
	watch: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ComicConfig = new ConfigManager<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 { RecursiveWatcher } from "./recursive_watcher.ts";
 | 
			
		||||
import { WatcherFilter } from "./watcher_filter.ts";
 | 
			
		||||
| 
						 | 
				
			
			@ -6,8 +5,11 @@ import { WatcherFilter } from "./watcher_filter.ts";
 | 
			
		|||
const createComicWatcherBase = (path: string) => {
 | 
			
		||||
	return new WatcherFilter(new RecursiveWatcher(path), (x) => x.endsWith(".zip"));
 | 
			
		||||
};
 | 
			
		||||
export const createComicWatcher = () => {
 | 
			
		||||
	const file = ComicConfig.get_config_file();
 | 
			
		||||
	console.log(`register comic ${file.watch.join(",")}`);
 | 
			
		||||
	return new WatcherCompositer(file.watch.map((path) => createComicWatcherBase(path)));
 | 
			
		||||
 | 
			
		||||
export const createComicWatcher = (paths: string[]) => {
 | 
			
		||||
	const uniquePaths = [...new Set(paths)].filter((path) => path.length > 0);
 | 
			
		||||
	if (uniquePaths.length === 0) {
 | 
			
		||||
		console.warn("[diff] No comic watch paths configured");
 | 
			
		||||
	}
 | 
			
		||||
	return new WatcherCompositer(uniquePaths.map((path) => createComicWatcherBase(path)));
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										71
									
								
								packages/server/src/route/settings.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								packages/server/src/route/settings.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,71 @@
 | 
			
		|||
import { Elysia, t, type Static } from "elysia";
 | 
			
		||||
import type { Kysely } from "kysely";
 | 
			
		||||
import type { db } from "dbtype";
 | 
			
		||||
import { AdminOnly, Permission } from "../permission/permission.ts";
 | 
			
		||||
import { get_setting, updatePersistedSetting, type PersistedSettingUpdate } from "../SettingConfig.ts";
 | 
			
		||||
 | 
			
		||||
const permissionOptions = Object.values(Permission).sort() as string[];
 | 
			
		||||
 | 
			
		||||
const updateBodySchema = t.Object({
 | 
			
		||||
	secure: t.Optional(t.Boolean()),
 | 
			
		||||
	cli: t.Optional(t.Boolean()),
 | 
			
		||||
	forbid_remote_admin_login: t.Optional(t.Boolean()),
 | 
			
		||||
	guest: t.Optional(t.Array(t.Enum(Permission))),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
type UpdateBody = Static<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", () => 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;
 | 
			
		||||
| 
						 | 
				
			
			@ -5,16 +5,18 @@ import { html } from "@elysiajs/html";
 | 
			
		|||
 | 
			
		||||
import { connectDB } from "./database.ts";
 | 
			
		||||
import { createDiffRouter, DiffManager } from "./diff/mod.ts";
 | 
			
		||||
import { get_setting } from "./SettingConfig.ts";
 | 
			
		||||
import { get_setting, initializeSetting } from "./SettingConfig.ts";
 | 
			
		||||
 | 
			
		||||
import { readFileSync } from "node:fs";
 | 
			
		||||
import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts";
 | 
			
		||||
import { createLoginRouter, createUserHandler, getAdmin, isAdminFirst } from "./login.ts";
 | 
			
		||||
import getContentRouter from "./route/contents.ts";
 | 
			
		||||
import { error_handler } from "./route/error_handler.ts";
 | 
			
		||||
import { createSettingsRouter } from "./route/settings.ts";
 | 
			
		||||
 | 
			
		||||
import { createInterface as createReadlineInterface } from "node:readline";
 | 
			
		||||
import { createComicWatcher } from "./diff/watcher/comic_watcher.ts";
 | 
			
		||||
import { loadComicConfig } from "./diff/watcher/ComicConfig.ts";
 | 
			
		||||
import type { DocumentAccessor, TagAccessor, UserAccessor } from "./model/mod.ts";
 | 
			
		||||
import { getTagRounter } from "./route/tags.ts";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -58,14 +60,16 @@ const normalizeError = (error: unknown): Error => {
 | 
			
		|||
 | 
			
		||||
 | 
			
		||||
export async function create_server() {
 | 
			
		||||
    const setting = get_setting();
 | 
			
		||||
    const db = await connectDB();
 | 
			
		||||
    await initializeSetting(db);
 | 
			
		||||
    const setting = get_setting();
 | 
			
		||||
 | 
			
		||||
    const userController = createSqliteUserController(db);
 | 
			
		||||
    const documentController = createSqliteDocumentAccessor(db);
 | 
			
		||||
    const tagController = createSqliteTagController(db);
 | 
			
		||||
    const diffManger = new DiffManager(documentController);
 | 
			
		||||
    diffManger.register("comic", createComicWatcher());
 | 
			
		||||
    const comicConfig = await loadComicConfig(db);
 | 
			
		||||
    await diffManger.register("comic", createComicWatcher(comicConfig.watch));
 | 
			
		||||
 | 
			
		||||
    if (setting.cli) {
 | 
			
		||||
        const userAdmin = await getAdmin(userController);
 | 
			
		||||
| 
						 | 
				
			
			@ -107,6 +111,7 @@ export async function create_server() {
 | 
			
		|||
            .use(createDiffRouter(diffManger))
 | 
			
		||||
            .use(getContentRouter(documentController))
 | 
			
		||||
            .use(getTagRounter(tagController))
 | 
			
		||||
            .use(createSettingsRouter(db))
 | 
			
		||||
            .use(createLoginRouter(userController))
 | 
			
		||||
        )
 | 
			
		||||
        .get("/doc/:id", async ({ params: { id }, set }) => {
 | 
			
		||||
| 
						 | 
				
			
			@ -137,7 +142,7 @@ export async function create_server() {
 | 
			
		|||
        .get("/setting", () => index_html)
 | 
			
		||||
        .get("/tags", () => index_html)
 | 
			
		||||
        .listen({
 | 
			
		||||
            hostname: setting.localmode ? "127.0.0.1" : "0.0.0.0",
 | 
			
		||||
            hostname: setting.hostname,
 | 
			
		||||
            port: setting.port,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,11 +1,7 @@
 | 
			
		|||
import { Elysia } from "elysia";
 | 
			
		||||
import { get_setting } from "./SettingConfig.ts";
 | 
			
		||||
import { connectDB } from "./database.ts";
 | 
			
		||||
import type { Kysely } from "kysely";
 | 
			
		||||
import type { DB } from "dbtype/src/types.ts";
 | 
			
		||||
 | 
			
		||||
export const SettingPlugin = new Elysia({
 | 
			
		||||
    name: "setting",
 | 
			
		||||
    seed: "ServerConfig"
 | 
			
		||||
})
 | 
			
		||||
    .state("setting", get_setting());
 | 
			
		||||
	name: "setting",
 | 
			
		||||
	seed: "ServerConfig",
 | 
			
		||||
}).derive(() => ({ setting: get_setting() }));
 | 
			
		||||
| 
						 | 
				
			
			@ -1,46 +1 @@
 | 
			
		|||
import { existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs";
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
export {};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										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"],
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										200
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										200
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -215,9 +215,6 @@ importers:
 | 
			
		|||
      '@types/better-sqlite3':
 | 
			
		||||
        specifier: ^7.6.13
 | 
			
		||||
        version: 7.6.13
 | 
			
		||||
      '@types/koa-compose':
 | 
			
		||||
        specifier: ^3.2.8
 | 
			
		||||
        version: 3.2.8
 | 
			
		||||
      '@types/node':
 | 
			
		||||
        specifier: ^22.15.33
 | 
			
		||||
        version: 22.15.33
 | 
			
		||||
| 
						 | 
				
			
			@ -230,6 +227,9 @@ importers:
 | 
			
		|||
      typescript:
 | 
			
		||||
        specifier: ^5.8.3
 | 
			
		||||
        version: 5.8.3
 | 
			
		||||
      vitest:
 | 
			
		||||
        specifier: ^2.1.3
 | 
			
		||||
        version: 2.1.9(@types/node@22.15.33)
 | 
			
		||||
 | 
			
		||||
packages:
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1426,27 +1426,12 @@ packages:
 | 
			
		|||
  '@ts-morph/common@0.19.0':
 | 
			
		||||
    resolution: {integrity: sha512-Unz/WHmd4pGax91rdIKWi51wnVUW11QttMEPpBiBgIewnc9UQIX7UDLxr5vRlqeByXCwhkF6VabSsI0raWcyAQ==}
 | 
			
		||||
 | 
			
		||||
  '@types/accepts@1.3.7':
 | 
			
		||||
    resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
 | 
			
		||||
 | 
			
		||||
  '@types/better-sqlite3@7.6.13':
 | 
			
		||||
    resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
 | 
			
		||||
 | 
			
		||||
  '@types/better-sqlite3@7.6.9':
 | 
			
		||||
    resolution: {integrity: sha512-FvktcujPDj9XKMJQWFcl2vVl7OdRIqsSRX9b0acWwTmwLK9CF2eqo/FRcmMLNpugKoX/avA6pb7TorDLmpgTnQ==}
 | 
			
		||||
 | 
			
		||||
  '@types/body-parser@1.19.6':
 | 
			
		||||
    resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
 | 
			
		||||
 | 
			
		||||
  '@types/connect@3.4.38':
 | 
			
		||||
    resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
 | 
			
		||||
 | 
			
		||||
  '@types/content-disposition@0.5.9':
 | 
			
		||||
    resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==}
 | 
			
		||||
 | 
			
		||||
  '@types/cookies@0.9.1':
 | 
			
		||||
    resolution: {integrity: sha512-E/DPgzifH4sM1UMadJMWd6mO2jOd4g1Ejwzx8/uRCDpJis1IrlyQEcGAYEomtAqRYmD5ORbNXMeI9U0RiVGZbg==}
 | 
			
		||||
 | 
			
		||||
  '@types/d3-array@3.2.1':
 | 
			
		||||
    resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1480,30 +1465,6 @@ packages:
 | 
			
		|||
  '@types/estree@1.0.8':
 | 
			
		||||
    resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
 | 
			
		||||
 | 
			
		||||
  '@types/express-serve-static-core@5.0.6':
 | 
			
		||||
    resolution: {integrity: sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==}
 | 
			
		||||
 | 
			
		||||
  '@types/express@5.0.3':
 | 
			
		||||
    resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==}
 | 
			
		||||
 | 
			
		||||
  '@types/http-assert@1.5.6':
 | 
			
		||||
    resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==}
 | 
			
		||||
 | 
			
		||||
  '@types/http-errors@2.0.5':
 | 
			
		||||
    resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
 | 
			
		||||
 | 
			
		||||
  '@types/keygrip@1.0.6':
 | 
			
		||||
    resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==}
 | 
			
		||||
 | 
			
		||||
  '@types/koa-compose@3.2.8':
 | 
			
		||||
    resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==}
 | 
			
		||||
 | 
			
		||||
  '@types/koa@2.15.0':
 | 
			
		||||
    resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==}
 | 
			
		||||
 | 
			
		||||
  '@types/mime@1.3.5':
 | 
			
		||||
    resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
 | 
			
		||||
 | 
			
		||||
  '@types/node@22.15.30':
 | 
			
		||||
    resolution: {integrity: sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1516,12 +1477,6 @@ packages:
 | 
			
		|||
  '@types/prop-types@15.7.14':
 | 
			
		||||
    resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==}
 | 
			
		||||
 | 
			
		||||
  '@types/qs@6.14.0':
 | 
			
		||||
    resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
 | 
			
		||||
 | 
			
		||||
  '@types/range-parser@1.2.7':
 | 
			
		||||
    resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
 | 
			
		||||
 | 
			
		||||
  '@types/react-dom@18.3.7':
 | 
			
		||||
    resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
| 
						 | 
				
			
			@ -1530,12 +1485,6 @@ packages:
 | 
			
		|||
  '@types/react@18.3.23':
 | 
			
		||||
    resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==}
 | 
			
		||||
 | 
			
		||||
  '@types/send@0.17.5':
 | 
			
		||||
    resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==}
 | 
			
		||||
 | 
			
		||||
  '@types/serve-static@1.15.8':
 | 
			
		||||
    resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==}
 | 
			
		||||
 | 
			
		||||
  '@types/tiny-async-pool@1.0.5':
 | 
			
		||||
    resolution: {integrity: sha512-8hqr+s4rBthBtb+k02NXejl7BGVbj7CD/ZB2rMSvoSjXO52aXbtm9q/JEey5uDjzADs/zXEo7bU9iX+M6glAUA==}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -4443,10 +4392,6 @@ snapshots:
 | 
			
		|||
      mkdirp: 2.1.6
 | 
			
		||||
      path-browserify: 1.0.1
 | 
			
		||||
 | 
			
		||||
  '@types/accepts@1.3.7':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/node': 22.15.33
 | 
			
		||||
 | 
			
		||||
  '@types/better-sqlite3@7.6.13':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/node': 22.15.33
 | 
			
		||||
| 
						 | 
				
			
			@ -4455,24 +4400,6 @@ snapshots:
 | 
			
		|||
    dependencies:
 | 
			
		||||
      '@types/node': 24.0.4
 | 
			
		||||
 | 
			
		||||
  '@types/body-parser@1.19.6':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/connect': 3.4.38
 | 
			
		||||
      '@types/node': 22.15.33
 | 
			
		||||
 | 
			
		||||
  '@types/connect@3.4.38':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/node': 22.15.33
 | 
			
		||||
 | 
			
		||||
  '@types/content-disposition@0.5.9': {}
 | 
			
		||||
 | 
			
		||||
  '@types/cookies@0.9.1':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/connect': 3.4.38
 | 
			
		||||
      '@types/express': 5.0.3
 | 
			
		||||
      '@types/keygrip': 1.0.6
 | 
			
		||||
      '@types/node': 22.15.33
 | 
			
		||||
 | 
			
		||||
  '@types/d3-array@3.2.1': {}
 | 
			
		||||
 | 
			
		||||
  '@types/d3-color@3.1.3': {}
 | 
			
		||||
| 
						 | 
				
			
			@ -4501,42 +4428,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  '@types/estree@1.0.8': {}
 | 
			
		||||
 | 
			
		||||
  '@types/express-serve-static-core@5.0.6':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/node': 22.15.33
 | 
			
		||||
      '@types/qs': 6.14.0
 | 
			
		||||
      '@types/range-parser': 1.2.7
 | 
			
		||||
      '@types/send': 0.17.5
 | 
			
		||||
 | 
			
		||||
  '@types/express@5.0.3':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/body-parser': 1.19.6
 | 
			
		||||
      '@types/express-serve-static-core': 5.0.6
 | 
			
		||||
      '@types/serve-static': 1.15.8
 | 
			
		||||
 | 
			
		||||
  '@types/http-assert@1.5.6': {}
 | 
			
		||||
 | 
			
		||||
  '@types/http-errors@2.0.5': {}
 | 
			
		||||
 | 
			
		||||
  '@types/keygrip@1.0.6': {}
 | 
			
		||||
 | 
			
		||||
  '@types/koa-compose@3.2.8':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/koa': 2.15.0
 | 
			
		||||
 | 
			
		||||
  '@types/koa@2.15.0':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/accepts': 1.3.7
 | 
			
		||||
      '@types/content-disposition': 0.5.9
 | 
			
		||||
      '@types/cookies': 0.9.1
 | 
			
		||||
      '@types/http-assert': 1.5.6
 | 
			
		||||
      '@types/http-errors': 2.0.5
 | 
			
		||||
      '@types/keygrip': 1.0.6
 | 
			
		||||
      '@types/koa-compose': 3.2.8
 | 
			
		||||
      '@types/node': 22.15.33
 | 
			
		||||
 | 
			
		||||
  '@types/mime@1.3.5': {}
 | 
			
		||||
 | 
			
		||||
  '@types/node@22.15.30':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      undici-types: 6.21.0
 | 
			
		||||
| 
						 | 
				
			
			@ -4551,10 +4442,6 @@ snapshots:
 | 
			
		|||
 | 
			
		||||
  '@types/prop-types@15.7.14': {}
 | 
			
		||||
 | 
			
		||||
  '@types/qs@6.14.0': {}
 | 
			
		||||
 | 
			
		||||
  '@types/range-parser@1.2.7': {}
 | 
			
		||||
 | 
			
		||||
  '@types/react-dom@18.3.7(@types/react@18.3.23)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/react': 18.3.23
 | 
			
		||||
| 
						 | 
				
			
			@ -4564,17 +4451,6 @@ snapshots:
 | 
			
		|||
      '@types/prop-types': 15.7.14
 | 
			
		||||
      csstype: 3.1.3
 | 
			
		||||
 | 
			
		||||
  '@types/send@0.17.5':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/mime': 1.3.5
 | 
			
		||||
      '@types/node': 22.15.33
 | 
			
		||||
 | 
			
		||||
  '@types/serve-static@1.15.8':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/http-errors': 2.0.5
 | 
			
		||||
      '@types/node': 22.15.33
 | 
			
		||||
      '@types/send': 0.17.5
 | 
			
		||||
 | 
			
		||||
  '@types/tiny-async-pool@1.0.5': {}
 | 
			
		||||
 | 
			
		||||
  '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3)':
 | 
			
		||||
| 
						 | 
				
			
			@ -4683,6 +4559,14 @@ snapshots:
 | 
			
		|||
    optionalDependencies:
 | 
			
		||||
      vite: 5.4.19(@types/node@22.15.30)
 | 
			
		||||
 | 
			
		||||
  '@vitest/mocker@2.1.9(vite@5.4.19(@types/node@22.15.33))':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@vitest/spy': 2.1.9
 | 
			
		||||
      estree-walker: 3.0.3
 | 
			
		||||
      magic-string: 0.30.17
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      vite: 5.4.19(@types/node@22.15.33)
 | 
			
		||||
 | 
			
		||||
  '@vitest/pretty-format@2.1.9':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      tinyrainbow: 1.2.0
 | 
			
		||||
| 
						 | 
				
			
			@ -6377,6 +6261,24 @@ snapshots:
 | 
			
		|||
      - supports-color
 | 
			
		||||
      - terser
 | 
			
		||||
 | 
			
		||||
  vite-node@2.1.9(@types/node@22.15.33):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      cac: 6.7.14
 | 
			
		||||
      debug: 4.4.1
 | 
			
		||||
      es-module-lexer: 1.7.0
 | 
			
		||||
      pathe: 1.1.2
 | 
			
		||||
      vite: 5.4.19(@types/node@22.15.33)
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - '@types/node'
 | 
			
		||||
      - less
 | 
			
		||||
      - lightningcss
 | 
			
		||||
      - sass
 | 
			
		||||
      - sass-embedded
 | 
			
		||||
      - stylus
 | 
			
		||||
      - sugarss
 | 
			
		||||
      - supports-color
 | 
			
		||||
      - terser
 | 
			
		||||
 | 
			
		||||
  vite@5.4.19(@types/node@22.15.30):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      esbuild: 0.21.5
 | 
			
		||||
| 
						 | 
				
			
			@ -6386,6 +6288,15 @@ snapshots:
 | 
			
		|||
      '@types/node': 22.15.30
 | 
			
		||||
      fsevents: 2.3.3
 | 
			
		||||
 | 
			
		||||
  vite@5.4.19(@types/node@22.15.33):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      esbuild: 0.21.5
 | 
			
		||||
      postcss: 8.5.4
 | 
			
		||||
      rollup: 4.42.0
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      '@types/node': 22.15.33
 | 
			
		||||
      fsevents: 2.3.3
 | 
			
		||||
 | 
			
		||||
  vitest@2.1.9(@types/node@22.15.30):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@vitest/expect': 2.1.9
 | 
			
		||||
| 
						 | 
				
			
			@ -6421,6 +6332,41 @@ snapshots:
 | 
			
		|||
      - supports-color
 | 
			
		||||
      - terser
 | 
			
		||||
 | 
			
		||||
  vitest@2.1.9(@types/node@22.15.33):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@vitest/expect': 2.1.9
 | 
			
		||||
      '@vitest/mocker': 2.1.9(vite@5.4.19(@types/node@22.15.33))
 | 
			
		||||
      '@vitest/pretty-format': 2.1.9
 | 
			
		||||
      '@vitest/runner': 2.1.9
 | 
			
		||||
      '@vitest/snapshot': 2.1.9
 | 
			
		||||
      '@vitest/spy': 2.1.9
 | 
			
		||||
      '@vitest/utils': 2.1.9
 | 
			
		||||
      chai: 5.2.0
 | 
			
		||||
      debug: 4.4.1
 | 
			
		||||
      expect-type: 1.2.1
 | 
			
		||||
      magic-string: 0.30.17
 | 
			
		||||
      pathe: 1.1.2
 | 
			
		||||
      std-env: 3.9.0
 | 
			
		||||
      tinybench: 2.9.0
 | 
			
		||||
      tinyexec: 0.3.2
 | 
			
		||||
      tinypool: 1.1.0
 | 
			
		||||
      tinyrainbow: 1.2.0
 | 
			
		||||
      vite: 5.4.19(@types/node@22.15.33)
 | 
			
		||||
      vite-node: 2.1.9(@types/node@22.15.33)
 | 
			
		||||
      why-is-node-running: 2.3.0
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      '@types/node': 22.15.33
 | 
			
		||||
    transitivePeerDependencies:
 | 
			
		||||
      - less
 | 
			
		||||
      - lightningcss
 | 
			
		||||
      - msw
 | 
			
		||||
      - sass
 | 
			
		||||
      - sass-embedded
 | 
			
		||||
      - stylus
 | 
			
		||||
      - sugarss
 | 
			
		||||
      - supports-color
 | 
			
		||||
      - terser
 | 
			
		||||
 | 
			
		||||
  wcwidth@1.0.1:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      defaults: 1.0.4
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue