diff --git a/src/main/java/io/papermc/bibliothek/BibliothekApplication.java b/src/main/java/io/papermc/bibliothek/BibliothekApplication.java index 90f679d..28e5653 100644 --- a/src/main/java/io/papermc/bibliothek/BibliothekApplication.java +++ b/src/main/java/io/papermc/bibliothek/BibliothekApplication.java @@ -24,13 +24,15 @@ package io.papermc.bibliothek; import io.papermc.bibliothek.configuration.AppConfiguration; +import io.papermc.bibliothek.configuration.StorageConfiguration; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.servlet.ServletComponentScan; @EnableConfigurationProperties({ - AppConfiguration.class + AppConfiguration.class, + StorageConfiguration.class }) @SpringBootApplication @ServletComponentScan diff --git a/src/main/java/io/papermc/bibliothek/configuration/AppConfiguration.java b/src/main/java/io/papermc/bibliothek/configuration/AppConfiguration.java index 2546b7f..68dc954 100644 --- a/src/main/java/io/papermc/bibliothek/configuration/AppConfiguration.java +++ b/src/main/java/io/papermc/bibliothek/configuration/AppConfiguration.java @@ -23,57 +23,15 @@ */ package io.papermc.bibliothek.configuration; -import jakarta.validation.constraints.NotNull; import java.net.URL; -import java.nio.file.Path; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; @ConfigurationProperties(prefix = "app") @Validated -public class AppConfiguration { - private URL apiBaseUrl; - private String apiTitle; - private String apiVersion; - private @NotNull Path storagePath; - - @SuppressWarnings("checkstyle:MethodName") - public URL getApiBaseUrl() { - return this.apiBaseUrl; - } - - @SuppressWarnings("checkstyle:MethodName") - public void setApiBaseUrl(final URL apiBaseUrl) { - this.apiBaseUrl = apiBaseUrl; - } - - @SuppressWarnings("checkstyle:MethodName") - public String getApiTitle() { - return this.apiTitle; - } - - @SuppressWarnings("checkstyle:MethodName") - public void setApiTitle(final String apiTitle) { - this.apiTitle = apiTitle; - } - - @SuppressWarnings("checkstyle:MethodName") - public String getApiVersion() { - return this.apiVersion; - } - - @SuppressWarnings("checkstyle:MethodName") - public void setApiVersion(final String apiVersion) { - this.apiVersion = apiVersion; - } - - @SuppressWarnings("checkstyle:MethodName") - public Path getStoragePath() { - return this.storagePath; - } - - @SuppressWarnings("checkstyle:MethodName") - public void setStoragePath(final Path storagePath) { - this.storagePath = storagePath; - } +public record AppConfiguration( + URL apiBaseUrl, + String apiTitle, + String apiVersion +) { } diff --git a/src/main/java/io/papermc/bibliothek/configuration/OpenAPIConfiguration.java b/src/main/java/io/papermc/bibliothek/configuration/OpenAPIConfiguration.java index 38f089d..4323cba 100644 --- a/src/main/java/io/papermc/bibliothek/configuration/OpenAPIConfiguration.java +++ b/src/main/java/io/papermc/bibliothek/configuration/OpenAPIConfiguration.java @@ -42,10 +42,10 @@ OpenAPI openAPI(final AppConfiguration configuration) { final OpenAPI api = new OpenAPI(); api.info( new Info() - .title(configuration.getApiTitle()) - .version(configuration.getApiVersion()) + .title(configuration.apiTitle()) + .version(configuration.apiVersion()) ); - final URL apiBaseUrl = configuration.getApiBaseUrl(); + final URL apiBaseUrl = configuration.apiBaseUrl(); if (apiBaseUrl != null) { api.servers(List.of(new Server().url(apiBaseUrl.toExternalForm()))); } diff --git a/src/main/java/io/papermc/bibliothek/configuration/StorageConfiguration.java b/src/main/java/io/papermc/bibliothek/configuration/StorageConfiguration.java new file mode 100644 index 0000000..35ac767 --- /dev/null +++ b/src/main/java/io/papermc/bibliothek/configuration/StorageConfiguration.java @@ -0,0 +1,49 @@ +/* + * This file is part of bibliothek, licensed under the MIT License. + * + * Copyright (c) 2019-2024 PaperMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.papermc.bibliothek.configuration; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.nio.file.Path; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@ConfigurationProperties(prefix = "app.storage") +@Validated +public record StorageConfiguration( + @NotNull Path cache, + @NotEmpty List sources +) { + public record Source( + String name, + Type type, + String value + ) { + public enum Type { + LOCAL, + REMOTE; + } + } +} diff --git a/src/main/java/io/papermc/bibliothek/controller/v2/DownloadController.java b/src/main/java/io/papermc/bibliothek/controller/v2/DownloadController.java index 4e46f7b..7031413 100644 --- a/src/main/java/io/papermc/bibliothek/controller/v2/DownloadController.java +++ b/src/main/java/io/papermc/bibliothek/controller/v2/DownloadController.java @@ -23,7 +23,6 @@ */ package io.papermc.bibliothek.controller.v2; -import io.papermc.bibliothek.configuration.AppConfiguration; import io.papermc.bibliothek.database.model.Build; import io.papermc.bibliothek.database.model.Project; import io.papermc.bibliothek.database.model.Version; @@ -35,6 +34,7 @@ import io.papermc.bibliothek.exception.DownloadNotFound; import io.papermc.bibliothek.exception.ProjectNotFound; import io.papermc.bibliothek.exception.VersionNotFound; +import io.papermc.bibliothek.service.DownloadService; import io.papermc.bibliothek.util.HTTP; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -48,6 +48,7 @@ import java.time.Duration; import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.AbstractResource; import org.springframework.core.io.FileSystemResource; import org.springframework.http.CacheControl; import org.springframework.http.HttpHeaders; @@ -64,22 +65,22 @@ @SuppressWarnings("checkstyle:FinalClass") public class DownloadController { private static final CacheControl CACHE = HTTP.sMaxAgePublicCache(Duration.ofDays(7)); - private final AppConfiguration configuration; private final ProjectCollection projects; private final VersionCollection versions; private final BuildCollection builds; + private final DownloadService service; @Autowired private DownloadController( - final AppConfiguration configuration, final ProjectCollection projects, final VersionCollection versions, - final BuildCollection builds + final BuildCollection builds, + final DownloadService service ) { - this.configuration = configuration; this.projects = projects; this.versions = versions; this.builds = builds; + this.service = service; } @ApiResponse( @@ -132,17 +133,12 @@ public ResponseEntity download( final Version version = this.versions.findByProjectAndName(project._id(), versionName).orElseThrow(VersionNotFound::new); final Build build = this.builds.findByProjectAndVersionAndNumber(project._id(), version._id(), buildNumber).orElseThrow(BuildNotFound::new); - for (final Map.Entry download : build.downloads().entrySet()) { - if (download.getValue().name().equals(downloadName)) { + for (final Map.Entry entry : build.downloads().entrySet()) { + final Build.Download download = entry.getValue(); + if (download.name().equals(downloadName)) { try { - return new JavaArchive( - this.configuration.getStoragePath() - .resolve(project.name()) - .resolve(version.name()) - .resolve(String.valueOf(build.number())) - .resolve(download.getValue().name()), - CACHE - ); + final Path path = this.service.resolve(project, version, build, download); + return JavaArchive.forPath(download, path, CACHE); } catch (final IOException e) { throw new DownloadFailed(e); } @@ -151,17 +147,23 @@ public ResponseEntity download( throw new DownloadNotFound(); } - private static class JavaArchive extends ResponseEntity { - JavaArchive(final Path path, final CacheControl cache) throws IOException { - super(new FileSystemResource(path), headersFor(path, cache), HttpStatus.OK); + private static class JavaArchive extends ResponseEntity { + static JavaArchive forPath(final Build.Download download, final Path path, final CacheControl cache) throws IOException { + final FileSystemResource resource = new FileSystemResource(path); + final HttpHeaders headers = headersFor(download, cache); + headers.setLastModified(Files.getLastModifiedTime(path).toInstant()); + return new JavaArchive(resource, headers); + } + + private JavaArchive(final AbstractResource resource, final HttpHeaders headers) { + super(resource, headers, HttpStatus.OK); } - private static HttpHeaders headersFor(final Path path, final CacheControl cache) throws IOException { + private static HttpHeaders headersFor(final Build.Download download, final CacheControl cache) { final HttpHeaders headers = new HttpHeaders(); headers.setCacheControl(cache); - headers.setContentDisposition(HTTP.attachmentDisposition(path.getFileName())); + headers.setContentDisposition(HTTP.attachmentDisposition(download.name())); headers.setContentType(HTTP.APPLICATION_JAVA_ARCHIVE); - headers.setLastModified(Files.getLastModifiedTime(path).toInstant()); return headers; } } diff --git a/src/main/java/io/papermc/bibliothek/controller/v2/VersionFamilyBuildsController.java b/src/main/java/io/papermc/bibliothek/controller/v2/VersionFamilyBuildsController.java index 63b1e16..45f4b2c 100644 --- a/src/main/java/io/papermc/bibliothek/controller/v2/VersionFamilyBuildsController.java +++ b/src/main/java/io/papermc/bibliothek/controller/v2/VersionFamilyBuildsController.java @@ -137,7 +137,7 @@ static VersionFamilyBuildsResponse from(final Project project, final VersionFami } @Schema - public static record VersionFamilyBuild( + public record VersionFamilyBuild( @Schema(name = "version", pattern = Version.PATTERN, example = "1.18") String version, @Schema(name = "build", pattern = "\\d+", example = "10") diff --git a/src/main/java/io/papermc/bibliothek/service/DownloadService.java b/src/main/java/io/papermc/bibliothek/service/DownloadService.java new file mode 100644 index 0000000..c14ad22 --- /dev/null +++ b/src/main/java/io/papermc/bibliothek/service/DownloadService.java @@ -0,0 +1,157 @@ +/* + * This file is part of bibliothek, licensed under the MIT License. + * + * Copyright (c) 2019-2024 PaperMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.papermc.bibliothek.service; + +import io.papermc.bibliothek.configuration.StorageConfiguration; +import io.papermc.bibliothek.database.model.Build; +import io.papermc.bibliothek.database.model.Project; +import io.papermc.bibliothek.database.model.Version; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.URI; +import java.net.URLConnection; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class DownloadService { + private static final Logger LOGGER = LoggerFactory.getLogger(DownloadService.class); + private static final HostnameVerifier HOSTNAME_VERIFIER = (hostname, session) -> true; + private final StorageConfiguration configuration; + + @Autowired + public DownloadService(final StorageConfiguration configuration) { + this.configuration = configuration; + } + + public Path resolve( + final Project project, + final Version version, + final Build build, + final Build.Download download + ) throws IOException { + final Path cached = this.resolvePath(this.configuration.cache(), project, version, build, download); + if (!Files.isRegularFile(cached)) { + Files.createDirectories(cached.getParent()); + + boolean wasSuccessful = false; + @Nullable List sourceExceptions = null; + dance: for (final StorageConfiguration.Source source : this.configuration.sources()) { + switch (source.type()) { + case LOCAL -> { + final Path localPath = this.resolvePath(Path.of(source.value()), project, version, build, download); + if (Files.isRegularFile(localPath)) { + try { + Files.copy(localPath, cached); + LOGGER.info("Cached resource {} from {}", cached.getFileName(), source.name()); + wasSuccessful = true; + break dance; + } catch (final IOException e) { + if (sourceExceptions == null) { + sourceExceptions = new ArrayList<>(); + } + sourceExceptions.add(e); + } + } + } + case REMOTE -> { + final URI uri = this.resolveUrl(source.value(), project, version, build, download); + try { + final URLConnection connection = uri.toURL().openConnection(); + if (connection instanceof final HttpsURLConnection https) { + https.setHostnameVerifier(HOSTNAME_VERIFIER); + } + try ( + final ReadableByteChannel channel = Channels.newChannel(connection.getInputStream()); + final FileOutputStream output = new FileOutputStream(cached.toFile()) + ) { + output.getChannel().transferFrom(channel, 0, Long.MAX_VALUE); + LOGGER.info("Cached resource {} from {}", cached.getFileName(), source.name()); + wasSuccessful = true; + break dance; + } + } catch (final IOException e) { + if (sourceExceptions == null) { + sourceExceptions = new ArrayList<>(); + } + sourceExceptions.add(e); + } + } + } + } + if (!wasSuccessful) { + final IOException exception = new IOException("Could not resolve download via CDN or Local Storage"); + if (sourceExceptions != null) { + for (final IOException sourceException : sourceExceptions) { + exception.addSuppressed(sourceException); + } + } + throw exception; + } + } + return cached; + } + + private Path resolvePath( + final Path base, + final Project project, + final Version version, + final Build build, + final Build.Download download + ) { + return base + .resolve(project.name()) + .resolve(version.name()) + .resolve(String.valueOf(build.number())) + .resolve(download.name()); + } + + private URI resolveUrl( + final String base, + final Project project, + final Version version, + final Build build, + final Build.Download download + ) { + return URI.create(String.format( + "%s/%s/%s/%d/%s", + base, + project.name(), + version.name(), + build.number(), + download.name() + )); + } +} diff --git a/src/main/java/io/papermc/bibliothek/util/HTTP.java b/src/main/java/io/papermc/bibliothek/util/HTTP.java index 6ca441f..2e3ead6 100644 --- a/src/main/java/io/papermc/bibliothek/util/HTTP.java +++ b/src/main/java/io/papermc/bibliothek/util/HTTP.java @@ -24,7 +24,6 @@ package io.papermc.bibliothek.util; import java.nio.charset.StandardCharsets; -import java.nio.file.Path; import java.time.Duration; import org.springframework.http.CacheControl; import org.springframework.http.ContentDisposition; @@ -48,7 +47,7 @@ public static CacheControl sMaxAgePublicCache(final Duration sMaxAge) { .sMaxAge(sMaxAge); } - public static ContentDisposition attachmentDisposition(final Path filename) { - return ContentDisposition.attachment().filename(filename.getFileName().toString(), StandardCharsets.UTF_8).build(); + public static ContentDisposition attachmentDisposition(final String filename) { + return ContentDisposition.attachment().filename(filename, StandardCharsets.UTF_8).build(); } }