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
@@ -2,7 +2,12 @@ import { BeanBase, deepExtend } from 'vona';
2
2
  import { Bean } from 'vona-module-a-bean';
3
3
 
4
4
  import type { EntityImageProvider } from '../entity/imageProvider.ts';
5
- import type { IImageProviderClientOptions, TypeImageProviderPick } from '../types/imageProvider.ts';
5
+ import type {
6
+ IImageProviderRecord,
7
+ TypeImageProviderClientOptionsByName,
8
+ TypeImageProviderOptionsByName,
9
+ TypeImageProviderPick,
10
+ } from '../types/imageProvider.ts';
6
11
 
7
12
  @Bean()
8
13
  export class BeanImageProvider extends BeanBase {
@@ -29,27 +34,29 @@ export class BeanImageProvider extends BeanBase {
29
34
  return await this.scope.model.imageProvider.insert(dataNew);
30
35
  }
31
36
 
32
- async getClientOptions<T extends IImageProviderClientOptions>(
33
- data: TypeImageProviderPick,
34
- clientOptionsCustom?: T,
35
- clientOptionsDefault?: T,
37
+ async getClientOptions<N extends keyof IImageProviderRecord>(
38
+ data: TypeImageProviderPick & { providerName: N },
39
+ clientOptionsCustom?: TypeImageProviderClientOptionsByName<N>,
40
+ clientOptionsDefault?: TypeImageProviderClientOptionsByName<N>,
36
41
  ) {
37
42
  const entityImageProvider = await this.bean.imageProvider.get(data);
38
43
  if (!entityImageProvider) return { entityImageProvider: undefined };
39
44
  const disabled = entityImageProvider.disabled;
40
- const imageProviderName = entityImageProvider.providerName;
45
+ const imageProviderName = entityImageProvider.providerName as N;
41
46
  const clientName = entityImageProvider.clientName ?? 'default';
42
47
  const onionSlice = this.bean.onion.imageProvider.getOnionSliceEnabled(true, imageProviderName);
43
48
  if (!onionSlice) throw new Error(`Image provider not found: ${imageProviderName}`);
44
- const beanFullName = onionSlice.beanOptions.beanFullName;
45
- const onionOptions = onionSlice.beanOptions.options ?? {};
46
- const clientOptions: IImageProviderClientOptions = deepExtend(
47
- clientOptionsDefault ?? {},
48
- onionOptions?.base,
49
- onionOptions?.clients?.[clientName],
50
- entityImageProvider.clientOptions,
49
+ const beanFullName = onionSlice.beanOptions.beanFullName as N;
50
+ const onionOptions =
51
+ (onionSlice.beanOptions.options as TypeImageProviderOptionsByName<N> | undefined) ??
52
+ ({} as TypeImageProviderOptionsByName<N>);
53
+ const clientOptions = deepExtend(
54
+ (clientOptionsDefault ?? {}) as TypeImageProviderClientOptionsByName<N>,
55
+ onionOptions.base,
56
+ onionOptions.clients?.[clientName as keyof typeof onionOptions.clients],
57
+ entityImageProvider.clientOptions as TypeImageProviderClientOptionsByName<N> | undefined,
51
58
  clientOptionsCustom,
52
- );
59
+ ) as TypeImageProviderClientOptionsByName<N>;
53
60
  return { entityImageProvider, disabled, beanFullName, onionOptions, clientOptions };
54
61
  }
55
62
  }
@@ -1,8 +1,10 @@
1
+ import fse from 'fs-extra';
1
2
  import { BeanBase } from 'vona';
2
3
  import { Bean } from 'vona-module-a-bean';
3
4
 
4
5
  import type {
5
6
  IImageDeliveryTokenPayload,
7
+ IImageDirectUploadTokenPayload,
6
8
  IImageUploadContextResolved,
7
9
  IImageUploadPolicyResolved,
8
10
  IImageUploadTokenPayload,
@@ -13,6 +15,8 @@ import type {
13
15
  IImageSceneRecord,
14
16
  } from '../types/imageScene.ts';
15
17
 
18
+ import { getImageExtension, matchesImageMimeType } from '../lib/imageUploadValidation.ts';
19
+
16
20
  @Bean()
17
21
  export class BeanImageUploadPolicy extends BeanBase {
18
22
  async createUploadToken(data: {
@@ -36,6 +40,21 @@ export class BeanImageUploadPolicy extends BeanBase {
36
40
  return { token, expiresIn: data.expiresIn };
37
41
  }
38
42
 
43
+ async createDirectUploadToken(data: { resourceId: string; path: string; expiresIn?: number }) {
44
+ const path = data.path;
45
+ const token = await this.bean.jwt.createTempAuthToken(
46
+ {
47
+ kind: 'imageDirectUpload',
48
+ resourceId: data.resourceId,
49
+ } as IImageDirectUploadTokenPayload,
50
+ {
51
+ path,
52
+ expiresIn: data.expiresIn,
53
+ },
54
+ );
55
+ return { token, expiresIn: data.expiresIn };
56
+ }
57
+
39
58
  async createDeliveryToken(data: {
40
59
  imageId: number | string;
41
60
  request: IImageDeliveryTokenPayload['request'];
@@ -68,6 +87,16 @@ export class BeanImageUploadPolicy extends BeanBase {
68
87
  return payload;
69
88
  }
70
89
 
90
+ async verifyDirectUploadToken(token: string | undefined, routePathRaw: string) {
91
+ const payload = (await this.bean.jwt.get('access').verify(token, {
92
+ path: routePathRaw,
93
+ })) as IImageDirectUploadTokenPayload | undefined;
94
+ if (!payload || payload.kind !== 'imageDirectUpload') {
95
+ return this.app.throw(401);
96
+ }
97
+ return payload;
98
+ }
99
+
71
100
  async verifyDeliveryToken(token: string | undefined, routePathRaw: string) {
72
101
  const payload = (await this.bean.jwt.get('access').verify(token, {
73
102
  path: routePathRaw,
@@ -121,7 +150,7 @@ export class BeanImageUploadPolicy extends BeanBase {
121
150
  if (maxSize && data.size > maxSize) {
122
151
  return this.app.throw(403, `image too large: maxSize=${maxSize}`);
123
152
  }
124
- if (mimeTypes.length > 0 && !this._matchesMimeType(mimeType, mimeTypes)) {
153
+ if (mimeTypes.length > 0 && !matchesImageMimeType(mimeType, mimeTypes)) {
125
154
  return this.app.throw(403, `unsupported image mimeType: ${mimeType}`);
126
155
  }
127
156
  return {
@@ -135,6 +164,35 @@ export class BeanImageUploadPolicy extends BeanBase {
135
164
  };
136
165
  }
137
166
 
167
+ async validateUploadFile(
168
+ file: {
169
+ file: string;
170
+ filename?: string;
171
+ mimeType: string;
172
+ },
173
+ payload: IImageUploadPolicyResolved,
174
+ ) {
175
+ const stat = await fse.stat(file.file);
176
+ const fileSize = Number(stat.size);
177
+ if (fileSize !== payload.fileSize) {
178
+ return this.app.throw(403, `image size mismatch: size=${fileSize}`);
179
+ }
180
+ if (payload.maxSize && fileSize > payload.maxSize) {
181
+ return this.app.throw(403, `image too large: maxSize=${payload.maxSize}`);
182
+ }
183
+ const mimeType = file.mimeType.toLowerCase();
184
+ if (mimeType !== payload.mimeType) {
185
+ return this.app.throw(403, `image mimeType mismatch: mimeType=${mimeType}`);
186
+ }
187
+ if (payload.mimeTypes?.length && !matchesImageMimeType(mimeType, payload.mimeTypes)) {
188
+ return this.app.throw(403, `unsupported image mimeType: ${mimeType}`);
189
+ }
190
+ const extension = getImageExtension(file.filename);
191
+ if (payload.extensions?.length && extension && !payload.extensions.includes(extension)) {
192
+ return this.app.throw(403, `unsupported image extension: ${extension}`);
193
+ }
194
+ }
195
+
138
196
  private _getSceneOptions(imageScene: keyof IImageSceneRecord): IDecoratorImageSceneOptions {
139
197
  const onionSlice = this.bean.onion.imageScene.getOnionSlice(imageScene);
140
198
  if (!onionSlice) throw new Error(`not found image scene: ${imageScene}`);
@@ -161,14 +219,4 @@ export class BeanImageUploadPolicy extends BeanBase {
161
219
  }
162
220
  return meta;
163
221
  }
164
-
165
- private _matchesMimeType(mimeType: string, mimeTypes: string[]) {
166
- return mimeTypes.some(item => {
167
- if (item === mimeType) return true;
168
- if (item.endsWith('/*')) {
169
- return mimeType.startsWith(`${item.slice(0, -1)}`);
170
- }
171
- return false;
172
- });
173
- }
174
222
  }
@@ -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('aImage', 'status+draftExpiresAt'),
10
+ },
11
+ })
12
+ export class MetaIndex extends BeanBase {}
@@ -1,7 +1,7 @@
1
1
  import { Meta } from 'vona-module-a-meta';
2
2
  import { BeanRedlockBase } from 'vona-module-a-redlock';
3
3
 
4
- export type TypeRedlockLockResource = never;
4
+ export type TypeRedlockLockResource = `image.directUpload.${string}`;
5
5
  export type TypeRedlockLockIsolateResource = 'imageProvider.register';
6
6
 
7
7
  @Meta()
@@ -34,6 +34,9 @@ export class MetaVersion extends BeanBase implements IMetaVersionUpdate {
34
34
  table.text(entityImage.storagePath);
35
35
  table.text(entityImage.deliveryBaseUrl);
36
36
  table.string(entityImage.imageScene, 255);
37
+ table.string(entityImage.status, 255).defaultTo('ready');
38
+ table.dateTime(entityImage.draftExpiresAt);
39
+ table.dateTime(entityImage.finalizedAt);
37
40
  });
38
41
  }
39
42
  }
@@ -0,0 +1,29 @@
1
+ import type { IScheduleExecute } from 'vona-module-a-schedule';
2
+
3
+ import { BeanBase } from 'vona';
4
+ import { Schedule } from 'vona-module-a-schedule';
5
+
6
+ const PruneBatchLimit = 100;
7
+
8
+ @Schedule({ repeat: { every: 30 * 60 * 1000 } })
9
+ export class ScheduleImageDraftPrune extends BeanBase implements IScheduleExecute {
10
+ async execute() {
11
+ const items = await this.scope.model.image.select({
12
+ columns: ['id'],
13
+ where: {
14
+ status: 'draft',
15
+ draftExpiresAt: {
16
+ _lt_: new Date(),
17
+ },
18
+ },
19
+ orders: [
20
+ ['draftExpiresAt', 'asc'],
21
+ ['id', 'asc'],
22
+ ],
23
+ limit: PruneBatchLimit,
24
+ });
25
+ for (const item of items) {
26
+ await this.bean.image.expireDraftImage(item.id);
27
+ }
28
+ }
29
+ }
@@ -12,6 +12,9 @@ export function config(_app: VonaApplication) {
12
12
  maxSize: 2 * 1024 * 1024,
13
13
  mimeTypes: ['image/png', 'image/jpeg', 'image/webp'],
14
14
  },
15
+ directUpload: {
16
+ draftExpiresIn: 30 * 60 * 1000,
17
+ },
15
18
  },
16
19
  };
17
20
  }
@@ -1,15 +1,15 @@
1
1
  import type { IUploadFile } from 'vona-module-a-upload';
2
2
  import type { IDecoratorControllerOptions } from 'vona-module-a-web';
3
3
 
4
- import fse from 'fs-extra';
5
4
  import { BeanBase } from 'vona';
6
5
  import { Core } from 'vona-module-a-core';
7
6
  import { Api, v } from 'vona-module-a-openapiutils';
7
+ import { Passport } from 'vona-module-a-user';
8
8
  import { Arg, Controller, Web } from 'vona-module-a-web';
9
9
 
10
- import type { IImageUploadTokenPayload } from '../types/image.ts';
11
-
12
10
  import { DtoImageDeliveryRequest } from '../dto/imageDeliveryRequest.ts';
11
+ import { DtoImageDirectUploadFinalizeRequest } from '../dto/imageDirectUploadFinalizeRequest.ts';
12
+ import { DtoImageDirectUploadFinalizeResponse } from '../dto/imageDirectUploadFinalizeResponse.ts';
13
13
  import { DtoImageDirectUploadRequest } from '../dto/imageDirectUploadRequest.ts';
14
14
  import { DtoImageDirectUploadResponse } from '../dto/imageDirectUploadResponse.ts';
15
15
  import { DtoImageUploadResponse } from '../dto/imageUploadResponse.ts';
@@ -36,7 +36,14 @@ export class ControllerImage extends BeanBase {
36
36
  token,
37
37
  this.ctx.route.routePathRaw,
38
38
  );
39
- await this._validateUploadFile(file, payload);
39
+ await this.bean.imageUploadPolicy.validateUploadFile(
40
+ {
41
+ file: file.file,
42
+ filename: file.info.filename,
43
+ mimeType: file.info.mimeType,
44
+ },
45
+ payload,
46
+ );
40
47
  const image = await this.bean.image.upload(
41
48
  payload.providerName,
42
49
  {
@@ -50,11 +57,7 @@ export class ControllerImage extends BeanBase {
50
57
  imageScene: payload.imageScene,
51
58
  },
52
59
  );
53
- return {
54
- ...image,
55
- url: await this.bean.image.getVariantUrl(image.id),
56
- signed: !!image.requireSignedURLs,
57
- };
60
+ return await this.bean.image.createImageActionResponse(image);
58
61
  }
59
62
 
60
63
  @Web.post('direct-upload')
@@ -70,6 +73,7 @@ export class ControllerImage extends BeanBase {
70
73
  {
71
74
  filename: data.filename,
72
75
  contentType: data.contentType,
76
+ requireSignedURLs: data.requireSignedURLs,
73
77
  expiry: data.expiry,
74
78
  customId: data.customId,
75
79
  },
@@ -81,6 +85,13 @@ export class ControllerImage extends BeanBase {
81
85
  );
82
86
  }
83
87
 
88
+ @Web.post('direct-upload/finalize')
89
+ @Api.body(DtoImageDirectUploadFinalizeResponse)
90
+ async finalizeDirectUpload(@Arg.body() data: DtoImageDirectUploadFinalizeRequest) {
91
+ const image = await this.bean.image.finalizeDirectUpload(data.imageId);
92
+ return await this.bean.image.createImageActionResponse(image);
93
+ }
94
+
84
95
  @Web.post('upload-url')
85
96
  @Api.body(DtoImageUploadResponse)
86
97
  async uploadUrl(@Arg.body() data: DtoImageUploadUrlRequest) {
@@ -95,6 +106,7 @@ export class ControllerImage extends BeanBase {
95
106
  url: data.url,
96
107
  filename: data.filename,
97
108
  contentType: data.contentType,
109
+ requireSignedURLs: data.requireSignedURLs,
98
110
  },
99
111
  {
100
112
  clientName: policy.clientName,
@@ -102,64 +114,22 @@ export class ControllerImage extends BeanBase {
102
114
  imageScene: policy.imageScene,
103
115
  },
104
116
  );
105
- return {
106
- ...image,
107
- url: await this.bean.image.getVariantUrl(image.id),
108
- signed: !!image.requireSignedURLs,
109
- };
117
+ return await this.bean.image.createImageActionResponse(image);
110
118
  }
111
119
 
112
120
  @Web.get('delivery/:imageId')
121
+ @Passport.public()
113
122
  async delivery(
114
123
  @Arg.param('imageId', v.tableIdentity()) imageId: number,
115
124
  @Arg.query(v.object(DtoImageDeliveryRequest)) query: DtoImageDeliveryRequest,
116
125
  ) {
117
126
  const payload = await this.bean.imageUploadPolicy.verifyDeliveryToken(
118
127
  query.token,
119
- this.ctx.route.routePathRaw,
128
+ this.scope.util.combineApiPath(`image/delivery/${imageId}`, false, true),
120
129
  );
121
130
  if (String(payload.imageId) !== String(imageId)) {
122
131
  return this.app.throw(401);
123
132
  }
124
133
  this.ctx.redirect(payload.targetUrl);
125
134
  }
126
-
127
- private async _validateUploadFile(file: IUploadFile, payload: IImageUploadTokenPayload) {
128
- const stat = await fse.stat(file.file);
129
- const fileSize = Number(stat.size);
130
- if (fileSize !== payload.fileSize) {
131
- return this.app.throw(403, `image size mismatch: size=${fileSize}`);
132
- }
133
- if (payload.maxSize && fileSize > payload.maxSize) {
134
- return this.app.throw(403, `image too large: maxSize=${payload.maxSize}`);
135
- }
136
- const mimeType = file.info.mimeType.toLowerCase();
137
- if (mimeType !== payload.mimeType) {
138
- return this.app.throw(403, `image mimeType mismatch: mimeType=${mimeType}`);
139
- }
140
- if (payload.mimeTypes?.length && !this._matchesMimeType(mimeType, payload.mimeTypes)) {
141
- return this.app.throw(403, `unsupported image mimeType: ${mimeType}`);
142
- }
143
- const extension = this._getExtension(file.info.filename);
144
- if (payload.extensions?.length && extension && !payload.extensions.includes(extension)) {
145
- return this.app.throw(403, `unsupported image extension: ${extension}`);
146
- }
147
- }
148
-
149
- private _matchesMimeType(mimeType: string, mimeTypes: string[]) {
150
- return mimeTypes.some(item => {
151
- if (item === mimeType) return true;
152
- if (item.endsWith('/*')) {
153
- return mimeType.startsWith(`${item.slice(0, -1)}`);
154
- }
155
- return false;
156
- });
157
- }
158
-
159
- private _getExtension(filename?: string) {
160
- if (!filename) return '';
161
- const index = filename.lastIndexOf('.');
162
- if (index === -1) return '';
163
- return filename.slice(index).toLowerCase();
164
- }
165
135
  }
@@ -0,0 +1,13 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+ import type { IDecoratorDtoOptions } from 'vona-module-a-web';
3
+
4
+ import { Api, v } from 'vona-module-a-openapiutils';
5
+ import { Dto } from 'vona-module-a-web';
6
+
7
+ export interface IDtoOptionsImageDirectUploadFinalizeRequest extends IDecoratorDtoOptions {}
8
+
9
+ @Dto<IDtoOptionsImageDirectUploadFinalizeRequest>()
10
+ export class DtoImageDirectUploadFinalizeRequest {
11
+ @Api.field(v.tableIdentity())
12
+ imageId: TableIdentity;
13
+ }
@@ -0,0 +1,10 @@
1
+ import type { IDecoratorDtoOptions } from 'vona-module-a-web';
2
+
3
+ import { Dto } from 'vona-module-a-web';
4
+
5
+ import { DtoImageUploadResponse } from './imageUploadResponse.ts';
6
+
7
+ export interface IDtoOptionsImageDirectUploadFinalizeResponse extends IDecoratorDtoOptions {}
8
+
9
+ @Dto<IDtoOptionsImageDirectUploadFinalizeResponse>()
10
+ export class DtoImageDirectUploadFinalizeResponse extends DtoImageUploadResponse {}
@@ -5,6 +5,7 @@ import { Api, v } from 'vona-module-a-openapiutils';
5
5
  import { Dto } from 'vona-module-a-web';
6
6
  import z from 'zod';
7
7
 
8
+ import type { TypeImageStatus } from '../types/image.ts';
8
9
  import type { IImageProviderRecord } from '../types/imageProvider.ts';
9
10
  import type { IImageSceneRecord } from '../types/imageScene.ts';
10
11
 
@@ -33,6 +34,15 @@ export class DtoImageDirectUploadResponse {
33
34
  @Api.field(v.optional())
34
35
  filename?: string;
35
36
 
37
+ @Api.field(v.optional())
38
+ requireSignedURLs?: boolean;
39
+
40
+ @Api.field(v.optional(), z.string())
41
+ status?: TypeImageStatus;
42
+
43
+ @Api.field(v.optional())
44
+ draftExpiresAt?: Date;
45
+
36
46
  @Api.field(v.optional(), z.string())
37
47
  imageScene?: keyof IImageSceneRecord;
38
48
  }
@@ -5,7 +5,7 @@ import { $schema, Api, v } from 'vona-module-a-openapiutils';
5
5
  import { Dto } from 'vona-module-a-web';
6
6
  import z from 'zod';
7
7
 
8
- import type { IImageNamedVariants } from '../types/image.ts';
8
+ import type { IImageNamedVariants, TypeImageStatus } from '../types/image.ts';
9
9
  import type { IImageProviderRecord } from '../types/imageProvider.ts';
10
10
  import type { IImageSceneRecord } from '../types/imageScene.ts';
11
11
 
@@ -51,6 +51,15 @@ export class DtoImageUploadResponse {
51
51
  @Api.field(v.optional(), z.string())
52
52
  imageScene?: keyof IImageSceneRecord;
53
53
 
54
+ @Api.field(v.optional(), z.string())
55
+ status?: TypeImageStatus;
56
+
57
+ @Api.field(v.optional())
58
+ draftExpiresAt?: Date;
59
+
60
+ @Api.field(v.optional())
61
+ finalizedAt?: Date;
62
+
54
63
  @Api.field(v.optional())
55
64
  uploadedAt?: Date;
56
65
 
@@ -5,7 +5,7 @@ import { $schema, Api, v } from 'vona-module-a-openapiutils';
5
5
  import { Dto } from 'vona-module-a-web';
6
6
  import z from 'zod';
7
7
 
8
- import type { IImageNamedVariants } from '../types/image.ts';
8
+ import type { IImageNamedVariants, TypeImageStatus } from '../types/image.ts';
9
9
  import type { IImageSceneRecord } from '../types/imageScene.ts';
10
10
 
11
11
  import { DtoImageTransformOptions } from './imageTransformOptions.tsx';
@@ -38,6 +38,15 @@ export class DtoImageView {
38
38
  @Api.field(v.optional())
39
39
  imageScene?: keyof IImageSceneRecord | string;
40
40
 
41
+ @Api.field(v.optional(), z.string())
42
+ status?: TypeImageStatus;
43
+
44
+ @Api.field(v.optional())
45
+ draftExpiresAt?: Date;
46
+
47
+ @Api.field(v.optional())
48
+ finalizedAt?: Date;
49
+
41
50
  @Api.field(v.optional())
42
51
  uploadedAt?: Date;
43
52
 
@@ -4,7 +4,7 @@ import { $schema, Api, v } from 'vona-module-a-openapiutils';
4
4
  import { Entity, EntityBase } from 'vona-module-a-orm';
5
5
  import z from 'zod';
6
6
 
7
- import type { IImageNamedVariants } from '../types/image.ts';
7
+ import type { IImageNamedVariants, TypeImageStatus } from '../types/image.ts';
8
8
  import type { IImageProviderRecord } from '../types/imageProvider.ts';
9
9
  import type { IImageSceneRecord } from '../types/imageScene.ts';
10
10
 
@@ -55,4 +55,13 @@ export class EntityImage extends EntityBase {
55
55
 
56
56
  @Api.field(v.optional(), z.string())
57
57
  imageScene?: keyof IImageSceneRecord;
58
+
59
+ @Api.field(v.optional(), z.string())
60
+ status?: TypeImageStatus;
61
+
62
+ @Api.field(v.optional())
63
+ draftExpiresAt?: Date;
64
+
65
+ @Api.field(v.optional())
66
+ finalizedAt?: Date;
58
67
  }
@@ -0,0 +1,15 @@
1
+ import path from 'node:path';
2
+
3
+ export function matchesImageMimeType(mimeType: string, mimeTypes: string[]) {
4
+ return mimeTypes.some(item => {
5
+ if (item === mimeType) return true;
6
+ if (item.endsWith('/*')) {
7
+ return mimeType.startsWith(`${item.slice(0, -1)}`);
8
+ }
9
+ return false;
10
+ });
11
+ }
12
+
13
+ export function getImageExtension(filename?: string) {
14
+ return path.extname(filename ?? '').toLowerCase();
15
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './imageProvider.ts';
2
2
  export * from './imageScene.ts';
3
+ export * from './imageUploadValidation.ts';
3
4
  export * from './imageVariant.ts';
@@ -39,6 +39,8 @@ export type IImageNamedVariants = Partial<Record<TypeImageVariantName, IImageTra
39
39
 
40
40
  export type TypeImageDeliveryExpiry = Date | string | number;
41
41
 
42
+ export type TypeImageStatus = 'draft' | 'ready' | 'expired';
43
+
42
44
  export interface IImageDeliveryOptions {
43
45
  signed?: boolean;
44
46
  expiresIn?: number;
@@ -142,6 +144,9 @@ export interface IImageResource<
142
144
  provider: keyof IImageProviderRecord;
143
145
  clientName: string;
144
146
  imageScene?: keyof IImageSceneRecord | string;
147
+ status?: TypeImageStatus;
148
+ draftExpiresAt?: Date;
149
+ finalizedAt?: Date;
145
150
  uploadedAt?: Date;
146
151
  }
147
152
 
@@ -153,6 +158,11 @@ export interface IImageDirectUploadResult<
153
158
  draft?: boolean;
154
159
  }
155
160
 
161
+ export interface IImageFinalizeDirectUploadResult<
162
+ TMeta extends TypeImageMeta = TypeImageMeta,
163
+ TRaw = unknown,
164
+ > extends IImageResource<TMeta, TRaw> {}
165
+
156
166
  export interface IImageView {
157
167
  id: TableIdentity;
158
168
  url: string;
@@ -162,6 +172,9 @@ export interface IImageView {
162
172
  provider: keyof IImageProviderRecord;
163
173
  clientName: string;
164
174
  imageScene?: keyof IImageSceneRecord | string;
175
+ status?: TypeImageStatus;
176
+ draftExpiresAt?: Date;
177
+ finalizedAt?: Date;
165
178
  uploadedAt?: Date;
166
179
  variants?: IImageNamedVariants;
167
180
  signed?: boolean;
@@ -194,6 +207,11 @@ export interface IImageUploadTokenPayload<
194
207
  kind: 'imageUpload';
195
208
  }
196
209
 
210
+ export interface IImageDirectUploadTokenPayload {
211
+ kind: 'imageDirectUpload';
212
+ resourceId: string;
213
+ }
214
+
197
215
  export interface IImageDeliveryTokenPayload {
198
216
  kind: 'imageDelivery';
199
217
  imageId: TableIdentity;
@@ -37,16 +37,37 @@ export type TypeImageProviderClientOptions<T> =
37
37
  T extends IDecoratorImageProviderOptions<any, infer O> ? O : never;
38
38
 
39
39
  export type TypeImageProviderClientName<T> =
40
- T extends IDecoratorImageProviderOptions<infer R, any> ? keyof R : never;
40
+ T extends IDecoratorImageProviderOptions<infer R, any> ? keyof R & string : never;
41
41
 
42
42
  export interface IDecoratorImageProviderOptions<
43
43
  R extends IImageProviderClientRecord = IImageProviderClientRecord,
44
44
  T extends IImageProviderClientOptions = IImageProviderClientOptions,
45
45
  > extends TypeOnionOptionsEnableSimple {
46
46
  base?: T;
47
- clients?: { [K in keyof R]?: T };
47
+ clients?: { [K in keyof R]?: R[K] extends undefined ? T : R[K] };
48
48
  }
49
49
 
50
+ export type TypeImageProviderOptionsByName<N extends keyof IImageProviderRecord> =
51
+ IImageProviderRecord[N];
52
+
53
+ export type TypeImageProviderClientOptionsByName<N extends keyof IImageProviderRecord> =
54
+ TypeImageProviderOptionsByName<N> extends IDecoratorImageProviderOptions<any, any>
55
+ ? TypeImageProviderClientOptions<TypeImageProviderOptionsByName<N>>
56
+ : never;
57
+
58
+ export type TypeImageProviderClientNameByName<N extends keyof IImageProviderRecord> =
59
+ TypeImageProviderOptionsByName<N> extends IDecoratorImageProviderOptions<any, any>
60
+ ? TypeImageProviderClientName<TypeImageProviderOptionsByName<N>>
61
+ : never;
62
+
63
+ export type TypeImageProviderExecuteByName<N extends keyof IImageProviderRecord> =
64
+ TypeImageProviderOptionsByName<N> extends IDecoratorImageProviderOptions<any, any>
65
+ ? IImageProviderExecute<
66
+ TypeImageProviderClientOptionsByName<N>,
67
+ TypeImageProviderOptionsByName<N>
68
+ >
69
+ : never;
70
+
50
71
  // Provider-internal resolved variant names include both declaration-merged named variants
51
72
  // and the internal `custom` sentinel used for ad hoc transform requests.
52
73
  export type TypeImageProviderResolvedVariantName = TypeImageVariantName | 'custom';
@@ -71,6 +92,11 @@ export interface IImageProviderExecute<
71
92
  clientOptions: T,
72
93
  options: O,
73
94
  ): Promise<IImageProviderDirectUploadResource>;
95
+ finalizeDirectUpload?(
96
+ image: EntityImage,
97
+ clientOptions: T,
98
+ options: O,
99
+ ): Promise<IImageProviderResource | undefined>;
74
100
  get(
75
101
  image: EntityImage,
76
102
  clientOptions: T,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-image-cloudflare",
3
- "version": "5.0.1",
3
+ "version": "5.0.4",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"