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
63 changes: 63 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ services:
APP_JWT_EXPIRATION_MS: ${APP_JWT_EXPIRATION_MS:-86400000}
APP_AUTH_GOOGLE_STATE_COOKIE_SECURE: ${GOOGLE_OAUTH_STATE_COOKIE_SECURE:-true}
OCR_SERVICE_API_KEY: ${OCR_API_KEY:?Set OCR_API_KEY for backend-to-OCR authentication}
# Object storage. Keep local for the default development profile;
# use STORAGE_PROVIDER=s3 with --profile storage for MinIO.
STORAGE_PROVIDER: ${STORAGE_PROVIDER:-local}
STORAGE_LOCAL_ROOT: ${STORAGE_LOCAL_ROOT:-/tmp/kixi/uploads}
STORAGE_PUBLIC_BASE_URL: ${STORAGE_PUBLIC_BASE_URL:-/uploads}
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT:-http://minio:9000}
STORAGE_S3_REGION: ${STORAGE_S3_REGION:-us-east-1}
STORAGE_S3_BUCKET: ${STORAGE_S3_BUCKET:-kixi-images}
STORAGE_S3_ACCESS_KEY: ${STORAGE_S3_ACCESS_KEY:-kixi-minio}
STORAGE_S3_SECRET_KEY: ${STORAGE_S3_SECRET_KEY:-kixi-minio-secret}
STORAGE_S3_PATH_STYLE_ACCESS: ${STORAGE_S3_PATH_STYLE_ACCESS:-true}
STORAGE_MAX_OBJECT_SIZE_BYTES: ${STORAGE_MAX_OBJECT_SIZE_BYTES:-20971520}
ports:
- "${BACKEND_PORT:-8080}:8080"
depends_on:
Expand Down Expand Up @@ -255,6 +267,53 @@ services:
networks:
- kixi-network

# ===========================================================================
# S3-compatible object storage (Optional Profile)
# ===========================================================================
# Start with STORAGE_PROVIDER=s3 docker-compose --profile storage up
minio:
image: minio/minio:RELEASE.2024-06-13T22-53-53Z
container_name: kixi-minio
profiles:
- storage
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${STORAGE_S3_ACCESS_KEY:-kixi-minio}
MINIO_ROOT_PASSWORD: ${STORAGE_S3_SECRET_KEY:-kixi-minio-secret}
ports:
- "${MINIO_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
networks:
- kixi-network

minio-init:
image: minio/mc:RELEASE.2024-06-13T21-21-32Z
container_name: kixi-minio-init
profiles:
- storage
depends_on:
minio:
condition: service_healthy
entrypoint: /bin/sh
command:
- -c
- >-
mc alias set local http://minio:9000
${STORAGE_S3_ACCESS_KEY:-kixi-minio}
${STORAGE_S3_SECRET_KEY:-kixi-minio-secret}
&& mc mb --ignore-existing local/${STORAGE_S3_BUCKET:-kixi-images}
&& mc anonymous set download local/${STORAGE_S3_BUCKET:-kixi-images}
networks:
- kixi-network

# =============================================================================
# Networks
# =============================================================================
Expand Down Expand Up @@ -282,3 +341,7 @@ volumes:
# Redis data persistence
redis_data:
name: kixi-redis-data

# MinIO object data for local integration runs
minio_data:
name: kixi-minio-data
41 changes: 41 additions & 0 deletions docs/IMAGE_STORAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Image storage

Question images are stored through the `ImageStorage` port. The backend supports:

- `local` (default): writes below `storage.local-root` and preserves the existing
`/uploads` URL base for local development;
- `s3`: uses AWS S3 or any S3-compatible endpoint such as MinIO.

The object key is generated by the backend (`questions/{questionId}/{uuid}.ext`).
The multipart filename is never used as a path component. Only JPEG, PNG and
WebP are accepted, and the default maximum object size is 20 MiB. The key is
stored in `question_images.storage_key`; legacy rows remain purgeable without a
storage delete because they predate this column.

## MinIO local integration

Run the optional profile with the S3 provider enabled:

```bash
STORAGE_PROVIDER=s3 \
STORAGE_PUBLIC_BASE_URL=http://localhost:9000/kixi-images \
docker compose --profile storage up --build
```

The profile creates the `kixi-images` bucket and enables anonymous downloads for
local development only. Do not carry that bucket policy to production. In
production, configure a private bucket with a controlled CDN or signed delivery
endpoint through `STORAGE_PUBLIC_BASE_URL`.

## OCR boundary

OCR returns a versioned region contract (`contractVersion: 1`) with page index,
source-file index, source dimensions and a bounded crop rectangle. The
persistence endpoint retains the submitted raster bytes, crops `questao_N`
regions, stores the PNG through the same `ImageStorage` port and associates it
with the persisted question.

PDF sources and `cabecalho`/`rodape` regions remain metadata-only in this slice:
the OCR service renders PDF pages internally and `question_images` requires a
question association. A future statement-asset contract must define those
cases before they are persisted automatically.
6 changes: 6 additions & 0 deletions services/backend-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@
</properties>

<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.25.60</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,53 @@ public Mono<OcrResponse> extractText(List<FilePart> files) {
.doOnError(error -> log.error("OCR request failed: type={}", error.getClass().getSimpleName()));
}

/**
* Extract text from bytes retained by the persistence flow. This variant
* avoids replaying a consumed multipart stream when OCR regions are later
* associated with their source page.
*/
public Mono<OcrResponse> extractTextFromUploadedFiles(List<OcrUploadedFile> files) {
if (files == null || files.isEmpty()) {
return Mono.error(new IllegalArgumentException("At least one file is required"));
}

MultipartBodyBuilder builder = new MultipartBodyBuilder();
for (OcrUploadedFile file : files) {
builder.part("images", file.content())
.filename(file.filename())
.contentType(file.contentType() != null
? file.contentType()
: getContentType(file.filename()));
}

log.info("Sending retained OCR request: {} file(s)", files.size());
return webClient.post()
.uri("/ocr/v1/extract")
.headers(this::applyAuthentication)
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(BodyInserters.fromMultipartData(builder.build()))
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, response ->
response.bodyToMono(String.class)
.flatMap(body -> Mono.error(new OcrClientException(
"OCR request failed: " + body,
response.statusCode().value()))))
.onStatus(HttpStatusCode::is5xxServerError, response ->
response.bodyToMono(String.class)
.flatMap(body -> Mono.error(new OcrServerException(
"OCR service error: " + body,
response.statusCode().value()))))
.bodyToMono(OcrResponse.class)
.timeout(timeout)
.retryWhen(Retry.backoff(maxRetries, Duration.ofSeconds(1))
.filter(this::isRetryable))
.doOnSuccess(response -> log.info(
"OCR retained request successful: requestId={}, status={}",
response.requestId(), response.status()))
.doOnError(error -> log.error("OCR retained request failed: type={}",
error.getClass().getSimpleName()));
}

/**
* Extract text from raw image bytes.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package ao.creativemode.kixi.client;

import org.springframework.http.MediaType;

/**
* Immutable upload retained for OCR persistence. Keeping the bytes here lets
* the backend associate OCR regions with the exact source page it submitted.
*/
public record OcrUploadedFile(
String filename,
MediaType contentType,
byte[] content) {

public OcrUploadedFile {
if (filename == null || filename.isBlank()) {
throw new IllegalArgumentException("Filename is required");
}
if (content == null || content.length == 0) {
throw new IllegalArgumentException("File content cannot be empty");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package ao.creativemode.kixi.config;

import java.net.URI;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;

@Configuration(proxyBeanMethods = false)
public class StorageConfiguration {

@Bean
@ConditionalOnProperty(prefix = "storage", name = "provider", havingValue = "s3")
S3Client s3Client(StorageProperties properties) {
if (properties.getS3AccessKey().isBlank() || properties.getS3SecretKey().isBlank()) {
throw new IllegalStateException("storage.s3-access-key and storage.s3-secret-key must be configured for S3 storage");
}

var builder = S3Client.builder()
.region(Region.of(properties.getS3Region()))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(properties.getS3AccessKey(), properties.getS3SecretKey())))
.serviceConfiguration(S3Configuration.builder()
.pathStyleAccessEnabled(properties.isS3PathStyleAccess())
.build());

if (!properties.getS3Endpoint().isBlank()) {
builder.endpointOverride(URI.create(properties.getS3Endpoint()));
}
return builder.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package ao.creativemode.kixi.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
* Object storage configuration. The default keeps local development compatible
* with the existing static upload directory; production should use S3.
*/
@Component
@ConfigurationProperties(prefix = "storage")
public class StorageProperties {

private String provider = "local";
private String localRoot = "services/backend-api/src/main/resources/static/uploads";
private String publicBaseUrl = "/uploads";
private String s3Endpoint = "";
private String s3Region = "us-east-1";
private String s3Bucket = "kixi-images";
private String s3AccessKey = "";
private String s3SecretKey = "";
private boolean s3PathStyleAccess = true;
private long maxObjectSizeBytes = 20L * 1024 * 1024;

public String getProvider() {
return provider;
}

public void setProvider(String provider) {
this.provider = provider;
}

public String getLocalRoot() {
return localRoot;
}

public void setLocalRoot(String localRoot) {
this.localRoot = localRoot;
}

public String getPublicBaseUrl() {
return publicBaseUrl;
}

public void setPublicBaseUrl(String publicBaseUrl) {
this.publicBaseUrl = publicBaseUrl;
}

public String getS3Endpoint() {
return s3Endpoint;
}

public void setS3Endpoint(String s3Endpoint) {
this.s3Endpoint = s3Endpoint;
}

public String getS3Region() {
return s3Region;
}

public void setS3Region(String s3Region) {
this.s3Region = s3Region;
}

public String getS3Bucket() {
return s3Bucket;
}

public void setS3Bucket(String s3Bucket) {
this.s3Bucket = s3Bucket;
}

public String getS3AccessKey() {
return s3AccessKey;
}

public void setS3AccessKey(String s3AccessKey) {
this.s3AccessKey = s3AccessKey;
}

public String getS3SecretKey() {
return s3SecretKey;
}

public void setS3SecretKey(String s3SecretKey) {
this.s3SecretKey = s3SecretKey;
}

public boolean isS3PathStyleAccess() {
return s3PathStyleAccess;
}

public void setS3PathStyleAccess(boolean s3PathStyleAccess) {
this.s3PathStyleAccess = s3PathStyleAccess;
}

public long getMaxObjectSizeBytes() {
return maxObjectSizeBytes;
}

public void setMaxObjectSizeBytes(long maxObjectSizeBytes) {
this.maxObjectSizeBytes = maxObjectSizeBytes;
}
}
Loading
Loading