@goodandready/dsh-cron 0.3.24 → 0.3.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api-actions.js +1 -317
- package/lib/api-collection.js +1 -250
- package/lib/api-helpers.js +1 -100
- package/lib/api-import-export.js +1 -155
- package/lib/api-item.js +1 -190
- package/lib/api-task-webhook.js +1 -247
- package/lib/api-webhook.js +1 -183
- package/lib/api.js +1 -178
- package/lib/artifacts.js +1 -200
- package/lib/chain-checkpoints.js +1 -223
- package/lib/client-src/00-open.js +62 -0
- package/lib/client-src/10-locales.js +484 -0
- package/lib/client-src/20-styles.js +64 -0
- package/lib/client-src/30-utils.js +248 -0
- package/lib/client-src/40-delivery-settings.js +96 -0
- package/lib/client-src/45-dag-graph-view.js +287 -0
- package/lib/client-src/46-live-logs-modal.js +108 -0
- package/lib/client-src/47-analytics-digest-modal.js +166 -0
- package/lib/client-src/48-timeline-heatmap-view.js +176 -0
- package/lib/client-src/50-cron-screen-state.js +447 -0
- package/lib/client-src/52-cron-screen-actions.js +330 -0
- package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
- package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
- package/lib/client-src/55-cron-screen-view.js +378 -0
- package/lib/client-src/60-modal-task-form-head.js +580 -0
- package/lib/client-src/62-modal-task-form-tail.js +466 -0
- package/lib/client-src/65-modal-dialogs.js +350 -0
- package/lib/client-src/70-settings-card.js +420 -0
- package/lib/client-src/80-sidebar-jobs.js +200 -0
- package/lib/client-src/90-close.js +200 -0
- package/lib/client.js +48 -3
- package/lib/commands.js +200 -0
- package/lib/durable-journal.js +1 -188
- package/lib/external-api.js +1 -74
- package/lib/index.js +17 -0
- package/lib/log-streamer.js +10 -2
- package/lib/metrics.js +1 -125
- package/lib/routes/api-actions.js +317 -0
- package/lib/routes/api-collection.js +250 -0
- package/lib/routes/api-helpers.js +100 -0
- package/lib/routes/api-import-export.js +155 -0
- package/lib/routes/api-item.js +191 -0
- package/lib/routes/api-task-webhook.js +247 -0
- package/lib/routes/api-webhook.js +183 -0
- package/lib/routes/api.js +178 -0
- package/lib/routes/external-api.js +74 -0
- package/lib/routes/index.js +11 -0
- package/lib/routes/metrics.js +125 -0
- package/lib/routes/routes.js +549 -0
- package/lib/routes.js +1 -549
- package/lib/runner-external.js +1 -352
- package/lib/runner-worktree.js +1 -90
- package/lib/runner.js +1 -881
- package/lib/runtimes/index.js +4 -0
- package/lib/runtimes/runner-external.js +352 -0
- package/lib/runtimes/runner-worktree.js +90 -0
- package/lib/runtimes/runner.js +881 -0
- package/lib/runtimes/runtimes.js +198 -0
- package/lib/runtimes.js +1 -198
- package/lib/scheduler/index.js +6 -0
- package/lib/scheduler/scheduler-assists.js +142 -0
- package/lib/scheduler/scheduler-completion.js +417 -0
- package/lib/scheduler/scheduler-execution.js +360 -0
- package/lib/scheduler/scheduler-lifecycle.js +157 -0
- package/lib/scheduler/scheduler-preflight.js +195 -0
- package/lib/scheduler/scheduler.js +751 -0
- package/lib/scheduler-assists.js +1 -142
- package/lib/scheduler-completion.js +1 -417
- package/lib/scheduler-execution.js +1 -360
- package/lib/scheduler-lifecycle.js +1 -157
- package/lib/scheduler-preflight.js +1 -195
- package/lib/scheduler.js +1 -751
- package/lib/store/artifacts.js +200 -0
- package/lib/store/chain-checkpoints.js +223 -0
- package/lib/store/durable-journal.js +188 -0
- package/lib/store/index.js +6 -0
- package/lib/store/store.js +1179 -0
- package/lib/store/task-schema.js +180 -0
- package/lib/store/task-transfer.js +310 -0
- package/lib/store.js +2 -1179
- package/lib/task-schema.js +1 -180
- package/lib/task-transfer.js +1 -310
- package/lib/tui-settings.js +157 -0
- package/package.json +6 -3
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { logStreamer } from '../log-streamer.js';
|
|
2
|
+
import { applyTaskPatch } from '../task-patch.js';
|
|
3
|
+
import { MANAGED_BY_CONFIG } from '../config-jobs.js';
|
|
4
|
+
import { sendJson, rejectCrossOrigin, readBody, SCRIPT_CONFIRM_HEADER, redactTaskSecrets } from '../http-utils.js';
|
|
5
|
+
import { normalizeTaskType } from '../runtimes.js';
|
|
6
|
+
import { handleHeartbeatPing, handleDryRunTask } from './api-helpers.js';
|
|
7
|
+
import { handleItemPost } from './api-actions.js';
|
|
8
|
+
import { refuseConfigOwned, NOT_FOUND, NOT_ALLOWED } from './api-collection.js';
|
|
9
|
+
|
|
10
|
+
// ------------------------------------------------------------ single task
|
|
11
|
+
|
|
12
|
+
export async function handleTaskItem({ store, scheduler, req, res, url, id, action, subAction, apiToken, ctx }) {
|
|
13
|
+
if (action === 'heartbeat') {
|
|
14
|
+
handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (req.method === 'POST' && action === 'dry-run') {
|
|
19
|
+
await handleDryRunTask({ store, scheduler, req, res, id, apiToken, ctx });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (req.method === 'GET') {
|
|
24
|
+
// Both /dsh-cron/tasks/:id/stream and /dsh-cron/tasks/:id/logs/stream (#392)
|
|
25
|
+
if (action === 'stream' || (action === 'logs' && subAction === 'stream')) {
|
|
26
|
+
logStreamer.handleSseRequest(id, req, res);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
30
|
+
if (action === 'archive') {
|
|
31
|
+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
32
|
+
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
33
|
+
const search = url.searchParams.get('search') || '';
|
|
34
|
+
const result = typeof store.getArchivedRuns === 'function'
|
|
35
|
+
? store.getArchivedRuns(id, { limit, offset, search })
|
|
36
|
+
: { runs: [], total: 0 };
|
|
37
|
+
sendJson(res, 200, { ok: true, taskId: id, ...result });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (action === 'stats') {
|
|
41
|
+
const stats = typeof store.getTaskStats === 'function' ? store.getTaskStats(id) : null;
|
|
42
|
+
sendJson(res, 200, { ok: true, taskId: id, stats });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (action === 'prompt-history') {
|
|
46
|
+
const task = store.get(id);
|
|
47
|
+
if (!task) {
|
|
48
|
+
sendJson(res, 404, NOT_FOUND);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
sendJson(res, 200, {
|
|
52
|
+
ok: true,
|
|
53
|
+
taskId: id,
|
|
54
|
+
promptVersion: task.promptVersion || 1,
|
|
55
|
+
promptHistory: task.promptHistory || [],
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (action === 'history') {
|
|
60
|
+
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
|
61
|
+
sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (action === 'git-diff') {
|
|
65
|
+
const task = store.get(id);
|
|
66
|
+
if (!task) {
|
|
67
|
+
sendJson(res, 404, NOT_FOUND);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const runs = store.getHistory(id, 10);
|
|
71
|
+
const runId = url.searchParams.get('runId') || subAction;
|
|
72
|
+
const targetRun = runId ? runs.find((r) => r.id === runId) : runs[0];
|
|
73
|
+
const diffData = targetRun?.gitDiff || null;
|
|
74
|
+
sendJson(res, 200, {
|
|
75
|
+
ok: true,
|
|
76
|
+
taskId: id,
|
|
77
|
+
runId: targetRun?.id || null,
|
|
78
|
+
gitDiff: diffData,
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (action === 'doctor') {
|
|
83
|
+
const task = store.get(id);
|
|
84
|
+
if (!task) {
|
|
85
|
+
sendJson(res, 404, NOT_FOUND);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const runs = store.getHistory(id, 5);
|
|
89
|
+
const latestRun = runs[0] || null;
|
|
90
|
+
const reportRun = runs.find((r) => r.doctorReport) || latestRun;
|
|
91
|
+
sendJson(res, 200, {
|
|
92
|
+
ok: true,
|
|
93
|
+
taskId: id,
|
|
94
|
+
doctorReport: reportRun?.doctorReport || null,
|
|
95
|
+
revisions: task.revisions || [],
|
|
96
|
+
previousPrompt: task.previousPrompt || null,
|
|
97
|
+
autoHeal: Boolean(task.autoHeal || task.selfHealing?.autoApply),
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!action) {
|
|
102
|
+
const task = store.get(id);
|
|
103
|
+
if (!task) {
|
|
104
|
+
sendJson(res, 404, NOT_FOUND);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
sendJson(res, 200, {
|
|
108
|
+
ok: true,
|
|
109
|
+
task: {
|
|
110
|
+
...redactTaskSecrets(task),
|
|
111
|
+
running: scheduler.isRunning(task.id),
|
|
112
|
+
runningSince: scheduler.runningSince(task.id),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (req.method === 'POST') {
|
|
119
|
+
const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, subAction });
|
|
120
|
+
if (handled) return;
|
|
121
|
+
}
|
|
122
|
+
if (req.method === 'PATCH') {
|
|
123
|
+
await patchTask({ store, scheduler, req, res, id });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (req.method === 'DELETE' || (req.method === 'POST' && action === 'delete')) {
|
|
127
|
+
deleteTask({ store, scheduler, res, id });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
sendJson(res, 405, NOT_ALLOWED);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Task actions that are plain POSTs. Returns true when one matched. */
|
|
134
|
+
|
|
135
|
+
export async function patchTask({ store, scheduler, req, res, id }) {
|
|
136
|
+
const current = store.get(id);
|
|
137
|
+
if (!current) {
|
|
138
|
+
sendJson(res, 404, NOT_FOUND);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (current.managedBy === MANAGED_BY_CONFIG) {
|
|
142
|
+
refuseConfigOwned(res, id);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const { body, error } = await readBody(req, res);
|
|
146
|
+
if (error) return;
|
|
147
|
+
const result = applyTaskPatch({
|
|
148
|
+
store,
|
|
149
|
+
scheduler,
|
|
150
|
+
id,
|
|
151
|
+
body,
|
|
152
|
+
allowCodeSwitch: req.headers[SCRIPT_CONFIRM_HEADER] === 'script',
|
|
153
|
+
});
|
|
154
|
+
if (!result.ok) {
|
|
155
|
+
const status = result.status || (result.notFound ? 404 : (result.needsConfirmation ? 403 : 400));
|
|
156
|
+
const error = result.needsConfirmation
|
|
157
|
+
? `Switching a task to the ${normalizeTaskType(body.type)} type over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`
|
|
158
|
+
: result.error;
|
|
159
|
+
sendJson(res, status, {
|
|
160
|
+
ok: false,
|
|
161
|
+
error,
|
|
162
|
+
...(result.unknownChannels ? { unknownChannels: result.unknownChannels } : {}),
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
sendJson(res, 200, { ok: true, task: redactTaskSecrets(result.task) });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
export function deleteTask({ store, scheduler, res, id }) {
|
|
171
|
+
const current = store.get(id);
|
|
172
|
+
if (current && current.managedBy === MANAGED_BY_CONFIG) {
|
|
173
|
+
refuseConfigOwned(res, id);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (current) {
|
|
177
|
+
if (scheduler && typeof scheduler.removeTask === 'function') {
|
|
178
|
+
scheduler.removeTask(id);
|
|
179
|
+
} else if (scheduler) {
|
|
180
|
+
scheduler.pauseTask(id);
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
store.delete(id);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
sendJson(res, 500, { ok: false, error: 'Failed to delete task: ' + err.message });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
sendJson(res, 200, { ok: true });
|
|
190
|
+
}
|
|
191
|
+
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/api-task-webhook.js — Incoming HTTP Webhook Triggers (#348).
|
|
3
|
+
*
|
|
4
|
+
* Implements POST /dsh-cron/webhook/:taskId and POST /dsh-cron/api/webhook/:taskId.
|
|
5
|
+
* Authenticates via HMAC SHA-256 signature (GitHub/Stripe style), per-task token,
|
|
6
|
+
* or master apiToken, applies debouncing, parses JSON/text payload, and triggers
|
|
7
|
+
* the task with variable interpolation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { logger } from '../logger.js';
|
|
12
|
+
import { sendJson } from '../http-utils.js';
|
|
13
|
+
|
|
14
|
+
const MAX_WEBHOOK_BYTES = 512 * 1024; // 512 KiB limit
|
|
15
|
+
const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
|
|
16
|
+
const lastWebhookRuns = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Constant-time string/buffer comparison.
|
|
20
|
+
*/
|
|
21
|
+
export function safeCompare(a, b) {
|
|
22
|
+
if (!a || !b) return false;
|
|
23
|
+
const bufA = Buffer.from(String(a), 'utf8');
|
|
24
|
+
const bufB = Buffer.from(String(b), 'utf8');
|
|
25
|
+
if (bufA.length !== bufB.length) return false;
|
|
26
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Verify HMAC SHA-256 signature from headers.
|
|
31
|
+
* Supports x-hub-signature-256 (sha256=...), x-signature-256, or x-dsh-cron-signature.
|
|
32
|
+
*/
|
|
33
|
+
export function verifyHmacSignature(rawBuffer, secret, signatureHeader) {
|
|
34
|
+
if (!secret || !signatureHeader) return false;
|
|
35
|
+
const expectedHex = crypto.createHmac('sha256', secret).update(rawBuffer).digest('hex');
|
|
36
|
+
const provided = String(signatureHeader).replace(/^sha256=/i, '').trim();
|
|
37
|
+
return safeCompare(expectedHex, provided);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Verify token from header or Bearer auth.
|
|
42
|
+
* Query parameter authentication is strictly rejected to prevent token leakage in proxy logs and browser history (#358).
|
|
43
|
+
*/
|
|
44
|
+
export function verifyToken(expectedToken, req, url) {
|
|
45
|
+
if (!expectedToken) return false;
|
|
46
|
+
const headers = req?.headers || {};
|
|
47
|
+
const headerToken = headers['x-dsh-cron-webhook-token'] || headers['x-webhook-token'];
|
|
48
|
+
const authHeader = headers['authorization'];
|
|
49
|
+
let bearerToken = '';
|
|
50
|
+
if (authHeader && /^Bearer\s+/i.test(authHeader)) {
|
|
51
|
+
bearerToken = authHeader.replace(/^Bearer\s+/i, '').trim();
|
|
52
|
+
}
|
|
53
|
+
const candidate = headerToken || bearerToken;
|
|
54
|
+
if (!candidate) return false;
|
|
55
|
+
return safeCompare(expectedToken, candidate);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read request body with raw Buffer preserved for signature verification.
|
|
60
|
+
*/
|
|
61
|
+
export function readRawBody(req, limit = MAX_WEBHOOK_BYTES) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let bytes = 0;
|
|
65
|
+
let settled = false;
|
|
66
|
+
|
|
67
|
+
const fail = (err) => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
reject(err);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
req.on('data', (chunk) => {
|
|
74
|
+
if (settled) return;
|
|
75
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
76
|
+
bytes += buf.length;
|
|
77
|
+
if (bytes > limit) {
|
|
78
|
+
fail(Object.assign(new Error('Webhook payload exceeds 512 KiB limit'), { statusCode: 413 }));
|
|
79
|
+
req.destroy();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
chunks.push(buf);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
req.on('end', () => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
const raw = chunks.length ? Buffer.concat(chunks) : Buffer.alloc(0);
|
|
89
|
+
let parsed = null;
|
|
90
|
+
const text = raw.toString('utf8').trim();
|
|
91
|
+
if (text) {
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(text);
|
|
94
|
+
} catch {
|
|
95
|
+
parsed = text;
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
parsed = {};
|
|
99
|
+
}
|
|
100
|
+
resolve({ raw, parsed });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
req.on('error', (err) => {
|
|
104
|
+
fail(Object.assign(err, { statusCode: 400 }));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Handle incoming webhook request for a task.
|
|
111
|
+
*/
|
|
112
|
+
export async function handleTaskWebhookRoute({ store, scheduler, req, res, taskId: explicitTaskId }) {
|
|
113
|
+
if (req.method !== 'POST') {
|
|
114
|
+
sendJson(res, 405, NOT_ALLOWED);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
119
|
+
let taskId = explicitTaskId;
|
|
120
|
+
if (!taskId) {
|
|
121
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
122
|
+
// /dsh-cron/webhook/:taskId or /dsh-cron/api/webhook/:taskId
|
|
123
|
+
const webhookIdx = parts.indexOf('webhook');
|
|
124
|
+
if (webhookIdx !== -1 && parts[webhookIdx + 1]) {
|
|
125
|
+
taskId = parts[webhookIdx + 1];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!taskId) {
|
|
130
|
+
sendJson(res, 400, { ok: false, error: 'Task ID is required in webhook URL path' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const task = store.get(taskId);
|
|
135
|
+
if (!task) {
|
|
136
|
+
sendJson(res, 404, { ok: false, error: `Task "${taskId}" not found` });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (task.status !== 'active') {
|
|
141
|
+
sendJson(res, 400, { ok: false, error: `Task "${taskId}" is not active (current status: ${task.status})` });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const isEnabled = Boolean(
|
|
146
|
+
task.webhookEnabled ||
|
|
147
|
+
(task.webhookTrigger && task.webhookTrigger.enabled)
|
|
148
|
+
);
|
|
149
|
+
if (!isEnabled) {
|
|
150
|
+
logger.warn(`[dsh-cron] Webhook received for task "${taskId}" but webhooks are not enabled for this task`);
|
|
151
|
+
sendJson(res, 403, { ok: false, error: `Webhooks are not enabled for task "${taskId}"` });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Read raw body
|
|
156
|
+
let bodyData;
|
|
157
|
+
try {
|
|
158
|
+
bodyData = await readRawBody(req);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
sendJson(res, err.statusCode || 400, { ok: false, error: err.message });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const { raw, parsed } = bodyData;
|
|
165
|
+
const headers = req.headers || {};
|
|
166
|
+
const settings = typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
167
|
+
|
|
168
|
+
// Authentication check (fail-closed)
|
|
169
|
+
const taskSecret = task.webhookSecret || (task.webhookTrigger && task.webhookTrigger.secret);
|
|
170
|
+
const taskToken = task.webhookToken || (task.webhookTrigger && task.webhookTrigger.token);
|
|
171
|
+
const masterToken = settings.apiToken;
|
|
172
|
+
|
|
173
|
+
let authenticated = false;
|
|
174
|
+
|
|
175
|
+
if (taskSecret) {
|
|
176
|
+
const sigHeader = headers['x-hub-signature-256'] || headers['x-signature-256'] || headers['x-dsh-cron-signature'];
|
|
177
|
+
if (sigHeader && verifyHmacSignature(raw, taskSecret, sigHeader)) {
|
|
178
|
+
authenticated = true;
|
|
179
|
+
} else {
|
|
180
|
+
logger.warn(`[dsh-cron] Invalid HMAC signature for webhook task "${taskId}"`);
|
|
181
|
+
sendJson(res, 401, { ok: false, error: 'Invalid HMAC signature' });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
} else if (taskToken) {
|
|
185
|
+
if (verifyToken(taskToken, req, url)) {
|
|
186
|
+
authenticated = true;
|
|
187
|
+
} else {
|
|
188
|
+
logger.warn(`[dsh-cron] Invalid webhook token for task "${taskId}"`);
|
|
189
|
+
sendJson(res, 401, { ok: false, error: 'Invalid webhook token' });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
} else if (masterToken) {
|
|
193
|
+
if (verifyToken(masterToken, req, url)) {
|
|
194
|
+
authenticated = true;
|
|
195
|
+
} else {
|
|
196
|
+
logger.warn(`[dsh-cron] Invalid API token for webhook task "${taskId}"`);
|
|
197
|
+
sendJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid token' });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
logger.warn(`[dsh-cron] Webhook task "${taskId}" rejected: no secret or token configured (fail-closed)`);
|
|
202
|
+
sendJson(res, 403, { ok: false, error: 'Webhook secret or token must be configured for security' });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Debounce check
|
|
207
|
+
const debounceMs = Math.max(0, Number(task.webhookDebounceMs || (task.webhookTrigger && task.webhookTrigger.debounceMs) || 0));
|
|
208
|
+
if (debounceMs > 0) {
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
const last = lastWebhookRuns.get(taskId) || 0;
|
|
211
|
+
if (now - last < debounceMs) {
|
|
212
|
+
logger.info(`[dsh-cron] Webhook for task "${taskId}" debounced (${now - last}ms < ${debounceMs}ms)`);
|
|
213
|
+
sendJson(res, 200, { ok: true, debounced: true, message: `Webhook debounced (${debounceMs}ms cooldown)` });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
lastWebhookRuns.set(taskId, now);
|
|
217
|
+
} else {
|
|
218
|
+
lastWebhookRuns.set(taskId, Date.now());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const query = Object.fromEntries(url.searchParams.entries());
|
|
222
|
+
const webhookPayload = {
|
|
223
|
+
body: parsed,
|
|
224
|
+
raw: raw.toString('utf8'),
|
|
225
|
+
headers,
|
|
226
|
+
query,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
logger.info(`[dsh-cron] Webhook accepted for task "${taskId}", triggering execution`);
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const runPromise = scheduler.triggerManualRun(taskId, {
|
|
233
|
+
webhook: webhookPayload,
|
|
234
|
+
triggeredBy: 'webhook',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
sendJson(res, 200, {
|
|
238
|
+
ok: true,
|
|
239
|
+
triggered: true,
|
|
240
|
+
taskId,
|
|
241
|
+
message: `Webhook received and task "${task.title}" triggered`,
|
|
242
|
+
});
|
|
243
|
+
} catch (runErr) {
|
|
244
|
+
logger.error(`[dsh-cron] Failed to trigger task "${taskId}" from webhook: ${runErr.message}`);
|
|
245
|
+
sendJson(res, 500, { ok: false, error: `Failed to trigger task: ${runErr.message}` });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { logger } from '../logger.js';
|
|
3
|
+
import { sendJson, readBody } from '../http-utils.js';
|
|
4
|
+
import { answerTelegramCallbackQuery, sendTelegramMessage, getDshDefaultTelegramCredentials } from '../telegram.js';
|
|
5
|
+
import { handleTelegramCommand } from '../telegram-commands.js';
|
|
6
|
+
import { bestEffort } from '../best-effort.js';
|
|
7
|
+
|
|
8
|
+
const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
|
|
9
|
+
const seenUpdateIds = new Set();
|
|
10
|
+
|
|
11
|
+
export async function handleTelegramWebhook({ store, scheduler, req, res }) {
|
|
12
|
+
if (req.method !== 'POST') {
|
|
13
|
+
sendJson(res, 405, NOT_ALLOWED);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const { body, error } = await readBody(req, res);
|
|
17
|
+
if (error || !body) return;
|
|
18
|
+
|
|
19
|
+
const settings = typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
20
|
+
const defaults = getDshDefaultTelegramCredentials();
|
|
21
|
+
const botToken = settings.botToken || defaults.botToken;
|
|
22
|
+
const allowedChatId = String(settings.chatId || defaults.chatId || '').trim();
|
|
23
|
+
|
|
24
|
+
// Secret token verification (Issue #208) - fail-closed: secret MUST be configured
|
|
25
|
+
const webhookSecret = settings.telegramWebhookSecret || settings.webhookSecret || settings.secretToken || process.env.TELEGRAM_WEBHOOK_SECRET;
|
|
26
|
+
if (!webhookSecret) {
|
|
27
|
+
logger.warn('[dsh-cron] unauthorized telegram webhook: webhook secret is not configured (fail-closed)');
|
|
28
|
+
sendJson(res, 401, { ok: false, error: 'Unauthorized: webhook secret is not configured' });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const headers = req.headers || {};
|
|
32
|
+
const receivedSecret = headers['x-telegram-bot-api-secret-token'] || headers['X-Telegram-Bot-Api-Secret-Token'];
|
|
33
|
+
if (!receivedSecret || typeof receivedSecret !== 'string') {
|
|
34
|
+
logger.warn('[dsh-cron] unauthorized telegram webhook: missing secret token header');
|
|
35
|
+
sendJson(res, 401, { ok: false, error: 'Unauthorized: missing secret token' });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const secretBuf = Buffer.from(String(webhookSecret));
|
|
39
|
+
const receivedBuf = Buffer.from(String(receivedSecret));
|
|
40
|
+
if (secretBuf.length !== receivedBuf.length || !crypto.timingSafeEqual(secretBuf, receivedBuf)) {
|
|
41
|
+
logger.warn('[dsh-cron] unauthorized telegram webhook: invalid secret token');
|
|
42
|
+
sendJson(res, 401, { ok: false, error: 'Unauthorized: invalid secret token' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Deduplicate updates by update_id (Issue #208)
|
|
47
|
+
if (body.update_id != null) {
|
|
48
|
+
if (seenUpdateIds.has(body.update_id)) {
|
|
49
|
+
sendJson(res, 200, { ok: true, duplicate: true });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
seenUpdateIds.add(body.update_id);
|
|
53
|
+
if (seenUpdateIds.size > 2000) {
|
|
54
|
+
const first = seenUpdateIds.values().next().value;
|
|
55
|
+
seenUpdateIds.delete(first);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 1. Text command message (#175)
|
|
60
|
+
const message = body.message;
|
|
61
|
+
if (message && typeof message.text === 'string' && message.text.trim().startsWith('/')) {
|
|
62
|
+
const fromId = String(message.from?.id || '');
|
|
63
|
+
const chatId = String(message.chat?.id || fromId);
|
|
64
|
+
// Fail-closed: empty allowedChatId denies control (Issue #208)
|
|
65
|
+
if (!allowedChatId || (chatId !== allowedChatId && fromId !== allowedChatId)) {
|
|
66
|
+
logger.warn(`[dsh-cron] unauthorized telegram command from chat ${chatId} / user ${fromId} (allowedChatId: ${allowedChatId || 'NOT_CONFIGURED'})`);
|
|
67
|
+
sendJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const result = await handleTelegramCommand({
|
|
71
|
+
store,
|
|
72
|
+
scheduler,
|
|
73
|
+
text: message.text,
|
|
74
|
+
chatId,
|
|
75
|
+
botToken,
|
|
76
|
+
});
|
|
77
|
+
sendJson(res, 200, result || { ok: true });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2. Inline callback query (#137)
|
|
82
|
+
const callbackQuery = body.callback_query;
|
|
83
|
+
if (!callbackQuery) {
|
|
84
|
+
sendJson(res, 200, { ok: true, ignored: true });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const queryId = callbackQuery.id;
|
|
89
|
+
const data = String(callbackQuery.data || '');
|
|
90
|
+
const fromId = String(callbackQuery.from?.id || '');
|
|
91
|
+
const chatId = String(callbackQuery.message?.chat?.id || fromId);
|
|
92
|
+
|
|
93
|
+
if (!allowedChatId || (chatId !== allowedChatId && fromId !== allowedChatId)) {
|
|
94
|
+
logger.warn(`[dsh-cron] unauthorized telegram callback from chat ${chatId} / user ${fromId} (allowedChatId: ${allowedChatId || 'NOT_CONFIGURED'})`);
|
|
95
|
+
if (botToken) {
|
|
96
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Unauthorized', showAlert: true });
|
|
97
|
+
}
|
|
98
|
+
sendJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const parts = data.split(':');
|
|
103
|
+
if (parts[0] !== 'cron' || parts.length < 3) {
|
|
104
|
+
if (botToken) {
|
|
105
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Unknown action' });
|
|
106
|
+
}
|
|
107
|
+
sendJson(res, 200, { ok: true, ignored: true });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const verb = parts[1];
|
|
112
|
+
const taskId = parts.slice(2).join(':');
|
|
113
|
+
const task = store.get(taskId);
|
|
114
|
+
|
|
115
|
+
if (!task) {
|
|
116
|
+
if (botToken) {
|
|
117
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Task not found', showAlert: true });
|
|
118
|
+
}
|
|
119
|
+
sendJson(res, 200, { ok: false, error: 'Task not found' });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const isConfigTask = task.source === 'config' || task.origin === 'config' || task.readOnly || task.managedBy === 'config';
|
|
124
|
+
if ((verb === 'pause' || verb === 'resume') && isConfigTask) {
|
|
125
|
+
if (botToken) {
|
|
126
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Cannot modify config-owned task', showAlert: true });
|
|
127
|
+
}
|
|
128
|
+
sendJson(res, 400, { ok: false, error: 'Cannot modify config-owned task' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (verb === 'run') {
|
|
133
|
+
try {
|
|
134
|
+
await scheduler.triggerManualRun(taskId);
|
|
135
|
+
if (botToken) {
|
|
136
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `🚀 Run triggered: ${task.title}` });
|
|
137
|
+
}
|
|
138
|
+
} catch (runErr) {
|
|
139
|
+
if (botToken) {
|
|
140
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `Run error: ${runErr.message}`, showAlert: true });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} else if (verb === 'pause') {
|
|
144
|
+
const isPaused = task.status === 'paused';
|
|
145
|
+
if (isPaused) {
|
|
146
|
+
scheduler.resumeTask(taskId);
|
|
147
|
+
if (botToken) {
|
|
148
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `▶️ Resumed: ${task.title}` });
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
scheduler.pauseTask(taskId, 'Paused via Telegram button');
|
|
152
|
+
if (botToken) {
|
|
153
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `⏸ Paused: ${task.title}` });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else if (verb === 'log') {
|
|
157
|
+
const history = store.getHistory(taskId, 1);
|
|
158
|
+
const lastRun = history && history[0];
|
|
159
|
+
if (!lastRun) {
|
|
160
|
+
if (botToken) {
|
|
161
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `No runs recorded for ${task.title}`, showAlert: true });
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
const summary = `[${task.title}] Last run: ${lastRun.status} (${lastRun.durationMs}ms)\n${lastRun.output || lastRun.error || 'Empty output'}`;
|
|
165
|
+
if (botToken) {
|
|
166
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: summary.slice(0, 190) });
|
|
167
|
+
if (summary.length > 190 && chatId) {
|
|
168
|
+
await sendTelegramMessage({
|
|
169
|
+
botToken,
|
|
170
|
+
chatId,
|
|
171
|
+
text: `*Full Output for ${task.title}:*\n\`\`\`\n${summary.slice(0, 3000)}\n\`\`\``,
|
|
172
|
+
parseMode: 'Markdown',
|
|
173
|
+
}).catch(() => {
|
|
174
|
+
bestEffort('telegram-full-output', () => {}, scheduler?.logger);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
sendJson(res, 200, { ok: true, verb, taskId });
|
|
182
|
+
}
|
|
183
|
+
|