Skip to content

Commit 74cb43f

Browse files
feat: Replace JClouds with AWS SDK + File NIO [DHIS2-20648] (#23918)
* feat: Replace JClouds with AWS SDK and NIO [DHIS2-20648] * feat: Align impl contracts for consistency. Add comments [DHIS2-20648] * feat: Remove remaining JClouds refs [DHIS2-20648] * feat: Harden security and add more tests [DHIS2-20648] * feat: Clean logs [DHIS2-20648] * Revert get content length * feat: Enable dir create/exists to keep existing behaviour * clean up tests * feat: Use static of * feat: Craete bucket first without HEAD call * feat: Address PR comments * remove redundant tests * feat: Remove AppStorageSource, no longer required * feat: Ensure streams are resetable when passing to s3 [DHIS2-20648] * resolve conflicts * remove test util method --------- Co-authored-by: Stian Sandvold <stian@dhis2.org>
1 parent e1e67ac commit 74cb43f

37 files changed

Lines changed: 1860 additions & 764 deletions

dhis-2/dhis-api/src/main/java/org/hisp/dhis/appmanager/App.java

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,6 @@ public class App implements Serializable {
8282

8383
private String defaultLocale;
8484

85-
private AppStorageSource appStorageSource;
86-
8785
private String folderName;
8886

8987
/** Optional. */
@@ -414,22 +412,12 @@ public void setBaseUrl(String baseUrl) {
414412
this.baseUrl = baseUrl;
415413
}
416414

417-
@JsonProperty
418-
@JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0)
419-
public AppStorageSource getAppStorageSource() {
420-
return appStorageSource;
421-
}
422-
423415
@JsonProperty
424416
@JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0)
425417
public List<AppShortcut> getShortcuts() {
426418
return shortcuts;
427419
}
428420

429-
public void setAppStorageSource(AppStorageSource appStorageSource) {
430-
this.appStorageSource = appStorageSource;
431-
}
432-
433421
@JsonProperty
434422
@JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0)
435423
public Set<String> getAuthorities() {

dhis-2/dhis-api/src/main/java/org/hisp/dhis/appmanager/AppFolderName.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ public BlobKey resolve(String resource) {
100100
* org.hisp.dhis.storage.BlobStoreService#deleteDirectory}.
101101
*/
102102
public BlobKeyPrefix asPrefix() {
103-
return new BlobKeyPrefix(path);
103+
return BlobKeyPrefix.of(path);
104104
}
105105

106106
@Nonnull

dhis-2/dhis-api/src/main/java/org/hisp/dhis/appmanager/AppStorageSource.java

Lines changed: 0 additions & 37 deletions
This file was deleted.

dhis-2/dhis-api/src/main/java/org/hisp/dhis/fileresource/FileResource.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ public String getStorageKey() {
201201
* rather than {@link String}.
202202
*/
203203
public BlobKey asBlobKey() {
204-
return new BlobKey(storageKey);
204+
return BlobKey.of(storageKey);
205205
}
206206

207207
public void setStorageKey(String storageKey) {

dhis-2/dhis-api/src/main/java/org/hisp/dhis/storage/BlobContainerName.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
* #resolve(BlobKey)} always produces a clean path without any slash-cleaning.
3939
*
4040
* <p>Configured via {@link org.hisp.dhis.external.conf.ConfigurationKey#FILESTORE_CONTAINER} and
41-
* resolved once at startup by {@link org.hisp.dhis.jclouds.JCloudsStore}.
41+
* resolved once at startup by the {@link org.hisp.dhis.storage.BlobStoreService} implementation
42+
* selected for the configured {@code filestore.provider}.
4243
*/
4344
public record BlobContainerName(String value) {
4445

dhis-2/dhis-api/src/main/java/org/hisp/dhis/storage/BlobKey.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ public record BlobKey(String value) {
5959
throw new IllegalArgumentException(
6060
"BlobKey value must not exceed " + MAX_KEY_LENGTH + " characters: " + value);
6161
}
62+
// Reject `..` as a path segment so filesystem-backed stores can't be tricked into writing
63+
// outside the container directory. Substrings like "a..b" are fine — only segments split by
64+
// `/` count. No DHIS2 caller produces `..` segments today (zip uploads sanitise via
65+
// ZipFileUtils, file-resource storage keys are UUID-shaped) so this is defense-in-depth.
66+
for (String segment : value.split("/", -1)) {
67+
if ("..".equals(segment)) {
68+
throw new IllegalArgumentException(
69+
"BlobKey value must not contain '..' as a path segment: " + value);
70+
}
71+
}
6272
}
6373

6474
/**

dhis-2/dhis-api/src/main/java/org/hisp/dhis/storage/BlobKeyPrefix.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ public record BlobKeyPrefix(@Nonnull String value) {
6161
/**
6262
* Creates a {@link BlobKeyPrefix} from {@code value}, stripping any leading or trailing {@code /}
6363
* before construction. Prefer this over the canonical constructor when the input may carry
64-
* store-native separators (e.g. JClouds appends a trailing {@code /} to folder names).
64+
* store-native separators (e.g. S3's {@code CommonPrefix} entries arrive with a trailing {@code
65+
* /}).
6566
*
6667
* <p>Examples:
6768
*

dhis-2/dhis-api/src/test/java/org/hisp/dhis/storage/BlobKeyTest.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,26 @@ void ofSingleSegment() {
6666
void ofJoinsSegmentsWithSlash() {
6767
assertEquals("apps/my-app/index.html", BlobKey.of("apps", "my-app", "index.html").value());
6868
}
69+
70+
@Test
71+
void leadingDotDotSegmentIsRejected() {
72+
assertThrows(IllegalArgumentException.class, () -> new BlobKey("../escape.txt"));
73+
}
74+
75+
@Test
76+
void interiorDotDotSegmentIsRejected() {
77+
assertThrows(IllegalArgumentException.class, () -> new BlobKey("a/../b/c"));
78+
}
79+
80+
@Test
81+
void trailingDotDotSegmentIsRejected() {
82+
assertThrows(IllegalArgumentException.class, () -> new BlobKey("a/b/.."));
83+
}
84+
85+
@Test
86+
void dotDotAsSubstringWithinSegmentIsAccepted() {
87+
// "a..b" is a perfectly legitimate filename — only `..` as a *segment* is rejected.
88+
BlobKey key = new BlobKey("apps/my..app/index.html");
89+
assertEquals("apps/my..app/index.html", key.value());
90+
}
6991
}

dhis-2/dhis-services/dhis-service-core/pom.xml

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -251,32 +251,11 @@
251251
<artifactId>annotations</artifactId>
252252
</dependency>
253253

254-
<!-- Apache JClouds -->
255-
256-
<dependency>
257-
<groupId>org.apache.jclouds</groupId>
258-
<artifactId>jclouds-core</artifactId>
259-
</dependency>
254+
<!-- AWS SDK for Java v2 -->
260255
<dependency>
261-
<groupId>org.apache.jclouds</groupId>
262-
<artifactId>jclouds-blobstore</artifactId>
263-
</dependency>
264-
<dependency>
265-
<groupId>org.apache.jclouds.api</groupId>
266-
<artifactId>filesystem</artifactId>
267-
</dependency>
268-
<dependency>
269-
<groupId>org.apache.jclouds.api</groupId>
256+
<groupId>software.amazon.awssdk</groupId>
270257
<artifactId>s3</artifactId>
271258
</dependency>
272-
<dependency>
273-
<groupId>org.apache.jclouds.provider</groupId>
274-
<artifactId>aws-s3</artifactId>
275-
</dependency>
276-
<dependency>
277-
<groupId>org.apache.jclouds.driver</groupId>
278-
<artifactId>jclouds-slf4j</artifactId>
279-
</dependency>
280259

281260
<!-- Image Processing -->
282261

dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/appmanager/JCloudsAppStorageService.java renamed to dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/appmanager/BlobStoreAppStorageService.java

Lines changed: 26 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
import java.io.File;
3838
import java.io.IOException;
3939
import java.io.InputStream;
40-
import java.net.MalformedURLException;
4140
import java.net.URI;
4241
import java.util.Collections;
4342
import java.util.Enumeration;
@@ -69,6 +68,7 @@
6968
import org.hisp.dhis.util.ZipBombException;
7069
import org.hisp.dhis.util.ZipFileUtils;
7170
import org.hisp.dhis.util.ZipSlipException;
71+
import org.springframework.core.io.ByteArrayResource;
7272
import org.springframework.core.io.FileSystemResource;
7373
import org.springframework.core.io.Resource;
7474
import org.springframework.core.io.UrlResource;
@@ -79,8 +79,8 @@
7979
*/
8080
@Slf4j
8181
@RequiredArgsConstructor
82-
@Service("org.hisp.dhis.appmanager.JCloudsAppStorageService")
83-
public class JCloudsAppStorageService implements AppStorageService {
82+
@Service
83+
public class BlobStoreAppStorageService implements AppStorageService {
8484

8585
private static final String BUNDLED_APP_INFO_FILENAME = "bundled-app-info.json";
8686
public static final String MANIFEST_WEBAPP_FILENAME = "manifest.webapp";
@@ -118,7 +118,6 @@ private void handleAppManifest(
118118
BiConsumer<App, BundledAppInfo> handler, BlobKeyPrefix folder, InputStream manifestStream) {
119119
try (InputStream inputStream = manifestStream) {
120120
App app = App.MAPPER.readValue(inputStream, App.class);
121-
app.setAppStorageSource(AppStorageSource.JCLOUDS);
122121
app.setFolderName(folder.value());
123122
app.setManifestTranslations(
124123
readAppManifestTranslations(
@@ -234,7 +233,6 @@ public App installApp(
234233
app = AppManager.readAppManifest(file, this.jsonMapper, topLevelFolder);
235234
folder = AppFolderName.ofKey(app.getKey());
236235
app.setFolderName(folder.path());
237-
app.setAppStorageSource(AppStorageSource.JCLOUDS);
238236
} catch (IOException e) {
239237
log.error("Failed to install app: Failure during reading manifest from zip file", e);
240238
app = new App();
@@ -294,9 +292,15 @@ private void unzipFile(File file, AppFolderName folder, String topLevelFolder)
294292
String filePath = getFilePath(folder.path(), topLevelFolder, zipEntry);
295293
// If it's the root folder, skip
296294
if (filePath == null) continue;
295+
if (zipEntry.isDirectory()) {
296+
// Materialise empty directories so the /api/apps/<app>/<dir> → /<dir>/ redirect
297+
// contract holds even when the zip contains a directory with no files inside.
298+
blobStore.createDirectory(BlobKeyPrefix.of(filePath));
299+
continue;
300+
}
297301
try (InputStream zipInputStream = zipFile.getInputStream(zipEntry)) {
298302
blobStore.putBlob(
299-
new BlobKey(filePath), zipInputStream, zipEntry.getSize(), null, null, null);
303+
BlobKey.of(filePath), zipInputStream, zipEntry.getSize(), null, null, null);
300304
}
301305
}
302306
}
@@ -338,30 +342,17 @@ public void deleteApp(@Nonnull App app) {
338342
AppFolderName folder = app.appFolder();
339343
blobStore.deleteBlob(folder.resolve(MANIFEST_WEBAPP_FILENAME));
340344

341-
// TODO(DHIS2-20648) Once the replacement BlobStoreService implementation does recursive
342-
// deleteDirectory on every backend (see contract test), this branch can collapse to a
343-
// single blobStore.deleteDirectory(folder.asPrefix()) call.
344-
if (blobStore.isFilesystem()) {
345-
// Delete all files related to app (works for local filestore)
346-
blobStore.deleteDirectory(folder.asPrefix());
347-
} else {
348-
// S3: deleteDirectory is not recursive, so enumerate and delete individually
349-
for (BlobKey key : blobStore.listKeys(folder.asPrefix())) {
350-
log.debug("Deleting app file: {}", key);
351-
blobStore.deleteBlob(key);
352-
}
353-
}
345+
blobStore.deleteDirectory(folder.asPrefix());
354346
log.info("Deleted app {}", app.getName());
355347
}
356348

357349
@Override
358350
@Nonnull
359351
public ResourceResult getAppResource(@CheckForNull App app, @Nonnull String resource)
360352
throws IOException {
361-
if (app == null || !app.getAppStorageSource().equals(AppStorageSource.JCLOUDS)) {
353+
if (app == null) {
362354
log.warn(
363-
"Can't look up resource {}. The specified app was not found in JClouds storage.",
364-
resource);
355+
"Can't look up resource {}. The specified app was not found in blob storage.", resource);
365356
return new ResourceNotFound(resource);
366357
}
367358
if (resource.isBlank()) {
@@ -374,25 +365,28 @@ public ResourceResult getAppResource(@CheckForNull App app, @Nonnull String reso
374365
if (blobStore.blobExists(key)) {
375366
return new ResourceFound(getResource(key));
376367
}
377-
if (keyExistsAsDirectory(key)) {
368+
if (blobStore.directoryExists(BlobKeyPrefix.of(key.value()))) {
378369
return new Redirect(resource + "/");
379370
}
380371
log.debug("ResourceNotFound {} for App {}", key, app.getName());
381372
return new ResourceNotFound(resource);
382373
}
383374

384-
private boolean keyExistsAsDirectory(BlobKey key) {
385-
return blobStore.listKeys(BlobKeyPrefix.of(key.value())).iterator().hasNext();
386-
}
387-
388-
private Resource getResource(@Nonnull BlobKey key) throws MalformedURLException {
375+
private Resource getResource(@Nonnull BlobKey key) throws IOException {
389376
if (blobStore.isFilesystem()) {
390377
return new FileSystemResource(
391378
locationManager.getFileForReading(blobStore.container().resolve(key)));
392-
} else {
393-
URI uri = fileResourceContentStore.getSignedGetContentUri(key);
379+
}
380+
URI uri = fileResourceContentStore.getSignedGetContentUri(key);
381+
if (uri != null) {
394382
return new UrlResource(uri);
395383
}
384+
// When backend supports neither filesystem access nor signed URLs (e.g. transient): buffer
385+
// the blob bytes so Spring can serve them directly.
386+
try (InputStream in = blobStore.openStream(key)) {
387+
if (in == null) throw new IOException("Blob not found: " + key);
388+
return new ByteArrayResource(in.readAllBytes());
389+
}
396390
}
397391

398392
/**
@@ -429,12 +423,11 @@ private static void logInstallSuccess(App app, String appFolder) {
429423

430424
private static void logDiscoveredApps(Map<String, Pair<App, BundledAppInfo>> apps) {
431425
if (apps.isEmpty()) {
432-
log.info("No apps found during JClouds discovery.");
426+
log.info("No apps found during blob store discovery");
433427
} else {
434428
apps.values()
435429
.forEach(
436-
pair ->
437-
log.info("Discovered app '{}' from JClouds storage ", pair.getLeft().getName()));
430+
pair -> log.info("Discovered app '{}' from blob storage ", pair.getLeft().getName()));
438431
}
439432
}
440433
}

0 commit comments

Comments
 (0)