@actuate-media/cms-core 0.32.0 → 0.34.0
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/dist/__tests__/api/form-webhooks.test.d.ts +2 -0
- package/dist/__tests__/api/form-webhooks.test.d.ts.map +1 -0
- package/dist/__tests__/api/form-webhooks.test.js +162 -0
- package/dist/__tests__/api/form-webhooks.test.js.map +1 -0
- package/dist/__tests__/cron/cron.test.js +19 -0
- package/dist/__tests__/cron/cron.test.js.map +1 -1
- package/dist/__tests__/forms/encryption.test.d.ts +2 -0
- package/dist/__tests__/forms/encryption.test.d.ts.map +1 -0
- package/dist/__tests__/forms/encryption.test.js +84 -0
- package/dist/__tests__/forms/encryption.test.js.map +1 -0
- package/dist/__tests__/forms/entries-export.test.d.ts +2 -0
- package/dist/__tests__/forms/entries-export.test.d.ts.map +1 -0
- package/dist/__tests__/forms/entries-export.test.js +69 -0
- package/dist/__tests__/forms/entries-export.test.js.map +1 -0
- package/dist/__tests__/forms/notify.test.d.ts +2 -0
- package/dist/__tests__/forms/notify.test.d.ts.map +1 -0
- package/dist/__tests__/forms/notify.test.js +114 -0
- package/dist/__tests__/forms/notify.test.js.map +1 -0
- package/dist/__tests__/forms/webhooks.test.d.ts +2 -0
- package/dist/__tests__/forms/webhooks.test.d.ts.map +1 -0
- package/dist/__tests__/forms/webhooks.test.js +203 -0
- package/dist/__tests__/forms/webhooks.test.js.map +1 -0
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +193 -2
- package/dist/api/handlers.js.map +1 -1
- package/dist/config/types.d.ts +11 -6
- package/dist/config/types.d.ts.map +1 -1
- package/dist/cron/index.d.ts +7 -0
- package/dist/cron/index.d.ts.map +1 -1
- package/dist/cron/index.js +15 -0
- package/dist/cron/index.js.map +1 -1
- package/dist/forms/encryption.d.ts +20 -0
- package/dist/forms/encryption.d.ts.map +1 -0
- package/dist/forms/encryption.js +80 -0
- package/dist/forms/encryption.js.map +1 -0
- package/dist/forms/entries.d.ts +12 -0
- package/dist/forms/entries.d.ts.map +1 -1
- package/dist/forms/entries.js +99 -3
- package/dist/forms/entries.js.map +1 -1
- package/dist/forms/index.d.ts +4 -1
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/index.js +4 -1
- package/dist/forms/index.js.map +1 -1
- package/dist/forms/notify.d.ts +37 -0
- package/dist/forms/notify.d.ts.map +1 -0
- package/dist/forms/notify.js +198 -0
- package/dist/forms/notify.js.map +1 -0
- package/dist/forms/submission.d.ts +21 -0
- package/dist/forms/submission.d.ts.map +1 -1
- package/dist/forms/submission.js +66 -7
- package/dist/forms/submission.js.map +1 -1
- package/dist/forms/webhooks.d.ts +54 -0
- package/dist/forms/webhooks.d.ts.map +1 -0
- package/dist/forms/webhooks.js +292 -0
- package/dist/forms/webhooks.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
const dbHolder = { current: {} };
|
|
3
|
+
vi.mock('../../db.js', () => ({ getDB: () => dbHolder.current }));
|
|
4
|
+
vi.mock('../../security/audit.js', () => ({ logEvent: vi.fn(async () => { }) }));
|
|
5
|
+
// Identity secret storage so the "encrypted" value equals the plaintext in tests.
|
|
6
|
+
vi.mock('../../security/secret-storage.js', () => ({
|
|
7
|
+
encryptSecret: async (s) => s,
|
|
8
|
+
decryptSecret: async (s) => s,
|
|
9
|
+
}));
|
|
10
|
+
const { SsrfBlockedError, safeFetchMock } = vi.hoisted(() => {
|
|
11
|
+
class SsrfBlockedError extends Error {
|
|
12
|
+
}
|
|
13
|
+
return { SsrfBlockedError, safeFetchMock: vi.fn() };
|
|
14
|
+
});
|
|
15
|
+
vi.mock('../../security/safe-fetch.js', () => ({
|
|
16
|
+
safeFetch: (...args) => safeFetchMock(...args),
|
|
17
|
+
SsrfBlockedError,
|
|
18
|
+
}));
|
|
19
|
+
import { createFormWebhook, listFormWebhooks, updateFormWebhook, deleteFormWebhook, dispatchFormWebhooks, testFormWebhook, FormWebhookError, } from '../../forms/webhooks.js';
|
|
20
|
+
import { signPayload } from '../../webhooks/index.js';
|
|
21
|
+
const ctx = { userId: 'u1', role: 'admin', locale: 'en', db: {} };
|
|
22
|
+
function fakeDb() {
|
|
23
|
+
const rows = [];
|
|
24
|
+
return {
|
|
25
|
+
rows,
|
|
26
|
+
formWebhook: {
|
|
27
|
+
create: async ({ data }) => {
|
|
28
|
+
const row = {
|
|
29
|
+
id: `wh_${rows.length + 1}`,
|
|
30
|
+
createdAt: new Date(),
|
|
31
|
+
updatedAt: new Date(),
|
|
32
|
+
lastDeliveryAt: null,
|
|
33
|
+
lastDeliveryStatus: null,
|
|
34
|
+
...data,
|
|
35
|
+
};
|
|
36
|
+
rows.push(row);
|
|
37
|
+
return row;
|
|
38
|
+
},
|
|
39
|
+
findMany: async ({ where }) => rows.filter((r) => r.formId === where.formId &&
|
|
40
|
+
(where.enabled === undefined || r.enabled === where.enabled)),
|
|
41
|
+
findUnique: async ({ where }) => rows.find((r) => r.id === where.id) ?? null,
|
|
42
|
+
update: async ({ where, data }) => {
|
|
43
|
+
const r = rows.find((x) => x.id === where.id);
|
|
44
|
+
Object.assign(r, data);
|
|
45
|
+
return r;
|
|
46
|
+
},
|
|
47
|
+
delete: async ({ where }) => {
|
|
48
|
+
const i = rows.findIndex((x) => x.id === where.id);
|
|
49
|
+
rows.splice(i, 1);
|
|
50
|
+
return {};
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function httpResponse(status) {
|
|
56
|
+
return { ok: status >= 200 && status < 300, status, text: async () => '' };
|
|
57
|
+
}
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
dbHolder.current = fakeDb();
|
|
60
|
+
safeFetchMock.mockReset();
|
|
61
|
+
});
|
|
62
|
+
describe('form webhook CRUD', () => {
|
|
63
|
+
it('creates a webhook, auto-generates a signing secret, and returns it once', async () => {
|
|
64
|
+
const wh = await createFormWebhook(ctx, 'form_1', { url: 'https://hooks.example.com/x' });
|
|
65
|
+
expect(wh.secret).toMatch(/^whsec_/);
|
|
66
|
+
expect(wh.events).toEqual(['form.submitted']);
|
|
67
|
+
expect(wh.enabled).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
it('masks the secret in list responses', async () => {
|
|
70
|
+
await createFormWebhook(ctx, 'form_1', { url: 'https://hooks.example.com/x', secret: 's3cret' });
|
|
71
|
+
const list = await listFormWebhooks('form_1');
|
|
72
|
+
expect(list).toHaveLength(1);
|
|
73
|
+
expect(list[0].secret).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
it('rejects SSRF / private webhook URLs', async () => {
|
|
76
|
+
await expect(createFormWebhook(ctx, 'form_1', { url: 'http://localhost:3000/hook' })).rejects.toBeInstanceOf(FormWebhookError);
|
|
77
|
+
await expect(createFormWebhook(ctx, 'form_1', { url: 'http://169.254.169.254/latest' })).rejects.toBeInstanceOf(FormWebhookError);
|
|
78
|
+
});
|
|
79
|
+
it('clamps retryCount and normalizes events on update', async () => {
|
|
80
|
+
const wh = await createFormWebhook(ctx, 'form_1', { url: 'https://hooks.example.com/x' });
|
|
81
|
+
const updated = await updateFormWebhook(ctx, wh.id, {
|
|
82
|
+
retryCount: 99,
|
|
83
|
+
events: ['form.submitted', 'bogus'],
|
|
84
|
+
});
|
|
85
|
+
expect(updated.retryCount).toBe(10);
|
|
86
|
+
expect(updated.events).toEqual(['form.submitted']);
|
|
87
|
+
});
|
|
88
|
+
it('deletes a webhook', async () => {
|
|
89
|
+
const wh = await createFormWebhook(ctx, 'form_1', { url: 'https://hooks.example.com/x' });
|
|
90
|
+
await deleteFormWebhook(ctx, wh.id);
|
|
91
|
+
expect(await listFormWebhooks('form_1')).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('dispatchFormWebhooks', () => {
|
|
95
|
+
it('delivers a signed payload to matching enabled webhooks', async () => {
|
|
96
|
+
safeFetchMock.mockResolvedValue(httpResponse(200));
|
|
97
|
+
await createFormWebhook(ctx, 'form_1', {
|
|
98
|
+
url: 'https://hooks.example.com/x',
|
|
99
|
+
secret: 'shh',
|
|
100
|
+
events: ['form.submitted'],
|
|
101
|
+
});
|
|
102
|
+
const res = await dispatchFormWebhooks('form_1', {
|
|
103
|
+
event: 'form.submitted',
|
|
104
|
+
formId: 'form_1',
|
|
105
|
+
submissionId: 'sub_1',
|
|
106
|
+
data: { name: 'Ada' },
|
|
107
|
+
});
|
|
108
|
+
expect(res).toEqual({ matched: 1, delivered: 1 });
|
|
109
|
+
expect(safeFetchMock).toHaveBeenCalledTimes(1);
|
|
110
|
+
const [url, opts] = safeFetchMock.mock.calls[0];
|
|
111
|
+
expect(url).toBe('https://hooks.example.com/x');
|
|
112
|
+
const expectedSig = await signPayload(opts.body, 'shh');
|
|
113
|
+
expect(opts.headers['X-Actuate-Signature']).toBe(expectedSig);
|
|
114
|
+
expect(opts.headers['X-Actuate-Event']).toBe('form.submitted');
|
|
115
|
+
expect(dbHolder.current.rows[0].lastDeliveryStatus).toBe('success');
|
|
116
|
+
});
|
|
117
|
+
it('ignores webhooks not subscribed to the event', async () => {
|
|
118
|
+
safeFetchMock.mockResolvedValue(httpResponse(200));
|
|
119
|
+
await createFormWebhook(ctx, 'form_1', {
|
|
120
|
+
url: 'https://hooks.example.com/x',
|
|
121
|
+
events: ['form.entry.starred'],
|
|
122
|
+
});
|
|
123
|
+
const res = await dispatchFormWebhooks('form_1', {
|
|
124
|
+
event: 'form.submitted',
|
|
125
|
+
formId: 'form_1',
|
|
126
|
+
});
|
|
127
|
+
expect(res.matched).toBe(0);
|
|
128
|
+
expect(safeFetchMock).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
it('does not deliver to disabled webhooks', async () => {
|
|
131
|
+
safeFetchMock.mockResolvedValue(httpResponse(200));
|
|
132
|
+
await createFormWebhook(ctx, 'form_1', {
|
|
133
|
+
url: 'https://hooks.example.com/x',
|
|
134
|
+
enabled: false,
|
|
135
|
+
});
|
|
136
|
+
const res = await dispatchFormWebhooks('form_1', { event: 'form.submitted', formId: 'form_1' });
|
|
137
|
+
expect(res.matched).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
it('stops retrying on a 4xx response', async () => {
|
|
140
|
+
safeFetchMock.mockResolvedValue(httpResponse(400));
|
|
141
|
+
await createFormWebhook(ctx, 'form_1', {
|
|
142
|
+
url: 'https://hooks.example.com/x',
|
|
143
|
+
retryCount: 3,
|
|
144
|
+
});
|
|
145
|
+
const res = await dispatchFormWebhooks('form_1', { event: 'form.submitted', formId: 'form_1' });
|
|
146
|
+
expect(res.delivered).toBe(0);
|
|
147
|
+
expect(safeFetchMock).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(dbHolder.current.rows[0].lastDeliveryStatus).toContain('failed');
|
|
149
|
+
});
|
|
150
|
+
it('retries on a 5xx response and succeeds with backoff', async () => {
|
|
151
|
+
// Fire backoff timers immediately so the test doesn't wait real seconds.
|
|
152
|
+
const timeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation(((fn) => {
|
|
153
|
+
fn();
|
|
154
|
+
return 0;
|
|
155
|
+
}));
|
|
156
|
+
try {
|
|
157
|
+
safeFetchMock
|
|
158
|
+
.mockResolvedValueOnce(httpResponse(503))
|
|
159
|
+
.mockResolvedValueOnce(httpResponse(200));
|
|
160
|
+
await createFormWebhook(ctx, 'form_1', {
|
|
161
|
+
url: 'https://hooks.example.com/x',
|
|
162
|
+
retryCount: 2,
|
|
163
|
+
});
|
|
164
|
+
const res = await dispatchFormWebhooks('form_1', {
|
|
165
|
+
event: 'form.submitted',
|
|
166
|
+
formId: 'form_1',
|
|
167
|
+
});
|
|
168
|
+
expect(res.delivered).toBe(1);
|
|
169
|
+
expect(safeFetchMock).toHaveBeenCalledTimes(2);
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
timeoutSpy.mockRestore();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
it('treats an SSRF block as a permanent failure (no retry)', async () => {
|
|
176
|
+
safeFetchMock.mockRejectedValue(new SsrfBlockedError('blocked'));
|
|
177
|
+
await createFormWebhook(ctx, 'form_1', {
|
|
178
|
+
url: 'https://hooks.example.com/x',
|
|
179
|
+
retryCount: 3,
|
|
180
|
+
});
|
|
181
|
+
const res = await dispatchFormWebhooks('form_1', { event: 'form.submitted', formId: 'form_1' });
|
|
182
|
+
expect(res.delivered).toBe(0);
|
|
183
|
+
expect(safeFetchMock).toHaveBeenCalledTimes(1);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe('testFormWebhook', () => {
|
|
187
|
+
it('sends a single sample delivery and records the result', async () => {
|
|
188
|
+
safeFetchMock.mockResolvedValue(httpResponse(200));
|
|
189
|
+
const wh = await createFormWebhook(ctx, 'form_1', {
|
|
190
|
+
url: 'https://hooks.example.com/x',
|
|
191
|
+
secret: 'shh',
|
|
192
|
+
});
|
|
193
|
+
const result = await testFormWebhook(ctx, wh.id);
|
|
194
|
+
expect(result.ok).toBe(true);
|
|
195
|
+
expect(result.attempts).toBe(1);
|
|
196
|
+
const [, opts] = safeFetchMock.mock.calls[0];
|
|
197
|
+
expect(JSON.parse(opts.body).data._test).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
it('throws a 404 FormWebhookError for an unknown webhook', async () => {
|
|
200
|
+
await expect(testFormWebhook(ctx, 'missing')).rejects.toMatchObject({ status: 404 });
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
//# sourceMappingURL=webhooks.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhooks.test.js","sourceRoot":"","sources":["../../../src/__tests__/forms/webhooks.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AA+/B5C,iFAAiF;AACjF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AA8JD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAS9E;AAkQD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAkrUzD"}
|
package/dist/api/handlers.js
CHANGED
|
@@ -24,10 +24,12 @@ import { isAuthorizedCronRequest, processCleanup, processSeoScan } from '../cron
|
|
|
24
24
|
import { processContentHealthScan } from '../content-health/cron.js';
|
|
25
25
|
import { verifyCaptcha, getCaptchaConfig } from '../security/captcha.js';
|
|
26
26
|
import { listForms as listFormsService, getFormById as getFormByIdService, getFormBySlug as getFormBySlugService, createForm as createFormService, updateForm as updateFormService, duplicateForm as duplicateFormService, deleteForm as deleteFormService, saveFormSchema as saveFormSchemaService, getFormSchema as getFormSchemaService, getNotificationSettings as getFormNotificationSettingsService, updateNotificationSettings as updateFormNotificationSettingsService, setNotifyEnabled as setFormNotifyEnabledService, getEmbedSettings as getFormEmbedSettingsService, updateEmbedSettings as updateFormEmbedSettingsService, getFormStats as getFormStatsService, getFormsSidebarCounts as getFormsSidebarCountsService, FormNotFoundError, FormValidationError, FormConflictError, FormHasSubmissionsError, } from '../forms/service.js';
|
|
27
|
-
import { listEntries as listEntriesService, getEntryWithSchema as getEntryWithSchemaService, getEntryCounts as getEntryCountsService, updateEntry as updateEntryService, bulkUpdateEntries as bulkUpdateEntriesService, deleteEntry as deleteEntryService, } from '../forms/entries.js';
|
|
27
|
+
import { listEntries as listEntriesService, getEntryWithSchema as getEntryWithSchemaService, getEntryCounts as getEntryCountsService, updateEntry as updateEntryService, bulkUpdateEntries as bulkUpdateEntriesService, deleteEntry as deleteEntryService, exportFormEntries as exportFormEntriesService, } from '../forms/entries.js';
|
|
28
28
|
import { processSubmission, buildPublicForm, SubmissionValidationError, } from '../forms/submission.js';
|
|
29
29
|
import { validateSubmissionFiles, FORM_UPLOAD_ALLOWED_MIME_TYPES, DEFAULT_MAX_FILE_BYTES, } from '../forms/files.js';
|
|
30
30
|
import { resolveSubmissionFields } from '../forms/submission.js';
|
|
31
|
+
import { createFormWebhook, listFormWebhooks, updateFormWebhook, deleteFormWebhook, testFormWebhook, FormWebhookError, } from '../forms/webhooks.js';
|
|
32
|
+
import { sendFormNotifications } from '../forms/notify.js';
|
|
31
33
|
import { checkForUpdates } from '../upgrade/version-check.js';
|
|
32
34
|
import { createUpgradePR } from '../upgrade/upgrade-pr.js';
|
|
33
35
|
import { encryptField, decryptField } from '../security/encrypted-fields.js';
|
|
@@ -290,6 +292,8 @@ function mapFormError(err) {
|
|
|
290
292
|
if (err instanceof FormHasSubmissionsError) {
|
|
291
293
|
return json({ error: err.message, code: 'FORM_HAS_SUBMISSIONS', submissionCount: err.submissionCount }, 409);
|
|
292
294
|
}
|
|
295
|
+
if (err instanceof FormWebhookError)
|
|
296
|
+
return errorResponse(err.message, err.status);
|
|
293
297
|
return null;
|
|
294
298
|
}
|
|
295
299
|
function parseFormsQuery(url) {
|
|
@@ -4428,6 +4432,41 @@ export function registerCMSRoutes(router) {
|
|
|
4428
4432
|
return internalError(err);
|
|
4429
4433
|
}
|
|
4430
4434
|
});
|
|
4435
|
+
// Defined before `/forms/entries/:entryId` so "export" is not parsed as an id.
|
|
4436
|
+
router.get('/forms/entries/export', async (request) => {
|
|
4437
|
+
try {
|
|
4438
|
+
const auth = await requireAuth(request);
|
|
4439
|
+
if (auth.error)
|
|
4440
|
+
return auth.error;
|
|
4441
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4442
|
+
if (roleErr)
|
|
4443
|
+
return roleErr;
|
|
4444
|
+
const formId = new URL(request.url).searchParams.get('formId');
|
|
4445
|
+
if (!formId)
|
|
4446
|
+
return errorResponse('"formId" query parameter is required', 400);
|
|
4447
|
+
const result = await exportFormEntriesService(formId);
|
|
4448
|
+
if (!result)
|
|
4449
|
+
return errorResponse('Form not found', 404);
|
|
4450
|
+
// PII access audit: a bulk export of decrypted submission data.
|
|
4451
|
+
void logEvent({
|
|
4452
|
+
event: 'form.entries.exported',
|
|
4453
|
+
userId: auth.session.userId,
|
|
4454
|
+
ipAddress: getClientIp(request),
|
|
4455
|
+
details: { formId, count: result.count },
|
|
4456
|
+
});
|
|
4457
|
+
const filename = `${result.formSlug || 'form'}-entries.csv`;
|
|
4458
|
+
return new Response(result.csv, {
|
|
4459
|
+
status: 200,
|
|
4460
|
+
headers: {
|
|
4461
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
4462
|
+
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
4463
|
+
},
|
|
4464
|
+
});
|
|
4465
|
+
}
|
|
4466
|
+
catch (err) {
|
|
4467
|
+
return internalError(err);
|
|
4468
|
+
}
|
|
4469
|
+
});
|
|
4431
4470
|
router.get('/forms/entries/:entryId', async (request, params) => {
|
|
4432
4471
|
try {
|
|
4433
4472
|
const auth = await requireAuth(request);
|
|
@@ -4439,6 +4478,13 @@ export function registerCMSRoutes(router) {
|
|
|
4439
4478
|
const result = await getEntryWithSchemaService(params.entryId);
|
|
4440
4479
|
if (!result)
|
|
4441
4480
|
return errorResponse('Entry not found', 404);
|
|
4481
|
+
// PII access audit: viewing a single entry exposes its decrypted fields.
|
|
4482
|
+
void logEvent({
|
|
4483
|
+
event: 'form.entry.viewed',
|
|
4484
|
+
userId: auth.session.userId,
|
|
4485
|
+
ipAddress: getClientIp(request),
|
|
4486
|
+
details: { entryId: params.entryId, formId: result.entry.formId },
|
|
4487
|
+
});
|
|
4442
4488
|
return json({ data: result });
|
|
4443
4489
|
}
|
|
4444
4490
|
catch (err) {
|
|
@@ -4720,6 +4766,147 @@ export function registerCMSRoutes(router) {
|
|
|
4720
4766
|
return mapFormError(err) ?? internalError(err);
|
|
4721
4767
|
}
|
|
4722
4768
|
});
|
|
4769
|
+
// ── Per-form webhooks (CRUD + test) ──────────────────────────────────────
|
|
4770
|
+
router.get('/forms/:id/webhooks', async (request, params) => {
|
|
4771
|
+
try {
|
|
4772
|
+
const auth = await requireAuth(request);
|
|
4773
|
+
if (auth.error)
|
|
4774
|
+
return auth.error;
|
|
4775
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4776
|
+
if (roleErr)
|
|
4777
|
+
return roleErr;
|
|
4778
|
+
const form = await getFormByIdService(params.id);
|
|
4779
|
+
if (!form)
|
|
4780
|
+
return errorResponse('Form not found', 404);
|
|
4781
|
+
const webhooks = await listFormWebhooks(params.id);
|
|
4782
|
+
return json({ data: webhooks });
|
|
4783
|
+
}
|
|
4784
|
+
catch (err) {
|
|
4785
|
+
return mapFormError(err) ?? internalError(err);
|
|
4786
|
+
}
|
|
4787
|
+
});
|
|
4788
|
+
router.post('/forms/:id/webhooks', async (request, params) => {
|
|
4789
|
+
try {
|
|
4790
|
+
const auth = await requireAuth(request);
|
|
4791
|
+
if (auth.error)
|
|
4792
|
+
return auth.error;
|
|
4793
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4794
|
+
if (roleErr)
|
|
4795
|
+
return roleErr;
|
|
4796
|
+
const form = await getFormByIdService(params.id);
|
|
4797
|
+
if (!form)
|
|
4798
|
+
return errorResponse('Form not found', 404);
|
|
4799
|
+
const body = (await request.json().catch(() => ({})));
|
|
4800
|
+
if (!body.url || typeof body.url !== 'string') {
|
|
4801
|
+
return errorResponse('A webhook url is required', 400);
|
|
4802
|
+
}
|
|
4803
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4804
|
+
const webhook = await createFormWebhook(ctx, params.id, body);
|
|
4805
|
+
// The plaintext secret is returned exactly once here, on creation.
|
|
4806
|
+
return json({ data: webhook }, 201);
|
|
4807
|
+
}
|
|
4808
|
+
catch (err) {
|
|
4809
|
+
return mapFormError(err) ?? internalError(err);
|
|
4810
|
+
}
|
|
4811
|
+
});
|
|
4812
|
+
router.patch('/forms/:id/webhooks/:webhookId', async (request, params) => {
|
|
4813
|
+
try {
|
|
4814
|
+
const auth = await requireAuth(request);
|
|
4815
|
+
if (auth.error)
|
|
4816
|
+
return auth.error;
|
|
4817
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4818
|
+
if (roleErr)
|
|
4819
|
+
return roleErr;
|
|
4820
|
+
const body = (await request.json().catch(() => ({})));
|
|
4821
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4822
|
+
const webhook = await updateFormWebhook(ctx, params.webhookId, body);
|
|
4823
|
+
return json({ data: webhook });
|
|
4824
|
+
}
|
|
4825
|
+
catch (err) {
|
|
4826
|
+
return mapFormError(err) ?? internalError(err);
|
|
4827
|
+
}
|
|
4828
|
+
});
|
|
4829
|
+
router.delete('/forms/:id/webhooks/:webhookId', async (request, params) => {
|
|
4830
|
+
try {
|
|
4831
|
+
const auth = await requireAuth(request);
|
|
4832
|
+
if (auth.error)
|
|
4833
|
+
return auth.error;
|
|
4834
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4835
|
+
if (roleErr)
|
|
4836
|
+
return roleErr;
|
|
4837
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4838
|
+
await deleteFormWebhook(ctx, params.webhookId);
|
|
4839
|
+
return json({ data: { success: true } });
|
|
4840
|
+
}
|
|
4841
|
+
catch (err) {
|
|
4842
|
+
return mapFormError(err) ?? internalError(err);
|
|
4843
|
+
}
|
|
4844
|
+
});
|
|
4845
|
+
router.post('/forms/:id/webhooks/:webhookId/test', async (request, params) => {
|
|
4846
|
+
try {
|
|
4847
|
+
const auth = await requireAuth(request);
|
|
4848
|
+
if (auth.error)
|
|
4849
|
+
return auth.error;
|
|
4850
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4851
|
+
if (roleErr)
|
|
4852
|
+
return roleErr;
|
|
4853
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4854
|
+
const result = await testFormWebhook(ctx, params.webhookId);
|
|
4855
|
+
return json({ data: result });
|
|
4856
|
+
}
|
|
4857
|
+
catch (err) {
|
|
4858
|
+
return mapFormError(err) ?? internalError(err);
|
|
4859
|
+
}
|
|
4860
|
+
});
|
|
4861
|
+
// ── Test notification email ──────────────────────────────────────────────
|
|
4862
|
+
router.post('/forms/:id/notifications/test', async (request, params) => {
|
|
4863
|
+
try {
|
|
4864
|
+
const auth = await requireAuth(request);
|
|
4865
|
+
if (auth.error)
|
|
4866
|
+
return auth.error;
|
|
4867
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4868
|
+
if (roleErr)
|
|
4869
|
+
return roleErr;
|
|
4870
|
+
const form = await getFormByIdService(params.id);
|
|
4871
|
+
if (!form)
|
|
4872
|
+
return errorResponse('Form not found', 404);
|
|
4873
|
+
const settings = await getFormNotificationSettingsService(params.id);
|
|
4874
|
+
const primaryRecipient = (settings?.recipients ?? [])[0];
|
|
4875
|
+
if (!settings?.enabled || !primaryRecipient) {
|
|
4876
|
+
return errorResponse('Configure at least one recipient before sending a test', 400);
|
|
4877
|
+
}
|
|
4878
|
+
// Sample values so operators see a realistic email. The autoresponder is
|
|
4879
|
+
// suppressed for tests so we never email an arbitrary sample address.
|
|
4880
|
+
const fields = form.fields ?? [];
|
|
4881
|
+
const sampleData = {};
|
|
4882
|
+
for (const field of fields) {
|
|
4883
|
+
if (field.type === 'honeypot' || field.type === 'divider' || field.type === 'richtext') {
|
|
4884
|
+
continue;
|
|
4885
|
+
}
|
|
4886
|
+
if (field.type === 'email')
|
|
4887
|
+
sampleData[field.key] = primaryRecipient;
|
|
4888
|
+
else if (field.type === 'checkbox' || field.type === 'consent')
|
|
4889
|
+
sampleData[field.key] = true;
|
|
4890
|
+
else
|
|
4891
|
+
sampleData[field.key] = `Sample ${field.label || field.key}`;
|
|
4892
|
+
}
|
|
4893
|
+
const ctx = {
|
|
4894
|
+
formId: form.id,
|
|
4895
|
+
formName: form.name,
|
|
4896
|
+
entryNumber: null,
|
|
4897
|
+
submittedAt: new Date().toISOString(),
|
|
4898
|
+
fields,
|
|
4899
|
+
data: sampleData,
|
|
4900
|
+
senderName: 'Test Submission',
|
|
4901
|
+
senderEmail: primaryRecipient,
|
|
4902
|
+
};
|
|
4903
|
+
const result = await sendFormNotifications({ ...settings, autoresponderEnabled: false }, ctx);
|
|
4904
|
+
return json({ data: result });
|
|
4905
|
+
}
|
|
4906
|
+
catch (err) {
|
|
4907
|
+
return mapFormError(err) ?? internalError(err);
|
|
4908
|
+
}
|
|
4909
|
+
});
|
|
4723
4910
|
// ---------------------------------------------------------------------------
|
|
4724
4911
|
// Redirects routes
|
|
4725
4912
|
// ---------------------------------------------------------------------------
|
|
@@ -8576,7 +8763,11 @@ export function registerCMSRoutes(router) {
|
|
|
8576
8763
|
if (!isAuthorizedCronRequest(request.headers.get('authorization'))) {
|
|
8577
8764
|
return errorResponse('Unauthorized', 401);
|
|
8578
8765
|
}
|
|
8579
|
-
const
|
|
8766
|
+
const retentionDays = getActuateConfig()?.forms?.retentionDays;
|
|
8767
|
+
const formSubmissionRetentionMs = typeof retentionDays === 'number' && retentionDays > 0
|
|
8768
|
+
? retentionDays * 24 * 60 * 60 * 1000
|
|
8769
|
+
: 0;
|
|
8770
|
+
const result = await processCleanup(db(), { formSubmissionRetentionMs });
|
|
8580
8771
|
return json({ data: result });
|
|
8581
8772
|
}
|
|
8582
8773
|
catch (err) {
|