diff --git a/src/routes/app-manager/list.ts b/src/routes/app-manager/list.ts index 522f60f..e2d5409 100644 --- a/src/routes/app-manager/list.ts +++ b/src/routes/app-manager/list.ts @@ -91,6 +91,7 @@ app }) .define(async (ctx) => { const id = ctx.query.id; + const deleteFile = !!ctx.query.deleteFile; // 是否删除文件, 默认不删除 if (!id) { throw new CustomError('id is required'); } @@ -106,7 +107,7 @@ app throw new CustomError('app is published'); } const files = app.data.files || []; - if (files.length > 0) { + if (deleteFile && files.length > 0) { await deleteFiles(files.map((item) => item.path)); } await app.destroy({ @@ -217,13 +218,25 @@ app }) .define(async (ctx) => { const tokenUser = ctx.state.tokenUser; - const { id, username } = ctx.query.data; - if (!id) { - throw new CustomError('id is required'); + const { id, username, appKey, version } = ctx.query.data; + if (!id && !appKey) { + throw new CustomError('id or appKey is required'); } const uid = await getUidByUsername(app, ctx, username); - const appList = await AppListModel.findByPk(id); + let appList: AppListModel | null = null; + if (id) { + appList = await AppListModel.findByPk(id); + if (appList?.uid !== uid) { + throw new CustomError('no permission'); + } + } + if (!appList && appKey) { + if (!version) { + throw new CustomError('version is required'); + } + appList = await AppListModel.findOne({ where: { key: appKey, version, uid } }); + } if (!appList) { throw new CustomError('app not found'); } @@ -265,7 +278,6 @@ app }) .addTo(app); - app .route({ path: 'app', diff --git a/src/routes/app-manager/user-app.ts b/src/routes/app-manager/user-app.ts index db9cf75..1748394 100644 --- a/src/routes/app-manager/user-app.ts +++ b/src/routes/app-manager/user-app.ts @@ -1,6 +1,7 @@ import { AppModel, AppListModel } from './module/index.ts'; import { app } from '@/app.ts'; import { setExpire } from './revoke.ts'; +import { deleteFileByPrefix } from '../file/index.ts'; app .route({ @@ -107,6 +108,7 @@ app .define(async (ctx) => { const tokenUser = ctx.state.tokenUser; const id = ctx.query.id; + const deleteFile = !!ctx.query.deleteFile; // 是否删除文件, 默认不删除 if (!id) { ctx.throw(500, 'id is required'); } @@ -120,6 +122,10 @@ app const list = await AppListModel.findAll({ where: { key: am.key, uid: tokenUser.id } }); await am.destroy({ force: true }); await Promise.all(list.map((item) => item.destroy({ force: true }))); + if (deleteFile) { + const username = tokenUser.username; + await deleteFileByPrefix(`${username}/${am.key}`); + } ctx.body = 'success'; return ctx; }) diff --git a/src/routes/config/config-key.ts b/src/routes/config/config-key.ts index 96a7b4f..38017ad 100644 --- a/src/routes/config/config-key.ts +++ b/src/routes/config/config-key.ts @@ -5,6 +5,7 @@ import { oss } from '@/app.ts'; import { ConfigOssService } from '@kevisual/oss/services'; import { User } from '@/models/user.ts'; import { defaultKeys } from './models/default-keys.ts'; + app .route({ path: 'config', @@ -20,7 +21,7 @@ app const user = new User(); user.setTokenUser(tokenUser); const isAdmin = await user.hasUser('admin'); - const usersConfig = ['upload.json', 'workspace.json', 'ai.json']; + const usersConfig = ['upload.json', 'workspace.json', 'ai.json', 'user.json']; const adminConfig = ['vip.json']; const configs = [...usersConfig, ...(isAdmin ? adminConfig : [])]; if (!configs.includes(configKey)) { @@ -34,6 +35,8 @@ app uid: tokenUser.id, }, defaults: { + title: defaultConfig?.key, + description: defaultConfig?.description || '', key: configKey, uid: tokenUser.id, data: defaultConfig?.data, diff --git a/src/routes/config/models/default-keys.ts b/src/routes/config/models/default-keys.ts index e3362ea..a27ec34 100644 --- a/src/routes/config/models/default-keys.ts +++ b/src/routes/config/models/default-keys.ts @@ -1,18 +1,27 @@ export const defaultKeys = [ { key: 'upload.json', + description: '上传配置', data: { key: 'upload', version: '1.0.0' }, }, { key: 'workspace.json', + description: '工作空间配置', data: { key: 'workspace', version: '1.0.0' }, }, { key: 'ai.json', + description: 'AI配置', data: { key: 'ai', version: '1.0.0' }, }, { key: 'vip.json', + description: 'VIP配置', data: { key: 'vip', version: '1.0.0' }, }, + { + key: 'user.json', + description: '用户配置', + data: { key: 'user', version: '1.0.0', redirectURL: '/root/center/' }, + }, ]; diff --git a/src/routes/file/module/get-minio-list.ts b/src/routes/file/module/get-minio-list.ts index 7a9f901..d9b29b0 100644 --- a/src/routes/file/module/get-minio-list.ts +++ b/src/routes/file/module/get-minio-list.ts @@ -1,5 +1,6 @@ -import { minioClient } from '@/app.ts'; -import { bucketName } from '@/modules/minio.ts'; +import dayjs from 'dayjs'; +import { minioClient } from '../../../modules/minio.ts'; +import { bucketName } from '../../../modules/minio.ts'; import { CopyDestinationOptions, CopySourceOptions } from 'minio'; type MinioListOpt = { prefix: string; @@ -95,7 +96,17 @@ export const deleteFiles = async (prefixs: string[]): Promise => { return false; } }; - +export const deleteFileByPrefix = async (prefix: string): Promise => { + try { + const allFiles = await getMinioList({ prefix, recursive: true }); + const files = allFiles.filter((item) => item.name.startsWith(prefix)); + await deleteFiles(files.map((item) => item.name)); + return true; + } catch (e) { + console.error('delete File Error not handle', e); + return false; + } +}; type GetMinioListAndSetToAppListOpts = { username: string; appKey: string; @@ -148,3 +159,61 @@ export const updateFileStat = async ( }; } }; + +/** + * 将用户A的文件移动到用户B + * @param usernameA + * @param usernameB + * @param clearOldUser 是否清除用户A的文件 + */ +export const mvUserAToUserB = async (usernameA: string, usernameB: string, clearOldUser = false) => { + const oldPrefix = `${usernameA}/`; + const newPrefix = `${usernameB}/`; + const listSource = await getMinioList({ prefix: oldPrefix, recursive: true }); + for (const item of listSource) { + const source = new CopySourceOptions({ Bucket: bucketName, Object: item.name }); + const stat = await getFileStat(item.name); + const newName = item.name.slice(oldPrefix.length); + const metadata = stat?.userMetadata; + const destination = new CopyDestinationOptions({ + Bucket: bucketName, + Object: `${newPrefix}${newName}`, + UserMetadata: metadata, + MetadataDirective: 'COPY', + }); + await minioClient.copyObject(source, destination); + } + if (clearOldUser) { + const files = await getMinioList({ prefix: oldPrefix, recursive: true }); + for (const file of files) { + await minioClient.removeObject(bucketName, file.name); + } + } +}; +export const backupUserA = async (usernameA: string, id: string, backName?: string) => { + const today = backName || dayjs().format('YYYY-MM-DD-HH-mm'); + const backupAllPrefix = `private/backup/${id}/`; + const backupPrefix = `private/backup/${id}/${today}`; + const backupList = await getMinioList({ prefix: backupAllPrefix }); + const backupListSort = backupList.sort((a, b) => -a.prefix.localeCompare(b.prefix)); + if (backupListSort.length > 2) { + const deleteBackup = backupListSort.slice(2); + for (const item of deleteBackup) { + const files = await getMinioList({ prefix: item.prefix, recursive: true }); + for (const file of files) { + await minioClient.removeObject(bucketName, file.name); + } + } + } + await mvUserAToUserB(usernameA, backupPrefix, false); +}; +/** + * 删除用户 + * @param username + */ +export const deleteUser = async (username: string) => { + const list = await getMinioList({ prefix: `${username}/`, recursive: true }); + for (const item of list) { + await minioClient.removeObject(bucketName, item.name); + } +}; diff --git a/src/routes/user/admin/user.ts b/src/routes/user/admin/user.ts new file mode 100644 index 0000000..c05b20f --- /dev/null +++ b/src/routes/user/admin/user.ts @@ -0,0 +1,149 @@ +import { app } from '@/app.ts'; +import { User } from '@/models/user.ts'; +import { nanoid } from 'nanoid'; +import { CustomError } from '@kevisual/router'; +import { backupUserA, deleteUser, mvUserAToUserB } from '@/routes/file/index.ts'; +export const checkUsername = (username: string) => { + if (username.length > 30) { + throw new CustomError(400, 'Username cannot be too long'); + } + if (!/^[a-zA-Z0-9_@]+$/.test(username)) { + throw new CustomError(400, 'Username cannot contain special characters'); + } + if (username.includes(' ')) { + throw new CustomError(400, 'Username cannot contain spaces'); + } +}; +export const checkUsernameShort = (username: string) => { + if (username.length < 3) { + throw new CustomError(400, 'Username cannot be too short'); + } +}; + +app + .route({ + path: 'user', + key: 'changeName', + middleware: ['auth-admin'], + }) + .define(async (ctx) => { + const { id, newName } = ctx.query.data || {}; + const user = await User.findByPk(id); + if (!user) { + ctx.throw(404, 'User not found'); + } + const oldName = user.username; + checkUsername(newName); + const findUserByUsername = await User.findOne({ where: { username: newName } }); + if (findUserByUsername) { + ctx.throw(400, 'Username already exists'); + } + user.username = newName; + try { + await user.save(); + // 迁移文件数据 + await backupUserA(oldName, user.id); // 备份文件数据 + await mvUserAToUserB(oldName, newName, true); // 迁移文件数据 + } catch (error) { + console.error('迁移文件数据失败', error); + ctx.throw(500, 'Failed to change username'); + } + ctx.body = user; + }) + .addTo(app); + +app + .route({ + path: 'user', + key: 'checkUserExist', + middleware: ['auth'], + }) + .define(async (ctx) => { + const { username } = ctx.query.data || {}; + if (!username) { + ctx.throw(400, 'Username is required'); + } + checkUsername(username); + const user = await User.findOne({ where: { username } }); + + ctx.body = { + id: user?.id, + username: user?.username, + }; + }) + .addTo(app); + +app + .route({ + path: 'user', + key: 'resetPassword', + middleware: ['auth-admin'], + }) + .define(async (ctx) => { + const { id, password } = ctx.query.data || {}; + const user = await User.findByPk(id); + if (!user) { + ctx.throw(404, 'User not found'); + } + let pwd = password || nanoid(6); + user.createPassword(pwd); + await user.save(); + + ctx.body = { + id: user.id, + username: user.username, + password: !password ? pwd : undefined, + }; + }) + .addTo(app); + +app + .route({ + path: 'user', + key: 'createNewUser', + middleware: ['auth-admin'], + }) + .define(async (ctx) => { + const { username, password, description } = ctx.query.data || {}; + if (!username) { + ctx.throw(400, 'Username is required'); + } + checkUsername(username); + const findUserByUsername = await User.findOne({ where: { username } }); + if (findUserByUsername) { + ctx.throw(400, 'Username already exists'); + } + let pwd = password || nanoid(6); + const user = await User.createUser(username, pwd, description); + ctx.body = { + id: user.id, + username: user.username, + description: user.description, + password: pwd, + }; + }) + .addTo(app); + +app + .route({ + path: 'user', + key: 'deleteUser', + middleware: ['auth-admin'], + }) + .define(async (ctx) => { + const { id } = ctx.query.data || {}; + const user = await User.findByPk(id); + if (!user) { + ctx.throw(404, 'User not found'); + } + await user.destroy(); + backupUserA(user.username, user.id); + deleteUser(user.username); + // TODO: EXPIRE 删除token + ctx.body = { + id: user.id, + username: user.username, + message: 'User deleted successfully', + }; + }) + .addTo(app); diff --git a/src/routes/user/index.ts b/src/routes/user/index.ts index 1688a43..7a39de9 100644 --- a/src/routes/user/index.ts +++ b/src/routes/user/index.ts @@ -9,4 +9,6 @@ import './init.ts' import './web-login.ts' -import './org-user/list.ts' \ No newline at end of file +import './org-user/list.ts' + +import './admin/user.ts'; \ No newline at end of file diff --git a/src/routes/user/list.ts b/src/routes/user/list.ts index e7e91d1..6dda8e7 100644 --- a/src/routes/user/list.ts +++ b/src/routes/user/list.ts @@ -1,6 +1,8 @@ import { app } from '@/app.ts'; import { User } from '@/models/user.ts'; import { CustomError } from '@kevisual/router'; +import { checkUsername } from './admin/user.ts'; +import { nanoid } from 'nanoid'; app .route({ @@ -18,7 +20,6 @@ app }) .addTo(app); - app .route({ path: 'user', @@ -28,9 +29,12 @@ app .define(async (ctx) => { const tokenUser = ctx.state.tokenUser; const { id, username, password, description } = ctx.query.data || {}; + if (!id) { + throw new CustomError(400, 'id is required'); + } const user = await User.findByPk(id); if (user.id !== tokenUser.id) { - throw new CustomError(401, 'Permission denied'); + throw new CustomError(403, 'Permission denied'); } if (!user) { @@ -59,21 +63,26 @@ app .route({ path: 'user', key: 'add', - middleware: ['auth'], + middleware: ['auth-admin'], }) .define(async (ctx) => { - const tokenUser = ctx.state.tokenUser; const { username, password, description } = ctx.query.data || {}; if (!username) { throw new CustomError(400, 'username is required'); } - const user = await User.createUser(username, password, description); - const token = await user.createToken(); + checkUsername(username); + const findUserByUsername = await User.findOne({ where: { username } }); + if (findUserByUsername) { + throw new CustomError(400, 'username already exists'); + } + const pwd = password || nanoid(6); + const user = await User.createUser(username, pwd, description); ctx.body = { id: user.id, username: user.username, description: user.description, needChangePassword: user.needChangePassword, - token, + password: pwd, }; - }); + }) + .addTo(app); diff --git a/src/routes/user/me.ts b/src/routes/user/me.ts index 7b5cb89..4d74526 100644 --- a/src/routes/user/me.ts +++ b/src/routes/user/me.ts @@ -209,13 +209,13 @@ app const { id } = tokenUser; const user = await User.findByPk(id); if (!user) { - ctx.throw(500, 'user not found'); + ctx.throw(404, 'user not found'); } user.setTokenUser(tokenUser); if (username) { user.username = username; } - if (password) { + if (password && user.type !== 'org') { user.createPassword(password); } if (description) { diff --git a/src/scripts/mv-minio.ts b/src/scripts/mv-minio.ts new file mode 100644 index 0000000..8d514e9 --- /dev/null +++ b/src/scripts/mv-minio.ts @@ -0,0 +1,15 @@ +process.env.NODE_ENV = 'development'; +// import { mvUserAToUserB, backupUserA } from '../routes/file/module/get-minio-list.ts'; + + +// mvUserAToUserB('demo', 'demo2'); + +// backupUserA('demo', '123', '2025-04-02-16-00'); +// backupUserA('demo', '123', '2025-04-02-16-01'); +// backupUserA('demo', '123', '2025-04-02-16-02'); +// backupUserA('demo', '123', '2025-04-02-16-03'); +// backupUserA('demo', '123', '2025-04-02-16-04'); +// backupUserA('demo', '123', '2025-04-02-16-05'); +// backupUserA('demo', '123', '2025-04-02-16-06'); +// backupUserA('demo', '123', '2025-04-02-16-07'); +// backupUserA('demo', '123', '2025-04-02-16-08'); diff --git a/src/utils/get-content-type.ts b/src/utils/get-content-type.ts index 5dc1806..e7d619d 100644 --- a/src/utils/get-content-type.ts +++ b/src/utils/get-content-type.ts @@ -5,6 +5,7 @@ export const getContentType = (filePath: string) => { const contentType = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.txt': 'text/plain; charset=utf-8', '.json': 'application/json; charset=utf-8', diff --git a/submodules/code-center-module b/submodules/code-center-module index 0a72db7..ad0d2e7 160000 --- a/submodules/code-center-module +++ b/submodules/code-center-module @@ -1 +1 @@ -Subproject commit 0a72db77719e8f99a4027566bc86217c85c1bab3 +Subproject commit ad0d2e717f0cd409530735ab7143c94d910f939e