public interface VoteHandler {

    void onVoteReceived(Vote vote, VotifierSession.ProtocolVersion protocolVersion, String remoteAddress) throws Exception;

    default void onError(Throwable throwable, boolean voteAlreadyCompleted, String remoteAddress) {
        throw new RuntimeException("Unimplemented onError handler");
    }

} public class Vote {

    /**
     * The name of the vote service.
     */
    private String serviceName;

    /**
     * The username of the voter.
     */
    private String username;

    /**
     * The address of the voter.
     */
    private String address;

    /**
     * The date and time of the vote.
     */
    private String timeStamp;

    private byte[] additionalData;

    @Deprecated
    public Vote() {
    }

    public Vote(String serviceName, String username, String address, String timeStamp) {
        this.serviceName = serviceName;
        this.username = username;
        this.address = address;
        this.timeStamp = timeStamp;
        this.additionalData = null;
    }

    public Vote(String serviceName, String username, String address, String timeStamp, byte[] additionalData) {
        this.serviceName = serviceName;
        this.username = username;
        this.address = address;
        this.timeStamp = timeStamp;
        this.additionalData = additionalData == null ? null : additionalData.clone();
    }

    public Vote(Vote vote) {
        this(vote.getServiceName(), vote.getUsername(), vote.getAddress(), vote.getTimeStamp(),
                vote.getAdditionalData() == null ? null : vote.getAdditionalData().clone());
    }

    private static String getTimestamp(JsonElement object) {
        try {
            return Long.toString(object.getAsLong());
        } catch (Exception e) {
            return object.getAsString();
        }
    }

    public Vote(JsonObject jsonObject) {
        this(jsonObject.get("serviceName").getAsString(),
                jsonObject.get("username").getAsString(),
                jsonObject.get("address").getAsString(),
                getTimestamp(jsonObject.get("timestamp")));
        if (jsonObject.has("additionalData"))
            additionalData = Base64.getDecoder().decode(jsonObject.get("additionalData").getAsString());
    }

    @Override
    public String toString() {
        String data;
        if (additionalData == null)
            data = "null";
        else
            data = Base64.getEncoder().encodeToString(additionalData);

        return "Vote (from:" + serviceName + " username:" + username
                + " address:" + address + " timeStamp:" + timeStamp
                + " additionalData:" + data + ")";
    }

    /**
     * Sets the serviceName.
     *
     * @param serviceName The new serviceName
     */
    @Deprecated
    public void setServiceName(String serviceName) {
        this.serviceName = serviceName;
    }

    /**
     * Gets the serviceName.
     *
     * @return The serviceName
     */
    public String getServiceName() {
        return serviceName;
    }

    /**
     * Sets the username.
     *
     * @param username The new username
     */
    @Deprecated
    public void setUsername(String username) {
        this.username = username;
    }

    /**
     * Gets the username.
     *
     * @return The username
     */
    public String getUsername() {
        return username;
    }

    /**
     * Sets the address.
     *
     * @param address The new address
     */
    @Deprecated
    public void setAddress(String address) {
        this.address = address;
    }

    /**
     * Gets the address.
     *
     * @return The address
     */
    public String getAddress() {
        return address;
    }

    /**
     * Sets the time stamp.
     *
     * @param timeStamp The new time stamp
     */
    @Deprecated
    public void setTimeStamp(String timeStamp) {
        this.timeStamp = timeStamp;
    }

    /**
     * Gets the time stamp.
     *
     * @return The time stamp
     */
    public String getTimeStamp() {
        return timeStamp;
    }

    /**
     * Returns additional data sent with the vote, if it exists.
     *
     * @return Additional data sent with the vote
     */
    public byte[] getAdditionalData() {
        return additionalData == null ? null : additionalData.clone();
    }

    public JsonObject serialize() {
        JsonObject ret = new JsonObject();
        ret.addProperty("serviceName", serviceName);
        ret.addProperty("username", username);
        ret.addProperty("address", address);
        ret.addProperty("timestamp", timeStamp);
        if (additionalData != null)
            ret.addProperty("additionalData", Base64.getEncoder().encodeToString(additionalData));
        return ret;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Vote)) return false;

        Vote vote = (Vote) o;

        if (!serviceName.equals(vote.serviceName)) return false;
        if (!username.equals(vote.username)) return false;
        if (!address.equals(vote.address)) return false;
        if (!timeStamp.equals(vote.timeStamp)) return false;
        return Arrays.equals(additionalData, vote.additionalData);
    }

    @Override
    public int hashCode() {
        int result = serviceName.hashCode();
        result = 31 * result + username.hashCode();
        result = 31 * result + address.hashCode();
        result = 31 * result + timeStamp.hashCode();
        return result;
    }
} public class VotifierServerBootstrap {
    private static final boolean USE_EPOLL = Epoll.isAvailable();

    private final String host;
    private final int port;
    private final EventLoopGroup bossLoopGroup;
    private final EventLoopGroup eventLoopGroup;
    private final VotifierPlugin plugin;
    private final boolean v1Disable;

    private Channel serverChannel;

    public VotifierServerBootstrap(String host, int port, VotifierPlugin plugin, boolean v1Disable) {
        this.host = host;
        this.port = port;
        this.plugin = plugin;
        this.v1Disable = v1Disable;
        if (USE_EPOLL) {
            this.bossLoopGroup = new EpollEventLoopGroup(1, createThreadFactory("Votifier epoll boss"));
            this.eventLoopGroup = new EpollEventLoopGroup(3, createThreadFactory("Votifier epoll worker"));
            plugin.getPluginLogger().info("Using epoll transport to accept votes.");
        } else {
            this.bossLoopGroup = new NioEventLoopGroup(1, createThreadFactory("Votifier NIO boss"));
            this.eventLoopGroup = new NioEventLoopGroup(3, createThreadFactory("Votifier NIO worker"));
            plugin.getPluginLogger().info("Using NIO transport to accept votes.");
        }
    }

    private static ThreadFactory createThreadFactory(String name) {
        return runnable -> {
            FastThreadLocalThread thread = new FastThreadLocalThread(runnable, name);
            thread.setDaemon(true);
            return thread;
        };
    }

    public void start(Consumer<Throwable> error) {
        Objects.requireNonNull(error, "error");

        VoteInboundHandler voteInboundHandler = new VoteInboundHandler(plugin);

        new ServerBootstrap()
                .channel(USE_EPOLL ? EpollServerSocketChannel.class : NioServerSocketChannel.class)
                .group(bossLoopGroup, eventLoopGroup)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel channel) {
                        channel.attr(VotifierSession.KEY).set(new VotifierSession());
                        channel.attr(VotifierPlugin.KEY).set(plugin);
                        channel.pipeline().addLast("greetingHandler", VotifierGreetingHandler.INSTANCE);
                        channel.pipeline().addLast("protocolDifferentiator", new VotifierProtocolDifferentiator(false, !v1Disable));
                        channel.pipeline().addLast("voteHandler", voteInboundHandler);
                    }
                })
                .bind(host, port)
                .addListener((ChannelFutureListener) future -> {
                    if (future.isSuccess()) {
                        serverChannel = future.channel();
                        plugin.getPluginLogger().info("Votifier enabled on socket " + serverChannel.localAddress() + ".");
                        error.accept(null);
                    } else {
                        SocketAddress socketAddress = future.channel().localAddress();
                        if (socketAddress == null) {
                            socketAddress = new InetSocketAddress(host, port);
                        }
                        plugin.getPluginLogger().error("Votifier was not able to bind to " + socketAddress.toString(), future.cause());
                        error.accept(future.cause());
                    }
                });
    }

    private Bootstrap client() {
        return new Bootstrap()
                .channel(USE_EPOLL ? EpollSocketChannel.class : NioSocketChannel.class)
                .group(eventLoopGroup);
    }

    public ProxyForwardingVoteSource createForwardingSource(List<ProxyForwardingVoteSource.BackendServer> backendServers,
                                                            VoteCache voteCache) {
        return new ProxyForwardingVoteSource(plugin, this::client, backendServers, voteCache);
    }

    public void shutdown() {
        if (serverChannel != null) {
            try {
                serverChannel.close().syncUninterruptibly();
            } catch (Exception e) {
                plugin.getPluginLogger().error("Unable to shutdown server channel", e);
            }
        }
        eventLoopGroup.shutdownGracefully();
        bossLoopGroup.shutdownGracefully();

        try {
            bossLoopGroup.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
            eventLoopGroup.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
} public class VotifierSession {
    public static final AttributeKey<VotifierSession> KEY = AttributeKey.valueOf("votifier_session");
    private ProtocolVersion version = ProtocolVersion.UNKNOWN;
    private final String challenge;
    private boolean hasCompletedVote = false;

    public VotifierSession() {
        challenge = TokenUtil.newToken();
    }

    public void setVersion(ProtocolVersion version) {
        if (this.version != ProtocolVersion.UNKNOWN)
            throw new IllegalStateException("Protocol version already switched");

        this.version = version;
    }

    public ProtocolVersion getVersion() {
        return version;
    }

    public String getChallenge() {
        return challenge;
    }

    public void completeVote() {
        if (hasCompletedVote)
            throw new IllegalStateException("Protocol completed vote twice!");

        hasCompletedVote = true;
    }

    public boolean hasCompletedVote() {
        return hasCompletedVote;
    }

    public enum ProtocolVersion {
        UNKNOWN("unknown"),
        ONE("protocol v1"),
        TWO("protocol v2"),
        TEST("test");

        public final String humanReadable;
        ProtocolVersion(String hr) {
            this.humanReadable = hr;
        }
    }
} public class RSAIO {

    /**
     * Saves the key pair to the disk.
     *
     * @param directory The directory to save to
     * @param keyPair   The key pair to save
     * @throws Exception If an error occurs
     */
    public static void save(File directory, KeyPair keyPair) throws Exception {
        PrivateKey privateKey = keyPair.getPrivate();
        PublicKey publicKey = keyPair.getPublic();

        // Store the public and private keys.
        X509EncodedKeySpec publicSpec = new X509EncodedKeySpec(
                publicKey.getEncoded());
        PKCS8EncodedKeySpec privateSpec = new PKCS8EncodedKeySpec(
                privateKey.getEncoded());
        try (FileOutputStream publicOut = new FileOutputStream(directory + "/public.key");
             FileOutputStream privateOut = new FileOutputStream(directory + "/private.key")) {

            publicOut.write(Base64.getEncoder().encode(publicSpec.getEncoded()));
            privateOut.write(Base64.getEncoder().encode(privateSpec.getEncoded()));
        }
    }

    public static byte[] readB64File(File directory, String name) throws IOException {
        File f = new File(directory, name);
        byte[] contents = Files.readAllBytes(f.toPath());
        String strContents = new String(contents, StandardCharsets.US_ASCII);
        strContents = strContents.trim();
        try {
            return Base64.getDecoder().decode(strContents);
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("Base64 decoding exception: This is probably due to a corrupted file, " +
                    "but in case it isn't, here is a b64 representation of what we read: " + new String(Base64.getEncoder().encode(contents), StandardCharsets.UTF_8), e);
        }
    }

    /**
     * Loads an RSA key pair from a directory. The directory must have the files
     * "public.key" and "private.key".
     *
     * @param directory The directory to load from
     * @return The key pair
     * @throws Exception If an error occurs
     */
    public static KeyPair load(File directory) throws Exception {
        // Read the public key file.
        byte[] encodedPublicKey = readB64File(directory, "public.key");

        // Read the private key file.
        byte[] encodedPrivateKey = readB64File(directory, "private.key");

        // Instantiate and return the key pair.
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(
                encodedPublicKey);
        PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
        PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(
                encodedPrivateKey);
        PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);
        return new KeyPair(publicKey, privateKey);
    }

} public class RSAKeygen {
    /**
     * Generates an RSA key pair.
     *
     * @param bits The amount of bits
     * @return The key pair
     */
    public static KeyPair generate(int bits) throws Exception {
        KeyPairGenerator keygen = KeyPairGenerator.getInstance("RSA");
        RSAKeyGenParameterSpec spec = new RSAKeyGenParameterSpec(bits,
                RSAKeyGenParameterSpec.F4);
        keygen.initialize(spec);
        return keygen.generateKeyPair();
    }

} public interface BackendServer {
    String getName();

    boolean sendPluginMessage(String channel, byte[] data);
} public interface LoggingAdapter {
    void error(String s);
    void error(String s, Object... o);
    void error(String s, Throwable e, Object... o);

    void warn(String s);
    void warn(String s, Object... o);

    void info(String s);
    void info(String s, Object... o);
} public interface ProxyVotifierPlugin extends VotifierPlugin {
    Collection<BackendServer> getAllBackendServers();

    Optional<BackendServer> getServer(String name);
} public interface VotifierScheduler {
    ScheduledVotifierTask sync(Runnable runnable);

    ScheduledVotifierTask onPool(Runnable runnable);

    ScheduledVotifierTask delayedSync(Runnable runnable, int delay, TimeUnit unit);

    ScheduledVotifierTask delayedOnPool(Runnable runnable, int delay, TimeUnit unit);

    ScheduledVotifierTask repeatOnPool(Runnable runnable, int delay, int repeat, TimeUnit unit);
} public interface ForwardingVoteSource {

    /**
     * Forwards a vote to all servers set up to receive votes.
     *
     * @param v Vote to forward to servers
     */
    void forward(Vote v);

    /**
     * Stop or close any oustanding network interfaces. Occurs on the onDisable method of a plugin.
     */
    void halt();

} public class ServerFilter {

    public ServerFilter(Collection<String> names, boolean whitelist) {
        this.names = names;
        this.whitelist = whitelist;
    }

    public ServerFilter() {
        this(Collections.emptySet(), true);
    }

    private final Collection<String> names;
    private final boolean whitelist;

    public boolean isAllowed(String name) {
        return names.contains(name) == whitelist;
    }
} public class FileVoteCache extends MemoryVoteCache {

    private final LoggingAdapter l;
    private final File cacheFile;
    private final ScheduledVotifierTask saveTask;

    public FileVoteCache(final VotifierPlugin plugin, File cacheFile, long voteTTL) throws IOException {
        super(plugin, voteTTL);
        this.cacheFile = cacheFile;
        this.l = plugin.getPluginLogger();

        load();

        saveTask = plugin.getScheduler().repeatOnPool(() -> {
            try {
                save();
            } catch (IOException e) {
                l.error("Unable to save cached votes, votes will be lost if you restart.", e);
            }
        }, 3, 3, TimeUnit.MINUTES);
    }

    private static Set<String> keySet(JsonObject object) {
        Set<String> set = new HashSet<>();
        for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
            set.add(entry.getKey());
        }
        return set;
    }

    private void load() throws IOException {
        // Load the cache from disk
        JsonObject object;
        try (BufferedReader reader = Files.newBufferedReader(cacheFile.toPath(), StandardCharsets.UTF_8)) {
            object = GsonInst.gson.fromJson(reader, JsonObject.class);
            if (object == null) {
                // When the input is not malformed but instead empty, this returns null. Simply assume it is empty.
                object = new JsonObject();
            }
        } catch (NoSuchFileException e) {
            object = new JsonObject();
        }

        boolean resave = false;

        // First, lets figure out if we are converting from a pre2.3.6 cache
        if (!object.has("players") || !object.has("servers") || !object.has("version") || object.size() != 3) {
            JsonObject oldObject = object;
            object = new JsonObject();
            object.add("servers", oldObject);
            object.add("players", new JsonObject());
            object.addProperty("version", 2);
            resave = true;
        }

        if (object.get("version").getAsInt() != 2)
            throw new IllegalStateException("Could not read cache file! Unknown version '" + object.get("version").getAsInt() + "' read.");

        JsonObject players = object.getAsJsonObject("players");
        JsonObject servers = object.getAsJsonObject("servers");

        for (String player : keySet(players)) {
            playerVoteCache.put(player, readVotes(players.getAsJsonArray(player)));
        }

        for (String server : keySet(servers)) {
            voteCache.put(server, readVotes(servers.getAsJsonArray(server)));
        }

        if (resave) {
            File replacementFile;
            for (int i = 0; ; i++) {
                replacementFile = new File(cacheFile.getParentFile(), cacheFile.getName() + ".bak." + i);
                if (!replacementFile.exists())
                    break;
            }

            if (!cacheFile.renameTo(replacementFile)) {
                l.error("Backup movement failed! Will not save.");
                return;
            }

            l.warn("Saving new vote cache format to file - backup moved to " + replacementFile.getAbsolutePath());
            save();
        }
    }

    private Collection<VoteWithRecordedTimestamp> readVotes(JsonArray voteArray) {
        Collection<VoteWithRecordedTimestamp> votes = new HashSet<>(voteArray.size());
        for (int i = 0; i < voteArray.size(); i++) {
            JsonObject voteObject = voteArray.get(i).getAsJsonObject();
            VoteWithRecordedTimestamp v = new VoteWithRecordedTimestamp(voteObject);
            if (hasTimedOut(v))
                l.warn("Purging out of date vote.", v);
            else
                votes.add(v);
        }
        return votes;
    }

    public void save() throws IOException {
        JsonObject votesObject = new JsonObject();

        cacheLock.lock();
        try {
            votesObject.addProperty("version", 2);
            votesObject.add("players", serializeMap(playerVoteCache));
            votesObject.add("servers", serializeMap(voteCache));
        } finally {
            cacheLock.unlock();
        }

        try (BufferedWriter writer = Files.newBufferedWriter(cacheFile.toPath(), StandardCharsets.UTF_8)) {
            GsonInst.gson.toJson(votesObject, writer);
        }
    }

    public JsonObject serializeMap(Map<String, Collection<VoteWithRecordedTimestamp>> map) {
        JsonObject o = new JsonObject();

        Iterator<Map.Entry<String, Collection<VoteWithRecordedTimestamp>>> entryItr = map.entrySet().iterator();
        while (entryItr.hasNext()) {
            Map.Entry<String, Collection<VoteWithRecordedTimestamp>> entry = entryItr.next();
            JsonArray array = new JsonArray();
            Iterator<VoteWithRecordedTimestamp> voteItr = entry.getValue().iterator();
            while (voteItr.hasNext()) {
                VoteWithRecordedTimestamp vote = voteItr.next();

                // if the vote is no longer valid, notify and remove
                if (hasTimedOut(vote)) {
                    l.warn("Purging out of date vote.", vote);
                    voteItr.remove();
                } else {
                    array.add(vote.serialize());
                }

            }

            // if, during our iteration, we TTL invalidated all of the votes
            if (entry.getValue().isEmpty())
                entryItr.remove();
            o.add(entry.getKey(), array);
        }
        return o;
    }


    public void halt() throws IOException {
        saveTask.cancel();
        save();
    }
} public class MemoryVoteCache implements VoteCache {

    private final LoggingAdapter l;
    private final long voteTTL;

    protected final Map<String, Collection<VoteWithRecordedTimestamp>> voteCache;
    protected final Map<String, Collection<VoteWithRecordedTimestamp>> playerVoteCache;

    protected final ReentrantLock cacheLock = new ReentrantLock();

    private final ScheduledVotifierTask sweepTask;

    public MemoryVoteCache(VotifierPlugin p, long voteTTL) {
        voteCache = new HashMap<>();
        playerVoteCache = new HashMap<>();

        this.voteTTL = voteTTL;

        this.l = p.getPluginLogger();
        this.sweepTask = p.getScheduler().repeatOnPool(this::sweep, 12, 12, TimeUnit.HOURS);
    }

    @Override
    public Collection<String> getCachedServers() {
        cacheLock.lock();
        try {
            return Collections.unmodifiableCollection(new ArrayList<>(voteCache.keySet()));
        } finally {
            cacheLock.unlock();
        }
    }

    @Override
    public void addToCache(Vote v, String server) {
        if (server == null) throw new NullPointerException();
        cacheLock.lock();
        try {
            voteCache.computeIfAbsent(server, k -> new HashSet<>()).add(new VoteWithRecordedTimestamp(v));
        } finally {
            cacheLock.unlock();
        }
    }

    @Override
    public void addToCachePlayer(Vote v, String player) {
        if (player == null) throw new NullPointerException();
        cacheLock.lock();
        try {
            playerVoteCache.computeIfAbsent(player, k -> new HashSet<>()).add(new VoteWithRecordedTimestamp(v));
        } finally {
            cacheLock.unlock();
        }
    }

    @Override
    public Collection<Vote> evictPlayer(String player) {
        if (player == null) throw new NullPointerException();
        cacheLock.lock();
        try {
            Collection<VoteWithRecordedTimestamp> playerVotes = playerVoteCache.remove(player);
            if (playerVotes != null) {
                return new HashSet<>(playerVotes);
            } else {
                return Collections.emptySet();
            }
        } finally {
            cacheLock.unlock();
        }
    }

    @Override
    public Collection<Vote> evict(String server) {
        if (server == null) throw new NullPointerException();
        cacheLock.lock();
        try {
            Collection<VoteWithRecordedTimestamp> serverVotes = voteCache.remove(server);
            if (serverVotes != null) {
                return new HashSet<>(serverVotes);
            } else {
                return Collections.emptySet();
            }
        } finally {
            cacheLock.unlock();
        }
    }

    public void sweep() {
        cacheLock.lock();
        try {
            sweep(voteCache);
            sweep(playerVoteCache);
        } finally {
            cacheLock.unlock();
        }
    }

    private void sweep(Map<String, Collection<VoteWithRecordedTimestamp>> m) {
        for (Map.Entry<String, Collection<VoteWithRecordedTimestamp>> entry : m.entrySet()) {
            Iterator<VoteWithRecordedTimestamp> vi = entry.getValue().iterator();
            while (vi.hasNext()) {
                VoteWithRecordedTimestamp v = vi.next();
                if (hasTimedOut(v)) {
                    l.warn("Purging out of date vote.", v);
                    vi.remove();
                }
            }
        }
    }

    protected boolean hasTimedOut(VoteWithRecordedTimestamp v) {
        if (voteTTL == -1) return false;

        long daysAsMillis = TimeUnit.DAYS.toMillis(voteTTL);
        return v.recorded + daysAsMillis < System.currentTimeMillis();
    }

    static class VoteWithRecordedTimestamp extends Vote {
        private final long recorded;

        VoteWithRecordedTimestamp(Vote vote) {
            super(vote);
            this.recorded = System.currentTimeMillis();
        }

        VoteWithRecordedTimestamp(JsonObject object) {
            super(object);
            if (object.has("recorded")) {
                this.recorded = object.get("recorded").getAsLong();
            } else {
                // Assign the current time, in the hope that the stale votes will eventually get purged.
                this.recorded = System.currentTimeMillis();
            }
        }

        @Override
        public JsonObject serialize() {
            JsonObject object = super.serialize();
            object.addProperty("recorded", this.recorded);
            return object;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            if (!super.equals(o)) return false;

            VoteWithRecordedTimestamp that = (VoteWithRecordedTimestamp) o;

            return recorded == that.recorded;
        }

        @Override
        public int hashCode() {
            int result = super.hashCode();
            result = 31 * result + (int) (recorded ^ (recorded >>> 32));
            return result;
        }
    }
} public interface VoteCache {

    /**
     * Returns the names of all server in which the cache is holding votes for
     *
     * @return Collection of all cached servers
     */
    Collection<String> getCachedServers();

    /**
     * Adds a vote to the vote cache for later sending
     *
     * @param v      Vote to add to cache
     * @param server Server to add vote under
     */
    void addToCache(Vote v, String server);

    /**
     * Adds a vote to the vote cache for later sending, specifically keyed to a player. This is for the instance when the
     * player is rewarded, however is not online any servers able to recieve said vote.
     *
     * @param v      Vote to add to the cache
     * @param player Server to add vote under
     */
    default void addToCachePlayer(Vote v, String player) {
    }

    /**
     * Evicts all votes from the vote cache and returns a collection of vote objects
     *
     * @param server Server name of which to evict the votes from the cache
     * @return A collection of all votes which were previously in the cache under the server
     */
    Collection<Vote> evict(String server);

    /**
     * Evicts all votes from the vote cache for a specific player not assigned to a server and returns a collection of
     * vote objects
     *
     * @param player Player name of the votes to evict from the cache
     * @return A collection of all votes which were previously in the cache under the player
     */
    default Collection<Vote> evictPlayer(String player) {
        return Collections.EMPTY_LIST;
    }
} public class ProxyForwardingVoteSource implements ForwardingVoteSource {
    private static final int MAX_RETRIES = 5;
    private final VotifierPlugin plugin;
    private final Supplier<Bootstrap> nettyBootstrap;
    private final List<BackendServer> backendServers;
    private final VoteCache voteCache;

    private static final StringDecoder STRING_DECODER = new StringDecoder(StandardCharsets.US_ASCII);

    public ProxyForwardingVoteSource(VotifierPlugin plugin, Supplier<Bootstrap> nettyBootstrap, List<BackendServer> backendServers, VoteCache voteCache) {
        this.plugin = plugin;
        this.nettyBootstrap = nettyBootstrap;
        this.backendServers = backendServers;
        this.voteCache = voteCache;
    }

    @Override
    public void forward(Vote v) {
        for (final BackendServer server : backendServers) {
            forwardVote(server, v, 0);
        }
    }

    private int fib(int t) {
        if (t <= 1) return 1;
        return fib(t - 2) + fib(t - 1);
    }

    private void forwardVote(final BackendServer server, final Vote v, final int tries) {
        nettyBootstrap.get()
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel channel) {
                        channel.pipeline().addLast(new DelimiterBasedFrameDecoder(256, true, Delimiters.lineDelimiter()));
                        channel.pipeline().addLast(new ReadTimeoutHandler(5, TimeUnit.SECONDS));
                        channel.pipeline().addLast(STRING_DECODER);
                        channel.pipeline().addLast(new VotifierProtocol2Encoder(server.key));
                        channel.pipeline().addLast(new VotifierProtocol2HandshakeHandler(v, new VotifierResponseHandler() {
                            @Override
                            public void onSuccess() {
                                if (plugin.isDebug()) {
                                    plugin.getPluginLogger().info("Successfully forwarded vote " + v + " to " + server.address + ".");
                                }
                            }

                            @Override
                            public void onFailure(Throwable error) {
                                handleFailure(server, v, error, tries);
                            }
                        }, plugin));
                    }
                })
                .connect(server.address)
                .addListener((ChannelFutureListener) future -> {
                    if (!future.isSuccess()) {
                        handleFailure(server, v, future.cause(), tries);
                    }
                });
    }

    private void handleFailure(final BackendServer server, final Vote v, Throwable cause, final int tries) {
        int nextDelay = fib(tries + 1);
        boolean willRetry = tries < MAX_RETRIES;

        String msg = "Unable to send vote to " + server.address + ".";
        if (willRetry) {
            msg += " Will retry sending in " + nextDelay + " second(s).";
        } else {
            if (voteCache == null) {
                msg += " This vote will be lost!";
            } else {
                voteCache.addToCache(v, server.name);
            }
        }

        if (plugin.isDebug()) {
            plugin.getPluginLogger().error(msg, cause);
        } else {
            plugin.getPluginLogger().error(msg);
        }

        if (willRetry) {
            plugin.getScheduler().delayedOnPool(() -> forwardVote(server, v, tries + 1), nextDelay, TimeUnit.SECONDS);
        }
    }

    @Override
    public void halt() {

    }

    public static class BackendServer {
        private final String name;
        private final InetSocketAddress address;
        private final Key key;

        public BackendServer(String name, InetSocketAddress address, Key key) {
            this.name = name;
            this.address = address;
            this.key = key;
        }
    }
} public class IOUtil {
    public static byte[] readAllBytes(InputStream from) throws IOException {
        ByteArrayOutputStream out = new ByteArrayOutputStream(8192);
        byte[] buf = new byte[8192];
        int read;
        while ((read = from.read(buf)) != -1) {
            out.write(buf, 0, read);
        }
        return out.toByteArray();
    }
} public class KeyCreator {
    public static Key createKeyFrom(String token) {
        return new SecretKeySpec(token.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    }
} public class TokenUtil {
    private TokenUtil() {

    }

    private static final SecureRandom RANDOM = new SecureRandom();

    public static String newToken() {
        return new BigInteger(130, RANDOM).toString(32);
    }
} public class NVReloadCmd implements SimpleCommand {

    private final VotifierPlugin plugin;

    public NVReloadCmd(VotifierPlugin plugin) {
        this.plugin = plugin;
    }

    @Override
    public void execute(SimpleCommand.Invocation invocation) {
        invocation.source().sendMessage(Component.text("Reloading NuVotifier...", NamedTextColor.GRAY));
        if (plugin.reload()) {
            invocation.source().sendMessage(Component.text("NuVotifier has been reloaded!", NamedTextColor.DARK_GREEN));
        } else {
            invocation.source().sendMessage(Component.text("Looks like there was a problem reloading NuVotifier, check the console!", NamedTextColor.DARK_RED));
        }
    }

    @Override
    public boolean hasPermission(SimpleCommand.Invocation invocation) {
        return invocation.source().hasPermission("nuvotifier.reload");
    }
} public class TestVoteCmd implements SimpleCommand {

    private final VotifierPlugin plugin;

    public TestVoteCmd(VotifierPlugin plugin) {
        this.plugin = plugin;
    }

    @Override
    public void execute(SimpleCommand.Invocation invocation) {
        Vote v;
        try {
            v = ArgsToVote.parse(invocation.arguments());
        } catch (IllegalArgumentException e) {
            invocation.source().sendMessage(Component.text("Error while parsing arguments to create test vote: " + e.getMessage(), NamedTextColor.DARK_RED));
            invocation.source().sendMessage(Component.text("Usage hint: /testvote [username] [serviceName=?] [username=?] [address=?] [localTimestamp=?] [timestamp=?]", NamedTextColor.GRAY));
            return;
        }

        plugin.onVoteReceived(v, VotifierSession.ProtocolVersion.TEST, "localhost.test");
        invocation.source().sendMessage(Component.text("Test vote executed: " + v.toString(), NamedTextColor.GREEN));
    }

    @Override
    public boolean hasPermission(SimpleCommand.Invocation invocation) {
        return invocation.source().hasPermission("nuvotifier.testvote");
    }
} public class VotifierEvent {
    private final Vote vote;

    public VotifierEvent(Vote vote) {
        this.vote = vote;
    }

    public Vote getVote() {
        return vote;
    }

    @Override
    public String toString() {
        return "VotifierEvent{" +
                "vote=" + vote +
                '}';
    }
}
import com.google.inject.Inject;
import com.moandjiezana.toml.Toml;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.event.proxy.ProxyReloadEvent;
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.ProxyServer;
import com.vexsoftware.votifier.VoteHandler;
import com.vexsoftware.votifier.model.Vote;
import com.vexsoftware.votifier.net.VotifierServerBootstrap;
import com.vexsoftware.votifier.net.VotifierSession;
import com.vexsoftware.votifier.net.protocol.v1crypto.RSAIO;
import com.vexsoftware.votifier.net.protocol.v1crypto.RSAKeygen;
import com.vexsoftware.votifier.platform.BackendServer;
import com.vexsoftware.votifier.platform.LoggingAdapter;
import com.vexsoftware.votifier.platform.ProxyVotifierPlugin;
import com.vexsoftware.votifier.platform.scheduler.VotifierScheduler;
import com.vexsoftware.votifier.support.forwarding.ForwardingVoteSource;
import com.vexsoftware.votifier.support.forwarding.ServerFilter;
import com.vexsoftware.votifier.support.forwarding.cache.FileVoteCache;
import com.vexsoftware.votifier.support.forwarding.cache.MemoryVoteCache;
import com.vexsoftware.votifier.support.forwarding.cache.VoteCache;
import com.vexsoftware.votifier.support.forwarding.proxy.ProxyForwardingVoteSource;
import com.vexsoftware.votifier.util.IOUtil;
import com.vexsoftware.votifier.util.KeyCreator;
import com.vexsoftware.votifier.util.TokenUtil;
import com.vexsoftware.votifier.velocity.cmd.NVReloadCmd;
import com.vexsoftware.votifier.velocity.cmd.TestVoteCmd;
import com.vexsoftware.votifier.velocity.event.VotifierEvent;
import org.slf4j.Logger;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.Key;
import java.security.KeyPair;
import java.util.*;
import java.util.stream.Collectors;
package com.vexsoftware.votifier.velocity;



@Plugin(id = "nuvotifier", name = "NuVotifier", version = "@version@", authors = "Ichbinjoe",
        description = "Safe, smart, and secure Votifier server plugin")
public class VotifierPlugin implements VoteHandler, ProxyVotifierPlugin {

    @Inject
    public Logger logger;
    private LoggingAdapter loggingAdapter;

    @Inject
    @DataDirectory
    public Path configDir;

    @Inject
    public ProxyServer server;

    private VotifierScheduler scheduler;