@ftisindia/create-app 0.1.6 → 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/.env.example +3 -0
- package/template/_package.json +7 -3
- package/template/docs/FORMS.md +87 -18
- package/template/docs/FORMS_CHECKLIST.md +36 -26
- package/template/docs/REPORTS.md +22 -4
- 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/migrations/20260625000000_form_builder_public_uploads/migration.sql +7 -0
- package/template/prisma/schema.prisma +13 -6
- package/template/scripts/push-report.ts +123 -0
- package/template/src/app.module.ts +4 -3
- package/template/src/common/swagger/api-error-responses.ts +8 -2
- 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/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/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/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 +12 -2
- package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +43 -3
- package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
- package/template/src/modules/forms/presentation/public-forms-files.controller.ts +66 -0
- 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/src/modules/settings/types/setting-definitions.ts +4 -0
- package/template/test/forms-captcha.e2e-spec.ts +163 -0
- package/template/test/forms-files.e2e-spec.ts +42 -20
- package/template/test/forms-outbox.e2e-spec.ts +271 -10
- package/template/test/forms-public.e2e-spec.ts +24 -0
- package/template/test/forms-submissions.e2e-spec.ts +2 -11
- 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
- package/template/test/route-registry.validator.spec.ts +4 -2
|
@@ -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
|
};
|
package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
2
|
+
import * as http from 'node:http';
|
|
3
|
+
import * as https from 'node:https';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
|
|
6
|
+
interface LookupAddress {
|
|
7
|
+
address: string;
|
|
8
|
+
family: 4 | 6;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface WebhookDeliveryOptions {
|
|
12
|
+
headers: Record<string, string>;
|
|
13
|
+
body: string;
|
|
14
|
+
timeoutMs: number;
|
|
15
|
+
lookup?: typeof dnsLookup;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface WebhookDeliveryResponse {
|
|
19
|
+
status: number;
|
|
20
|
+
statusText: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_HTTPS_PORT = 443;
|
|
24
|
+
const DEFAULT_HTTP_PORT = 80;
|
|
25
|
+
|
|
26
|
+
export async function deliverWebhookRequest(
|
|
27
|
+
rawUrl: string,
|
|
28
|
+
options: WebhookDeliveryOptions,
|
|
29
|
+
): Promise<WebhookDeliveryResponse> {
|
|
30
|
+
const url = parseWebhookUrl(rawUrl);
|
|
31
|
+
const endpoint = await resolveWebhookEndpoint(url, options.lookup ?? dnsLookup);
|
|
32
|
+
|
|
33
|
+
return sendPinnedRequest(url, endpoint, options);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseWebhookUrl(rawUrl: string): URL {
|
|
37
|
+
let url: URL;
|
|
38
|
+
try {
|
|
39
|
+
url = new URL(rawUrl);
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error('Webhook URL is invalid.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (url.username || url.password) {
|
|
45
|
+
throw new Error('Webhook URL must not include credentials.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
49
|
+
throw new Error('Webhook URL must use http or https.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return url;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function resolveWebhookEndpoint(url: URL, lookup: typeof dnsLookup): Promise<LookupAddress> {
|
|
56
|
+
const hostname = normalizeHostname(url.hostname);
|
|
57
|
+
const literalFamily = isIP(hostname);
|
|
58
|
+
const addresses: LookupAddress[] =
|
|
59
|
+
literalFamily === 0
|
|
60
|
+
? (await lookup(hostname, { all: true, verbatim: false })).map((entry) => ({
|
|
61
|
+
address: entry.address,
|
|
62
|
+
family: entry.family === 6 ? 6 : 4,
|
|
63
|
+
}))
|
|
64
|
+
: [{ address: hostname, family: literalFamily as 4 | 6 }];
|
|
65
|
+
|
|
66
|
+
if (addresses.length === 0) {
|
|
67
|
+
throw new Error('Webhook hostname did not resolve to an address.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const normalized = addresses.map((entry) => ({
|
|
71
|
+
address: normalizeHostname(entry.address),
|
|
72
|
+
family: entry.family,
|
|
73
|
+
}));
|
|
74
|
+
const allowLocalNetwork = process.env.NODE_ENV !== 'production';
|
|
75
|
+
const unsafe = normalized.filter((entry) => isReservedAddress(entry.address));
|
|
76
|
+
|
|
77
|
+
if (!allowLocalNetwork && unsafe.length > 0) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Webhook destination resolves to a private or reserved address (${unsafe[0]!.address}).`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (url.protocol === 'http:') {
|
|
84
|
+
if (!allowLocalNetwork) {
|
|
85
|
+
throw new Error('Webhook http destinations are allowed only outside production.');
|
|
86
|
+
}
|
|
87
|
+
if (normalized.some((entry) => !isLoopbackAddress(entry.address))) {
|
|
88
|
+
throw new Error('Webhook http destinations must resolve only to loopback addresses.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return normalized[0]!;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sendPinnedRequest(
|
|
96
|
+
url: URL,
|
|
97
|
+
endpoint: LookupAddress,
|
|
98
|
+
options: WebhookDeliveryOptions,
|
|
99
|
+
): Promise<WebhookDeliveryResponse> {
|
|
100
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
101
|
+
const requestOptions: https.RequestOptions = {
|
|
102
|
+
protocol: url.protocol,
|
|
103
|
+
hostname: endpoint.address,
|
|
104
|
+
family: endpoint.family,
|
|
105
|
+
port: url.port ? Number(url.port) : defaultPort(url),
|
|
106
|
+
method: 'POST',
|
|
107
|
+
path: `${url.pathname}${url.search}`,
|
|
108
|
+
headers: { ...options.headers, host: url.host },
|
|
109
|
+
timeout: options.timeoutMs,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const servername = normalizeHostname(url.hostname);
|
|
113
|
+
if (url.protocol === 'https:' && isIP(servername) === 0) {
|
|
114
|
+
requestOptions.servername = servername;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const req = transport.request(requestOptions, (res) => {
|
|
119
|
+
const status = res.statusCode ?? 0;
|
|
120
|
+
const statusText = res.statusMessage ?? '';
|
|
121
|
+
res.resume();
|
|
122
|
+
res.once('end', () => resolve({ status, statusText }));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
req.once('timeout', () => {
|
|
126
|
+
req.destroy(new Error('Webhook request timed out.'));
|
|
127
|
+
});
|
|
128
|
+
req.once('error', reject);
|
|
129
|
+
req.end(options.body);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function defaultPort(url: URL): number {
|
|
134
|
+
return url.protocol === 'https:' ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeHostname(hostname: string): string {
|
|
138
|
+
return hostname.replace(/^\[/u, '').replace(/\]$/u, '').toLowerCase();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isLoopbackAddress(address: string): boolean {
|
|
142
|
+
const family = isIP(address);
|
|
143
|
+
if (family === 4) {
|
|
144
|
+
const value = ipv4ToNumber(address);
|
|
145
|
+
return value !== null && inIpv4Range(value, '127.0.0.0', 8);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (family === 6) {
|
|
149
|
+
const value = ipv6ToBigInt(address);
|
|
150
|
+
return value === 1n;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isReservedAddress(address: string): boolean {
|
|
157
|
+
const family = isIP(address);
|
|
158
|
+
if (family === 4) {
|
|
159
|
+
const value = ipv4ToNumber(address);
|
|
160
|
+
if (value === null) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return IPV4_RESERVED_RANGES.some(([base, prefix]) => inIpv4Range(value, base, prefix));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (family === 6) {
|
|
168
|
+
return isReservedIpv6(address);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const IPV4_RESERVED_RANGES: Array<[base: string, prefix: number]> = [
|
|
175
|
+
['0.0.0.0', 8],
|
|
176
|
+
['10.0.0.0', 8],
|
|
177
|
+
['100.64.0.0', 10],
|
|
178
|
+
['127.0.0.0', 8],
|
|
179
|
+
['169.254.0.0', 16],
|
|
180
|
+
['172.16.0.0', 12],
|
|
181
|
+
['192.0.0.0', 24],
|
|
182
|
+
['192.0.2.0', 24],
|
|
183
|
+
['192.168.0.0', 16],
|
|
184
|
+
['198.18.0.0', 15],
|
|
185
|
+
['198.51.100.0', 24],
|
|
186
|
+
['203.0.113.0', 24],
|
|
187
|
+
['224.0.0.0', 4],
|
|
188
|
+
['240.0.0.0', 4],
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
function ipv4ToNumber(address: string): number | null {
|
|
192
|
+
const parts = address.split('.');
|
|
193
|
+
if (parts.length !== 4) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let value = 0;
|
|
198
|
+
for (const part of parts) {
|
|
199
|
+
if (!/^\d+$/u.test(part)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const byte = Number(part);
|
|
203
|
+
if (byte < 0 || byte > 255) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
value = (value << 8) + byte;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return value >>> 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function inIpv4Range(value: number, base: string, prefix: number): boolean {
|
|
213
|
+
const baseValue = ipv4ToNumber(base);
|
|
214
|
+
if (baseValue === null) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
|
219
|
+
return (value & mask) === (baseValue & mask);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isReservedIpv6(address: string): boolean {
|
|
223
|
+
const value = ipv6ToBigInt(address);
|
|
224
|
+
if (value === null) {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const mappedIpv4 = ipv4MappedAddress(value);
|
|
229
|
+
if (mappedIpv4 !== null) {
|
|
230
|
+
return IPV4_RESERVED_RANGES.some(([base, prefix]) => inIpv4Range(mappedIpv4, base, prefix));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return IPV6_RESERVED_RANGES.some(([base, prefix]) => inIpv6Range(value, base, prefix));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const IPV6_RESERVED_RANGES: Array<[base: string, prefix: number]> = [
|
|
237
|
+
['::', 128],
|
|
238
|
+
['::1', 128],
|
|
239
|
+
['::', 8],
|
|
240
|
+
['64:ff9b::', 96],
|
|
241
|
+
['100::', 64],
|
|
242
|
+
['2001:2::', 48],
|
|
243
|
+
['2001:10::', 28],
|
|
244
|
+
['2001:db8::', 32],
|
|
245
|
+
['2002::', 16],
|
|
246
|
+
['fc00::', 7],
|
|
247
|
+
['fe80::', 10],
|
|
248
|
+
['ff00::', 8],
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
function ipv4MappedAddress(value: bigint): number | null {
|
|
252
|
+
const mappedPrefix = 0xffffn << 32n;
|
|
253
|
+
const prefix = value >> 32n;
|
|
254
|
+
if (prefix !== mappedPrefix >> 32n) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return Number(value & 0xffffffffn);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function inIpv6Range(value: bigint, base: string, prefix: number): boolean {
|
|
262
|
+
const baseValue = ipv6ToBigInt(base);
|
|
263
|
+
if (baseValue === null) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const mask = prefix === 0 ? 0n : ((1n << BigInt(prefix)) - 1n) << BigInt(128 - prefix);
|
|
268
|
+
return (value & mask) === (baseValue & mask);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function ipv6ToBigInt(address: string): bigint | null {
|
|
272
|
+
let normalized = address.toLowerCase();
|
|
273
|
+
const zoneIndex = normalized.indexOf('%');
|
|
274
|
+
if (zoneIndex !== -1) {
|
|
275
|
+
normalized = normalized.slice(0, zoneIndex);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (normalized.includes('.')) {
|
|
279
|
+
const lastColon = normalized.lastIndexOf(':');
|
|
280
|
+
const embedded = ipv4ToNumber(normalized.slice(lastColon + 1));
|
|
281
|
+
if (lastColon === -1 || embedded === null) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
const high = ((embedded >>> 16) & 0xffff).toString(16);
|
|
285
|
+
const low = (embedded & 0xffff).toString(16);
|
|
286
|
+
normalized = `${normalized.slice(0, lastColon)}:${high}:${low}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const halves = normalized.split('::');
|
|
290
|
+
if (halves.length > 2) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const head = splitIpv6Half(halves[0] ?? '');
|
|
295
|
+
const tail = splitIpv6Half(halves[1] ?? '');
|
|
296
|
+
const missing = halves.length === 2 ? 8 - head.length - tail.length : 0;
|
|
297
|
+
if (missing < 0 || (halves.length === 1 && head.length !== 8)) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const parts = [...head, ...Array.from({ length: missing }, () => '0'), ...tail];
|
|
302
|
+
if (parts.length !== 8) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let value = 0n;
|
|
307
|
+
for (const part of parts) {
|
|
308
|
+
if (!/^[\da-f]{1,4}$/u.test(part)) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
value = (value << 16n) + BigInt(Number.parseInt(part, 16));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function splitIpv6Half(half: string): string[] {
|
|
318
|
+
return half.length === 0 ? [] : half.split(':');
|
|
319
|
+
}
|