Skip to content

Commit 93ed2f0

Browse files
delchevclaude
andcommitted
feat(cms): Attachments SDK helper + facade — store/read record attachments in the CMS
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/<Master>/<yyyy>/<MM>/<uuid>/<file> 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 <noreply@anthropic.com>
1 parent 5a525bc commit 93ed2f0

4 files changed

Lines changed: 402 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
3+
*
4+
* All rights reserved. This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
6+
* http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
9+
*/
10+
package org.eclipse.dirigible.components.api.cms;
11+
12+
import java.time.LocalDate;
13+
import java.time.format.DateTimeFormatter;
14+
15+
/**
16+
* Computes the CMS storage path for a record attachment:
17+
* {@code /Attachments/<MasterEntity>/<yyyy>/<MM>/<uuid>/<file-name>} - the master entity type, then
18+
* the upload year and month, then a per-upload uuid folder, then the original file name. The uuid
19+
* folder makes every upload collision-free while preserving the original file name, and the
20+
* entity/date prefix keeps the {@code /Attachments} tree browseable in the Documents perspective.
21+
*
22+
* <p>
23+
* Pure and deterministic (date and uuid are supplied by the caller) so it is unit-testable in
24+
* isolation; {@link AttachmentsFacade} feeds it {@code LocalDate.now()} and a fresh uuid.
25+
*/
26+
public final class AttachmentPath {
27+
28+
/** Root CMS folder under which all record attachments are stored. */
29+
public static final String ROOT = "/Attachments";
30+
31+
private static final DateTimeFormatter YEAR = DateTimeFormatter.ofPattern("yyyy");
32+
private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("MM");
33+
34+
private AttachmentPath() {}
35+
36+
/**
37+
* The folder that holds a single upload: {@code /Attachments/<Master>/<yyyy>/<MM>/<uuid>}.
38+
*
39+
* @param masterEntity the owning entity type name (sanitized to a safe path segment)
40+
* @param date the upload date
41+
* @param uuid the per-upload identifier
42+
* @return the folder path (no trailing separator)
43+
*/
44+
public static String folder(String masterEntity, LocalDate date, String uuid) {
45+
return ROOT + "/" + segment(masterEntity) + "/" + date.format(YEAR) + "/" + date.format(MONTH) + "/" + segment(uuid);
46+
}
47+
48+
/**
49+
* The full document path for an uploaded file: the {@link #folder(String, LocalDate, String)} plus
50+
* the (base-)name of the file.
51+
*
52+
* @param masterEntity the owning entity type name
53+
* @param date the upload date
54+
* @param uuid the per-upload identifier
55+
* @param fileName the original file name (any directory part is stripped)
56+
* @return the full CMS document path
57+
*/
58+
public static String build(String masterEntity, LocalDate date, String uuid, String fileName) {
59+
return folder(masterEntity, date, uuid) + "/" + fileName(fileName);
60+
}
61+
62+
/**
63+
* Reduce an identifier to a safe single path segment: everything outside {@code [A-Za-z0-9_-]}
64+
* becomes {@code _}. Guards against a path-traversal or a separator sneaking into the master name
65+
* or uuid.
66+
*
67+
* @param value the raw value
68+
* @return the sanitized segment ({@code _} when blank)
69+
*/
70+
static String segment(String value) {
71+
if (value == null || value.isBlank()) {
72+
return "_";
73+
}
74+
return value.trim()
75+
.replaceAll("[^A-Za-z0-9_-]", "_");
76+
}
77+
78+
/**
79+
* The base file name with any directory part and path separators removed, so an uploaded name can
80+
* never escape its uuid folder. The rest of the name (spaces, dots, unicode) is preserved.
81+
*
82+
* @param name the original file name
83+
* @return the safe base file name ({@code file} when blank)
84+
*/
85+
static String fileName(String name) {
86+
if (name == null || name.isBlank()) {
87+
return "file";
88+
}
89+
String base = name.trim()
90+
.replace('\\', '/');
91+
int slash = base.lastIndexOf('/');
92+
if (slash >= 0) {
93+
base = base.substring(slash + 1);
94+
}
95+
base = base.strip();
96+
return base.isEmpty() ? "file" : base;
97+
}
98+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
3+
*
4+
* All rights reserved. This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
6+
* http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
9+
*/
10+
package org.eclipse.dirigible.components.api.cms;
11+
12+
import java.io.ByteArrayInputStream;
13+
import java.io.IOException;
14+
import java.io.InputStream;
15+
import java.time.LocalDate;
16+
import java.util.Map;
17+
import java.util.Optional;
18+
import java.util.UUID;
19+
20+
import org.eclipse.dirigible.components.engine.cms.CmisConstants;
21+
import org.eclipse.dirigible.components.engine.cms.CmisContentStream;
22+
import org.eclipse.dirigible.components.engine.cms.CmisDocument;
23+
import org.eclipse.dirigible.components.engine.cms.CmisFolder;
24+
import org.eclipse.dirigible.components.engine.cms.CmisObject;
25+
import org.eclipse.dirigible.components.engine.cms.CmisSession;
26+
import org.eclipse.dirigible.components.engine.cms.CmisSessionFactory;
27+
28+
/**
29+
* Stores and reads record attachments in the CMS (the same tenant-scoped store the Documents
30+
* perspective browses), under the {@link AttachmentPath} layout
31+
* {@code /Attachments/<Master>/<yyyy>/<MM>/<uuid>/<file>}. The bytes live in the CMS; the calling
32+
* entity keeps only the returned path (+ metadata) as its handle.
33+
*
34+
* <p>
35+
* Reuses the folder-ensure + content-stream write pattern of the engine-document {@code CmsStore};
36+
* exposed to client code through the {@code org.eclipse.dirigible.sdk.cms.Attachments} SDK facade.
37+
* The CMS session is resolved per call via {@link CmisSessionFactory}, so writes/reads land in the
38+
* current tenant's store.
39+
*/
40+
public final class AttachmentsFacade {
41+
42+
private static final String PATH_SEPARATOR = "/";
43+
44+
private AttachmentsFacade() {}
45+
46+
/**
47+
* Store an uploaded file as a new attachment of the given master entity.
48+
*
49+
* @param masterEntity the owning entity type name (e.g. {@code Company})
50+
* @param fileName the original file name
51+
* @param contentType the MIME type (best-effort; the bytes are stored regardless)
52+
* @param content the file bytes
53+
* @return the stored-attachment metadata (path, name, content type, size, uuid)
54+
* @throws IOException if the CMS write fails
55+
*/
56+
public static StoredAttachment store(String masterEntity, String fileName, String contentType, byte[] content) throws IOException {
57+
String uuid = UUID.randomUUID()
58+
.toString();
59+
LocalDate today = LocalDate.now();
60+
String folderPath = AttachmentPath.folder(masterEntity, today, uuid);
61+
String safeName = AttachmentPath.fileName(fileName);
62+
String documentPath = folderPath + PATH_SEPARATOR + safeName;
63+
64+
CmisSession session = CmisSessionFactory.getSession();
65+
CmisFolder folder = ensureFolder(session, folderPath);
66+
Map<String, String> properties =
67+
Map.of(CmisConstants.OBJECT_TYPE_ID, CmisConstants.OBJECT_TYPE_DOCUMENT, CmisConstants.NAME, safeName);
68+
try (InputStream inputStream = new ByteArrayInputStream(content)) {
69+
CmisContentStream stream = session.getObjectFactory()
70+
.createContentStream(safeName, content.length, contentType, inputStream);
71+
folder.createDocument(properties, stream);
72+
}
73+
return new StoredAttachment(documentPath, safeName, contentType, content.length, uuid);
74+
}
75+
76+
/**
77+
* Open an attachment's content for reading (streaming to an HTTP response). The caller must consume
78+
* the stream before the request completes.
79+
*
80+
* @param path the stored attachment path
81+
* @return the content stream
82+
* @throws IOException if the object is missing or not a document
83+
*/
84+
public static InputStream read(String path) throws IOException {
85+
CmisObject object = CmisSessionFactory.getSession()
86+
.getObjectByPath(path);
87+
if (object instanceof CmisDocument document) {
88+
return document.getContentStream()
89+
.getStream();
90+
}
91+
throw new IOException("Attachment is not a document: " + path);
92+
}
93+
94+
/**
95+
* Delete an attachment file from the CMS. A missing object is a no-op (idempotent).
96+
*
97+
* @param path the stored attachment path
98+
* @throws IOException if the delete fails for a reason other than absence
99+
*/
100+
public static void delete(String path) throws IOException {
101+
CmisSession session = CmisSessionFactory.getSession();
102+
CmisObject object;
103+
try {
104+
object = session.getObjectByPath(path);
105+
} catch (IOException absent) {
106+
return;
107+
}
108+
object.delete();
109+
}
110+
111+
/**
112+
* Ensure every folder level of {@code path} exists, creating the missing ones ({@code createFolder}
113+
* is single-level). Mirrors the engine-document {@code CmsStore} approach.
114+
*/
115+
private static CmisFolder ensureFolder(CmisSession session, String path) throws IOException {
116+
CmisFolder current = session.getRootFolder();
117+
StringBuilder currentPath = new StringBuilder();
118+
for (String segment : path.split(PATH_SEPARATOR)) {
119+
if (segment.isEmpty()) {
120+
continue;
121+
}
122+
currentPath.append(PATH_SEPARATOR)
123+
.append(segment);
124+
Optional<CmisFolder> existing = findFolder(session, currentPath.toString());
125+
if (existing.isPresent()) {
126+
current = existing.get();
127+
} else {
128+
current = current.createFolder(
129+
Map.of(CmisConstants.OBJECT_TYPE_ID, CmisConstants.OBJECT_TYPE_FOLDER, CmisConstants.NAME, segment));
130+
}
131+
}
132+
return current;
133+
}
134+
135+
/** The CMS signals a missing object with an {@link IOException} from {@code getObjectByPath}. */
136+
private static Optional<CmisFolder> findFolder(CmisSession session, String path) {
137+
try {
138+
CmisObject object = session.getObjectByPath(path);
139+
return object instanceof CmisFolder folder ? Optional.of(folder) : Optional.empty();
140+
} catch (IOException absent) {
141+
return Optional.empty();
142+
}
143+
}
144+
145+
/**
146+
* Metadata of a stored attachment - what the owning entity records as its handle.
147+
*
148+
* @param path the full CMS document path
149+
* @param fileName the (sanitized) stored file name
150+
* @param contentType the MIME type
151+
* @param size the size in bytes
152+
* @param uuid the per-upload folder identifier
153+
*/
154+
public record StoredAttachment(String path, String fileName, String contentType, long size, String uuid) {
155+
}
156+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
3+
*
4+
* All rights reserved. This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
6+
* http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
9+
*/
10+
package org.eclipse.dirigible.components.api.cms;
11+
12+
import static org.junit.jupiter.api.Assertions.assertEquals;
13+
14+
import java.time.LocalDate;
15+
16+
import org.junit.jupiter.api.Test;
17+
18+
/**
19+
* Verifies the deterministic {@code /Attachments/<Master>/<yyyy>/<MM>/<uuid>/<file>} layout and the
20+
* path-safety of the master and file-name segments.
21+
*/
22+
class AttachmentPathTest {
23+
24+
private static final LocalDate JULY = LocalDate.of(2026, 7, 21);
25+
26+
@Test
27+
void folderIsEntityYearMonthUuid() {
28+
assertEquals("/Attachments/Company/2026/07/abc-123", AttachmentPath.folder("Company", JULY, "abc-123"));
29+
}
30+
31+
@Test
32+
void buildAppendsTheFileName() {
33+
assertEquals("/Attachments/Company/2026/07/abc-123/contract.pdf", AttachmentPath.build("Company", JULY, "abc-123", "contract.pdf"));
34+
}
35+
36+
@Test
37+
void monthIsZeroPadded() {
38+
assertEquals("/Attachments/Expense/2026/03/u/x.png", AttachmentPath.build("Expense", LocalDate.of(2026, 3, 9), "u", "x.png"));
39+
}
40+
41+
@Test
42+
void fileNameStripsDirectoryAndTraversal() {
43+
assertEquals("passwd", AttachmentPath.fileName("../../etc/passwd"));
44+
assertEquals("file.docx", AttachmentPath.fileName("C:\\Users\\x\\file.docx"));
45+
assertEquals("report 2026.pdf", AttachmentPath.fileName("report 2026.pdf"));
46+
}
47+
48+
@Test
49+
void blankFileNameFallsBackToFile() {
50+
assertEquals("file", AttachmentPath.fileName(" "));
51+
assertEquals("file", AttachmentPath.fileName(null));
52+
}
53+
54+
@Test
55+
void masterSegmentIsSanitized() {
56+
// a separator or traversal in the master name can never escape the /Attachments tree
57+
assertEquals("/Attachments/Sales_Invoice____x/2026/07/u/f", AttachmentPath.build("Sales Invoice/../x", JULY, "u", "f"));
58+
assertEquals("_", AttachmentPath.segment(" "));
59+
}
60+
}

0 commit comments

Comments
 (0)