-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathbasic.js
More file actions
453 lines (408 loc) · 14.5 KB
/
basic.js
File metadata and controls
453 lines (408 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
const cds = require("@sap/cds")
const LOG = cds.log("attachments")
const { computeHash, traverseEntity } = require("../lib/helper")
class AttachmentsService extends cds.Service {
init() {
this.on("DeleteAttachment", async (msg) => {
await this.delete(msg.data.url, msg.data.target)
})
this.on("DeleteInfectedAttachment", async (msg) => {
const { target, hash, keys } = msg.data
const attachment = await SELECT.one
.from(target)
.where(Object.assign({ hash }, keys))
.columns("url")
if (attachment && attachment.url) {
//Might happen that a draft object is the target
try {
const url = attachment.url
const activeEntity = cds.model.definitions[target]
const draftEntity = target
? cds.model.definitions?.[target + ".draft"]
: undefined
await UPDATE(activeEntity)
.where({ url: url })
.set({ content: null, url: null, hash: null })
if (draftEntity) {
await UPDATE(draftEntity)
.where({ url: url })
.set({ content: null, url: null, hash: null })
}
await this.delete(url, target)
} catch (error) {
LOG.error(`Failed to delete infected file from object store`, error)
}
} else {
LOG.warn(
`Cannot delete malware file with the hash ${hash} for attachment ${target}, keys: ${keys}`,
)
}
})
return super.init()
}
/**
* Uploads attachments to the database and initiates malware scans for database-stored files
* @param {import('@sap/cds').Entity} attachments - Attachments entity definition
* @param {Array|Object} data - The attachment data to be uploaded
* @returns {Promise<Array>} - Result of the upsert operation
*/
async put(attachments, data) {
if (!Array.isArray(data)) {
data = [data]
}
// Check if an attachment with this ID already has content
const existing = await SELECT.one
.from(attachments)
.where({ ID: { in: data.map((d) => d.ID) }, content: { "!=": null } })
if (existing) {
const error = new Error("Attachment already exists")
error.status = 409
throw error
}
LOG.debug("Starting database attachment upload", {
attachmentEntity: attachments.name,
fileCount: data.length,
filenames: data.map((d) => d.filename || "unknown"),
})
let res
try {
res = await Promise.all(
data.map(async (d) => {
const res = await UPSERT(d).into(attachments)
const attachmentForHash = await this.get(attachments, { ID: d.ID })
// If this is just the PUT for metadata, there is not yet any file to retrieve
if (attachmentForHash) {
const hash = await computeHash(attachmentForHash)
await this.update(attachments, { ID: d.ID }, { hash })
}
return res
}),
)
LOG.debug("Attachment records upserted to database successfully", {
attachmentEntity: attachments.name,
recordCount: data.length,
})
} catch (error) {
LOG.error(
"Failed to upsert attachment records to database",
error,
"Check database connectivity and attachment entity configuration",
{
attachmentEntity: attachments.name,
recordCount: data.length,
errorMessage: error.message,
},
)
throw error
}
// Initiate malware scanning for database-stored files
LOG.debug("Initiating malware scans for database-stored files", {
fileCount: data.length,
fileIds: data.map((d) => d.ID),
})
const MalwareScanner = await cds.connect.to("malwareScanner")
await Promise.all(
data.map(async (d) => {
await MalwareScanner.emit("ScanAttachmentsFile", {
target: attachments.name,
keys: { ID: d.ID },
})
}),
)
return res
}
/**
* Registers attachment handlers for the given service and entity
* @param {import('@sap/cds').Entity} attachments - The attachment service instance
* @param {string} keys - The keys to identify the attachment
* @param {import('@sap/cds').Request} req - The request object
* @returns {Buffer|Stream|null} - The content of the attachment or null if not found
*/
async get(attachments, keys) {
LOG.debug("Downloading attachment for", {
attachmentName: attachments.name,
attachmentKeys: keys,
})
let result = await SELECT.from(attachments, keys).columns("content")
if (!result && attachments.isDraft) {
attachments = attachments.actives
result = await SELECT.from(attachments, keys).columns("content")
}
return result?.content ? result.content : null
}
/**
* Returns a handler to copy updated attachments content from draft to active / object store
* @param {import('@sap/cds').Entity} attachments - Attachments entity definition
* @returns {Function} - The draft save handler function
*/
draftSaveHandler(attachments) {
const queryFields = this.getFields(attachments)
return async (_, req) => {
// The below query loads the attachments into streams
const cqn = SELECT(queryFields)
.from(attachments.drafts)
.where([
...req.subject.ref[0].where.map((x) =>
x.ref ? { ref: ["up_", ...x.ref] } : x,
),
// NOTE: needs skip LargeBinary fix to Lean Draft
])
cqn.where({ content: { "!=": null } })
const draftAttachments = await cqn
if (draftAttachments.length) await this.put(attachments, draftAttachments)
}
}
/**
* Returns the fields to be selected from Attachments entity definition
* including the association keys if Attachments entity definition is associated to another entity
* @param {import('@sap/cds').Entity} attachments - Attachments entity definition
* @returns {Array} - Array of fields to be selected
*/
getFields(attachments) {
const attachmentFields = ["filename", "mimeType", "content", "url", "ID"]
const { up_ } = attachments.keys
if (up_)
return up_.keys
.map((k) => "up__" + k.ref[0])
.concat(...attachmentFields)
.map((k) => ({ ref: [k] }))
else return Object.keys(attachments.keys)
}
/**
* Registers handlers for attachment entities in the service
* @param {cds.Service} srv - The CDS service instance
*/
registerHandlers(srv) {
if (!cds.env.fiori.move_media_data_in_db) {
srv.after(
"SAVE",
async function saveDraftAttachments(res, req) {
if (
req.target.isDraft ||
!req.target.drafts ||
!req.target._attachments.hasAttachmentsComposition ||
!req.target._attachments.attachmentCompositions
) {
return
}
await Promise.all(
req.target._attachments.attachmentCompositions.map(
(attachmentsEle) => {
const target = traverseEntity(req.target, attachmentsEle)
if (!target) {
LOG.error(
`Could not resolve target for attachment composition: ${attachmentsEle}`,
)
return
}
return this.draftSaveHandler(target)(res, req)
},
),
)
}.bind(this),
)
}
}
/**
* Updates attachment metadata in the database
* @param {import('@sap/cds').Entity} Attachments - Attachments entity definition
* @param {string} key - The key of the attachment to update
* @param {*} data - The data to update the attachment with
* @returns {Promise} - Result of the update operation
*/
async update(Attachments, key, data) {
LOG.debug("Updating attachment for", {
attachmentName: Attachments.name,
attachmentKey: key,
})
return await UPDATE(Attachments, key).with(data)
}
/**
* Retrieves the malware scan status of an attachment
* @param {import('@sap/cds').Entity} Attachments - Attachments entity definition
* @param {string} key - The key of the attachment to retrieve the status for
* @returns {{ status: string, lastScan: Date }} - The malware scan status of the attachment
*/
async getStatus(Attachments, key) {
const result = await SELECT.from(Attachments, key).columns([
"status",
"lastScan",
])
return {
status: result?.status,
lastScan: result?.lastScan,
}
}
/**
* Registers attachment handlers for the given service and entity
* @param {*} records - The records to process
* @param {import('@sap/cds').Request} req - The request object
*/
async deleteAttachmentsWithKeys(records, req) {
if (!req.attachmentsToDelete) return
for (const attachment of req.attachmentsToDelete) {
if (attachment.url) {
const attachmentsSrv = await cds.connect.to("attachments")
LOG.info(
"[deleteAttachmentsWithKeys] Emitting DeleteAttachment for:",
attachment.url,
)
await attachmentsSrv.emit("DeleteAttachment", attachment)
LOG.info(
"[deleteAttachmentsWithKeys] Emitted DeleteAttachment for:",
attachment.url,
)
} else {
LOG.warn(
`Attachment cannot be deleted because URL is missing`,
attachment,
)
}
}
LOG.info("[deleteAttachmentsWithKeys] Finished")
}
/**
* Add non-draft deletion data to the request
* @param {import('@sap/cds').Request} req - The request object
*/
async attachNonDraftDeletionData(req) {
if (!req.target?.["@_is_media_data"]) return
if (!req.subject) return
const attachments = await SELECT.from(req.subject).columns("url")
if (attachments.length) {
req.attachmentsToDelete = attachments.map((a) => ({
...a,
target: req.target.name,
}))
}
}
/**
* Traverses nested data by a given path array.
* @param {Object} root - The root object or array to traverse.
* @param {Array} path - The array of keys representing the path.
* @returns {*} - The value found at the path, or [] if not found.
*/
traverseDataByPath(root, path) {
let current = root
for (let i = 0; i < path.length; i++) {
const part = path[i]
if (Array.isArray(current)) {
return current.flatMap((item) =>
this.traverseDataByPath(item, path.slice(i)),
)
}
if (!current || !(part in current)) return []
current = current[part]
}
return current
}
/**
* Registers attachment handlers for the given service and entity
* @param {import('@sap/cds').Request} req - The request object
*/
async attachDeletionData(req) {
if (!req.target?.drafts) return
const attachmentCompositions =
req?.target?._attachments.attachmentCompositions
if (attachmentCompositions.length > 0) {
const whereCond = req.subject?.ref?.[0]?.where
if (!whereCond) return
const columns = []
for (const path of attachmentCompositions) {
let current = columns
for (let i = 0; i < path.length; i++) {
const segment = path[i]
let next = current.find(
(c) => c.ref?.length === 1 && c.ref[0] === segment,
)
if (!next) {
next = { ref: [segment], expand: [] }
current.push(next)
}
current = next.expand
if (i === path.length - 1) {
current.push("*")
}
}
}
const [draft, active] = await Promise.all([
SELECT.one.from(req.target.drafts).where(whereCond).columns(columns),
SELECT.one.from(req.target).where(whereCond).columns(columns),
])
if (!active) return
const attachmentsToDelete = []
for (const attachmentsComp of attachmentCompositions) {
const activeAttachments =
this.traverseDataByPath(active, attachmentsComp) || []
const draftAttachments =
this.traverseDataByPath(draft, attachmentsComp) || []
const draftAttachmentIDs = new Set(
draftAttachments.filter((a) => a.ID).map((a) => a.ID),
)
// Find attachments present in the active entity but not in the draft
const deletedAttachments = activeAttachments.filter(
(att) => att.url && att.ID && !draftAttachmentIDs.has(att.ID),
)
const entityTarget = traverseEntity(req.target, attachmentsComp)
if (deletedAttachments.length) {
attachmentsToDelete.push(
...deletedAttachments.map((attachment) => ({
url: attachment.url,
target: entityTarget.name,
})),
)
}
}
if (attachmentsToDelete.length > 0) {
req.attachmentsToDelete = attachmentsToDelete
}
}
}
/**
* Registers attachment handlers for the given service and entity
* @param {{draftEntity: string, activeEntity:import('@sap/cds').Entity, id:string}} param0 - The service and entities
* @returns
*/
async getAttachmentsToDelete({ draftEntity, activeEntity, whereXpr }) {
const [draftAttachments, activeAttachments] = await Promise.all([
SELECT.from(draftEntity).columns("url").where(whereXpr),
SELECT.from(activeEntity).columns("url").where(whereXpr),
])
const activeUrls = new Set(activeAttachments.map((a) => a.url))
return draftAttachments
.filter(({ url }) => !activeUrls.has(url))
.map(({ url }) => ({ url, target: draftEntity.name }))
}
/**
* Add draft attachment deletion data to the request
* @param {import('@sap/cds').Request} req - The request object
*/
async attachDraftDeletionData(req) {
const name = req?.target?.name
const draftEntity = cds.model.definitions[name]
const activeEntity = name
? cds.model.definitions?.[name.split(".").slice(0, -1).join(".")]
: undefined
if (!draftEntity || !activeEntity) return
const attachmentId = req.data?.ID
if (!attachmentId) return
const attachmentsToDelete = await this.getAttachmentsToDelete({
draftEntity,
activeEntity,
whereXpr: { ID: attachmentId },
})
if (attachmentsToDelete.length) {
req.attachmentsToDelete = attachmentsToDelete
}
}
/**
* Deletes a file from the database. Does not delete metadata
* @param {string} url - The url of the file to delete
* @returns {Promise} - Promise resolving when deletion is complete
*/
async delete(url, target) {
return await UPDATE(target).where({ url }).with({ content: null })
}
}
AttachmentsService.prototype._is_queueable = true
module.exports = AttachmentsService