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
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kalai-attach",
|
|
3
|
+
"description": "CAP cds-plugin providing image and attachment storing out-of-the-box.",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"repository": "capjsattachments-kalai",
|
|
6
|
+
"author": "Kalai",
|
|
7
|
+
"homepage": "https://github.com/Kalaikovan-airdit",
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"main": "cds-plugin.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"index.cds",
|
|
12
|
+
"_i18n",
|
|
13
|
+
"lib",
|
|
14
|
+
"db",
|
|
15
|
+
"srv"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"lint": "npx eslint .",
|
|
19
|
+
"test": "npx jest --silent=true --runInBand"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@aws-sdk/client-s3": "^3.918.0",
|
|
23
|
+
"@aws-sdk/lib-storage": "^3.918.0",
|
|
24
|
+
"@azure/storage-blob": "^12.29.1",
|
|
25
|
+
"@google-cloud/storage": "^7.17.3",
|
|
26
|
+
"axios": "^1.4.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@cap-js/cds-test": ">=0",
|
|
30
|
+
"@cap-js/sqlite": "^2",
|
|
31
|
+
"eslint": "^9.36.0",
|
|
32
|
+
"express": "^4.18.2",
|
|
33
|
+
"release-it": "^19.2.2"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@sap/cds": ">=8"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18.0.0"
|
|
40
|
+
},
|
|
41
|
+
"cds": {
|
|
42
|
+
"requires": {
|
|
43
|
+
"kinds": {
|
|
44
|
+
"attachments-azure": {
|
|
45
|
+
"impl": "./srv/azure-blob-storage"
|
|
46
|
+
},
|
|
47
|
+
"malwareScanner-mocked": {
|
|
48
|
+
"model": "./srv/malwareScanner-mocked"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"attachments": {
|
|
52
|
+
"kind": "attachments-azure",
|
|
53
|
+
"scan": false,
|
|
54
|
+
"outbox": true
|
|
55
|
+
},
|
|
56
|
+
"[development]": {
|
|
57
|
+
"attachments": { "kind": "attachments-azure" }
|
|
58
|
+
},
|
|
59
|
+
"[production]": {
|
|
60
|
+
"attachments": { "kind": "attachments-azure" }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"workspaces": [
|
|
65
|
+
"tests/incidents-app/"
|
|
66
|
+
]
|
|
67
|
+
}
|
package/srv/aws-s3.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
const { S3Client, GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3')
|
|
2
|
+
const { Upload } = require("@aws-sdk/lib-storage")
|
|
3
|
+
const cds = require("@sap/cds")
|
|
4
|
+
const LOG = cds.log('attachments')
|
|
5
|
+
const utils = require('../lib/helper')
|
|
6
|
+
|
|
7
|
+
module.exports = class AWSAttachmentsService extends require("./object-store") {
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates or retrieves a cached S3 client for the specified tenant
|
|
11
|
+
* @returns {Promise<{client: import('@aws-sdk/client-s3').S3Client, bucket: string}>}
|
|
12
|
+
*/
|
|
13
|
+
async retrieveClient() {
|
|
14
|
+
const tenantID = this.separateObjectStore ? cds.context.tenant : 'shared'
|
|
15
|
+
LOG.debug('Retrieving S3 client for', { tenantID })
|
|
16
|
+
const existingClient = this.clientsCache.get(tenantID)
|
|
17
|
+
if (existingClient) {
|
|
18
|
+
LOG.debug('Using cached S3 client', {
|
|
19
|
+
tenantID,
|
|
20
|
+
bucket: existingClient.bucket
|
|
21
|
+
})
|
|
22
|
+
return existingClient
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
LOG.debug(`Fetching object store credentials for tenant ${tenantID}. Using ${this.separateObjectStore ? 'shared' : 'tenant-specific'} object store.`)
|
|
27
|
+
const credentials = this.separateObjectStore
|
|
28
|
+
? (await utils.getObjectStoreCredentials(tenantID))?.credentials
|
|
29
|
+
: cds.env.requires?.objectStore?.credentials
|
|
30
|
+
|
|
31
|
+
if (!credentials) {
|
|
32
|
+
throw new Error("SAP Object Store instance is not bound.")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const requiredFields = ['bucket', 'region', 'access_key_id', 'secret_access_key']
|
|
36
|
+
const missingFields = requiredFields.filter(field => !credentials[field])
|
|
37
|
+
|
|
38
|
+
if (missingFields.length > 0) {
|
|
39
|
+
if (credentials.container_name) {
|
|
40
|
+
throw new Error('Azure Blob Storage found where AWS S3 credentials expected, please check your service bindings.')
|
|
41
|
+
} else if (credentials.projectId) {
|
|
42
|
+
throw new Error('Google Cloud Platform credentials found where AWS S3 credentials expected, please check your service bindings.')
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`Missing Object Store credentials: ${missingFields.join(', ')}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
LOG.debug('Creating S3 client', {
|
|
48
|
+
tenantID,
|
|
49
|
+
region: credentials.region,
|
|
50
|
+
bucket: credentials.bucket
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const s3Client = new S3Client({
|
|
54
|
+
region: credentials.region,
|
|
55
|
+
credentials: {
|
|
56
|
+
accessKeyId: credentials.access_key_id,
|
|
57
|
+
secretAccessKey: credentials.secret_access_key,
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const newS3Client = {
|
|
62
|
+
client: s3Client,
|
|
63
|
+
bucket: credentials.bucket,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.clientsCache.set(tenantID, newS3Client)
|
|
67
|
+
|
|
68
|
+
LOG.debug('s3 client has been created successfully', {
|
|
69
|
+
tenantID,
|
|
70
|
+
bucket: newS3Client.bucket,
|
|
71
|
+
region: credentials.region
|
|
72
|
+
})
|
|
73
|
+
return newS3Client;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
LOG.error(
|
|
76
|
+
'Failed to create tenant-specific S3 client', error,
|
|
77
|
+
'Check Service Manager and Object Store instance configuration',
|
|
78
|
+
{ tenantID })
|
|
79
|
+
throw error
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @inheritdoc
|
|
85
|
+
*/
|
|
86
|
+
async put(attachments, data) {
|
|
87
|
+
if (Array.isArray(data)) {
|
|
88
|
+
LOG.debug('Processing bulk file upload', {
|
|
89
|
+
fileCount: data.length,
|
|
90
|
+
filenames: data.map(d => d.filename)
|
|
91
|
+
})
|
|
92
|
+
return Promise.all(
|
|
93
|
+
data.map((d) => this.put(attachments, d))
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const startTime = Date.now()
|
|
98
|
+
LOG.debug('Starting file upload to S3', {
|
|
99
|
+
attachmentEntity: attachments.name,
|
|
100
|
+
tenant: cds.context.tenant
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const { client, bucket } = await this.retrieveClient()
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const { content, ...metadata } = data
|
|
107
|
+
const Key = metadata.url
|
|
108
|
+
|
|
109
|
+
if (!Key) {
|
|
110
|
+
LOG.error(
|
|
111
|
+
'File key/URL is required for S3 upload', null,
|
|
112
|
+
'Ensure attachment data includes a valid URL/key',
|
|
113
|
+
{ metadata: { ...metadata, content: !!content } })
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!content) {
|
|
118
|
+
LOG.error(
|
|
119
|
+
'File content is required for S3 upload', null,
|
|
120
|
+
'Ensure attachment data includes file content',
|
|
121
|
+
{ key: Key, hasContent: !!content })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const input = {
|
|
126
|
+
Bucket: bucket,
|
|
127
|
+
Key,
|
|
128
|
+
Body: content,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
LOG.info('Uploading file to S3', {
|
|
132
|
+
bucket: bucket,
|
|
133
|
+
key: Key,
|
|
134
|
+
filename: metadata.filename,
|
|
135
|
+
contentSize: content.length || content.size || 'unknown'
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const multipartUpload = new Upload({
|
|
139
|
+
client: client,
|
|
140
|
+
params: input,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// The file upload has to be done first, so super.put can compute the hash and trigger malware scan
|
|
144
|
+
await multipartUpload.done()
|
|
145
|
+
await super.put(attachments, metadata)
|
|
146
|
+
|
|
147
|
+
const duration = Date.now() - startTime
|
|
148
|
+
LOG.debug('File upload to S3 completed successfully', {
|
|
149
|
+
filename: metadata.filename,
|
|
150
|
+
fileId: metadata.ID,
|
|
151
|
+
bucket: bucket,
|
|
152
|
+
key: Key,
|
|
153
|
+
duration
|
|
154
|
+
})
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const duration = Date.now() - startTime
|
|
157
|
+
LOG.error(
|
|
158
|
+
'File upload to S3 failed', err,
|
|
159
|
+
'Check S3 connectivity, credentials, and bucket permissions',
|
|
160
|
+
{ filename: data?.filename, fileId: data?.ID, bucket: bucket, key: data?.url, duration })
|
|
161
|
+
throw err
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @inheritdoc
|
|
167
|
+
*/
|
|
168
|
+
async get(attachments, keys) {
|
|
169
|
+
const startTime = Date.now()
|
|
170
|
+
|
|
171
|
+
LOG.info('Starting file download from S3', {
|
|
172
|
+
attachmentEntity: attachments.name,
|
|
173
|
+
keys,
|
|
174
|
+
tenant: cds.context.tenant
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const { client, bucket } = await this.retrieveClient()
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
LOG.debug('Fetching attachment metadata', { keys })
|
|
181
|
+
const response = await SELECT.from(attachments, keys).columns("url")
|
|
182
|
+
|
|
183
|
+
if (!response?.url) {
|
|
184
|
+
LOG.warn(
|
|
185
|
+
'File URL not found in database', null,
|
|
186
|
+
'Check if the attachment exists and has been properly uploaded',
|
|
187
|
+
{ keys, hasResponse: !!response })
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const Key = response.url
|
|
192
|
+
|
|
193
|
+
LOG.debug('Streaming file from S3', {
|
|
194
|
+
bucket: bucket,
|
|
195
|
+
key: Key
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const content = await client.send(
|
|
199
|
+
new GetObjectCommand({
|
|
200
|
+
Bucket: bucket,
|
|
201
|
+
Key,
|
|
202
|
+
})
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
const duration = Date.now() - startTime
|
|
206
|
+
LOG.debug('File streamed from S3 successfully', {
|
|
207
|
+
fileId: keys.ID,
|
|
208
|
+
bucket: bucket,
|
|
209
|
+
key: Key,
|
|
210
|
+
duration
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return content.Body
|
|
214
|
+
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const duration = Date.now() - startTime
|
|
217
|
+
const suggestion = error.name === 'NoSuchKey' ?
|
|
218
|
+
'File may have been deleted from S3 or URL is incorrect' :
|
|
219
|
+
error.name === 'AccessDenied' ?
|
|
220
|
+
'Check S3 bucket permissions and credentials' :
|
|
221
|
+
'Check S3 connectivity and configuration'
|
|
222
|
+
|
|
223
|
+
LOG.error(
|
|
224
|
+
'File download from S3 failed', error,
|
|
225
|
+
suggestion,
|
|
226
|
+
{ fileId: keys?.ID, bucket: bucket, attachmentName: attachments.name, duration })
|
|
227
|
+
|
|
228
|
+
throw error
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Deletes a file from S3 based on the provided key
|
|
234
|
+
* @param {string} Key - The key of the file to delete
|
|
235
|
+
* @returns {Promise} - Promise resolving when deletion is complete
|
|
236
|
+
*/
|
|
237
|
+
async delete(Key) {
|
|
238
|
+
const { client, bucket } = await this.retrieveClient()
|
|
239
|
+
LOG.debug(`[AWS S3] Executing delete for file ${Key} in bucket ${bucket}`)
|
|
240
|
+
|
|
241
|
+
const response = await client.send(
|
|
242
|
+
new DeleteObjectCommand({
|
|
243
|
+
Bucket: bucket,
|
|
244
|
+
Key,
|
|
245
|
+
})
|
|
246
|
+
)
|
|
247
|
+
return response.DeleteMarker
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
const { BlobServiceClient } = require('@azure/storage-blob')
|
|
2
|
+
const cds = require("@sap/cds")
|
|
3
|
+
const LOG = cds.log('attachments')
|
|
4
|
+
const utils = require('../lib/helper')
|
|
5
|
+
|
|
6
|
+
module.exports = class AzureAttachmentsService extends require("./object-store") {
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates or retrieves a cached Azure Blob Storage client for the given tenant
|
|
10
|
+
* @returns {Promise<{blobServiceClient: import('@azure/storage-blob').BlobServiceClient, containerClient: import('@azure/storage-blob').ContainerClient}>}
|
|
11
|
+
*/
|
|
12
|
+
async retrieveClient() {
|
|
13
|
+
try{
|
|
14
|
+
const container_name="aisp"
|
|
15
|
+
const sas_token="sp=rcwdl&st=2025-12-26T10:41:33Z&se=2030-12-25T18:56:33Z&spr=https&sv=2024-11-04&sr=c&sig=i5ENp1nzh0GrnNd%2FCAnkBBK3vCrHI8vCnHS9og%2F8P8I%3D"
|
|
16
|
+
const container_uri="https://aairdoc9262.blob.core.windows.net"
|
|
17
|
+
|
|
18
|
+
const blobServiceClient = new BlobServiceClient(container_uri + "?" + sas_token)
|
|
19
|
+
const containerClient = blobServiceClient.getContainerClient(container_name)
|
|
20
|
+
|
|
21
|
+
const newAzureCredentials = {
|
|
22
|
+
containerClient,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
this.clientsCache.set(newAzureCredentials)
|
|
26
|
+
|
|
27
|
+
LOG.debug('Azure Blob Storage client has been created successful', {
|
|
28
|
+
|
|
29
|
+
containerName: containerClient.containerName
|
|
30
|
+
})
|
|
31
|
+
return newAzureCredentials;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
LOG.error(
|
|
34
|
+
'Failed to create tenant-specific Azure Blob Storage client', error,
|
|
35
|
+
'Check Service Manager and Azure Blob Storage instance configuration',
|
|
36
|
+
{ tenantID })
|
|
37
|
+
throw error
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @inheritdoc
|
|
43
|
+
*/
|
|
44
|
+
async put(attachments, data) {
|
|
45
|
+
if (Array.isArray(data)) {
|
|
46
|
+
LOG.debug('Processing bulk file upload', {
|
|
47
|
+
fileCount: data.length,
|
|
48
|
+
filenames: data.map(d => d.filename)
|
|
49
|
+
})
|
|
50
|
+
return Promise.all(
|
|
51
|
+
data.map((d) => this.put(attachments, d))
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const startTime = Date.now()
|
|
56
|
+
|
|
57
|
+
LOG.debug('Starting file upload to Azure Blob Storage', {
|
|
58
|
+
attachmentEntity: attachments.name,
|
|
59
|
+
tenant: cds.context.tenant
|
|
60
|
+
})
|
|
61
|
+
const { containerClient } = await this.retrieveClient()
|
|
62
|
+
try {
|
|
63
|
+
let { content: _content, ...metadata } = data
|
|
64
|
+
const blobName = metadata.url
|
|
65
|
+
|
|
66
|
+
if (!blobName) {
|
|
67
|
+
LOG.error(
|
|
68
|
+
'File key/URL is required for Azure Blob Storage upload', null,
|
|
69
|
+
'Ensure attachment data includes a valid URL/key',
|
|
70
|
+
{ metadata: { ...metadata, content: !!_content } })
|
|
71
|
+
throw new Error('File key is required for upload')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!_content) {
|
|
75
|
+
LOG.error(
|
|
76
|
+
'File content is required for Azure Blob Storage upload', null,
|
|
77
|
+
'Ensure attachment data includes file content',
|
|
78
|
+
{ key: blobName, hasContent: !!_content })
|
|
79
|
+
throw new Error('File content is required for upload')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const blobClient = containerClient.getBlockBlobClient(blobName)
|
|
83
|
+
|
|
84
|
+
LOG.debug('Uploading file to Azure Blob Storage', {
|
|
85
|
+
containerName: containerClient.containerName,
|
|
86
|
+
blobName,
|
|
87
|
+
filename: metadata.filename,
|
|
88
|
+
contentSize: _content.length || _content.size || 'unknown'
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Handle different content types for update
|
|
92
|
+
let contentLength
|
|
93
|
+
const content = _content
|
|
94
|
+
if (Buffer.isBuffer(content)) {
|
|
95
|
+
contentLength = content.length
|
|
96
|
+
} else if (content && typeof content.length === 'number') {
|
|
97
|
+
contentLength = content.length
|
|
98
|
+
} else if (content && typeof content.size === 'number') {
|
|
99
|
+
contentLength = content.size
|
|
100
|
+
} else {
|
|
101
|
+
// Convert to buffer if needed
|
|
102
|
+
const chunks = []
|
|
103
|
+
for await (const chunk of content) {
|
|
104
|
+
chunks.push(chunk)
|
|
105
|
+
}
|
|
106
|
+
_content = Buffer.concat(chunks)
|
|
107
|
+
contentLength = _content.length
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The file upload has to be done first, so super.put can compute the hash and trigger malware scan
|
|
111
|
+
await blobClient.upload(_content, contentLength)
|
|
112
|
+
await super.put(attachments, metadata)
|
|
113
|
+
|
|
114
|
+
const duration = Date.now() - startTime
|
|
115
|
+
LOG.debug('File upload to Azure Blob Storage completed successfully', {
|
|
116
|
+
filename: metadata.filename,
|
|
117
|
+
fileId: metadata.ID,
|
|
118
|
+
containerName: containerClient.containerName,
|
|
119
|
+
blobName,
|
|
120
|
+
duration
|
|
121
|
+
})
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const duration = Date.now() - startTime
|
|
124
|
+
LOG.error(
|
|
125
|
+
'File upload to Azure Blob Storage failed', err,
|
|
126
|
+
'Check Azure Blob Storage connectivity, credentials, and container permissions',
|
|
127
|
+
{ filename: data?.filename, fileId: data?.ID, containerName: containerClient.containerName, blobName: data?.url, duration })
|
|
128
|
+
throw err
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @inheritdoc
|
|
134
|
+
*/
|
|
135
|
+
async get(attachments, keys) {
|
|
136
|
+
const startTime = Date.now()
|
|
137
|
+
LOG.debug('Starting stream from Azure Blob Storage', {
|
|
138
|
+
attachmentEntity: attachments.name,
|
|
139
|
+
keys,
|
|
140
|
+
tenant: cds.context.tenant
|
|
141
|
+
})
|
|
142
|
+
const { containerClient } = await this.retrieveClient()
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
LOG.debug('Fetching attachment metadata', { keys })
|
|
146
|
+
const response = await SELECT.from(attachments, keys).columns("url")
|
|
147
|
+
|
|
148
|
+
if (!response?.url) {
|
|
149
|
+
LOG.warn(
|
|
150
|
+
'File URL not found in database', null,
|
|
151
|
+
'Check if the attachment exists and has been properly uploaded',
|
|
152
|
+
{ keys, hasResponse: !!response })
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
LOG.debug('Streaming file from Azure Blob Storage', {
|
|
157
|
+
containerName: containerClient.containerName,
|
|
158
|
+
fileId: keys.ID,
|
|
159
|
+
blobName: response.url
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const blobClient = containerClient.getBlockBlobClient(response.url)
|
|
163
|
+
const downloadResponse = await blobClient.download()
|
|
164
|
+
|
|
165
|
+
const duration = Date.now() - startTime
|
|
166
|
+
LOG.debug('File streamed from Azure Blob Storage successfully', {
|
|
167
|
+
fileId: keys.ID,
|
|
168
|
+
duration
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return downloadResponse.readableStreamBody
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const duration = Date.now() - startTime
|
|
174
|
+
const suggestion = error.code === 'BlobNotFound' ?
|
|
175
|
+
'File may have been deleted from Azure Blob Storage or URL is incorrect' :
|
|
176
|
+
error.code === 'AuthenticationFailed' ?
|
|
177
|
+
'Check Azure Blob Storage credentials and SAS token' :
|
|
178
|
+
'Check Azure Blob Storage connectivity and configuration'
|
|
179
|
+
|
|
180
|
+
LOG.error(
|
|
181
|
+
'File download from Azure Blob Storage failed', error,
|
|
182
|
+
suggestion,
|
|
183
|
+
{ fileId: keys?.ID, containerName: containerClient.containerName, attachmentName: attachments.name, duration })
|
|
184
|
+
|
|
185
|
+
throw error
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Deletes a file from Azure Blob Storage
|
|
191
|
+
* @param {string} Key - The key of the file to delete
|
|
192
|
+
* @returns {Promise} - Promise resolving when deletion is complete
|
|
193
|
+
*/
|
|
194
|
+
async delete(blobName) {
|
|
195
|
+
const { containerClient } = await this.retrieveClient()
|
|
196
|
+
LOG.debug(`[Azure] Executing delete for file ${blobName} in bucket ${containerClient.containerName}`)
|
|
197
|
+
|
|
198
|
+
const blobClient = containerClient.getBlockBlobClient(blobName)
|
|
199
|
+
const response = await blobClient.delete()
|
|
200
|
+
return response._response.status === 202
|
|
201
|
+
}
|
|
202
|
+
}
|