db 마이그레이션 기능 #17
					 15 changed files with 664 additions and 479 deletions
				
			
		| 
						 | 
					@ -53,6 +53,12 @@ export const UserSchema = z.object({
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type User = z.infer<typeof UserSchema>;
 | 
					export type User = z.infer<typeof UserSchema>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const UserSettingSchema = z.object({
 | 
				
			||||||
 | 
						fileDeepLinkRegex: z.string().optional(),
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type UserSetting = z.infer<typeof UserSettingSchema>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const SchemaMigrationSchema = z.object({
 | 
					export const SchemaMigrationSchema = z.object({
 | 
				
			||||||
	version: z.string().nullable(),
 | 
						version: z.string().nullable(),
 | 
				
			||||||
	dirty: z.boolean(),
 | 
						dirty: z.boolean(),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -45,6 +45,11 @@ export interface Users {
 | 
				
			||||||
  username: string | null;
 | 
					  username: string | null;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface UserSettings {
 | 
				
			||||||
 | 
					  username: string;
 | 
				
			||||||
 | 
					  settings: string | null;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface DB {
 | 
					export interface DB {
 | 
				
			||||||
  doc_tag_relation: DocTagRelation;
 | 
					  doc_tag_relation: DocTagRelation;
 | 
				
			||||||
  document: Document;
 | 
					  document: Document;
 | 
				
			||||||
| 
						 | 
					@ -52,4 +57,5 @@ export interface DB {
 | 
				
			||||||
  schema_migration: SchemaMigration;
 | 
					  schema_migration: SchemaMigration;
 | 
				
			||||||
  tags: Tags;
 | 
					  tags: Tags;
 | 
				
			||||||
  users: Users;
 | 
					  users: Users;
 | 
				
			||||||
 | 
					  user_settings: UserSettings;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,50 +0,0 @@
 | 
				
			||||||
// import { promises } from "fs";
 | 
					 | 
				
			||||||
// const { readdir, writeFile } = promises;
 | 
					 | 
				
			||||||
// import { dirname, join } from "path";
 | 
					 | 
				
			||||||
// import { createGenerator } from "ts-json-schema-generator";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// async function genSchema(path: string, typename: string) {
 | 
					 | 
				
			||||||
// 	const gen = createGenerator({
 | 
					 | 
				
			||||||
// 		path: path,
 | 
					 | 
				
			||||||
// 		type: typename,
 | 
					 | 
				
			||||||
// 		tsconfig: "tsconfig.json",
 | 
					 | 
				
			||||||
// 	});
 | 
					 | 
				
			||||||
// 	const schema = gen.createSchema(typename);
 | 
					 | 
				
			||||||
// 	if (schema.definitions != undefined) {
 | 
					 | 
				
			||||||
// 		const definitions = schema.definitions;
 | 
					 | 
				
			||||||
// 		const definition = definitions[typename];
 | 
					 | 
				
			||||||
// 		if (typeof definition == "object") {
 | 
					 | 
				
			||||||
// 			let property = definition.properties;
 | 
					 | 
				
			||||||
// 			if (property) {
 | 
					 | 
				
			||||||
// 				property["$schema"] = {
 | 
					 | 
				
			||||||
// 					type: "string",
 | 
					 | 
				
			||||||
// 				};
 | 
					 | 
				
			||||||
// 			}
 | 
					 | 
				
			||||||
// 		}
 | 
					 | 
				
			||||||
// 	}
 | 
					 | 
				
			||||||
// 	const text = JSON.stringify(schema);
 | 
					 | 
				
			||||||
// 	await writeFile(join(dirname(path), `${typename}.schema.json`), text);
 | 
					 | 
				
			||||||
// }
 | 
					 | 
				
			||||||
// function capitalize(s: string) {
 | 
					 | 
				
			||||||
// 	return s.charAt(0).toUpperCase() + s.slice(1);
 | 
					 | 
				
			||||||
// }
 | 
					 | 
				
			||||||
// async function setToALL(path: string) {
 | 
					 | 
				
			||||||
// 	console.log(`scan ${path}`);
 | 
					 | 
				
			||||||
// 	const direntry = await readdir(path, { withFileTypes: true });
 | 
					 | 
				
			||||||
// 	const works = direntry
 | 
					 | 
				
			||||||
// 		.filter((x) => x.isFile() && x.name.endsWith("Config.ts"))
 | 
					 | 
				
			||||||
// 		.map((x) => {
 | 
					 | 
				
			||||||
// 			const name = x.name;
 | 
					 | 
				
			||||||
// 			const m = /(.+)\.ts/.exec(name);
 | 
					 | 
				
			||||||
// 			if (m !== null) {
 | 
					 | 
				
			||||||
// 				const typename = m[1];
 | 
					 | 
				
			||||||
// 				return genSchema(join(path, typename), capitalize(typename));
 | 
					 | 
				
			||||||
// 			}
 | 
					 | 
				
			||||||
// 		});
 | 
					 | 
				
			||||||
// 	await Promise.all(works);
 | 
					 | 
				
			||||||
// 	const subdir = direntry.filter((x) => x.isDirectory()).map((x) => x.name);
 | 
					 | 
				
			||||||
// 	for (const x of subdir) {
 | 
					 | 
				
			||||||
// 		await setToALL(join(path, x));
 | 
					 | 
				
			||||||
// 	}
 | 
					 | 
				
			||||||
// }
 | 
					 | 
				
			||||||
// setToALL("src");
 | 
					 | 
				
			||||||
| 
						 | 
					@ -65,8 +65,8 @@ export async function up(db: Kysely<any>) {
 | 
				
			||||||
    await db
 | 
					    await db
 | 
				
			||||||
        .insertInto('schema_migration')
 | 
					        .insertInto('schema_migration')
 | 
				
			||||||
        .values({
 | 
					        .values({
 | 
				
			||||||
            version: '0.0.1',
 | 
					            version: '2024-12-27',
 | 
				
			||||||
            dirty: false,
 | 
					            dirty: 0,
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
        .execute();
 | 
					        .execute();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										18
									
								
								packages/server/migrations/2025-06-26.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								packages/server/migrations/2025-06-26.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,18 @@
 | 
				
			||||||
 | 
					import { Kysely, sql } from 'kysely';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function up(db: Kysely<any>) {
 | 
				
			||||||
 | 
					    await db.schema
 | 
				
			||||||
 | 
					        .createTable("user_settings")
 | 
				
			||||||
 | 
					        .addColumn("username", "varchar(256)", col => col.notNull().primaryKey())
 | 
				
			||||||
 | 
					        .addColumn("settings", "json", col => col.notNull())
 | 
				
			||||||
 | 
					        .addForeignKeyConstraint("user_settings_username_fk", ["username"], "users", ["username"])
 | 
				
			||||||
 | 
					        .execute();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await db.updateTable("schema_migration")
 | 
				
			||||||
 | 
					        .set({ version: "2025-06-26", dirty: 0 })
 | 
				
			||||||
 | 
					        .execute();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function down(db: Kysely<any>) {
 | 
				
			||||||
 | 
					    throw new Error('Downward migrations are not supported. Restore from backup.');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -6,37 +6,37 @@
 | 
				
			||||||
	"type": "module",
 | 
						"type": "module",
 | 
				
			||||||
	"scripts": {
 | 
						"scripts": {
 | 
				
			||||||
		"dev": "tsx watch src/app.ts",
 | 
							"dev": "tsx watch src/app.ts",
 | 
				
			||||||
		"start": "tsx src/app.ts"
 | 
							"start": "tsx src/app.ts",
 | 
				
			||||||
 | 
							"migrate": "tsx tools/migration.ts"
 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
	"author": "",
 | 
						"author": "",
 | 
				
			||||||
	"license": "ISC",
 | 
						"license": "ISC",
 | 
				
			||||||
	"dependencies": {
 | 
						"dependencies": {
 | 
				
			||||||
		"@std/async": "npm:@jsr/std__async@^1.0.12",
 | 
							"@std/async": "npm:@jsr/std__async@^1.0.13",
 | 
				
			||||||
		"@zip.js/zip.js": "^2.7.60",
 | 
							"@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.4.5",
 | 
							"dotenv": "^16.5.0",
 | 
				
			||||||
		"jose": "^5.9.3",
 | 
							"jose": "^5.10.0",
 | 
				
			||||||
		"koa": "^2.15.3",
 | 
							"koa": "^2.16.1",
 | 
				
			||||||
		"koa-bodyparser": "^4.4.1",
 | 
							"koa-bodyparser": "^4.4.1",
 | 
				
			||||||
		"koa-compose": "^4.1.0",
 | 
							"koa-compose": "^4.1.0",
 | 
				
			||||||
		"koa-router": "^12.0.1",
 | 
							"koa-router": "^12.0.1",
 | 
				
			||||||
		"kysely": "^0.27.4",
 | 
							"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.11",
 | 
							"@types/better-sqlite3": "^7.6.13",
 | 
				
			||||||
		"@types/jsonwebtoken": "^8.5.9",
 | 
							"@types/jsonwebtoken": "^8.5.9",
 | 
				
			||||||
		"@types/koa": "^2.15.0",
 | 
							"@types/koa": "^2.15.0",
 | 
				
			||||||
		"@types/koa-bodyparser": "^4.3.12",
 | 
							"@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/koa-router": "^7.4.8",
 | 
				
			||||||
		"@types/node": "^22.15.3",
 | 
							"@types/node": "^22.15.33",
 | 
				
			||||||
		"@types/tiny-async-pool": "^1.0.5",
 | 
							"@types/tiny-async-pool": "^1.0.5",
 | 
				
			||||||
		"nodemon": "^3.1.7",
 | 
							"tsx": "^4.20.3",
 | 
				
			||||||
		"tsx": "^4.19.1",
 | 
							"typescript": "^5.8.3"
 | 
				
			||||||
		"typescript": "^5.6.2"
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,7 +0,0 @@
 | 
				
			||||||
// import { contextBridge, ipcRenderer } from "electron";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// contextBridge.exposeInMainWorld("electron", {
 | 
					 | 
				
			||||||
// 	passwordReset: async (username: string, toPw: string) => {
 | 
					 | 
				
			||||||
// 		return await ipcRenderer.invoke("reset_password", username, toPw);
 | 
					 | 
				
			||||||
// 	},
 | 
					 | 
				
			||||||
// });
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,14 +1,16 @@
 | 
				
			||||||
import { Kysely } from "kysely";
 | 
					import { Kysely } from "kysely";
 | 
				
			||||||
import { getKysely } from "./db/kysely.ts";
 | 
					import { getKysely } from "./db/kysely.ts";
 | 
				
			||||||
 | 
					import fs from "node:fs/promises";
 | 
				
			||||||
 | 
					import path from "path";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function connectDB() {
 | 
					export async function connectDB() {
 | 
				
			||||||
	const kysely = getKysely();
 | 
						const kysely = getKysely();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	let tries = 0;
 | 
						let tries = 0;
 | 
				
			||||||
	for (;;) {
 | 
						for (; ;) {
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			console.log("try to connect db");
 | 
								console.log("try to connect db");
 | 
				
			||||||
			await kysely.selectNoFrom(eb=> eb.val(1).as("dummy")).execute();
 | 
								await kysely.selectNoFrom(eb => eb.val(1).as("dummy")).execute();
 | 
				
			||||||
			console.log("connect success");
 | 
								console.log("connect success");
 | 
				
			||||||
		} catch (err) {
 | 
							} catch (err) {
 | 
				
			||||||
			if (tries < 3) {
 | 
								if (tries < 3) {
 | 
				
			||||||
| 
						 | 
					@ -25,20 +27,72 @@ export async function connectDB() {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function checkTableExists(kysely: Kysely<any>, table: string) {
 | 
					async function checkTableExists(kysely: Kysely<any>, table: string) {
 | 
				
			||||||
	const result = await kysely.selectFrom("sqlite_master").where("type", "=", "table").where("name", "=", table).executeTakeFirst();
 | 
						const result = await kysely.selectFrom("sqlite_master")
 | 
				
			||||||
 | 
							.selectAll()
 | 
				
			||||||
 | 
							.where("type", "=", "table")
 | 
				
			||||||
 | 
							.where("name", "=", table)
 | 
				
			||||||
 | 
							.executeTakeFirst();
 | 
				
			||||||
	return result !== undefined;
 | 
						return result !== undefined;
 | 
				
			||||||
} 
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function migrateDB() {
 | 
					export async function migrateDB() {
 | 
				
			||||||
	const kysely = getKysely();
 | 
						const kysely = getKysely();
 | 
				
			||||||
	let version_number = 0;
 | 
					 | 
				
			||||||
	// is schema_migration exists?
 | 
						// is schema_migration exists?
 | 
				
			||||||
	const hasTable = await checkTableExists(kysely, "schema_migration");
 | 
						const hasTable = await checkTableExists(kysely, "schema_migration");
 | 
				
			||||||
	if (!hasTable) {
 | 
						if (!hasTable) {
 | 
				
			||||||
		// migrate from 0
 | 
							// 2. 마이그레이션 실행 (최초 마이그레이션)
 | 
				
			||||||
		// create schema_migration
 | 
							const migration = await import("../migrations/2024-12-27.ts");
 | 
				
			||||||
 | 
							await migration.up(kysely);
 | 
				
			||||||
 | 
							console.log("최초 마이그레이션 완료");
 | 
				
			||||||
 | 
							return;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const version = await kysely.selectFrom("schema_migration").executeTakeFirst();
 | 
						// 현재 버전 확인
 | 
				
			||||||
 | 
						const row = await kysely.selectFrom("schema_migration").selectAll().executeTakeFirst();
 | 
				
			||||||
 | 
						const currentVersion = row?.version ?? "0001-01-01"; // 기본값 설정
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// 마이그레이션 목록 정의 (버전순 정렬 필수)
 | 
				
			||||||
 | 
						const migrations = await readMigrations();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// 현재 버전보다 높은 migration만 실행
 | 
				
			||||||
 | 
						let lastestVersion = currentVersion;
 | 
				
			||||||
 | 
						console.log(`현재 DB 버전: ${currentVersion}`);
 | 
				
			||||||
 | 
						for (const m of migrations) {
 | 
				
			||||||
 | 
							if (compareVersion(m.version, currentVersion) > 0) {
 | 
				
			||||||
 | 
								console.log(`마이그레이션 실행: ${m.version}`);
 | 
				
			||||||
 | 
								const migration = await import(m.file);
 | 
				
			||||||
 | 
								await migration.up(kysely);
 | 
				
			||||||
 | 
								await kysely.updateTable("schema_migration")
 | 
				
			||||||
 | 
									.set({ version: m.version, dirty: 0 })
 | 
				
			||||||
 | 
									.execute();
 | 
				
			||||||
 | 
								lastestVersion = m.version;
 | 
				
			||||||
 | 
								console.log(`마이그레이션 완료: ${m.version}`);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if (lastestVersion !== currentVersion) {
 | 
				
			||||||
 | 
							console.log(`마이그레이션 완료. ${currentVersion} -> ${lastestVersion}`);
 | 
				
			||||||
 | 
						} else {
 | 
				
			||||||
 | 
							console.log("마이그레이션 필요 없음");
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function readMigrations(): Promise<{ version: string; file: string }[]> {
 | 
				
			||||||
 | 
						const migrationsDir = path.join(import.meta.dirname, "../migrations");
 | 
				
			||||||
 | 
						const files = (await fs.readdir(migrationsDir))
 | 
				
			||||||
 | 
							.filter(file => file.endsWith(".ts"))
 | 
				
			||||||
 | 
							.map(file => {
 | 
				
			||||||
 | 
								const version = file.match(/(\d{4}-\d{2}-\d{2})/)?.[0] || "0001-01-01";
 | 
				
			||||||
 | 
								return { version, file: `../migrations/${file}` };
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						return files.sort((a, b) => compareVersion(a.version, b.version));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Date 기반 버전 비교 함수. 
 | 
				
			||||||
 | 
					function compareVersion(a: string, b: string): number {
 | 
				
			||||||
 | 
						const dateA = new Date(a);
 | 
				
			||||||
 | 
						const dateB = new Date(b);
 | 
				
			||||||
 | 
						if (dateA < dateB) return -1;
 | 
				
			||||||
 | 
						if (dateA > dateB) return 1;
 | 
				
			||||||
 | 
						return 0; // 같을 경우
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
import { getKysely } from "./kysely.ts";
 | 
					import { getKysely } from "./kysely.ts";
 | 
				
			||||||
import { type IUser, Password, type UserAccessor, type UserCreateInput } from "../model/user.ts";
 | 
					import { type IUser, IUserSettings, Password, type UserAccessor, type UserCreateInput } from "../model/user.ts";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SqliteUser implements IUser {
 | 
					class SqliteUser implements IUser {
 | 
				
			||||||
	readonly username: string;
 | 
						readonly username: string;
 | 
				
			||||||
| 
						 | 
					@ -41,6 +41,24 @@ class SqliteUser implements IUser {
 | 
				
			||||||
			.executeTakeFirst();
 | 
								.executeTakeFirst();
 | 
				
			||||||
		return (result.numDeletedRows ?? 0n) > 0;
 | 
							return (result.numDeletedRows ?? 0n) > 0;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						async get_settings(): Promise<IUserSettings | undefined> {
 | 
				
			||||||
 | 
							const settings = await this.kysely
 | 
				
			||||||
 | 
								.selectFrom("user_settings")
 | 
				
			||||||
 | 
								.select("settings")
 | 
				
			||||||
 | 
								.where("username", "=", this.username)
 | 
				
			||||||
 | 
								.executeTakeFirst();
 | 
				
			||||||
 | 
							if (!settings) return undefined;
 | 
				
			||||||
 | 
							return settings.settings ? JSON.parse(settings.settings) as IUserSettings : undefined;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						async set_settings(settings: IUserSettings) {
 | 
				
			||||||
 | 
							const settingsJson = JSON.stringify(settings);
 | 
				
			||||||
 | 
							const result = await this.kysely
 | 
				
			||||||
 | 
								.insertInto("user_settings")
 | 
				
			||||||
 | 
								.values({ username: this.username, settings: settingsJson })
 | 
				
			||||||
 | 
								.onConflict((oc) => oc.doUpdateSet({ settings: settingsJson }))
 | 
				
			||||||
 | 
								.executeTakeFirst();
 | 
				
			||||||
 | 
							return (result.numInsertedOrUpdatedRows ?? 0n) > 0;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const createSqliteUserController = (kysely = getKysely()): UserAccessor => {
 | 
					export const createSqliteUserController = (kysely = getKysely()): UserAccessor => {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -76,7 +76,7 @@ function setToken(ctx: Koa.Context, token_name: string, token_payload: string |
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const createLoginMiddleware = (userController: UserAccessor) => async (ctx: Koa.Context, _next: Koa.Next) => {
 | 
					export const createLoginHandler = (userController: UserAccessor) => async (ctx: Koa.Context, _next: Koa.Next) => {
 | 
				
			||||||
	const setting = get_setting();
 | 
						const setting = get_setting();
 | 
				
			||||||
	const secretKey = setting.jwt_secretkey;
 | 
						const secretKey = setting.jwt_secretkey;
 | 
				
			||||||
	const body = ctx.request.body;
 | 
						const body = ctx.request.body;
 | 
				
			||||||
| 
						 | 
					@ -115,7 +115,7 @@ export const createLoginMiddleware = (userController: UserAccessor) => async (ct
 | 
				
			||||||
	return;
 | 
						return;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const LogoutMiddleware = (ctx: Koa.Context, next: Koa.Next) => {
 | 
					export const LogoutHandler = (ctx: Koa.Context, _next: Koa.Next) => {
 | 
				
			||||||
	const setting = get_setting();
 | 
						const setting = get_setting();
 | 
				
			||||||
	ctx.cookies.set(accessTokenName, null);
 | 
						ctx.cookies.set(accessTokenName, null);
 | 
				
			||||||
	ctx.cookies.set(refreshTokenName, null);
 | 
						ctx.cookies.set(refreshTokenName, null);
 | 
				
			||||||
| 
						 | 
					@ -127,9 +127,9 @@ export const LogoutMiddleware = (ctx: Koa.Context, next: Koa.Next) => {
 | 
				
			||||||
	return;
 | 
						return;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const createUserMiddleWare =
 | 
					export const createUserHandler =
 | 
				
			||||||
	(userController: UserAccessor) => async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
 | 
						(userController: UserAccessor) => async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
 | 
				
			||||||
		const refreshToken = refreshTokenHandler(userController);
 | 
							const refreshToken = makeRefreshToken(userController);
 | 
				
			||||||
		const setting = get_setting();
 | 
							const setting = get_setting();
 | 
				
			||||||
		const setGuest = async () => {
 | 
							const setGuest = async () => {
 | 
				
			||||||
			setToken(ctx, accessTokenName, null, 0);
 | 
								setToken(ctx, accessTokenName, null, 0);
 | 
				
			||||||
| 
						 | 
					@ -140,7 +140,7 @@ export const createUserMiddleWare =
 | 
				
			||||||
		return await refreshToken(ctx, setGuest, next);
 | 
							return await refreshToken(ctx, setGuest, next);
 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const refreshTokenHandler = (cntr: UserAccessor) => async (ctx: Koa.Context, fail: Koa.Next, next: Koa.Next) => {
 | 
					const makeRefreshToken = (cntr: UserAccessor) => async (ctx: Koa.Context, fail: Koa.Next, next: Koa.Next) => {
 | 
				
			||||||
	const accessPayload = ctx.cookies.get(accessTokenName);
 | 
						const accessPayload = ctx.cookies.get(accessTokenName);
 | 
				
			||||||
	const setting = get_setting();
 | 
						const setting = get_setting();
 | 
				
			||||||
	const secretKey = setting.jwt_secretkey;
 | 
						const secretKey = setting.jwt_secretkey;
 | 
				
			||||||
| 
						 | 
					@ -200,7 +200,7 @@ const refreshTokenHandler = (cntr: UserAccessor) => async (ctx: Koa.Context, fai
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => {
 | 
					export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => {
 | 
				
			||||||
	const handler = refreshTokenHandler(cntr);
 | 
						const handler = makeRefreshToken(cntr);
 | 
				
			||||||
	await handler(ctx, fail, success);
 | 
						await handler(ctx, fail, success);
 | 
				
			||||||
	async function fail() {
 | 
						async function fail() {
 | 
				
			||||||
		const user = ctx.state.user as PayloadInfo;
 | 
							const user = ctx.state.user as PayloadInfo;
 | 
				
			||||||
| 
						 | 
					@ -242,12 +242,54 @@ export const resetPasswordMiddleware = (cntr: UserAccessor) => async (ctx: Koa.C
 | 
				
			||||||
	ctx.type = "json";
 | 
						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) {
 | 
					export function createLoginRouter(userController: UserAccessor) {
 | 
				
			||||||
	const router = new Router();
 | 
						const router = new Router();
 | 
				
			||||||
	router.post("/login", createLoginMiddleware(userController));
 | 
						router.post("/login", createLoginHandler(userController));
 | 
				
			||||||
	router.post("/logout", LogoutMiddleware);
 | 
						router.post("/logout", LogoutHandler);
 | 
				
			||||||
	router.post("/refresh", createRefreshTokenMiddleware(userController));
 | 
						router.post("/refresh", createRefreshTokenMiddleware(userController));
 | 
				
			||||||
	router.post("/reset", resetPasswordMiddleware(userController));
 | 
						router.post("/reset", resetPasswordMiddleware(userController));
 | 
				
			||||||
 | 
						router.get("/settings", getUserSettingHandler(userController));
 | 
				
			||||||
 | 
						router.post("/settings", setUserSettingHandler(userController));
 | 
				
			||||||
	return router;
 | 
						return router;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,3 +1,4 @@
 | 
				
			||||||
 | 
					import { UserSetting } from "dbtype";
 | 
				
			||||||
import { createHmac, randomBytes } from "node:crypto";
 | 
					import { createHmac, randomBytes } from "node:crypto";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function hashForPassword(salt: string, password: string) {
 | 
					function hashForPassword(salt: string, password: string) {
 | 
				
			||||||
| 
						 | 
					@ -41,6 +42,8 @@ export interface UserCreateInput {
 | 
				
			||||||
	password: string;
 | 
						password: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type IUserSettings = UserSetting;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface IUser {
 | 
					export interface IUser {
 | 
				
			||||||
	readonly username: string;
 | 
						readonly username: string;
 | 
				
			||||||
	readonly password: Password;
 | 
						readonly password: Password;
 | 
				
			||||||
| 
						 | 
					@ -65,6 +68,18 @@ export interface IUser {
 | 
				
			||||||
	 * @param password password to set
 | 
						 * @param password password to set
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	reset_password(password: string): Promise<void>;
 | 
						reset_password(password: string): Promise<void>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * get user settings
 | 
				
			||||||
 | 
						 * @returns user settings, or undefined if not set
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
 | 
						get_settings(): Promise<IUserSettings | undefined>;
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * set user settings
 | 
				
			||||||
 | 
						 * @param settings user settings to set
 | 
				
			||||||
 | 
						 * @returns if settings updated, return true
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
 | 
						set_settings(settings: IUserSettings): Promise<boolean>;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface UserAccessor {
 | 
					export interface UserAccessor {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -36,20 +36,21 @@ export enum Permission {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const createPermissionCheckMiddleware =
 | 
					export const createPermissionCheckMiddleware =
 | 
				
			||||||
	(...permissions: string[]) =>
 | 
						(...permissions: string[]) =>
 | 
				
			||||||
	async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
 | 
							async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
 | 
				
			||||||
		const user = ctx.state.user;
 | 
								const user = ctx.state.user;
 | 
				
			||||||
		if (user.username === "admin") {
 | 
								if (user.username === "admin") {
 | 
				
			||||||
			return await next();
 | 
									return await next();
 | 
				
			||||||
		}
 | 
								}
 | 
				
			||||||
		const user_permission = user.permission;
 | 
								const user_permission = user.permission;
 | 
				
			||||||
		// if permissions is not subset of user permission
 | 
								// if permissions is not subset of user permission
 | 
				
			||||||
		if (!permissions.map((p) => user_permission.includes(p)).every((x) => x)) {
 | 
								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.");
 | 
										return sendError(401, "you are guest. login needed.");
 | 
				
			||||||
			}return sendError(403, "do not have permission");
 | 
									} return sendError(403, "do not have permission");
 | 
				
			||||||
		}
 | 
								}
 | 
				
			||||||
		await next();
 | 
								await next();
 | 
				
			||||||
	};
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
 | 
					export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
 | 
				
			||||||
	const user = ctx.state.user;
 | 
						const user = ctx.state.user;
 | 
				
			||||||
	if (user.username !== "admin") {
 | 
						if (user.username !== "admin") {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,7 +8,7 @@ import { get_setting, SettingConfig } from "./SettingConfig.ts";
 | 
				
			||||||
import { createReadStream, readFileSync } from "node:fs";
 | 
					import { createReadStream, readFileSync } from "node:fs";
 | 
				
			||||||
import bodyparser from "koa-bodyparser";
 | 
					import bodyparser from "koa-bodyparser";
 | 
				
			||||||
import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts";
 | 
					import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts";
 | 
				
			||||||
import { createLoginRouter, createUserMiddleWare, 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";
 | 
				
			||||||
import { error_handler } from "./route/error_handler.ts";
 | 
					import { error_handler } from "./route/error_handler.ts";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -63,7 +63,7 @@ class ServerApplication {
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		app.use(bodyparser());
 | 
							app.use(bodyparser());
 | 
				
			||||||
		app.use(error_handler);
 | 
							app.use(error_handler);
 | 
				
			||||||
		app.use(createUserMiddleWare(this.userController));
 | 
							app.use(createUserHandler(this.userController));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		const diff_router = createDiffRouter(this.diffManger);
 | 
							const diff_router = createDiffRouter(this.diffManger);
 | 
				
			||||||
		this.diffManger.register("comic", createComicWatcher());
 | 
							this.diffManger.register("comic", createComicWatcher());
 | 
				
			||||||
| 
						 | 
					@ -238,8 +238,6 @@ class ServerApplication {
 | 
				
			||||||
	static async createServer() {
 | 
						static async createServer() {
 | 
				
			||||||
		const db = await connectDB();
 | 
							const db = await connectDB();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// todo : db migration
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		const app = new ServerApplication({
 | 
							const app = new ServerApplication({
 | 
				
			||||||
			userController: createSqliteUserController(db),
 | 
								userController: createSqliteUserController(db),
 | 
				
			||||||
			documentController: createSqliteDocumentAccessor(db),
 | 
								documentController: createSqliteDocumentAccessor(db),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										16
									
								
								packages/server/tools/migration.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								packages/server/tools/migration.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,16 @@
 | 
				
			||||||
 | 
					import { migrateDB } from "../src/database.ts";
 | 
				
			||||||
 | 
					import { config } from "dotenv";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					config(); // Load environment variables from .env file
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function runMigration() {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					        await migrateDB();
 | 
				
			||||||
 | 
					        console.log("Database migration completed successfully.");
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					        console.error("Database migration failed:", error);
 | 
				
			||||||
 | 
					        process.exit(1);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					await runMigration();
 | 
				
			||||||
							
								
								
									
										814
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										814
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
		Loading…
	
	Add table
		
		Reference in a new issue