s3 bucket integration for image storage

This commit is contained in:
Adrien
2026-04-04 13:26:55 +02:00
parent 5acfdd33c1
commit b154e29f2d
9 changed files with 195 additions and 91 deletions
+13
View File
@@ -32,6 +32,13 @@
<type>pom</type> <type>pom</type>
<scope>import</scope> <scope>import</scope>
</dependency> </dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>2.30.14</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>
@@ -108,6 +115,12 @@
<version>3.0.3</version> <version>3.0.3</version>
</dependency> </dependency>
<!-- AWS SDK v2 — S3 figure storage -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
<!-- Jackson (JSON) --> <!-- Jackson (JSON) -->
<dependency> <dependency>
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>
@@ -100,9 +100,9 @@ public class BookEmbeddingService {
// Step 4: For each figure, generate vision description and embed caption // Step 4: For each figure, generate vision description and embed caption
for (FigureEntity figure : figures) { for (FigureEntity figure : figures) {
Path imagePath = figureStorageService.resolve(figure.getImagePath()); byte[] imageBytes = figureStorageService.getBytes(figure.getImagePath());
String description = visionDescriptionService.describe( String description = visionDescriptionService.describe(
imagePath, figure.getCaption()); imageBytes, figure.getCaption());
// Use description as caption fallback if no caption was detected // Use description as caption fallback if no caption was detected
if (figure.getCaption() == null || figure.getCaption().isBlank()) { if (figure.getCaption() == null || figure.getCaption().isBlank()) {
@@ -1,25 +1,37 @@
package com.aiteacher.config; package com.aiteacher.config;
import org.springframework.beans.factory.annotation.Value; import com.aiteacher.figure.FigureStorageService;
import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 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) { public FigureStorageConfig(FigureStorageService figureStorageService) {
this.basePath = Paths.get(basePath).toAbsolutePath().normalize().toString(); this.figureStorageService = figureStorageService;
} }
@Override @GetMapping("/{bookId}/{filename}")
public void addResourceHandlers(ResourceHandlerRegistry registry) { public void serve(@PathVariable String bookId,
// Serve GET /api/v1/figures/** from the local file store @PathVariable String filename,
registry.addResourceHandler("/api/v1/figures/**") HttpServletResponse response) throws IOException {
.addResourceLocations("file:" + basePath + "/figures/"); 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);
}
} }
} }
@@ -20,7 +20,9 @@ public class SecurityConfig {
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/figures/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults()) .httpBasic(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable); .csrf(AbstractHttpConfigurer::disable);
return http.build(); return http.build();
@@ -3,12 +3,10 @@ package com.aiteacher.document;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient; 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.stereotype.Service;
import org.springframework.util.MimeTypeUtils; import org.springframework.util.MimeTypeUtils;
import java.nio.file.Path;
/** /**
* Generates a clinical text description for an extracted figure image * Generates a clinical text description for an extracted figure image
* using the OpenAI vision model via Spring AI ChatClient. * 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. * 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 { try {
return chatClient.prompt() return chatClient.prompt()
.user(u -> u .user(u -> u
.text(PROMPT) .text(PROMPT)
.media(MimeTypeUtils.IMAGE_PNG, new FileSystemResource(imagePath.toFile()))) .media(MimeTypeUtils.IMAGE_PNG, new ByteArrayResource(imageBytes)))
.call() .call()
.content(); .content();
} catch (Exception ex) { } catch (Exception ex) {
log.warn("Vision description failed for {}: {} — using caption as fallback", log.warn("Vision description failed: {} — using caption as fallback", ex.getMessage());
imagePath.getFileName(), ex.getMessage());
return captionFallback != null ? captionFallback : "Figure"; return captionFallback != null ? captionFallback : "Figure";
} }
} }
@@ -1,24 +1,27 @@
package com.aiteacher.figure; package com.aiteacher.figure;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.nio.file.Path;
import java.util.UUID; import java.util.UUID;
public interface FigureStorageService { public interface FigureStorageService {
/** /**
* Saves an extracted image to the figure store and returns the relative path * Saves an extracted image to S3 and returns the object key stored in the database.
* (relative to the configured base-path) stored in the database.
*/ */
String save(UUID bookId, String figureId, BufferedImage image); 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); void deleteAll(UUID bookId);
} }
@@ -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());
}
}
}
@@ -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<ObjectIdentifier> 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());
}
}
}
+5 -1
View File
@@ -55,7 +55,11 @@ app:
auth: auth:
password: ${APP_PASSWORD:changeme} password: ${APP_PASSWORD:changeme}
figure-storage: 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 min-image-size-px: 100
embedding: embedding:
batch-size: 20 batch-size: 20