Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
- If `@cap-js/audit-logging` is installed automatically trigger audit logs for the security events.
- Duplicate file names to a single attachment entity are automatically assigned a distinguishing suffix.
- Local testing using a Postgres database now possible.
- Native server-side `copy()` method on `AttachmentsService` for copying attachments between entities without transferring binary data through the application. Supports all storage backends (DB, AWS S3, Azure Blob Storage, GCP Cloud Storage) with backend-native copy operations.

### Fixed

Expand Down
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The `@cap-js/attachments` package is a [CDS plugin](https://cap.cloud.sap/docs/n
- [Visibility Control for Attachments UI Facet Generation](#visibility-control-for-attachments-ui-facet-generation)
- [Example Usage](#example-usage)
- [Non-Draft Upload](#non-draft-upload)
- [Copying Attachments](#copying-attachments)
- [Specify the maximum file size](#specify-the-maximum-file-size)
- [Restrict allowed MIME types](#restrict-allowed-mime-types)
- [Minimum and Maximum Number of Attachments](#minimum-and-maximum-number-of-attachments)
Expand Down Expand Up @@ -289,6 +290,79 @@ entity Incidents {
}
```

### Copying Attachments

The `AttachmentsService` exposes a programmatic `copy()` method that copies an attachment to a new record. On cloud storage backends (AWS S3, Azure Blob Storage, GCP Cloud Storage) this uses a backend-native server-side copy — no binary data is transferred through your application. On database storage it reads and inserts the content directly.

**Signature:**

```js
const AttachmentsSrv = await cds.connect.to("attachments")
await AttachmentsSrv.copy(
sourceAttachmentsEntity,
sourceKeys,
targetAttachmentsEntity,
(targetKeys = {}),
)
```

| Parameter | Description |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sourceAttachmentsEntity` | CDS entity definition of the source attachment composition. |
| `sourceKeys` | Keys of the attachment (e.g. `{ ID: '...' }`) |
| `targetAttachmentsEntity` | CDS entity definition of the target attachment composition. |
| `targetKeys` | Parent FK fields for the new record (e.g. `{ up__ID: '...' }`). When `targetAttachmentsEntity` is a draft table, must also include `DraftAdministrativeData_DraftUUID`. |

The scan `status`, `lastScan`, and `hash` are inherited from the source — no re-scan is triggered since the binary content is identical. Copying an attachment when the status is not `Clean` is rejected with a `400` error.

> [!NOTE]
> Only copies within the same tenant are supported. Cross-tenant copies are not possible.

#### Examples

<details>

<summary>copy between two active records:</summary>

```js
const { Incidents } = ProcessorService.entities

await AttachmentsSrv.copy(
Incidents.attachments,
{ ID: sourceAttachmentID },
Incidents.attachments,
{ up__ID: targetIncidentID },
)
```

</details>

<details>

<summary>copy into a new draft record (e.g. creating an incident from a template)</summary>

```js
const { Incidents } = ProcessorService.entities

// Look up the draft session UUID for the target incident's open draft
const targetDraft = await SELECT.one
.from(Incidents.drafts, { ID: targetIncidentID })
.columns("DraftAdministrativeData_DraftUUID")

await AttachmentsSrv.copy(
Incidents.attachments,
{ ID: sourceAttachmentID },
Incidents.attachments.drafts,
{
up__ID: targetIncidentID,
DraftAdministrativeData_DraftUUID:
targetDraft.DraftAdministrativeData_DraftUUID,
},
)
```

</details>

### Non-Draft Upload

For scenarios where the entity is not draft-enabled, for example [`tests/non-draft-request.http`](./tests/non-draft-request.http), separate HTTP requests for metadata creation and asset uploading need to be performed manually.
Expand Down
8 changes: 8 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import cds from "@sap/cds/eslint.config.mjs"
export default [
...cds,
{
rules: {
"no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", reportUsedIgnorePattern: true },
],
},
},
{
name: "test-files-config",
files: ["tests/**/*"],
Expand Down
15 changes: 5 additions & 10 deletions lib/generic-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ const {
} = require("./helper")
const { getMime } = require("./mime")

const isMultitenacyEnabled = !!cds.env.requires.multitenancy
const objectStoreKind = cds.env.requires?.attachments?.objectStore?.kind

/**
* Finalizes the preparation of a single attachment's data
* @param {object} data - The attachment data
Expand Down Expand Up @@ -57,10 +54,9 @@ async function finalizePrepareAttachment(data, req) {
}

if (!data.url) {
data.url =
isMultitenacyEnabled && objectStoreKind === "shared"
? `${req.tenant}_${cds.utils.uuid()}`
: cds.utils.uuid()
const attachment = await cds.connect.to("attachments")
// Generate URL for object store
data.url = await attachment.createUrlForAttachment(data)
}
data.ID ??= cds.utils.uuid()

Expand Down Expand Up @@ -368,10 +364,9 @@ async function validateAndInsertAttachmentFromDBHandler(data, target, req) {
)
return

// Generate URL for object store
data.url = cds.utils.uuid()

const attachment = await cds.connect.to("attachments")
// Generate URL for object store
data.url = await attachment.createUrlForAttachment(data)

// Let the attachments service handle storage (object store + DB metadata)
await attachment.put(target, data)
Expand Down
38 changes: 38 additions & 0 deletions srv/aws-s3.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const {
GetObjectCommand,
DeleteObjectCommand,
HeadObjectCommand,
CopyObjectCommand,
} = require("@aws-sdk/client-s3")
const { Upload } = require("@aws-sdk/lib-storage")
const cds = require("@sap/cds")
Expand Down Expand Up @@ -328,6 +329,43 @@ module.exports = class AWSAttachmentsService extends require("./object-store") {
}
}

/**
* @inheritdoc
*/
async copy(
sourceAttachmentsEntity,
sourceKeys,
targetAttachmentsEntity,
targetKeys = {},
) {
LOG.debug("Copying attachment (S3)", {
source: sourceAttachmentsEntity.name,
sourceKeys,
target: targetAttachmentsEntity.name,
})
const safeTargetKeys = this._sanitizeTargetKeys(targetKeys)
const { source, newID, newUrl } = await this._prepareCopy(
sourceAttachmentsEntity,
sourceKeys,
)
const { client, bucket } = await this.retrieveClient()
if (await this.exists(newUrl)) {
const err = new Error("Target blob already exists")
err.status = 409
throw err
}
await client.send(
new CopyObjectCommand({
Bucket: bucket,
CopySource: `${bucket}/${source.url}`,
Key: newUrl,
}),
)
const newRecord = { ...source, ...safeTargetKeys, ID: newID, url: newUrl }
await INSERT(newRecord).into(targetAttachmentsEntity)
return newRecord
}

/**
* Deletes a file from S3 based on the provided key
* @param {string} Key - The key of the file to delete
Expand Down
33 changes: 33 additions & 0 deletions srv/azure-blob-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,39 @@ module.exports = class AzureAttachmentsService extends (
}
}

/**
* @inheritdoc
*/
async copy(
sourceAttachmentsEntity,
sourceKeys,
targetAttachmentsEntity,
targetKeys = {},
) {
LOG.debug("Copying attachment (Azure)", {
source: sourceAttachmentsEntity.name,
sourceKeys,
target: targetAttachmentsEntity.name,
})
const safeTargetKeys = this._sanitizeTargetKeys(targetKeys)
const { source, newID, newUrl } = await this._prepareCopy(
sourceAttachmentsEntity,
sourceKeys,
)
const { containerClient } = await this.retrieveClient()
if (await this.exists(newUrl)) {
const err = new Error("Target blob already exists")
err.status = 409
throw err
}
const sourceBlobClient = containerClient.getBlockBlobClient(source.url)
const targetBlobClient = containerClient.getBlockBlobClient(newUrl)
await targetBlobClient.syncCopyFromURL(sourceBlobClient.url)
const newRecord = { ...source, ...safeTargetKeys, ID: newID, url: newUrl }
await INSERT(newRecord).into(targetAttachmentsEntity)
return newRecord
}

/**
* Deletes a file from Azure Blob Storage
* @param {string} Key - The key of the file to delete
Expand Down
126 changes: 126 additions & 0 deletions srv/basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,132 @@ class AttachmentsService extends cds.Service {
}
}

/**
* Strips protected fields from targetKeys so callers cannot override
* security-sensitive metadata (status, hash, url, etc.).
* @param {object} targetKeys - Raw target keys from the caller
* @returns {object} - Sanitized target keys containing only FK fields
*/
_sanitizeTargetKeys(targetKeys) {
const sanitized = {}
for (const [key, value] of Object.entries(targetKeys)) {
if (key.startsWith("up_") || key.startsWith("DraftAdministrativeData")) {
sanitized[key] = value
} else {
LOG.warn(`Ignoring protected field in targetKeys: ${key}`)
}
}
return sanitized
}

/**
*
* @param {*} data
* @returns
*/
createUrlForAttachment() {
const isMultiTenancyEnabled = !!cds.env.requires.multitenancy
const objectStoreKind = cds.env.requires?.attachments?.objectStore?.kind
return isMultiTenancyEnabled && objectStoreKind === "shared"
? `${cds.context.tenant}_${cds.utils.uuid()}`
: cds.utils.uuid()
}

/**
* Prepares a copy operation by validating the source and generating new identifiers.
* Shared by all storage backends.
* @param {import('@sap/cds').Entity} sourceAttachmentsEntity - Source attachment entity definition
* @param {object} sourceKeys - Keys identifying the source attachment (e.g. { ID: '...' })
* @returns {Promise<{ source: object, newID: string, newUrl: string }>}
*/
async _prepareCopy(sourceAttachmentsEntity, sourceKeys) {
// srv.run so auth is enforced
const srv = await cds.connect.to(
sourceAttachmentsEntity._service?.name ?? "db",
)
const source = await srv.run(
SELECT.one
.from(sourceAttachmentsEntity, sourceKeys)
.columns(
"url",
"filename",
"mimeType",
"note",
"hash",
"status",
"lastScan",
),
)
if (!source) {
const err = new Error("Source attachment not found")
err.status = 404
throw err
}
if (source.status !== "Clean") {
const err = new Error(
`Cannot copy attachment with status: ${source.status}. Only a Clean Status is allowed`,
)
err.status = 400
throw err
}
const newUrl = this.createUrlForAttachment(source)

return { source, newID: cds.utils.uuid(), newUrl }
}

/**
* Copies an attachment to a new record, reusing the binary content from storage.
* For DB storage, reads content and inserts a new record.
* Cloud backends override this to use native server-side copy.
* Scan status, lastScan, and hash are inherited from the source — no re-scan needed.
*
* Tenant note: only copies within the same tenant are supported. Cross-tenant
* copies are not allowed because the storage backends resolve credentials for
* the current tenant only.
*
* @param {import('@sap/cds').Entity} sourceAttachmentsEntity - Source attachment entity definition.
* Pass `sourceAttachmentsEntity.drafts` to copy from a draft-only source.
* @param {object} sourceKeys - Keys identifying the source attachment (e.g. { ID: '...' })
* @param {import('@sap/cds').Entity} targetAttachmentsEntity - Target attachment entity definition.
* Pass `targetAttachmentsEntity.drafts` to insert into the draft shadow table (i.e. when the target
* parent entity is currently in a draft editing session). In that case targetKeys must include
* DraftAdministrativeData_DraftUUID.
* @param {object} [targetKeys={}] - Parent FK fields for the new record (e.g. { up__ID: '...' }).
* When targeting a draft table, must also include DraftAdministrativeData_DraftUUID.
* Protected fields (status, hash, url, etc.) are stripped automatically.
* @returns {Promise<object>} - New attachment metadata (without content)
*/
async copy(
sourceAttachmentsEntity,
sourceKeys,
targetAttachmentsEntity,
targetKeys = {},
) {
LOG.debug("Copying attachment (DB)", {
source: sourceAttachmentsEntity.name,
sourceKeys,
target: targetAttachmentsEntity.name,
})
const safeTargetKeys = this._sanitizeTargetKeys(targetKeys)
const { source, newID, newUrl } = await this._prepareCopy(
sourceAttachmentsEntity,
sourceKeys,
)
const content = await this.get(sourceAttachmentsEntity, sourceKeys)
const newRecord = {
...source,
// Must be spread into afterwards else source up_ overrides target keys
...safeTargetKeys,
ID: newID,
url: newUrl,
}
await INSERT.into(targetAttachmentsEntity).entries({
...newRecord,
content,
})
return newRecord
}

/**
* Deletes a file from the database. Does not delete metadata
* @param {string} url - The url of the file to delete
Expand Down
Loading