@open-mercato/core 0.6.2-develop.3380.1.bb14509041 → 0.6.2-develop.3400.1.d948268f50

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 (64) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/api/file/[id]/route.js +8 -8
  3. package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
  4. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +7 -8
  5. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
  6. package/dist/modules/attachments/api/library/[id]/route.js +6 -2
  7. package/dist/modules/attachments/api/library/[id]/route.js.map +2 -2
  8. package/dist/modules/attachments/api/library/route.js +2 -0
  9. package/dist/modules/attachments/api/library/route.js.map +2 -2
  10. package/dist/modules/attachments/api/openapi.js +5 -1
  11. package/dist/modules/attachments/api/openapi.js.map +2 -2
  12. package/dist/modules/attachments/api/partitions/route.js +10 -1
  13. package/dist/modules/attachments/api/partitions/route.js.map +2 -2
  14. package/dist/modules/attachments/api/route.js +19 -7
  15. package/dist/modules/attachments/api/route.js.map +2 -2
  16. package/dist/modules/attachments/backend/config/attachments/page.js +3 -1
  17. package/dist/modules/attachments/backend/config/attachments/page.js.map +2 -2
  18. package/dist/modules/attachments/cli.js +6 -2
  19. package/dist/modules/attachments/cli.js.map +2 -2
  20. package/dist/modules/attachments/components/AttachmentPartitionSettings.js +216 -10
  21. package/dist/modules/attachments/components/AttachmentPartitionSettings.js.map +2 -2
  22. package/dist/modules/attachments/di.js +13 -0
  23. package/dist/modules/attachments/di.js.map +7 -0
  24. package/dist/modules/attachments/lib/drivers/driverFactory.js +67 -0
  25. package/dist/modules/attachments/lib/drivers/driverFactory.js.map +7 -0
  26. package/dist/modules/attachments/lib/drivers/index.js +11 -0
  27. package/dist/modules/attachments/lib/drivers/index.js.map +7 -0
  28. package/dist/modules/attachments/lib/drivers/legacyPublicDriver.js +42 -0
  29. package/dist/modules/attachments/lib/drivers/legacyPublicDriver.js.map +7 -0
  30. package/dist/modules/attachments/lib/drivers/localDriver.js +70 -0
  31. package/dist/modules/attachments/lib/drivers/localDriver.js.map +7 -0
  32. package/dist/modules/attachments/lib/drivers/types.js +1 -0
  33. package/dist/modules/attachments/lib/drivers/types.js.map +7 -0
  34. package/dist/modules/attachments/lib/ocrQueue.js +10 -5
  35. package/dist/modules/attachments/lib/ocrQueue.js.map +2 -2
  36. package/dist/modules/attachments/lib/storage.js.map +2 -2
  37. package/dist/modules/attachments/lib/thumbnailCache.js +11 -13
  38. package/dist/modules/attachments/lib/thumbnailCache.js.map +2 -2
  39. package/dist/modules/integrations/backend/integrations/[id]/page.js +31 -5
  40. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  41. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +14 -2
  42. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  43. package/package.json +11 -7
  44. package/src/modules/attachments/api/file/[id]/route.ts +10 -8
  45. package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +8 -8
  46. package/src/modules/attachments/api/library/[id]/route.ts +7 -3
  47. package/src/modules/attachments/api/library/route.ts +2 -0
  48. package/src/modules/attachments/api/openapi.ts +4 -0
  49. package/src/modules/attachments/api/partitions/route.ts +10 -1
  50. package/src/modules/attachments/api/route.ts +20 -7
  51. package/src/modules/attachments/backend/config/attachments/page.tsx +3 -1
  52. package/src/modules/attachments/cli.ts +7 -2
  53. package/src/modules/attachments/components/AttachmentPartitionSettings.tsx +275 -7
  54. package/src/modules/attachments/di.ts +13 -0
  55. package/src/modules/attachments/lib/drivers/driverFactory.ts +91 -0
  56. package/src/modules/attachments/lib/drivers/index.ts +4 -0
  57. package/src/modules/attachments/lib/drivers/legacyPublicDriver.ts +46 -0
  58. package/src/modules/attachments/lib/drivers/localDriver.ts +79 -0
  59. package/src/modules/attachments/lib/drivers/types.ts +28 -0
  60. package/src/modules/attachments/lib/ocrQueue.ts +14 -6
  61. package/src/modules/attachments/lib/storage.ts +13 -0
  62. package/src/modules/attachments/lib/thumbnailCache.ts +25 -19
  63. package/src/modules/integrations/backend/integrations/[id]/page.tsx +30 -4
  64. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +15 -2
@@ -37,6 +37,8 @@ type Partition = {
37
37
  isPublic: boolean
38
38
  requiresOcr: boolean
39
39
  ocrModel: string | null
40
+ storageDriver: string
41
+ configJson: Record<string, unknown> | null
40
42
  envKey: string
41
43
  createdAt: string | null
42
44
  }
@@ -45,6 +47,8 @@ type DialogState =
45
47
  | { mode: 'create' }
46
48
  | { mode: 'edit'; entry: Partition }
47
49
 
50
+ type S3CredentialsSource = 'integration' | 'env'
51
+
48
52
  const DEFAULT_FORM = {
49
53
  code: '',
50
54
  title: '',
@@ -52,15 +56,36 @@ const DEFAULT_FORM = {
52
56
  isPublic: false,
53
57
  requiresOcr: true,
54
58
  ocrModel: '',
59
+ storageDriver: 'local',
60
+ s3CredentialsSource: 'integration' as S3CredentialsSource,
61
+ s3CredentialsEnvPrefix: '',
62
+ s3Bucket: '',
63
+ s3Region: '',
64
+ s3Endpoint: '',
65
+ s3ForcePathStyle: false,
55
66
  }
56
67
 
68
+ const ALL_STORAGE_DRIVER_OPTIONS = [
69
+ { value: 'local', label: 'Local filesystem' },
70
+ { value: 's3', label: 'Amazon S3 / S3-compatible', requiresModule: 's3' },
71
+ ]
72
+
73
+ const OCR_MODEL_DEFAULT_VALUE = '__default__'
74
+
57
75
  const OCR_MODEL_OPTIONS = [
58
- { value: '', label: 'Default (from environment)' },
76
+ { value: OCR_MODEL_DEFAULT_VALUE, label: 'Default (from environment)' },
59
77
  { value: 'gpt-4o', label: 'GPT-4o (Recommended)' },
60
78
  { value: 'gpt-4o-mini', label: 'GPT-4o Mini (Faster, Lower Cost)' },
61
79
  ]
62
80
 
63
- export function AttachmentPartitionSettings() {
81
+ export type AttachmentPartitionSettingsProps = {
82
+ s3Enabled: boolean
83
+ }
84
+
85
+ export function AttachmentPartitionSettings({ s3Enabled }: AttachmentPartitionSettingsProps) {
86
+ const storageDriverOptions = ALL_STORAGE_DRIVER_OPTIONS.filter(
87
+ (opt) => !opt.requiresModule || (opt.requiresModule === 's3' && s3Enabled),
88
+ )
64
89
  const t = useT()
65
90
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
66
91
  const [items, setItems] = React.useState<Partition[]>([])
@@ -71,6 +96,9 @@ export function AttachmentPartitionSettings() {
71
96
  const [submitting, setSubmitting] = React.useState(false)
72
97
  const [error, setError] = React.useState<string | null>(null)
73
98
 
99
+ const s3Unavailable = !s3Enabled
100
+ const s3SelectedWhileUnavailable = s3Unavailable && form.storageDriver === 's3'
101
+
74
102
  const loadErrorMessage = t('attachments.partitions.errors.load', 'Failed to load partitions.')
75
103
 
76
104
  const loadItems = React.useCallback(async () => {
@@ -112,6 +140,8 @@ export function AttachmentPartitionSettings() {
112
140
 
113
141
  const openDialog = React.useCallback((state: DialogState) => {
114
142
  if (state.mode === 'edit') {
143
+ const cfg = state.entry.configJson ?? {}
144
+ const credsPrefix = typeof cfg.credentialsEnvPrefix === 'string' ? cfg.credentialsEnvPrefix : ''
115
145
  setForm({
116
146
  code: state.entry.code,
117
147
  title: state.entry.title,
@@ -119,6 +149,13 @@ export function AttachmentPartitionSettings() {
119
149
  isPublic: state.entry.isPublic,
120
150
  requiresOcr: state.entry.requiresOcr,
121
151
  ocrModel: state.entry.ocrModel ?? '',
152
+ storageDriver: state.entry.storageDriver ?? 'local',
153
+ s3CredentialsSource: credsPrefix ? 'env' : 'integration',
154
+ s3CredentialsEnvPrefix: credsPrefix,
155
+ s3Bucket: typeof cfg.bucket === 'string' ? cfg.bucket : '',
156
+ s3Region: typeof cfg.region === 'string' ? cfg.region : '',
157
+ s3Endpoint: typeof cfg.endpoint === 'string' ? cfg.endpoint : '',
158
+ s3ForcePathStyle: cfg.forcePathStyle === true,
122
159
  })
123
160
  } else {
124
161
  setForm(DEFAULT_FORM)
@@ -142,9 +179,42 @@ export function AttachmentPartitionSettings() {
142
179
  setError(t('attachments.partitions.errors.required', 'Code and title are required.'))
143
180
  return
144
181
  }
182
+ if (form.storageDriver === 's3' && form.s3CredentialsSource === 'env') {
183
+ if (!form.s3CredentialsEnvPrefix.trim()) {
184
+ setError(
185
+ t(
186
+ 'attachments.partitions.errors.s3EnvPrefixRequired',
187
+ 'Credentials env prefix is required when using a separate credentials source.',
188
+ ),
189
+ )
190
+ return
191
+ }
192
+ if (!form.s3Bucket.trim()) {
193
+ setError(
194
+ t(
195
+ 'attachments.partitions.errors.s3BucketRequired',
196
+ 'Bucket is required when using a separate credentials source.',
197
+ ),
198
+ )
199
+ return
200
+ }
201
+ }
145
202
  setSubmitting(true)
146
203
  setError(null)
147
204
  try {
205
+ const s3ConfigJson =
206
+ form.storageDriver === 's3'
207
+ ? {
208
+ bucket: form.s3Bucket.trim() || undefined,
209
+ region: form.s3Region.trim() || undefined,
210
+ endpoint: form.s3Endpoint.trim() || undefined,
211
+ forcePathStyle: form.s3ForcePathStyle || undefined,
212
+ ...(form.s3CredentialsSource === 'env'
213
+ ? { credentialsEnvPrefix: form.s3CredentialsEnvPrefix.trim() || undefined }
214
+ : {}),
215
+ }
216
+ : null
217
+
148
218
  const payload = {
149
219
  code: trimmedCode,
150
220
  title: trimmedTitle,
@@ -152,6 +222,8 @@ export function AttachmentPartitionSettings() {
152
222
  isPublic: form.isPublic,
153
223
  requiresOcr: form.requiresOcr,
154
224
  ocrModel: form.ocrModel.trim() || null,
225
+ storageDriver: form.storageDriver,
226
+ configJson: s3ConfigJson,
155
227
  }
156
228
  const method = dialog.mode === 'create' ? 'POST' : 'PUT'
157
229
  const body =
@@ -259,6 +331,15 @@ export function AttachmentPartitionSettings() {
259
331
  </span>
260
332
  ),
261
333
  },
334
+ {
335
+ header: t('attachments.partitions.table.storageDriver', 'Storage'),
336
+ accessorKey: 'storageDriver',
337
+ cell: ({ row }) => (
338
+ <span className="text-sm capitalize">
339
+ {row.original.storageDriver === 's3' ? 'S3' : row.original.storageDriver ?? 'local'}
340
+ </span>
341
+ ),
342
+ },
262
343
  {
263
344
  header: t('attachments.partitions.table.envKey', 'Env variable'),
264
345
  accessorKey: 'envKey',
@@ -408,8 +489,13 @@ export function AttachmentPartitionSettings() {
408
489
  {t('attachments.partitions.form.ocrModelLabel', 'OCR Model')}
409
490
  </Label>
410
491
  <Select
411
- value={form.ocrModel || undefined}
412
- onValueChange={(value) => setForm((prev) => ({ ...prev, ocrModel: value ?? '' }))}
492
+ value={form.ocrModel ? form.ocrModel : OCR_MODEL_DEFAULT_VALUE}
493
+ onValueChange={(value) =>
494
+ setForm((prev) => ({
495
+ ...prev,
496
+ ocrModel: value === OCR_MODEL_DEFAULT_VALUE ? '' : value,
497
+ }))
498
+ }
413
499
  >
414
500
  <SelectTrigger id="partition-ocr-model">
415
501
  <SelectValue />
@@ -417,7 +503,12 @@ export function AttachmentPartitionSettings() {
417
503
  <SelectContent>
418
504
  {OCR_MODEL_OPTIONS.map((option) => (
419
505
  <SelectItem key={option.value} value={option.value}>
420
- {t(`attachments.partitions.form.ocrModelOptions.${option.value || 'default'}`, option.label)}
506
+ {t(
507
+ `attachments.partitions.form.ocrModelOptions.${
508
+ option.value === OCR_MODEL_DEFAULT_VALUE ? 'default' : option.value
509
+ }`,
510
+ option.label,
511
+ )}
421
512
  </SelectItem>
422
513
  ))}
423
514
  </SelectContent>
@@ -430,6 +521,175 @@ export function AttachmentPartitionSettings() {
430
521
  </p>
431
522
  </div>
432
523
  )}
524
+ <div className="space-y-2">
525
+ <Label htmlFor="partition-storage-driver">
526
+ {t('attachments.partitions.form.storageDriverLabel', 'Storage Driver')}
527
+ </Label>
528
+ <select
529
+ id="partition-storage-driver"
530
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
531
+ value={form.storageDriver}
532
+ onChange={(event) => setForm((prev) => ({ ...prev, storageDriver: event.target.value }))}
533
+ >
534
+ {storageDriverOptions.map((option) => (
535
+ <option
536
+ key={option.value}
537
+ value={option.value}
538
+ disabled={option.value === 's3' && s3Unavailable}
539
+ >
540
+ {option.label}
541
+ </option>
542
+ ))}
543
+ </select>
544
+ {s3Unavailable ? (
545
+ <p className="text-xs text-muted-foreground">
546
+ {t(
547
+ 'attachments.partitions.form.storageDriverS3DisabledHelp',
548
+ 'S3 is disabled. Set OM_ENABLE_STORAGE_S3=true and restart to enable S3 partitions.',
549
+ )}
550
+ </p>
551
+ ) : null}
552
+ </div>
553
+ {form.storageDriver === 's3' && (
554
+ <div className="space-y-3 rounded-md border bg-muted/30 p-3">
555
+ <p className="text-xs font-medium text-muted-foreground">
556
+ {t('attachments.partitions.form.s3ConfigTitle', 'S3 Configuration')}
557
+ </p>
558
+ <div className="space-y-2">
559
+ <Label htmlFor="partition-s3-creds-source">
560
+ {t('attachments.partitions.form.s3CredsSourceLabel', 'Credentials source')}
561
+ </Label>
562
+ <select
563
+ id="partition-s3-creds-source"
564
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
565
+ value={form.s3CredentialsSource}
566
+ onChange={(event) =>
567
+ setForm((prev) => ({
568
+ ...prev,
569
+ s3CredentialsSource: event.target.value as S3CredentialsSource,
570
+ ...(event.target.value === 'integration' ? { s3CredentialsEnvPrefix: '' } : {}),
571
+ }))
572
+ }
573
+ >
574
+ <option value="integration">
575
+ {t(
576
+ 'attachments.partitions.form.s3CredsSourceIntegration',
577
+ 'Use Integration Marketplace credentials',
578
+ )}
579
+ </option>
580
+ <option value="env">
581
+ {t(
582
+ 'attachments.partitions.form.s3CredsSourceEnv',
583
+ 'Use separate env-based credentials',
584
+ )}
585
+ </option>
586
+ </select>
587
+ <p className="text-xs text-muted-foreground">
588
+ {form.s3CredentialsSource === 'integration'
589
+ ? t(
590
+ 'attachments.partitions.form.s3CredsSourceIntegrationHelp',
591
+ 'This partition reuses the credentials, bucket, region and endpoint configured in the S3 integration. Override individual fields below only when needed.',
592
+ )
593
+ : t(
594
+ 'attachments.partitions.form.s3CredsSourceEnvHelp',
595
+ 'This partition uses its own credentials read from environment variables. The integration credentials are ignored. Bucket and credentials prefix are required.',
596
+ )}
597
+ </p>
598
+ </div>
599
+ {form.s3CredentialsSource === 'env' && (
600
+ <div className="space-y-2">
601
+ <Label htmlFor="partition-s3-creds-prefix">
602
+ {t('attachments.partitions.form.s3CredsPrefixLabel', 'Credentials Env Prefix')}
603
+ </Label>
604
+ <Input
605
+ id="partition-s3-creds-prefix"
606
+ value={form.s3CredentialsEnvPrefix}
607
+ onChange={(event) => setForm((prev) => ({ ...prev, s3CredentialsEnvPrefix: event.target.value }))}
608
+ placeholder="OM_INTEGRATION_STORAGE_S3"
609
+ className="font-mono"
610
+ />
611
+ <p className="text-xs text-muted-foreground">
612
+ {t(
613
+ 'attachments.partitions.form.s3CredsPrefixHelp',
614
+ 'Reads {PREFIX}_ACCESS_KEY_ID and {PREFIX}_SECRET_ACCESS_KEY from environment variables.',
615
+ )}
616
+ </p>
617
+ </div>
618
+ )}
619
+ <div className="space-y-2">
620
+ <Label htmlFor="partition-s3-bucket">
621
+ {form.s3CredentialsSource === 'integration'
622
+ ? t('attachments.partitions.form.s3BucketOverrideLabel', 'Bucket override (optional)')
623
+ : t('attachments.partitions.form.s3BucketLabel', 'Bucket')}
624
+ </Label>
625
+ <Input
626
+ id="partition-s3-bucket"
627
+ value={form.s3Bucket}
628
+ onChange={(event) => setForm((prev) => ({ ...prev, s3Bucket: event.target.value }))}
629
+ placeholder={
630
+ form.s3CredentialsSource === 'integration'
631
+ ? t(
632
+ 'attachments.partitions.form.s3BucketOverridePlaceholder',
633
+ 'Leave empty to use integration bucket',
634
+ )
635
+ : 'my-company-attachments'
636
+ }
637
+ />
638
+ </div>
639
+ <div className="space-y-2">
640
+ <Label htmlFor="partition-s3-region">
641
+ {form.s3CredentialsSource === 'integration'
642
+ ? t('attachments.partitions.form.s3RegionOverrideLabel', 'Region override (optional)')
643
+ : t('attachments.partitions.form.s3RegionLabel', 'Region')}
644
+ </Label>
645
+ <Input
646
+ id="partition-s3-region"
647
+ value={form.s3Region}
648
+ onChange={(event) => setForm((prev) => ({ ...prev, s3Region: event.target.value }))}
649
+ placeholder={
650
+ form.s3CredentialsSource === 'integration'
651
+ ? t(
652
+ 'attachments.partitions.form.s3RegionOverridePlaceholder',
653
+ 'Leave empty to use integration region',
654
+ )
655
+ : 'us-east-1'
656
+ }
657
+ />
658
+ </div>
659
+ <div className="space-y-2">
660
+ <Label htmlFor="partition-s3-endpoint">
661
+ {form.s3CredentialsSource === 'integration'
662
+ ? t('attachments.partitions.form.s3EndpointOverrideLabel', 'Custom endpoint override (optional)')
663
+ : t('attachments.partitions.form.s3EndpointLabel', 'Custom Endpoint')}
664
+ </Label>
665
+ <Input
666
+ id="partition-s3-endpoint"
667
+ value={form.s3Endpoint}
668
+ onChange={(event) => setForm((prev) => ({ ...prev, s3Endpoint: event.target.value }))}
669
+ placeholder={
670
+ form.s3CredentialsSource === 'integration'
671
+ ? t(
672
+ 'attachments.partitions.form.s3EndpointOverridePlaceholder',
673
+ 'Leave empty to use integration endpoint',
674
+ )
675
+ : 'https://fra1.digitaloceanspaces.com'
676
+ }
677
+ />
678
+ <p className="text-xs text-muted-foreground">
679
+ {t('attachments.partitions.form.s3EndpointHelp', 'Leave empty for AWS S3. Required for MinIO, DigitalOcean Spaces, etc.')}
680
+ </p>
681
+ </div>
682
+ <label className="flex items-center gap-2 text-sm font-medium">
683
+ <input
684
+ type="checkbox"
685
+ className="h-4 w-4 rounded border"
686
+ checked={form.s3ForcePathStyle}
687
+ onChange={(event) => setForm((prev) => ({ ...prev, s3ForcePathStyle: event.target.checked }))}
688
+ />
689
+ {t('attachments.partitions.form.s3ForcePathStyleLabel', 'Force Path Style (required for MinIO)')}
690
+ </label>
691
+ </div>
692
+ )}
433
693
  {dialog ? (
434
694
  <div className="rounded-md border bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
435
695
  <div>
@@ -444,13 +704,21 @@ export function AttachmentPartitionSettings() {
444
704
  </code>
445
705
  </div>
446
706
  ) : null}
447
- {error ? <p className="text-sm text-red-600">{error}</p> : null}
707
+ {s3SelectedWhileUnavailable ? (
708
+ <p className="text-sm text-status-error-text">
709
+ {t(
710
+ 'attachments.partitions.errors.s3Disabled',
711
+ 'This partition uses S3, but S3 is disabled. Set OM_ENABLE_STORAGE_S3=true and restart the app.',
712
+ )}
713
+ </p>
714
+ ) : null}
715
+ {error ? <p className="text-sm text-status-error-text">{error}</p> : null}
448
716
  </form>
449
717
  <DialogFooter>
450
718
  <Button variant="ghost" onClick={closeDialog}>
451
719
  {t('attachments.partitions.actions.cancel', 'Cancel')}
452
720
  </Button>
453
- <Button onClick={() => void handleSubmit()} disabled={submitting}>
721
+ <Button onClick={() => void handleSubmit()} disabled={submitting || s3SelectedWhileUnavailable}>
454
722
  {dialog?.mode === 'edit'
455
723
  ? t('attachments.partitions.actions.save', 'Save changes')
456
724
  : t('attachments.partitions.actions.create', 'Create partition')}
@@ -0,0 +1,13 @@
1
+ import { asFunction } from 'awilix'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { StorageDriverFactory } from './lib/drivers/driverFactory'
4
+
5
+ export function register(container: AppContainer) {
6
+ container.register({
7
+ storageDriverFactory: asFunction(({ em }: { em: ConstructorParameters<typeof StorageDriverFactory>[0] }) =>
8
+ new StorageDriverFactory(em),
9
+ )
10
+ .singleton()
11
+ .proxy(),
12
+ })
13
+ }
@@ -0,0 +1,91 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { StorageDriver } from './types'
3
+ import { LocalStorageDriver } from './localDriver'
4
+ import { LegacyPublicStorageDriver } from './legacyPublicDriver'
5
+ import { AttachmentPartition } from '../../data/entities'
6
+
7
+ type DriverScope = { tenantId: string; organizationId: string }
8
+ type CredentialEnhancer = (config: Record<string, unknown>, scope: DriverScope) => Promise<Record<string, unknown>>
9
+
10
+ type DriverRegistration = { factory: (config: Record<string, unknown>) => StorageDriver }
11
+ type EnhancerRegistration = { enhancer: CredentialEnhancer }
12
+
13
+ const moduleDriverRegistry = new Map<string, DriverRegistration>()
14
+ const moduleEnhancerRegistry = new Map<string, EnhancerRegistration>()
15
+
16
+ export function registerExternalStorageDriver(
17
+ key: string,
18
+ factory: (config: Record<string, unknown>) => StorageDriver,
19
+ ): void {
20
+ moduleDriverRegistry.set(key, { factory })
21
+ }
22
+
23
+ export function registerExternalCredentialEnhancer(key: string, enhancer: CredentialEnhancer): void {
24
+ moduleEnhancerRegistry.set(key, { enhancer })
25
+ }
26
+
27
+ export class StorageDriverFactory {
28
+ private readonly cache = new Map<string, StorageDriver>()
29
+ private readonly localDriver = new LocalStorageDriver()
30
+ private readonly legacyPublicDriver = new LegacyPublicStorageDriver()
31
+ private readonly externalDrivers = new Map<string, (config: Record<string, unknown>) => StorageDriver>()
32
+ private readonly credentialEnhancers = new Map<string, CredentialEnhancer>()
33
+
34
+ constructor(private readonly em: EntityManager) {}
35
+
36
+ registerDriver(key: string, factory: (config: Record<string, unknown>) => StorageDriver): void {
37
+ this.externalDrivers.set(key, factory)
38
+ }
39
+
40
+ registerCredentialEnhancer(key: string, enhancer: CredentialEnhancer): void {
41
+ this.credentialEnhancers.set(key, enhancer)
42
+ }
43
+
44
+ resolveForAttachment(
45
+ storageDriver: string,
46
+ configJson?: Record<string, unknown> | null,
47
+ ): StorageDriver {
48
+ switch (storageDriver) {
49
+ case 'legacyPublic':
50
+ return this.legacyPublicDriver
51
+
52
+ case 'local':
53
+ return this.localDriver
54
+
55
+ default: {
56
+ const externalFactory =
57
+ this.externalDrivers.get(storageDriver) ?? moduleDriverRegistry.get(storageDriver)?.factory
58
+ if (externalFactory) {
59
+ const cacheKey = `${storageDriver}:${JSON.stringify(configJson ?? {})}`
60
+ const cached = this.cache.get(cacheKey)
61
+ if (cached) return cached
62
+ const driver = externalFactory(configJson ?? {})
63
+ this.cache.set(cacheKey, driver)
64
+ return driver
65
+ }
66
+ return this.localDriver
67
+ }
68
+ }
69
+ }
70
+
71
+ async resolveForPartition(partitionCode: string, scope?: DriverScope): Promise<StorageDriver> {
72
+ const partition = await this.em.findOne(AttachmentPartition, { code: partitionCode })
73
+ if (!partition) return this.localDriver
74
+
75
+ const driverKey = partition.storageDriver ?? 'local'
76
+ console.log(`[storageDriverFactory] resolveForPartition: partition=${partitionCode} driverKey=${driverKey} moduleDrivers=${[...moduleDriverRegistry.keys()].join(',')} instanceDrivers=${[...this.externalDrivers.keys()].join(',')}`)
77
+ let config: Record<string, unknown> = partition.configJson ?? {}
78
+
79
+ const activeEnhancer =
80
+ this.credentialEnhancers.get(driverKey) ?? moduleEnhancerRegistry.get(driverKey)?.enhancer
81
+ if (scope && activeEnhancer) {
82
+ config = await activeEnhancer(config, scope)
83
+ // Skip shared cache for scope-enhanced configs (credentials are per-tenant)
84
+ const externalFactory =
85
+ this.externalDrivers.get(driverKey) ?? moduleDriverRegistry.get(driverKey)?.factory
86
+ if (externalFactory) return externalFactory(config)
87
+ }
88
+
89
+ return this.resolveForAttachment(driverKey, config)
90
+ }
91
+ }
@@ -0,0 +1,4 @@
1
+ export type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from './types'
2
+ export { LocalStorageDriver } from './localDriver'
3
+ export { LegacyPublicStorageDriver } from './legacyPublicDriver'
4
+ export { StorageDriverFactory, registerExternalStorageDriver, registerExternalCredentialEnhancer } from './driverFactory'
@@ -0,0 +1,46 @@
1
+ import { promises as fs } from 'fs'
2
+ import path from 'path'
3
+ import type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from './types'
4
+
5
+ export class LegacyPublicStorageDriver implements StorageDriver {
6
+ readonly key = 'legacyPublic'
7
+
8
+ store(_payload: StoreFilePayload): Promise<StoredFile> {
9
+ throw new Error('legacy-public driver is read-only')
10
+ }
11
+
12
+ async read(_partitionCode: string, storagePath: string): Promise<ReadFileResult> {
13
+ const absolutePath = this.resolveAbsolutePath(storagePath)
14
+ const buffer = await fs.readFile(absolutePath)
15
+ return { buffer }
16
+ }
17
+
18
+ async delete(_partitionCode: string, storagePath: string): Promise<void> {
19
+ const absolutePath = this.resolveAbsolutePath(storagePath)
20
+ try {
21
+ await fs.unlink(absolutePath)
22
+ } catch {
23
+ // best-effort removal
24
+ }
25
+ }
26
+
27
+ async toLocalPath(
28
+ _partitionCode: string,
29
+ storagePath: string,
30
+ ): Promise<{ filePath: string; cleanup: () => Promise<void> }> {
31
+ return {
32
+ filePath: this.resolveAbsolutePath(storagePath),
33
+ cleanup: async () => {},
34
+ }
35
+ }
36
+
37
+ private resolveAbsolutePath(storagePath: string): string {
38
+ let safeRelative = storagePath.replace(/^\/*/, '')
39
+ let prev: string
40
+ do {
41
+ prev = safeRelative
42
+ safeRelative = safeRelative.replace(/\.\.(\/|\\)/g, '')
43
+ } while (safeRelative !== prev)
44
+ return path.join(process.cwd(), safeRelative)
45
+ }
46
+ }
@@ -0,0 +1,79 @@
1
+ import { promises as fs } from 'fs'
2
+ import path from 'path'
3
+ import { randomUUID } from 'crypto'
4
+ import { resolvePartitionRoot } from '../storage'
5
+ import type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from './types'
6
+
7
+ function sanitizeFileName(fileName: string): string {
8
+ if (!fileName) return 'file'
9
+ return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')
10
+ }
11
+
12
+ function resolveOrgSegment(orgId: string | null | undefined): string {
13
+ if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`
14
+ return 'org_shared'
15
+ }
16
+
17
+ function resolveTenantSegment(tenantId: string | null | undefined): string {
18
+ if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`
19
+ return 'tenant_shared'
20
+ }
21
+
22
+ export class LocalStorageDriver implements StorageDriver {
23
+ readonly key = 'local'
24
+
25
+ async store(payload: StoreFilePayload): Promise<StoredFile> {
26
+ const root = resolvePartitionRoot(payload.partitionCode)
27
+ const orgSegment = resolveOrgSegment(payload.orgId ?? null)
28
+ const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)
29
+ const safeName = sanitizeFileName(payload.fileName || 'file')
30
+ const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)
31
+ const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`
32
+ const relativePath = path.join(orgSegment, tenantSegment, storedName)
33
+ const absolutePath = path.join(root, relativePath)
34
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true })
35
+ await fs.writeFile(absolutePath, payload.buffer)
36
+ return {
37
+ storagePath: relativePath.replace(/\\/g, '/'),
38
+ }
39
+ }
40
+
41
+ async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {
42
+ const absolutePath = this.resolveAbsolutePath(partitionCode, storagePath)
43
+ const buffer = await fs.readFile(absolutePath)
44
+ return { buffer }
45
+ }
46
+
47
+ async delete(partitionCode: string, storagePath: string): Promise<void> {
48
+ const absolutePath = this.resolveAbsolutePath(partitionCode, storagePath)
49
+ try {
50
+ await fs.unlink(absolutePath)
51
+ } catch {
52
+ // best-effort removal
53
+ }
54
+ }
55
+
56
+ async toLocalPath(
57
+ partitionCode: string,
58
+ storagePath: string,
59
+ ): Promise<{ filePath: string; cleanup: () => Promise<void> }> {
60
+ const filePath = this.resolveAbsolutePath(partitionCode, storagePath)
61
+ return {
62
+ filePath,
63
+ cleanup: async () => {
64
+ // no-op: local path is the real file, do not delete
65
+ },
66
+ }
67
+ }
68
+
69
+ private resolveAbsolutePath(partitionCode: string, storagePath: string): string {
70
+ let safeRelative = storagePath.replace(/^\/*/, '')
71
+ let prev: string
72
+ do {
73
+ prev = safeRelative
74
+ safeRelative = safeRelative.replace(/\.\.(\/|\\)/g, '')
75
+ } while (safeRelative !== prev)
76
+ const root = resolvePartitionRoot(partitionCode)
77
+ return path.join(root, safeRelative)
78
+ }
79
+ }
@@ -0,0 +1,28 @@
1
+ export type StoreFilePayload = {
2
+ partitionCode: string
3
+ orgId: string | null | undefined
4
+ tenantId: string | null | undefined
5
+ fileName: string
6
+ buffer: Buffer
7
+ }
8
+
9
+ export type StoredFile = {
10
+ storagePath: string
11
+ driverMeta?: Record<string, unknown> | null
12
+ }
13
+
14
+ export type ReadFileResult = {
15
+ buffer: Buffer
16
+ contentType?: string
17
+ }
18
+
19
+ export interface StorageDriver {
20
+ readonly key: string
21
+ store(payload: StoreFilePayload): Promise<StoredFile>
22
+ read(partitionCode: string, storagePath: string): Promise<ReadFileResult>
23
+ delete(partitionCode: string, storagePath: string): Promise<void>
24
+ toLocalPath(
25
+ partitionCode: string,
26
+ storagePath: string,
27
+ ): Promise<{ filePath: string; cleanup: () => Promise<void> }>
28
+ }