From 93ed2f0ac6c7566bdf317dc9a372b68362d1fa15 Mon Sep 17 00:00:00 2001 From: delchev Date: Wed, 22 Jul 2026 10:35:33 +0300 Subject: [PATCH] =?UTF-8?q?feat(cms):=20Attachments=20SDK=20helper=20+=20f?= =?UTF-8?q?acade=20=E2=80=94=20store/read=20record=20attachments=20in=20th?= =?UTF-8?q?e=20CMS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foundation (PR-1) for record file attachments: an AttachmentsFacade (api-cms) that stores an uploaded file in the tenant CMS under the structured path /Attachments///// and reads/deletes it (reusing the engine-document CmsStore folder-ensure + content-stream pattern), plus the client-facing sdk.cms.Attachments delegate (store -> {path,name,contentType,size,uuid}; open(path) -> stream; delete). Path building is a pure, unit-tested AttachmentPath (entity/date/uuid/file, with master + file-name path-safety). No new engine — reuses the existing CmisSessionFactory session, so writes land in the caller's tenant store. This is the reusable primitive the generated attachment controller verbs (a later PR, once function: Attachment marks the child entity) call; see kf-catalog PROPOSAL_ATTACHMENTS. Unit test green (AttachmentPathTest, 6); both modules compile + release javadoc clean. Co-Authored-By: Claude Fable 5 --- .../components/api/cms/AttachmentPath.java | 98 +++++++++++ .../components/api/cms/AttachmentsFacade.java | 156 ++++++++++++++++++ .../api/cms/AttachmentPathTest.java | 60 +++++++ .../dirigible/sdk/cms/Attachments.java | 88 ++++++++++ 4 files changed, 402 insertions(+) create mode 100644 components/api/api-cms/src/main/java/org/eclipse/dirigible/components/api/cms/AttachmentPath.java create mode 100644 components/api/api-cms/src/main/java/org/eclipse/dirigible/components/api/cms/AttachmentsFacade.java create mode 100644 components/api/api-cms/src/test/java/org/eclipse/dirigible/components/api/cms/AttachmentPathTest.java create mode 100644 components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/cms/Attachments.java diff --git a/components/api/api-cms/src/main/java/org/eclipse/dirigible/components/api/cms/AttachmentPath.java b/components/api/api-cms/src/main/java/org/eclipse/dirigible/components/api/cms/AttachmentPath.java new file mode 100644 index 00000000000..eabbe9b7e63 --- /dev/null +++ b/components/api/api-cms/src/main/java/org/eclipse/dirigible/components/api/cms/AttachmentPath.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.api.cms; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * Computes the CMS storage path for a record attachment: + * {@code /Attachments/////} - the master entity type, then + * the upload year and month, then a per-upload uuid folder, then the original file name. The uuid + * folder makes every upload collision-free while preserving the original file name, and the + * entity/date prefix keeps the {@code /Attachments} tree browseable in the Documents perspective. + * + *

+ * Pure and deterministic (date and uuid are supplied by the caller) so it is unit-testable in + * isolation; {@link AttachmentsFacade} feeds it {@code LocalDate.now()} and a fresh uuid. + */ +public final class AttachmentPath { + + /** Root CMS folder under which all record attachments are stored. */ + public static final String ROOT = "/Attachments"; + + private static final DateTimeFormatter YEAR = DateTimeFormatter.ofPattern("yyyy"); + private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("MM"); + + private AttachmentPath() {} + + /** + * The folder that holds a single upload: {@code /Attachments////}. + * + * @param masterEntity the owning entity type name (sanitized to a safe path segment) + * @param date the upload date + * @param uuid the per-upload identifier + * @return the folder path (no trailing separator) + */ + public static String folder(String masterEntity, LocalDate date, String uuid) { + return ROOT + "/" + segment(masterEntity) + "/" + date.format(YEAR) + "/" + date.format(MONTH) + "/" + segment(uuid); + } + + /** + * The full document path for an uploaded file: the {@link #folder(String, LocalDate, String)} plus + * the (base-)name of the file. + * + * @param masterEntity the owning entity type name + * @param date the upload date + * @param uuid the per-upload identifier + * @param fileName the original file name (any directory part is stripped) + * @return the full CMS document path + */ + public static String build(String masterEntity, LocalDate date, String uuid, String fileName) { + return folder(masterEntity, date, uuid) + "/" + fileName(fileName); + } + + /** + * Reduce an identifier to a safe single path segment: everything outside {@code [A-Za-z0-9_-]} + * becomes {@code _}. Guards against a path-traversal or a separator sneaking into the master name + * or uuid. + * + * @param value the raw value + * @return the sanitized segment ({@code _} when blank) + */ + static String segment(String value) { + if (value == null || value.isBlank()) { + return "_"; + } + return value.trim() + .replaceAll("[^A-Za-z0-9_-]", "_"); + } + + /** + * The base file name with any directory part and path separators removed, so an uploaded name can + * never escape its uuid folder. The rest of the name (spaces, dots, unicode) is preserved. + * + * @param name the original file name + * @return the safe base file name ({@code file} when blank) + */ + static String fileName(String name) { + if (name == null || name.isBlank()) { + return "file"; + } + String base = name.trim() + .replace('\\', '/'); + int slash = base.lastIndexOf('/'); + if (slash >= 0) { + base = base.substring(slash + 1); + } + base = base.strip(); + return base.isEmpty() ? "file" : base; + } +} diff --git a/components/api/api-cms/src/main/java/org/eclipse/dirigible/components/api/cms/AttachmentsFacade.java b/components/api/api-cms/src/main/java/org/eclipse/dirigible/components/api/cms/AttachmentsFacade.java new file mode 100644 index 00000000000..acea1b0aa48 --- /dev/null +++ b/components/api/api-cms/src/main/java/org/eclipse/dirigible/components/api/cms/AttachmentsFacade.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.api.cms; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDate; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.eclipse.dirigible.components.engine.cms.CmisConstants; +import org.eclipse.dirigible.components.engine.cms.CmisContentStream; +import org.eclipse.dirigible.components.engine.cms.CmisDocument; +import org.eclipse.dirigible.components.engine.cms.CmisFolder; +import org.eclipse.dirigible.components.engine.cms.CmisObject; +import org.eclipse.dirigible.components.engine.cms.CmisSession; +import org.eclipse.dirigible.components.engine.cms.CmisSessionFactory; + +/** + * Stores and reads record attachments in the CMS (the same tenant-scoped store the Documents + * perspective browses), under the {@link AttachmentPath} layout + * {@code /Attachments/////}. The bytes live in the CMS; the calling + * entity keeps only the returned path (+ metadata) as its handle. + * + *

+ * Reuses the folder-ensure + content-stream write pattern of the engine-document {@code CmsStore}; + * exposed to client code through the {@code org.eclipse.dirigible.sdk.cms.Attachments} SDK facade. + * The CMS session is resolved per call via {@link CmisSessionFactory}, so writes/reads land in the + * current tenant's store. + */ +public final class AttachmentsFacade { + + private static final String PATH_SEPARATOR = "/"; + + private AttachmentsFacade() {} + + /** + * Store an uploaded file as a new attachment of the given master entity. + * + * @param masterEntity the owning entity type name (e.g. {@code Company}) + * @param fileName the original file name + * @param contentType the MIME type (best-effort; the bytes are stored regardless) + * @param content the file bytes + * @return the stored-attachment metadata (path, name, content type, size, uuid) + * @throws IOException if the CMS write fails + */ + public static StoredAttachment store(String masterEntity, String fileName, String contentType, byte[] content) throws IOException { + String uuid = UUID.randomUUID() + .toString(); + LocalDate today = LocalDate.now(); + String folderPath = AttachmentPath.folder(masterEntity, today, uuid); + String safeName = AttachmentPath.fileName(fileName); + String documentPath = folderPath + PATH_SEPARATOR + safeName; + + CmisSession session = CmisSessionFactory.getSession(); + CmisFolder folder = ensureFolder(session, folderPath); + Map properties = + Map.of(CmisConstants.OBJECT_TYPE_ID, CmisConstants.OBJECT_TYPE_DOCUMENT, CmisConstants.NAME, safeName); + try (InputStream inputStream = new ByteArrayInputStream(content)) { + CmisContentStream stream = session.getObjectFactory() + .createContentStream(safeName, content.length, contentType, inputStream); + folder.createDocument(properties, stream); + } + return new StoredAttachment(documentPath, safeName, contentType, content.length, uuid); + } + + /** + * Open an attachment's content for reading (streaming to an HTTP response). The caller must consume + * the stream before the request completes. + * + * @param path the stored attachment path + * @return the content stream + * @throws IOException if the object is missing or not a document + */ + public static InputStream read(String path) throws IOException { + CmisObject object = CmisSessionFactory.getSession() + .getObjectByPath(path); + if (object instanceof CmisDocument document) { + return document.getContentStream() + .getStream(); + } + throw new IOException("Attachment is not a document: " + path); + } + + /** + * Delete an attachment file from the CMS. A missing object is a no-op (idempotent). + * + * @param path the stored attachment path + * @throws IOException if the delete fails for a reason other than absence + */ + public static void delete(String path) throws IOException { + CmisSession session = CmisSessionFactory.getSession(); + CmisObject object; + try { + object = session.getObjectByPath(path); + } catch (IOException absent) { + return; + } + object.delete(); + } + + /** + * Ensure every folder level of {@code path} exists, creating the missing ones ({@code createFolder} + * is single-level). Mirrors the engine-document {@code CmsStore} approach. + */ + private static CmisFolder ensureFolder(CmisSession session, String path) throws IOException { + CmisFolder current = session.getRootFolder(); + StringBuilder currentPath = new StringBuilder(); + for (String segment : path.split(PATH_SEPARATOR)) { + if (segment.isEmpty()) { + continue; + } + currentPath.append(PATH_SEPARATOR) + .append(segment); + Optional existing = findFolder(session, currentPath.toString()); + if (existing.isPresent()) { + current = existing.get(); + } else { + current = current.createFolder( + Map.of(CmisConstants.OBJECT_TYPE_ID, CmisConstants.OBJECT_TYPE_FOLDER, CmisConstants.NAME, segment)); + } + } + return current; + } + + /** The CMS signals a missing object with an {@link IOException} from {@code getObjectByPath}. */ + private static Optional findFolder(CmisSession session, String path) { + try { + CmisObject object = session.getObjectByPath(path); + return object instanceof CmisFolder folder ? Optional.of(folder) : Optional.empty(); + } catch (IOException absent) { + return Optional.empty(); + } + } + + /** + * Metadata of a stored attachment - what the owning entity records as its handle. + * + * @param path the full CMS document path + * @param fileName the (sanitized) stored file name + * @param contentType the MIME type + * @param size the size in bytes + * @param uuid the per-upload folder identifier + */ + public record StoredAttachment(String path, String fileName, String contentType, long size, String uuid) { + } +} diff --git a/components/api/api-cms/src/test/java/org/eclipse/dirigible/components/api/cms/AttachmentPathTest.java b/components/api/api-cms/src/test/java/org/eclipse/dirigible/components/api/cms/AttachmentPathTest.java new file mode 100644 index 00000000000..c77614f6efc --- /dev/null +++ b/components/api/api-cms/src/test/java/org/eclipse/dirigible/components/api/cms/AttachmentPathTest.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.api.cms; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.LocalDate; + +import org.junit.jupiter.api.Test; + +/** + * Verifies the deterministic {@code /Attachments/////} layout and the + * path-safety of the master and file-name segments. + */ +class AttachmentPathTest { + + private static final LocalDate JULY = LocalDate.of(2026, 7, 21); + + @Test + void folderIsEntityYearMonthUuid() { + assertEquals("/Attachments/Company/2026/07/abc-123", AttachmentPath.folder("Company", JULY, "abc-123")); + } + + @Test + void buildAppendsTheFileName() { + assertEquals("/Attachments/Company/2026/07/abc-123/contract.pdf", AttachmentPath.build("Company", JULY, "abc-123", "contract.pdf")); + } + + @Test + void monthIsZeroPadded() { + assertEquals("/Attachments/Expense/2026/03/u/x.png", AttachmentPath.build("Expense", LocalDate.of(2026, 3, 9), "u", "x.png")); + } + + @Test + void fileNameStripsDirectoryAndTraversal() { + assertEquals("passwd", AttachmentPath.fileName("../../etc/passwd")); + assertEquals("file.docx", AttachmentPath.fileName("C:\\Users\\x\\file.docx")); + assertEquals("report 2026.pdf", AttachmentPath.fileName("report 2026.pdf")); + } + + @Test + void blankFileNameFallsBackToFile() { + assertEquals("file", AttachmentPath.fileName(" ")); + assertEquals("file", AttachmentPath.fileName(null)); + } + + @Test + void masterSegmentIsSanitized() { + // a separator or traversal in the master name can never escape the /Attachments tree + assertEquals("/Attachments/Sales_Invoice____x/2026/07/u/f", AttachmentPath.build("Sales Invoice/../x", JULY, "u", "f")); + assertEquals("_", AttachmentPath.segment(" ")); + } +} diff --git a/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/cms/Attachments.java b/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/cms/Attachments.java new file mode 100644 index 00000000000..ab5ac2d65d0 --- /dev/null +++ b/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/cms/Attachments.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.sdk.cms; + +import java.io.IOException; +import java.io.InputStream; + +import org.eclipse.dirigible.components.api.cms.AttachmentsFacade; + +/** + * Client SDK for record attachments: store an uploaded file against a master entity (into the + * tenant CMS under {@code /Attachments/////}), and open a stored file + * for download. The owning entity keeps the returned {@link Attachment#path()} (plus its metadata) + * as its handle; the bytes live in the CMS and are browseable in the Documents perspective. + * + *

+ * Intended for the generated attachment controllers (upload/download/delete verbs) and for + * hand-written {@code custom/} code; authorization is the caller's responsibility (the generated + * controller checks the master entity's role before calling here). + */ +public final class Attachments { + + private Attachments() {} + + /** + * Store an uploaded file as a new attachment of the given master entity. + * + * @param masterEntity the owning entity type name (e.g. {@code Company}) + * @param fileName the original file name + * @param contentType the MIME type + * @param content the file bytes + * @return the stored attachment's metadata (path, name, content type, size, uuid) + */ + public static Attachment store(String masterEntity, String fileName, String contentType, byte[] content) { + try { + AttachmentsFacade.StoredAttachment stored = AttachmentsFacade.store(masterEntity, fileName, contentType, content); + return new Attachment(stored.path(), stored.fileName(), stored.contentType(), stored.size(), stored.uuid()); + } catch (IOException e) { + throw new IllegalStateException("Failed to store attachment [" + fileName + "] for [" + masterEntity + "]", e); + } + } + + /** + * Open a stored attachment's content for reading. The caller must consume/close the stream. + * + * @param path the stored attachment path + * @return the content stream + */ + public static InputStream open(String path) { + try { + return AttachmentsFacade.read(path); + } catch (IOException e) { + throw new IllegalStateException("Failed to open attachment [" + path + "]", e); + } + } + + /** + * Delete a stored attachment file. A missing file is a no-op. + * + * @param path the stored attachment path + */ + public static void delete(String path) { + try { + AttachmentsFacade.delete(path); + } catch (IOException e) { + throw new IllegalStateException("Failed to delete attachment [" + path + "]", e); + } + } + + /** + * Metadata of a stored attachment - the handle the owning entity records. + * + * @param path the full CMS document path + * @param fileName the stored file name + * @param contentType the MIME type + * @param size the size in bytes + * @param uuid the per-upload folder identifier + */ + public record Attachment(String path, String fileName, String contentType, long size, String uuid) { + } +}