Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
4a58656
fix: add MTLS-based token retrieval for Service Manager in helper.js
RoshniNaveenaS Aug 4, 2025
3afd564
refactor: register standard PUT handler for non-draft attachments
RoshniNaveenaS Aug 4, 2025
06d8027
refactor(plugin): unify attachment handler registration for draft and…
RoshniNaveenaS Aug 5, 2025
286aca4
feat(helper): support token fetching via mutual TLS (mTLS) for Servic…
RoshniNaveenaS Aug 5, 2025
68110b0
Update basic.js
RoshniNaveenaS Aug 5, 2025
7125317
Update aws-s3.js
RoshniNaveenaS Aug 5, 2025
b3b80fd
Update basic.js
RoshniNaveenaS Aug 5, 2025
4797bd3
Update helper.js
RoshniNaveenaS Aug 5, 2025
29f8896
Update basic.js
RoshniNaveenaS Aug 5, 2025
ae6ed4d
Update basic.js
RoshniNaveenaS Aug 5, 2025
ba26b50
Update basic.js
RoshniNaveenaS Aug 11, 2025
d0a019c
testcases passing change - notFinal
RoshniNaveenaS Aug 11, 2025
0d6c9d2
refactor2
RoshniNaveenaS Aug 11, 2025
3b853c0
refactor3
RoshniNaveenaS Aug 11, 2025
6a20230
chore: add more logs
viniciuslora Aug 11, 2025
861699a
chore: fix missing await method for scan request
viniciuslora Aug 11, 2025
23ac419
refactor: use isAttachmentAnnotated() to detect media data entities
RoshniNaveenaS Aug 12, 2025
478d77e
docs(tests): add usage note for non-draft upload example
RoshniNaveenaS Aug 12, 2025
9b4ea32
Revert "chore: add more logs"
viniciuslora Aug 12, 2025
95b9d8d
Merge branch 'fix/non-draft_mtls_upload' of https://github.com/cap-js…
viniciuslora Aug 12, 2025
0def121
chore: fix typo
viniciuslora Aug 12, 2025
a6c8392
chore: enhance debug logs
viniciuslora Aug 13, 2025
0a1644c
Update CHANGELOG.md
RoshniNaveenaS Aug 14, 2025
57aaacb
chore: add treatment for read after write and fix wrong if check
viniciuslora Aug 15, 2025
19a9761
chore: add logs for download and delete
viniciuslora Aug 15, 2025
2ff1f7d
chore: add logs for download and delete
viniciuslora Aug 15, 2025
d540f3d
fix: remove req.target.name for in case is not existing
viniciuslora Aug 15, 2025
63dc013
chore: remove await for scan request
viniciuslora Aug 15, 2025
4ca8b96
Indentation fix
eric-pSAP Sep 5, 2025
10949a2
Rearranged HTTP requests to correct order, fixed variable names, re-a…
eric-pSAP Sep 5, 2025
18eb037
Remove markers
eric-pSAP Sep 5, 2025
ec7949c
Lint warning fixes
eric-pSAP Sep 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,17 @@ entity Incidents {
```
In this example, the `@attachments.disable_facet` is set to `true`, which means the plugin will be hidden by default.

## Non-Draft Upload Example

For scenarios where the entity is not draft-enabled, see [`tests/non-draft-request.http`](./tests/non-draft-request.http) for sample `.http` requests to perform metadata creation and content upload.

The typical sequence includes:

1. **POST** to create attachment metadata
2. **PUT** to upload file content using the ID returned

> This is useful for non-draft-enabled entity sets. Make sure to replace `{{host}}`, `{{auth}}`, and IDs accordingly.

## Multitenancy

The plugin supports multitenancy scenarios, allowing both shared and tenant-specific object store instances.
Expand Down
28 changes: 18 additions & 10 deletions lib/aws-s3.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ module.exports = class AWSAttachmentsService extends require("./basic") {
throw new Error("Service Manager Instance is not bound");
}

const { sm_url, url, clientid, clientsecret } = serviceManagerCreds;
const token = await utils.fetchToken(url, clientid, clientsecret);
const { sm_url, url, clientid, clientsecret, certificate, key, certurl } = serviceManagerCreds;
const token = await utils.fetchToken(url, clientid, clientsecret, certificate, key, certurl);

const objectStoreCreds = await utils.getObjectStoreCredentials(tenantID, sm_url, token);

Expand Down Expand Up @@ -186,7 +186,7 @@ module.exports = class AWSAttachmentsService extends require("./basic") {
await Promise.all([multipartUpload.done()]);

const keys = { ID: req.data.ID }
scanRequest(req.target, keys, req)
await scanRequest(req.target, keys, req)
}
} else if (req?.data?.note) {
const key = { ID: req.data.ID };
Expand Down Expand Up @@ -252,6 +252,21 @@ module.exports = class AWSAttachmentsService extends require("./basic") {
srv.before(["DELETE", "UPDATE"], entity, this.attachDeletionData.bind(this));
srv.after(["DELETE", "UPDATE"], entity, this.deleteAttachmentsWithKeys.bind(this));

srv.prepend(() => {
if (mediaElement.drafts) {
srv.on(
"PUT",
mediaElement.drafts,
this.updateContentHandler.bind(this)
);
}
});
}

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));
Expand Down Expand Up @@ -279,13 +294,6 @@ module.exports = class AWSAttachmentsService extends require("./basic") {
});
}

async nonDraftHandler(attachments, data) {
const isDraftEnabled = false;
const response = await SELECT.from(attachments, { ID: data.ID }).columns("url");
if (response?.url) data.url = response.url;
return this.put(attachments, [data], isDraftEnabled);
}

async delete(Key, req) {
// Check separate object store instances
if (separateObjectStore) {
Expand Down
31 changes: 25 additions & 6 deletions lib/basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const cds = require('@sap/cds');
const DEBUG = cds.debug('attachments');
const { SELECT, UPSERT, UPDATE } = cds.ql;
const { scanRequest } = require('./malwareScanner')
const attachmentIDRegex = /\/\w+\(.*ID=([0-9a-fA-F-]{36})/

module.exports = class AttachmentsService extends cds.Service {

Expand All @@ -25,7 +26,15 @@ module.exports = class AttachmentsService extends cds.Service {
);
}

if(this.kind === 'db') data.map((d) => { scanRequest(attachments, { ID: d.ID })})
if (this.kind === 'db') {
await Promise.all(
data.map(async (d) => {
DEBUG?.(`Scanning attachment ID: ${d.ID}`);
await scanRequest(attachments, { ID: d.ID });
DEBUG?.(`Scan completed for ID: ${d.ID}`);
})
);
}

return res;
}
Expand All @@ -43,7 +52,7 @@ module.exports = class AttachmentsService extends cds.Service {
/**
* Returns a handler to copy updated attachments content from draft to active / object store
*/
draftSaveHandler(attachments) {
draftSaveHandler(attachments) {
const queryFields = this.getFields(attachments);


Expand All @@ -65,9 +74,13 @@ module.exports = class AttachmentsService extends cds.Service {
};
}

async nonDraftHandler(attachments, data) {
const isDraftEnabled = false;
return this.put(attachments, [data], null, isDraftEnabled);
async nonDraftHandler(req, attachment) {
if (req?.content?.url?.endsWith("/content")) {
const attachmentID = req.content.url.match(attachmentIDRegex)[1];
const data = { ID: attachmentID, content: req.content }
const isDraftEnabled = false;
return this.put(attachment, [data], null, isDraftEnabled);
}
}

getFields(attachments) {
Expand All @@ -81,7 +94,13 @@ module.exports = class AttachmentsService extends cds.Service {
else return Object.keys(attachments.keys);
}

async registerUpdateHandlers(srv, entity, target) {
registerUpdateHandlers(srv, entity, target) {
srv.after("PUT", target, async (req) => {
await this.nonDraftHandler(req, target);
});
}

registerDraftUpdateHandlers(srv, entity, target) {
srv.after("SAVE", entity, this.draftSaveHandler(target));
return;
}
Expand Down
85 changes: 64 additions & 21 deletions lib/helper.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,7 @@
const axios = require('axios');
const cds = require('@sap/cds');
const DEBUG = cds.debug('attachments');

async function fetchToken(url, clientid, clientsecret) {
try {
const tokenResponse = await axios.post(`${url}/oauth/token`, null, {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
},
params: {
grant_type: 'client_credentials',
client_id: clientid,
client_secret: clientsecret
}
});

const token = tokenResponse.data.access_token;
return token;
} catch (error) {
DEBUG?.(`Error fetching token: ${error.message}`);
}
}
const https = require("https");

async function getObjectStoreCredentials(tenantID, sm_url, token) {
try {
Expand All @@ -40,6 +20,69 @@ async function getObjectStoreCredentials(tenantID, sm_url, token) {
}
}

async function fetchToken(url, clientid, clientsecret, certificate, key, certURL) {
if (certificate && key && certURL) {
return fetchTokenWithMTLS(certURL, clientid, certificate, key);
} else if (clientid && clientsecret) {
return fetchTokenWithClientSecret(url, clientid, clientsecret);
} else {
throw new Error("Invalid credentials provided for token fetching.");
}
}

async function fetchTokenWithClientSecret(url, clientid, clientsecret) {
try {
DEBUG?.("Using OAuth client credentials to fetch token.");
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
};
const response = await axios.post(`${url}/oauth/token`, null, {
headers,
params: {
grant_type: "client_credentials",
client_id: clientid,
client_secret: clientsecret,
},
});
return response.data.access_token;
} catch (error) {
DEBUG?.(`Error fetching token for client credentials: ${error.message}`);
throw error;
}
}

async function fetchTokenWithMTLS(certURL, clientid, certificate, key) {
try {
DEBUG?.("Using MTLS certificate/key to fetch token.");

const requestBody = new URLSearchParams({
grant_type: 'client_credentials',
response_type: 'token',
client_id: clientid
}).toString()

const options = {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
},
url: `${certURL}/oauth/token`,
method: 'POST',
data: requestBody,
httpsAgent: new https.Agent({
cert: certificate,
key: key
})
}
const response = await axios(options);
return response.data.access_token;
} catch (error) {
DEBUG?.(`Error fetching token with MTLS: ${error.message}`);
throw error;
}
}

module.exports = {
fetchToken,
getObjectStoreCredentials,
Expand Down
12 changes: 11 additions & 1 deletion lib/malwareScanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const DEBUG = cds.debug('attachments')
const { SELECT } = cds.ql;

async function scanRequest(Attachments, key, req) {
DEBUG?.(`Malware Scanning: Scanning content to entity ${key}`);
const scanEnabled = cds.env.requires?.attachments?.scan ?? true
const AttachmentsSrv = await cds.connect.to("attachments")

Expand All @@ -14,6 +15,8 @@ async function scanRequest(Attachments, key, req) {
activeEntity = Attachments
}

DEBUG?.(`Malware Scanning: Scanning content to entity ${key} - activeEntity = ${activeEntity}`);

let currEntity = draftEntity == undefined ? activeEntity : draftEntity

if (!scanEnabled) {
Expand All @@ -30,13 +33,20 @@ async function scanRequest(Attachments, key, req) {
}
}

DEBUG?.(`Malware Scanning: Scanning content to entity ${key} - setting status to "Scanning"`);

await updateStatus(AttachmentsSrv, key, "Scanning", currEntity, draftEntity, activeEntity)

const credentials = getCredentials()
DEBUG?.(`Malware Scanning: Scanning content to entity ${key} - credentials = ${JSON.stringify(credentials)}`);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not log credentials in any log output

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This log was added just for debug options. I was having several problem with credentials checking, thought it would be useful. May be removed if you find insecure.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll move this to a separate PR.

const contentStream = await AttachmentsSrv.get(currEntity, key)

DEBUG?.(`Malware Scanning: Scanning content to entity ${key} - contentStream = ${contentStream}`);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we logging contentStream binary ? What purpose does it serve?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above - just checking if every step is being complete, since in BTP is not possible to debug with breakpoints.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll move this to a separate PR.


let fileContent
try {
fileContent = await streamToString(contentStream)
fileContent = await streamToString(contentStream);

} catch (err) {
DEBUG?.("Malware Scanning: Cannot read file content", err)
await updateStatus(AttachmentsSrv, key, "Failed", currEntity, draftEntity, activeEntity)
Expand Down
51 changes: 24 additions & 27 deletions lib/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ cds.once("served", async function registerPluginHandlers () {
if (elementName === "SiblingEntity") continue // REVISIT: Why do we have this?
const element = entity.elements[elementName], target = element._target

if (!target?.["@_is_media_data"]) continue;

if (!isAttachmentAnnotated(target)) continue;
const isDraft = !!target?.drafts;
const targets = isDraft ? [target, target.drafts] : [target];

Expand All @@ -51,32 +50,32 @@ cds.once("served", async function registerPluginHandlers () {

srv.after("READ", targets, readAttachment)

const putTarget = isDraft ? target.drafts : target;
srv.before("PUT", putTarget, (req) => validateAttachmentSize(req))

const op = isDraft ? "NEW" : "CREATE";
srv.before(op, putTarget, (req) => {
req.data.url = cds.utils.uuid()
const isMultitenacyEnabled = !!cds.env.requires.multitenancy;
const objectStoreKind = cds.env.requires?.attachments?.objectStore?.kind;
if (isMultitenacyEnabled && objectStoreKind === "shared") {
req.data.url = `${req.tenant}_${req.data.url}`;
}
req.data.ID = cds.utils.uuid()
let ext = extname(req.data.filename).toLowerCase().slice(1)
req.data.mimeType = Ext2MimeTyes[ext] || "application/octet-stream"
});

if (isDraft) {
AttachmentsSrv.registerUpdateHandlers(srv, entity, target)
srv.before("PUT", target.drafts, (req) => validateAttachmentSize(req))
srv.before("NEW", target.drafts, (req) => onPrepareAttachment(req));
AttachmentsSrv.registerDraftUpdateHandlers(srv, entity, target)
} else {
srv.after("PUT", target, (req) => nonDraftUpload(req, target))
srv.before("PUT", target, (req) => validateAttachmentSize(req))
srv.before("CREATE", target, (req) => onPrepareAttachment(req));
AttachmentsSrv.registerUpdateHandlers(srv, entity, target);
}
}
})
}
}

function onPrepareAttachment (req) {
req.data.url = cds.utils.uuid()
const isMultitenacyEnabled = !!cds.env.requires.multitenancy;
const objectStoreKind = cds.env.requires?.attachments?.objectStore?.kind;
if (isMultitenacyEnabled && objectStoreKind === "shared") {
req.data.url = `${req.tenant}_${req.data.url}`;
}
req.data.ID = cds.utils.uuid()
let ext = extname(req.data.filename).toLowerCase().slice(1)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any chance the user sends the request with a file without extension? Will this code break if extension is not there for file ?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like that if nothing is found at the Ext2MimeTypes map, it will fallback as "application/octet-stream" mime type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, The code will not break if there’s no file extension. It will simply assign mimeType = "application/octet-stream".

req.data.mimeType = Ext2MimeTyes[ext] || "application/octet-stream";
}

async function validateAttachment (req) {

/* removing case condition for mediaType annotation as in our case binary value and metadata is stored in different database */
Expand Down Expand Up @@ -105,13 +104,7 @@ cds.once("served", async function registerPluginHandlers () {
attachment.content = await AttachmentsSrv.get(target, keys, req) //Dependency -> sending req object for usage in SDM plugin
}

async function nonDraftUpload(req, target) {
if (req?.content?.url?.endsWith("/content")) {
const attachmentID = req.content.url.match(attachmentIDRegex)[1];
AttachmentsSrv.nonDraftHandler(target, { ID: attachmentID, content: req.content });
}
}
})
});

function validateAttachmentSize (req) {
const contentLengthHeader = req.headers["content-length"]
Expand All @@ -128,6 +121,10 @@ function validateAttachmentSize (req) {
}
}

function isAttachmentAnnotated (target) {
return !!target?.["@_is_media_data"]
}

module.exports = { validateAttachmentSize }

const Ext2MimeTyes = {
Expand Down
6 changes: 3 additions & 3 deletions tests/non-draft-request.http
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Authorization: {{auth}}

### Get attachments content
@attachmentsID = {{attachments.response.body.value[1].ID}}
GET {{host}}/odata/v4/processor/Incidents({{incidentsID}})/attachments(ID={{attachmentsID}})/content
GET {{host}}/odata/v4/processor/Incidents({{incidentsID}})/attachments(up__ID=${incidentID},ID=${attachmentID})/content
Authorization: {{auth}}

### Delete attachment
Expand All @@ -35,12 +35,12 @@ Content-Type: application/json

### Put attachment content (content request)
@newAttachmentID = {{createAttachment.response.body.ID}}
PUT {{host}}/odata/v4/processor/Incidents({{incidentsID}})/attachments(ID={{newAttachmentID}})/content
PUT {{host}}/odata/v4/processor/Incidents({{incidentsID}})/attachments(up__ID=${incidentID},ID=${attachmentID})/content
Authorization: {{auth}}
Content-Type: image/jpeg

< ./integration/content/sample-1.jpg

### Fetching newly created attachment content
GET {{host}}/odata/v4/processor/Incidents({{incidentsID}})/attachments(ID={{newAttachmentID}})/content
GET {{host}}/odata/v4/processor/Incidents({{incidentsID}})/attachments(up__ID=${incidentID},ID=${attachmentID})/content
Authorization: {{auth}}
Loading