kalai-attach 1.0.0

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 (98) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +451 -0
  3. package/_i18n/i18n.properties +6 -0
  4. package/_i18n/i18n_ar.properties +6 -0
  5. package/_i18n/i18n_bg.properties +6 -0
  6. package/_i18n/i18n_cs.properties +6 -0
  7. package/_i18n/i18n_da.properties +6 -0
  8. package/_i18n/i18n_de.properties +6 -0
  9. package/_i18n/i18n_el.properties +6 -0
  10. package/_i18n/i18n_en.properties +6 -0
  11. package/_i18n/i18n_en_US_saptrc.properties +6 -0
  12. package/_i18n/i18n_es.properties +6 -0
  13. package/_i18n/i18n_es_MX.properties +6 -0
  14. package/_i18n/i18n_fi.properties +6 -0
  15. package/_i18n/i18n_fr.properties +6 -0
  16. package/_i18n/i18n_he.properties +6 -0
  17. package/_i18n/i18n_hr.properties +6 -0
  18. package/_i18n/i18n_hu.properties +6 -0
  19. package/_i18n/i18n_it.properties +6 -0
  20. package/_i18n/i18n_ja.properties +6 -0
  21. package/_i18n/i18n_kk.properties +6 -0
  22. package/_i18n/i18n_ko.properties +6 -0
  23. package/_i18n/i18n_ms.properties +6 -0
  24. package/_i18n/i18n_nl.properties +6 -0
  25. package/_i18n/i18n_no.properties +6 -0
  26. package/_i18n/i18n_pl.properties +6 -0
  27. package/_i18n/i18n_pt.properties +6 -0
  28. package/_i18n/i18n_ro.properties +6 -0
  29. package/_i18n/i18n_ru.properties +6 -0
  30. package/_i18n/i18n_sh.properties +6 -0
  31. package/_i18n/i18n_sk.properties +6 -0
  32. package/_i18n/i18n_sl.properties +6 -0
  33. package/_i18n/i18n_sv.properties +6 -0
  34. package/_i18n/i18n_th.properties +6 -0
  35. package/_i18n/i18n_tr.properties +6 -0
  36. package/_i18n/i18n_uk.properties +6 -0
  37. package/_i18n/i18n_vi.properties +6 -0
  38. package/_i18n/i18n_zh_CN.properties +6 -0
  39. package/_i18n/i18n_zh_TW.properties +6 -0
  40. package/_i18n/messages.properties +6 -0
  41. package/_i18n/messages_en_US_saptrc.properties +4 -0
  42. package/cds-plugin.js +4 -0
  43. package/db/data/sap.attachments-ScanStates.csv +6 -0
  44. package/db/data/sap.attachments-ScanStates_texts.csv +6 -0
  45. package/db/data/sap.attachments-ScanStates_texts_ar.csv +6 -0
  46. package/db/data/sap.attachments-ScanStates_texts_bg.csv +6 -0
  47. package/db/data/sap.attachments-ScanStates_texts_cs.csv +6 -0
  48. package/db/data/sap.attachments-ScanStates_texts_da.csv +6 -0
  49. package/db/data/sap.attachments-ScanStates_texts_de.csv +6 -0
  50. package/db/data/sap.attachments-ScanStates_texts_el.csv +6 -0
  51. package/db/data/sap.attachments-ScanStates_texts_en.csv +6 -0
  52. package/db/data/sap.attachments-ScanStates_texts_en_US_saptrc.csv +6 -0
  53. package/db/data/sap.attachments-ScanStates_texts_es.csv +6 -0
  54. package/db/data/sap.attachments-ScanStates_texts_es_MX.csv +6 -0
  55. package/db/data/sap.attachments-ScanStates_texts_fi.csv +6 -0
  56. package/db/data/sap.attachments-ScanStates_texts_fr.csv +6 -0
  57. package/db/data/sap.attachments-ScanStates_texts_he.csv +6 -0
  58. package/db/data/sap.attachments-ScanStates_texts_hr.csv +6 -0
  59. package/db/data/sap.attachments-ScanStates_texts_hu.csv +6 -0
  60. package/db/data/sap.attachments-ScanStates_texts_it.csv +6 -0
  61. package/db/data/sap.attachments-ScanStates_texts_ja.csv +6 -0
  62. package/db/data/sap.attachments-ScanStates_texts_kk.csv +6 -0
  63. package/db/data/sap.attachments-ScanStates_texts_ko.csv +6 -0
  64. package/db/data/sap.attachments-ScanStates_texts_ms.csv +6 -0
  65. package/db/data/sap.attachments-ScanStates_texts_nl.csv +6 -0
  66. package/db/data/sap.attachments-ScanStates_texts_no.csv +6 -0
  67. package/db/data/sap.attachments-ScanStates_texts_pl.csv +6 -0
  68. package/db/data/sap.attachments-ScanStates_texts_pt.csv +6 -0
  69. package/db/data/sap.attachments-ScanStates_texts_ro.csv +6 -0
  70. package/db/data/sap.attachments-ScanStates_texts_ru.csv +6 -0
  71. package/db/data/sap.attachments-ScanStates_texts_sh.csv +6 -0
  72. package/db/data/sap.attachments-ScanStates_texts_sk.csv +6 -0
  73. package/db/data/sap.attachments-ScanStates_texts_sl.csv +6 -0
  74. package/db/data/sap.attachments-ScanStates_texts_sv.csv +6 -0
  75. package/db/data/sap.attachments-ScanStates_texts_th.csv +6 -0
  76. package/db/data/sap.attachments-ScanStates_texts_tr.csv +6 -0
  77. package/db/data/sap.attachments-ScanStates_texts_uk.csv +6 -0
  78. package/db/data/sap.attachments-ScanStates_texts_vi.csv +6 -0
  79. package/db/data/sap.attachments-ScanStates_texts_zh_CN.csv +6 -0
  80. package/db/data/sap.attachments-ScanStates_texts_zh_TW.csv +6 -0
  81. package/db/index.cds +85 -0
  82. package/index.cds +1 -0
  83. package/lib/csn-runtime-extension.js +31 -0
  84. package/lib/generic-handlers.js +199 -0
  85. package/lib/helper.js +407 -0
  86. package/lib/mtx/server.js +583 -0
  87. package/lib/plugin.js +112 -0
  88. package/package.json +67 -0
  89. package/srv/aws-s3.js +249 -0
  90. package/srv/azure-blob-storage.js +202 -0
  91. package/srv/basic.js +331 -0
  92. package/srv/gcp.js +226 -0
  93. package/srv/malwareScanner-mocked.cds +3 -0
  94. package/srv/malwareScanner-mocked.js +127 -0
  95. package/srv/malwareScanner.cds +23 -0
  96. package/srv/malwareScanner.js +151 -0
  97. package/srv/object-store.js +44 -0
  98. package/srv/standard.js +3 -0
@@ -0,0 +1,583 @@
1
+ const cds = require('@sap/cds')
2
+ const LOG = cds.log('attachments')
3
+ const axios = require('axios')
4
+ const https = require("https")
5
+ const { S3Client, paginateListObjectsV2, DeleteObjectsCommand } = require('@aws-sdk/client-s3')
6
+ const { BlobServiceClient } = require('@azure/storage-blob')
7
+ const { Storage } = require('@google-cloud/storage')
8
+ const { validateServiceManagerCredentials, fetchObjectStoreBinding } = require('../helper')
9
+
10
+ const PATH = {
11
+ SERVICE_INSTANCE: "v1/service_instances",
12
+ SERVICE_BINDING: "v1/service_bindings",
13
+ SERVICE_PLAN: "v1/service_plans",
14
+ SERVICE_OFFERING: "v1/service_offerings"
15
+ }
16
+
17
+ const HTTP_METHOD = {
18
+ POST: "post",
19
+ GET: "get",
20
+ DELETE: "delete"
21
+ }
22
+
23
+ const STATE = {
24
+ SUCCEEDED: "succeeded",
25
+ FAILED: "failed",
26
+ }
27
+
28
+ let POLL_WAIT_TIME = 5000
29
+ const ASYNC_TIMEOUT = 5 * 60 * 1000
30
+
31
+ /**
32
+ * Waits for the specified number of milliseconds
33
+ * @param {number} milliseconds - Time to wait in milliseconds
34
+ * @returns {Promise} - Resolves after the specified time
35
+ */
36
+ async function wait(milliseconds) {
37
+ if (milliseconds <= 0) {
38
+ return
39
+ }
40
+ await new Promise(function (resolve) {
41
+ setTimeout(resolve, milliseconds)
42
+ })
43
+ }
44
+
45
+ /**
46
+ * Registers attachment handlers for the given service and entity
47
+ * @param {string} sm_url - Service Manager URL
48
+ * @param {import('axios').Method} method - HTTP method
49
+ * @param {string} path - API path
50
+ * @param {string} token - OAuth token
51
+ * @param {*} params - Query parameters
52
+ * @returns {string} - Response data
53
+ */
54
+ const _serviceManagerRequest = async (sm_url, method, path, token, params = {}) => {
55
+ try {
56
+ const response = await axios({
57
+ method,
58
+ url: `${sm_url}/${path}`,
59
+ headers: {
60
+ 'Accept': 'application/json',
61
+ 'Authorization': `Bearer ${token}`
62
+ },
63
+ params
64
+ })
65
+
66
+ return response?.data?.items?.[0] // Error handling : return undefined instead of crashing when .items is undefined
67
+
68
+ } catch (error) {
69
+ LOG.error(`Service Manager API request failed - ${method.toUpperCase()} ${path}`, error,
70
+ 'Check Service Manager connectivity and credentials',
71
+ { method, path, sm_url, params })
72
+ throw error
73
+ }
74
+ }
75
+
76
+ const _fetchToken = async (url, clientid, clientsecret, certificate, key) => {
77
+ try {
78
+ const tokenUrl = `${url}/oauth/token`
79
+ const headers = {
80
+ Accept: "application/json",
81
+ "Content-Type": "application/x-www-form-urlencoded",
82
+ }
83
+ const body = "grant_type=client_credentials"
84
+
85
+ // Case 1: OAuth Client Credentials Flow
86
+ if (clientid && clientsecret) {
87
+ LOG.debug('Using OAuth client credentials to fetch token.', { url, clientid })
88
+ const response = await axios.post(tokenUrl, null, {
89
+ headers,
90
+ params: {
91
+ grant_type: "client_credentials",
92
+ client_id: clientid,
93
+ client_secret: clientsecret,
94
+ },
95
+ })
96
+
97
+ if (!response.data?.access_token) {
98
+ LOG.error('OAuth token response missing access_token', null,
99
+ 'Check clientid/clientsecret validity and Service Manager configuration',
100
+ { clientid, responseData: response.data })
101
+ throw new Error('Access token not found in OAuth token response')
102
+ }
103
+
104
+ return response.data.access_token
105
+ }
106
+
107
+ LOG.debug('OAuth client credentials missing - checking for MTLS credentials', { url, clientid })
108
+
109
+ // Case 2: MTLS Flow
110
+ if (certificate && key) {
111
+ LOG.debug('MTLS certificate and key found - proceeding with MTLS token fetch.', { url, clientid })
112
+ const agent = new https.Agent({ cert: certificate, key: key })
113
+
114
+ const response = await axios.post(tokenUrl, body, {
115
+ headers,
116
+ httpsAgent: agent,
117
+ })
118
+
119
+ if (!response.data?.access_token) {
120
+ LOG.error('MTLS token response missing access_token', null,
121
+ 'Check MTLS certificate/key validity and Service Manager configuration',
122
+ { clientid, responseData: response.data })
123
+ throw new Error('Access token not found in MTLS token response')
124
+ }
125
+
126
+ return response.data.access_token
127
+ }
128
+
129
+ // If neither flow is possible
130
+ throw new Error("Missing authentication credentials: Provide either OAuth clientid/clientsecret or MTLS certificate/key.")
131
+ } catch (error) {
132
+ LOG.error('Failed to fetch OAuth token using provided credentials', error,
133
+ 'Verify Service Manager credentials and connectivity')
134
+ throw error
135
+ }
136
+ }
137
+
138
+ const _getOfferingID = async (sm_url, token) => {
139
+ const offerings = await _serviceManagerRequest(sm_url, HTTP_METHOD.GET, PATH.SERVICE_OFFERING, token, { fieldQuery: "name eq 'objectstore'" })
140
+ const offeringID = offerings.id
141
+ if (!offeringID) LOG.debug('Object store service offering not found in Service Manager', { sm_url })
142
+ return offeringID
143
+ }
144
+
145
+ /**
146
+ * Registers attachment handlers for the given service and entity
147
+ * @param {string} sm_url - Service Manager URL
148
+ * @param {string} token - OAuth token
149
+ * @param {string} offeringID - Service Offering ID
150
+ * @returns
151
+ */
152
+ const _getPlanID = async (sm_url, token, offeringID) => {
153
+ // Recheck the fieldQuery for catalog_name
154
+ const supportedPlans = ["standard", "s3-standard"]
155
+ for (const planName of supportedPlans) {
156
+ LOG.debug('Fetching object store plan from Service Manager', { planName })
157
+ try {
158
+ const plan = await _serviceManagerRequest(
159
+ sm_url,
160
+ HTTP_METHOD.GET,
161
+ PATH.SERVICE_PLAN,
162
+ token,
163
+ {
164
+ fieldQuery: `service_offering_id eq '${offeringID}' and catalog_name eq '${planName}'`,
165
+ }
166
+ )
167
+ if (plan?.id) {
168
+ LOG.debug('Using object store plan', { planName, planID: plan.id, offeringID })
169
+ return plan.id
170
+ }
171
+ } catch (error) {
172
+ LOG.error(`Failed to fetch plan "${planName}" from Service Manager`, error,
173
+ 'Check Service Manager connectivity and credentials',
174
+ { sm_url, offeringID, planName })
175
+ throw error
176
+ }
177
+ }
178
+ LOG.debug('No supported object store service plan found in Service Manager', { sm_url, attempted: supportedPlans.join(", ") })
179
+ throw new Error(
180
+ `No supported object store service plan found in Service Manager.`
181
+ )
182
+ }
183
+
184
+ /**
185
+ * Creates an object store instance for the given tenant
186
+ * @param {string} sm_url - Service Manager URL
187
+ * @param {string} tenant - Tenant ID
188
+ * @param {string} planID - Service Plan ID
189
+ * @param {string} token - OAuth token
190
+ * @returns
191
+ */
192
+ const _createObjectStoreInstance = async (sm_url, tenant, planID, token) => {
193
+ try {
194
+ const response = await axios.post(`${sm_url}/v1/service_instances`, {
195
+ name: `object-store-${tenant}-${cds.utils.uuid()}`,
196
+ service_plan_id: planID,
197
+ parameters: {},
198
+ labels: { tenant_id: [tenant], service: ["OBJECT_STORE"] }
199
+ }, {
200
+ headers: {
201
+ 'Accept': 'application/json',
202
+ 'Authorization': `Bearer ${token}`,
203
+ 'Content-Type': 'application/json'
204
+ }
205
+ })
206
+ const instancePath = response.headers.location.substring(1)
207
+ const instanceId = await _pollUntilDone(sm_url, instancePath, token)
208
+ return instanceId.data.resource_id
209
+ } catch (error) {
210
+ LOG.error(`Failed to create object store instance for tenant - ${tenant}`, error,
211
+ 'Check Service Manager connectivity and credentials',
212
+ { sm_url, tenant, planID })
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Polls the service manager until the instance is in a terminal state
218
+ * @param {string} sm_url - Service Manager URL
219
+ * @param {string} instancePath - Path to the service instance
220
+ * @param {string} token - OAuth token
221
+ * @returns
222
+ */
223
+ const _pollUntilDone = async (sm_url, instancePath, token) => {
224
+ try {
225
+ let iteration = 1
226
+ const startTime = Date.now()
227
+ let isReady = false
228
+ while (!isReady) {
229
+ await wait(POLL_WAIT_TIME * iteration)
230
+ iteration++
231
+
232
+ const instanceStatus = await axios.get(`${sm_url}/${instancePath}`, {
233
+ headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${token}` }
234
+ })
235
+
236
+ if (instanceStatus.data.state === STATE.SUCCEEDED) {
237
+ isReady = true
238
+ return instanceStatus
239
+ }
240
+
241
+ if (Date.now() - startTime > ASYNC_TIMEOUT) {
242
+ LOG.debug('Timed out waiting for service instance to be ready', { instancePath, sm_url })
243
+ }
244
+
245
+ if (instanceStatus.data.state === STATE.FAILED) {
246
+ LOG.debug('Service instance creation failed', { instancePath, sm_url, details: instanceStatus.data })
247
+ }
248
+ }
249
+ } catch (error) {
250
+ LOG.error('Error polling for object store instance readiness', error,
251
+ 'Check Service Manager connectivity and instance status',
252
+ { sm_url, instancePath })
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Registers attachment handlers for the given service and entity
258
+ * @param {string} sm_url - Service Manager URL
259
+ * @param {string} tenant - Tenant ID
260
+ * @param {string} instanceID - Service Instance ID
261
+ * @param {string} token - OAuth token
262
+ * @returns
263
+ */
264
+ const _bindObjectStoreInstance = async (sm_url, tenant, instanceID, token) => {
265
+ if (instanceID) {
266
+ try {
267
+ const response = await axios.post(`${sm_url}/${PATH.SERVICE_BINDING}`, {
268
+ name: `object-store-${tenant}-${cds.utils.uuid()}`,
269
+ service_instance_id: instanceID,
270
+ parameters: {},
271
+ labels: { tenant_id: [tenant], service: ["OBJECT_STORE"] }
272
+ }, {
273
+ headers: {
274
+ 'Accept': 'application/json',
275
+ 'Authorization': `Bearer ${token}`,
276
+ 'Content-Type': 'application/json'
277
+ }
278
+ })
279
+ return response.data.id
280
+ } catch (error) {
281
+ LOG.error(`Error binding object store instance for tenant - ${tenant}`, error,
282
+ 'Check Service Manager connectivity and credentials',
283
+ { sm_url, tenant, instanceID })
284
+ }
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Registers attachment handlers for the given service and entity
290
+ * @param {string} sm_url - Service Manager URL
291
+ * @param {string} tenant - Tenant ID
292
+ * @param {string} token - OAuth token
293
+ * @returns {string} - Binding ID
294
+ */
295
+ const _getBindingIdForDeletion = async (sm_url, tenant, token) => {
296
+ try {
297
+ const getBindingCredentials = await _serviceManagerRequest(sm_url, HTTP_METHOD.GET, PATH.SERVICE_BINDING, token, {
298
+ labelQuery: `service eq 'OBJECT_STORE' and tenant_id eq '${tenant}'`
299
+ })
300
+ if (!getBindingCredentials?.id) {
301
+ LOG.warn('No binding credentials found for tenant during deletion', { tenant })
302
+ return null // Handle missing data gracefully
303
+ }
304
+ return getBindingCredentials.id
305
+
306
+ } catch (error) {
307
+ LOG.error(`Error fetching binding credentials for tenant - ${tenant}`, error,
308
+ 'Check Service Manager connectivity and credentials',
309
+ { sm_url, tenant })
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Registers attachment handlers for the given service and entity
315
+ * @param {string} sm_url - Service Manager URL
316
+ * @param {string} bindingID - Binding ID
317
+ * @param {string} token - OAuth token
318
+ */
319
+ const _deleteBinding = async (sm_url, bindingID, token) => {
320
+ if (bindingID) {
321
+ try {
322
+ await axios.delete(`${sm_url}/${PATH.SERVICE_BINDING}/${bindingID}`, {
323
+ headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${token}` }
324
+ })
325
+ } catch (error) {
326
+ LOG.error(`Error deleting binding - ${bindingID}`, error,
327
+ 'Check Service Manager connectivity and credentials',
328
+ { sm_url, bindingID })
329
+ }
330
+ } else {
331
+ LOG.warn('No binding ID provided for deletion, skipping delete operation')
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Registers attachment handlers for the given service and entity
337
+ * @param {string} sm_url - Service Manager URL
338
+ * @param {string} tenant - Tenant ID
339
+ * @param {string} token - OAuth token
340
+ * @returns {string} - Instance ID
341
+ */
342
+ const _getInstanceIdForDeletion = async (sm_url, tenant, token) => {
343
+ try {
344
+ const instanceId = await _serviceManagerRequest(sm_url, HTTP_METHOD.GET, PATH.SERVICE_INSTANCE, token, { labelQuery: `service eq 'OBJECT_STORE' and tenant_id eq '${tenant}'` })
345
+ return instanceId.id
346
+ } catch (error) {
347
+ LOG.error(`Error fetching service instance id for tenant - ${tenant}`, error,
348
+ 'Check Service Manager connectivity and credentials',
349
+ { sm_url, tenant })
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Deletes an object store instance
355
+ * @param {string} sm_url - Service Manager URL
356
+ * @param {string} instanceID - Service Instance ID
357
+ * @param {string} token - OAuth token
358
+ */
359
+ const _deleteObjectStoreInstance = async (sm_url, instanceID, token) => {
360
+ if (instanceID) {
361
+ try {
362
+ const response = await axios.delete(`${sm_url}/${PATH.SERVICE_INSTANCE}/${instanceID}`, {
363
+ headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${token}` }
364
+ })
365
+ const instancePath = response.headers.get("location").substring(1)
366
+ await _pollUntilDone(sm_url, instancePath, token) // remove
367
+ LOG.debug('Object store instance deleted', { instanceID })
368
+ } catch (error) {
369
+ LOG.error(
370
+ `Error deleting object store instance - ${instanceID}`, error,
371
+ 'Check Service Manager connectivity and credentials',
372
+ { sm_url, instanceID })
373
+ }
374
+ }
375
+ }
376
+
377
+ cds.on('listening', async () => {
378
+ const profile = cds.env.profile
379
+ const objectStoreKind = cds.env.requires?.attachments?.objectStore?.kind
380
+ if (profile === 'mtx-sidecar') {
381
+ const ds = await cds.connect.to("cds.xt.DeploymentService")
382
+ if (objectStoreKind === "separate") {
383
+ ds.after('subscribe', async (_, req) => {
384
+ const { tenant } = req.data
385
+ try {
386
+ const serviceManagerCredentials = cds.env.requires?.serviceManager?.credentials || {}
387
+ validateServiceManagerCredentials(serviceManagerCredentials)
388
+ const { sm_url, url, clientid, clientsecret, certificate, key } = serviceManagerCredentials
389
+
390
+ const token = await _fetchToken(url, clientid, clientsecret, certificate, key)
391
+
392
+ const existingTenantBindings = await fetchObjectStoreBinding(tenant, token);
393
+
394
+ if (existingTenantBindings.length) {
395
+ LOG.info(`Existing tenant specific object store for ${tenant} exists. Skipping creation of new one.`)
396
+ return;
397
+ }
398
+
399
+ const offeringID = await _getOfferingID(sm_url, token)
400
+ const planID = await _getPlanID(sm_url, token, offeringID)
401
+
402
+ const instanceID = await _createObjectStoreInstance(sm_url, tenant, planID, token)
403
+ LOG.debug('Object Store instance created', { tenant, instanceID })
404
+
405
+ await _bindObjectStoreInstance(sm_url, tenant, instanceID, token)
406
+ } catch (error) {
407
+ LOG.error(`Error setting up object store for tenant - ${tenant}`, error,
408
+ 'Check Service Manager connectivity and credentials',
409
+ { tenant })
410
+ }
411
+ })
412
+
413
+ ds.after('unsubscribe', async (_, req) => {
414
+ const { tenant } = req.data
415
+ try {
416
+ const serviceManagerCredentials = cds.env.requires?.serviceManager?.credentials || {}
417
+ validateServiceManagerCredentials(serviceManagerCredentials)
418
+
419
+ const { sm_url, url, clientid, clientsecret, certificate, key } = serviceManagerCredentials
420
+
421
+ const token = await _fetchToken(url, clientid, clientsecret, certificate, key)
422
+
423
+ const bindingID = await _getBindingIdForDeletion(sm_url, tenant, token)
424
+
425
+ await _deleteBinding(sm_url, bindingID, token)
426
+
427
+ const service_instance_id = await _getInstanceIdForDeletion(sm_url, tenant, token)
428
+
429
+ await _deleteObjectStoreInstance(sm_url, service_instance_id, token)
430
+ } catch (error) {
431
+ LOG.error(
432
+ `Error deleting object store service for tenant - ${tenant}`, error,
433
+ 'Check Service Manager connectivity and credentials',
434
+ { tenant })
435
+ }
436
+
437
+ })
438
+ } else if (objectStoreKind === "shared") {
439
+ ds.after('unsubscribe', async (_, req) => {
440
+ const { tenant } = req.data
441
+
442
+ const creds = cds.env.requires?.objectStore?.credentials
443
+ if (!creds) throw new Error("SAP Object Store instance credentials not found.")
444
+
445
+ switch (cds.env.requires?.attachments?.kind) {
446
+ case "s3":
447
+ await _cleanupAWSS3Objects(creds, tenant)
448
+ break
449
+ case "azure":
450
+ await _cleanupAzureBlobObjects(creds, tenant)
451
+ break
452
+ case "gcp":
453
+ await _cleanupGoogleCloudObjects(creds, tenant)
454
+ break
455
+ default:
456
+ LOG.warn('Unsupported object store kind for cleanup', { kind: cds.env.requires?.attachments?.kind, tenant })
457
+ }
458
+ })
459
+
460
+ }
461
+ }
462
+ module.exports = cds.server
463
+ })
464
+
465
+ /**
466
+ * Cleanup for AWS S3 objects for a given tenant
467
+ * @param {*} creds - AWS S3 credentials
468
+ * @param {string} tenant - Tenant ID
469
+ */
470
+ const _cleanupAWSS3Objects = async (creds, tenant) => {
471
+ const client = new S3Client({
472
+ region: creds.region,
473
+ credentials: {
474
+ accessKeyId: creds.access_key_id,
475
+ secretAccessKey: creds.secret_access_key,
476
+ },
477
+ })
478
+
479
+ const bucket = creds.bucket
480
+ const keysToDelete = []
481
+
482
+ try {
483
+ const paginator = paginateListObjectsV2({ client }, {
484
+ Bucket: bucket,
485
+ Prefix: tenant,
486
+ })
487
+
488
+ for await (const page of paginator) {
489
+ page.Contents?.forEach(obj => {
490
+ keysToDelete.push({ Key: obj.Key })
491
+ })
492
+ }
493
+
494
+ if (keysToDelete.length > 0) {
495
+ await client.send(new DeleteObjectsCommand({
496
+ Bucket: bucket,
497
+ Delete: { Objects: keysToDelete },
498
+ }))
499
+ LOG.debug('[AWS S3] S3 objects deleted for tenant', { tenant, deletedCount: keysToDelete.length })
500
+ } else {
501
+ LOG.debug('[AWS S3] No S3 objects found for tenant during cleanup', { tenant })
502
+ }
503
+ } catch (error) {
504
+ LOG.error(
505
+ `Failed to clean up S3 objects for tenant "${tenant}"`, error,
506
+ 'Check AWS S3 connectivity and permissions',
507
+ { tenant })
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Cleanup for Azure Blob Storage objects for a given tenant
513
+ * @param {*} creds - Azure Blob Storage credentials
514
+ * @param {string} tenant - Tenant ID
515
+ */
516
+ const _cleanupAzureBlobObjects = async (creds, tenant) => {
517
+ const blobServiceClient = new BlobServiceClient(`${creds.container_uri}?${creds.sas_token}`)
518
+ const containerClient = blobServiceClient.getContainerClient(creds.container)
519
+
520
+ try {
521
+ const blobsToDelete = []
522
+ for await (const blob of containerClient.listBlobsFlat({ prefix: tenant })) {
523
+ blobsToDelete.push(blob.name)
524
+ }
525
+
526
+ for (const blobName of blobsToDelete) {
527
+ const blockBlobClient = containerClient.getBlockBlobClient(blobName)
528
+ await blockBlobClient.delete()
529
+ }
530
+
531
+ LOG.debug('[Azure] Azure Blob objects deleted for tenant', { tenant, deletedCount: blobsToDelete.length })
532
+ } catch (error) {
533
+ LOG.error(
534
+ `Failed to clean up Azure Blob objects for tenant "${tenant}"`, error,
535
+ 'Check Azure Blob Storage connectivity and permissions',
536
+ { tenant })
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Cleanup for Google Cloud Storage objects for a given tenant
542
+ * @param {*} creds - Google Cloud Storage credentials
543
+ * @param {string} tenant - Tenant ID
544
+ */
545
+ const _cleanupGoogleCloudObjects = async (creds, tenant) => {
546
+ const storageClient = new Storage({
547
+ projectId: creds.project_id,
548
+ credentials: creds.service_account
549
+ })
550
+ const bucket = storageClient.bucket(creds.bucket_name)
551
+
552
+ try {
553
+ let pageToken = undefined
554
+ let totalDeleted = 0
555
+ do {
556
+ const [files, nextQuery] = await bucket.getFiles({
557
+ prefix: tenant,
558
+ maxResults: 1000, // or another reasonable batch size
559
+ pageToken
560
+ })
561
+
562
+ const deletePromises = files.map(file => file.delete())
563
+ await Promise.all(deletePromises)
564
+ totalDeleted += files.length
565
+ pageToken = nextQuery?.pageToken
566
+ } while (pageToken)
567
+
568
+ LOG.debug('[GCP] Google Cloud Storage objects deleted for tenant', { tenant, deletedCount: totalDeleted })
569
+ } catch (error) {
570
+ LOG.error(
571
+ `Failed to clean up Google Cloud Platform objects for tenant "${tenant}"`, error,
572
+ 'Check Google Cloud Storage connectivity and permissions',
573
+ { tenant })
574
+ }
575
+ }
576
+
577
+ module.exports = {
578
+ _fetchToken,
579
+ _serviceManagerRequest,
580
+ _getOfferingID,
581
+ _getPlanID,
582
+ _createObjectStoreInstance
583
+ }
package/lib/plugin.js ADDED
@@ -0,0 +1,112 @@
1
+ const cds = require("@sap/cds")
2
+ const { validateAttachment, readAttachment, validateAttachmentSize, onPrepareAttachment, validateAttachmentMimeType } = require("./generic-handlers")
3
+ require("./csn-runtime-extension")
4
+ const LOG = cds.log('attachments')
5
+
6
+ cds.on(cds.version >= "8.6.0" ? "compile.to.edmx" : "loaded", unfoldModel)
7
+
8
+ function unfoldModel(csn) {
9
+ const meta = csn.meta ??= {}
10
+ if (!("sap.attachments.Attachments" in csn.definitions)) return
11
+ if (meta._enhanced_for_attachments) return
12
+ // const csnCopy = structuredClone(csn) // REVISIT: Why did we add this cloning?
13
+ const hasFacetForComp = (comp, facets) => facets.some(f => f.Target === `${comp.name}/@UI.LineItem` || (f.Facets && hasFacetForComp(comp, f.Facets)))
14
+ cds.linked(csn).forall("Composition", (comp) => {
15
+ if (comp._target && comp._target["@_is_media_data"] && comp.parent && comp.is2many) {
16
+ let facets = comp.parent["@UI.Facets"]
17
+ if (!facets) return
18
+ if (comp["@attachments.disable_facet"] !== undefined) {
19
+ LOG.warn(`@attachments.disable_facet is deprecated! Please annotate ${comp.name} with @UI.Hidden`)
20
+ }
21
+ if (!comp["@attachments.disable_facet"] && !hasFacetForComp(comp, facets)) {
22
+ LOG.debug(`Adding @UI.Facet to: ${comp.parent.name}`)
23
+ const attachmentsFacet = {
24
+ $Type: "UI.ReferenceFacet",
25
+ Target: `${comp.name}/@UI.LineItem`,
26
+ ID: `${comp.name}_attachments`,
27
+ Label: "{i18n>Attachments}",
28
+ }
29
+ if (comp["@UI.Hidden"]) {
30
+ attachmentsFacet["@UI.Hidden"] = comp["@UI.Hidden"]
31
+ }
32
+ facets.push(attachmentsFacet)
33
+ //Hide parent key so it cannot be selected from Columns on the UI
34
+ Object.keys(comp._target.elements).filter(e => e.startsWith('up__')).forEach(ele => {
35
+ comp._target.elements[ele]['@UI.Hidden'] = true;
36
+ })
37
+ if (comp._target.elements['up_']) {
38
+ comp._target.elements['up_']['@UI.Hidden'] = true;
39
+ }
40
+ }
41
+
42
+ }
43
+ })
44
+ meta._enhanced_for_attachments = true
45
+ }
46
+
47
+ cds.ApplicationService.handle_attachments = cds.service.impl(async function () {
48
+ if (!cds.env.requires.attachments) return;
49
+ LOG.debug(`Registering handlers for attachments entities for service: ${this.name}`)
50
+ this.before("READ", validateAttachment)
51
+ this.after("READ", readAttachment)
52
+ this.before("PUT", validateAttachmentSize)
53
+ this.before("PUT", validateAttachmentMimeType)
54
+ this.before("NEW", onPrepareAttachment)
55
+ this.before("CREATE", (req) => {
56
+ return onPrepareAttachment(req)
57
+ })
58
+
59
+ this.before(["DELETE", "UPDATE"], async function collectDeletedAttachmentsForDraftEnabled(req) {
60
+ if (!req.target?._attachments.hasAttachmentsComposition) return;
61
+ const AttachmentsSrv = await cds.connect.to("attachments")
62
+ return AttachmentsSrv.attachDeletionData.bind(AttachmentsSrv)(req)
63
+ })
64
+ this.after(["DELETE", "UPDATE"], async function deleteCollectedDeletedAttachmentsForDraftEnabled(res, req) {
65
+ if (!req.target?._attachments.hasAttachmentsComposition) return;
66
+ const AttachmentsSrv = await cds.connect.to("attachments")
67
+ return AttachmentsSrv.deleteAttachmentsWithKeys.bind(AttachmentsSrv)(res, req)
68
+ })
69
+
70
+
71
+ this.prepend(() =>
72
+ this.on(
73
+ ["PUT", "UPDATE"],
74
+ async function putUpdateAttachments(req, next) {
75
+ // Skip entities which are not Attachments and skip if content is not updated
76
+ if (!req.target._attachments.isAttachmentsEntity || !req.data.content) return next()
77
+
78
+ let metadata = await this.run(SELECT.from(req.subject).columns('url', ...Object.keys(req.target.keys)))
79
+ if (metadata.length > 1) {
80
+ return req.error(501, 'MultiUpdateNotSupported')
81
+ }
82
+ metadata = metadata[0]
83
+ if (!metadata) {
84
+ return req.reject(404)
85
+ }
86
+ req.data.ID = metadata.ID
87
+ req.data.url ??= metadata.url
88
+ for (const key in metadata) {
89
+ if (key.startsWith('up_')) {
90
+ req.data[key] = metadata[key]
91
+ }
92
+ }
93
+ const AttachmentsSrv = await cds.connect.to("attachments")
94
+ return await AttachmentsSrv.put(req.target, req.data)
95
+ }
96
+ )
97
+ )
98
+
99
+ this.prepend(() =>
100
+ this.on(
101
+ ["CREATE"],
102
+ async function createAttachments(req, next) {
103
+ if (!req.target._attachments.isAttachmentsEntity || req.req?.url?.endsWith('/content') || !req.data.url || !(req.data.content || (Array.isArray(req.data) && req.data[0]?.content))) return next()
104
+ const AttachmentsSrv = await cds.connect.to("attachments")
105
+ return AttachmentsSrv.put(req.target, req.data)
106
+ }
107
+ )
108
+ )
109
+
110
+ const AttachmentsSrv = await cds.connect.to("attachments")
111
+ AttachmentsSrv.registerHandlers(this)
112
+ })