Skip to content

Commit f265c31

Browse files
schiwekMKoblerS
andauthored
Leverage generic handler registration (#309)
Use CAPs API to register generic handlers to allow developers to more easily register own handlers --------- Co-authored-by: Simon Kobler <32038731+KoblerS@users.noreply.github.com>
1 parent 39ea186 commit f265c31

21 files changed

+545
-314
lines changed

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,11 @@ Scan status codes:
200200
- `Failed`: Scanning failed.
201201

202202
> [!Note]
203-
> The plugin currently supports file uploads up to 400 MB in size per attachment as this is a limitation of the [malware scanning service](https://help.sap.com/docs/malware-scanning-servce/sap-malware-scanning-service/what-is-sap-malware-scanning-service). Please note: this limitation remains even with the malware scanner disabled.
204203
> The malware scanner supports mTLS authentication which requires an annual renewal of the certificate. Previously, basic authentication was used which has now been deprecated.
205204
205+
> [!Note]
206+
> If the malware scanner reports a file size larger than the limit specified via [@Validation.Maximum](#specify-the-maximum-file-size) it removes the file and sets the status of the attachment metadata to failed.
207+
206208

207209
### Visibility Control for Attachments UI Facet Generation
208210

@@ -243,6 +245,23 @@ The typical sequence includes:
243245
1. **POST** -> create attachment metadata, returns ID
244246
2. **PUT** -> upload file content using the ID
245247

248+
### Specify the maximum file size
249+
250+
You can specify the maximum file size by annotating the attachments content property with `@Validation.Maximum`
251+
252+
```cds
253+
entity Incidents {
254+
...
255+
attachments: Composition of many Attachments;
256+
}
257+
258+
annotate Incidents.attachments with {
259+
content @Validation.Maximum : '20MB';
260+
}
261+
```
262+
263+
The default is 400MB
264+
246265
## Releases
247266

248267
- The plugin is released to [NPM Registry](https://www.npmjs.com/package/@cap-js/attachments).

_i18n/messages.properties

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
UnableToDownloadAttachmentScanStatusNotClean=Unable to download the attachment as scan status is not clean.
2-
InvalidContentSize=Invalid Content Size.
3-
AttachmentSizeExceeded=File Size limit exceeded beyond 400 MB.
2+
AttachmentSizeExceeded=File size limit exceeded beyond {0}.
43
MultiUpdateNotSupported=Multi update is not supported.

lib/generic-handlers.js

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const cds = require('@sap/cds')
22
const LOG = cds.log('attachments')
33
const { extname } = require("path")
4+
const { MAX_FILE_SIZE, sizeInBytes } = require('./helper')
45

56
const isMultitenacyEnabled = !!cds.env.requires.multitenancy
67
const objectStoreKind = cds.env.requires?.attachments?.objectStore?.kind
@@ -68,20 +69,21 @@ async function readAttachment([attachment], req) {
6869
attachment.content = await AttachmentsSrv.get(target, keys)
6970
}
7071

72+
/**
73+
* Checks the attachments size against the maximum defined by the annotation `@Validation.Maximum`. Default 400mb.
74+
* If the limit is reached by the reported size of the content-length header or if the stream length exceeds
75+
* the limits the error is thrown.
76+
* @param {import('@sap/cds').Request} req
77+
* @throws AttachmentSizeExceeded
78+
*/
7179
function validateAttachmentSize(req) {
7280
if (!req.target?._attachments.isAttachmentsEntity || !req.data.content) return;
7381

74-
const contentLengthHeader = req.headers["content-length"]
75-
let fileSizeInBytes
82+
const maxFileSize = req.target.elements['content']['@Validation.Maximum'] ? (sizeInBytes(req.target.elements['content']['@Validation.Maximum'], req.target.name) ?? MAX_FILE_SIZE) : MAX_FILE_SIZE;
7683

77-
if (contentLengthHeader) {
78-
fileSizeInBytes = Number(contentLengthHeader)
79-
const MAX_FILE_SIZE = 419430400 //400 MB in bytes
80-
if (fileSizeInBytes > MAX_FILE_SIZE) {
81-
return req.reject(400, "AttachmentSizeExceeded")
82-
}
83-
} else {
84-
return req.reject(400, "InvalidContentSize")
84+
if (req.headers["content-length"] && Number(req.headers["content-length"]) > maxFileSize) {
85+
if (req.data.content.pause) { req.data.content.pause() }
86+
return req.reject({status: 413, message: "AttachmentSizeExceeded", args: [req.target.elements['content']['@Validation.Maximum'] ?? '400MB']})
8587
}
8688
}
8789

lib/helper.js

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function validateServiceManagerCredentials(serviceManagerCreds) {
3636
*/
3737
function validateInputs(tenantID, sm_url, token) {
3838
if (!tenantID) {
39-
LOG.error( 'Tenant ID is required for object store credentials', null,
39+
LOG.error('Tenant ID is required for object store credentials', null,
4040
'Ensure multitenancy is properly configured and tenant context is available', { tenantID })
4141
return false
4242
}
@@ -48,7 +48,7 @@ function validateInputs(tenantID, sm_url, token) {
4848
}
4949

5050
if (!token) {
51-
LOG.error( 'Access token is required for Service Manager API', null,
51+
LOG.error('Access token is required for Service Manager API', null,
5252
'Check if token fetching completed successfully', { hasToken: !!token })
5353
return false
5454
}
@@ -67,20 +67,20 @@ async function fetchObjectStoreBinding(tenantID, token) {
6767

6868
validateServiceManagerCredentials(serviceManagerCreds)
6969

70-
const { sm_url, url, clientid, clientsecret, certificate, key, certurl } = serviceManagerCreds
71-
72-
73-
if (!token) {
74-
LOG.debug('Fetching access token for tenant', { tenantID, sm_url: sm_url })
75-
token = await fetchToken(url, clientid, clientsecret, certificate, key, certurl)
76-
}
77-
78-
LOG.debug('Fetching object store credentials', { tenantID, sm_url })
79-
80-
if (!validateInputs(tenantID, sm_url, token)) {
81-
return null
82-
}
83-
70+
const { sm_url, url, clientid, clientsecret, certificate, key, certurl } = serviceManagerCreds
71+
72+
73+
if (!token) {
74+
LOG.debug('Fetching access token for tenant', { tenantID, sm_url: sm_url })
75+
token = await fetchToken(url, clientid, clientsecret, certificate, key, certurl)
76+
}
77+
78+
LOG.debug('Fetching object store credentials', { tenantID, sm_url })
79+
80+
if (!validateInputs(tenantID, sm_url, token)) {
81+
return null
82+
}
83+
8484
LOG.debug('Making Service Manager API call', {
8585
tenantID,
8686
endpoint: `${sm_url}/v1/service_bindings`,
@@ -108,7 +108,7 @@ async function getObjectStoreCredentials(tenantID) {
108108
const items = await fetchObjectStoreBinding(tenantID)
109109

110110
if (!items.length) {
111-
LOG.error( `No object store service binding found for tenant`, null,
111+
LOG.error(`No object store service binding found for tenant`, null,
112112
'Ensure an Object Store instance is subscribed and bound for this tenant',
113113
{ tenantID, itemsFound: 0 })
114114
return null
@@ -129,7 +129,7 @@ async function getObjectStoreCredentials(tenantID) {
129129
'Verify Service Manager URL and API endpoint' :
130130
'Check network connectivity and Service Manager instance health'
131131

132-
LOG.error( 'Failed to fetch object store credentials', error, suggestion, {
132+
LOG.error('Failed to fetch object store credentials', error, suggestion, {
133133
tenantID,
134134
httpStatus: error.response?.status,
135135
responseData: error.response?.data
@@ -171,7 +171,7 @@ async function fetchToken(url, clientid, clientsecret, certificate, key, certURL
171171
return fetchTokenWithClientSecret(url, clientid, clientsecret)
172172
} else {
173173
const suggestion = 'Ensure Service Manager binding includes either (clientid + clientsecret) or (certificate + key + certurl)'
174-
LOG.error( 'Insufficient credentials for token fetching', null, suggestion, {
174+
LOG.error('Insufficient credentials for token fetching', null, suggestion, {
175175
hasClientId: !!clientid,
176176
hasClientSecret: !!clientsecret,
177177
hasCertificate: !!certificate,
@@ -274,7 +274,7 @@ async function fetchTokenWithMTLS(certURL, clientid, certificate, key) {
274274
const duration = Date.now() - startTime
275275

276276
if (!response.data?.access_token) {
277-
LOG.error( 'MTLS token response missing access_token', null,
277+
LOG.error('MTLS token response missing access_token', null,
278278
'Check MTLS certificate/key validity and Service Manager configuration',
279279
{ clientid, duration, responseData: response.data })
280280
throw new Error('Access token not found in MTLS token response')
@@ -305,10 +305,59 @@ async function computeHash(input) {
305305
return hash.digest('hex')
306306
}
307307

308+
309+
const multipliers = {}
310+
multipliers.B = 1;
311+
multipliers.KB = multipliers.B * 1024;
312+
multipliers.MB = multipliers.KB * 1024;
313+
multipliers.GB = multipliers.MB * 1024;
314+
multipliers.TB = multipliers.GB * 1024;
315+
multipliers.PB = multipliers.TB * 1024;
316+
multipliers.EB = multipliers.PB * 1024;
317+
multipliers.ZB = multipliers.EB * 1024;
318+
319+
const MAX_FILE_SIZE = 419430400 //400 MB in bytes
320+
321+
/**
322+
* Converts a byte size string into the corresponding number.
323+
*
324+
* @param {string} size 20mb for example
325+
* @returns Byte size or null if nothing was found
326+
*/
327+
function sizeInBytes(size, target) {
328+
if (!size || (typeof size !== 'string' && typeof size !== 'number')) {
329+
LOG.warn(`Could not determine the maximum byte size for the content of ${target}`)
330+
return
331+
}
332+
333+
if (typeof size === 'number') {
334+
return size
335+
}
336+
337+
const value = parseFloat(size);
338+
if (isNaN(value)) {
339+
LOG.warn(`Could not determine the maximum byte size for the content of ${target}`)
340+
return
341+
}
342+
343+
const unitMatches = size.toUpperCase().match(/([KMGTPEZ]I?)?B$/)
344+
// Remove any optional "i" from the unit of measurement (ex, MiB).
345+
const unit = unitMatches[0]?.replace(/i/i, "")
346+
347+
if (!unit) {
348+
LOG.warn(`Could not determine the maximum byte size for the content of ${target}`)
349+
return
350+
}
351+
352+
return value * multipliers[unit]
353+
}
354+
308355
module.exports = {
309356
fetchToken,
310357
getObjectStoreCredentials,
311358
computeHash,
359+
sizeInBytes,
312360
fetchObjectStoreBinding,
313-
validateServiceManagerCredentials
361+
validateServiceManagerCredentials,
362+
MAX_FILE_SIZE
314363
}

lib/plugin.js

Lines changed: 72 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -44,46 +44,83 @@ function unfoldModel(csn) {
4444
meta._enhanced_for_attachments = true
4545
}
4646

47-
cds.once("served", async function registerPluginHandlers() {
48-
if (!("sap.attachments.Attachments" in cds.model.definitions)) return
49-
const AttachmentsSrv = await cds.connect.to("attachments")
50-
// Searching all associations to attachments to add respective handlers
51-
for (let srv of cds.services) {
52-
if (srv instanceof cds.ApplicationService) {
53-
LOG.debug(`Registering handlers for attachments entities for service: ${srv.name}`)
54-
srv.before("READ", validateAttachment)
55-
srv.after("READ", readAttachment)
56-
srv.before("PUT", validateAttachmentSize)
57-
srv.before("NEW", onPrepareAttachment)
58-
srv.before("CREATE", (req) => {
59-
return onPrepareAttachment(req)
60-
})
47+
cds.ApplicationService.handle_attachments = cds.service.impl(async function () {
48+
if (!cds.env.requires.attachments) return;
49+
LOG.debug(`Registering handlers for attachments entities for service: ${this.name}`)
50+
this.before("READ", validateAttachment)
51+
this.after("READ", readAttachment)
52+
this.before("PUT", validateAttachmentSize)
53+
this.before("NEW", onPrepareAttachment)
54+
this.before("CREATE", (req) => {
55+
return onPrepareAttachment(req)
56+
})
6157

62-
srv.before(["DELETE", "UPDATE"], function collectDeletedAttachmentsForDraftEnabled(req) {
63-
if (!req.target?._attachments.hasAttachmentsComposition) return;
58+
this.before(["DELETE", "UPDATE"], async function collectDeletedAttachmentsForDraftEnabled(req) {
59+
if (!req.target?._attachments.hasAttachmentsComposition) return;
60+
const AttachmentsSrv = await cds.connect.to("attachments")
61+
return AttachmentsSrv.attachDeletionData.bind(AttachmentsSrv)(req)
62+
})
63+
this.after(["DELETE", "UPDATE"], async function deleteCollectedDeletedAttachmentsForDraftEnabled(res, req) {
64+
if (!req.target?._attachments.hasAttachmentsComposition) return;
65+
const AttachmentsSrv = await cds.connect.to("attachments")
66+
return AttachmentsSrv.deleteAttachmentsWithKeys.bind(AttachmentsSrv)(res, req)
67+
})
6468

65-
return AttachmentsSrv.attachDeletionData.bind(AttachmentsSrv)(req)
66-
})
67-
srv.after(["DELETE", "UPDATE"], function deleteCollectedDeletedAttachmentsForDraftEnabled(res, req) {
68-
if (!req.target?._attachments.hasAttachmentsComposition) return;
69+
// case: attachments uploaded in draft and draft is discarded
70+
this.before(["CANCEL"], async function collectDiscardedAttachmentsForDraftEnabled(req) {
71+
if (!req.target?.actives || !req.target?._attachments.hasAttachmentsComposition) return;
72+
const AttachmentsSrv = await cds.connect.to("attachments")
73+
return AttachmentsSrv.attachDraftDiscardDeletionData.bind(AttachmentsSrv)(req)
74+
})
75+
this.after(["CANCEL"], async function deleteCollectedDiscardedAttachmentsForDraftEnabled(res, req) {
76+
// Check for actives to make sure it is the draft entity
77+
if (!req.target?.actives || !req.target?._attachments.hasAttachmentsComposition) return;
78+
const AttachmentsSrv = await cds.connect.to("attachments")
79+
return AttachmentsSrv.deleteAttachmentsWithKeys.bind(AttachmentsSrv)(res, req)
80+
})
6981

70-
return AttachmentsSrv.deleteAttachmentsWithKeys.bind(AttachmentsSrv)(res, req)
71-
})
7282

73-
// case: attachments uploaded in draft and draft is discarded
74-
srv.before(["CANCEL"], function collectDiscardedAttachmentsForDraftEnabled(req) {
75-
if (!req.target?.actives || !req.target?._attachments.hasAttachmentsComposition) return;
83+
this.prepend(() =>
84+
this.on(
85+
["PUT", "UPDATE"],
86+
async function putUpdateAttachments(req, next) {
87+
// Skip entities which are not Attachments and skip if content is not updated
88+
if (!req.target._attachments.isAttachmentsEntity || !req.data.content) return next()
7689

77-
return AttachmentsSrv.attachDraftDiscardDeletionData.bind(AttachmentsSrv)(req)
78-
})
79-
srv.after(["CANCEL"], function deleteCollectedDiscardedAttachmentsForDraftEnabled(res, req) {
80-
// Check for actives to make sure it is the draft entity
81-
if (!req.target?.actives || !req.target?._attachments.hasAttachmentsComposition) return;
90+
let metadata = await this.run(SELECT.from(req.subject).columns('url', ...Object.keys(req.target.keys)))
91+
if (metadata.length > 1) {
92+
return req.error(501, 'MultiUpdateNotSupported')
93+
}
94+
metadata = metadata[0]
95+
if (!metadata) {
96+
return req.reject(404)
97+
}
98+
req.data.ID = metadata.ID
99+
req.data.url ??= metadata.url
100+
for (const key in metadata) {
101+
if (key.startsWith('up_')) {
102+
req.data[key] = metadata[key]
103+
}
104+
}
105+
const AttachmentsSrv = await cds.connect.to("attachments")
106+
return await AttachmentsSrv.put(req.target, req.data)
107+
}
108+
)
109+
)
82110

83-
return AttachmentsSrv.deleteAttachmentsWithKeys.bind(AttachmentsSrv)(res, req)
84-
})
111+
this.prepend(() =>
112+
this.on(
113+
["CREATE"],
114+
async function createAttachments(req, next) {
115+
if (!req.target._attachments.isAttachmentsEntity || req.req?.url?.endsWith('/content') || !req.data.url || !(req.data.content || (Array.isArray(req.data) && req.data[0]?.content))) return next()
116+
const AttachmentsSrv = await cds.connect.to("attachments")
117+
return AttachmentsSrv.put(req.target, req.data)
118+
}
119+
)
120+
)
85121

86-
AttachmentsSrv.registerHandlers(srv)
87-
}
88-
}
122+
123+
124+
const AttachmentsSrv = await cds.connect.to("attachments")
125+
AttachmentsSrv.registerHandlers(this)
89126
})

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@
4646
},
4747
"kinds": {
4848
"malwareScanner-mock": {
49+
"model": "@cap-js/attachments/srv/malwareScanner-mocked",
4950
"impl": "@cap-js/attachments/srv/malwareScanner-mocked"
5051
},
5152
"malwareScanner-btp": {
53+
"model": "@cap-js/attachments/srv/malwareScanner",
5254
"impl": "@cap-js/attachments/srv/malwareScanner"
5355
},
5456
"attachments-db": {
@@ -89,7 +91,6 @@
8991
"kind": "malwareScanner-mock"
9092
},
9193
"attachments": {
92-
"scan": false,
9394
"kind": "db"
9495
}
9596
},

srv/aws-s3.js

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ module.exports = class AWSAttachmentsService extends require("./object-store") {
140140
params: input,
141141
})
142142

143-
// The file upload has to be done first, so super.put can compute the hash
143+
// The file upload has to be done first, so super.put can compute the hash and trigger malware scan
144144
await multipartUpload.done()
145145
await super.put(attachments, metadata)
146146

@@ -152,14 +152,6 @@ module.exports = class AWSAttachmentsService extends require("./object-store") {
152152
key: Key,
153153
duration
154154
})
155-
156-
// Initiate malware scan if configured
157-
LOG.debug('Initiating malware scan for uploaded file', {
158-
fileId: metadata.ID,
159-
filename: metadata.filename
160-
})
161-
const MalwareScanner = await cds.connect.to('malwareScanner')
162-
await MalwareScanner.emit('ScanFile', { target: attachments.name, keys: { ID: metadata.ID } })
163155
} catch (err) {
164156
const duration = Date.now() - startTime
165157
LOG.error(

0 commit comments

Comments
 (0)