@open-mercato/sync-akeneo 0.9.1-develop.7553.1.8dbab05b5a → 0.9.1-develop.7567.1.b5e80ff620

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.
@@ -0,0 +1,339 @@
1
+ import { createAkeneoClient, type AkeneoClientDeps } from '../lib/client'
2
+
3
+ const validCredentials = {
4
+ apiUrl: 'https://tenant.cloud.akeneo.com',
5
+ clientId: 'client-id',
6
+ clientSecret: 'client-secret',
7
+ username: 'api-user',
8
+ password: 'api-password',
9
+ }
10
+
11
+ const publicAkeneoLookupHost: AkeneoClientDeps['lookupHost'] = async () => [{ address: '93.184.216.34', family: 4 }]
12
+
13
+ const MIB = 1024 * 1024
14
+ const DEFAULT_MEDIA_MAX_BYTES = 25 * MIB
15
+ const OVERSIZED_MEDIA_BYTES = 26 * MIB
16
+ const STREAMED_OVERSIZED_MEDIA_BYTES = 40 * MIB
17
+ const DOWNLOAD_PATH = '/api/rest/v1/media-files/asset-1/download'
18
+ const DOWNLOAD_URL = `https://tenant.cloud.akeneo.com${DOWNLOAD_PATH}`
19
+ const TOKEN_PATH = '/api/oauth/v1/token'
20
+ const PRODUCTS_PATH = '/api/rest/v1/products-uuid'
21
+ const ENV_KEYS = [
22
+ 'OM_INTEGRATION_AKENEO_REQUEST_TIMEOUT_MS',
23
+ 'OM_INTEGRATION_AKENEO_MAX_RESPONSE_BYTES',
24
+ 'OM_ATTACHMENT_MAX_UPLOAD_MB',
25
+ ] as const
26
+
27
+ function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
28
+ return new Response(JSON.stringify(body), {
29
+ status: 200,
30
+ ...init,
31
+ headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
32
+ })
33
+ }
34
+
35
+ function tokenResponse(): Response {
36
+ return jsonResponse({ access_token: 'token-123', expires_in: 3600 })
37
+ }
38
+
39
+ function meteredBody(totalBytes: number, chunkBytes = MIB, fill = 0) {
40
+ const meter = { pulledBytes: 0, cancelled: false }
41
+ const stream = new ReadableStream<Uint8Array>({
42
+ pull(controller) {
43
+ if (meter.pulledBytes >= totalBytes) {
44
+ controller.close()
45
+ return
46
+ }
47
+ const size = Math.min(chunkBytes, totalBytes - meter.pulledBytes)
48
+ meter.pulledBytes += size
49
+ controller.enqueue(new Uint8Array(size).fill(fill))
50
+ },
51
+ cancel() {
52
+ meter.cancelled = true
53
+ },
54
+ }, { highWaterMark: 0 })
55
+ return { stream, meter }
56
+ }
57
+
58
+ function stalledBody(prefix: Uint8Array = new Uint8Array(16)) {
59
+ return new ReadableStream<Uint8Array>({
60
+ start(controller) {
61
+ controller.enqueue(prefix)
62
+ },
63
+ pull() {
64
+ return new Promise<void>(() => undefined)
65
+ },
66
+ }, { highWaterMark: 0 })
67
+ }
68
+
69
+ function jsonPayloadOfSize(totalBytes: number): string {
70
+ const skeleton = JSON.stringify({ items_count: 0, padding: '' })
71
+ return JSON.stringify({ items_count: 0, padding: 'x'.repeat(totalBytes - skeleton.length) })
72
+ }
73
+
74
+ function createClientServing(routes: Record<string, () => Response>) {
75
+ const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
76
+ const url = new URL(String(input))
77
+ if (url.pathname === TOKEN_PATH && !routes[TOKEN_PATH]) return tokenResponse()
78
+ const route = routes[url.pathname]
79
+ if (route) return route()
80
+ return new Response('unexpected', { status: 500 })
81
+ })
82
+ const client = createAkeneoClient(validCredentials, {
83
+ lookupHost: publicAkeneoLookupHost,
84
+ fetchImpl: fetchMock as unknown as typeof fetch,
85
+ })
86
+ return { client, fetchMock }
87
+ }
88
+
89
+ async function settleWithin<T>(promise: Promise<T>, ms: number): Promise<'resolved' | 'timed-out' | 'rejected' | 'still-reading'> {
90
+ return Promise.race([
91
+ promise.then(
92
+ () => 'resolved' as const,
93
+ (error: unknown) => (error instanceof Error && error.name === 'FetchTimeoutError' ? 'timed-out' as const : 'rejected' as const),
94
+ ),
95
+ new Promise<'still-reading'>((resolve) => setTimeout(() => resolve('still-reading'), ms).unref()),
96
+ ])
97
+ }
98
+
99
+ describe('akeneo client response limits (issues #7233, #7242)', () => {
100
+ const originalEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]))
101
+
102
+ afterEach(() => {
103
+ for (const key of ENV_KEYS) {
104
+ const value = originalEnv[key]
105
+ if (value === undefined) delete process.env[key]
106
+ else process.env[key] = value
107
+ }
108
+ })
109
+
110
+ describe('media downloads (#7233)', () => {
111
+ it('accepts a chunked media body below the cap and reports its real byte length', async () => {
112
+ const { stream } = meteredBody(3 * MIB)
113
+ const { client } = createClientServing({
114
+ [DOWNLOAD_PATH]: () => new Response(stream, { status: 200, headers: { 'content-type': 'image/png' } }),
115
+ })
116
+
117
+ const binary = await client.downloadMediaFile(DOWNLOAD_URL)
118
+
119
+ expect(binary.buffer.byteLength).toBe(3 * MIB)
120
+ expect(binary.contentLength).toBe(3 * MIB)
121
+ expect(binary.contentType).toBe('image/png')
122
+ })
123
+
124
+ it('accepts a media body of exactly the attachment upload limit', async () => {
125
+ const { stream } = meteredBody(DEFAULT_MEDIA_MAX_BYTES)
126
+ const { client } = createClientServing({
127
+ [DOWNLOAD_PATH]: () => new Response(stream, { status: 200, headers: { 'content-type': 'image/png' } }),
128
+ })
129
+
130
+ const binary = await client.downloadMediaFile(DOWNLOAD_URL)
131
+
132
+ expect(binary.buffer.byteLength).toBe(DEFAULT_MEDIA_MAX_BYTES)
133
+ })
134
+
135
+ it('aborts a chunked media body without Content-Length once it passes the cap, without buffering the rest', async () => {
136
+ const { stream, meter } = meteredBody(STREAMED_OVERSIZED_MEDIA_BYTES)
137
+ const { client } = createClientServing({
138
+ [DOWNLOAD_PATH]: () => new Response(stream, { status: 200, headers: { 'content-type': 'image/png' } }),
139
+ })
140
+
141
+ await expect(client.downloadMediaFile(DOWNLOAD_URL)).rejects.toMatchObject({
142
+ name: 'AkeneoResponseTooLargeError',
143
+ limitBytes: DEFAULT_MEDIA_MAX_BYTES,
144
+ })
145
+
146
+ expect(meter.pulledBytes).toBeLessThanOrEqual(DEFAULT_MEDIA_MAX_BYTES + MIB)
147
+ expect(meter.pulledBytes).toBeLessThan(STREAMED_OVERSIZED_MEDIA_BYTES)
148
+ expect(meter.cancelled).toBe(true)
149
+ })
150
+
151
+ it('rejects a declared Content-Length above the cap before reading the body', async () => {
152
+ const { stream, meter } = meteredBody(OVERSIZED_MEDIA_BYTES)
153
+ const { client } = createClientServing({
154
+ [DOWNLOAD_PATH]: () => new Response(stream, {
155
+ status: 200,
156
+ headers: { 'content-type': 'image/png', 'content-length': String(OVERSIZED_MEDIA_BYTES) },
157
+ }),
158
+ })
159
+
160
+ await expect(client.downloadMediaFile(DOWNLOAD_URL)).rejects.toMatchObject({ name: 'AkeneoResponseTooLargeError' })
161
+
162
+ expect(meter.pulledBytes).toBeLessThanOrEqual(MIB)
163
+ })
164
+
165
+ it('follows OM_ATTACHMENT_MAX_UPLOAD_MB for the media cap', async () => {
166
+ process.env.OM_ATTACHMENT_MAX_UPLOAD_MB = '1'
167
+ const { stream, meter } = meteredBody(3 * MIB, 256 * 1024)
168
+ const { client } = createClientServing({
169
+ [DOWNLOAD_PATH]: () => new Response(stream, { status: 200, headers: { 'content-type': 'image/png' } }),
170
+ })
171
+
172
+ await expect(client.downloadMediaFile(DOWNLOAD_URL)).rejects.toMatchObject({ limitBytes: MIB })
173
+
174
+ expect(meter.pulledBytes).toBeLessThanOrEqual(MIB + 256 * 1024)
175
+ })
176
+
177
+ it('reports the bytes actually received, not a misleading Content-Length header', async () => {
178
+ const { client } = createClientServing({
179
+ [DOWNLOAD_PATH]: () => new Response(new Uint8Array(5), {
180
+ status: 200,
181
+ headers: { 'content-type': 'image/png', 'content-length': '1' },
182
+ }),
183
+ })
184
+
185
+ const binary = await client.downloadMediaFile(DOWNLOAD_URL)
186
+
187
+ expect(binary.buffer.byteLength).toBe(5)
188
+ expect(binary.contentLength).toBe(5)
189
+ })
190
+
191
+ it('keeps the request deadline running while the media body is consumed', async () => {
192
+ process.env.OM_INTEGRATION_AKENEO_REQUEST_TIMEOUT_MS = '100'
193
+ const { client } = createClientServing({
194
+ [DOWNLOAD_PATH]: () => new Response(stalledBody(), { status: 200, headers: { 'content-type': 'image/png' } }),
195
+ })
196
+
197
+ await expect(settleWithin(client.downloadMediaFile(DOWNLOAD_URL), 1_500)).resolves.toBe('timed-out')
198
+ })
199
+ })
200
+
201
+ describe('JSON and error bodies (#7242)', () => {
202
+ it('reads an ordinary multi-page product listing', async () => {
203
+ const { client, fetchMock } = createClientServing({
204
+ [PRODUCTS_PATH]: () => {
205
+ const pageCalls = fetchMock.mock.calls.filter(([input]) => new URL(String(input)).pathname === PRODUCTS_PATH)
206
+ const isSecondPage = new URL(String(pageCalls[pageCalls.length - 1]?.[0])).searchParams.get('page') === '2'
207
+ return jsonResponse({
208
+ _embedded: { items: [{ uuid: isSecondPage ? 'uuid-2' : 'uuid-1' }] },
209
+ _links: isSecondPage ? {} : { next: { href: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2` } },
210
+ })
211
+ },
212
+ })
213
+
214
+ const first = await client.listProducts({ batchSize: 1 })
215
+ const second = await client.listProducts({ batchSize: 1, nextUrl: first.nextUrl })
216
+
217
+ expect(first.items.map((item) => item.uuid)).toEqual(['uuid-1'])
218
+ expect(first.nextUrl).toBe(`https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2`)
219
+ expect(second.items.map((item) => item.uuid)).toEqual(['uuid-2'])
220
+ expect(second.nextUrl).toBeNull()
221
+ })
222
+
223
+ it('accepts a JSON body of exactly the configured response limit', async () => {
224
+ process.env.OM_INTEGRATION_AKENEO_MAX_RESPONSE_BYTES = String(64 * 1024)
225
+ const payload = jsonPayloadOfSize(64 * 1024)
226
+ const { client } = createClientServing({
227
+ '/api/rest/v1/system-information': () => new Response(payload, { status: 200, headers: { 'content-type': 'application/json' } }),
228
+ })
229
+
230
+ expect(Buffer.byteLength(payload)).toBe(64 * 1024)
231
+ await expect(client.getSystemProbe()).resolves.toEqual({ version: null })
232
+ })
233
+
234
+ it('aborts a chunked JSON body without Content-Length once it passes the configured limit', async () => {
235
+ process.env.OM_INTEGRATION_AKENEO_MAX_RESPONSE_BYTES = String(MIB)
236
+ const { stream, meter } = meteredBody(8 * MIB, 256 * 1024, 0x20)
237
+ const { client } = createClientServing({
238
+ [PRODUCTS_PATH]: () => new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } }),
239
+ })
240
+
241
+ await expect(client.listProducts({ batchSize: 10, nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2` }))
242
+ .rejects.toMatchObject({ name: 'AkeneoResponseTooLargeError', limitBytes: MIB })
243
+
244
+ expect(meter.pulledBytes).toBeLessThanOrEqual(MIB + 256 * 1024)
245
+ expect(meter.cancelled).toBe(true)
246
+ })
247
+
248
+ it('rejects a JSON body whose declared Content-Length is above the limit before reading it', async () => {
249
+ process.env.OM_INTEGRATION_AKENEO_MAX_RESPONSE_BYTES = String(MIB)
250
+ const { stream, meter } = meteredBody(8 * MIB, 256 * 1024, 0x20)
251
+ const { client } = createClientServing({
252
+ [PRODUCTS_PATH]: () => new Response(stream, {
253
+ status: 200,
254
+ headers: { 'content-type': 'application/json', 'content-length': String(8 * MIB) },
255
+ }),
256
+ })
257
+
258
+ await expect(client.listProducts({ batchSize: 10, nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2` }))
259
+ .rejects.toMatchObject({ name: 'AkeneoResponseTooLargeError' })
260
+
261
+ expect(meter.pulledBytes).toBe(0)
262
+ })
263
+
264
+ it('bounds the OAuth token response body', async () => {
265
+ process.env.OM_INTEGRATION_AKENEO_MAX_RESPONSE_BYTES = String(MIB)
266
+ const { stream, meter } = meteredBody(8 * MIB, 256 * 1024, 0x20)
267
+ const { client } = createClientServing({
268
+ [TOKEN_PATH]: () => new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } }),
269
+ })
270
+
271
+ await expect(client.listProducts({ batchSize: 10, nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2` }))
272
+ .rejects.toMatchObject({ name: 'AkeneoResponseTooLargeError' })
273
+
274
+ expect(meter.pulledBytes).toBeLessThanOrEqual(MIB + 256 * 1024)
275
+ })
276
+
277
+ it('keeps the request deadline running while a JSON page drips', async () => {
278
+ process.env.OM_INTEGRATION_AKENEO_REQUEST_TIMEOUT_MS = '100'
279
+ const { client } = createClientServing({
280
+ [PRODUCTS_PATH]: () => new Response(stalledBody(new TextEncoder().encode('{"_embedded":{"items":[')), {
281
+ status: 200,
282
+ headers: { 'content-type': 'application/json' },
283
+ }),
284
+ })
285
+
286
+ const outcome = await settleWithin(
287
+ client.listProducts({ batchSize: 10, nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2` }),
288
+ 1_500,
289
+ )
290
+
291
+ expect(outcome).toBe('timed-out')
292
+ })
293
+
294
+ it('keeps the request deadline running while the OAuth token body drips', async () => {
295
+ process.env.OM_INTEGRATION_AKENEO_REQUEST_TIMEOUT_MS = '100'
296
+ const { client } = createClientServing({
297
+ [TOKEN_PATH]: () => new Response(stalledBody(new TextEncoder().encode('{"access_token":')), {
298
+ status: 200,
299
+ headers: { 'content-type': 'application/json' },
300
+ }),
301
+ })
302
+
303
+ const outcome = await settleWithin(
304
+ client.listProducts({ batchSize: 10, nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2` }),
305
+ 1_500,
306
+ )
307
+
308
+ expect(outcome).toBe('timed-out')
309
+ })
310
+
311
+ it('truncates a huge error body instead of buffering all of it', async () => {
312
+ const { stream, meter } = meteredBody(8 * MIB, 256 * 1024, 0x61)
313
+ const { client } = createClientServing({
314
+ [PRODUCTS_PATH]: () => new Response(stream, { status: 500, headers: { 'content-type': 'text/plain' } }),
315
+ })
316
+
317
+ const error = await client.listProducts({
318
+ batchSize: 10,
319
+ nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2`,
320
+ }).catch((err: unknown) => err as Error)
321
+
322
+ expect(error).toBeInstanceOf(Error)
323
+ expect(error.message.startsWith('Akeneo request failed (500): aaaa')).toBe(true)
324
+ expect(error.message.length).toBeLessThanOrEqual(64 * 1024 + 64)
325
+ expect(meter.pulledBytes).toBeLessThanOrEqual(64 * 1024 + 256 * 1024)
326
+ })
327
+
328
+ it('keeps short error bodies intact in the thrown message', async () => {
329
+ const { client } = createClientServing({
330
+ [PRODUCTS_PATH]: () => new Response('{"message":"Forbidden"}', { status: 403 }),
331
+ })
332
+
333
+ await expect(client.listProducts({
334
+ batchSize: 10,
335
+ nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2`,
336
+ })).rejects.toThrow('Akeneo request failed (403): {"message":"Forbidden"}')
337
+ })
338
+ })
339
+ })
@@ -52,6 +52,7 @@
52
52
  "sync_akeneo.discovery.locales": "Sprachen",
53
53
  "sync_akeneo.discovery.priceKinds": "Katalogpreisarten",
54
54
  "sync_akeneo.discovery.rediscovered": "Akeneo-Felder aktualisiert und fehlende Zuordnungen hinzugefügt.",
55
+ "sync_akeneo.errors.mediaStorageRejected": "Akeneo-Medium {code} wurde vom Anhangsspeicher abgelehnt ({reason}).",
55
56
  "sync_akeneo.feature.configure": "Akeneo-Synchronisierungseinstellungen konfigurieren",
56
57
  "sync_akeneo.feature.view": "Akeneo-Synchronisierungskonfiguration anzeigen",
57
58
  "sync_akeneo.firstImport.action": "Ersten vollständigen Import starten",
@@ -52,6 +52,7 @@
52
52
  "sync_akeneo.discovery.locales": "Locales",
53
53
  "sync_akeneo.discovery.priceKinds": "Catalog price kinds",
54
54
  "sync_akeneo.discovery.rediscovered": "Akeneo fields refreshed and missing mappings added.",
55
+ "sync_akeneo.errors.mediaStorageRejected": "Akeneo media {code} was rejected by attachment storage ({reason}).",
55
56
  "sync_akeneo.feature.configure": "Configure Akeneo sync settings",
56
57
  "sync_akeneo.feature.view": "View Akeneo sync configuration",
57
58
  "sync_akeneo.firstImport.action": "Run the first full import",
@@ -52,6 +52,7 @@
52
52
  "sync_akeneo.discovery.locales": "Idiomas",
53
53
  "sync_akeneo.discovery.priceKinds": "Tipos de precios de catálogo",
54
54
  "sync_akeneo.discovery.rediscovered": "Campos Akeneo actualizados y asignaciones faltantes agregadas.",
55
+ "sync_akeneo.errors.mediaStorageRejected": "El almacenamiento de adjuntos rechazó el archivo multimedia de Akeneo {code} ({reason}).",
55
56
  "sync_akeneo.feature.configure": "Configurar ajustes de sincronización Akeneo",
56
57
  "sync_akeneo.feature.view": "Ver configuración de sincronización Akeneo",
57
58
  "sync_akeneo.firstImport.action": "Ejecutar la primera importación completa",
@@ -52,6 +52,7 @@
52
52
  "sync_akeneo.discovery.locales": "로케일",
53
53
  "sync_akeneo.discovery.priceKinds": "카탈로그 가격 종류",
54
54
  "sync_akeneo.discovery.rediscovered": "Akeneo 필드가 새로고침되고 누락된 매핑이 추가되었습니다.",
55
+ "sync_akeneo.errors.mediaStorageRejected": "첨부 파일 저장소가 Akeneo 미디어 {code}을(를) 거부했습니다({reason}).",
55
56
  "sync_akeneo.feature.configure": "Akeneo 동기화 설정 구성",
56
57
  "sync_akeneo.feature.view": "Akeneo 동기화 구성 보기",
57
58
  "sync_akeneo.firstImport.action": "첫 전체 가져오기 실행",
@@ -52,6 +52,7 @@
52
52
  "sync_akeneo.discovery.locales": "Lokalizacje",
53
53
  "sync_akeneo.discovery.priceKinds": "Rodzaje cen katalogowych",
54
54
  "sync_akeneo.discovery.rediscovered": "Pola Akeneo odświeżone i brakujące mapowania dodane.",
55
+ "sync_akeneo.errors.mediaStorageRejected": "Magazyn załączników odrzucił plik multimedialny Akeneo {code} ({reason}).",
55
56
  "sync_akeneo.feature.configure": "Konfiguracja ustawień synchronizacji Akeneo",
56
57
  "sync_akeneo.feature.view": "Podgląd konfiguracji synchronizacji Akeneo",
57
58
  "sync_akeneo.firstImport.action": "Uruchom pierwszy pełny import",
@@ -6,6 +6,8 @@ import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib
6
6
  import { emitCrudSideEffects, setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'
7
7
  import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
8
8
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
9
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
11
  import type { DataMapping } from '@open-mercato/core/modules/data_sync/lib/adapter'
10
12
  import type { ExternalIdMappingService } from '@open-mercato/core/modules/data_sync/lib/id-mapping'
11
13
  import { SyncExternalIdMapping } from '@open-mercato/core/modules/integrations/data/entities'
@@ -25,9 +27,13 @@ import { normalizeEntityFieldsetConfig, type CustomFieldsetDefinition } from '@o
25
27
  import { invalidateDefinitionsCache } from '@open-mercato/core/modules/entities/api/definitions.cache'
26
28
  import { Attachment } from '@open-mercato/core/modules/attachments/data/entities'
27
29
  import { buildAttachmentFileUrl, buildAttachmentImageUrl, slugifyAttachmentFileName } from '@open-mercato/core/modules/attachments/lib/imageUrls'
28
- import { deletePartitionFile, storePartitionFile } from '@open-mercato/core/modules/attachments/lib/storage'
29
- import { ensureDefaultPartitions, resolveDefaultPartitionCode } from '@open-mercato/core/modules/attachments/lib/partitions'
30
- import { mergeAttachmentMetadata } from '@open-mercato/core/modules/attachments/lib/metadata'
30
+ import type { AttachmentService } from '@open-mercato/core/modules/attachments'
31
+ import {
32
+ isScopedAttachmentUploadError,
33
+ type ScopedAttachmentUploadErrorCode,
34
+ type ScopedAttachmentUploadService,
35
+ } from '@open-mercato/core/modules/attachments/lib/scoped-upload-service'
36
+ import type { IntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'
31
37
  import { attachmentCrudEvents, attachmentCrudIndexer } from '@open-mercato/core/modules/attachments/lib/crud'
32
38
  import { emitCatalogQueryIndexEvent } from '@open-mercato/core/modules/catalog/commands/shared'
33
39
  import { SalesChannel } from '@open-mercato/core/modules/sales/data/entities'
@@ -52,7 +58,7 @@ import {
52
58
  type AkeneoValues,
53
59
  buildDefaultAkeneoMapping,
54
60
  } from './shared'
55
- import type { AkeneoClient } from './client'
61
+ import { isAkeneoResponseTooLargeError, type AkeneoClient } from './client'
56
62
  import { inferAkeneoProductMapping } from './inference'
57
63
  import { normalizeMarkdownText } from './markdown'
58
64
 
@@ -61,6 +67,12 @@ type ImportScope = {
61
67
  tenantId: string
62
68
  }
63
69
 
70
+ const REJECTED_MEDIA_UPLOAD_CODES: ReadonlySet<ScopedAttachmentUploadErrorCode> = new Set([
71
+ 'dangerous_executable',
72
+ 'max_upload_size',
73
+ 'active_content',
74
+ ])
75
+
64
76
  type UpsertResult = {
65
77
  localId: string
66
78
  action: 'create' | 'update' | 'skip'
@@ -2305,42 +2317,64 @@ export async function createAkeneoImporter(client: AkeneoClient, scope: ImportSc
2305
2317
  }
2306
2318
  }
2307
2319
 
2308
- async function upsertAsset(asset: DesiredAsset): Promise<string> {
2320
+ async function recordRejectedMedia(asset: DesiredAsset, reason: string): Promise<void> {
2321
+ const integrationLogService = container.resolve('integrationLogService') as IntegrationLogService
2322
+ await integrationLogService.write({
2323
+ integrationId: 'sync_akeneo',
2324
+ level: 'warn',
2325
+ code: 'sync_akeneo.media_rejected',
2326
+ message: `Akeneo media ${asset.remote.codeOrUrl} was not imported: ${reason}`,
2327
+ scopeEntityType: asset.entityId,
2328
+ scopeEntityId: asset.recordId,
2329
+ payload: {
2330
+ externalId: asset.externalId,
2331
+ remoteCode: asset.remote.codeOrUrl,
2332
+ kind: asset.kind,
2333
+ reason,
2334
+ },
2335
+ }, scope)
2336
+ }
2337
+
2338
+ async function upsertAsset(asset: DesiredAsset): Promise<string | null> {
2309
2339
  const existingAttachmentId = await externalIdMappingService.lookupLocalId('sync_akeneo', 'attachment', asset.externalId, scope)
2310
2340
  if (existingAttachmentId) return existingAttachmentId
2311
2341
 
2312
- await ensureDefaultPartitions(em)
2313
- const binary = await client.downloadMediaFile(asset.remote.codeOrUrl)
2342
+ let binary: Awaited<ReturnType<AkeneoClient['downloadMediaFile']>>
2343
+ try {
2344
+ binary = await client.downloadMediaFile(asset.remote.codeOrUrl)
2345
+ } catch (error) {
2346
+ if (!isAkeneoResponseTooLargeError(error)) throw error
2347
+ await recordRejectedMedia(asset, 'max_upload_size')
2348
+ return null
2349
+ }
2314
2350
  const fileName = asset.remote.fileNameHint ?? binary.fileName ?? `akeneo-${randomUUID().slice(0, 8)}`
2315
- const partitionCode = resolveDefaultPartitionCode(asset.entityId)
2316
- const stored = await storePartitionFile({
2317
- partitionCode,
2318
- orgId: scope.organizationId,
2319
- tenantId: scope.tenantId,
2320
- fileName,
2321
- buffer: binary.buffer,
2322
- })
2323
- const attachment = em.create(Attachment, {
2324
- entityId: asset.entityId,
2325
- recordId: asset.recordId,
2326
- organizationId: scope.organizationId,
2327
- tenantId: scope.tenantId,
2328
- partitionCode,
2329
- fileName,
2330
- mimeType: asset.remote.mimeTypeHint ?? binary.contentType ?? 'application/octet-stream',
2331
- fileSize: binary.contentLength ?? binary.buffer.byteLength,
2332
- storageDriver: 'local',
2333
- storagePath: stored.storagePath,
2334
- storageMetadata: mergeAttachmentMetadata(null, {
2335
- assignments: [{ type: asset.entityId, id: asset.recordId }],
2336
- }),
2337
- url: buildAttachmentFileUrl('pending'),
2338
- content: null,
2339
- })
2340
- em.persist(attachment)
2341
- await em.flush()
2351
+ const uploadService = container.resolve('attachmentScopedUploadService') as ScopedAttachmentUploadService
2352
+ let attachment: Attachment
2353
+ try {
2354
+ attachment = await uploadService.upload({
2355
+ tenantId: scope.tenantId,
2356
+ organizationId: scope.organizationId,
2357
+ entityId: asset.entityId,
2358
+ recordId: asset.recordId,
2359
+ fileName,
2360
+ declaredMimeType: asset.remote.mimeTypeHint ?? binary.contentType,
2361
+ buffer: binary.buffer,
2362
+ })
2363
+ } catch (error) {
2364
+ if (!isScopedAttachmentUploadError(error)) throw error
2365
+ if (REJECTED_MEDIA_UPLOAD_CODES.has(error.code)) {
2366
+ await recordRejectedMedia(asset, error.code)
2367
+ return null
2368
+ }
2369
+ const { translate } = await resolveTranslations()
2370
+ throw new Error(translate(
2371
+ 'sync_akeneo.errors.mediaStorageRejected',
2372
+ 'Akeneo media {code} was rejected by attachment storage ({reason}).',
2373
+ { code: asset.remote.codeOrUrl, reason: error.code },
2374
+ ))
2375
+ }
2342
2376
  attachment.url = asset.kind === 'image'
2343
- ? buildAttachmentImageUrl(attachment.id, { slug: slugifyAttachmentFileName(fileName) })
2377
+ ? buildAttachmentImageUrl(attachment.id, { slug: slugifyAttachmentFileName(attachment.fileName) })
2344
2378
  : buildAttachmentFileUrl(attachment.id)
2345
2379
  attachment.storageMetadata = {
2346
2380
  ...(attachment.storageMetadata ?? {}),
@@ -2350,11 +2384,32 @@ export async function createAkeneoImporter(client: AkeneoClient, scope: ImportSc
2350
2384
  remoteCode: asset.remote.codeOrUrl,
2351
2385
  }
2352
2386
  await em.flush()
2353
- await emitAttachmentCrudChange('created', attachment)
2387
+ await emitAttachmentCrudChange('updated', attachment)
2354
2388
  await externalIdMappingService.storeExternalIdMapping('sync_akeneo', 'attachment', attachment.id, asset.externalId, scope)
2355
2389
  return attachment.id
2356
2390
  }
2357
2391
 
2392
+ async function releaseImportedAttachment(attachment: Attachment): Promise<boolean> {
2393
+ const attachmentService = container.resolve('attachmentService') as AttachmentService
2394
+ if (!attachmentService.releaseScoped) {
2395
+ throw new Error('[internal] attachmentService.releaseScoped is not available')
2396
+ }
2397
+ try {
2398
+ await attachmentService.releaseScoped({
2399
+ attachmentId: attachment.id,
2400
+ tenantId: scope.tenantId,
2401
+ organizationId: scope.organizationId,
2402
+ expectedOwner: { entityId: attachment.entityId, recordId: attachment.recordId },
2403
+ expectedAssignment: { type: attachment.entityId, id: attachment.recordId },
2404
+ expectedPartitionCode: attachment.partitionCode,
2405
+ })
2406
+ return true
2407
+ } catch (error) {
2408
+ if (isCrudHttpError(error) && error.status === 409) return false
2409
+ throw error
2410
+ }
2411
+ }
2412
+
2358
2413
  async function reconcileAttachments(params: {
2359
2414
  entityId: string
2360
2415
  recordId: string
@@ -2362,12 +2417,11 @@ export async function createAkeneoImporter(client: AkeneoClient, scope: ImportSc
2362
2417
  reconciliation: AkeneoReconciliationSettings
2363
2418
  }): Promise<{ heroAttachmentId: string | null; heroAttachmentUrl: string | null }> {
2364
2419
  const desiredExternalIds = new Set(params.desiredAssets.map((asset) => asset.externalId))
2365
- const desiredImages = params.desiredAssets.filter((asset) => asset.kind === 'image')
2366
- const desiredFiles = params.desiredAssets.filter((asset) => asset.kind === 'file')
2367
- const desiredAttachmentIds = await Promise.all(params.desiredAssets.map((asset) => upsertAsset(asset)))
2368
- const heroAttachmentId = desiredImages.length > 0
2369
- ? desiredAttachmentIds[params.desiredAssets.findIndex((asset) => asset.externalId === desiredImages[0]?.externalId)]
2370
- : null
2420
+ let heroAttachmentId: string | null = null
2421
+ for (const asset of params.desiredAssets) {
2422
+ const attachmentId = await upsertAsset(asset)
2423
+ if (!heroAttachmentId && attachmentId && asset.kind === 'image') heroAttachmentId = attachmentId
2424
+ }
2371
2425
  const heroAttachmentUrl = heroAttachmentId ? buildAttachmentImageUrl(heroAttachmentId) : null
2372
2426
 
2373
2427
  const existing = await findWithDecryption(em, Attachment, {
@@ -2385,8 +2439,7 @@ export async function createAkeneoImporter(client: AkeneoClient, scope: ImportSc
2385
2439
  ? params.reconciliation.deleteMissingMedia
2386
2440
  : params.reconciliation.deleteMissingAttachments
2387
2441
  if (!shouldDelete || desiredExternalIds.has(externalId)) continue
2388
- await deletePartitionFile(attachment.partitionCode, attachment.storagePath, attachment.storageDriver)
2389
- await em.remove(attachment).flush()
2442
+ if (!(await releaseImportedAttachment(attachment))) continue
2390
2443
  await emitAttachmentCrudChange('deleted', attachment)
2391
2444
  }
2392
2445