cabloy 5.1.97 → 5.1.98

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 (122) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +27 -0
  3. package/cabloy-docs/.vitepress/config.mjs +7 -0
  4. package/cabloy-docs/backend/image-guide.md +544 -0
  5. package/cabloy-docs/frontend/image-guide.md +512 -0
  6. package/cabloy-docs/frontend/introduction.md +2 -0
  7. package/cabloy-docs/frontend/ioc-and-beans.md +34 -3
  8. package/cabloy-docs/frontend/model-architecture.md +2 -0
  9. package/cabloy-docs/frontend/model-resource-owner-pattern.md +2 -0
  10. package/cabloy-docs/frontend/model-state-guide.md +2 -0
  11. package/cabloy-docs/frontend/reading-zova-for-vue-developers.md +10 -8
  12. package/cabloy-docs/frontend/server-data.md +2 -0
  13. package/cabloy-docs/frontend/state-architecture-for-vue-developers.md +218 -0
  14. package/cabloy-docs/frontend/system-startup-guide.md +2 -0
  15. package/cabloy-docs/frontend/zova-vs-vue3-comparison.md +11 -10
  16. package/cabloy-docs/fullstack/image-workflow.md +403 -0
  17. package/cabloy-docs/fullstack/introduction.md +1 -0
  18. package/package.json +1 -1
  19. package/vona/package.original.json +0 -1
  20. package/vona/packages-vona/vona/package.json +1 -1
  21. package/vona/pnpm-lock.yaml +424 -47
  22. package/vona/src/suite/cabloy-basic/modules/basic-siteadmin/src/bean/ssrSite.admin.ts +4 -0
  23. package/vona/src/suite/cabloy-basic/modules/basic-siteweb/src/bean/ssrSite.web.ts +4 -0
  24. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/cli/ssrSite/boilerplate/{{sceneName}}.{{beanName}}.ts_ +3 -0
  25. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/package.json +1 -1
  26. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/service/ssrHandler.ts +11 -2
  27. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/types/ssrSite.ts +6 -0
  28. package/vona/src/suite-vendor/a-cabloy/package.json +1 -1
  29. package/vona/src/suite-vendor/a-file/modules/a-file/cli/fileProvider/boilerplate/{{sceneName}}.{{beanName}}.ts_ +23 -0
  30. package/vona/src/suite-vendor/a-file/modules/a-file/cli/fileScene/boilerplate/{{sceneName}}.{{beanName}}.ts_ +5 -0
  31. package/vona/src/suite-vendor/a-file/modules/a-file/package.json +66 -0
  32. package/vona/src/suite-vendor/a-file/modules/a-file/src/.metadata/index.ts +425 -0
  33. package/vona/src/suite-vendor/a-file/modules/a-file/src/.metadata/locales.ts +18 -0
  34. package/vona/src/suite-vendor/a-file/modules/a-file/src/.metadata/this.ts +2 -0
  35. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/bean.file.ts +322 -0
  36. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/bean.fileProvider.ts +62 -0
  37. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/bean.fileUploadPolicy.ts +196 -0
  38. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/meta.redlock.ts +11 -0
  39. package/vona/src/suite-vendor/a-file/modules/a-file/src/bean/meta.version.ts +38 -0
  40. package/vona/src/suite-vendor/a-file/modules/a-file/src/config/config.ts +16 -0
  41. package/vona/src/suite-vendor/a-file/modules/a-file/src/config/locale/en-us.ts +1 -0
  42. package/vona/src/suite-vendor/a-file/modules/a-file/src/config/locale/zh-cn.ts +1 -0
  43. package/vona/src/suite-vendor/a-file/modules/a-file/src/controller/file.ts +167 -0
  44. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadRequest.ts +33 -0
  45. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDirectUploadResponse.ts +41 -0
  46. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileDownloadRequest.ts +12 -0
  47. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileUploadResponse.ts +59 -0
  48. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileUploadTokenRequest.ts +24 -0
  49. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileUploadTokenResponse.ts +16 -0
  50. package/vona/src/suite-vendor/a-file/modules/a-file/src/dto/fileUploadUrlRequest.ts +33 -0
  51. package/vona/src/suite-vendor/a-file/modules/a-file/src/entity/file.ts +55 -0
  52. package/vona/src/suite-vendor/a-file/modules/a-file/src/entity/fileProvider.ts +24 -0
  53. package/vona/src/suite-vendor/a-file/modules/a-file/src/index.ts +4 -0
  54. package/vona/src/suite-vendor/a-file/modules/a-file/src/lib/fileProvider.ts +7 -0
  55. package/vona/src/suite-vendor/a-file/modules/a-file/src/lib/fileScene.ts +7 -0
  56. package/vona/src/suite-vendor/a-file/modules/a-file/src/lib/fileUploadValidation.ts +15 -0
  57. package/vona/src/suite-vendor/a-file/modules/a-file/src/lib/index.ts +3 -0
  58. package/vona/src/suite-vendor/a-file/modules/a-file/src/model/file.ts +10 -0
  59. package/vona/src/suite-vendor/a-file/modules/a-file/src/model/fileProvider.ts +10 -0
  60. package/vona/src/suite-vendor/a-file/modules/a-file/src/types/file.ts +144 -0
  61. package/vona/src/suite-vendor/a-file/modules/a-file/src/types/fileProvider.ts +111 -0
  62. package/vona/src/suite-vendor/a-file/modules/a-file/src/types/fileScene.ts +53 -0
  63. package/vona/src/suite-vendor/a-file/modules/a-file/src/types/index.ts +3 -0
  64. package/vona/src/suite-vendor/a-file/modules/a-file/tsconfig.build.json +11 -0
  65. package/vona/src/suite-vendor/a-file/modules/a-file/tsconfig.json +7 -0
  66. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/package.json +53 -0
  67. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/src/.metadata/index.ts +104 -0
  68. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/src/.metadata/this.ts +2 -0
  69. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/src/bean/fileProvider.cloudflare.ts +123 -0
  70. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/src/index.ts +1 -0
  71. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/src/service/fileCloudflare.ts +245 -0
  72. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/tsconfig.build.json +11 -0
  73. package/vona/src/suite-vendor/a-file/modules/file-cloudflare/tsconfig.json +7 -0
  74. package/vona/src/suite-vendor/a-file/modules/file-native/package.json +50 -0
  75. package/vona/src/suite-vendor/a-file/modules/file-native/src/.metadata/index.ts +111 -0
  76. package/vona/src/suite-vendor/a-file/modules/file-native/src/.metadata/this.ts +2 -0
  77. package/vona/src/suite-vendor/a-file/modules/file-native/src/bean/fileProvider.native.ts +125 -0
  78. package/vona/src/suite-vendor/a-file/modules/file-native/src/config/config.ts +7 -0
  79. package/vona/src/suite-vendor/a-file/modules/file-native/src/index.ts +1 -0
  80. package/vona/src/suite-vendor/a-file/modules/file-native/src/service/fileNative.ts +125 -0
  81. package/vona/src/suite-vendor/a-file/modules/file-native/tsconfig.build.json +11 -0
  82. package/vona/src/suite-vendor/a-file/modules/file-native/tsconfig.json +7 -0
  83. package/vona/src/suite-vendor/a-file/package.json +14 -0
  84. package/vona/src/suite-vendor/a-file/tsconfig.base.json +4 -0
  85. package/vona/src/suite-vendor/a-file/tsconfig.json +16 -0
  86. package/vona/src/suite-vendor/a-image/modules/a-image/package.json +4 -1
  87. package/vona/src/suite-vendor/a-image/modules/a-image/src/.metadata/index.ts +61 -3
  88. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.image.ts +336 -152
  89. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.imageProvider.ts +21 -14
  90. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.imageUploadPolicy.ts +59 -11
  91. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/meta.index.ts +12 -0
  92. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/meta.redlock.ts +1 -1
  93. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/meta.version.ts +3 -0
  94. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/schedule.imageDraftPrune.ts +29 -0
  95. package/vona/src/suite-vendor/a-image/modules/a-image/src/config/config.ts +3 -0
  96. package/vona/src/suite-vendor/a-image/modules/a-image/src/controller/image.ts +24 -54
  97. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDirectUploadFinalizeRequest.ts +13 -0
  98. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDirectUploadFinalizeResponse.ts +10 -0
  99. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDirectUploadResponse.ts +10 -0
  100. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadResponse.ts +10 -1
  101. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageView.ts +10 -1
  102. package/vona/src/suite-vendor/a-image/modules/a-image/src/entity/image.ts +10 -1
  103. package/vona/src/suite-vendor/a-image/modules/a-image/src/lib/imageUploadValidation.ts +15 -0
  104. package/vona/src/suite-vendor/a-image/modules/a-image/src/lib/index.ts +1 -0
  105. package/vona/src/suite-vendor/a-image/modules/a-image/src/types/image.ts +18 -0
  106. package/vona/src/suite-vendor/a-image/modules/a-image/src/types/imageProvider.ts +28 -2
  107. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/package.json +1 -1
  108. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/bean/imageProvider.cloudflare.ts +13 -1
  109. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/service/imageCloudflare.ts +26 -0
  110. package/vona/src/suite-vendor/a-image/modules/image-native/package.json +6 -2
  111. package/vona/src/suite-vendor/a-image/modules/image-native/src/.metadata/index.ts +37 -0
  112. package/vona/src/suite-vendor/a-image/modules/image-native/src/bean/imageProvider.native.ts +21 -1
  113. package/vona/src/suite-vendor/a-image/modules/image-native/src/controller/image.ts +57 -0
  114. package/vona/src/suite-vendor/a-image/modules/image-native/src/service/imageNative.ts +142 -34
  115. package/vona/src/suite-vendor/a-image/package.json +1 -1
  116. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  117. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/types/entity.ts +2 -4
  118. package/vona/src/suite-vendor/a-vona/modules/a-ormutils/package.json +1 -1
  119. package/vona/src/suite-vendor/a-vona/modules/a-ormutils/src/lib/columns.ts +7 -3
  120. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  121. package/zova/pnpm-lock.yaml +389 -397
  122. package/zova/src/suite/cabloy-basic/modules/basic-image/src/component/formFieldImage/controller.tsx +0 -9
@@ -0,0 +1,322 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+
3
+ import { BeanBase } from 'vona';
4
+ import { Bean } from 'vona-module-a-bean';
5
+
6
+ import type { EntityFile } from '../entity/file.ts';
7
+ import type {
8
+ IFileDeliveryOptions,
9
+ IFileDirectUploadInput,
10
+ IFileDirectUploadResult,
11
+ IFileProviderDirectUploadResource,
12
+ IFileProviderResource,
13
+ IFileResource,
14
+ IFileUploadContextResolved,
15
+ IFileUploadInput,
16
+ IFileUploadOptions,
17
+ IFileUploadUrlInput,
18
+ } from '../types/file.ts';
19
+ import type {
20
+ IFileProviderRecord,
21
+ TypeFileProviderClientOptionsByName,
22
+ TypeFileProviderExecuteByName,
23
+ TypeFileProviderOptionsByName,
24
+ } from '../types/fileProvider.ts';
25
+
26
+ interface IFileProviderContext<N extends keyof IFileProviderRecord = keyof IFileProviderRecord> {
27
+ beanFileProvider: TypeFileProviderExecuteByName<N>;
28
+ clientOptions: TypeFileProviderClientOptionsByName<N>;
29
+ onionOptions: TypeFileProviderOptionsByName<N>;
30
+ }
31
+
32
+ @Bean()
33
+ export class BeanFile extends BeanBase {
34
+ async upload<N extends keyof IFileProviderRecord>(
35
+ providerName: N,
36
+ input: IFileUploadInput,
37
+ options?: IFileUploadOptions<TypeFileProviderClientOptionsByName<N>>,
38
+ ): Promise<IFileResource> {
39
+ const providerContext = await this._getProviderContextByInput(providerName, options);
40
+ const fileProviderResource = await providerContext.beanFileProvider.upload(
41
+ { ...input, public: options?.public ?? input.public, meta: options?.meta ?? input.meta },
42
+ providerContext.clientOptions,
43
+ providerContext.onionOptions,
44
+ );
45
+ const file = await this._insertFile(
46
+ providerName,
47
+ providerContext.clientName,
48
+ fileProviderResource,
49
+ {
50
+ fileScene: options?.fileScene,
51
+ public: options?.public ?? input.public,
52
+ },
53
+ );
54
+ return this._combineFileResource(file, fileProviderResource);
55
+ }
56
+
57
+ async uploadUrl<N extends keyof IFileProviderRecord>(
58
+ providerName: N,
59
+ input: IFileUploadUrlInput,
60
+ options?: IFileUploadOptions<TypeFileProviderClientOptionsByName<N>>,
61
+ ): Promise<IFileResource> {
62
+ const providerContext = await this._getProviderContextByInput(providerName, options);
63
+ if (!providerContext.beanFileProvider.uploadUrl) {
64
+ throw new Error(`File provider does not support uploadUrl: ${String(providerName)}`);
65
+ }
66
+ const fileProviderResource = await providerContext.beanFileProvider.uploadUrl(
67
+ { ...input, public: options?.public ?? input.public, meta: options?.meta ?? input.meta },
68
+ providerContext.clientOptions,
69
+ providerContext.onionOptions,
70
+ );
71
+ const file = await this._insertFile(
72
+ providerName,
73
+ providerContext.clientName,
74
+ fileProviderResource,
75
+ {
76
+ fileScene: options?.fileScene,
77
+ public: options?.public ?? input.public,
78
+ },
79
+ );
80
+ return this._combineFileResource(file, fileProviderResource);
81
+ }
82
+
83
+ async createDirectUpload<N extends keyof IFileProviderRecord>(
84
+ providerName: N,
85
+ input: IFileDirectUploadInput,
86
+ options?: IFileUploadOptions<TypeFileProviderClientOptionsByName<N>>,
87
+ ): Promise<IFileDirectUploadResult> {
88
+ const providerContext = await this._getProviderContextByInput(providerName, options);
89
+ if (!providerContext.beanFileProvider.createDirectUpload) {
90
+ throw new Error(`File provider does not support createDirectUpload: ${String(providerName)}`);
91
+ }
92
+ const fileProviderResource = await providerContext.beanFileProvider.createDirectUpload(
93
+ { ...input, public: options?.public ?? input.public, meta: options?.meta ?? input.meta },
94
+ providerContext.clientOptions,
95
+ providerContext.onionOptions,
96
+ );
97
+ const file = await this._insertFile(
98
+ providerName,
99
+ providerContext.clientName,
100
+ fileProviderResource,
101
+ {
102
+ fileScene: options?.fileScene,
103
+ public: options?.public ?? input.public,
104
+ },
105
+ );
106
+ return {
107
+ ...this._combineFileResource(file, fileProviderResource),
108
+ uploadUrl: fileProviderResource.uploadUrl,
109
+ headers: fileProviderResource.headers,
110
+ method: fileProviderResource.method,
111
+ };
112
+ }
113
+
114
+ async get(fileId: TableIdentity): Promise<IFileResource | undefined> {
115
+ const file = await this.scope.model.file.getById(fileId);
116
+ if (!file) return;
117
+ const fileProviderResource = await this._getFileProviderResource(file);
118
+ return this._combineFileResource(file, fileProviderResource);
119
+ }
120
+
121
+ async delete(fileId: TableIdentity) {
122
+ const file = await this.scope.model.file.getById(fileId);
123
+ if (!file) return;
124
+ const { beanFileProvider, clientOptions, onionOptions } = await this._getProviderContext(file);
125
+ await beanFileProvider.delete(file, clientOptions, onionOptions);
126
+ await this.scope.model.file.deleteById(file.id);
127
+ }
128
+
129
+ async getDownloadUrl(fileId: TableIdentity, deliveryOptions?: IFileDeliveryOptions) {
130
+ const file = await this.scope.model.file.getById(fileId);
131
+ if (!file) throw new Error(`not found file: ${fileId}`);
132
+ const deliveryOptionsResolved = this._mergeDeliveryOptions(file, deliveryOptions);
133
+ const { beanFileProvider, clientOptions, onionOptions } = await this._getProviderContext(file);
134
+ if (
135
+ deliveryOptionsResolved.signed &&
136
+ (clientOptions.signedDeliveryKind ?? 'proxy') === 'proxy'
137
+ ) {
138
+ return await this._createSignedDownloadUrl(file.id, deliveryOptionsResolved);
139
+ }
140
+ return await beanFileProvider.getDownloadUrl(
141
+ file,
142
+ clientOptions,
143
+ onionOptions,
144
+ deliveryOptionsResolved,
145
+ );
146
+ }
147
+
148
+ async download(fileId: TableIdentity, deliveryOptions?: IFileDeliveryOptions) {
149
+ const file = await this.scope.model.file.getById(fileId);
150
+ if (!file) throw new Error(`not found file: ${fileId}`);
151
+ const deliveryOptionsResolved = this._mergeDeliveryOptions(file, deliveryOptions);
152
+ const { beanFileProvider, clientOptions, onionOptions } = await this._getProviderContext(file);
153
+ if (
154
+ deliveryOptionsResolved.signed &&
155
+ (clientOptions.signedDeliveryKind ?? 'proxy') === 'proxy'
156
+ ) {
157
+ return {
158
+ kind: 'url' as const,
159
+ url: await this._createSignedDownloadUrl(file.id, deliveryOptionsResolved),
160
+ filename: file.filename,
161
+ contentType: file.contentType,
162
+ signed: true,
163
+ };
164
+ }
165
+ if (beanFileProvider.download) {
166
+ return await beanFileProvider.download(
167
+ file,
168
+ clientOptions,
169
+ onionOptions,
170
+ deliveryOptionsResolved,
171
+ );
172
+ }
173
+ return {
174
+ kind: 'url' as const,
175
+ url: await beanFileProvider.getDownloadUrl(
176
+ file,
177
+ clientOptions,
178
+ onionOptions,
179
+ deliveryOptionsResolved,
180
+ ),
181
+ filename: file.filename,
182
+ contentType: file.contentType,
183
+ signed: !!deliveryOptionsResolved.signed,
184
+ };
185
+ }
186
+
187
+ private async _getFileProviderResource(file: EntityFile) {
188
+ const { beanFileProvider, clientOptions, onionOptions } = await this._getProviderContext(file);
189
+ return await beanFileProvider.get(file, clientOptions, onionOptions);
190
+ }
191
+
192
+ private async _getProviderContext<N extends keyof IFileProviderRecord>(
193
+ file: EntityFile & { providerName: N },
194
+ ): Promise<IFileProviderContext<N>> {
195
+ const providerContext = await this.bean.fileProvider.getClientOptions({
196
+ providerName: file.providerName,
197
+ clientName: file.clientName,
198
+ });
199
+ if (!providerContext.entityFileProvider) {
200
+ throw new Error(`File provider not found: ${String(file.providerName)}.${file.clientName}`);
201
+ }
202
+ return {
203
+ beanFileProvider: this._getBeanFileProvider(file.providerName, providerContext.beanFullName),
204
+ clientOptions: this._normalizeOptions(providerContext.clientOptions),
205
+ onionOptions: this._normalizeOptions(providerContext.onionOptions),
206
+ };
207
+ }
208
+
209
+ private async _getProviderContextByInput<N extends keyof IFileProviderRecord>(
210
+ providerName: N,
211
+ options?: IFileUploadOptions<TypeFileProviderClientOptionsByName<N>>,
212
+ ): Promise<IFileProviderContext<N> & { clientName: string }> {
213
+ const clientName = options?.clientName ?? 'default';
214
+ const providerContext = await this.bean.fileProvider.getClientOptions(
215
+ {
216
+ providerName,
217
+ clientName,
218
+ },
219
+ options?.clientOptions,
220
+ );
221
+ if (!providerContext.entityFileProvider || providerContext.disabled) {
222
+ return this.app.throw(403);
223
+ }
224
+ return {
225
+ clientName,
226
+ beanFileProvider: this._getBeanFileProvider(providerName, providerContext.beanFullName),
227
+ clientOptions: this._normalizeOptions(providerContext.clientOptions),
228
+ onionOptions: this._normalizeOptions(providerContext.onionOptions),
229
+ };
230
+ }
231
+
232
+ private _getBeanFileProvider<N extends keyof IFileProviderRecord>(
233
+ _providerName: N,
234
+ beanFullName: string,
235
+ ): TypeFileProviderExecuteByName<N> {
236
+ return this.app.bean._getBean<TypeFileProviderExecuteByName<N>>(beanFullName as never);
237
+ }
238
+
239
+ private _normalizeOptions<T extends object>(options: T | undefined): T {
240
+ return (options ?? {}) as T;
241
+ }
242
+
243
+ private _mergeDeliveryOptions(
244
+ file: Pick<IFileResource, 'public'>,
245
+ 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;
251
+ return {
252
+ signed,
253
+ expiresIn,
254
+ expiresAt,
255
+ responseMode,
256
+ };
257
+ }
258
+
259
+ private async _createSignedDownloadUrl(
260
+ fileId: TableIdentity,
261
+ deliveryOptions: IFileDeliveryOptions,
262
+ ) {
263
+ const routePath = this.scope.util.combineApiPath(`file/download/${fileId}`, false, true);
264
+ const tokenPayload = await this.bean.fileUploadPolicy.createDownloadToken({
265
+ fileId,
266
+ expiresIn: deliveryOptions.expiresIn,
267
+ });
268
+ const routeUrl = this.app.util.getAbsoluteUrlByApiPath(routePath);
269
+ const url = new URL(routeUrl);
270
+ url.searchParams.set('token', tokenPayload.token);
271
+ return url.toString();
272
+ }
273
+
274
+ private async _insertFile(
275
+ providerName: keyof IFileProviderRecord,
276
+ clientName: string,
277
+ fileProviderResource: IFileProviderResource | IFileProviderDirectUploadResource,
278
+ context?: Partial<IFileUploadContextResolved>,
279
+ ) {
280
+ return await this.scope.model.file.insert({
281
+ providerName,
282
+ clientName,
283
+ resourceId: fileProviderResource.resourceId,
284
+ bucket: fileProviderResource.bucket,
285
+ objectKey: fileProviderResource.objectKey,
286
+ filename: fileProviderResource.filename,
287
+ contentType: fileProviderResource.contentType,
288
+ size: fileProviderResource.size,
289
+ etag: fileProviderResource.etag,
290
+ public: fileProviderResource.public ?? context?.public,
291
+ meta: fileProviderResource.meta,
292
+ storagePath: fileProviderResource.storagePath,
293
+ deliveryBaseUrl: fileProviderResource.deliveryBaseUrl,
294
+ fileScene: context?.fileScene,
295
+ });
296
+ }
297
+
298
+ private _combineFileResource(
299
+ file: EntityFile,
300
+ fileProviderResource?: IFileProviderResource,
301
+ ): IFileResource {
302
+ return {
303
+ id: file.id,
304
+ provider: file.providerName,
305
+ clientName: file.clientName,
306
+ fileScene: file.fileScene,
307
+ resourceId: fileProviderResource?.resourceId ?? file.resourceId,
308
+ bucket: fileProviderResource?.bucket ?? file.bucket,
309
+ objectKey: fileProviderResource?.objectKey ?? file.objectKey,
310
+ filename: fileProviderResource?.filename ?? file.filename,
311
+ contentType: fileProviderResource?.contentType ?? file.contentType,
312
+ size: fileProviderResource?.size ?? file.size,
313
+ etag: fileProviderResource?.etag ?? file.etag,
314
+ public: fileProviderResource?.public ?? file.public,
315
+ meta: fileProviderResource?.meta ?? file.meta,
316
+ storagePath: fileProviderResource?.storagePath ?? file.storagePath,
317
+ deliveryBaseUrl: fileProviderResource?.deliveryBaseUrl ?? file.deliveryBaseUrl,
318
+ raw: fileProviderResource?.raw,
319
+ uploadedAt: file.createdAt,
320
+ };
321
+ }
322
+ }
@@ -0,0 +1,62 @@
1
+ import { BeanBase, deepExtend } from 'vona';
2
+ import { Bean } from 'vona-module-a-bean';
3
+
4
+ import type { EntityFileProvider } from '../entity/fileProvider.ts';
5
+ import type {
6
+ IFileProviderRecord,
7
+ TypeFileProviderClientOptionsByName,
8
+ TypeFileProviderOptionsByName,
9
+ TypeFileProviderPick,
10
+ } from '../types/fileProvider.ts';
11
+
12
+ @Bean()
13
+ export class BeanFileProvider extends BeanBase {
14
+ async get(data: TypeFileProviderPick) {
15
+ if (!data.id && !data.clientName) data = { ...data, clientName: 'default' };
16
+ const res = await this.scope.model.fileProvider.get(data);
17
+ if (res) return res;
18
+ if (data.id) throw new Error(`not found file provider: ${data.id}`);
19
+ if (!data.providerName) throw new Error('Invalid file provider');
20
+ return await this.scope.redlock.lockIsolate('fileProvider.register', async () => {
21
+ return await this._registerFileProviderLock(data);
22
+ });
23
+ }
24
+
25
+ private async _registerFileProviderLock(data: TypeFileProviderPick) {
26
+ const res = await this.scope.model.fileProvider.get(data, { cache: { force: true } });
27
+ if (res) return res;
28
+ const dataNew: Partial<EntityFileProvider> = {
29
+ disabled: false,
30
+ providerName: data.providerName,
31
+ clientName: data.clientName,
32
+ clientOptions: undefined,
33
+ };
34
+ return await this.scope.model.fileProvider.insert(dataNew);
35
+ }
36
+
37
+ async getClientOptions<N extends keyof IFileProviderRecord>(
38
+ data: TypeFileProviderPick & { providerName: N },
39
+ clientOptionsCustom?: TypeFileProviderClientOptionsByName<N>,
40
+ clientOptionsDefault?: TypeFileProviderClientOptionsByName<N>,
41
+ ) {
42
+ const entityFileProvider = await this.bean.fileProvider.get(data);
43
+ if (!entityFileProvider) return { entityFileProvider: undefined };
44
+ const disabled = entityFileProvider.disabled;
45
+ const fileProviderName = entityFileProvider.providerName as N;
46
+ const clientName = entityFileProvider.clientName ?? 'default';
47
+ const onionSlice = this.bean.onion.fileProvider.getOnionSliceEnabled(true, fileProviderName);
48
+ if (!onionSlice) throw new Error(`File provider not found: ${fileProviderName}`);
49
+ const beanFullName = onionSlice.beanOptions.beanFullName as N;
50
+ const onionOptions =
51
+ (onionSlice.beanOptions.options as TypeFileProviderOptionsByName<N> | undefined) ??
52
+ ({} as TypeFileProviderOptionsByName<N>);
53
+ const clientOptions = deepExtend(
54
+ (clientOptionsDefault ?? {}) as TypeFileProviderClientOptionsByName<N>,
55
+ onionOptions.base,
56
+ onionOptions.clients?.[clientName as keyof typeof onionOptions.clients],
57
+ entityFileProvider.clientOptions as TypeFileProviderClientOptionsByName<N> | undefined,
58
+ clientOptionsCustom,
59
+ ) as TypeFileProviderClientOptionsByName<N>;
60
+ return { entityFileProvider, disabled, beanFullName, onionOptions, clientOptions };
61
+ }
62
+ }
@@ -0,0 +1,196 @@
1
+ import fse from 'fs-extra';
2
+ import { BeanBase } from 'vona';
3
+ import { Bean } from 'vona-module-a-bean';
4
+
5
+ import type {
6
+ IFileDownloadTokenPayload,
7
+ IFileUploadContextResolved,
8
+ IFileUploadPolicyResolved,
9
+ IFileUploadTokenPayload,
10
+ } from '../types/file.ts';
11
+ import type {
12
+ IDecoratorFileSceneOptions,
13
+ IDecoratorFileSceneOptionsProvider,
14
+ IFileSceneRecord,
15
+ } from '../types/fileScene.ts';
16
+
17
+ import { getFileExtension, matchesFileMimeType } from '../lib/fileUploadValidation.ts';
18
+
19
+ @Bean()
20
+ export class BeanFileUploadPolicy extends BeanBase {
21
+ async createUploadToken(data: {
22
+ fileScene: keyof IFileSceneRecord;
23
+ size: number;
24
+ mimeType: string;
25
+ expiresIn?: number;
26
+ }) {
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);
44
+ const token = await this.bean.jwt.createTempAuthToken(
45
+ {
46
+ kind: 'fileDownload',
47
+ fileId: data.fileId,
48
+ } as IFileDownloadTokenPayload,
49
+ {
50
+ path,
51
+ expiresIn: data.expiresIn,
52
+ },
53
+ );
54
+ return { token, expiresIn: data.expiresIn };
55
+ }
56
+
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
+ async verifyDownloadToken(token: string | undefined, routePathRaw: string) {
68
+ const payload = (await this.bean.jwt.get('access').verify(token, {
69
+ path: routePathRaw,
70
+ })) as IFileDownloadTokenPayload | undefined;
71
+ if (!payload || payload.kind !== 'fileDownload') {
72
+ return this.app.throw(401);
73
+ }
74
+ return payload;
75
+ }
76
+
77
+ async validateUploadFile(
78
+ file: {
79
+ file: string;
80
+ filename?: string;
81
+ mimeType: string;
82
+ },
83
+ payload: IFileUploadTokenPayload,
84
+ ) {
85
+ const stat = await fse.stat(file.file);
86
+ 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}`);
92
+ }
93
+ 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)) {
98
+ return this.app.throw(403, `unsupported file mimeType: ${mimeType}`);
99
+ }
100
+ 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}`);
103
+ }
104
+ }
105
+
106
+ async resolveUploadContext(data: {
107
+ fileScene: keyof IFileSceneRecord;
108
+ }): Promise<IFileUploadContextResolved> {
109
+ const fileScene = data.fileScene;
110
+ const sceneOptions = this._getSceneOptions(fileScene);
111
+ const { providerName, clientName } = await this._resolveProvider(sceneOptions);
112
+ const providerContext = await this.bean.fileProvider.getClientOptions({
113
+ providerName,
114
+ clientName,
115
+ });
116
+ if (!providerContext.entityFileProvider || providerContext.disabled) {
117
+ return this.app.throw(403, `File provider unavailable: ${providerName}.${clientName}`);
118
+ }
119
+ return {
120
+ fileScene,
121
+ providerName,
122
+ clientName,
123
+ public: this._resolvePublic(providerContext.clientOptions, sceneOptions),
124
+ meta: await this._resolveSceneMeta(sceneOptions),
125
+ };
126
+ }
127
+
128
+ async resolveUploadPolicy(data: {
129
+ fileScene: keyof IFileSceneRecord;
130
+ size: number;
131
+ mimeType: string;
132
+ }): Promise<IFileUploadPolicyResolved> {
133
+ const fileConfig = this.scope.config.file;
134
+ const context = await this.resolveUploadContext({ fileScene: data.fileScene });
135
+ const sceneOptions = this._getSceneOptions(context.fileScene);
136
+ const uploadOptions = {
137
+ ...(fileConfig.upload ?? {}),
138
+ ...(sceneOptions.upload ?? {}),
139
+ };
140
+ const maxSize = uploadOptions.maxSize;
141
+ const mimeTypes = [...(uploadOptions.mimeTypes ?? [])];
142
+ const extensions = [...(uploadOptions.extensions ?? [])];
143
+ const mimeType = data.mimeType.toLowerCase();
144
+ if (maxSize && data.size > maxSize) {
145
+ return this.app.throw(403, `file too large: maxSize=${maxSize}`);
146
+ }
147
+ if (mimeTypes.length > 0 && !matchesFileMimeType(mimeType, mimeTypes)) {
148
+ return this.app.throw(403, `unsupported file mimeType: ${mimeType}`);
149
+ }
150
+ return {
151
+ ...context,
152
+ maxSize,
153
+ mimeTypes: mimeTypes.length > 0 ? mimeTypes : undefined,
154
+ extensions: extensions.length > 0 ? extensions : undefined,
155
+ multiple: uploadOptions.multiple,
156
+ fileSize: data.size,
157
+ mimeType,
158
+ };
159
+ }
160
+
161
+ private _getSceneOptions(fileScene: keyof IFileSceneRecord): IDecoratorFileSceneOptions {
162
+ const onionSlice = this.bean.onion.fileScene.getOnionSlice(fileScene);
163
+ if (!onionSlice) throw new Error(`not found file scene: ${fileScene}`);
164
+ return onionSlice.beanOptions.options ?? {};
165
+ }
166
+
167
+ private async _resolveProvider(
168
+ sceneOptions: IDecoratorFileSceneOptions,
169
+ ): Promise<Required<IDecoratorFileSceneOptionsProvider>> {
170
+ const provider =
171
+ typeof sceneOptions.provider === 'function'
172
+ ? await sceneOptions.provider(this.ctx)
173
+ : sceneOptions.provider;
174
+ const providerName = provider?.providerName ?? this.scope.config.file.defaultProvider;
175
+ const clientName = provider?.clientName ?? this.scope.config.file.defaultClientName;
176
+ return { providerName, clientName };
177
+ }
178
+
179
+ private async _resolveSceneMeta(sceneOptions: IDecoratorFileSceneOptions) {
180
+ const meta = sceneOptions.meta;
181
+ if (!meta) return undefined;
182
+ if (typeof meta === 'function') {
183
+ return await meta(this.ctx);
184
+ }
185
+ return meta;
186
+ }
187
+
188
+ private _resolvePublic(
189
+ clientOptions: { public?: boolean } | undefined,
190
+ sceneOptions: IDecoratorFileSceneOptions,
191
+ ) {
192
+ return (
193
+ sceneOptions.upload?.public ?? clientOptions?.public ?? this.scope.config.file.public ?? false
194
+ );
195
+ }
196
+ }
@@ -0,0 +1,11 @@
1
+ import { Meta } from 'vona-module-a-meta';
2
+ import { BeanRedlockBase } from 'vona-module-a-redlock';
3
+
4
+ export type TypeRedlockLockResource = never;
5
+ export type TypeRedlockLockIsolateResource = 'fileProvider.register';
6
+
7
+ @Meta()
8
+ export class MetaRedlock extends BeanRedlockBase<
9
+ TypeRedlockLockResource,
10
+ TypeRedlockLockIsolateResource
11
+ > {}
@@ -0,0 +1,38 @@
1
+ import type { IMetaVersionUpdate, IMetaVersionUpdateOptions } from 'vona-module-a-version';
2
+
3
+ import { BeanBase } from 'vona';
4
+ import { Meta } from 'vona-module-a-meta';
5
+
6
+ @Meta()
7
+ export class MetaVersion extends BeanBase implements IMetaVersionUpdate {
8
+ async update(options: IMetaVersionUpdateOptions) {
9
+ if (options.version === 1) {
10
+ const entityFileProvider = this.scope.entity.fileProvider;
11
+ await this.bean.model.createTable(entityFileProvider.$table, table => {
12
+ table.basicFieldsSimple();
13
+ table.boolean(entityFileProvider.disabled).defaultTo(false);
14
+ table.string(entityFileProvider.providerName, 255);
15
+ table.string(entityFileProvider.clientName, 255);
16
+ table.json(entityFileProvider.clientOptions);
17
+ });
18
+ const entityFile = this.scope.entity.file;
19
+ await this.bean.model.createTable(entityFile.$table, table => {
20
+ table.basicFields();
21
+ table.string(entityFile.providerName, 255);
22
+ table.string(entityFile.clientName, 255);
23
+ table.string(entityFile.resourceId, 255);
24
+ table.string(entityFile.bucket, 255);
25
+ table.text(entityFile.objectKey);
26
+ table.string(entityFile.filename, 255);
27
+ table.string(entityFile.contentType, 255);
28
+ table.integer(entityFile.size);
29
+ table.string(entityFile.etag, 255);
30
+ table.boolean(entityFile.public).defaultTo(false);
31
+ table.json(entityFile.meta);
32
+ table.text(entityFile.storagePath);
33
+ table.text(entityFile.deliveryBaseUrl);
34
+ table.string(entityFile.fileScene, 255);
35
+ });
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,16 @@
1
+ import type { VonaApplication } from 'vona';
2
+
3
+ import type { IFileProviderRecord } from '../types/fileProvider.ts';
4
+
5
+ export function config(_app: VonaApplication) {
6
+ return {
7
+ file: {
8
+ defaultProvider: 'file-native:native' as keyof IFileProviderRecord,
9
+ defaultClientName: 'default',
10
+ public: false,
11
+ upload: {
12
+ maxSize: 20 * 1024 * 1024,
13
+ },
14
+ },
15
+ };
16
+ }