diff --git a/API/api/src/main/java/tc/oc/api/ApiManifest.java b/API/api/src/main/java/tc/oc/api/ApiManifest.java index d48d99d..2432922 100644 --- a/API/api/src/main/java/tc/oc/api/ApiManifest.java +++ b/API/api/src/main/java/tc/oc/api/ApiManifest.java @@ -3,6 +3,7 @@ package tc.oc.api; import tc.oc.api.document.DocumentsManifest; import tc.oc.api.engagement.EngagementModelManifest; import tc.oc.api.games.GameModelManifest; +import tc.oc.api.http.HttpManifest; import tc.oc.api.maps.MapModelManifest; import tc.oc.api.match.MatchModelManifest; import tc.oc.api.message.MessagesManifest; @@ -29,6 +30,7 @@ public final class ApiManifest extends HybridManifest { install(new DocumentsManifest()); install(new MessagesManifest()); install(new ModelsManifest()); + install(new HttpManifest()); install(new ServerModelManifest()); install(new UserModelManifest()); diff --git a/API/api/src/main/java/tc/oc/api/http/HttpClient.java b/API/api/src/main/java/tc/oc/api/http/HttpClient.java index f66209a..3a3797b 100644 --- a/API/api/src/main/java/tc/oc/api/http/HttpClient.java +++ b/API/api/src/main/java/tc/oc/api/http/HttpClient.java @@ -3,6 +3,8 @@ package tc.oc.api.http; import java.io.IOException; import java.io.OutputStream; import java.lang.reflect.Type; +import java.net.MalformedURLException; +import java.net.URL; import java.time.Duration; import java.util.concurrent.Callable; import java.util.concurrent.Executors; @@ -105,10 +107,6 @@ public class HttpClient implements Connectable { }); } - public String getBaseUrl() { - return this.config.getBaseUrl(); - } - public ListenableFuture get(String path, HttpOption... options) { return get(path, (TypeToken) null, options); } @@ -166,11 +164,17 @@ public class HttpClient implements Connectable { } protected ListenableFuture request(String method, String path, @Nullable Object content, @Nullable TypeToken returnType, HttpOption...options) { + final GenericUrl url; + try { + url = new GenericUrl(new URL(config.getBaseUrl(), path)); + } catch(MalformedURLException e) { + throw new IllegalArgumentException(e.getMessage()); + } + // NOTE: Serialization must happen synchronously, because getter methods may not be thread-safe final HttpContent httpContent = content == null ? null : new Content(gson.toJson(content)); - GenericUrl url = new GenericUrl(this.getBaseUrl() + path); - HttpRequest request; + final HttpRequest request; try { request = requestFactory.buildRequest(method, url, httpContent).setThrowExceptionOnExecuteError(false); } catch (IOException e) { diff --git a/API/api/src/main/java/tc/oc/api/http/HttpClientConfiguration.java b/API/api/src/main/java/tc/oc/api/http/HttpClientConfiguration.java index e784dd8..dec539c 100644 --- a/API/api/src/main/java/tc/oc/api/http/HttpClientConfiguration.java +++ b/API/api/src/main/java/tc/oc/api/http/HttpClientConfiguration.java @@ -1,5 +1,7 @@ package tc.oc.api.http; +import java.net.URL; + public interface HttpClientConfiguration { int DEFAULT_THREADS = 0; @@ -10,7 +12,7 @@ public interface HttpClientConfiguration { /** * Base URL of the API. End points will be appended to this address. */ - String getBaseUrl(); + URL getBaseUrl(); /** * Number of threads to execute requests. 0 indicates an unbounded number diff --git a/API/api/src/main/java/tc/oc/api/http/HttpClientConfigurationImpl.java b/API/api/src/main/java/tc/oc/api/http/HttpClientConfigurationImpl.java index 3ce171a..f090d90 100644 --- a/API/api/src/main/java/tc/oc/api/http/HttpClientConfigurationImpl.java +++ b/API/api/src/main/java/tc/oc/api/http/HttpClientConfigurationImpl.java @@ -1,7 +1,9 @@ package tc.oc.api.http; +import java.net.URL; import javax.inject.Inject; +import tc.oc.commons.core.configuration.ConfigUtils; import tc.oc.minecraft.api.configuration.Configuration; import tc.oc.minecraft.api.configuration.ConfigurationSection; @@ -24,8 +26,8 @@ public class HttpClientConfigurationImpl implements HttpClientConfiguration { } @Override - public String getBaseUrl() { - return config.getString(BASE_URL_PATH); + public URL getBaseUrl() { + return ConfigUtils.needUrl(config, BASE_URL_PATH); } @Override diff --git a/API/api/src/main/java/tc/oc/api/http/HttpManifest.java b/API/api/src/main/java/tc/oc/api/http/HttpManifest.java index f14b1db..88e9439 100644 --- a/API/api/src/main/java/tc/oc/api/http/HttpManifest.java +++ b/API/api/src/main/java/tc/oc/api/http/HttpManifest.java @@ -7,7 +7,7 @@ public class HttpManifest extends HybridManifest { @Override protected void configure() { expose(HttpClient.class); - bind(HttpClient.class); + bind(HttpClient.class).asEagerSingleton(); bind(HttpClientConfiguration.class) .to(HttpClientConfigurationImpl.class); } diff --git a/API/api/src/main/resources/config.yml b/API/api/src/main/resources/config.yml index eb5946e..87dc319 100644 --- a/API/api/src/main/resources/config.yml +++ b/API/api/src/main/resources/config.yml @@ -33,5 +33,3 @@ queue: logging: root: level: INFO - tc-oc-api-bukkit-BukkitApi: - level: INFO diff --git a/API/ocn/src/main/java/tc/oc/api/ocn/OCNApiManifest.java b/API/ocn/src/main/java/tc/oc/api/ocn/OCNApiManifest.java index f798297..48cc5ea 100644 --- a/API/ocn/src/main/java/tc/oc/api/ocn/OCNApiManifest.java +++ b/API/ocn/src/main/java/tc/oc/api/ocn/OCNApiManifest.java @@ -1,6 +1,5 @@ package tc.oc.api.ocn; -import tc.oc.api.http.HttpManifest; import tc.oc.api.minecraft.queue.MinecraftQueueManifest; import tc.oc.api.model.ModelBinders; import tc.oc.commons.core.inject.HybridManifest; @@ -11,6 +10,5 @@ public class OCNApiManifest extends HybridManifest implements ModelBinders { protected void configure() { install(new OCNModelsManifest()); install(new MinecraftQueueManifest()); - install(new HttpManifest()); } } diff --git a/Commons/core/src/main/i18n/templates/pgm/PGMMessages.properties b/Commons/core/src/main/i18n/templates/pgm/PGMMessages.properties index 655749f..edec828 100644 --- a/Commons/core/src/main/i18n/templates/pgm/PGMMessages.properties +++ b/Commons/core/src/main/i18n/templates/pgm/PGMMessages.properties @@ -223,4 +223,7 @@ tnt.license.use.restricted = You need a TNT license to use TNT or Redstone on th item.locked = This item cannot be removed from its slot -stats.hotbar = {0} kills ({1} streak) {2} deaths {3} K/D \ No newline at end of file +stats.hotbar = {0} kills ({1} streak) {2} deaths {3} K/D + +announce.online = Announced server as online +announce.offline = Announced server as offline diff --git a/PGM/src/main/java/tc/oc/pgm/PGMManifest.java b/PGM/src/main/java/tc/oc/pgm/PGMManifest.java index 44d6b5f..6e36213 100644 --- a/PGM/src/main/java/tc/oc/pgm/PGMManifest.java +++ b/PGM/src/main/java/tc/oc/pgm/PGMManifest.java @@ -19,6 +19,7 @@ import tc.oc.pgm.freeze.FreezeListener; import tc.oc.pgm.listeners.BlockTransformListener; import tc.oc.pgm.listeners.MatchAnnouncer; import tc.oc.pgm.listeners.PGMListener; +import tc.oc.pgm.listing.ListingManifest; import tc.oc.pgm.map.MapLibrary; import tc.oc.pgm.map.MapLibraryImpl; import tc.oc.pgm.map.MapLoader; @@ -56,6 +57,8 @@ public final class PGMManifest extends HybridManifest { install(new MatchPlayerEventRouter.Manifest()); install(new MatchAnalyticsManifest()); + install(new ListingManifest()); + bind(MatchManager.class); bind(MatchLoader.class); bind(MatchFinder.class).to(MatchLoader.class); diff --git a/PGM/src/main/java/tc/oc/pgm/listing/ListingCommands.java b/PGM/src/main/java/tc/oc/pgm/listing/ListingCommands.java new file mode 100644 index 0000000..1015d03 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/listing/ListingCommands.java @@ -0,0 +1,34 @@ +package tc.oc.pgm.listing; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.ImmutableList; +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import com.sk89q.minecraft.util.commands.SuggestException; +import org.bukkit.command.CommandSender; + +@Singleton +public class ListingCommands { + + private final ListingService listingService; + + @Inject ListingCommands(ListingService listingService) { + this.listingService = listingService; + } + + @Command( + aliases = "announce", + usage = "[on|off]", + desc = "Announce the server to the public listing service", + min = 0, + max = 1 + ) + @CommandPermissions("pgm.listing.announce") + public void announce(CommandContext args, CommandSender sender) throws CommandException, SuggestException { + listingService.update("on".equals(args.tryString(0, ImmutableList.of("on", "off")).orElse("on")), sender); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/listing/ListingConfiguration.java b/PGM/src/main/java/tc/oc/pgm/listing/ListingConfiguration.java new file mode 100644 index 0000000..a2e80aa --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/listing/ListingConfiguration.java @@ -0,0 +1,37 @@ +package tc.oc.pgm.listing; + +import java.net.URL; +import java.util.OptionalInt; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import tc.oc.commons.core.configuration.ConfigUtils; +import tc.oc.minecraft.api.configuration.Configuration; +import tc.oc.minecraft.api.configuration.ConfigurationSection; +import tc.oc.net.UriUtils; + +class ListingConfiguration { + + private final ConfigurationSection config; + + @Inject ListingConfiguration(Configuration root) { + this.config = root.needSection("announce"); + } + + public boolean enabled() { + return config.getBoolean("enabled", false); + } + + public URL announceUrl() { + return ConfigUtils.getUrl(config, "url", UriUtils.url("https://oc.tc/announce")); + } + + public @Nullable String serverHost() { + return config.getString("server-host"); + } + + public OptionalInt serverPort() { + final int port = config.getInt("server-port", 0); + return port != 0 ? OptionalInt.of(port) : OptionalInt.empty(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/listing/ListingManifest.java b/PGM/src/main/java/tc/oc/pgm/listing/ListingManifest.java new file mode 100644 index 0000000..bb1504f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/listing/ListingManifest.java @@ -0,0 +1,21 @@ +package tc.oc.pgm.listing; + +import tc.oc.commons.core.commands.CommandBinder; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.minecraft.api.event.ListenerBinder; + +public class ListingManifest extends HybridManifest { + + @Override + protected void configure() { + bind(ListingConfiguration.class); + bind(ListingService.class).to(ListingServiceImpl.class); + + final ListenerBinder listeners = new ListenerBinder(binder()); + listeners.bindListener().to(PingListener.class); + listeners.bindListener().to(ListingServiceImpl.class); + + new CommandBinder(binder()) + .register(ListingCommands.class); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/listing/ListingService.java b/PGM/src/main/java/tc/oc/pgm/listing/ListingService.java new file mode 100644 index 0000000..783fcf0 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/listing/ListingService.java @@ -0,0 +1,16 @@ +package tc.oc.pgm.listing; + +import javax.annotation.Nullable; + +import com.google.common.util.concurrent.ListenableFuture; +import org.bukkit.command.CommandSender; +import tc.oc.api.message.types.Reply; + +public interface ListingService { + + ListenableFuture update(boolean online); + + ListenableFuture update(boolean online, CommandSender sender); + + @Nullable String sessionDigest(); +} diff --git a/PGM/src/main/java/tc/oc/pgm/listing/ListingServiceImpl.java b/PGM/src/main/java/tc/oc/pgm/listing/ListingServiceImpl.java new file mode 100644 index 0000000..d2df37b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/listing/ListingServiceImpl.java @@ -0,0 +1,118 @@ +package tc.oc.pgm.listing; + +import java.security.SecureRandom; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.ListenableFuture; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.apache.commons.codec.binary.Hex; +import org.apache.commons.codec.digest.DigestUtils; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import tc.oc.api.http.HttpClient; +import tc.oc.api.http.HttpOption; +import tc.oc.api.message.types.Reply; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.core.commands.CommandFutureCallback; +import tc.oc.commons.core.concurrent.Flexecutor; +import tc.oc.minecraft.api.event.Enableable; +import tc.oc.minecraft.api.server.LocalServer; +import tc.oc.minecraft.scheduler.Sync; + +@Singleton +class ListingServiceImpl implements ListingService, Enableable { + + private final HttpClient http; + private final ListingConfiguration config; + private final LocalServer localServer; + private final SecureRandom random = new SecureRandom(); + private final Flexecutor executor; + private final Audiences audiences; + private final ConsoleCommandSender console; + + private boolean online; + private @Nullable String sessionId; + private @Nullable String sessionDigest; + + @Inject ListingServiceImpl(HttpClient http, ListingConfiguration config, LocalServer localServer, @Sync(defer = true) Flexecutor executor, Audiences audiences, ConsoleCommandSender console) { + this.http = http; + this.config = config; + this.localServer = localServer; + this.executor = executor; + this.audiences = audiences; + this.console = console; + } + + @Override + public @Nullable String sessionDigest() { + return sessionDigest; + } + + @Override + public void enable() { + if(config.enabled()) { + // Don't announce until we are ready to receive the ping + executor.execute(() -> update(true)); + } + } + + @Override + public void disable() { + if(online) { + update(false); + } + } + + @Override + public ListenableFuture update(boolean online) { + return update(online, console); + } + + @Override + public ListenableFuture update(boolean online, CommandSender sender) { + this.online = online; + + if(sessionId == null) { + final byte[] bytes = new byte[20]; + random.nextBytes(bytes); + sessionId = Hex.encodeHexString(bytes); + sessionDigest = DigestUtils.sha1Hex(sessionId); + } + + final ListenableFuture future = http.post(config.announceUrl().toString(), new ListingUpdate() { + @Override public @Nullable String host() { + return config.serverHost(); + } + + @Override + public int port() { + return config.serverPort().orElseGet(localServer::getPort); + } + + @Override + public boolean online() { + return online; + } + + @Override + public String session() { + return sessionId; + } + }, Reply.class, HttpOption.INFINITE_RETRY); + + executor.callback( + future, + CommandFutureCallback.onSuccess(sender, reply -> { + if(!online) { + sessionId = sessionDigest = null; + } + + audiences.get(sender).sendMessage(new TranslatableComponent(online ? "announce.online" : "announce.offline")); + }) + ); + + return future; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/listing/ListingUpdate.java b/PGM/src/main/java/tc/oc/pgm/listing/ListingUpdate.java new file mode 100644 index 0000000..9f5b0d2 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/listing/ListingUpdate.java @@ -0,0 +1,18 @@ +package tc.oc.pgm.listing; + +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Document; + +@Serialize +public interface ListingUpdate extends Document { + + @Nullable String host(); + + int port(); + + boolean online(); + + String session(); +} diff --git a/PGM/src/main/java/tc/oc/pgm/listing/PingListener.java b/PGM/src/main/java/tc/oc/pgm/listing/PingListener.java new file mode 100644 index 0000000..b94e929 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/listing/PingListener.java @@ -0,0 +1,54 @@ +package tc.oc.pgm.listing; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Provider; +import javax.inject.Singleton; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.server.ServerListPingEvent; +import tc.oc.minecraft.api.event.Enableable; +import tc.oc.pgm.match.Match; + +@Singleton +public class PingListener implements Listener, Enableable { + + private final Provider matchProvider; + private final ListingService listingService; + + @Inject PingListener(Provider matchProvider, ListingService listingService) { + this.matchProvider = matchProvider; + this.listingService = listingService; + } + + @EventHandler + private void onPing(ServerListPingEvent event) { + event.getExtra().put("pgm", new Info()); + } + + private class Info { + private class Map { + final String name; + final @Nullable String icon; + + private Map(String name, String icon) { + this.name = name; + this.icon = icon; + } + } + + final @Nullable String session = listingService.sessionDigest(); + final Map map; + final int participants; + final int observers; + + Info() { + final Match match = matchProvider.get(); + this.map = new Map(match.getMap().getName(), + match.getMap().getThumbnailUri().orElse(null)); + this.participants = match.getParticipatingPlayers().size(); + this.observers = match.getObservingPlayers().size(); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapDefinition.java b/PGM/src/main/java/tc/oc/pgm/map/MapDefinition.java index 9768368..5086845 100644 --- a/PGM/src/main/java/tc/oc/pgm/map/MapDefinition.java +++ b/PGM/src/main/java/tc/oc/pgm/map/MapDefinition.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.Optional; import javax.annotation.Nullable; import javax.inject.Inject; import javax.inject.Provider; @@ -64,6 +65,10 @@ public class MapDefinition { return folder; } + public Optional getThumbnailUri() { + return getFolder().getThumbnailUri(); + } + public String getDottedPath() { return Joiner.on(".").join(getFolder().getRelativePath()); } diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapDocument.java b/PGM/src/main/java/tc/oc/pgm/map/MapDocument.java index 835a148..673d59d 100644 --- a/PGM/src/main/java/tc/oc/pgm/map/MapDocument.java +++ b/PGM/src/main/java/tc/oc/pgm/map/MapDocument.java @@ -10,6 +10,7 @@ import java.util.UUID; import javax.annotation.Nullable; import javax.inject.Inject; +import com.google.common.collect.Collections2; import tc.oc.api.docs.AbstractModel; import tc.oc.api.docs.SemanticVersion; import tc.oc.api.docs.virtual.MapDoc; @@ -105,7 +106,7 @@ public class MapDocument extends AbstractModel implements MapDoc { @Override public Collection images() { - return folder.getThumbnails(); + return Collections2.transform(folder.getImages(), path -> path.getFileName().toString()); } @Override diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapFolder.java b/PGM/src/main/java/tc/oc/pgm/map/MapFolder.java index 724590f..ea0f4dc 100644 --- a/PGM/src/main/java/tc/oc/pgm/map/MapFolder.java +++ b/PGM/src/main/java/tc/oc/pgm/map/MapFolder.java @@ -9,14 +9,18 @@ import java.nio.file.Path; import java.util.Collection; import java.util.Collections; import java.util.Objects; +import java.util.Optional; import javax.annotation.Nullable; +import tc.oc.commons.core.util.Lazy; import tc.oc.commons.core.util.Utils; +import tc.oc.image.ImageUtils; public class MapFolder { public static final String MAP_DESCRIPTION_FILE_NAME = "map.xml"; - public static final String THUMBNAIL_FILE_NAME = "map.png"; + public static final String IMAGE_FILE_NAME = "map.png"; + public static final int THUMBNAIL_HEIGHT = 64; public static boolean isMapFolder(Path path) { return Files.isDirectory(path) && Files.isRegularFile(path.resolve(MAP_DESCRIPTION_FILE_NAME)); @@ -24,7 +28,17 @@ public class MapFolder { private final MapSource source; private final Path path; - private Collection thumbnails; + + private final Lazy> images = Lazy.from(() -> { + final Path path = getAbsolutePath().resolve(IMAGE_FILE_NAME); + return Files.isRegularFile(path)? Collections.singleton(path) + : Collections.emptySet(); + }); + + private final Lazy> thumbnailUri = Lazy.from( + () -> getImages().stream().findAny() + .map(path -> ImageUtils.thumbnailUri(path, THUMBNAIL_HEIGHT)) + ); public MapFolder(MapSource source, Path path) { this.source = source; @@ -111,14 +125,11 @@ public class MapFolder { return getRelativeUrl(getRelativeDescriptionFilePath()); } - public Collection getThumbnails() { - if(thumbnails == null) { - if(Files.isRegularFile(getAbsolutePath().resolve(THUMBNAIL_FILE_NAME))) { - thumbnails = Collections.singleton(THUMBNAIL_FILE_NAME); - } else { - thumbnails = Collections.emptySet(); - } - } - return thumbnails; + public Optional getThumbnailUri() { + return thumbnailUri.get(); + } + + public Collection getImages() { + return images.get(); } } diff --git a/PGM/src/main/resources/config.yml b/PGM/src/main/resources/config.yml index b7e53c3..21727da 100644 --- a/PGM/src/main/resources/config.yml +++ b/PGM/src/main/resources/config.yml @@ -59,6 +59,21 @@ # If false, PGM will load its classes but not enable itself enabled: true +# Public PGM listing service +# -------------------------- +# If announce is enabled, and this server is running the PGM plugin, +# the listing service will be notified whenever this server starts up +# or shuts down. If the server is reachable at the announced address, +# it will be included in the public list. +# +# WARNING: Enabling this will publish your IP address to the world, +# unless you set server-host to something else. +announce: + enabled: false # Announce this server? + # server-port: 25565 # Public port - defaults to whatever port is bound at startup + # server-host: myserver.com # Public hostname or IP - if not set, the listing service will use + # the IP address that the announcement originates from + # Variables accessible by the XML pre-processor environment: ranked: false diff --git a/PGM/src/main/resources/plugin.yml b/PGM/src/main/resources/plugin.yml index 8899e4f..774aada 100644 --- a/PGM/src/main/resources/plugin.yml +++ b/PGM/src/main/resources/plugin.yml @@ -201,3 +201,7 @@ permissions: pgm.destroyable.edit: description: Allows the player to edit the properties of destroyables + + pgm.listing.announce: + description: Allows the /announce command + default: op diff --git a/Util/core/src/main/java/tc/oc/commons/core/configuration/ConfigUtils.java b/Util/core/src/main/java/tc/oc/commons/core/configuration/ConfigUtils.java index 0aa7390..d7ba401 100644 --- a/Util/core/src/main/java/tc/oc/commons/core/configuration/ConfigUtils.java +++ b/Util/core/src/main/java/tc/oc/commons/core/configuration/ConfigUtils.java @@ -1,5 +1,7 @@ package tc.oc.commons.core.configuration; +import java.net.MalformedURLException; +import java.net.URL; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -15,6 +17,7 @@ import java.time.Duration; import tc.oc.commons.core.util.Predicates; import tc.oc.commons.core.util.TimeUtils; import tc.oc.minecraft.api.configuration.ConfigurationSection; +import tc.oc.minecraft.api.configuration.InvalidConfigurationException; public final class ConfigUtils { private ConfigUtils() {} @@ -50,6 +53,26 @@ public final class ConfigUtils { return getDuration(section, path, null); } + public static URL needUrl(ConfigurationSection section, String path) { + return parseUrl(section, path, section.needString(path), null); + } + + public static @Nullable URL getUrl(ConfigurationSection section, String path) { + return getUrl(section, path, null); + } + + public static URL getUrl(ConfigurationSection section, String path, URL def) { + return parseUrl(section, path, section.getString(path), def); + } + + private static URL parseUrl(ConfigurationSection section, String path, @Nullable String value, URL def) { + try { + return value == null ? def : new URL(value); + } catch(MalformedURLException e) { + throw new InvalidConfigurationException(section, path, e.getMessage()); + } + } + private static void buildDeepMap(Map map, ConfigurationSection section, String prefix) { for(String key : section.getKeys()) { final Object obj = section.get(key); diff --git a/Util/core/src/main/java/tc/oc/image/ImageUtils.java b/Util/core/src/main/java/tc/oc/image/ImageUtils.java new file mode 100644 index 0000000..af428e5 --- /dev/null +++ b/Util/core/src/main/java/tc/oc/image/ImageUtils.java @@ -0,0 +1,49 @@ +package tc.oc.image; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import javax.imageio.ImageIO; + +import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableMap; +import tc.oc.net.UriUtils; + +public interface ImageUtils { + + static BufferedImage resize(BufferedImage image, int width, int height) { + final BufferedImage resized = new BufferedImage(width, 64, image.getType()); + final Graphics2D g = resized.createGraphics(); + g.addRenderingHints(ImmutableMap.of(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR)); + g.drawImage(image, 0, 0, width, height, null); + g.dispose(); + return resized; + } + + static byte[] png(BufferedImage image) { + try { + final ByteArrayOutputStream data = new ByteArrayOutputStream(); + ImageIO.write(image, "png", data); + return data.toByteArray(); + } catch(IOException e) { + throw Throwables.propagate(e); + } + } + + static byte[] thumbnail(Path path, int height) { + try { + final BufferedImage image = ImageIO.read(path.toFile()); + final BufferedImage thumb = resize(image, height * image.getWidth() / image.getHeight(), height); + return png(thumb); + } catch(IOException e) { + throw Throwables.propagate(e); + } + } + + static String thumbnailUri(Path path, int height) { + return UriUtils.dataUri("image/png", thumbnail(path, height)); + } +} diff --git a/Util/core/src/main/java/tc/oc/net/UriUtils.java b/Util/core/src/main/java/tc/oc/net/UriUtils.java new file mode 100644 index 0000000..db88412 --- /dev/null +++ b/Util/core/src/main/java/tc/oc/net/UriUtils.java @@ -0,0 +1,20 @@ +package tc.oc.net; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Base64; + +public interface UriUtils { + + static String dataUri(String mime, byte[] data) { + return "data:" + mime + ";base64," + Base64.getEncoder().encodeToString(data); + } + + static URL url(String spec) { + try { + return new URL(spec); + } catch(MalformedURLException e) { + throw new IllegalArgumentException("Malformed URL: " + spec); + } + } +} diff --git a/pom.xml b/pom.xml index ed7862c..f0b6765 100644 --- a/pom.xml +++ b/pom.xml @@ -92,7 +92,7 @@ commons-codec commons-codec - 1.3 + 1.8 net.sf.trove4j