@ftisindia/create-app 0.2.0 → 0.3.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/_package.json +3 -2
- package/template/docs/FORMS.md +53 -3
- package/template/docs/FORMS_CHECKLIST.md +3 -1
- package/template/docs/REPORTS.md +10 -1
- package/template/prisma/migrations/20260625000000_form_builder_public_uploads/migration.sql +7 -0
- package/template/prisma/schema.prisma +8 -5
- package/template/scripts/push-report.ts +123 -0
- package/template/src/common/swagger/api-error-responses.ts +8 -2
- package/template/src/modules/forms/application/services/data-sources/conference-tracks.data-source.ts +32 -0
- package/template/src/modules/forms/application/services/forms-files.service.ts +143 -39
- package/template/src/modules/forms/application/services/forms-public.service.ts +2 -1
- package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +5 -3
- package/template/src/modules/forms/dto/public-file-upload-response.dto.ts +10 -0
- package/template/src/modules/forms/dto/public-submit-form.dto.ts +9 -0
- package/template/src/modules/forms/forms.module.ts +10 -2
- package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +43 -3
- package/template/src/modules/forms/presentation/public-forms-files.controller.ts +66 -0
- package/template/src/modules/settings/types/setting-definitions.ts +4 -0
- package/template/test/forms-files.e2e-spec.ts +41 -19
- package/template/test/forms-submissions.e2e-spec.ts +2 -11
- package/template/test/route-registry.validator.spec.ts +4 -2
package/package.json
CHANGED
package/template/_package.json
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"format": "prettier --write .",
|
|
11
11
|
"format:check": "prettier --check .",
|
|
12
12
|
"forms:push": "ts-node scripts/push-form.ts",
|
|
13
|
+
"reports:push": "ts-node scripts/push-report.ts",
|
|
13
14
|
"gen:form": "node scripts/gen-form.mjs",
|
|
14
15
|
"gen:module": "node scripts/gen-module.mjs",
|
|
15
16
|
"lint": "eslint .",
|
|
@@ -33,8 +34,8 @@
|
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"@casl/ability": "6.8.1",
|
|
36
|
-
"@ftisindia/form-builder": "^
|
|
37
|
-
"@ftisindia/report-builder": "^0.
|
|
37
|
+
"@ftisindia/form-builder": "^1.0.0",
|
|
38
|
+
"@ftisindia/report-builder": "^0.3.0",
|
|
38
39
|
"@nestjs/common": "11.1.24",
|
|
39
40
|
"@nestjs/config": "4.0.4",
|
|
40
41
|
"@nestjs/core": "11.1.24",
|
package/template/docs/FORMS.md
CHANGED
|
@@ -49,6 +49,14 @@ rolls back everything.
|
|
|
49
49
|
Implement the engine interface, decorate the provider, list it in a module —
|
|
50
50
|
the registry bootstrap finds it at startup:
|
|
51
51
|
|
|
52
|
+
Definitions can reference a `dataSource.key` only after a matching provider is
|
|
53
|
+
registered. This template ships `ConferenceTracksDataSource` in
|
|
54
|
+
`src/modules/forms/application/services/data-sources/` for the bundled
|
|
55
|
+
`abstract-submission` example (`conference-tracks`). It is intentionally static
|
|
56
|
+
so fresh apps can publish and render the example immediately; replace it with an
|
|
57
|
+
org-scoped Prisma/API lookup before using real production master data. An
|
|
58
|
+
unregistered source fails save/publish lint with `UNKNOWN_DATA_SOURCE`.
|
|
59
|
+
|
|
52
60
|
```ts
|
|
53
61
|
import { FormDataSource } from './modules/forms/infrastructure/registry/form-extension.decorators';
|
|
54
62
|
import type { DataSourceDef, DataSourceContext, DataSourceOption } from '@ftisindia/form-builder';
|
|
@@ -95,9 +103,51 @@ Flip a published definition with `POST .../public-access { "access": "public" }`
|
|
|
95
103
|
(requires `forms.managePublicAccess`, writes an audit row). Public routes are
|
|
96
104
|
throttled tighter than the app default; the engine enforces the per-IP/day
|
|
97
105
|
cap (`forms.maxSubmissionsPerIpPerDay` setting, per-form override in the
|
|
98
|
-
definition's `settings`) and the captcha seam (`settings.captcha: true`
|
|
106
|
+
definition's `settings`) and the captcha seam (`settings.captcha: true` -
|
|
99
107
|
publish fails closed unless a `CaptchaVerifier` is bound to the
|
|
100
|
-
`FORMS_CAPTCHA_VERIFIER` token). Public
|
|
108
|
+
`FORMS_CAPTCHA_VERIFIER` token). Public file fields are allowed through the
|
|
109
|
+
public upload endpoint: upload first, then submit `{ fileId }` in `data` and the
|
|
110
|
+
returned token in `uploadTokens[fileId]`. Anonymous upload volume is capped by
|
|
111
|
+
`forms.maxPublicUploadsPerIpPerDay`. Like the public submission cap, it depends on a resolvable request IP; configure proxy/IP handling correctly in deployments behind a reverse proxy.
|
|
112
|
+
|
|
113
|
+
A captcha-enabled public form keeps failing closed until the app binds a real
|
|
114
|
+
verifier. Example Turnstile-style adapter:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { Injectable, Module } from '@nestjs/common';
|
|
118
|
+
import { ConfigService } from '@nestjs/config';
|
|
119
|
+
import type { CaptchaVerifier, FormsContext } from '@ftisindia/form-builder';
|
|
120
|
+
import { FORMS_CAPTCHA_VERIFIER } from './modules/forms/forms.tokens';
|
|
121
|
+
|
|
122
|
+
@Injectable()
|
|
123
|
+
export class TurnstileCaptchaVerifier implements CaptchaVerifier {
|
|
124
|
+
constructor(private readonly config: ConfigService) {}
|
|
125
|
+
|
|
126
|
+
async verify(token: string, _ctx: FormsContext): Promise<boolean> {
|
|
127
|
+
const body = new URLSearchParams({
|
|
128
|
+
secret: this.config.getOrThrow<string>('TURNSTILE_SECRET_KEY'),
|
|
129
|
+
response: token,
|
|
130
|
+
});
|
|
131
|
+
const response = await fetch(
|
|
132
|
+
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
|
133
|
+
{ method: 'POST', body },
|
|
134
|
+
);
|
|
135
|
+
const result = (await response.json()) as { success?: boolean };
|
|
136
|
+
return result.success === true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Module({
|
|
141
|
+
providers: [
|
|
142
|
+
{ provide: FORMS_CAPTCHA_VERIFIER, useClass: TurnstileCaptchaVerifier },
|
|
143
|
+
],
|
|
144
|
+
})
|
|
145
|
+
export class AppCaptchaModule {}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
After that binding is present, a definition can opt in with
|
|
149
|
+
`settings.captcha: true`; without the binding, publish fails before the form can
|
|
150
|
+
go public.
|
|
101
151
|
|
|
102
152
|
## Files
|
|
103
153
|
|
|
@@ -161,7 +211,7 @@ npm run forms:push -- --file src/modules/forms/definitions/customer-feedback.for
|
|
|
161
211
|
--org demo --user owner@example.com --publish
|
|
162
212
|
```
|
|
163
213
|
|
|
164
|
-
`gen:form` writes `src/modules/forms/definitions/<key>.form.json` and
|
|
214
|
+
Example files under `src/modules/forms/examples/` are reference fixtures; copy or scaffold into `src/modules/forms/definitions/` before pushing. `gen:form` writes `src/modules/forms/definitions/<key>.form.json` and
|
|
165
215
|
`test/<key>.form.spec.ts` (meta-schema + publish-stage lint — fails `npm test`
|
|
166
216
|
the moment the definition would be rejected by the API). `forms:push` loads a
|
|
167
217
|
definition file into an org through the real engine path: it impersonates an
|
|
@@ -23,6 +23,7 @@ local PostgreSQL `postgres/root`: `npm run dogfood`, generated app
|
|
|
23
23
|
|
|
24
24
|
- [x] All 11 form permission keys exist in `permissionKeys`, are seeded, and `test/forms-permission-sync.spec.ts` passes (engine <-> template set equality).
|
|
25
25
|
- [x] Every protected form route is in `routePermissionRegistry` (the boot-time registry validator passes).
|
|
26
|
+
- [x] The bundled `ConferenceTracksDataSource` backs the abstract-submission example; production data sources remain app-provided `@FormDataSource()` providers.
|
|
26
27
|
|
|
27
28
|
## Gating
|
|
28
29
|
|
|
@@ -45,10 +46,11 @@ local PostgreSQL `postgres/root`: `npm run dogfood`, generated app
|
|
|
45
46
|
|
|
46
47
|
- [x] Public path: `@Public()` render/submit works without membership; a non-public form 404s on the public routes; org-scoped storage still enforced; permissioned paths unaffected.
|
|
47
48
|
- [x] Tier-1 abuse baseline active: public routes throttled tighter than the app default; per-IP/day caps read from typed settings (with per-form override) and enforced; IP/UA stamped on anonymous submissions; captcha seam invoked when enabled; captcha-enabled-without-adapter fails at publish, not at submit.
|
|
49
|
+
- [x] Captcha remains fail-closed by default: apps must bind a real `CaptchaVerifier` to `FORMS_CAPTCHA_VERIFIER` before publishing public forms with `settings.captcha: true`.
|
|
48
50
|
|
|
49
51
|
## Files
|
|
50
52
|
|
|
51
|
-
- [x] Upload -> submit -> `LINKED` flow proven; a foreign user's `fileId` is rejected (IDOR);
|
|
53
|
+
- [x] Upload -> submit -> `LINKED` flow proven; a foreign user's `fileId` is rejected (IDOR); public uploads require the per-file token returned by the public upload endpoint; GC sweeps orphaned `TEMPORARY` files past TTL.
|
|
52
54
|
|
|
53
55
|
## Webhooks
|
|
54
56
|
|
package/template/docs/REPORTS.md
CHANGED
|
@@ -50,8 +50,17 @@ definition AND again at execute time.
|
|
|
50
50
|
|
|
51
51
|
Example definitions live in `src/modules/reports/examples/` — `org-members`
|
|
52
52
|
(custom source, no form builder anywhere) and `abstract-review-board` (the
|
|
53
|
-
form-backed quick path, indexed tier).
|
|
53
|
+
form-backed quick path, indexed tier).
|
|
54
|
+
|
|
55
|
+
## Definitions as code (`reports:push`)
|
|
54
56
|
|
|
57
|
+
```bash
|
|
58
|
+
npm run reports:push -- --file src/modules/reports/definitions/member-directory.report.json \
|
|
59
|
+
--org demo --user owner@example.com --publish
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`src/modules/reports/definitions/*.report.json` files are source files; they are not loaded automatically at boot. `reports:push` loads a definition into an org through the real service path, so member RBAC, save/publish linting, versioning, and audit rows match the HTTP API. Re-pushing unchanged content is a no-op; changed content creates the next draft version, and `--publish` publishes it.
|
|
63
|
+
|
|
55
64
|
## One contract: the QuerySpec
|
|
56
65
|
|
|
57
66
|
The frontend never builds queries. Every grid interaction posts a declarative
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- AlterTable
|
|
2
|
+
ALTER TABLE "UploadedFile" ALTER COLUMN "ownerId" DROP NOT NULL,
|
|
3
|
+
ADD COLUMN "claimTokenHash" TEXT,
|
|
4
|
+
ADD COLUMN "uploadedIp" TEXT;
|
|
5
|
+
|
|
6
|
+
-- CreateIndex
|
|
7
|
+
CREATE INDEX "UploadedFile_orgId_uploadedIp_createdAt_idx" ON "UploadedFile"("orgId", "uploadedIp", "createdAt");
|
|
@@ -298,7 +298,7 @@ model AuditLog {
|
|
|
298
298
|
@@index([createdAt])
|
|
299
299
|
}
|
|
300
300
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
301
|
-
// @ftisindia/form-builder
|
|
301
|
+
// @ftisindia/form-builder - canonical Prisma model snippet (ENGINE_SCHEMA_VERSION 3)
|
|
302
302
|
//
|
|
303
303
|
// The APP owns schema.prisma and the migration history (ecosystem guide §10.1).
|
|
304
304
|
// Copy these models into your app's prisma/schema.prisma verbatim and run
|
|
@@ -441,9 +441,11 @@ model UploadedFile {
|
|
|
441
441
|
size Int
|
|
442
442
|
// sha256 hex.
|
|
443
443
|
checksum String
|
|
444
|
-
//
|
|
445
|
-
ownerId
|
|
446
|
-
|
|
444
|
+
// Authenticated uploads have ownerId; anonymous public uploads have a claim token.
|
|
445
|
+
ownerId String?
|
|
446
|
+
claimTokenHash String?
|
|
447
|
+
uploadedIp String?
|
|
448
|
+
orgId String
|
|
447
449
|
|
|
448
450
|
status FileStatus @default(TEMPORARY)
|
|
449
451
|
submissionId String?
|
|
@@ -451,7 +453,8 @@ model UploadedFile {
|
|
|
451
453
|
createdAt DateTime @default(now())
|
|
452
454
|
|
|
453
455
|
@@index([orgId, status])
|
|
454
|
-
@@index([ownerId, status])
|
|
456
|
+
@@index([ownerId, status])
|
|
457
|
+
@@index([orgId, uploadedIp, createdAt])
|
|
455
458
|
@@index([status, createdAt])
|
|
456
459
|
}
|
|
457
460
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push a report-definition JSON file into an organisation through the real
|
|
3
|
+
* report engine path: member RBAC, save/publish linting, versioning, and audit
|
|
4
|
+
* rows all stay intact.
|
|
5
|
+
*
|
|
6
|
+
* npm run reports:push -- --file src/modules/reports/definitions/my-report.report.json \
|
|
7
|
+
* --org <org-id-or-slug> --user <member-email> [--publish]
|
|
8
|
+
*
|
|
9
|
+
* Idempotent: if the file matches the latest stored version's authored content,
|
|
10
|
+
* nothing new is created. With --publish, a matching DRAFT is published in place.
|
|
11
|
+
* Runs under ts-node because it bootstraps Nest.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { NestFactory } from '@nestjs/core';
|
|
15
|
+
import type { ReportDefinition } from '@ftisindia/report-builder';
|
|
16
|
+
import { AppModule } from '../src/app.module';
|
|
17
|
+
import { PrismaService } from '../src/database/prisma/prisma.service';
|
|
18
|
+
import { RbacCacheService } from '../src/modules/access-control/application/services/rbac-cache.service';
|
|
19
|
+
import { RequestContextService } from '../src/modules/request-context/application/services/request-context.service';
|
|
20
|
+
import { ReportsDefinitionsService } from '../src/modules/reports/application/services/reports-definitions.service';
|
|
21
|
+
import { CreateReportDefinitionDto } from '../src/modules/reports/dto/create-report-definition.dto';
|
|
22
|
+
|
|
23
|
+
function argValue(flag: string): string | undefined {
|
|
24
|
+
const index = process.argv.indexOf(flag);
|
|
25
|
+
return index !== -1 ? process.argv[index + 1] : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stableStringify(value: unknown): string {
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
31
|
+
}
|
|
32
|
+
if (value && typeof value === 'object') {
|
|
33
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
34
|
+
.filter(([, v]) => v !== undefined)
|
|
35
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
36
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
|
|
37
|
+
return `{${entries.join(',')}}`;
|
|
38
|
+
}
|
|
39
|
+
return JSON.stringify(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Version/status are managed by the engine - compare authored content only. */
|
|
43
|
+
function normalized(schema: ReportDefinition): string {
|
|
44
|
+
const { version: _version, status: _status, ...rest } = schema;
|
|
45
|
+
return stableStringify(rest);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function main() {
|
|
49
|
+
const file = argValue('--file');
|
|
50
|
+
const orgRef = argValue('--org');
|
|
51
|
+
const userEmail = argValue('--user');
|
|
52
|
+
const publish = process.argv.includes('--publish');
|
|
53
|
+
if (!file || !orgRef || !userEmail) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'Usage: npm run reports:push -- --file <path> --org <org-id-or-slug> --user <member-email> [--publish]',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const definition = JSON.parse(readFileSync(file, 'utf8')) as ReportDefinition;
|
|
60
|
+
if (!definition.key) {
|
|
61
|
+
throw new Error(`${file} does not look like a report definition (missing "key").`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const app = await NestFactory.createApplicationContext(AppModule, {
|
|
65
|
+
logger: ['error', 'warn'],
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
const prisma = app.get(PrismaService);
|
|
69
|
+
const user = await prisma.user.findFirst({ where: { email: userEmail } });
|
|
70
|
+
if (!user) {
|
|
71
|
+
throw new Error(`No user found with email "${userEmail}".`);
|
|
72
|
+
}
|
|
73
|
+
const org = await prisma.organisation.findFirst({
|
|
74
|
+
where: { OR: [{ id: orgRef }, { slug: orgRef }] },
|
|
75
|
+
});
|
|
76
|
+
if (!org) {
|
|
77
|
+
throw new Error(`No organisation found with id or slug "${orgRef}".`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const rbac = await app.get(RbacCacheService).getContext(user.id, org.id);
|
|
81
|
+
const requestContext = app.get(RequestContextService);
|
|
82
|
+
const definitions = app.get(ReportsDefinitionsService);
|
|
83
|
+
|
|
84
|
+
await requestContext.run(
|
|
85
|
+
{ source: 'worker', orgId: org.id, userId: user.id, rbac },
|
|
86
|
+
async () => {
|
|
87
|
+
const latest = await prisma.reportDefinition.findFirst({
|
|
88
|
+
where: { orgId: org.id, key: definition.key },
|
|
89
|
+
orderBy: { version: 'desc' },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (latest && normalized(latest.schema as unknown as ReportDefinition) === normalized(definition)) {
|
|
93
|
+
if (publish && latest.status === 'DRAFT') {
|
|
94
|
+
await definitions.publish(org.id, definition.key);
|
|
95
|
+
console.log(`Unchanged content - published existing draft v${latest.version}.`);
|
|
96
|
+
} else {
|
|
97
|
+
console.log(
|
|
98
|
+
`No changes - "${definition.key}" v${latest.version} (${latest.status}) already matches ${file}.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const created = await definitions.create(
|
|
105
|
+
org.id,
|
|
106
|
+
definition as unknown as CreateReportDefinitionDto,
|
|
107
|
+
);
|
|
108
|
+
console.log(`Created "${created.key}" v${created.version} (DRAFT).`);
|
|
109
|
+
if (publish) {
|
|
110
|
+
await definitions.publish(org.id, created.key);
|
|
111
|
+
console.log(`Published "${created.key}" v${created.version}.`);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
} finally {
|
|
116
|
+
await app.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
main().catch((error) => {
|
|
121
|
+
console.error(error instanceof Error ? error.message : error);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
});
|
|
@@ -6,13 +6,15 @@ import {
|
|
|
6
6
|
ApiGoneResponse,
|
|
7
7
|
ApiInternalServerErrorResponse,
|
|
8
8
|
ApiNotFoundResponse,
|
|
9
|
+
ApiPayloadTooLargeResponse,
|
|
9
10
|
ApiServiceUnavailableResponse,
|
|
10
11
|
ApiTooManyRequestsResponse,
|
|
11
12
|
ApiUnauthorizedResponse,
|
|
13
|
+
ApiUnsupportedMediaTypeResponse,
|
|
12
14
|
} from '@nestjs/swagger';
|
|
13
15
|
import { ErrorResponseDto } from '../dto/error-response.dto';
|
|
14
16
|
|
|
15
|
-
type ApiErrorStatus = 400 | 401 | 403 | 404 | 409 | 410 | 429 | 500 | 503;
|
|
17
|
+
type ApiErrorStatus = 400 | 401 | 403 | 404 | 409 | 410 | 413 | 415 | 429 | 500 | 503;
|
|
16
18
|
|
|
17
19
|
const errorResponseFactories = {
|
|
18
20
|
400: ApiBadRequestResponse,
|
|
@@ -21,6 +23,8 @@ const errorResponseFactories = {
|
|
|
21
23
|
404: ApiNotFoundResponse,
|
|
22
24
|
409: ApiConflictResponse,
|
|
23
25
|
410: ApiGoneResponse,
|
|
26
|
+
413: ApiPayloadTooLargeResponse,
|
|
27
|
+
415: ApiUnsupportedMediaTypeResponse,
|
|
24
28
|
429: ApiTooManyRequestsResponse,
|
|
25
29
|
500: ApiInternalServerErrorResponse,
|
|
26
30
|
503: ApiServiceUnavailableResponse,
|
|
@@ -33,6 +37,8 @@ const descriptions: Record<ApiErrorStatus, string> = {
|
|
|
33
37
|
404: 'The requested resource was not found.',
|
|
34
38
|
409: 'The request conflicts with current resource state.',
|
|
35
39
|
410: 'The resource is no longer available.',
|
|
40
|
+
413: 'The request payload is too large.',
|
|
41
|
+
415: 'The uploaded media type is not supported.',
|
|
36
42
|
429: 'Too many requests.',
|
|
37
43
|
500: 'Unexpected server error.',
|
|
38
44
|
503: 'The service is temporarily unavailable.',
|
|
@@ -51,4 +57,4 @@ export function ApiErrorResponses(...statuses: ApiErrorStatus[]) {
|
|
|
51
57
|
|
|
52
58
|
export function ApiProtectedErrorResponses(...extraStatuses: ApiErrorStatus[]) {
|
|
53
59
|
return ApiErrorResponses(400, 401, 403, ...extraStatuses);
|
|
54
|
-
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import type {
|
|
3
|
+
DataSourceContext,
|
|
4
|
+
DataSourceDef,
|
|
5
|
+
DataSourceOption,
|
|
6
|
+
} from '@ftisindia/form-builder';
|
|
7
|
+
import { FormDataSource } from '../../../infrastructure/registry/form-extension.decorators';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Example data source for src/modules/forms/examples/abstract-submission.form.json.
|
|
11
|
+
*
|
|
12
|
+
* Real apps should replace this static list with an org-scoped Prisma/API
|
|
13
|
+
* lookup using ctx.orgId. Keeping the shipped example self-contained makes
|
|
14
|
+
* the data-source registry usable immediately after scaffolding.
|
|
15
|
+
*/
|
|
16
|
+
@Injectable()
|
|
17
|
+
@FormDataSource()
|
|
18
|
+
export class ConferenceTracksDataSource implements DataSourceDef {
|
|
19
|
+
readonly key = 'conference-tracks';
|
|
20
|
+
|
|
21
|
+
fetch(
|
|
22
|
+
_params: Record<string, unknown>,
|
|
23
|
+
_ctx: DataSourceContext,
|
|
24
|
+
): Promise<DataSourceOption[]> {
|
|
25
|
+
return Promise.resolve([
|
|
26
|
+
{ value: 'clinical-research', label: 'Clinical Research' },
|
|
27
|
+
{ value: 'public-health', label: 'Public Health' },
|
|
28
|
+
{ value: 'biomedical-engineering', label: 'Biomedical Engineering' },
|
|
29
|
+
{ value: 'data-science', label: 'Data Science' },
|
|
30
|
+
]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import {
|
|
3
3
|
BadRequestException,
|
|
4
|
-
ConflictException,
|
|
4
|
+
ConflictException,
|
|
5
|
+
HttpException,
|
|
6
|
+
HttpStatus,
|
|
5
7
|
Injectable,
|
|
6
8
|
NotFoundException,
|
|
7
9
|
PayloadTooLargeException,
|
|
@@ -12,7 +14,12 @@ import {
|
|
|
12
14
|
acceptSatisfied,
|
|
13
15
|
walkFields,
|
|
14
16
|
} from '@ftisindia/form-builder';
|
|
15
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
FieldDef,
|
|
19
|
+
FormDefinitionRecord,
|
|
20
|
+
OrgFormsPolicy,
|
|
21
|
+
UploadedFileRecord,
|
|
22
|
+
} from '@ftisindia/form-builder';
|
|
16
23
|
import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
|
|
17
24
|
import { RequestContextService } from '../../../request-context/application/services/request-context.service';
|
|
18
25
|
import { UploadFileQueryDto } from '../../dto/upload-file-query.dto';
|
|
@@ -23,15 +30,20 @@ import { LocalDiskStorageAdapter } from '../../infrastructure/storage/local-disk
|
|
|
23
30
|
import { FormsSettingsReader } from './forms-settings-reader.service';
|
|
24
31
|
|
|
25
32
|
const sniffer = new DefaultMagicByteSniffer();
|
|
33
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
type UploadedMultipartFile = {
|
|
36
|
+
originalname: string;
|
|
37
|
+
size: number;
|
|
38
|
+
buffer: Buffer;
|
|
39
|
+
};
|
|
26
40
|
|
|
27
41
|
/**
|
|
28
|
-
* Upload-before-submit (design doc
|
|
29
|
-
* submission later carries only the file id.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* ownership). Files start TEMPORARY; submit-time linking promotes to LINKED;
|
|
34
|
-
* the GC sweeps the rest after the TTL.
|
|
42
|
+
* Upload-before-submit (design doc section 10): bytes are stored here, the form
|
|
43
|
+
* submission later carries only the file id. Authenticated uploads bind to the
|
|
44
|
+
* user id; public uploads bind to an anonymous claim token returned once by the
|
|
45
|
+
* public upload endpoint. Submit-time linking re-checks the same ownership or
|
|
46
|
+
* token claim before promoting the file to LINKED.
|
|
35
47
|
*/
|
|
36
48
|
@Injectable()
|
|
37
49
|
export class FormsFilesService {
|
|
@@ -48,24 +60,90 @@ export class FormsFilesService {
|
|
|
48
60
|
orgId: string,
|
|
49
61
|
formKey: string,
|
|
50
62
|
query: UploadFileQueryDto,
|
|
51
|
-
file:
|
|
63
|
+
file: UploadedMultipartFile | undefined,
|
|
52
64
|
user: AuthenticatedUser,
|
|
53
65
|
) {
|
|
54
66
|
this.requestContext.assertOrgScope(orgId);
|
|
67
|
+
this.assertUploadRequest(query, file);
|
|
68
|
+
|
|
69
|
+
const definition = await this.resolveTargetDefinition(orgId, formKey, query);
|
|
70
|
+
const field = this.requireFileField(definition, query.field);
|
|
71
|
+
const policy = await this.settings.policyFor(orgId);
|
|
72
|
+
const record = await this.storeUpload(orgId, field, file, policy, {
|
|
73
|
+
ownerId: user.id,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return this.toResponse(record);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async uploadPublic(
|
|
80
|
+
orgId: string,
|
|
81
|
+
formKey: string,
|
|
82
|
+
query: UploadFileQueryDto,
|
|
83
|
+
file: UploadedMultipartFile | undefined,
|
|
84
|
+
) {
|
|
85
|
+
this.requestContext.merge({ orgId });
|
|
86
|
+
this.assertUploadRequest(query, file);
|
|
87
|
+
if (query.submissionId) {
|
|
88
|
+
throw new BadRequestException('Public uploads cannot target draft submissions.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const definition = await this.resolvePublicDefinition(orgId, formKey, query);
|
|
92
|
+
const field = this.requireFileField(definition, query.field);
|
|
93
|
+
const policy = await this.settings.policyFor(orgId);
|
|
94
|
+
await this.enforcePublicUploadCap(orgId, policy);
|
|
95
|
+
|
|
96
|
+
const uploadToken = randomBytes(32).toString('base64url');
|
|
97
|
+
const record = await this.storeUpload(orgId, field, file, policy, {
|
|
98
|
+
ownerId: null,
|
|
99
|
+
claimTokenHash: createHash('sha256').update(uploadToken).digest('hex'),
|
|
100
|
+
uploadedIp: this.requestContext.getIpAddress() ?? null,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return { ...this.toResponse(record), uploadToken };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async getMeta(orgId: string, fileId: string) {
|
|
107
|
+
this.requestContext.assertOrgScope(orgId);
|
|
108
|
+
const record = await this.files.findById(orgId, fileId);
|
|
109
|
+
if (!record) {
|
|
110
|
+
throw new NotFoundException('File was not found.');
|
|
111
|
+
}
|
|
112
|
+
return this.toResponse(record);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Storage keys never leave the server. */
|
|
116
|
+
private toResponse(record: UploadedFileRecord) {
|
|
117
|
+
return {
|
|
118
|
+
fileId: record.id,
|
|
119
|
+
originalName: record.originalName,
|
|
120
|
+
mimeType: record.mimeType,
|
|
121
|
+
size: record.size,
|
|
122
|
+
checksum: record.checksum,
|
|
123
|
+
status: record.status,
|
|
124
|
+
createdAt: record.createdAt,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private assertUploadRequest(
|
|
129
|
+
query: UploadFileQueryDto,
|
|
130
|
+
file: UploadedMultipartFile | undefined,
|
|
131
|
+
): asserts file is UploadedMultipartFile {
|
|
55
132
|
if (!file || !file.buffer || file.size === 0) {
|
|
56
133
|
throw new BadRequestException('A non-empty file is required (multipart field "file").');
|
|
57
134
|
}
|
|
58
135
|
if (!query.field) {
|
|
59
136
|
throw new BadRequestException('The "field" query parameter is required.');
|
|
60
137
|
}
|
|
138
|
+
}
|
|
61
139
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
140
|
+
private async storeUpload(
|
|
141
|
+
orgId: string,
|
|
142
|
+
field: FieldDef,
|
|
143
|
+
file: UploadedMultipartFile,
|
|
144
|
+
policy: OrgFormsPolicy,
|
|
145
|
+
ownership: { ownerId: string | null; claimTokenHash?: string; uploadedIp?: string | null },
|
|
146
|
+
) {
|
|
69
147
|
const capMb = Math.min(field.maxSizeMb ?? Infinity, policy.maxFileSizeMb ?? Infinity);
|
|
70
148
|
if (Number.isFinite(capMb) && file.size > capMb * 1024 * 1024) {
|
|
71
149
|
throw new PayloadTooLargeException(`File exceeds the ${capMb} MB limit for this field.`);
|
|
@@ -84,39 +162,38 @@ export class FormsFilesService {
|
|
|
84
162
|
const checksum = createHash('sha256').update(file.buffer).digest('hex');
|
|
85
163
|
const storageKey = `${orgId}/${randomUUID()}`;
|
|
86
164
|
await this.storage.put(storageKey, file.buffer);
|
|
87
|
-
|
|
165
|
+
return this.files.create({
|
|
88
166
|
storageKey,
|
|
89
167
|
originalName: file.originalname,
|
|
90
168
|
mimeType: sniffed.mimeType,
|
|
91
169
|
size: file.size,
|
|
92
170
|
checksum,
|
|
93
|
-
ownerId:
|
|
171
|
+
ownerId: ownership.ownerId,
|
|
172
|
+
claimTokenHash: ownership.claimTokenHash ?? null,
|
|
173
|
+
uploadedIp: ownership.uploadedIp ?? null,
|
|
94
174
|
orgId,
|
|
95
175
|
status: 'TEMPORARY',
|
|
96
176
|
});
|
|
97
|
-
return this.toResponse(record);
|
|
98
177
|
}
|
|
99
178
|
|
|
100
|
-
async
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
if (!
|
|
104
|
-
|
|
179
|
+
private async enforcePublicUploadCap(orgId: string, policy: OrgFormsPolicy): Promise<void> {
|
|
180
|
+
const cap = policy.maxPublicUploadsPerIpPerDay;
|
|
181
|
+
const ipAddress = this.requestContext.getIpAddress();
|
|
182
|
+
if (!cap || cap <= 0 || !ipAddress) {
|
|
183
|
+
return;
|
|
105
184
|
}
|
|
106
|
-
return this.toResponse(record);
|
|
107
|
-
}
|
|
108
185
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
186
|
+
const count = await this.files.countPublicUploadsByIpSince(
|
|
187
|
+
orgId,
|
|
188
|
+
ipAddress,
|
|
189
|
+
new Date(Date.now() - DAY_MS),
|
|
190
|
+
);
|
|
191
|
+
if (count >= cap) {
|
|
192
|
+
throw new HttpException(
|
|
193
|
+
'Public upload limit reached. Please try again later.',
|
|
194
|
+
HttpStatus.TOO_MANY_REQUESTS,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
120
197
|
}
|
|
121
198
|
|
|
122
199
|
private async resolveTargetDefinition(
|
|
@@ -152,6 +229,33 @@ export class FormsFilesService {
|
|
|
152
229
|
return record;
|
|
153
230
|
}
|
|
154
231
|
|
|
232
|
+
private async resolvePublicDefinition(
|
|
233
|
+
orgId: string,
|
|
234
|
+
formKey: string,
|
|
235
|
+
query: UploadFileQueryDto,
|
|
236
|
+
): Promise<FormDefinitionRecord> {
|
|
237
|
+
const record =
|
|
238
|
+
query.version !== undefined
|
|
239
|
+
? await this.definitions.findByKeyVersion(orgId, formKey, query.version)
|
|
240
|
+
: await this.definitions.findLatest(orgId, formKey, 'PUBLISHED');
|
|
241
|
+
if (
|
|
242
|
+
!record ||
|
|
243
|
+
record.status !== 'PUBLISHED' ||
|
|
244
|
+
(record.schema.settings?.access ?? 'private') !== 'public'
|
|
245
|
+
) {
|
|
246
|
+
throw new NotFoundException('Form was not found.');
|
|
247
|
+
}
|
|
248
|
+
return record;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private requireFileField(definition: FormDefinitionRecord, name: string): FieldDef {
|
|
252
|
+
const field = this.findFileField(definition.schema.fields, name);
|
|
253
|
+
if (!field) {
|
|
254
|
+
throw new NotFoundException(`Form has no file field named "${name}".`);
|
|
255
|
+
}
|
|
256
|
+
return field;
|
|
257
|
+
}
|
|
258
|
+
|
|
155
259
|
private findFileField(fields: FieldDef[], name: string): FieldDef | undefined {
|
|
156
260
|
let found: FieldDef | undefined;
|
|
157
261
|
walkFields(fields, (field) => {
|
|
@@ -9,7 +9,8 @@ const FORMS_SETTING_KEYS = [
|
|
|
9
9
|
'forms.maxFileSizeMb',
|
|
10
10
|
'forms.enableRuleIteration',
|
|
11
11
|
'forms.virusScanRequired',
|
|
12
|
-
'forms.maxSubmissionsPerIpPerDay',
|
|
12
|
+
'forms.maxSubmissionsPerIpPerDay',
|
|
13
|
+
'forms.maxPublicUploadsPerIpPerDay',
|
|
13
14
|
'forms.webhookAllowedHosts',
|
|
14
15
|
] as const;
|
|
15
16
|
|
|
@@ -18,7 +19,7 @@ const FORMS_SETTING_KEYS = [
|
|
|
18
19
|
* setting definitions' defaults for unset keys (the SettingsService 404s on
|
|
19
20
|
* unset keys, which is wrong for policy reads). The result feeds every
|
|
20
21
|
* engine call that takes an OrgFormsPolicy — dangerous-action gating, rule
|
|
21
|
-
* iteration, file caps, public
|
|
22
|
+
* iteration, file caps, public submission/upload caps.
|
|
22
23
|
*/
|
|
23
24
|
@Injectable()
|
|
24
25
|
export class FormsSettingsReader {
|
|
@@ -45,7 +46,8 @@ export class FormsSettingsReader {
|
|
|
45
46
|
enableRuleIteration: read<boolean>('forms.enableRuleIteration'),
|
|
46
47
|
maxFileSizeMb: read<number>('forms.maxFileSizeMb'),
|
|
47
48
|
virusScanRequired: read<boolean>('forms.virusScanRequired'),
|
|
48
|
-
maxSubmissionsPerIpPerDay: read<number>('forms.maxSubmissionsPerIpPerDay'),
|
|
49
|
+
maxSubmissionsPerIpPerDay: read<number>('forms.maxSubmissionsPerIpPerDay'),
|
|
50
|
+
maxPublicUploadsPerIpPerDay: read<number>('forms.maxPublicUploadsPerIpPerDay'),
|
|
49
51
|
webhookAllowedHosts: read<string[]>('forms.webhookAllowedHosts'),
|
|
50
52
|
captchaConfigured: this.captcha != null,
|
|
51
53
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
+
import { FileUploadResponseDto } from './file-upload-response.dto';
|
|
3
|
+
|
|
4
|
+
export class PublicFileUploadResponseDto extends FileUploadResponseDto {
|
|
5
|
+
@ApiProperty({
|
|
6
|
+
example: 'qg7N4gJvG7lQzZ5aH8gHovC6hRr0QZq1LurNkrXJq6w',
|
|
7
|
+
description: 'One-time claim token required when submitting this public file reference.',
|
|
8
|
+
})
|
|
9
|
+
uploadToken!: string;
|
|
10
|
+
}
|
|
@@ -16,6 +16,15 @@ export class PublicSubmitFormDto {
|
|
|
16
16
|
@Min(1)
|
|
17
17
|
version?: number;
|
|
18
18
|
|
|
19
|
+
@ApiPropertyOptional({
|
|
20
|
+
type: 'object',
|
|
21
|
+
additionalProperties: { type: 'string' },
|
|
22
|
+
description: 'Public upload claim tokens keyed by fileId.',
|
|
23
|
+
})
|
|
24
|
+
@IsOptional()
|
|
25
|
+
@IsObject()
|
|
26
|
+
uploadTokens?: Record<string, string>;
|
|
27
|
+
|
|
19
28
|
@ApiPropertyOptional({ description: 'Captcha token, required when the form enables captcha.' })
|
|
20
29
|
@IsOptional()
|
|
21
30
|
@IsString()
|
|
@@ -20,6 +20,7 @@ import { FormsFilesService } from './application/services/forms-files.service';
|
|
|
20
20
|
import { FormsPublicService } from './application/services/forms-public.service';
|
|
21
21
|
import { FormsSettingsReader } from './application/services/forms-settings-reader.service';
|
|
22
22
|
import { FormsSubmissionsService } from './application/services/forms-submissions.service';
|
|
23
|
+
import { ConferenceTracksDataSource } from './application/services/data-sources/conference-tracks.data-source';
|
|
23
24
|
import { AuthenticateActionHandler } from './application/services/handlers/authenticate.action';
|
|
24
25
|
import { LoggingEmailHandler } from './application/services/handlers/logging-email.handler';
|
|
25
26
|
import { SendConfirmationEmailAction } from './application/services/handlers/send-confirmation-email.action';
|
|
@@ -42,7 +43,8 @@ import { FormsDefinitionsController } from './presentation/forms-definitions.con
|
|
|
42
43
|
import { FormsFilesController } from './presentation/forms-files.controller';
|
|
43
44
|
import { FormsSubmissionsController } from './presentation/forms-submissions.controller';
|
|
44
45
|
import { FormsUploadInterceptor } from './presentation/forms-upload.interceptor';
|
|
45
|
-
import { PublicFormsController } from './presentation/public-forms.controller';
|
|
46
|
+
import { PublicFormsController } from './presentation/public-forms.controller';
|
|
47
|
+
import { PublicFormsFilesController } from './presentation/public-forms-files.controller';
|
|
46
48
|
import { FORMS_CAPTCHA_VERIFIER } from './forms.tokens';
|
|
47
49
|
|
|
48
50
|
/**
|
|
@@ -61,7 +63,8 @@ import { FORMS_CAPTCHA_VERIFIER } from './forms.tokens';
|
|
|
61
63
|
FormsDefinitionsController,
|
|
62
64
|
FormsFilesController,
|
|
63
65
|
FormsSubmissionsController,
|
|
64
|
-
PublicFormsController,
|
|
66
|
+
PublicFormsController,
|
|
67
|
+
PublicFormsFilesController,
|
|
65
68
|
],
|
|
66
69
|
providers: [
|
|
67
70
|
// Adapters over the ecosystem seams.
|
|
@@ -76,6 +79,8 @@ import { FORMS_CAPTCHA_VERIFIER } from './forms.tokens';
|
|
|
76
79
|
PrismaFileStore,
|
|
77
80
|
PrismaActionLogStore,
|
|
78
81
|
FormsSettingsReader,
|
|
82
|
+
// Secure default: public forms with settings.captcha=true fail publish
|
|
83
|
+
// until the app replaces this provider with a real CaptchaVerifier.
|
|
79
84
|
{ provide: FORMS_CAPTCHA_VERIFIER, useValue: undefined },
|
|
80
85
|
// Engine registries — singletons; core field types registered up front.
|
|
81
86
|
{
|
|
@@ -196,6 +201,9 @@ import { FORMS_CAPTCHA_VERIFIER } from './forms.tokens';
|
|
|
196
201
|
LocalDiskStorageAdapter,
|
|
197
202
|
FileGcService,
|
|
198
203
|
FormsUploadInterceptor,
|
|
204
|
+
// Example data source backing examples/abstract-submission.form.json.
|
|
205
|
+
// Delete or replace it with an org-scoped lookup in production apps.
|
|
206
|
+
ConferenceTracksDataSource,
|
|
199
207
|
// Default outbox handlers + shipped form actions.
|
|
200
208
|
LoggingEmailHandler,
|
|
201
209
|
WebhookOutboxHandler,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
1
2
|
import { Injectable } from '@nestjs/common';
|
|
2
3
|
import { Prisma, UploadedFile as UploadedFileRow } from '@prisma/client';
|
|
3
4
|
import type {
|
|
@@ -26,6 +27,8 @@ export class PrismaFileStore implements FileStore {
|
|
|
26
27
|
size: record.size,
|
|
27
28
|
checksum: record.checksum,
|
|
28
29
|
ownerId: record.ownerId,
|
|
30
|
+
claimTokenHash: record.claimTokenHash ?? null,
|
|
31
|
+
uploadedIp: record.uploadedIp ?? null,
|
|
29
32
|
orgId: record.orgId,
|
|
30
33
|
status: record.status,
|
|
31
34
|
},
|
|
@@ -34,12 +37,19 @@ export class PrismaFileStore implements FileStore {
|
|
|
34
37
|
return this.toRecord(row);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
async findById(
|
|
40
|
+
async findById(
|
|
41
|
+
orgId: string,
|
|
42
|
+
id: string,
|
|
43
|
+
tx?: EngineTx,
|
|
44
|
+
presentedClaimToken?: string,
|
|
45
|
+
): Promise<UploadedFileRecord | null> {
|
|
38
46
|
const row = await this.client(tx).uploadedFile.findFirst({
|
|
39
47
|
where: { id, orgId },
|
|
40
48
|
});
|
|
41
49
|
|
|
42
|
-
return row
|
|
50
|
+
return row
|
|
51
|
+
? this.toRecord(row, this.claimTokenMatches(row.claimTokenHash, presentedClaimToken))
|
|
52
|
+
: null;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
async updateStatus(
|
|
@@ -71,6 +81,21 @@ export class PrismaFileStore implements FileStore {
|
|
|
71
81
|
return this.toRecord(row);
|
|
72
82
|
}
|
|
73
83
|
|
|
84
|
+
async countPublicUploadsByIpSince(
|
|
85
|
+
orgId: string,
|
|
86
|
+
ipAddress: string,
|
|
87
|
+
since: Date,
|
|
88
|
+
): Promise<number> {
|
|
89
|
+
return this.client().uploadedFile.count({
|
|
90
|
+
where: {
|
|
91
|
+
orgId,
|
|
92
|
+
ownerId: null,
|
|
93
|
+
uploadedIp: ipAddress,
|
|
94
|
+
createdAt: { gte: since },
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
74
99
|
async listGcEligible(cutoff: Date, limit: number): Promise<UploadedFileRecord[]> {
|
|
75
100
|
const rows = await this.client().uploadedFile.findMany({
|
|
76
101
|
where: {
|
|
@@ -90,7 +115,21 @@ export class PrismaFileStore implements FileStore {
|
|
|
90
115
|
});
|
|
91
116
|
}
|
|
92
117
|
|
|
93
|
-
private
|
|
118
|
+
private claimTokenMatches(
|
|
119
|
+
expectedHash: string | null,
|
|
120
|
+
presentedClaimToken: string | undefined,
|
|
121
|
+
): boolean {
|
|
122
|
+
if (!expectedHash || !presentedClaimToken) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const presentedHash = createHash('sha256').update(presentedClaimToken).digest('hex');
|
|
127
|
+
const expected = Buffer.from(expectedHash, 'hex');
|
|
128
|
+
const presented = Buffer.from(presentedHash, 'hex');
|
|
129
|
+
return expected.length === presented.length && timingSafeEqual(expected, presented);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private toRecord(row: UploadedFileRow, claimTokenValid?: boolean): UploadedFileRecord {
|
|
94
133
|
return {
|
|
95
134
|
id: row.id,
|
|
96
135
|
storageKey: row.storageKey,
|
|
@@ -99,6 +138,7 @@ export class PrismaFileStore implements FileStore {
|
|
|
99
138
|
size: row.size,
|
|
100
139
|
checksum: row.checksum,
|
|
101
140
|
ownerId: row.ownerId,
|
|
141
|
+
claimTokenValid,
|
|
102
142
|
orgId: row.orgId,
|
|
103
143
|
status: row.status as FileStatus,
|
|
104
144
|
submissionId: row.submissionId,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Controller,
|
|
3
|
+
HttpCode,
|
|
4
|
+
Param,
|
|
5
|
+
Post,
|
|
6
|
+
Query,
|
|
7
|
+
UploadedFile,
|
|
8
|
+
UseGuards,
|
|
9
|
+
UseInterceptors,
|
|
10
|
+
} from '@nestjs/common';
|
|
11
|
+
import {
|
|
12
|
+
ApiBody,
|
|
13
|
+
ApiConsumes,
|
|
14
|
+
ApiOkResponse,
|
|
15
|
+
ApiOperation,
|
|
16
|
+
ApiParam,
|
|
17
|
+
ApiTags,
|
|
18
|
+
} from '@nestjs/swagger';
|
|
19
|
+
import { Throttle } from '@nestjs/throttler';
|
|
20
|
+
import { ApiProtectedErrorResponses } from '../../../common/swagger/api-error-responses';
|
|
21
|
+
import { Public } from '../../access-control/presentation/public.decorator';
|
|
22
|
+
import { OrgScopeGuard } from '../../request-context/presentation/org-scope.guard';
|
|
23
|
+
import { FormsFilesService } from '../application/services/forms-files.service';
|
|
24
|
+
import { PublicFileUploadResponseDto } from '../dto/public-file-upload-response.dto';
|
|
25
|
+
import { UploadFileQueryDto } from '../dto/upload-file-query.dto';
|
|
26
|
+
import { FormsUploadInterceptor } from './forms-upload.interceptor';
|
|
27
|
+
|
|
28
|
+
@ApiTags('Forms (public)')
|
|
29
|
+
@ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
|
|
30
|
+
@ApiParam({ name: 'formKey', description: 'Form definition key.' })
|
|
31
|
+
@ApiProtectedErrorResponses(400, 404, 413, 415, 429)
|
|
32
|
+
@Controller('public/organisations/:orgId/forms/:formKey/files')
|
|
33
|
+
@UseGuards(OrgScopeGuard)
|
|
34
|
+
export class PublicFormsFilesController {
|
|
35
|
+
constructor(private readonly filesService: FormsFilesService) {}
|
|
36
|
+
|
|
37
|
+
@Post()
|
|
38
|
+
@Public()
|
|
39
|
+
@HttpCode(200)
|
|
40
|
+
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
|
41
|
+
@UseInterceptors(FormsUploadInterceptor)
|
|
42
|
+
@ApiConsumes('multipart/form-data')
|
|
43
|
+
@ApiBody({
|
|
44
|
+
schema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: { file: { type: 'string', format: 'binary' } },
|
|
47
|
+
required: ['file'],
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
@ApiOperation({
|
|
51
|
+
summary:
|
|
52
|
+
'Upload a file for a public form field. Submit must include the returned fileId and uploadToken.',
|
|
53
|
+
})
|
|
54
|
+
@ApiOkResponse({
|
|
55
|
+
description: 'Uploaded public file reference.',
|
|
56
|
+
type: PublicFileUploadResponseDto,
|
|
57
|
+
})
|
|
58
|
+
upload(
|
|
59
|
+
@Param('orgId') orgId: string,
|
|
60
|
+
@Param('formKey') formKey: string,
|
|
61
|
+
@Query() query: UploadFileQueryDto,
|
|
62
|
+
@UploadedFile() file: { originalname: string; size: number; buffer: Buffer } | undefined,
|
|
63
|
+
) {
|
|
64
|
+
return this.filesService.uploadPublic(orgId, formKey, query, file);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -57,6 +57,10 @@ export const settingDefinitions = {
|
|
|
57
57
|
defaultValue: 50,
|
|
58
58
|
parse: parsePositiveInt('forms.maxSubmissionsPerIpPerDay', 100000),
|
|
59
59
|
},
|
|
60
|
+
'forms.maxPublicUploadsPerIpPerDay': {
|
|
61
|
+
defaultValue: 100,
|
|
62
|
+
parse: parsePositiveInt('forms.maxPublicUploadsPerIpPerDay', 100000),
|
|
63
|
+
},
|
|
60
64
|
// SSRF gate for definition webhooks: empty list = webhooks disabled until
|
|
61
65
|
// an admin explicitly allows destination hosts (changes are audited).
|
|
62
66
|
'forms.webhookAllowedHosts': {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
2
2
|
import { Test } from '@nestjs/testing';
|
|
3
|
-
import { DataSourceRegistry } from '@ftisindia/form-builder';
|
|
4
3
|
import request from 'supertest';
|
|
5
4
|
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
|
|
6
5
|
import { PrismaService } from '../src/database/prisma/prisma.service';
|
|
@@ -43,16 +42,6 @@ describe('Forms file uploads (e2e)', () => {
|
|
|
43
42
|
await app.init();
|
|
44
43
|
prisma = app.get(PrismaService);
|
|
45
44
|
|
|
46
|
-
// The abstract-like definition binds a select to this source; register the
|
|
47
|
-
// stub BEFORE creating the definition so save-time lint passes.
|
|
48
|
-
app.get(DataSourceRegistry).register({
|
|
49
|
-
key: 'conference-tracks',
|
|
50
|
-
fetch: () => Promise.resolve([
|
|
51
|
-
{ value: 'ai', label: 'Artificial Intelligence' },
|
|
52
|
-
{ value: 'ml', label: 'Machine Learning' },
|
|
53
|
-
]),
|
|
54
|
-
});
|
|
55
|
-
|
|
56
45
|
owner = await createUserAndOrg('files-owner');
|
|
57
46
|
formKey = await publishDefinition(abstractDefinition(uniqueFormKey('abstract')));
|
|
58
47
|
});
|
|
@@ -87,7 +76,7 @@ describe('Forms file uploads (e2e)', () => {
|
|
|
87
76
|
const draft = await request(app.getHttpServer())
|
|
88
77
|
.post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
|
|
89
78
|
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
90
|
-
.send({ mode: 'draft', data: { title: 'Versioned upload', track: '
|
|
79
|
+
.send({ mode: 'draft', data: { title: 'Versioned upload', track: 'clinical-research' } })
|
|
91
80
|
.expect(200);
|
|
92
81
|
|
|
93
82
|
const createdV2 = await request(app.getHttpServer())
|
|
@@ -136,7 +125,7 @@ describe('Forms file uploads (e2e)', () => {
|
|
|
136
125
|
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
137
126
|
.send({
|
|
138
127
|
mode: 'submit',
|
|
139
|
-
data: { title: 'On Linking Files', track: '
|
|
128
|
+
data: { title: 'On Linking Files', track: 'clinical-research', manuscript: { fileId } },
|
|
140
129
|
})
|
|
141
130
|
.expect(200);
|
|
142
131
|
|
|
@@ -159,7 +148,7 @@ describe('Forms file uploads (e2e)', () => {
|
|
|
159
148
|
.set('Authorization', `Bearer ${member.accessToken}`)
|
|
160
149
|
.send({
|
|
161
150
|
mode: 'submit',
|
|
162
|
-
data: { title: 'Stolen Reference', track: '
|
|
151
|
+
data: { title: 'Stolen Reference', track: 'public-health', manuscript: { fileId } },
|
|
163
152
|
})
|
|
164
153
|
.expect(422);
|
|
165
154
|
|
|
@@ -186,15 +175,48 @@ describe('Forms file uploads (e2e)', () => {
|
|
|
186
175
|
expect(row).toBeNull();
|
|
187
176
|
});
|
|
188
177
|
|
|
189
|
-
it('
|
|
190
|
-
const
|
|
191
|
-
|
|
178
|
+
it('accepts public uploads only with the returned claim token', async () => {
|
|
179
|
+
const key = await publishDefinition(abstractDefinition(uniqueFormKey('public-file')));
|
|
180
|
+
await request(app.getHttpServer())
|
|
181
|
+
.post(`/organisations/${owner.orgId}/forms/${key}/versions/1/public-access`)
|
|
192
182
|
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
193
183
|
.send({ access: 'public' })
|
|
184
|
+
.expect(200);
|
|
185
|
+
|
|
186
|
+
const upload = await request(app.getHttpServer())
|
|
187
|
+
.post(`/public/organisations/${owner.orgId}/forms/${key}/files`)
|
|
188
|
+
.query({ field: 'manuscript' })
|
|
189
|
+
.attach('file', PDF_BYTES, 'public-manuscript.pdf')
|
|
190
|
+
.expect(200);
|
|
191
|
+
|
|
192
|
+
const fileId = upload.body.fileId as string;
|
|
193
|
+
const uploadToken = upload.body.uploadToken as string;
|
|
194
|
+
expect(fileId).toBeTruthy();
|
|
195
|
+
expect(uploadToken).toBeTruthy();
|
|
196
|
+
expect(upload.body.status).toBe('TEMPORARY');
|
|
197
|
+
|
|
198
|
+
const rejected = await request(app.getHttpServer())
|
|
199
|
+
.post(`/public/organisations/${owner.orgId}/forms/${key}/submissions`)
|
|
200
|
+
.send({
|
|
201
|
+
data: { title: 'Missing token', track: 'clinical-research', manuscript: { fileId } },
|
|
202
|
+
})
|
|
194
203
|
.expect(422);
|
|
204
|
+
const errors = rejected.body.error.details.errors as Array<{ code: string; path: string }>;
|
|
205
|
+
expect(errors.some((error) => error.code === 'FILE_REFERENCE')).toBe(true);
|
|
206
|
+
|
|
207
|
+
const submitted = await request(app.getHttpServer())
|
|
208
|
+
.post(`/public/organisations/${owner.orgId}/forms/${key}/submissions`)
|
|
209
|
+
.send({
|
|
210
|
+
data: { title: 'Public file', track: 'clinical-research', manuscript: { fileId } },
|
|
211
|
+
uploadTokens: { [fileId]: uploadToken },
|
|
212
|
+
})
|
|
213
|
+
.expect(200);
|
|
195
214
|
|
|
196
|
-
const
|
|
197
|
-
|
|
215
|
+
const submissionId = submitted.body.submissionId as string;
|
|
216
|
+
const row = await prisma.uploadedFile.findUnique({ where: { id: fileId } });
|
|
217
|
+
expect(row?.ownerId).toBeNull();
|
|
218
|
+
expect(row?.status).toBe('LINKED');
|
|
219
|
+
expect(row?.submissionId).toBe(submissionId);
|
|
198
220
|
});
|
|
199
221
|
|
|
200
222
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
2
2
|
import { Test } from '@nestjs/testing';
|
|
3
|
-
import { ActionRegistry
|
|
3
|
+
import { ActionRegistry } from '@ftisindia/form-builder';
|
|
4
4
|
import request from 'supertest';
|
|
5
5
|
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
|
|
6
6
|
import { PrismaService } from '../src/database/prisma/prisma.service';
|
|
@@ -51,15 +51,6 @@ describe('Forms submissions engine flow (e2e)', () => {
|
|
|
51
51
|
execute: (ctx) => Promise.resolve({ ...ctx.data }),
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
const dataSources = app.get(DataSourceRegistry);
|
|
55
|
-
dataSources.register({
|
|
56
|
-
key: 'conference-tracks',
|
|
57
|
-
fetch: () => Promise.resolve([
|
|
58
|
-
{ value: 'ai', label: 'Artificial Intelligence' },
|
|
59
|
-
{ value: 'ml', label: 'Machine Learning' },
|
|
60
|
-
]),
|
|
61
|
-
});
|
|
62
|
-
|
|
63
54
|
owner = await createUserAndOrg('sub-owner');
|
|
64
55
|
});
|
|
65
56
|
|
|
@@ -263,7 +254,7 @@ describe('Forms submissions engine flow (e2e)', () => {
|
|
|
263
254
|
await request(app.getHttpServer())
|
|
264
255
|
.post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
|
|
265
256
|
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
266
|
-
.send({ mode: 'submit', data: { track: '
|
|
257
|
+
.send({ mode: 'submit', data: { track: 'clinical-research' } })
|
|
267
258
|
.expect(200);
|
|
268
259
|
});
|
|
269
260
|
|
|
@@ -12,7 +12,8 @@ import { FormsDataSourcesController } from '../src/modules/forms/presentation/fo
|
|
|
12
12
|
import { FormsDefinitionsController } from '../src/modules/forms/presentation/forms-definitions.controller';
|
|
13
13
|
import { FormsFilesController } from '../src/modules/forms/presentation/forms-files.controller';
|
|
14
14
|
import { FormsSubmissionsController } from '../src/modules/forms/presentation/forms-submissions.controller';
|
|
15
|
-
import { PublicFormsController } from '../src/modules/forms/presentation/public-forms.controller';
|
|
15
|
+
import { PublicFormsController } from '../src/modules/forms/presentation/public-forms.controller';
|
|
16
|
+
import { PublicFormsFilesController } from '../src/modules/forms/presentation/public-forms-files.controller';
|
|
16
17
|
import { InvitationsController } from '../src/modules/invitations/presentation/invitations.controller';
|
|
17
18
|
import { MembershipsController } from '../src/modules/memberships/presentation/memberships.controller';
|
|
18
19
|
import { OrganisationsController } from '../src/modules/organisations/presentation/organisations.controller';
|
|
@@ -34,7 +35,8 @@ const controllers = [
|
|
|
34
35
|
FormsDefinitionsController,
|
|
35
36
|
FormsFilesController,
|
|
36
37
|
FormsSubmissionsController,
|
|
37
|
-
PublicFormsController,
|
|
38
|
+
PublicFormsController,
|
|
39
|
+
PublicFormsFilesController,
|
|
38
40
|
InvitationsController,
|
|
39
41
|
MembershipsController,
|
|
40
42
|
OrganisationsController,
|