diff --git a/back/api/env.ts b/back/api/env.ts index 51fdc631..c19cab0a 100644 --- a/back/api/env.ts +++ b/back/api/env.ts @@ -1,12 +1,12 @@ -import { Router, Request, Response, NextFunction } from 'express'; -import { Container } from 'typedi'; -import EnvService from '../services/env'; -import { Logger } from 'winston'; -import { celebrate, Joi } from 'celebrate'; -import multer from 'multer'; -import config from '../config'; +import { Joi, celebrate } from 'celebrate'; +import { NextFunction, Request, Response, Router } from 'express'; import fs from 'fs'; +import multer from 'multer'; +import { Container } from 'typedi'; +import { Logger } from 'winston'; +import config from '../config'; import { safeJSONParse } from '../config/util'; +import EnvService from '../services/env'; const route = Router(); const storage = multer.diskStorage({ @@ -196,6 +196,40 @@ export default (app: Router) => { }, ); + route.put( + '/pin', + celebrate({ + body: Joi.array().items(Joi.number().required()), + }), + async (req: Request, res: Response, next: NextFunction) => { + const logger: Logger = Container.get('logger'); + try { + const envService = Container.get(EnvService); + const data = await envService.pin(req.body); + return res.send({ code: 200, data }); + } catch (e) { + return next(e); + } + }, + ); + + route.put( + '/unpin', + celebrate({ + body: Joi.array().items(Joi.number().required()), + }), + async (req: Request, res: Response, next: NextFunction) => { + const logger: Logger = Container.get('logger'); + try { + const envService = Container.get(EnvService); + const data = await envService.unPin(req.body); + return res.send({ code: 200, data }); + } catch (e) { + return next(e); + } + }, + ); + route.post( '/upload', upload.single('env'), diff --git a/back/data/cron.ts b/back/data/cron.ts index 425d8f05..0e859e24 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; + log_name?: string; userId?: number; constructor(options: Crontab) { @@ -46,6 +47,7 @@ export class Crontab { this.extra_schedules = options.extra_schedules; this.task_before = options.task_before; this.task_after = options.task_after; + this.log_name = options.log_name; this.userId = options.userId; } } @@ -57,7 +59,7 @@ export enum CrontabStatus { 'disabled', } -export interface CronInstance extends Model, Crontab { } +export interface CronInstance extends Model, Crontab {} export const CrontabModel = sequelize.define('Crontab', { name: { unique: 'compositeIndex', @@ -86,5 +88,6 @@ export const CrontabModel = sequelize.define('Crontab', { extra_schedules: DataTypes.JSON, task_before: DataTypes.STRING, task_after: DataTypes.STRING, + log_name: DataTypes.STRING, userId: { type: DataTypes.NUMBER, allowNull: true }, }); diff --git a/back/data/env.ts b/back/data/env.ts index e96611e8..294d9f12 100644 --- a/back/data/env.ts +++ b/back/data/env.ts @@ -1,5 +1,5 @@ +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '.'; -import { DataTypes, Model, ModelDefined } from 'sequelize'; export class Env { value?: string; @@ -10,6 +10,7 @@ export class Env { name?: string; remarks?: string; userId?: number; + isPinned?: 1 | 0; constructor(options: Env) { this.value = options.value; @@ -23,6 +24,7 @@ export class Env { this.name = options.name; this.remarks = options.remarks || ''; this.userId = options.userId; + this.isPinned = options.isPinned || 0; } } @@ -45,4 +47,5 @@ export const EnvModel = sequelize.define('Env', { name: { type: DataTypes.STRING, unique: 'compositeIndex' }, remarks: DataTypes.STRING, userId: { type: DataTypes.NUMBER, allowNull: true }, + isPinned: { type: DataTypes.NUMBER, field: 'is_pinned' }, }); diff --git a/back/loaders/db.ts b/back/loaders/db.ts index ac142a2d..a0af7c6d 100644 --- a/back/loaders/db.ts +++ b/back/loaders/db.ts @@ -56,6 +56,14 @@ export default async () => { try { await sequelize.query('alter table Crontabs add column task_after TEXT'); } catch (error) {} + try { + await sequelize.query( + 'alter table Crontabs add column log_name VARCHAR(255)', + ); + } catch (error) {} + try { + await sequelize.query('alter table Envs add column is_pinned NUMBER'); + } catch (error) {} Logger.info('✌️ DB loaded'); } catch (error) { diff --git a/back/services/cron.ts b/back/services/cron.ts index c2f32597..8894792c 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -511,15 +511,61 @@ export default class CronService { `[panel][开始执行任务] 参数: ${JSON.stringify(params)}`, ); - let { id, command, log_path } = cron; - const uniqPath = await getUniqPath(command, `${id}`); - const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); - const logDirPath = path.resolve(config.logPath, `${uniqPath}`); - if (log_path?.split('/')?.every((x) => x !== uniqPath)) { - await fs.mkdir(logDirPath, { recursive: true }); + let { id, command, log_path, log_name } = cron; + + // Check if log_name is an absolute path + const isAbsolutePath = log_name && log_name.startsWith('/'); + + let uniqPath: string; + let absolutePath: string; + let logPath: string; + + if (isAbsolutePath) { + // Special case: /dev/null is allowed as-is to discard logs + if (log_name === '/dev/null') { + uniqPath = log_name; + absolutePath = log_name; + logPath = log_name; + } else { + // For other absolute paths, ensure they are within the safe log directory + const normalizedLogName = path.normalize(log_name!); + const normalizedLogPath = path.normalize(config.logPath); + + if (!normalizedLogName.startsWith(normalizedLogPath)) { + this.logger.error( + `[panel][日志路径安全检查失败] 绝对路径必须在日志目录内: ${log_name}`, + ); + // Fallback to auto-generated path for security + const fallbackUniqPath = await getUniqPath(command, `${id}`); + const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); + const logDirPath = path.resolve(config.logPath, `${fallbackUniqPath}`); + if (log_path?.split('/')?.every((x) => x !== fallbackUniqPath)) { + await fs.mkdir(logDirPath, { recursive: true }); + } + logPath = `${fallbackUniqPath}/${logTime}.log`; + absolutePath = path.resolve(config.logPath, `${logPath}`); + uniqPath = fallbackUniqPath; + } else { + // Absolute path is safe, use it + uniqPath = log_name!; + absolutePath = log_name!; + logPath = log_name!; + } + } + } else { + // Sanitize log_name to prevent path traversal for relative paths + const sanitizedLogName = log_name + ? log_name.replace(/[\/\\\.]/g, '_').replace(/^_+|_+$/g, '') + : ''; + uniqPath = sanitizedLogName || (await getUniqPath(command, `${id}`)); + const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); + const logDirPath = path.resolve(config.logPath, `${uniqPath}`); + if (log_path?.split('/')?.every((x) => x !== uniqPath)) { + await fs.mkdir(logDirPath, { recursive: true }); + } + logPath = `${uniqPath}/${logTime}.log`; + absolutePath = path.resolve(config.logPath, `${logPath}`); } - const logPath = `${uniqPath}/${logTime}.log`; - const absolutePath = path.resolve(config.logPath, `${logPath}`); const cp = spawn( `real_log_path=${logPath} no_delay=true ${this.makeCommand( cron, diff --git a/back/services/env.ts b/back/services/env.ts index fb91e824..a6f5ef5d 100644 --- a/back/services/env.ts +++ b/back/services/env.ts @@ -1,7 +1,8 @@ -import { Service, Inject } from 'typedi'; +import groupBy from 'lodash/groupBy'; +import { FindOptions, Op } from 'sequelize'; +import { Inject, Service } from 'typedi'; import winston from 'winston'; import config from '../config'; -import * as fs from 'fs/promises'; import { Env, EnvModel, @@ -11,8 +12,6 @@ import { minPosition, stepPosition, } from '../data/env'; -import groupBy from 'lodash/groupBy'; -import { FindOptions, Op } from 'sequelize'; import { writeFileWithLock } from '../shared/utils'; @Service() @@ -173,6 +172,7 @@ export default class EnvService { } try { const result = await this.find(condition, [ + ['isPinned', 'DESC'], ['position', 'DESC'], ['createdAt', 'ASC'], ]); @@ -219,6 +219,14 @@ export default class EnvService { await this.set_envs(); } + public async pin(ids: number[]) { + await EnvModel.update({ isPinned: 1 }, { where: { id: ids } }); + } + + public async unPin(ids: number[]) { + await EnvModel.update({ isPinned: 0 }, { where: { id: ids } }); + } + public async set_envs() { const envs = await this.envs('', { name: { [Op.not]: null }, diff --git a/back/validation/schedule.ts b/back/validation/schedule.ts index a280156a..3a429ed1 100644 --- a/back/validation/schedule.ts +++ b/back/validation/schedule.ts @@ -1,6 +1,8 @@ import { Joi } from 'celebrate'; import cron_parser from 'cron-parser'; import { ScheduleType } from '../interface/schedule'; +import path from 'path'; +import config from '../config'; const validateSchedule = (value: string, helpers: any) => { if ( @@ -37,4 +39,43 @@ export const commonCronSchema = { extra_schedules: Joi.array().optional().allow(null), task_before: Joi.string().optional().allow('').allow(null), task_after: Joi.string().optional().allow('').allow(null), + log_name: Joi.string() + .optional() + .allow('') + .allow(null) + .custom((value, helpers) => { + if (!value) return value; + + // Check if it's an absolute path + if (value.startsWith('/')) { + // Allow /dev/null as special case + if (value === '/dev/null') { + return value; + } + + // For other absolute paths, ensure they are within the safe log directory + const normalizedValue = path.normalize(value); + const normalizedLogPath = path.normalize(config.logPath); + + if (!normalizedValue.startsWith(normalizedLogPath)) { + return helpers.error('string.unsafePath'); + } + + return value; + } + + // For relative names, enforce strict pattern + if (!/^[a-zA-Z0-9_-]+$/.test(value)) { + return helpers.error('string.pattern.base'); + } + if (value.length > 100) { + return helpers.error('string.max'); + } + return value; + }) + .messages({ + 'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符', + 'string.max': '日志名称不能超过100个字符', + 'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null', + }), }; diff --git a/shell/check.sh b/shell/check.sh index fd2cd6c9..ae4db058 100755 --- a/shell/check.sh +++ b/shell/check.sh @@ -24,8 +24,8 @@ copy_dep() { pm2_log() { echo -e "---> pm2日志" - local panelOut="/root/.pm2/logs/panel-out.log" - local panelError="/root/.pm2/logs/panel-error.log" + local panelOut="/root/.pm2/logs/qinglong-out.log" + local panelError="/root/.pm2/logs/qinglong-error.log" tail -n 300 "$panelOut" tail -n 300 "$panelError" } diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 9a21ef3a..7d1e7675 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -521,5 +521,14 @@ "远程仓库缓存": "Remote repository cache", "SSH 文件缓存": "SSH file cache", "清除依赖缓存": "Clean dependency cache", - "清除成功": "Clean successful" + "清除成功": "Clean successful", + "日志名称": "Log Name", + "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate", + "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports absolute paths like /dev/null", + "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports /dev/null to discard logs, other absolute paths must be within log directory", + "请输入自定义日志文件夹名称": "Please enter a custom log folder name", + "请输入自定义日志文件夹名称或绝对路径": "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" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index c19d6984..97e97f20 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -521,5 +521,14 @@ "远程仓库缓存": "远程仓库缓存", "SSH 文件缓存": "SSH 文件缓存", "清除依赖缓存": "清除依赖缓存", - "清除成功": "清除成功" + "清除成功": "清除成功", + "日志名称": "日志名称", + "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成", + "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null", + "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内", + "请输入自定义日志文件夹名称": "请输入自定义日志文件夹名称", + "请输入自定义日志文件夹名称或绝对路径": "请输入自定义日志文件夹名称或绝对路径", + "请输入自定义日志文件夹名称或 /dev/null": "请输入自定义日志文件夹名称或 /dev/null", + "日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符", + "日志名称不能超过100个字符": "日志名称不能超过100个字符" } diff --git a/src/pages/crontab/modal.tsx b/src/pages/crontab/modal.tsx index 6a7c0492..d010adbe 100644 --- a/src/pages/crontab/modal.tsx +++ b/src/pages/crontab/modal.tsx @@ -180,6 +180,42 @@ const CronModal = ({ + { + if (!value) return Promise.resolve(); + // Allow /dev/null specifically + if (value === '/dev/null') return Promise.resolve(); + // Warn about other absolute paths (server will validate) + if (value.startsWith('/')) { + // We can't validate the exact path on frontend, but inform user + return Promise.resolve(); + } + // For relative names, enforce strict pattern + if (!/^[a-zA-Z0-9_-]+$/.test(value)) { + return Promise.reject( + intl.get('日志名称只能包含字母、数字、下划线和连字符'), + ); + } + if (value.length > 100) { + return Promise.reject(intl.get('日志名称不能超过100个字符')); + } + return Promise.resolve(); + }, + }, + ]} + > + + { { title: intl.get('操作'), key: 'action', - width: 120, + width: 160, render: (text: string, record: any, index: number) => { const isPc = !isPhone; return ( @@ -208,6 +207,23 @@ const Env = () => { )} + + pinOrUnpinEnv(record, index)}> + {record.isPinned === 1 ? ( + + ) : ( + + )} + + deleteEnv(record, index)}> @@ -305,6 +321,51 @@ const Env = () => { setIsModalVisible(true); }; + const pinOrUnpinEnv = (record: any, index: number) => { + Modal.confirm({ + title: `确认${ + record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') + }`, + content: ( + <> + {intl.get('确认')} + {record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')} + Env{' '} + + {record.name}: {record.value} + {' '} + {intl.get('吗')} + + ), + onOk() { + request + .put( + `${config.apiPrefix}envs/${ + record.isPinned === 1 ? 'unpin' : 'pin' + }`, + [record.id], + ) + .then(({ code, data }) => { + if (code === 200) { + message.success( + `${ + record.isPinned === 1 + ? intl.get('取消置顶') + : intl.get('置顶') + }${intl.get('成功')}`, + ); + getEnvs(); + } + }); + }, + }); + }; + const deleteEnv = (record: any, index: number) => { Modal.confirm({ title: intl.get('确认删除'), @@ -589,6 +650,20 @@ const Env = () => { > {intl.get('批量禁用')} + + {intl.get('已选择')} {selectedRowIds?.length}