Add image-gen module: Spring AI ImageModel options on the wire, a moderation gate, and safe storage of generated images in MinIO (sniffed content type, byte and pixel caps, server-chosen keys, capped URL fetcher); per-provider cost table dated 2026-10-28. ImageModel and ModerationModel are hand-written fakes; MinIO is real via Testcontainers; no image provider called.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
package com.ankurm.imagegen;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Downloads an image a provider returned as a URL (DALL-E 3 style {@code response_format=url}).
|
||||
* The URL comes from a third party, so it is treated as untrusted input: https only, the host must
|
||||
* be on a list, redirects are not followed, the read stops at the byte cap, and there is a timeout.
|
||||
*/
|
||||
public final class RemoteFetcher {
|
||||
|
||||
private final ImagePolicy policy;
|
||||
private final HttpClient http = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NEVER)
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
|
||||
public RemoteFetcher(ImagePolicy policy) {
|
||||
this.policy = policy;
|
||||
}
|
||||
|
||||
public byte[] fetch(String url) {
|
||||
URI uri;
|
||||
try {
|
||||
uri = URI.create(url);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new RejectedException("bad-url", "not a URI");
|
||||
}
|
||||
boolean httpsOk = "https".equalsIgnoreCase(uri.getScheme());
|
||||
boolean httpOk = policy.allowInsecure() && "http".equalsIgnoreCase(uri.getScheme());
|
||||
if (!httpsOk && !httpOk) {
|
||||
throw new RejectedException("bad-scheme", String.valueOf(uri.getScheme()));
|
||||
}
|
||||
if (uri.getHost() == null || !policy.fetchHosts().contains(uri.getHost().toLowerCase())) {
|
||||
throw new RejectedException("host-not-allowed", String.valueOf(uri.getHost()));
|
||||
}
|
||||
try {
|
||||
HttpResponse<InputStream> r = http.send(
|
||||
HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(15)).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofInputStream());
|
||||
if (r.statusCode() != 200) {
|
||||
r.body().close();
|
||||
throw new RejectedException("fetch-status", String.valueOf(r.statusCode()));
|
||||
}
|
||||
return readCapped(r.body());
|
||||
} catch (IOException e) {
|
||||
throw new RejectedException("fetch-failed", e.getClass().getSimpleName());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RejectedException("fetch-failed", "interrupted");
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads at most maxBytes + 1 bytes so an endless body costs us one byte over the cap, not the heap. */
|
||||
private byte[] readCapped(InputStream in) throws IOException {
|
||||
try (in) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[8192];
|
||||
long total = 0;
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
total += n;
|
||||
if (total > policy.maxBytes()) {
|
||||
throw new RejectedException("too-large", "download passed the " + policy.maxBytes() + "-byte cap");
|
||||
}
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user