feat!: use elysia js intead of koa				#18
		
		
	
					 19 changed files with 1034 additions and 1719 deletions
				
			
		| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
{
 | 
			
		||||
	"name": "followed",
 | 
			
		||||
	"name": "server",
 | 
			
		||||
	"version": "1.0.0",
 | 
			
		||||
	"description": "",
 | 
			
		||||
	"main": "build/app.js",
 | 
			
		||||
| 
						 | 
				
			
			@ -12,28 +12,24 @@
 | 
			
		|||
	"author": "",
 | 
			
		||||
	"license": "ISC",
 | 
			
		||||
	"dependencies": {
 | 
			
		||||
		"@elysiajs/cors": "^1.3.3",
 | 
			
		||||
		"@elysiajs/html": "^1.3.1",
 | 
			
		||||
		"@elysiajs/static": "^1.3.0",
 | 
			
		||||
		"@std/async": "npm:@jsr/std__async@^1.0.13",
 | 
			
		||||
		"@zip.js/zip.js": "^2.7.62",
 | 
			
		||||
		"better-sqlite3": "^9.6.0",
 | 
			
		||||
		"chokidar": "^3.6.0",
 | 
			
		||||
		"dbtype": "workspace:dbtype",
 | 
			
		||||
		"dotenv": "^16.5.0",
 | 
			
		||||
		"elysia": "^1.3.20",
 | 
			
		||||
		"jose": "^5.10.0",
 | 
			
		||||
		"koa": "^2.16.1",
 | 
			
		||||
		"koa-bodyparser": "^4.4.1",
 | 
			
		||||
		"koa-compose": "^4.1.0",
 | 
			
		||||
		"koa-router": "^12.0.1",
 | 
			
		||||
		"kysely": "^0.27.6",
 | 
			
		||||
		"natural-orderby": "^2.0.3",
 | 
			
		||||
		"tiny-async-pool": "^1.3.0"
 | 
			
		||||
	},
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@types/better-sqlite3": "^7.6.13",
 | 
			
		||||
		"@types/jsonwebtoken": "^8.5.9",
 | 
			
		||||
		"@types/koa": "^2.15.0",
 | 
			
		||||
		"@types/koa-bodyparser": "^4.3.12",
 | 
			
		||||
		"@types/koa-compose": "^3.2.8",
 | 
			
		||||
		"@types/koa-router": "^7.4.8",
 | 
			
		||||
		"@types/node": "^22.15.33",
 | 
			
		||||
		"@types/tiny-async-pool": "^1.0.5",
 | 
			
		||||
		"tsx": "^4.20.3",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,5 @@
 | 
			
		|||
import { create_server } from "./server.ts";
 | 
			
		||||
 | 
			
		||||
create_server().then((server) => {
 | 
			
		||||
	server.start_server();
 | 
			
		||||
}).catch((err) => {
 | 
			
		||||
create_server().catch((err) => {
 | 
			
		||||
	console.error(err);
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										18
									
								
								packages/server/src/controller.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								packages/server/src/controller.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,18 @@
 | 
			
		|||
import Elysia from "elysia";
 | 
			
		||||
import { connectDB } from "./database.ts";
 | 
			
		||||
import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts";
 | 
			
		||||
 | 
			
		||||
export async function createControllers() {
 | 
			
		||||
    const db = await connectDB();
 | 
			
		||||
 | 
			
		||||
    const userController = createSqliteUserController(db);
 | 
			
		||||
    const documentController = createSqliteDocumentAccessor(db);
 | 
			
		||||
    const tagController = createSqliteTagController(db);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        userController,
 | 
			
		||||
        documentController,
 | 
			
		||||
        tagController
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1,85 +1,57 @@
 | 
			
		|||
import type Koa from "koa";
 | 
			
		||||
import Router from "koa-router";
 | 
			
		||||
import type { ContentFile } from "../content/mod.ts";
 | 
			
		||||
import { AdminOnlyMiddleware } from "../permission/permission.ts";
 | 
			
		||||
import { sendError } from "../route/error_handler.ts";
 | 
			
		||||
import { Elysia, t } from "elysia";
 | 
			
		||||
import type { DiffManager } from "./diff.ts";
 | 
			
		||||
import type { ContentFile } from "../content/mod.ts";
 | 
			
		||||
import { AdminOnly } from "../permission/permission.ts";
 | 
			
		||||
import { sendError } from "../route/error_handler.ts";
 | 
			
		||||
 | 
			
		||||
function content_file_to_return(x: ContentFile) {
 | 
			
		||||
	return { path: x.path, type: x.type };
 | 
			
		||||
}
 | 
			
		||||
const toSerializableContent = (file: ContentFile) => ({ path: file.path, type: file.type });
 | 
			
		||||
 | 
			
		||||
export const getAdded = (diffmgr: DiffManager) => (ctx: Koa.Context, next: Koa.Next) => {
 | 
			
		||||
	const ret = diffmgr.getAdded();
 | 
			
		||||
	ctx.body = ret.map((x) => ({
 | 
			
		||||
		type: x.type,
 | 
			
		||||
		value: x.value.map((x) => ({ path: x.path, type: x.type })),
 | 
			
		||||
	}));
 | 
			
		||||
	ctx.type = "json";
 | 
			
		||||
};
 | 
			
		||||
const CommitEntrySchema = t.Array(t.Object({
 | 
			
		||||
	type: t.String(),
 | 
			
		||||
	path: t.String(),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
type PostAddedBody = {
 | 
			
		||||
	type: string;
 | 
			
		||||
	path: string;
 | 
			
		||||
}[];
 | 
			
		||||
const CommitAllSchema = t.Object({
 | 
			
		||||
	type: t.String(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function checkPostAddedBody(body: unknown): body is PostAddedBody {
 | 
			
		||||
	if (Array.isArray(body)) {
 | 
			
		||||
		return body.map((x) => "type" in x && "path" in x).every((x) => x);
 | 
			
		||||
	}
 | 
			
		||||
	return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const postAdded = (diffmgr: DiffManager) => async (ctx: Router.IRouterContext, next: Koa.Next) => {
 | 
			
		||||
	const reqbody = ctx.request.body;
 | 
			
		||||
	if (!checkPostAddedBody(reqbody)) {
 | 
			
		||||
		sendError(400, "format exception");
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	const allWork = reqbody.map((op) => diffmgr.commit(op.type, op.path));
 | 
			
		||||
	const results = await Promise.all(allWork);
 | 
			
		||||
	ctx.body = {
 | 
			
		||||
		ok: true,
 | 
			
		||||
		docs: results,
 | 
			
		||||
	};
 | 
			
		||||
	ctx.type = "json";
 | 
			
		||||
	await next();
 | 
			
		||||
};
 | 
			
		||||
export const postAddedAll = (diffmgr: DiffManager) => async (ctx: Router.IRouterContext, next: Koa.Next) => {
 | 
			
		||||
	if (!ctx.is("json")) {
 | 
			
		||||
		sendError(400, "format exception");
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	const reqbody = ctx.request.body as Record<string, unknown>;
 | 
			
		||||
	if (!("type" in reqbody)) {
 | 
			
		||||
		sendError(400, 'format exception: there is no "type"');
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	const t = reqbody.type;
 | 
			
		||||
	if (typeof t !== "string") {
 | 
			
		||||
		sendError(400, 'format exception: invalid type of "type"');
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	await diffmgr.commitAll(t);
 | 
			
		||||
	ctx.body = {
 | 
			
		||||
		ok: true,
 | 
			
		||||
	};
 | 
			
		||||
	ctx.type = "json";
 | 
			
		||||
	await next();
 | 
			
		||||
};
 | 
			
		||||
/*
 | 
			
		||||
export const getNotWatched = (diffmgr : DiffManager)=> (ctx:Router.IRouterContext,next:Koa.Next)=>{
 | 
			
		||||
    ctx.body = {
 | 
			
		||||
        added: diffmgr.added.map(content_file_to_return),
 | 
			
		||||
        deleted: diffmgr.deleted.map(content_file_to_return),
 | 
			
		||||
    };
 | 
			
		||||
    ctx.type = 'json';
 | 
			
		||||
}*/
 | 
			
		||||
 | 
			
		||||
export function createDiffRouter(diffmgr: DiffManager) {
 | 
			
		||||
	const ret = new Router();
 | 
			
		||||
	ret.get("/list", AdminOnlyMiddleware, getAdded(diffmgr));
 | 
			
		||||
	ret.post("/commit", AdminOnlyMiddleware, postAdded(diffmgr));
 | 
			
		||||
	ret.post("/commitall", AdminOnlyMiddleware, postAddedAll(diffmgr));
 | 
			
		||||
	return ret;
 | 
			
		||||
}
 | 
			
		||||
export const createDiffRouter = (diffmgr: DiffManager) =>
 | 
			
		||||
	new Elysia({ name: "diff-router" })
 | 
			
		||||
		.group("/diff", (app) =>
 | 
			
		||||
			app
 | 
			
		||||
				.get("/list", () => {
 | 
			
		||||
					return diffmgr.getAdded().map((entry) => ({
 | 
			
		||||
						type: entry.type,
 | 
			
		||||
						value: entry.value.map(toSerializableContent),
 | 
			
		||||
					}));
 | 
			
		||||
				}, {
 | 
			
		||||
					beforeHandle: AdminOnly,
 | 
			
		||||
				})
 | 
			
		||||
				.post("/commit", async ({ body }) => {
 | 
			
		||||
					if (body.length === 0) {
 | 
			
		||||
						return { ok: true, docs: [] as number[] };
 | 
			
		||||
					}
 | 
			
		||||
					const results = await Promise.all(body.map(({ type, path }) => diffmgr.commit(type, path)));
 | 
			
		||||
					return {
 | 
			
		||||
						ok: true,
 | 
			
		||||
						docs: results,
 | 
			
		||||
					};
 | 
			
		||||
				}, {
 | 
			
		||||
					beforeHandle: AdminOnly,
 | 
			
		||||
					body: CommitEntrySchema,
 | 
			
		||||
				})
 | 
			
		||||
				.post("/commitall", async ({ body }) => {
 | 
			
		||||
					const { type } = body;
 | 
			
		||||
					if (!type) {
 | 
			
		||||
						sendError(400, 'format exception: there is no "type"');
 | 
			
		||||
					}
 | 
			
		||||
					await diffmgr.commitAll(type);
 | 
			
		||||
					return { ok: true };
 | 
			
		||||
				}, {
 | 
			
		||||
					beforeHandle: AdminOnly,
 | 
			
		||||
					body: CommitAllSchema,
 | 
			
		||||
				})
 | 
			
		||||
				.get("/*", () => {
 | 
			
		||||
					sendError(404);
 | 
			
		||||
				})
 | 
			
		||||
			);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import { ConfigManager } from "../../util/configRW.ts";
 | 
			
		||||
import ComicSchema from "./ComicConfig.schema.json" assert { type: "json" };
 | 
			
		||||
import ComicSchema from "./ComicConfig.schema.json" with { type: "json" };
 | 
			
		||||
export interface ComicConfig {
 | 
			
		||||
	watch: string[];
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,19 +1,8 @@
 | 
			
		|||
import { Elysia, t, type Context } from "elysia";
 | 
			
		||||
import { SignJWT, jwtVerify, errors } from "jose";
 | 
			
		||||
import type Koa from "koa";
 | 
			
		||||
import Router from "koa-router";
 | 
			
		||||
import type { IUser, UserAccessor } from "./model/mod.ts";
 | 
			
		||||
import { sendError } from "./route/error_handler.ts";
 | 
			
		||||
import { ClientRequestError } from "./route/error_handler.ts";
 | 
			
		||||
import { get_setting } from "./SettingConfig.ts";
 | 
			
		||||
import { LoginRequestSchema, LoginResetRequestSchema } from "dbtype";
 | 
			
		||||
 | 
			
		||||
type LoginResponse = {
 | 
			
		||||
	accessExpired: number;
 | 
			
		||||
} & PayloadInfo;
 | 
			
		||||
 | 
			
		||||
type RefreshResponse = {
 | 
			
		||||
	accessExpired: number;
 | 
			
		||||
	refresh: boolean;
 | 
			
		||||
} & PayloadInfo;
 | 
			
		||||
 | 
			
		||||
type PayloadInfo = {
 | 
			
		||||
	username: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -24,18 +13,41 @@ export type UserState = {
 | 
			
		|||
	user: PayloadInfo;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const isUserState = (obj: object | string): obj is PayloadInfo => {
 | 
			
		||||
	if (typeof obj === "string") return false;
 | 
			
		||||
	return "username" in obj && "permission" in obj && Array.isArray((obj as { permission: unknown }).permission);
 | 
			
		||||
type AuthStore = {
 | 
			
		||||
	user: PayloadInfo;
 | 
			
		||||
	refreshed: boolean;
 | 
			
		||||
	authenticated: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type LoginResponse = {
 | 
			
		||||
	accessExpired: number;
 | 
			
		||||
} & PayloadInfo;
 | 
			
		||||
 | 
			
		||||
type RefreshResponse = {
 | 
			
		||||
	accessExpired: number;
 | 
			
		||||
	refresh: boolean;
 | 
			
		||||
} & PayloadInfo;
 | 
			
		||||
 | 
			
		||||
type RefreshPayloadInfo = { username: string };
 | 
			
		||||
const isRefreshToken = (obj: object | string): obj is RefreshPayloadInfo => {
 | 
			
		||||
	if (typeof obj === "string") return false;
 | 
			
		||||
	return "username" in obj && typeof (obj as { username: unknown }).username === "string";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const accessExpiredTime = 60 * 60 * 2; // 2 hour
 | 
			
		||||
type CookieJar = Context["cookie"];
 | 
			
		||||
 | 
			
		||||
const LoginBodySchema = t.Object({
 | 
			
		||||
	username: t.String(),
 | 
			
		||||
	password: t.String(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const ResetBodySchema = t.Object({
 | 
			
		||||
	username: t.String(),
 | 
			
		||||
	oldpassword: t.String(),
 | 
			
		||||
	newpassword: t.String(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const SettingsBodySchema = t.Record(t.String(), t.Unknown());
 | 
			
		||||
 | 
			
		||||
const accessExpiredTime = 60 * 60 * 2; // 2 hours
 | 
			
		||||
const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 days
 | 
			
		||||
 | 
			
		||||
async function createAccessToken(payload: PayloadInfo, secret: string) {
 | 
			
		||||
	return await new SignJWT(payload)
 | 
			
		||||
		.setProtectedHeader({ alg: "HS256" })
 | 
			
		||||
| 
						 | 
				
			
			@ -43,7 +55,6 @@ async function createAccessToken(payload: PayloadInfo, secret: string) {
 | 
			
		|||
		.sign(new TextEncoder().encode(secret));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 day;
 | 
			
		||||
async function createRefreshToken(payload: RefreshPayloadInfo, secret: string) {
 | 
			
		||||
	return await new SignJWT(payload)
 | 
			
		||||
		.setProtectedHeader({ alg: "HS256" })
 | 
			
		||||
| 
						 | 
				
			
			@ -57,10 +68,10 @@ class TokenExpiredError extends Error {
 | 
			
		|||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function verifyToken(token: string, secret: string) {
 | 
			
		||||
async function verifyToken<T>(token: string, secret: string): Promise<T> {
 | 
			
		||||
	try {
 | 
			
		||||
		const { payload } = await jwtVerify(token, new TextEncoder().encode(secret));
 | 
			
		||||
		return payload as PayloadInfo;
 | 
			
		||||
		return payload as T;
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		if (error instanceof errors.JWTExpired) {
 | 
			
		||||
			throw new TokenExpiredError();
 | 
			
		||||
| 
						 | 
				
			
			@ -72,241 +83,245 @@ async function verifyToken(token: string, secret: string) {
 | 
			
		|||
export const accessTokenName = "access_token";
 | 
			
		||||
export const refreshTokenName = "refresh_token";
 | 
			
		||||
 | 
			
		||||
function setToken(ctx: Koa.Context, token_name: string, token_payload: string | null, expiredtime: number) {
 | 
			
		||||
	const setting = get_setting();
 | 
			
		||||
	if (token_payload === null && !ctx.cookies.get(token_name)) {
 | 
			
		||||
function setToken(cookie: CookieJar, token_name: string, token_payload: string | null, expiredSeconds: number) {
 | 
			
		||||
	if (token_payload === null) {
 | 
			
		||||
		cookie[token_name]?.remove();
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	ctx.cookies.set(token_name, token_payload, {
 | 
			
		||||
	const setting = get_setting();
 | 
			
		||||
	cookie[token_name].set({
 | 
			
		||||
		value: token_payload,
 | 
			
		||||
		httpOnly: true,
 | 
			
		||||
		secure: setting.secure,
 | 
			
		||||
		sameSite: "strict",
 | 
			
		||||
		expires: new Date(Date.now() + expiredtime * 1000),
 | 
			
		||||
		expires: new Date(Date.now() + expiredSeconds * 1000),
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const createLoginHandler = (userController: UserAccessor) => async (ctx: Koa.Context, _next: Koa.Next) => {
 | 
			
		||||
	const setting = get_setting();
 | 
			
		||||
	const secretKey = setting.jwt_secretkey;
 | 
			
		||||
	const body = ctx.request.body;
 | 
			
		||||
	const {
 | 
			
		||||
		username,
 | 
			
		||||
		password,
 | 
			
		||||
	} = LoginRequestSchema.parse(body);
 | 
			
		||||
	// if admin login is forbidden?
 | 
			
		||||
	if (username === "admin" && setting.forbid_remote_admin_login) {
 | 
			
		||||
		return sendError(403, "forbidden remote admin login");
 | 
			
		||||
const isUserState = (obj: unknown): obj is PayloadInfo => {
 | 
			
		||||
	if (typeof obj !== "object" || obj === null) {
 | 
			
		||||
		return false;
 | 
			
		||||
	}
 | 
			
		||||
	const user = await userController.findUser(username);
 | 
			
		||||
	// username not exist
 | 
			
		||||
	if (user === undefined) return sendError(401, "not authorized");
 | 
			
		||||
	// password not matched
 | 
			
		||||
	if (!user.password.check_password(password)) {
 | 
			
		||||
		return sendError(401, "not authorized");
 | 
			
		||||
	}
 | 
			
		||||
	// create token
 | 
			
		||||
	const userPermission = await user.get_permissions();
 | 
			
		||||
	const payload = await createAccessToken({
 | 
			
		||||
		username: user.username,
 | 
			
		||||
		permission: userPermission,
 | 
			
		||||
	}, secretKey);
 | 
			
		||||
	const payload2 = await createRefreshToken({
 | 
			
		||||
		username: user.username,
 | 
			
		||||
	}, secretKey);
 | 
			
		||||
	setToken(ctx, accessTokenName, payload, accessExpiredTime);
 | 
			
		||||
	setToken(ctx, refreshTokenName, payload2, refreshExpiredTime);
 | 
			
		||||
	ctx.body = {
 | 
			
		||||
		username: user.username,
 | 
			
		||||
		permission: userPermission,
 | 
			
		||||
		accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime,
 | 
			
		||||
	} satisfies LoginResponse;
 | 
			
		||||
	console.log(`${username} logined`);
 | 
			
		||||
	return;
 | 
			
		||||
	return "username" in obj && "permission" in obj && Array.isArray((obj as { permission: unknown }).permission);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const LogoutHandler = (ctx: Koa.Context, _next: Koa.Next) => {
 | 
			
		||||
const isRefreshToken = (obj: unknown): obj is RefreshPayloadInfo => {
 | 
			
		||||
	if (typeof obj !== "object" || obj === null) {
 | 
			
		||||
		return false;
 | 
			
		||||
	}
 | 
			
		||||
	return "username" in obj && typeof (obj as { username: unknown }).username === "string";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type AuthResult = {
 | 
			
		||||
	user: PayloadInfo;
 | 
			
		||||
	refreshed: boolean;
 | 
			
		||||
	success: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
async function authenticate(
 | 
			
		||||
	cookie: CookieJar,
 | 
			
		||||
	userController: UserAccessor,
 | 
			
		||||
	options: { forceRefresh?: boolean } = {},
 | 
			
		||||
): Promise<AuthResult> {
 | 
			
		||||
	const setting = get_setting();
 | 
			
		||||
	ctx.cookies.set(accessTokenName, null);
 | 
			
		||||
	ctx.cookies.set(refreshTokenName, null);
 | 
			
		||||
	ctx.body = {
 | 
			
		||||
		ok: true,
 | 
			
		||||
	const secretKey = setting.jwt_secretkey;
 | 
			
		||||
	const accessCookie = cookie[accessTokenName];
 | 
			
		||||
	const refreshCookie = cookie[refreshTokenName];
 | 
			
		||||
	const accessValue = accessCookie?.value;
 | 
			
		||||
	const refreshValue = refreshCookie?.value;
 | 
			
		||||
 | 
			
		||||
	const guestUser: PayloadInfo = {
 | 
			
		||||
		username: "",
 | 
			
		||||
		permission: setting.guest,
 | 
			
		||||
	};
 | 
			
		||||
	return;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createUserHandler =
 | 
			
		||||
	(userController: UserAccessor) => async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
 | 
			
		||||
		const refreshToken = makeRefreshToken(userController);
 | 
			
		||||
		const setting = get_setting();
 | 
			
		||||
		const setGuest = async () => {
 | 
			
		||||
			setToken(ctx, accessTokenName, null, 0);
 | 
			
		||||
			setToken(ctx, refreshTokenName, null, 0);
 | 
			
		||||
			ctx.state.user = { username: "", permission: setting.guest };
 | 
			
		||||
			return await next();
 | 
			
		||||
	const setGuest = (): AuthResult => {
 | 
			
		||||
		accessCookie?.remove();
 | 
			
		||||
		refreshCookie?.remove();
 | 
			
		||||
		return { user: guestUser, refreshed: false, success: false };
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const issueAccessForUser = async (username: string): Promise<AuthResult> => {
 | 
			
		||||
		const account = await userController.findUser(username);
 | 
			
		||||
		if (!account) {
 | 
			
		||||
			return setGuest();
 | 
			
		||||
		}
 | 
			
		||||
		const permissions = await account.get_permissions();
 | 
			
		||||
		const payload: PayloadInfo = {
 | 
			
		||||
			username: account.username,
 | 
			
		||||
			permission: permissions,
 | 
			
		||||
		};
 | 
			
		||||
		return await refreshToken(ctx, setGuest, next);
 | 
			
		||||
		const accessToken = await createAccessToken(payload, secretKey);
 | 
			
		||||
		setToken(cookie, accessTokenName, accessToken, accessExpiredTime);
 | 
			
		||||
		return { user: payload, refreshed: true, success: true };
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
const makeRefreshToken = (cntr: UserAccessor) => async (ctx: Koa.Context, fail: Koa.Next, next: Koa.Next) => {
 | 
			
		||||
	const accessPayload = ctx.cookies.get(accessTokenName);
 | 
			
		||||
	const setting = get_setting();
 | 
			
		||||
	const secretKey = setting.jwt_secretkey;
 | 
			
		||||
 | 
			
		||||
	if (!accessPayload) {
 | 
			
		||||
		return await checkRefreshAndUpdate();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	try {
 | 
			
		||||
		const payload = await verifyToken(accessPayload, secretKey);
 | 
			
		||||
		if (isUserState(payload)) {
 | 
			
		||||
			ctx.state.user = payload;
 | 
			
		||||
			return await next();
 | 
			
		||||
	const tryRefresh = async (): Promise<AuthResult> => {
 | 
			
		||||
		if (!refreshValue) {
 | 
			
		||||
			return setGuest();
 | 
			
		||||
		}
 | 
			
		||||
		console.error("Invalid token detected");
 | 
			
		||||
		throw new Error("Token form invalid");
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		if (error instanceof TokenExpiredError) {
 | 
			
		||||
			return await checkRefreshAndUpdate();
 | 
			
		||||
		}
 | 
			
		||||
		throw error;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async function checkRefreshAndUpdate() {
 | 
			
		||||
		const refreshPayload = ctx.cookies.get(refreshTokenName);
 | 
			
		||||
		if (!refreshPayload) {
 | 
			
		||||
			return await fail(); // Refresh token doesn't exist
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			const payload = await verifyToken(refreshPayload, secretKey);
 | 
			
		||||
			if (isRefreshToken(payload)) {
 | 
			
		||||
				const user = await cntr.findUser(payload.username);
 | 
			
		||||
				if (!user) return await fail(); // User does not exist
 | 
			
		||||
 | 
			
		||||
				const permissions = await user.get_permissions();
 | 
			
		||||
				const newAccessToken = await createAccessToken({
 | 
			
		||||
					username: user.username,
 | 
			
		||||
					permission: permissions,
 | 
			
		||||
				}, secretKey);
 | 
			
		||||
 | 
			
		||||
				setToken(ctx, accessTokenName, newAccessToken, accessExpiredTime);
 | 
			
		||||
				ctx.state.user = { username: payload.username, permission: permissions };
 | 
			
		||||
			} else {
 | 
			
		||||
				console.error("Invalid token detected");
 | 
			
		||||
				throw new Error("Token form invalid");
 | 
			
		||||
			const payload = await verifyToken<RefreshPayloadInfo>(refreshValue, secretKey);
 | 
			
		||||
			if (!isRefreshToken(payload)) {
 | 
			
		||||
				return setGuest();
 | 
			
		||||
			}
 | 
			
		||||
			return await issueAccessForUser(payload.username);
 | 
			
		||||
		} catch {
 | 
			
		||||
			return setGuest();
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	if (options.forceRefresh) {
 | 
			
		||||
		if (accessValue) {
 | 
			
		||||
			try {
 | 
			
		||||
				const payload = await verifyToken<PayloadInfo>(accessValue, secretKey);
 | 
			
		||||
				if (isUserState(payload)) {
 | 
			
		||||
					const accessToken = await createAccessToken(payload, secretKey);
 | 
			
		||||
					setToken(cookie, accessTokenName, accessToken, accessExpiredTime);
 | 
			
		||||
					return { user: payload, refreshed: true, success: true };
 | 
			
		||||
				}
 | 
			
		||||
				return setGuest();
 | 
			
		||||
			} catch (error) {
 | 
			
		||||
				if (!(error instanceof TokenExpiredError)) {
 | 
			
		||||
					return setGuest();
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return await tryRefresh();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (accessValue) {
 | 
			
		||||
		try {
 | 
			
		||||
			const payload = await verifyToken<PayloadInfo>(accessValue, secretKey);
 | 
			
		||||
			if (isUserState(payload)) {
 | 
			
		||||
				return { user: payload, refreshed: false, success: true };
 | 
			
		||||
			}
 | 
			
		||||
			return setGuest();
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			if (error instanceof TokenExpiredError) {
 | 
			
		||||
				// Refresh token is expired
 | 
			
		||||
				return await fail();
 | 
			
		||||
			if (!(error instanceof TokenExpiredError)) {
 | 
			
		||||
				return setGuest();
 | 
			
		||||
			}
 | 
			
		||||
			throw error;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return await next();
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => {
 | 
			
		||||
	const handler = makeRefreshToken(cntr);
 | 
			
		||||
	await handler(ctx, fail, success);
 | 
			
		||||
	async function fail() {
 | 
			
		||||
		const user = ctx.state.user as PayloadInfo;
 | 
			
		||||
		ctx.body = {
 | 
			
		||||
			refresh: false,
 | 
			
		||||
			accessExpired: 0,
 | 
			
		||||
			...user,
 | 
			
		||||
		} satisfies RefreshResponse;
 | 
			
		||||
		ctx.type = "json";
 | 
			
		||||
	};
 | 
			
		||||
	async function success() {
 | 
			
		||||
		const user = ctx.state.user as PayloadInfo;
 | 
			
		||||
		ctx.body = {
 | 
			
		||||
			...user,
 | 
			
		||||
			refresh: true,
 | 
			
		||||
			accessExpired: Math.floor(Date.now() / 1000 + accessExpiredTime),
 | 
			
		||||
		} satisfies RefreshResponse;
 | 
			
		||||
		ctx.type = "json";
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export const resetPasswordMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => {
 | 
			
		||||
	const body = ctx.request.body;
 | 
			
		||||
	const {
 | 
			
		||||
		username,
 | 
			
		||||
		oldpassword,
 | 
			
		||||
		newpassword,
 | 
			
		||||
	} = LoginResetRequestSchema.parse(body);
 | 
			
		||||
	const user = await cntr.findUser(username);
 | 
			
		||||
	if (user === undefined) {
 | 
			
		||||
		return sendError(403, "not authorized");
 | 
			
		||||
	}
 | 
			
		||||
	if (!user.password.check_password(oldpassword)) {
 | 
			
		||||
		return sendError(403, "not authorized");
 | 
			
		||||
	}
 | 
			
		||||
	user.reset_password(newpassword);
 | 
			
		||||
	ctx.body = { ok: true };
 | 
			
		||||
	ctx.type = "json";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function getUserSettingHandler(userController: UserAccessor) {
 | 
			
		||||
	return async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
 | 
			
		||||
		const username = ctx.state.user.username;
 | 
			
		||||
		if (!username) {
 | 
			
		||||
			return sendError(403, "not authorized");
 | 
			
		||||
		}
 | 
			
		||||
		const user = await userController.findUser(username);
 | 
			
		||||
		if (user === undefined) {
 | 
			
		||||
			return sendError(403, "not authorized");
 | 
			
		||||
		}
 | 
			
		||||
		const settings = await user.get_settings();
 | 
			
		||||
		if (settings === undefined) {
 | 
			
		||||
			ctx.body = {};
 | 
			
		||||
			ctx.type = "json";
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		ctx.body = settings;
 | 
			
		||||
		ctx.type = "json";
 | 
			
		||||
		await next();
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
export function setUserSettingHandler(userController: UserAccessor) {
 | 
			
		||||
	return async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
 | 
			
		||||
		const username = ctx.state.user.username;
 | 
			
		||||
		if (!username) {
 | 
			
		||||
			return sendError(403, "not authorized");
 | 
			
		||||
		}
 | 
			
		||||
		const user = await userController.findUser(username);
 | 
			
		||||
		if (user === undefined) {
 | 
			
		||||
			return sendError(403, "not authorized");
 | 
			
		||||
		}
 | 
			
		||||
		const body = ctx.request.body;
 | 
			
		||||
		const settings = body as Record<string, unknown>;
 | 
			
		||||
		await user.set_settings(settings);
 | 
			
		||||
		ctx.body = { ok: true };
 | 
			
		||||
		ctx.type = "json";
 | 
			
		||||
		await next();
 | 
			
		||||
	};
 | 
			
		||||
	return await tryRefresh();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function createLoginRouter(userController: UserAccessor) {
 | 
			
		||||
	const router = new Router();
 | 
			
		||||
	router.post("/login", createLoginHandler(userController));
 | 
			
		||||
	router.post("/logout", LogoutHandler);
 | 
			
		||||
	router.post("/refresh", createRefreshTokenMiddleware(userController));
 | 
			
		||||
	router.post("/reset", resetPasswordMiddleware(userController));
 | 
			
		||||
	router.get("/settings", getUserSettingHandler(userController));
 | 
			
		||||
	router.post("/settings", setUserSettingHandler(userController));
 | 
			
		||||
	return router;
 | 
			
		||||
}
 | 
			
		||||
export const createLoginRouter = (userController: UserAccessor) => {
 | 
			
		||||
	return new Elysia({ name: "login-router" })
 | 
			
		||||
		.group("/user", (app) =>
 | 
			
		||||
			app
 | 
			
		||||
				.post("/login", async ({ body, cookie, set }) => {
 | 
			
		||||
					const setting = get_setting();
 | 
			
		||||
					const secretKey = setting.jwt_secretkey;
 | 
			
		||||
					const { username, password } = body;
 | 
			
		||||
 | 
			
		||||
					if (username === "admin" && setting.forbid_remote_admin_login) {
 | 
			
		||||
						throw new ClientRequestError(403, "forbidden remote admin login");
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					const user = await userController.findUser(username);
 | 
			
		||||
					if (!user || !user.password.check_password(password)) {
 | 
			
		||||
						throw new ClientRequestError(401, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					const permission = await user.get_permissions();
 | 
			
		||||
					const accessToken = await createAccessToken({ username: user.username, permission }, secretKey);
 | 
			
		||||
					const refreshToken = await createRefreshToken({ username: user.username }, secretKey);
 | 
			
		||||
 | 
			
		||||
					setToken(cookie, accessTokenName, accessToken, accessExpiredTime);
 | 
			
		||||
					setToken(cookie, refreshTokenName, refreshToken, refreshExpiredTime);
 | 
			
		||||
 | 
			
		||||
					set.status = 200;
 | 
			
		||||
 | 
			
		||||
					return {
 | 
			
		||||
						username: user.username,
 | 
			
		||||
						permission,
 | 
			
		||||
						accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime,
 | 
			
		||||
					} satisfies LoginResponse;
 | 
			
		||||
				}, {
 | 
			
		||||
					body: LoginBodySchema,
 | 
			
		||||
				})
 | 
			
		||||
				.post("/logout", ({ cookie, set }) => {
 | 
			
		||||
					const setting = get_setting();
 | 
			
		||||
					setToken(cookie, accessTokenName, null, 0);
 | 
			
		||||
					setToken(cookie, refreshTokenName, null, 0);
 | 
			
		||||
					set.status = 200;
 | 
			
		||||
					return {
 | 
			
		||||
						ok: true,
 | 
			
		||||
						username: "",
 | 
			
		||||
						permission: setting.guest,
 | 
			
		||||
					};
 | 
			
		||||
				})
 | 
			
		||||
				.post("/refresh", async ({ cookie }) => {
 | 
			
		||||
					const auth = await authenticate(cookie, userController, { forceRefresh: true });
 | 
			
		||||
					if (!auth.success) {
 | 
			
		||||
						throw new ClientRequestError(401, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
					return {
 | 
			
		||||
						...auth.user,
 | 
			
		||||
						refresh: true,
 | 
			
		||||
						accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime,
 | 
			
		||||
					} satisfies RefreshResponse;
 | 
			
		||||
				})
 | 
			
		||||
				.post("/reset", async ({ body }) => {
 | 
			
		||||
					const { username, oldpassword, newpassword } = body;
 | 
			
		||||
					const account = await userController.findUser(username);
 | 
			
		||||
					if (!account || !account.password.check_password(oldpassword)) {
 | 
			
		||||
						throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
					await account.reset_password(newpassword);
 | 
			
		||||
					return { ok: true };
 | 
			
		||||
				}, {
 | 
			
		||||
					body: ResetBodySchema,
 | 
			
		||||
				})
 | 
			
		||||
				.get("/settings", async ({ store }) => {
 | 
			
		||||
					const { user } = store as AuthStore;
 | 
			
		||||
					if (!user.username) {
 | 
			
		||||
						throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
					const account = await userController.findUser(user.username);
 | 
			
		||||
					if (!account) {
 | 
			
		||||
						throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
					return (await account.get_settings()) ?? {};
 | 
			
		||||
				})
 | 
			
		||||
				.post("/settings", async ({ body, store }) => {
 | 
			
		||||
					const { user } = store as AuthStore;
 | 
			
		||||
					if (!user.username) {
 | 
			
		||||
						throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
					const account = await userController.findUser(user.username);
 | 
			
		||||
					if (!account) {
 | 
			
		||||
						throw new ClientRequestError(403, "not authorized");
 | 
			
		||||
					}
 | 
			
		||||
					await account.set_settings(body as Record<string, unknown>);
 | 
			
		||||
					return { ok: true };
 | 
			
		||||
				}, {
 | 
			
		||||
					body: SettingsBodySchema,
 | 
			
		||||
				}),
 | 
			
		||||
		);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createUserHandler = (userController: UserAccessor) => {
 | 
			
		||||
	return new Elysia({
 | 
			
		||||
		name: "user-handler",
 | 
			
		||||
		seed: "UserAccess",
 | 
			
		||||
	})
 | 
			
		||||
		.derive({ as: "scoped" }, async ({ cookie }) => {
 | 
			
		||||
			const auth = await authenticate(cookie, userController);
 | 
			
		||||
			return {
 | 
			
		||||
				user: auth.user,
 | 
			
		||||
				refreshed: auth.refreshed,
 | 
			
		||||
				authenticated: auth.success,
 | 
			
		||||
			};
 | 
			
		||||
		});
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getAdmin = async (cntr: UserAccessor) => {
 | 
			
		||||
	const admin = await cntr.findUser("admin");
 | 
			
		||||
	if (admin === undefined) {
 | 
			
		||||
		throw new Error("initial process failed!"); // ???
 | 
			
		||||
		throw new Error("initial process failed!");
 | 
			
		||||
	}
 | 
			
		||||
	return admin;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,3 @@
 | 
			
		|||
import { check_type } from "../util/type_check.ts";
 | 
			
		||||
import type {
 | 
			
		||||
	DocumentBody, 
 | 
			
		||||
	QueryListOption,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,5 @@
 | 
			
		|||
import type Koa from "koa";
 | 
			
		||||
import type { UserState } from "../login.ts";
 | 
			
		||||
import { sendError } from "../route/error_handler.ts";
 | 
			
		||||
import type { UserState } from "../login.ts";
 | 
			
		||||
 | 
			
		||||
export enum Permission {
 | 
			
		||||
	// ========
 | 
			
		||||
| 
						 | 
				
			
			@ -34,27 +33,36 @@ export enum Permission {
 | 
			
		|||
	modifyTagDesc = "ModifyTagDesc",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const createPermissionCheckMiddleware =
 | 
			
		||||
	(...permissions: string[]) =>
 | 
			
		||||
		async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
 | 
			
		||||
			const user = ctx.state.user;
 | 
			
		||||
			if (user.username === "admin") {
 | 
			
		||||
				return await next();
 | 
			
		||||
			}
 | 
			
		||||
			const user_permission = user.permission;
 | 
			
		||||
			// if permissions is not subset of user permission
 | 
			
		||||
			if (!permissions.map((p) => user_permission.includes(p)).every((x) => x)) {
 | 
			
		||||
				if (user.username === "") {
 | 
			
		||||
					return sendError(401, "you are guest. login needed.");
 | 
			
		||||
				} return sendError(403, "do not have permission");
 | 
			
		||||
			}
 | 
			
		||||
			await next();
 | 
			
		||||
		};
 | 
			
		||||
type PermissionCheckContext = {
 | 
			
		||||
	user?: UserState["user"];
 | 
			
		||||
	store?: { user?: UserState["user"] };
 | 
			
		||||
} & Record<string, unknown>;
 | 
			
		||||
 | 
			
		||||
export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
 | 
			
		||||
	const user = ctx.state.user;
 | 
			
		||||
	if (user.username !== "admin") {
 | 
			
		||||
		return sendError(403, "admin only");
 | 
			
		||||
const resolveUser = (context: PermissionCheckContext): UserState["user"] => {
 | 
			
		||||
	const user = context.user ?? context.store?.user;
 | 
			
		||||
	if (!user) {
 | 
			
		||||
		sendError(401, "you are guest. login needed.");
 | 
			
		||||
	}
 | 
			
		||||
	await next();
 | 
			
		||||
	return user as UserState["user"];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createPermissionCheck = (...permissions: string[]) => (context: PermissionCheckContext) => {
 | 
			
		||||
    const user = resolveUser(context);
 | 
			
		||||
    if (user.username === "admin") {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
    const user_permission = user.permission;
 | 
			
		||||
    if (!permissions.every((p) => user_permission.includes(p))) {
 | 
			
		||||
        if (user.username === "") {
 | 
			
		||||
            throw sendError(401, "you are guest. login needed.");
 | 
			
		||||
        }
 | 
			
		||||
        throw sendError(403, "do not have permission");
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const AdminOnly = (context: PermissionCheckContext) => {
 | 
			
		||||
	const user = resolveUser(context);
 | 
			
		||||
    if (user.username !== "admin") {
 | 
			
		||||
        throw sendError(403, "admin only");
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,63 +0,0 @@
 | 
			
		|||
import type { DefaultContext, Middleware, Next, ParameterizedContext } from "koa";
 | 
			
		||||
import compose from "koa-compose";
 | 
			
		||||
import Router from "koa-router";
 | 
			
		||||
import ComicRouter from "./comic.ts";
 | 
			
		||||
import type { ContentContext } from "./context.ts";
 | 
			
		||||
import VideoRouter from "./video.ts";
 | 
			
		||||
 | 
			
		||||
const table: { [s: string]: Router | undefined } = {
 | 
			
		||||
	comic: new ComicRouter(),
 | 
			
		||||
	video: new VideoRouter(),
 | 
			
		||||
};
 | 
			
		||||
const all_middleware =
 | 
			
		||||
	(cont: string | undefined, restarg: string | undefined) =>
 | 
			
		||||
	async (ctx: ParameterizedContext<ContentContext, DefaultContext>, next: Next) => {
 | 
			
		||||
		if (cont === undefined) {
 | 
			
		||||
			ctx.status = 404;
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		if (ctx.state.location === undefined) {
 | 
			
		||||
			ctx.status = 404;
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		
 | 
			
		||||
		if (ctx.state.location.type !== cont) {
 | 
			
		||||
			console.error("not matched");
 | 
			
		||||
			ctx.status = 404;
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		const router = table[cont];
 | 
			
		||||
		if (router === undefined) {
 | 
			
		||||
			ctx.status = 404;
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		const rest = `/${restarg ?? ""}`;
 | 
			
		||||
		const result = router.match(rest, "GET");
 | 
			
		||||
		if (!result.route) {
 | 
			
		||||
			return await next();
 | 
			
		||||
		}
 | 
			
		||||
		// biome-ignore lint/suspicious/noExplicitAny: <explanation>
 | 
			
		||||
		const chain = result.pathAndMethod.reduce((combination: Middleware<any & DefaultContext, any>[], cur) => {
 | 
			
		||||
			combination.push(async (ctx, next) => {
 | 
			
		||||
				const captures = cur.captures(rest);
 | 
			
		||||
				ctx.params = cur.params(rest, captures);
 | 
			
		||||
				ctx.request.params = ctx.params;
 | 
			
		||||
				ctx.routerPath = cur.path;
 | 
			
		||||
				return await next();
 | 
			
		||||
			});
 | 
			
		||||
			return combination.concat(cur.stack);
 | 
			
		||||
		}, []);
 | 
			
		||||
		return await compose(chain)(ctx, next);
 | 
			
		||||
	};
 | 
			
		||||
export class AllContentRouter extends Router<ContentContext> {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		this.get("/:content_type", async (ctx, next) => {
 | 
			
		||||
			return await all_middleware(ctx.params.content_type, undefined)(ctx, next);
 | 
			
		||||
		});
 | 
			
		||||
		this.get("/:content_type/:rest(.*)", async (ctx, next) => {
 | 
			
		||||
			const cont = ctx.params.content_type as string;
 | 
			
		||||
			return await all_middleware(cont, ctx.params.rest)(ctx, next);
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,72 +1,90 @@
 | 
			
		|||
import type { Context } from "koa";
 | 
			
		||||
import Router from "koa-router";
 | 
			
		||||
import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap.ts";
 | 
			
		||||
import type { ContentContext } from "./context.ts";
 | 
			
		||||
import { since_last_modified } from "./util.ts";
 | 
			
		||||
import type { Context as ElysiaContext } from "elysia";
 | 
			
		||||
import { Readable } from "node:stream";
 | 
			
		||||
import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap.ts";
 | 
			
		||||
 | 
			
		||||
const imageExtensions = new Set(["gif", "png", "jpeg", "bmp", "webp", "jpg", "avif"]);
 | 
			
		||||
 | 
			
		||||
async function renderZipImage(ctx: Context, path: string, page: number) {
 | 
			
		||||
	const image_ext = ["gif", "png", "jpeg", "bmp", "webp", "jpg", "avif"];
 | 
			
		||||
const extensionToMime = (ext: string) => {
 | 
			
		||||
	if (ext === "jpg") return "image/jpeg";
 | 
			
		||||
	return `image/${ext}`;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ResponseSet = Pick<ElysiaContext["set"], "status" | "headers">;
 | 
			
		||||
 | 
			
		||||
type RenderOptions = {
 | 
			
		||||
	path: string;
 | 
			
		||||
	page: number;
 | 
			
		||||
	reqHeaders: Headers;
 | 
			
		||||
	set: ResponseSet;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export async function renderComicPage({ path, page, reqHeaders, set }: RenderOptions) {
 | 
			
		||||
	const zip = await readZip(path);
 | 
			
		||||
	const entries = (await entriesByNaturalOrder(zip.reader)).filter((x) => {
 | 
			
		||||
		const ext = x.filename.split(".").pop();
 | 
			
		||||
		return ext !== undefined && image_ext.includes(ext);
 | 
			
		||||
	});
 | 
			
		||||
	if (0 <= page && page < entries.length) {
 | 
			
		||||
		const entry = entries[page];
 | 
			
		||||
		const last_modified = entry.lastModDate;
 | 
			
		||||
		if (since_last_modified(ctx, last_modified)) {
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		const read_stream = await createReadableStreamFromZip(zip.reader, entry);
 | 
			
		||||
		const nodeReadableStream = new Readable();
 | 
			
		||||
		nodeReadableStream._read = () => { };
 | 
			
		||||
 | 
			
		||||
		read_stream.pipeTo(new WritableStream({
 | 
			
		||||
	try {
 | 
			
		||||
		const entries = (await entriesByNaturalOrder(zip.reader)).filter((entry) => {
 | 
			
		||||
			const ext = entry.filename.split(".").pop()?.toLowerCase();
 | 
			
		||||
			return ext !== undefined && imageExtensions.has(ext);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (page < 0 || page >= entries.length) {
 | 
			
		||||
			set.status = 404;
 | 
			
		||||
			zip.reader.close();
 | 
			
		||||
			return null;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const entry = entries[page];
 | 
			
		||||
		const lastModified = entry.lastModDate ?? new Date();
 | 
			
		||||
		const ifModifiedSince = reqHeaders.get("if-modified-since");
 | 
			
		||||
 | 
			
		||||
		const headers = (set.headers ??= {} as Record<string, string | number>);
 | 
			
		||||
		headers["Date"] = new Date().toUTCString();
 | 
			
		||||
		headers["Last-Modified"] = lastModified.toUTCString();
 | 
			
		||||
 | 
			
		||||
		if (ifModifiedSince) {
 | 
			
		||||
			const cachedDate = new Date(ifModifiedSince);
 | 
			
		||||
			if (!Number.isNaN(cachedDate.valueOf()) && lastModified <= cachedDate) {
 | 
			
		||||
				set.status = 304;
 | 
			
		||||
				zip.reader.close();
 | 
			
		||||
				return null;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const readStream = await createReadableStreamFromZip(zip.reader, entry);
 | 
			
		||||
		const nodeReadable = new Readable({
 | 
			
		||||
			read() {
 | 
			
		||||
				// noop
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		readStream.pipeTo(new WritableStream({
 | 
			
		||||
			write(chunk) {
 | 
			
		||||
				nodeReadableStream.push(chunk);
 | 
			
		||||
				nodeReadable.push(chunk);
 | 
			
		||||
			},
 | 
			
		||||
			close() {
 | 
			
		||||
				nodeReadableStream.push(null);
 | 
			
		||||
				nodeReadable.push(null);
 | 
			
		||||
			},
 | 
			
		||||
		}));
 | 
			
		||||
		nodeReadableStream.on("error", (err) => {
 | 
			
		||||
			console.error("readalbe stream error",err);
 | 
			
		||||
			ctx.status = 500;
 | 
			
		||||
			ctx.body = "Internal Server Error";
 | 
			
		||||
			zip.reader.close();
 | 
			
		||||
			return;
 | 
			
		||||
		})).catch((err) => {
 | 
			
		||||
			nodeReadable.destroy(err);
 | 
			
		||||
		});
 | 
			
		||||
		nodeReadableStream.on("close", () => {
 | 
			
		||||
 | 
			
		||||
		nodeReadable.on("close", () => {
 | 
			
		||||
			zip.reader.close();
 | 
			
		||||
		});
 | 
			
		||||
		nodeReadable.on("error", () => {
 | 
			
		||||
			zip.reader.close();
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		ctx.body = nodeReadableStream;
 | 
			
		||||
		ctx.response.length = entry.uncompressedSize;
 | 
			
		||||
		ctx.response.type = entry.filename.split(".").pop() as string;
 | 
			
		||||
		ctx.status = 200;
 | 
			
		||||
		ctx.set("Date", new Date().toUTCString());
 | 
			
		||||
		ctx.set("Last-Modified", last_modified.toUTCString());
 | 
			
		||||
	} else {
 | 
			
		||||
		ctx.status = 404;
 | 
			
		||||
		const ext = entry.filename.split(".").pop()?.toLowerCase() ?? "jpeg";
 | 
			
		||||
		headers["Content-Type"] = extensionToMime(ext);
 | 
			
		||||
		if (typeof entry.uncompressedSize === "number") {
 | 
			
		||||
			headers["Content-Length"] = entry.uncompressedSize.toString();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		set.status = 200;
 | 
			
		||||
		return nodeReadable;
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		zip.reader.close();
 | 
			
		||||
		throw error;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class ComicRouter extends Router<ContentContext> {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		this.get("/", async (ctx, next) => {
 | 
			
		||||
			await renderZipImage(ctx, ctx.state.location.path, 0);
 | 
			
		||||
		});
 | 
			
		||||
		this.get("/:page(\\d+)", async (ctx, next) => {
 | 
			
		||||
			const page = Number.parseInt(ctx.params.page);
 | 
			
		||||
			await renderZipImage(ctx, ctx.state.location.path, page);
 | 
			
		||||
		});
 | 
			
		||||
		this.get("/thumbnail", async (ctx, next) => {
 | 
			
		||||
			await renderZipImage(ctx, ctx.state.location.path, 0);
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default ComicRouter;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,248 +1,193 @@
 | 
			
		|||
import type { Context, Next } from "koa";
 | 
			
		||||
import Router from "koa-router";
 | 
			
		||||
import { Elysia, t } from "elysia";
 | 
			
		||||
import { join } from "node:path";
 | 
			
		||||
import type {
 | 
			
		||||
	Document,
 | 
			
		||||
	QueryListOption,
 | 
			
		||||
} from "dbtype";
 | 
			
		||||
import type { Document, QueryListOption } from "dbtype";
 | 
			
		||||
import type { DocumentAccessor } from "../model/doc.ts";
 | 
			
		||||
import {
 | 
			
		||||
	AdminOnlyMiddleware as AdminOnly,
 | 
			
		||||
	createPermissionCheckMiddleware as PerCheck,
 | 
			
		||||
	Permission as Per,
 | 
			
		||||
} from "../permission/permission.ts";
 | 
			
		||||
import { AllContentRouter } from "./all.ts";
 | 
			
		||||
import type { ContentLocation } from "./context.ts";
 | 
			
		||||
import { AdminOnly, createPermissionCheck, Permission as Per } from "../permission/permission.ts";
 | 
			
		||||
import { sendError } from "./error_handler.ts";
 | 
			
		||||
import { ParseQueryArgString, ParseQueryArray, ParseQueryBoolean, ParseQueryNumber } from "./util.ts";
 | 
			
		||||
import { oshash } from "src/util/oshash.ts";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
const ContentIDHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
 | 
			
		||||
	const num = Number.parseInt(ctx.params.num);
 | 
			
		||||
	const document = await controller.findById(num, true);
 | 
			
		||||
	if (document === undefined) {
 | 
			
		||||
		return sendError(404, "document does not exist.");
 | 
			
		||||
	}
 | 
			
		||||
	ctx.body = document;
 | 
			
		||||
	ctx.type = "json";
 | 
			
		||||
};
 | 
			
		||||
const ContentTagIDHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
 | 
			
		||||
	const num = Number.parseInt(ctx.params.num);
 | 
			
		||||
	const document = await controller.findById(num, true);
 | 
			
		||||
	if (document === undefined) {
 | 
			
		||||
		return sendError(404, "document does not exist.");
 | 
			
		||||
	}
 | 
			
		||||
	ctx.body = document.tags;
 | 
			
		||||
	ctx.type = "json";
 | 
			
		||||
};
 | 
			
		||||
const ContentQueryHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
 | 
			
		||||
	
 | 
			
		||||
	const query_limit = ctx.query.limit;
 | 
			
		||||
	const query_cursor = ctx.query.cursor;
 | 
			
		||||
	const query_word = ctx.query.word;
 | 
			
		||||
	const query_content_type = ctx.query.content_type;
 | 
			
		||||
	const query_offset = ctx.query.offset;
 | 
			
		||||
	const query_use_offset = ctx.query.use_offset;
 | 
			
		||||
	if ([
 | 
			
		||||
		query_limit,
 | 
			
		||||
		query_cursor,
 | 
			
		||||
		query_word,
 | 
			
		||||
		query_content_type,
 | 
			
		||||
		query_offset,
 | 
			
		||||
		query_use_offset,
 | 
			
		||||
	].some((x) => Array.isArray(x))) {
 | 
			
		||||
		return sendError(400, "paramter can not be array");
 | 
			
		||||
	}
 | 
			
		||||
	const limit = Math.min(ParseQueryNumber(query_limit) ?? 20, 100);
 | 
			
		||||
	const cursor = ParseQueryNumber(query_cursor);
 | 
			
		||||
	const word = ParseQueryArgString(query_word);
 | 
			
		||||
	const content_type = ParseQueryArgString(query_content_type);
 | 
			
		||||
	const offset = ParseQueryNumber(query_offset);
 | 
			
		||||
	if (Number.isNaN(limit) || Number.isNaN(cursor) || Number.isNaN(offset)) {
 | 
			
		||||
		return sendError(400, "parameter limit, cursor or offset is not a number");
 | 
			
		||||
	}
 | 
			
		||||
	const allow_tag = ParseQueryArray(ctx.query.allow_tag);
 | 
			
		||||
	const [ok, use_offset] = ParseQueryBoolean(query_use_offset);
 | 
			
		||||
	if (!ok) {
 | 
			
		||||
		return sendError(400, "use_offset must be true or false.");
 | 
			
		||||
	}
 | 
			
		||||
	const option: QueryListOption = {
 | 
			
		||||
		limit: limit,
 | 
			
		||||
		allow_tag: allow_tag,
 | 
			
		||||
		word: word,
 | 
			
		||||
		cursor: cursor,
 | 
			
		||||
		eager_loading: true,
 | 
			
		||||
		offset: offset,
 | 
			
		||||
		use_offset: use_offset ?? false,
 | 
			
		||||
		content_type: content_type,
 | 
			
		||||
	};
 | 
			
		||||
	const document = await controller.findList(option);
 | 
			
		||||
	ctx.body = document;
 | 
			
		||||
	ctx.type = "json";
 | 
			
		||||
};
 | 
			
		||||
const UpdateContentHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
 | 
			
		||||
	const num = Number.parseInt(ctx.params.num);
 | 
			
		||||
 | 
			
		||||
	if (ctx.request.type !== "json") {
 | 
			
		||||
		return sendError(400, "update fail. invalid document type: it is not json.");
 | 
			
		||||
	}
 | 
			
		||||
	if (typeof ctx.request.body !== "object") {
 | 
			
		||||
		return sendError(400, "update fail. invalid argument: not");
 | 
			
		||||
	}
 | 
			
		||||
	const content_desc: Partial<Document> & { id: number } = {
 | 
			
		||||
		id: num,
 | 
			
		||||
		...ctx.request.body,
 | 
			
		||||
	};
 | 
			
		||||
	const success = await controller.update(content_desc);
 | 
			
		||||
	ctx.body = JSON.stringify(success);
 | 
			
		||||
	ctx.type = "json";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const AddTagHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
 | 
			
		||||
	let tag_name = ctx.params.tag;
 | 
			
		||||
	const num = Number.parseInt(ctx.params.num);
 | 
			
		||||
	if (typeof tag_name === "undefined") {
 | 
			
		||||
		return sendError(400, "??? Unreachable");
 | 
			
		||||
	}
 | 
			
		||||
	tag_name = String(tag_name);
 | 
			
		||||
	const c = await controller.findById(num);
 | 
			
		||||
	if (c === undefined) {
 | 
			
		||||
		return sendError(404);
 | 
			
		||||
	}
 | 
			
		||||
	const r = await controller.addTag(c, tag_name);
 | 
			
		||||
	ctx.body = JSON.stringify(r);
 | 
			
		||||
	ctx.type = "json";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const DelTagHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
 | 
			
		||||
	let tag_name = ctx.params.tag;
 | 
			
		||||
	const num = Number.parseInt(ctx.params.num);
 | 
			
		||||
	if (typeof tag_name === "undefined") {
 | 
			
		||||
		return sendError(400, "?? Unreachable");
 | 
			
		||||
	}
 | 
			
		||||
	tag_name = String(tag_name);
 | 
			
		||||
	const c = await controller.findById(num);
 | 
			
		||||
	if (c === undefined) {
 | 
			
		||||
		return sendError(404);
 | 
			
		||||
	}
 | 
			
		||||
	const r = await controller.delTag(c, tag_name);
 | 
			
		||||
	ctx.body = JSON.stringify(r);
 | 
			
		||||
	ctx.type = "json";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const DeleteContentHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
 | 
			
		||||
	const num = Number.parseInt(ctx.params.num);
 | 
			
		||||
	const r = await controller.del(num);
 | 
			
		||||
	ctx.body = JSON.stringify(r);
 | 
			
		||||
	ctx.type = "json";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ContentHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
 | 
			
		||||
	const num = Number.parseInt(ctx.params.num);
 | 
			
		||||
	const document = await controller.findById(num, true);
 | 
			
		||||
	if (document === undefined) {
 | 
			
		||||
		return sendError(404, "document does not exist.");
 | 
			
		||||
	}
 | 
			
		||||
	if (document.deleted_at !== null) {
 | 
			
		||||
		return sendError(404, "document has been removed.");
 | 
			
		||||
	}
 | 
			
		||||
	const path = join(document.basepath, document.filename);
 | 
			
		||||
	ctx.state.location = {
 | 
			
		||||
		path: path,
 | 
			
		||||
		type: document.content_type,
 | 
			
		||||
		additional: document.additional,
 | 
			
		||||
	} as ContentLocation;
 | 
			
		||||
	await next();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function RehashContentHandler(controller: DocumentAccessor) {
 | 
			
		||||
	return async (ctx: Context, next: Next) => {
 | 
			
		||||
		const num = Number.parseInt(ctx.params.num);
 | 
			
		||||
		const c = await controller.findById(num);
 | 
			
		||||
		if (c === undefined || c.deleted_at !== null) {
 | 
			
		||||
			return sendError(404);
 | 
			
		||||
		}
 | 
			
		||||
		const filepath = join(c.basepath, c.filename);
 | 
			
		||||
		let new_hash: string;
 | 
			
		||||
		try {
 | 
			
		||||
			new_hash = (await oshash(filepath)).toString();
 | 
			
		||||
		}
 | 
			
		||||
		catch (e) {
 | 
			
		||||
			// if file is not found, return 404
 | 
			
		||||
			if ( (e as NodeJS.ErrnoException).code === "ENOENT") {
 | 
			
		||||
				return sendError(404, "file not found");
 | 
			
		||||
			}
 | 
			
		||||
			throw e;
 | 
			
		||||
		}
 | 
			
		||||
		const r = await controller.update({
 | 
			
		||||
			id: num,
 | 
			
		||||
			content_hash: new_hash,
 | 
			
		||||
		});
 | 
			
		||||
		ctx.body = JSON.stringify(r);
 | 
			
		||||
		ctx.type = "json";
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getSimilarDocumentHandler(controller: DocumentAccessor) {
 | 
			
		||||
	return async (ctx: Context, next: Next) => {
 | 
			
		||||
		const num = Number.parseInt(ctx.params.num);
 | 
			
		||||
		const c = await controller.findById(num, true);
 | 
			
		||||
		if (c === undefined) {
 | 
			
		||||
			return sendError(404);
 | 
			
		||||
		}
 | 
			
		||||
		const r = await controller.getSimilarDocument(c);
 | 
			
		||||
		ctx.body = r;
 | 
			
		||||
		ctx.type = "json";
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getRescanDocumentHandler(controller: DocumentAccessor) {
 | 
			
		||||
	return async (ctx: Context, next: Next) => {
 | 
			
		||||
		const num = Number.parseInt(ctx.params.num);
 | 
			
		||||
		const c = await controller.findById(num, true);
 | 
			
		||||
		if (c === undefined) {
 | 
			
		||||
			return sendError(404);
 | 
			
		||||
		}
 | 
			
		||||
		await controller.rescanDocument(c);
 | 
			
		||||
		// 204 No Content
 | 
			
		||||
		ctx.status = 204;
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ContentGidListHandler(controller: DocumentAccessor) {
 | 
			
		||||
	return async (ctx: Context, next: Next) => {
 | 
			
		||||
		const gid_list = ParseQueryArray(ctx.query.gid).map((x) => Number.parseInt(x))
 | 
			
		||||
		if (gid_list.some((x) => Number.isNaN(x))) {
 | 
			
		||||
			return sendError(400, "gid is not a number");
 | 
			
		||||
		}
 | 
			
		||||
		// size limit
 | 
			
		||||
		if (gid_list.length > 100) {
 | 
			
		||||
			return sendError(400, "gid list is too long");
 | 
			
		||||
		}
 | 
			
		||||
		const r = await controller.findByGidList(gid_list);
 | 
			
		||||
		ctx.body = r;
 | 
			
		||||
		ctx.type = "json";
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
import { renderComicPage } from "./comic.ts";
 | 
			
		||||
 | 
			
		||||
export const getContentRouter = (controller: DocumentAccessor) => {
 | 
			
		||||
	const ret = new Router();
 | 
			
		||||
	ret.get("/search", PerCheck(Per.QueryContent), ContentQueryHandler(controller));
 | 
			
		||||
	ret.get("/_gid", PerCheck(Per.QueryContent), ContentGidListHandler(controller));
 | 
			
		||||
	ret.get("/:num(\\d+)", PerCheck(Per.QueryContent), ContentIDHandler(controller));
 | 
			
		||||
	ret.all("/:num(\\d+)/(.*)", PerCheck(Per.QueryContent), ContentHandler(controller));
 | 
			
		||||
	ret.post("/:num(\\d+)", AdminOnly, UpdateContentHandler(controller));
 | 
			
		||||
	ret.del("/:num(\\d+)", AdminOnly, DeleteContentHandler(controller));
 | 
			
		||||
	// ret.post("/",AdminOnly,CreateContentHandler(controller));
 | 
			
		||||
	ret.get("/:num(\\d+)/similars", PerCheck(Per.QueryContent), getSimilarDocumentHandler(controller));
 | 
			
		||||
	ret.get("/:num(\\d+)/tags", PerCheck(Per.QueryContent), ContentTagIDHandler(controller));
 | 
			
		||||
	ret.post("/:num(\\d+)/tags/:tag", PerCheck(Per.ModifyTag), AddTagHandler(controller));
 | 
			
		||||
	ret.del("/:num(\\d+)/tags/:tag", PerCheck(Per.ModifyTag), DelTagHandler(controller));
 | 
			
		||||
	ret.use("/:num(\\d+)", PerCheck(Per.QueryContent), new AllContentRouter().routes());
 | 
			
		||||
	ret.post("/:num(\\d+)/_rehash", AdminOnly, RehashContentHandler(controller));
 | 
			
		||||
	ret.post("/:num(\\d+)/_rescan", AdminOnly, getRescanDocumentHandler(controller));
 | 
			
		||||
	return ret;
 | 
			
		||||
    return new Elysia({ name: "content-router" })
 | 
			
		||||
        .get("/search", async ({ query }) => {
 | 
			
		||||
            const limit = Math.min(Number(query.limit ?? 20), 100);
 | 
			
		||||
            const option: QueryListOption = {
 | 
			
		||||
                limit: limit,
 | 
			
		||||
                allow_tag: query.allow_tag?.split(",") ?? [],
 | 
			
		||||
                word: query.word,
 | 
			
		||||
                cursor: Number(query.cursor),
 | 
			
		||||
                eager_loading: true,
 | 
			
		||||
                offset: Number(query.offset),
 | 
			
		||||
                use_offset: query.use_offset === 'true',
 | 
			
		||||
                content_type: query.content_type,
 | 
			
		||||
            };
 | 
			
		||||
            return await controller.findList(option);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
            query: t.Object({
 | 
			
		||||
                limit: t.Optional(t.String()),
 | 
			
		||||
                cursor: t.Optional(t.String()),
 | 
			
		||||
                word: t.Optional(t.String()),
 | 
			
		||||
                content_type: t.Optional(t.String()),
 | 
			
		||||
                offset: t.Optional(t.String()),
 | 
			
		||||
                use_offset: t.Optional(t.String()),
 | 
			
		||||
                allow_tag: t.Optional(t.String()),
 | 
			
		||||
            })
 | 
			
		||||
        })
 | 
			
		||||
        .get("/_gid", async ({ query }) => {
 | 
			
		||||
            const gid_list = query.gid.split(",").map(x => Number.parseInt(x));
 | 
			
		||||
            if (gid_list.some(x => Number.isNaN(x)) || gid_list.length > 100) {
 | 
			
		||||
                throw sendError(400, "Invalid GID list");
 | 
			
		||||
            }
 | 
			
		||||
            return await controller.findByGidList(gid_list);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
            query: t.Object({ gid: t.String() })
 | 
			
		||||
        })
 | 
			
		||||
        .get("/:num", async ({ params: { num } }) => {
 | 
			
		||||
            const document = await controller.findById(num, true);
 | 
			
		||||
            if (document === undefined) {
 | 
			
		||||
                throw sendError(404, "document does not exist.");
 | 
			
		||||
            }
 | 
			
		||||
            return document;
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
            params: t.Object({ num: t.Numeric() })
 | 
			
		||||
        })
 | 
			
		||||
        .post("/:num", async ({ params: { num }, body }) => {
 | 
			
		||||
            const content_desc: Partial<Document> & { id: number } = {
 | 
			
		||||
                id: num,
 | 
			
		||||
                ...body,
 | 
			
		||||
            };
 | 
			
		||||
            return await controller.update(content_desc);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: AdminOnly,
 | 
			
		||||
            params: t.Object({ num: t.Numeric() }),
 | 
			
		||||
            body: t.Object({}, { additionalProperties: true })
 | 
			
		||||
        })
 | 
			
		||||
        .delete("/:num", async ({ params: { num } }) => {
 | 
			
		||||
            return await controller.del(num);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: AdminOnly,
 | 
			
		||||
            params: t.Object({ num: t.Numeric() })
 | 
			
		||||
        })
 | 
			
		||||
        .get("/:num/similars", async ({ params: { num } }) => {
 | 
			
		||||
            const doc = await controller.findById(num, true);
 | 
			
		||||
            if (doc === undefined) {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            return await controller.getSimilarDocument(doc);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
            params: t.Object({ num: t.Numeric() })
 | 
			
		||||
        })
 | 
			
		||||
        .get("/:num/tags", async ({ params: { num } }) => {
 | 
			
		||||
            const document = await controller.findById(num, true);
 | 
			
		||||
            if (document === undefined) {
 | 
			
		||||
                throw sendError(404, "document does not exist.");
 | 
			
		||||
            }
 | 
			
		||||
            return document.tags;
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
            params: t.Object({ num: t.Numeric() })
 | 
			
		||||
        })
 | 
			
		||||
        .post("/:num/tags/:tag", async ({ params: { num, tag } }) => {
 | 
			
		||||
            const doc = await controller.findById(num);
 | 
			
		||||
            if (doc === undefined) {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            return await controller.addTag(doc, tag);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.ModifyTag),
 | 
			
		||||
            params: t.Object({ num: t.Numeric(), tag: t.String() })
 | 
			
		||||
        })
 | 
			
		||||
        .delete("/:num/tags/:tag", async ({ params: { num, tag } }) => {
 | 
			
		||||
            const doc = await controller.findById(num);
 | 
			
		||||
            if (doc === undefined) {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            return await controller.delTag(doc, tag);
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: createPermissionCheck(Per.ModifyTag),
 | 
			
		||||
            params: t.Object({ num: t.Numeric(), tag: t.String() })
 | 
			
		||||
        })
 | 
			
		||||
        .post("/:num/_rehash", async ({ params: { num } }) => {
 | 
			
		||||
            const doc = await controller.findById(num);
 | 
			
		||||
            if (doc === undefined || doc.deleted_at !== null) {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            const filepath = join(doc.basepath, doc.filename);
 | 
			
		||||
            try {
 | 
			
		||||
                const new_hash = (await oshash(filepath)).toString();
 | 
			
		||||
                return await controller.update({ id: num, content_hash: new_hash });
 | 
			
		||||
            } catch (e) {
 | 
			
		||||
                if ((e as NodeJS.ErrnoException).code === "ENOENT") {
 | 
			
		||||
                    throw sendError(404, "file not found");
 | 
			
		||||
                }
 | 
			
		||||
                throw e;
 | 
			
		||||
            }
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: AdminOnly,
 | 
			
		||||
            params: t.Object({ num: t.Numeric() })
 | 
			
		||||
        })
 | 
			
		||||
        .post("/:num/_rescan", async ({ params: { num }, set }) => {
 | 
			
		||||
            const doc = await controller.findById(num, true);
 | 
			
		||||
            if (doc === undefined) {
 | 
			
		||||
                throw sendError(404);
 | 
			
		||||
            }
 | 
			
		||||
            await controller.rescanDocument(doc);
 | 
			
		||||
            set.status = 204; // No Content
 | 
			
		||||
        }, {
 | 
			
		||||
            beforeHandle: AdminOnly,
 | 
			
		||||
            params: t.Object({ num: t.Numeric() })
 | 
			
		||||
        })
 | 
			
		||||
        .group("/:num", (app) =>
 | 
			
		||||
            app
 | 
			
		||||
                .derive(async ({ params: { num } }) => {
 | 
			
		||||
                    const docId = typeof num === "number" ? num : Number.parseInt(String(num));
 | 
			
		||||
                    if (Number.isNaN(docId)) {
 | 
			
		||||
                        throw sendError(400, "invalid document id");
 | 
			
		||||
                    }
 | 
			
		||||
                    const document = await controller.findById(docId, true);
 | 
			
		||||
                    if (document === undefined) {
 | 
			
		||||
                        throw sendError(404, "document does not exist.");
 | 
			
		||||
                    }
 | 
			
		||||
                    return { document, docId };
 | 
			
		||||
                })
 | 
			
		||||
                .get("/comic/thumbnail", async ({ document, request, set }) => {
 | 
			
		||||
                    if (document.content_type !== "comic") {
 | 
			
		||||
                        throw sendError(404);
 | 
			
		||||
                    }
 | 
			
		||||
                    const path = join(document.basepath, document.filename);
 | 
			
		||||
                    const body = await renderComicPage({
 | 
			
		||||
                        path,
 | 
			
		||||
                        page: 0,
 | 
			
		||||
                        reqHeaders: request.headers,
 | 
			
		||||
                        set,
 | 
			
		||||
                    });
 | 
			
		||||
                    return body ?? undefined;
 | 
			
		||||
                }, {
 | 
			
		||||
                    beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
                    params: t.Object({ num: t.Numeric() }),
 | 
			
		||||
                })
 | 
			
		||||
                .get("/comic/:page", async ({ document, params: { page }, request, set }) => {
 | 
			
		||||
                    if (document.content_type !== "comic") {
 | 
			
		||||
                        throw sendError(404);
 | 
			
		||||
                    }
 | 
			
		||||
                    const pageIndex = page;
 | 
			
		||||
                    const path = join(document.basepath, document.filename);
 | 
			
		||||
                    const body = await renderComicPage({
 | 
			
		||||
                        path,
 | 
			
		||||
                        page: pageIndex,
 | 
			
		||||
                        reqHeaders: request.headers,
 | 
			
		||||
                        set,
 | 
			
		||||
                    });
 | 
			
		||||
                    return body ?? undefined;
 | 
			
		||||
                }, {
 | 
			
		||||
                    beforeHandle: createPermissionCheck(Per.QueryContent),
 | 
			
		||||
                    params: t.Object({ num: t.Numeric(), page: t.Numeric() }),
 | 
			
		||||
                })
 | 
			
		||||
        );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default getContentRouter;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,4 @@
 | 
			
		|||
import { ZodError } from "dbtype";
 | 
			
		||||
import type { Context, Next } from "koa";
 | 
			
		||||
 | 
			
		||||
export interface ErrorFormat {
 | 
			
		||||
	code: number;
 | 
			
		||||
| 
						 | 
				
			
			@ -7,15 +6,12 @@ export interface ErrorFormat {
 | 
			
		|||
	detail?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ClientRequestError implements Error {
 | 
			
		||||
	name: string;
 | 
			
		||||
	message: string;
 | 
			
		||||
	stack?: string | undefined;
 | 
			
		||||
export class ClientRequestError extends Error {
 | 
			
		||||
	code: number;
 | 
			
		||||
 | 
			
		||||
	constructor(code: number, message: string) {
 | 
			
		||||
		super(message);
 | 
			
		||||
		this.name = "client request error";
 | 
			
		||||
		this.message = message;
 | 
			
		||||
		this.code = code;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -25,36 +21,32 @@ const code_to_message_table: { [key: number]: string | undefined } = {
 | 
			
		|||
	404: "NotFound",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const error_handler = async (ctx: Context, next: Next) => {
 | 
			
		||||
	try {
 | 
			
		||||
		await next();
 | 
			
		||||
	} catch (err) {
 | 
			
		||||
		if (err instanceof ClientRequestError) {
 | 
			
		||||
			const body: ErrorFormat = {
 | 
			
		||||
				code: err.code,
 | 
			
		||||
				message: code_to_message_table[err.code] ?? "",
 | 
			
		||||
				detail: err.message,
 | 
			
		||||
			};
 | 
			
		||||
			ctx.status = err.code;
 | 
			
		||||
			ctx.body = body;
 | 
			
		||||
		} 
 | 
			
		||||
		else if (err instanceof ZodError) {
 | 
			
		||||
			const body: ErrorFormat = {
 | 
			
		||||
				code: 400,
 | 
			
		||||
				message: "BadRequest",
 | 
			
		||||
				detail: err.errors.map((x) => x.message).join(", "),
 | 
			
		||||
			};
 | 
			
		||||
			ctx.status = 400;
 | 
			
		||||
			ctx.body = body;
 | 
			
		||||
		}
 | 
			
		||||
		else {
 | 
			
		||||
			throw err;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
export const error_handler = ({ code, error, set }: { code: string, error: Error, set: { status?: number | string } }) => {
 | 
			
		||||
    if (error instanceof ClientRequestError) {
 | 
			
		||||
        set.status = error.code;
 | 
			
		||||
        return {
 | 
			
		||||
            code: error.code,
 | 
			
		||||
            message: code_to_message_table[error.code] ?? "",
 | 
			
		||||
            detail: error.message,
 | 
			
		||||
        } satisfies ErrorFormat;
 | 
			
		||||
    }
 | 
			
		||||
    if (error instanceof ZodError) {
 | 
			
		||||
        set.status = 400;
 | 
			
		||||
        return {
 | 
			
		||||
            code: 400,
 | 
			
		||||
            message: "BadRequest",
 | 
			
		||||
            detail: error.errors.map((x) => x.message).join(", "),
 | 
			
		||||
        } satisfies ErrorFormat;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    set.status = 500;
 | 
			
		||||
    return {
 | 
			
		||||
        code: 500,
 | 
			
		||||
        message: "Internal Server Error",
 | 
			
		||||
        detail: error.message,
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const sendError = (code: number, message?: string) => {
 | 
			
		||||
export const sendError = (code: number, message?: string): never => {
 | 
			
		||||
	throw new ClientRequestError(code, message ?? "");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default error_handler;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,29 +1,31 @@
 | 
			
		|||
import { type Context, Next } from "koa";
 | 
			
		||||
import Router, { type RouterContext } from "koa-router";
 | 
			
		||||
import { Elysia, t } from "elysia";
 | 
			
		||||
import type { TagAccessor } from "../model/tag.ts";
 | 
			
		||||
import { createPermissionCheckMiddleware as PerCheck, Permission } from "../permission/permission.ts";
 | 
			
		||||
import { createPermissionCheck, Permission } from "../permission/permission.ts";
 | 
			
		||||
import { sendError } from "./error_handler.ts";
 | 
			
		||||
 | 
			
		||||
export function getTagRounter(tagController: TagAccessor) {
 | 
			
		||||
	const router = new Router();
 | 
			
		||||
	router.get("/", PerCheck(Permission.QueryContent), async (ctx: Context) => {
 | 
			
		||||
		if (ctx.query.withCount) {
 | 
			
		||||
			const c = await tagController.getAllTagCount();
 | 
			
		||||
			ctx.body = c;
 | 
			
		||||
		} else {
 | 
			
		||||
			const c = await tagController.getAllTagList();
 | 
			
		||||
			ctx.body = c;
 | 
			
		||||
		}
 | 
			
		||||
		ctx.type = "json";
 | 
			
		||||
	});
 | 
			
		||||
	router.get("/:tag_name", PerCheck(Permission.QueryContent), async (ctx: RouterContext) => {
 | 
			
		||||
		const tag_name = ctx.params.tag_name;
 | 
			
		||||
		const c = await tagController.getTagByName(tag_name);
 | 
			
		||||
		if (!c) {
 | 
			
		||||
			sendError(404, "tags not found");
 | 
			
		||||
		}
 | 
			
		||||
		ctx.body = c;
 | 
			
		||||
		ctx.type = "json";
 | 
			
		||||
	});
 | 
			
		||||
	return router;
 | 
			
		||||
	return new Elysia({ name: "tags-router" })
 | 
			
		||||
		.get("/", async ({ query }) => {
 | 
			
		||||
			if (query.withCount !== undefined) {
 | 
			
		||||
				return await tagController.getAllTagCount();
 | 
			
		||||
			}
 | 
			
		||||
			return await tagController.getAllTagList();
 | 
			
		||||
		}, {
 | 
			
		||||
			beforeHandle: createPermissionCheck(Permission.QueryContent),
 | 
			
		||||
			query: t.Object({
 | 
			
		||||
				withCount: t.Optional(t.String()),
 | 
			
		||||
			})
 | 
			
		||||
		})
 | 
			
		||||
		.get("/:tag_name", async ({ params: { tag_name } }) => {
 | 
			
		||||
			const tag = await tagController.getTagByName(tag_name);
 | 
			
		||||
			if (!tag) {
 | 
			
		||||
				sendError(404, "tags not found");
 | 
			
		||||
			}
 | 
			
		||||
			return tag;
 | 
			
		||||
		}, {
 | 
			
		||||
			beforeHandle: createPermissionCheck(Permission.QueryContent),
 | 
			
		||||
			params: t.Object({
 | 
			
		||||
				tag_name: t.String(),
 | 
			
		||||
			})
 | 
			
		||||
		});
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,37 +0,0 @@
 | 
			
		|||
import type { Context } from "koa";
 | 
			
		||||
 | 
			
		||||
export function ParseQueryNumber(s: string[] | string | undefined): number | undefined {
 | 
			
		||||
	if (s === undefined) return undefined;
 | 
			
		||||
	if (typeof s === "object") return undefined;
 | 
			
		||||
	return Number.parseInt(s);
 | 
			
		||||
}
 | 
			
		||||
export function ParseQueryArray(s: string[] | string | undefined) {
 | 
			
		||||
	const input = s ?? [];
 | 
			
		||||
	const r = Array.isArray(input) ? input : input.split(",");
 | 
			
		||||
	return r.map((x) => decodeURIComponent(x));
 | 
			
		||||
}
 | 
			
		||||
export function ParseQueryArgString(s: string[] | string | undefined) {
 | 
			
		||||
	if (typeof s === "object") return undefined;
 | 
			
		||||
	return s === undefined ? s : decodeURIComponent(s);
 | 
			
		||||
}
 | 
			
		||||
export function ParseQueryBoolean(s: string[] | string | undefined): [boolean, boolean | undefined] {
 | 
			
		||||
	let value: boolean | undefined;
 | 
			
		||||
 | 
			
		||||
	if (s === "true") {
 | 
			
		||||
		value = true;
 | 
			
		||||
	} else if (s === "false") {
 | 
			
		||||
		value = false;
 | 
			
		||||
	} else if (s === undefined) {
 | 
			
		||||
		value = undefined;
 | 
			
		||||
	} else return [false, undefined];
 | 
			
		||||
	return [true, value];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function since_last_modified(ctx: Context, last_modified: Date): boolean {
 | 
			
		||||
	const con = ctx.get("If-Modified-Since");
 | 
			
		||||
	if (con === "") return false;
 | 
			
		||||
	const mdate = new Date(con);
 | 
			
		||||
	if (last_modified > mdate) return false;
 | 
			
		||||
	ctx.status = 304;
 | 
			
		||||
	return true;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,67 +0,0 @@
 | 
			
		|||
import { createReadStream, promises } from "node:fs";
 | 
			
		||||
import type { Context } from "koa";
 | 
			
		||||
import Router from "koa-router";
 | 
			
		||||
import type { ContentContext } from "./context.ts";
 | 
			
		||||
 | 
			
		||||
export async function renderVideo(ctx: Context, path: string) {
 | 
			
		||||
	const ext = path.trim().split(".").pop();
 | 
			
		||||
	if (ext === undefined) {
 | 
			
		||||
		// ctx.status = 404;
 | 
			
		||||
		console.error(`${path}:${ext}`);
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	ctx.response.type = ext;
 | 
			
		||||
	const range_text = ctx.request.get("range");
 | 
			
		||||
	const stat = await promises.stat(path);
 | 
			
		||||
	let start = 0;
 | 
			
		||||
	let end = 0;
 | 
			
		||||
	ctx.set("Last-Modified", new Date(stat.mtime).toUTCString());
 | 
			
		||||
	ctx.set("Date", new Date().toUTCString());
 | 
			
		||||
	ctx.set("Accept-Ranges", "bytes");
 | 
			
		||||
	if (range_text === "") {
 | 
			
		||||
		end = 1024 * 512;
 | 
			
		||||
		end = Math.min(end, stat.size - 1);
 | 
			
		||||
		if (start > end) {
 | 
			
		||||
			ctx.status = 416;
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		ctx.status = 200;
 | 
			
		||||
		ctx.length = stat.size;
 | 
			
		||||
		const stream = createReadStream(path);
 | 
			
		||||
		ctx.body = stream;
 | 
			
		||||
	} else {
 | 
			
		||||
		const m = range_text.match(/^bytes=(\d+)-(\d*)/);
 | 
			
		||||
		if (m === null) {
 | 
			
		||||
			ctx.status = 416;
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		start = Number.parseInt(m[1]);
 | 
			
		||||
		end = m[2].length > 0 ? Number.parseInt(m[2]) : start + 1024 * 1024;
 | 
			
		||||
		end = Math.min(end, stat.size - 1);
 | 
			
		||||
		if (start > end) {
 | 
			
		||||
			ctx.status = 416;
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		ctx.status = 206;
 | 
			
		||||
		ctx.length = end - start + 1;
 | 
			
		||||
		ctx.response.set("Content-Range", `bytes ${start}-${end}/${stat.size}`);
 | 
			
		||||
		ctx.body = createReadStream(path, {
 | 
			
		||||
			start: start,
 | 
			
		||||
			end: end,
 | 
			
		||||
		}); // inclusive range.
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class VideoRouter extends Router<ContentContext> {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		this.get("/", async (ctx, next) => {
 | 
			
		||||
			await renderVideo(ctx, ctx.state.location.path);
 | 
			
		||||
		});
 | 
			
		||||
		this.get("/thumbnail", async (ctx, next) => {
 | 
			
		||||
			await renderVideo(ctx, ctx.state.location.path);
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default VideoRouter;
 | 
			
		||||
| 
						 | 
				
			
			@ -1,12 +1,13 @@
 | 
			
		|||
import Koa from "koa";
 | 
			
		||||
import Router from "koa-router";
 | 
			
		||||
import { Elysia, t } from "elysia";
 | 
			
		||||
import { cors } from "@elysiajs/cors";
 | 
			
		||||
import { staticPlugin } from "@elysiajs/static";
 | 
			
		||||
import { html } from "@elysiajs/html";
 | 
			
		||||
 | 
			
		||||
import { connectDB } from "./database.ts";
 | 
			
		||||
import { createDiffRouter, DiffManager } from "./diff/mod.ts";
 | 
			
		||||
import { get_setting, SettingConfig } from "./SettingConfig.ts";
 | 
			
		||||
import { get_setting } from "./SettingConfig.ts";
 | 
			
		||||
 | 
			
		||||
import { createReadStream, readFileSync } from "node:fs";
 | 
			
		||||
import bodyparser from "koa-bodyparser";
 | 
			
		||||
import { readFileSync } from "node:fs";
 | 
			
		||||
import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts";
 | 
			
		||||
import { createLoginRouter, createUserHandler, getAdmin, isAdminFirst } from "./login.ts";
 | 
			
		||||
import getContentRouter from "./route/contents.ts";
 | 
			
		||||
| 
						 | 
				
			
			@ -18,238 +19,129 @@ import type { DocumentAccessor, TagAccessor, UserAccessor } from "./model/mod.ts
 | 
			
		|||
import { getTagRounter } from "./route/tags.ts";
 | 
			
		||||
 | 
			
		||||
import { config } from "dotenv";
 | 
			
		||||
import { extname, join } from "node:path";
 | 
			
		||||
config();
 | 
			
		||||
 | 
			
		||||
class ServerApplication {
 | 
			
		||||
	readonly userController: UserAccessor;
 | 
			
		||||
	readonly documentController: DocumentAccessor;
 | 
			
		||||
	readonly tagController: TagAccessor;
 | 
			
		||||
	readonly diffManger: DiffManager;
 | 
			
		||||
	readonly app: Koa;
 | 
			
		||||
	private index_html: string;
 | 
			
		||||
	private constructor(controller: {
 | 
			
		||||
		userController: UserAccessor;
 | 
			
		||||
		documentController: DocumentAccessor;
 | 
			
		||||
		tagController: TagAccessor;
 | 
			
		||||
	}) {
 | 
			
		||||
		this.userController = controller.userController;
 | 
			
		||||
		this.documentController = controller.documentController;
 | 
			
		||||
		this.tagController = controller.tagController;
 | 
			
		||||
 | 
			
		||||
		this.diffManger = new DiffManager(this.documentController);
 | 
			
		||||
		this.app = new Koa();
 | 
			
		||||
		this.index_html = readFileSync("dist/index.html", "utf-8");
 | 
			
		||||
	}
 | 
			
		||||
	private async setup() {
 | 
			
		||||
		const setting = get_setting();
 | 
			
		||||
		const app = this.app;
 | 
			
		||||
 | 
			
		||||
		if (setting.cli) {
 | 
			
		||||
			const userAdmin = await getAdmin(this.userController);
 | 
			
		||||
			if (await isAdminFirst(userAdmin)) {
 | 
			
		||||
				const rl = createReadlineInterface({
 | 
			
		||||
					input: process.stdin,
 | 
			
		||||
					output: process.stdout,
 | 
			
		||||
				});
 | 
			
		||||
				const pw = await new Promise((res: (data: string) => void, err) => {
 | 
			
		||||
					rl.question("put admin password :", (data) => {
 | 
			
		||||
						res(data);
 | 
			
		||||
					});
 | 
			
		||||
				});
 | 
			
		||||
				rl.close();
 | 
			
		||||
				userAdmin.reset_password(pw);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		app.use(bodyparser());
 | 
			
		||||
		app.use(error_handler);
 | 
			
		||||
		app.use(createUserHandler(this.userController));
 | 
			
		||||
 | 
			
		||||
		const diff_router = createDiffRouter(this.diffManger);
 | 
			
		||||
		this.diffManger.register("comic", createComicWatcher());
 | 
			
		||||
 | 
			
		||||
		console.log("setup router");
 | 
			
		||||
 | 
			
		||||
		const router = new Router();
 | 
			
		||||
		router.use("/api/(.*)", async (ctx, next) => {
 | 
			
		||||
			// For CORS
 | 
			
		||||
			ctx.res.setHeader("access-control-allow-origin", "*");
 | 
			
		||||
			await next();
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		router.use("/api/diff", diff_router.routes());
 | 
			
		||||
		router.use("/api/diff", diff_router.allowedMethods());
 | 
			
		||||
 | 
			
		||||
		const content_router = getContentRouter(this.documentController);
 | 
			
		||||
		router.use("/api/doc", content_router.routes());
 | 
			
		||||
		router.use("/api/doc", content_router.allowedMethods());
 | 
			
		||||
 | 
			
		||||
		const tags_router = getTagRounter(this.tagController);
 | 
			
		||||
		router.use("/api/tags", tags_router.allowedMethods());
 | 
			
		||||
		router.use("/api/tags", tags_router.routes());
 | 
			
		||||
 | 
			
		||||
		this.serve_with_meta_index(router);
 | 
			
		||||
		this.serve_index(router);
 | 
			
		||||
		this.serve_static_file(router);
 | 
			
		||||
 | 
			
		||||
		const login_router = createLoginRouter(this.userController);
 | 
			
		||||
		router.use("/api/user", login_router.routes());
 | 
			
		||||
		router.use("/api/user", login_router.allowedMethods());
 | 
			
		||||
 | 
			
		||||
		if (setting.mode === "development") {
 | 
			
		||||
			let mm_count = 0;
 | 
			
		||||
			app.use(async (ctx, next) => {
 | 
			
		||||
				console.log(`=== Request No ${mm_count++} \t===`);
 | 
			
		||||
				const ip = ctx.get("X-Real-IP").length > 0 ? ctx.get("X-Real-IP") : ctx.ip;
 | 
			
		||||
				const fromClient = ctx.state.user.username === "" ? ip : ctx.state.user.username;
 | 
			
		||||
				console.log(`${mm_count}	${fromClient} : ${ctx.method} ${ctx.url}`);
 | 
			
		||||
				const start = Date.now();
 | 
			
		||||
				await next();
 | 
			
		||||
				const end = Date.now();
 | 
			
		||||
				console.log(`${mm_count}	${fromClient} : ${ctx.method} ${ctx.url} ${ctx.status} ${end - start}ms`);
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
		app.use(router.routes());
 | 
			
		||||
		app.use(router.allowedMethods());
 | 
			
		||||
		console.log("setup done");
 | 
			
		||||
	}
 | 
			
		||||
	private serve_index(router: Router) {
 | 
			
		||||
		const serveindex = (url: string) => {
 | 
			
		||||
			router.get(url, (ctx) => {
 | 
			
		||||
				ctx.type = "html";
 | 
			
		||||
				ctx.body = this.index_html;
 | 
			
		||||
				const setting = get_setting();
 | 
			
		||||
				ctx.set("x-content-type-options", "no-sniff");
 | 
			
		||||
				if (setting.mode === "development") {
 | 
			
		||||
					ctx.set("cache-control", "no-cache");
 | 
			
		||||
				} else {
 | 
			
		||||
					ctx.set("cache-control", "public, max-age=3600");
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
		};
 | 
			
		||||
		serveindex("/");
 | 
			
		||||
		serveindex("/doc/:rest(.*)");
 | 
			
		||||
		serveindex("/search");
 | 
			
		||||
		serveindex("/login");
 | 
			
		||||
		serveindex("/profile");
 | 
			
		||||
		serveindex("/difference");
 | 
			
		||||
		serveindex("/setting");
 | 
			
		||||
		serveindex("/tags");
 | 
			
		||||
	}
 | 
			
		||||
	private serve_with_meta_index(router: Router) {
 | 
			
		||||
		const DocMiddleware = async (ctx: Koa.ParameterizedContext) => {
 | 
			
		||||
			const docId = Number.parseInt(ctx.params.id);
 | 
			
		||||
			const doc = await this.documentController.findById(docId, true);
 | 
			
		||||
			// biome-ignore lint/suspicious/noImplicitAnyLet: <explanation>
 | 
			
		||||
			let meta;
 | 
			
		||||
			if (doc === undefined) {
 | 
			
		||||
				ctx.status = 404;
 | 
			
		||||
				meta = NotFoundContent();
 | 
			
		||||
			} else {
 | 
			
		||||
				ctx.status = 200;
 | 
			
		||||
				meta = createOgTagContent(
 | 
			
		||||
					doc.title,
 | 
			
		||||
					doc.tags.join(", "),
 | 
			
		||||
					`https://aeolian.monoid.top/api/doc/${docId}/comic/thumbnail`,
 | 
			
		||||
				);
 | 
			
		||||
			}
 | 
			
		||||
			const html = makeMetaTagInjectedHTML(this.index_html, meta);
 | 
			
		||||
			serveHTML(ctx, html);
 | 
			
		||||
		};
 | 
			
		||||
		router.get("/doc/:id(\\d+)", DocMiddleware);
 | 
			
		||||
 | 
			
		||||
		function NotFoundContent() {
 | 
			
		||||
			return createOgTagContent("Not Found Doc", "Not Found", "");
 | 
			
		||||
		}
 | 
			
		||||
		function makeMetaTagInjectedHTML(html: string, tagContent: string) {
 | 
			
		||||
			return html.replace("<!--MetaTag-Outlet-->", tagContent);
 | 
			
		||||
		}
 | 
			
		||||
		function serveHTML(ctx: Koa.Context, file: string) {
 | 
			
		||||
			ctx.type = "html";
 | 
			
		||||
			ctx.body = file;
 | 
			
		||||
			const setting = get_setting();
 | 
			
		||||
			ctx.set("x-content-type-options", "no-sniff");
 | 
			
		||||
			if (setting.mode === "development") {
 | 
			
		||||
				ctx.set("cache-control", "no-cache");
 | 
			
		||||
			} else {
 | 
			
		||||
				ctx.set("cache-control", "public, max-age=3600");
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		function createMetaTagContent(key: string, value: string) {
 | 
			
		||||
			return `<meta property="${key}" content="${value}">`;
 | 
			
		||||
		}
 | 
			
		||||
		function createOgTagContent(title: string, description: string, image: string) {
 | 
			
		||||
			return [
 | 
			
		||||
				createMetaTagContent("og:title", title),
 | 
			
		||||
				createMetaTagContent("og:type", "website"),
 | 
			
		||||
				createMetaTagContent("og:description", description),
 | 
			
		||||
				createMetaTagContent("og:image", image),
 | 
			
		||||
				// createMetaTagContent("og:image:width","480"),
 | 
			
		||||
				// createMetaTagContent("og:image","480"),
 | 
			
		||||
				// createMetaTagContent("og:image:type","image/png"),
 | 
			
		||||
				createMetaTagContent("twitter:card", "summary_large_image"),
 | 
			
		||||
				createMetaTagContent("twitter:title", title),
 | 
			
		||||
				createMetaTagContent("twitter:description", description),
 | 
			
		||||
				createMetaTagContent("twitter:image", image),
 | 
			
		||||
			].join("\n");
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	private serve_static_file(router: Router) {
 | 
			
		||||
		router.get("/assets/(.*)", async (ctx, next) => {
 | 
			
		||||
			const setting = get_setting();
 | 
			
		||||
			const ext = extname(ctx.path);
 | 
			
		||||
			ctx.type = ext;
 | 
			
		||||
			ctx.body = createReadStream(join("dist",`.${ctx.path}`));
 | 
			
		||||
			ctx.set("x-content-type-options", "no-sniff");
 | 
			
		||||
			if (setting.mode === "development") {
 | 
			
		||||
				ctx.set("cache-control", "no-cache");
 | 
			
		||||
			} else {
 | 
			
		||||
				ctx.set("cache-control", "public, max-age=3600");
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
		// const static_file_server = (path: string, type: string) => {
 | 
			
		||||
		// 	router.get(`/${path}`, async (ctx, next) => {
 | 
			
		||||
		// 		const setting = get_setting();
 | 
			
		||||
		// 		ctx.type = type;
 | 
			
		||||
		// 		ctx.body = createReadStream(path);
 | 
			
		||||
		// 		ctx.set("x-content-type-options", "no-sniff");
 | 
			
		||||
		// 		if (setting.mode === "development") {
 | 
			
		||||
		// 			ctx.set("cache-control", "no-cache");
 | 
			
		||||
		// 		} else {
 | 
			
		||||
		// 			ctx.set("cache-control", "public, max-age=3600");
 | 
			
		||||
		// 		}
 | 
			
		||||
		// 	});
 | 
			
		||||
		// };
 | 
			
		||||
		// const setting = get_setting();
 | 
			
		||||
		// static_file_server("dist/bundle.css", "css");
 | 
			
		||||
		// static_file_server("dist/bundle.js", "js");
 | 
			
		||||
		// if (setting.mode === "development") {
 | 
			
		||||
		// 	static_file_server("dist/bundle.js.map", "text");
 | 
			
		||||
		// 	static_file_server("dist/bundle.css.map", "text");
 | 
			
		||||
		// }
 | 
			
		||||
	}
 | 
			
		||||
	start_server() {
 | 
			
		||||
		const setting = get_setting();
 | 
			
		||||
		// todo : support https
 | 
			
		||||
		console.log(`listen on http://${setting.localmode ? "localhost" : "0.0.0.0"}:${setting.port}`);
 | 
			
		||||
		return this.app.listen(setting.port, setting.localmode ? "127.0.0.1" : "0.0.0.0");
 | 
			
		||||
	}
 | 
			
		||||
	static async createServer() {
 | 
			
		||||
		const db = await connectDB();
 | 
			
		||||
 | 
			
		||||
		const app = new ServerApplication({
 | 
			
		||||
			userController: createSqliteUserController(db),
 | 
			
		||||
			documentController: createSqliteDocumentAccessor(db),
 | 
			
		||||
			tagController: createSqliteTagController(db),
 | 
			
		||||
		});
 | 
			
		||||
		await app.setup();
 | 
			
		||||
		return app;
 | 
			
		||||
	}
 | 
			
		||||
function createMetaTagContent(key: string, value: string) {
 | 
			
		||||
    return `<meta property="${key}" content="${value}">`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function createOgTagContent(title: string, description:string, image: string) {
 | 
			
		||||
    return [
 | 
			
		||||
        createMetaTagContent("og:title", title),
 | 
			
		||||
        createMetaTagContent("og:type", "website"),
 | 
			
		||||
        createMetaTagContent("og:description", description),
 | 
			
		||||
        createMetaTagContent("og:image", image),
 | 
			
		||||
        createMetaTagContent("twitter:card", "summary_large_image"),
 | 
			
		||||
        createMetaTagContent("twitter:title", title),
 | 
			
		||||
        createMetaTagContent("twitter:description", description),
 | 
			
		||||
        createMetaTagContent("twitter:image", image),
 | 
			
		||||
    ].join("\n");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function makeMetaTagInjectedHTML(html: string, tagContent: string) {
 | 
			
		||||
    return html.replace("<!--MetaTag-Outlet-->", tagContent);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const normalizeError = (error: unknown): Error => {
 | 
			
		||||
    if (error instanceof Error) {
 | 
			
		||||
        return error;
 | 
			
		||||
    }
 | 
			
		||||
    if (typeof error === "string") {
 | 
			
		||||
        return new Error(error);
 | 
			
		||||
    }
 | 
			
		||||
    try {
 | 
			
		||||
        return new Error(JSON.stringify(error));
 | 
			
		||||
    } catch (_err) {
 | 
			
		||||
        return new Error("Unknown error");
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export async function create_server() {
 | 
			
		||||
	return await ServerApplication.createServer();
 | 
			
		||||
}
 | 
			
		||||
    const setting = get_setting();
 | 
			
		||||
    const db = await connectDB();
 | 
			
		||||
 | 
			
		||||
export default { create_server };
 | 
			
		||||
    const userController = createSqliteUserController(db);
 | 
			
		||||
    const documentController = createSqliteDocumentAccessor(db);
 | 
			
		||||
    const tagController = createSqliteTagController(db);
 | 
			
		||||
    const diffManger = new DiffManager(documentController);
 | 
			
		||||
    diffManger.register("comic", createComicWatcher());
 | 
			
		||||
 | 
			
		||||
    if (setting.cli) {
 | 
			
		||||
        const userAdmin = await getAdmin(userController);
 | 
			
		||||
        if (await isAdminFirst(userAdmin)) {
 | 
			
		||||
            const rl = createReadlineInterface({
 | 
			
		||||
                input: process.stdin,
 | 
			
		||||
                output: process.stdout,
 | 
			
		||||
            });
 | 
			
		||||
            const pw = await new Promise((res: (data: string) => void) => {
 | 
			
		||||
                rl.question("put admin password :", (data) => {
 | 
			
		||||
                    res(data);
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
            rl.close();
 | 
			
		||||
            await userAdmin.reset_password(pw);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const index_html = readFileSync("dist/index.html", "utf-8");
 | 
			
		||||
 | 
			
		||||
    const app = new Elysia()
 | 
			
		||||
        .use(cors())
 | 
			
		||||
        .use(staticPlugin({
 | 
			
		||||
            assets: "dist/assets",
 | 
			
		||||
            prefix: "/assets",
 | 
			
		||||
            headers: {
 | 
			
		||||
                "X-Content-Type-Options": "nosniff",
 | 
			
		||||
                "Cache-Control": setting.mode === "development" ? "no-cache" : "public, max-age=3600",
 | 
			
		||||
            }
 | 
			
		||||
        }))
 | 
			
		||||
        .use(html())
 | 
			
		||||
        .onError((context) => error_handler({
 | 
			
		||||
            code: typeof context.code === "number" ? String(context.code) : context.code,
 | 
			
		||||
            error: normalizeError(context.error),
 | 
			
		||||
            set: context.set,
 | 
			
		||||
        }))
 | 
			
		||||
        .use(createUserHandler(userController))
 | 
			
		||||
        .group("/api", (app) => app
 | 
			
		||||
            .use(createDiffRouter(diffManger))
 | 
			
		||||
            .use(getContentRouter(documentController))
 | 
			
		||||
            .use(getTagRounter(tagController))
 | 
			
		||||
            .use(createLoginRouter(userController))
 | 
			
		||||
        )
 | 
			
		||||
        .get("/doc/:id", async ({ params: { id }, set }) => {
 | 
			
		||||
            const docId = Number.parseInt(id, 10);
 | 
			
		||||
            const doc = await documentController.findById(docId, true);
 | 
			
		||||
            let meta;
 | 
			
		||||
            if (doc === undefined) {
 | 
			
		||||
                set.status = 404;
 | 
			
		||||
                meta = createOgTagContent("Not Found Doc", "Not Found", "");
 | 
			
		||||
            } else {
 | 
			
		||||
                set.status = 200;
 | 
			
		||||
                meta = createOgTagContent(
 | 
			
		||||
                    doc.title,
 | 
			
		||||
                    doc.tags.join(", "),
 | 
			
		||||
                    `https://aeolian.monoid.top/api/doc/${docId}/comic/thumbnail`,
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
            return makeMetaTagInjectedHTML(index_html, meta);
 | 
			
		||||
        }, {
 | 
			
		||||
            params: t.Object({ id: t.String() })
 | 
			
		||||
        })
 | 
			
		||||
        .get("/", () => index_html)
 | 
			
		||||
        .get("/doc/*", () => index_html)
 | 
			
		||||
        .get("/search", () => index_html)
 | 
			
		||||
        .get("/login", () => index_html)
 | 
			
		||||
        .get("/profile", () => index_html)
 | 
			
		||||
        .get("/difference", () => index_html)
 | 
			
		||||
        .get("/setting", () => index_html)
 | 
			
		||||
        .get("/tags", () => index_html)
 | 
			
		||||
        .listen({
 | 
			
		||||
            hostname: setting.localmode ? "127.0.0.1" : "0.0.0.0",
 | 
			
		||||
            port: setting.port,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
    console.log(`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}`);
 | 
			
		||||
 | 
			
		||||
    return app;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								packages/server/src/setting.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/server/src/setting.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
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());
 | 
			
		||||
| 
						 | 
				
			
			@ -1,17 +0,0 @@
 | 
			
		|||
export function check_type<T>(obj: unknown, check_proto: Record<string, string | undefined>): obj is T {
 | 
			
		||||
	if (typeof obj !== "object" || obj === null) return false;
 | 
			
		||||
	for (const it in check_proto) {
 | 
			
		||||
		let defined = check_proto[it];
 | 
			
		||||
		if (defined === undefined) return false;
 | 
			
		||||
		defined = defined.trim();
 | 
			
		||||
		if (defined.endsWith("[]")) {
 | 
			
		||||
			if (!Array.isArray((obj as Record<string, unknown>)[it])) {
 | 
			
		||||
				return false;
 | 
			
		||||
			}
 | 
			
		||||
		// biome-ignore lint/suspicious/useValidTypeof: <explanation>
 | 
			
		||||
		} else if (defined !== typeof (obj as Record<string, unknown>)[it]) {
 | 
			
		||||
			return false;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return true;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										839
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										839
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
		Loading…
	
	Add table
		
		Reference in a new issue