Announce support

This commit is contained in:
Jedediah Smith 2017-02-18 01:08:38 -05:00
parent 0562d80699
commit b883ef5799
25 changed files with 466 additions and 28 deletions

View File

@ -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());

View File

@ -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 <T> ListenableFuture<T> request(String method, String path, @Nullable Object content, @Nullable TypeToken<T> 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) {

View File

@ -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

View File

@ -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

View File

@ -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);
}

View File

@ -33,5 +33,3 @@ queue:
logging:
root:
level: INFO
tc-oc-api-bukkit-BukkitApi:
level: INFO

View File

@ -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());
}
}

View File

@ -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
stats.hotbar = {0} kills ({1} streak) {2} deaths {3} K/D
announce.online = Announced server as online
announce.offline = Announced server as offline

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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<Reply> update(boolean online);
ListenableFuture<Reply> update(boolean online, CommandSender sender);
@Nullable String sessionDigest();
}

View File

@ -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<Reply> update(boolean online) {
return update(online, console);
}
@Override
public ListenableFuture<Reply> 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<Reply> 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;
}
}

View File

@ -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();
}

View File

@ -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<Match> matchProvider;
private final ListingService listingService;
@Inject PingListener(Provider<Match> 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();
}
}
}

View File

@ -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<String> getThumbnailUri() {
return getFolder().getThumbnailUri();
}
public String getDottedPath() {
return Joiner.on(".").join(getFolder().getRelativePath());
}

View File

@ -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<String> images() {
return folder.getThumbnails();
return Collections2.transform(folder.getImages(), path -> path.getFileName().toString());
}
@Override

View File

@ -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<String> thumbnails;
private final Lazy<Collection<Path>> images = Lazy.from(() -> {
final Path path = getAbsolutePath().resolve(IMAGE_FILE_NAME);
return Files.isRegularFile(path)? Collections.singleton(path)
: Collections.emptySet();
});
private final Lazy<Optional<String>> 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<String> 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<String> getThumbnailUri() {
return thumbnailUri.get();
}
public Collection<Path> getImages() {
return images.get();
}
}

View File

@ -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

View File

@ -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

View File

@ -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<String, Object> map, ConfigurationSection section, String prefix) {
for(String key : section.getKeys()) {
final Object obj = section.get(key);

View File

@ -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));
}
}

View File

@ -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);
}
}
}

View File

@ -92,7 +92,7 @@
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.3</version>
<version>1.8</version>
</dependency>
<dependency>
<groupId>net.sf.trove4j</groupId>