243 lines
		
	
	
	
		
			8 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			243 lines
		
	
	
	
		
			8 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import Koa from "koa";
 | |
| import Router from "koa-router";
 | |
| 
 | |
| import { connectDB } from "./database";
 | |
| import { createDiffRouter, DiffManager } from "./diff/mod";
 | |
| import { get_setting, SettingConfig } from "./SettingConfig";
 | |
| 
 | |
| import { createReadStream, readFileSync } from "node:fs";
 | |
| import bodyparser from "koa-bodyparser";
 | |
| import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod";
 | |
| import { createLoginRouter, createUserMiddleWare, getAdmin, isAdminFirst } from "./login";
 | |
| import getContentRouter from "./route/contents";
 | |
| import { error_handler } from "./route/error_handler";
 | |
| 
 | |
| import { createInterface as createReadlineInterface } from "node:readline";
 | |
| import { createComicWatcher } from "./diff/watcher/comic_watcher";
 | |
| import type { DocumentAccessor, TagAccessor, UserAccessor } from "./model/mod";
 | |
| import { getTagRounter } from "./route/tags";
 | |
| 
 | |
| import { config } from "dotenv";
 | |
| 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("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(createUserMiddleWare(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.prelude.duckdns.org/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) {
 | |
| 		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 setting = get_setting();
 | |
| 		const db = await connectDB();
 | |
| 
 | |
| 		const app = new ServerApplication({
 | |
| 			userController: createSqliteUserController(db),
 | |
| 			documentController: createSqliteDocumentAccessor(db),
 | |
| 			tagController: createSqliteTagController(db),
 | |
| 		});
 | |
| 		await app.setup();
 | |
| 		return app;
 | |
| 	}
 | |
| }
 | |
| 
 | |
| export async function create_server() {
 | |
| 	return await ServerApplication.createServer();
 | |
| }
 | |
| 
 | |
| export default { create_server };
 |