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.
- package/LICENSE +201 -0
- package/README.md +451 -0
- package/_i18n/i18n.properties +6 -0
- package/_i18n/i18n_ar.properties +6 -0
- package/_i18n/i18n_bg.properties +6 -0
- package/_i18n/i18n_cs.properties +6 -0
- package/_i18n/i18n_da.properties +6 -0
- package/_i18n/i18n_de.properties +6 -0
- package/_i18n/i18n_el.properties +6 -0
- package/_i18n/i18n_en.properties +6 -0
- package/_i18n/i18n_en_US_saptrc.properties +6 -0
- package/_i18n/i18n_es.properties +6 -0
- package/_i18n/i18n_es_MX.properties +6 -0
- package/_i18n/i18n_fi.properties +6 -0
- package/_i18n/i18n_fr.properties +6 -0
- package/_i18n/i18n_he.properties +6 -0
- package/_i18n/i18n_hr.properties +6 -0
- package/_i18n/i18n_hu.properties +6 -0
- package/_i18n/i18n_it.properties +6 -0
- package/_i18n/i18n_ja.properties +6 -0
- package/_i18n/i18n_kk.properties +6 -0
- package/_i18n/i18n_ko.properties +6 -0
- package/_i18n/i18n_ms.properties +6 -0
- package/_i18n/i18n_nl.properties +6 -0
- package/_i18n/i18n_no.properties +6 -0
- package/_i18n/i18n_pl.properties +6 -0
- package/_i18n/i18n_pt.properties +6 -0
- package/_i18n/i18n_ro.properties +6 -0
- package/_i18n/i18n_ru.properties +6 -0
- package/_i18n/i18n_sh.properties +6 -0
- package/_i18n/i18n_sk.properties +6 -0
- package/_i18n/i18n_sl.properties +6 -0
- package/_i18n/i18n_sv.properties +6 -0
- package/_i18n/i18n_th.properties +6 -0
- package/_i18n/i18n_tr.properties +6 -0
- package/_i18n/i18n_uk.properties +6 -0
- package/_i18n/i18n_vi.properties +6 -0
- package/_i18n/i18n_zh_CN.properties +6 -0
- package/_i18n/i18n_zh_TW.properties +6 -0
- package/_i18n/messages.properties +6 -0
- package/_i18n/messages_en_US_saptrc.properties +4 -0
- package/cds-plugin.js +4 -0
- package/db/data/sap.attachments-ScanStates.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_ar.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_bg.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_cs.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_da.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_de.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_el.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_en.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_en_US_saptrc.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_es.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_es_MX.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_fi.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_fr.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_he.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_hr.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_hu.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_it.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_ja.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_kk.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_ko.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_ms.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_nl.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_no.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_pl.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_pt.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_ro.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_ru.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_sh.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_sk.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_sl.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_sv.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_th.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_tr.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_uk.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_vi.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_zh_CN.csv +6 -0
- package/db/data/sap.attachments-ScanStates_texts_zh_TW.csv +6 -0
- package/db/index.cds +85 -0
- package/index.cds +1 -0
- package/lib/csn-runtime-extension.js +31 -0
- package/lib/generic-handlers.js +199 -0
- package/lib/helper.js +407 -0
- package/lib/mtx/server.js +583 -0
- package/lib/plugin.js +112 -0
- package/package.json +67 -0
- package/srv/aws-s3.js +249 -0
- package/srv/azure-blob-storage.js +202 -0
- package/srv/basic.js +331 -0
- package/srv/gcp.js +226 -0
- package/srv/malwareScanner-mocked.cds +3 -0
- package/srv/malwareScanner-mocked.js +127 -0
- package/srv/malwareScanner.cds +23 -0
- package/srv/malwareScanner.js +151 -0
- package/srv/object-store.js +44 -0
- 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
|
+
})
|