feat: add app configuration management with Kysely integration
- Implemented `getAppConfig` and `upsertAppConfig` functions in `config.ts` for managing application settings in the database. - Updated `mod.ts` to export the new configuration functions. - Refactored `ComicConfig.ts` to load and update comic watch paths using the new configuration functions. - Modified `comic_watcher.ts` to accept paths as parameters for creating watchers. - Created a new settings router in `settings.ts` for managing application settings via HTTP requests. - Integrated the settings router into the main server in `server.ts`. - Updated the settings management to use the new database-backed configuration. - Removed legacy configuration management code from `configRW.ts`. - Added integration tests for the settings router and error handling. - Updated `vitest` configuration for testing. - Cleaned up unused type definitions in `pnpm-lock.yaml`.
This commit is contained in:
parent
018e2e998b
commit
d28c255d21
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