From b154e29f2d7e637258593dab595c8caecf06b6c3 Mon Sep 17 00:00:00 2001 From: Adrien Date: Sat, 4 Apr 2026 13:26:55 +0200 Subject: [PATCH] s3 bucket integration for image storage --- backend/pom.xml | 13 ++ .../aiteacher/book/BookEmbeddingService.java | 4 +- .../aiteacher/config/FigureStorageConfig.java | 42 ++++-- .../com/aiteacher/config/SecurityConfig.java | 4 +- .../document/VisionDescriptionService.java | 11 +- .../figure/FigureStorageService.java | 15 +- .../figure/LocalFigureStorageService.java | 59 -------- .../figure/S3FigureStorageService.java | 132 ++++++++++++++++++ backend/src/main/resources/application.yaml | 6 +- 9 files changed, 195 insertions(+), 91 deletions(-) delete mode 100644 backend/src/main/java/com/aiteacher/figure/LocalFigureStorageService.java create mode 100644 backend/src/main/java/com/aiteacher/figure/S3FigureStorageService.java 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