Files
justbackup/src/main/java/io/ib67/sfcraft/s3/SimpleS3Client.java
T
2026-07-17 22:45:25 +08:00

171 lines
7.0 KiB
Java

package io.ib67.sfcraft.s3;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Objects;
import java.util.TreeMap;
/**
* A tiny synchronous S3 client on top of {@link HttpClient}, covering only what
* this mod needs: headBucket, putObject, getObject and deleteObject. Requests
* are authenticated with AWS Signature Version 4 and work against any
* S3-compatible endpoint, in both path-style and virtual-hosted style.
*/
public final class SimpleS3Client implements AutoCloseable {
private static final DateTimeFormatter AMZ_DATE =
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT).withZone(ZoneOffset.UTC);
private final HttpClient http;
private final String scheme;
// authority as HttpClient will put it into the Host header: default ports stripped
private final String hostPort;
private final String region;
private final String accessKey;
private final String secretKey;
private final boolean pathStyle;
public SimpleS3Client(String endpoint, String region, String accessKey, String secretKey, boolean pathStyle) {
var uri = URI.create(endpoint);
this.scheme = Objects.requireNonNull(uri.getScheme(), "endpoint has no scheme: " + endpoint);
Objects.requireNonNull(uri.getHost(), "endpoint has no host: " + endpoint);
var defaultPort = "https".equals(scheme) ? 443 : 80;
this.hostPort = uri.getPort() == -1 || uri.getPort() == defaultPort
? uri.getHost()
: uri.getHost() + ":" + uri.getPort();
this.region = Objects.requireNonNull(region);
this.accessKey = Objects.requireNonNull(accessKey);
this.secretKey = Objects.requireNonNull(secretKey);
this.pathStyle = pathStyle;
this.http = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(10))
.build();
}
/**
* Throws {@link S3Exception} if the bucket does not exist or the credentials
* cannot access it.
*/
public void headBucket(String bucket) throws IOException, InterruptedException {
var target = target(bucket, null);
var request = signed("HEAD", target, AwsV4Signer.EMPTY_PAYLOAD_SHA256)
.HEAD().build();
var response = http.send(request, HttpResponse.BodyHandlers.discarding());
ensureSuccess("HEAD " + target.uri(), response.statusCode(), null);
}
public void putObject(String bucket, String key, Path file, String contentType)
throws IOException, InterruptedException {
var target = target(bucket, key);
var request = signed("PUT", target, sha256Hex(file))
.header("content-type", contentType)
.PUT(HttpRequest.BodyPublishers.ofFile(file))
.build();
var response = http.send(request, HttpResponse.BodyHandlers.ofString());
ensureSuccess("PUT " + target.uri(), response.statusCode(), response.body());
}
/**
* Returns the object body; the caller is responsible for closing it.
*/
public InputStream getObject(String bucket, String key) throws IOException, InterruptedException {
var target = target(bucket, key);
var request = signed("GET", target, AwsV4Signer.EMPTY_PAYLOAD_SHA256)
.GET().build();
var response = http.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() / 100 != 2) {
String body;
try (var in = response.body()) {
body = new String(in.readNBytes(4096), StandardCharsets.UTF_8);
}
throw new S3Exception("GET " + target.uri(), response.statusCode(), body);
}
return response.body();
}
public void deleteObject(String bucket, String key) throws IOException, InterruptedException {
var target = target(bucket, key);
var request = signed("DELETE", target, AwsV4Signer.EMPTY_PAYLOAD_SHA256)
.DELETE().build();
var response = http.send(request, HttpResponse.BodyHandlers.ofString());
ensureSuccess("DELETE " + target.uri(), response.statusCode(), response.body());
}
@Override
public void close() {
http.close();
}
private record Target(URI uri, String host, String encodedPath) {
}
private Target target(String bucket, String key) {
var host = pathStyle ? hostPort : bucket + "." + hostPort;
var path = new StringBuilder();
if (pathStyle) {
path.append('/').append(URLEncoder.encode(bucket, Charset.defaultCharset()));
}
if (key != null) {
for (var segment : key.split("/", -1)) {
path.append('/').append(URLEncoder.encode(segment, Charset.defaultCharset()));
}
}
if (path.isEmpty()) {
path.append('/');
}
return new Target(URI.create(scheme + "://" + host + path), host, path.toString());
}
/**
* Host is signed but never set explicitly — HttpClient forbids that and
* derives it from the URI; {@link #hostPort} mirrors that derivation.
*/
private HttpRequest.Builder signed(String method, Target target, String payloadHash) {
var amzDate = AMZ_DATE.format(Instant.now());
var headers = new TreeMap<String, String>();
headers.put("host", target.host());
headers.put("x-amz-content-sha256", payloadHash);
headers.put("x-amz-date", amzDate);
var authorization = AwsV4Signer.authorization(method, target.encodedPath(), "",
headers, payloadHash, amzDate, region, "s3", accessKey, secretKey);
return HttpRequest.newBuilder(target.uri())
.header("x-amz-content-sha256", payloadHash)
.header("x-amz-date", amzDate)
.header("authorization", authorization);
}
private static void ensureSuccess(String request, int statusCode, String body) throws S3Exception {
if (statusCode / 100 != 2) {
throw new S3Exception(request, statusCode, body);
}
}
private static String sha256Hex(Path file) throws IOException {
var digest = AwsV4Signer.sha256();
try (var in = Files.newInputStream(file)) {
var buffer = new byte[64 * 1024];
int read;
while ((read = in.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
return HexFormat.of().formatHex(digest.digest());
}
}