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
24 changes: 14 additions & 10 deletions citations.md
Original file line number Diff line number Diff line change
@@ -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**
<Briefly describe what part of the task the AI assisted with — e.g., refactoring, writing docs, fixing errors, setting up build tools, etc.>
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**
<List or paraphrase the key prompts you used.>
- “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**
<List files, configurations, or code generated/edited with AI help.>
- `src/main/java/dev/coms4156/project/metadetect/service/SupabaseStorageService.java`
- `src/test/java/dev/coms4156/project/metadetect/service/SupabaseStorageServiceTest.java`

---

### **Verification**
<List how you tested/validated the AI-assisted changes (build, test suite, manual review, etc.).>
- `mvn -q -Dtest=SupabaseStorageServiceTest test`

---

### **Attribution Statement**
> Portions of this commit or configuration were generated with assistance from OpenAI ChatGPT (GPT-5) on <date>. 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.

---

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -33,14 +34,27 @@ 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

// 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.
*
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down