@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
package/lib/api-webhook.js
CHANGED
|
@@ -1,183 +1 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
1
|
+
export * from './routes/api-webhook.js';
|
package/lib/api.js
CHANGED
|
@@ -1,178 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { sendJson, rejectCrossOrigin, redactTaskSecrets } from './http-utils.js';
|
|
3
|
-
import {
|
|
4
|
-
handleTaskExport,
|
|
5
|
-
handleTaskImport,
|
|
6
|
-
applyImportPlan,
|
|
7
|
-
} from './api-import-export.js';
|
|
8
|
-
import { handleTelegramWebhook } from './api-webhook.js';
|
|
9
|
-
import {
|
|
10
|
-
handleHeartbeatPing,
|
|
11
|
-
handleSchedulePreview,
|
|
12
|
-
handleDryRunTask,
|
|
13
|
-
handleScheduleTimeline,
|
|
14
|
-
} from './api-helpers.js';
|
|
15
|
-
import {
|
|
16
|
-
refuseConfigOwned,
|
|
17
|
-
handleTaskCollection,
|
|
18
|
-
createOrUpdateTask,
|
|
19
|
-
NOT_ALLOWED,
|
|
20
|
-
NOT_FOUND,
|
|
21
|
-
} from './api-collection.js';
|
|
22
|
-
import { handleTaskItem, deleteTask } from './api-item.js';
|
|
23
|
-
import { handleItemPost } from './api-actions.js';
|
|
24
|
-
|
|
25
|
-
export {
|
|
26
|
-
handleTaskExport,
|
|
27
|
-
handleTaskImport,
|
|
28
|
-
applyImportPlan,
|
|
29
|
-
handleTelegramWebhook,
|
|
30
|
-
handleHeartbeatPing,
|
|
31
|
-
handleSchedulePreview,
|
|
32
|
-
handleDryRunTask,
|
|
33
|
-
handleScheduleTimeline,
|
|
34
|
-
refuseConfigOwned,
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
|
|
39
|
-
* route families — the #66 alias is kept alive by the same code path instead
|
|
40
|
-
* of a duplicated handler.
|
|
41
|
-
*
|
|
42
|
-
* IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
|
|
43
|
-
* with one, the core degrades matching to exact-path equality and every
|
|
44
|
-
* deeper path unrouted (#96). Exported for unit tests.
|
|
45
|
-
*/
|
|
46
|
-
export function createCronApiHandler(store, scheduler, collection) {
|
|
47
|
-
const ctx = collection?.ctx;
|
|
48
|
-
return async function handleCronApi(req, res) {
|
|
49
|
-
try {
|
|
50
|
-
const apiToken = store?.getSettings?.()?.apiToken;
|
|
51
|
-
const authOptions = { apiToken, ctx };
|
|
52
|
-
const url = new URL(req.url, 'http://127.0.0.1');
|
|
53
|
-
const parts = url.pathname.split('/').filter(Boolean);
|
|
54
|
-
const scope = parts[1]; // 'tasks' | 'action' | 'heartbeat-ping'
|
|
55
|
-
const id = parts[2];
|
|
56
|
-
const action = parts[3];
|
|
57
|
-
const subAction = parts[4];
|
|
58
|
-
|
|
59
|
-
if (scope === 'telegram' && id === 'webhook') {
|
|
60
|
-
await handleTelegramWebhook({ store, scheduler, req, res });
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
if (scope === 'webhook' && id) {
|
|
64
|
-
await handleTaskWebhookRoute({ store, scheduler, req, res, taskId: id });
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if ((scope === 'heartbeat' || scope === 'heartbeat-ping') && id) {
|
|
69
|
-
handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (scope === 'schedule' && id === 'preview') {
|
|
74
|
-
await handleSchedulePreview({ req, res, url, store, apiToken, ctx });
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
if (scope === 'schedule' && id === 'timeline-24h') {
|
|
78
|
-
await handleScheduleTimeline({ req, res, url, store, scheduler, apiToken, ctx });
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (!id) {
|
|
83
|
-
await handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken, ctx });
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
88
|
-
|
|
89
|
-
// Export/import are collection operations that live under /tasks/:id/…,
|
|
90
|
-
// so they are matched before the generic id handling below.
|
|
91
|
-
if (id === 'export') {
|
|
92
|
-
if (req.method !== 'GET') {
|
|
93
|
-
sendJson(res, 405, NOT_ALLOWED);
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
handleTaskExport({ store, req, res });
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
if (id === 'import') {
|
|
100
|
-
if (req.method !== 'POST') {
|
|
101
|
-
sendJson(res, 405, NOT_ALLOWED);
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
await handleTaskImport({ store, scheduler, req, res });
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
await handleTaskItem({ store, scheduler, req, res, url, id, action, subAction, apiToken, ctx });
|
|
109
|
-
} catch (err) {
|
|
110
|
-
sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// --------------------------------------------------------- external surface
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Task operations for the token-guarded /dsh-cron/api/* prefix (#54). The shape
|
|
119
|
-
* is deliberately small — list, read, create or update, delete, manual run —
|
|
120
|
-
* and reuses the handlers above, so the confirmation gate for code-executing
|
|
121
|
-
* tasks and the config-owned refusals are the same code, not a second copy.
|
|
122
|
-
*/
|
|
123
|
-
export async function handleExternalTaskRequest({ store, scheduler, req, res, url, apiToken }) {
|
|
124
|
-
const parts = url.pathname.split('/').filter(Boolean); // dsh-cron, api, [tasks|heartbeat|schedule], :id?, :action?
|
|
125
|
-
if (parts[2] === 'webhook' && parts[3]) {
|
|
126
|
-
await handleTaskWebhookRoute({ store, scheduler, req, res, taskId: parts[3] });
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
if (parts[2] === 'heartbeat' && parts[3]) {
|
|
130
|
-
handleHeartbeatPing({ store, req, res, taskId: parts[3], apiToken });
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
if (parts[2] === 'schedule' && parts[3] === 'preview') {
|
|
134
|
-
await handleSchedulePreview({ req, res, url, store, apiToken });
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
const id = parts[3];
|
|
138
|
-
const action = parts[4];
|
|
139
|
-
const subAction = parts[5];
|
|
140
|
-
|
|
141
|
-
if (!id) {
|
|
142
|
-
if (req.method === 'GET') {
|
|
143
|
-
const list = store
|
|
144
|
-
.list({ status: url.searchParams.get('status') || 'all', query: url.searchParams.get('query') || '' })
|
|
145
|
-
.map((task) => ({ ...redactTaskSecrets(task), running: scheduler.isRunning(task.id) }));
|
|
146
|
-
sendJson(res, 200, { ok: true, tasks: list });
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
if (req.method === 'POST') {
|
|
150
|
-
await createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx: undefined });
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
sendJson(res, 405, NOT_ALLOWED);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (req.method === 'POST') {
|
|
158
|
-
const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, subAction });
|
|
159
|
-
if (handled) return;
|
|
160
|
-
}
|
|
161
|
-
if (req.method === 'GET' && !action) {
|
|
162
|
-
const task = store.get(id);
|
|
163
|
-
if (!task) {
|
|
164
|
-
sendJson(res, 404, NOT_FOUND);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
sendJson(res, 200, { ok: true, task: { ...redactTaskSecrets(task), running: scheduler.isRunning(id) } });
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
if (req.method === 'DELETE' && !action) {
|
|
171
|
-
deleteTask({ store, scheduler, res, id });
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
sendJson(res, 405, NOT_ALLOWED);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// webhook and helper endpoints moved to ./api-webhook.js and ./api-helpers.js
|
|
178
|
-
|
|
1
|
+
export * from './routes/api.js';
|
package/lib/artifacts.js
CHANGED
|
@@ -1,200 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
-
|
|
5
|
-
export const SAFE_MIME_TYPES = {
|
|
6
|
-
'.csv': 'text/csv; charset=utf-8',
|
|
7
|
-
'.json': 'application/json; charset=utf-8',
|
|
8
|
-
'.txt': 'text/plain; charset=utf-8',
|
|
9
|
-
'.log': 'text/plain; charset=utf-8',
|
|
10
|
-
'.md': 'text/markdown; charset=utf-8',
|
|
11
|
-
'.pdf': 'application/pdf',
|
|
12
|
-
'.png': 'image/png',
|
|
13
|
-
'.jpg': 'image/jpeg',
|
|
14
|
-
'.jpeg': 'image/jpeg',
|
|
15
|
-
'.webp': 'image/webp',
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
export class ArtifactManager {
|
|
19
|
-
constructor(baseDir) {
|
|
20
|
-
this.baseDir = baseDir || path.join(process.cwd(), 'artifacts');
|
|
21
|
-
this.maxArtifactBytes = 10 * 1024 * 1024; // 10 MiB per artifact
|
|
22
|
-
this.defaultRetentionDays = 7;
|
|
23
|
-
this.maxTotalStorageBytes = 50 * 1024 * 1024; // 50 MiB total quota
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
ensureDir() {
|
|
27
|
-
if (!fs.existsSync(this.baseDir)) {
|
|
28
|
-
fs.mkdirSync(this.baseDir, { recursive: true });
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Sanitizes a requested filename to prevent path traversal (#321)
|
|
34
|
-
*/
|
|
35
|
-
sanitizeFilename(name) {
|
|
36
|
-
if (!name || typeof name !== 'string') return 'artifact.txt';
|
|
37
|
-
const clean = path.basename(name).replace(/[\0\r\n\\\/]/g, '_');
|
|
38
|
-
return clean || 'artifact.txt';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
getFilePath(artifactId, filename) {
|
|
42
|
-
if (!filename || filename.includes('..') || path.isAbsolute(filename)) {
|
|
43
|
-
throw new Error('Path traversal attempt detected in artifact storage');
|
|
44
|
-
}
|
|
45
|
-
const safeName = this.sanitizeFilename(filename);
|
|
46
|
-
const target = path.resolve(this.baseDir, `${artifactId}_${safeName}`);
|
|
47
|
-
// Security check: ensure path stays strictly within baseDir
|
|
48
|
-
if (!target.startsWith(path.resolve(this.baseDir) + path.sep)) {
|
|
49
|
-
throw new Error('Path traversal attempt detected in artifact storage');
|
|
50
|
-
}
|
|
51
|
-
return target;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Resolves safe MIME type for download/preview
|
|
56
|
-
*/
|
|
57
|
-
resolveMimeType(filename) {
|
|
58
|
-
const ext = path.extname(filename).toLowerCase();
|
|
59
|
-
// Neutralize dangerous web formats to prevent XSS / script execution
|
|
60
|
-
if (ext === '.html' || ext === '.htm' || ext === '.svg' || ext === '.js') {
|
|
61
|
-
return 'text/plain; charset=utf-8';
|
|
62
|
-
}
|
|
63
|
-
return SAFE_MIME_TYPES[ext] || 'application/octet-stream';
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Saves a new artifact file bound to a run (#321)
|
|
68
|
-
*/
|
|
69
|
-
saveArtifact(runId, taskId, filename, contentBuffer) {
|
|
70
|
-
this.ensureDir();
|
|
71
|
-
const buf = Buffer.isBuffer(contentBuffer) ? contentBuffer : Buffer.from(contentBuffer || '', 'utf8');
|
|
72
|
-
|
|
73
|
-
if (buf.length > this.maxArtifactBytes) {
|
|
74
|
-
throw new Error(`Artifact size (${buf.length} bytes) exceeds limit of ${this.maxArtifactBytes} bytes`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const artifactId = 'art_' + randomUUID().replace(/-/g, '').slice(0, 16);
|
|
78
|
-
const safeName = this.sanitizeFilename(filename);
|
|
79
|
-
const filePath = this.getFilePath(artifactId, safeName);
|
|
80
|
-
|
|
81
|
-
fs.writeFileSync(filePath, buf);
|
|
82
|
-
|
|
83
|
-
const sha256 = createHash('sha256').update(buf).digest('hex');
|
|
84
|
-
const mimeType = this.resolveMimeType(safeName);
|
|
85
|
-
|
|
86
|
-
return {
|
|
87
|
-
id: artifactId,
|
|
88
|
-
name: safeName,
|
|
89
|
-
sizeBytes: buf.length,
|
|
90
|
-
mimeType,
|
|
91
|
-
sha256,
|
|
92
|
-
runId,
|
|
93
|
-
taskId,
|
|
94
|
-
createdAt: Date.now(),
|
|
95
|
-
status: 'available',
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Reads an artifact file buffer
|
|
101
|
-
*/
|
|
102
|
-
readArtifact(artifactId, filename) {
|
|
103
|
-
const filePath = this.getFilePath(artifactId, filename);
|
|
104
|
-
if (!fs.existsSync(filePath)) {
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
return fs.readFileSync(filePath);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Generates a safe preview of the artifact content (first 64 KiB)
|
|
112
|
-
*/
|
|
113
|
-
generatePreview(artifactId, filename) {
|
|
114
|
-
const buf = this.readArtifact(artifactId, filename);
|
|
115
|
-
if (!buf) return { ok: false, error: 'Artifact file not found on disk' };
|
|
116
|
-
|
|
117
|
-
const ext = path.extname(filename).toLowerCase();
|
|
118
|
-
const isText = ['.txt', '.csv', '.json', '.log', '.md'].includes(ext);
|
|
119
|
-
|
|
120
|
-
if (isText) {
|
|
121
|
-
const slice = buf.subarray(0, 64 * 1024);
|
|
122
|
-
return {
|
|
123
|
-
ok: true,
|
|
124
|
-
kind: 'text',
|
|
125
|
-
mimeType: this.resolveMimeType(filename),
|
|
126
|
-
content: slice.toString('utf8'),
|
|
127
|
-
truncated: buf.length > slice.length,
|
|
128
|
-
sizeBytes: buf.length,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
ok: true,
|
|
134
|
-
kind: 'binary',
|
|
135
|
-
mimeType: this.resolveMimeType(filename),
|
|
136
|
-
sizeBytes: buf.length,
|
|
137
|
-
preview: `[Binary file: ${filename} (${buf.length} bytes)]`,
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Cleans up expired artifacts and enforces size quotas (#321)
|
|
143
|
-
*/
|
|
144
|
-
cleanupArtifacts(store, { retentionDays = this.defaultRetentionDays, maxTotalBytes = this.maxTotalStorageBytes } = {}) {
|
|
145
|
-
if (!fs.existsSync(this.baseDir)) return { removedCount: 0, freedBytes: 0 };
|
|
146
|
-
|
|
147
|
-
const cutoff = Date.now() - retentionDays * 86400000;
|
|
148
|
-
let files = fs.readdirSync(this.baseDir);
|
|
149
|
-
let totalBytes = 0;
|
|
150
|
-
const fileStats = [];
|
|
151
|
-
|
|
152
|
-
for (const f of files) {
|
|
153
|
-
const p = path.join(this.baseDir, f);
|
|
154
|
-
try {
|
|
155
|
-
const stat = fs.statSync(p);
|
|
156
|
-
totalBytes += stat.size;
|
|
157
|
-
fileStats.push({ path: p, filename: f, mtime: stat.mtimeMs, size: stat.size });
|
|
158
|
-
} catch (e) {
|
|
159
|
-
// file unlinked concurrently
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
let removedCount = 0;
|
|
164
|
-
let freedBytes = 0;
|
|
165
|
-
|
|
166
|
-
// 1. Remove files older than retentionDays
|
|
167
|
-
for (const item of fileStats) {
|
|
168
|
-
if (item.mtime < cutoff) {
|
|
169
|
-
try {
|
|
170
|
-
fs.unlinkSync(item.path);
|
|
171
|
-
removedCount++;
|
|
172
|
-
freedBytes += item.size;
|
|
173
|
-
totalBytes -= item.size;
|
|
174
|
-
} catch (e) {
|
|
175
|
-
void e;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// 2. Enforce quota: remove oldest files if totalBytes > maxTotalBytes
|
|
181
|
-
if (totalBytes > maxTotalBytes) {
|
|
182
|
-
fileStats.sort((a, b) => a.mtime - b.mtime);
|
|
183
|
-
for (const item of fileStats) {
|
|
184
|
-
if (totalBytes <= maxTotalBytes) break;
|
|
185
|
-
if (fs.existsSync(item.path)) {
|
|
186
|
-
try {
|
|
187
|
-
fs.unlinkSync(item.path);
|
|
188
|
-
removedCount++;
|
|
189
|
-
freedBytes += item.size;
|
|
190
|
-
totalBytes -= item.size;
|
|
191
|
-
} catch (e) {
|
|
192
|
-
void e;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return { removedCount, freedBytes };
|
|
199
|
-
}
|
|
200
|
-
}
|
|
1
|
+
export * from './store/artifacts.js';
|