Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andwhyour 2c57ada739 Fix: Disable/enable associated cron tasks when subscription is disabled/enabled
When a subscription is disabled, the associated cron tasks (created by the subscription) were still running and updating scripts. This fix ensures that:
- When disabling a subscription, all cron tasks with matching sub_id are also disabled
- When enabling a subscription, all cron tasks with matching sub_id are also enabled

This addresses the actual root cause: subscription tasks don't run when disabled (as the owner correctly pointed out), but the cron tasks created by those subscriptions were still active.

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-20 16:11:56 +00:00
copilot-swe-agent[bot]andwhyour 87a1a3d2eb Fix disabled subscriptions still updating tasks
- Filter setSshConfig() to only configure SSH keys for enabled subscriptions
- Remove SSH keys when subscriptions are disabled
- This prevents disabled subscriptions from running scheduled tasks

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-20 15:50:35 +00:00
copilot-swe-agent[bot] 99064bde10 Initial plan 2025-11-20 15:44:23 +00:00
42 changed files with 383 additions and 1035 deletions
+24 -33
View File
@@ -9,13 +9,15 @@ on:
- "develop" - "develop"
tags: tags:
- "v*" - "v*"
schedule:
- cron: "00 20 * * *"
workflow_dispatch: workflow_dispatch:
jobs: jobs:
code_gitlab: code_gitlab:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - uses: Yikun/hub-mirror-action@master
@@ -30,7 +32,7 @@ jobs:
code_gitee: code_gitee:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - uses: Yikun/hub-mirror-action@master
@@ -45,12 +47,12 @@ jobs:
build-static: build-static:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v3
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
with: with:
cache: "pnpm" cache: "pnpm"
@@ -81,7 +83,7 @@ jobs:
needs: build-static needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - uses: Yikun/hub-mirror-action@master
@@ -97,7 +99,7 @@ jobs:
needs: build-static needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - uses: Yikun/hub-mirror-action@master
@@ -110,7 +112,6 @@ jobs:
force_update: true force_update: true
build: build:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static needs: build-static
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@@ -120,21 +121,14 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v3
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
with: with:
cache: "pnpm" cache: "pnpm"
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone - name: Setup timezone
uses: szenius/set-timezone@v2.0 uses: szenius/set-timezone@v2.0
with: with:
@@ -160,13 +154,19 @@ jobs:
images: | images: |
${{ github.repository }} ${{ github.repository }}
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
# generate Docker tags based on the following events/attributes
# nightly, master, pr-2, 1.2.3, 1.2, 1
flavor: | flavor: |
latest=false latest=false
tags: | tags: |
type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }} type=schedule,pattern=nightly
type=edge
type=ref,event=pr
type=ref,event=branch,enable=${{ github.ref != format('refs/heads/{0}', 'master') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -208,21 +208,14 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v3
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
with: with:
cache: "pnpm" cache: "pnpm"
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone - name: Setup timezone
uses: szenius/set-timezone@v2.0 uses: szenius/set-timezone@v2.0
with: with:
@@ -261,9 +254,7 @@ jobs:
context: . context: .
file: ./docker/310.Dockerfile file: ./docker/310.Dockerfile
push: true push: true
tags: | tags: whyour/qinglong:python3.10
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-python3.10 cache-from: type=registry,ref=whyour/qinglong:cache-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max
+1 -1
View File
@@ -16,7 +16,7 @@ export default (app: Router) => {
searchValue: Joi.string().optional().allow(''), searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''), type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''), status: Joi.string().optional().allow(''),
}).unknown(true), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
+4 -6
View File
@@ -29,7 +29,7 @@ export default (app: Router) => {
celebrate({ celebrate({
query: Joi.object({ query: Joi.object({
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
}).unknown(true), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
@@ -79,7 +79,7 @@ export default (app: Router) => {
query: Joi.object({ query: Joi.object({
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
file: Joi.string().required(), file: Joi.string().required(),
}).unknown(true), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
@@ -103,7 +103,7 @@ export default (app: Router) => {
}), }),
query: Joi.object({ query: Joi.object({
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
}).unknown(true), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
@@ -129,8 +129,7 @@ export default (app: Router) => {
content: Joi.string().optional().allow(''), content: Joi.string().optional().allow(''),
originFilename: Joi.string().optional().allow(''), originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''), directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''), }),
}).unknown(true),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
@@ -176,7 +175,6 @@ export default (app: Router) => {
path, path,
`${originFilename.replace(/\//g, '')}`, `${originFilename.replace(/\//g, '')}`,
); );
await fs.mkdir(path, { recursive: true });
const filePath = join(path, `${filename.replace(/\//g, '')}`); const filePath = join(path, `${filename.replace(/\//g, '')}`);
const fileExists = await fileExist(filePath); const fileExists = await fileExist(filePath);
if (fileExists) { if (fileExists) {
+1 -1
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
-13
View File
@@ -374,19 +374,6 @@ export default (app: Router) => {
}, },
); );
route.get(
'/notify-log',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const data = await systemService.getNotifyLog();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete( route.delete(
'/log', '/log',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
-14
View File
@@ -64,19 +64,6 @@ if (!process.env.QL_DIR) {
const lastVersionFile = `https://qn.whyour.cn/version.yaml`; const lastVersionFile = `https://qn.whyour.cn/version.yaml`;
// Get and normalize QlBaseUrl
let baseUrl = process.env.QlBaseUrl || '';
if (baseUrl) {
// Ensure it starts with /
if (!baseUrl.startsWith('/')) {
baseUrl = `/${baseUrl}`;
}
// Remove trailing slash for consistency in route definitions
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
}
const rootPath = process.env.QL_DIR as string; const rootPath = process.env.QL_DIR as string;
const envFound = dotenv.config({ path: path.join(rootPath, '.env') }); const envFound = dotenv.config({ path: path.join(rootPath, '.env') });
@@ -129,7 +116,6 @@ if (envFound.error) {
export default { export default {
...config, ...config,
jwt: config.jwt, jwt: config.jwt,
baseUrl,
rootPath, rootPath,
tmpPath, tmpPath,
dataPath, dataPath,
-1
View File
@@ -142,7 +142,6 @@ export class WebhookNotification extends NotificationBaseInfo {
export class LarkNotification extends NotificationBaseInfo { export class LarkNotification extends NotificationBaseInfo {
public larkKey = ''; public larkKey = '';
public larkSecret = '';
} }
export class NtfyNotification extends NotificationBaseInfo { export class NtfyNotification extends NotificationBaseInfo {
-15
View File
@@ -28,12 +28,6 @@ export enum AuthDataType {
'removeLogFrequency' = 'removeLogFrequency', 'removeLogFrequency' = 'removeLogFrequency',
'systemConfig' = 'systemConfig', 'systemConfig' = 'systemConfig',
'authConfig' = 'authConfig', 'authConfig' = 'authConfig',
'notifyLog' = 'notifyLog',
}
export enum NotifyStatus {
'success',
'fail',
} }
export interface SystemConfigInfo { export interface SystemConfigInfo {
@@ -55,14 +49,6 @@ export interface LoginLogInfo {
status?: LoginStatus; status?: LoginStatus;
} }
export interface NotifyLogInfo {
timestamp?: number;
title?: string;
content?: string;
status?: NotifyStatus;
notifyType?: string;
}
export interface TokenInfo { export interface TokenInfo {
value: string; value: string;
timestamp: number; timestamp: number;
@@ -95,7 +81,6 @@ export interface AuthInfo {
export type SystemModelInfo = SystemConfigInfo & export type SystemModelInfo = SystemConfigInfo &
Partial<NotificationInfo> & Partial<NotificationInfo> &
LoginLogInfo & LoginLogInfo &
Partial<NotifyLogInfo> &
Partial<AuthInfo>; Partial<AuthInfo>;
export interface SystemInstance export interface SystemInstance
+5 -41
View File
@@ -13,35 +13,8 @@ import { isValidToken } from '../shared/auth';
import path from 'path'; import path from 'path';
export default ({ app }: { app: Application }) => { export default ({ app }: { app: Application }) => {
// Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true);
app.set('strict routing', true);
app.set('trust proxy', 'loopback'); app.set('trust proxy', 'loopback');
app.use(cors()); app.use(cors());
// Security: Path normalization middleware to prevent case variation attacks
app.use((req, res, next) => {
const originalPath = req.path;
const normalizedPath = originalPath.toLowerCase();
// Block requests with case variations on protected paths
if (originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
return res.status(400).json({
code: 400,
message: 'Invalid path format'
});
}
next();
});
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) {
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
}
app.get(`${config.api.prefix}/env.js`, serveEnv); app.get(`${config.api.prefix}/env.js`, serveEnv);
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath)); app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
@@ -56,7 +29,7 @@ export default ({ app }: { app: Application }) => {
secret: config.jwt.secret, secret: config.jwt.secret,
algorithms: ['HS384'], algorithms: ['HS384'],
}).unless({ }).unless({
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i], path: [...config.apiWhiteList, /^\/(?!api\/).*/],
}), }),
); );
@@ -71,20 +44,19 @@ export default ({ app }: { app: Application }) => {
}); });
app.use(async (req: Request, res, next) => { app.use(async (req: Request, res, next) => {
const pathLower = req.path.toLowerCase(); if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next(); return next();
} }
const headerToken = getToken(req); const headerToken = getToken(req);
if (pathLower.startsWith('/open/')) { if (req.path.startsWith('/open/')) {
const apps = await shareStore.getApps(); const apps = await shareStore.getApps();
const doc = apps?.filter((x) => const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken), x.tokens?.find((y) => y.value === headerToken),
)?.[0]; )?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) { if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken); const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/); const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1]; const key = keyMatch && keyMatch[1];
if ( if (
doc.scopes.includes(key as any) && doc.scopes.includes(key as any) &&
@@ -119,15 +91,7 @@ export default ({ app }: { app: Application }) => {
}); });
app.use(async (req, res, next) => { app.use(async (req, res, next) => {
const pathLower = req.path.toLowerCase(); if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(req.path)
) {
return next(); return next();
} }
const authInfo = const authInfo =
+2 -2
View File
@@ -13,7 +13,7 @@ import { AuthDataType, SystemModel } from '../data/system';
import SystemService from '../services/system'; import SystemService from '../services/system';
import UserService from '../services/user'; import UserService from '../services/user';
import { writeFile, readFile } from 'fs/promises'; import { writeFile, readFile } from 'fs/promises';
import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util'; import { createRandomString, fileExist, safeJSONParse } from '../config/util';
import OpenService from '../services/open'; import OpenService from '../services/open';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import Logger from './logger'; import Logger from './logger';
@@ -50,7 +50,7 @@ export default async () => {
const [authConfig] = await SystemModel.findOrCreate({ const [authConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.authConfig }, where: { type: AuthDataType.authConfig },
}); });
if (!authConfig?.info || isDemoEnv()) { if (!authConfig?.info) {
let authInfo = { let authInfo = {
username: 'admin', username: 'admin',
password: 'admin', password: 'admin',
+1 -2
View File
@@ -5,10 +5,9 @@ import SockService from '../services/sock';
import { getPlatform } from '../config/util'; import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth'; import { isValidToken } from '../shared/auth';
import config from '../config';
export default async ({ server }: { server: Server }) => { export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } }); const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} });
const sockService = Container.get(SockService); const sockService = Container.get(SockService);
echo.on('connection', async (conn) => { echo.on('connection', async (conn) => {
-1
View File
@@ -231,7 +231,6 @@ message NotificationInfo {
optional string webhookContentType = 57; optional string webhookContentType = 57;
optional string larkKey = 58; optional string larkKey = 58;
optional string larkSecret = 69;
optional string ntfyUrl = 59; optional string ntfyUrl = 59;
optional string ntfyTopic = 60; optional string ntfyTopic = 60;
+1 -19
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.21.12 // protoc v3.17.3
// source: back/protos/api.proto // source: back/protos/api.proto
/* eslint-disable */ /* eslint-disable */
@@ -382,7 +382,6 @@ export interface NotificationInfo {
webhookMethod?: string | undefined; webhookMethod?: string | undefined;
webhookContentType?: string | undefined; webhookContentType?: string | undefined;
larkKey?: string | undefined; larkKey?: string | undefined;
larkSecret?: string | undefined;
ntfyUrl?: string | undefined; ntfyUrl?: string | undefined;
ntfyTopic?: string | undefined; ntfyTopic?: string | undefined;
ntfyPriority?: string | undefined; ntfyPriority?: string | undefined;
@@ -2948,7 +2947,6 @@ function createBaseNotificationInfo(): NotificationInfo {
webhookMethod: undefined, webhookMethod: undefined,
webhookContentType: undefined, webhookContentType: undefined,
larkKey: undefined, larkKey: undefined,
larkSecret: undefined,
ntfyUrl: undefined, ntfyUrl: undefined,
ntfyTopic: undefined, ntfyTopic: undefined,
ntfyPriority: undefined, ntfyPriority: undefined,
@@ -3138,9 +3136,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.larkKey !== undefined) { if (message.larkKey !== undefined) {
writer.uint32(466).string(message.larkKey); writer.uint32(466).string(message.larkKey);
} }
if (message.larkSecret !== undefined) {
writer.uint32(554).string(message.larkSecret);
}
if (message.ntfyUrl !== undefined) { if (message.ntfyUrl !== undefined) {
writer.uint32(474).string(message.ntfyUrl); writer.uint32(474).string(message.ntfyUrl);
} }
@@ -3645,14 +3640,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.larkKey = reader.string(); message.larkKey = reader.string();
continue; continue;
} }
case 69: {
if (tag !== 554) {
break;
}
message.larkSecret = reader.string();
continue;
}
case 59: { case 59: {
if (tag !== 474) { if (tag !== 474) {
break; break;
@@ -3810,7 +3797,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
webhookMethod: isSet(object.webhookMethod) ? globalThis.String(object.webhookMethod) : undefined, webhookMethod: isSet(object.webhookMethod) ? globalThis.String(object.webhookMethod) : undefined,
webhookContentType: isSet(object.webhookContentType) ? globalThis.String(object.webhookContentType) : undefined, webhookContentType: isSet(object.webhookContentType) ? globalThis.String(object.webhookContentType) : undefined,
larkKey: isSet(object.larkKey) ? globalThis.String(object.larkKey) : undefined, larkKey: isSet(object.larkKey) ? globalThis.String(object.larkKey) : undefined,
larkSecret: isSet(object.larkSecret) ? globalThis.String(object.larkSecret) : undefined,
ntfyUrl: isSet(object.ntfyUrl) ? globalThis.String(object.ntfyUrl) : undefined, ntfyUrl: isSet(object.ntfyUrl) ? globalThis.String(object.ntfyUrl) : undefined,
ntfyTopic: isSet(object.ntfyTopic) ? globalThis.String(object.ntfyTopic) : undefined, ntfyTopic: isSet(object.ntfyTopic) ? globalThis.String(object.ntfyTopic) : undefined,
ntfyPriority: isSet(object.ntfyPriority) ? globalThis.String(object.ntfyPriority) : undefined, ntfyPriority: isSet(object.ntfyPriority) ? globalThis.String(object.ntfyPriority) : undefined,
@@ -4004,9 +3990,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.larkKey !== undefined) { if (message.larkKey !== undefined) {
obj.larkKey = message.larkKey; obj.larkKey = message.larkKey;
} }
if (message.larkSecret !== undefined) {
obj.larkSecret = message.larkSecret;
}
if (message.ntfyUrl !== undefined) { if (message.ntfyUrl !== undefined) {
obj.ntfyUrl = message.ntfyUrl; obj.ntfyUrl = message.ntfyUrl;
} }
@@ -4103,7 +4086,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.webhookMethod = object.webhookMethod ?? undefined; message.webhookMethod = object.webhookMethod ?? undefined;
message.webhookContentType = object.webhookContentType ?? undefined; message.webhookContentType = object.webhookContentType ?? undefined;
message.larkKey = object.larkKey ?? undefined; message.larkKey = object.larkKey ?? undefined;
message.larkSecret = object.larkSecret ?? undefined;
message.ntfyUrl = object.ntfyUrl ?? undefined; message.ntfyUrl = object.ntfyUrl ?? undefined;
message.ntfyTopic = object.ntfyTopic ?? undefined; message.ntfyTopic = object.ntfyTopic ?? undefined;
message.ntfyPriority = object.ntfyPriority ?? undefined; message.ntfyPriority = object.ntfyPriority ?? undefined;
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.21.12 // protoc v3.17.3
// source: back/protos/cron.proto // source: back/protos/cron.proto
/* eslint-disable */ /* eslint-disable */
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.21.12 // protoc v3.17.3
// source: back/protos/health.proto // source: back/protos/health.proto
/* eslint-disable */ /* eslint-disable */
+6 -13
View File
@@ -4,7 +4,7 @@ import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron'; import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import { exec, execSync } from 'child_process'; import { exec, execSync } from 'child_process';
import fs from 'fs/promises'; import fs from 'fs/promises';
import CronExpressionParser from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
import { import {
getFileContentByName, getFileContentByName,
fileExist, fileExist,
@@ -29,7 +29,7 @@ import { logStreamManager } from '../shared/logStreamManager';
@Service() @Service()
export default class CronService { export default class CronService {
constructor(@Inject('logger') private logger: winston.Logger) { } constructor(@Inject('logger') private logger: winston.Logger) {}
private isNodeCron(cron: Crontab) { private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron; const { schedule, extra_schedules } = cron;
@@ -165,7 +165,7 @@ export default class CronService {
let cron; let cron;
try { try {
cron = await this.getDb({ id }); cron = await this.getDb({ id });
} catch (err) { } } catch (err) {}
if (!cron) { if (!cron) {
continue; continue;
} }
@@ -467,10 +467,7 @@ export default class CronService {
for (const doc of docs) { for (const doc of docs) {
// Kill all running instances of this task // Kill all running instances of this task
try { try {
if (doc.pid) { const command = this.makeCommand(doc);
await killTask(doc.pid);
}
const command = doc.command.replace(/\s+/g, ' ').trim();
await killAllTasks(command); await killAllTasks(command);
this.logger.info( this.logger.info(
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`, `[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
@@ -510,7 +507,7 @@ export default class CronService {
let { id, command, log_name } = cron; let { id, command, log_name } = cron;
const uniqPath = const uniqPath =
log_name === '/dev/null' || !log_name log_name === '/dev/null'
? await getUniqPath(command, `${id}`) ? await getUniqPath(command, `${id}`)
: log_name; : log_name;
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
@@ -644,11 +641,7 @@ export default class CronService {
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) { if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`; 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} `;
// Only include log_name if it has a truthy value to avoid passing null/undefined to shell
if (tab.log_name) {
commandVariable += `log_name=${tab.log_name} `;
}
if (tab.task_before) { if (tab.task_before) {
commandVariable += `task_before='${tab.task_before commandVariable += `task_before='${tab.task_before
.replace(/'/g, "'\\''") .replace(/'/g, "'\\''")
+1 -1
View File
@@ -107,7 +107,7 @@ export default class DependenceService {
query: any = {}, query: any = {},
): Promise<Dependence[]> { ): Promise<Dependence[]> {
let condition = query; let condition = query;
if (type && DependenceTypes[type] !== undefined) { if (DependenceTypes[type]) {
condition.type = DependenceTypes[type]; condition.type = DependenceTypes[type];
} }
if (status) { if (status) {
+2 -3
View File
@@ -13,11 +13,10 @@ import {
stepPosition, stepPosition,
} from '../data/env'; } from '../data/env';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { sequelize } from '../data';
@Service() @Service()
export default class EnvService { export default class EnvService {
constructor(@Inject('logger') private logger: winston.Logger) { } constructor(@Inject('logger') private logger: winston.Logger) {}
public async create(payloads: Env[]): Promise<Env[]> { public async create(payloads: Env[]): Promise<Env[]> {
const envs = await this.envs(); const envs = await this.envs();
@@ -147,7 +146,7 @@ export default class EnvService {
} }
try { try {
const result = await this.find(condition, [ const result = await this.find(condition, [
[sequelize.literal('COALESCE(`isPinned`, 0)'), 'DESC'], ['isPinned', 'DESC'],
['position', 'DESC'], ['position', 'DESC'],
['createdAt', 'ASC'], ['createdAt', 'ASC'],
]); ]);
+5 -19
View File
@@ -550,33 +550,19 @@ export default class NotificationService {
} }
private async lark() { private async lark() {
let { larkKey, larkSecret } = this.params; let { larkKey } = this.params;
if (!larkKey.startsWith('http')) { if (!larkKey.startsWith('http')) {
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`; larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`;
} }
const body: Record<string, any> = {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
};
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (larkSecret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${larkSecret}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
try { try {
const res = await httpClient.post(larkKey, { const res = await httpClient.post(larkKey, {
...this.gotOption, ...this.gotOption,
json: body, json: {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
},
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
if (res.StatusCode === 0 || res.code === 0) { if (res.StatusCode === 0 || res.code === 0) {
+4 -4
View File
@@ -133,15 +133,15 @@ export default class SshKeyService {
} }
public async addGlobalSSHKey(key: string, alias: string): Promise<void> { public async addGlobalSSHKey(key: string, alias: string): Promise<void> {
await this.generatePrivateKeyFile(`~global_${alias}`, key); await this.generatePrivateKeyFile(`global_${alias}`, key);
// Create a global SSH config entry that matches all hosts // Create a global SSH config entry that matches all hosts
// This allows the key to be used for any Git repository // This allows the key to be used for any Git repository
await this.generateGlobalSshConfig(`~global_${alias}`); await this.generateGlobalSshConfig(`global_${alias}`);
} }
public async removeGlobalSSHKey(alias: string): Promise<void> { public async removeGlobalSSHKey(alias: string): Promise<void> {
await this.removePrivateKeyFile(`~global_${alias}`); await this.removePrivateKeyFile(`global_${alias}`);
await this.removeSshConfig(`~global_${alias}`); await this.removeSshConfig(`global_${alias}`);
} }
private async generateGlobalSshConfig(alias: string) { private async generateGlobalSshConfig(alias: string) {
+10
View File
@@ -350,6 +350,11 @@ export default class SubscriptionService {
for (const doc of docs) { for (const doc of docs) {
await this.handleTask(doc.get({ plain: true }), false); await this.handleTask(doc.get({ plain: true }), false);
} }
// Disable associated cron tasks
const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
if (crons?.length) {
await this.crontabService.disabled(crons.map((x) => x.id!));
}
} }
public async enabled(ids: number[]) { public async enabled(ids: number[]) {
@@ -359,6 +364,11 @@ export default class SubscriptionService {
for (const doc of docs) { for (const doc of docs) {
await this.handleTask(doc.get({ plain: true })); await this.handleTask(doc.get({ plain: true }));
} }
// Enable associated cron tasks
const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
if (crons?.length) {
await this.crontabService.enabled(crons.map((x) => x.id!));
}
} }
public async log(id: number) { public async log(id: number) {
-37
View File
@@ -30,8 +30,6 @@ import {
SystemInstance, SystemInstance,
SystemModel, SystemModel,
SystemModelInfo, SystemModelInfo,
NotifyStatus,
NotifyLogInfo,
} from '../data/system'; } from '../data/system';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import NotificationService from './notify'; import NotificationService from './notify';
@@ -391,34 +389,11 @@ export default class SystemService {
if (notificationInfo && typeString) { if (notificationInfo && typeString) {
notificationInfo.type = typeString; notificationInfo.type = typeString;
} }
let notifyType: string | undefined;
if (notificationInfo?.type) {
notifyType = typeString || (notificationInfo.type as string);
} else {
try {
const notifConfig = await this.getDb({ type: AuthDataType.notification });
notifyType = notifConfig.info?.type as string | undefined;
} catch (e) {}
}
const isSuccess = await this.notificationService.notify( const isSuccess = await this.notificationService.notify(
title, title,
content, content,
notificationInfo, notificationInfo,
); );
await SystemModel.create({
type: AuthDataType.notifyLog,
info: {
timestamp: Date.now(),
title,
content,
status: isSuccess ? NotifyStatus.success : NotifyStatus.fail,
notifyType,
},
});
if (isSuccess) { if (isSuccess) {
return { code: 200, message: '通知发送成功' }; return { code: 200, message: '通知发送成功' };
} else { } else {
@@ -426,18 +401,6 @@ export default class SystemService {
} }
} }
public async getNotifyLog(): Promise<Array<NotifyLogInfo>> {
const docs = await SystemModel.findAll({
where: { type: AuthDataType.notifyLog },
order: [['id', 'DESC']],
});
if (docs.length > 200) {
const ids = docs.slice(200).map((x) => x.id!);
await SystemModel.destroy({ where: { id: ids } });
}
return docs.slice(0, 200).map((x) => ({ ...x.info, id: x.id }));
}
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) { public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
if (!command.startsWith(TASK_COMMAND)) { if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`; command = `${TASK_COMMAND} ${command}`;
+3 -3
View File
@@ -15,11 +15,11 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
}); });
// Default to single instance mode (0) for backward compatibility // Default to single instance mode (0) for backward compatibility
const allowSingleInstances = const allowMultipleInstances =
existingCron?.allow_multiple_instances === 0; existingCron?.allow_multiple_instances === 1;
if ( if (
allowSingleInstances && !allowMultipleInstances &&
existingCron && existingCron &&
existingCron.pid && existingCron.pid &&
(existingCron.status === CrontabStatus.running || (existingCron.status === CrontabStatus.running ||
+2 -2
View File
@@ -1,5 +1,5 @@
import { Joi } from 'celebrate'; import { Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
import path from 'path'; import path from 'path';
import config from '../config'; import config from '../config';
@@ -81,5 +81,5 @@ export const commonCronSchema = {
'string.max': '日志名称不能超过100个字符', 'string.max': '日志名称不能超过100个字符',
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null', 'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
}), }),
allow_multiple_instances: Joi.number().optional().valid(0, 1).allow(null), allow_multiple_instances: Joi.number().optional().valid(0, 1),
}; };
+3 -4
View File
@@ -69,10 +69,9 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \ PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
HOME=/root
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \ ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \ NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \ PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
@@ -84,6 +83,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR} WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1 CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"] ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+3 -4
View File
@@ -69,10 +69,9 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \ PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
HOME=/root
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \ ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \ NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \ PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
@@ -84,6 +83,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR} WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1 CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"] ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+7 -5
View File
@@ -1,5 +1,7 @@
#!/bin/bash #!/bin/bash
export PATH="$HOME/bin:$PATH"
dir_shell=/ql/shell dir_shell=/ql/shell
. $dir_shell/share.sh . $dir_shell/share.sh
@@ -21,11 +23,11 @@ log_with_style() {
if [ -f /etc/alpine-release ]; then if [ -f /etc/alpine-release ]; then
if ! grep -q "^options ndots:0" /etc/resolv.conf 2>/dev/null; then if ! grep -q "^options ndots:0" /etc/resolv.conf 2>/dev/null; then
echo "options ndots:0" >> /etc/resolv.conf echo "options ndots:0" >> /etc/resolv.conf
log_with_style "INFO" "🔧 0. 已配置 DNS 解析优化 (ndots:0)" log_with_style "INFO" "🔧 已配置 DNS 解析优化 (ndots:0)"
fi fi
fi fi
log_with_style "INFO" "🚀 1. 检测配置文件..." log_with_style "INFO" "🚀 1. 检测配置文件..."
load_ql_envs load_ql_envs
export_ql_envs export_ql_envs
. $dir_shell/env.sh . $dir_shell/env.sh
@@ -39,16 +41,16 @@ log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..."
reload_pm2 reload_pm2
if [[ $AutoStartBot == true ]]; then if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 3. 启动 bot..." log_with_style "INFO" "🤖 3. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 & nohup ql bot >$dir_log/bot.log 2>&1 &
fi fi
if [[ $EnableExtraShell == true ]]; then if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..." log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 & nohup ql extra >$dir_log/extra.log 2>&1 &
fi fi
log_with_style "SUCCESS" "🎉 容器启动成功!" log_with_style "SUCCESS" "🎉 容器启动成功!"
crond -f >/dev/null crond -f >/dev/null
+2 -2
View File
@@ -77,9 +77,9 @@
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"multer": "2.1.1", "multer": "1.4.5-lts.1",
"node-schedule": "^2.1.0", "node-schedule": "^2.1.0",
"nodemailer": "^8.0.1", "nodemailer": "^6.9.16",
"p-queue-cjs": "7.3.4", "p-queue-cjs": "7.3.4",
"@bufbuild/protobuf": "^2.10.0", "@bufbuild/protobuf": "^2.10.0",
"ps-tree": "^1.2.0", "ps-tree": "^1.2.0",
+259 -568
View File
File diff suppressed because it is too large Load Diff
+5 -37
View File
@@ -52,7 +52,6 @@ const push_config = {
DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN
FSKEY: '', // 飞书机器人的 FSKEY FSKEY: '', // 飞书机器人的 FSKEY
FSSECRET: '', // 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
// 推送到个人QQhttp://127.0.0.1/send_private_msg // 推送到个人QQhttp://127.0.0.1/send_private_msg
// 群:http://127.0.0.1/send_group_msg // 群:http://127.0.0.1/send_group_msg
@@ -482,13 +481,9 @@ function tgBotNotify(text, desp) {
timeout, timeout,
}; };
if (TG_PROXY_HOST && TG_PROXY_PORT) { if (TG_PROXY_HOST && TG_PROXY_PORT) {
let proxyHost = TG_PROXY_HOST;
if (TG_PROXY_AUTH && !TG_PROXY_HOST.includes('@')) {
proxyHost = `${TG_PROXY_AUTH}@${TG_PROXY_HOST}`;
}
let agent; let agent;
agent = new ProxyAgent({ agent = new ProxyAgent({
uri: `http://${proxyHost}:${TG_PROXY_PORT}`, uri: `http://${TG_PROXY_AUTH}${TG_PROXY_HOST}:${TG_PROXY_PORT}`,
}); });
options.dispatcher = agent; options.dispatcher = agent;
} }
@@ -994,29 +989,11 @@ function aibotkNotify(text, desp) {
function fsBotNotify(text, desp) { function fsBotNotify(text, desp) {
return new Promise((resolve) => { return new Promise((resolve) => {
const { FSKEY, FSSECRET } = push_config; const { FSKEY } = push_config;
if (FSKEY) { if (FSKEY) {
const body = {
msg_type: 'text',
content: { text: `${text}\n\n${desp}` },
};
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (FSSECRET) {
const crypto = require('crypto');
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${FSSECRET}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
const options = { const options = {
url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`, url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`,
json: body, json: { msg_type: 'text', content: { text: `${text}\n\n${desp}` } },
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -1285,15 +1262,7 @@ function ntfyNotify(text, desp) {
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const { const { NTFY_URL, NTFY_TOPIC, NTFY_PRIORITY, NTFY_TOKEN, NTFY_USERNAME, NTFY_PASSWORD, NTFY_ACTIONS } = push_config;
NTFY_URL,
NTFY_TOPIC,
NTFY_PRIORITY,
NTFY_TOKEN,
NTFY_USERNAME,
NTFY_PASSWORD,
NTFY_ACTIONS,
} = push_config;
if (NTFY_TOPIC) { if (NTFY_TOPIC) {
const options = { const options = {
url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`, url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`,
@@ -1308,8 +1277,7 @@ function ntfyNotify(text, desp) {
if (NTFY_TOKEN) { if (NTFY_TOKEN) {
options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`; options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`;
} else if (NTFY_USERNAME && NTFY_PASSWORD) { } else if (NTFY_USERNAME && NTFY_PASSWORD) {
options.headers['Authorization'] = options.headers['Authorization'] = `Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`;
`Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`;
} }
if (NTFY_ACTIONS) { if (NTFY_ACTIONS) {
options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS); options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS);
-15
View File
@@ -49,7 +49,6 @@ push_config = {
'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN 'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN
'FSKEY': '', # 飞书机器人的 FSKEY 'FSKEY': '', # 飞书机器人的 FSKEY
'FSSECRET': '', # 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
'GOBOT_URL': '', # go-cqhttp 'GOBOT_URL': '', # go-cqhttp
# 推送到个人QQhttp://127.0.0.1/send_private_msg # 推送到个人QQhttp://127.0.0.1/send_private_msg
@@ -234,20 +233,6 @@ def feishu_bot(title: str, content: str) -> None:
url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}' url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}'
data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}} data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}}
# Add signature if secret is provided
# Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
# and signs an empty message, which differs from typical HMAC usage
if push_config.get("FSSECRET"):
timestamp = str(int(time.time()))
string_to_sign = f'{timestamp}\n{push_config.get("FSSECRET")}'
hmac_code = hmac.new(
string_to_sign.encode("utf-8"), digestmod=hashlib.sha256
).digest()
sign = base64.b64encode(hmac_code).decode("utf-8")
data["timestamp"] = timestamp
data["sign"] = sign
response = requests.post(url, data=json.dumps(data)).json() response = requests.post(url, data=json.dumps(data)).json()
if response.get("StatusCode") == 0 or response.get("code") == 0: if response.get("StatusCode") == 0 or response.get("code") == 0:
-1
View File
@@ -389,7 +389,6 @@
"消息接收人": "message recipient", "消息接收人": "message recipient",
"调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "Version, you can specify 'pro' for the Professional version and 'personal' for the Personal version. If left blank, it will default to the Professional version.", "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "Version, you can specify 'pro' for the Professional version and 'personal' for the Personal version. If left blank, it will default to the Professional version.",
"飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "Feishu group bot: https://www.feishu.cn/hc/zh-CN/articles/360024984973", "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "Feishu group bot: https://www.feishu.cn/hc/zh-CN/articles/360024984973",
"飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "Feishu group bot signature secret, obtained after enabling signature verification in security settings",
"邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "Email service name, e.g., 126, 163, Gmail, QQ, etc. Supported list: https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json", "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "Email service name, e.g., 126, 163, Gmail, QQ, etc. Supported list: https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json",
"邮箱地址": "Email Address", "邮箱地址": "Email Address",
"SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions", "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions",
-1
View File
@@ -389,7 +389,6 @@
"消息接收人": "消息接收人", "消息接收人": "消息接收人",
"调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版", "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版",
"飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973", "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973",
"飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "飞书群组机器人加签密钥,安全设置中开启签名校验后获得",
"邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json", "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json",
"邮箱地址": "邮箱地址", "邮箱地址": "邮箱地址",
"SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定", "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定",
+4 -8
View File
@@ -3,7 +3,7 @@ import config from '@/utils/config';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Form, Input, Modal, Select, Space, message } from 'antd'; import { Button, Form, Input, Modal, Select, Space, message } from 'antd';
import CronExpressionParser from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import { getScheduleType, scheduleTypeMap } from './const'; import { getScheduleType, scheduleTypeMap } from './const';
@@ -91,14 +91,10 @@ const CronModal = ({
{ required: true }, { required: true },
{ {
validator: (_, value) => { validator: (_, value) => {
try { if (!value || CronExpressionParser.parse(value).hasNext()) {
if (!value || CronExpressionParser.parse(value).hasNext()) { return Promise.resolve();
return Promise.resolve();
}
return Promise.reject(intl.get('Cron表达式格式有误'));
} catch (e) {
return Promise.reject(intl.get('Cron表达式格式有误'));
} }
return Promise.reject(intl.get('Cron表达式格式有误'));
}, },
}, },
]} ]}
+1 -1
View File
@@ -16,7 +16,7 @@ const SaveModal = ({
const handleOk = async (values: any) => { const handleOk = async (values: any) => {
setLoading(true); setLoading(true);
const payload = { ...values, originFilename: file.title, content: file.content }; const payload = { ...file, ...values, originFilename: file.title };
request request
.post(`${config.apiPrefix}scripts`, payload) .post(`${config.apiPrefix}scripts`, payload)
.then(({ code, data }) => { .then(({ code, data }) => {
-22
View File
@@ -26,7 +26,6 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import SecuritySettings from './security'; import SecuritySettings from './security';
import LoginLog from './loginLog'; import LoginLog from './loginLog';
import NotifyLog from './notifyLog';
import NotificationSetting from './notification'; import NotificationSetting from './notification';
import Other from './other'; import Other from './other';
import About from './about'; import About from './about';
@@ -126,7 +125,6 @@ const Setting = () => {
const [editedApp, setEditedApp] = useState<any>(); const [editedApp, setEditedApp] = useState<any>();
const [tabActiveKey, setTabActiveKey] = useState('security'); const [tabActiveKey, setTabActiveKey] = useState('security');
const [loginLogData, setLoginLogData] = useState<any[]>([]); const [loginLogData, setLoginLogData] = useState<any[]>([]);
const [notifyLogData, setNotifyLogData] = useState<any[]>([]);
const [notificationInfo, setNotificationInfo] = useState<any>(); const [notificationInfo, setNotificationInfo] = useState<any>();
const containergRef = useRef<HTMLDivElement>(null); const containergRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number>(0); const [height, setHeight] = useState<number>(0);
@@ -255,8 +253,6 @@ const Setting = () => {
getApps(); getApps();
} else if (activeKey === 'login') { } else if (activeKey === 'login') {
getLoginLog(); getLoginLog();
} else if (activeKey === 'notifylog') {
getNotifyLog();
} else if (activeKey === 'notification') { } else if (activeKey === 'notification') {
getNotification(); getNotification();
} }
@@ -275,19 +271,6 @@ const Setting = () => {
}); });
}; };
const getNotifyLog = () => {
request
.get(`${config.apiPrefix}system/notify-log`)
.then(({ code, data }) => {
if (code === 200) {
setNotifyLogData(data);
}
})
.catch((error: any) => {
console.log(error);
});
};
useEffect(() => { useEffect(() => {
if (isDemoEnv) { if (isDemoEnv) {
getApps(); getApps();
@@ -361,11 +344,6 @@ const Setting = () => {
label: intl.get('登录日志'), label: intl.get('登录日志'),
children: <LoginLog height={height} data={loginLogData} />, children: <LoginLog height={height} data={loginLogData} />,
}, },
{
key: 'notifylog',
label: intl.get('通知日志'),
children: <NotifyLog height={height} data={notifyLogData} />,
},
{ {
key: 'dependence', key: 'dependence',
label: intl.get('依赖设置'), label: intl.get('依赖设置'),
-103
View File
@@ -1,103 +0,0 @@
import intl from 'react-intl-universal';
import React from 'react';
import { Table, Tag } from 'antd';
import dayjs from 'dayjs';
interface NotifyLogItem {
id?: number;
timestamp?: number;
title?: string;
content?: string;
status?: number;
notifyType?: string;
}
const NotifyStatusLabel: Record<number, string> = {
0: '成功',
1: '失败',
};
const NotifyStatusColor: Record<number, string> = {
0: 'success',
1: 'error',
};
const columns = [
{
title: intl.get('序号'),
width: 50,
render: (text: string, record: any, index: number) => {
return index + 1;
},
},
{
title: intl.get('发送时间'),
dataIndex: 'timestamp',
key: 'timestamp',
width: 160,
render: (text: string, record: any) => {
return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss');
},
},
{
title: intl.get('标题'),
dataIndex: 'title',
key: 'title',
width: 200,
},
{
title: intl.get('内容'),
dataIndex: 'content',
key: 'content',
render: (text: string) => {
if (!text) return '';
return text.length > 100 ? text.slice(0, 100) + '...' : text;
},
},
{
title: intl.get('推送渠道'),
dataIndex: 'notifyType',
key: 'notifyType',
width: 120,
},
{
title: intl.get('发送状态'),
dataIndex: 'status',
key: 'status',
width: 90,
render: (text: string, record: NotifyLogItem) => {
const statusKey = record.status ?? 1;
return (
<Tag
color={NotifyStatusColor[statusKey]}
style={{ marginRight: 0 }}
>
{intl.get(NotifyStatusLabel[statusKey])}
</Tag>
);
},
},
];
const NotifyLog = ({
data,
height,
}: {
data: Array<NotifyLogItem>;
height: number;
}) => {
return (
<>
<Table
columns={columns}
pagination={false}
dataSource={data}
rowKey="id"
size="middle"
scroll={{ x: 1000, y: height }}
/>
</>
);
};
export default NotifyLog;
+8 -12
View File
@@ -12,7 +12,7 @@ import {
} from 'antd'; } from 'antd';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import config from '@/utils/config'; import config from '@/utils/config';
import CronExpressionParser from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
import isNil from 'lodash/isNil'; import isNil from 'lodash/isNil';
const { Option } = Select; const { Option } = Select;
@@ -378,17 +378,13 @@ const SubscriptionModal = ({
{ required: true }, { required: true },
{ {
validator: (rule, value) => { validator: (rule, value) => {
try { if (
if ( scheduleType === 'interval' ||
scheduleType === 'interval' || !value ||
!value || CronExpressionParser.parse(value).hasNext()
CronExpressionParser.parse(value).hasNext() ) {
) { return Promise.resolve();
return Promise.resolve(); } else {
} else {
return Promise.reject(intl.get('Subscription表达式格式有误'));
}
} catch (e) {
return Promise.reject(intl.get('Subscription表达式格式有误')); return Promise.reject(intl.get('Subscription表达式格式有误'));
} }
}, },
-6
View File
@@ -395,12 +395,6 @@ export default {
), ),
required: true, required: true,
}, },
{
label: 'larkSecret',
tip: intl.get(
'飞书群组机器人加签密钥,安全设置中开启签名校验后获得',
),
},
], ],
email: [ email: [
{ {
+6 -6
View File
@@ -84,12 +84,12 @@ let _request = axios.create({
}); });
const apiWhiteList = [ const apiWhiteList = [
`${config.baseUrl}api/user/login`, '/api/user/login',
`${config.baseUrl}open/auth/token`, '/open/auth/token',
`${config.baseUrl}api/user/two-factor/login`, '/api/user/two-factor/login',
`${config.baseUrl}api/system`, '/api/system',
`${config.baseUrl}api/user/init`, '/api/user/init',
`${config.baseUrl}api/user/notification/init`, '/api/user/notification/init',
]; ];
_request.interceptors.request.use((_config) => { _request.interceptors.request.use((_config) => {
+1 -1
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import { LANG_MAP, LOG_END_SYMBOL } from './const'; import { LANG_MAP, LOG_END_SYMBOL } from './const';
import CronExpressionParser from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
import { ICrontab } from '@/pages/crontab/type'; import { ICrontab } from '@/pages/crontab/type';
export default function browserType() { export default function browserType() {
+9 -5
View File
@@ -1,6 +1,10 @@
version: 2.20.2 version: 2.19.2
changeLogLink: https://t.me/jiao_long/434 changeLogLink: https://t.me/jiao_long/431
publishTime: 2026-03-01 1800 publishTime: 2025-06-27 23:59
changeLog: | changeLog: |
1. 修复 path 安全漏洞(重要) 1. 备份数据支持选择模块,支持清除依赖缓存
2. QLAPI 和 openapi 的 systemNotify 支持自定义通知类型和参数
3. ntfy 增加可选的认证与用户动作,感谢 https://github.com/liheji
4. 修复取消安装依赖
5. 修复环境变量过大解析报错
6. 修改服务启动方式