feat: add user settings management and update related handlers

This commit is contained in:
monoid 2025-06-26 20:54:20 +09:00
parent 73fb3c94b3
commit d530dd8cb6
8 changed files with 114 additions and 26 deletions

View file

@ -53,6 +53,12 @@ export const UserSchema = z.object({
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({
version: z.string().nullable(),
dirty: z.boolean(),

View file

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

View file

@ -4,7 +4,7 @@ export async function up(db: Kysely<any>) {
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();

View file

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

View file

@ -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<UserState>, 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<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", 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;
}

View file

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

View file

@ -36,20 +36,21 @@ export enum Permission {
export const createPermissionCheckMiddleware =
(...permissions: string[]) =>
async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const user = ctx.state.user;
if (user.username === "admin") {
return await next();
}
const user_permission = user.permission;
// if permissions is not subset of user permission
if (!permissions.map((p) => user_permission.includes(p)).every((x) => x)) {
if (user.username === "") {
return sendError(401, "you are guest. login needed.");
}return sendError(403, "do not have permission");
}
await next();
};
async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const user = ctx.state.user;
if (user.username === "admin") {
return await next();
}
const user_permission = user.permission;
// if permissions is not subset of user permission
if (!permissions.map((p) => user_permission.includes(p)).every((x) => x)) {
if (user.username === "") {
return sendError(401, "you are guest. login needed.");
} return sendError(403, "do not have permission");
}
await next();
};
export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const user = ctx.state.user;
if (user.username !== "admin") {

View file

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