Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
The format is based on [Keep a Changelog](http://keepachangelog.com/).

## Version 4.0.0

### Changed

- **Breaking:** The `outbox` configuration key under `cds.requires.attachments` has been renamed to `queue`. Projects that explicitly set `attachments.outbox: true` in their own CDS configuration must rename the key to `queue`. A deprecation warning is logged at startup when the old key is detected.

## Version 3.12.3

### Fixed
Expand Down
15 changes: 2 additions & 13 deletions lib/generic-handlers.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
const cds = require("@sap/cds")
const LOG = cds.log("attachments")
const { extname } = require("path")
const {
MAX_FILE_SIZE,
sizeInBytes,
checkMimeTypeMatch,
inferTargetCAP8,
} = require("./helper")
const { MAX_FILE_SIZE, sizeInBytes, checkMimeTypeMatch } = require("./helper")
const { getMime } = require("./mime")

/**
Expand All @@ -27,13 +22,7 @@ async function finalizePrepareAttachment(data, req) {
// Only try to populate parent keys if there is a parent reference
if (parentRef && parentRef.length > 0) {
let target
if (cds.infer?.target) {
// CAP 9+: Use cds.infer.target
target = cds.infer.target({ SELECT: { from: { ref: parentRef } } })
} else {
// CAP 8 fallback: Use inferTargetCAP8 helper
target = inferTargetCAP8(req, parentRef)
}
target = cds.infer.target({ SELECT: { from: { ref: parentRef } } })

if (target?.keys) {
LOG.info(`Populating parent keys for attachment upload`, target)
Expand Down
28 changes: 0 additions & 28 deletions lib/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -428,33 +428,6 @@ async function computeHash(input) {
return hash.digest("hex")
}

function inferTargetCAP8(req, ref) {
const model = cds.context?.model || cds.model

// Extract entity/navigation names from ref array
// Handle both simple strings and objects with .id property
const names = ref
.map((part) => {
if (typeof part === "string") return part
if (typeof part === "object" && part.id) return part.id
return null
})
.filter(Boolean)

const name = names.join(".")

let target = model.definitions[name]
if (!target) return null

// draft fallback
if (target["@odata.draft.enabled"]) {
const draft = model.definitions[`${name}.drafts`]
if (draft) target = draft
}

return target
}

const multipliers = {}
multipliers.B = 1
multipliers.KB = multipliers.B * 1024
Expand Down Expand Up @@ -848,7 +821,6 @@ module.exports = {
traverseEntity,
buildBackAssocChain,
MAX_FILE_SIZE,
inferTargetCAP8,
getAttachmentKind,
handleDuplicates,
createSizeCheckHandler,
Expand Down
19 changes: 9 additions & 10 deletions lib/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,11 @@ const {
validateAttachmentMimeType,
validateAndInsertAttachmentFromDBHandler,
} = require("./generic-handlers")
const {
inferTargetCAP8,
getAttachmentKind,
handleDuplicates,
} = require("./helper")
const { getAttachmentKind, handleDuplicates } = require("./helper")
require("./csn-runtime-extension")
const LOG = cds.log("attachments")

cds.on(cds.version >= "8.6.0" ? "compile.to.edmx" : "loaded", unfoldModel)
cds.on("compile.to.edmx", unfoldModel)

// Register the db handler ONCE (not per-service) to intercept attachment INSERT
// and handle it through the attachments service instead of native DB insert
Expand All @@ -24,6 +20,11 @@ cds.on(cds.version >= "8.6.0" ? "compile.to.edmx" : "loaded", unfoldModel)
// NOTE: Must use db.prepend() to ensure handlers run before existing ones
cds.once("served", () => {
if (!cds.env.requires.attachments) return
if (cds.env.requires.attachments?.outbox !== undefined) {
LOG.warn(
"`cds.requires.attachments.outbox` is deprecated; use `queue` instead.",
)
}
const { db } = cds.services

db.prepend(() => {
Expand Down Expand Up @@ -511,8 +512,7 @@ cds.ApplicationService.handle_attachments = cds.service.impl(async function () {
if (req.query?.INSERT?.into.ref.length > 1) {
const ref = req.query.INSERT.into.ref.slice(0, -1)
const parentQuery = { SELECT: { from: { ref: ref }, one: true } }
const parent =
cds.infer?.target?.(parentQuery) || inferTargetCAP8(req, ref)
const parent = cds.infer.target(parentQuery)
if (!parent) {
LOG.warn(
`Could not determine parent target. Ref: ${JSON.stringify(ref)}`,
Expand Down Expand Up @@ -593,8 +593,7 @@ cds.ApplicationService.handle_attachments = cds.service.impl(async function () {
if (req.query?.DELETE?.from?.ref.length > 1) {
const ref = req.query.DELETE.from.ref.slice(0, -1)
const parentQuery = { SELECT: { from: { ref: ref }, one: true } }
const parent =
cds.infer?.target?.(parentQuery) || inferTargetCAP8(req, ref)
const parent = cds.infer.target(parentQuery)
if (!parent) {
LOG.warn(
`Could not determine parent target. Ref: ${JSON.stringify(ref)}`,
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@cap-js/attachments",
"description": "CAP cds-plugin providing image and attachment storing out-of-the-box.",
"version": "3.12.2",
"version": "4.0.0",
"repository": "cap-js/attachments",
"author": "SAP SE (https://www.sap.com)",
"homepage": "https://cap.cloud.sap/",
Expand Down Expand Up @@ -39,7 +39,7 @@
"husky": "^9.1.7"
},
"peerDependencies": {
"@sap/cds": ">=8"
"@sap/cds": ">=9"
Comment thread
eric-pSAP marked this conversation as resolved.
},
"engines": {
"node": ">=18.0.0"
Expand Down Expand Up @@ -94,7 +94,7 @@
}
},
"attachments": {
"outbox": true,
"queue": true,
Comment thread
eric-pSAP marked this conversation as resolved.
Outdated
Comment thread
eric-pSAP marked this conversation as resolved.
Outdated
"scan": true,
"deduplicateFileNames": true,
"scanExpiryMs": 259200000,
Expand Down
13 changes: 2 additions & 11 deletions srv/attachments/gcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -392,17 +392,8 @@ module.exports = class GoogleAttachmentsService extends (
)

const file = bucket.file(blobName)
let response
try {
response = await file.delete()
} catch (error) {
if (error.statusCode === 404) {
response = error
} else {
throw error
}
}
if (response?.[0]?.statusCode !== 204) {
const [response] = await file.delete({ ignoreNotFound: true })
if (response?.statusCode !== 204) {
LOG.warn("File has not been deleted from Google Cloud Storage", {
blobName,
bucketName: bucket.name,
Expand Down
42 changes: 21 additions & 21 deletions tests/integration/attachments-non-draft.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,20 @@ describe("Tests for uploading/deleting and fetching attachments through API call
expect(resCreate.data.title).toBe("New Incident")
})

it("should create attachment metadata", async () => {
it("Should create attachment metadata", async () => {
const incidentID = await newIncident(POST, "admin")
const attachmentID = await createAttachmentMetadata(incidentID)
expect(attachmentID).toBeDefined()
})

it("should upload attachment content", async () => {
it("Should upload attachment content", async () => {
const incidentID = await newIncident(POST, "admin")
const attachmentID = await createAttachmentMetadata(incidentID)
const response = await uploadAttachmentContent(incidentID, attachmentID)
expect(response.status).toBe(204)
})

it("unknown extension throws warning", async () => {
it("Unknown extension throws warning", async () => {
const incidentID = await newIncident(POST, "admin")
const response = await POST(
`/odata/v4/admin/Incidents(${incidentID})/attachments`,
Expand All @@ -66,7 +66,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call
)
})

it("should list attachments for incident", async () => {
it("Should list attachments for incident", async () => {
const incidentID = await newIncident(POST, "admin")
const attachmentID = await createAttachmentMetadata(incidentID)
const scanCleanWaiter = waitForScanStatus("Clean", attachmentID)
Expand Down Expand Up @@ -110,7 +110,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call
expect(Buffer.compare(response.data, originalContent)).toBe(0)
})

it("should delete attachment and verify deletion", async () => {
it("Should delete attachment and verify deletion", async () => {
const incidentID = await newIncident(POST, "admin")
const attachmentID = await createAttachmentMetadata(incidentID)
const scanCleanWaiter = waitForScanStatus("Clean", attachmentID)
Expand Down Expand Up @@ -199,7 +199,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call
expect(responseContent.status).toBe(200)
})

it("should NOT allow overwriting an existing attachment file via /content handler", async () => {
it("Should NOT allow overwriting an existing attachment file via /content handler", async () => {
const incidentID = await newIncident(POST, "admin")
// Create attachment metadata
const attachmentID = await createAttachmentMetadata(incidentID)
Expand Down Expand Up @@ -236,7 +236,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call
)
})

it("should ALLOW overwriting content when @Capabilities.UpdateRestrictions.NonUpdateableProperties is empty", async () => {
it("Should ALLOW overwriting content when @Capabilities.UpdateRestrictions.NonUpdateableProperties is empty", async () => {
const incidentID = await newIncident(POST, "admin")

// Create attachment metadata on overwritableAttachments
Expand Down Expand Up @@ -281,7 +281,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call
expect(overwriteRes.status).toBe(204)
})

it("should add and fetch attachments for both NonDraftTest and SingleTestDetails in non-draft mode", async () => {
it("Should add and fetch attachments for both NonDraftTest and SingleTestDetails in non-draft mode", async () => {
const testID = cds.utils.uuid()
const detailsID = cds.utils.uuid()
await POST(`odata/v4/processor/NonDraftTest`, {
Expand Down Expand Up @@ -331,7 +331,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call
expect(childAttachment.data.filename).toBe("childfile.pdf")
})

it("should delete attachments for both NonDraftTest and SingleTestDetails in non-draft mode", async () => {
it("Should delete attachments for both NonDraftTest and SingleTestDetails in non-draft mode", async () => {
const testID = cds.utils.uuid()
const detailsID = cds.utils.uuid()
await POST(`odata/v4/processor/NonDraftTest`, {
Expand Down Expand Up @@ -393,7 +393,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call
})

// prettier-ignore
isNotLocal("should delete file from object store if data is deleted", async () => {
isNotLocal("Should delete file from object store if data is deleted", async () => {
const detailsID = cds.utils.uuid()

const testID = await newIncident(
Expand Down Expand Up @@ -443,7 +443,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call
expect(await deletion).toBe(true)
})

it("should create NonDraftTest entities using programmatic INSERT and add attachments", async () => {
it("Should create NonDraftTest entities using programmatic INSERT and add attachments", async () => {
const firstID = cds.utils.uuid()
const secondID = cds.utils.uuid()

Expand Down Expand Up @@ -498,7 +498,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call
expect(attachment2.data.filename).toBe("file2.pdf")
})

it("should delete attachments for both NonDraftTest and SingleTestDetails when entities are deleted in non-draft mode", async () => {
it("Should delete attachments for both NonDraftTest and SingleTestDetails when entities are deleted in non-draft mode", async () => {
const testID = cds.utils.uuid()
const detailsID = cds.utils.uuid()
await POST(`odata/v4/processor/NonDraftTest`, {
Expand Down Expand Up @@ -553,7 +553,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call
})
})

it("should handle duplicate filenames on deep insert", async () => {
it("Should handle duplicate filenames on deep insert", async () => {
const incidentID = cds.utils.uuid()
const { data: incident } = await POST("/odata/v4/admin/Incidents", {
ID: incidentID,
Expand Down Expand Up @@ -838,7 +838,7 @@ describe("Testing max and min amounts of attachments", () => {
})
})

it("custom error message can be specified targeting composition property", async () => {
it("Custom error message can be specified targeting composition property", async () => {
await POST(`odata/v4/validation-test-non-draft/Incidents`, {
customer_ID: "1004155",
title: "ABC",
Expand All @@ -860,7 +860,7 @@ describe("Testing max and min amounts of attachments", () => {
})
})

it("custom error message can be specified for entity", async () => {
it("Custom error message can be specified for entity", async () => {
await POST(`odata/v4/validation-test-non-draft/Incidents`, {
customer_ID: "1004155",
title: "ABC",
Expand Down Expand Up @@ -924,7 +924,7 @@ describe("Row-level security on attachments composition", () => {
await scanCleanWaiter
})

it("should allow DOWNLOAD attachment content for authorized user (alice)", async () => {
it("Should allow DOWNLOAD attachment content for authorized user (alice)", async () => {
// Now, try to GET the attachment content as alice
const getRes = await GET(
`/odata/v4/restriction/Incidents(ID=${restrictionID})/attachments(up__ID=${restrictionID},ID=${attachmentID})/content`,
Expand All @@ -936,7 +936,7 @@ describe("Row-level security on attachments composition", () => {
expect(getRes.data).not.toBeUndefined()
})

it("should reject CREATE attachment for unauthorized user", async () => {
it("Should reject CREATE attachment for unauthorized user", async () => {
await POST(
`/odata/v4/restriction/Incidents(ID=${restrictionID})/attachments`,
{
Expand All @@ -950,7 +950,7 @@ describe("Row-level security on attachments composition", () => {
})
})

it("should reject UPDATE attachment for unauthorized user", async () => {
it("Should reject UPDATE attachment for unauthorized user", async () => {
// Assume an attachment exists, try to update as bob
await axios
.patch(
Expand All @@ -965,7 +965,7 @@ describe("Row-level security on attachments composition", () => {
})
})

it("should reject DOWNLOAD attachment content for unauthorized user", async () => {
it("Should reject DOWNLOAD attachment content for unauthorized user", async () => {
await GET(
`/odata/v4/restriction/Incidents(ID=${restrictionID})/attachments(up__ID=${restrictionID},ID=${attachmentID})/content`,
{
Expand All @@ -976,7 +976,7 @@ describe("Row-level security on attachments composition", () => {
})
})

it("should reject DELETE attachment for unauthorized user", async () => {
it("Should reject DELETE attachment for unauthorized user", async () => {
await DELETE(
`/odata/v4/restriction/Incidents(ID=${restrictionID})/attachments(up__ID=${restrictionID},ID=${attachmentID})`,
{
Expand All @@ -987,7 +987,7 @@ describe("Row-level security on attachments composition", () => {
})
})

it("should not allow bob to PUT into file alice has POSTed", async () => {
it("Should not allow bob to PUT into file alice has POSTed", async () => {
const attachRes = await POST(
`/odata/v4/restriction/Incidents(ID=${restrictionID})/attachments`,
{
Expand Down
Loading