diff --git a/back/api/subscription.ts b/back/api/subscription.ts index 85587a16..c4af1565 100644 --- a/back/api/subscription.ts +++ b/back/api/subscription.ts @@ -3,7 +3,7 @@ import { Container } from 'typedi'; import { Logger } from 'winston'; import SubscriptionService from '../services/subscription'; import { celebrate, Joi } from 'celebrate'; -import cron_parser from 'cron-parser'; +import { CronExpressionParser } from 'cron-parser'; const route = Router(); export default (app: Router) => { @@ -60,7 +60,7 @@ export default (app: Router) => { try { if ( !req.body.schedule || - cron_parser.parseExpression(req.body.schedule).hasNext() + CronExpressionParser.parse(req.body.schedule).hasNext() ) { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.create(req.body); @@ -193,7 +193,7 @@ export default (app: Router) => { if ( !req.body.schedule || typeof req.body.schedule === 'object' || - cron_parser.parseExpression(req.body.schedule).hasNext() + CronExpressionParser.parse(req.body.schedule).hasNext() ) { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.update(req.body); diff --git a/back/data/env.ts b/back/data/env.ts index d08d2ec7..bd24f22a 100644 --- a/back/data/env.ts +++ b/back/data/env.ts @@ -44,5 +44,5 @@ export const EnvModel = sequelize.define('Env', { position: DataTypes.NUMBER, name: { type: DataTypes.STRING, unique: 'compositeIndex' }, remarks: DataTypes.STRING, - isPinned: { type: DataTypes.NUMBER, field: 'is_pinned' }, + isPinned: DataTypes.NUMBER, }); diff --git a/back/loaders/db.ts b/back/loaders/db.ts index ad6fe659..7ca07d72 100644 --- a/back/loaders/db.ts +++ b/back/loaders/db.ts @@ -64,9 +64,9 @@ export default async () => { await sequelize.query( 'alter table Crontabs add column log_name VARCHAR(255)', ); - } catch (error) {} + } catch (error) { } try { - await sequelize.query('alter table Envs add column is_pinned NUMBER'); + await sequelize.query('alter table Envs add column isPinned NUMBER'); } catch (error) {} Logger.info('✌️ DB loaded'); diff --git a/back/loaders/deps.ts b/back/loaders/deps.ts index 767b9dfd..ade1bfeb 100644 --- a/back/loaders/deps.ts +++ b/back/loaders/deps.ts @@ -58,6 +58,6 @@ export default async (src: string = 'deps') => { }); watcher - .on('add', (path) => linkToNodeModule(src)) - .on('change', (path) => linkToNodeModule(src)); + .on('add', () => linkToNodeModule(src)) + .on('change', () => linkToNodeModule(src)); }; diff --git a/back/services/cron.ts b/back/services/cron.ts index cbae3cc0..67bfefd2 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -4,7 +4,7 @@ import config from '../config'; import { Crontab, CrontabModel, CrontabStatus } from '../data/cron'; import { exec, execSync } from 'child_process'; import fs from 'fs/promises'; -import cron_parser from 'cron-parser'; +import { CronExpressionParser } from 'cron-parser'; import { getFileContentByName, fileExist, @@ -27,7 +27,7 @@ import { ScheduleType } from '../interface/schedule'; @Service() export default class CronService { - constructor(@Inject('logger') private logger: winston.Logger) {} + constructor(@Inject('logger') private logger: winston.Logger) { } private isNodeCron(cron: Crontab) { const { schedule, extra_schedules } = cron; @@ -49,9 +49,27 @@ export default class CronService { return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule); } + private async getLogName(cron: Crontab) { + const { log_name, command, id } = cron; + if (log_name === '/dev/null') { + return log_name; + } + let uniqPath = await getUniqPath(command, `${id}`); + if (log_name) { + const normalizedLogName = log_name.startsWith('/') ? log_name : path.join(config.logPath, log_name); + if (normalizedLogName.startsWith(config.logPath)) { + uniqPath = log_name; + } + } + const logDirPath = path.resolve(config.logPath, `${uniqPath}`); + await fs.mkdir(logDirPath, { recursive: true }); + return uniqPath; + } + public async create(payload: Crontab): Promise { const tab = new Crontab(payload); tab.saved = false; + tab.log_name = await this.getLogName(tab); const doc = await this.insert(tab); if (isDemoEnv()) { @@ -82,6 +100,7 @@ export default class CronService { const doc = await this.getDb({ id: payload.id }); const tab = new Crontab({ ...doc, ...payload }); tab.saved = false; + tab.log_name = await this.getLogName(tab); const newDoc = await this.updateDb(tab); if (doc.isDisabled === 1 || isDemoEnv()) { @@ -142,7 +161,7 @@ export default class CronService { let cron; try { cron = await this.getDb({ id }); - } catch (err) {} + } catch (err) { } if (!cron) { continue; } @@ -476,61 +495,14 @@ export default class CronService { `[panel][开始执行任务] 参数: ${JSON.stringify(params)}`, ); - 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}`); - } + let { id, command, log_name } = cron; + + const uniqPath = log_name === '/dev/null' ? (await getUniqPath(command, `${id}`)) : log_name; + const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); + const logDirPath = path.resolve(config.logPath, `${uniqPath}`); + await fs.mkdir(logDirPath, { recursive: true }); + 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, @@ -610,7 +582,9 @@ export default class CronService { if (!doc) { return ''; } - + if (doc.log_name === '/dev/null') { + return '日志设置为忽略'; + } const absolutePath = path.resolve(config.logPath, `${doc.log_path}`); const logFileExist = doc.log_path && (await fileExist(absolutePath)); if (logFileExist) { @@ -653,9 +627,7 @@ export default class CronService { if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) { command = `${TASK_PREFIX}${tab.command}`; } - let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${ - tab.id - } `; + let commandVariable = `real_time=${Boolean(realTime)} log_name=${tab.log_name} no_tee=true ID=${tab.id} `; if (tab.task_before) { commandVariable += `task_before='${tab.task_before .replace(/'/g, "'\\''") @@ -716,7 +688,7 @@ export default class CronService { if ( command && schedule && - cron_parser.parseExpression(schedule).hasNext() + CronExpressionParser.parse(schedule).hasNext() ) { const name = namePrefix + '_' + index; diff --git a/back/validation/schedule.ts b/back/validation/schedule.ts index 3a429ed1..60d82bcd 100644 --- a/back/validation/schedule.ts +++ b/back/validation/schedule.ts @@ -1,5 +1,5 @@ import { Joi } from 'celebrate'; -import cron_parser from 'cron-parser'; +import { CronExpressionParser } from 'cron-parser'; import { ScheduleType } from '../interface/schedule'; import path from 'path'; import config from '../config'; @@ -13,7 +13,7 @@ const validateSchedule = (value: string, helpers: any) => { } try { - if (cron_parser.parseExpression(value).hasNext()) { + if (CronExpressionParser.parse(value).hasNext()) { return value; } } catch (e) { @@ -45,27 +45,26 @@ export const commonCronSchema = { .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)) { + + if (!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(value)) { return helpers.error('string.pattern.base'); } if (value.length > 100) { diff --git a/package.json b/package.json index 523fff55..d55f2048 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "chokidar": "^4.0.1", "compression": "^1.7.4", "cors": "^2.8.5", - "cron-parser": "^4.9.0", + "cron-parser": "^5.4.0", "cross-spawn": "^7.0.6", "dayjs": "^1.11.13", "dotenv": "^16.4.6", diff --git a/shell/task.sh b/shell/task.sh index a51eeaef..07c23c5a 100755 --- a/shell/task.sh +++ b/shell/task.sh @@ -46,18 +46,22 @@ handle_log_path() { time=$(date "+$mtime_format") log_time=$(format_log_time "$mtime_format" "$time") - log_dir_tmp="${file_param##*/}" - if [[ $file_param =~ "/" ]]; then - if [[ $file_param == /* ]]; then - log_dir_tmp_path="${file_param:1}" - else - log_dir_tmp_path="${file_param}" + if [[ -z $log_name ]]; then + log_dir_tmp="${file_param##*/}" + if [[ $file_param =~ "/" ]]; then + if [[ $file_param == /* ]]; then + log_dir_tmp_path="${file_param:1}" + else + log_dir_tmp_path="${file_param}" + fi fi + log_dir_tmp_path="${log_dir_tmp_path%/*}" + log_dir_tmp_path="${log_dir_tmp_path##*/}" + [[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}" + log_dir="${log_dir_tmp%.*}${suffix}" + else + log_dir="$log_name" fi - log_dir_tmp_path="${log_dir_tmp_path%/*}" - log_dir_tmp_path="${log_dir_tmp_path##*/}" - [[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}" - log_dir="${log_dir_tmp%.*}${suffix}" log_path="$log_dir/$log_time.log" if [[ ${real_log_path:=} ]]; then @@ -73,6 +77,11 @@ handle_log_path() { if [[ "${real_time:=}" == "true" ]]; then cmd="" fi + + if [[ "${log_dir:=}" == "/dev/null" ]]; then + cmd=">> /dev/null" + log_path="/dev/null" + fi } format_params() { diff --git a/src/app.ts b/src/app.ts index 8aa3dfac..f18e4a19 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,11 +7,18 @@ export function rootContainer(container: any) { 'en': require('./locales/en-US.json'), 'zh': require('./locales/zh-CN.json'), }; - let currentLocale = intl.determineLocale({ - urlLocaleKey: 'lang', - cookieLocaleKey: 'lang', - localStorageLocaleKey: 'lang', - }).slice(0, 2); + let currentLocale: string; + try { + currentLocale = intl.determineLocale({ + urlLocaleKey: 'lang', + cookieLocaleKey: 'lang', + localStorageLocaleKey: 'lang', + }).slice(0, 2); + } catch (e: unknown) { + // Handle decodeURIComponent errors from malformed cookies + console.warn('Failed to determine locale from cookies:', e); + currentLocale = ''; + } if (!currentLocale || !Object.keys(locales).includes(currentLocale)) { currentLocale = 'zh'; diff --git a/src/pages/crontab/index.tsx b/src/pages/crontab/index.tsx index 976c0ffa..68aa7280 100644 --- a/src/pages/crontab/index.tsx +++ b/src/pages/crontab/index.tsx @@ -66,6 +66,7 @@ const SHOW_TAB_COUNT = 10; const Crontab = () => { const { headerStyle, isPhone, theme } = useOutletContext(); + const [allSubscriptions, setAllSubscriptions] = useState([]); const columns: ColumnProps[] = [ { title: intl.get('名称'), @@ -247,8 +248,8 @@ const Crontab = () => { > {record.last_execution_time ? dayjs(record.last_execution_time * 1000).format( - 'YYYY-MM-DD HH:mm:ss', - ) + 'YYYY-MM-DD HH:mm:ss', + ) : '-'} ); @@ -272,6 +273,12 @@ const Crontab = () => { title: intl.get('关联订阅'), width: 185, render: (text, record: any) => record?.subscription?.name || '-', + key: 'sub_id', + dataIndex: 'sub_id', + filters: allSubscriptions.map((sub) => ({ + text: sub.name || sub.alias, + value: sub.id, + })), }, { title: intl.get('操作'), @@ -361,11 +368,10 @@ const Crontab = () => { const getCrons = () => { setLoading(true); const { page, size, sorter, filters } = pageConf; - let url = `${ - config.apiPrefix - }crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify( - filters, - )}`; + let url = `${config.apiPrefix + }crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify( + filters, + )}`; if (sorter && sorter.column && sorter.order) { url += `&sorter=${JSON.stringify({ field: sorter.column.key, @@ -523,9 +529,8 @@ const Crontab = () => { const enabledOrDisabledCron = (record: any, index: number) => { Modal.confirm({ - title: `确认${ - record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') - }`, + title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') + }`, content: ( <> {intl.get('确认')} @@ -540,8 +545,7 @@ const Crontab = () => { onOk() { request .put( - `${config.apiPrefix}crons/${ - record.isDisabled === 1 ? 'enable' : 'disable' + `${config.apiPrefix}crons/${record.isDisabled === 1 ? 'enable' : 'disable' }`, [record.id], ) @@ -565,9 +569,8 @@ const Crontab = () => { const pinOrUnPinCron = (record: any, index: number) => { Modal.confirm({ - title: `确认${ - record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') - }`, + title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') + }`, content: ( <> {intl.get('确认')} @@ -582,8 +585,7 @@ const Crontab = () => { onOk() { request .put( - `${config.apiPrefix}crons/${ - record.isPinned === 1 ? 'unpin' : 'pin' + `${config.apiPrefix}crons/${record.isPinned === 1 ? 'unpin' : 'pin' }`, [record.id], ) @@ -799,8 +801,20 @@ const Crontab = () => { } }, [viewConf, enabledCronViews]); + const getAllSubscriptions = () => { + request + .get(`${config.apiPrefix}subscriptions`) + .then(({ code, data }) => { + if (code === 200) { + setAllSubscriptions(data || []); + } + }) + .catch(() => {}); + }; + useEffect(() => { getCronViews(); + getAllSubscriptions(); }, []); const viewAction = (key: string) => { @@ -1014,6 +1028,7 @@ const Crontab = () => { )} { diff --git a/src/pages/crontab/modal.tsx b/src/pages/crontab/modal.tsx index d010adbe..8951df66 100644 --- a/src/pages/crontab/modal.tsx +++ b/src/pages/crontab/modal.tsx @@ -190,22 +190,15 @@ const CronModal = ({ { validator: (_, value) => { 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(); + if (value.length > 100) { + return Promise.reject(intl.get('日志名称不能超过100个字符')); } - // For relative names, enforce strict pattern - if (!/^[a-zA-Z0-9_-]+$/.test(value)) { + if (!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(value)) { return Promise.reject( intl.get('日志名称只能包含字母、数字、下划线和连字符'), ); } - if (value.length > 100) { - return Promise.reject(intl.get('日志名称不能超过100个字符')); - } return Promise.resolve(); }, }, diff --git a/src/pages/setting/index.tsx b/src/pages/setting/index.tsx index b846b7ee..a3242536 100644 --- a/src/pages/setting/index.tsx +++ b/src/pages/setting/index.tsx @@ -49,6 +49,7 @@ const Setting = () => { reloadTheme, systemInfo, } = useOutletContext(); + console.log('user',user) const columns = [ { title: intl.get('名称'), diff --git a/src/pages/subscription/modal.tsx b/src/pages/subscription/modal.tsx index 332d61c4..90c2f810 100644 --- a/src/pages/subscription/modal.tsx +++ b/src/pages/subscription/modal.tsx @@ -12,7 +12,7 @@ import { } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; -import cron_parser from 'cron-parser'; +import { CronExpressionParser } from 'cron-parser'; import isNil from 'lodash/isNil'; const { Option } = Select; @@ -224,8 +224,8 @@ const SubscriptionModal = ({ type === 'raw' ? 'file' : url.startsWith('http') - ? 'public-repo' - : 'private-repo'; + ? 'public-repo' + : 'private-repo'; form.setFieldsValue({ type: _type, @@ -381,7 +381,7 @@ const SubscriptionModal = ({ if ( scheduleType === 'interval' || !value || - cron_parser.parseExpression(value).hasNext() + CronExpressionParser.parse(value).hasNext() ) { return Promise.resolve(); } else { diff --git a/src/utils/index.ts b/src/utils/index.ts index 381a7337..954b35b3 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,6 @@ import intl from 'react-intl-universal'; import { LANG_MAP, LOG_END_SYMBOL } from './const'; -import cron_parser from 'cron-parser'; +import { CronExpressionParser } from 'cron-parser'; import { ICrontab } from '@/pages/crontab/type'; export default function browserType() { @@ -155,9 +155,9 @@ export default function browserType() { shell === 'none' ? {} : { - shell, // wechat qq uc 360 2345 sougou liebao maxthon - shellVs, - }, + shell, // wechat qq uc 360 2345 sougou liebao maxthon + shellVs, + }, ); console.log( @@ -333,11 +333,11 @@ export function getCommandScript( export function parseCrontab(schedule: string): Date | null { try { - const time = cron_parser.parseExpression(schedule); + const time = CronExpressionParser.parse(schedule); if (time) { return time.next().toDate(); } - } catch (error) {} + } catch (error) { } return null; }