From d530dd8cb6dcb96cd745a866782fe2e3a7eb58c3 Mon Sep 17 00:00:00 2001 From: monoid Date: Thu, 26 Jun 2025 20:54:20 +0900 Subject: [PATCH] feat: add user settings management and update related handlers --- packages/dbtype/src/api.ts | 6 ++ packages/dbtype/src/types.ts | 6 ++ packages/server/migrations/2025-06-26.ts | 2 +- packages/server/src/db/user.ts | 20 ++++++- packages/server/src/login.ts | 58 +++++++++++++++++--- packages/server/src/model/user.ts | 15 +++++ packages/server/src/permission/permission.ts | 29 +++++----- packages/server/src/server.ts | 4 +- 8 files changed, 114 insertions(+), 26 deletions(-) diff --git a/packages/dbtype/src/api.ts b/packages/dbtype/src/api.ts index 1634c7e..6ee2075 100644 --- a/packages/dbtype/src/api.ts +++ b/packages/dbtype/src/api.ts @@ -53,6 +53,12 @@ export const UserSchema = z.object({ export type User = z.infer; +export const UserSettingSchema = z.object({ + fileDeepLinkRegex: z.string().optional(), +}); + +export type UserSetting = z.infer; + export const SchemaMigrationSchema = z.object({ version: z.string().nullable(), dirty: z.boolean(), diff --git a/packages/dbtype/src/types.ts b/packages/dbtype/src/types.ts index cbdb075..d7bd3d2 100644 --- a/packages/dbtype/src/types.ts +++ b/packages/dbtype/src/types.ts @@ -45,6 +45,11 @@ export interface Users { username: string | null; } +export interface UserSettings { + username: string; + settings: string | null; +} + export interface DB { doc_tag_relation: DocTagRelation; document: Document; @@ -52,4 +57,5 @@ export interface DB { schema_migration: SchemaMigration; tags: Tags; users: Users; + user_settings: UserSettings; } diff --git a/packages/server/migrations/2025-06-26.ts b/packages/server/migrations/2025-06-26.ts index 08e8e77..43688e4 100644 --- a/packages/server/migrations/2025-06-26.ts +++ b/packages/server/migrations/2025-06-26.ts @@ -4,7 +4,7 @@ export async function up(db: Kysely) { await db.schema .createTable("user_settings") .addColumn("username", "varchar(256)", col => col.notNull().primaryKey()) - .addColumn("settings", "jsonb", col => col.notNull()) + .addColumn("settings", "json", col => col.notNull()) .addForeignKeyConstraint("user_settings_username_fk", ["username"], "users", ["username"]) .execute(); diff --git a/packages/server/src/db/user.ts b/packages/server/src/db/user.ts index 84cdf5f..d73cf2e 100644 --- a/packages/server/src/db/user.ts +++ b/packages/server/src/db/user.ts @@ -1,5 +1,5 @@ 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 { readonly username: string; @@ -41,6 +41,24 @@ class SqliteUser implements IUser { .executeTakeFirst(); return (result.numDeletedRows ?? 0n) > 0; } + async get_settings(): Promise { + 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 => { diff --git a/packages/server/src/login.ts b/packages/server/src/login.ts index 7d347c9..d38c9b3 100644 --- a/packages/server/src/login.ts +++ b/packages/server/src/login.ts @@ -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 secretKey = setting.jwt_secretkey; const body = ctx.request.body; @@ -115,7 +115,7 @@ export const createLoginMiddleware = (userController: UserAccessor) => async (ct return; }; -export const LogoutMiddleware = (ctx: Koa.Context, next: Koa.Next) => { +export const LogoutHandler = (ctx: Koa.Context, _next: Koa.Next) => { const setting = get_setting(); ctx.cookies.set(accessTokenName, null); ctx.cookies.set(refreshTokenName, null); @@ -127,9 +127,9 @@ export const LogoutMiddleware = (ctx: Koa.Context, next: Koa.Next) => { return; }; -export const createUserMiddleWare = +export const createUserHandler = (userController: UserAccessor) => async (ctx: Koa.ParameterizedContext, next: Koa.Next) => { - const refreshToken = refreshTokenHandler(userController); + const refreshToken = makeRefreshToken(userController); const setting = get_setting(); const setGuest = async () => { setToken(ctx, accessTokenName, null, 0); @@ -140,7 +140,7 @@ export const createUserMiddleWare = 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 setting = get_setting(); 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) => { - const handler = refreshTokenHandler(cntr); + const handler = makeRefreshToken(cntr); await handler(ctx, fail, success); async function fail() { const user = ctx.state.user as PayloadInfo; @@ -242,12 +242,54 @@ export const resetPasswordMiddleware = (cntr: UserAccessor) => async (ctx: Koa.C ctx.type = "json"; }; +export function getUserSettingHandler(userController: UserAccessor) { + return async (ctx: Koa.ParameterizedContext, 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, 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; + 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", createLoginMiddleware(userController)); - router.post("/logout", LogoutMiddleware); + 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; } diff --git a/packages/server/src/model/user.ts b/packages/server/src/model/user.ts index fdfff72..b0adc1f 100644 --- a/packages/server/src/model/user.ts +++ b/packages/server/src/model/user.ts @@ -1,3 +1,4 @@ +import { UserSetting } from "dbtype"; import { createHmac, randomBytes } from "node:crypto"; function hashForPassword(salt: string, password: string) { @@ -41,6 +42,8 @@ export interface UserCreateInput { password: string; } +export type IUserSettings = UserSetting; + export interface IUser { readonly username: string; readonly password: Password; @@ -65,6 +68,18 @@ export interface IUser { * @param password password to set */ reset_password(password: string): Promise; + + /** + * get user settings + * @returns user settings, or undefined if not set + */ + get_settings(): Promise; + /** + * set user settings + * @param settings user settings to set + * @returns if settings updated, return true + */ + set_settings(settings: IUserSettings): Promise; } export interface UserAccessor { diff --git a/packages/server/src/permission/permission.ts b/packages/server/src/permission/permission.ts index 4632ef6..04f96c7 100644 --- a/packages/server/src/permission/permission.ts +++ b/packages/server/src/permission/permission.ts @@ -36,20 +36,21 @@ export enum Permission { export const createPermissionCheckMiddleware = (...permissions: string[]) => - async (ctx: Koa.ParameterizedContext, next: Koa.Next) => { - const user = ctx.state.user; - if (user.username === "admin") { - return await next(); - } - const user_permission = user.permission; - // if permissions is not subset of user permission - if (!permissions.map((p) => user_permission.includes(p)).every((x) => x)) { - if (user.username === "") { - return sendError(401, "you are guest. login needed."); - }return sendError(403, "do not have permission"); - } - await next(); - }; + async (ctx: Koa.ParameterizedContext, next: Koa.Next) => { + const user = ctx.state.user; + if (user.username === "admin") { + return await next(); + } + const user_permission = user.permission; + // if permissions is not subset of user permission + if (!permissions.map((p) => user_permission.includes(p)).every((x) => x)) { + if (user.username === "") { + return sendError(401, "you are guest. login needed."); + } return sendError(403, "do not have permission"); + } + await next(); + }; + export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext, next: Koa.Next) => { const user = ctx.state.user; if (user.username !== "admin") { diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 6726d30..6ba4037 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -8,7 +8,7 @@ import { get_setting, SettingConfig } from "./SettingConfig.ts"; import { createReadStream, readFileSync } from "node:fs"; import bodyparser from "koa-bodyparser"; 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 { error_handler } from "./route/error_handler.ts"; @@ -63,7 +63,7 @@ class ServerApplication { } app.use(bodyparser()); app.use(error_handler); - app.use(createUserMiddleWare(this.userController)); + app.use(createUserHandler(this.userController)); const diff_router = createDiffRouter(this.diffManger); this.diffManger.register("comic", createComicWatcher());