@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/attachments/api/file/[id]/route.js +8 -8
- package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +7 -8
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
- package/dist/modules/attachments/api/library/[id]/route.js +6 -2
- package/dist/modules/attachments/api/library/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/library/route.js +2 -0
- package/dist/modules/attachments/api/library/route.js.map +2 -2
- package/dist/modules/attachments/api/openapi.js +5 -1
- package/dist/modules/attachments/api/openapi.js.map +2 -2
- package/dist/modules/attachments/api/partitions/route.js +10 -1
- package/dist/modules/attachments/api/partitions/route.js.map +2 -2
- package/dist/modules/attachments/api/route.js +19 -7
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/backend/config/attachments/page.js +3 -1
- package/dist/modules/attachments/backend/config/attachments/page.js.map +2 -2
- package/dist/modules/attachments/cli.js +6 -2
- package/dist/modules/attachments/cli.js.map +2 -2
- package/dist/modules/attachments/components/AttachmentPartitionSettings.js +216 -10
- package/dist/modules/attachments/components/AttachmentPartitionSettings.js.map +2 -2
- package/dist/modules/attachments/di.js +13 -0
- package/dist/modules/attachments/di.js.map +7 -0
- package/dist/modules/attachments/lib/drivers/driverFactory.js +67 -0
- package/dist/modules/attachments/lib/drivers/driverFactory.js.map +7 -0
- package/dist/modules/attachments/lib/drivers/index.js +11 -0
- package/dist/modules/attachments/lib/drivers/index.js.map +7 -0
- package/dist/modules/attachments/lib/drivers/legacyPublicDriver.js +42 -0
- package/dist/modules/attachments/lib/drivers/legacyPublicDriver.js.map +7 -0
- package/dist/modules/attachments/lib/drivers/localDriver.js +70 -0
- package/dist/modules/attachments/lib/drivers/localDriver.js.map +7 -0
- package/dist/modules/attachments/lib/drivers/types.js +1 -0
- package/dist/modules/attachments/lib/drivers/types.js.map +7 -0
- package/dist/modules/attachments/lib/ocrQueue.js +10 -5
- package/dist/modules/attachments/lib/ocrQueue.js.map +2 -2
- package/dist/modules/attachments/lib/storage.js.map +2 -2
- package/dist/modules/attachments/lib/thumbnailCache.js +11 -13
- package/dist/modules/attachments/lib/thumbnailCache.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/[id]/page.js +31 -5
- package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +14 -2
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
- package/package.json +11 -7
- package/src/modules/attachments/api/file/[id]/route.ts +10 -8
- package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +8 -8
- package/src/modules/attachments/api/library/[id]/route.ts +7 -3
- package/src/modules/attachments/api/library/route.ts +2 -0
- package/src/modules/attachments/api/openapi.ts +4 -0
- package/src/modules/attachments/api/partitions/route.ts +10 -1
- package/src/modules/attachments/api/route.ts +20 -7
- package/src/modules/attachments/backend/config/attachments/page.tsx +3 -1
- package/src/modules/attachments/cli.ts +7 -2
- package/src/modules/attachments/components/AttachmentPartitionSettings.tsx +275 -7
- package/src/modules/attachments/di.ts +13 -0
- package/src/modules/attachments/lib/drivers/driverFactory.ts +91 -0
- package/src/modules/attachments/lib/drivers/index.ts +4 -0
- package/src/modules/attachments/lib/drivers/legacyPublicDriver.ts +46 -0
- package/src/modules/attachments/lib/drivers/localDriver.ts +79 -0
- package/src/modules/attachments/lib/drivers/types.ts +28 -0
- package/src/modules/attachments/lib/ocrQueue.ts +14 -6
- package/src/modules/attachments/lib/storage.ts +13 -0
- package/src/modules/attachments/lib/thumbnailCache.ts +25 -19
- package/src/modules/integrations/backend/integrations/[id]/page.tsx +30 -4
- 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:
|
|
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
|
|
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
|
|
412
|
-
onValueChange={(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(
|
|
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
|
-
{
|
|
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
|
+
}
|