s3 bucket integration for image storage
This commit is contained in:
@@ -32,6 +32,13 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>bom</artifactId>
|
||||
<version>2.30.14</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
@@ -108,6 +115,12 @@
|
||||
<version>3.0.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- AWS SDK v2 — S3 figure storage -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>s3</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Jackson (JSON) -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user