@ftisindia/create-app 0.1.6 → 0.2.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/package.json +1 -1
- package/template/.env.example +3 -0
- package/template/_package.json +4 -1
- package/template/docs/FORMS.md +34 -15
- package/template/docs/FORMS_CHECKLIST.md +34 -26
- package/template/docs/REPORTS.md +12 -3
- package/template/docs/REPORTS_CHECKLIST.md +150 -95
- package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
- package/template/prisma/schema.prisma +5 -1
- package/template/src/app.module.ts +4 -3
- package/template/src/config/env.validation.ts +3 -0
- package/template/src/config/forms.config.ts +1 -0
- package/template/src/config/reports.config.ts +2 -0
- package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
- package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +64 -16
- package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +40 -18
- package/template/src/modules/forms/forms.module.ts +2 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
- package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +81 -0
- package/template/src/modules/reports/application/services/reports-exports.service.ts +6 -2
- package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +43 -30
- package/template/test/forms-captcha.e2e-spec.ts +163 -0
- package/template/test/forms-files.e2e-spec.ts +1 -1
- package/template/test/forms-outbox.e2e-spec.ts +271 -10
- package/template/test/forms-public.e2e-spec.ts +24 -0
- package/template/test/forms-throttling.e2e-spec.ts +146 -0
- package/template/test/forms-webhooks.e2e-spec.ts +150 -8
- package/template/test/jest-e2e.json +1 -0
- package/template/test/reports-advanced.e2e-spec.ts +13 -0
- package/template/test/reports-query.e2e-spec.ts +52 -0
- package/template/test/reports-tiers.e2e-spec.ts +106 -20
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { Injectable } from '@nestjs/common';
|
|
2
3
|
import { FormOutboxJob as FormOutboxJobRow, Prisma } from '@prisma/client';
|
|
3
4
|
import type {
|
|
@@ -20,96 +21,117 @@ export class PrismaOutboxStore implements OutboxStore {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
async enqueue(job: OutboxJobInput, tx: EngineTx): Promise<void> {
|
|
23
|
-
await this.client(tx).formOutboxJob.
|
|
24
|
-
data:
|
|
25
|
-
type: job.type,
|
|
26
|
-
payload: job.payload as unknown as Prisma.InputJsonValue,
|
|
27
|
-
status: 'PENDING',
|
|
28
|
-
attempts: 0,
|
|
29
|
-
maxAttempts: job.maxAttempts ?? 8,
|
|
30
|
-
idempotencyKey: job.idempotencyKey ?? null,
|
|
31
|
-
runAfter: job.runAfter ?? null,
|
|
32
|
-
orgId: job.orgId ?? null,
|
|
33
|
-
actorUserId: job.actorUserId ?? null,
|
|
34
|
-
originRequestId: job.originRequestId ?? null,
|
|
35
|
-
},
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Claims due PENDING jobs and stale PROCESSING jobs by selecting candidates
|
|
41
|
-
* and flipping them to PROCESSING with a guarded updateMany. This is safe
|
|
42
|
-
* for the template's single in-process dispatcher; multi-instance
|
|
43
|
-
* deployments should move the claim to `SELECT ... FOR UPDATE SKIP LOCKED`.
|
|
44
|
-
*/
|
|
45
|
-
async claimDue(now: Date, batchSize: number): Promise<OutboxJobRecord[]> {
|
|
46
|
-
const staleProcessingBefore = new Date(now.getTime() - PROCESSING_LEASE_MS);
|
|
47
|
-
const claimableWhere: Prisma.FormOutboxJobWhereInput = {
|
|
48
|
-
OR: [
|
|
24
|
+
await this.client(tx).formOutboxJob.createMany({
|
|
25
|
+
data: [
|
|
49
26
|
{
|
|
27
|
+
type: job.type,
|
|
28
|
+
payload: job.payload as unknown as Prisma.InputJsonValue,
|
|
50
29
|
status: 'PENDING',
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
30
|
+
attempts: 0,
|
|
31
|
+
maxAttempts: job.maxAttempts ?? 8,
|
|
32
|
+
idempotencyKey: job.idempotencyKey ?? null,
|
|
33
|
+
runAfter: job.runAfter ?? null,
|
|
34
|
+
orgId: job.orgId ?? null,
|
|
35
|
+
actorUserId: job.actorUserId ?? null,
|
|
36
|
+
originRequestId: job.originRequestId ?? null,
|
|
56
37
|
},
|
|
57
38
|
],
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const due = await this.client().formOutboxJob.findMany({
|
|
61
|
-
where: claimableWhere,
|
|
62
|
-
orderBy: { createdAt: 'asc' },
|
|
63
|
-
take: batchSize,
|
|
64
|
-
select: { id: true },
|
|
39
|
+
skipDuplicates: true,
|
|
65
40
|
});
|
|
41
|
+
}
|
|
66
42
|
|
|
67
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Claims due PENDING jobs and stale PROCESSING jobs with row-level locks so
|
|
45
|
+
* multiple app instances can poll without double-delivering the same job.
|
|
46
|
+
*/
|
|
47
|
+
async claimDue(now: Date, batchSize: number): Promise<OutboxJobRecord[]> {
|
|
48
|
+
if (batchSize <= 0) {
|
|
68
49
|
return [];
|
|
69
50
|
}
|
|
70
51
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
await this.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
52
|
+
const staleProcessingBefore = new Date(now.getTime() - PROCESSING_LEASE_MS);
|
|
53
|
+
const claimedBy = randomUUID();
|
|
54
|
+
const claimed = await this.prisma.$transaction(
|
|
55
|
+
(tx) =>
|
|
56
|
+
tx.$queryRaw<FormOutboxJobRow[]>`
|
|
57
|
+
WITH due AS (
|
|
58
|
+
SELECT "id"
|
|
59
|
+
FROM "FormOutboxJob"
|
|
60
|
+
WHERE (
|
|
61
|
+
"status" = 'PENDING'
|
|
62
|
+
AND ("runAfter" IS NULL OR "runAfter" <= (${now}::timestamptz AT TIME ZONE 'UTC'))
|
|
63
|
+
)
|
|
64
|
+
OR (
|
|
65
|
+
"status" = 'PROCESSING'
|
|
66
|
+
AND "updatedAt" <= (${staleProcessingBefore}::timestamptz AT TIME ZONE 'UTC')
|
|
67
|
+
)
|
|
68
|
+
ORDER BY "createdAt" ASC
|
|
69
|
+
LIMIT ${batchSize}
|
|
70
|
+
FOR UPDATE SKIP LOCKED
|
|
71
|
+
)
|
|
72
|
+
UPDATE "FormOutboxJob" AS job
|
|
73
|
+
SET "status" = 'PROCESSING',
|
|
74
|
+
"claimedBy" = ${claimedBy},
|
|
75
|
+
"updatedAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
|
76
|
+
FROM due
|
|
77
|
+
WHERE job."id" = due."id"
|
|
78
|
+
RETURNING job.*
|
|
79
|
+
`,
|
|
80
|
+
);
|
|
81
81
|
|
|
82
82
|
return claimed.map((row) => this.toRecord(row));
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
async markDone(id: string): Promise<
|
|
86
|
-
await this.client().formOutboxJob.
|
|
87
|
-
where: { id },
|
|
88
|
-
data: { status: 'DONE' },
|
|
85
|
+
async markDone(id: string, claimedBy: string): Promise<boolean> {
|
|
86
|
+
const result = await this.client().formOutboxJob.updateMany({
|
|
87
|
+
where: { id, status: 'PROCESSING', claimedBy },
|
|
88
|
+
data: { status: 'DONE', claimedBy: null },
|
|
89
89
|
});
|
|
90
|
+
return result.count === 1;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
async markRetry(
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
async markRetry(
|
|
94
|
+
id: string,
|
|
95
|
+
claimedBy: string,
|
|
96
|
+
attempts: number,
|
|
97
|
+
runAfter: Date,
|
|
98
|
+
lastError: string,
|
|
99
|
+
): Promise<boolean> {
|
|
100
|
+
const result = await this.client().formOutboxJob.updateMany({
|
|
101
|
+
where: { id, status: 'PROCESSING', claimedBy },
|
|
95
102
|
data: {
|
|
96
103
|
status: 'PENDING',
|
|
104
|
+
claimedBy: null,
|
|
97
105
|
attempts,
|
|
98
106
|
runAfter,
|
|
99
107
|
lastError,
|
|
100
108
|
},
|
|
101
109
|
});
|
|
110
|
+
return result.count === 1;
|
|
102
111
|
}
|
|
103
112
|
|
|
104
|
-
async markFailed(id: string, attempts: number, lastError: string): Promise<
|
|
105
|
-
await this.client().formOutboxJob.
|
|
106
|
-
where: { id },
|
|
113
|
+
async markFailed(id: string, claimedBy: string, attempts: number, lastError: string): Promise<boolean> {
|
|
114
|
+
const result = await this.client().formOutboxJob.updateMany({
|
|
115
|
+
where: { id, status: 'PROCESSING', claimedBy },
|
|
107
116
|
data: {
|
|
108
117
|
status: 'FAILED',
|
|
118
|
+
claimedBy: null,
|
|
109
119
|
attempts,
|
|
110
120
|
lastError,
|
|
111
121
|
},
|
|
112
122
|
});
|
|
123
|
+
return result.count === 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async touchProcessing(id: string, claimedBy: string): Promise<boolean> {
|
|
127
|
+
const touched = await this.prisma.$executeRaw`
|
|
128
|
+
UPDATE "FormOutboxJob"
|
|
129
|
+
SET "updatedAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
|
130
|
+
WHERE "id" = ${id}
|
|
131
|
+
AND "status" = 'PROCESSING'
|
|
132
|
+
AND "claimedBy" = ${claimedBy}
|
|
133
|
+
`;
|
|
134
|
+
return touched === 1;
|
|
113
135
|
}
|
|
114
136
|
|
|
115
137
|
private toRecord(row: FormOutboxJobRow): OutboxJobRecord {
|
|
@@ -118,6 +140,7 @@ export class PrismaOutboxStore implements OutboxStore {
|
|
|
118
140
|
type: row.type,
|
|
119
141
|
payload: row.payload as unknown as Record<string, unknown>,
|
|
120
142
|
status: row.status as OutboxStatus,
|
|
143
|
+
claimedBy: row.claimedBy,
|
|
121
144
|
attempts: row.attempts,
|
|
122
145
|
maxAttempts: row.maxAttempts,
|
|
123
146
|
idempotencyKey: row.idempotencyKey,
|
package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts
CHANGED
|
@@ -8,8 +8,11 @@ import { ConfigService } from '@nestjs/config';
|
|
|
8
8
|
import { ReportExportService } from '@ftisindia/report-builder';
|
|
9
9
|
import { PrismaService } from '../../../../database/prisma/prisma.service';
|
|
10
10
|
import { RequestContextService } from '../../../request-context/application/services/request-context.service';
|
|
11
|
+
import { LocalDiskReportExportStorage } from '../../infrastructure/storage/local-disk-export-storage.adapter';
|
|
11
12
|
import { RequestReportsContext } from '../../infrastructure/request-reports-context';
|
|
12
13
|
|
|
14
|
+
const RETENTION_BATCH_SIZE = 100;
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
17
|
* Reports-OWNED async export worker (report design §9). Instead of riding the
|
|
15
18
|
* forms transactional outbox, it polls the engine's own `ReportExportJob`
|
|
@@ -25,12 +28,15 @@ import { RequestReportsContext } from '../../infrastructure/request-reports-cont
|
|
|
25
28
|
export class ReportsExportDispatcherService implements OnApplicationBootstrap, OnModuleDestroy {
|
|
26
29
|
private readonly logger = new Logger('ReportsExportWorker');
|
|
27
30
|
private timer?: NodeJS.Timeout;
|
|
31
|
+
private retentionTimer?: NodeJS.Timeout;
|
|
28
32
|
private ticking = false;
|
|
33
|
+
private sweepingRetention = false;
|
|
29
34
|
|
|
30
35
|
constructor(
|
|
31
36
|
private readonly config: ConfigService,
|
|
32
37
|
private readonly prisma: PrismaService,
|
|
33
38
|
private readonly exports: ReportExportService,
|
|
39
|
+
private readonly storage: LocalDiskReportExportStorage,
|
|
34
40
|
private readonly requestContext: RequestContextService,
|
|
35
41
|
private readonly reportsContext: RequestReportsContext,
|
|
36
42
|
) {}
|
|
@@ -44,11 +50,22 @@ export class ReportsExportDispatcherService implements OnApplicationBootstrap, O
|
|
|
44
50
|
void this.tick();
|
|
45
51
|
}, pollMs);
|
|
46
52
|
this.timer.unref?.();
|
|
53
|
+
|
|
54
|
+
const retentionSweepMs = this.config.get<number>('reports.exportRetentionSweepMs') ?? 3_600_000;
|
|
55
|
+
this.retentionTimer = setInterval(() => {
|
|
56
|
+
void this.sweepRetention();
|
|
57
|
+
}, retentionSweepMs);
|
|
58
|
+
this.retentionTimer.unref?.();
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
onModuleDestroy(): void {
|
|
50
62
|
if (this.timer) {
|
|
51
63
|
clearInterval(this.timer);
|
|
64
|
+
this.timer = undefined;
|
|
65
|
+
}
|
|
66
|
+
if (this.retentionTimer) {
|
|
67
|
+
clearInterval(this.retentionTimer);
|
|
68
|
+
this.retentionTimer = undefined;
|
|
52
69
|
}
|
|
53
70
|
}
|
|
54
71
|
|
|
@@ -103,6 +120,50 @@ export class ReportsExportDispatcherService implements OnApplicationBootstrap, O
|
|
|
103
120
|
return { claimed, done, failed };
|
|
104
121
|
}
|
|
105
122
|
|
|
123
|
+
/** Delete expired local export files and clear their job fileId. */
|
|
124
|
+
async cleanupExpiredFiles(now = new Date()): Promise<number> {
|
|
125
|
+
const retentionDays = this.config.get<number>('reports.exportRetentionDays') ?? 7;
|
|
126
|
+
if (retentionDays <= 0) {
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000);
|
|
131
|
+
const jobs = await this.prisma.reportExportJob.findMany({
|
|
132
|
+
where: {
|
|
133
|
+
status: 'DONE',
|
|
134
|
+
fileId: { not: null },
|
|
135
|
+
updatedAt: { lt: cutoff },
|
|
136
|
+
},
|
|
137
|
+
orderBy: { updatedAt: 'asc' },
|
|
138
|
+
take: RETENTION_BATCH_SIZE,
|
|
139
|
+
select: { id: true, orgId: true, fileId: true },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
let removed = 0;
|
|
143
|
+
for (const job of jobs) {
|
|
144
|
+
if (job.fileId === null) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
await this.storage.delete(job.fileId);
|
|
149
|
+
const result = await this.prisma.reportExportJob.updateMany({
|
|
150
|
+
where: { id: job.id, orgId: job.orgId, fileId: job.fileId },
|
|
151
|
+
data: {
|
|
152
|
+
fileId: null,
|
|
153
|
+
error: `Export file expired after ${retentionDays} day(s).`,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
removed += result.count;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
this.logger.warn(
|
|
159
|
+
`Failed to expire export file for job ${job.id}: ${error instanceof Error ? error.message : String(error)}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return removed;
|
|
165
|
+
}
|
|
166
|
+
|
|
106
167
|
private async tick(): Promise<void> {
|
|
107
168
|
if (this.ticking) {
|
|
108
169
|
return;
|
|
@@ -121,4 +182,24 @@ export class ReportsExportDispatcherService implements OnApplicationBootstrap, O
|
|
|
121
182
|
this.ticking = false;
|
|
122
183
|
}
|
|
123
184
|
}
|
|
185
|
+
|
|
186
|
+
private async sweepRetention(): Promise<void> {
|
|
187
|
+
if (this.sweepingRetention) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
this.sweepingRetention = true;
|
|
191
|
+
try {
|
|
192
|
+
const removed = await this.cleanupExpiredFiles();
|
|
193
|
+
if (removed > 0) {
|
|
194
|
+
this.logger.log(`Expired ${removed} report export file(s).`);
|
|
195
|
+
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
this.logger.error(
|
|
198
|
+
'Export retention sweep failed.',
|
|
199
|
+
error instanceof Error ? error.stack : String(error),
|
|
200
|
+
);
|
|
201
|
+
} finally {
|
|
202
|
+
this.sweepingRetention = false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
124
205
|
}
|
|
@@ -61,12 +61,16 @@ export class ReportsExportsService {
|
|
|
61
61
|
const job = await withReportsErrorMapping(() =>
|
|
62
62
|
this.engine.getJob(orgId, jobId, this.reportsContext),
|
|
63
63
|
);
|
|
64
|
-
if (job.status !== 'DONE'
|
|
64
|
+
if (job.status !== 'DONE') {
|
|
65
65
|
throw new NotFoundException('The export is not ready for download.');
|
|
66
66
|
}
|
|
67
|
+
const fileId = job.fileId;
|
|
68
|
+
if (fileId === null) {
|
|
69
|
+
throw new NotFoundException('The export file is no longer available.');
|
|
70
|
+
}
|
|
67
71
|
const format = job.spec.format;
|
|
68
72
|
return {
|
|
69
|
-
stream: this.storage.read(
|
|
73
|
+
stream: this.storage.read(fileId),
|
|
70
74
|
fileName: `${job.reportKey}-v${job.reportVersion}.${format}`,
|
|
71
75
|
mimeType: FORMAT_MIME[format] ?? 'application/octet-stream',
|
|
72
76
|
};
|
package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
import { createReadStream } from 'node:fs';
|
|
2
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { dirname, join, resolve, sep } from 'node:path';
|
|
4
1
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import
|
|
2
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
3
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
7
|
import { Injectable } from '@nestjs/common';
|
|
7
8
|
import { ConfigService } from '@nestjs/config';
|
|
8
9
|
import type { ExportFileSink } from '@ftisindia/report-builder';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
* Reports-
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Files land under `<exportStorageDir>/<orgId>/<uuid>.<ext>`; the returned
|
|
17
|
-
* `fileId` is that storage key, stored on the ReportExportJob row and used by
|
|
18
|
-
* the download route. The key is server-generated (no user input), and reads
|
|
19
|
-
* are guarded against escaping the configured directory.
|
|
12
|
+
* Reports-owned export file storage. Files land under
|
|
13
|
+
* `<exportStorageDir>/<orgId>/<uuid>.<ext>`; the returned fileId is that
|
|
14
|
+
* storage key, stored on the ReportExportJob row and used by the download
|
|
15
|
+
* route. The key is server-generated, and reads/deletes are guarded against
|
|
16
|
+
* escaping the configured directory.
|
|
20
17
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* Operational boundary: this local adapter is correct for a single app
|
|
19
|
+
* instance or for multiple instances sharing the same mounted volume. A
|
|
20
|
+
* horizontally scaled deployment without shared storage must rebind this
|
|
21
|
+
* provider to an object-storage adapter so any node can download a file
|
|
22
|
+
* written by any worker.
|
|
23
23
|
*/
|
|
24
24
|
@Injectable()
|
|
25
25
|
export class LocalDiskReportExportStorage implements ExportFileSink {
|
|
@@ -33,30 +33,35 @@ export class LocalDiskReportExportStorage implements ExportFileSink {
|
|
|
33
33
|
opts: { orgId: string; ownerId: string; fileName: string; mimeType: string },
|
|
34
34
|
chunks: AsyncIterable<Uint8Array>,
|
|
35
35
|
): Promise<{ fileId: string; size: number }> {
|
|
36
|
-
// Collected into one buffer before writing. Bounded by the engine's export
|
|
37
|
-
// caps (row limits + the snapshot duration bound, §9) long before memory
|
|
38
|
-
// becomes a concern.
|
|
39
|
-
const collected: Buffer[] = [];
|
|
40
|
-
for await (const chunk of chunks) {
|
|
41
|
-
collected.push(Buffer.from(chunk));
|
|
42
|
-
}
|
|
43
|
-
const bytes = Buffer.concat(collected);
|
|
44
|
-
|
|
45
36
|
const safeOrg = sanitizeSegment(opts.orgId);
|
|
46
37
|
const storageKey = `${safeOrg}/${randomUUID()}${extensionOf(opts.fileName)}`;
|
|
47
38
|
const absolute = this.resolveKey(storageKey);
|
|
48
39
|
await mkdir(dirname(absolute), { recursive: true });
|
|
49
|
-
await writeFile(absolute, bytes);
|
|
50
40
|
|
|
51
|
-
|
|
41
|
+
let size = 0;
|
|
42
|
+
try {
|
|
43
|
+
await pipeline(
|
|
44
|
+
Readable.from(countingChunks(chunks, (byteLength) => {
|
|
45
|
+
size += byteLength;
|
|
46
|
+
})),
|
|
47
|
+
createWriteStream(absolute),
|
|
48
|
+
);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
await rm(absolute, { force: true });
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { fileId: storageKey, size };
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
/** A readable stream for the download route. Guarded against path traversal. */
|
|
55
57
|
read(storageKey: string): Readable {
|
|
56
58
|
return createReadStream(this.resolveKey(storageKey));
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
async delete(storageKey: string): Promise<void> {
|
|
62
|
+
await rm(this.resolveKey(storageKey), { force: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
60
65
|
private resolveKey(storageKey: string): string {
|
|
61
66
|
const absolute = resolve(join(this.baseDir, storageKey));
|
|
62
67
|
if (absolute !== this.baseDir && !absolute.startsWith(this.baseDir + sep)) {
|
|
@@ -66,14 +71,22 @@ export class LocalDiskReportExportStorage implements ExportFileSink {
|
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
/** Keep path segments to a safe alphabet (uuids / org ids are already safe). */
|
|
70
74
|
function sanitizeSegment(segment: string): string {
|
|
71
75
|
return segment.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
72
76
|
}
|
|
73
77
|
|
|
74
|
-
/** Server-controlled extension: a short alphanumeric suffix, or none. */
|
|
75
78
|
function extensionOf(fileName: string): string {
|
|
76
79
|
const dot = fileName.lastIndexOf('.');
|
|
77
80
|
const extension = dot >= 0 ? fileName.slice(dot) : '';
|
|
78
81
|
return /^\.[A-Za-z0-9]+$/.test(extension) ? extension.toLowerCase() : '';
|
|
79
82
|
}
|
|
83
|
+
|
|
84
|
+
async function* countingChunks(
|
|
85
|
+
chunks: AsyncIterable<Uint8Array>,
|
|
86
|
+
onChunk: (byteLength: number) => void,
|
|
87
|
+
): AsyncGenerator<Uint8Array> {
|
|
88
|
+
for await (const chunk of chunks) {
|
|
89
|
+
onChunk(chunk.byteLength);
|
|
90
|
+
yield chunk;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
2
|
+
import { Test } from '@nestjs/testing';
|
|
3
|
+
import type { CaptchaVerifier } from '@ftisindia/form-builder';
|
|
4
|
+
import request from 'supertest';
|
|
5
|
+
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
|
|
6
|
+
import { FORMS_CAPTCHA_VERIFIER } from '../src/modules/forms/forms.tokens';
|
|
7
|
+
|
|
8
|
+
describe('Forms captcha seam (e2e)', () => {
|
|
9
|
+
let app: INestApplication;
|
|
10
|
+
let owner: { accessToken: string; orgId: string };
|
|
11
|
+
let formKey: string;
|
|
12
|
+
const seenTokens: string[] = [];
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
|
|
16
|
+
process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
|
|
17
|
+
process.env.AUTH_GOOGLE_ENABLED ||= 'false';
|
|
18
|
+
process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
|
|
19
|
+
process.env.NODE_ENV = 'test';
|
|
20
|
+
process.env.FORMS_OUTBOX_ENABLED = 'false';
|
|
21
|
+
|
|
22
|
+
const captcha: CaptchaVerifier = {
|
|
23
|
+
verify: (token) => {
|
|
24
|
+
seenTokens.push(token);
|
|
25
|
+
return Promise.resolve(token === 'valid-token');
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const { AppModule } = await import('../src/app.module');
|
|
30
|
+
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
|
31
|
+
.overrideProvider(FORMS_CAPTCHA_VERIFIER)
|
|
32
|
+
.useValue(captcha)
|
|
33
|
+
.compile();
|
|
34
|
+
|
|
35
|
+
app = moduleRef.createNestApplication();
|
|
36
|
+
app.useGlobalFilters(new HttpExceptionFilter());
|
|
37
|
+
app.useGlobalPipes(
|
|
38
|
+
new ValidationPipe({
|
|
39
|
+
forbidNonWhitelisted: true,
|
|
40
|
+
transform: true,
|
|
41
|
+
whitelist: true,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
await app.init();
|
|
45
|
+
|
|
46
|
+
owner = await createUserAndOrg('captcha-owner');
|
|
47
|
+
formKey = await publishDefinition(publicCaptchaDefinition(uniqueFormKey('captcha')));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
await app.close();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects missing or invalid tokens and accepts a valid captcha token', async () => {
|
|
55
|
+
const payload = {
|
|
56
|
+
data: {
|
|
57
|
+
fullName: 'Captcha User',
|
|
58
|
+
email: 'captcha@example.com',
|
|
59
|
+
ticketType: 'standard',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
await request(app.getHttpServer())
|
|
64
|
+
.post(`/public/organisations/${owner.orgId}/forms/${formKey}/submissions`)
|
|
65
|
+
.send(payload)
|
|
66
|
+
.expect(422);
|
|
67
|
+
|
|
68
|
+
await request(app.getHttpServer())
|
|
69
|
+
.post(`/public/organisations/${owner.orgId}/forms/${formKey}/submissions`)
|
|
70
|
+
.send({ ...payload, captchaToken: 'invalid-token' })
|
|
71
|
+
.expect(422);
|
|
72
|
+
|
|
73
|
+
const submitted = await request(app.getHttpServer())
|
|
74
|
+
.post(`/public/organisations/${owner.orgId}/forms/${formKey}/submissions`)
|
|
75
|
+
.send({ ...payload, captchaToken: 'valid-token' })
|
|
76
|
+
.expect(200);
|
|
77
|
+
|
|
78
|
+
expect(submitted.body.submissionId).toBeTruthy();
|
|
79
|
+
expect(seenTokens).toEqual(['invalid-token', 'valid-token']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function publicCaptchaDefinition(key: string): Record<string, unknown> {
|
|
83
|
+
return {
|
|
84
|
+
key,
|
|
85
|
+
version: 1,
|
|
86
|
+
title: 'Captcha public form',
|
|
87
|
+
settings: { access: 'public', captcha: true },
|
|
88
|
+
fields: [
|
|
89
|
+
{
|
|
90
|
+
type: 'text',
|
|
91
|
+
name: 'fullName',
|
|
92
|
+
validators: { required: true, maxLength: 120 },
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
type: 'email',
|
|
96
|
+
name: 'email',
|
|
97
|
+
validators: { required: true },
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
type: 'select',
|
|
101
|
+
name: 'ticketType',
|
|
102
|
+
validators: { required: true, options: ['standard', 'student', 'vip'] },
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
actions: {
|
|
106
|
+
submit: ['validateAll', 'persist'],
|
|
107
|
+
saveDraft: ['persistDraft'],
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function publishDefinition(definition: Record<string, unknown>): Promise<string> {
|
|
113
|
+
const created = await request(app.getHttpServer())
|
|
114
|
+
.post(`/organisations/${owner.orgId}/forms`)
|
|
115
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
116
|
+
.send({ definition })
|
|
117
|
+
.expect(201);
|
|
118
|
+
|
|
119
|
+
await request(app.getHttpServer())
|
|
120
|
+
.post(
|
|
121
|
+
`/organisations/${owner.orgId}/forms/${definition.key as string}/versions/${created.body.version as number}/publish`,
|
|
122
|
+
)
|
|
123
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
124
|
+
.expect(200);
|
|
125
|
+
|
|
126
|
+
return definition.key as string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function createUserAndOrg(label: string) {
|
|
130
|
+
const suffix = uniqueSuffix();
|
|
131
|
+
const signup = await request(app.getHttpServer())
|
|
132
|
+
.post('/auth/signup')
|
|
133
|
+
.send({
|
|
134
|
+
email: `${label}-${suffix}@example.com`,
|
|
135
|
+
password: 'test-password-123',
|
|
136
|
+
displayName: label,
|
|
137
|
+
})
|
|
138
|
+
.expect(201);
|
|
139
|
+
|
|
140
|
+
const accessToken = signup.body.accessToken as string;
|
|
141
|
+
const org = await request(app.getHttpServer())
|
|
142
|
+
.post('/organisations')
|
|
143
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
144
|
+
.send({
|
|
145
|
+
name: `${label} ${suffix}`,
|
|
146
|
+
slug: `${label}-${suffix}`,
|
|
147
|
+
})
|
|
148
|
+
.expect(201);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
accessToken,
|
|
152
|
+
orgId: org.body.organisation.id as string,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function uniqueSuffix() {
|
|
157
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function uniqueFormKey(label: string) {
|
|
161
|
+
return `${label}-${uniqueSuffix()}`.toLowerCase();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
@@ -176,7 +176,7 @@ describe('Forms file uploads (e2e)', () => {
|
|
|
176
176
|
|
|
177
177
|
await prisma.uploadedFile.update({
|
|
178
178
|
where: { id: fileId },
|
|
179
|
-
data: { createdAt: new Date(Date.now() -
|
|
179
|
+
data: { createdAt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) },
|
|
180
180
|
});
|
|
181
181
|
|
|
182
182
|
const removed = await app.get(FileGcService).runOnce();
|