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/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,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
|