diff --git a/src/main/java/io/papermc/fill/gradle/FillExtensionImpl.java b/src/main/java/io/papermc/fill/gradle/FillExtensionImpl.java index 6d3c062..bd2cc40 100644 --- a/src/main/java/io/papermc/fill/gradle/FillExtensionImpl.java +++ b/src/main/java/io/papermc/fill/gradle/FillExtensionImpl.java @@ -22,7 +22,6 @@ import org.gradle.api.provider.Property; import org.gradle.api.provider.ProviderFactory; import org.jspecify.annotations.NullMarked; -import java.time.Instant; @NullMarked public class FillExtensionImpl implements FillExtension { diff --git a/src/main/java/io/papermc/fill/gradle/task/PublishToFillTask.java b/src/main/java/io/papermc/fill/gradle/task/PublishToFillTask.java index be4d7f7..2272855 100644 --- a/src/main/java/io/papermc/fill/gradle/task/PublishToFillTask.java +++ b/src/main/java/io/papermc/fill/gradle/task/PublishToFillTask.java @@ -24,28 +24,32 @@ import io.papermc.fill.model.Checksums; import io.papermc.fill.model.Commit; import io.papermc.fill.model.Download; -import io.papermc.fill.model.request.PublishRequest; +import io.papermc.fill.model.request.v3.PublishRequest; +import io.papermc.fill.model.request.v3.StageRequest; import io.papermc.fill.model.response.v3.BuildResponse; +import io.papermc.fill.model.response.v3.StageResponse; import io.papermc.fill.model.response.v3.VersionResponse; +import io.papermc.fill.model.response.v3.VersionsResponse; import java.io.File; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Instant; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.function.Consumer; import javax.inject.Inject; -import io.papermc.fill.model.response.v3.VersionsResponse; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.errors.MissingObjectException; import org.eclipse.jgit.lib.Constants; @@ -66,6 +70,7 @@ @UntrackedTask(because = "PublishToFillTask should always run when requested") public abstract class PublishToFillTask extends DefaultTask implements AutoCloseable { public static final String NAME = "publishToFill"; + private static final String APPLICATION_JAVA_ARCHIVE = "application/java-archive"; private static final String USER_AGENT = "Fill (Gradle Plugin)"; private final HttpClient httpClient = HttpClient.newBuilder() .build(); @@ -102,7 +107,7 @@ private void runWithGit(final Git git) { final String familyId = extension.getVersionFamily().get(); final String versionId = extension.getVersion().get(); final FillExtension.Build build = extension.getBuild(); - final int buildId = build.getId().get(); + final int buildNumber = build.getId().get(); final String timeString = this.getExtension().get().getBuildTimestamp().getOrNull(); final Instant time; if (timeString != null) { @@ -117,52 +122,29 @@ private void runWithGit(final Git git) { final List commits = this.gatherCommits(git, extension); + if (!extension.getApiToken().isPresent()) { + throw new GradleException("API token is not present"); + } + final String apiToken = extension.getApiToken().get(); final UUID id = UUID.randomUUID(); - final List requests = new ArrayList<>(); + final Map downloads = new HashMap<>(); + final List uploads = new ArrayList<>(); try { - final Map downloads = new HashMap<>(); - for (final FillExtension.Download download : build.getDownloads()) { final String key = download.getName(); - final String name = download.getNameResolver().get().name(project, familyId, versionId, buildId); + final String name = download.getNameResolver().get().name(project, familyId, versionId, buildNumber); final Path path = download.getFile().get().getAsFile().toPath(); - final byte[] content = Files.readAllBytes(path); final String sha256 = Hashing.sha256().hashBytes(content).toString(); - final int size = (int) Files.size(path); - downloads.put(key, new Download(name, new Checksums(sha256), size)); - - final HttpRequest.Builder builder = HttpRequest.newBuilder(); - builder.header("User-Agent", USER_AGENT); - builder.header("Content-Type", "multipart/form-data; boundary=boundary"); - builder.uri(URI.create(extension.getApiUrl().get() + "/upload")); - - final List requestParts = new ArrayList<>(); - requestParts.add(("--boundary\r\nContent-Disposition: form-data; name=\"request\"\r\nContent-Type: application/json\r\n\r\n{\"id\":\"" + id + "\"}\r\n").getBytes(StandardCharsets.UTF_8)); - requestParts.add(("--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"" + name + "\"\r\n\r\n").getBytes(StandardCharsets.UTF_8)); - requestParts.add(content); - requestParts.add(("\r\n--boundary").getBytes(StandardCharsets.UTF_8)); - - builder.POST(HttpRequest.BodyPublishers.ofByteArrays(requestParts)); - - if (extension.getApiToken().isPresent()) { - builder.header("Authorization", extension.getApiToken().get()); - } else { - throw new GradleException("API token is not present"); - } - - requests.add(builder.build()); + final int size = content.length; + final Download requestDownload = new Download(name, new Checksums(sha256), size); + downloads.put(key, requestDownload); + uploads.add(new PendingUpload(requestDownload, content, APPLICATION_JAVA_ARCHIVE, contentMd5(content))); } - for (final HttpRequest request : requests) { - try { - final HttpResponse response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { - throw new GradleException("Failed to post data to the API: " + response.statusCode() + ": " + response.body()); - } - } catch (final Exception e) { - throw new GradleException("Failed to post data to the API", e); - } + for (final PendingUpload upload : uploads) { + final URI uploadUrl = this.requestUploadUrl(extension, apiToken, id, upload); + this.upload(uploadUrl, upload); } final PublishRequest request = new PublishRequest( @@ -170,40 +152,87 @@ private void runWithGit(final Git git) { project, familyId, versionId, - buildId, + buildNumber, time, build.getChannel().get(), commits.reversed(), downloads ); - - try { - final HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(URI.create(extension.getApiUrl().get() + "/publish")) - .header("Content-Type", "application/json") - .header("User-Agent", USER_AGENT); - builder.POST(HttpRequest.BodyPublishers.ofString(MapperHolder.MAPPER.writeValueAsString(request))); - if (extension.getApiToken().isPresent()) { - builder.headers("Authorization", extension.getApiToken().get()); - } else { - throw new GradleException("Api token is not present."); - } - - final HttpRequest post = builder.build(); - final HttpResponse response = this.httpClient.send(post, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 201) { - throw new GradleException("Failed to post data to the API: " + response.statusCode() + ": " + response.body()); - } - } catch (final Exception e) { - throw new GradleException("Failed to post data to the API: " + e.getMessage(), e); - } + this.publish(extension, apiToken, request); } catch (final JsonProcessingException e) { - throw new GradleException("Failed to serialize json", e); + throw new GradleException("Failed to serialize JSON", e); } catch (final IOException e) { - throw new GradleException("Failed to read file", e); + throw new GradleException("Failed to publish files", e); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GradleException("Publishing was interrupted", e); } } + private URI requestUploadUrl( + final FillExtension extension, + final String apiToken, + final UUID id, + final PendingUpload upload + ) throws IOException, InterruptedException { + final StageRequest request = new StageRequest(id, upload.download(), upload.contentType(), upload.contentMd5()); + final HttpRequest httpRequest = HttpRequest.newBuilder() + .uri(URI.create(extension.getApiUrl().get() + "/v3/publishing/stage")) + .header("Authorization", apiToken) + .header("Content-Type", "application/json") + .header("User-Agent", USER_AGENT) + .POST(HttpRequest.BodyPublishers.ofString(MapperHolder.MAPPER.writeValueAsString(request))) + .build(); + final HttpResponse response = this.httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException("Failed to create upload URL: " + response.statusCode() + ": " + response.body()); + } + return MapperHolder.MAPPER.readValue(response.body(), StageResponse.class).url(); + } + + private void upload(final URI uploadUrl, final PendingUpload upload) throws IOException, InterruptedException { + final HttpRequest request = HttpRequest.newBuilder() + .uri(uploadUrl) + .header("Content-MD5", upload.contentMd5()) + .header("Content-Type", upload.contentType()) + .header("x-amz-meta-sha256", upload.download().checksums().sha256()) + .PUT(HttpRequest.BodyPublishers.ofByteArray(upload.content())) + .build(); + final HttpResponse response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() / 100 != 2) { + throw new IOException("Failed to upload file: " + response.statusCode() + ": " + response.body()); + } + } + + private void publish( + final FillExtension extension, + final String apiToken, + final PublishRequest request + ) throws IOException, InterruptedException { + final HttpRequest httpRequest = HttpRequest.newBuilder() + .uri(URI.create(extension.getApiUrl().get() + "/v3/publishing/publish")) + .header("Authorization", apiToken) + .header("Content-Type", "application/json") + .header("User-Agent", USER_AGENT) + .POST(HttpRequest.BodyPublishers.ofString(MapperHolder.MAPPER.writeValueAsString(request))) + .build(); + final HttpResponse response = this.httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 201) { + throw new IOException("Failed to publish build: " + response.statusCode() + ": " + response.body()); + } + } + + private static String contentMd5(final byte[] content) { + try { + return Base64.getEncoder().encodeToString(MessageDigest.getInstance("MD5").digest(content)); + } catch (final NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + private record PendingUpload(Download download, byte[] content, String contentType, String contentMd5) { + } + private List gatherCommits(Git git, FillExtension extension) { final List commits = new ArrayList<>(); try (final RevWalk revWalk = new RevWalk(git.getRepository())) { diff --git a/src/main/java/io/papermc/fill/model/AbstractDownload.java b/src/main/java/io/papermc/fill/model/AbstractDownload.java new file mode 100644 index 0000000..dbdc941 --- /dev/null +++ b/src/main/java/io/papermc/fill/model/AbstractDownload.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 PaperMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.papermc.fill.model; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public interface AbstractDownload extends Named { + Checksums checksums(); + + int size(); +} diff --git a/src/main/java/io/papermc/fill/model/Commit.java b/src/main/java/io/papermc/fill/model/Commit.java index 88568a8..5bc58ca 100644 --- a/src/main/java/io/papermc/fill/model/Commit.java +++ b/src/main/java/io/papermc/fill/model/Commit.java @@ -18,11 +18,17 @@ import java.time.Instant; import org.jspecify.annotations.NullMarked; -// TODO: include author? @NullMarked public record Commit( String sha, Instant time, String message ) { + public static String getShortSha(final Commit commit) { + return commit.sha().substring(0, 7); + } + + public String summary() { + return this.message.split("\\R")[0]; + } } diff --git a/src/main/java/io/papermc/fill/model/Download.java b/src/main/java/io/papermc/fill/model/Download.java index 3c6a74f..852bce5 100644 --- a/src/main/java/io/papermc/fill/model/Download.java +++ b/src/main/java/io/papermc/fill/model/Download.java @@ -15,6 +15,7 @@ */ package io.papermc.fill.model; +import java.net.URI; import org.jspecify.annotations.NullMarked; @NullMarked @@ -22,5 +23,8 @@ public record Download( String name, Checksums checksums, int size -) { +) implements AbstractDownload { + public DownloadWithUrl withUrl(final URI url) { + return new DownloadWithUrl(this.name, this.checksums, this.size, url); + } } diff --git a/src/main/java/io/papermc/fill/model/DownloadWithUrl.java b/src/main/java/io/papermc/fill/model/DownloadWithUrl.java index d64d939..20d9f4f 100644 --- a/src/main/java/io/papermc/fill/model/DownloadWithUrl.java +++ b/src/main/java/io/papermc/fill/model/DownloadWithUrl.java @@ -24,5 +24,5 @@ public record DownloadWithUrl( Checksums checksums, int size, URI url -) { +) implements AbstractDownload { } diff --git a/src/main/java/io/papermc/fill/model/Java.java b/src/main/java/io/papermc/fill/model/Java.java index 4e381e9..08f2e78 100644 --- a/src/main/java/io/papermc/fill/model/Java.java +++ b/src/main/java/io/papermc/fill/model/Java.java @@ -15,7 +15,6 @@ */ package io.papermc.fill.model; -import java.util.List; import org.jspecify.annotations.NullMarked; @NullMarked @@ -23,15 +22,4 @@ public record Java( JavaVersion version, JavaFlags flags ) { - @NullMarked - public record JavaVersion( - int minimum - ) { - } - - @NullMarked - public record JavaFlags( - List recommended - ) { - } } diff --git a/src/main/java/io/papermc/fill/model/JavaFlags.java b/src/main/java/io/papermc/fill/model/JavaFlags.java new file mode 100644 index 0000000..4699be2 --- /dev/null +++ b/src/main/java/io/papermc/fill/model/JavaFlags.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 PaperMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.papermc.fill.model; + +import java.util.List; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public record JavaFlags( + List recommended +) { +} diff --git a/src/main/java/io/papermc/fill/model/request/UploadRequest.java b/src/main/java/io/papermc/fill/model/JavaVersion.java similarity index 86% rename from src/main/java/io/papermc/fill/model/request/UploadRequest.java rename to src/main/java/io/papermc/fill/model/JavaVersion.java index efa6019..1e663bf 100644 --- a/src/main/java/io/papermc/fill/model/request/UploadRequest.java +++ b/src/main/java/io/papermc/fill/model/JavaVersion.java @@ -13,13 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.papermc.fill.model.request; +package io.papermc.fill.model; -import java.util.UUID; import org.jspecify.annotations.NullMarked; @NullMarked -public record UploadRequest( - UUID id +public record JavaVersion( + int minimum ) { } diff --git a/src/main/java/io/papermc/fill/model/Named.java b/src/main/java/io/papermc/fill/model/Named.java new file mode 100644 index 0000000..8f89385 --- /dev/null +++ b/src/main/java/io/papermc/fill/model/Named.java @@ -0,0 +1,23 @@ +/* + * Copyright 2024 PaperMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.papermc.fill.model; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public interface Named { + String name(); +} diff --git a/src/main/java/io/papermc/fill/model/request/PublishRequest.java b/src/main/java/io/papermc/fill/model/request/PublishRequest.java deleted file mode 100644 index 1a5b232..0000000 --- a/src/main/java/io/papermc/fill/model/request/PublishRequest.java +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2024 PaperMC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.papermc.fill.model.request; - -import io.papermc.fill.model.BuildChannel; -import io.papermc.fill.model.Commit; -import io.papermc.fill.model.Download; -import java.time.Instant; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -@NullMarked -public record PublishRequest( - UUID id, - String project, - String family, - String version, - int build, - Instant time, - BuildChannel channel, - List commits, - Map downloads -) { - @NullMarked - public static final class Builder { - private static final int BUILD_NOT_SET = Integer.MIN_VALUE; - private @Nullable UUID id; - private @Nullable String project; - private @Nullable String family; - private @Nullable String version; - private int build = BUILD_NOT_SET; - private @Nullable Instant time; - private @Nullable BuildChannel channel; - private final List commits = new ArrayList<>(); - private final Map downloads = new HashMap<>(); - - public Builder project(final UUID id) { - this.id = id; - return this; - } - - public Builder project(final String project) { - this.project = project; - return this; - } - - public Builder family(final String family) { - this.family = family; - return this; - } - - public Builder version(final String version) { - this.version = version; - return this; - } - - public Builder build(final int build) { - this.build = build; - return this; - } - - public Builder time(final Instant time) { - this.time = time; - return this; - } - - public Builder channel(final BuildChannel channel) { - this.channel = channel; - return this; - } - - public Builder addCommit(final Commit commit) { - this.commits.add(commit); - return this; - } - - public Builder addDownload(final String key, final Download download) { - this.downloads.put(key, download); - return this; - } - - public PublishRequest build() { - if (this.id == null) throw new IllegalStateException("id must be set"); - if (this.project == null) throw new IllegalStateException("project must be set"); - if (this.family == null) throw new IllegalStateException("family must be set"); - if (this.version == null) throw new IllegalStateException("version must be set"); - if (this.build == BUILD_NOT_SET) throw new IllegalStateException("build must be set"); - if (this.time == null) throw new IllegalStateException("time must be set"); - if (this.channel == null) throw new IllegalStateException("channel must be set"); - return new PublishRequest( - this.id, - this.project, - this.family, - this.version, - this.build, - this.time, - this.channel, - List.copyOf(this.commits), - Map.copyOf(this.downloads) - ); - } - } -} diff --git a/src/main/java/io/papermc/fill/model/request/v3/PublishRequest.java b/src/main/java/io/papermc/fill/model/request/v3/PublishRequest.java new file mode 100644 index 0000000..50a9c22 --- /dev/null +++ b/src/main/java/io/papermc/fill/model/request/v3/PublishRequest.java @@ -0,0 +1,40 @@ +/* + * Copyright 2024 PaperMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.papermc.fill.model.request.v3; + +import io.papermc.fill.model.BuildChannel; +import io.papermc.fill.model.Commit; +import io.papermc.fill.model.Download; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public record PublishRequest( + UUID id, + String project, + String family, + String version, + @Deprecated(forRemoval = true) + int build, + Instant time, + BuildChannel channel, + List commits, + Map downloads +) { +} diff --git a/src/main/java/io/papermc/fill/model/request/v3/StageRequest.java b/src/main/java/io/papermc/fill/model/request/v3/StageRequest.java new file mode 100644 index 0000000..4405648 --- /dev/null +++ b/src/main/java/io/papermc/fill/model/request/v3/StageRequest.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024 PaperMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.papermc.fill.model.request.v3; + +import io.papermc.fill.model.Download; +import java.util.UUID; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public record StageRequest( + UUID id, + Download download, + String contentType, + String contentMd5 +) { +} diff --git a/src/main/java/io/papermc/fill/model/response/v3/StageResponse.java b/src/main/java/io/papermc/fill/model/response/v3/StageResponse.java new file mode 100644 index 0000000..ef74091 --- /dev/null +++ b/src/main/java/io/papermc/fill/model/response/v3/StageResponse.java @@ -0,0 +1,26 @@ +/* + * Copyright 2024 PaperMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.papermc.fill.model.response.v3; + +import java.net.URI; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public record StageResponse( + boolean ok, + URI url +) { +} diff --git a/src/main/java/io/papermc/fill/model/response/v3/VersionsResponse.java b/src/main/java/io/papermc/fill/model/response/v3/VersionsResponse.java index 164aac7..5059743 100644 --- a/src/main/java/io/papermc/fill/model/response/v3/VersionsResponse.java +++ b/src/main/java/io/papermc/fill/model/response/v3/VersionsResponse.java @@ -15,9 +15,11 @@ */ package io.papermc.fill.model.response.v3; -import org.jspecify.annotations.NullMarked; import java.util.List; +import org.jspecify.annotations.NullMarked; @NullMarked -public record VersionsResponse(List versions) { +public record VersionsResponse( + List versions +) { }