Compare commits

..
Author SHA1 Message Date
whyour 41910b5236 fix(dependencies): avoid repeated checks when opening dependency management (#3122)
* fix(dependencies): cache status reads and refresh asynchronously

* fix(cli): expose asynchronous dependency refresh operations
2026-11-03 23:08:16 +08:00
whyour 442ab2c616 fix(security): restrict task environment controls and app scopes (#3121)
* fix(security): restrict task environment controls and app scopes

* fix(security): preserve legacy scopes and add read-only access

* feat(security): add read-only scopes for remaining resources

* fix(security): reject apps scope grants in app API

* fix(security): protect system app and task file boundaries

* fix: block case-variant secret paths and task runner env overrides
2026-11-03 23:04:43 +08:00
whyour a5f22f8d44 fix(cli): support cross-platform config and prepare 0.1.4 (#3119) 2026-10-31 02:14:44 +08:00
whyour f2c2f5e4f8 release: Qinglong 2.24.0 2026-10-29 09:58:40 +08:00
whyour 2efc5e4503 build: unify Node 24 and upgrade release flow 2026-10-26 23:26:40 +08:00
54 changed files with 1728 additions and 288 deletions
+2 -147
View File
@@ -405,151 +405,6 @@ jobs:
cache-from: type=registry,ref=whyour/qinglong:cache-debian
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
build-alpine310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
context: .
file: ./docker/Dockerfile.310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-alpine-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
build-debian310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:debian-python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
# One unmodified amd64 package for migration acceptance only. This does not
# publish bundles or advance development/formal channel indexes.
build-npm-migration-bundle:
@@ -616,7 +471,7 @@ jobs:
publish:
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, build-alpine310, build-debian310, verify-npm-package]
needs: [build-alpine, build-debian, verify-npm-package]
runs-on: ubuntu-latest
permissions:
contents: read
@@ -652,7 +507,7 @@ jobs:
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
npm publish "$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
npm publish "./$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
else
echo '::error::Could not check the published npm version; refusing to publish.'
exit 1
+20 -2
View File
@@ -25,7 +25,7 @@ on:
type: boolean
default: false
prerelease_version:
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
description: Develop only, e.g. 0.1.5-beta.0 (alpha/beta/rc)
type: string
default: ''
@@ -93,8 +93,26 @@ jobs:
if-no-files-found: error
retention-days: 14
platform-smoke:
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-24.04-arm]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: cli/package-lock.json
- run: npm ci --prefix cli --no-audit --no-fund
- run: npm run build:cli
- run: node --test cli/test/remote/platformSmoke.test.cjs
publish:
needs: package
needs: [package, platform-smoke]
if: >-
github.repository == 'whyour/qinglong' &&
((github.ref == 'refs/heads/master' &&
-8
View File
@@ -70,14 +70,6 @@ jobs:
file: docker/Dockerfile.debian
cache: debian
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
- image: alpine-python3.10
file: docker/Dockerfile.310
cache: alpine-python3.10
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
- image: debian-python3.10
file: docker/Dockerfile.debian310
cache: debian-python3.10
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
steps:
- uses: actions/checkout@v6
with:
+68 -6
View File
@@ -11,12 +11,11 @@ export default (app: Router) => {
route.get(
'/',
celebrate({
query:
Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
query: Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
@@ -31,6 +30,69 @@ export default (app: Router) => {
},
);
route.post(
'/refresh',
celebrate({
body: Joi.object({
type: Joi.string().valid('nodejs', 'python3').required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const { job, accepted } = dependenceService.startRefresh(req.body.type);
if (!accepted) {
return res.status(409).send({
code: 409,
message: 'Another dependency refresh is already running',
});
}
return res.send({ code: 200, data: job });
} catch (e) {
return next(e);
}
},
);
route.get(
'/refresh/active',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
res.set('Cache-Control', 'no-store');
return res.send({
code: 200,
data: dependenceService.getActiveRefreshJob() || null,
});
} catch (e) {
return next(e);
}
},
);
route.get(
'/refresh/:id',
celebrate({
params: Joi.object({ id: Joi.string().uuid().required() }),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const job = dependenceService.getRefreshJob(req.params.id);
if (!job) {
return res.status(404).send({
code: 404,
message: 'Dependency refresh job not found',
});
}
res.set('Cache-Control', 'no-store');
return res.send({ code: 200, data: job });
} catch (e) {
return next(e);
}
},
);
route.post(
'/',
celebrate({
+2 -2
View File
@@ -27,7 +27,7 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
name: Joi.string().optional().allow('').disallow('system'),
scopes: Joi.array().items(Joi.string().required()),
scopes: Joi.array().items(Joi.string().required().invalid('apps')),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -47,7 +47,7 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
name: Joi.string().optional().allow(''),
scopes: Joi.array().items(Joi.string()),
scopes: Joi.array().items(Joi.string().invalid('apps')),
id: Joi.number().required(),
}),
}),
+19 -1
View File
@@ -24,7 +24,25 @@ export interface AppToken {
expiration: number;
}
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard';
export type AppScope =
| 'crons'
| 'crons:read'
| 'subscriptions'
| 'subscriptions:read'
| 'envs'
| 'envs:read'
| 'configs'
| 'configs:read'
| 'scripts'
| 'scripts:read'
| 'logs'
| 'logs:read'
| 'dependencies'
| 'dependencies:read'
| 'dashboard'
| 'dashboard:read'
| 'system'
| 'apps';
export interface AppInstance extends Model<App, App>, App {}
export const AppModel = sequelize.define<AppInstance>('App', {
+37 -1
View File
@@ -112,7 +112,43 @@ export default ({ app }: { app: Application }) => {
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (!doc.scopes.includes(key as AppScope)) {
const isReadRequest =
(['GET', 'HEAD'].includes(req.method) &&
!(
key === 'crons' && /^\/open\/crons\/import\/?$/.test(pathLower)
)) ||
(['scripts', 'logs'].includes(key || '') &&
req.method === 'POST' &&
/^\/open\/(?:scripts|logs)\/download\/?$/.test(pathLower));
const hasNamespaceScope =
doc.scopes.includes(key as AppScope) ||
([
'crons',
'subscriptions',
'envs',
'configs',
'scripts',
'logs',
'dependencies',
'dashboard',
].includes(key || '') &&
doc.scopes.includes(`${key}:read` as AppScope) &&
isReadRequest);
if (!hasNamespaceScope) {
const err = new UnauthorizedError('credentials_bad_scheme', {
message: t('暂无权限'),
});
return next(err);
}
// The built-in system app still needs its local CLI maintenance API.
const appRouteAllowed =
key !== 'system' ||
doc.name === 'system' ||
((req.method === 'GET' || req.method === 'HEAD') &&
(pathLower === '/open/system' || pathLower === '/open/system/')) ||
(req.method === 'PUT' && pathLower === '/open/system/notify');
if (!appRouteAllowed) {
const err = new UnauthorizedError('credentials_bad_scheme', {
message: t('暂无权限'),
});
+3 -2
View File
@@ -222,6 +222,9 @@ export default async () => {
}
} catch { }
// Replace legacy task environment files before recovered tasks can run.
await envService.set_envs();
// 初始化保存一次ck和定时任务数据
cronClient.readiness.configure(async () => {
await cronService.autosave_crontab(true);
@@ -229,8 +232,6 @@ export default async () => {
});
await cronClient.readiness.recover();
await envService.set_envs();
const authInfo = await userService.getAuthInfo();
await shareStore.updateAuthInfo(authInfo);
await openService.refreshApps();
+76 -19
View File
@@ -27,9 +27,20 @@ import taskLimit from '../shared/pLimit';
import { detectOS } from '../config/util';
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
import { t, tf } from '../shared/i18n';
import { randomUUID } from 'crypto';
type RefreshType = 'nodejs' | 'python3';
type RefreshJob = {
id: string;
type: RefreshType;
state: 'running' | 'completed' | 'failed';
};
@Service()
export default class DependenceService {
private activeRefreshJob?: RefreshJob;
private refreshJobs = new Map<string, RefreshJob>();
constructor(
@Inject('logger') private logger: winston.Logger,
private sockService: SockService,
@@ -118,11 +129,6 @@ export default class DependenceService {
query: any = {},
): Promise<Dependence[]> {
let condition = query;
const dependenceType =
type && DependenceTypes[type] !== undefined
? DependenceTypes[type]
: undefined;
await this.refreshInstalledStatuses(dependenceType);
if (type && DependenceTypes[type] !== undefined) {
condition.type = DependenceTypes[type];
}
@@ -145,6 +151,52 @@ export default class DependenceService {
}
}
public startRefresh(type: RefreshType): {
job: RefreshJob;
accepted: boolean;
} {
if (this.activeRefreshJob) {
return {
job: this.activeRefreshJob,
accepted: this.activeRefreshJob.type === type,
};
}
const job: RefreshJob = { id: randomUUID(), type, state: 'running' };
this.activeRefreshJob = job;
this.refreshJobs.set(job.id, job);
while (this.refreshJobs.size > 20) {
const oldestId = this.refreshJobs.keys().next().value;
this.refreshJobs.delete(oldestId);
}
void this.refreshInstalledStatuses(DependenceTypes[type])
.then(() => {
job.state = 'completed';
})
.catch((error) => {
this.logger.error('Dependency refresh failed: %o', error);
job.state = 'failed';
})
.finally(() => {
if (this.activeRefreshJob === job) {
this.activeRefreshJob = undefined;
}
});
return { job, accepted: true };
}
public getRefreshJob(id: string): RefreshJob | undefined {
return this.refreshJobs.get(id);
}
public getActiveRefreshJob(): RefreshJob | undefined {
return this.activeRefreshJob?.state === 'running'
? this.activeRefreshJob
: undefined;
}
private async refreshInstalledStatuses(type?: DependenceTypes) {
const cacheDependenceTypes = [
DependenceTypes.nodejs,
@@ -160,26 +212,31 @@ export default class DependenceService {
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
},
});
const checkableDocs = docs.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
);
const checks = await concurrentRun(
docs
.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
)
.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
checkableDocs.map((doc) => async () => this.isDependenceInstalled(doc)),
5,
);
const missingIds = (checks || []).filter(
(id): id is number => id !== undefined,
);
if (missingIds.length) {
for (const [index, installed] of (checks || []).entries()) {
if (installed) continue;
const doc = checkableDocs[index];
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{ where: { id: missingIds } },
{
where: {
id: doc.id,
status: DependenceStatus.installed,
name: doc.name,
type: doc.type,
updatedAt: (doc as typeof doc & { updatedAt: Date }).updatedAt,
// Sequelize's automatic timestamp is absent from Dependence's attribute type.
} as any,
},
);
}
}
+22 -3
View File
@@ -14,12 +14,17 @@ import {
} from '../data/env';
import { writeFileWithLock } from '../shared/utils';
import { sequelize } from '../data';
import {
assertAllowedTaskEnvName,
isAllowedTaskEnvName,
} from '../shared/taskEnvName';
@Service()
export default class EnvService {
constructor(@Inject('logger') private logger: winston.Logger) { }
public async create(payloads: Env[]): Promise<Env[]> {
payloads.forEach((payload) => assertAllowedTaskEnvName(payload.name));
const envs = await this.envs();
let position = initPosition;
if (
@@ -52,6 +57,7 @@ export default class EnvService {
public async update(payload: Env): Promise<Env> {
const doc = await this.getDb({ id: payload.id });
const tab = new Env({ ...doc, ...payload });
assertAllowedTaskEnvName(tab.name);
const newDoc = await this.updateDb(tab);
await this.set_envs();
return newDoc;
@@ -151,7 +157,10 @@ export default class EnvService {
['position', 'DESC'],
['createdAt', 'ASC'],
]);
return result;
return result.map((env) => ({
...env,
securityBlocked: !isAllowedTaskEnvName(env.name),
}));
} catch (error) {
throw error;
}
@@ -182,11 +191,14 @@ export default class EnvService {
}
public async enabled(ids: number[]) {
const docs = await EnvModel.findAll({ where: { id: ids } });
docs.forEach((doc) => assertAllowedTaskEnvName(doc.name));
await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } });
await this.set_envs();
}
public async updateNames({ ids, name }: { ids: number[]; name: string }) {
assertAllowedTaskEnvName(name);
await EnvModel.update({ name }, { where: { id: ids } });
await this.set_envs();
}
@@ -242,6 +254,13 @@ export default class EnvService {
name: { [Op.not]: null },
status: EnvStatus.normal,
});
const invalid = envs.filter((env) => !isAllowedTaskEnvName(env.name));
if (invalid.length > 0) {
this.logger.warn(
'Skipped reserved environment variables in task files: %s',
JSON.stringify(invalid.map((env) => ({ id: env.id, name: env.name }))),
);
}
const groups = groupBy(envs, 'name');
let env_string = '';
let js_env_string = '';
@@ -250,8 +269,8 @@ export default class EnvService {
if (Object.prototype.hasOwnProperty.call(groups, key)) {
const group = groups[key];
// 忽略不符合bash要求的环境变量名称
if (/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(key)) {
// Ignore invalid and reserved names, including legacy database rows.
if (isAllowedTaskEnvName(key)) {
let value = group
.map((x) => x.value)
.join('&')
+1 -1
View File
@@ -8,6 +8,6 @@ export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) {
return resolveFileAccess(config.logPath, [filePath || '', fileName]);
return resolveFileAccess(config.logPath, [filePath || '', fileName], ['.tmp']);
}
}
+10
View File
@@ -92,6 +92,10 @@ export default class OpenService {
private async updateDb(payload: Partial<App>): Promise<App> {
return this.withAppsLock(async () => {
const target = await AppModel.findOne({ where: { id: payload.id } });
if (target?.name === 'system' || payload.name === 'system') {
throw Object.assign(new Error(t('暂无权限')), { status: 403 });
}
await AppModel.update(payload, { where: { id: payload.id } });
const apps = await this.find({});
await shareStore.updateApps(apps);
@@ -109,6 +113,12 @@ export default class OpenService {
public async remove(ids: number[]) {
return this.withAppsLock(async () => {
const systemApp = await AppModel.findOne({
where: { id: ids, name: 'system' },
});
if (systemApp) {
throw Object.assign(new Error(t('暂无权限')), { status: 403 });
}
await AppModel.destroy({ where: { id: ids } });
const apps = await this.find({});
await shareStore.updateApps(apps);
+4 -1
View File
@@ -24,8 +24,11 @@ export function resolveFileAccess(
const resolvedRoot = path.resolve(root);
const target = path.resolve(resolvedRoot, ...parts);
if (target === resolvedRoot || !isWithin(resolvedRoot, target)) return '';
// File systems such as default APFS can resolve a differently cased spelling
// of the same entry. Check every protected name without case sensitivity.
const blockedNames = new Set(blacklist.map((part) => part.toLowerCase()));
const isBlocked = (relative: string) =>
relative.split(path.sep).some((part) => blacklist.includes(part));
relative.split(path.sep).some((part) => blockedNames.has(part.toLowerCase()));
if (isBlocked(path.relative(resolvedRoot, target))) return '';
try {
const realRoot = fs.realpathSync(resolvedRoot);
+1
View File
@@ -23,6 +23,7 @@ const messages: Record<string, Record<string, string>> = {
'代理层数不能超过 20': 'Proxy hop count cannot exceed 20',
'环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖':
'QL_TRUST_PROXY is active and cannot be overridden by system settings',
环境变量名称不可使用: 'Environment variable name is reserved or invalid',
系统配置不存在: 'System configuration does not exist',
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本':
'Version history only supports UTF-8 text scripts up to 1 MiB',
+79
View File
@@ -0,0 +1,79 @@
import { t } from './i18n';
// These names control the task runner or the interpreters it starts. Values
// managed through the environment API must remain task data, not executable
// configuration.
const reservedNames = new Set([
'TASK_BEFORE',
'TASK_AFTER',
'WORK_DIR',
'TASK_SHELL_PARAMS',
'SCRIPT_PARAMS',
'WHICH_PROGRAM',
'ISJSORPYTHONFILE',
'EXPORTED_VARIABLES',
'FOUND_DOUBLE_DASH',
'LOG_DIR',
'LOG_PATH',
'ID',
'BEGIN_TIMESTAMP',
'TIME_FORMAT',
'MTIME_FORMAT',
'IS_MACOS',
'RANDOMDELAY',
'RANDOMDELAYFILEEXTENSIONS',
'RANDOMDELAYIGNOREDMINUTES',
'COMMAND_TIMEOUT_TIME',
'SET_U_ON',
'_TASK_EXIT_CODE',
'PATH',
'HOME',
'SHELL',
'ENV',
'BASH_ENV',
'BASHOPTS',
'SHELLOPTS',
'IFS',
'CDPATH',
'GLOBIGNORE',
'PROMPT_COMMAND',
'PS4',
'NODE_OPTIONS',
'NODE_PATH',
'PREV_NODE_OPTIONS',
'PREV_NODE_PATH',
'PREV_PYTHONPATH',
'GIT_SSH',
'GIT_SSH_COMMAND',
'GIT_ASKPASS',
'GIT_EXEC_PATH',
'GIT_TEMPLATE_DIR',
'SSH_ASKPASS',
]);
export function isAllowedTaskEnvName(name: unknown): name is string {
if (typeof name !== 'string' || !/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(name)) {
return false;
}
const upper = name.toUpperCase();
return (
!reservedNames.has(upper) &&
!upper.startsWith('BASH_') &&
!upper.startsWith('QL') &&
!upper.startsWith('FILE_') &&
!upper.startsWith('DIR_') &&
!upper.startsWith('LD_') &&
!upper.startsWith('DYLD_') &&
!upper.startsWith('PYTHON') &&
!upper.startsWith('GIT_CONFIG_')
);
}
export function assertAllowedTaskEnvName(name: unknown): asserts name is string {
if (!isAllowedTaskEnvName(name)) {
throw Object.assign(new Error(t('环境变量名称不可使用')), {
status: 400,
});
}
}
+6 -6
View File
@@ -48,7 +48,7 @@ The CLI also supports task/subscription CRUD, application and secret management,
```sh
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
ql subscription create --type public-repo --url https://example.com/repo.git --alias demo --schedule-type crontab --schedule '0 0 * * *' --json
ql app create --name agent --scopes crons,subscriptions --show-secrets --json
ql app create --name agent --scopes crons,subscriptions,envs --show-secrets --json
ql env create --data @envs.json --json
ql api request PUT /open/crons/run --data '[12,13]' --json
```
@@ -68,7 +68,7 @@ Protected requests use `Authorization: Bearer <token>`. Two credential sources a
### Application login
Create a dedicated panel application with the required scopes, such as crons, subscriptions or envs. `login` and `auth login` are equivalent. Interactive login hides the Client Secret; secret command-line arguments are not supported.
Create a dedicated panel application with the required scopes. Existing scopes retain their original permissions. For read-only access, use `crons:read`, `subscriptions:read`, `envs:read`, `configs:read`, `scripts:read`, `dependencies:read`, `logs:read`, or `dashboard:read`. Script and log downloads use POST but are available to their read-only scopes. `GET /crons/import` writes tasks and is excluded from `crons:read`. `login` and `auth login` are equivalent. Interactive login hides the Client Secret; secret command-line arguments are not supported.
```sh
ql login --url https://ql.example.com
@@ -76,7 +76,7 @@ ql auth status --scope crons --json
# For automation, inject QL_CLIENT_ID and QL_CLIENT_SECRET before the same login command
```
Credentials/token are stored in `~/.config/qinglong/cli.json`, a plaintext file owned by the current user with mode 0600. `QL_CLI_CONFIG` selects another file. Login validates application credentials, not every resource permission.
Credentials/token are stored in `~/.config/qinglong/cli.json` as plaintext. The CLI requests mode `0600` when creating the file and rejects symlinks and non-regular files when reading it. Windows, WSL mounts, and some network or device filesystems do not reliably report POSIX permissions, so reads do not depend on mode bits or UID. Keep the config in a location accessible only to your user and protect it with the filesystem's actual permissions or ACLs; use the direct-token environment variables below if that cannot be guaranteed. `QL_CLI_CONFIG` selects another file. Login validates application credentials, not every resource permission.
### Direct access token
@@ -128,11 +128,11 @@ npm run test:cli
node cli/scripts/verify-package.cjs
```
Tests cover requests, token refresh, output, errors, no automatic retries, permissions and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
Tests cover requests, token refresh, output, errors, no automatic retries, config file type and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After both matrix jobs succeed, master pushes publish that exact archive to npm as latest, using GitHub Actions OIDC trusted publishing without an NPM_TOKEN secret. Manual runs publish only when run on master with publish enabled; PRs, develop and forks never publish. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. Publishing uses Node 24 with `id-token: write` granted only to the publish job. New packages need an initial publication before configuring their trusted publisher.
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs, and verifies saved login and command reads on real Windows and Linux ARM64 filesystems. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After all validation jobs succeed, master pushes publish that exact archive to npm as `latest`; a manual run on master with `publish` enabled can also publish a stable release. Develop pushes only verify. A manual run on develop with `publish` enabled and `prerelease_version` set (for example `0.1.5-beta.0`) publishes under the matching `alpha`, `beta` or `rc` tag. PRs and forks never publish. CLI-only changes do not trigger the main image build, except for the few internal CLI upgrade files used by the image. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. The publish job uses Node 24 and GitHub Actions OIDC without an `NPM_TOKEN` secret, with `id-token: write` granted only to that job. New packages need an initial publication before configuring their trusted publisher.
The CLI has an independent stable version in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. Existing versions are skipped with a notice; registry failures stop publication. Only stable X.Y.Z versions are published by this workflow. Publication does not rebuild the verified archive or run package lifecycle scripts.
The CLI stable version is maintained independently in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. A develop prerelease version is set only in the workflow run and is not written back to the repository. An existing stable version is skipped with a notice; a duplicate prerelease version fails, as does a registry lookup error. The publish job does not rebuild the verified archive or run package lifecycle scripts.
## Panel-internal tools
+6 -6
View File
@@ -48,7 +48,7 @@ ql auth logout --json
```sh
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
ql subscription create --type public-repo --url https://example.com/repo.git --alias demo --schedule-type crontab --schedule '0 0 * * *' --json
ql app create --name agent --scopes crons,subscriptions --show-secrets --json
ql app create --name agent --scopes crons,subscriptions,envs --show-secrets --json
ql env create --data @envs.json --json
ql api request PUT /open/crons/run --data '[12,13]' --json
```
@@ -68,7 +68,7 @@ ql api request PUT /open/crons/run --data '[12,13]' --json
### 应用凭据登录
在面板创建专用应用,按需授予 crons、subscriptions、envs 等权限。`login` 与 `auth login` 等价;交互输入 Client ID 和不回显的 Client Secret,密钥不支持命令行参数。
在面板创建专用应用,按需授予 crons、subscriptions 等权限。旧 scope 保留原有权限;仅读取时可选用 `crons:read`、`subscriptions:read`、`envs:read`、`configs:read`、`scripts:read`、`dependencies:read`、`logs:read`、`dashboard:read`。脚本和日志下载虽使用 POST,也允许对应只读权限;`GET /crons/import` 会写入任务,不属于只读操作。`login` 与 `auth login` 等价;交互输入 Client ID 和不回显的 Client Secret,密钥不支持命令行参数。
```sh
ql login --url https://ql.example.com
@@ -76,7 +76,7 @@ ql auth status --scope crons --json
# 自动化环境事先注入 QL_CLIENT_ID 和 QL_CLIENT_SECRET,再运行同一 login 命令
```
凭据和 token 保存到 `~/.config/qinglong/cli.json`,为当前用户所有的 0600 明文文件。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
凭据和 token 保存到 `~/.config/qinglong/cli.json` 明文文件。CLI 创建文件时会请求 `0600` 权限,并在读取时拒绝符号链接和非普通文件;由于 Windows、WSL 挂载盘及部分网络或设备文件系统不能可靠提供 POSIX 权限位,读取时不以权限位或 UID 为准。请将配置保存在仅当前用户可访问的位置,并按实际文件系统的权限或 ACL 保护它;无法保证时可使用下述环境令牌方式。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
### 直接使用访问令牌
@@ -128,11 +128,11 @@ npm run test:cli
node cli/scripts/verify-package.cjs
```
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、权限和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、配置文件类型和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。两个矩阵任务成功后,master 推送会将这份已验证的 tgz 发布到 npm 的 latest 标签,通过 GitHub Actions OIDC 可信发布,无需 `NPM_TOKEN` Secret。手动运行需选择 master 并勾选 publish;PR、develop 和 fork 不发布。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24,并仅在该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试,并在 Windows 与 Linux ARM64 上用真实文件系统验证登录和命令读取。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。全部验证成功后,master 推送会将这份 tgz 发布到 npm 的 `latest` 标签;手动在 master 勾选 `publish` 也可发布稳定版。develop 推送只验证;手动在 develop 勾选 `publish` 并填写 `prerelease_version`(例如 `0.1.5-beta.0`)可发布到对应的 `alpha`、`beta` 或 `rc` 标签。PR 和 fork 不发布。CLI 专属改动不会触发主镜像构建,少数被镜像使用的 CLI 内部升级文件除外。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24 和 GitHub Actions OIDC,无需 `NPM_TOKEN` Secret,仅该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
CLI 版本独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。已发布的版本会提示并跳过;registry 查询失败则停止发布。该流程仅发布 X.Y.Z 稳定版本,不重新构建产物或执行包生命周期脚本。
CLI 稳定版版本号独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。develop 预发布版版本号仅在工作流运行时设置,不写回仓库。已发布的稳定版版本号会提示并跳过;重复的预发布版版本号会报错,registry 查询失败也会停止发布。发布 job 不重新构建产物或执行包生命周期脚本。
## 面板内部工具
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@whyour/qinglong-cli",
"version": "0.1.3",
"version": "0.1.4",
"license": "Apache-2.0",
"bin": {
"ql": "dist/npm/ql.js"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.3",
"version": "0.1.4",
"description": "Authenticated remote management CLI for QingLong 2.x",
"type": "commonjs",
"bin": {
+18 -5
View File
@@ -1,16 +1,18 @@
# OpenAPI 全量参考 / Complete OpenAPI reference
CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
CLI 对应本版本后端的 151 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
The catalogue covers 146 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
The catalogue covers 151 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
## 认证与权限 / Authentication and permissions
常规使用 `ql login` 的应用凭据。scope 是 /open 后首个路径段:crons、subscriptions、envs、configs、scripts、logs、dependencies、system、dashboard 等。应用管理需要 apps;user、health、update 等路由同样接受服务端权限校验。面板 UI 当前没有列出全部这些 scope,不能把路由存在理解成现有应用已获授权。
常规使用 `ql login` 的应用凭据。scope 通常对应 /open 后首个路径段:crons、subscriptions、envs、configs、scripts、logs、dependencies、system、dashboard 等。旧 scope 保留原有权限;上述资源除 system 外可选用对应的 `:read` scope。`crons:read` 不包含会写入任务的 `GET /crons/import`;`scripts:read`、`logs:read` 包含使用 POST 的下载,不包含写入、删除或执行。`dashboard:read` 不允许 POST 统计上报。外部应用的 `system` scope 仅允许读取系统状态和发送通知。应用管理需要 apps;`GET /apps` 会返回 Client Secret,因此不提供 `apps:read`。user、health、update 等路由同样接受服务端权限校验。路由存在不代表现有应用已获授权。
认证步骤、环境变量优先级、应用登录、面板会话与双因素验证、退出行为,统一见 [认证参考](panel.md#authentication)。应用管理可用 apps 权限的应用或授权面板会话;QL_URL/QL_ACCESS_TOKEN 不保存、不刷新,不自动退回应用配置。
认证步骤、环境变量优先级、应用登录、面板会话与双因素验证、退出行为,统一见 [认证参考](panel.md#authentication)。应用管理可用已有 apps 权限的应用或授权面板会话;后端创建和更新应用时拒绝授予 apps 权限。QL_URL/QL_ACCESS_TOKEN 不保存、不刷新,不自动退回应用配置。
See the [authentication reference](panel.md#authentication) for application login, direct-token precedence, owner sessions/2FA and logout. App management accepts apps-scoped credentials or an authorized owner session. Direct tokens are not saved/refreshed and never fall back to application configuration.
See the [authentication reference](panel.md#authentication) for application login, direct-token precedence, owner sessions/2FA and logout. App management accepts existing apps-scoped credentials or an authorized owner session; the backend rejects granting apps scope when creating or updating an application. Direct tokens are not saved/refreshed and never fall back to application configuration.
Existing scopes retain their original access. Use the resource's `:read` scope for read-only access to crons, subscriptions, envs, configs, scripts, dependencies, logs, or dashboard. `crons:read` excludes the mutating `GET /crons/import`; `scripts:read` and `logs:read` include downloads (POST), but exclude writes, deletion, and execution. `dashboard:read` excludes statistics reporting (POST). External `system` applications may only read system status and send notifications. `GET /apps` returns Client Secrets, so there is no `apps:read` scope.
`auth status --scope <scope>` tests a representative read; update has no read endpoint and is not offered by status. This does not prove permission for every write. `app list/create/update/reset-secret` 默认隐藏 client_secret/tokens;只有明确需要凭据时才加 --show-secrets,并避免把输出记录到共享日志。其他资源/通用请求可能含环境值、配置、脚本、订阅凭据和会话,按敏感数据处理。
@@ -49,16 +51,24 @@ ql script get --query '{"file":"demo.js","path":""}' --json
ql script history-list --query '{"filename":"demo.js","path":""}' --json
ql script history-detail --query '{"filename":"demo.js","path":"","id":"VERSION_UUID"}' --json
ql script history-restore --data '{"filename":"demo.js","path":"","id":"VERSION_UUID","expectedHash":"CURRENT_SHA256"}' --json
# Requires the full configs scope or a panel session token.
ql config save --data '{"name":"example.sh","content":"# example"}' --json
ql config get --query '{"path":"example.sh"}' --json
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
ql dependency create --data '[{"name":"example-package","type":0}]' --json
ql dependency refresh --data '{"type":"python3"}' --json
ql dependency refresh-active --json
ql dependency refresh-status <JOB_UUID> --json
ql system data-export --data '{}' --output ./panel.tgz --json
ql system data-import --file ./panel.tgz --json
ql api request PUT /open/crons/run --data '[12,13]' --json
ql api request GET /open/crons --query '{"page":1,"size":100,"searchValue":"demo"}' --json
```
依赖 refresh 的 type 为 nodejs 或 python3,立即返回后台任务 id 与 state;用 refresh-status 查询该 UUID,直到 completed 或 failed。refresh-active 返回运行中的任务或 null,普通 list 不触发检查。
Dependency refresh accepts type nodejs or python3 and immediately returns a background job id and state. Poll refresh-status with that UUID until completed or failed. refresh-active returns the running job or null; listing dependencies does not trigger checks.
任务 create/update 需要 command/schedule;name、labels、sub_id、extra_schedules、task_before/after、log_name、allow_multiple_instances、work_dir 等字段可通过 --data 提交。订阅 create 需要 type/url/alias/schedule_type,update 需要 id/type/url/alias;interval_schedule、pull_option、dependences、extensions、sub_before/after、proxy、autoAddCron/autoDelCron 等用 --data。private-repo 凭据仅通过受保护文件或 stdin 提供。环境变量与依赖 create 接受对象数组,应用 scopes 为字符串数组。其余准确字段/枚举由当前后端 Joi schema 校验:见 [back/api](https://github.com/whyour/qinglong/tree/develop/back/api) 和 [定时规则 schema](https://github.com/whyour/qinglong/blob/develop/back/validation/schedule.ts)。
Task creation/update needs command/schedule; additional fields use --data. Subscription creation needs type/url/alias/schedule_type; updates need id/type/url/alias. Interval schedules, private repository credentials, filters, hooks and booleans use --data. Env/dependency creation accepts arrays; application scopes is a string array. The server validates field types/enums; the linked backend schemas are authoritative.
@@ -151,6 +161,9 @@ System/user operations below execute remotely through the panel API. They do not
| `ql log delete ` | DELETE | `logs` | --data, --query |
| `ql log download ` | POST | `logs/download` | --data, --output, --query |
| `ql dependency list ` | GET | `dependencies` | --query |
| `ql dependency refresh ` | POST | `dependencies/refresh` | --data, --query |
| `ql dependency refresh-active ` | GET | `dependencies/refresh/active` | --query |
| `ql dependency refresh-status <id>` | GET | `dependencies/refresh/:id` | --query |
| `ql dependency create ` | POST | `dependencies` | --data, --query |
| `ql dependency update <id>` | PUT | `dependencies` | --data, --query |
| `ql dependency delete <id...>` | DELETE | `dependencies` | --query |
+19
View File
@@ -5,6 +5,7 @@ export interface OpenOperation {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
params?: string[];
uuidParams?: string[];
body?: 'ids' | 'json' | 'object-id';
upload?: string;
download?: boolean;
@@ -334,6 +335,24 @@ export const openOperations: readonly OpenOperation[] = [
download: true,
},
{ name: 'dependency list', method: 'GET', path: 'dependencies' },
{
name: 'dependency refresh',
method: 'POST',
path: 'dependencies/refresh',
body: 'json',
},
{
name: 'dependency refresh-active',
method: 'GET',
path: 'dependencies/refresh/active',
},
{
name: 'dependency refresh-status',
method: 'GET',
path: 'dependencies/refresh/:id',
params: ['id'],
uuidParams: ['id'],
},
{
name: 'dependency create',
method: 'POST',
+15 -7
View File
@@ -17,20 +17,28 @@ export function configPath(): string {
export function readConfig(): StoredConfig {
let fd: number | undefined;
try {
fd = fs.openSync(
configPath(),
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
);
const file = configPath();
const pathStat = fs.lstatSync(file);
if (!pathStat.isFile())
fail(
translate(
process.env,
'CLI 配置必须是普通文件,且不能是符号链接。',
),
);
// Not every platform or filesystem implements POSIX mode bits or O_NOFOLLOW.
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
const stat = fs.fstatSync(fd);
if (
!stat.isFile() ||
stat.mode & 0o077 ||
(process.getuid && stat.uid !== process.getuid())
pathStat.dev !== stat.dev ||
pathStat.ino !== stat.ino
) {
fail(
translate(
process.env,
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。',
'CLI 配置必须是普通文件,且不能是符号链接。',
),
);
}
+8
View File
@@ -107,6 +107,14 @@ export async function auth(
{
crons: 'crons?page=1&size=1',
configs: 'configs/files',
'envs:read': 'envs',
'configs:read': 'configs/files',
'scripts:read': 'scripts',
'crons:read': 'crons?page=1&size=1',
'subscriptions:read': 'subscriptions',
'dependencies:read': 'dependencies',
'logs:read': 'logs',
'dashboard:read': 'dashboard/overview',
dashboard: 'dashboard/overview',
} as Record<string, string>
)[scope] ?? scope,
+15 -3
View File
@@ -49,12 +49,19 @@ async function input(value: string | boolean | undefined): Promise<unknown> {
}
}
const UUID_PATTERN =
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
function route(method: string, path: string): OpenOperation {
const match = openOperations.find(
(op) =>
op.method === method &&
new RegExp(
'^' + op.path.replace(/:[A-Za-z]+/g, '[1-9][0-9]*') + '$',
'^' +
op.path.replace(/:([A-Za-z]+)/g, (_match, name) =>
op.uuidParams?.includes(name) ? UUID_PATTERN : '[1-9][0-9]*',
) +
'$',
).test(path),
);
if (!match)
@@ -63,7 +70,8 @@ function route(method: string, path: string): OpenOperation {
'Method/path is not a supported OpenAPI route.',
);
for (const [index, part] of match.path.split('/').entries())
if (part.startsWith(':')) integer(path.split('/')[index]!);
if (part.startsWith(':') && !match.uuidParams?.includes(part.slice(1)))
integer(path.split('/')[index]!);
return match;
}
@@ -107,7 +115,11 @@ export async function openCommand(
if (!generic) {
endpoint = op.path;
for (const [index, name] of (op.params ?? []).entries()) {
const value = integer(command.positionals[index]!);
const positional = command.positionals[index]!;
const isUuid = op.uuidParams?.includes(name);
if (isUuid && !new RegExp(`^${UUID_PATTERN}$`).test(positional))
usage('任务 ID 必须为 UUID。', 'Job ID must be a UUID.');
const value = isUuid ? positional : integer(positional);
endpoint = endpoint.replace(`:${name}`, String(value));
if (op.body === 'object-id') {
if (body !== undefined && !isRecord(body))
+8
View File
@@ -26,6 +26,14 @@ export const commands: readonly CommandSpec[] = [
default: 'crons',
choices: [
...new Set(openOperations.map((op) => op.path.split('/')[0]!)),
'envs:read',
'configs:read',
'scripts:read',
'crons:read',
'subscriptions:read',
'dependencies:read',
'logs:read',
'dashboard:read',
].filter((scope) => !['auth', 'update'].includes(scope)),
},
},
+2 -2
View File
@@ -175,8 +175,8 @@ export const english: Record<string, string> = {
'Request failed or returned invalid JSON. Check the instance URL, network and TLS certificate.',
'API 请求被拒绝(code %s),请检查%s。':
'API request rejected (code %s). Check %s.',
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。':
'CLI config must be an owned private regular file (0600).',
'CLI 配置必须是普通文件,且不能是符号链接。':
'CLI config must be a regular file, not a symbolic link.',
'CLI 配置无效,请运行 ql login。': 'Invalid CLI config. Run ql login.',
'尚未登录,请运行 ql login。': 'Not logged in. Run ql login.',
'无法读取 CLI 配置,请检查文件权限并运行 ql login。':
+4 -4
View File
@@ -28,12 +28,12 @@ const fs = require('node:fs/promises');
assert.equal(result.status, 0, args.slice(0, 2).join(' ') + ': ' + result.stderr);
return JSON.parse(result.stdout).data;
};
const app = cli(['app', 'create', '--name', 'cli-fixture', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps', '--show-secrets'], true);
const app = cli(['app', 'create', '--name', 'cli-fixture', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard', '--show-secrets'], true);
assert.ok(app.client_id && app.client_secret);
cli(['login', '--url', url], false, { QL_CLIENT_ID: app.client_id, QL_CLIENT_SECRET: app.client_secret });
cli(['auth', 'status', '--scope', 'apps']);
cli(['app', 'update', String(app.id), '--name', 'cli-updated', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps']);
assert.ok(cli(['app', 'list']).every(item => !('client_secret' in item) && !('tokens' in item)));
cli(['auth', 'status', '--scope', 'crons']);
cli(['app', 'update', String(app.id), '--name', 'cli-updated', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard'], true);
assert.ok(cli(['app', 'list'], true).every(item => !('client_secret' in item) && !('tokens' in item)));
const task = cli(['task', 'create', '--name', 'cli-task', '--command', 'echo OPENAPI_FIXTURE', '--schedule', '0 0 * * *']);
cli(['task', 'update', String(task.id), '--name', 'cli-task-updated', '--command', 'echo OPENAPI_UPDATED', '--schedule', '0 1 * * *']);
cli(['task', 'disable', String(task.id)]); cli(['task', 'enable', String(task.id)]);
+88 -3
View File
@@ -81,6 +81,13 @@ async function fixture(t) {
code: 200,
data: { data: [{ id: 12, name: 'example' }], total: 1 },
});
else if (
['/panel/open/envs', '/panel/open/configs/files', '/panel/open/scripts',
'/panel/open/subscriptions', '/panel/open/dependencies',
'/panel/open/logs', '/panel/open/dashboard/overview']
.includes(url.pathname)
)
json({ code: 200, data: [] });
else if (
['/panel/open/crons/run', '/panel/open/crons/stop'].includes(
url.pathname,
@@ -155,6 +162,21 @@ test('compiled entry: login aliases, private storage, status, refresh and local
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
assert.doesNotMatch(status.out, /test-secret|test-token/);
assert.equal(f.requests.at(-1).url.pathname, '/panel/open/crons');
for (const [scope, pathname] of [
['envs:read', '/panel/open/envs'],
['configs:read', '/panel/open/configs/files'],
['scripts:read', '/panel/open/scripts'],
['crons:read', '/panel/open/crons'],
['subscriptions:read', '/panel/open/subscriptions'],
['dependencies:read', '/panel/open/dependencies'],
['logs:read', '/panel/open/logs'],
['dashboard:read', '/panel/open/dashboard/overview'],
]) {
const checked = await f.invoke(['auth', 'status', '--scope', scope, '--json']);
assert.equal(checked.code, 0, checked.err);
assert.equal(JSON.parse(checked.out).data.scopeChecked, scope);
assert.equal(f.requests.at(-1).url.pathname, pathname);
}
const config = JSON.parse(fs.readFileSync(f.file));
config.expiration = 1;
fs.writeFileSync(f.file, JSON.stringify(config));
@@ -296,11 +318,13 @@ test('malformed successful responses are rejected, including expired authenticat
failure(await f.invoke(['login', '--url', f.url, '--json']), 1);
});
test('config permissions, malformed content and symlinks fail closed', async (t) => {
test('config content and symlinks are checked independently of permission bits', async (t) => {
const f = await fixture(t);
await f.login();
fs.chmodSync(f.file, 0o644);
failure(await f.invoke(['task', 'list', '--json']), 1);
const sharedMode = await f.invoke(['task', 'list', '--json']);
assert.equal(sharedMode.code, 0, sharedMode.err);
assert.equal(JSON.parse(sharedMode.out).data.total, 1);
fs.chmodSync(f.file, 0o600);
fs.writeFileSync(f.file, '{broken');
failure(await f.invoke(['task', 'list', '--json']), 1);
@@ -312,12 +336,73 @@ test('config permissions, malformed content and symlinks fail closed', async (t)
const target = path.join(f.dir, 'target');
fs.renameSync(f.file, target);
fs.symlinkSync(target, f.file);
failure(await f.invoke(['task', 'list', '--json']), 1);
const linked = await f.invoke(['task', 'list', '--json'], { QL_LANG: 'en' });
failure(linked, 1);
assert.match(JSON.parse(linked.err).message, /regular file, not a symbolic link/);
await f.login();
assert.equal(fs.lstatSync(f.file).isSymbolicLink(), false);
assert.equal(JSON.parse(fs.readFileSync(target)).clientId, 123);
});
test('Windows accepts a saved config despite synthetic 0666 mode bits', async (t) => {
const f = await fixture(t);
await f.login();
const preload = path.join(f.dir, 'windows-mode.cjs');
fs.writeFileSync(
preload,
`Object.defineProperty(process, 'platform', { value: 'win32' });
const fs = require('node:fs');
const originalFstatSync = fs.fstatSync;
const originalOpenSync = fs.openSync;
fs.openSync = function (file, flags, ...args) {
return originalOpenSync(file, typeof flags === 'number'
? flags & ~fs.constants.O_NOFOLLOW : flags, ...args);
};
fs.fstatSync = function (...args) {
const stat = originalFstatSync(...args);
stat.mode = (stat.mode & ~0o777) | 0o666;
return stat;
};\n`,
);
const env = { NODE_OPTIONS: `--require=${preload}` };
const status = await f.invoke(['auth', 'status', '--json'], env);
assert.equal(status.code, 0, status.err);
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
const list = await f.invoke(['task', 'list', '--json'], env);
assert.equal(list.code, 0, list.err);
assert.equal(JSON.parse(list.out).data.total, 1);
const target = path.join(f.dir, 'target');
fs.renameSync(f.file, target);
fs.symlinkSync(target, f.file);
failure(await f.invoke(['task', 'list', '--json'], env), 1);
});
test('Linux ARM accepts a saved config with synthetic mode and owner', async (t) => {
const f = await fixture(t);
await f.login();
const preload = path.join(f.dir, 'arm-mode.cjs');
fs.writeFileSync(
preload,
`Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'arm' });
const fs = require('node:fs');
const originalFstatSync = fs.fstatSync;
fs.fstatSync = function (...args) {
const stat = originalFstatSync(...args);
stat.mode = (stat.mode & ~0o777) | 0o666;
stat.uid = 999999;
return stat;
};\n`,
);
const env = { NODE_OPTIONS: `--require=${preload}` };
const status = await f.invoke(['auth', 'status', '--json'], env);
assert.equal(status.code, 0, status.err);
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
const list = await f.invoke(['task', 'list', '--json'], env);
assert.equal(list.code, 0, list.err);
assert.equal(JSON.parse(list.out).data.total, 1);
});
test('standalone compiled artifact and symlink run without repo runtime dependencies', async (t) => {
const f = await fixture(t);
const dist = path.join(f.dir, 'standalone');
+24 -2
View File
@@ -83,6 +83,27 @@ test('script history commands send exact query parameters and restore payloads',
assert.equal(f.requests.length, count, 'missing restore payload must not issue a request');
});
test('dependency refresh commands send the dependency type and address background jobs', async t => {
const f = await fixture(t);
success(await f.run(['dependency', 'refresh', '--data', '{"type":"python3"}']));
assert.equal(f.requests[0].method, 'POST');
assert.equal(f.requests[0].url, '/panel/open/dependencies/refresh');
assert.deepEqual(JSON.parse(f.requests[0].body), { type: 'python3' });
const id = '32ec8b22-2c48-4bcf-85d4-b394198417ba';
success(await f.run(['dependency', 'refresh-status', id]));
assert.equal(f.requests[1].method, 'GET');
assert.equal(f.requests[1].url, `/panel/open/dependencies/refresh/${id}`);
success(await f.run(['dependency', 'refresh-active']));
assert.equal(f.requests[2].method, 'GET');
assert.equal(f.requests[2].url, '/panel/open/dependencies/refresh/active');
success(await f.run(['api', 'request', 'GET', `/open/dependencies/refresh/${id}`]));
assert.equal(f.requests[3].url, `/panel/open/dependencies/refresh/${id}`);
assert.equal((await f.run(['dependency', 'refresh-status', '7'])).code, 2);
assert.equal((await f.run(['api', 'request', 'GET', '/open/dependencies/refresh/7'])).code, 2);
assert.equal((await f.run(['dependency', 'refresh'])).code, 2);
assert.equal(f.requests.length, 4, 'invalid arguments must not issue a request');
});
test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
const root = path.resolve(__dirname, '../../../back/api');
const expected = [];
@@ -132,7 +153,8 @@ test('all added named operations reach their registered method/path, including u
const file = path.join(f.root, 'fixture.json'); await fs.writeFile(file, '{}');
for (const op of openOperations.filter(op => !op.existing)) {
const args = op.name.split(' ');
for (const _ of op.params ?? []) args.push('7');
const parameter = name => op.uuidParams?.includes(name) ? '32ec8b22-2c48-4bcf-85d4-b394198417ba' : '7';
for (const name of op.params ?? []) args.push(parameter(name));
if (op.body === 'ids') args.push('7', '8');
else if (op.body) {
const data = op.name.startsWith('task ') ? { command: 'echo fixture', schedule: '0 0 * * *' }
@@ -144,7 +166,7 @@ test('all added named operations reach their registered method/path, including u
success(await f.run(args));
const last = f.requests.at(-1);
assert.equal(last.method, op.method, op.name);
assert.equal(last.url, '/panel/open/' + op.path.replace(/:[A-Za-z]+/g, '7'), op.name);
assert.equal(last.url, '/panel/open/' + op.path.replace(/:([A-Za-z]+)/g, (_match, name) => parameter(name)), op.name);
assert.equal(last.headers.authorization, op.anonymous ? undefined : 'Bearer fixture-token', op.name);
if (op.upload) assert.ok(last.body.includes(`name="${op.upload}"; filename="fixture.json"`), op.name);
}
+74
View File
@@ -0,0 +1,74 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const { spawn } = require('node:child_process');
const entry = path.resolve(__dirname, '../../dist/npm/ql.js');
test('saved login works on the host filesystem', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-platform-smoke-'));
const config = path.join(dir, 'cli.json');
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
res.setHeader('content-type', 'application/json');
if (url.pathname === '/panel/open/auth/token') {
assert.equal(url.searchParams.get('client_id'), 'smoke-id');
assert.equal(url.searchParams.get('client_secret'), 'smoke-secret');
res.end(JSON.stringify({
code: 200,
data: {
token: 'smoke-token',
expiration: Math.floor(Date.now() / 1000) + 3600,
},
}));
return;
}
assert.equal(req.headers.authorization, 'Bearer smoke-token');
assert.equal(url.pathname, '/panel/open/crons');
res.end(JSON.stringify({ code: 200, data: { data: [], total: 0 } }));
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
t.after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
});
const env = {
...process.env,
QL_CLI_CONFIG: config,
QL_CLIENT_ID: 'smoke-id',
QL_CLIENT_SECRET: 'smoke-secret',
QL_URL: '',
QL_ACCESS_TOKEN: '',
};
const invoke = (args) => new Promise((resolve, reject) => {
const child = spawn(process.execPath, [entry, ...args], {
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
let err = '';
child.stdout.on('data', (data) => (out += data));
child.stderr.on('data', (data) => (err += data));
child.once('error', reject);
child.once('close', (code) => resolve({ code, out, err }));
});
const url = `http://127.0.0.1:${server.address().port}/panel`;
const login = await invoke(['login', '--url', url, '--json']);
assert.equal(login.code, 0, login.err);
assert.equal(JSON.parse(login.out).data.authenticated, true);
assert.equal(fs.lstatSync(config).isFile(), true);
const status = await invoke(['auth', 'status', '--json']);
assert.equal(status.code, 0, status.err);
assert.equal(JSON.parse(status.out).data.authenticated, true);
const list = await invoke(['task', 'list', '--json']);
assert.equal(list.code, 0, list.err);
assert.equal(JSON.parse(list.out).data.total, 0);
});
+23
View File
@@ -0,0 +1,23 @@
# 青龙 2.24.0
## 新增功能
- Node.js 原生部署支持 Python venv 隔离环境。Python 依赖可使用包名、Git URL,以及位于脚本或订阅仓库中的 requirements 文件和 pyproject.toml;拉取订阅不会自动安装仓库声明的依赖。
- 新增通知审计日志,便于查看通知发送结果和失败原因。
- 提供经过平台校验的升级包,支持在线暂存、离线安装及失败回退。升级过程保留账号、配置、环境变量、任务和脚本等数据。
## 修复与优化
- 默认 Alpine 与 Debian 镜像统一使用 Node.js 24;SQLite 依赖升级至 1.1.4。ARM64 升级包使用原生构建环境。
- 修复额外调度规则校验、单实例任务运行期间的新触发排队、订阅同步、Python 依赖安装与脚本发现等问题。
- 修复系统日志清理时活跃日志文件被移除、页面加载时序和升级流程中的异常情况,并补齐相关中英文提示。
- 独立远程 CLI 更新至 0.1.3,最低 Node.js 版本为 22.12.0。
## 镜像与升级提醒
- `2.24.0` 的 Alpine 镜像支持 amd64、arm/v6、arm/v7、arm64、ppc64le、s390x 和 386;`2.24.0-debian` 支持 amd64、arm64、ppc64le 和 s390x。Debian 暂不提供 arm/v7。
- 2.23.0 是 Python 3.10 兼容镜像的最后一个版本;2.24.0 不再更新该变体。需要 Python 3.10 的用户请先评估迁移方案。
- 升级前停止任务并备份完整持久化数据目录,记录原镜像摘要和启动配置。升级后检查脚本、依赖、订阅与通知;如需回退,请同时恢复与旧版本匹配的数据备份。
- 主 npm 包 `@whyour/qinglong` 与独立远程 CLI 包 `@whyour/qinglong-cli` 均提供 `ql` 命令。在已有面板的主机上安装远程 CLI 时,使用独立 npm prefix 或 `npm exec --package=@whyour/qinglong-cli -- ql ...`,避免覆盖面板内置入口。
本版本以 2.23.0 为上一正式版本。开发候选的源码、npm 包与 11 个发布镜像架构已完成隔离验收;正式产物发布后仍以各目标渠道的实际构建结果为准。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@whyour/qinglong",
"packageManager": "pnpm@8.3.1",
"version": "2.24.0-dev.0",
"version": "2.24.0",
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
"repository": {
"type": "git",
+15
View File
@@ -1,5 +1,9 @@
{
"选中条数": "Selected {count} items",
"重新检查依赖": "Recheck dependencies",
"依赖检查失败": "Dependency check failed",
"正在检查依赖": "Checking {type} dependencies",
"依赖检查状态已中断": "Dependency check status was lost. Please retry.",
"登录失败次数": "Failed {count} time(s)",
"间隔定时规则": "Every {value} {unit}",
"SOCK5代理,例如 IP:PORT": "SOCKS5 proxy, e.g. IP:PORT",
@@ -112,6 +116,7 @@
"代理端口": "Proxy Port",
"以后再说": "Later",
"仪表盘": "Dashboard",
"仪表盘(只读)": "Dashboard (read only)",
"任务": "Task",
"任务数": "Tasks",
"任务未运行": "Task not running",
@@ -122,6 +127,7 @@
"使用手机应用扫描二维码,或者输入秘钥": "Scan the QR code with the mobile app or enter the key",
"依赖文件": "Dependency Files",
"依赖管理": "Dependency Management",
"依赖管理(只读)": "Dependencies (read only)",
"依赖类型": "Dependency Type",
"依赖缓存": "Dependency cache",
"依赖设置": "Dependence Settings",
@@ -188,6 +194,8 @@
"可用,你使用的版本为": "available, the version you are using is",
"同时删除关联任务和脚本": "Delete associated tasks and scripts as well",
"名称": "Name",
"名称受限": "Restricted name",
"名称受限,未注入任务": "Restricted name; not injected into tasks",
"名称不能为保留关键字": "The name cannot be a reserved keyword",
"吗": "?",
"否": "No",
@@ -228,6 +236,7 @@
"完成设置": "Finish Setup",
"定时": "Schedule",
"定时任务": "Scheduled Tasks",
"定时任务(只读)": "Scheduled Tasks (read only)",
"定时任务并发数": "Concurrent Scheduled Tasks",
"定时类型": "Schedule Type",
"定时规则": "Schedule Rule",
@@ -331,6 +340,7 @@
"日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators",
"日志文件": "Log files",
"日志管理": "Log Management",
"日志管理(只读)": "Logs (read only)",
"时": "hour(s)",
"时区": "Timezone",
"是": "Yes",
@@ -411,6 +421,7 @@
"版本": "Version",
"状态": "Status",
"环境变量": "Environment variable",
"环境变量(只读)": "Environment variables (read only)",
"生成数据中...": "Generating data...",
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
"用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "Token, which can be obtained after scanning and logging in, is available under 'My Account' -> 'Settings' -> 'Tokens'. Please refer to the instructions for detailed steps: https://www.weplusbot.com/",
@@ -494,6 +505,7 @@
"脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "Working directory for script execution. Leave blank for auto-detection. Relative paths are based on the scripts directory. Absolute paths are also supported.",
"脚本文件": "Script files",
"脚本管理": "Script Management",
"脚本(只读)": "Scripts (read only)",
"自动删除任务": "Automatically Delete Tasks",
"自动拆分": "Auto Split",
"自动添加任务": "Automatically Add Tasks",
@@ -508,11 +520,13 @@
"视图管理": "View Management",
"订阅": "Subscription",
"订阅管理": "Subscription Management",
"订阅管理(只读)": "Subscriptions (read only)",
"设置": "Settings",
"访问密钥": "Access Key",
"语言": "Language",
"请": "Please",
"请先按如下方式修复:": "Please fix it as follows:",
"请先修改环境变量名称": "Rename the environment variable first",
"请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "Request body format: key1: value1 (separate multiple keys with line breaks). URL or body must contain $title, $content is optional and corresponds to the API content position.",
"请求头Content-Type": "Request Header Content-Type",
"请求头格式Custom-Header1: Header1,多个换行分割": "Request header format: Custom-Header1: Header1 (separate multiple headers with line breaks)",
@@ -606,6 +620,7 @@
"邮箱服务名称,比如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 for SMTP authentication",
"配置文件": "Configuration Files",
"配置文件(只读)": "Configuration files (read only)",
"重启": "Restart",
"重命名": "Rename",
"重新下载": "Redownload",
+15
View File
@@ -1,5 +1,9 @@
{
"选中条数": "已选择 {count} 项",
"重新检查依赖": "重新检查依赖",
"依赖检查失败": "依赖检查失败",
"正在检查依赖": "正在检查 {type} 依赖",
"依赖检查状态已中断": "依赖检查状态已中断,请重新检查",
"登录失败次数": "失败{count}次",
"间隔定时规则": "每{value}{unit}",
"SOCK5代理,例如 IP:PORT": "SOCK5代理,例如 IP:PORT",
@@ -111,6 +115,7 @@
"代理端口": "代理端口",
"以后再说": "以后再说",
"仪表盘": "仪表盘",
"仪表盘(只读)": "仪表盘(只读)",
"任务": "任务",
"任务数": "任务数",
"任务未运行": "任务未运行",
@@ -121,6 +126,7 @@
"使用手机应用扫描二维码,或者输入秘钥": "使用手机应用扫描二维码,或者输入秘钥",
"依赖文件": "依赖文件",
"依赖管理": "依赖管理",
"依赖管理(只读)": "依赖管理(只读)",
"依赖类型": "依赖类型",
"依赖缓存": "依赖缓存",
"依赖设置": "依赖设置",
@@ -187,6 +193,8 @@
"可用,你使用的版本为": "可用,你使用的版本为",
"同时删除关联任务和脚本": "同时删除关联任务和脚本",
"名称": "名称",
"名称受限": "名称受限",
"名称受限,未注入任务": "名称受限,未注入任务",
"名称不能为保留关键字": "名称不能为保留关键字",
"吗": "吗",
"否": "否",
@@ -227,6 +235,7 @@
"完成设置": "完成设置",
"定时": "定时",
"定时任务": "定时任务",
"定时任务(只读)": "定时任务(只读)",
"定时任务并发数": "定时任务并发数",
"定时类型": "定时类型",
"定时规则": "定时规则",
@@ -330,6 +339,7 @@
"日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符",
"日志文件": "日志文件",
"日志管理": "日志管理",
"日志管理(只读)": "日志管理(只读)",
"时": "时",
"时区": "时区",
"是": "是",
@@ -411,6 +421,7 @@
"版本": "版本",
"状态": "状态",
"环境变量": "环境变量",
"环境变量(只读)": "环境变量(只读)",
"生成数据中...": "生成数据中...",
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
"用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/",
@@ -494,6 +505,7 @@
"脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径",
"脚本文件": "脚本文件",
"脚本管理": "脚本管理",
"脚本(只读)": "脚本(只读)",
"自动删除任务": "自动删除任务",
"自动拆分": "自动拆分",
"自动添加任务": "自动添加任务",
@@ -508,11 +520,13 @@
"视图管理": "视图管理",
"订阅": "订阅",
"订阅管理": "订阅管理",
"订阅管理(只读)": "订阅管理(只读)",
"设置": "设置",
"访问密钥": "访问密钥",
"语言": "语言",
"请": "请",
"请先按如下方式修复:": "请先按如下方式修复:",
"请先修改环境变量名称": "请先修改环境变量名称",
"请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置",
"请求头Content-Type": "请求头Content-Type",
"请求头格式Custom-Header1: Header1,多个换行分割": "请求头格式Custom-Header1: Header1,多个换行分割",
@@ -606,6 +620,7 @@
"邮箱服务名称,比如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",
"邮箱认证地址": "邮箱认证地址",
"配置文件": "配置文件",
"配置文件(只读)": "配置文件(只读)",
"重启": "重启",
"重命名": "重命名",
"重新下载": "重新下载",
+175 -10
View File
@@ -87,6 +87,7 @@ const StatusMap: Record<number, { icon: React.ReactNode; color: string }> = {
const Dependence = () => {
const { headerStyle, isPhone } = useOutletContext<SharedContext>();
const [statusFilter, setStatusFilter] = useState<number[]>([]);
const columns: any = [
{
title: intl.get('序号'),
@@ -106,6 +107,7 @@ const Dependence = () => {
key: 'status',
width: 120,
dataIndex: 'status',
filteredValue: statusFilter.length ? statusFilter : null,
filters: [
{
text: intl.get('队列中'),
@@ -233,6 +235,9 @@ const Dependence = () => {
];
const [value, setValue] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [refreshingType, setRefreshingType] = useState<string | null>(null);
const [refreshGeneration, setRefreshGeneration] = useState(0);
const [isModalVisible, setIsModalVisible] = useState(false);
const [editedDependence, setEditedDependence] = useState();
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
@@ -240,9 +245,26 @@ const Dependence = () => {
const [logDependence, setLogDependence] = useState<any>();
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
const [type, setType] = useState('nodejs');
const listQuery = useRef({ type, searchText, statusFilter });
listQuery.current = { type, searchText, statusFilter };
const tableRef = useRef<HTMLDivElement>(null);
const latestListRequest = useRef(0);
const refreshTimer = useRef<ReturnType<typeof setTimeout>>();
const activeRefreshId = useRef<string | null>(null);
const refreshRequestVersion = useRef(0);
const alive = useRef(true);
const [height, setHeight] = useState<number>(0);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
latestListRequest.current += 1;
refreshRequestVersion.current += 1;
if (refreshTimer.current) clearTimeout(refreshTimer.current);
};
}, []);
useResizeObserver(tableRef, (entry) => {
const _height =
entry.target?.parentElement?.parentElement?.parentElement?.offsetHeight;
@@ -256,23 +278,146 @@ const Dependence = () => {
});
const getDependencies = (status?: number[]) => {
const query = listQuery.current;
const requestId = ++latestListRequest.current;
setLoading(true);
request
.get(
`${
config.apiPrefix
}dependencies?searchValue=${searchText}&type=${type}&status=${
status || ''
}`,
`${config.apiPrefix}dependencies?searchValue=${query.searchText}&type=${
query.type
}&status=${status ?? query.statusFilter}`,
)
.then(({ code, data }) => {
if (code === 200) {
if (
alive.current &&
requestId === latestListRequest.current &&
code === 200
) {
setValue(data);
}
})
.finally(() => setLoading(false));
.finally(() => {
if (alive.current && requestId === latestListRequest.current) {
setLoading(false);
}
});
};
const scheduleRefresh = (callback: () => void, delay: number) => {
if (refreshTimer.current) clearTimeout(refreshTimer.current);
refreshTimer.current = setTimeout(callback, delay);
};
const finishRefresh = (id: string, completed: boolean) => {
if (activeRefreshId.current !== id) return;
refreshRequestVersion.current += 1;
activeRefreshId.current = null;
if (refreshTimer.current) clearTimeout(refreshTimer.current);
setRefreshing(false);
setRefreshingType(null);
setRefreshGeneration((generation) => generation + 1);
if (!completed) {
message.error(intl.get('依赖检查失败'));
}
};
const attachRefresh = (job: { id: string; type: string }) => {
if (!alive.current) return;
refreshRequestVersion.current += 1;
if (refreshTimer.current) clearTimeout(refreshTimer.current);
activeRefreshId.current = job.id;
setRefreshing(true);
setRefreshingType(job.type);
scheduleRefresh(() => pollRefresh(job.id), 0);
};
const recoverRefresh = (
reloadWhenIdle = false,
retryOnError = false,
notifyWhenIdle = false,
) => {
const version = ++refreshRequestVersion.current;
request
.get(`${config.apiPrefix}dependencies/refresh/active`)
.then(({ code, data }) => {
if (!alive.current || version !== refreshRequestVersion.current) return;
if (code === 200 && data) {
attachRefresh(data);
} else if (reloadWhenIdle) {
activeRefreshId.current = null;
setRefreshing(false);
setRefreshingType(null);
setRefreshGeneration((generation) => generation + 1);
if (notifyWhenIdle) message.warning(intl.get('依赖检查状态已中断'));
}
})
.catch(() => {
if (
alive.current &&
version === refreshRequestVersion.current &&
retryOnError
) {
scheduleRefresh(() => {
if (alive.current && version === refreshRequestVersion.current) {
recoverRefresh(reloadWhenIdle, true, notifyWhenIdle);
}
}, 5000);
}
});
};
const pollRefresh = (id: string) => {
request
.get(`${config.apiPrefix}dependencies/refresh/${id}`)
.then(({ code, data }) => {
if (!alive.current || activeRefreshId.current !== id) return;
if (code !== 200) {
scheduleRefresh(() => pollRefresh(id), 5000);
return;
}
if (data.state === 'running') {
scheduleRefresh(() => pollRefresh(id), 2000);
} else {
finishRefresh(id, data.state === 'completed');
}
})
.catch((error) => {
if (!alive.current || activeRefreshId.current !== id) return;
if (error?.response?.status === 404) {
activeRefreshId.current = null;
recoverRefresh(true, true, true);
} else {
scheduleRefresh(() => pollRefresh(id), 5000);
}
});
};
const startRefresh = () => {
if (activeRefreshId.current) return;
const version = ++refreshRequestVersion.current;
setRefreshing(true);
setRefreshingType(type);
request
.post(`${config.apiPrefix}dependencies/refresh`, { type })
.then(({ code, data }) => {
if (!alive.current || version !== refreshRequestVersion.current) return;
if (code === 200) {
attachRefresh(data);
} else {
recoverRefresh(true, true);
}
})
.catch(() => {
if (alive.current && version === refreshRequestVersion.current) {
recoverRefresh(true, true);
}
});
};
useEffect(() => {
recoverRefresh(false, true);
}, []);
const addDependence = () => {
setEditedDependence(null as any);
setIsModalVisible(true);
@@ -450,12 +595,16 @@ const Dependence = () => {
};
const onSearch = (value: string) => {
setSearchText(value.trim());
const trimmed = value.trim();
if (trimmed !== searchText) {
latestListRequest.current += 1;
setSearchText(trimmed);
}
};
useEffect(() => {
getDependencies();
}, [searchText, type]);
}, [searchText, type, refreshGeneration]);
useEffect(() => {
if (logDependence) {
@@ -506,6 +655,7 @@ const Dependence = () => {
}, []);
const onTabChange = (activeKey: string) => {
latestListRequest.current += 1;
setSelectedRowIds([]);
setType(activeKey);
};
@@ -551,7 +701,9 @@ const Dependence = () => {
scroll={{ x: 768, y: height }}
loading={loading}
onChange={(pagination, filters) => {
getDependencies(filters?.status as number[]);
const status = (filters?.status as number[]) || [];
setStatusFilter(status);
getDependencies(status);
}}
/>
</DndProvider>
@@ -563,6 +715,19 @@ const Dependence = () => {
className="ql-container-wrapper dependence-wrapper ql-container-wrapper-has-tab"
title={intl.get('依赖管理')}
extra={[
<Button
key="refresh"
icon={<SyncOutlined />}
loading={refreshing}
disabled={type === 'linux' || loading}
onClick={startRefresh}
>
{refreshingType
? intl.get('正在检查依赖', {
type: refreshingType === 'nodejs' ? 'Node' : 'Python',
})
: intl.get('重新检查依赖')}
</Button>,
<Search
placeholder={intl.get('请输入名称')}
style={{ width: 'auto' }}
+24 -2
View File
@@ -174,7 +174,7 @@ const Env = () => {
title: intl.get('状态'),
key: 'status',
dataIndex: 'status',
width: 100,
width: 170,
filters: [
{
text: intl.get('已启用'),
@@ -192,6 +192,13 @@ const Env = () => {
<Tag color={StatusColor[record.status]} style={{ marginRight: 0 }}>
{intl.get(Status[record.status])}
</Tag>
{record.securityBlocked && (
<Tooltip title={intl.get('名称受限,未注入任务')}>
<Tag color="warning" style={{ marginRight: 0 }}>
{intl.get('名称受限')}
</Tag>
</Tooltip>
)}
</Space>
);
},
@@ -202,6 +209,8 @@ const Env = () => {
width: 160,
render: (text: string, record: any, index: number) => {
const isPc = !isPhone;
const isBlockedEnable =
record.status === Status.已禁用 && record.securityBlocked;
return (
<Space size="middle">
<Tooltip title={isPc ? intl.get('编辑') : ''}>
@@ -212,7 +221,9 @@ const Env = () => {
<Tooltip
title={
isPc
? record.status === Status.已禁用
? isBlockedEnable
? intl.get('请先修改环境变量名称')
: record.status === Status.已禁用
? intl.get('启用')
: intl.get('禁用')
: ''
@@ -278,6 +289,10 @@ const Env = () => {
};
const enabledOrDisabledEnv = (record: any, index: number) => {
if (record.status === Status.已禁用 && record.securityBlocked) {
message.error(intl.get('请先修改环境变量名称'));
return;
}
Modal.confirm({
title: intl.get('确认操作', {
operation:
@@ -538,6 +553,13 @@ const Env = () => {
};
const operateEnvs = (operationStatus: number) => {
if (
operationStatus === 0 &&
value.some((env) => selectedRowIds.includes(env.id) && env.securityBlocked)
) {
message.error(intl.get('请先修改环境变量名称'));
return;
}
Modal.confirm({
title: intl.get('确认操作', {
operation: intl.get(OperationName[operationStatus]),
+40
View File
@@ -20,30 +20,58 @@ export default {
name: intl.get('定时任务'),
value: 'crons',
},
{
name: intl.get('定时任务(只读)'),
value: 'crons:read',
},
{
name: intl.get('环境变量'),
value: 'envs',
},
{
name: intl.get('环境变量(只读)'),
value: 'envs:read',
},
{
name: intl.get('订阅管理'),
value: 'subscriptions',
},
{
name: intl.get('订阅管理(只读)'),
value: 'subscriptions:read',
},
{
name: intl.get('配置文件'),
value: 'configs',
},
{
name: intl.get('配置文件(只读)'),
value: 'configs:read',
},
{
name: intl.get('脚本管理'),
value: 'scripts',
},
{
name: intl.get('脚本(只读)'),
value: 'scripts:read',
},
{
name: intl.get('日志管理'),
value: 'logs',
},
{
name: intl.get('日志管理(只读)'),
value: 'logs:read',
},
{
name: intl.get('依赖管理'),
value: 'dependencies',
},
{
name: intl.get('依赖管理(只读)'),
value: 'dependencies:read',
},
{
name: intl.get('系统信息'),
value: 'system',
@@ -52,17 +80,29 @@ export default {
name: intl.get('仪表盘'),
value: 'dashboard',
},
{
name: intl.get('仪表盘(只读)'),
value: 'dashboard:read',
},
],
scopesMap: {
crons: intl.get('定时任务'),
'crons:read': intl.get('定时任务(只读)'),
envs: intl.get('环境变量'),
'envs:read': intl.get('环境变量(只读)'),
subscriptions: intl.get('订阅管理'),
'subscriptions:read': intl.get('订阅管理(只读)'),
configs: intl.get('配置文件'),
'configs:read': intl.get('配置文件(只读)'),
scripts: intl.get('脚本管理'),
'scripts:read': intl.get('脚本(只读)'),
logs: intl.get('日志管理'),
'logs:read': intl.get('日志管理(只读)'),
dependencies: intl.get('依赖管理'),
'dependencies:read': intl.get('依赖管理(只读)'),
system: intl.get('系统信息'),
dashboard: intl.get('仪表盘'),
'dashboard:read': intl.get('仪表盘(只读)'),
},
notificationModes: [
{ value: 'gotify', label: 'Gotify' },
@@ -43,6 +43,104 @@ test('environment values remain literal when the generated JavaScript loads', as
);
});
test('environment API rejects execution controls and legacy rows stay out of task files', async () => {
const output = {};
let writes = 0;
const warnings = [];
const legacy = [
{ id: 1, name: 'task_before', value: 'touch /tmp/untrusted' },
{ id: 2, name: 'NODE_OPTIONS', value: '--require /tmp/untrusted.js' },
{ id: 3, name: 'PREV_NODE_OPTIONS', value: '--import=data:text/javascript,bad' },
{ id: 4, name: 'GIT_SSH_COMMAND', value: 'sh -c bad' },
{ id: 5, name: 'PATH', value: '/tmp/untrusted' },
{ id: 6, name: 'file_task_before', value: '/tmp/untrusted.sh' },
{ id: 7, name: 'SAFE_VALUE', value: 'kept' },
{ id: 8, name: 'bad\nname', value: 'untrusted' },
{ id: 9, name: 'work_dir', value: '/tmp/untrusted' },
{ id: 10, name: 'task_shell_params', value: 'other.sh' },
{ id: 11, name: 'which_program', value: 'bash' },
{ id: 12, name: 'script_params', value: 'other.sh' },
{ id: 13, name: 'LOG_DIR', value: '../../config' },
{ id: 14, name: 'EXPORTED_VARIABLES', value: 'PATH' },
{ id: 15, name: 'begin_timestamp', value: 'a[$(printf BAD)]' },
{ id: 16, name: 'is_macos', value: 'a[$(printf BAD)]' },
{ id: 17, name: 'RandomDelayIgnoredMinutes', value: 'a[$(printf BAD)]' },
{ id: 18, name: 'ID', value: '999' },
{ id: 19, name: 'BASH_XTRACEFD', value: '2' },
];
const Env = load(path.resolve('back/services/env.ts'), {
typedi: decorators,
'../config': { envFile: 'sh', jsEnvFile: 'js', pyEnvFile: 'py' },
'../data/env': {
Env: class { constructor(value) { Object.assign(this, value); } },
EnvModel: {
update: async (value, query) => {
writes++;
},
findAll: async () => [legacy[0]],
},
EnvStatus: { normal: 0, disabled: 1 },
},
'../data': { sequelize: { literal: (value) => value } },
'../shared/utils': {
writeFileWithLock: async (file, value) => { output[file] = value; },
},
}).default;
const service = new Env({
...logger,
warn: (...args) => warnings.push(args),
});
service.envs = async () => legacy;
service.insert = async () => { writes++; return []; };
service.getDb = async () => ({ id: 1, name: 'SAFE_VALUE', value: 'kept' });
service.updateDb = async () => { writes++; return {}; };
for (const name of [
'task_before', 'PREV_NODE_OPTIONS', 'GIT_SSH_COMMAND', 'work_dir',
'task_shell_params', 'which_program', 'script_params', 'LOG_DIR',
'EXPORTED_VARIABLES',
'begin_timestamp', 'is_macos', 'RandomDelayIgnoredMinutes', 'ID',
'BASH_XTRACEFD',
]) {
await assert.rejects(
service.create([
{ name: 'SAFE_VALUE', value: 'ok' },
{ name, value: 'untrusted' },
]),
/环境变量名称不可使用/,
);
}
await assert.rejects(
service.update({ id: 1, name: 'BASH_ENV', value: '/tmp/untrusted.sh' }),
/环境变量名称不可使用/,
);
await assert.rejects(
service.updateNames({ ids: [1], name: 'PYTHONPATH' }),
/环境变量名称不可使用/,
);
assert.equal(writes, 0);
await service.set_envs();
assert.equal(writes, 0);
assert.equal(warnings.length, 1);
assert.match(warnings[0][1], /GIT_SSH_COMMAND/);
assert.equal(warnings[0][1].includes('\n'), false);
await assert.rejects(service.enabled([1]), /环境变量名称不可使用/);
assert.equal(writes, 0);
delete service.envs;
service.find = async () => legacy;
const listed = await service.envs();
assert.equal(listed[0].securityBlocked, true);
assert.equal(listed[6].securityBlocked, false);
for (const contents of Object.values(output)) {
assert.doesNotMatch(
contents,
/task_before|NODE_OPTIONS|GIT_SSH_COMMAND|PATH|work_dir|task_shell_params|which_program|script_params|LOG_DIR|EXPORTED_VARIABLES|begin_timestamp|is_macos|RandomDelayIgnoredMinutes|ID|BASH_XTRACEFD/,
);
assert.match(contents, /SAFE_VALUE/);
}
});
test('subscription shell parameters round trip without expansion or command substitution', () => {
const { formatCommand } = load(path.resolve('back/config/subscription.ts'));
const payload =
+69
View File
@@ -0,0 +1,69 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const express = require('express');
const { errors } = require('celebrate');
const path = require('node:path');
const load = require('../helpers/load-security-module.cjs');
test('application API rejects granting apps scope on create and update', async (t) => {
const calls = [];
class OpenService {}
const service = {
create: async (body) => {
calls.push(['create', body]);
return body;
},
update: async (body) => {
calls.push(['update', body]);
return body;
},
};
const route = load(path.join(__dirname, '../../back/api/open.ts'), {
'../services/open': OpenService,
typedi: { Container: { get: () => service } },
}).default;
const app = express();
app.use(express.json());
for (const prefix of ['/api', '/open']) {
const router = express.Router();
route(router);
app.use(prefix, router);
}
app.use(errors());
const server = app.listen(0, '127.0.0.1');
t.after(() => new Promise((resolve) => server.close(resolve)));
await new Promise((resolve, reject) => {
server.once('listening', resolve);
server.once('error', reject);
});
const request = async (prefix, method, body) => {
const response = await fetch(
`http://127.0.0.1:${server.address().port}${prefix}/apps`,
{
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
},
);
return response.status;
};
for (const prefix of ['/api', '/open']) {
assert.equal(
await request(prefix, 'POST', { name: 'new', scopes: ['crons', 'apps'] }),
400,
);
assert.equal(
await request(prefix, 'PUT', { id: 1, scopes: ['apps'] }),
400,
);
assert.equal(
await request(prefix, 'POST', { name: 'new', scopes: ['crons:read'] }),
200,
);
assert.equal(
await request(prefix, 'PUT', { id: 1, scopes: ['crons'] }),
200,
);
}
assert.equal(calls.length, 4);
});
-2
View File
@@ -51,8 +51,6 @@ test('release images receive the same-run artifact and verify it before use', ()
for (const name of [
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
]) {
const job = workflow.jobs[name];
assert.equal(job.needs, 'build-static');
+93 -15
View File
@@ -1,7 +1,7 @@
const assert = require('node:assert/strict');
const test = require('node:test');
test('dependency listing marks cache entries missing from disk for reinstall', async (t) => {
test('dependency listing uses stored status until explicitly refreshed', async (t) => {
const moduleStubs = new Map();
const stubModule = (modulePath, exports) => {
const resolved = require.resolve(modulePath);
@@ -23,24 +23,47 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
};
const DependenceTypes = { nodejs: 0, python3: 1, linux: 2 };
const docs = [
{ id: 1, name: 'missing-package', type: 0, status: 1 },
{ id: 2, name: 'present-package', type: 0, status: 1 },
{
id: 1,
name: 'missing-package',
type: 0,
status: 1,
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
{
id: 2,
name: 'present-package',
type: 0,
status: 1,
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
];
const updates = [];
const probes = [];
const DependenceModel = {
findAll: async ({ where }) => {
if (where.status === DependenceStatus.installed) {
return docs;
return docs
.filter((doc) => doc.status === DependenceStatus.installed)
.map((doc) => ({ ...doc }));
}
return docs.map((doc) => ({
...doc,
status: updates.some((update) => update.ids.includes(doc.id))
? DependenceStatus.installFailed
: doc.status,
}));
return docs.map((doc) => ({ ...doc }));
},
update: async ({ status }, { where }) => {
updates.push({ status, ids: where.id });
const changed = docs.filter(
(doc) =>
doc.id === where.id &&
doc.status === where.status &&
doc.name === where.name &&
doc.type === where.type &&
doc.updatedAt.getTime() === where.updatedAt.getTime(),
);
for (const doc of changed) {
doc.status = status;
doc.updatedAt = new Date(doc.updatedAt.getTime() + 1000);
}
if (changed.length)
updates.push({ status, ids: changed.map((d) => d.id) });
},
};
@@ -65,8 +88,12 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
getPid: async () => 0,
getUninstallCommand: () => '',
killTask: async () => {},
promiseExecSuccess: async (command) =>
command === 'check:present-package' ? 'present-package 1.0.0\n' : '',
promiseExecSuccess: async (command) => {
probes.push(command);
return command === 'check:present-package'
? 'present-package 1.0.0\n'
: '';
},
});
stubModule('../../back/config/const', {
LINUX_DEPENDENCE_COMMAND: { Alpine: {} },
@@ -104,15 +131,29 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
const DependenceService = require('../../back/services/dependence').default;
const service = new DependenceService({}, {});
const result = await service.dependencies({
const query = {
searchValue: '',
type: 'nodejs',
status: '',
});
};
const cached = await service.dependencies(query);
assert.deepEqual(updates, []);
assert.deepEqual(probes, []);
assert.deepEqual(
cached.map(({ status }) => status),
[1, 1],
);
const { job } = service.startRefresh('nodejs');
assert.equal(job.state, 'running');
await new Promise(setImmediate);
assert.equal(service.getRefreshJob(job.id).state, 'completed');
const result = await service.dependencies(query);
assert.deepEqual(updates, [
{ status: DependenceStatus.installFailed, ids: [1] },
]);
assert.deepEqual(probes, ['check:missing-package', 'check:present-package']);
assert.deepEqual(
result.map(({ id, status }) => ({ id, status })),
[
@@ -123,4 +164,41 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
await service.dependencies({ searchValue: '', type: 'linux', status: '' });
assert.equal(updates.length, 1);
assert.equal(probes.length, 2);
let finishProbe;
service.isDependenceInstalled = async () => {
await new Promise((resolve) => {
finishProbe = resolve;
});
return false;
};
const racing = service.startRefresh('nodejs');
await new Promise(setImmediate);
docs[1].updatedAt = new Date(docs[1].updatedAt.getTime() + 1000);
finishProbe();
await new Promise(setImmediate);
assert.equal(racing.job.state, 'completed');
assert.equal(docs[1].status, DependenceStatus.installed);
assert.equal(updates.length, 1);
let finishRefresh;
service.refreshInstalledStatuses = () =>
new Promise((resolve) => {
finishRefresh = resolve;
});
const first = service.startRefresh('nodejs');
assert.equal(first.accepted, true);
assert.equal(service.getActiveRefreshJob().id, first.job.id);
assert.equal(service.startRefresh('nodejs').job.id, first.job.id);
assert.equal(service.startRefresh('python3').accepted, false);
finishRefresh();
await new Promise(setImmediate);
assert.equal(first.job.state, 'completed');
assert.equal(service.getActiveRefreshJob(), undefined);
const second = service.startRefresh('python3');
assert.equal(second.accepted, true);
finishRefresh();
await new Promise(setImmediate);
assert.equal(second.job.state, 'completed');
});
+62
View File
@@ -0,0 +1,62 @@
require('reflect-metadata');
const assert = require('node:assert/strict');
const test = require('node:test');
const { randomUUID } = require('node:crypto');
const express = require('express');
const { errors } = require('celebrate');
const { Container } = require('typedi');
const load = require('../helpers/load-security-module.cjs');
test('dependency refresh API starts one job and exposes its status', async (t) => {
const job = { id: randomUUID(), type: 'nodejs', state: 'running' };
class DependenceService {
startRefresh(type) {
return { job, accepted: type === job.type };
}
getRefreshJob(id) {
return id === job.id ? job : undefined;
}
getActiveRefreshJob() {
return job.state === 'running' ? job : undefined;
}
}
Container.set(DependenceService, new DependenceService());
t.after(() => Container.remove(DependenceService));
const api = load('back/api/dependence.ts', {
'../services/dependence': { __esModule: true, default: DependenceService },
});
const app = express();
app.use(express.json());
api.default(app);
app.use(errors());
const server = app.listen(0, '127.0.0.1');
await new Promise((resolve) => server.once('listening', resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
const base = `http://127.0.0.1:${server.address().port}/dependencies/refresh`;
const start = (type) =>
fetch(base, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type }),
});
const started = await start('nodejs');
assert.equal(started.status, 200);
assert.deepEqual((await started.json()).data, job);
assert.equal((await start('python3')).status, 409);
assert.equal((await start('linux')).status, 400);
const active = await fetch(`${base}/active`);
assert.equal(active.headers.get('cache-control'), 'no-store');
assert.deepEqual((await active.json()).data, job);
const running = await fetch(`${base}/${job.id}`);
assert.equal(running.headers.get('cache-control'), 'no-store');
assert.equal((await running.json()).data.state, 'running');
job.state = 'completed';
assert.equal((await (await fetch(`${base}/active`)).json()).data, null);
assert.equal(
(await (await fetch(`${base}/${job.id}`)).json()).data.state,
'completed',
);
assert.equal((await fetch(`${base}/${randomUUID()}`)).status, 404);
});
+20 -3
View File
@@ -35,8 +35,15 @@ function serviceFor(docs) {
(!where.id || where.id.includes(d.id)),
),
update: async (values, { where }) => {
const ids = Array.isArray(where.id) ? where.id : [where.id];
docs
.filter((d) => where.id.includes(d.id))
.filter(
(d) =>
ids.includes(d.id) &&
(where.status === undefined || d.status === where.status) &&
(where.updatedAt === undefined ||
d.updatedAt === where.updatedAt),
)
.forEach((d) => Object.assign(d, values));
},
destroy: async ({ where }) => removed.push(...where.id),
@@ -63,7 +70,7 @@ function serviceFor(docs) {
return { service, probes, removed, uninstalls };
}
test('listing preserves Python source results and still detects missing named packages', async () => {
test('explicit refresh preserves Python sources and detects missing named packages', async () => {
const docs = [
' requirements with spaces/requirements.txt ',
'requirements-dev.in',
@@ -71,7 +78,13 @@ test('listing preserves Python source results and still detects missing named pa
'git+https://example.invalid/repo.git',
'https://example.invalid/pkg.whl',
'missing-package',
].map((name, i) => ({ id: i + 1, name, type: 1, status: 1 }));
].map((name, i) => ({
id: i + 1,
name,
type: 1,
status: 1,
updatedAt: new Date('2026-01-01T00:00:00Z'),
}));
const { service, probes } = serviceFor(docs);
for (let i = 0; i < 2; i++) {
await service.dependencies({
@@ -80,6 +93,10 @@ test('listing preserves Python source results and still detects missing named pa
status: '',
});
}
assert.deepEqual(probes, []);
const { job } = service.startRefresh('python3');
await new Promise(setImmediate);
assert.equal(job.state, 'completed');
assert.deepEqual(
docs.map((d) => d.status),
[1, 1, 1, 1, 1, 2],
+20
View File
@@ -115,7 +115,9 @@ for (const customData of [false, true]) {
);
for (const name of [
'token.json',
'TOKEN.JSON',
'grpc/client.key',
'Grpc/client.key',
'secret-link',
'data/scripts/secret-link',
'data/scripts/../config/token.json',
@@ -176,5 +178,23 @@ test('log APIs allow configuration-like names while retaining directory boundari
])
assert.equal((await invoke(method, route, values)).code, 403, filename);
}
const hidden = path.join(config.logPath, '.tmp', 'own_user.list');
fs.mkdirSync(path.dirname(hidden), { recursive: true });
fs.writeFileSync(hidden, 'hidden task command');
for (const values of [
{ path: '.tmp', file: 'own_user.list', filename: 'own_user.list' },
{ path: '.TMP', file: 'own_user.list', filename: 'own_user.list' },
{ path: '', file: '.tmp/own_user.list', filename: '.tmp/own_user.list' },
{ path: '', file: '.Tmp/own_user.list', filename: '.Tmp/own_user.list' },
]) {
for (const [method, route] of [
['get', '/detail'],
['post', '/download'],
['delete', '/'],
]) {
assert.equal((await invoke(method, route, values)).code, 403);
}
}
assert.equal(fs.readFileSync(hidden, 'utf8'), 'hidden task command');
assert.equal(fs.readFileSync(secret, 'utf8'), 'PANEL-SECRET');
});
+3
View File
@@ -24,11 +24,14 @@ test('file access enforces directory boundaries, blacklist descendants and symli
'../config-other/secret',
'/etc/passwd',
'grpc/client.key',
'GRPC/client.key',
'alias/client.key',
'outside/new.txt',
'dangling',
'auth.json',
'AUTH.JSON',
'nested/auth.json',
'nested/Auth.Json',
'',
]) {
assert.equal(
+250 -2
View File
@@ -13,6 +13,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
fs.mkdirSync(path.join(tmp, dir), { recursive: true });
fs.writeFileSync(path.join(tmp, 'config/grpc/client.key'), 'SENTINEL');
fs.writeFileSync(path.join(tmp, 'config/normal.txt'), 'normal');
fs.writeFileSync(path.join(tmp, 'config/config.sh'), 'original');
const secret = 'http-security-test';
const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 });
@@ -51,6 +52,10 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
scopes: ['configs'],
tokens: [{ value: 'config-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['configs:read'],
tokens: [{ value: 'config-read-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['configs', 'scripts'],
tokens: [
@@ -61,13 +66,55 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
scopes: ['scripts'],
tokens: [{ value: 'script-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['scripts:read'],
tokens: [{ value: 'script-read-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['envs'],
tokens: [{ value: 'env-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['envs:read'],
tokens: [{ value: 'env-read-app', expiration: Date.now() / 1000 + 3600 }],
},
{
name: 'external-system',
scopes: ['system'],
tokens: [{ value: 'system-app', expiration: Date.now() / 1000 + 3600 }],
},
{
name: 'system',
scopes: ['system'],
tokens: [{ value: 'internal-system', expiration: Date.now() / 1000 + 3600 }],
},
];
for (const scope of ['crons', 'subscriptions', 'dependencies', 'logs', 'dashboard']) {
for (const suffix of ['', ':read']) {
apps.push({
scopes: [`${scope}${suffix}`],
tokens: [{
value: `${scope}${suffix}-app`,
expiration: Date.now() / 1000 + 3600,
}],
});
}
}
let resetCalls = 0;
let commandCalls = 0;
let envWriteCalls = 0;
let scopedMutationCalls = 0;
let initialized = 0;
const User = class {};
const Script = class {
getFile(folder, filename) {
return fs.promises.readFile(path.join(config.scriptPath, folder, filename), 'utf8');
}
checkFilePath(folder, filename) {
return path.join(config.scriptPath, folder || '', filename);
}
};
const script = new Script();
const user = {
initializeUser: async () => {
initialized++;
@@ -90,7 +137,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
},
'../config/serverEnv': { serveEnv: (_req, res) => res.end() },
'../services/user': User,
'../services/script': class {},
'../services/script': Script,
'../data/open': {},
'../data/system': {},
typedi: { Service: () => (x) => x },
@@ -111,7 +158,8 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
mocks.typedi = {
...mocks.typedi,
Container: {
get: (x) => (x === User ? user : x === History ? history : configService),
get: (x) =>
x === User ? user : x === History ? history : x === Script ? script : configService,
},
};
mocks['../api'] = () => {
@@ -126,6 +174,41 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
historyCache,
).default(router);
router.get('/envs', (_req, res) => res.json({ code: 200 }));
router.post('/envs', (_req, res) => {
envWriteCalls++;
res.json({ code: 200 });
});
for (const route of [
'/crons', '/subscriptions', '/dependencies', '/logs', '/dashboard/overview',
]) {
router.get(route, (_req, res) => res.json({ code: 200 }));
}
for (const [method, route] of [
['put', '/crons/run'],
['put', '/subscriptions/run'],
['post', '/dependencies'],
['delete', '/logs'],
['post', '/dashboard/record'],
['get', '/crons/import'],
]) {
router[method](route, (_req, res) => {
scopedMutationCalls++;
res.json({ code: 200 });
});
}
router.post('/logs/download', (_req, res) =>
res.json({ code: 200, data: 'downloaded' }),
);
router.get('/system', (_req, res) => res.json({ code: 200 }));
router.put('/system/notify', (_req, res) => res.json({ code: 200 }));
router.put('/system/auth/reset', (_req, res) => {
resetCalls++;
res.json({ code: 200 });
});
router.put('/system/command-run', (_req, res) => {
commandCalls++;
res.json({ code: 200 });
});
return router;
};
const app = express();
@@ -203,6 +286,68 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
assert.equal((await request(`${prefix}/envs`)).status, 401, prefix);
}
assert.equal((await request('/open/envs', 'env-app')).body.code, 200);
assert.equal((await request('/open/envs', 'env-read-app')).body.code, 200);
for (const prefix of ['/open', '/panel/open']) {
assert.equal(
(await request(`${prefix}/envs`, 'env-read-app', 'POST', [
{ name: 'SAFE_VALUE', value: 'kept' },
])).status,
401,
);
}
assert.equal(envWriteCalls, 0);
assert.equal(
(await request('/open/envs', 'env-app', 'POST', [
{ name: 'SAFE_VALUE', value: 'kept' },
])).body.code,
200,
);
assert.equal(envWriteCalls, 1);
for (const [scope, readPath, method, writePath] of [
['crons', '/crons', 'PUT', '/crons/run'],
['subscriptions', '/subscriptions', 'PUT', '/subscriptions/run'],
['dependencies', '/dependencies', 'POST', '/dependencies'],
['logs', '/logs', 'DELETE', '/logs'],
['dashboard', '/dashboard/overview', 'POST', '/dashboard/record'],
]) {
for (const prefix of ['/open', '/panel/open']) {
assert.equal(
(await request(`${prefix}${readPath}`, `${scope}:read-app`)).body.code,
200,
);
assert.equal(
(await request(`${prefix}${writePath}`, `${scope}:read-app`, method))
.status,
401,
);
}
assert.equal(
(await request(`/open${writePath}`, `${scope}-app`, method)).body.code,
200,
);
}
assert.equal(scopedMutationCalls, 5);
for (const prefix of ['/open', '/panel/open']) {
assert.equal(
(await request(`${prefix}/crons/import`, 'crons:read-app')).status,
401,
);
assert.equal(
(await request(`${prefix}/logs/download`, 'logs:read-app', 'POST'))
.body.data,
'downloaded',
);
}
const importedWithHead = await fetch(
`http://127.0.0.1:${server.address().port}/open/crons/import`,
{ method: 'HEAD', headers: { Authorization: 'Bearer crons:read-app' } },
);
assert.equal(importedWithHead.status, 401);
assert.equal(
(await request('/open/crons/import', 'crons-app')).body.code,
200,
);
assert.equal(scopedMutationCalls, 6);
assert.equal(
(await request('/open/configs/detail?path=normal.txt', 'env-app')).status,
401,
@@ -212,6 +357,58 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
.data,
'normal',
);
assert.equal(
(await request('/open/configs/detail?path=normal.txt', 'config-read-app'))
.body.data,
'normal',
);
for (const prefix of ['/open', '/panel/open']) {
for (const name of ['normal.txt', 'config.sh', 'task_before.sh']) {
assert.equal(
(await request(`${prefix}/configs/save`, 'config-read-app', 'POST', {
name,
content: 'untrusted',
})).status,
401,
);
}
}
assert.equal(fs.readFileSync(path.join(tmp, 'config/config.sh'), 'utf8'), 'original');
assert.equal((await request('/open/system', 'system-app')).body.code, 200);
assert.equal(
(await request('/open/system/notify', 'system-app', 'PUT', {
title: 'title',
content: 'content',
})).body.code,
200,
);
for (const prefix of ['/open', '/panel/open']) {
assert.equal(
(await request(`${prefix}/system/auth/reset`, 'system-app', 'PUT', {
password: 'changed',
})).status,
401,
);
assert.equal(
(await request(`${prefix}/system/command-run`, 'system-app', 'PUT', {
command: 'id',
})).status,
401,
);
}
assert.equal(resetCalls, 0);
assert.equal(commandCalls, 0);
assert.equal(
(await request('/open/system/auth/reset', 'internal-system', 'PUT', {
retries: 0,
})).body.code,
200,
);
assert.equal(
(await request('/api/system/auth/reset', valid, 'PUT', { retries: 0 })).body.code,
200,
);
assert.equal(resetCalls, 2);
assert.equal(
(await request('/open/configs/detail?path=grpc/client.key', 'config-app'))
.body.code,
@@ -277,6 +474,14 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
).body.code,
200,
);
assert.equal(
(await request('/api/configs/save', valid, 'POST', {
name: 'config.sh',
content: 'owner config',
})).body.code,
200,
);
assert.equal(fs.readFileSync(path.join(tmp, 'config/config.sh'), 'utf8'), 'owner config');
assert.equal(
(
await request('/api/configs/save', valid, 'POST', {
@@ -296,6 +501,15 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
).status,
401,
);
assert.equal(
(
await request(`${prefix}/scripts`, 'script-read-app', 'POST', {
filename: 'test.js',
content: 'unauthorized',
})
).status,
401,
);
assert.equal(
(
await request(`${prefix}/scripts`, 'script-app', 'POST', {
@@ -310,4 +524,38 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
'authorized script',
);
}
assert.equal(
(await request('/open/scripts/detail?file=test.js', 'script-read-app')).body
.data,
'authorized script',
);
const downloaded = await fetch(
`http://127.0.0.1:${server.address().port}/open/scripts/download`,
{
method: 'POST',
headers: {
Authorization: 'Bearer script-read-app',
'Content-Type': 'application/json',
},
body: JSON.stringify({ filename: 'test.js' }),
},
);
assert.equal(downloaded.status, 200);
assert.equal(await downloaded.text(), 'authorized script');
for (const method of ['PUT', 'DELETE']) {
assert.equal(
(await request('/open/scripts', 'script-read-app', method, {
filename: 'test.js', content: 'unauthorized',
})).status,
401,
);
}
for (const path of ['run', 'stop', 'rename', 'history/restore']) {
assert.equal(
(await request(`/open/scripts/${path}`, 'script-read-app', 'PUT', {
filename: 'test.js', content: 'unauthorized',
})).status,
401,
);
}
});
-2
View File
@@ -273,8 +273,6 @@ test('Gitee workflows verify both mirrors and do not gate image publication', ()
for (const name of [
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
]) {
assert.equal(workflow.jobs[name].needs, 'build-static');
}
+1 -1
View File
@@ -220,7 +220,7 @@ test('npm publication consumes the exact archive whose isolated installation pas
const download = workflow.jobs.publish.steps.find((step) => step.uses?.startsWith('actions/download-artifact@'));
assert.equal(download.with.name, 'qinglong-npm-${{ github.sha }}');
const publication = workflow.jobs.publish.steps.find((step) => step.name === 'Publish npm package with OIDC');
assert.match(publication.run, /npm publish "\$archive" --ignore-scripts/);
assert.match(publication.run, /npm publish "\.\/\$archive" --ignore-scripts/);
assert.ok(!workflow.jobs.publish.steps.some((step) => /pnpm build/.test(step.run || '')));
});
+2 -4
View File
@@ -12,12 +12,10 @@ const publication = workflow.jobs.publish.steps.find(
(step) => step.name === 'Publish npm package with OIDC',
).run;
test('npm publication waits for every release image, including Python 3.10', () => {
test('npm publication waits for every supported release image', () => {
for (const name of [
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
])
assert.ok(workflow.jobs.publish.needs.includes(name), name);
});
@@ -77,7 +75,7 @@ case 'network': console.log('upstream unavailable'); process.exit(1);
assert.equal(fs.existsSync(path.join(root, 'published')), published);
if (published)
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'published'), 'utf8')), [
'npm-panel/verified.tgz', '--ignore-scripts', '--access', 'public', '--registry=https://registry.npmjs.org',
'./npm-panel/verified.tgz', '--ignore-scripts', '--access', 'public', '--registry=https://registry.npmjs.org',
]);
});
}
+10 -2
View File
@@ -24,8 +24,9 @@ test('startup closes interrupted instances without inventing exit codes or rewri
{ id: 4, status: 3, started_at: 100, finished_at: 130, exit_code: 7 },
]);
const systemConfig = { info: { lang: 'en-US' } };
const startupEvents = [];
const service = {
set_envs: async () => {},
set_envs: async () => { startupEvents.push('environment'); },
getAuthInfo: async () => ({}),
initializeSystemApp: async () => {},
refreshApps: async () => {},
@@ -70,7 +71,10 @@ test('startup closes interrupted instances without inventing exit codes or rewri
'../shared/i18n': { setLang() {} },
'./logger': {},
'../schedule/client': {
readiness: { configure() {}, recover: async () => {} },
readiness: {
configure() {},
recover: async () => { startupEvents.push('scheduler'); },
},
},
}).default;
// Dependency installation is unrelated to startup reconciliation.
@@ -82,6 +86,7 @@ test('startup closes interrupted instances without inventing exit codes or rewri
} finally {
global.setTimeout = originalTimeout;
}
assert.deepEqual(startupEvents, ['environment', 'scheduler']);
const rows = await instances.findAll({ order: [['id', 'ASC']], raw: true });
assert.equal(rows[0].status, 2);
assert.ok(
@@ -109,6 +114,9 @@ test('startup closes interrupted instances without inventing exit codes or rewri
} finally {
global.setTimeout = originalTimeout;
}
assert.deepEqual(startupEvents, [
'environment', 'scheduler', 'environment', 'scheduler',
]);
assert.deepEqual(
await instances.findAll({ order: [['id', 'ASC']], raw: true }),
rows,
-2
View File
@@ -225,8 +225,6 @@ test('static backfill uses existing snapshots and skips all source, build and im
'verify-gitee-mirrors',
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
]) {
assert.equal(workflow.jobs[name].needs, 'build-static');
assert.doesNotMatch(workflow.jobs[name].if || '', /always\(/);
+16
View File
@@ -397,6 +397,22 @@ for (const mutation of ['resetSecret', 'remove', 'update', 'create']) {
});
}
test('application mutations cannot change or expose the built-in system app', async (t) => {
const state = appsFixture(t);
for (const change of [
() => state.service.resetSecret(1),
() => state.service.update({ id: 1, name: 'system', scopes: ['crons'] }),
() => state.service.update({ id: 2, name: 'system', scopes: ['crons'] }),
() => state.service.remove([1, 2]),
]) {
await assert.rejects(change(), (error) => error.status === 403);
}
assert.equal(state.apps().length, 2);
assert.equal(state.apps()[0].client_secret, 'system-secret');
assert.equal(state.apps()[0].tokens[0].value, 'system-token');
assert.deepEqual(state.cached(), state.apps());
});
test('system initialization preserves active tokens while migrating dashboard scope', async (t) => {
const state = appsFixture(t);
await AppModel.update({ scopes: ['crons', 'system'] }, { where: { id: 1 } });
+146
View File
@@ -0,0 +1,146 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
const source = ts.createSourceFile(
'index.tsx',
fs.readFileSync(
path.join(__dirname, '../../src/pages/dependence/index.tsx'),
'utf8',
),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const names = new Set([
'scheduleRefresh',
'finishRefresh',
'attachRefresh',
'recoverRefresh',
'pollRefresh',
'startRefresh',
]);
const declarations = [];
function visit(node) {
if (ts.isVariableDeclaration(node) && names.has(node.name.getText(source))) {
declarations.push(`var ${node.getText(source)};`);
}
ts.forEachChild(node, visit);
}
visit(source);
function fixture() {
const requests = [];
const timers = new Map();
let timerId = 0;
const state = { refreshing: false, type: null, reloads: 0 };
const request = (url) => {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
requests.push({ url, resolve, reject });
return promise;
};
const context = {
config: { apiPrefix: '/api/' },
type: 'python3',
alive: { current: true },
activeRefreshId: { current: null },
refreshRequestVersion: { current: 0 },
refreshTimer: {},
request: { get: request, post: request },
setRefreshing: (value) => {
state.refreshing = value;
},
setRefreshingType: (value) => {
state.type = value;
},
setRefreshGeneration: () => {
state.reloads += 1;
},
message: { error() {}, warning() {} },
intl: { get: (value) => value },
setTimeout: (fn, delay) => {
timers.set(++timerId, { fn, delay });
return timerId;
},
clearTimeout: (id) => timers.delete(id),
};
vm.createContext(context);
vm.runInContext(
ts.transpile(declarations.join('\n'), { target: ts.ScriptTarget.ES2020 }),
context,
);
return { context, requests, timers, state };
}
const settle = () => new Promise(setImmediate);
const newJob = { id: 'new-job', type: 'python3' };
test('late recovery cannot replace a newly attached job or clear it as idle', async () => {
for (const data of [{ id: 'old-job', type: 'nodejs' }, null]) {
const f = fixture();
f.context.recoverRefresh(true, true);
f.context.attachRefresh(newJob);
f.requests[0].resolve({ code: 200, data });
await settle();
assert.equal(f.context.activeRefreshId.current, newJob.id);
assert.equal(f.state.refreshing, true);
assert.equal(f.state.reloads, 0);
assert.equal([...f.timers.values()][0].delay, 0);
}
});
test('late recovery failure cannot replace the new job poll timer', async () => {
const f = fixture();
f.context.recoverRefresh(false, true);
f.context.attachRefresh(newJob);
f.requests[0].reject(new Error('offline'));
await settle();
assert.equal(f.timers.size, 1);
assert.equal([...f.timers.values()][0].delay, 0);
});
test('a queued recovery retry is invalidated when a new job starts', async () => {
const f = fixture();
f.context.recoverRefresh(false, true);
f.requests[0].reject(new Error('offline'));
await settle();
const retry = [...f.timers.values()][0].fn;
f.context.startRefresh();
retry();
assert.equal(f.requests.length, 2);
f.requests[1].resolve({ code: 200, data: newJob });
await settle();
assert.equal(f.context.activeRefreshId.current, newJob.id);
});
test('starting a job invalidates recovery before the start response arrives', async () => {
const f = fixture();
f.context.recoverRefresh(false, true);
f.context.startRefresh();
f.requests[0].resolve({ code: 200, data: { id: 'old-job', type: 'nodejs' } });
await settle();
assert.equal(f.context.activeRefreshId.current, null);
f.requests[1].resolve({ code: 200, data: newJob });
await settle();
assert.equal(f.context.activeRefreshId.current, newJob.id);
});
test('current recovery still attaches and polls the running job', async () => {
const f = fixture();
f.context.recoverRefresh();
f.requests[0].resolve({ code: 200, data: newJob });
await settle();
[...f.timers.values()][0].fn();
assert.equal(f.requests[1].url, '/api/dependencies/refresh/new-job');
f.requests[1].resolve({ code: 200, data: { ...newJob, state: 'completed' } });
await settle();
assert.equal(f.state.refreshing, false);
assert.equal(f.state.reloads, 1);
});
+11 -8
View File
@@ -1,9 +1,12 @@
version: 2.24.0-dev.0
changeLogLink: https://github.com/whyour/qinglong/blob/develop/docs/releases/2.24.0-dev.0.md
publishTime: "2026-10-25 19:22:54"
version: 2.24.0
changeLogLink: https://github.com/whyour/qinglong/blob/v2.24.0/docs/releases/2.24.0.md
publishTime: "2026-10-29 02:14:58"
changeLog: |
1. 开发候选 2.24.0-dev.0,尚未正式发布;以 2.23.0 为上一正式版本
2. Alpine 与 Debian 镜像统一使用 Node.js 24,升级 sqlite3 至 1.1.4
3. Alpine 保留 arm/v6 和 arm/v7;Debian 暂不提供 arm/v7 镜像
4. ARM64 升级包在原生 runner 构建,避免 x64 QEMU 下的归档异常
5. 升级前备份完整数据,并在目标设备验证脚本、依赖和通知
1. Alpine 与 Debian 默认镜像统一使用 Node.js 24,SQLite 依赖升级至 1.1.4
2. 新增 Node.js 原生部署的 Python venv 隔离支持,以及 Python Git URL、requirements、pyproject 依赖源
3. 新增通知审计日志,完善通知失败记录与配置提示
4. 升级包按平台构建并校验;支持在线暂存、离线安装、失败回退及保留数据迁移
5. 修复任务调度、额外规则、依赖安装、脚本发现和订阅同步等问题
6. 独立远程 CLI 更新至 0.1.3,最低 Node.js 版本为 22.12.0
7. Alpine 保留 arm/v6 和 arm/v7;Debian 暂不提供 arm/v7,Python 3.10 兼容镜像停止更新
8. 升级前备份完整数据,并在目标设备验证脚本、依赖、订阅和通知