cabloy 5.1.102 → 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 (127) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +29 -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/vona/packages-vona/vona/package.json +1 -1
  16. package/vona/src/suite/a-training/modules/training-record/src/.metadata/index.ts +30 -4
  17. package/vona/src/suite/a-training/modules/training-record/src/bean/fileScene.dossierFile.ts +1 -1
  18. package/vona/src/suite/a-training/modules/training-record/src/bean/imageScene.sceneImage.ts +1 -0
  19. package/vona/src/suite/a-training/modules/training-record/src/dto/recordSelectResItem.tsx +8 -6
  20. package/vona/src/suite/a-training/modules/training-record/src/dto/recordView.tsx +8 -6
  21. package/vona/src/suite/a-training/modules/training-record/src/index.ts +0 -1
  22. package/vona/src/suite/a-training/modules/training-record/src/lib/index.ts +0 -1
  23. package/vona/src/suite/a-training/modules/training-record/test/record.test.ts +10 -12
  24. package/vona/src/suite/a-training/modules/training-student/src/bean/imageScene.studentImage.ts +1 -0
  25. package/vona/src/suite/a-training/modules/training-student/src/dto/detailRecordBase.tsx +9 -10
  26. package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +2 -2
  27. package/vona/src/suite-vendor/a-file/modules/a-file/package.json +3 -2
  28. package/vona/src/suite-vendor/a-file/modules/a-file/src/.metadata/index.ts +155 -35
  29. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/bean.file.ts +283 -54
  30. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/bean.fileUploadPolicy.ts +46 -63
  31. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/meta.index.ts +12 -0
  32. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/meta.redlock.ts +1 -1
  33. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/meta.version.ts +3 -0
  34. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/schedule.fileDraftPrune.ts +29 -0
  35. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/serializerTransform.resolveView.ts +45 -0
  36. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/serializerTransform.resolveViews.ts +45 -0
  37. package/vona/src/suite-vendor/a-file/modules/a-file/src/config/config.ts +6 -0
  38. package/vona/src/suite-vendor/a-file/modules/a-file/src/controller/file.ts +64 -56
  39. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadFinalizeRequest.ts +13 -0
  40. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadFinalizeResponse.ts +10 -0
  41. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadRequest.ts +0 -3
  42. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadResponse.ts +2 -14
  43. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDownloadRequest.ts +3 -0
  44. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileUploadPolicyResponse.ts +3 -0
  45. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileUploadResponse.ts +0 -25
  46. 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
  47. package/vona/src/suite-vendor/a-file/modules/a-file/src/entity/file.ts +10 -0
  48. package/vona/src/suite-vendor/a-file/modules/a-file/src/lib/fileUploadUrl.ts +329 -0
  49. package/vona/src/suite-vendor/a-file/modules/a-file/src/lib/index.ts +1 -0
  50. package/vona/src/suite-vendor/a-file/modules/a-file/src/types/file.ts +51 -10
  51. package/vona/src/suite-vendor/a-file/modules/a-file/src/types/fileProvider.ts +8 -3
  52. package/vona/src/suite-vendor/a-file/modules/a-file/src/types/fileScene.ts +1 -1
  53. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/package.json +1 -1
  54. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/src/bean/fileProvider.cloudflare.ts +12 -4
  55. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/src/service/fileCloudflare.ts +52 -18
  56. package/vona/src/suite-vendor/a-file/modules/file-native/package.json +1 -1
  57. package/vona/src/suite-vendor/a-file/modules/file-native/src/bean/fileProvider.native.ts +5 -5
  58. package/vona/src/suite-vendor/a-file/modules/file-native/src/service/fileNative.ts +7 -8
  59. package/vona/src/suite-vendor/a-file/package.json +4 -4
  60. package/vona/src/suite-vendor/a-image/modules/a-image/package.json +1 -1
  61. package/vona/src/suite-vendor/a-image/modules/a-image/src/.metadata/index.ts +16 -19
  62. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.image.ts +99 -74
  63. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.imageUploadPolicy.ts +28 -68
  64. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/meta.version.ts +1 -1
  65. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/serializerTransform.resolveView.ts +3 -0
  66. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/serializerTransform.resolveViews.ts +3 -0
  67. package/vona/src/suite-vendor/a-image/modules/a-image/src/config/config.ts +4 -0
  68. package/vona/src/suite-vendor/a-image/modules/a-image/src/controller/image.ts +78 -34
  69. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDeliveryRequest.ts +3 -0
  70. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDirectUploadRequest.ts +0 -3
  71. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDirectUploadResponse.ts +1 -27
  72. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadPolicyResponse.ts +6 -0
  73. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadResponse.ts +2 -36
  74. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadUrlRequest.ts +0 -3
  75. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageView.ts +4 -27
  76. package/vona/src/suite-vendor/a-image/modules/a-image/src/entity/image.ts +1 -1
  77. package/vona/src/suite-vendor/a-image/modules/a-image/src/types/image.ts +36 -29
  78. package/vona/src/suite-vendor/a-image/modules/a-image/src/types/imageProvider.ts +4 -4
  79. package/vona/src/suite-vendor/a-image/modules/a-image/src/types/imageScene.ts +1 -0
  80. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/package.json +1 -1
  81. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/bean/imageProvider.cloudflare.ts +8 -11
  82. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/lib/cloudflareImageUrl.ts +4 -16
  83. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/service/imageCloudflare.ts +17 -16
  84. package/vona/src/suite-vendor/a-image/modules/image-native/package.json +2 -6
  85. package/vona/src/suite-vendor/a-image/modules/image-native/src/.metadata/index.ts +0 -37
  86. package/vona/src/suite-vendor/a-image/modules/image-native/src/bean/imageProvider.native.ts +13 -36
  87. package/vona/src/suite-vendor/a-image/modules/image-native/src/service/imageNative.ts +100 -113
  88. package/vona/src/suite-vendor/a-image/package.json +1 -1
  89. package/vona/src/suite-vendor/a-vona/modules/a-jwt/package.json +1 -1
  90. package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/config/config.ts +3 -1
  91. package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/service/jwtClient.ts +9 -4
  92. package/vona/src/suite-vendor/a-vona/modules/a-upload/package.json +1 -1
  93. package/vona/src/suite-vendor/a-vona/modules/a-upload/src/bean/interceptor.upload.ts +113 -64
  94. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  95. package/zova/packages-zova/zova/package.json +2 -2
  96. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +50 -12
  97. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +610 -114
  98. package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +44 -1
  99. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +50 -12
  100. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +610 -114
  101. package/zova/src/suite/cabloy-basic/modules/basic-file/src/.metadata/index.ts +16 -12
  102. package/zova/src/suite/cabloy-basic/modules/basic-file/src/api/file.ts +25 -25
  103. package/zova/src/suite/cabloy-basic/modules/basic-file/src/api/openapi/schemas.ts +18 -24
  104. package/zova/src/suite/cabloy-basic/modules/basic-file/src/api/openapi/types.ts +228 -291
  105. package/zova/src/suite/cabloy-basic/modules/basic-file/src/apiSchema/file.ts +5 -5
  106. package/zova/src/suite/cabloy-basic/modules/basic-file/src/bean/tableCell.file.tsx +23 -5
  107. package/zova/src/suite/cabloy-basic/modules/basic-file/src/component/formFieldFile/controller.tsx +72 -43
  108. package/zova/src/suite/cabloy-basic/modules/basic-file/src/lib/file.ts +22 -4
  109. package/zova/src/suite/cabloy-basic/modules/basic-image/src/.metadata/index.ts +16 -42
  110. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/image.ts +5 -25
  111. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/schemas.ts +10 -24
  112. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/types.ts +216 -333
  113. package/zova/src/suite/cabloy-basic/modules/basic-image/src/apiSchema/image.ts +0 -5
  114. package/zova/src/suite/cabloy-basic/modules/basic-image/src/bean/tableCell.image.tsx +38 -12
  115. package/zova/src/suite/cabloy-basic/modules/basic-image/src/component/formFieldImage/controller.tsx +79 -32
  116. package/zova/src/suite/cabloy-basic/modules/basic-image/src/lib/preview.ts +22 -4
  117. package/zova/src/suite/cabloy-basic/modules/basic-image/src/types/image.ts +3 -4
  118. package/zova/src/suite-vendor/a-zova/modules/a-model/package.json +1 -1
  119. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/utils.ts +29 -1
  120. package/zova/src/suite-vendor/a-zova/modules/a-model/src/types/query.ts +2 -2
  121. package/zova/src/suite-vendor/a-zova/package.json +2 -2
  122. package/vona/src/suite/a-training/modules/training-record/src/lib/resolveDossierFiles.ts +0 -34
  123. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadTokenRequest.ts +0 -24
  124. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadTokenResponse.ts +0 -16
  125. package/vona/src/suite-vendor/a-image/modules/image-native/src/controller/image.ts +0 -57
  126. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/imageNativeImage.ts +0 -31
  127. package/zova/src/suite/cabloy-basic/modules/basic-image/src/apiSchema/imageNativeImage.ts +0 -13
@@ -5,16 +5,18 @@ import { Bean } from 'vona-module-a-bean';
5
5
 
6
6
  import type { EntityFile } from '../entity/file.ts';
7
7
  import type {
8
+ IFileActionResponse,
8
9
  IFileDeliveryOptions,
9
10
  IFileDirectUploadInput,
10
- IFileDirectUploadResult,
11
+ IFileDirectUploadResponse,
12
+ IFileProviderDeliveryOptions,
11
13
  IFileProviderDirectUploadResource,
12
14
  IFileProviderResource,
13
15
  IFileResource,
14
- IFileUploadContextResolved,
15
16
  IFileUploadInput,
16
17
  IFileUploadOptions,
17
18
  IFileUploadUrlInput,
19
+ IFileView,
18
20
  } from '../types/file.ts';
19
21
  import type {
20
22
  IFileProviderRecord,
@@ -22,6 +24,7 @@ import type {
22
24
  TypeFileProviderExecuteByName,
23
25
  TypeFileProviderOptionsByName,
24
26
  } from '../types/fileProvider.ts';
27
+ import type { IFileSceneRecord } from '../types/fileScene.ts';
25
28
 
26
29
  interface IFileProviderContext<N extends keyof IFileProviderRecord = keyof IFileProviderRecord> {
27
30
  beanFileProvider: TypeFileProviderExecuteByName<N>;
@@ -29,6 +32,18 @@ interface IFileProviderContext<N extends keyof IFileProviderRecord = keyof IFile
29
32
  onionOptions: TypeFileProviderOptionsByName<N>;
30
33
  }
31
34
 
35
+ interface IInsertFileContext {
36
+ fileScene?: keyof IFileSceneRecord;
37
+ public?: boolean;
38
+ status?: EntityFile['status'];
39
+ draftExpiresAt?: Date;
40
+ finalizedAt?: Date;
41
+ }
42
+
43
+ interface IFileDeliveryOptionsResolved extends IFileProviderDeliveryOptions {
44
+ audience: boolean;
45
+ }
46
+
32
47
  @Bean()
33
48
  export class BeanFile extends BeanBase {
34
49
  async upload<N extends keyof IFileProviderRecord>(
@@ -49,6 +64,7 @@ export class BeanFile extends BeanBase {
49
64
  {
50
65
  fileScene: options?.fileScene,
51
66
  public: options?.public ?? input.public,
67
+ status: 'ready',
52
68
  },
53
69
  );
54
70
  return this._combineFileResource(file, fileProviderResource);
@@ -61,7 +77,10 @@ export class BeanFile extends BeanBase {
61
77
  ): Promise<IFileResource> {
62
78
  const providerContext = await this._getProviderContextByInput(providerName, options);
63
79
  if (!providerContext.beanFileProvider.uploadUrl) {
64
- throw new Error(`File provider does not support uploadUrl: ${String(providerName)}`);
80
+ return this.app.throw(
81
+ 403,
82
+ `File provider does not support uploadUrl: ${String(providerName)}`,
83
+ );
65
84
  }
66
85
  const fileProviderResource = await providerContext.beanFileProvider.uploadUrl(
67
86
  { ...input, public: options?.public ?? input.public, meta: options?.meta ?? input.meta },
@@ -75,6 +94,7 @@ export class BeanFile extends BeanBase {
75
94
  {
76
95
  fileScene: options?.fileScene,
77
96
  public: options?.public ?? input.public,
97
+ status: 'ready',
78
98
  },
79
99
  );
80
100
  return this._combineFileResource(file, fileProviderResource);
@@ -84,10 +104,16 @@ export class BeanFile extends BeanBase {
84
104
  providerName: N,
85
105
  input: IFileDirectUploadInput,
86
106
  options?: IFileUploadOptions<TypeFileProviderClientOptionsByName<N>>,
87
- ): Promise<IFileDirectUploadResult> {
107
+ ): Promise<IFileDirectUploadResponse> {
88
108
  const providerContext = await this._getProviderContextByInput(providerName, options);
89
- if (!providerContext.beanFileProvider.createDirectUpload) {
90
- throw new Error(`File provider does not support createDirectUpload: ${String(providerName)}`);
109
+ if (
110
+ !providerContext.beanFileProvider.createDirectUpload ||
111
+ !providerContext.beanFileProvider.finalizeDirectUpload
112
+ ) {
113
+ return this.app.throw(
114
+ 403,
115
+ `File provider does not support createDirectUpload: ${String(providerName)}`,
116
+ );
91
117
  }
92
118
  const fileProviderResource = await providerContext.beanFileProvider.createDirectUpload(
93
119
  { ...input, public: options?.public ?? input.public, meta: options?.meta ?? input.meta },
@@ -101,14 +127,53 @@ export class BeanFile extends BeanBase {
101
127
  {
102
128
  fileScene: options?.fileScene,
103
129
  public: options?.public ?? input.public,
130
+ status: 'draft',
131
+ draftExpiresAt: this._resolveDirectUploadDraftExpiresAt(input.expiry),
104
132
  },
105
133
  );
106
- return {
107
- ...this._combineFileResource(file, fileProviderResource),
108
- uploadUrl: fileProviderResource.uploadUrl,
109
- headers: fileProviderResource.headers,
110
- method: fileProviderResource.method,
111
- };
134
+ return this._createDirectUploadResponse(file, fileProviderResource);
135
+ }
136
+
137
+ async finalizeDirectUpload(fileId: TableIdentity): Promise<IFileResource> {
138
+ return await this.scope.redlock.lock(`file.directUpload.${fileId}`, async () => {
139
+ const file = await this.scope.model.file.getById(fileId);
140
+ if (!file) throw new Error(`not found file: ${fileId}`);
141
+ if (file.status !== 'draft') {
142
+ return this.app.throw(403, `file is not draft: ${fileId}`);
143
+ }
144
+ const draftExpiresAt = this._normalizeDate(file.draftExpiresAt);
145
+ if (draftExpiresAt && draftExpiresAt.getTime() < Date.now()) {
146
+ await this.scope.model.file.updateById(file.id, { status: 'expired' });
147
+ return this.app.throw(403, `file draft expired: ${fileId}`);
148
+ }
149
+ const providerContext = await this._getProviderContext(file);
150
+ if (!providerContext.beanFileProvider.finalizeDirectUpload) {
151
+ return this.app.throw(
152
+ 403,
153
+ `File provider does not support finalizeDirectUpload: ${String(file.providerName)}`,
154
+ );
155
+ }
156
+ const fileProviderResource = await providerContext.beanFileProvider.finalizeDirectUpload(
157
+ file,
158
+ providerContext.clientOptions,
159
+ providerContext.onionOptions,
160
+ );
161
+ if (!fileProviderResource) {
162
+ return this.app.throw(403, `file direct upload not ready: ${fileId}`);
163
+ }
164
+ const finalizedAt = new Date();
165
+ const fileUpdated = await this.scope.model.file.updateById(
166
+ file.id,
167
+ this._buildFilePersistData(fileProviderResource, {
168
+ fileScene: file.fileScene,
169
+ public: file.public,
170
+ status: 'ready',
171
+ draftExpiresAt,
172
+ finalizedAt,
173
+ }),
174
+ );
175
+ return this._combineFileResource({ ...file, ...fileUpdated }, fileProviderResource);
176
+ });
112
177
  }
113
178
 
114
179
  async get(fileId: TableIdentity): Promise<IFileResource | undefined> {
@@ -118,6 +183,19 @@ export class BeanFile extends BeanBase {
118
183
  return this._combineFileResource(file, fileProviderResource);
119
184
  }
120
185
 
186
+ async expireDraftFile(fileId: TableIdentity) {
187
+ return await this.scope.redlock.lock(`file.directUpload.${fileId}`, async () => {
188
+ const file = await this.scope.model.file.getById(fileId);
189
+ if (!file || file.status !== 'draft') return;
190
+ const draftExpiresAt = this._normalizeDate(file.draftExpiresAt);
191
+ if (!draftExpiresAt || draftExpiresAt.getTime() >= Date.now()) return;
192
+ const { beanFileProvider, clientOptions, onionOptions } =
193
+ await this._getProviderContext(file);
194
+ await beanFileProvider.delete(file, clientOptions, onionOptions);
195
+ await this.scope.model.file.updateById(file.id, { status: 'expired' });
196
+ });
197
+ }
198
+
121
199
  async delete(fileId: TableIdentity) {
122
200
  const file = await this.scope.model.file.getById(fileId);
123
201
  if (!file) return;
@@ -129,12 +207,10 @@ export class BeanFile extends BeanBase {
129
207
  async getDownloadUrl(fileId: TableIdentity, deliveryOptions?: IFileDeliveryOptions) {
130
208
  const file = await this.scope.model.file.getById(fileId);
131
209
  if (!file) throw new Error(`not found file: ${fileId}`);
210
+ this._assertFileReady(file);
132
211
  const deliveryOptionsResolved = this._mergeDeliveryOptions(file, deliveryOptions);
133
212
  const { beanFileProvider, clientOptions, onionOptions } = await this._getProviderContext(file);
134
- if (
135
- deliveryOptionsResolved.signed &&
136
- (clientOptions.signedDeliveryKind ?? 'proxy') === 'proxy'
137
- ) {
213
+ if (this._shouldUseProxySignedDelivery(deliveryOptionsResolved, clientOptions)) {
138
214
  return await this._createSignedDownloadUrl(file.id, deliveryOptionsResolved);
139
215
  }
140
216
  return await beanFileProvider.getDownloadUrl(
@@ -148,12 +224,10 @@ export class BeanFile extends BeanBase {
148
224
  async download(fileId: TableIdentity, deliveryOptions?: IFileDeliveryOptions) {
149
225
  const file = await this.scope.model.file.getById(fileId);
150
226
  if (!file) throw new Error(`not found file: ${fileId}`);
227
+ this._assertFileReady(file);
151
228
  const deliveryOptionsResolved = this._mergeDeliveryOptions(file, deliveryOptions);
152
229
  const { beanFileProvider, clientOptions, onionOptions } = await this._getProviderContext(file);
153
- if (
154
- deliveryOptionsResolved.signed &&
155
- (clientOptions.signedDeliveryKind ?? 'proxy') === 'proxy'
156
- ) {
230
+ if (this._shouldUseProxySignedDelivery(deliveryOptionsResolved, clientOptions)) {
157
231
  return {
158
232
  kind: 'url' as const,
159
233
  url: await this._createSignedDownloadUrl(file.id, deliveryOptionsResolved),
@@ -162,26 +236,70 @@ export class BeanFile extends BeanBase {
162
236
  signed: true,
163
237
  };
164
238
  }
165
- if (beanFileProvider.download) {
166
- return await beanFileProvider.download(
167
- file,
168
- clientOptions,
169
- onionOptions,
170
- deliveryOptionsResolved,
171
- );
239
+ return await this._downloadFromProvider(
240
+ file,
241
+ beanFileProvider,
242
+ clientOptions,
243
+ onionOptions,
244
+ deliveryOptionsResolved,
245
+ );
246
+ }
247
+
248
+ async downloadForDelivery(fileId: TableIdentity, options?: { protected?: boolean }) {
249
+ const file = await this.scope.model.file.getById(fileId);
250
+ if (!file) throw new Error(`not found file: ${fileId}`);
251
+ this._assertFileReady(file);
252
+ const { beanFileProvider, clientOptions, onionOptions } = await this._getProviderContext(file);
253
+ return await this._downloadFromProvider(file, beanFileProvider, clientOptions, onionOptions, {
254
+ protected: options?.protected ?? !file.public,
255
+ responseMode: 'buffer',
256
+ });
257
+ }
258
+
259
+ async resolveView(
260
+ fileId?: TableIdentity,
261
+ fileScene?: keyof IFileSceneRecord,
262
+ deliveryOptions?: IFileDeliveryOptions,
263
+ ): Promise<IFileView | undefined> {
264
+ if (!fileId) return;
265
+ const file = await this.get(fileId);
266
+ if (!file) return;
267
+ this._assertFileReady(file);
268
+ if (fileScene && file.fileScene !== fileScene) {
269
+ throw new Error(`file scene mismatch: file=${file.fileScene}, expected=${fileScene}`);
172
270
  }
271
+ return await this._createFileView(file, deliveryOptions);
272
+ }
273
+
274
+ async resolveViews(
275
+ fileIds?: TableIdentity[],
276
+ fileScene?: keyof IFileSceneRecord,
277
+ deliveryOptions?: IFileDeliveryOptions,
278
+ ) {
279
+ if (!fileIds) return;
280
+ if (!fileIds.length) return [];
281
+ const items = await Promise.all(
282
+ fileIds.map(fileId => this.resolveView(fileId, fileScene, deliveryOptions)),
283
+ );
284
+ return items.filter((item): item is IFileView => !!item);
285
+ }
286
+
287
+ async createFileActionResponse(
288
+ file: IFileResource,
289
+ deliveryOptions?: IFileDeliveryOptions,
290
+ ): Promise<IFileActionResponse> {
291
+ this._assertFileReady(file);
292
+ const deliveryOptionsResolved = this._mergeDeliveryOptions(file, deliveryOptions);
173
293
  return {
174
- kind: 'url' as const,
175
- url: await beanFileProvider.getDownloadUrl(
176
- file,
177
- clientOptions,
178
- onionOptions,
179
- deliveryOptionsResolved,
180
- ),
294
+ id: file.id,
181
295
  filename: file.filename,
182
296
  contentType: file.contentType,
183
- signed: !!deliveryOptionsResolved.signed,
184
- };
297
+ size: file.size,
298
+ public: file.public,
299
+ uploadedAt: file.uploadedAt,
300
+ url: await this.getDownloadUrl(file.id, deliveryOptions),
301
+ signed: deliveryOptionsResolved.protected,
302
+ } satisfies IFileActionResponse;
185
303
  }
186
304
 
187
305
  private async _getFileProviderResource(file: EntityFile) {
@@ -240,46 +358,138 @@ export class BeanFile extends BeanBase {
240
358
  return (options ?? {}) as T;
241
359
  }
242
360
 
361
+ private async _downloadFromProvider(
362
+ file: EntityFile,
363
+ beanFileProvider: TypeFileProviderExecuteByName<keyof IFileProviderRecord>,
364
+ clientOptions: TypeFileProviderClientOptionsByName<keyof IFileProviderRecord>,
365
+ onionOptions: TypeFileProviderOptionsByName<keyof IFileProviderRecord>,
366
+ deliveryOptions: IFileProviderDeliveryOptions,
367
+ ) {
368
+ if (beanFileProvider.download) {
369
+ return await beanFileProvider.download(file, clientOptions, onionOptions, deliveryOptions);
370
+ }
371
+ return {
372
+ kind: 'url' as const,
373
+ url: await beanFileProvider.getDownloadUrl(
374
+ file,
375
+ clientOptions,
376
+ onionOptions,
377
+ deliveryOptions,
378
+ ),
379
+ filename: file.filename,
380
+ contentType: file.contentType,
381
+ signed: deliveryOptions.protected,
382
+ };
383
+ }
384
+
243
385
  private _mergeDeliveryOptions(
244
386
  file: Pick<IFileResource, 'public'>,
245
387
  deliveryOptions?: IFileDeliveryOptions,
246
- ): IFileDeliveryOptions {
247
- const signed = deliveryOptions?.signed ?? !file.public;
248
- const expiresIn = deliveryOptions?.expiresIn;
249
- const expiresAt = deliveryOptions?.expiresAt;
250
- const responseMode = deliveryOptions?.responseMode;
388
+ ): IFileDeliveryOptionsResolved {
389
+ const audience = deliveryOptions?.audience ?? false;
251
390
  return {
252
- signed,
253
- expiresIn,
254
- expiresAt,
255
- responseMode,
391
+ protected: audience || !file.public,
392
+ expiresIn:
393
+ deliveryOptions?.expiresIn ??
394
+ (audience ? this.scope.config.file.delivery.audienceExpiresIn : undefined),
395
+ audience,
396
+ responseMode: deliveryOptions?.responseMode,
256
397
  };
257
398
  }
258
399
 
400
+ private _shouldUseProxySignedDelivery(
401
+ deliveryOptions: IFileDeliveryOptionsResolved,
402
+ clientOptions: TypeFileProviderClientOptionsByName<keyof IFileProviderRecord>,
403
+ ) {
404
+ return (
405
+ deliveryOptions.protected &&
406
+ (deliveryOptions.audience || (clientOptions.signedDeliveryKind ?? 'proxy') === 'proxy')
407
+ );
408
+ }
409
+
259
410
  private async _createSignedDownloadUrl(
260
411
  fileId: TableIdentity,
261
- deliveryOptions: IFileDeliveryOptions,
412
+ deliveryOptions: IFileDeliveryOptionsResolved,
262
413
  ) {
263
- const routePath = this.scope.util.combineApiPath(`file/download/${fileId}`, false, true);
414
+ const routePath = this.scope.util.combineApiPath('file/download', false, true);
415
+ const audienceUserId = this._resolveAudienceUserId(deliveryOptions);
264
416
  const tokenPayload = await this.bean.fileUploadPolicy.createDownloadToken({
265
417
  fileId,
266
418
  expiresIn: deliveryOptions.expiresIn,
419
+ audienceUserId,
267
420
  });
268
421
  const routeUrl = this.app.util.getAbsoluteUrlByApiPath(routePath);
269
422
  const url = new URL(routeUrl);
423
+ url.searchParams.set('fileId', String(fileId));
270
424
  url.searchParams.set('token', tokenPayload.token);
271
425
  return url.toString();
272
426
  }
273
427
 
274
- private async _insertFile(
275
- providerName: keyof IFileProviderRecord,
276
- clientName: string,
428
+ private _resolveAudienceUserId(deliveryOptions: IFileDeliveryOptionsResolved) {
429
+ if (!deliveryOptions.audience) return;
430
+ const user = this.bean.passport.currentUser;
431
+ if (!user || user.anonymous) return this.app.throw(401);
432
+ return user.id;
433
+ }
434
+
435
+ private async _createFileView(file: IFileResource, deliveryOptions?: IFileDeliveryOptions) {
436
+ const deliveryOptionsResolved = this._mergeDeliveryOptions(file, deliveryOptions);
437
+ return {
438
+ id: file.id,
439
+ filename: file.filename,
440
+ contentType: file.contentType,
441
+ size: file.size,
442
+ public: file.public,
443
+ uploadedAt: file.uploadedAt,
444
+ downloadUrl: await this.getDownloadUrl(file.id, deliveryOptions),
445
+ signed: deliveryOptionsResolved.protected,
446
+ } satisfies IFileView;
447
+ }
448
+
449
+ private _createDirectUploadResponse(
450
+ file: EntityFile,
451
+ fileProviderResource: IFileProviderDirectUploadResource,
452
+ ) {
453
+ return {
454
+ id: file.id,
455
+ uploadUrl: fileProviderResource.uploadUrl,
456
+ headers: fileProviderResource.headers,
457
+ method: fileProviderResource.method,
458
+ filename: fileProviderResource.filename,
459
+ public: fileProviderResource.public ?? file.public,
460
+ } satisfies IFileDirectUploadResponse;
461
+ }
462
+
463
+ private _resolveDirectUploadDraftExpiresAt(expiry?: IFileDirectUploadInput['expiry']) {
464
+ return (
465
+ this._normalizeDate(expiry) ??
466
+ new Date(Date.now() + this.scope.config.file.directUpload.draftExpiresIn)
467
+ );
468
+ }
469
+
470
+ private _normalizeDate(value: unknown) {
471
+ if (!value) return undefined;
472
+ if (value instanceof Date) {
473
+ return Number.isNaN(value.getTime()) ? undefined : value;
474
+ }
475
+ const date = new Date(value as string | number);
476
+ return Number.isNaN(date.getTime()) ? undefined : date;
477
+ }
478
+
479
+ private _assertFileReady(file: Pick<IFileResource, 'id' | 'status'>) {
480
+ if (file.status === 'draft') {
481
+ return this.app.throw(403, `file draft not ready: ${file.id}`);
482
+ }
483
+ if (file.status === 'expired') {
484
+ return this.app.throw(403, `file draft expired: ${file.id}`);
485
+ }
486
+ }
487
+
488
+ private _buildFilePersistData(
277
489
  fileProviderResource: IFileProviderResource | IFileProviderDirectUploadResource,
278
- context?: Partial<IFileUploadContextResolved>,
490
+ context?: IInsertFileContext,
279
491
  ) {
280
- return await this.scope.model.file.insert({
281
- providerName,
282
- clientName,
492
+ return {
283
493
  resourceId: fileProviderResource.resourceId,
284
494
  bucket: fileProviderResource.bucket,
285
495
  objectKey: fileProviderResource.objectKey,
@@ -292,6 +502,22 @@ export class BeanFile extends BeanBase {
292
502
  storagePath: fileProviderResource.storagePath,
293
503
  deliveryBaseUrl: fileProviderResource.deliveryBaseUrl,
294
504
  fileScene: context?.fileScene,
505
+ status: context?.status,
506
+ draftExpiresAt: context?.draftExpiresAt,
507
+ finalizedAt: context?.finalizedAt,
508
+ };
509
+ }
510
+
511
+ private async _insertFile(
512
+ providerName: keyof IFileProviderRecord,
513
+ clientName: string,
514
+ fileProviderResource: IFileProviderResource | IFileProviderDirectUploadResource,
515
+ context?: IInsertFileContext,
516
+ ) {
517
+ return await this.scope.model.file.insert({
518
+ providerName,
519
+ clientName,
520
+ ...this._buildFilePersistData(fileProviderResource, context),
295
521
  });
296
522
  }
297
523
 
@@ -304,6 +530,9 @@ export class BeanFile extends BeanBase {
304
530
  provider: file.providerName,
305
531
  clientName: file.clientName,
306
532
  fileScene: file.fileScene,
533
+ status: file.status,
534
+ draftExpiresAt: file.status === 'draft' ? file.draftExpiresAt : undefined,
535
+ finalizedAt: file.finalizedAt,
307
536
  resourceId: fileProviderResource?.resourceId ?? file.resourceId,
308
537
  bucket: fileProviderResource?.bucket ?? file.bucket,
309
538
  objectKey: fileProviderResource?.objectKey ?? file.objectKey,
@@ -6,8 +6,8 @@ import type {
6
6
  IFileDownloadTokenPayload,
7
7
  IFileUploadContextResolved,
8
8
  IFileUploadPolicyResolved,
9
- IFileUploadTokenPayload,
10
9
  } from '../types/file.ts';
10
+ import type { IFileProviderExecute } from '../types/fileProvider.ts';
11
11
  import type {
12
12
  IDecoratorFileSceneOptions,
13
13
  IDecoratorFileSceneOptionsProvider,
@@ -18,33 +18,17 @@ import { getFileExtension, matchesFileMimeType } from '../lib/fileUploadValidati
18
18
 
19
19
  @Bean()
20
20
  export class BeanFileUploadPolicy extends BeanBase {
21
- async createUploadToken(data: {
22
- fileScene: keyof IFileSceneRecord;
23
- size: number;
24
- mimeType: string;
21
+ async createDownloadToken(data: {
22
+ fileId: number | string;
25
23
  expiresIn?: number;
24
+ audienceUserId?: IFileDownloadTokenPayload['audienceUserId'];
26
25
  }) {
27
- const payload = await this.resolveUploadPolicy(data);
28
- const path = this.scope.util.combineApiPath('file/upload', false, true);
29
- const token = await this.bean.jwt.createTempAuthToken(
30
- {
31
- kind: 'fileUpload',
32
- ...payload,
33
- } as IFileUploadTokenPayload,
34
- {
35
- path,
36
- expiresIn: data.expiresIn,
37
- },
38
- );
39
- return { token, expiresIn: data.expiresIn };
40
- }
41
-
42
- async createDownloadToken(data: { fileId: number | string; expiresIn?: number }) {
43
- const path = this.scope.util.combineApiPath(`file/download/${data.fileId}`, false, true);
26
+ const path = this.scope.util.combineApiPath('file/download', false, true);
44
27
  const token = await this.bean.jwt.createTempAuthToken(
45
28
  {
46
29
  kind: 'fileDownload',
47
30
  fileId: data.fileId,
31
+ audienceUserId: data.audienceUserId,
48
32
  } as IFileDownloadTokenPayload,
49
33
  {
50
34
  path,
@@ -54,16 +38,6 @@ export class BeanFileUploadPolicy extends BeanBase {
54
38
  return { token, expiresIn: data.expiresIn };
55
39
  }
56
40
 
57
- async verifyUploadToken(token: string | undefined, routePathRaw: string) {
58
- const payload = (await this.bean.jwt.get('access').verify(token, {
59
- path: routePathRaw,
60
- })) as IFileUploadTokenPayload | undefined;
61
- if (!payload || payload.kind !== 'fileUpload') {
62
- return this.app.throw(401);
63
- }
64
- return payload;
65
- }
66
-
67
41
  async verifyDownloadToken(token: string | undefined, routePathRaw: string) {
68
42
  const payload = (await this.bean.jwt.get('access').verify(token, {
69
43
  path: routePathRaw,
@@ -80,26 +54,20 @@ export class BeanFileUploadPolicy extends BeanBase {
80
54
  filename?: string;
81
55
  mimeType: string;
82
56
  },
83
- payload: IFileUploadTokenPayload,
57
+ policy: IFileUploadPolicyResolved,
84
58
  ) {
85
59
  const stat = await fse.stat(file.file);
86
60
  const fileSize = Number(stat.size);
87
- if (fileSize !== payload.fileSize) {
88
- return this.app.throw(403, `file size mismatch: size=${fileSize}`);
89
- }
90
- if (payload.maxSize && fileSize > payload.maxSize) {
91
- return this.app.throw(403, `file too large: maxSize=${payload.maxSize}`);
61
+ if (policy.maxSize && fileSize > policy.maxSize) {
62
+ return this.app.throw(403, `file too large: maxSize=${policy.maxSize}`);
92
63
  }
93
64
  const mimeType = file.mimeType.toLowerCase();
94
- if (mimeType !== payload.mimeType) {
95
- return this.app.throw(403, `file mimeType mismatch: mimeType=${mimeType}`);
96
- }
97
- if (payload.mimeTypes?.length && !matchesFileMimeType(mimeType, payload.mimeTypes)) {
65
+ if (policy.mimeTypes?.length && !matchesFileMimeType(mimeType, policy.mimeTypes)) {
98
66
  return this.app.throw(403, `unsupported file mimeType: ${mimeType}`);
99
67
  }
100
68
  const extension = getFileExtension(file.filename);
101
- if (payload.extensions?.length && extension && !payload.extensions.includes(extension)) {
102
- return this.app.throw(403, `unsupported file extension: ${extension}`);
69
+ if (policy.extensions?.length && (!extension || !policy.extensions.includes(extension))) {
70
+ return this.app.throw(403, `unsupported file extension: ${extension ?? '(none)'}`);
103
71
  }
104
72
  }
105
73
 
@@ -130,13 +98,19 @@ export class BeanFileUploadPolicy extends BeanBase {
130
98
  const fileScene = data.fileScene;
131
99
  const sceneOptions = this._getSceneOptions(fileScene);
132
100
  const { providerName, clientName } = await this._resolveProvider(sceneOptions);
133
- const providerContext = await this.bean.fileProvider.getClientOptions({
101
+ const {
102
+ entityFileProvider,
103
+ disabled,
104
+ beanFullName,
105
+ clientOptions: providerClientOptions,
106
+ } = await this.bean.fileProvider.getClientOptions({
134
107
  providerName,
135
108
  clientName,
136
109
  });
137
- if (!providerContext.entityFileProvider || providerContext.disabled) {
110
+ if (!entityFileProvider || disabled) {
138
111
  return this.app.throw(403, `File provider unavailable: ${providerName}.${clientName}`);
139
112
  }
113
+ const fileProvider = this.app.bean._getBean<IFileProviderExecute>(beanFullName as never);
140
114
  const uploadOptions = {
141
115
  ...(fileConfig.upload ?? {}),
142
116
  ...(sceneOptions.upload ?? {}),
@@ -149,15 +123,16 @@ export class BeanFileUploadPolicy extends BeanBase {
149
123
  mimeTypes: mimeTypes.length > 0 ? mimeTypes : undefined,
150
124
  extensions: extensions.length > 0 ? extensions : undefined,
151
125
  multiple: uploadOptions.multiple,
152
- public: this._resolvePublic(providerContext.clientOptions, sceneOptions),
126
+ public: this._resolvePublic(providerClientOptions, sceneOptions),
127
+ directUpload:
128
+ typeof fileProvider.createDirectUpload === 'function' &&
129
+ typeof fileProvider.finalizeDirectUpload === 'function',
153
130
  };
154
131
  }
155
132
 
156
- async resolveUploadPolicy(data: {
133
+ async resolveUploadUrlPolicy(data: {
157
134
  fileScene: keyof IFileSceneRecord;
158
- size: number;
159
- mimeType: string;
160
- }): Promise<IFileUploadPolicyResolved> {
135
+ }): Promise<Omit<IFileUploadPolicyResolved, 'fileSize' | 'mimeType'>> {
161
136
  const fileConfig = this.scope.config.file;
162
137
  const context = await this.resolveUploadContext({ fileScene: data.fileScene });
163
138
  const sceneOptions = this._getSceneOptions(context.fileScene);
@@ -165,22 +140,32 @@ export class BeanFileUploadPolicy extends BeanBase {
165
140
  ...(fileConfig.upload ?? {}),
166
141
  ...(sceneOptions.upload ?? {}),
167
142
  };
168
- const maxSize = uploadOptions.maxSize;
169
143
  const mimeTypes = [...(uploadOptions.mimeTypes ?? [])];
170
144
  const extensions = [...(uploadOptions.extensions ?? [])];
171
- const mimeType = data.mimeType.toLowerCase();
172
- if (maxSize && data.size > maxSize) {
173
- return this.app.throw(403, `file too large: maxSize=${maxSize}`);
174
- }
175
- if (mimeTypes.length > 0 && !matchesFileMimeType(mimeType, mimeTypes)) {
176
- return this.app.throw(403, `unsupported file mimeType: ${mimeType}`);
177
- }
178
145
  return {
179
146
  ...context,
180
- maxSize,
147
+ maxSize: uploadOptions.maxSize,
181
148
  mimeTypes: mimeTypes.length > 0 ? mimeTypes : undefined,
182
149
  extensions: extensions.length > 0 ? extensions : undefined,
183
150
  multiple: uploadOptions.multiple,
151
+ };
152
+ }
153
+
154
+ async resolveUploadPolicy(data: {
155
+ fileScene: keyof IFileSceneRecord;
156
+ size: number;
157
+ mimeType: string;
158
+ }): Promise<IFileUploadPolicyResolved> {
159
+ const policy = await this.resolveUploadUrlPolicy({ fileScene: data.fileScene });
160
+ const mimeType = data.mimeType.toLowerCase();
161
+ if (policy.maxSize && data.size > policy.maxSize) {
162
+ return this.app.throw(403, `file too large: maxSize=${policy.maxSize}`);
163
+ }
164
+ if (policy.mimeTypes?.length && !matchesFileMimeType(mimeType, policy.mimeTypes)) {
165
+ return this.app.throw(403, `unsupported file mimeType: ${mimeType}`);
166
+ }
167
+ return {
168
+ ...policy,
184
169
  fileSize: data.size,
185
170
  mimeType,
186
171
  };
@@ -217,8 +202,6 @@ export class BeanFileUploadPolicy extends BeanBase {
217
202
  clientOptions: { public?: boolean } | undefined,
218
203
  sceneOptions: IDecoratorFileSceneOptions,
219
204
  ) {
220
- return (
221
- sceneOptions.upload?.public ?? clientOptions?.public ?? this.scope.config.file.public ?? false
222
- );
205
+ return sceneOptions.public ?? clientOptions?.public ?? this.scope.config.file.public ?? false;
223
206
  }
224
207
  }
@@ -0,0 +1,12 @@
1
+ import type { IMetaOptionsIndex } from 'vona-module-a-index';
2
+
3
+ import { BeanBase } from 'vona';
4
+ import { Meta } from 'vona-module-a-meta';
5
+ import { $tableColumns } from 'vona-module-a-ormutils';
6
+
7
+ @Meta<IMetaOptionsIndex>({
8
+ indexes: {
9
+ ...$tableColumns('aFile', 'status+draftExpiresAt'),
10
+ },
11
+ })
12
+ export class MetaIndex extends BeanBase {}