diff --git a/.gitignore b/.gitignore index aaf6b5cd..cc008dab 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ node_modules/ gen/ .vscode/ -package-lock.json \ No newline at end of file +package-lock.json diff --git a/lib/aws-s3.js b/lib/aws-s3.js index 18556ea9..47a27d3c 100644 --- a/lib/aws-s3.js +++ b/lib/aws-s3.js @@ -76,6 +76,10 @@ module.exports = class AWSAttachmentsService extends require("./basic") { } else { logConfig.info('Separate object store mode enabled - clients will be created per tenant') } + + this.on('DeleteAttachment', async msg => { + await this.delete(msg.url); + }); } /** @@ -375,54 +379,6 @@ module.exports = class AWSAttachmentsService extends require("./basic") { } } - /** - * Deletes a file from S3 based on the provided key - * @param {string} key - The key of the file to delete - * @returns {Promise} - Promise resolving when deletion is complete - */ - async deleteAttachment(key) { - if (!key) return - return await this.delete(key) - } - - /** - * 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?.length > 0) { - req.attachmentsToDelete.forEach((attachment) => { - this.deleteAttachment(attachment.url) - }) - } - } - - /** - * Registers attachment handlers for the given service and entity - * @param {import('@sap/cds').Request} req - The request object - */ - async attachDeletionData(req) { - const attachments = cds.model.definitions[req?.target?.name + ".attachments"] - if (attachments) { - const diffData = await req.diff() - let deletedAttachments = [] - diffData.attachments?.filter((object) => { - return object._op === "delete" - }) - .map((attachment) => { - deletedAttachments.push(attachment.ID) - }) - - if (deletedAttachments.length > 0) { - let attachmentsToDelete = await SELECT.from(attachments).columns("url").where({ ID: { in: [...deletedAttachments] } }) - if (attachmentsToDelete.length > 0) { - req.attachmentsToDelete = attachmentsToDelete - } - } - } - } - /** * Registers attachment handlers for the given service and entity * @param {import('@sap/cds').Request} req - The request object @@ -481,128 +437,48 @@ module.exports = class AWSAttachmentsService extends require("./basic") { } } - /** - * Registers attachment handlers for the given service and entity - * @param {{draftEntity: string, activeEntity: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 })) - } - - /** - * Add draft attachment deletion data to the request - * @param {import('@sap/cds').Request} req - The request object - */ - async attachDraftDeletionData(req) { - const draftEntity = cds.model.definitions[req?.target?.name] - const name = req?.target?.name - const activeEntity = name ? cds.model.definitions?.[name.split(".").slice(0, -1).join(".")] : undefined - - if (!draftEntity || !activeEntity) return - - const diff = await req.diff() - if (diff._op !== "delete" || !diff.ID) return - - const attachmentsToDelete = await this.getAttachmentsToDelete({ - draftEntity, - activeEntity, - whereXpr: { ID: diff.ID } - }) - - if (attachmentsToDelete.length) { - req.attachmentsToDelete = attachmentsToDelete - } - } - - /** - * Add draft discard deletion data to the request - * @param {import('@sap/cds').Request} req - The request object - */ - async attachDraftDiscardDeletionData(req) { - const parentEntity = req.target.name.split('.').slice(0, -1).join('.') - const draftEntity = cds.model.definitions[`${parentEntity}.attachments.drafts`] - const activeEntity = cds.model.definitions[`${parentEntity}.attachments`] - if (!draftEntity || !activeEntity) return - - const whereXpr = [] - for (const foreignKey of activeEntity.keys['up_']._foreignKeys) { - if (whereXpr.length) { - whereXpr.push('and') - } - whereXpr.push( - {ref: [foreignKey.parentElement.name]}, - '=', - {val: req.data[foreignKey.childElement.name]} - ) - } - const attachmentsToDelete = await this.getAttachmentsToDelete({ - draftEntity, - activeEntity, - whereXpr - }) - - if (attachmentsToDelete.length) { - req.attachmentsToDelete = attachmentsToDelete - } - } - - /** - * @inheritdoc - */ - registerUpdateHandlers(srv, entity, mediaElement) { - srv.before(["DELETE", "UPDATE"], entity, this.attachDeletionData.bind(this)) - srv.after(["DELETE", "UPDATE"], entity, this.deleteAttachmentsWithKeys.bind(this)) - - srv.prepend(() => { - srv.on( - "PUT", - mediaElement, - this.updateContentHandler.bind(this) - ) - }) - } - /** * @inheritdoc */ - registerDraftUpdateHandlers(srv, entity, mediaElement) { - srv.before(["DELETE", "UPDATE"], entity, this.attachDeletionData.bind(this)) - srv.after(["DELETE", "UPDATE"], entity, this.deleteAttachmentsWithKeys.bind(this)) - - // case: attachments uploaded in draft and draft is discarded - srv.before("CANCEL", entity.drafts, this.attachDraftDiscardDeletionData.bind(this)) - srv.after("CANCEL", entity.drafts, this.deleteAttachmentsWithKeys.bind(this)) - - srv.prepend(() => { - if (mediaElement.drafts) { + registerUpdateHandlers(srv, mediaElements) { + for (const mediaElement of mediaElements) { + srv.prepend(() => { srv.on( "PUT", - mediaElement.drafts, + mediaElement, this.updateContentHandler.bind(this) ) + }) + } + } - // case: attachments uploaded in draft and deleted before saving - srv.before( - "DELETE", - mediaElement.drafts, - this.attachDraftDeletionData.bind(this) - ) - srv.after( - "DELETE", - mediaElement.drafts, - this.deleteAttachmentsWithKeys.bind(this) - ) - } - }) + /** + * @inheritdoc + */ + registerDraftUpdateHandlers(srv, entity, mediaElements) { + for (const mediaElement of mediaElements) { + srv.prepend(() => { + if (mediaElement.drafts) { + srv.on( + "PUT", + mediaElement.drafts, + this.updateContentHandler.bind(this) + ) + + // case: attachments uploaded in draft and deleted before saving + srv.before( + "DELETE", + mediaElement.drafts, + this.attachDraftDeletionData.bind(this) + ) + srv.after( + "DELETE", + mediaElement.drafts, + this.deleteAttachmentsWithKeys.bind(this) + ) + } + }) + } } /** diff --git a/lib/basic.js b/lib/basic.js index c467f5bf..a9d9db30 100644 --- a/lib/basic.js +++ b/lib/basic.js @@ -3,7 +3,7 @@ const { SELECT, UPSERT, UPDATE } = cds.ql const { scanRequest } = require('./malwareScanner') const { logConfig } = require('./logger') -module.exports = class AttachmentsService extends cds.Service { +class AttachmentsService extends cds.Service { /** * Uploads attachments to the database and initiates malware scans for database-stored files * @param {cds.Entity} attachments - Attachments entity definition @@ -173,10 +173,12 @@ module.exports = class AttachmentsService extends cds.Service { * @param {cds.Entity} entity - The entity containing attachment associations * @param {cds.Entity} target - Attachments entity definition to register handlers for */ - registerUpdateHandlers(srv, entity, target) { - srv.after("PUT", target, async (req) => { - await this.nonDraftHandler(req, target, srv) - }) + registerUpdateHandlers(srv, targets) { + for (const target of targets) { + srv.after("PUT", target, async (req) => { + await this.nonDraftHandler(req, target, srv) + }) + } } /** @@ -185,8 +187,10 @@ module.exports = class AttachmentsService extends cds.Service { * @param {cds.Entity} entity - The entity containing attachment associations * @param {cds.Entity} target - Attachments entity definition to register handlers for */ - registerDraftUpdateHandlers(srv, entity, target) { - srv.after("SAVE", entity, this.draftSaveHandler(target)) + registerDraftUpdateHandlers(srv, entity, targets) { + for (const target of targets) { + srv.after("SAVE", entity, this.draftSaveHandler(target)) + } return } @@ -202,6 +206,7 @@ module.exports = class AttachmentsService extends cds.Service { attachmentName: Attachments.name, attachmentKey: key }) + return await UPDATE(Attachments, key).with(data) } @@ -225,4 +230,134 @@ module.exports = class AttachmentsService extends cds.Service { async deleteInfectedAttachment(Attachments, key) { return await UPDATE(Attachments, key).with({ content: null }) } + + + + /** + * 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) { + req.attachmentsToDelete?.forEach(async (attachment) => { + if (attachment.url) { + const attachmentsSrv = await cds.connect.to('attachments') + await attachmentsSrv.emit('DeleteAttachment', { url: attachment.url }) + } else { + logConfig.warn(`Attachment cannot be deleted because URL is missing`, attachment); + } + }) + } + + /** + * Registers attachment handlers for the given service and entity + * @param {import('@sap/cds').Request} req - The request object + */ + async attachDeletionData(req) { + const attachmentCompositions = Object.keys(req?.target?.associations) + .filter(assoc => req?.target?.associations[assoc]._target['@_is_media_data']) + if (attachmentCompositions.length > 0) { + const diffData = await req.diff() + const queries = [] + for (const attachmentsComp of attachmentCompositions) { + let deletedAttachments = [] + diffData[attachmentsComp]?.forEach(object => { + if (object._op === "delete") { + deletedAttachments.push(object.ID) + } + }) + if (deletedAttachments.length) { + queries.push( + SELECT.from(req.target.associations[attachmentsComp]._target).columns("url").where({ ID: { in: [...deletedAttachments] } }) + ) + } + } + if (queries.length > 0) { + const attachmentsToDelete = (await Promise.all(queries)).flat() + if (attachmentsToDelete.length > 0) { + req.attachmentsToDelete = attachmentsToDelete + } + } + } + } + + /** + * Registers attachment handlers for the given service and entity + * @param {{draftEntity: string, activeEntity: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 })) + } + + /** + * Add draft attachment deletion data to the request + * @param {import('@sap/cds').Request} req - The request object + */ + async attachDraftDeletionData(req) { + const draftEntity = cds.model.definitions[req?.target?.name] + const name = req?.target?.name + const activeEntity = name ? cds.model.definitions?.[name.split(".").slice(0, -1).join(".")] : undefined + + if (!draftEntity || !activeEntity) return + + const diff = await req.diff() + if (diff._op !== "delete" || !diff.ID) return + + const attachmentsToDelete = await this.getAttachmentsToDelete({ + draftEntity, + activeEntity, + whereXpr: { ID: diff.ID } + }) + + if (attachmentsToDelete.length) { + req.attachmentsToDelete = attachmentsToDelete + } + } + + /** + * Add draft discard deletion data to the request + * @param {import('@sap/cds').Request} req - The request object + */ + async attachDraftDiscardDeletionData(req) { + const parentEntity = req.target.name.split('.').slice(0, -1).join('.') + const draftEntity = cds.model.definitions[`${parentEntity}.attachments.drafts`] + const activeEntity = cds.model.definitions[`${parentEntity}.attachments`] + if (!draftEntity || !activeEntity) return + + const whereXpr = [] + for (const foreignKey of activeEntity.keys['up_']._foreignKeys) { + if (whereXpr.length) { + whereXpr.push('and') + } + whereXpr.push( + {ref: [foreignKey.parentElement.name]}, + '=', + {val: req.data[foreignKey.childElement.name]} + ) + } + + const attachmentsToDelete = await this.getAttachmentsToDelete({ + draftEntity, + activeEntity, + whereXpr + }) + + if (attachmentsToDelete.length > 0) { + req.attachmentsToDelete = attachmentsToDelete + } + } } + + +AttachmentsService.prototype._is_queueable = true; + +module.exports = AttachmentsService; diff --git a/lib/plugin.js b/lib/plugin.js index b2e4f96b..0c0e27e2 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -44,6 +44,9 @@ cds.once("served", async function registerPluginHandlers() { if (srv instanceof cds.ApplicationService) { Object.values(srv.entities).forEach((entity) => { + const mediaDraftEntities = [] + const mediaEntities = [] + for (let elementName in entity.elements) { if (elementName === "SiblingEntity") continue // REVISIT: Why do we have this? const element = entity.elements[elementName], target = element._target @@ -57,17 +60,32 @@ cds.once("served", async function registerPluginHandlers() { srv.before("READ", targets, validateAttachment) srv.after("READ", targets, readAttachment) - + + srv.before("PUT", isDraft ? target.drafts : target, (req) => validateAttachmentSize(req)) + if (isDraft) { - srv.before("PUT", target.drafts, (req) => validateAttachmentSize(req)) srv.before("NEW", target.drafts, (req) => onPrepareAttachment(req)) - AttachmentsSrv.registerDraftUpdateHandlers(srv, entity, target) + mediaDraftEntities.push(target) } else { - srv.before("PUT", target, (req) => validateAttachmentSize(req)) srv.before("CREATE", target, (req) => onPrepareAttachment(req)) - AttachmentsSrv.registerUpdateHandlers(srv, entity, target) + mediaEntities.push(target) } } + + if (mediaDraftEntities.length) { + AttachmentsSrv.registerDraftUpdateHandlers(srv, entity, mediaDraftEntities) + srv.before(["DELETE", "UPDATE"], entity, AttachmentsSrv.attachDeletionData.bind(AttachmentsSrv)) + srv.after(["DELETE", "UPDATE"], entity, AttachmentsSrv.deleteAttachmentsWithKeys.bind(AttachmentsSrv)) + + // case: attachments uploaded in draft and draft is discarded + srv.before("CANCEL", entity.drafts, AttachmentsSrv.attachDraftDiscardDeletionData.bind(AttachmentsSrv)) + srv.after("CANCEL", entity.drafts, AttachmentsSrv.deleteAttachmentsWithKeys.bind(AttachmentsSrv)) + } + if (mediaEntities.length) { + AttachmentsSrv.registerUpdateHandlers(srv, mediaEntities) + srv.before(["DELETE", "UPDATE"], entity, AttachmentsSrv.attachDeletionData.bind(AttachmentsSrv)) + srv.after(["DELETE", "UPDATE"], entity, AttachmentsSrv.deleteAttachmentsWithKeys.bind(AttachmentsSrv)) + } }) } } diff --git a/package.json b/package.json index e041bd32..68a451cc 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ } }, "attachments": { + "outbox": true, "scan": true, "objectStore": { "kind": "separate" diff --git a/tests/incidents-app/.gitignore b/tests/incidents-app/.gitignore new file mode 100644 index 00000000..b92ceaa2 --- /dev/null +++ b/tests/incidents-app/.gitignore @@ -0,0 +1,3 @@ + +# added by cds +.cdsrc-private.json diff --git a/tests/incidents-app/package.json b/tests/incidents-app/package.json index dd1afd1c..afdd011a 100644 --- a/tests/incidents-app/package.json +++ b/tests/incidents-app/package.json @@ -22,10 +22,6 @@ } } } - }, - "attachments": { - "kind": "db", - "scan": false } } }, diff --git a/tests/integration/attachments.test.js b/tests/integration/attachments.test.js index ede19402..bae0c867 100644 --- a/tests/integration/attachments.test.js +++ b/tests/integration/attachments.test.js @@ -147,15 +147,33 @@ describe("Tests for uploading/deleting attachments through API calls - in-memory ) expect(contentResponse.status).to.equal(200) + const attachmentData = await GET( + `odata/v4/processor/Incidents(ID=${incidentID},IsActiveEntity=true)/attachments(up__ID=${incidentID},ID=${sampleDocID},IsActiveEntity=true)` + ) + + //trigger to delete attachment + await utils.draftModeEdit("processor", "Incidents", incidentID, "ProcessorService") + + + const db = await cds.connect.to('db'); + const attachmentIDs = [] + db.before('*', req => { + if (req.event === 'CREATE' && req.target?.name === 'cds.outbox.Messages') { + const msg = JSON.parse(req.query.INSERT.entries[0].msg); + attachmentIDs.push(msg.data.url) + } + }) + //delete attachment let action = () => DELETE( `odata/v4/processor/Incidents_attachments(up__ID=${incidentID},ID=${sampleDocID},IsActiveEntity=false)` ) - //trigger to delete attachment - await utils.draftModeEdit("processor", "Incidents", incidentID, "ProcessorService") await utils.draftModeSave("processor", "Incidents", incidentID, action, "ProcessorService") + expect(attachmentIDs[0]).to.equal(attachmentData.data.url); + expect(attachmentIDs.length).to.equal(1); + //read attachments list for Incident const response = await GET( `odata/v4/processor/Incidents(ID=${incidentID},IsActiveEntity=true)/attachments`