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
package/srv/basic.js ADDED
@@ -0,0 +1,331 @@
1
+ const cds = require('@sap/cds')
2
+ const LOG = cds.log('attachments')
3
+ const { computeHash, traverseEntity } = require('../lib/helper')
4
+
5
+ class AttachmentsService extends cds.Service {
6
+
7
+ init() {
8
+ this.on('DeleteAttachment', async msg => {
9
+ await this.delete(msg.data.url, msg.data.target)
10
+ })
11
+
12
+ this.on('DeleteInfectedAttachment', async msg => {
13
+ const { target, hash, keys } = msg.data
14
+ const attachment = await SELECT.one.from(target).where(Object.assign({ hash }, keys)).columns('url')
15
+ if (attachment) { //Might happen that a draft object is the target
16
+ await this.delete(attachment.url, target)
17
+ } else {
18
+ LOG.warn(`Cannot delete malware file with the hash ${hash} for attachment ${target}, keys: ${keys}`)
19
+ }
20
+ })
21
+ return super.init()
22
+ }
23
+
24
+ /**
25
+ * Uploads attachments to the database and initiates malware scans for database-stored files
26
+ * @param {cds.Entity} attachments - Attachments entity definition
27
+ * @param {Array|Object} data - The attachment data to be uploaded
28
+ * @returns {Promise<Array>} - Result of the upsert operation
29
+ */
30
+ async put(attachments, data) {
31
+ if (!Array.isArray(data)) {
32
+ data = [data]
33
+ }
34
+
35
+ LOG.debug('Starting database attachment upload', {
36
+ attachmentEntity: attachments.name,
37
+ fileCount: data.length,
38
+ filenames: data.map((d) => d.filename || 'unknown'),
39
+ })
40
+
41
+ let res
42
+
43
+ try {
44
+ res = await Promise.all(
45
+ data.map(async (d) => {
46
+ const res = await UPSERT(d).into(attachments)
47
+ const attachmentForHash = await this.get(attachments, { ID: d.ID })
48
+ // If this is just the PUT for metadata, there is not yet any file to retrieve
49
+ if (attachmentForHash) {
50
+ const hash = await computeHash(attachmentForHash)
51
+ await this.update(attachments, { ID: d.ID }, { hash })
52
+ }
53
+ return res
54
+ })
55
+ )
56
+
57
+ LOG.debug('Attachment records upserted to database successfully', {
58
+ attachmentEntity: attachments.name,
59
+ recordCount: data.length
60
+ })
61
+
62
+ } catch (error) {
63
+ LOG.error(
64
+ 'Failed to upsert attachment records to database', error,
65
+ 'Check database connectivity and attachment entity configuration',
66
+ { attachmentEntity: attachments.name, recordCount: data.length, errorMessage: error.message })
67
+ throw error
68
+ }
69
+
70
+ // Initiate malware scanning for database-stored files
71
+ LOG.debug('Initiating malware scans for database-stored files', {
72
+ fileCount: data.length,
73
+ fileIds: data.map(d => d.ID)
74
+ })
75
+
76
+ const MalwareScanner = await cds.connect.to('malwareScanner')
77
+ await Promise.all(
78
+ data.map(async (d) => {
79
+ await MalwareScanner.emit('ScanAttachmentsFile', { target: attachments.name, keys: { ID: d.ID } })
80
+ })
81
+ )
82
+
83
+ return res
84
+ }
85
+
86
+ /**
87
+ * Registers attachment handlers for the given service and entity
88
+ * @param {cds.Entity} attachments - The attachment service instance
89
+ * @param {string} keys - The keys to identify the attachment
90
+ * @param {import('@sap/cds').Request} req - The request object
91
+ * @returns {Buffer|Stream|null} - The content of the attachment or null if not found
92
+ */
93
+ async get(attachments, keys) {
94
+ LOG.debug("Downloading attachment for", {
95
+ attachmentName: attachments.name,
96
+ attachmentKeys: keys
97
+ })
98
+ let result = await SELECT.from(attachments, keys).columns("content")
99
+ if (!result && attachments.isDraft) {
100
+ attachments = attachments.actives
101
+ result = await SELECT.from(attachments, keys).columns("content")
102
+ }
103
+ return (result?.content) ? result.content : null
104
+ }
105
+ /**
106
+ * Returns a handler to copy updated attachments content from draft to active / object store
107
+ * @param {cds.Entity} attachments - Attachments entity definition
108
+ * @returns {Function} - The draft save handler function
109
+ */
110
+ draftSaveHandler(attachments) {
111
+ const queryFields = this.getFields(attachments)
112
+
113
+ return async (_, req) => {
114
+ // The below query loads the attachments into streams
115
+ const cqn = SELECT(queryFields)
116
+ .from(attachments.drafts)
117
+ .where([
118
+ ...req.subject.ref[0].where.map((x) =>
119
+ x.ref ? { ref: ["up_", ...x.ref] } : x
120
+ )
121
+ // NOTE: needs skip LargeBinary fix to Lean Draft
122
+ ])
123
+ cqn.where({ content: { '!=': null } })
124
+ const draftAttachments = await cqn
125
+
126
+ if (draftAttachments.length)
127
+ await this.put(attachments, draftAttachments)
128
+ }
129
+ }
130
+ /**
131
+ * Returns the fields to be selected from Attachments entity definition
132
+ * including the association keys if Attachments entity definition is associated to another entity
133
+ * @param {cds.Entity} attachments - Attachments entity definition
134
+ * @returns {Array} - Array of fields to be selected
135
+ */
136
+ getFields(attachments) {
137
+ const attachmentFields = ["filename", "mimeType", "content", "url", "ID"]
138
+ const { up_ } = attachments.keys
139
+ if (up_)
140
+ return up_.keys
141
+ .map((k) => "up__" + k.ref[0])
142
+ .concat(...attachmentFields)
143
+ .map((k) => ({ ref: [k] }))
144
+ else return Object.keys(attachments.keys)
145
+ }
146
+
147
+ /**
148
+ * Registers handlers for attachment entities in the service
149
+ * @param {cds.Service} srv - The CDS service instance
150
+ */
151
+ registerHandlers(srv) {
152
+ if (!cds.env.fiori.move_media_data_in_db) {
153
+ srv.after("SAVE", async function saveDraftAttachments(res, req) {
154
+ if (
155
+ req.target.isDraft ||
156
+ !req.target.drafts ||
157
+ !req.target._attachments.hasAttachmentsComposition ||
158
+ !req.target._attachments.attachmentCompositions
159
+ ) {
160
+ return
161
+ }
162
+ await Promise.all(
163
+ req.target._attachments.attachmentCompositions.map(attachmentsEle =>{
164
+ const target = traverseEntity(req.target, attachmentsEle)
165
+ if (!target) {
166
+ LOG.error(`Could not resolve target for attachment composition: ${attachmentsEle}`)
167
+ return
168
+ }
169
+ return this.draftSaveHandler(target)(res, req)
170
+ })
171
+ )
172
+ }.bind(this))
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Updates attachment metadata in the database
178
+ * @param {cds.Entity} Attachments - Attachments entity definition
179
+ * @param {string} key - The key of the attachment to update
180
+ * @param {*} data - The data to update the attachment with
181
+ * @returns {Promise} - Result of the update operation
182
+ */
183
+ async update(Attachments, key, data) {
184
+ LOG.debug("Updating attachment for", {
185
+ attachmentName: Attachments.name,
186
+ attachmentKey: key
187
+ })
188
+
189
+ return await UPDATE(Attachments, key).with(data)
190
+ }
191
+
192
+ /**
193
+ * Retrieves the malware scan status of an attachment
194
+ * @param {cds.Entity} Attachments - Attachments entity definition
195
+ * @param {string} key - The key of the attachment to retrieve the status for
196
+ * @returns {string} - The malware scan status of the attachment
197
+ */
198
+ async getStatus(Attachments, key) {
199
+ const result = await SELECT.from(Attachments, key).columns('status')
200
+ return result?.status
201
+ }
202
+
203
+ /**
204
+ * Registers attachment handlers for the given service and entity
205
+ * @param {*} records - The records to process
206
+ * @param {import('@sap/cds').Request} req - The request object
207
+ */
208
+ async deleteAttachmentsWithKeys(records, req) {
209
+ req.attachmentsToDelete?.forEach(async (attachment) => {
210
+ if (attachment.url) {
211
+ const attachmentsSrv = await cds.connect.to('attachments')
212
+ await attachmentsSrv.emit('DeleteAttachment', { url: attachment.url, target: attachment.target })
213
+ } else {
214
+ LOG.warn(`Attachment cannot be deleted because URL is missing`, attachment)
215
+ }
216
+ })
217
+ }
218
+
219
+ /**
220
+ * Traverses nested data by a given path array.
221
+ * @param {Object} root - The root object or array to traverse.
222
+ * @param {Array} path - The array of keys representing the path.
223
+ * @returns {*} - The value found at the path, or [] if not found.
224
+ */
225
+ traverseDataByPath(root, path) {
226
+ let current = root
227
+ for (let i = 0; i < path.length; i++) {
228
+ const part = path[i]
229
+ if (Array.isArray(current)) {
230
+ return current.flatMap(item => this.traverseDataByPath(item, path.slice(i)))
231
+ }
232
+ if (!current || !(part in current)) return []
233
+ current = current[part]
234
+ }
235
+ return current
236
+ }
237
+
238
+ /**
239
+ * Registers attachment handlers for the given service and entity
240
+ * @param {import('@sap/cds').Request} req - The request object
241
+ */
242
+ async attachDeletionData(req) {
243
+ const attachmentCompositions = req?.target?._attachments.attachmentCompositions
244
+ if (attachmentCompositions.length > 0) {
245
+ const diffData = await req.diff()
246
+ if (!diffData || Object.keys(diffData).length === 0) {
247
+ return
248
+ }
249
+ const queries = []
250
+ const queryTargets = []
251
+ for (const attachmentsComp of attachmentCompositions) {
252
+ const leaf = this.traverseDataByPath(diffData, attachmentsComp)
253
+ const deletedAttachments = Array.isArray(leaf) ? leaf.filter(obj => obj._op === "delete").map(obj => obj.ID) : []
254
+
255
+ const entityTarget = traverseEntity(req.target, attachmentsComp)
256
+ if (deletedAttachments.length) {
257
+ queries.push(
258
+ SELECT.from(entityTarget).columns("url").where({ ID: { in: [...deletedAttachments] } })
259
+ )
260
+ queryTargets.push(entityTarget.name)
261
+ }
262
+ }
263
+ if (queries.length > 0) {
264
+ const attachmentsToDelete = (await Promise.all(queries)).reduce((acc, attachments, idx) => {
265
+ attachments.forEach(attachment => attachment.target = queryTargets[idx])
266
+ acc = acc.concat(attachments)
267
+ return acc;
268
+ }, [])
269
+ if (attachmentsToDelete.length > 0) {
270
+ req.attachmentsToDelete = attachmentsToDelete
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Registers attachment handlers for the given service and entity
278
+ * @param {{draftEntity: string, activeEntity:cds.Entity, id:string}} param0 - The service and entities
279
+ * @returns
280
+ */
281
+ async getAttachmentsToDelete({ draftEntity, activeEntity, whereXpr }) {
282
+ const [draftAttachments, activeAttachments] = await Promise.all([
283
+ SELECT.from(draftEntity).columns("url").where(whereXpr),
284
+ SELECT.from(activeEntity).columns("url").where(whereXpr)
285
+ ])
286
+
287
+ const activeUrls = new Set(activeAttachments.map(a => a.url))
288
+ return draftAttachments
289
+ .filter(({ url }) => !activeUrls.has(url))
290
+ .map(({ url }) => ({ url, target: draftEntity.name }))
291
+ }
292
+
293
+ /**
294
+ * Add draft attachment deletion data to the request
295
+ * @param {import('@sap/cds').Request} req - The request object
296
+ */
297
+ async attachDraftDeletionData(req) {
298
+ const draftEntity = cds.model.definitions[req?.target?.name]
299
+ const name = req?.target?.name
300
+ const activeEntity = name ? cds.model.definitions?.[name.split(".").slice(0, -1).join(".")] : undefined
301
+
302
+ if (!draftEntity || !activeEntity) return
303
+
304
+ const diff = await req.diff()
305
+ if (diff._op !== "delete" || !diff.ID) return
306
+
307
+ const attachmentsToDelete = await this.getAttachmentsToDelete({
308
+ draftEntity,
309
+ activeEntity,
310
+ whereXpr: { ID: diff.ID }
311
+ })
312
+
313
+ if (attachmentsToDelete.length) {
314
+ req.attachmentsToDelete = attachmentsToDelete
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Deletes a file from the database. Does not delete metadata
320
+ * @param {string} url - The url of the file to delete
321
+ * @returns {Promise} - Promise resolving when deletion is complete
322
+ */
323
+ async delete(url, target) {
324
+ return await UPDATE(target).where({ url }).with({ content: null })
325
+ }
326
+ }
327
+
328
+
329
+ AttachmentsService.prototype._is_queueable = true
330
+
331
+ module.exports = AttachmentsService
package/srv/gcp.js ADDED
@@ -0,0 +1,226 @@
1
+ const { Storage } = require('@google-cloud/storage')
2
+ const cds = require("@sap/cds")
3
+ const LOG = cds.log('attachments')
4
+ const utils = require('../lib/helper')
5
+
6
+ module.exports = class GoogleAttachmentsService extends require("./object-store") {
7
+
8
+ /**
9
+ * Creates or retrieves a cached Google Cloud Platform client for the given tenant
10
+ * @returns {Promise<{bucket: import('@google-cloud/storage').Bucket}>}
11
+ */
12
+ async retrieveClient() {
13
+ const tenantID = this.separateObjectStore ? cds.context.tenant : 'shared'
14
+ LOG.debug('Retrieving tenant-specific Google Cloud Platform client', { tenantID })
15
+ const existingClient = this.clientsCache.get(tenantID)
16
+ if (existingClient) {
17
+ LOG.debug('Using cached GCP client', {
18
+ tenantID,
19
+ bucketName: existingClient.bucket.name
20
+ })
21
+ return existingClient
22
+ }
23
+
24
+ try {
25
+ LOG.debug(`Fetching object store credentials for tenant ${tenantID}. Using ${this.separateObjectStore ? 'shared' : 'tenant-specific'} object store.`)
26
+ const credentials = this.separateObjectStore
27
+ ? (await utils.getObjectStoreCredentials(tenantID))?.credentials
28
+ : cds.env.requires?.objectStore?.credentials
29
+
30
+ if (!credentials) {
31
+ throw new Error("SAP Object Store instance is not bound.")
32
+ }
33
+
34
+ // Validate required credentials
35
+ const requiredFields = ['bucket', 'projectId', 'base64EncodedPrivateKeyData']
36
+ const missingFields = requiredFields.filter(field => !credentials[field])
37
+
38
+ if (missingFields.length > 0) {
39
+ if (credentials.access_key_id) {
40
+ throw new Error('AWS S3 credentials found where Google Cloud Platform credentials expected, please check your service bindings.')
41
+ } else if (credentials.container_name) {
42
+ throw new Error('Azure credentials found where Google Cloud Platform credentials expected, please check your service bindings.')
43
+ }
44
+ throw new Error(`Missing Google Cloud Platform credentials: ${missingFields.join(', ')}`)
45
+ }
46
+
47
+ LOG.debug('Creating Google Cloud Platform client for tenant', {
48
+ tenantID,
49
+ bucketName: credentials.bucket
50
+ })
51
+
52
+ const storageClient = new Storage({
53
+ projectId: credentials.projectId,
54
+ credentials: JSON.parse(Buffer.from(credentials.base64EncodedPrivateKeyData, 'base64').toString('utf8'))
55
+ })
56
+
57
+ const newGoogleClient = {
58
+ bucket: storageClient.bucket(credentials.bucket),
59
+ }
60
+
61
+ this.clientsCache.set(tenantID, newGoogleClient)
62
+
63
+ LOG.debug('Google Cloud Platform client has been created successful', {
64
+ tenantID,
65
+ bucketName: newGoogleClient.bucket.name
66
+ })
67
+
68
+ return newGoogleClient
69
+
70
+ } catch (error) {
71
+ LOG.error(
72
+ 'Failed to create tenant-specific Google Cloud Platform client', error,
73
+ 'Check Service Manager and Google Cloud Platform instance configuration',
74
+ { tenantID })
75
+ throw error
76
+ }
77
+ }
78
+
79
+ /**
80
+ * @inheritdoc
81
+ */
82
+ async put(attachments, data) {
83
+ if (Array.isArray(data)) {
84
+ LOG.debug('Processing bulk file upload', {
85
+ fileCount: data.length,
86
+ filenames: data.map(d => d.filename)
87
+ })
88
+ return Promise.all(
89
+ data.map((d) => this.put(attachments, d))
90
+ )
91
+ }
92
+
93
+ const startTime = Date.now()
94
+
95
+ LOG.debug('Starting file upload to Google Cloud Platform', {
96
+ attachmentEntity: attachments.name,
97
+ tenant: cds.context.tenant
98
+ })
99
+
100
+ const { bucket } = await this.retrieveClient()
101
+
102
+ try {
103
+ const { content, ...metadata } = data
104
+ const blobName = metadata.url
105
+
106
+ if (!blobName) {
107
+ LOG.error(
108
+ 'File key/URL is required for Google Cloud Platform upload', null,
109
+ 'Ensure attachment data includes a valid URL/key',
110
+ { metadata: { ...metadata, content: !!content } })
111
+ throw new Error('File key is required for upload')
112
+ }
113
+
114
+ if (!content) {
115
+ LOG.error(
116
+ 'File content is required for Google Cloud Platform upload', null,
117
+ 'Ensure attachment data includes file content',
118
+ { key: blobName, hasContent: !!content })
119
+ throw new Error('File content is required for upload')
120
+ }
121
+
122
+ const file = bucket.file(blobName)
123
+
124
+ LOG.debug('Uploading file to Google Cloud Platform', {
125
+ bucketName: bucket.name,
126
+ blobName,
127
+ filename: metadata.filename,
128
+ contentSize: content.length || content.size || 'unknown'
129
+ })
130
+
131
+ // The file upload has to be done first, so super.put can compute the hash and trigger malware scan
132
+ await file.save(content)
133
+ await super.put(attachments, metadata)
134
+
135
+ const duration = Date.now() - startTime
136
+ LOG.debug('File upload to Google Cloud Platform completed successfully', {
137
+ filename: metadata.filename,
138
+ fileId: metadata.ID,
139
+ bucketName: bucket.name,
140
+ blobName,
141
+ duration
142
+ })
143
+ } catch (err) {
144
+ const duration = Date.now() - startTime
145
+ LOG.error(
146
+ 'File upload to Google Cloud Platform failed', err,
147
+ 'Check Google Cloud Platform connectivity, credentials, and container permissions',
148
+ { filename: data?.filename, fileId: data?.ID, bucketName: bucket.name, blobName: data?.url, duration })
149
+ throw err
150
+ }
151
+ }
152
+
153
+ /**
154
+ * @inheritdoc
155
+ */
156
+ async get(attachments, keys) {
157
+ const startTime = Date.now()
158
+ LOG.debug('Starting stream from Google Cloud Platform', {
159
+ attachmentEntity: attachments.name,
160
+ keys,
161
+ tenant: cds.context.tenant
162
+ })
163
+ const { bucket } = await this.retrieveClient()
164
+
165
+ try {
166
+ LOG.debug('Fetching attachment metadata', { keys })
167
+ const response = await SELECT.from(attachments, keys).columns("url")
168
+
169
+ if (!response?.url) {
170
+ LOG.warn(
171
+ 'File URL not found in database', null,
172
+ 'Check if the attachment exists and has been properly uploaded',
173
+ { keys, hasResponse: !!response })
174
+ return null
175
+ }
176
+
177
+ const blobName = response.url
178
+
179
+ LOG.debug('Streaming file from Google Cloud Platform', {
180
+ bucketName: bucket.name,
181
+ blobName
182
+ })
183
+
184
+ const file = bucket.file(blobName)
185
+ const readStream = await file.createReadStream()
186
+
187
+ const duration = Date.now() - startTime
188
+ LOG.debug('File streamed from Google Cloud Platform successfully', {
189
+ fileId: keys.ID,
190
+ bucketName: bucket.name,
191
+ blobName,
192
+ duration
193
+ })
194
+
195
+ return readStream
196
+ } catch (error) {
197
+ const duration = Date.now() - startTime
198
+ const suggestion = error.code === 'BlobNotFound' ?
199
+ 'File may have been deleted from Google Cloud Platform or URL is incorrect' :
200
+ error.code === 'AuthenticationFailed' ?
201
+ 'Check Google Cloud Platform credentials and SAS token' :
202
+ 'Check Google Cloud Platform connectivity and configuration'
203
+
204
+ LOG.error(
205
+ 'File download from Google Cloud Platform failed', error,
206
+ suggestion,
207
+ { fileId: keys?.ID, bucketName: bucket.name, attachmentName: attachments.name, duration })
208
+
209
+ throw error
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Deletes a file from Google Cloud Platform
215
+ * @param {string} Key - The key of the file to delete
216
+ * @returns {Promise} - Promise resolving when deletion is complete
217
+ */
218
+ async delete(blobName) {
219
+ const { bucket } = await this.retrieveClient()
220
+ LOG.debug(`[GCP] Executing delete for file ${blobName} in bucket ${bucket.name}`)
221
+
222
+ const file = bucket.file(blobName)
223
+ const response = await file.delete()
224
+ return response[0]?.statusCode === 204
225
+ }
226
+ }
@@ -0,0 +1,3 @@
1
+ using {malwareScanner} from './malwareScanner';
2
+
3
+ annotate malwareScanner with @impl : './malwareScanner-mocked';
@@ -0,0 +1,127 @@
1
+ const cds = require('@sap/cds')
2
+ const LOG = cds.log('attachments')
3
+ const crypto = require('crypto');
4
+
5
+ class MockedMalwareScanner extends cds.ApplicationService {
6
+ init() {
7
+ this.on('ScanAttachmentsFile', this.scanAttachmentsFile)
8
+ this.on('scan', this.scanFile)
9
+
10
+ return super.init()
11
+ }
12
+
13
+ /**
14
+ * Scans the "content" property of the given entity, specified by the CSN name (target) and
15
+ * the keys object, for malware.
16
+ * Updates the status property on the given entity to reflect if the file is clean.
17
+ * Triggers an attachments service delete event to remove the malware.
18
+ * @param {{data: {target: string, keys: object}}} msg The target is the CSN Entity name, which is used to lookup entity via cds.model.definitions[<target>].
19
+ */
20
+ async scanAttachmentsFile(msg) {
21
+ const { target, keys } = msg.data
22
+ const scanEnabled = cds.env.requires?.attachments?.scan ?? true
23
+ if (!scanEnabled) {
24
+ LOG.warn(`Malware scanner is disabled! Please consider enabling it`)
25
+ return
26
+ }
27
+
28
+ LOG.debug(`Initiating malware scan request for ${target}, ${JSON.stringify(keys)} `)
29
+
30
+ const AttachmentsSrv = await cds.connect.to("attachments")
31
+
32
+ const model = cds.context.model ?? cds.model
33
+ //Make sure its the active target
34
+ const _target = model.definitions[target].actives ?? model.definitions[target]
35
+
36
+ if (!_target) {
37
+ LOG.error(`Could not scan ${target}, ${JSON.stringify(keys)} for malware as no CSN entity definition was found for the name!`)
38
+ return
39
+ }
40
+
41
+ await this.updateStatus(_target, keys, "Scanning")
42
+
43
+ LOG.debug(`Fetching file content for scanning for ${target}, ${JSON.stringify(keys)}`)
44
+ const contentStream = await AttachmentsSrv.get(model.definitions[target], keys)
45
+
46
+ if (!contentStream) {
47
+ LOG.warn(`Cannot fetch file content for malware scanning for ${target}, ${JSON.stringify(keys)}! Check if the file exists.`)
48
+ await this.updateStatus(_target, keys, "Failed")
49
+ return
50
+ }
51
+
52
+ let res;
53
+ try {
54
+ res = await this.scan(contentStream)
55
+ } catch (err) {
56
+ LOG.error(`Request to malware scanner failed for ${target}, ${JSON.stringify(keys)}`, err)
57
+ await this.updateStatus(target, keys, "Failed")
58
+ throw err;
59
+ }
60
+
61
+ let status = res.isMalware ? "Infected" : "Clean"
62
+ const hash = res.hash
63
+
64
+ if (status === "Infected") {
65
+ LOG.warn(`Malware scan completed for ${target}, ${keys} - file is infected. Triggering delete of the file.`)
66
+ await AttachmentsSrv.emit('DeleteInfectedAttachment', { target: target, keys, hash })
67
+ if (_target.drafts?.name === target) {
68
+ await AttachmentsSrv.emit('DeleteInfectedAttachment', { target: _target.drafts.name, keys, hash })
69
+ }
70
+ } else {
71
+ LOG.debug(`Malware scan completed for ${target}, ${keys} - file is clean`)
72
+ }
73
+
74
+ // Assign hash as another condition to ensure the correct file is marked as fine
75
+ await this.updateStatus(_target, Object.assign({ hash }, keys), status)
76
+ }
77
+
78
+ /**
79
+ * Mocks scanning the file. Always returns true!
80
+ * @param {import('@sap/cds').Request} the request object
81
+ */
82
+ async scanFile(req) {
83
+ const { file } = req.data;
84
+
85
+ LOG.info(`Setting scan status to Clean (development mode)!`)
86
+
87
+ let fileSize = 0;
88
+ const hash = crypto.createHash('sha256');
89
+
90
+ if (file) {
91
+ for await (const chunk of file) {
92
+ fileSize += chunk.length;
93
+ hash.update(chunk);
94
+ }
95
+ }
96
+
97
+ const sha256Hash = hash.digest('hex');
98
+ file?.destroy();
99
+
100
+ return {
101
+ isMalware: false,
102
+ encryptedContentDetected: false,
103
+ scanSize: fileSize,
104
+ finding: undefined,
105
+ mimeType: 'empty',
106
+ hash: sha256Hash,
107
+ };
108
+ }
109
+
110
+ async getFileInformation(_target, keys) {
111
+ const dbResult = await SELECT.one.from(_target.drafts || _target).columns('mimeType').where(keys)
112
+ return dbResult
113
+ }
114
+
115
+ async updateStatus(_target, keys, status) {
116
+ if (_target.drafts) {
117
+ await Promise.all([
118
+ UPDATE.entity(_target).where(keys).set({ status }),
119
+ UPDATE.entity(_target.drafts).where(keys).set({ status })
120
+ ])
121
+ } else {
122
+ await UPDATE.entity(_target).where(keys).set({ status })
123
+ }
124
+ }
125
+ }
126
+
127
+ module.exports = MockedMalwareScanner