From 4758400df6f68d49c0006772ae59d203dd07b1ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:12:26 +0000 Subject: [PATCH 01/13] Initial plan From db93ca9aa90f2e8b381875359f321c38d57cde4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:24:00 +0000 Subject: [PATCH 02/13] Add multi-user backend infrastructure: User model, management service and API Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/api/index.ts | 2 + back/api/userManagement.ts | 114 ++++++++++++++++++++++++++++++++ back/data/cron.ts | 3 + back/data/dependence.ts | 3 + back/data/env.ts | 3 + back/data/subscription.ts | 3 + back/data/user.ts | 56 ++++++++++++++++ back/loaders/express.ts | 12 ++++ back/services/user.ts | 67 ++++++++++++++----- back/services/userManagement.ts | 104 +++++++++++++++++++++++++++++ back/types/express.d.ts | 5 ++ 11 files changed, 356 insertions(+), 16 deletions(-) create mode 100644 back/api/userManagement.ts create mode 100644 back/data/user.ts create mode 100644 back/services/userManagement.ts diff --git a/back/api/index.ts b/back/api/index.ts index 6dcab62f..31655f9c 100644 --- a/back/api/index.ts +++ b/back/api/index.ts @@ -11,6 +11,7 @@ import system from './system'; import subscription from './subscription'; import update from './update'; import health from './health'; +import userManagement from './userManagement'; export default () => { const app = Router(); @@ -26,6 +27,7 @@ export default () => { subscription(app); update(app); health(app); + userManagement(app); return app; }; diff --git a/back/api/userManagement.ts b/back/api/userManagement.ts new file mode 100644 index 00000000..3799ad1c --- /dev/null +++ b/back/api/userManagement.ts @@ -0,0 +1,114 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { Container } from 'typedi'; +import { celebrate, Joi } from 'celebrate'; +import UserManagementService from '../services/userManagement'; +import { UserRole } from '../data/user'; + +const route = Router(); + +// Middleware to check if user is admin +const requireAdmin = (req: Request, res: Response, next: NextFunction) => { + if (req.user && (req.user as any).role === UserRole.admin) { + return next(); + } + return res.status(403).send({ code: 403, message: '需要管理员权限' }); +}; + +export default (app: Router) => { + app.use('/user-management', route); + + // List all users (admin only) + route.get( + '/', + requireAdmin, + async (req: Request, res: Response, next: NextFunction) => { + try { + const userManagementService = Container.get(UserManagementService); + const data = await userManagementService.list(req.query.searchValue as string); + res.send({ code: 200, data }); + } catch (e) { + return next(e); + } + }, + ); + + // Get a specific user (admin only) + route.get( + '/:id', + requireAdmin, + async (req: Request, res: Response, next: NextFunction) => { + try { + const userManagementService = Container.get(UserManagementService); + const data = await userManagementService.get(Number(req.params.id)); + res.send({ code: 200, data }); + } catch (e) { + return next(e); + } + }, + ); + + // Create a new user (admin only) + route.post( + '/', + requireAdmin, + celebrate({ + body: Joi.object({ + username: Joi.string().required(), + password: Joi.string().required(), + role: Joi.number().valid(UserRole.admin, UserRole.user).default(UserRole.user), + status: Joi.number().optional(), + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const userManagementService = Container.get(UserManagementService); + const data = await userManagementService.create(req.body); + res.send({ code: 200, data, message: '创建用户成功' }); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); + } + }, + ); + + // Update a user (admin only) + route.put( + '/', + requireAdmin, + celebrate({ + body: Joi.object({ + id: Joi.number().required(), + username: Joi.string().required(), + password: Joi.string().required(), + role: Joi.number().valid(UserRole.admin, UserRole.user), + status: Joi.number().optional(), + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const userManagementService = Container.get(UserManagementService); + const data = await userManagementService.update(req.body); + res.send({ code: 200, data, message: '更新用户成功' }); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); + } + }, + ); + + // Delete users (admin only) + route.delete( + '/', + requireAdmin, + celebrate({ + body: Joi.array().items(Joi.number()).required(), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const userManagementService = Container.get(UserManagementService); + const count = await userManagementService.delete(req.body); + res.send({ code: 200, data: count, message: '删除用户成功' }); + } catch (e) { + return next(e); + } + }, + ); +}; diff --git a/back/data/cron.ts b/back/data/cron.ts index b234d59c..425d8f05 100644 --- a/back/data/cron.ts +++ b/back/data/cron.ts @@ -21,6 +21,7 @@ export class Crontab { extra_schedules?: Array<{ schedule: string }>; task_before?: string; task_after?: string; + userId?: number; constructor(options: Crontab) { this.name = options.name; @@ -45,6 +46,7 @@ export class Crontab { this.extra_schedules = options.extra_schedules; this.task_before = options.task_before; this.task_after = options.task_after; + this.userId = options.userId; } } @@ -84,4 +86,5 @@ export const CrontabModel = sequelize.define('Crontab', { extra_schedules: DataTypes.JSON, task_before: DataTypes.STRING, task_after: DataTypes.STRING, + userId: { type: DataTypes.NUMBER, allowNull: true }, }); diff --git a/back/data/dependence.ts b/back/data/dependence.ts index 601f461c..f1723422 100644 --- a/back/data/dependence.ts +++ b/back/data/dependence.ts @@ -9,6 +9,7 @@ export class Dependence { name: string; log?: string[]; remark?: string; + userId?: number; constructor(options: Dependence) { this.id = options.id; @@ -21,6 +22,7 @@ export class Dependence { this.name = options.name.trim(); this.log = options.log || []; this.remark = options.remark || ''; + this.userId = options.userId; } } @@ -59,5 +61,6 @@ export const DependenceModel = sequelize.define( status: DataTypes.NUMBER, log: DataTypes.JSON, remark: DataTypes.STRING, + userId: { type: DataTypes.NUMBER, allowNull: true }, }, ); diff --git a/back/data/env.ts b/back/data/env.ts index f6a9094e..e96611e8 100644 --- a/back/data/env.ts +++ b/back/data/env.ts @@ -9,6 +9,7 @@ export class Env { position?: number; name?: string; remarks?: string; + userId?: number; constructor(options: Env) { this.value = options.value; @@ -21,6 +22,7 @@ export class Env { this.position = options.position; this.name = options.name; this.remarks = options.remarks || ''; + this.userId = options.userId; } } @@ -42,4 +44,5 @@ export const EnvModel = sequelize.define('Env', { position: DataTypes.NUMBER, name: { type: DataTypes.STRING, unique: 'compositeIndex' }, remarks: DataTypes.STRING, + userId: { type: DataTypes.NUMBER, allowNull: true }, }); diff --git a/back/data/subscription.ts b/back/data/subscription.ts index bb561d2f..127a1bc4 100644 --- a/back/data/subscription.ts +++ b/back/data/subscription.ts @@ -31,6 +31,7 @@ export class Subscription { proxy?: string; autoAddCron?: 1 | 0; autoDelCron?: 1 | 0; + userId?: number; constructor(options: Subscription) { this.id = options.id; @@ -60,6 +61,7 @@ export class Subscription { this.proxy = options.proxy; this.autoAddCron = options.autoAddCron ? 1 : 0; this.autoDelCron = options.autoDelCron ? 1 : 0; + this.userId = options.userId; } } @@ -111,5 +113,6 @@ export const SubscriptionModel = sequelize.define( proxy: { type: DataTypes.STRING, allowNull: true }, autoAddCron: { type: DataTypes.NUMBER, allowNull: true }, autoDelCron: { type: DataTypes.NUMBER, allowNull: true }, + userId: { type: DataTypes.NUMBER, allowNull: true }, }, ); diff --git a/back/data/user.ts b/back/data/user.ts new file mode 100644 index 00000000..14edae6b --- /dev/null +++ b/back/data/user.ts @@ -0,0 +1,56 @@ +import { sequelize } from '.'; +import { DataTypes, Model } from 'sequelize'; + +export class User { + id?: number; + username: string; + password: string; + role: UserRole; + status: UserStatus; + createdAt?: Date; + updatedAt?: Date; + + constructor(options: User) { + this.id = options.id; + this.username = options.username; + this.password = options.password; + this.role = options.role || UserRole.user; + this.status = + typeof options.status === 'number' && UserStatus[options.status] + ? options.status + : UserStatus.active; + this.createdAt = options.createdAt; + this.updatedAt = options.updatedAt; + } +} + +export enum UserRole { + 'admin' = 0, + 'user' = 1, +} + +export enum UserStatus { + 'active' = 0, + 'disabled' = 1, +} + +export interface UserInstance extends Model, User {} +export const UserModel = sequelize.define('User', { + username: { + type: DataTypes.STRING, + unique: true, + allowNull: false, + }, + password: { + type: DataTypes.STRING, + allowNull: false, + }, + role: { + type: DataTypes.NUMBER, + defaultValue: UserRole.user, + }, + status: { + type: DataTypes.NUMBER, + defaultValue: UserStatus.active, + }, +}); diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 41c98398..26af17ea 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -42,6 +42,18 @@ export default ({ app }: { app: Application }) => { return next(); }); + // Extract userId and role from JWT + app.use((req: Request, res, next) => { + if (req.auth) { + const payload = req.auth as any; + req.user = { + userId: payload.userId, + role: payload.role, + }; + } + return next(); + }); + app.use(async (req: Request, res, next) => { if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) { return next(); diff --git a/back/services/user.ts b/back/services/user.ts index 604b3c82..d5ecfc0e 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -24,12 +24,17 @@ import uniq from 'lodash/uniq'; import pickBy from 'lodash/pickBy'; import isNil from 'lodash/isNil'; import { shareStore } from '../shared/store'; +import UserManagementService from './userManagement'; +import { UserRole } from '../data/user'; @Service() export default class UserService { @Inject((type) => NotificationService) private notificationService!: NotificationService; + @Inject((type) => UserManagementService) + private userManagementService!: UserManagementService; + constructor( @Inject('logger') private logger: winston.Logger, private scheduleService: ScheduleService, @@ -93,27 +98,57 @@ export default class UserService { const { country, province, city, isp } = ipAddress; address = uniq([country, province, city, isp]).filter(Boolean).join(' '); } - if (username === cUsername && password === cPassword) { + + // Check if this is a regular user (not admin) trying to login + let authenticatedUser = null; + let userId: number | undefined = undefined; + let userRole = UserRole.admin; + + // First check if it's the system admin + const isSystemAdmin = username === cUsername && password === cPassword; + + if (!isSystemAdmin) { + // Try to authenticate as a regular user + try { + authenticatedUser = await this.userManagementService.authenticate(username, password); + if (authenticatedUser) { + userId = authenticatedUser.id; + userRole = authenticatedUser.role; + } + } catch (e: any) { + // User disabled or other error + return { code: 400, message: e.message }; + } + } + + if (isSystemAdmin || authenticatedUser) { const data = createRandomString(50, 100); - const expiration = twoFactorActivated ? '60d' : '20d'; - let token = jwt.sign({ data }, config.jwt.secret, { + const expiration = (isSystemAdmin && twoFactorActivated) ? '60d' : '20d'; + let token = jwt.sign( + { data, userId, role: userRole }, + config.jwt.secret, + { expiresIn: config.jwt.expiresIn || expiration, algorithm: 'HS384', }); - await this.updateAuthInfo(content, { - token, - tokens: { - ...tokens, - [req.platform]: token, - }, - lastlogon: timestamp, - retries: 0, - lastip: ip, - lastaddr: address, - platform: req.platform, - isTwoFactorChecking: false, - }); + // Only update authInfo for system admin + if (isSystemAdmin) { + await this.updateAuthInfo(content, { + token, + tokens: { + ...tokens, + [req.platform]: token, + }, + lastlogon: timestamp, + retries: 0, + lastip: ip, + lastaddr: address, + platform: req.platform, + isTwoFactorChecking: false, + }); + } + this.notificationService.notify( '登录通知', `你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}在 ${address} ${ diff --git a/back/services/userManagement.ts b/back/services/userManagement.ts new file mode 100644 index 00000000..5653399a --- /dev/null +++ b/back/services/userManagement.ts @@ -0,0 +1,104 @@ +import { Service, Inject } from 'typedi'; +import winston from 'winston'; +import { User, UserModel, UserRole, UserStatus } from '../data/user'; +import { Op } from 'sequelize'; + +@Service() +export default class UserManagementService { + constructor(@Inject('logger') private logger: winston.Logger) {} + + public async list(searchText?: string): Promise { + let query: any = {}; + if (searchText) { + query = { + username: { [Op.like]: `%${searchText}%` }, + }; + } + const docs = await UserModel.findAll({ where: query }); + return docs.map((x) => x.get({ plain: true })); + } + + public async get(id: number): Promise { + const doc = await UserModel.findByPk(id); + if (!doc) { + throw new Error('用户不存在'); + } + return doc.get({ plain: true }); + } + + public async getByUsername(username: string): Promise { + const doc = await UserModel.findOne({ where: { username } }); + if (!doc) { + return null; + } + return doc.get({ plain: true }); + } + + public async create(payload: User): Promise { + const existingUser = await this.getByUsername(payload.username); + if (existingUser) { + throw new Error('用户名已存在'); + } + + if (payload.password === 'admin') { + throw new Error('密码不能设置为admin'); + } + + const doc = await UserModel.create(payload); + return doc.get({ plain: true }); + } + + public async update(payload: User): Promise { + if (!payload.id) { + throw new Error('缺少用户ID'); + } + + const existingUser = await this.get(payload.id); + if (!existingUser) { + throw new Error('用户不存在'); + } + + if (payload.password === 'admin') { + throw new Error('密码不能设置为admin'); + } + + // Check if username is being changed and if new username already exists + if (payload.username !== existingUser.username) { + const userWithSameUsername = await this.getByUsername(payload.username); + if (userWithSameUsername && userWithSameUsername.id !== payload.id) { + throw new Error('用户名已存在'); + } + } + + const [, [updated]] = await UserModel.update(payload, { + where: { id: payload.id }, + returning: true, + }); + return updated.get({ plain: true }); + } + + public async delete(ids: number[]): Promise { + const count = await UserModel.destroy({ where: { id: ids } }); + return count; + } + + public async authenticate( + username: string, + password: string, + ): Promise { + const user = await this.getByUsername(username); + if (!user) { + return null; + } + + if (user.password !== password) { + return null; + } + + if (user.status === UserStatus.disabled) { + throw new Error('用户已被禁用'); + } + + return user; + } +} diff --git a/back/types/express.d.ts b/back/types/express.d.ts index 4a8fd216..57d00d6d 100644 --- a/back/types/express.d.ts +++ b/back/types/express.d.ts @@ -6,6 +6,11 @@ declare global { namespace Express { interface Request { platform: 'desktop' | 'mobile'; + user?: { + userId?: number; + role?: number; + }; + auth?: any; } } } \ No newline at end of file From 489454daa024b4bb6656e603e990912198c16c0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:30:04 +0000 Subject: [PATCH 03/13] Add user-scoped data filtering for cron operations Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/api/cron.ts | 63 ++++++++++++++++++++++++------------------- back/services/cron.ts | 55 ++++++++++++++++++++++++++++++------- 2 files changed, 81 insertions(+), 37 deletions(-) diff --git a/back/api/cron.ts b/back/api/cron.ts index 1bc99062..f443ac72 100644 --- a/back/api/cron.ts +++ b/back/api/cron.ts @@ -145,7 +145,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const data = await cronService.crontabs(req.query as any); + const data = await cronService.crontabs({ + ...req.query as any, + userId: req.user?.userId + }); return res.send({ code: 200, data }); } catch (e) { logger.error('🔥 error: %o', e); @@ -177,7 +180,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const data = await cronService.create(req.body); + const data = await cronService.create({ + ...req.body, + userId: req.user?.userId + }); return res.send({ code: 200, data }); } catch (e) { return next(e); @@ -194,10 +200,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const data = await cronService.run(req.body); + const data = await cronService.run(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -211,10 +217,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const data = await cronService.stop(req.body); + const data = await cronService.stop(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -234,10 +240,11 @@ export default (app: Router) => { const data = await cronService.removeLabels( req.body.ids, req.body.labels, + req.user?.userId, ); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -254,10 +261,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const data = await cronService.addLabels(req.body.ids, req.body.labels); + const data = await cronService.addLabels(req.body.ids, req.body.labels, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -271,10 +278,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const data = await cronService.disabled(req.body); + const data = await cronService.disabled(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -288,10 +295,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const data = await cronService.enabled(req.body); + const data = await cronService.enabled(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -344,10 +351,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const data = await cronService.remove(req.body); + const data = await cronService.remove(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -361,10 +368,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const data = await cronService.pin(req.body); + const data = await cronService.pin(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -378,10 +385,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const data = await cronService.unPin(req.body); + const data = await cronService.unPin(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); diff --git a/back/services/cron.ts b/back/services/cron.ts index a838ce9e..8616619b 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -29,6 +29,30 @@ import { ScheduleType } from '../interface/schedule'; export default class CronService { constructor(@Inject('logger') private logger: winston.Logger) {} + private addUserIdFilter(query: any, userId?: number) { + if (userId !== undefined) { + query.userId = userId; + } + return query; + } + + private async checkOwnership(ids: number[], userId?: number): Promise { + if (userId === undefined) { + // Admin can access all crons + return; + } + const crons = await CrontabModel.findAll({ + where: { id: ids }, + attributes: ['id', 'userId'], + }); + const unauthorized = crons.filter( + (cron) => cron.userId !== userId && cron.userId !== undefined + ); + if (unauthorized.length > 0) { + throw new Error('无权限操作该定时任务'); + } + } + private isNodeCron(cron: Crontab) { const { schedule, extra_schedules } = cron; if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) { @@ -156,21 +180,26 @@ export default class CronService { } } - public async remove(ids: number[]) { + public async remove(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); await CrontabModel.destroy({ where: { id: ids } }); await cronClient.delCron(ids.map(String)); await this.setCrontab(); } - public async pin(ids: number[]) { + public async pin(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); await CrontabModel.update({ isPinned: 1 }, { where: { id: ids } }); } - public async unPin(ids: number[]) { + public async unPin(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); await CrontabModel.update({ isPinned: 0 }, { where: { id: ids } }); } - public async addLabels(ids: string[], labels: string[]) { + public async addLabels(ids: string[], labels: string[], userId?: number) { + const numIds = ids.map(Number); + await this.checkOwnership(numIds, userId); const docs = await CrontabModel.findAll({ where: { id: ids } }); for (const doc of docs) { await CrontabModel.update( @@ -182,7 +211,9 @@ export default class CronService { } } - public async removeLabels(ids: string[], labels: string[]) { + public async removeLabels(ids: string[], labels: string[], userId?: number) { + const numIds = ids.map(Number); + await this.checkOwnership(numIds, userId); const docs = await CrontabModel.findAll({ where: { id: ids } }); for (const doc of docs) { await CrontabModel.update( @@ -377,6 +408,7 @@ export default class CronService { sorter: string; filters: string; queryString: string; + userId?: number; }): Promise<{ data: Crontab[]; total: number }> { const searchText = params?.searchValue; const page = Number(params?.page || '0'); @@ -397,6 +429,7 @@ export default class CronService { this.formatSearchText(query, searchText); this.formatFilterQuery(query, filterQuery); this.formatViewSort(order, viewQuery); + this.addUserIdFilter(query, params?.userId); if (sorterQuery) { const { field, type } = sorterQuery; @@ -429,7 +462,8 @@ export default class CronService { return doc.get({ plain: true }); } - public async run(ids: number[]) { + public async run(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); await CrontabModel.update( { status: CrontabStatus.queued }, { where: { id: ids } }, @@ -439,7 +473,8 @@ export default class CronService { }); } - public async stop(ids: number[]) { + public async stop(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); const docs = await CrontabModel.findAll({ where: { id: ids } }); for (const doc of docs) { if (doc.pid) { @@ -533,13 +568,15 @@ export default class CronService { }); } - public async disabled(ids: number[]) { + public async disabled(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } }); await cronClient.delCron(ids.map(String)); await this.setCrontab(); } - public async enabled(ids: number[]) { + public async enabled(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } }); const docs = await CrontabModel.findAll({ where: { id: ids } }); const sixCron = docs From f355b4e441f369eebd91e945268f092beb2185cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:33:31 +0000 Subject: [PATCH 04/13] Add user-scoped data filtering for env operations Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/api/env.ts | 28 +++++++++++++-------------- back/services/env.ts | 45 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 51 insertions(+), 22 deletions(-) diff --git a/back/api/env.ts b/back/api/env.ts index 845b5df5..51fdc631 100644 --- a/back/api/env.ts +++ b/back/api/env.ts @@ -26,7 +26,7 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); - const data = await envService.envs(req.query.searchValue as string); + const data = await envService.envs(req.query.searchValue as string, {}, req.user?.userId); return res.send({ code: 200, data }); } catch (e) { logger.error('🔥 error: %o', e); @@ -54,7 +54,7 @@ export default (app: Router) => { if (!req.body?.length) { return res.send({ code: 400, message: '参数不正确' }); } - const data = await envService.create(req.body); + const data = await envService.create(req.body, req.user?.userId); return res.send({ code: 200, data }); } catch (e) { return next(e); @@ -93,10 +93,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); - const data = await envService.remove(req.body); + const data = await envService.remove(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -132,10 +132,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); - const data = await envService.disabled(req.body); + const data = await envService.disabled(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -149,10 +149,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); - const data = await envService.enabled(req.body); + const data = await envService.enabled(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -169,10 +169,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); - const data = await envService.updateNames(req.body); + const data = await envService.updateNames(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); diff --git a/back/services/env.ts b/back/services/env.ts index be89cc86..ea5590ce 100644 --- a/back/services/env.ts +++ b/back/services/env.ts @@ -19,8 +19,32 @@ import { writeFileWithLock } from '../shared/utils'; export default class EnvService { constructor(@Inject('logger') private logger: winston.Logger) {} - public async create(payloads: Env[]): Promise { - const envs = await this.envs(); + private addUserIdFilter(query: any, userId?: number) { + if (userId !== undefined) { + query.userId = userId; + } + return query; + } + + private async checkOwnership(ids: number[], userId?: number): Promise { + if (userId === undefined) { + // Admin can access all envs + return; + } + const envs = await EnvModel.findAll({ + where: { id: ids }, + attributes: ['id', 'userId'], + }); + const unauthorized = envs.filter( + (env) => env.userId !== userId && env.userId !== undefined + ); + if (unauthorized.length > 0) { + throw new Error('无权限操作该环境变量'); + } + } + + public async create(payloads: Env[], userId?: number): Promise { + const envs = await this.envs('', {}, userId); let position = initPosition; if ( envs && @@ -31,7 +55,7 @@ export default class EnvService { } const tabs = payloads.map((x) => { position = position - stepPosition; - const tab = new Env({ ...x, position }); + const tab = new Env({ ...x, position, userId }); return tab; }); const docs = await this.insert(tabs); @@ -62,7 +86,8 @@ export default class EnvService { return await this.getDb({ id: payload.id }); } - public async remove(ids: number[]) { + public async remove(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); await EnvModel.destroy({ where: { id: ids } }); await this.set_envs(); } @@ -119,8 +144,9 @@ export default class EnvService { return parseFloat(position.toPrecision(16)); } - public async envs(searchText: string = '', query: any = {}): Promise { + public async envs(searchText: string = '', query: any = {}, userId?: number): Promise { let condition = { ...query }; + this.addUserIdFilter(condition, userId); if (searchText) { const encodeText = encodeURI(searchText); const reg = { @@ -172,7 +198,8 @@ export default class EnvService { return doc.get({ plain: true }); } - public async disabled(ids: number[]) { + public async disabled(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); await EnvModel.update( { status: EnvStatus.disabled }, { where: { id: ids } }, @@ -180,12 +207,14 @@ export default class EnvService { await this.set_envs(); } - public async enabled(ids: number[]) { + public async enabled(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } }); await this.set_envs(); } - public async updateNames({ ids, name }: { ids: number[]; name: string }) { + public async updateNames({ ids, name }: { ids: number[]; name: string }, userId?: number) { + await this.checkOwnership(ids, userId); await EnvModel.update({ name }, { where: { id: ids } }); await this.set_envs(); } From b2b1777c6b570507e5b23fb28935ab7a2a05e62a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:37:05 +0000 Subject: [PATCH 05/13] Security improvements: Fix ownership checks, add password hashing with bcrypt Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/api/userManagement.ts | 2 +- back/services/cron.ts | 2 +- back/services/env.ts | 2 +- back/services/userManagement.ts | 35 +++++++++++++++++++++------ package.json | 42 +++++++++++++++++---------------- pnpm-lock.yaml | 39 ++++++++++++++++++++++++++---- 6 files changed, 88 insertions(+), 34 deletions(-) diff --git a/back/api/userManagement.ts b/back/api/userManagement.ts index 3799ad1c..3b008688 100644 --- a/back/api/userManagement.ts +++ b/back/api/userManagement.ts @@ -8,7 +8,7 @@ const route = Router(); // Middleware to check if user is admin const requireAdmin = (req: Request, res: Response, next: NextFunction) => { - if (req.user && (req.user as any).role === UserRole.admin) { + if (req.user && req.user.role === UserRole.admin) { return next(); } return res.status(403).send({ code: 403, message: '需要管理员权限' }); diff --git a/back/services/cron.ts b/back/services/cron.ts index 8616619b..c2f32597 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -46,7 +46,7 @@ export default class CronService { attributes: ['id', 'userId'], }); const unauthorized = crons.filter( - (cron) => cron.userId !== userId && cron.userId !== undefined + (cron) => cron.userId !== undefined && cron.userId !== userId ); if (unauthorized.length > 0) { throw new Error('无权限操作该定时任务'); diff --git a/back/services/env.ts b/back/services/env.ts index ea5590ce..fb91e824 100644 --- a/back/services/env.ts +++ b/back/services/env.ts @@ -36,7 +36,7 @@ export default class EnvService { attributes: ['id', 'userId'], }); const unauthorized = envs.filter( - (env) => env.userId !== userId && env.userId !== undefined + (env) => env.userId !== undefined && env.userId !== userId ); if (unauthorized.length > 0) { throw new Error('无权限操作该环境变量'); diff --git a/back/services/userManagement.ts b/back/services/userManagement.ts index 5653399a..60cdf594 100644 --- a/back/services/userManagement.ts +++ b/back/services/userManagement.ts @@ -2,11 +2,21 @@ import { Service, Inject } from 'typedi'; import winston from 'winston'; import { User, UserModel, UserRole, UserStatus } from '../data/user'; import { Op } from 'sequelize'; +import bcrypt from 'bcrypt'; @Service() export default class UserManagementService { constructor(@Inject('logger') private logger: winston.Logger) {} + private async hashPassword(password: string): Promise { + const saltRounds = 10; + return bcrypt.hash(password, saltRounds); + } + + private async verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); + } + public async list(searchText?: string): Promise { let query: any = {}; if (searchText) { @@ -40,11 +50,15 @@ export default class UserManagementService { throw new Error('用户名已存在'); } - if (payload.password === 'admin') { - throw new Error('密码不能设置为admin'); + if (payload.password.length < 6) { + throw new Error('密码长度至少为6位'); } - const doc = await UserModel.create(payload); + // Hash the password before storing + const hashedPassword = await this.hashPassword(payload.password); + const userWithHashedPassword = { ...payload, password: hashedPassword }; + + const doc = await UserModel.create(userWithHashedPassword); return doc.get({ plain: true }); } @@ -58,8 +72,8 @@ export default class UserManagementService { throw new Error('用户不存在'); } - if (payload.password === 'admin') { - throw new Error('密码不能设置为admin'); + if (payload.password && payload.password.length < 6) { + throw new Error('密码长度至少为6位'); } // Check if username is being changed and if new username already exists @@ -70,7 +84,13 @@ export default class UserManagementService { } } - const [, [updated]] = await UserModel.update(payload, { + // Hash the password if it's being updated + const updatePayload = { ...payload }; + if (payload.password) { + updatePayload.password = await this.hashPassword(payload.password); + } + + const [, [updated]] = await UserModel.update(updatePayload, { where: { id: payload.id }, returning: true, }); @@ -91,7 +111,8 @@ export default class UserManagementService { return null; } - if (user.password !== password) { + const isPasswordValid = await this.verifyPassword(password, user.password); + if (!isPasswordValid) { return null; } diff --git a/package.json b/package.json index 8c2dbc9e..e3cd8f2b 100644 --- a/package.json +++ b/package.json @@ -54,12 +54,16 @@ } }, "dependencies": { + "@bufbuild/protobuf": "^2.10.0", "@grpc/grpc-js": "^1.14.0", "@grpc/proto-loader": "^0.8.0", + "@keyv/sqlite": "^4.0.1", "@otplib/preset-default": "^12.0.1", + "bcrypt": "^6.0.0", "body-parser": "^1.20.3", "celebrate": "^15.0.3", "chokidar": "^4.0.1", + "compression": "^1.7.4", "cors": "^2.8.5", "cron-parser": "^4.9.0", "cross-spawn": "^7.0.6", @@ -69,51 +73,49 @@ "express-jwt": "^8.4.1", "express-rate-limit": "^7.4.1", "express-urlrewrite": "^2.0.3", - "undici": "^7.9.0", + "helmet": "^8.1.0", "hpagent": "^1.2.0", "http-proxy-middleware": "^3.0.3", "iconv-lite": "^0.6.3", + "ip2region": "2.3.0", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", + "keyv": "^5.2.3", "lodash": "^4.17.21", "multer": "1.4.5-lts.1", "node-schedule": "^2.1.0", "nodemailer": "^6.9.16", "p-queue-cjs": "7.3.4", - "@bufbuild/protobuf": "^2.10.0", + "proper-lockfile": "^4.1.2", "ps-tree": "^1.2.0", "reflect-metadata": "^0.2.2", + "request-ip": "3.3.0", "sequelize": "^6.37.5", "sockjs": "^0.3.24", "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3", "toad-scheduler": "^3.0.1", "typedi": "^0.10.0", + "undici": "^7.9.0", "uuid": "^11.0.3", "winston": "^3.17.0", - "winston-daily-rotate-file": "^5.0.0", - "request-ip": "3.3.0", - "ip2region": "2.3.0", - "keyv": "^5.2.3", - "@keyv/sqlite": "^4.0.1", - "proper-lockfile": "^4.1.2", - "compression": "^1.7.4", - "helmet": "^8.1.0" + "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { - "moment": "2.30.1", "@ant-design/icons": "^5.0.1", "@ant-design/pro-layout": "6.38.22", - "@codemirror/view": "^6.34.1", "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.34.1", "@monaco-editor/react": "4.2.1", "@react-hook/resize-observer": "^2.0.2", - "react-router-dom": "6.26.1", + "@types/bcrypt": "^6.0.0", "@types/body-parser": "^1.19.2", + "@types/compression": "^1.7.2", "@types/cors": "^2.8.12", "@types/cross-spawn": "^6.0.2", "@types/express": "^4.17.13", "@types/express-jwt": "^6.0.4", "@types/file-saver": "2.0.2", + "@types/helmet": "^4.0.0", "@types/js-yaml": "^4.0.5", "@types/jsonwebtoken": "^8.5.8", "@types/lodash": "^4.14.185", @@ -121,17 +123,17 @@ "@types/node": "^17.0.21", "@types/node-schedule": "^1.3.2", "@types/nodemailer": "^6.4.4", + "@types/proper-lockfile": "^4.1.4", + "@types/ps-tree": "^1.1.6", "@types/qrcode.react": "^1.0.2", "@types/react": "^18.0.20", "@types/react-copy-to-clipboard": "^5.0.4", "@types/react-dom": "^18.0.6", + "@types/request-ip": "0.0.41", "@types/serve-handler": "^6.1.1", "@types/sockjs": "^0.3.33", "@types/sockjs-client": "^1.5.1", "@types/uuid": "^8.3.4", - "@types/request-ip": "0.0.41", - "@types/proper-lockfile": "^4.1.4", - "@types/ps-tree": "^1.1.6", "@uiw/codemirror-extensions-langs": "^4.21.9", "@uiw/react-codemirror": "^4.21.9", "@umijs/max": "^4.4.4", @@ -143,9 +145,9 @@ "axios": "^1.4.0", "compression-webpack-plugin": "9.2.0", "concurrently": "^7.0.0", - "react-hotkeys-hook": "^4.6.1", "file-saver": "2.0.2", "lint-staged": "^13.0.3", + "moment": "2.30.1", "monaco-editor": "0.33.0", "nodemon": "^3.0.1", "prettier": "^2.5.1", @@ -161,7 +163,9 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.3.1", + "react-hotkeys-hook": "^4.6.1", "react-intl-universal": "^2.12.0", + "react-router-dom": "6.26.1", "react-split-pane": "^0.1.92", "sockjs-client": "^1.6.0", "ts-node": "^10.9.2", @@ -169,8 +173,6 @@ "tslib": "^2.4.0", "typescript": "5.2.2", "vh-check": "^2.0.5", - "virtualizedtableforantd4": "1.3.0", - "@types/compression": "^1.7.2", - "@types/helmet": "^4.0.0" + "virtualizedtableforantd4": "1.3.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50cce37f..917e3c81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - overrides: sqlite3: git+https://github.com/whyour/node-sqlite3.git#v1.0.3 @@ -23,6 +19,9 @@ dependencies: '@otplib/preset-default': specifier: ^12.0.1 version: 12.0.1 + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 body-parser: specifier: ^1.20.3 version: 1.20.3 @@ -160,6 +159,9 @@ devDependencies: '@react-hook/resize-observer': specifier: ^2.0.2 version: 2.0.2(react@18.3.1) + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 '@types/body-parser': specifier: ^1.19.2 version: 1.19.5 @@ -3786,6 +3788,12 @@ packages: '@babel/types': 7.26.0 dev: true + /@types/bcrypt@6.0.0: + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + dependencies: + '@types/node': 17.0.45 + dev: true + /@types/body-parser@1.19.5: resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} dependencies: @@ -5747,6 +5755,15 @@ packages: /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + /bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + requiresBuild: true + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + dev: false + /before@0.0.1: resolution: {integrity: sha512-1J5SWbkoVJH9DTALN8igB4p+nPKZzPrJ/HomqBDLpfUvDXCdjdBmBUcH5McZfur0lftVssVU6BZug5NYh87zTw==} dev: true @@ -10212,6 +10229,11 @@ packages: resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} dev: false + /node-addon-api@8.5.0: + resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} + engines: {node: ^18 || ^20 || >= 21} + dev: false + /node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -10238,6 +10260,11 @@ packages: formdata-polyfill: 4.0.10 dev: true + /node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + dev: false + /node-gyp@8.4.1: resolution: {integrity: sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==} engines: {node: '>= 10.12.0'} @@ -15213,3 +15240,7 @@ packages: - encoding - supports-color dev: false + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false From 2ff7a186e77e73fd61452b0386b7f8209ae9a8b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:38:20 +0000 Subject: [PATCH 06/13] Changes before error encountered Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- MULTI_USER_GUIDE.md | 112 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 MULTI_USER_GUIDE.md diff --git a/MULTI_USER_GUIDE.md b/MULTI_USER_GUIDE.md new file mode 100644 index 00000000..9cbd4945 --- /dev/null +++ b/MULTI_USER_GUIDE.md @@ -0,0 +1,112 @@ +# 多用户管理功能说明 (Multi-User Management Guide) + +## 功能概述 (Overview) + +青龙面板现已支持多用户管理和数据隔离功能。管理员可以创建多个用户账号,每个用户只能看到和操作自己的数据。 + +Qinglong now supports multi-user management with data isolation. Administrators can create multiple user accounts, and each user can only see and operate their own data. + +## 用户角色 (User Roles) + +### 管理员 (Admin) +- 可以访问所有用户的数据 +- 可以创建、编辑、删除用户 +- 可以管理系统设置 +- Can access all users' data +- Can create, edit, and delete users +- Can manage system settings + +### 普通用户 (Regular User) +- 只能访问自己创建的数据 +- 可以管理自己的定时任务、环境变量、订阅和依赖 +- 无法访问其他用户的数据 +- Can only access their own data +- Can manage their own cron jobs, environment variables, subscriptions, and dependencies +- Cannot access other users' data + +## API 使用 (API Usage) + +### 用户管理接口 (User Management Endpoints) + +所有用户管理接口需要管理员权限。 +All user management endpoints require admin privileges. + +#### 获取用户列表 (Get User List) +``` +GET /api/user-management?searchValue=keyword +``` + +#### 创建用户 (Create User) +``` +POST /api/user-management +{ + "username": "user1", + "password": "password123", + "role": 1, // 0: admin, 1: user + "status": 0 // 0: active, 1: disabled +} +``` + +#### 更新用户 (Update User) +``` +PUT /api/user-management +{ + "id": 1, + "username": "user1", + "password": "newpassword", + "role": 1, + "status": 0 +} +``` + +#### 删除用户 (Delete Users) +``` +DELETE /api/user-management +[1, 2, 3] // User IDs to delete +``` + +## 数据隔离 (Data Isolation) + +### 定时任务 (Cron Jobs) +- 每个用户创建的定时任务会自动关联到该用户 +- 用户只能查看、编辑、运行、删除自己的定时任务 +- 管理员可以查看所有用户的定时任务 + +### 环境变量 (Environment Variables) +- 每个用户的环境变量相互隔离 +- 用户只能查看和修改自己的环境变量 +- 管理员可以查看所有环境变量 + +### 订阅和依赖 (Subscriptions and Dependencies) +- 用户数据完全隔离 +- Only accessible by the owning user and admins + +## 密码安全 (Password Security) + +- 所有密码使用 bcrypt 加密存储 +- 密码长度最少为 6 位 +- 建议使用强密码 +- All passwords are hashed with bcrypt +- Minimum password length is 6 characters +- Strong passwords are recommended + +## 向后兼容 (Backward Compatibility) + +- 原有的单用户系统管理员账号继续有效 +- 已存在的数据可以被所有用户访问(遗留数据) +- 新创建的数据会自动关联到创建者 +- The original system admin account remains valid +- Existing data is accessible by all users (legacy data) +- Newly created data is automatically associated with the creator + +## 注意事项 (Notes) + +1. **首次使用**:首次使用多用户功能时,建议先创建一个管理员账号作为备份 +2. **密码管理**:请妥善保管用户密码,忘记密码需要管理员重置 +3. **数据迁移**:如需将现有数据分配给特定用户,请联系管理员手动更新数据库 +4. **权限控制**:删除用户不会删除该用户的数据,数据会变为遗留数据 + +1. **First Use**: When first using multi-user functionality, it's recommended to create an admin account as a backup +2. **Password Management**: Please keep user passwords safe; forgotten passwords need admin reset +3. **Data Migration**: To assign existing data to specific users, contact admin for manual database update +4. **Permission Control**: Deleting a user doesn't delete their data; the data becomes legacy data From 777fd3fb239800b782daebc3e99ee9f8e0fa4c01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Nov 2025 16:37:37 +0000 Subject: [PATCH 07/13] Add user-scoped data filtering for subscription and dependence operations Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/api/dependence.ts | 28 +++++++++++----------- back/api/subscription.ts | 36 +++++++++++++++------------- back/services/dependence.ts | 39 ++++++++++++++++++++++++++---- back/services/subscription.ts | 45 +++++++++++++++++++++++++++++------ 4 files changed, 106 insertions(+), 42 deletions(-) diff --git a/back/api/dependence.ts b/back/api/dependence.ts index 2d9dc465..5ec48fe6 100644 --- a/back/api/dependence.ts +++ b/back/api/dependence.ts @@ -22,7 +22,7 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const dependenceService = Container.get(DependenceService); - const data = await dependenceService.dependencies(req.query as any); + const data = await dependenceService.dependencies(req.query as any, [], {}, req.user?.userId); return res.send({ code: 200, data }); } catch (e) { logger.error('🔥 error: %o', e); @@ -45,7 +45,7 @@ export default (app: Router) => { async (req: Request, res: Response, next: NextFunction) => { try { const dependenceService = Container.get(DependenceService); - const data = await dependenceService.create(req.body); + const data = await dependenceService.create(req.body, req.user?.userId); return res.send({ code: 200, data }); } catch (e) { return next(e); @@ -82,10 +82,10 @@ export default (app: Router) => { async (req: Request, res: Response, next: NextFunction) => { try { const dependenceService = Container.get(DependenceService); - const data = await dependenceService.remove(req.body); + const data = await dependenceService.remove(req.body, false, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -98,10 +98,10 @@ export default (app: Router) => { async (req: Request, res: Response, next: NextFunction) => { try { const dependenceService = Container.get(DependenceService); - const data = await dependenceService.remove(req.body, true); + const data = await dependenceService.remove(req.body, true, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -132,10 +132,10 @@ export default (app: Router) => { async (req: Request, res: Response, next: NextFunction) => { try { const dependenceService = Container.get(DependenceService); - const data = await dependenceService.reInstall(req.body); + const data = await dependenceService.reInstall(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -148,10 +148,10 @@ export default (app: Router) => { async (req: Request, res: Response, next: NextFunction) => { try { const dependenceService = Container.get(DependenceService); - await dependenceService.cancel(req.body); + await dependenceService.cancel(req.body, req.user?.userId); return res.send({ code: 200 }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); diff --git a/back/api/subscription.ts b/back/api/subscription.ts index 85587a16..3cfc4b43 100644 --- a/back/api/subscription.ts +++ b/back/api/subscription.ts @@ -16,6 +16,7 @@ export default (app: Router) => { const data = await subscriptionService.list( req.query.searchValue as string, req.query.ids as string, + req.user?.userId, ); return res.send({ code: 200, data }); } catch (e) { @@ -63,7 +64,10 @@ export default (app: Router) => { cron_parser.parseExpression(req.body.schedule).hasNext() ) { const subscriptionService = Container.get(SubscriptionService); - const data = await subscriptionService.create(req.body); + const data = await subscriptionService.create({ + ...req.body, + userId: req.user?.userId, + }); return res.send({ code: 200, data }); } else { return res.send({ code: 400, message: 'param schedule error' }); @@ -83,10 +87,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); - const data = await subscriptionService.run(req.body); + const data = await subscriptionService.run(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -100,10 +104,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); - const data = await subscriptionService.stop(req.body); + const data = await subscriptionService.stop(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -117,10 +121,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); - const data = await subscriptionService.disabled(req.body); + const data = await subscriptionService.disabled(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -134,10 +138,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); - const data = await subscriptionService.enabled(req.body); + const data = await subscriptionService.enabled(req.body, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); @@ -220,10 +224,10 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); - const data = await subscriptionService.remove(req.body, req.query); + const data = await subscriptionService.remove(req.body, req.query, req.user?.userId); return res.send({ code: 200, data }); - } catch (e) { - return next(e); + } catch (e: any) { + return res.send({ code: 400, message: e.message }); } }, ); diff --git a/back/services/dependence.ts b/back/services/dependence.ts index 1cb7e559..7e90113a 100644 --- a/back/services/dependence.ts +++ b/back/services/dependence.ts @@ -30,9 +30,33 @@ export default class DependenceService { private sockService: SockService, ) { } - public async create(payloads: Dependence[]): Promise { + private addUserIdFilter(query: any, userId?: number) { + if (userId !== undefined) { + query.userId = userId; + } + return query; + } + + private async checkOwnership(ids: number[], userId?: number): Promise { + if (userId === undefined) { + // Admin can access all dependencies + return; + } + const dependencies = await DependenceModel.findAll({ + where: { id: ids }, + attributes: ['id', 'userId'], + }); + const unauthorized = dependencies.filter( + (dep) => dep.userId !== undefined && dep.userId !== userId + ); + if (unauthorized.length > 0) { + throw new Error('无权限操作该依赖'); + } + } + + public async create(payloads: Dependence[], userId?: number): Promise { const tabs = payloads.map((x) => { - const tab = new Dependence({ ...x, status: DependenceStatus.queued }); + const tab = new Dependence({ ...x, status: DependenceStatus.queued, userId }); return tab; }); const docs = await this.insert(tabs); @@ -65,7 +89,8 @@ export default class DependenceService { return await this.getDb({ id: payload.id }); } - public async remove(ids: number[], force = false): Promise { + public async remove(ids: number[], force = false, userId?: number): Promise { + await this.checkOwnership(ids, userId); const docs = await DependenceModel.findAll({ where: { id: ids } }); for (const doc of docs) { taskLimit.removeQueuedDependency(doc); @@ -105,8 +130,10 @@ export default class DependenceService { }, sort: any = [], query: any = {}, + userId?: number, ): Promise { let condition = query; + this.addUserIdFilter(condition, userId); if (DependenceTypes[type]) { condition.type = DependenceTypes[type]; } @@ -141,7 +168,8 @@ export default class DependenceService { return taskLimit.waitDependencyQueueDone(); } - public async reInstall(ids: number[]): Promise { + public async reInstall(ids: number[], userId?: number): Promise { + await this.checkOwnership(ids, userId); await DependenceModel.update( { status: DependenceStatus.queued, log: [] }, { where: { id: ids } }, @@ -155,7 +183,8 @@ export default class DependenceService { return docs; } - public async cancel(ids: number[]) { + public async cancel(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); const docs = await DependenceModel.findAll({ where: { id: ids } }); for (const doc of docs) { taskLimit.removeQueuedDependency(doc); diff --git a/back/services/subscription.ts b/back/services/subscription.ts index 5b13161b..b30a4333 100644 --- a/back/services/subscription.ts +++ b/back/services/subscription.ts @@ -42,11 +42,37 @@ export default class SubscriptionService { private crontabService: CrontabService, ) {} + private addUserIdFilter(query: any, userId?: number) { + if (userId !== undefined) { + query.userId = userId; + } + return query; + } + + private async checkOwnership(ids: number[], userId?: number): Promise { + if (userId === undefined) { + // Admin can access all subscriptions + return; + } + const subscriptions = await SubscriptionModel.findAll({ + where: { id: ids }, + attributes: ['id', 'userId'], + }); + const unauthorized = subscriptions.filter( + (sub) => sub.userId !== undefined && sub.userId !== userId + ); + if (unauthorized.length > 0) { + throw new Error('无权限操作该订阅'); + } + } + public async list( searchText?: string, ids?: string, + userId?: number, ): Promise { - let query = {}; + let query: any = {}; + this.addUserIdFilter(query, userId); const subIds = JSON.parse(ids || '[]'); if (searchText) { const reg = { @@ -262,7 +288,8 @@ export default class SubscriptionService { ); } - public async remove(ids: number[], query: { force?: boolean }) { + public async remove(ids: number[], query: { force?: boolean }, userId?: number) { + await this.checkOwnership(ids, userId); const docs = await SubscriptionModel.findAll({ where: { id: ids } }); for (const doc of docs) { await this.handleTask(doc.get({ plain: true }), false); @@ -273,7 +300,7 @@ export default class SubscriptionService { if (query?.force === true) { const crons = await CrontabModel.findAll({ where: { sub_id: ids } }); if (crons?.length) { - await this.crontabService.remove(crons.map((x) => x.id!)); + await this.crontabService.remove(crons.map((x) => x.id!), userId); } for (const doc of docs) { const filePath = join(config.scriptPath, doc.alias); @@ -294,7 +321,8 @@ export default class SubscriptionService { return doc.get({ plain: true }); } - public async run(ids: number[]) { + public async run(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); await SubscriptionModel.update( { status: SubscriptionStatus.queued }, { where: { id: ids } }, @@ -304,7 +332,8 @@ export default class SubscriptionService { }); } - public async stop(ids: number[]) { + public async stop(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); const docs = await SubscriptionModel.findAll({ where: { id: ids } }); for (const doc of docs) { if (doc.pid) { @@ -339,7 +368,8 @@ export default class SubscriptionService { }); } - public async disabled(ids: number[]) { + public async disabled(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); await SubscriptionModel.update({ is_disabled: 1 }, { where: { id: ids } }); const docs = await SubscriptionModel.findAll({ where: { id: ids } }); await this.setSshConfig(); @@ -348,7 +378,8 @@ export default class SubscriptionService { } } - public async enabled(ids: number[]) { + public async enabled(ids: number[], userId?: number) { + await this.checkOwnership(ids, userId); await SubscriptionModel.update({ is_disabled: 0 }, { where: { id: ids } }); const docs = await SubscriptionModel.findAll({ where: { id: ids } }); await this.setSshConfig(); From 5c798a0e93c5cfdf5b85a8bc20d323ef51b8c181 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:38:26 +0000 Subject: [PATCH 08/13] Add user management frontend interface for admins Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/api/user.ts | 1 + src/locales/en-US.json | 17 +- src/locales/zh-CN.json | 17 +- src/pages/setting/index.tsx | 10 + src/pages/setting/userManagement.tsx | 264 +++++++++++++++++++++++++++ 5 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 src/pages/setting/userManagement.tsx diff --git a/back/api/user.ts b/back/api/user.ts index 9da5ef0d..b50c5492 100644 --- a/back/api/user.ts +++ b/back/api/user.ts @@ -97,6 +97,7 @@ export default (app: Router) => { username: authInfo.username, avatar: authInfo.avatar, twoFactorActivated: authInfo.twoFactorActivated, + role: req.user?.role, }, }); } catch (e) { diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 7d1e7675..a7561195 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -530,5 +530,20 @@ "请输入自定义日志文件夹名称或绝对路径": "Please enter a custom log folder name or absolute path", "请输入自定义日志文件夹名称或 /dev/null": "Please enter a custom log folder name or /dev/null", "日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens", - "日志名称不能超过100个字符": "Log name cannot exceed 100 characters" + "日志名称不能超过100个字符": "Log name cannot exceed 100 characters", + "用户管理": "User Management", + "用户名": "Username", + "密码": "Password", + "角色": "Role", + "管理员": "Admin", + "普通用户": "User", + "启用": "Enabled", + "禁用": "Disabled", + "创建时间": "Created At", + "确认删除选中的用户吗": "Are you sure to delete selected users?", + "请输入用户名": "Please enter username", + "请输入密码": "Please enter password", + "密码长度至少为6位": "Password must be at least 6 characters", + "新增用户": "Add User", + "编辑用户": "Edit User" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 97e97f20..f7b4bc89 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -530,5 +530,20 @@ "请输入自定义日志文件夹名称或绝对路径": "请输入自定义日志文件夹名称或绝对路径", "请输入自定义日志文件夹名称或 /dev/null": "请输入自定义日志文件夹名称或 /dev/null", "日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符", - "日志名称不能超过100个字符": "日志名称不能超过100个字符" + "日志名称不能超过100个字符": "日志名称不能超过100个字符", + "用户管理": "用户管理", + "用户名": "用户名", + "密码": "密码", + "角色": "角色", + "管理员": "管理员", + "普通用户": "普通用户", + "启用": "启用", + "禁用": "禁用", + "创建时间": "创建时间", + "确认删除选中的用户吗": "确认删除选中的用户吗", + "请输入用户名": "请输入用户名", + "请输入密码": "请输入密码", + "密码长度至少为6位": "密码长度至少为6位", + "新增用户": "新增用户", + "编辑用户": "编辑用户" } diff --git a/src/pages/setting/index.tsx b/src/pages/setting/index.tsx index b846b7ee..141713fd 100644 --- a/src/pages/setting/index.tsx +++ b/src/pages/setting/index.tsx @@ -35,6 +35,7 @@ import './index.less'; import useResizeObserver from '@react-hook/resize-observer'; import SystemLog from './systemLog'; import Dependence from './dependence'; +import UserManagement from './userManagement'; const { Text } = Typography; const isDemoEnv = window.__ENV__DeployEnv === 'demo'; @@ -343,6 +344,15 @@ const Setting = () => { label: intl.get('登录日志'), children: , }, + ...(user?.role === 0 && !isDemoEnv + ? [ + { + key: 'user-management', + label: intl.get('用户管理'), + children: , + }, + ] + : []), { key: 'dependence', label: intl.get('依赖设置'), diff --git a/src/pages/setting/userManagement.tsx b/src/pages/setting/userManagement.tsx new file mode 100644 index 00000000..46492560 --- /dev/null +++ b/src/pages/setting/userManagement.tsx @@ -0,0 +1,264 @@ +import intl from 'react-intl-universal'; +import React, { useState, useEffect } from 'react'; +import { + Button, + Table, + Space, + Modal, + Form, + Input, + Select, + message, + Tag, +} from 'antd'; +import { + EditOutlined, + DeleteOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import { request } from '@/utils/http'; +import config from '@/utils/config'; + +const { Option } = Select; + +interface User { + id: number; + username: string; + password?: string; + role: number; + status: number; + createdAt: string; + updatedAt: string; +} + +const UserManagement: React.FC<{ height: number }> = ({ height }) => { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [isModalVisible, setIsModalVisible] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [form] = Form.useForm(); + + const columns = [ + { + title: intl.get('用户名'), + dataIndex: 'username', + key: 'username', + }, + { + title: intl.get('角色'), + dataIndex: 'role', + key: 'role', + render: (role: number) => ( + + {role === 0 ? intl.get('管理员') : intl.get('普通用户')} + + ), + }, + { + title: intl.get('状态'), + dataIndex: 'status', + key: 'status', + render: (status: number) => ( + + {status === 0 ? intl.get('启用') : intl.get('禁用')} + + ), + }, + { + title: intl.get('创建时间'), + dataIndex: 'createdAt', + key: 'createdAt', + render: (text: string) => text ? new Date(text).toLocaleString() : '-', + }, + { + title: intl.get('操作'), + key: 'action', + render: (_: any, record: User) => ( + + + + + ), + }, + ]; + + const fetchUsers = async () => { + setLoading(true); + try { + const { code, data } = await request.get( + `${config.apiPrefix}user-management` + ); + if (code === 200) { + setUsers(data); + } + } catch (error) { + message.error('Failed to fetch users'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchUsers(); + }, []); + + const handleAdd = () => { + setEditingUser(null); + form.resetFields(); + setIsModalVisible(true); + }; + + const handleEdit = (record: User) => { + setEditingUser(record); + form.setFieldsValue({ + username: record.username, + role: record.role, + status: record.status, + }); + setIsModalVisible(true); + }; + + const handleDelete = (ids: number[]) => { + Modal.confirm({ + title: intl.get('确认删除'), + content: intl.get('确认删除选中的用户吗'), + onOk: async () => { + try { + const { code, message: msg } = await request.delete( + `${config.apiPrefix}user-management`, + { data: ids } + ); + if (code === 200) { + message.success(msg || intl.get('删除成功')); + fetchUsers(); + } else { + message.error(msg || intl.get('删除失败')); + } + } catch (error: any) { + message.error(error.message || intl.get('删除失败')); + } + }, + }); + }; + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + + if (editingUser) { + // Update user + const { code, message: msg } = await request.put( + `${config.apiPrefix}user-management`, + { ...values, id: editingUser.id } + ); + if (code === 200) { + message.success(msg || intl.get('更新成功')); + setIsModalVisible(false); + fetchUsers(); + } else { + message.error(msg || intl.get('更新失败')); + } + } else { + // Create user + const { code, message: msg } = await request.post( + `${config.apiPrefix}user-management`, + values + ); + if (code === 200) { + message.success(msg || intl.get('创建成功')); + setIsModalVisible(false); + fetchUsers(); + } else { + message.error(msg || intl.get('创建失败')); + } + } + } catch (error: any) { + message.error(error.message || intl.get('操作失败')); + } + }; + + return ( + <> +
+ +
+ + setIsModalVisible(false)} + > +
+ + + + + + + + + + + + + +
+ + ); +}; + +export default UserManagement; From d42074f76abea04f070be32ee6ce509252363ec4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:48:31 +0000 Subject: [PATCH 09/13] Add data migration script and comprehensive migration guide Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- MIGRATION_GUIDE.md | 252 ++++++++++++++++++++++++++++++++ MULTI_USER_GUIDE.md | 46 +++++- migrate-to-multiuser.js | 317 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 613 insertions(+), 2 deletions(-) create mode 100644 MIGRATION_GUIDE.md create mode 100755 migrate-to-multiuser.js diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 00000000..7664991e --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,252 @@ +# Multi-User Data Migration Guide + +This document explains how to migrate existing data to the multi-user system. + +## Overview + +When upgrading to the multi-user version of Qinglong, all existing data (cron tasks, environment variables, subscriptions, and dependencies) will be treated as "legacy data" that is accessible to all users. + +To properly isolate data between users, you need to migrate existing data to specific user accounts. + +## Migration Script + +The `migrate-to-multiuser.js` script helps you assign existing legacy data to specific users. + +### Prerequisites + +- Node.js installed +- Sequelize and dotenv packages (already included in the project) +- At least one user created in the User Management interface + +### Usage + +#### 1. List All Users + +First, see all available users in your system: + +```bash +node migrate-to-multiuser.js --list-users +``` + +Output example: +``` +Users in the system: +ID Username Role Status +-- -------- ---- ------ +1 admin Admin Enabled +2 user1 User Enabled +3 user2 User Enabled +``` + +#### 2. Preview Migration (Dry Run) + +Before making changes, preview what will be migrated: + +```bash +node migrate-to-multiuser.js --userId=1 --dry-run +``` + +Or by username: + +```bash +node migrate-to-multiuser.js --username=admin --dry-run +``` + +Output example: +``` +Legacy Data Statistics: + Cron tasks: 15 + Environment variables: 8 + Subscriptions: 3 + Dependencies: 5 + +DRY RUN: No changes will be made. + +Would assign all legacy data to user ID 1 +``` + +#### 3. Perform Migration + +Once you're ready, run the migration without `--dry-run`: + +**By User ID:** +```bash +node migrate-to-multiuser.js --userId=1 +``` + +**By Username:** +```bash +node migrate-to-multiuser.js --username=admin +``` + +Output example: +``` +Found user 'admin' with ID 1 + +Legacy Data Statistics: + Cron tasks: 15 + Environment variables: 8 + Subscriptions: 3 + Dependencies: 5 + +Migrating data to user ID 1... +✓ Migrated 15 cron tasks +✓ Migrated 8 environment variables +✓ Migrated 3 subscriptions +✓ Migrated 5 dependencies + +✓ Migration completed successfully! +``` + +### Command Line Options + +| Option | Description | +|--------|-------------| +| `--userId=` | Assign all legacy data to user with this ID | +| `--username=` | Assign all legacy data to user with this username | +| `--list-users` | List all users in the system | +| `--dry-run` | Show what would be changed without making changes | +| `--help` | Show help message | + +## Migration Strategy + +### Scenario 1: Single User to Multi-User + +If you're upgrading from single-user to multi-user and want to keep all existing data under one admin account: + +1. Create an admin user in the User Management interface +2. Run migration: `node migrate-to-multiuser.js --username=admin` + +### Scenario 2: Distribute Data to Multiple Users + +If you want to distribute existing data to different users: + +1. Create all necessary user accounts first +2. Identify which data belongs to which user (you may need to do this manually by checking the database) +3. For each user, manually update the `userId` field in the database tables (`Crontabs`, `Envs`, `Subscriptions`, `Dependences`) + +**SQL Example:** +```sql +-- Assign specific cron tasks to user ID 2 +UPDATE Crontabs +SET userId = 2 +WHERE name LIKE '%user2%' AND userId IS NULL; + +-- Assign specific environment variables to user ID 2 +UPDATE Envs +SET userId = 2 +WHERE name LIKE '%USER2%' AND userId IS NULL; +``` + +### Scenario 3: Keep as Shared Data + +If you want certain data to remain accessible to all users: + +- Simply don't run the migration script +- Data with `userId = NULL` remains as "legacy data" accessible to everyone +- This is useful for shared cron tasks or environment variables + +## Important Notes + +1. **Backup First**: Always backup your database before running migration scripts + ```bash + cp data/database.sqlite data/database.sqlite.backup + ``` + +2. **Test in Dry Run**: Always use `--dry-run` first to see what will change + +3. **One-Time Operation**: The script only migrates data where `userId` is NULL + - Already migrated data won't be changed + - You can run it multiple times safely + +4. **Transaction Safety**: The migration uses database transactions + - If any error occurs, all changes are rolled back + - Your data remains safe + +5. **User Must Exist**: The target user must exist before migration + - Create users in the User Management interface first + - Use `--list-users` to verify users exist + +## Troubleshooting + +### Error: "User not found" + +**Problem:** The specified user doesn't exist in the database. + +**Solution:** +1. Run `node migrate-to-multiuser.js --list-users` to see available users +2. Create the user in User Management interface if needed +3. Use correct user ID or username + +### Error: "Database connection failed" + +**Problem:** Cannot connect to the database. + +**Solution:** +1. Check that `data/database.sqlite` exists +2. Verify database file permissions +3. Check `QL_DATA_DIR` environment variable if using custom path + +### Error: "Migration failed" + +**Problem:** An error occurred during migration. + +**Solution:** +1. Check the error message for details +2. Verify database is not corrupted +3. Restore from backup if needed +4. Check database file permissions + +## Manual Migration + +If you prefer to migrate data manually using SQL: + +### Connect to Database +```bash +sqlite3 data/database.sqlite +``` + +### Check Legacy Data +```sql +-- Count legacy cron tasks +SELECT COUNT(*) FROM Crontabs WHERE userId IS NULL; + +-- View legacy cron tasks +SELECT id, name, command FROM Crontabs WHERE userId IS NULL; +``` + +### Migrate Data +```sql +-- Migrate all legacy data to user ID 1 +UPDATE Crontabs SET userId = 1 WHERE userId IS NULL; +UPDATE Envs SET userId = 1 WHERE userId IS NULL; +UPDATE Subscriptions SET userId = 1 WHERE userId IS NULL; +UPDATE Dependences SET userId = 1 WHERE userId IS NULL; +``` + +### Verify Migration +```sql +-- Check if any legacy data remains +SELECT + (SELECT COUNT(*) FROM Crontabs WHERE userId IS NULL) as legacy_crons, + (SELECT COUNT(*) FROM Envs WHERE userId IS NULL) as legacy_envs, + (SELECT COUNT(*) FROM Subscriptions WHERE userId IS NULL) as legacy_subs, + (SELECT COUNT(*) FROM Dependences WHERE userId IS NULL) as legacy_deps; +``` + +## Support + +If you encounter issues with data migration: + +1. Check this guide for solutions +2. Review the error messages carefully +3. Ensure you have a recent backup +4. Open an issue on GitHub with: + - Error messages + - Migration command used + - Database statistics (from dry run) + +## Related Documentation + +- [MULTI_USER_GUIDE.md](./MULTI_USER_GUIDE.md) - Complete multi-user feature guide +- [README.md](./README.md) - Main project documentation diff --git a/MULTI_USER_GUIDE.md b/MULTI_USER_GUIDE.md index 9cbd4945..4e3bd25c 100644 --- a/MULTI_USER_GUIDE.md +++ b/MULTI_USER_GUIDE.md @@ -90,6 +90,48 @@ DELETE /api/user-management - Minimum password length is 6 characters - Strong passwords are recommended +## 数据迁移 (Data Migration) + +### 迁移工具 (Migration Tool) + +项目提供了数据迁移脚本,可以将现有数据分配给特定用户。 + +A migration script is provided to assign existing data to specific users. + +#### 使用方法 (Usage) + +1. **列出所有用户 (List all users)** +```bash +node migrate-to-multiuser.js --list-users +``` + +2. **预览迁移(不实际执行)(Dry run)** +```bash +node migrate-to-multiuser.js --userId=1 --dry-run +``` + +3. **将数据迁移到指定用户ID (Migrate to user ID)** +```bash +node migrate-to-multiuser.js --userId=1 +``` + +4. **将数据迁移到指定用户名 (Migrate to username)** +```bash +node migrate-to-multiuser.js --username=admin +``` + +#### 注意事项 (Important Notes) + +- 迁移脚本只会处理 `userId` 为空的数据(遗留数据) +- 已分配给用户的数据不会被修改 +- 建议先使用 `--dry-run` 预览变更 +- 迁移过程中如果出错会自动回滚 + +- The script only migrates data where `userId` is NULL (legacy data) +- Data already assigned to users will not be changed +- It's recommended to use `--dry-run` first to preview changes +- Changes are automatically rolled back if an error occurs + ## 向后兼容 (Backward Compatibility) - 原有的单用户系统管理员账号继续有效 @@ -103,10 +145,10 @@ DELETE /api/user-management 1. **首次使用**:首次使用多用户功能时,建议先创建一个管理员账号作为备份 2. **密码管理**:请妥善保管用户密码,忘记密码需要管理员重置 -3. **数据迁移**:如需将现有数据分配给特定用户,请联系管理员手动更新数据库 +3. **数据迁移**:使用提供的 `migrate-to-multiuser.js` 脚本将现有数据分配给特定用户 4. **权限控制**:删除用户不会删除该用户的数据,数据会变为遗留数据 1. **First Use**: When first using multi-user functionality, it's recommended to create an admin account as a backup 2. **Password Management**: Please keep user passwords safe; forgotten passwords need admin reset -3. **Data Migration**: To assign existing data to specific users, contact admin for manual database update +3. **Data Migration**: Use the provided `migrate-to-multiuser.js` script to assign existing data to specific users 4. **Permission Control**: Deleting a user doesn't delete their data; the data becomes legacy data diff --git a/migrate-to-multiuser.js b/migrate-to-multiuser.js new file mode 100755 index 00000000..0252ddef --- /dev/null +++ b/migrate-to-multiuser.js @@ -0,0 +1,317 @@ +#!/usr/bin/env node + +/** + * Multi-User Data Migration Script + * + * This script migrates existing data (Cron, Env, Subscription, Dependence) + * to be associated with specific users. + * + * Usage: + * node migrate-to-multiuser.js --userId=1 + * node migrate-to-multiuser.js --username=admin + * node migrate-to-multiuser.js --list-users + * + * Options: + * --userId= Assign all legacy data to user with this ID + * --username= Assign all legacy data to user with this username + * --list-users List all users in the system + * --dry-run Show what would be changed without making changes + * --help Show this help message + */ + +const path = require('path'); +const fs = require('fs'); +const Sequelize = require('sequelize'); + +// Load environment variables +require('dotenv').config(); + +// Configuration +const config = { + dbPath: process.env.QL_DATA_DIR || path.join(__dirname, '../data'), + rootPath: __dirname, +}; + +// Initialize Sequelize +const sequelize = new Sequelize({ + dialect: 'sqlite', + storage: path.join(config.dbPath, 'database.sqlite'), + logging: false, +}); + +// Define models +const UserModel = sequelize.define('User', { + username: Sequelize.STRING, + password: Sequelize.STRING, + role: Sequelize.NUMBER, + status: Sequelize.NUMBER, +}); + +const CrontabModel = sequelize.define('Crontab', { + name: Sequelize.STRING, + command: Sequelize.STRING, + schedule: Sequelize.STRING, + userId: Sequelize.NUMBER, +}); + +const EnvModel = sequelize.define('Env', { + name: Sequelize.STRING, + value: Sequelize.STRING, + userId: Sequelize.NUMBER, +}); + +const SubscriptionModel = sequelize.define('Subscription', { + name: Sequelize.STRING, + url: Sequelize.STRING, + userId: Sequelize.NUMBER, +}); + +const DependenceModel = sequelize.define('Dependence', { + name: Sequelize.STRING, + type: Sequelize.NUMBER, + userId: Sequelize.NUMBER, +}); + +// Parse command line arguments +function parseArgs() { + const args = { + userId: null, + username: null, + listUsers: false, + dryRun: false, + help: false, + }; + + process.argv.slice(2).forEach(arg => { + if (arg.startsWith('--userId=')) { + args.userId = parseInt(arg.split('=')[1]); + } else if (arg.startsWith('--username=')) { + args.username = arg.split('=')[1]; + } else if (arg === '--list-users') { + args.listUsers = true; + } else if (arg === '--dry-run') { + args.dryRun = true; + } else if (arg === '--help' || arg === '-h') { + args.help = true; + } + }); + + return args; +} + +// Show help +function showHelp() { + console.log(` +Multi-User Data Migration Script + +This script migrates existing data (Cron, Env, Subscription, Dependence) +to be associated with specific users. + +Usage: + node migrate-to-multiuser.js --userId=1 + node migrate-to-multiuser.js --username=admin + node migrate-to-multiuser.js --list-users + +Options: + --userId= Assign all legacy data to user with this ID + --username= Assign all legacy data to user with this username + --list-users List all users in the system + --dry-run Show what would be changed without making changes + --help Show this help message + +Examples: + # List all users + node migrate-to-multiuser.js --list-users + + # Migrate all data to user ID 1 (dry run) + node migrate-to-multiuser.js --userId=1 --dry-run + + # Migrate all data to user 'admin' + node migrate-to-multiuser.js --username=admin + +Note: This script will only migrate data where userId is NULL or undefined. +Data already assigned to users will not be changed. + `); +} + +// List all users +async function listUsers() { + const users = await UserModel.findAll(); + + if (users.length === 0) { + console.log('\nNo users found in the database.'); + console.log('Please create users first using the User Management interface.'); + return; + } + + console.log('\nUsers in the system:'); + console.log('ID\tUsername\tRole\t\tStatus'); + console.log('--\t--------\t----\t\t------'); + + users.forEach(user => { + const role = user.role === 0 ? 'Admin' : 'User'; + const status = user.status === 0 ? 'Enabled' : 'Disabled'; + console.log(`${user.id}\t${user.username}\t\t${role}\t\t${status}`); + }); + console.log(''); +} + +// Get statistics of legacy data +async function getStatistics() { + const stats = { + crons: await CrontabModel.count({ where: { userId: null } }), + envs: await EnvModel.count({ where: { userId: null } }), + subscriptions: await SubscriptionModel.count({ where: { userId: null } }), + dependences: await DependenceModel.count({ where: { userId: null } }), + }; + + return stats; +} + +// Migrate data to a specific user +async function migrateData(userId, dryRun = false) { + const stats = await getStatistics(); + + console.log('\nLegacy Data Statistics:'); + console.log(` Cron tasks: ${stats.crons}`); + console.log(` Environment variables: ${stats.envs}`); + console.log(` Subscriptions: ${stats.subscriptions}`); + console.log(` Dependencies: ${stats.dependences}`); + console.log(''); + + if (stats.crons + stats.envs + stats.subscriptions + stats.dependences === 0) { + console.log('No legacy data found. All data is already assigned to users.'); + return; + } + + if (dryRun) { + console.log('DRY RUN: No changes will be made.\n'); + console.log(`Would assign all legacy data to user ID ${userId}`); + return; + } + + console.log(`Migrating data to user ID ${userId}...`); + + const transaction = await sequelize.transaction(); + + try { + // Migrate crons + if (stats.crons > 0) { + await CrontabModel.update( + { userId }, + { where: { userId: null }, transaction } + ); + console.log(`✓ Migrated ${stats.crons} cron tasks`); + } + + // Migrate envs + if (stats.envs > 0) { + await EnvModel.update( + { userId }, + { where: { userId: null }, transaction } + ); + console.log(`✓ Migrated ${stats.envs} environment variables`); + } + + // Migrate subscriptions + if (stats.subscriptions > 0) { + await SubscriptionModel.update( + { userId }, + { where: { userId: null }, transaction } + ); + console.log(`✓ Migrated ${stats.subscriptions} subscriptions`); + } + + // Migrate dependences + if (stats.dependences > 0) { + await DependenceModel.update( + { userId }, + { where: { userId: null }, transaction } + ); + console.log(`✓ Migrated ${stats.dependences} dependencies`); + } + + await transaction.commit(); + console.log('\n✓ Migration completed successfully!'); + } catch (error) { + await transaction.rollback(); + console.error('\n✗ Migration failed:', error.message); + throw error; + } +} + +// Main function +async function main() { + const args = parseArgs(); + + if (args.help) { + showHelp(); + return; + } + + try { + // Test database connection + await sequelize.authenticate(); + console.log('Database connection established.'); + + if (args.listUsers) { + await listUsers(); + return; + } + + // Validate arguments + if (!args.userId && !args.username) { + console.error('\nError: You must specify either --userId or --username'); + console.log('Use --help for usage information.'); + process.exit(1); + } + + // Get user ID + let userId = args.userId; + + if (args.username) { + const user = await UserModel.findOne({ + where: { username: args.username } + }); + + if (!user) { + console.error(`\nError: User '${args.username}' not found.`); + console.log('Use --list-users to see available users.'); + process.exit(1); + } + + userId = user.id; + console.log(`Found user '${args.username}' with ID ${userId}`); + } else { + // Verify user exists + const user = await UserModel.findByPk(userId); + + if (!user) { + console.error(`\nError: User with ID ${userId} not found.`); + console.log('Use --list-users to see available users.'); + process.exit(1); + } + + console.log(`Found user '${user.username}' with ID ${userId}`); + } + + // Perform migration + await migrateData(userId, args.dryRun); + + } catch (error) { + console.error('\nError:', error.message); + process.exit(1); + } finally { + await sequelize.close(); + } +} + +// Run the script +if (require.main === module) { + main().catch(error => { + console.error('Fatal error:', error); + process.exit(1); + }); +} + +module.exports = { main, listUsers, migrateData, getStatistics }; From bf9be821ba24b93b98e04999616f5d27454b124a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:57:18 +0000 Subject: [PATCH 10/13] Add ALTER TABLE statements for userId columns in db.ts Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/loaders/db.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/back/loaders/db.ts b/back/loaders/db.ts index f2d0a50d..a4e216fc 100644 --- a/back/loaders/db.ts +++ b/back/loaders/db.ts @@ -6,6 +6,7 @@ import { AppModel } from '../data/open'; import { SystemModel } from '../data/system'; import { SubscriptionModel } from '../data/subscription'; import { CrontabViewModel } from '../data/cronView'; +import { UserModel } from '../data/user'; import { sequelize } from '../data'; export default async () => { @@ -17,6 +18,7 @@ export default async () => { await EnvModel.sync(); await SubscriptionModel.sync(); await CrontabViewModel.sync(); + await UserModel.sync(); // 初始化新增字段 try { @@ -64,6 +66,20 @@ export default async () => { try { await sequelize.query('alter table Envs add column isPinned NUMBER'); } catch (error) {} + + // Multi-user support: Add userId columns + try { + await sequelize.query('alter table Crontabs add column userId NUMBER'); + } catch (error) {} + try { + await sequelize.query('alter table Envs add column userId NUMBER'); + } catch (error) {} + try { + await sequelize.query('alter table Subscriptions add column userId NUMBER'); + } catch (error) {} + try { + await sequelize.query('alter table Dependences add column userId NUMBER'); + } catch (error) {} Logger.info('✌️ DB loaded'); } catch (error) { From 6aefc61be661e25252abf563a2967190f1994b25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:10:16 +0000 Subject: [PATCH 11/13] Fix authentication for regular users by validating JWT tokens Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/loaders/express.ts | 7 ++++++ back/loaders/sock.ts | 50 +++++++++++++++++++++++++++++------------ 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 26af17ea..756f982e 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -88,6 +88,13 @@ export default ({ app }: { app: Application }) => { return next(); } + // If JWT has been successfully verified by expressjwt middleware, allow the request + // This handles regular users whose tokens are not stored in authInfo + if (req.auth) { + return next(); + } + + // For system admin, also check against stored token const authInfo = await shareStore.getAuthInfo(); if (authInfo && headerToken) { const { token = '', tokens = {} } = authInfo; diff --git a/back/loaders/sock.ts b/back/loaders/sock.ts index d7db3d97..0568cfb7 100644 --- a/back/loaders/sock.ts +++ b/back/loaders/sock.ts @@ -4,6 +4,8 @@ import { Container } from 'typedi'; import SockService from '../services/sock'; import { getPlatform } from '../config/util'; import { shareStore } from '../shared/store'; +import jwt from 'jsonwebtoken'; +import config from '../config'; export default async ({ server }: { server: Server }) => { const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} }); @@ -14,26 +16,46 @@ export default async ({ server }: { server: Server }) => { conn.close('404'); } - const authInfo = await shareStore.getAuthInfo(); const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); - if (authInfo) { - const { token = '', tokens = {} } = authInfo; - if (headerToken === token || tokens[platform] === headerToken) { - sockService.addClient(conn); + + let isAuthenticated = false; - conn.on('data', (message) => { - conn.write(message); - }); - - conn.on('close', function () { - sockService.removeClient(conn); - }); - - return; + // First try to verify JWT token (for regular users) + if (headerToken) { + try { + jwt.verify(headerToken, config.jwt.secret, { algorithms: ['HS384'] }); + isAuthenticated = true; + } catch (error) { + // JWT verification failed, will try authInfo check next } } + // Also check against stored token for system admin + if (!isAuthenticated) { + const authInfo = await shareStore.getAuthInfo(); + if (authInfo) { + const { token = '', tokens = {} } = authInfo; + if (headerToken === token || tokens[platform] === headerToken) { + isAuthenticated = true; + } + } + } + + if (isAuthenticated) { + sockService.addClient(conn); + + conn.on('data', (message) => { + conn.write(message); + }); + + conn.on('close', function () { + sockService.removeClient(conn); + }); + + return; + } + conn.close('404'); }); From 07fcb09cc681c3b511498bc6e462e481f863502c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:36:12 +0000 Subject: [PATCH 12/13] Add log isolation and admin-only access for system/login logs Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/api/log.ts | 151 +++++++++++++++++++++++++++++++++++++++++++++ back/api/system.ts | 17 +++++ back/api/user.ts | 9 +++ 3 files changed, 177 insertions(+) diff --git a/back/api/log.ts b/back/api/log.ts index 020d572e..13f94dec 100644 --- a/back/api/log.ts +++ b/back/api/log.ts @@ -8,8 +8,12 @@ import { readDirs, removeAnsi, rmPath, + IFile, } from '../config/util'; import LogService from '../services/log'; +import CronService from '../services/cron'; +import { UserRole } from '../data/user'; +import { Crontab } from '../data/cron'; const route = Router(); const blacklist = ['.tmp']; @@ -20,6 +24,39 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const result = await readDirs(config.logPath, config.logPath, blacklist); + + // Filter logs based on user permissions + if (req.user?.role !== UserRole.admin && req.user?.userId) { + const cronService = Container.get(CronService); + const { data: userCrons } = await cronService.crontabs({ + searchValue: '', + page: '0', + size: '0', + sorter: '', + filters: '', + queryString: '', + userId: req.user.userId, + }); + + // Build a set of log paths that the user has access to + const allowedLogPaths = new Set( + userCrons + .filter((cron: Crontab) => cron.log_name && cron.log_name !== '/dev/null') + .map((cron: Crontab) => cron.log_name) + ); + + // Filter the result to only include logs the user owns + const filteredResult = (result as IFile[]).filter((item: IFile) => + item.type === 'directory' && (allowedLogPaths.has(item.title) || allowedLogPaths.has(`${item.title}/`)) + ); + + res.send({ + code: 200, + data: filteredResult, + }); + return; + } + res.send({ code: 200, data: result, @@ -45,6 +82,35 @@ export default (app: Router) => { message: '暂无权限', }); } + + // Check if user has permission to view this log + if (req.user?.role !== UserRole.admin && req.user?.userId) { + const cronService = Container.get(CronService); + const { data: userCrons } = await cronService.crontabs({ + searchValue: '', + page: '0', + size: '0', + sorter: '', + filters: '', + queryString: '', + userId: req.user.userId, + }); + + const logPath = (req.query.path as string) || ''; + const hasAccess = userCrons.some((cron: Crontab) => + cron.log_name && + cron.log_name !== '/dev/null' && + (logPath.startsWith(cron.log_name) || cron.log_name.startsWith(logPath)) + ); + + if (!hasAccess) { + return res.send({ + code: 403, + message: '暂无权限', + }); + } + } + const content = await getFileContentByName(finalPath); res.send({ code: 200, data: removeAnsi(content) }); } catch (e) { @@ -68,6 +134,35 @@ export default (app: Router) => { message: '暂无权限', }); } + + // Check if user has permission to view this log + if (req.user?.role !== UserRole.admin && req.user?.userId) { + const cronService = Container.get(CronService); + const { data: userCrons } = await cronService.crontabs({ + searchValue: '', + page: '0', + size: '0', + sorter: '', + filters: '', + queryString: '', + userId: req.user.userId, + }); + + const logPath = (req.query.path as string) || ''; + const hasAccess = userCrons.some((cron: Crontab) => + cron.log_name && + cron.log_name !== '/dev/null' && + (logPath.startsWith(cron.log_name) || cron.log_name.startsWith(logPath)) + ); + + if (!hasAccess) { + return res.send({ + code: 403, + message: '暂无权限', + }); + } + } + const content = await getFileContentByName(finalPath); res.send({ code: 200, data: content }); } catch (e) { @@ -99,6 +194,34 @@ export default (app: Router) => { message: '暂无权限', }); } + + // Check if user has permission to delete this log + if (req.user?.role !== UserRole.admin && req.user?.userId) { + const cronService = Container.get(CronService); + const { data: userCrons } = await cronService.crontabs({ + searchValue: '', + page: '0', + size: '0', + sorter: '', + filters: '', + queryString: '', + userId: req.user.userId, + }); + + const hasAccess = userCrons.some((cron: Crontab) => + cron.log_name && + cron.log_name !== '/dev/null' && + (path.startsWith(cron.log_name) || cron.log_name.startsWith(path)) + ); + + if (!hasAccess) { + return res.send({ + code: 403, + message: '暂无权限', + }); + } + } + await rmPath(finalPath); res.send({ code: 200 }); } catch (e) { @@ -129,6 +252,34 @@ export default (app: Router) => { message: '暂无权限', }); } + + // Check if user has permission to download this log + if (req.user?.role !== UserRole.admin && req.user?.userId) { + const cronService = Container.get(CronService); + const { data: userCrons } = await cronService.crontabs({ + searchValue: '', + page: '0', + size: '0', + sorter: '', + filters: '', + queryString: '', + userId: req.user.userId, + }); + + const hasAccess = userCrons.some((cron: Crontab) => + cron.log_name && + cron.log_name !== '/dev/null' && + (path.startsWith(cron.log_name) || cron.log_name.startsWith(path)) + ); + + if (!hasAccess) { + return res.send({ + code: 403, + message: '暂无权限', + }); + } + } + return res.download(filePath, filename, (err) => { if (err) { return next(err); diff --git a/back/api/system.ts b/back/api/system.ts index 8074e6ff..dbde7abd 100644 --- a/back/api/system.ts +++ b/back/api/system.ts @@ -14,6 +14,7 @@ import { } from '../config/util'; import dayjs from 'dayjs'; import multer from 'multer'; +import { UserRole } from '../data/user'; const route = Router(); const storage = multer.diskStorage({ @@ -357,6 +358,14 @@ export default (app: Router) => { }), async (req: Request, res: Response, next: NextFunction) => { try { + // Only admin can view system logs + if (req.user?.role !== UserRole.admin) { + return res.send({ + code: 403, + message: '暂无权限', + }); + } + const systemService = Container.get(SystemService); await systemService.getSystemLog( res, @@ -375,6 +384,14 @@ export default (app: Router) => { '/log', async (req: Request, res: Response, next: NextFunction) => { try { + // Only admin can delete system logs + if (req.user?.role !== UserRole.admin) { + return res.send({ + code: 403, + message: '暂无权限', + }); + } + const systemService = Container.get(SystemService); await systemService.deleteSystemLog(); res.send({ code: 200 }); diff --git a/back/api/user.ts b/back/api/user.ts index b50c5492..d57824e6 100644 --- a/back/api/user.ts +++ b/back/api/user.ts @@ -9,6 +9,7 @@ import { v4 as uuidV4 } from 'uuid'; import rateLimit from 'express-rate-limit'; import config from '../config'; import { isDemoEnv } from '../config/util'; +import { UserRole } from '../data/user'; const route = Router(); const storage = multer.diskStorage({ @@ -179,6 +180,14 @@ export default (app: Router) => { async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { + // Only admin can view login logs + if (req.user?.role !== UserRole.admin) { + return res.send({ + code: 403, + message: '暂无权限', + }); + } + const userService = Container.get(UserService); const data = await userService.getLoginLog(); res.send({ code: 200, data }); From 0deebcfc88affa37649fad1db297b06517d83b93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:53:19 +0000 Subject: [PATCH 13/13] Hide system log and login log tabs for non-admin users Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- src/pages/setting/index.tsx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/pages/setting/index.tsx b/src/pages/setting/index.tsx index 141713fd..d2d03f58 100644 --- a/src/pages/setting/index.tsx +++ b/src/pages/setting/index.tsx @@ -334,16 +334,20 @@ const Setting = () => { label: intl.get('通知设置'), children: , }, - { - key: 'syslog', - label: intl.get('系统日志'), - children: , - }, - { - key: 'login', - label: intl.get('登录日志'), - children: , - }, + ...(user?.role === 0 + ? [ + { + key: 'syslog', + label: intl.get('系统日志'), + children: , + }, + { + key: 'login', + label: intl.get('登录日志'), + children: , + }, + ] + : []), ...(user?.role === 0 && !isDemoEnv ? [ {