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
Original file line number Diff line number Diff line change
@@ -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/<MasterEntity>/<yyyy>/<MM>/<uuid>/<file-name>} - 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.
*
* <p>
* 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/<Master>/<yyyy>/<MM>/<uuid>}.
*
* @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;
}
}
Original file line number Diff line number Diff line change
@@ -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/<Master>/<yyyy>/<MM>/<uuid>/<file>}. The bytes live in the CMS; the calling
* entity keeps only the returned path (+ metadata) as its handle.
*
* <p>
* 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<String, String> 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<CmisFolder> 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<CmisFolder> 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) {
}
}
Original file line number Diff line number Diff line change
@@ -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/<Master>/<yyyy>/<MM>/<uuid>/<file>} 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(" "));
}
}
Loading
Loading