diff --git a/src/main/java/io/papermc/bibliothek/BibliothekApplication.java b/src/main/java/io/papermc/bibliothek/BibliothekApplication.java index 1cf670f..b692e7d 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 2923e29..1201c66 100644 --- a/src/main/java/io/papermc/bibliothek/configuration/AppConfiguration.java +++ b/src/main/java/io/papermc/bibliothek/configuration/AppConfiguration.java @@ -23,69 +23,15 @@ */ package io.papermc.bibliothek.configuration; -import jakarta.validation.constraints.NotNull; import java.net.URL; -import java.nio.file.Path; -import org.jetbrains.annotations.Nullable; 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; - private String cdnUrl; - - @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; - } - - @SuppressWarnings("checkstyle:MethodName") - public @Nullable String getCdnUrl() { - return this.cdnUrl; - } - - @SuppressWarnings("checkstyle:MethodName") - public void setCdnUrl(final String cdnUrl) { - this.cdnUrl = cdnUrl; - } +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 6152861..70251d0 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..8188e40 --- /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-2023 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 d73f7f0..7a2ec5f 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; @@ -43,17 +43,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.validation.constraints.Pattern; import java.io.IOException; -import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.Map; -import java.util.function.BiFunction; -import org.jetbrains.annotations.Nullable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.AbstractResource; import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.UrlResource; import org.springframework.http.CacheControl; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -69,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( @@ -137,20 +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 JavaArchive.resolve( - this.configuration, - download.getValue(), - (cdn, file) -> URI.create(String.format("%s/%s/%s/%d/%s", cdn, project.name(), version.name(), build.number(), file.name())), - (path, file) -> path - .resolve(project.name()) - .resolve(version.name()) - .resolve(String.valueOf(build.number())) - .resolve(file.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); } @@ -160,52 +148,7 @@ public ResponseEntity download( } private static class JavaArchive extends ResponseEntity { - public static JavaArchive resolve( - final AppConfiguration config, - final Build.Download download, - final BiFunction cdnGetter, - final BiFunction localGetter, - final CacheControl cache - ) throws IOException { - @Nullable IOException cdnException = null; - final @Nullable String cdnUrl = config.getCdnUrl(); - if (cdnUrl != null) { - final @Nullable URI cdn = cdnGetter.apply(cdnUrl, download); - if (cdn != null) { - try { - return forUrl(download, cdn, cache); - } catch (final IOException e) { - cdnException = e; - } - } - } - @Nullable IOException localException = null; - final @Nullable Path local = localGetter.apply(config.getStoragePath(), download); - if (local != null) { - try { - return forPath(download, local, cache); - } catch (final IOException e) { - localException = e; - } - } - final IOException exception = new IOException("Could not resolve download via CDN or Local Storage"); - if (cdnException != null) { - exception.addSuppressed(cdnException); - } - if (localException != null) { - exception.addSuppressed(localException); - } - throw exception; - } - - private static JavaArchive forUrl(final Build.Download download, final URI uri, final CacheControl cache) throws IOException { - final UrlResource resource = new UrlResource(uri); - final HttpHeaders headers = headersFor(download, cache); - headers.setLastModified(resource.lastModified()); - return new JavaArchive(resource, headers); - } - - private static JavaArchive forPath(final Build.Download download, final Path path, final CacheControl cache) throws IOException { + 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()); 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..82de3e6 --- /dev/null +++ b/src/main/java/io/papermc/bibliothek/service/DownloadService.java @@ -0,0 +1,134 @@ +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() + )); + } +}