cabloy 5.1.101 → 5.1.103

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.
Files changed (128) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +36 -0
  3. package/CLAUDE.local.md +4 -0
  4. package/cabloy-docs/.vitepress/config.mjs +3 -0
  5. package/cabloy-docs/backend/file-guide.md +148 -0
  6. package/cabloy-docs/backend/image-guide.md +40 -40
  7. package/cabloy-docs/backend/jwt-guide.md +21 -0
  8. package/cabloy-docs/frontend/file-guide.md +147 -0
  9. package/cabloy-docs/frontend/image-guide.md +18 -9
  10. package/cabloy-docs/frontend/model-state-guide.md +12 -0
  11. package/cabloy-docs/frontend/use-state-data-best-practices.md +54 -0
  12. package/cabloy-docs/fullstack/file-workflow.md +135 -0
  13. package/cabloy-docs/fullstack/image-workflow.md +12 -3
  14. package/package.json +1 -1
  15. package/scripts/upgrade.ts +26 -10
  16. package/vona/packages-vona/vona/package.json +1 -1
  17. package/vona/src/suite/a-training/modules/training-record/src/.metadata/index.ts +30 -4
  18. package/vona/src/suite/a-training/modules/training-record/src/bean/fileScene.dossierFile.ts +1 -1
  19. package/vona/src/suite/a-training/modules/training-record/src/bean/imageScene.sceneImage.ts +1 -0
  20. package/vona/src/suite/a-training/modules/training-record/src/dto/recordSelectResItem.tsx +8 -6
  21. package/vona/src/suite/a-training/modules/training-record/src/dto/recordView.tsx +8 -6
  22. package/vona/src/suite/a-training/modules/training-record/src/index.ts +0 -1
  23. package/vona/src/suite/a-training/modules/training-record/src/lib/index.ts +0 -1
  24. package/vona/src/suite/a-training/modules/training-record/test/record.test.ts +10 -12
  25. package/vona/src/suite/a-training/modules/training-student/src/bean/imageScene.studentImage.ts +1 -0
  26. package/vona/src/suite/a-training/modules/training-student/src/dto/detailRecordBase.tsx +9 -10
  27. package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +2 -2
  28. package/vona/src/suite-vendor/a-file/modules/a-file/package.json +3 -2
  29. package/vona/src/suite-vendor/a-file/modules/a-file/src/.metadata/index.ts +155 -35
  30. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/bean.file.ts +283 -54
  31. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/bean.fileUploadPolicy.ts +46 -63
  32. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/meta.index.ts +12 -0
  33. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/meta.redlock.ts +1 -1
  34. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/meta.version.ts +3 -0
  35. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/schedule.fileDraftPrune.ts +29 -0
  36. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/serializerTransform.resolveView.ts +45 -0
  37. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/serializerTransform.resolveViews.ts +45 -0
  38. package/vona/src/suite-vendor/a-file/modules/a-file/src/config/config.ts +6 -0
  39. package/vona/src/suite-vendor/a-file/modules/a-file/src/controller/file.ts +64 -56
  40. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadFinalizeRequest.ts +13 -0
  41. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadFinalizeResponse.ts +10 -0
  42. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadRequest.ts +0 -3
  43. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadResponse.ts +2 -14
  44. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDownloadRequest.ts +3 -0
  45. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileUploadPolicyResponse.ts +3 -0
  46. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileUploadResponse.ts +0 -25
  47. package/vona/src/{suite/a-training/modules/training-record/src/dto/recordDossierFileView.ts → suite-vendor/a-file/modules/a-file/src/dto/fileView.ts} +3 -15
  48. package/vona/src/suite-vendor/a-file/modules/a-file/src/entity/file.ts +10 -0
  49. package/vona/src/suite-vendor/a-file/modules/a-file/src/lib/fileUploadUrl.ts +329 -0
  50. package/vona/src/suite-vendor/a-file/modules/a-file/src/lib/index.ts +1 -0
  51. package/vona/src/suite-vendor/a-file/modules/a-file/src/types/file.ts +51 -10
  52. package/vona/src/suite-vendor/a-file/modules/a-file/src/types/fileProvider.ts +8 -3
  53. package/vona/src/suite-vendor/a-file/modules/a-file/src/types/fileScene.ts +1 -1
  54. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/package.json +1 -1
  55. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/src/bean/fileProvider.cloudflare.ts +12 -4
  56. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/src/service/fileCloudflare.ts +52 -18
  57. package/vona/src/suite-vendor/a-file/modules/file-native/package.json +1 -1
  58. package/vona/src/suite-vendor/a-file/modules/file-native/src/bean/fileProvider.native.ts +5 -5
  59. package/vona/src/suite-vendor/a-file/modules/file-native/src/service/fileNative.ts +7 -8
  60. package/vona/src/suite-vendor/a-file/package.json +4 -4
  61. package/vona/src/suite-vendor/a-image/modules/a-image/package.json +1 -1
  62. package/vona/src/suite-vendor/a-image/modules/a-image/src/.metadata/index.ts +16 -19
  63. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.image.ts +99 -74
  64. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.imageUploadPolicy.ts +28 -68
  65. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/meta.version.ts +1 -1
  66. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/serializerTransform.resolveView.ts +3 -0
  67. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/serializerTransform.resolveViews.ts +3 -0
  68. package/vona/src/suite-vendor/a-image/modules/a-image/src/config/config.ts +4 -0
  69. package/vona/src/suite-vendor/a-image/modules/a-image/src/controller/image.ts +78 -34
  70. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDeliveryRequest.ts +3 -0
  71. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDirectUploadRequest.ts +0 -3
  72. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDirectUploadResponse.ts +1 -27
  73. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadPolicyResponse.ts +6 -0
  74. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadResponse.ts +2 -36
  75. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadUrlRequest.ts +0 -3
  76. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageView.ts +4 -27
  77. package/vona/src/suite-vendor/a-image/modules/a-image/src/entity/image.ts +1 -1
  78. package/vona/src/suite-vendor/a-image/modules/a-image/src/types/image.ts +36 -29
  79. package/vona/src/suite-vendor/a-image/modules/a-image/src/types/imageProvider.ts +4 -4
  80. package/vona/src/suite-vendor/a-image/modules/a-image/src/types/imageScene.ts +1 -0
  81. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/package.json +1 -1
  82. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/bean/imageProvider.cloudflare.ts +8 -11
  83. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/lib/cloudflareImageUrl.ts +4 -16
  84. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/service/imageCloudflare.ts +17 -16
  85. package/vona/src/suite-vendor/a-image/modules/image-native/package.json +2 -6
  86. package/vona/src/suite-vendor/a-image/modules/image-native/src/.metadata/index.ts +0 -37
  87. package/vona/src/suite-vendor/a-image/modules/image-native/src/bean/imageProvider.native.ts +13 -36
  88. package/vona/src/suite-vendor/a-image/modules/image-native/src/service/imageNative.ts +100 -113
  89. package/vona/src/suite-vendor/a-image/package.json +1 -1
  90. package/vona/src/suite-vendor/a-vona/modules/a-jwt/package.json +1 -1
  91. package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/config/config.ts +3 -1
  92. package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/service/jwtClient.ts +9 -4
  93. package/vona/src/suite-vendor/a-vona/modules/a-upload/package.json +1 -1
  94. package/vona/src/suite-vendor/a-vona/modules/a-upload/src/bean/interceptor.upload.ts +113 -64
  95. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  96. package/zova/packages-zova/zova/package.json +2 -2
  97. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +50 -12
  98. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +610 -114
  99. package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +44 -1
  100. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +50 -12
  101. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +610 -114
  102. package/zova/src/suite/cabloy-basic/modules/basic-file/src/.metadata/index.ts +16 -12
  103. package/zova/src/suite/cabloy-basic/modules/basic-file/src/api/file.ts +25 -25
  104. package/zova/src/suite/cabloy-basic/modules/basic-file/src/api/openapi/schemas.ts +18 -24
  105. package/zova/src/suite/cabloy-basic/modules/basic-file/src/api/openapi/types.ts +228 -291
  106. package/zova/src/suite/cabloy-basic/modules/basic-file/src/apiSchema/file.ts +5 -5
  107. package/zova/src/suite/cabloy-basic/modules/basic-file/src/bean/tableCell.file.tsx +23 -5
  108. package/zova/src/suite/cabloy-basic/modules/basic-file/src/component/formFieldFile/controller.tsx +72 -43
  109. package/zova/src/suite/cabloy-basic/modules/basic-file/src/lib/file.ts +22 -4
  110. package/zova/src/suite/cabloy-basic/modules/basic-image/src/.metadata/index.ts +16 -42
  111. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/image.ts +5 -25
  112. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/schemas.ts +10 -24
  113. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/types.ts +216 -333
  114. package/zova/src/suite/cabloy-basic/modules/basic-image/src/apiSchema/image.ts +0 -5
  115. package/zova/src/suite/cabloy-basic/modules/basic-image/src/bean/tableCell.image.tsx +38 -12
  116. package/zova/src/suite/cabloy-basic/modules/basic-image/src/component/formFieldImage/controller.tsx +79 -32
  117. package/zova/src/suite/cabloy-basic/modules/basic-image/src/lib/preview.ts +22 -4
  118. package/zova/src/suite/cabloy-basic/modules/basic-image/src/types/image.ts +3 -4
  119. package/zova/src/suite-vendor/a-zova/modules/a-model/package.json +1 -1
  120. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/utils.ts +29 -1
  121. package/zova/src/suite-vendor/a-zova/modules/a-model/src/types/query.ts +2 -2
  122. package/zova/src/suite-vendor/a-zova/package.json +2 -2
  123. package/vona/src/suite/a-training/modules/training-record/src/lib/resolveDossierFiles.ts +0 -34
  124. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadTokenRequest.ts +0 -24
  125. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadTokenResponse.ts +0 -16
  126. package/vona/src/suite-vendor/a-image/modules/image-native/src/controller/image.ts +0 -57
  127. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/imageNativeImage.ts +0 -31
  128. package/zova/src/suite/cabloy-basic/modules/basic-image/src/apiSchema/imageNativeImage.ts +0 -13
@@ -0,0 +1,329 @@
1
+ import fse from 'fs-extra';
2
+ import { lookup as dnsLookup } from 'node:dns/promises';
3
+ import https from 'node:https';
4
+ import net from 'node:net';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { Transform } from 'node:stream';
8
+ import { pipeline } from 'node:stream/promises';
9
+
10
+ import type { IFileUploadUrlPolicyResolved } from '../types/file.ts';
11
+
12
+ import { getFileExtension, matchesFileMimeType } from './fileUploadValidation.ts';
13
+
14
+ const MAX_REDIRECTS = 3;
15
+ const CONNECT_TIMEOUT = 10_000;
16
+ const RESPONSE_TIMEOUT = 15_000;
17
+ const READ_TIMEOUT = 15_000;
18
+ const OVERALL_TIMEOUT = 30_000;
19
+
20
+ export interface IRemoteFileUploadUrlInput {
21
+ url: string;
22
+ filename?: string;
23
+ policy: IFileUploadUrlPolicyResolved;
24
+ }
25
+
26
+ export interface IRemoteFileUploadUrlResult {
27
+ file: string;
28
+ filename: string;
29
+ contentType?: string;
30
+ size: number;
31
+ cleanup: () => Promise<void>;
32
+ }
33
+
34
+ interface IRemoteResponse {
35
+ request: import('node:http').ClientRequest;
36
+ response: import('node:http').IncomingMessage;
37
+ }
38
+
39
+ export async function downloadFileUploadUrl(
40
+ input: IRemoteFileUploadUrlInput,
41
+ ): Promise<IRemoteFileUploadUrlResult> {
42
+ const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), 'cabloy-file-url-'));
43
+ let active: import('node:http').ClientRequest | import('node:http').IncomingMessage | undefined;
44
+ const overallTimer = setTimeout(() => {
45
+ active?.destroy(new Error('Remote file fetch timed out'));
46
+ }, OVERALL_TIMEOUT);
47
+ try {
48
+ let url = validateRemoteUrl(input.url);
49
+ let remote: IRemoteResponse | undefined;
50
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
51
+ remote = await requestRemote(url, item => {
52
+ active = item;
53
+ });
54
+ const location = remote.response.headers.location;
55
+ if (isRedirect(remote.response.statusCode) && location) {
56
+ if (redirects === MAX_REDIRECTS) {
57
+ throw new Error(`Remote file exceeded redirect limit: ${MAX_REDIRECTS}`);
58
+ }
59
+ remote.response.resume();
60
+ url = validateRemoteUrl(new URL(location, url).toString());
61
+ continue;
62
+ }
63
+ break;
64
+ }
65
+ if (!remote || isRedirect(remote.response.statusCode)) {
66
+ throw new Error('Remote file redirect response is missing a location');
67
+ }
68
+ if (
69
+ !remote.response.statusCode ||
70
+ remote.response.statusCode < 200 ||
71
+ remote.response.statusCode >= 300
72
+ ) {
73
+ throw new Error(`Remote file fetch failed: ${remote.response.statusCode ?? 0}`);
74
+ }
75
+
76
+ const filename = sanitizeFilename(input.filename) ?? filenameFromUrl(url);
77
+ validateExtension(filename, input.policy);
78
+ const contentType = normalizeContentType(remote.response.headers['content-type']);
79
+ validateContentType(contentType, input.policy);
80
+ const contentLength = parseContentLength(remote.response.headers['content-length']);
81
+ if (contentLength !== undefined) validateSize(contentLength, input.policy);
82
+
83
+ const file = path.join(tempDir, 'download');
84
+ let size = 0;
85
+ const limit = new Transform({
86
+ transform(chunk: Buffer, _encoding, callback) {
87
+ size += chunk.length;
88
+ try {
89
+ validateSize(size, input.policy);
90
+ callback(null, chunk);
91
+ } catch (error) {
92
+ callback(error as Error);
93
+ }
94
+ },
95
+ });
96
+ remote.response.setTimeout(READ_TIMEOUT, () => {
97
+ remote.response.destroy(new Error('Remote file read timed out'));
98
+ });
99
+ active = remote.response;
100
+ await pipeline(remote.response, limit, fse.createWriteStream(file, { mode: 0o600 }));
101
+ validateSize(size, input.policy);
102
+ return {
103
+ file,
104
+ filename,
105
+ contentType,
106
+ size,
107
+ cleanup: async () => await fse.remove(tempDir),
108
+ };
109
+ } catch (error) {
110
+ await fse.remove(tempDir);
111
+ throw error;
112
+ } finally {
113
+ clearTimeout(overallTimer);
114
+ }
115
+ }
116
+
117
+ function validateRemoteUrl(value: string) {
118
+ let url: URL;
119
+ try {
120
+ url = new URL(value);
121
+ } catch {
122
+ throw new TypeError('Remote file URL is invalid');
123
+ }
124
+ if (url.protocol !== 'https:') {
125
+ throw new TypeError('Remote file URL must use HTTPS');
126
+ }
127
+ if (url.username || url.password) {
128
+ throw new TypeError('Remote file URL must not include credentials');
129
+ }
130
+ if (url.port && url.port !== '443') {
131
+ throw new TypeError('Remote file URL must use port 443');
132
+ }
133
+ if (!url.hostname) {
134
+ throw new TypeError('Remote file URL must include a hostname');
135
+ }
136
+ return url;
137
+ }
138
+
139
+ async function requestRemote(
140
+ url: URL,
141
+ setActive: (
142
+ item: import('node:http').ClientRequest | import('node:http').IncomingMessage,
143
+ ) => void,
144
+ ): Promise<IRemoteResponse> {
145
+ const address = await resolvePublicAddress(url.hostname);
146
+ return await new Promise<IRemoteResponse>((resolve, reject) => {
147
+ let settled = false;
148
+ const rejectOnce = (error: Error) => {
149
+ if (settled) return;
150
+ settled = true;
151
+ reject(error);
152
+ };
153
+ const request = https.request(
154
+ url,
155
+ {
156
+ agent: false,
157
+ lookup(hostname, _options, callback) {
158
+ if (hostname !== url.hostname) {
159
+ callback(
160
+ Object.assign(new Error('Remote file DNS hostname mismatch'), {
161
+ code: 'EHOSTUNREACH',
162
+ }),
163
+ address.address,
164
+ address.family,
165
+ );
166
+ return;
167
+ }
168
+ callback(null, address.address, address.family);
169
+ },
170
+ },
171
+ response => {
172
+ if (settled) {
173
+ response.destroy();
174
+ return;
175
+ }
176
+ settled = true;
177
+ response.setTimeout(RESPONSE_TIMEOUT, () => {
178
+ response.destroy(new Error('Remote file response timed out'));
179
+ });
180
+ setActive(response);
181
+ resolve({ request, response });
182
+ },
183
+ );
184
+ request.setTimeout(CONNECT_TIMEOUT, () => {
185
+ request.destroy(new Error('Remote file connection timed out'));
186
+ });
187
+ request.once('error', rejectOnce);
188
+ setActive(request);
189
+ request.end();
190
+ });
191
+ }
192
+
193
+ async function resolvePublicAddress(hostname: string) {
194
+ const literalFamily = net.isIP(hostname);
195
+ const addresses = literalFamily
196
+ ? [{ address: hostname, family: literalFamily }]
197
+ : await dnsLookup(hostname, { all: true, verbatim: true });
198
+ if (!addresses.length) throw new Error('Remote file hostname did not resolve');
199
+ for (const address of addresses) {
200
+ if (!isPublicAddress(address.address, address.family)) {
201
+ throw new Error(`Remote file hostname resolved to a prohibited address: ${address.address}`);
202
+ }
203
+ }
204
+ return addresses[0]!;
205
+ }
206
+
207
+ function isRedirect(statusCode?: number) {
208
+ return [301, 302, 303, 307, 308].includes(statusCode ?? 0);
209
+ }
210
+
211
+ function normalizeContentType(value: string | string[] | undefined) {
212
+ const contentType = Array.isArray(value) ? value[0] : value;
213
+ return contentType?.split(';', 1)[0]?.trim().toLowerCase() || undefined;
214
+ }
215
+
216
+ function parseContentLength(value: string | string[] | undefined) {
217
+ const contentLength = Array.isArray(value) ? value[0] : value;
218
+ if (contentLength === undefined) return undefined;
219
+ if (!/^\d+$/.test(contentLength)) throw new Error('Remote file Content-Length is invalid');
220
+ const size = Number(contentLength);
221
+ if (!Number.isSafeInteger(size)) throw new Error('Remote file Content-Length is invalid');
222
+ return size;
223
+ }
224
+
225
+ function validateSize(size: number, policy: IFileUploadUrlPolicyResolved) {
226
+ if (policy.maxSize !== undefined && size > policy.maxSize) {
227
+ throw new Error(`Remote file is too large: maxSize=${policy.maxSize}`);
228
+ }
229
+ }
230
+
231
+ function validateContentType(
232
+ contentType: string | undefined,
233
+ policy: IFileUploadUrlPolicyResolved,
234
+ ) {
235
+ if (
236
+ policy.mimeTypes?.length &&
237
+ (!contentType || !matchesFileMimeType(contentType, policy.mimeTypes))
238
+ ) {
239
+ throw new Error(`Unsupported remote file mimeType: ${contentType ?? 'missing'}`);
240
+ }
241
+ }
242
+
243
+ function validateExtension(filename: string, policy: IFileUploadUrlPolicyResolved) {
244
+ const extension = getFileExtension(filename);
245
+ if (policy.extensions?.length && !policy.extensions.includes(extension)) {
246
+ throw new Error(`Unsupported remote file extension: ${extension || 'missing'}`);
247
+ }
248
+ }
249
+
250
+ function filenameFromUrl(url: URL) {
251
+ const pathname = decodeURIComponent(url.pathname);
252
+ return sanitizeFilename(path.posix.basename(pathname)) ?? 'download';
253
+ }
254
+
255
+ function sanitizeFilename(value: string | undefined) {
256
+ if (!value) return undefined;
257
+ const filename = path
258
+ .basename(value.replaceAll('\\', '/'))
259
+ .replace(/\p{Cc}/gu, '')
260
+ .trim();
261
+ return filename || undefined;
262
+ }
263
+
264
+ function isPublicAddress(address: string, family: number) {
265
+ if (family === 4) return isPublicIpv4(address);
266
+ if (family === 6) return isPublicIpv6(address);
267
+ return false;
268
+ }
269
+
270
+ function isPublicIpv4(address: string) {
271
+ const octets = address.split('.').map(Number);
272
+ if (
273
+ octets.length !== 4 ||
274
+ octets.some(item => !Number.isInteger(item) || item < 0 || item > 255)
275
+ ) {
276
+ return false;
277
+ }
278
+ const [a, b, c] = octets;
279
+ if (a === 0 || a === 10 || a === 127 || a >= 224) return false;
280
+ if (a === 100 && b >= 64 && b <= 127) return false;
281
+ if (a === 169 && b === 254) return false;
282
+ if (a === 172 && b >= 16 && b <= 31) return false;
283
+ if (a === 192 && (b === 0 || b === 168)) return false;
284
+ if (a === 192 && b === 88 && c === 99) return false;
285
+ if (a === 192 && b === 0 && c === 2) return false;
286
+ if (a === 198 && (b === 18 || b === 19 || b === 51)) return false;
287
+ if (a === 203 && b === 0 && c === 113) return false;
288
+ return true;
289
+ }
290
+
291
+ function isPublicIpv6(address: string) {
292
+ const normalized = address.toLowerCase();
293
+ if (normalized === '::' || normalized === '::1') return false;
294
+ const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
295
+ if (mapped) return isPublicIpv4(mapped[1]!);
296
+ const value = parseIpv6(normalized);
297
+ if (value === undefined) return false;
298
+ if (inIpv6Range(value, 0xfc000000000000000000000000000000n, 7)) return false;
299
+ if (inIpv6Range(value, 0xfe800000000000000000000000000000n, 10)) return false;
300
+ if (inIpv6Range(value, 0xff000000000000000000000000000000n, 8)) return false;
301
+ if (!inIpv6Range(value, 0x20000000000000000000000000000000n, 3)) return false;
302
+ if (inIpv6Range(value, 0x20010db8000000000000000000000000n, 32)) return false;
303
+ if (inIpv6Range(value, 0x2001000000000000000000000000000n, 32)) return false;
304
+ if (inIpv6Range(value, 0x2002000000000000000000000000000n, 16)) return false;
305
+ return true;
306
+ }
307
+
308
+ function parseIpv6(value: string): bigint | undefined {
309
+ const parts = value.split('::');
310
+ if (parts.length > 2) return undefined;
311
+ const [head, tail] = parts;
312
+ const left = head ? head.split(':') : [];
313
+ const right = tail ? tail.split(':') : [];
314
+ if ((tail === undefined && left.length !== 8) || left.length + right.length > 8) {
315
+ return undefined;
316
+ }
317
+ const groups: string[] = [
318
+ ...left,
319
+ ...Array.from<string>({ length: 8 - left.length - right.length }).fill('0'),
320
+ ...right,
321
+ ];
322
+ if (groups.some(group => !/^[0-9a-f]{1,4}$/.test(group))) return undefined;
323
+ return groups.reduce<bigint>((result, group) => (result << 16n) + BigInt(`0x${group}`), 0n);
324
+ }
325
+
326
+ function inIpv6Range(value: bigint, prefix: bigint, bits: number) {
327
+ const mask = ((1n << BigInt(bits)) - 1n) << BigInt(128 - bits);
328
+ return (value & mask) === (prefix & mask);
329
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './fileProvider.ts';
2
2
  export * from './fileScene.ts';
3
+ export * from './fileUploadUrl.ts';
3
4
  export * from './fileUploadValidation.ts';
@@ -4,12 +4,18 @@ import type { IFileProviderClientOptions, IFileProviderRecord } from './fileProv
4
4
  import type { IFileSceneRecord } from './fileScene.ts';
5
5
 
6
6
  export type TypeFileMeta = Record<string, unknown>;
7
- export type TypeFileDeliveryExpiry = Date | string | number;
7
+ export type TypeFileDirectUploadExpiry = Date | string | number;
8
+ export type TypeFileStatus = 'draft' | 'ready' | 'expired';
8
9
 
9
10
  export interface IFileDeliveryOptions {
10
- signed?: boolean;
11
11
  expiresIn?: number;
12
- expiresAt?: TypeFileDeliveryExpiry;
12
+ audience?: boolean;
13
+ responseMode?: 'auto' | 'buffer' | 'url';
14
+ }
15
+
16
+ export interface IFileProviderDeliveryOptions {
17
+ protected: boolean;
18
+ expiresIn?: number;
13
19
  responseMode?: 'auto' | 'buffer' | 'url';
14
20
  }
15
21
 
@@ -25,6 +31,7 @@ export interface IFileUploadInput<TMeta extends TypeFileMeta = TypeFileMeta> {
25
31
 
26
32
  export interface IFileUploadUrlInput<TMeta extends TypeFileMeta = TypeFileMeta> {
27
33
  url: string;
34
+ policy?: IFileUploadUrlPolicyResolved;
28
35
  filename?: string;
29
36
  contentType?: string;
30
37
  size?: number;
@@ -40,7 +47,7 @@ export interface IFileDirectUploadInput<TMeta extends TypeFileMeta = TypeFileMet
40
47
  objectKey?: string;
41
48
  public?: boolean;
42
49
  meta?: TMeta;
43
- expiry?: TypeFileDeliveryExpiry;
50
+ expiry?: TypeFileDirectUploadExpiry;
44
51
  }
45
52
 
46
53
  export interface IFileUploadContextResolved<TMeta extends TypeFileMeta = TypeFileMeta> {
@@ -92,6 +99,9 @@ export interface IFileResource<
92
99
  provider: keyof IFileProviderRecord;
93
100
  clientName: string;
94
101
  fileScene?: keyof IFileSceneRecord | string;
102
+ status?: TypeFileStatus;
103
+ draftExpiresAt?: Date;
104
+ finalizedAt?: Date;
95
105
  uploadedAt?: Date;
96
106
  }
97
107
 
@@ -104,6 +114,37 @@ export interface IFileDirectUploadResult<
104
114
  method?: 'PUT' | 'POST';
105
115
  }
106
116
 
117
+ export interface IFileActionResponse {
118
+ id: TableIdentity;
119
+ filename?: string;
120
+ contentType?: string;
121
+ size?: number;
122
+ public?: boolean;
123
+ uploadedAt?: Date;
124
+ url: string;
125
+ signed: boolean;
126
+ }
127
+
128
+ export interface IFileDirectUploadResponse {
129
+ id: TableIdentity;
130
+ uploadUrl: string;
131
+ headers?: Record<string, string>;
132
+ method?: 'PUT' | 'POST';
133
+ filename?: string;
134
+ public?: boolean;
135
+ }
136
+
137
+ export interface IFileView {
138
+ id: TableIdentity;
139
+ filename?: string;
140
+ contentType?: string;
141
+ size?: number;
142
+ public?: boolean;
143
+ uploadedAt?: Date;
144
+ downloadUrl: string;
145
+ signed?: boolean;
146
+ }
147
+
107
148
  export interface IFileUploadOptions<
108
149
  TClientOptions extends IFileProviderClientOptions = IFileProviderClientOptions,
109
150
  TMeta extends TypeFileMeta = TypeFileMeta,
@@ -115,26 +156,26 @@ export interface IFileUploadOptions<
115
156
  fileScene?: keyof IFileSceneRecord;
116
157
  }
117
158
 
118
- export interface IFileUploadPolicyResolved<
159
+ export interface IFileUploadUrlPolicyResolved<
119
160
  TMeta extends TypeFileMeta = TypeFileMeta,
120
161
  > extends IFileUploadContextResolved<TMeta> {
121
162
  maxSize?: number;
122
163
  mimeTypes?: string[];
123
164
  extensions?: string[];
124
165
  multiple?: boolean;
125
- fileSize: number;
126
- mimeType: string;
127
166
  }
128
167
 
129
- export interface IFileUploadTokenPayload<
168
+ export interface IFileUploadPolicyResolved<
130
169
  TMeta extends TypeFileMeta = TypeFileMeta,
131
- > extends IFileUploadPolicyResolved<TMeta> {
132
- kind: 'fileUpload';
170
+ > extends IFileUploadUrlPolicyResolved<TMeta> {
171
+ fileSize: number;
172
+ mimeType: string;
133
173
  }
134
174
 
135
175
  export interface IFileDownloadTokenPayload {
136
176
  kind: 'fileDownload';
137
177
  fileId: TableIdentity;
178
+ audienceUserId?: TableIdentity;
138
179
  }
139
180
 
140
181
  declare module 'vona' {
@@ -4,8 +4,8 @@ import type { ServiceOnion, TypeOnionOptionsEnableSimple } from 'vona-module-a-o
4
4
  import type { EntityFile } from '../entity/file.ts';
5
5
  import type { EntityFileProvider } from '../entity/fileProvider.ts';
6
6
  import type {
7
- IFileDeliveryOptions,
8
7
  IFileDirectUploadInput,
8
+ IFileProviderDeliveryOptions,
9
9
  IFileDownloadResult,
10
10
  IFileProviderDirectUploadResource,
11
11
  IFileProviderResource,
@@ -78,19 +78,24 @@ export interface IFileProviderExecute<
78
78
  clientOptions: T,
79
79
  options: O,
80
80
  ): Promise<IFileProviderDirectUploadResource>;
81
+ finalizeDirectUpload?(
82
+ file: EntityFile,
83
+ clientOptions: T,
84
+ options: O,
85
+ ): Promise<IFileProviderResource | undefined>;
81
86
  get(file: EntityFile, clientOptions: T, options: O): Promise<IFileProviderResource | undefined>;
82
87
  delete(file: EntityFile, clientOptions: T, options: O): Promise<void>;
83
88
  getDownloadUrl(
84
89
  file: EntityFile,
85
90
  clientOptions: T,
86
91
  options: O,
87
- deliveryOptions?: IFileDeliveryOptions,
92
+ deliveryOptions?: IFileProviderDeliveryOptions,
88
93
  ): Promise<string>;
89
94
  download?(
90
95
  file: EntityFile,
91
96
  clientOptions: T,
92
97
  options: O,
93
- deliveryOptions?: IFileDeliveryOptions,
98
+ deliveryOptions?: IFileProviderDeliveryOptions,
94
99
  ): Promise<IFileDownloadResult>;
95
100
  }
96
101
 
@@ -10,7 +10,6 @@ export interface IFileSceneOptionsUpload {
10
10
  mimeTypes?: string[];
11
11
  extensions?: string[];
12
12
  multiple?: boolean;
13
- public?: boolean;
14
13
  }
15
14
 
16
15
  export interface IDecoratorFileSceneOptionsProvider {
@@ -33,6 +32,7 @@ export type TypeFileSceneOptionsMetaResolver = (
33
32
  export interface IDecoratorFileSceneOptions {
34
33
  provider?: TypeDecoratorFileSceneOptionsProvider;
35
34
  upload?: IFileSceneOptionsUpload;
35
+ public?: boolean;
36
36
  meta?: Record<string, any> | TypeFileSceneOptionsMetaResolver;
37
37
  }
38
38
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-file-cloudflare",
3
- "version": "5.0.3",
3
+ "version": "5.0.4",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -1,8 +1,8 @@
1
1
  import type {
2
2
  EntityFile,
3
3
  IDecoratorFileProviderOptions,
4
- IFileDeliveryOptions,
5
4
  IFileDirectUploadInput,
5
+ IFileProviderDeliveryOptions,
6
6
  IFileDownloadResult,
7
7
  IFileProviderClientOptions,
8
8
  IFileProviderClientRecord,
@@ -66,6 +66,14 @@ export class FileProviderCloudflare
66
66
  return await this.scope.service.fileCloudflare.createDirectUpload(input, clientOptions);
67
67
  }
68
68
 
69
+ async finalizeDirectUpload(
70
+ file: EntityFile,
71
+ clientOptions: IFileProviderCloudflareClientOptions,
72
+ _options: IFileProviderOptionsCloudflare,
73
+ ) {
74
+ return await this.scope.service.fileCloudflare.finalizeDirectUpload(file, clientOptions);
75
+ }
76
+
69
77
  async get(
70
78
  file: EntityFile,
71
79
  clientOptions: IFileProviderCloudflareClientOptions,
@@ -97,7 +105,7 @@ export class FileProviderCloudflare
97
105
  file: EntityFile,
98
106
  clientOptions: IFileProviderCloudflareClientOptions,
99
107
  _options: IFileProviderOptionsCloudflare,
100
- deliveryOptions?: IFileDeliveryOptions,
108
+ deliveryOptions?: IFileProviderDeliveryOptions,
101
109
  ) {
102
110
  return await this.scope.service.fileCloudflare.getDownloadUrl(
103
111
  file,
@@ -110,14 +118,14 @@ export class FileProviderCloudflare
110
118
  file: EntityFile,
111
119
  clientOptions: IFileProviderCloudflareClientOptions,
112
120
  options: IFileProviderOptionsCloudflare,
113
- deliveryOptions?: IFileDeliveryOptions,
121
+ deliveryOptions?: IFileProviderDeliveryOptions,
114
122
  ): Promise<IFileDownloadResult> {
115
123
  return {
116
124
  kind: 'url',
117
125
  url: await this.getDownloadUrl(file, clientOptions, options, deliveryOptions),
118
126
  filename: file.filename,
119
127
  contentType: file.contentType,
120
- signed: !!(deliveryOptions?.signed ?? !(file.public ?? clientOptions.public)),
128
+ signed: !!deliveryOptions?.protected,
121
129
  };
122
130
  }
123
131
  }
@@ -1,7 +1,7 @@
1
1
  import type {
2
2
  EntityFile,
3
- IFileDeliveryOptions,
4
3
  IFileDirectUploadInput,
4
+ IFileProviderDeliveryOptions,
5
5
  IFileProviderDirectUploadResource,
6
6
  IFileProviderResource,
7
7
  IFileUploadInput,
@@ -20,6 +20,7 @@ import fse from 'fs-extra';
20
20
  import path from 'node:path';
21
21
  import { BeanBase, uuidv4 } from 'vona';
22
22
  import { Service } from 'vona-module-a-bean';
23
+ import { downloadFileUploadUrl } from 'vona-module-a-file';
23
24
 
24
25
  import type { IFileProviderCloudflareClientOptions } from '../bean/fileProvider.cloudflare.ts';
25
26
 
@@ -67,32 +68,28 @@ export class ServiceFileCloudflare extends BeanBase {
67
68
  input: IFileUploadUrlInput,
68
69
  options: IFileProviderCloudflareClientOptions,
69
70
  ): Promise<IFileProviderResource> {
70
- const response = await fetch(input.url);
71
- if (!response.ok) {
72
- throw new Error(`Remote file fetch failed: ${response.status}`);
71
+ if (!input.policy) {
72
+ throw new Error('Resolved file upload policy is required for remote URL ingestion');
73
73
  }
74
- const tempDir = await this.app.util.getPublicPathPhysical('.temp-file-cloudflare', true);
75
- const tempFile = path.join(
76
- tempDir,
77
- `${uuidv4()}${path.extname(input.filename ?? new URL(input.url).pathname)}`,
78
- );
79
- const arrayBuffer = await response.arrayBuffer();
80
- await fse.writeFile(tempFile, Buffer.from(arrayBuffer));
74
+ const downloaded = await downloadFileUploadUrl({
75
+ url: input.url,
76
+ filename: input.filename,
77
+ policy: input.policy,
78
+ });
81
79
  try {
82
80
  return await this.upload(
83
81
  {
84
- file: tempFile,
85
- filename: input.filename ?? path.basename(new URL(input.url).pathname),
86
- contentType: input.contentType ?? response.headers.get('content-type') ?? undefined,
87
- size: input.size,
88
- objectKey: input.objectKey,
82
+ file: downloaded.file,
83
+ filename: downloaded.filename,
84
+ contentType: downloaded.contentType,
85
+ size: downloaded.size,
89
86
  public: input.public,
90
87
  meta: input.meta,
91
88
  },
92
89
  options,
93
90
  );
94
91
  } finally {
95
- await fse.remove(tempFile);
92
+ await downloaded.cleanup();
96
93
  }
97
94
  }
98
95
 
@@ -126,6 +123,43 @@ export class ServiceFileCloudflare extends BeanBase {
126
123
  };
127
124
  }
128
125
 
126
+ async finalizeDirectUpload(
127
+ file: EntityFile,
128
+ options: IFileProviderCloudflareClientOptions,
129
+ ): Promise<IFileProviderResource | undefined> {
130
+ let metadata;
131
+ try {
132
+ metadata = await this.getMetadata(file, options);
133
+ } catch (error: any) {
134
+ if (error?.$metadata?.httpStatusCode === 404 || error?.name === 'NotFound') return undefined;
135
+ throw error;
136
+ }
137
+ if (!metadata?.ContentLength) return undefined;
138
+ const size = Number(metadata.ContentLength);
139
+ if (file.size !== undefined && size !== file.size) {
140
+ throw new Error(`direct upload size mismatch: expected=${file.size}, actual=${size}`);
141
+ }
142
+ const contentType = metadata.ContentType?.toLowerCase();
143
+ if (file.contentType && contentType !== file.contentType.toLowerCase()) {
144
+ throw new Error(
145
+ `direct upload content type mismatch: expected=${file.contentType}, actual=${contentType}`,
146
+ );
147
+ }
148
+ return {
149
+ resourceId: file.resourceId,
150
+ bucket: file.bucket,
151
+ objectKey: file.objectKey,
152
+ filename: file.filename,
153
+ contentType: metadata.ContentType ?? file.contentType,
154
+ size,
155
+ etag: this._normalizeEtag(metadata.ETag),
156
+ public: file.public,
157
+ meta: file.meta,
158
+ deliveryBaseUrl: file.deliveryBaseUrl,
159
+ raw: metadata,
160
+ };
161
+ }
162
+
129
163
  async remove(
130
164
  file: Pick<EntityFile, 'bucket' | 'objectKey'>,
131
165
  options: IFileProviderCloudflareClientOptions,
@@ -143,7 +177,7 @@ export class ServiceFileCloudflare extends BeanBase {
143
177
  async getDownloadUrl(
144
178
  file: Pick<EntityFile, 'bucket' | 'objectKey' | 'deliveryBaseUrl' | 'public'>,
145
179
  options: IFileProviderCloudflareClientOptions,
146
- deliveryOptions?: IFileDeliveryOptions,
180
+ deliveryOptions?: IFileProviderDeliveryOptions,
147
181
  ) {
148
182
  const normalized = this._normalizeClientOptions(options);
149
183
  const bucket = file.bucket ?? normalized.bucket;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-file-native",
3
- "version": "5.0.3",
3
+ "version": "5.0.4",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"