feat!: use elysia js intead of koa #18

Merged
monoid merged 10 commits from elysia into main 2025-10-01 02:04:47 +09:00
19 changed files with 1034 additions and 1719 deletions
Showing only changes of commit 018e2e998b - Show all commits

View file

@ -1,5 +1,5 @@
{ {
"name": "followed", "name": "server",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "build/app.js", "main": "build/app.js",
@ -12,28 +12,24 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "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", "@std/async": "npm:@jsr/std__async@^1.0.13",
"@zip.js/zip.js": "^2.7.62", "@zip.js/zip.js": "^2.7.62",
"better-sqlite3": "^9.6.0", "better-sqlite3": "^9.6.0",
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"dbtype": "workspace:dbtype", "dbtype": "workspace:dbtype",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"elysia": "^1.3.20",
"jose": "^5.10.0", "jose": "^5.10.0",
"koa": "^2.16.1",
"koa-bodyparser": "^4.4.1",
"koa-compose": "^4.1.0",
"koa-router": "^12.0.1",
"kysely": "^0.27.6", "kysely": "^0.27.6",
"natural-orderby": "^2.0.3", "natural-orderby": "^2.0.3",
"tiny-async-pool": "^1.3.0" "tiny-async-pool": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^8.5.9",
"@types/koa": "^2.15.0",
"@types/koa-bodyparser": "^4.3.12",
"@types/koa-compose": "^3.2.8", "@types/koa-compose": "^3.2.8",
"@types/koa-router": "^7.4.8",
"@types/node": "^22.15.33", "@types/node": "^22.15.33",
"@types/tiny-async-pool": "^1.0.5", "@types/tiny-async-pool": "^1.0.5",
"tsx": "^4.20.3", "tsx": "^4.20.3",

View file

@ -1,7 +1,5 @@
import { create_server } from "./server.ts"; import { create_server } from "./server.ts";
create_server().then((server) => { create_server().catch((err) => {
server.start_server();
}).catch((err) => {
console.error(err); console.error(err);
}); });

View 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
};
}

View file

@ -1,85 +1,57 @@
import type Koa from "koa"; import { Elysia, t } from "elysia";
import Router from "koa-router";
import type { ContentFile } from "../content/mod.ts";
import { AdminOnlyMiddleware } from "../permission/permission.ts";
import { sendError } from "../route/error_handler.ts";
import type { DiffManager } from "./diff.ts"; import type { DiffManager } from "./diff.ts";
import type { ContentFile } from "../content/mod.ts";
import { AdminOnly } from "../permission/permission.ts";
import { sendError } from "../route/error_handler.ts";
function content_file_to_return(x: ContentFile) { const toSerializableContent = (file: ContentFile) => ({ path: file.path, type: file.type });
return { path: x.path, type: x.type };
}
export const getAdded = (diffmgr: DiffManager) => (ctx: Koa.Context, next: Koa.Next) => { const CommitEntrySchema = t.Array(t.Object({
const ret = diffmgr.getAdded(); type: t.String(),
ctx.body = ret.map((x) => ({ path: t.String(),
type: x.type, }));
value: x.value.map((x) => ({ path: x.path, type: x.type })),
const CommitAllSchema = t.Object({
type: t.String(),
});
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),
})); }));
ctx.type = "json"; }, {
}; beforeHandle: AdminOnly,
})
type PostAddedBody = { .post("/commit", async ({ body }) => {
type: string; if (body.length === 0) {
path: string; return { ok: true, docs: [] as number[] };
}[];
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; const results = await Promise.all(body.map(({ type, path }) => diffmgr.commit(type, path)));
} return {
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, ok: true,
docs: results, docs: results,
}; };
ctx.type = "json"; }, {
await next(); beforeHandle: AdminOnly,
}; body: CommitEntrySchema,
export const postAddedAll = (diffmgr: DiffManager) => async (ctx: Router.IRouterContext, next: Koa.Next) => { })
if (!ctx.is("json")) { .post("/commitall", async ({ body }) => {
sendError(400, "format exception"); const { type } = body;
return; if (!type) {
}
const reqbody = ctx.request.body as Record<string, unknown>;
if (!("type" in reqbody)) {
sendError(400, 'format exception: there is no "type"'); sendError(400, 'format exception: there is no "type"');
return;
} }
const t = reqbody.type; await diffmgr.commitAll(type);
if (typeof t !== "string") { return { ok: true };
sendError(400, 'format exception: invalid type of "type"'); }, {
return; beforeHandle: AdminOnly,
} body: CommitAllSchema,
await diffmgr.commitAll(t); })
ctx.body = { .get("/*", () => {
ok: true, sendError(404);
}; })
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;
}

View file

@ -1,5 +1,5 @@
import { ConfigManager } from "../../util/configRW.ts"; 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 { export interface ComicConfig {
watch: string[]; watch: string[];
} }

View file

@ -1,19 +1,8 @@
import { Elysia, t, type Context } from "elysia";
import { SignJWT, jwtVerify, errors } from "jose"; import { SignJWT, jwtVerify, errors } from "jose";
import type Koa from "koa";
import Router from "koa-router";
import type { IUser, UserAccessor } from "./model/mod.ts"; import type { IUser, UserAccessor } from "./model/mod.ts";
import { sendError } from "./route/error_handler.ts"; import { ClientRequestError } from "./route/error_handler.ts";
import { get_setting } from "./SettingConfig.ts"; import { get_setting } from "./SettingConfig.ts";
import { LoginRequestSchema, LoginResetRequestSchema } from "dbtype";
type LoginResponse = {
accessExpired: number;
} & PayloadInfo;
type RefreshResponse = {
accessExpired: number;
refresh: boolean;
} & PayloadInfo;
type PayloadInfo = { type PayloadInfo = {
username: string; username: string;
@ -24,18 +13,41 @@ export type UserState = {
user: PayloadInfo; user: PayloadInfo;
}; };
const isUserState = (obj: object | string): obj is PayloadInfo => { type AuthStore = {
if (typeof obj === "string") return false; user: PayloadInfo;
return "username" in obj && "permission" in obj && Array.isArray((obj as { permission: unknown }).permission); refreshed: boolean;
authenticated: boolean;
}; };
type LoginResponse = {
accessExpired: number;
} & PayloadInfo;
type RefreshResponse = {
accessExpired: number;
refresh: boolean;
} & PayloadInfo;
type RefreshPayloadInfo = { username: string }; type RefreshPayloadInfo = { username: string };
const isRefreshToken = (obj: object | string): obj is RefreshPayloadInfo => {
if (typeof obj === "string") return false;
return "username" in obj && typeof (obj as { username: unknown }).username === "string";
};
const accessExpiredTime = 60 * 60 * 2; // 2 hour type CookieJar = Context["cookie"];
const LoginBodySchema = t.Object({
username: t.String(),
password: t.String(),
});
const ResetBodySchema = t.Object({
username: t.String(),
oldpassword: t.String(),
newpassword: t.String(),
});
const SettingsBodySchema = t.Record(t.String(), t.Unknown());
const accessExpiredTime = 60 * 60 * 2; // 2 hours
const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 days
async function createAccessToken(payload: PayloadInfo, secret: string) { async function createAccessToken(payload: PayloadInfo, secret: string) {
return await new SignJWT(payload) return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
@ -43,7 +55,6 @@ async function createAccessToken(payload: PayloadInfo, secret: string) {
.sign(new TextEncoder().encode(secret)); .sign(new TextEncoder().encode(secret));
} }
const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 day;
async function createRefreshToken(payload: RefreshPayloadInfo, secret: string) { async function createRefreshToken(payload: RefreshPayloadInfo, secret: string) {
return await new SignJWT(payload) return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
@ -57,10 +68,10 @@ class TokenExpiredError extends Error {
} }
} }
async function verifyToken(token: string, secret: string) { async function verifyToken<T>(token: string, secret: string): Promise<T> {
try { try {
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret)); const { payload } = await jwtVerify(token, new TextEncoder().encode(secret));
return payload as PayloadInfo; return payload as T;
} catch (error) { } catch (error) {
if (error instanceof errors.JWTExpired) { if (error instanceof errors.JWTExpired) {
throw new TokenExpiredError(); throw new TokenExpiredError();
@ -72,241 +83,245 @@ async function verifyToken(token: string, secret: string) {
export const accessTokenName = "access_token"; export const accessTokenName = "access_token";
export const refreshTokenName = "refresh_token"; export const refreshTokenName = "refresh_token";
function setToken(ctx: Koa.Context, token_name: string, token_payload: string | null, expiredtime: number) { function setToken(cookie: CookieJar, token_name: string, token_payload: string | null, expiredSeconds: number) {
const setting = get_setting(); if (token_payload === null) {
if (token_payload === null && !ctx.cookies.get(token_name)) { cookie[token_name]?.remove();
return; return;
} }
ctx.cookies.set(token_name, token_payload, { const setting = get_setting();
cookie[token_name].set({
value: token_payload,
httpOnly: true, httpOnly: true,
secure: setting.secure, secure: setting.secure,
sameSite: "strict", sameSite: "strict",
expires: new Date(Date.now() + expiredtime * 1000), expires: new Date(Date.now() + expiredSeconds * 1000),
}); });
} }
export const createLoginHandler = (userController: UserAccessor) => async (ctx: Koa.Context, _next: Koa.Next) => { const isUserState = (obj: unknown): obj is PayloadInfo => {
const setting = get_setting(); if (typeof obj !== "object" || obj === null) {
const secretKey = setting.jwt_secretkey; return false;
const body = ctx.request.body;
const {
username,
password,
} = LoginRequestSchema.parse(body);
// if admin login is forbidden?
if (username === "admin" && setting.forbid_remote_admin_login) {
return sendError(403, "forbidden remote admin login");
} }
const user = await userController.findUser(username); return "username" in obj && "permission" in obj && Array.isArray((obj as { permission: unknown }).permission);
// username not exist
if (user === undefined) return sendError(401, "not authorized");
// password not matched
if (!user.password.check_password(password)) {
return sendError(401, "not authorized");
}
// create token
const userPermission = await user.get_permissions();
const payload = await createAccessToken({
username: user.username,
permission: userPermission,
}, secretKey);
const payload2 = await createRefreshToken({
username: user.username,
}, secretKey);
setToken(ctx, accessTokenName, payload, accessExpiredTime);
setToken(ctx, refreshTokenName, payload2, refreshExpiredTime);
ctx.body = {
username: user.username,
permission: userPermission,
accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime,
} satisfies LoginResponse;
console.log(`${username} logined`);
return;
}; };
export const LogoutHandler = (ctx: Koa.Context, _next: Koa.Next) => { const isRefreshToken = (obj: unknown): obj is RefreshPayloadInfo => {
if (typeof obj !== "object" || obj === null) {
return false;
}
return "username" in obj && typeof (obj as { username: unknown }).username === "string";
};
type AuthResult = {
user: PayloadInfo;
refreshed: boolean;
success: boolean;
};
async function authenticate(
cookie: CookieJar,
userController: UserAccessor,
options: { forceRefresh?: boolean } = {},
): Promise<AuthResult> {
const setting = get_setting(); const setting = get_setting();
ctx.cookies.set(accessTokenName, null); const secretKey = setting.jwt_secretkey;
ctx.cookies.set(refreshTokenName, null); const accessCookie = cookie[accessTokenName];
ctx.body = { const refreshCookie = cookie[refreshTokenName];
const accessValue = accessCookie?.value;
const refreshValue = refreshCookie?.value;
const guestUser: PayloadInfo = {
username: "",
permission: setting.guest,
};
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,
};
const accessToken = await createAccessToken(payload, secretKey);
setToken(cookie, accessTokenName, accessToken, accessExpiredTime);
return { user: payload, refreshed: true, success: true };
};
const tryRefresh = async (): Promise<AuthResult> => {
if (!refreshValue) {
return setGuest();
}
try {
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)) {
return setGuest();
}
}
}
return await tryRefresh();
}
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, ok: true,
username: "", username: "",
permission: setting.guest, permission: setting.guest,
}; };
return; })
}; .post("/refresh", async ({ cookie }) => {
const auth = await authenticate(cookie, userController, { forceRefresh: true });
export const createUserHandler = if (!auth.success) {
(userController: UserAccessor) => async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => { throw new ClientRequestError(401, "not authorized");
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();
};
return await refreshToken(ctx, setGuest, next);
};
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();
} }
return {
try { ...auth.user,
const payload = await verifyToken(accessPayload, secretKey);
if (isUserState(payload)) {
ctx.state.user = payload;
return await next();
}
console.error("Invalid token detected");
throw new Error("Token form invalid");
} catch (error) {
if (error instanceof TokenExpiredError) {
return await checkRefreshAndUpdate();
}
throw error;
}
async function checkRefreshAndUpdate() {
const refreshPayload = ctx.cookies.get(refreshTokenName);
if (!refreshPayload) {
return await fail(); // Refresh token doesn't exist
}
try {
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");
}
} catch (error) {
if (error instanceof TokenExpiredError) {
// Refresh token is expired
return await fail();
}
throw error;
}
return await next();
}
};
export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => {
const handler = makeRefreshToken(cntr);
await handler(ctx, fail, success);
async function fail() {
const user = ctx.state.user as PayloadInfo;
ctx.body = {
refresh: false,
accessExpired: 0,
...user,
} satisfies RefreshResponse;
ctx.type = "json";
};
async function success() {
const user = ctx.state.user as PayloadInfo;
ctx.body = {
...user,
refresh: true, refresh: true,
accessExpired: Math.floor(Date.now() / 1000 + accessExpiredTime), accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime,
} satisfies RefreshResponse; } satisfies RefreshResponse;
ctx.type = "json"; })
.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({
export const resetPasswordMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => { name: "user-handler",
const body = ctx.request.body; seed: "UserAccess",
const { })
username, .derive({ as: "scoped" }, async ({ cookie }) => {
oldpassword, const auth = await authenticate(cookie, userController);
newpassword, return {
} = LoginResetRequestSchema.parse(body); user: auth.user,
const user = await cntr.findUser(username); refreshed: auth.refreshed,
if (user === undefined) { authenticated: auth.success,
return sendError(403, "not authorized"); };
} });
if (!user.password.check_password(oldpassword)) {
return sendError(403, "not authorized");
}
user.reset_password(newpassword);
ctx.body = { ok: true };
ctx.type = "json";
}; };
export function getUserSettingHandler(userController: UserAccessor) {
return async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const username = ctx.state.user.username;
if (!username) {
return sendError(403, "not authorized");
}
const user = await userController.findUser(username);
if (user === undefined) {
return sendError(403, "not authorized");
}
const settings = await user.get_settings();
if (settings === undefined) {
ctx.body = {};
ctx.type = "json";
return;
}
ctx.body = settings;
ctx.type = "json";
await next();
};
}
export function setUserSettingHandler(userController: UserAccessor) {
return async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const username = ctx.state.user.username;
if (!username) {
return sendError(403, "not authorized");
}
const user = await userController.findUser(username);
if (user === undefined) {
return sendError(403, "not authorized");
}
const body = ctx.request.body;
const settings = body as Record<string, unknown>;
await user.set_settings(settings);
ctx.body = { ok: true };
ctx.type = "json";
await next();
};
}
export function createLoginRouter(userController: UserAccessor) {
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 getAdmin = async (cntr: UserAccessor) => { export const getAdmin = async (cntr: UserAccessor) => {
const admin = await cntr.findUser("admin"); const admin = await cntr.findUser("admin");
if (admin === undefined) { if (admin === undefined) {
throw new Error("initial process failed!"); // ??? throw new Error("initial process failed!");
} }
return admin; return admin;
}; };

View file

@ -1,4 +1,3 @@
import { check_type } from "../util/type_check.ts";
import type { import type {
DocumentBody, DocumentBody,
QueryListOption, QueryListOption,

View file

@ -1,6 +1,5 @@
import type Koa from "koa";
import type { UserState } from "../login.ts";
import { sendError } from "../route/error_handler.ts"; import { sendError } from "../route/error_handler.ts";
import type { UserState } from "../login.ts";
export enum Permission { export enum Permission {
// ======== // ========
@ -34,27 +33,36 @@ export enum Permission {
modifyTagDesc = "ModifyTagDesc", modifyTagDesc = "ModifyTagDesc",
} }
export const createPermissionCheckMiddleware = type PermissionCheckContext = {
(...permissions: string[]) => user?: UserState["user"];
async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => { store?: { user?: UserState["user"] };
const user = ctx.state.user; } & Record<string, unknown>;
const resolveUser = (context: PermissionCheckContext): UserState["user"] => {
const user = context.user ?? context.store?.user;
if (!user) {
sendError(401, "you are guest. login needed.");
}
return user as UserState["user"];
};
export const createPermissionCheck = (...permissions: string[]) => (context: PermissionCheckContext) => {
const user = resolveUser(context);
if (user.username === "admin") { if (user.username === "admin") {
return await next(); return;
} }
const user_permission = user.permission; const user_permission = user.permission;
// if permissions is not subset of user permission if (!permissions.every((p) => user_permission.includes(p))) {
if (!permissions.map((p) => user_permission.includes(p)).every((x) => x)) {
if (user.username === "") { if (user.username === "") {
return sendError(401, "you are guest. login needed."); throw sendError(401, "you are guest. login needed.");
} return sendError(403, "do not have permission");
} }
await next(); throw sendError(403, "do not have permission");
}; }
};
export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const user = ctx.state.user; export const AdminOnly = (context: PermissionCheckContext) => {
if (user.username !== "admin") { const user = resolveUser(context);
return sendError(403, "admin only"); if (user.username !== "admin") {
throw sendError(403, "admin only");
} }
await next();
}; };

View file

@ -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);
});
}
}

View file

@ -1,72 +1,90 @@
import type { Context } from "koa"; import type { Context as ElysiaContext } from "elysia";
import Router from "koa-router";
import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap.ts";
import type { ContentContext } from "./context.ts";
import { since_last_modified } from "./util.ts";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap.ts";
const imageExtensions = new Set(["gif", "png", "jpeg", "bmp", "webp", "jpg", "avif"]);
async function renderZipImage(ctx: Context, path: string, page: number) { const extensionToMime = (ext: string) => {
const image_ext = ["gif", "png", "jpeg", "bmp", "webp", "jpg", "avif"]; if (ext === "jpg") return "image/jpeg";
return `image/${ext}`;
};
type ResponseSet = Pick<ElysiaContext["set"], "status" | "headers">;
type RenderOptions = {
path: string;
page: number;
reqHeaders: Headers;
set: ResponseSet;
};
export async function renderComicPage({ path, page, reqHeaders, set }: RenderOptions) {
const zip = await readZip(path); const zip = await readZip(path);
const entries = (await entriesByNaturalOrder(zip.reader)).filter((x) => {
const ext = x.filename.split(".").pop();
return ext !== undefined && image_ext.includes(ext);
});
if (0 <= page && page < entries.length) {
const entry = entries[page];
const last_modified = entry.lastModDate;
if (since_last_modified(ctx, last_modified)) {
return;
}
const read_stream = await createReadableStreamFromZip(zip.reader, entry);
const nodeReadableStream = new Readable();
nodeReadableStream._read = () => { };
read_stream.pipeTo(new WritableStream({ try {
const entries = (await entriesByNaturalOrder(zip.reader)).filter((entry) => {
const ext = entry.filename.split(".").pop()?.toLowerCase();
return ext !== undefined && imageExtensions.has(ext);
});
if (page < 0 || page >= entries.length) {
set.status = 404;
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) { write(chunk) {
nodeReadableStream.push(chunk); nodeReadable.push(chunk);
}, },
close() { close() {
nodeReadableStream.push(null); nodeReadable.push(null);
}, },
})); })).catch((err) => {
nodeReadableStream.on("error", (err) => { nodeReadable.destroy(err);
console.error("readalbe stream error",err);
ctx.status = 500;
ctx.body = "Internal Server Error";
zip.reader.close();
return;
}); });
nodeReadableStream.on("close", () => {
nodeReadable.on("close", () => {
zip.reader.close();
});
nodeReadable.on("error", () => {
zip.reader.close(); zip.reader.close();
}); });
ctx.body = nodeReadableStream; const ext = entry.filename.split(".").pop()?.toLowerCase() ?? "jpeg";
ctx.response.length = entry.uncompressedSize; headers["Content-Type"] = extensionToMime(ext);
ctx.response.type = entry.filename.split(".").pop() as string; if (typeof entry.uncompressedSize === "number") {
ctx.status = 200; headers["Content-Length"] = entry.uncompressedSize.toString();
ctx.set("Date", new Date().toUTCString()); }
ctx.set("Last-Modified", last_modified.toUTCString());
} else { set.status = 200;
ctx.status = 404; 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;

View file

@ -1,248 +1,193 @@
import type { Context, Next } from "koa"; import { Elysia, t } from "elysia";
import Router from "koa-router";
import { join } from "node:path"; import { join } from "node:path";
import type { import type { Document, QueryListOption } from "dbtype";
Document,
QueryListOption,
} from "dbtype";
import type { DocumentAccessor } from "../model/doc.ts"; import type { DocumentAccessor } from "../model/doc.ts";
import { import { AdminOnly, createPermissionCheck, Permission as Per } from "../permission/permission.ts";
AdminOnlyMiddleware as AdminOnly,
createPermissionCheckMiddleware as PerCheck,
Permission as Per,
} from "../permission/permission.ts";
import { AllContentRouter } from "./all.ts";
import type { ContentLocation } from "./context.ts";
import { sendError } from "./error_handler.ts"; import { sendError } from "./error_handler.ts";
import { ParseQueryArgString, ParseQueryArray, ParseQueryBoolean, ParseQueryNumber } from "./util.ts";
import { oshash } from "src/util/oshash.ts"; import { oshash } from "src/util/oshash.ts";
import { renderComicPage } from "./comic.ts";
export const getContentRouter = (controller: DocumentAccessor) => {
const ContentIDHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => { return new Elysia({ name: "content-router" })
const num = Number.parseInt(ctx.params.num); .get("/search", async ({ query }) => {
const document = await controller.findById(num, true); const limit = Math.min(Number(query.limit ?? 20), 100);
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 = { const option: QueryListOption = {
limit: limit, limit: limit,
allow_tag: allow_tag, allow_tag: query.allow_tag?.split(",") ?? [],
word: word, word: query.word,
cursor: cursor, cursor: Number(query.cursor),
eager_loading: true, eager_loading: true,
offset: offset, offset: Number(query.offset),
use_offset: use_offset ?? false, use_offset: query.use_offset === 'true',
content_type: content_type, content_type: query.content_type,
}; };
const document = await controller.findList(option); return await controller.findList(option);
ctx.body = document; }, {
ctx.type = "json"; beforeHandle: createPermissionCheck(Per.QueryContent),
}; query: t.Object({
const UpdateContentHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => { limit: t.Optional(t.String()),
const num = Number.parseInt(ctx.params.num); cursor: t.Optional(t.String()),
word: t.Optional(t.String()),
if (ctx.request.type !== "json") { content_type: t.Optional(t.String()),
return sendError(400, "update fail. invalid document type: it is not json."); 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");
} }
if (typeof ctx.request.body !== "object") { return await controller.findByGidList(gid_list);
return sendError(400, "update fail. invalid argument: not"); }, {
} beforeHandle: createPermissionCheck(Per.QueryContent),
const content_desc: Partial<Document> & { id: number } = { query: t.Object({ gid: t.String() })
id: num, })
...ctx.request.body, .get("/:num", async ({ params: { num } }) => {
};
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); const document = await controller.findById(num, true);
if (document === undefined) { if (document === undefined) {
return sendError(404, "document does not exist."); throw sendError(404, "document does not exist.");
} }
if (document.deleted_at !== null) { return document;
return sendError(404, "document has been removed."); }, {
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);
} }
const path = join(document.basepath, document.filename); return await controller.getSimilarDocument(doc);
ctx.state.location = { }, {
path: path, beforeHandle: createPermissionCheck(Per.QueryContent),
type: document.content_type, params: t.Object({ num: t.Numeric() })
additional: document.additional, })
} as ContentLocation; .get("/:num/tags", async ({ params: { num } }) => {
await next(); const document = await controller.findById(num, true);
}; if (document === undefined) {
throw sendError(404, "document does not exist.");
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); return document.tags;
let new_hash: string; }, {
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 { try {
new_hash = (await oshash(filepath)).toString(); const new_hash = (await oshash(filepath)).toString();
} return await controller.update({ id: num, content_hash: new_hash });
catch (e) { } catch (e) {
// if file is not found, return 404 if ((e as NodeJS.ErrnoException).code === "ENOENT") {
if ( (e as NodeJS.ErrnoException).code === "ENOENT") { throw sendError(404, "file not found");
return sendError(404, "file not found");
} }
throw e; throw e;
} }
const r = await controller.update({ }, {
id: num, beforeHandle: AdminOnly,
content_hash: new_hash, 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,
}); });
ctx.body = JSON.stringify(r); return body ?? undefined;
ctx.type = "json"; }, {
}; beforeHandle: createPermissionCheck(Per.QueryContent),
} params: t.Object({ num: t.Numeric() }),
})
function getSimilarDocumentHandler(controller: DocumentAccessor) { .get("/comic/:page", async ({ document, params: { page }, request, set }) => {
return async (ctx: Context, next: Next) => { if (document.content_type !== "comic") {
const num = Number.parseInt(ctx.params.num); throw sendError(404);
const c = await controller.findById(num, true);
if (c === undefined) {
return sendError(404);
} }
const r = await controller.getSimilarDocument(c); const pageIndex = page;
ctx.body = r; const path = join(document.basepath, document.filename);
ctx.type = "json"; const body = await renderComicPage({
}; path,
} page: pageIndex,
reqHeaders: request.headers,
function getRescanDocumentHandler(controller: DocumentAccessor) { set,
return async (ctx: Context, next: Next) => { });
const num = Number.parseInt(ctx.params.num); return body ?? undefined;
const c = await controller.findById(num, true); }, {
if (c === undefined) { beforeHandle: createPermissionCheck(Per.QueryContent),
return sendError(404); params: t.Object({ num: t.Numeric(), page: t.Numeric() }),
} })
await controller.rescanDocument(c); );
// 204 No Content
ctx.status = 204;
};
}
function ContentGidListHandler(controller: DocumentAccessor) {
return async (ctx: Context, next: Next) => {
const gid_list = ParseQueryArray(ctx.query.gid).map((x) => Number.parseInt(x))
if (gid_list.some((x) => Number.isNaN(x))) {
return sendError(400, "gid is not a number");
}
// size limit
if (gid_list.length > 100) {
return sendError(400, "gid list is too long");
}
const r = await controller.findByGidList(gid_list);
ctx.body = r;
ctx.type = "json";
};
}
export const getContentRouter = (controller: DocumentAccessor) => {
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;
}; };
export default getContentRouter; export default getContentRouter;

View file

@ -1,5 +1,4 @@
import { ZodError } from "dbtype"; import { ZodError } from "dbtype";
import type { Context, Next } from "koa";
export interface ErrorFormat { export interface ErrorFormat {
code: number; code: number;
@ -7,15 +6,12 @@ export interface ErrorFormat {
detail?: string; detail?: string;
} }
class ClientRequestError implements Error { export class ClientRequestError extends Error {
name: string;
message: string;
stack?: string | undefined;
code: number; code: number;
constructor(code: number, message: string) { constructor(code: number, message: string) {
super(message);
this.name = "client request error"; this.name = "client request error";
this.message = message;
this.code = code; this.code = code;
} }
} }
@ -25,36 +21,32 @@ const code_to_message_table: { [key: number]: string | undefined } = {
404: "NotFound", 404: "NotFound",
}; };
export const error_handler = async (ctx: Context, next: Next) => { export const error_handler = ({ code, error, set }: { code: string, error: Error, set: { status?: number | string } }) => {
try { if (error instanceof ClientRequestError) {
await next(); set.status = error.code;
} catch (err) { return {
if (err instanceof ClientRequestError) { code: error.code,
const body: ErrorFormat = { message: code_to_message_table[error.code] ?? "",
code: err.code, detail: error.message,
message: code_to_message_table[err.code] ?? "", } satisfies ErrorFormat;
detail: err.message,
};
ctx.status = err.code;
ctx.body = body;
} }
else if (err instanceof ZodError) { if (error instanceof ZodError) {
const body: ErrorFormat = { set.status = 400;
return {
code: 400, code: 400,
message: "BadRequest", message: "BadRequest",
detail: err.errors.map((x) => x.message).join(", "), detail: error.errors.map((x) => x.message).join(", "),
}; } satisfies ErrorFormat;
ctx.status = 400;
ctx.body = body;
}
else {
throw err;
} }
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 ?? ""); throw new ClientRequestError(code, message ?? "");
}; };
export default error_handler;

View file

@ -1,29 +1,31 @@
import { type Context, Next } from "koa"; import { Elysia, t } from "elysia";
import Router, { type RouterContext } from "koa-router";
import type { TagAccessor } from "../model/tag.ts"; import type { TagAccessor } from "../model/tag.ts";
import { createPermissionCheckMiddleware as PerCheck, Permission } from "../permission/permission.ts"; import { createPermissionCheck, Permission } from "../permission/permission.ts";
import { sendError } from "./error_handler.ts"; import { sendError } from "./error_handler.ts";
export function getTagRounter(tagController: TagAccessor) { export function getTagRounter(tagController: TagAccessor) {
const router = new Router(); return new Elysia({ name: "tags-router" })
router.get("/", PerCheck(Permission.QueryContent), async (ctx: Context) => { .get("/", async ({ query }) => {
if (ctx.query.withCount) { if (query.withCount !== undefined) {
const c = await tagController.getAllTagCount(); return await tagController.getAllTagCount();
ctx.body = c;
} else {
const c = await tagController.getAllTagList();
ctx.body = c;
} }
ctx.type = "json"; return await tagController.getAllTagList();
}); }, {
router.get("/:tag_name", PerCheck(Permission.QueryContent), async (ctx: RouterContext) => { beforeHandle: createPermissionCheck(Permission.QueryContent),
const tag_name = ctx.params.tag_name; query: t.Object({
const c = await tagController.getTagByName(tag_name); withCount: t.Optional(t.String()),
if (!c) { })
})
.get("/:tag_name", async ({ params: { tag_name } }) => {
const tag = await tagController.getTagByName(tag_name);
if (!tag) {
sendError(404, "tags not found"); sendError(404, "tags not found");
} }
ctx.body = c; return tag;
ctx.type = "json"; }, {
beforeHandle: createPermissionCheck(Permission.QueryContent),
params: t.Object({
tag_name: t.String(),
})
}); });
return router;
} }

View file

@ -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;
}

View file

@ -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;

View file

@ -1,12 +1,13 @@
import Koa from "koa"; import { Elysia, t } from "elysia";
import Router from "koa-router"; import { cors } from "@elysiajs/cors";
import { staticPlugin } from "@elysiajs/static";
import { html } from "@elysiajs/html";
import { connectDB } from "./database.ts"; import { connectDB } from "./database.ts";
import { createDiffRouter, DiffManager } from "./diff/mod.ts"; import { createDiffRouter, DiffManager } from "./diff/mod.ts";
import { get_setting, SettingConfig } from "./SettingConfig.ts"; import { get_setting } from "./SettingConfig.ts";
import { createReadStream, readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import bodyparser from "koa-bodyparser";
import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts"; import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts";
import { createLoginRouter, createUserHandler, getAdmin, isAdminFirst } from "./login.ts"; import { createLoginRouter, createUserHandler, getAdmin, isAdminFirst } from "./login.ts";
import getContentRouter from "./route/contents.ts"; import getContentRouter from "./route/contents.ts";
@ -18,238 +19,129 @@ import type { DocumentAccessor, TagAccessor, UserAccessor } from "./model/mod.ts
import { getTagRounter } from "./route/tags.ts"; import { getTagRounter } from "./route/tags.ts";
import { config } from "dotenv"; import { config } from "dotenv";
import { extname, join } from "node:path";
config(); config();
class ServerApplication { function createMetaTagContent(key: string, value: string) {
readonly userController: UserAccessor; return `<meta property="${key}" content="${value}">`;
readonly documentController: DocumentAccessor; }
readonly tagController: TagAccessor;
readonly diffManger: DiffManager;
readonly app: Koa;
private index_html: string;
private constructor(controller: {
userController: UserAccessor;
documentController: DocumentAccessor;
tagController: TagAccessor;
}) {
this.userController = controller.userController;
this.documentController = controller.documentController;
this.tagController = controller.tagController;
this.diffManger = new DiffManager(this.documentController); function createOgTagContent(title: string, description:string, image: string) {
this.app = new Koa(); return [
this.index_html = readFileSync("dist/index.html", "utf-8"); 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;
} }
private async setup() { 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() {
const setting = get_setting(); const setting = get_setting();
const app = this.app; const db = await connectDB();
const userController = createSqliteUserController(db);
const documentController = createSqliteDocumentAccessor(db);
const tagController = createSqliteTagController(db);
const diffManger = new DiffManager(documentController);
diffManger.register("comic", createComicWatcher());
if (setting.cli) { if (setting.cli) {
const userAdmin = await getAdmin(this.userController); const userAdmin = await getAdmin(userController);
if (await isAdminFirst(userAdmin)) { if (await isAdminFirst(userAdmin)) {
const rl = createReadlineInterface({ const rl = createReadlineInterface({
input: process.stdin, input: process.stdin,
output: process.stdout, output: process.stdout,
}); });
const pw = await new Promise((res: (data: string) => void, err) => { const pw = await new Promise((res: (data: string) => void) => {
rl.question("put admin password :", (data) => { rl.question("put admin password :", (data) => {
res(data); res(data);
}); });
}); });
rl.close(); rl.close();
userAdmin.reset_password(pw); await userAdmin.reset_password(pw);
} }
} }
app.use(bodyparser());
app.use(error_handler);
app.use(createUserHandler(this.userController));
const diff_router = createDiffRouter(this.diffManger); const index_html = readFileSync("dist/index.html", "utf-8");
this.diffManger.register("comic", createComicWatcher());
console.log("setup router"); const app = new Elysia()
.use(cors())
const router = new Router(); .use(staticPlugin({
router.use("/api/(.*)", async (ctx, next) => { assets: "dist/assets",
// For CORS prefix: "/assets",
ctx.res.setHeader("access-control-allow-origin", "*"); headers: {
await next(); "X-Content-Type-Options": "nosniff",
}); "Cache-Control": setting.mode === "development" ? "no-cache" : "public, max-age=3600",
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()); .use(html())
console.log("setup done"); .onError((context) => error_handler({
} code: typeof context.code === "number" ? String(context.code) : context.code,
private serve_index(router: Router) { error: normalizeError(context.error),
const serveindex = (url: string) => { set: context.set,
router.get(url, (ctx) => { }))
ctx.type = "html"; .use(createUserHandler(userController))
ctx.body = this.index_html; .group("/api", (app) => app
const setting = get_setting(); .use(createDiffRouter(diffManger))
ctx.set("x-content-type-options", "no-sniff"); .use(getContentRouter(documentController))
if (setting.mode === "development") { .use(getTagRounter(tagController))
ctx.set("cache-control", "no-cache"); .use(createLoginRouter(userController))
} else { )
ctx.set("cache-control", "public, max-age=3600"); .get("/doc/:id", async ({ params: { id }, set }) => {
} const docId = Number.parseInt(id, 10);
}); const doc = await documentController.findById(docId, true);
};
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; let meta;
if (doc === undefined) { if (doc === undefined) {
ctx.status = 404; set.status = 404;
meta = NotFoundContent(); meta = createOgTagContent("Not Found Doc", "Not Found", "");
} else { } else {
ctx.status = 200; set.status = 200;
meta = createOgTagContent( meta = createOgTagContent(
doc.title, doc.title,
doc.tags.join(", "), doc.tags.join(", "),
`https://aeolian.monoid.top/api/doc/${docId}/comic/thumbnail`, `https://aeolian.monoid.top/api/doc/${docId}/comic/thumbnail`,
); );
} }
const html = makeMetaTagInjectedHTML(this.index_html, meta); return makeMetaTagInjectedHTML(index_html, meta);
serveHTML(ctx, html); }, {
}; params: t.Object({ id: t.String() })
router.get("/doc/:id(\\d+)", DocMiddleware); })
.get("/", () => index_html)
function NotFoundContent() { .get("/doc/*", () => index_html)
return createOgTagContent("Not Found Doc", "Not Found", ""); .get("/search", () => index_html)
} .get("/login", () => index_html)
function makeMetaTagInjectedHTML(html: string, tagContent: string) { .get("/profile", () => index_html)
return html.replace("<!--MetaTag-Outlet-->", tagContent); .get("/difference", () => index_html)
} .get("/setting", () => index_html)
function serveHTML(ctx: Koa.Context, file: string) { .get("/tags", () => index_html)
ctx.type = "html"; .listen({
ctx.body = file; hostname: setting.localmode ? "127.0.0.1" : "0.0.0.0",
const setting = get_setting(); port: setting.port,
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({ console.log(`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}`);
userController: createSqliteUserController(db),
documentController: createSqliteDocumentAccessor(db),
tagController: createSqliteTagController(db),
});
await app.setup();
return app; return app;
}
} }
export async function create_server() {
return await ServerApplication.createServer();
}
export default { create_server };

View 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());

View file

@ -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

File diff suppressed because it is too large Load diff