Skip to content

Commit 51ae0e4

Browse files
KoblerSschiwekM
andauthored
Add content overwrite support (#415)
# Add Content Overwrite Support for Attachments ### New Feature ✨ Introduces configurable content overwrite behavior for the `@cap-js/attachments` plugin. By default, attachment content is protected from being overwritten (returning a `409 Conflict` error). Users can now opt-in to allow content overwrites on a per-composition basis using the `@Capabilities.UpdateRestrictions.NonUpdateableProperties` annotation. ### Changes * `db/index.cds`: Added default annotation `@Capabilities.UpdateRestrictions.NonUpdateableProperties: [content]` to the `Attachments` aspect, preventing content overwrites out of the box. * `srv/basic.js`: Introduced the `_isContentUpdateRestricted()` helper method that inspects the `@Capabilities.UpdateRestrictions.NonUpdateableProperties` annotation on an attachment entity. The existing 409-conflict check in `put()` is now gated behind this helper, so the restriction is only enforced when the annotation includes `content`. * `srv/aws-s3.js`, `srv/azure-blob-storage.js`, `srv/gcp.js`: Updated the `put()` upload guard in all three storage backends to call `_isContentUpdateRestricted()` before checking for an existing file, enabling overwrite behavior when the annotation allows it. * `tests/incidents-app/db/attachments.cds`: Added a new `overwritableAttachments` composition annotated with `@Capabilities.UpdateRestrictions.NonUpdateableProperties: []` for testing the opt-in overwrite scenario. * `tests/integration/attachments-non-draft.test.js`: Added an integration test that verifies content can be successfully overwritten when the annotation is set to an empty array. * `README.md`: Added a new **Allow Overwriting Attachment Content** section documenting the feature, including a CDS example and a note about runtime evaluation across storage backends. Also updated the Table of Contents. * `CHANGELOG.md`: Documented the new feature under version `3.11.0`. * `package.json`: Bumped version from `3.10.0` to `3.11.0`. - [ ] 🔄 Regenerate and Update Summary --- 📬 [Subscribe to the Hyperspace PR Bot DL](https://url.sap/451kgs) to get the latest announcements and pilot features! <details> <summary>PR Bot Information</summary> **Version:** `1.19.15` | 📖 [Documentation](https://url.sap/dy9ocn) | 🚨 [Create Incident](https://url.sap/budnv9) | 💬 [Feedback](https://url.sap/my4dn3) - Output Template: [Default Template](https://github.tools.sap/intelligent-insights/i2-pull-request/blob/main/src/services/llm/prompts/summary_default_output_template.md) - Summary Prompt: [Default Prompt](https://github.tools.sap/intelligent-insights/i2-pull-request/blob/main/src/services/llm/prompts/summary_instructions_prompt.md) - File Content Strategy: Full file content - LLM: `anthropic--claude-4.6-sonnet` - Correlation ID: `5ade5400-2e74-11f1-9f6b-5a4534e5aad0` - Event Trigger: `pull_request.opened` </details> --------- Co-authored-by: Marten Schiwek <marten.schiwek@sap.com>
1 parent 0619fac commit 51ae0e4

10 files changed

Lines changed: 136 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44
This project adheres to [Semantic Versioning](http://semver.org/).
55
The format is based on [Keep a Changelog](http://keepachangelog.com/).
66

7+
## Version 3.11.0
8+
9+
### Added
10+
11+
- Support for controlling content overwrite behavior via `@Capabilities.UpdateRestrictions.NonUpdateableProperties`. By default, `content` is listed as non-updateable, preventing overwrites with a `409` error. Setting the annotation to an empty array (`[]`) on a specific attachment composition allows content to be overwritten.
12+
13+
### Fixed
14+
715
## Version 3.10.0
816

917
### Added

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,18 @@ The `@cap-js/attachments` package is a [CDS plugin](https://cap.cloud.sap/docs/n
1717
- [Storage Targets](#storage-targets)
1818
- [Malware Scanner](#malware-scanner)
1919
- [Automatic file rescanning](#automatic-file-rescanning)
20+
- [Audit logging](#audit-logging)
2021
- [Visibility Control for Attachments UI Facet Generation](#visibility-control-for-attachments-ui-facet-generation)
2122
- [Example Usage](#example-usage)
22-
- [Non-Draft Upload](#non-draft-upload)
2323
- [Copying Attachments](#copying-attachments)
24+
- [Examples](#examples)
25+
- [Non-Draft Upload](#non-draft-upload)
2426
- [Specify the maximum file size](#specify-the-maximum-file-size)
2527
- [Restrict allowed MIME types](#restrict-allowed-mime-types)
2628
- [Minimum and Maximum Number of Attachments](#minimum-and-maximum-number-of-attachments)
2729
- [Limit to a Maximum of 2 Attachments](#limit-to-a-maximum-of-2-attachments)
2830
- [Require a Minimum of 2 Attachments](#require-a-minimum-of-2-attachments)
31+
- [Allow Overwriting Attachment Content](#allow-overwriting-attachment-content)
2932
- [Releases](#releases)
3033
- [Minimum UI5 and CAP NodeJS Version](#minimum-ui5-and-cap-nodejs-version)
3134
- [Architecture Overview](#architecture-overview)
@@ -446,6 +449,30 @@ entity Incidents {
446449
}
447450
```
448451

452+
### Allow Overwriting Attachment Content
453+
454+
By default, the `Attachments` aspect annotates the entity with `@Capabilities.UpdateRestrictions.NonUpdateableProperties: [content]`, which prevents overwriting the content of an existing attachment. Any attempt to upload new content to an attachment that already has content will be rejected with a `409 Conflict` error.
455+
456+
To allow overwriting attachment content, override the annotation with an empty array on the specific attachment composition:
457+
458+
```cds
459+
using { Attachments } from '@cap-js/attachments';
460+
461+
entity Incidents {
462+
...
463+
attachments: Composition of many Attachments;
464+
}
465+
466+
// Allow content to be overwritten
467+
annotate Incidents.attachments with
468+
@Capabilities.UpdateRestrictions.NonUpdateableProperties: [] {};
469+
```
470+
471+
With this annotation in place, uploading new content via `PUT` to an attachment that already has content will overwrite the existing content instead of returning a `409` error.
472+
473+
> [!NOTE]
474+
> This annotation is evaluated at runtime by all storage backends. When content overwrite is allowed, uploading to an existing attachment replaces the stored file.
475+
449476
## Releases
450477

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

db/index.cds

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ context sap.attachments {
3838
note : String @title: '{i18n>Note}' @UI.MultiLineText;
3939
}
4040

41+
annotate Attachments with @Capabilities.UpdateRestrictions.NonUpdateableProperties : [
42+
content
43+
];
44+
4145

4246
// -- Fiori Annotations ----------------------------------------------------------
4347

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@cap-js/attachments",
33
"description": "CAP cds-plugin providing image and attachment storing out-of-the-box.",
4-
"version": "3.10.0",
4+
"version": "3.11.0",
55
"repository": "cap-js/attachments",
66
"author": "SAP SE (https://www.sap.com)",
77
"homepage": "https://cap.cloud.sap/",

srv/aws-s3.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,10 @@ module.exports = class AWSAttachmentsService extends require("./object-store") {
171171
return
172172
}
173173

174-
if (await this.exists(Key)) {
174+
if (
175+
this._isContentUpdateRestricted(attachments) &&
176+
(await this.exists(Key))
177+
) {
175178
const error = new Error("Attachment already exists")
176179
error.status = 409
177180
throw error

srv/azure-blob-storage.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,10 @@ module.exports = class AzureAttachmentsService extends (
156156

157157
const blobClient = containerClient.getBlockBlobClient(blobName)
158158

159-
if (await this.exists(blobName)) {
159+
if (
160+
this._isContentUpdateRestricted(attachments) &&
161+
(await this.exists(blobName))
162+
) {
160163
const error = new Error("Attachment already exists")
161164
error.status = 409
162165
throw error

srv/basic.js

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,28 @@ class AttachmentsService extends cds.Service {
7070
return super.init()
7171
}
7272

73+
/**
74+
* Checks whether content updates are restricted for the given attachment entity.
75+
* Returns true if `content` is listed in @Capabilities.UpdateRestrictions.NonUpdateableProperties,
76+
* meaning overwriting existing content is NOT allowed.
77+
* Returns false if the annotation is missing, is an empty array, or does not include `content`.
78+
* @param {import('@sap/cds').Entity} attachments - Attachments entity definition
79+
* @returns {boolean}
80+
*/
81+
_isContentUpdateRestricted(attachments) {
82+
const nonUpdateable =
83+
attachments["@Capabilities.UpdateRestrictions.NonUpdateableProperties"]
84+
// If annotation is not set, allow content overwrite by default
85+
if (!nonUpdateable) return false
86+
// If it's an array, check if 'content' is listed
87+
if (Array.isArray(nonUpdateable)) {
88+
return nonUpdateable.some(
89+
(prop) => prop === "content" || prop?.["="] === "content",
90+
)
91+
}
92+
return false
93+
}
94+
7395
/**
7496
* Uploads attachments to the database and initiates malware scans for database-stored files
7597
* @param {import('@sap/cds').Entity} attachments - Attachments entity definition
@@ -81,14 +103,16 @@ class AttachmentsService extends cds.Service {
81103
data = [data]
82104
}
83105

84-
// Check if an attachment with this ID already has content
85-
const existing = await SELECT.one
86-
.from(attachments)
87-
.where({ ID: { in: data.map((d) => d.ID) }, content: { "!=": null } })
88-
if (existing) {
89-
const error = new Error("Attachment already exists")
90-
error.status = 409
91-
throw error
106+
// Check if an attachment with this ID already has content (only if content is non-updateable)
107+
if (this._isContentUpdateRestricted(attachments)) {
108+
const existing = await SELECT.one
109+
.from(attachments)
110+
.where({ ID: { in: data.map((d) => d.ID) }, content: { "!=": null } })
111+
if (existing) {
112+
const error = new Error("Attachment already exists")
113+
error.status = 409
114+
throw error
115+
}
92116
}
93117

94118
LOG.debug("Starting database attachment upload", {

srv/gcp.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,10 @@ module.exports = class GoogleAttachmentsService extends (
162162

163163
const file = bucket.file(blobName)
164164

165-
if (await this.exists(blobName)) {
165+
if (
166+
this._isContentUpdateRestricted(attachments) &&
167+
(await this.exists(blobName))
168+
) {
166169
const error = new Error("Attachment already exists")
167170
error.status = 409
168171
throw error

tests/incidents-app/db/attachments.cds

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ extend my.Incidents with {
1616

1717
@UI.Hidden
1818
maximumSizeAttachments : Composition of many Attachments;
19+
20+
@UI.Hidden
21+
overwritableAttachments : Composition of many Attachments;
1922
}
2023

2124
annotate my.Incidents.maximumSizeAttachments with {
@@ -26,6 +29,9 @@ annotate my.Incidents.mediaTypeAttachments with {
2629
content @Core.AcceptableMediaTypes: ['image/jpeg'];
2730
}
2831

32+
// Allow overwriting content for overwritableAttachments by setting empty NonUpdateableProperties
33+
annotate my.Incidents.overwritableAttachments with @Capabilities.UpdateRestrictions.NonUpdateableProperties: [];
34+
2935
@UI.Facets: [{
3036
$Type : 'UI.ReferenceFacet',
3137
Target: 'attachments/@UI.LineItem',

tests/integration/attachments-non-draft.test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,51 @@ describe("Tests for uploading/deleting and fetching attachments through API call
224224
)
225225
})
226226

227+
it("should ALLOW overwriting content when @Capabilities.UpdateRestrictions.NonUpdateableProperties is empty", async () => {
228+
const incidentID = await newIncident(POST, "admin")
229+
230+
// Create attachment metadata on overwritableAttachments
231+
const createRes = await POST(
232+
`/odata/v4/admin/Incidents(${incidentID})/overwritableAttachments`,
233+
{ filename: "sample.pdf" },
234+
{ headers: { "Content-Type": "application/json" } },
235+
)
236+
const attachmentID = createRes.data.ID
237+
expect(attachmentID).toBeDefined()
238+
239+
// Upload initial file content
240+
const fileContent = readFileSync(
241+
join(__dirname, "..", "integration", "content/sample.pdf"),
242+
)
243+
const uploadRes = await PUT(
244+
`/odata/v4/admin/Incidents(${incidentID})/overwritableAttachments(up__ID=${incidentID},ID=${attachmentID})/content`,
245+
fileContent,
246+
{
247+
headers: {
248+
"Content-Type": "application/pdf",
249+
"Content-Length": fileContent.length,
250+
},
251+
},
252+
)
253+
expect(uploadRes.status).toBe(204)
254+
255+
// Overwrite with different content - this should succeed
256+
const newFileContent = readFileSync(
257+
join(__dirname, "..", "integration", "content/test.pdf"),
258+
)
259+
const overwriteRes = await PUT(
260+
`/odata/v4/admin/Incidents(${incidentID})/overwritableAttachments(up__ID=${incidentID},ID=${attachmentID})/content`,
261+
newFileContent,
262+
{
263+
headers: {
264+
"Content-Type": "application/pdf",
265+
"Content-Length": newFileContent.length,
266+
},
267+
},
268+
)
269+
expect(overwriteRes.status).toBe(204)
270+
})
271+
227272
it("should add and fetch attachments for both NonDraftTest and SingleTestDetails in non-draft mode", async () => {
228273
const testID = cds.utils.uuid()
229274
const detailsID = cds.utils.uuid()

0 commit comments

Comments
 (0)