diff --git a/backend/pom.xml b/backend/pom.xml
index 50b8e2e..cc5cd35 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -32,6 +32,13 @@
pom
import
+
+ software.amazon.awssdk
+ bom
+ 2.30.14
+ pom
+ import
+
@@ -108,6 +115,12 @@
3.0.3
+
+
+ software.amazon.awssdk
+ s3
+
+
com.fasterxml.jackson.core
diff --git a/backend/src/main/java/com/aiteacher/book/BookEmbeddingService.java b/backend/src/main/java/com/aiteacher/book/BookEmbeddingService.java
index 96b9db7..834c0d2 100644
--- a/backend/src/main/java/com/aiteacher/book/BookEmbeddingService.java
+++ b/backend/src/main/java/com/aiteacher/book/BookEmbeddingService.java
@@ -100,9 +100,9 @@ public class BookEmbeddingService {
// Step 4: For each figure, generate vision description and embed caption
for (FigureEntity figure : figures) {
- Path imagePath = figureStorageService.resolve(figure.getImagePath());
+ byte[] imageBytes = figureStorageService.getBytes(figure.getImagePath());
String description = visionDescriptionService.describe(
- imagePath, figure.getCaption());
+ imageBytes, figure.getCaption());
// Use description as caption fallback if no caption was detected
if (figure.getCaption() == null || figure.getCaption().isBlank()) {
diff --git a/backend/src/main/java/com/aiteacher/config/FigureStorageConfig.java b/backend/src/main/java/com/aiteacher/config/FigureStorageConfig.java
index ee27799..45602be 100644
--- a/backend/src/main/java/com/aiteacher/config/FigureStorageConfig.java
+++ b/backend/src/main/java/com/aiteacher/config/FigureStorageConfig.java
@@ -1,25 +1,37 @@
package com.aiteacher.config;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
-import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import com.aiteacher.figure.FigureStorageService;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
-import java.nio.file.Paths;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
-@Configuration
-public class FigureStorageConfig implements WebMvcConfigurer {
+/**
+ * Serves figure images by redirecting to a presigned S3 URL.
+ * The key stored in DB is the full S3 object key, e.g. "figures/{bookId}/{figureId}.png".
+ */
+@RestController
+@RequestMapping("/api/v1/figures")
+public class FigureStorageConfig {
- private final String basePath;
+ private final FigureStorageService figureStorageService;
- public FigureStorageConfig(@Value("${app.figure-storage.base-path:./uploads}") String basePath) {
- this.basePath = Paths.get(basePath).toAbsolutePath().normalize().toString();
+ public FigureStorageConfig(FigureStorageService figureStorageService) {
+ this.figureStorageService = figureStorageService;
}
- @Override
- public void addResourceHandlers(ResourceHandlerRegistry registry) {
- // Serve GET /api/v1/figures/** from the local file store
- registry.addResourceHandler("/api/v1/figures/**")
- .addResourceLocations("file:" + basePath + "/figures/");
+ @GetMapping("/{bookId}/{filename}")
+ public void serve(@PathVariable String bookId,
+ @PathVariable String filename,
+ HttpServletResponse response) throws IOException {
+ String key = "figures/" + bookId + "/" + filename;
+ try {
+ String url = figureStorageService.presignedUrl(key);
+ response.sendRedirect(url);
+ } catch (Exception ex) {
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Figure not found: " + key);
+ }
}
}
diff --git a/backend/src/main/java/com/aiteacher/config/SecurityConfig.java b/backend/src/main/java/com/aiteacher/config/SecurityConfig.java
index 6e6194a..c6d4d8c 100644
--- a/backend/src/main/java/com/aiteacher/config/SecurityConfig.java
+++ b/backend/src/main/java/com/aiteacher/config/SecurityConfig.java
@@ -20,7 +20,9 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
- .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/api/v1/figures/**").permitAll()
+ .anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable);
return http.build();
diff --git a/backend/src/main/java/com/aiteacher/document/VisionDescriptionService.java b/backend/src/main/java/com/aiteacher/document/VisionDescriptionService.java
index 4a3d18c..86380a3 100644
--- a/backend/src/main/java/com/aiteacher/document/VisionDescriptionService.java
+++ b/backend/src/main/java/com/aiteacher/document/VisionDescriptionService.java
@@ -3,12 +3,10 @@ package com.aiteacher.document;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
-import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Service;
import org.springframework.util.MimeTypeUtils;
-import java.nio.file.Path;
-
/**
* Generates a clinical text description for an extracted figure image
* using the OpenAI vision model via Spring AI ChatClient.
@@ -32,17 +30,16 @@ public class VisionDescriptionService {
/**
* Returns a description string. Falls back to the provided caption if vision fails.
*/
- public String describe(Path imagePath, String captionFallback) {
+ public String describe(byte[] imageBytes, String captionFallback) {
try {
return chatClient.prompt()
.user(u -> u
.text(PROMPT)
- .media(MimeTypeUtils.IMAGE_PNG, new FileSystemResource(imagePath.toFile())))
+ .media(MimeTypeUtils.IMAGE_PNG, new ByteArrayResource(imageBytes)))
.call()
.content();
} catch (Exception ex) {
- log.warn("Vision description failed for {}: {} — using caption as fallback",
- imagePath.getFileName(), ex.getMessage());
+ log.warn("Vision description failed: {} — using caption as fallback", ex.getMessage());
return captionFallback != null ? captionFallback : "Figure";
}
}
diff --git a/backend/src/main/java/com/aiteacher/figure/FigureStorageService.java b/backend/src/main/java/com/aiteacher/figure/FigureStorageService.java
index 4a257ee..9720ee7 100644
--- a/backend/src/main/java/com/aiteacher/figure/FigureStorageService.java
+++ b/backend/src/main/java/com/aiteacher/figure/FigureStorageService.java
@@ -1,24 +1,27 @@
package com.aiteacher.figure;
import java.awt.image.BufferedImage;
-import java.nio.file.Path;
import java.util.UUID;
public interface FigureStorageService {
/**
- * Saves an extracted image to the figure store and returns the relative path
- * (relative to the configured base-path) stored in the database.
+ * Saves an extracted image to S3 and returns the object key stored in the database.
*/
String save(UUID bookId, String figureId, BufferedImage image);
/**
- * Resolves a stored relative path to an absolute filesystem path.
+ * Downloads the image bytes for the given S3 object key.
*/
- Path resolve(String relativePath);
+ byte[] getBytes(String key);
/**
- * Deletes all figure files for the given book.
+ * Returns a presigned GET URL valid for 1 hour for the given S3 object key.
+ */
+ String presignedUrl(String key);
+
+ /**
+ * Deletes all figure objects for the given book.
*/
void deleteAll(UUID bookId);
}
diff --git a/backend/src/main/java/com/aiteacher/figure/LocalFigureStorageService.java b/backend/src/main/java/com/aiteacher/figure/LocalFigureStorageService.java
deleted file mode 100644
index 48a3df3..0000000
--- a/backend/src/main/java/com/aiteacher/figure/LocalFigureStorageService.java
+++ /dev/null
@@ -1,59 +0,0 @@
-package com.aiteacher.figure;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Service;
-
-import javax.imageio.ImageIO;
-import java.awt.image.BufferedImage;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.UUID;
-
-@Service
-public class LocalFigureStorageService implements FigureStorageService {
-
- private static final Logger log = LoggerFactory.getLogger(LocalFigureStorageService.class);
-
- private final Path basePath;
-
- public LocalFigureStorageService(@Value("${app.figure-storage.base-path:./uploads}") String basePath) {
- this.basePath = Paths.get(basePath).toAbsolutePath().normalize();
- }
-
- @Override
- public String save(UUID bookId, String figureId, BufferedImage image) {
- try {
- Path dir = basePath.resolve("figures").resolve(bookId.toString());
- Files.createDirectories(dir);
- String filename = figureId + ".png";
- Path file = dir.resolve(filename);
- ImageIO.write(image, "PNG", file.toFile());
- // Return relative path for storage in DB
- return "figures/" + bookId + "/" + filename;
- } catch (IOException ex) {
- throw new RuntimeException("Failed to save figure " + figureId, ex);
- }
- }
-
- @Override
- public Path resolve(String relativePath) {
- return basePath.resolve(relativePath);
- }
-
- @Override
- public void deleteAll(UUID bookId) {
- Path dir = basePath.resolve("figures").resolve(bookId.toString());
- if (!Files.exists(dir)) return;
- try (var walk = Files.walk(dir)) {
- walk.sorted(java.util.Comparator.reverseOrder())
- .map(Path::toFile)
- .forEach(java.io.File::delete);
- } catch (IOException ex) {
- log.warn("Could not fully delete figures for book {}: {}", bookId, ex.getMessage());
- }
- }
-}
diff --git a/backend/src/main/java/com/aiteacher/figure/S3FigureStorageService.java b/backend/src/main/java/com/aiteacher/figure/S3FigureStorageService.java
new file mode 100644
index 0000000..705209f
--- /dev/null
+++ b/backend/src/main/java/com/aiteacher/figure/S3FigureStorageService.java
@@ -0,0 +1,132 @@
+package com.aiteacher.figure;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.S3Configuration;
+import software.amazon.awssdk.services.s3.model.*;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
+import software.amazon.awssdk.services.s3.model.S3Object;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+@Service
+public class S3FigureStorageService implements FigureStorageService {
+
+ private static final Logger log = LoggerFactory.getLogger(S3FigureStorageService.class);
+
+ private final S3Client s3;
+ private final S3Presigner presigner;
+ private final String bucket;
+
+ public S3FigureStorageService(
+ @Value("${app.figure-storage.endpoint}") String endpoint,
+ @Value("${app.figure-storage.region}") String region,
+ @Value("${app.figure-storage.bucket}") String bucket,
+ @Value("${app.figure-storage.access-key-id}") String accessKeyId,
+ @Value("${app.figure-storage.secret-access-key}") String secretKey) {
+ this.bucket = bucket;
+ URI endpointUri = URI.create(endpoint);
+ StaticCredentialsProvider credentials = StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(accessKeyId, secretKey));
+ Region awsRegion = Region.of(region);
+
+ S3Configuration s3Config = S3Configuration.builder()
+ .pathStyleAccessEnabled(true)
+ .build();
+
+ this.s3 = S3Client.builder()
+ .endpointOverride(endpointUri)
+ .region(awsRegion)
+ .credentialsProvider(credentials)
+ .serviceConfiguration(s3Config)
+ .build();
+
+ this.presigner = S3Presigner.builder()
+ .endpointOverride(endpointUri)
+ .region(awsRegion)
+ .credentialsProvider(credentials)
+ .serviceConfiguration(s3Config)
+ .build();
+ }
+
+ @Override
+ public String save(UUID bookId, String figureId, BufferedImage image) {
+ String key = "figures/" + bookId + "/" + figureId + ".png";
+ try {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ImageIO.write(image, "PNG", out);
+ byte[] bytes = out.toByteArray();
+
+ s3.putObject(
+ PutObjectRequest.builder().bucket(bucket).key(key)
+ .contentType("image/png").contentLength((long) bytes.length).build(),
+ RequestBody.fromBytes(bytes));
+ return key;
+ } catch (IOException ex) {
+ throw new RuntimeException("Failed to encode figure " + figureId, ex);
+ } catch (S3Exception ex) {
+ throw new RuntimeException("Failed to upload figure " + figureId + " to S3", ex);
+ }
+ }
+
+ @Override
+ public byte[] getBytes(String key) {
+ try {
+ return s3.getObjectAsBytes(
+ GetObjectRequest.builder().bucket(bucket).key(key).build()).asByteArray();
+ } catch (S3Exception ex) {
+ throw new RuntimeException("Failed to download figure from S3: " + key, ex);
+ }
+ }
+
+ @Override
+ public String presignedUrl(String key) {
+ GetObjectPresignRequest request = GetObjectPresignRequest.builder()
+ .signatureDuration(Duration.ofHours(1))
+ .getObjectRequest(r -> r.bucket(bucket).key(key))
+ .build();
+ return presigner.presignGetObject(request).url().toString();
+ }
+
+ @Override
+ public void deleteAll(UUID bookId) {
+ String prefix = "figures/" + bookId + "/";
+ try {
+ List toDelete = new ArrayList<>();
+ ListObjectsV2Request listRequest = ListObjectsV2Request.builder()
+ .bucket(bucket).prefix(prefix).build();
+
+ s3.listObjectsV2Paginator(listRequest).stream()
+ .flatMap(page -> page.contents().stream())
+ .map(S3Object::key)
+ .map(k -> ObjectIdentifier.builder().key(k).build())
+ .forEach(toDelete::add);
+
+ if (toDelete.isEmpty()) return;
+
+ s3.deleteObjects(DeleteObjectsRequest.builder()
+ .bucket(bucket)
+ .delete(Delete.builder().objects(toDelete).build())
+ .build());
+ log.info("Deleted {} figures from S3 for book {}", toDelete.size(), bookId);
+ } catch (S3Exception ex) {
+ log.warn("Could not fully delete figures for book {} from S3: {}", bookId, ex.getMessage());
+ }
+ }
+}
diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml
index 47a929d..f045ac8 100644
--- a/backend/src/main/resources/application.yaml
+++ b/backend/src/main/resources/application.yaml
@@ -55,7 +55,11 @@ app:
auth:
password: ${APP_PASSWORD:changeme}
figure-storage:
- base-path: ${FIGURE_STORAGE_PATH:./uploads}
+ endpoint: https://s3.immich-ad.ovh
+ region: garage
+ bucket: ${S3_BUCKET:aiteacher}
+ access-key-id: ${S3_ACCESS_KEY_ID}
+ secret-access-key: ${S3_SECRET_ACCESS_KEY}
min-image-size-px: 100
embedding:
batch-size: 20