From 4dd749a837926f0eba2058dafa7b447d93fcba3e Mon Sep 17 00:00:00 2001 From: Jalen Stephens <108702328+Jalen-Stephens@users.noreply.github.com> Date: Sat, 29 Nov 2025 20:58:31 -0500 Subject: [PATCH] fix(storage): encode Supabase paths and normalize project base URL --- citations.md | 24 ++++++----- .../service/SupabaseStorageService.java | 38 ++++++++++------ .../service/SupabaseStorageServiceTest.java | 43 +++++++++++++++++++ 3 files changed, 82 insertions(+), 23 deletions(-) diff --git a/citations.md b/citations.md index f9616f1..abaa048 100644 --- a/citations.md +++ b/citations.md @@ -1,41 +1,45 @@ ### **Commit / Ticket Reference** -- **Commit:** -- **Ticket:** -- **Date:** -- **Team Member:** +- **Commit:** fix(storage): encode Supabase paths and normalize project base URL +- **Ticket:** N/A (prod bugfix) +- **Date:** 2025-11-29 +- **Team Member:** Jalen Stephens --- ### **AI Tool Information** - **Tool Used:** OpenAI ChatGPT (GPT-5) -- **Access Method:** ChatGPT Web (.edu academic access) +- **Access Method:** Codex CLI (local, sandboxed; no paid API calls) - **Configuration:** Default model settings -- **Cost:** $0 (no paid API calls) +- **Cost:** $0 (course-provided access) --- ### **Purpose of AI Assistance** - +Identified and fixed Supabase storage path handling so filenames with spaces are safely encoded; normalized project base URLs to avoid double slashes in upload/sign/delete endpoints; ensured tests cover space-encoding paths. --- ### **Prompts / Interaction Summary** - +- “Uploads fail when filenames contain spaces; make storage paths URL-safe for Supabase.” +- “Fix double-encoding/double-slash issues in SupabaseStorageService.” +- “Provide commit message and fill citations template.” --- ### **Resulting Artifacts** - +- `src/main/java/dev/coms4156/project/metadetect/service/SupabaseStorageService.java` +- `src/test/java/dev/coms4156/project/metadetect/service/SupabaseStorageServiceTest.java` --- ### **Verification** - +- `mvn -q -Dtest=SupabaseStorageServiceTest test` --- ### **Attribution Statement** > Portions of this commit or configuration were generated with assistance from OpenAI ChatGPT (GPT-5) on . All AI-generated content was reviewed, verified, and finalized by the development team. +> Portions of this commit or configuration were generated with assistance from OpenAI ChatGPT (GPT-5) on 2025-11-29. All AI-generated content was reviewed, verified, and finalized by the development team. --- diff --git a/src/main/java/dev/coms4156/project/metadetect/service/SupabaseStorageService.java b/src/main/java/dev/coms4156/project/metadetect/service/SupabaseStorageService.java index fa477f2..12829d2 100644 --- a/src/main/java/dev/coms4156/project/metadetect/service/SupabaseStorageService.java +++ b/src/main/java/dev/coms4156/project/metadetect/service/SupabaseStorageService.java @@ -1,5 +1,6 @@ package dev.coms4156.project.metadetect.service; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Duration; import org.slf4j.Logger; @@ -33,7 +34,7 @@ public class SupabaseStorageService { private static final Logger log = LoggerFactory.getLogger(SupabaseStorageService.class); private final WebClient supabase; - private final String projectBase; // e.g., https://xyz.supabase.co + private final String projectBase; // e.g., https://xyz.supabase.co (no trailing slash) private final String bucket; // e.g., metadetect-images private final int signedUrlTtlSeconds; // e.g., 600 private final String supabaseAnonKey; // required by Storage API @@ -41,6 +42,19 @@ public class SupabaseStorageService { // Variant of WebClient that removes Content-Type on DELETE requests. private final WebClient supabaseNoCtOnDelete; + // Encodes a bucket object path by URL-encoding each segment (spaces -> %20, etc.). + private static String encodePath(String objectPath) { + if (objectPath == null || objectPath.isBlank()) { + throw new IllegalArgumentException("objectPath must not be blank"); + } + return UriComponentsBuilder.newInstance() + .pathSegment(objectPath.split("/")) + .build() + .encode() + .toUriString() + .substring(1); // drop leading '/' + } + /** * Constructs a Supabase Storage adapter used for upload/sign/delete operations. * @@ -58,7 +72,10 @@ public SupabaseStorageService( @Value("${metadetect.supabase.anonKey}") String supabaseAnonKey ) { this.supabase = supabaseWebClient; - this.projectBase = projectBase; + // Normalize to avoid double slashes when concatenating paths. + this.projectBase = projectBase != null && projectBase.endsWith("/") + ? projectBase.substring(0, projectBase.length() - 1) + : projectBase; this.bucket = bucket; this.signedUrlTtlSeconds = signedUrlTtlSeconds; this.supabaseAnonKey = supabaseAnonKey; @@ -97,14 +114,8 @@ public String uploadObject(byte[] bytes, String bearerJwt) { // URL-encode segments to avoid 400s on special characters. - String encoded = UriComponentsBuilder.newInstance() - .pathSegment(objectPath.split("/")) - .build() - .encode() - .toUriString() - .substring(1); // drop leading '/' - - String url = projectBase + "/storage/v1/object/" + bucket + "/" + encoded; + String encoded = encodePath(objectPath); + URI url = URI.create(projectBase + "/storage/v1/object/" + bucket + "/" + encoded); try { supabase @@ -143,7 +154,8 @@ public String uploadObject(byte[] bytes, * @return absolute https URL suitable for direct client download */ public String createSignedUrl(String storagePath, String userBearerJwt) { - String url = projectBase + "/storage/v1/object/sign/" + bucket + "/" + storagePath; + String encoded = encodePath(storagePath); + URI url = URI.create(projectBase + "/storage/v1/object/sign/" + bucket + "/" + encoded); String bodyJson = "{\"expiresIn\":" + signedUrlTtlSeconds + "}"; String signedFromApi = supabase.post() @@ -196,8 +208,8 @@ public void deleteObject(String objectPath, String bearer) { if (objectPath == null || objectPath.isBlank()) { return; } - - String url = projectBase + "/storage/v1/object/" + bucket + "/" + objectPath; + String encoded = encodePath(objectPath); + URI url = URI.create(projectBase + "/storage/v1/object/" + bucket + "/" + encoded); try { supabaseNoCtOnDelete diff --git a/src/test/java/dev/coms4156/project/metadetect/service/SupabaseStorageServiceTest.java b/src/test/java/dev/coms4156/project/metadetect/service/SupabaseStorageServiceTest.java index 54f43c7..7f40cce 100644 --- a/src/test/java/dev/coms4156/project/metadetect/service/SupabaseStorageServiceTest.java +++ b/src/test/java/dev/coms4156/project/metadetect/service/SupabaseStorageServiceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.net.SocketException; @@ -101,6 +102,23 @@ void uploadObject_putsRawBytesToCorrectPath_withAuthAndApikeyHeaders() throws Ex assertEquals(MediaType.IMAGE_PNG_VALUE, req.getHeader("Content-Type")); } + @Test + void uploadObject_encodesSpacesInPath() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + storageService.uploadObject( + "hello".getBytes(), + MediaType.IMAGE_PNG_VALUE, + "user 123/photo folder/my photo.png", + "bearer.jwt.here" + ); + + RecordedRequest req = server.takeRequest(); + assertEquals( + "/storage/v1/object/metadetect-images/user%20123/photo%20folder/my%20photo.png", + req.getPath()); + } + /** * Verifies createSignedUrl issues POST to /sign endpoint and * reconstructs the final absolute URL using projectBase. @@ -134,6 +152,31 @@ void createSignedUrl_returnsAbsoluteProjectUrl_andSendsHeaders() throws Exceptio assertEquals("application/json", req.getHeader("Content-Type")); } + @Test + void createSignedUrl_encodesSpacesInPath() throws Exception { + String relative = + "/storage/v1/object/sign/metadetect-images/" + + "user%20123/my%20photo%201.png?token=abc&expires=321"; + + server.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{\"signedURL\":\"" + relative + "\"}")); + + String abs = storageService.createSignedUrl( + "user 123/my photo 1.png", + "bearer.jwt.here" + ); + + assertNotNull(abs); + assertTrue(abs.endsWith(relative)); + + RecordedRequest req = server.takeRequest(); + assertEquals( + "/storage/v1/object/sign/metadetect-images/user%20123/my%20photo%201.png", + req.getPath()); + } + @Test void deleteObject_ignoresBlankPath() throws Exception { // No enqueue; nothing should hit the server