diff --git a/.docker-compose/connect-to-db.sh b/.docker-compose/connect-to-db.sh
new file mode 100755
index 0000000..904ea99
--- /dev/null
+++ b/.docker-compose/connect-to-db.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+# Connect to a locally running database that was started up via docker-compose.
+
+set -eu
+
+DB_IP=$(docker inspect \
+ --format='{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
+ triplea-database-1)
+
+export PGPASSWORD=postgres
+psql -h "$DB_IP" -U postgres
diff --git a/.docker-compose/database/01-init.sql b/.docker-compose/database/01-init.sql
new file mode 100644
index 0000000..d63bf5c
--- /dev/null
+++ b/.docker-compose/database/01-init.sql
@@ -0,0 +1,5 @@
+create user lobby_user password 'lobby';
+create database lobby_db owner lobby_user;
+
+create user error_report_user password 'error_report';
+create database error_report owner error_report_user;
diff --git a/.docker-compose/nginx/default.conf b/.docker-compose/nginx/default.conf
new file mode 100644
index 0000000..b37b111
--- /dev/null
+++ b/.docker-compose/nginx/default.conf
@@ -0,0 +1,57 @@
+server {
+ listen 80;
+ listen [::]:80;
+ server_name localhost;
+
+ location / {
+ proxy_pass http://lobby:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 90;
+ }
+
+ location /game-connection/ws {
+ proxy_pass http://lobby:8080;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+ location /player-connection/ws {
+ proxy_pass http://lobby:8080;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+
+ location /game-support {
+ proxy_pass http://game-support-server:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 90;
+ }
+
+ location /maps {
+ proxy_pass http://maps-server:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 90;
+ }
+}
diff --git a/Dockerfile b/Dockerfile
index b09b037..9e1eec4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1 +1,9 @@
FROM alpine:latest
+
+
+#FROM openjdk:11-jre-slim-buster
+#
+#EXPOSE 8080
+#ADD configuration.yml /
+#ADD build/libs/triplea-dropwizard-server.jar /
+#CMD java -jar triplea-dropwizard-server.jar server /configuration.yml
diff --git a/build.gradle b/build.gradle
index 8becfaa..b6fc335 100644
--- a/build.gradle
+++ b/build.gradle
@@ -7,9 +7,6 @@ plugins {
apply plugin: "com.diffplug.spotless"
-dependencies {
- implementation("triplea:lobby-client:2.6.14756")
-}
repositories {
mavenCentral()
@@ -40,3 +37,55 @@ spotless {
removeUnusedImports()
}
}
+
+subprojects {
+
+ ext {
+ apacheHttpComponentsVersion = "4.5.14"
+ awaitilityVersion = "4.2.1"
+ bcryptVersion = "0.10.2"
+ caffeineVersion = "3.1.8"
+ checkstyleVersion = "8.45"
+ commonsCliVersion = "1.8.0"
+ commonsCodecVersion = "1.17.0"
+ commonsIoVersion = "2.16.1"
+ commonsMathVersion = "3.6.1"
+ commonsTextVersion = "1.12.0"
+ databaseRiderVersion = "1.42.0"
+ dropwizardVersion = "2.1.0"
+ dropwizardWebsocketsVersion = "1.3.14"
+ equalsVerifierVersion = "3.16.1"
+ feignCoreVersion = "13.2.1"
+ feignGsonVersion = "13.2.1"
+ javaWebSocketVersion = "1.5.3"
+ gsonVersion = "2.11.0"
+ guavaVersion = "33.2.1-jre"
+ hamcrestJsonVersion = "0.3"
+ hamcrestOptionalVersion = "2.0.0"
+ hamcrestVersion = "2.0.0.0"
+ jacksonDataTypeVersion = "2.17.1"
+ jakartaMailVersion = "2.0.1"
+ javaWebsocketVersion = "1.5.3"
+ javaxActivationVersion = "1.1.1"
+ jaxbApiVersion = "2.3.1"
+ jaxbCoreVersion = "4.0.5"
+ jaxbImplVersion = "4.0.5"
+ jdbiVersion = "3.45.1"
+ jetbrainsAnnotationsVersion = "24.1.0"
+ jlayerVersion = "1.0.1.4"
+ junitJupiterVersion = "5.10.2"
+ junitPlatformLauncherVersion = "1.10.2"
+ logbackClassicVersion = "1.2.11"
+ mockitoVersion = "5.11.0"
+ openFeignVersion = "13.2.1"
+ postgresqlVersion = "42.7.3"
+ snakeYamlVersion = "2.7"
+ sonatypeGoodiesPrefsVersion = "2.3.9"
+ substanceVersion = "4.5.0"
+ wireMockJunit5Version = "1.3.1"
+ wireMockVersion = "3.0.1"
+ xchartVersion = "3.8.8"
+ xmlUnitCore = "2.10.0"
+ xmlUnitMatchers = "2.10.0"
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..291eae4
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,69 @@
+# This docker-compose file depends on './gradlew shadowJar'
+#
+# Launches all of the background servers used by TripleA.
+# The main entrypoint to those services is NGINX which
+# is listening on localhost:80
+#
+version: '3'
+services:
+ database:
+ image: postgres:10
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: postgres
+ volumes:
+ - ./.docker-compose/database/01-init.sql:/docker-entrypoint-initdb.d/01-init.sql
+ ports:
+ - "5432:5432"
+ healthcheck:
+ test: echo 'select 1' | psql -h localhost -U postgres | grep -q '1 row'
+ interval: 3s
+ retries: 10
+ timeout: 3s
+
+ lobby:
+ build:
+ context: spitfire-server/dropwizard-server/
+ dockerfile: Dockerfile
+ environment:
+ - DATABASE_USER=lobby_user
+ - DATABASE_PASSWORD=lobby
+ - DB_URL=database:5432/lobby_db
+ ports:
+ - "8080"
+ depends_on:
+ - database
+
+ game-support-server:
+ build:
+ context: servers/game-support/server/
+ dockerfile: Dockerfile
+ environment:
+ - DB_URL=database:5432/lobby_db
+ ports:
+ - "8080"
+ depends_on:
+ - database
+
+ maps-server:
+ build:
+ context: servers/maps/server/
+ dockerfile: Dockerfile
+ environment:
+ - DB_URL=database:5432/lobby_db
+ ports:
+ - "8080"
+ depends_on:
+ - database
+
+ nginx:
+ image: nginx:stable-alpine-perl
+ volumes:
+ - ./.docker-compose/nginx/default.conf:/etc/nginx/conf.d/default.conf
+ ports:
+ - "80:80"
+ links:
+ - game-support-server
+ - maps-server
+ - lobby
diff --git a/http-clients/github-client/build.gradle b/http-clients/github-client/build.gradle
new file mode 100644
index 0000000..699c8e8
--- /dev/null
+++ b/http-clients/github-client/build.gradle
@@ -0,0 +1,13 @@
+plugins {
+ id "java"
+}
+
+dependencies {
+ implementation "io.github.openfeign:feign-core:13.2.1"
+ implementation "io.github.openfeign:feign-gson:13.2.1"
+// implementation project(":lib:feign-common")
+// implementation project(":lib:java-extras")
+ testImplementation "ru.lanwen.wiremock:wiremock-junit5:1.3.1"
+ testImplementation "com.github.tomakehurst:wiremock:1.3.1"
+// testImplementation project(":lib:test-common")
+}
diff --git a/http-clients/github-client/src/main/java/org/triplea/http/client/github/BranchInfoResponse.java b/http-clients/github-client/src/main/java/org/triplea/http/client/github/BranchInfoResponse.java
new file mode 100644
index 0000000..f445718
--- /dev/null
+++ b/http-clients/github-client/src/main/java/org/triplea/http/client/github/BranchInfoResponse.java
@@ -0,0 +1,42 @@
+package org.triplea.http.client.github;
+
+import com.google.gson.annotations.SerializedName;
+import java.time.Instant;
+import lombok.AllArgsConstructor;
+import lombok.ToString;
+
+/**
+ * Represents the data returned by github API for their 'branches' endpoint. This class Presents a
+ * simplified interface for what is otherwise a JSON response.
+ */
+@ToString
+@AllArgsConstructor
+public class BranchInfoResponse {
+ @SerializedName("commit")
+ private final LastCommit lastCommit;
+
+ /** Returns the date of the last commit. */
+ public Instant getLastCommitDate() {
+ return Instant.parse(lastCommit.commit.commitDetails.date);
+ }
+
+ @ToString
+ @AllArgsConstructor
+ private static class LastCommit {
+
+ private final Commit commit;
+
+ @ToString
+ @AllArgsConstructor
+ private static class Commit {
+ @SerializedName("author")
+ private final CommitDetails commitDetails;
+
+ @ToString
+ @AllArgsConstructor
+ private static class CommitDetails {
+ private final String date;
+ }
+ }
+ }
+}
diff --git a/http-clients/github-client/src/main/java/org/triplea/http/client/github/CreateIssueRequest.java b/http-clients/github-client/src/main/java/org/triplea/http/client/github/CreateIssueRequest.java
new file mode 100644
index 0000000..b5358cd
--- /dev/null
+++ b/http-clients/github-client/src/main/java/org/triplea/http/client/github/CreateIssueRequest.java
@@ -0,0 +1,31 @@
+package org.triplea.http.client.github;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+import org.triplea.http.client.HttpClientConstants;
+import org.triplea.java.StringUtils;
+
+/** Represents request data to create a github issue. */
+@ToString
+@EqualsAndHashCode
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CreateIssueRequest {
+ private String title;
+ private String body;
+ private String[] labels;
+
+ public String getTitle() {
+ return title == null ? null : StringUtils.truncate(title, HttpClientConstants.TITLE_MAX_LENGTH);
+ }
+
+ public String getBody() {
+ return body == null
+ ? null
+ : StringUtils.truncate(body, HttpClientConstants.REPORT_BODY_MAX_LENGTH);
+ }
+}
diff --git a/http-clients/github-client/src/main/java/org/triplea/http/client/github/CreateIssueResponse.java b/http-clients/github-client/src/main/java/org/triplea/http/client/github/CreateIssueResponse.java
new file mode 100644
index 0000000..509b5fc
--- /dev/null
+++ b/http-clients/github-client/src/main/java/org/triplea/http/client/github/CreateIssueResponse.java
@@ -0,0 +1,15 @@
+package org.triplea.http.client.github;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.ToString;
+
+/** Response JSON object from github after we create a new issue. */
+@ToString
+@AllArgsConstructor
+@Getter
+public class CreateIssueResponse {
+ @SerializedName("html_url")
+ private final String htmlUrl;
+}
diff --git a/http-clients/github-client/src/main/java/org/triplea/http/client/github/GithubApiClient.java b/http-clients/github-client/src/main/java/org/triplea/http/client/github/GithubApiClient.java
new file mode 100644
index 0000000..4d61fc4
--- /dev/null
+++ b/http-clients/github-client/src/main/java/org/triplea/http/client/github/GithubApiClient.java
@@ -0,0 +1,142 @@
+package org.triplea.http.client.github;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import feign.FeignException;
+import java.net.URI;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Optional;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import lombok.Getter;
+import org.slf4j.LoggerFactory;
+import org.triplea.http.client.HttpClient;
+
+/** Can be used to interact with github's webservice API. */
+public class GithubApiClient {
+ /** If this client is set to 'test' mode, we will return a stubbed response. */
+ @VisibleForTesting
+ static final String STUBBED_RETURN_VALUE =
+ "API-token==test--returned-a-stubbed-github-issue-link";
+
+ private final GithubApiFeignClient githubApiFeignClient;
+
+ /**
+ * Flag useful for testing, when set to true no API calls will be made and a hardcoded stubbed
+ * value of {@code STUBBED_RETURN_VALUE} will always be returned.
+ */
+ private final boolean stubbingModeEnabled;
+
+ @Getter private final String org;
+ private final String repo;
+
+ /**
+ * @param uri The URI for githubs webservice API.
+ * @param authToken Auth token that will be sent to Github for webservice calls. Can be empty, but
+ * if specified must be valid (no auth token still works, but rate limits will be more
+ * restrictive).
+ * @param org Name of the github org to be queried.
+ * @param repo Name of the github repository, may be left null if using only 'org' level APIs (EG:
+ * list repositories)
+ * @param stubbingModeEnabled When set to true, stub values will be returned and the github web
+ * API will not actually be contacted.
+ */
+ @Builder
+ public GithubApiClient(
+ @Nonnull URI uri,
+ @Nonnull String authToken,
+ @Nonnull String org,
+ String repo,
+ final boolean stubbingModeEnabled) {
+ githubApiFeignClient =
+ HttpClient.newClient(
+ GithubApiFeignClient.class,
+ uri,
+ Strings.isNullOrEmpty(authToken)
+ ? Map.of()
+ : Map.of("Authorization", "token " + authToken));
+ this.stubbingModeEnabled = stubbingModeEnabled;
+ this.org = org;
+ this.repo = repo;
+ }
+
+ /**
+ * Invokes github web-API to create a github issue with the provided parameter data.
+ *
+ * @param createIssueRequest Upload data for creating the body and title of the github issue.
+ * @return Response from server containing link to the newly created issue.
+ * @throws feign.FeignException thrown on error or if non-2xx response is received
+ */
+ public CreateIssueResponse newIssue(final CreateIssueRequest createIssueRequest) {
+ Preconditions.checkNotNull(repo);
+ if (stubbingModeEnabled) {
+ return new CreateIssueResponse(
+ STUBBED_RETURN_VALUE + String.valueOf(Math.random()).substring(0, 5));
+ }
+
+ return githubApiFeignClient.newIssue(org, repo, createIssueRequest);
+ }
+
+ /**
+ * Returns a listing of the repositories within a github organization. This call handles paging,
+ * it returns a complete list and may perform multiple calls to Github.
+ *
+ *
Example equivalent cUrl call:
+ *
+ *
curl https://api.github.com/orgs/triplea-maps/repos
+ */
+ public Collection listRepositories() {
+ final Collection allRepos = new HashSet<>();
+ int pageNumber = 1;
+ Collection repos = listRepositories(pageNumber);
+ while (!repos.isEmpty()) {
+ pageNumber++;
+ allRepos.addAll(repos);
+ repos = listRepositories(pageNumber);
+ }
+ return allRepos;
+ }
+
+ private Collection listRepositories(int pageNumber) {
+ final Map queryParams = new HashMap<>();
+ queryParams.put("per_page", "100");
+ queryParams.put("page", String.valueOf(pageNumber));
+
+ return githubApiFeignClient.listRepos(queryParams, org);
+ }
+
+ /**
+ * Fetches details of a specific branch on a specific repo. Useful for retrieving info about the
+ * last commit to the repo. Note, the repo listing contains a 'last_push' date, but this method
+ * should be used instead as the last_push date on a repo can be for any branch (even PRs).
+ *
+ * Example equivalent cUrl:
+ * https://api.github.com/repos/triplea-maps/star_wars_galactic_war/branches/master
+ *
+ * @param branch Which branch to be queried.
+ * @return Payload response object representing the response from Github's web API.
+ */
+ public BranchInfoResponse fetchBranchInfo(String branch) {
+ return fetchBranchInfo(repo, branch);
+ }
+
+ public BranchInfoResponse fetchBranchInfo(String repo, String branch) {
+ Preconditions.checkNotNull(repo);
+ return githubApiFeignClient.getBranchInfo(org, repo, branch);
+ }
+
+ public Optional fetchLatestVersion() {
+ Preconditions.checkNotNull(repo);
+ try {
+ return Optional.of(githubApiFeignClient.getLatestRelease(org, repo).getTagName());
+ } catch (final FeignException e) {
+ LoggerFactory.getLogger(GithubApiClient.class)
+ .warn("No data received from server for latest engine version", e);
+ return Optional.empty();
+ }
+ }
+}
diff --git a/http-clients/github-client/src/main/java/org/triplea/http/client/github/GithubApiFeignClient.java b/http-clients/github-client/src/main/java/org/triplea/http/client/github/GithubApiFeignClient.java
new file mode 100644
index 0000000..cc4e957
--- /dev/null
+++ b/http-clients/github-client/src/main/java/org/triplea/http/client/github/GithubApiFeignClient.java
@@ -0,0 +1,38 @@
+package org.triplea.http.client.github;
+
+import com.google.common.annotations.VisibleForTesting;
+import feign.FeignException;
+import feign.Param;
+import feign.QueryMap;
+import feign.RequestLine;
+import java.util.List;
+import java.util.Map;
+
+@SuppressWarnings("InterfaceNeverImplemented")
+interface GithubApiFeignClient {
+
+ @VisibleForTesting String CREATE_ISSUE_PATH = "/repos/{org}/{repo}/issues";
+ @VisibleForTesting String LIST_REPOS_PATH = "/orgs/{org}/repos";
+ @VisibleForTesting String BRANCHES_PATH = "/repos/{org}/{repo}/branches/{branch}";
+ @VisibleForTesting String LATEST_RELEASE_PATH = "/repos/{org}/{repo}/releases/latest";
+
+ /**
+ * Creates a new issue on github.com.
+ *
+ * @throws FeignException Thrown on non-2xx responses.
+ */
+ @RequestLine("POST " + CREATE_ISSUE_PATH)
+ CreateIssueResponse newIssue(
+ @Param("org") String org, @Param("repo") String repo, CreateIssueRequest createIssueRequest);
+
+ @RequestLine("GET " + LIST_REPOS_PATH)
+ List listRepos(
+ @QueryMap Map queryParams, @Param("org") String org);
+
+ @RequestLine("GET " + BRANCHES_PATH)
+ BranchInfoResponse getBranchInfo(
+ @Param("org") String org, @Param("repo") String repo, @Param("branch") String branch);
+
+ @RequestLine("GET " + LATEST_RELEASE_PATH)
+ LatestReleaseResponse getLatestRelease(@Param("org") String org, @Param("repo") String repo);
+}
diff --git a/http-clients/github-client/src/main/java/org/triplea/http/client/github/LatestReleaseResponse.java b/http-clients/github-client/src/main/java/org/triplea/http/client/github/LatestReleaseResponse.java
new file mode 100644
index 0000000..2c7fed2
--- /dev/null
+++ b/http-clients/github-client/src/main/java/org/triplea/http/client/github/LatestReleaseResponse.java
@@ -0,0 +1,12 @@
+package org.triplea.http.client.github;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class LatestReleaseResponse {
+ @SerializedName("tag_name")
+ String tagName;
+}
diff --git a/http-clients/github-client/src/main/java/org/triplea/http/client/github/MapRepoListing.java b/http-clients/github-client/src/main/java/org/triplea/http/client/github/MapRepoListing.java
new file mode 100644
index 0000000..e0322bd
--- /dev/null
+++ b/http-clients/github-client/src/main/java/org/triplea/http/client/github/MapRepoListing.java
@@ -0,0 +1,25 @@
+package org.triplea.http.client.github;
+
+import com.google.gson.annotations.SerializedName;
+import java.net.URI;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+
+/** Response object from Github listing the details of an organization's repositories. */
+@ToString
+@AllArgsConstructor
+@EqualsAndHashCode
+@Builder
+public class MapRepoListing {
+ @SerializedName("html_url")
+ String htmlUrl;
+
+ @Getter String name;
+
+ public URI getUri() {
+ return URI.create(htmlUrl);
+ }
+}
diff --git a/http-clients/github-client/src/test/java/org/triplea/http/client/github/GithubApiClientTest.java b/http-clients/github-client/src/test/java/org/triplea/http/client/github/GithubApiClientTest.java
new file mode 100644
index 0000000..a3612cc
--- /dev/null
+++ b/http-clients/github-client/src/test/java/org/triplea/http/client/github/GithubApiClientTest.java
@@ -0,0 +1,124 @@
+package org.triplea.http.client.github;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsCollectionContaining.hasItem;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import java.net.URI;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.Collection;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.test.common.TestDataFileReader;
+import ru.lanwen.wiremock.ext.WiremockResolver;
+import ru.lanwen.wiremock.ext.WiremockUriResolver;
+
+@ExtendWith({WiremockResolver.class, WiremockUriResolver.class})
+class GithubApiClientTest {
+
+ @Test
+ void repoListing(@WiremockResolver.Wiremock final WireMockServer server) {
+ stubRepoListingResponse(
+ 1,
+ server,
+ TestDataFileReader.readContents("sample_responses/repo_listing_response_page1.json"));
+ stubRepoListingResponse(
+ 2,
+ server,
+ TestDataFileReader.readContents("sample_responses/repo_listing_response_page2.json"));
+ stubRepoListingResponse(3, server, "[]");
+
+ final Collection repos =
+ GithubApiClient.builder()
+ .org("example-org")
+ .repo("map-repo")
+ .uri(URI.create(server.baseUrl()))
+ .build()
+ .listRepositories();
+
+ assertThat(repos, hasSize(3));
+ assertThat(
+ repos,
+ hasItem(
+ MapRepoListing.builder()
+ .htmlUrl("https://github.com/triplea-maps/tutorial")
+ .name("tutorial")
+ .build()));
+ assertThat(
+ repos,
+ hasItem(
+ MapRepoListing.builder()
+ .htmlUrl("https://github.com/triplea-maps/aa_enhanced_revised")
+ .name("aa_enhanced_revised")
+ .build()));
+ assertThat(
+ repos,
+ hasItem(
+ MapRepoListing.builder()
+ .htmlUrl("https://github.com/triplea-maps/roman_invasion")
+ .name("roman_invasion")
+ .build()));
+ }
+
+ private void stubRepoListingResponse(
+ final int expectedPageNumber, final WireMockServer server, final String response) {
+ server.stubFor(
+ get("/orgs/example-org/repos?per_page=100&page=" + expectedPageNumber)
+ .willReturn(aResponse().withStatus(200).withBody(response)));
+ }
+
+ @Test
+ @DisplayName("Invoke branches API and verify we can retrieve last commit date")
+ void branchListingResponseFetchLastCommitDate(
+ @WiremockResolver.Wiremock final WireMockServer server) {
+ final String exampleResponse =
+ TestDataFileReader.readContents("sample_responses/branch_listing_response.json");
+ server.stubFor(
+ get("/repos/example-org/map-repo/branches/master")
+ .withHeader("Authorization", equalTo("token test-token"))
+ .willReturn(aResponse().withStatus(200).withBody(exampleResponse)));
+
+ final BranchInfoResponse branchInfoResponse =
+ GithubApiClient.builder()
+ .authToken("test-token")
+ .org("example-org")
+ .repo("map-repo")
+ .uri(URI.create(server.baseUrl()))
+ .build()
+ .fetchBranchInfo("master");
+
+ final Instant expectedLastCommitDate =
+ LocalDateTime.of(2021, 2, 4, 19, 30, 32).atOffset(ZoneOffset.UTC).toInstant();
+ assertThat(branchInfoResponse.getLastCommitDate(), is(expectedLastCommitDate));
+ }
+
+ @Test
+ void getLatestRelease(@WiremockResolver.Wiremock final WireMockServer server) {
+ final String exampleResponse =
+ TestDataFileReader.readContents("sample_responses/latest_release_response.json");
+ server.stubFor(
+ get("/repos/example-org/map-repo/releases/latest")
+ .withHeader("Authorization", equalTo("token test-token"))
+ .willReturn(aResponse().withStatus(200).withBody(exampleResponse)));
+
+ final String latestVersion =
+ GithubApiClient.builder()
+ .authToken("test-token")
+ .org("example-org")
+ .repo("map-repo")
+ .uri(URI.create(server.baseUrl()))
+ .build()
+ .fetchLatestVersion()
+ .orElseThrow();
+
+ assertThat(latestVersion, is("2.5.22294"));
+ }
+}
diff --git a/http-clients/github-client/src/test/resources/logback-test.xml b/http-clients/github-client/src/test/resources/logback-test.xml
new file mode 100644
index 0000000..2222e34
--- /dev/null
+++ b/http-clients/github-client/src/test/resources/logback-test.xml
@@ -0,0 +1,11 @@
+
+
+
+ %d [%thread] %logger{36} %-5level: %msg%n
+
+
+
+
+
+
+
diff --git a/http-clients/github-client/src/test/resources/sample_responses/branch_listing_response.json b/http-clients/github-client/src/test/resources/sample_responses/branch_listing_response.json
new file mode 100644
index 0000000..9460576
--- /dev/null
+++ b/http-clients/github-client/src/test/resources/sample_responses/branch_listing_response.json
@@ -0,0 +1,97 @@
+{
+ "name": "master",
+ "commit": {
+ "sha": "acbae06cf21433af89be31e72acfee1995bf43f3",
+ "node_id": "MDY6Q29tbWl0NTA5MTExMzU6YWNiYWUwNmNmMjE0MzNhZjg5YmUzMWU3MmFjZmVlMTk5NWJmNDNmMw==",
+ "commit": {
+ "author": {
+ "name": "authorName",
+ "email": "email@gmail.com",
+ "date": "2021-02-04T19:30:32Z"
+ },
+ "committer": {
+ "name": "authorName",
+ "email": "email@gmail.com",
+ "date": "2021-02-04T19:30:32Z"
+ },
+ "message": "Add description.html file",
+ "tree": {
+ "sha": "29eba65784a2ea4da212945ecbc3f147647118ea",
+ "url": "https://api.github.com/repos/triplea-maps/star_wars_galactic_war/git/trees/29eba65784a2ea4da212945ecbc3f147647118ea"
+ },
+ "url": "https://api.github.com/repos/triplea-maps/star_wars_galactic_war/git/commits/acbae06cf21433af89be31e72acfee1995bf43f3",
+ "comment_count": 0,
+ "verification": {
+ "verified": false,
+ "reason": "unsigned",
+ "signature": null,
+ "payload": null
+ }
+ },
+ "url": "https://api.github.com/repos/triplea-maps/star_wars_galactic_war/commits/acbae06cf21433af89be31e72acfee1995bf43f3",
+ "html_url": "https://github.com/triplea-maps/star_wars_galactic_war/commit/acbae06cf21433af89be31e72acfee1995bf43f3",
+ "comments_url": "https://api.github.com/repos/triplea-maps/star_wars_galactic_war/commits/acbae06cf21433af89be31e72acfee1995bf43f3/comments",
+ "author": {
+ "login": "authorName",
+ "id": 12397753,
+ "node_id": "MDQ6VXNlcjEyMzk3NzUz",
+ "avatar_url": "https://avatars.githubusercontent.com/u/1239?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/authorName",
+ "html_url": "https://github.com/authorName",
+ "followers_url": "https://api.github.com/users/authorName/followers",
+ "following_url": "https://api.github.com/users/authorName/following{/other_user}",
+ "gists_url": "https://api.github.com/users/authorName/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/authorName/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/authorName/subscriptions",
+ "organizations_url": "https://api.github.com/users/authorName/orgs",
+ "repos_url": "https://api.github.com/users/authorName/repos",
+ "events_url": "https://api.github.com/users/authorName/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/authorName/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "committer": {
+ "login": "authorName",
+ "id": 12390000,
+ "node_id": "MDQ6VXNlcjEyMzk3NzUz",
+ "avatar_url": "https://avatars.githubusercontent.com/u/12397753?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/authorName",
+ "html_url": "https://github.com/authorName",
+ "followers_url": "https://api.github.com/users/authorName/followers",
+ "following_url": "https://api.github.com/users/authorName/following{/other_user}",
+ "gists_url": "https://api.github.com/users/authorName/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/authorName/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/authorName/subscriptions",
+ "organizations_url": "https://api.github.com/users/authorName/orgs",
+ "repos_url": "https://api.github.com/users/authorName/repos",
+ "events_url": "https://api.github.com/users/authorName/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/authorName/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "parents": [
+ {
+ "sha": "151fb26082cd58e5a0c997beee41011461480b9b",
+ "url": "https://api.github.com/repos/triplea-maps/star_wars_galactic_war/commits/151fb26082cd58e5a0c997beee41011461480b9b",
+ "html_url": "https://github.com/triplea-maps/star_wars_galactic_war/commit/151fb26082cd58e5a0c997beee41011461480b9b"
+ }
+ ]
+ },
+ "_links": {
+ "self": "https://api.github.com/repos/triplea-maps/star_wars_galactic_war/branches/master",
+ "html": "https://github.com/triplea-maps/star_wars_galactic_war/tree/master"
+ },
+ "protected": false,
+ "protection": {
+ "enabled": false,
+ "required_status_checks": {
+ "enforcement_level": "off",
+ "contexts": [
+
+ ]
+ }
+ },
+ "protection_url": "https://api.github.com/repos/triplea-maps/star_wars_galactic_war/branches/master/protection"
+}
diff --git a/http-clients/github-client/src/test/resources/sample_responses/latest_release_response.json b/http-clients/github-client/src/test/resources/sample_responses/latest_release_response.json
new file mode 100644
index 0000000..7f6781a
--- /dev/null
+++ b/http-clients/github-client/src/test/resources/sample_responses/latest_release_response.json
@@ -0,0 +1,346 @@
+{
+ "url": "https://api.github.com/repos/triplea-game/triplea/releases/33335898",
+ "assets_url": "https://api.github.com/repos/triplea-game/triplea/releases/33335898/assets",
+ "upload_url": "https://uploads.github.com/repos/triplea-game/triplea/releases/33335898/assets{?name,label}",
+ "html_url": "https://github.com/triplea-game/triplea/releases/tag/2.5.22294",
+ "id": 33335898,
+ "author": {
+ "login": "tripleabuilderbot",
+ "id": 14932076,
+ "node_id": "MDQ6VXNlcjE0OTMyMDc2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14932076?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/tripleabuilderbot",
+ "html_url": "https://github.com/tripleabuilderbot",
+ "followers_url": "https://api.github.com/users/tripleabuilderbot/followers",
+ "following_url": "https://api.github.com/users/tripleabuilderbot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/tripleabuilderbot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/tripleabuilderbot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/tripleabuilderbot/subscriptions",
+ "organizations_url": "https://api.github.com/users/tripleabuilderbot/orgs",
+ "repos_url": "https://api.github.com/users/tripleabuilderbot/repos",
+ "events_url": "https://api.github.com/users/tripleabuilderbot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/tripleabuilderbot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "node_id": "MDc6UmVsZWFzZTMzMzM1ODk4",
+ "tag_name": "2.5.22294",
+ "target_commitish": "f516043a9d4ae146de4a6305165ce3a686a92a32",
+ "name": "",
+ "draft": false,
+ "prerelease": false,
+ "created_at": "2020-11-02T05:11:01Z",
+ "published_at": "2020-11-02T05:12:47Z",
+ "assets": [
+ {
+ "url": "https://api.github.com/repos/triplea-game/triplea/releases/assets/27820580",
+ "id": 27820580,
+ "node_id": "MDEyOlJlbGVhc2VBc3NldDI3ODIwNTgw",
+ "name": "migrations.zip",
+ "label": "",
+ "uploader": {
+ "login": "tripleabuilderbot",
+ "id": 14932076,
+ "node_id": "MDQ6VXNlcjE0OTMyMDc2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14932076?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/tripleabuilderbot",
+ "html_url": "https://github.com/tripleabuilderbot",
+ "followers_url": "https://api.github.com/users/tripleabuilderbot/followers",
+ "following_url": "https://api.github.com/users/tripleabuilderbot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/tripleabuilderbot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/tripleabuilderbot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/tripleabuilderbot/subscriptions",
+ "organizations_url": "https://api.github.com/users/tripleabuilderbot/orgs",
+ "repos_url": "https://api.github.com/users/tripleabuilderbot/repos",
+ "events_url": "https://api.github.com/users/tripleabuilderbot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/tripleabuilderbot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "content_type": "application/zip",
+ "state": "uploaded",
+ "size": 7420,
+ "download_count": 66,
+ "created_at": "2020-11-02T05:12:44Z",
+ "updated_at": "2020-11-02T05:12:45Z",
+ "browser_download_url": "https://github.com/triplea-game/triplea/releases/download/2.5.22294/migrations.zip"
+ },
+ {
+ "url": "https://api.github.com/repos/triplea-game/triplea/releases/assets/27820570",
+ "id": 27820570,
+ "node_id": "MDEyOlJlbGVhc2VBc3NldDI3ODIwNTcw",
+ "name": "triplea-game-headed-2.5.22294.zip",
+ "label": "",
+ "uploader": {
+ "login": "tripleabuilderbot",
+ "id": 14932076,
+ "node_id": "MDQ6VXNlcjE0OTMyMDc2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14932076?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/tripleabuilderbot",
+ "html_url": "https://github.com/tripleabuilderbot",
+ "followers_url": "https://api.github.com/users/tripleabuilderbot/followers",
+ "following_url": "https://api.github.com/users/tripleabuilderbot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/tripleabuilderbot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/tripleabuilderbot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/tripleabuilderbot/subscriptions",
+ "organizations_url": "https://api.github.com/users/tripleabuilderbot/orgs",
+ "repos_url": "https://api.github.com/users/tripleabuilderbot/repos",
+ "events_url": "https://api.github.com/users/tripleabuilderbot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/tripleabuilderbot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "content_type": "application/zip",
+ "state": "uploaded",
+ "size": 26094498,
+ "download_count": 223,
+ "created_at": "2020-11-02T05:12:25Z",
+ "updated_at": "2020-11-02T05:12:26Z",
+ "browser_download_url": "https://github.com/triplea-game/triplea/releases/download/2.5.22294/triplea-game-headed-2.5.22294.zip"
+ },
+ {
+ "url": "https://api.github.com/repos/triplea-game/triplea/releases/assets/27820575",
+ "id": 27820575,
+ "node_id": "MDEyOlJlbGVhc2VBc3NldDI3ODIwNTc1",
+ "name": "triplea-game-headless-2.5.22294.zip",
+ "label": "",
+ "uploader": {
+ "login": "tripleabuilderbot",
+ "id": 14932076,
+ "node_id": "MDQ6VXNlcjE0OTMyMDc2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14932076?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/tripleabuilderbot",
+ "html_url": "https://github.com/tripleabuilderbot",
+ "followers_url": "https://api.github.com/users/tripleabuilderbot/followers",
+ "following_url": "https://api.github.com/users/tripleabuilderbot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/tripleabuilderbot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/tripleabuilderbot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/tripleabuilderbot/subscriptions",
+ "organizations_url": "https://api.github.com/users/tripleabuilderbot/orgs",
+ "repos_url": "https://api.github.com/users/tripleabuilderbot/repos",
+ "events_url": "https://api.github.com/users/tripleabuilderbot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/tripleabuilderbot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "content_type": "application/zip",
+ "state": "uploaded",
+ "size": 18797934,
+ "download_count": 105,
+ "created_at": "2020-11-02T05:12:38Z",
+ "updated_at": "2020-11-02T05:12:41Z",
+ "browser_download_url": "https://github.com/triplea-game/triplea/releases/download/2.5.22294/triplea-game-headless-2.5.22294.zip"
+ },
+ {
+ "url": "https://api.github.com/repos/triplea-game/triplea/releases/assets/27820573",
+ "id": 27820573,
+ "node_id": "MDEyOlJlbGVhc2VBc3NldDI3ODIwNTcz",
+ "name": "triplea-lobby-server-2.5.22294.zip",
+ "label": "",
+ "uploader": {
+ "login": "tripleabuilderbot",
+ "id": 14932076,
+ "node_id": "MDQ6VXNlcjE0OTMyMDc2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14932076?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/tripleabuilderbot",
+ "html_url": "https://github.com/tripleabuilderbot",
+ "followers_url": "https://api.github.com/users/tripleabuilderbot/followers",
+ "following_url": "https://api.github.com/users/tripleabuilderbot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/tripleabuilderbot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/tripleabuilderbot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/tripleabuilderbot/subscriptions",
+ "organizations_url": "https://api.github.com/users/tripleabuilderbot/orgs",
+ "repos_url": "https://api.github.com/users/tripleabuilderbot/repos",
+ "events_url": "https://api.github.com/users/tripleabuilderbot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/tripleabuilderbot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "content_type": "application/zip",
+ "state": "uploaded",
+ "size": 23701788,
+ "download_count": 55,
+ "created_at": "2020-11-02T05:12:35Z",
+ "updated_at": "2020-11-02T05:12:38Z",
+ "browser_download_url": "https://github.com/triplea-game/triplea/releases/download/2.5.22294/triplea-lobby-server-2.5.22294.zip"
+ },
+ {
+ "url": "https://api.github.com/repos/triplea-game/triplea/releases/assets/27820582",
+ "id": 27820582,
+ "node_id": "MDEyOlJlbGVhc2VBc3NldDI3ODIwNTgy",
+ "name": "triplea-maps-server-2.5.22294.zip",
+ "label": "",
+ "uploader": {
+ "login": "tripleabuilderbot",
+ "id": 14932076,
+ "node_id": "MDQ6VXNlcjE0OTMyMDc2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14932076?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/tripleabuilderbot",
+ "html_url": "https://github.com/tripleabuilderbot",
+ "followers_url": "https://api.github.com/users/tripleabuilderbot/followers",
+ "following_url": "https://api.github.com/users/tripleabuilderbot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/tripleabuilderbot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/tripleabuilderbot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/tripleabuilderbot/subscriptions",
+ "organizations_url": "https://api.github.com/users/tripleabuilderbot/orgs",
+ "repos_url": "https://api.github.com/users/tripleabuilderbot/repos",
+ "events_url": "https://api.github.com/users/tripleabuilderbot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/tripleabuilderbot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "content_type": "application/zip",
+ "state": "uploaded",
+ "size": 21665863,
+ "download_count": 47,
+ "created_at": "2020-11-02T05:12:46Z",
+ "updated_at": "2020-11-02T05:12:46Z",
+ "browser_download_url": "https://github.com/triplea-game/triplea/releases/download/2.5.22294/triplea-maps-server-2.5.22294.zip"
+ },
+ {
+ "url": "https://api.github.com/repos/triplea-game/triplea/releases/assets/27820572",
+ "id": 27820572,
+ "node_id": "MDEyOlJlbGVhc2VBc3NldDI3ODIwNTcy",
+ "name": "TripleA_2.5.22294_macos.dmg",
+ "label": "",
+ "uploader": {
+ "login": "tripleabuilderbot",
+ "id": 14932076,
+ "node_id": "MDQ6VXNlcjE0OTMyMDc2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14932076?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/tripleabuilderbot",
+ "html_url": "https://github.com/tripleabuilderbot",
+ "followers_url": "https://api.github.com/users/tripleabuilderbot/followers",
+ "following_url": "https://api.github.com/users/tripleabuilderbot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/tripleabuilderbot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/tripleabuilderbot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/tripleabuilderbot/subscriptions",
+ "organizations_url": "https://api.github.com/users/tripleabuilderbot/orgs",
+ "repos_url": "https://api.github.com/users/tripleabuilderbot/repos",
+ "events_url": "https://api.github.com/users/tripleabuilderbot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/tripleabuilderbot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "content_type": "application/x-apple-diskimage",
+ "state": "uploaded",
+ "size": 58909491,
+ "download_count": 6229,
+ "created_at": "2020-11-02T05:12:33Z",
+ "updated_at": "2020-11-02T05:12:34Z",
+ "browser_download_url": "https://github.com/triplea-game/triplea/releases/download/2.5.22294/TripleA_2.5.22294_macos.dmg"
+ },
+ {
+ "url": "https://api.github.com/repos/triplea-game/triplea/releases/assets/27820571",
+ "id": 27820571,
+ "node_id": "MDEyOlJlbGVhc2VBc3NldDI3ODIwNTcx",
+ "name": "TripleA_2.5.22294_unix.sh",
+ "label": "",
+ "uploader": {
+ "login": "tripleabuilderbot",
+ "id": 14932076,
+ "node_id": "MDQ6VXNlcjE0OTMyMDc2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14932076?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/tripleabuilderbot",
+ "html_url": "https://github.com/tripleabuilderbot",
+ "followers_url": "https://api.github.com/users/tripleabuilderbot/followers",
+ "following_url": "https://api.github.com/users/tripleabuilderbot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/tripleabuilderbot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/tripleabuilderbot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/tripleabuilderbot/subscriptions",
+ "organizations_url": "https://api.github.com/users/tripleabuilderbot/orgs",
+ "repos_url": "https://api.github.com/users/tripleabuilderbot/repos",
+ "events_url": "https://api.github.com/users/tripleabuilderbot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/tripleabuilderbot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "content_type": "application/x-sh",
+ "state": "uploaded",
+ "size": 64495298,
+ "download_count": 3696,
+ "created_at": "2020-11-02T05:12:28Z",
+ "updated_at": "2020-11-02T05:12:32Z",
+ "browser_download_url": "https://github.com/triplea-game/triplea/releases/download/2.5.22294/TripleA_2.5.22294_unix.sh"
+ },
+ {
+ "url": "https://api.github.com/repos/triplea-game/triplea/releases/assets/27820567",
+ "id": 27820567,
+ "node_id": "MDEyOlJlbGVhc2VBc3NldDI3ODIwNTY3",
+ "name": "TripleA_2.5.22294_windows-32bit.exe",
+ "label": "",
+ "uploader": {
+ "login": "tripleabuilderbot",
+ "id": 14932076,
+ "node_id": "MDQ6VXNlcjE0OTMyMDc2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14932076?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/tripleabuilderbot",
+ "html_url": "https://github.com/tripleabuilderbot",
+ "followers_url": "https://api.github.com/users/tripleabuilderbot/followers",
+ "following_url": "https://api.github.com/users/tripleabuilderbot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/tripleabuilderbot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/tripleabuilderbot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/tripleabuilderbot/subscriptions",
+ "organizations_url": "https://api.github.com/users/tripleabuilderbot/orgs",
+ "repos_url": "https://api.github.com/users/tripleabuilderbot/repos",
+ "events_url": "https://api.github.com/users/tripleabuilderbot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/tripleabuilderbot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "content_type": "application/x-ms-dos-executable",
+ "state": "uploaded",
+ "size": 64250880,
+ "download_count": 2227,
+ "created_at": "2020-11-02T05:12:22Z",
+ "updated_at": "2020-11-02T05:12:24Z",
+ "browser_download_url": "https://github.com/triplea-game/triplea/releases/download/2.5.22294/TripleA_2.5.22294_windows-32bit.exe"
+ },
+ {
+ "url": "https://api.github.com/repos/triplea-game/triplea/releases/assets/27820579",
+ "id": 27820579,
+ "node_id": "MDEyOlJlbGVhc2VBc3NldDI3ODIwNTc5",
+ "name": "TripleA_2.5.22294_windows-64bit.exe",
+ "label": "",
+ "uploader": {
+ "login": "tripleabuilderbot",
+ "id": 14932076,
+ "node_id": "MDQ6VXNlcjE0OTMyMDc2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14932076?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/tripleabuilderbot",
+ "html_url": "https://github.com/tripleabuilderbot",
+ "followers_url": "https://api.github.com/users/tripleabuilderbot/followers",
+ "following_url": "https://api.github.com/users/tripleabuilderbot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/tripleabuilderbot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/tripleabuilderbot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/tripleabuilderbot/subscriptions",
+ "organizations_url": "https://api.github.com/users/tripleabuilderbot/orgs",
+ "repos_url": "https://api.github.com/users/tripleabuilderbot/repos",
+ "events_url": "https://api.github.com/users/tripleabuilderbot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/tripleabuilderbot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "content_type": "application/x-ms-dos-executable",
+ "state": "uploaded",
+ "size": 63516160,
+ "download_count": 30564,
+ "created_at": "2020-11-02T05:12:42Z",
+ "updated_at": "2020-11-02T05:12:44Z",
+ "browser_download_url": "https://github.com/triplea-game/triplea/releases/download/2.5.22294/TripleA_2.5.22294_windows-64bit.exe"
+ }
+ ],
+ "tarball_url": "https://api.github.com/repos/triplea-game/triplea/tarball/2.5.22294",
+ "zipball_url": "https://api.github.com/repos/triplea-game/triplea/zipball/2.5.22294",
+ "body": ""
+}
diff --git a/http-clients/github-client/src/test/resources/sample_responses/repo_listing_response_page1.json b/http-clients/github-client/src/test/resources/sample_responses/repo_listing_response_page1.json
new file mode 100644
index 0000000..c7e33d3
--- /dev/null
+++ b/http-clients/github-client/src/test/resources/sample_responses/repo_listing_response_page1.json
@@ -0,0 +1,200 @@
+[
+ {
+ "id": 43531223,
+ "node_id": "MDEwOlJlcG9zaXRvcnk0MzUzMTIyMw==",
+ "name": "tutorial",
+ "full_name": "triplea-maps/tutorial",
+ "private": false,
+ "owner": {
+ "login": "triplea-maps",
+ "id": 14303309,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjE0MzAzMzA5",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14303309?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/triplea-maps",
+ "html_url": "https://github.com/triplea-maps",
+ "followers_url": "https://api.github.com/users/triplea-maps/followers",
+ "following_url": "https://api.github.com/users/triplea-maps/following{/other_user}",
+ "gists_url": "https://api.github.com/users/triplea-maps/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/triplea-maps/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/triplea-maps/subscriptions",
+ "organizations_url": "https://api.github.com/users/triplea-maps/orgs",
+ "repos_url": "https://api.github.com/users/triplea-maps/repos",
+ "events_url": "https://api.github.com/users/triplea-maps/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/triplea-maps/received_events",
+ "type": "Organization",
+ "site_admin": false
+ },
+ "html_url": "https://github.com/triplea-maps/tutorial",
+ "description": null,
+ "fork": false,
+ "url": "https://api.github.com/repos/triplea-maps/tutorial",
+ "forks_url": "https://api.github.com/repos/triplea-maps/tutorial/forks",
+ "keys_url": "https://api.github.com/repos/triplea-maps/tutorial/keys{/key_id}",
+ "collaborators_url": "https://api.github.com/repos/triplea-maps/tutorial/collaborators{/collaborator}",
+ "teams_url": "https://api.github.com/repos/triplea-maps/tutorial/teams",
+ "hooks_url": "https://api.github.com/repos/triplea-maps/tutorial/hooks",
+ "issue_events_url": "https://api.github.com/repos/triplea-maps/tutorial/issues/events{/number}",
+ "events_url": "https://api.github.com/repos/triplea-maps/tutorial/events",
+ "assignees_url": "https://api.github.com/repos/triplea-maps/tutorial/assignees{/user}",
+ "branches_url": "https://api.github.com/repos/triplea-maps/tutorial/branches{/branch}",
+ "tags_url": "https://api.github.com/repos/triplea-maps/tutorial/tags",
+ "blobs_url": "https://api.github.com/repos/triplea-maps/tutorial/git/blobs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/triplea-maps/tutorial/git/tags{/sha}",
+ "git_refs_url": "https://api.github.com/repos/triplea-maps/tutorial/git/refs{/sha}",
+ "trees_url": "https://api.github.com/repos/triplea-maps/tutorial/git/trees{/sha}",
+ "statuses_url": "https://api.github.com/repos/triplea-maps/tutorial/statuses/{sha}",
+ "languages_url": "https://api.github.com/repos/triplea-maps/tutorial/languages",
+ "stargazers_url": "https://api.github.com/repos/triplea-maps/tutorial/stargazers",
+ "contributors_url": "https://api.github.com/repos/triplea-maps/tutorial/contributors",
+ "subscribers_url": "https://api.github.com/repos/triplea-maps/tutorial/subscribers",
+ "subscription_url": "https://api.github.com/repos/triplea-maps/tutorial/subscription",
+ "commits_url": "https://api.github.com/repos/triplea-maps/tutorial/commits{/sha}",
+ "git_commits_url": "https://api.github.com/repos/triplea-maps/tutorial/git/commits{/sha}",
+ "comments_url": "https://api.github.com/repos/triplea-maps/tutorial/comments{/number}",
+ "issue_comment_url": "https://api.github.com/repos/triplea-maps/tutorial/issues/comments{/number}",
+ "contents_url": "https://api.github.com/repos/triplea-maps/tutorial/contents/{+path}",
+ "compare_url": "https://api.github.com/repos/triplea-maps/tutorial/compare/{base}...{head}",
+ "merges_url": "https://api.github.com/repos/triplea-maps/tutorial/merges",
+ "archive_url": "https://api.github.com/repos/triplea-maps/tutorial/{archive_format}{/ref}",
+ "downloads_url": "https://api.github.com/repos/triplea-maps/tutorial/downloads",
+ "issues_url": "https://api.github.com/repos/triplea-maps/tutorial/issues{/number}",
+ "pulls_url": "https://api.github.com/repos/triplea-maps/tutorial/pulls{/number}",
+ "milestones_url": "https://api.github.com/repos/triplea-maps/tutorial/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/triplea-maps/tutorial/notifications{?since,all,participating}",
+ "labels_url": "https://api.github.com/repos/triplea-maps/tutorial/labels{/name}",
+ "releases_url": "https://api.github.com/repos/triplea-maps/tutorial/releases{/id}",
+ "deployments_url": "https://api.github.com/repos/triplea-maps/tutorial/deployments",
+ "created_at": "2015-10-02T01:54:08Z",
+ "updated_at": "2021-02-04T19:31:35Z",
+ "pushed_at": "2021-02-04T19:31:31Z",
+ "git_url": "git://github.com/triplea-maps/tutorial.git",
+ "ssh_url": "git@github.com:triplea-maps/tutorial.git",
+ "clone_url": "https://github.com/triplea-maps/tutorial.git",
+ "svn_url": "https://github.com/triplea-maps/tutorial",
+ "homepage": null,
+ "size": 45829,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "language": "HTML",
+ "has_issues": true,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_wiki": true,
+ "has_pages": false,
+ "forks_count": 1,
+ "mirror_url": null,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 0,
+ "license": null,
+ "forks": 1,
+ "open_issues": 0,
+ "watchers": 0,
+ "default_branch": "master",
+ "permissions": {
+ "admin": false,
+ "push": false,
+ "pull": true
+ }
+ },
+ {
+ "id": 50909601,
+ "node_id": "MDEwOlJlcG9zaXRvcnk1MDkwOTYwMQ==",
+ "name": "aa_enhanced_revised",
+ "full_name": "triplea-maps/aa_enhanced_revised",
+ "private": false,
+ "owner": {
+ "login": "triplea-maps",
+ "id": 14303309,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjE0MzAzMzA5",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14303309?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/triplea-maps",
+ "html_url": "https://github.com/triplea-maps",
+ "followers_url": "https://api.github.com/users/triplea-maps/followers",
+ "following_url": "https://api.github.com/users/triplea-maps/following{/other_user}",
+ "gists_url": "https://api.github.com/users/triplea-maps/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/triplea-maps/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/triplea-maps/subscriptions",
+ "organizations_url": "https://api.github.com/users/triplea-maps/orgs",
+ "repos_url": "https://api.github.com/users/triplea-maps/repos",
+ "events_url": "https://api.github.com/users/triplea-maps/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/triplea-maps/received_events",
+ "type": "Organization",
+ "site_admin": false
+ },
+ "html_url": "https://github.com/triplea-maps/aa_enhanced_revised",
+ "description": null,
+ "fork": false,
+ "url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised",
+ "forks_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/forks",
+ "keys_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/keys{/key_id}",
+ "collaborators_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/collaborators{/collaborator}",
+ "teams_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/teams",
+ "hooks_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/hooks",
+ "issue_events_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/issues/events{/number}",
+ "events_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/events",
+ "assignees_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/assignees{/user}",
+ "branches_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/branches{/branch}",
+ "tags_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/tags",
+ "blobs_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/git/blobs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/git/tags{/sha}",
+ "git_refs_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/git/refs{/sha}",
+ "trees_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/git/trees{/sha}",
+ "statuses_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/statuses/{sha}",
+ "languages_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/languages",
+ "stargazers_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/stargazers",
+ "contributors_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/contributors",
+ "subscribers_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/subscribers",
+ "subscription_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/subscription",
+ "commits_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/commits{/sha}",
+ "git_commits_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/git/commits{/sha}",
+ "comments_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/comments{/number}",
+ "issue_comment_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/issues/comments{/number}",
+ "contents_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/contents/{+path}",
+ "compare_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/compare/{base}...{head}",
+ "merges_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/merges",
+ "archive_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/{archive_format}{/ref}",
+ "downloads_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/downloads",
+ "issues_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/issues{/number}",
+ "pulls_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/pulls{/number}",
+ "milestones_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/notifications{?since,all,participating}",
+ "labels_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/labels{/name}",
+ "releases_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/releases{/id}",
+ "deployments_url": "https://api.github.com/repos/triplea-maps/aa_enhanced_revised/deployments",
+ "created_at": "2016-02-02T09:33:26Z",
+ "updated_at": "2016-02-02T09:33:26Z",
+ "pushed_at": "2017-12-14T04:37:33Z",
+ "git_url": "git://github.com/triplea-maps/aa_enhanced_revised.git",
+ "ssh_url": "git@github.com:triplea-maps/aa_enhanced_revised.git",
+ "clone_url": "https://github.com/triplea-maps/aa_enhanced_revised.git",
+ "svn_url": "https://github.com/triplea-maps/aa_enhanced_revised",
+ "homepage": null,
+ "size": 380,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "language": null,
+ "has_issues": true,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_wiki": true,
+ "has_pages": false,
+ "forks_count": 0,
+ "mirror_url": null,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 0,
+ "license": null,
+ "forks": 0,
+ "open_issues": 0,
+ "watchers": 0,
+ "default_branch": "master",
+ "permissions": {
+ "admin": false,
+ "push": false,
+ "pull": true
+ }
+ }
+]
diff --git a/http-clients/github-client/src/test/resources/sample_responses/repo_listing_response_page2.json b/http-clients/github-client/src/test/resources/sample_responses/repo_listing_response_page2.json
new file mode 100644
index 0000000..01ca1b9
--- /dev/null
+++ b/http-clients/github-client/src/test/resources/sample_responses/repo_listing_response_page2.json
@@ -0,0 +1,101 @@
+[
+ {
+ "id": 50909644,
+ "node_id": "MDEwOlJlcG9zaXRvcnk1MDkwOTY0NA==",
+ "name": "roman_invasion",
+ "full_name": "triplea-maps/roman_invasion",
+ "private": false,
+ "owner": {
+ "login": "triplea-maps",
+ "id": 14303309,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjE0MzAzMzA5",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14303309?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/triplea-maps",
+ "html_url": "https://github.com/triplea-maps",
+ "followers_url": "https://api.github.com/users/triplea-maps/followers",
+ "following_url": "https://api.github.com/users/triplea-maps/following{/other_user}",
+ "gists_url": "https://api.github.com/users/triplea-maps/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/triplea-maps/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/triplea-maps/subscriptions",
+ "organizations_url": "https://api.github.com/users/triplea-maps/orgs",
+ "repos_url": "https://api.github.com/users/triplea-maps/repos",
+ "events_url": "https://api.github.com/users/triplea-maps/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/triplea-maps/received_events",
+ "type": "Organization",
+ "site_admin": false
+ },
+ "html_url": "https://github.com/triplea-maps/roman_invasion",
+ "description": null,
+ "fork": false,
+ "url": "https://api.github.com/repos/triplea-maps/roman_invasion",
+ "forks_url": "https://api.github.com/repos/triplea-maps/roman_invasion/forks",
+ "keys_url": "https://api.github.com/repos/triplea-maps/roman_invasion/keys{/key_id}",
+ "collaborators_url": "https://api.github.com/repos/triplea-maps/roman_invasion/collaborators{/collaborator}",
+ "teams_url": "https://api.github.com/repos/triplea-maps/roman_invasion/teams",
+ "hooks_url": "https://api.github.com/repos/triplea-maps/roman_invasion/hooks",
+ "issue_events_url": "https://api.github.com/repos/triplea-maps/roman_invasion/issues/events{/number}",
+ "events_url": "https://api.github.com/repos/triplea-maps/roman_invasion/events",
+ "assignees_url": "https://api.github.com/repos/triplea-maps/roman_invasion/assignees{/user}",
+ "branches_url": "https://api.github.com/repos/triplea-maps/roman_invasion/branches{/branch}",
+ "tags_url": "https://api.github.com/repos/triplea-maps/roman_invasion/tags",
+ "blobs_url": "https://api.github.com/repos/triplea-maps/roman_invasion/git/blobs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/triplea-maps/roman_invasion/git/tags{/sha}",
+ "git_refs_url": "https://api.github.com/repos/triplea-maps/roman_invasion/git/refs{/sha}",
+ "trees_url": "https://api.github.com/repos/triplea-maps/roman_invasion/git/trees{/sha}",
+ "statuses_url": "https://api.github.com/repos/triplea-maps/roman_invasion/statuses/{sha}",
+ "languages_url": "https://api.github.com/repos/triplea-maps/roman_invasion/languages",
+ "stargazers_url": "https://api.github.com/repos/triplea-maps/roman_invasion/stargazers",
+ "contributors_url": "https://api.github.com/repos/triplea-maps/roman_invasion/contributors",
+ "subscribers_url": "https://api.github.com/repos/triplea-maps/roman_invasion/subscribers",
+ "subscription_url": "https://api.github.com/repos/triplea-maps/roman_invasion/subscription",
+ "commits_url": "https://api.github.com/repos/triplea-maps/roman_invasion/commits{/sha}",
+ "git_commits_url": "https://api.github.com/repos/triplea-maps/roman_invasion/git/commits{/sha}",
+ "comments_url": "https://api.github.com/repos/triplea-maps/roman_invasion/comments{/number}",
+ "issue_comment_url": "https://api.github.com/repos/triplea-maps/roman_invasion/issues/comments{/number}",
+ "contents_url": "https://api.github.com/repos/triplea-maps/roman_invasion/contents/{+path}",
+ "compare_url": "https://api.github.com/repos/triplea-maps/roman_invasion/compare/{base}...{head}",
+ "merges_url": "https://api.github.com/repos/triplea-maps/roman_invasion/merges",
+ "archive_url": "https://api.github.com/repos/triplea-maps/roman_invasion/{archive_format}{/ref}",
+ "downloads_url": "https://api.github.com/repos/triplea-maps/roman_invasion/downloads",
+ "issues_url": "https://api.github.com/repos/triplea-maps/roman_invasion/issues{/number}",
+ "pulls_url": "https://api.github.com/repos/triplea-maps/roman_invasion/pulls{/number}",
+ "milestones_url": "https://api.github.com/repos/triplea-maps/roman_invasion/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/triplea-maps/roman_invasion/notifications{?since,all,participating}",
+ "labels_url": "https://api.github.com/repos/triplea-maps/roman_invasion/labels{/name}",
+ "releases_url": "https://api.github.com/repos/triplea-maps/roman_invasion/releases{/id}",
+ "deployments_url": "https://api.github.com/repos/triplea-maps/roman_invasion/deployments",
+ "created_at": "2016-02-02T09:33:59Z",
+ "updated_at": "2016-02-02T09:33:59Z",
+ "pushed_at": "2017-12-14T04:28:01Z",
+ "git_url": "git://github.com/triplea-maps/roman_invasion.git",
+ "ssh_url": "git@github.com:triplea-maps/roman_invasion.git",
+ "clone_url": "https://github.com/triplea-maps/roman_invasion.git",
+ "svn_url": "https://github.com/triplea-maps/roman_invasion",
+ "homepage": null,
+ "size": 512,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "language": null,
+ "has_issues": true,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_wiki": true,
+ "has_pages": false,
+ "forks_count": 0,
+ "mirror_url": null,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 0,
+ "license": null,
+ "forks": 0,
+ "open_issues": 0,
+ "watchers": 0,
+ "default_branch": "master",
+ "permissions": {
+ "admin": false,
+ "push": false,
+ "pull": true
+ }
+ }
+]
diff --git a/server/README-WIP.md b/server/README-WIP.md
new file mode 100644
index 0000000..855c4c3
--- /dev/null
+++ b/server/README-WIP.md
@@ -0,0 +1,12 @@
+/servers folder is a WIP
+
+The folder will contain a subdirectory for each server that we will be deploying.
+
+Currently we have things bundled as largely just one server.
+
+This folder is not yet used, it is a start of an effort to break this up and re-organize
+the code a bit to be yet more modular.
+
+Further, with very specific sub-folders and more executables, we will have more opportunity
+to do continuous deployments on any update without having to worry for example about
+disconnecting everyone from chat.
diff --git a/server/README.md b/server/README.md
new file mode 100644
index 0000000..5ca91fa
--- /dev/null
+++ b/server/README.md
@@ -0,0 +1,26 @@
+## Lobby Server
+
+### Background
+
+Lobby-Server is a 'new' server to host lobby and other functionalities. Historically this was
+powered by a pure java stack that used java sockets (NIO). The java server was written very early
+in the project, mid-2000s, the 'http-server' allows for a modern (2019) server to be used.
+The modern server has integration with JDBI, annotation based rate limiting, authentication and
+affords an opportunity to rewrite the lobby server in a simpler and more modular fashion.
+
+### Authentication
+
+Connecting to the server, on success, will issue an API key back to the client. Subsequent
+interactions from the client with the server will send the API key to server for further
+authorization. Keep in mind all endpoints are publicly available.
+
+### Communication Directions - Http to Server & Websocket to Client
+
+Communication to server is done via standard Http endpoints. Server will process these messages
+triggering event listeners that will communicate back to clients via websocket.
+
+### Keep-Alive
+
+This concept is to avoid 'ghosts' when we fail to process a disconnect. Players and connected games
+will need to send HTTP requests to a keep-alive endpoint to explicitly 'register' their liveness. When
+these messages are not received after a cut-off period, then the game or player are removed.
diff --git a/server/database-test-support/build.gradle b/server/database-test-support/build.gradle
new file mode 100644
index 0000000..fce41d2
--- /dev/null
+++ b/server/database-test-support/build.gradle
@@ -0,0 +1,10 @@
+plugins {
+ id "java"
+}
+
+dependencies {
+ implementation "com.github.database-rider:rider-junit5:$databaseRiderVersion"
+ implementation "org.jdbi:jdbi3-core:$jdbiVersion"
+ implementation "org.jdbi:jdbi3-sqlobject:$jdbiVersion"
+ implementation "org.junit.jupiter:junit-jupiter-api:$junitJupiterVersion"
+}
diff --git a/server/database-test-support/src/main/java/org/triplea/spitfire/database/DatabaseTestSupport.java b/server/database-test-support/src/main/java/org/triplea/spitfire/database/DatabaseTestSupport.java
new file mode 100644
index 0000000..8e66fc1
--- /dev/null
+++ b/server/database-test-support/src/main/java/org/triplea/spitfire/database/DatabaseTestSupport.java
@@ -0,0 +1,18 @@
+package org.triplea.spitfire.database;
+
+public abstract class DatabaseTestSupport extends DbRiderTestExtension {
+ @Override
+ protected String getDatabaseUser() {
+ return "lobby_user";
+ }
+
+ @Override
+ protected String getDatabasePassword() {
+ return "lobby";
+ }
+
+ @Override
+ protected String getDatabaseUrl() {
+ return "jdbc:postgresql://localhost:5432/lobby_db";
+ }
+}
diff --git a/server/database-test-support/src/main/java/org/triplea/spitfire/database/DbRiderTestExtension.java b/server/database-test-support/src/main/java/org/triplea/spitfire/database/DbRiderTestExtension.java
new file mode 100644
index 0000000..4d089dd
--- /dev/null
+++ b/server/database-test-support/src/main/java/org/triplea/spitfire/database/DbRiderTestExtension.java
@@ -0,0 +1,101 @@
+package org.triplea.spitfire.database;
+
+import com.github.database.rider.junit5.DBUnitExtension;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import org.jdbi.v3.core.Jdbi;
+import org.jdbi.v3.core.mapper.RowMapperFactory;
+import org.jdbi.v3.sqlobject.SqlObjectPlugin;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.BeforeEachCallback;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ParameterContext;
+import org.junit.jupiter.api.extension.ParameterResolutionException;
+import org.junit.jupiter.api.extension.ParameterResolver;
+
+/**
+ * Extend this class to more easily test database with DbRider. Once extended, a test can then use
+ * DbRider '@DataSet' annotations to inject data into database. After each test executes, the
+ * database is wiped clean.
+ *
+ * Second, this extension will constructor or test method parameter inject any classes that can
+ * be instantiated via a 'Jdbi.onDemand(class)' or 'new DaoClass(jdbi)'.
+ */
+@ExtendWith(DBUnitExtension.class)
+public abstract class DbRiderTestExtension
+ implements BeforeAllCallback, BeforeEachCallback, ParameterResolver {
+
+ private static Jdbi jdbi;
+
+ protected abstract String getDatabaseUser();
+
+ protected abstract String getDatabasePassword();
+
+ protected abstract String getDatabaseUrl();
+
+ /** Return all row mappers that should be registered with JDBI. */
+ protected abstract Collection rowMappers();
+
+ @Override
+ public void beforeAll(final ExtensionContext context) {
+ if (jdbi == null) {
+ jdbi = Jdbi.create(getDatabaseUrl(), getDatabaseUser(), getDatabasePassword());
+ jdbi.installPlugin(new SqlObjectPlugin());
+ rowMappers().forEach(jdbi::registerRowMapper);
+ }
+ }
+
+ @Override
+ public void beforeEach(final ExtensionContext context) throws Exception {
+ final URL cleanupFileUrl = getClass().getClassLoader().getResource("db-cleanup.sql");
+ if (cleanupFileUrl != null) {
+ final String cleanupSql = Files.readString(Path.of(cleanupFileUrl.toURI()));
+ jdbi.withHandle(handle -> handle.execute(cleanupSql));
+ }
+ }
+
+ @Override
+ public boolean supportsParameter(
+ final ParameterContext parameterContext, final ExtensionContext extensionContext)
+ throws ParameterResolutionException {
+
+ // check if there is a one-arg (JDBI) constructor
+ try {
+ parameterContext.getParameter().getType().getConstructor(Jdbi.class);
+ return true;
+ } catch (final NoSuchMethodException e) {
+ // no-op, object is constructed potentially another way
+ }
+ try {
+ jdbi.onDemand(parameterContext.getParameter().getType());
+ return true;
+ } catch (final IllegalArgumentException ignored) {
+ return false;
+ }
+ }
+
+ @Override
+ public Object resolveParameter(
+ final ParameterContext parameterContext, final ExtensionContext extensionContext)
+ throws ParameterResolutionException {
+
+ // try to create the class using constructor that accepts one Jdbi
+ try {
+ final Constructor> constructor =
+ parameterContext.getParameter().getType().getConstructor(Jdbi.class);
+ return constructor.newInstance(jdbi);
+ } catch (final NoSuchMethodException
+ | InvocationTargetException
+ | IllegalAccessException
+ | InstantiationException e) {
+ // no-op, object is constructed via 'jdbi.onDemand'
+ }
+
+ return jdbi.onDemand(parameterContext.getParameter().getType());
+ }
+}
diff --git a/server/database/.dockerignore b/server/database/.dockerignore
new file mode 100644
index 0000000..7a8b76c
--- /dev/null
+++ b/server/database/.dockerignore
@@ -0,0 +1,2 @@
+*
+!src/
diff --git a/server/database/README.md b/server/database/README.md
new file mode 100644
index 0000000..8f8719b
--- /dev/null
+++ b/server/database/README.md
@@ -0,0 +1,24 @@
+# Database
+
+Hosts database migrations and docker to run a database locally.
+
+Stores:
+ - users
+ - lobby chat history
+ - user ban information
+ - moderator audit logs
+ - bug report history and rate limits
+ - uploaded map information
+
+For more information see: [database documentation](/docs/development/database/)
+
+## Working with database locally
+
+- install docker
+- run: `./spitfire-server/database/start_docker_db`
+- connect to DB with: `./spitfire-server/database/connect_to_docker_db`
+
+## Example data
+
+The example data inserted into a local docker will create an admin user
+named "test" with password "test".
diff --git a/server/database/build.gradle b/server/database/build.gradle
new file mode 100644
index 0000000..6c88120
--- /dev/null
+++ b/server/database/build.gradle
@@ -0,0 +1,40 @@
+import org.flywaydb.gradle.task.*
+
+buildscript {
+ dependencies {
+ classpath "org.postgresql:postgresql:$postgresqlVersion"
+ }
+}
+
+plugins {
+ id "org.flywaydb.flyway" version "9.22.3"
+}
+
+flyway {
+ driver = "org.postgresql.Driver"
+}
+
+task flywayMigrateLobbyDb(type: FlywayMigrateTask) {
+ url= "jdbc:postgresql://localhost:5432/lobby_db"
+ user = "lobby_user"
+ password = "lobby"
+ locations = ["filesystem:src/main/resources/db/migration/lobby_db"]
+}
+
+task flywayMigrateErrorReportDb(type: FlywayMigrateTask) {
+ url= "jdbc:postgresql://localhost:5432/error_report"
+ user = "error_report_user"
+ password = "error_report"
+ locations = ["filesystem:src/main/resources/db/migration/error_report"]
+}
+
+task portableInstaller(type: Zip, group: "release") {
+ from "src/main/resources/db/migration/"
+ archiveFileName = "migrations.zip"
+}
+
+task release(group: "release", dependsOn: portableInstaller) {
+ doLast {
+ publishArtifacts(portableInstaller.outputs.files)
+ }
+}
diff --git a/server/database/connect_to_docker_db b/server/database/connect_to_docker_db
new file mode 100755
index 0000000..69b3697
--- /dev/null
+++ b/server/database/connect_to_docker_db
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+# Simple helper script to connect to a DB running locally on docker.
+export PGPASSWORD=postgres
+psql -h localhost -U postgres
+
+# after connecting, use "\l" to print the list of database
+# Use "\c " to connect to a database
+# After connecting to a database, use "\d" to list the tables in the current database
diff --git a/server/database/load_sample_data b/server/database/load_sample_data
new file mode 100755
index 0000000..25f6ec0
--- /dev/null
+++ b/server/database/load_sample_data
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+bold="\e[1m"
+bold_green="${bold}\e[32m"
+normal="\e[0m"
+
+echo -e "[${bold_green}Inserting sample data${normal}]"
+
+export PGPASSWORD=postgres
+psql -h localhost -U postgres lobby_db \
+ < "$(dirname "$0")/sql/sample_data/lobby_db_sample_data.sql"
diff --git a/server/database/reset_docker_db b/server/database/reset_docker_db
new file mode 100755
index 0000000..61c638a
--- /dev/null
+++ b/server/database/reset_docker_db
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+set -eu
+
+PSQL="psql -h localhost -U postgres"
+
+export PGPASSWORD=postgres
+
+# Check if we can connect to database
+if ! echo 'select 1' \
+ | $PSQL 2> /dev/null \
+ | grep -q '1 row';
+then
+ echo "ERROR: docker not running, start the database first"
+ exit 1
+fi
+
+echo "Force killing open connections to database"
+echo "select pg_terminate_backend(pid) from pg_stat_activity where datname='lobby_db';" | $PSQL
+echo "drop database lobby_db" | $PSQL
+
+scriptDir="$(dirname "$0")"
+$PSQL < "$scriptDir/sql/init/02-create-databases.sql"
+
+echo "Deploying schema"
+"$scriptDir/../../gradlew" flywayMigrateLobbyDb
+
+"$scriptDir/load_sample_data"
diff --git a/server/database/sql/init/01-create-users.sql b/server/database/sql/init/01-create-users.sql
new file mode 100644
index 0000000..5e67ff9
--- /dev/null
+++ b/server/database/sql/init/01-create-users.sql
@@ -0,0 +1,2 @@
+create user lobby_user password 'lobby';
+create user error_report_user password 'error_report';
diff --git a/server/database/sql/init/02-create-databases.sql b/server/database/sql/init/02-create-databases.sql
new file mode 100644
index 0000000..508136b
--- /dev/null
+++ b/server/database/sql/init/02-create-databases.sql
@@ -0,0 +1,2 @@
+create database lobby_db owner lobby_user;
+create database error_report owner error_report_user;
diff --git a/server/database/sql/sample_data/lobby_db_sample_data.sql b/server/database/sql/sample_data/lobby_db_sample_data.sql
new file mode 100644
index 0000000..4b08dcb
--- /dev/null
+++ b/server/database/sql/sample_data/lobby_db_sample_data.sql
@@ -0,0 +1,115 @@
+delete
+from temp_password_request;
+delete
+from moderator_action_history;
+delete
+from lobby_api_key;
+delete
+from game_hosting_api_key;
+delete
+from lobby_user;
+delete
+from access_log;
+delete
+from user_role;
+delete
+from map_tag_value;
+delete
+from map_index;
+delete
+from map_tag_allowed_value;
+delete
+from map_tag;
+
+
+insert into user_role(id, name)
+values (1, 'ADMIN'), -- user can add/remove admins and add/remove moderators, has boot/ban privileges
+ (2, 'MODERATOR'), -- user has boot/ban privileges
+ (3, 'PLAYER'), -- standard registered user
+ (4, 'ANONYMOUS'), -- users that are not registered, they do not have an entry in lobby_user table
+ (5, 'HOST'); -- AKA LobbyWatcher, special connection for hosts to send game updates to lobby
+
+insert into lobby_user(id, username, email, user_role_id, bcrypt_password)
+values (1000, 'test', 'email@email.com', (select id from user_role where name = 'ADMIN'),
+ '$2a$10$Ut3tvElEhPPr4s5wPd4dFuOvY25fa4r5XH3T7ucFTr5gJsotZl5d6'), -- password = 'test'
+ (1001, 'user1', 'email@email.com', (select id from user_role where name = 'PLAYER'),
+ '$2a$10$C4rHfjK/seKexc6KlyknP.oFVBZ7Wi.kp91qUQFgmkKajwgczXzcS');
+
+insert into access_log(access_time, username, ip, system_id, lobby_user_id)
+values (now() - interval '1 days', 'user1', '1.1.1.1', 'system-id1', null),
+ (now() - interval '2 days', 'user2', '1.1.1.2', 'system-id2', null),
+ (now() - interval '3 days', 'user3', '1.1.1.3', 'system-id3', null),
+ (now() - interval '4 days', 'user1', '1.1.1.2', 'system-id4', null),
+ (now() - interval '5 days', 'user2', '1.1.1.4', 'system-id5', null);
+
+insert into moderator_action_history(lobby_user_id, action_name, action_target)
+values (1000, 'ACTION_1', 'TARGET_1'),
+ (1000, 'ACTION_2', 'TARGET_2'),
+ (1000, 'ACTION_3', 'TARGET_3'),
+ (1000, 'ACTION_4', 'TARGET_4'),
+ (1000, 'ACTION_5', 'TARGET_5'),
+ (1000, 'ACTION_6', 'TARGET_6'),
+ (1000, 'ACTION_7', 'TARGET_7'),
+ (1000, 'ACTION_8', 'TARGET_8'),
+ (1000, 'ACTION_9', 'TARGET_9'),
+ (1000, 'ACTION_10', 'TARGET_10'),
+ (1000, 'ACTION_11', 'TARGET_11'),
+ (1000, 'ACTION_12', 'TARGET_12'),
+ (1000, 'ACTION_13', 'TARGET_13'),
+ (1000, 'ACTION_14', 'TARGET_14'),
+ (1000, 'ACTION_15', 'TARGET_15'),
+ (1000, 'ACTION_16', 'TARGET_16'),
+ (1000, 'ACTION_17', 'TARGET_17'),
+ (1000, 'ACTION_18', 'TARGET_18'),
+ (1000, 'ACTION_19', 'TARGET_19'),
+ (1000, 'ACTION_20', 'TARGET_20'),
+ (1000, 'ACTION_21', 'TARGET_21'),
+ (1000, 'ACTION_22', 'TARGET_22'),
+ (1000, 'ACTION_23', 'TARGET_23'),
+ (1000, 'ACTION_24', 'TARGET_24'),
+ (1000, 'ACTION_25', 'TARGET_25'),
+ (1000, 'ACTION_26', 'TARGET_26'),
+ (1000, 'ACTION_27', 'TARGET_27'),
+ (1000, 'ACTION_28', 'TARGET_28'),
+ (1000, 'ACTION_29', 'TARGET_29'),
+ (1000, 'ACTION_30', 'TARGET_30'),
+ (1000, 'ACTION_31', 'TARGET_31'),
+ (1000, 'ACTION_32', 'TARGET_32'),
+ (1000, 'ACTION_33', 'TARGET_33'),
+ (1000, 'ACTION_34', 'TARGET_34'),
+ (1000, 'ACTION_35', 'TARGET_35'),
+ (1000, 'ACTION_36', 'TARGET_36'),
+ (1000, 'ACTION_37', 'TARGET_37'),
+ (1000, 'ACTION_38', 'TARGET_38'),
+ (1000, 'ACTION_39', 'TARGET_39'),
+ (1000, 'ACTION_40', 'TARGET_40'),
+ (1000, 'ACTION_41', 'TARGET_41'),
+ (1000, 'ACTION_42', 'TARGET_42'),
+ (1000, 'ACTION_43', 'TARGET_43'),
+ (1000, 'ACTION_44', 'TARGET_44'),
+ (1000, 'ACTION_45', 'TARGET_45'),
+ (1000, 'ACTION_46', 'TARGET_46'),
+ (1000, 'ACTION_47', 'TARGET_47'),
+ (1000, 'ACTION_48', 'TARGET_48'),
+ (1000, 'ACTION_49', 'TARGET_49'),
+ (1000, 'ACTION_50', 'TARGET_50');
+
+insert into map_index
+(id, map_name, last_commit_date, repo_url,
+ description, download_size_bytes, download_url,
+ preview_image_url)
+values (1, 'test-map', now() - interval '1 days', 'http://repo-url',
+ 'map description', 1024, 'http://download-url',
+ 'http://preview-image');
+
+insert into map_tag (id, name, display_order)
+values (1000, 'Rating', 1),
+ (2000, 'Category', 2);
+
+insert into map_tag_allowed_value (id, map_tag_id, value)
+values (300, 1000, '1'),
+ (301, 1000, '2'),
+ (302, 1000, '3'),
+ (400, 2000, 'Complete'),
+ (401, 2000, 'Awesome');
+
diff --git a/server/database/src/main/resources/db/migration/error_report/V1.00.00__create_tables.sql b/server/database/src/main/resources/db/migration/error_report/V1.00.00__create_tables.sql
new file mode 100644
index 0000000..f0bef3d
--- /dev/null
+++ b/server/database/src/main/resources/db/migration/error_report/V1.00.00__create_tables.sql
@@ -0,0 +1,43 @@
+create table error_report_history
+(
+ id serial primary key,
+ user_ip character varying(30) not null,
+ date_created timestamp not null default now()
+);
+alter table error_report_history owner to error_report_user;
+comment on table error_report_history is
+ $$Table that stores timestamps by user IP address of when error reports were created. Used to do rate limiting.$$;
+comment on column error_report_history.user_ip is 'IP address of a user that has submitted an error report';
+comment on column error_report_history.date_created is 'Timestamp when error report was created in DB';
+
+
+alter table error_report_history
+ add column system_id varchar(36);
+alter table error_report_history
+ add column report_title varchar(125);
+alter table error_report_history
+ add column game_version varchar(16);
+alter table error_report_history
+ add column created_issue_link varchar(128);
+
+update error_report_history
+set system_id = '---' || random(),
+ report_title = '---' || random(),
+ game_version = '2.0.20234',
+ created_issue_link = '----' || random();
+
+alter table error_report_history
+ alter column system_id set not null;
+alter table error_report_history
+ alter column report_title set not null;
+alter table error_report_history
+ alter column game_version set not null;
+alter table error_report_history
+ alter column created_issue_link set not null;
+
+-- the title and version pair should be unique, otherwise we are looking at a duplicate
+alter table error_report_history
+ add constraint error_report_history_unique_title_version unique (report_title, game_version);
+-- all links should be unique
+alter table error_report_history
+ add constraint error_report_history_unique_link unique (created_issue_link);
diff --git a/server/database/src/main/resources/db/migration/lobby_db/V2.00.00__lobby.sql b/server/database/src/main/resources/db/migration/lobby_db/V2.00.00__lobby.sql
new file mode 100644
index 0000000..5879b74
--- /dev/null
+++ b/server/database/src/main/resources/db/migration/lobby_db/V2.00.00__lobby.sql
@@ -0,0 +1,224 @@
+comment on database lobby_db is 'Database used by lobby';
+
+create table bad_word
+(
+ word character varying(40) not null primary key,
+ date_created timestamp without time zone not null default now()
+);
+alter table bad_word
+ owner to lobby_user;
+comment on table bad_word is 'A table representing a blacklist of words, usernames may not contain these words.';
+
+
+create table banned_username
+(
+ username character varying(40) not null primary key,
+ date_created timestamptz not null default now()
+);
+alter table banned_username
+ owner to lobby_user;
+comment on table banned_username is 'A Table storing a username blacklilst.';
+comment on column banned_username.username is 'The blacklisted username';
+
+create table user_role
+(
+ id int primary key,
+ name character varying(16) not null unique
+);
+
+comment on table user_role is 'Table storing the names of the different user (authentication) roles.';
+insert into user_role(id, name)
+values (1, 'ADMIN'), -- user can add/remove admins and add/remove moderators, has boot/ban privileges
+ (2, 'MODERATOR'), -- user has boot/ban privileges
+ (3, 'PLAYER'), -- standard registered user
+ (4, 'ANONYMOUS'), -- users that are not registered, they do not have an entry in lobby_user table
+ (5, 'HOST'); -- AKA 'Lobby Watcher', special connection for hosts to send game updates to lobby
+
+create table lobby_user
+(
+ id serial primary key,
+ username character varying(40) not null unique,
+ password character varying(60),
+ email character varying(40) not null check (email like '%@%' and email <> ''),
+ date_created timestamptz not null default current_timestamp,
+ last_login timestamptz,
+ user_role_id int not null references user_role (id),
+ bcrypt_password character(60) check (char_length(bcrypt_password) = 60)
+);
+alter table lobby_user
+ owner to lobby_user;
+alter table lobby_user
+ add constraint lobby_user_pass_check check (password IS NOT NULL OR bcrypt_password IS NOT NULL);
+comment on table lobby_user is 'Stores information about Triplea Lobby users.';
+comment on column lobby_user.username is 'Defines the in-game username.';
+comment on column lobby_user.password is
+ $$The legacy MD5Crypt hash of the password. The length of the hash must always be 34 chars.
+ Either password or bcrypt_password must be not null.$$;
+comment on column lobby_user.email is
+ $$Email storage of every user. Large size to match the maximum email length.
+ More information here: https://stackoverflow.com/a/574698.$$;
+comment on column lobby_user.bcrypt_password is
+ $$The BCrypt-Hashed password of the user, should be the same as the md5 password but in another form.
+ The length of the hash must always be 60 chars. Either password or bcrypt_password must be not null.$$;
+
+
+create table access_log
+(
+ access_time timestamptz not null default now(),
+ username varchar(40) not null,
+ ip inet not null,
+ system_id varchar(36) not null,
+ registered boolean not null
+);
+
+alter table access_log
+ owner to lobby_user;
+
+comment on table access_log is
+ $$ Audit table recording access to the lobby. $$;
+
+comment on column access_log.registered is
+ $$ True if the user was registered when accessing the lobby;
+ otherwise false if the user was anonymous$$;
+
+
+create table moderator_action_history
+(
+ id serial primary key,
+ lobby_user_id int not null references lobby_user (id),
+ date_created timestamptz not null default current_timestamp,
+ action_name varchar(64) not null,
+ action_target varchar(40) not null
+);
+alter table moderator_action_history
+ owner to lobby_user;
+comment on table moderator_action_history is 'Table storing an audit history of actions taken by moderators';
+comment on column moderator_action_history.lobby_user_id is 'FK to lobby_user table, this is the moderator that initiated an action.';
+comment on column moderator_action_history.date_created is 'Row creation timestamp, when the action was taken.';
+comment on column moderator_action_history.action_name is 'Specifier of what action the moderator took, eg: ban|mute';
+comment on column moderator_action_history.action_target is 'The target of the action, eg: banned player name, banned IP';
+
+
+create table error_report_history
+(
+ id serial primary key,
+ user_ip character varying(30) not null,
+ date_created timestamp not null default now()
+);
+alter table error_report_history
+ owner to lobby_user;
+comment on table error_report_history is
+ $$Table that stores timestamps by user IP address of when error reports were created. Used to do rate limiting.$$;
+comment on column error_report_history.user_ip is 'IP address of a user that has submitted an error report';
+comment on column error_report_history.date_created is 'Timestamp when error report was created in DB';
+
+
+create table banned_user
+(
+ id serial primary key,
+ public_id varchar(36) not null unique,
+ username varchar(40) not null,
+ system_id varchar(36) not null,
+ ip inet not null,
+ ban_expiry timestamptz not null,
+ date_created timestamptz not null default now()
+);
+alter table banned_user
+ owner to lobby_user;
+
+create index banned_user_ip on banned_user(ip, system_id);
+
+comment on table banned_user is
+ $$ Table that records player bans, when players join lobby we check their IP address and hashed mac
+ against this table. If there there is an IP or mac match, then the user is not allowed to join. $$;
+comment on column banned_user.public_id is
+ $$ A value that publicly identifiers the ban. When a player is rejected from joining lobby we can
+ show them this ID value. If the player wants to dispute the ban, they can give us the public id
+ and we would be able to remove the ban. $$;
+comment on column banned_user.username is
+ $$ Record of the name of the user at the time of ban, used for reference purposes. $$;
+
+
+create table temp_password_request
+(
+ id serial primary key,
+ lobby_user_id int not null references lobby_user (id),
+ temp_password varchar(60) not null,
+ date_created timestamptz not null default now(),
+ date_invalidated timestamptz
+);
+alter table temp_password_request
+ owner to lobby_user;
+comment on table temp_password_request is
+ $$Table that stores temporary passwords issued to players. They are intended to be single use.$$;
+comment on column temp_password_request.lobby_user_id is 'FK to lobby_user table.';
+comment on column temp_password_request.temp_password is 'Temp password value created for user.';
+comment on column temp_password_request.date_created is 'Timestamp of when the ban temporary password was created.';
+comment on column temp_password_request.date_invalidated is
+ $$Timestamp of when the temporary password is either used or marked invalid.
+ A temp password can be marked as invalid if multiple are issued.$$;
+
+
+create table temp_password_request_history
+(
+ id serial primary key,
+ inetaddress inet not null,
+ username varchar(40) not null,
+ date_created timestamptz not null default now()
+);
+alter table temp_password_request_history
+ owner to lobby_user;
+comment on table temp_password_request_history is
+ $$Table that stores requests for temporary passwords for audit purposes. This will let us rate limit requests and
+ prevent a single player from spamming email to many userse.$$;
+comment on column temp_password_request_history.inetaddress is 'IP of the address making the temp password request.';
+comment on column temp_password_request_history.username is 'The requested username for a temp password.';
+comment on column temp_password_request_history.date_created is 'Timestamp of when the temp password request is made';
+create index temp_password_request_history_inet on temp_password_request_history (inetaddress);
+
+
+create table lobby_api_key
+(
+ id serial primary key,
+ username varchar(40) not null,
+ lobby_user_id int references lobby_user (id),
+ user_role_id int not null references user_role (id),
+ player_chat_id varchar(36) not null unique,
+ key varchar(256) not null unique,
+ system_id varchar(36) not null,
+ ip inet not null,
+ date_created timestamptz not null default now()
+);
+
+alter table lobby_api_key
+ owner to lobby_user;
+comment on table lobby_api_key is
+ $$ Table that stores api keys of lobby-connected users along with identifiers. Note, old records
+ are periodically dropped from the table to keep the table length manageable. $$;
+comment on column lobby_api_key.username is
+ $$ The name of the player at the time of login. For registered players, this will match their
+registered name. $$;
+comment on column lobby_api_key.system_id is
+ $$ Identifier that is generated on client system and is passed to the backend. Identifies the
+users devices. $$;
+comment on column lobby_api_key.player_chat_id is
+ $$ Identifier assigned to the player when they enter chat, used as a unique identifier
+to reference a player that has joined the lobby. This value is public facing and is sent
+to the front-end so that moderators can send the value back when requesting users to be banned. $$;
+comment on column lobby_api_key.key is
+ $$ API key value granted to a user. Users are allowed to have multiple API-keys if they
+login in multiple times. $$;
+
+
+create table game_hosting_api_key
+(
+ id serial primary key,
+ key character varying(256) not null unique,
+ ip inet not null,
+ date_created timestamptz not null default now()
+);
+alter table game_hosting_api_key
+ owner to lobby_user;
+comment on table game_hosting_api_key is
+ $$ Table dedicated for storing "Lobby Watcher" api-keys. Game hosting connections create a dedicated
+ connection to the lobby with their own API key $$;
diff --git a/server/database/src/main/resources/db/migration/lobby_db/V2.00.01__add_chat_history.sql b/server/database/src/main/resources/db/migration/lobby_db/V2.00.01__add_chat_history.sql
new file mode 100644
index 0000000..e7a016e
--- /dev/null
+++ b/server/database/src/main/resources/db/migration/lobby_db/V2.00.01__add_chat_history.sql
@@ -0,0 +1,23 @@
+create table lobby_chat_history
+(
+ id serial primary key,
+ date timestamp without time zone not null default now(),
+ username character varying(40) not null,
+ lobby_api_key_id int not null references lobby_api_key (id),
+ message character varying(240) not null
+);
+
+comment on table lobby_chat_history is
+ $$Table recording history of all chat messages in the lobby$$;
+comment on column lobby_chat_history.username is
+ $$De-normalized column representing the name of the user that
+ sent the chat message. It is denormalized for query convenience.$$;
+comment on column lobby_chat_history.lobby_api_key_id is
+ $$Foreign key reference to the API-key used by the user when logging in. Useful to
+ know the users IP and system id information for cross-reference.$$;
+
+comment on column lobby_chat_history.message is
+ $$The contents of the chat message sent by the user$$;
+
+create index lobby_chat_date on lobby_chat_history (date);
+create index lobby_chat_username on lobby_chat_history (username);
diff --git a/server/database/src/main/resources/db/migration/lobby_db/V2.00.02__game_and_player_history.sql b/server/database/src/main/resources/db/migration/lobby_db/V2.00.02__game_and_player_history.sql
new file mode 100644
index 0000000..bd1643d
--- /dev/null
+++ b/server/database/src/main/resources/db/migration/lobby_db/V2.00.02__game_and_player_history.sql
@@ -0,0 +1,39 @@
+create table lobby_game
+(
+ id serial primary key,
+ host_name character varying(40) not null,
+ game_id character varying(36) not null,
+ game_hosting_api_key_id int not null references game_hosting_api_key (id),
+ date_created timestamp without time zone not null default now()
+);
+
+comment on table lobby_game is
+ $$Records games that have been posted to the lobby$$;
+comment on column lobby_game.host_name is
+ $$Name of the game host, can be a bot or a player name$$;
+comment on column lobby_game.game_id is
+ $$ID of the game as assigned by the lobby. 'game_id' is a publicly known field and is sent to players.
+ A single game can have multiple game-ids if it is disconnected and reconnects.$$;
+
+create index lobby_game_host_name_game_id on lobby_game (host_name, game_id);
+
+
+create table game_chat_history
+(
+ id serial primary key,
+ lobby_game_id int not null references lobby_game (id),
+ date timestamp without time zone not null default now(),
+ username character varying(40) not null,
+ message character varying(240) not null
+);
+
+comment on table game_chat_history is
+ $$Table recording history of chat messages in games connected to the lobby.
+ Of note, players can connect directly to lobby games and may not necessarily have an API key.$$;
+comment on column game_chat_history.username is
+ $$The name of the player sending a chat message$$;
+comment on column game_chat_history.message is
+ $$Contents of the chat message sent$$;
+
+create index game_chat_history_username on game_chat_history (username);
+create index game_chat_history_date on game_chat_history (date);
diff --git a/server/database/src/main/resources/db/migration/lobby_db/V2.00.03__change_game_history_index.sql b/server/database/src/main/resources/db/migration/lobby_db/V2.00.03__change_game_history_index.sql
new file mode 100644
index 0000000..1172700
--- /dev/null
+++ b/server/database/src/main/resources/db/migration/lobby_db/V2.00.03__change_game_history_index.sql
@@ -0,0 +1,11 @@
+drop index lobby_game_host_name_game_id;
+create index lobby_game_game_id on lobby_game (game_id);
+
+alter table lobby_chat_history
+ alter column date type timestamptz;
+alter table game_chat_history
+ alter column date type timestamptz;
+alter table lobby_game
+ alter column date_created type timestamptz;
+alter table bad_word
+ alter column date_created type timestamptz;
diff --git a/server/database/src/main/resources/db/migration/lobby_db/V2.00.04__add_lobby_user_fk_to_access_log.sql b/server/database/src/main/resources/db/migration/lobby_db/V2.00.04__add_lobby_user_fk_to_access_log.sql
new file mode 100644
index 0000000..331dbb5
--- /dev/null
+++ b/server/database/src/main/resources/db/migration/lobby_db/V2.00.04__add_lobby_user_fk_to_access_log.sql
@@ -0,0 +1,3 @@
+alter table access_log drop column registered;
+alter table access_log add column lobby_user_id int references lobby_user(id);
+alter table lobby_user drop column last_login;
diff --git a/server/database/src/main/resources/db/migration/lobby_db/V2.00.05__drop_legacy_password.sql b/server/database/src/main/resources/db/migration/lobby_db/V2.00.05__drop_legacy_password.sql
new file mode 100644
index 0000000..5b7c872
--- /dev/null
+++ b/server/database/src/main/resources/db/migration/lobby_db/V2.00.05__drop_legacy_password.sql
@@ -0,0 +1,2 @@
+alter table lobby_user drop column password;
+alter table lobby_user alter column bcrypt_password set not null;
diff --git a/server/database/src/main/resources/db/migration/lobby_db/V2.00.06__error_report_issue_and_title_column.sql b/server/database/src/main/resources/db/migration/lobby_db/V2.00.06__error_report_issue_and_title_column.sql
new file mode 100644
index 0000000..d473914
--- /dev/null
+++ b/server/database/src/main/resources/db/migration/lobby_db/V2.00.06__error_report_issue_and_title_column.sql
@@ -0,0 +1,30 @@
+alter table error_report_history
+ add column system_id varchar(36);
+alter table error_report_history
+ add column report_title varchar(125);
+alter table error_report_history
+ add column game_version varchar(16);
+alter table error_report_history
+ add column created_issue_link varchar(128);
+
+update error_report_history
+set system_id = '---' || random(),
+ report_title = '---' || random(),
+ game_version = '2.0.20234',
+ created_issue_link = '----' || random();
+
+alter table error_report_history
+ alter column system_id set not null;
+alter table error_report_history
+ alter column report_title set not null;
+alter table error_report_history
+ alter column game_version set not null;
+alter table error_report_history
+ alter column created_issue_link set not null;
+
+-- the title and version pair should be unique, otherwise we are looking at a duplicate
+alter table error_report_history
+ add constraint error_report_history_unique_title_version unique (report_title, game_version);
+-- all links should be unique
+alter table error_report_history
+ add constraint error_report_history_unique_link unique (created_issue_link);
diff --git a/server/database/src/main/resources/db/migration/lobby_db/V2.01.00__fix_username_ban_casing.sql b/server/database/src/main/resources/db/migration/lobby_db/V2.01.00__fix_username_ban_casing.sql
new file mode 100644
index 0000000..1b33b89
--- /dev/null
+++ b/server/database/src/main/resources/db/migration/lobby_db/V2.01.00__fix_username_ban_casing.sql
@@ -0,0 +1,11 @@
+-- delete any extra name bans that are duplicates
+delete from banned_username bu
+where exists (
+ select 1
+ from banned_username t2
+ where bu.date_created < t2.date_created
+ and bu.username ilike t2.username
+);
+
+-- upper case existing entries
+update banned_username set username = upper(username);
diff --git a/server/database/src/main/resources/db/migration/lobby_db/V2.01.01__maps_index_tables.sql b/server/database/src/main/resources/db/migration/lobby_db/V2.01.01__maps_index_tables.sql
new file mode 100644
index 0000000..a2defd3
--- /dev/null
+++ b/server/database/src/main/resources/db/migration/lobby_db/V2.01.01__maps_index_tables.sql
@@ -0,0 +1,33 @@
+create table map_index
+(
+ id serial primary key,
+ map_name varchar(256) not null,
+ last_commit_date timestamptz not null check (last_commit_date < now()),
+ repo_url varchar(256) not null unique check (repo_url like 'http%'),
+ preview_image_url varchar(256) not null check (repo_url like 'http%'),
+ description varchar(3000) not null,
+ download_size_bytes integer not null,
+ download_url varchar(256) not null unique check (download_url like 'http%'),
+ date_created timestamptz not null default now(),
+ date_updated timestamptz not null default now()
+);
+
+create table tag_type
+(
+ id int primary key,
+ name varchar(64) not null unique,
+ type varchar(32) not null,
+ -- disallow display_order values over 1000, we do not expect nearly that many unique tag types
+ display_order int not null unique check (display_order >= 0 and display_order < 1000)
+);
+
+create table map_tag_values
+(
+ id serial primary key,
+ map_id integer references map_index (id),
+ tag_type_id integer references tag_type (id),
+ tag_value varchar(128) not null
+);
+
+alter table map_tag_values
+ add constraint map_id__tag_id__is__unique unique (map_id, tag_type_id);
diff --git a/server/database/src/main/resources/db/migration/lobby_db/V2.01.02__map_tags.sql b/server/database/src/main/resources/db/migration/lobby_db/V2.01.02__map_tags.sql
new file mode 100644
index 0000000..f7ab86e
--- /dev/null
+++ b/server/database/src/main/resources/db/migration/lobby_db/V2.01.02__map_tags.sql
@@ -0,0 +1,34 @@
+drop table map_tag_values;
+drop table tag_type;
+
+create table map_tag
+(
+ id serial primary key,
+ name varchar(64) not null unique,
+ display_order int not null unique check (display_order >=0 and display_order <= 1000)
+);
+comment on table map_tag is 'Defines the set of map tags.';
+
+create table map_tag_allowed_value
+(
+ id serial primary key,
+ map_tag_id int not null references map_tag(id),
+ value varchar(64) not null
+);
+alter table map_tag_allowed_value
+ add constraint map_tag_values_unique unique (map_tag_id, value);
+
+comment on table map_tag_allowed_value is 'Defines the set of allowed values per tag.';
+
+create table map_tag_value
+(
+ id serial primary key,
+ map_tag_id int not null references map_tag(id),
+ map_index_id int not null references map_index(id),
+ map_tag_allowed_value_id int not null references map_tag_allowed_value(id)
+);
+
+alter table map_tag_value
+ add constraint map_tag_value_unique unique (map_tag_id, map_index_id);
+
+comment on table map_tag_value is 'Join table relating maps to tags';
diff --git a/server/database/src/main/resources/db/migration/lobby_db/V2.02.00__lobby_message.sql b/server/database/src/main/resources/db/migration/lobby_db/V2.02.00__lobby_message.sql
new file mode 100644
index 0000000..a2b7e18
--- /dev/null
+++ b/server/database/src/main/resources/db/migration/lobby_db/V2.02.00__lobby_message.sql
@@ -0,0 +1,9 @@
+create table lobby_message
+(
+ message varchar(256) not null
+);
+comment on table lobby_message is 'Stores the message that we display to users on login';
+
+insert into lobby_message(message)
+values ('Welcome to the TripleA lobby!\n' ||
+ 'Please no politics, stay respectful, be welcoming, have fun.');
diff --git a/server/database/start_docker_db b/server/database/start_docker_db
new file mode 100755
index 0000000..a2d5b0c
--- /dev/null
+++ b/server/database/start_docker_db
@@ -0,0 +1,51 @@
+#!/bin/bash
+
+set -eu
+
+bold="\e[1m"
+bold_green="${bold}\e[32m"
+normal="\e[0m"
+
+function main() {
+ echo -e "[${bold_green}STARTING DATABASE${normal}]"
+
+ runDocker
+ waitForDatabaseToStart
+ echo -e "[${bold_green}DONE${normal}]"
+}
+
+function runDocker() {
+ echo "Stopping database.."
+ docker stop database || echo "[OK] Database not running.."
+ docker run \
+ --rm -d \
+ --name="database" \
+ -e "POSTGRES_PASSWORD=postgres" \
+ -p "5432:5432" \
+ -v "$(pwd)/$(dirname "$0")/sql/init/:/docker-entrypoint-initdb.d/" \
+ "postgres:10"
+}
+
+function waitForDatabaseToStart() {
+ echo -n "Waiting for Database start.."
+ tryAttempt=0
+ export PGPASSWORD=postgres
+ while ! (
+ echo 'select 1' \
+ | psql -h localhost -U postgres 2> /dev/null \
+ | grep -q '1 row'); do
+
+ sleep 0.2
+ echo -n .
+
+ tryAttempt=$((tryAttempt+1))
+ # timeout after 10s
+ if [ "$tryAttempt" -gt 50 ]; then
+ echo "Aborting DB startup (timed out)"
+ exit 1
+ fi
+ done
+ echo ""
+}
+
+main
diff --git a/server/dropwizard-server/build.gradle b/server/dropwizard-server/build.gradle
new file mode 100644
index 0000000..2506dd8
--- /dev/null
+++ b/server/dropwizard-server/build.gradle
@@ -0,0 +1,75 @@
+plugins {
+ id "java"
+ id "application"
+ id "com.github.johnrengelman.shadow" version "7.1.2"
+}
+
+archivesBaseName = "$group-$name"
+mainClassName = "org.triplea.spitfire.server.SpitfireServerApplication"
+ext {
+ releasesDir = file("$buildDir/releases")
+}
+
+jar {
+ manifest {
+ attributes "Main-Class": mainClassName
+ }
+}
+
+task portableInstaller(type: Zip, group: "release", dependsOn: shadowJar) {
+ from file("configuration.yml")
+
+ from(shadowJar.outputs) {
+ into "bin"
+ }
+}
+
+task release(group: "release", dependsOn: portableInstaller) {
+ doLast {
+ publishArtifacts(portableInstaller.outputs.files)
+ }
+}
+
+shadowJar {
+ archiveClassifier.set ""
+ // mergeServiceFiles is needed by dropwizard
+ // Without this configuration parsing breaks and is unable to find connector type "http" for
+ // the following YAML snippet: server: {applicationConnectors: [{type: http, port: 8080}]
+ mergeServiceFiles()
+}
+
+configurations {
+ testImplementation {
+ // database-rider brings in slf4j-simple as a transitive dependency
+ // DropWizard has logback baked in and cannot have multiple slf4j bindings.
+ exclude group: "org.slf4j", module: "slf4j-simple"
+ }
+}
+
+dependencies {
+ implementation "com.liveperson:dropwizard-websockets:$dropwizardWebsocketsVersion"
+ implementation "io.dropwizard:dropwizard-auth:$dropwizardVersion"
+ implementation "io.dropwizard:dropwizard-core:$dropwizardVersion"
+ implementation "io.dropwizard:dropwizard-jdbi3:$dropwizardVersion"
+// implementation project(":game-app:domain-data")
+// implementation project(":http-clients:github-client")
+// implementation project(":http-clients:lobby-client")
+
+ implementation("triplea:lobby-client:2.6.14756")
+
+// implementation project(":lib:feign-common")
+// implementation project(":lib:java-extras")
+// implementation project(":lib:websocket-client")
+// implementation project(":lib:websocket-server")
+ implementation project(":server:server-lib")
+ implementation project(":server:lobby-module")
+ testImplementation "com.github.database-rider:rider-junit5:$databaseRiderVersion"
+ testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion"
+ testImplementation "io.github.openfeign:feign-core:$feignCoreVersion"
+ testImplementation "io.github.openfeign:feign-gson:$feignGsonVersion"
+ testImplementation "org.awaitility:awaitility:$awaitilityVersion"
+ testImplementation "org.junit.jupiter:junit-jupiter-api:$junitJupiterVersion"
+// testImplementation project(":lib:test-common")
+ testImplementation "org.java-websocket:Java-WebSocket:$javaWebSocketVersion"
+ runtimeOnly "org.postgresql:postgresql:$postgresqlVersion"
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/ResponseStatus.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/ResponseStatus.java
new file mode 100644
index 0000000..be2229c
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/ResponseStatus.java
@@ -0,0 +1,31 @@
+package org.triplea.spitfire.server;
+
+import javax.annotation.Nonnull;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status.Family;
+import lombok.Builder;
+import lombok.Getter;
+
+/**
+ * Creates a custom http Status Code, mainly used to be able to create a custom reason phrase. This
+ * is useful if we cannot transmit an http body and need to pass information through the status
+ * reason.
+ */
+@Builder
+public class ResponseStatus implements Response.StatusType {
+ @Getter(onMethod_ = @Override)
+ @Nonnull
+ private final String reasonPhrase;
+
+ @Nonnull private final Integer statusCode;
+
+ @Override
+ public Family getFamily() {
+ return Family.familyOf(getStatusCode());
+ }
+
+ @Override
+ public int getStatusCode() {
+ return statusCode;
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/SpitfireServerApplication.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/SpitfireServerApplication.java
new file mode 100644
index 0000000..309dbed
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/SpitfireServerApplication.java
@@ -0,0 +1,167 @@
+package org.triplea.spitfire.server;
+
+import com.codahale.metrics.MetricRegistry;
+import io.dropwizard.Application;
+import io.dropwizard.jdbi3.JdbiFactory;
+import io.dropwizard.setup.Bootstrap;
+import io.dropwizard.setup.Environment;
+import java.time.Duration;
+import java.util.List;
+import lombok.extern.slf4j.Slf4j;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.LobbyModuleRowMappers;
+import org.triplea.dropwizard.common.AuthenticationConfiguration;
+import org.triplea.dropwizard.common.IllegalArgumentMapper;
+import org.triplea.dropwizard.common.JdbiLogging;
+import org.triplea.dropwizard.common.ServerConfiguration;
+import org.triplea.dropwizard.common.ServerConfiguration.WebsocketConfig;
+import org.triplea.http.client.web.socket.WebsocketPaths;
+import org.triplea.modules.chat.ChatMessagingService;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.modules.game.listing.GameListing;
+import org.triplea.modules.latest.version.LatestVersionModule;
+import org.triplea.spitfire.server.access.authentication.ApiKeyAuthenticator;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+import org.triplea.spitfire.server.access.authorization.BannedPlayerFilter;
+import org.triplea.spitfire.server.access.authorization.RoleAuthorizer;
+import org.triplea.spitfire.server.controllers.latest.version.LatestVersionController;
+import org.triplea.spitfire.server.controllers.lobby.GameHostingController;
+import org.triplea.spitfire.server.controllers.lobby.GameListingController;
+import org.triplea.spitfire.server.controllers.lobby.LobbyWatcherController;
+import org.triplea.spitfire.server.controllers.lobby.PlayersInGameController;
+import org.triplea.spitfire.server.controllers.lobby.moderation.AccessLogController;
+import org.triplea.spitfire.server.controllers.lobby.moderation.BadWordsController;
+import org.triplea.spitfire.server.controllers.lobby.moderation.DisconnectUserController;
+import org.triplea.spitfire.server.controllers.lobby.moderation.GameChatHistoryController;
+import org.triplea.spitfire.server.controllers.lobby.moderation.ModeratorAuditHistoryController;
+import org.triplea.spitfire.server.controllers.lobby.moderation.ModeratorsController;
+import org.triplea.spitfire.server.controllers.lobby.moderation.MuteUserController;
+import org.triplea.spitfire.server.controllers.lobby.moderation.RemoteActionsController;
+import org.triplea.spitfire.server.controllers.lobby.moderation.UserBanController;
+import org.triplea.spitfire.server.controllers.lobby.moderation.UsernameBanController;
+import org.triplea.spitfire.server.controllers.user.account.CreateAccountController;
+import org.triplea.spitfire.server.controllers.user.account.ForgotPasswordController;
+import org.triplea.spitfire.server.controllers.user.account.LoginController;
+import org.triplea.spitfire.server.controllers.user.account.PlayerInfoController;
+import org.triplea.spitfire.server.controllers.user.account.UpdateAccountController;
+import org.triplea.web.socket.GameConnectionWebSocket;
+import org.triplea.web.socket.GenericWebSocket;
+import org.triplea.web.socket.PlayerConnectionWebSocket;
+import org.triplea.web.socket.SessionBannedCheck;
+import org.triplea.web.socket.WebSocketMessagingBus;
+
+/**
+ * Main entry-point for launching drop wizard HTTP server. This class is responsible for configuring
+ * any Jersey plugins, registering resources (controllers) and injecting those resources with
+ * configuration properties from 'AppConfig'.
+ */
+@Slf4j
+public class SpitfireServerApplication extends Application {
+
+ private static final String[] DEFAULT_ARGS = new String[] {"server", "configuration.yml"};
+
+ private ServerConfiguration serverConfiguration;
+
+ /**
+ * Main entry-point method, launches the drop-wizard http server. If no args are passed then will
+ * use default values suitable for local development.
+ */
+ public static void main(final String[] args) throws Exception {
+ final SpitfireServerApplication application = new SpitfireServerApplication();
+ // if no args are provided then we will use default values.
+ application.run(args.length == 0 ? DEFAULT_ARGS : args);
+ }
+
+ @Override
+ public void initialize(final Bootstrap bootstrap) {
+ serverConfiguration =
+ ServerConfiguration.build(
+ bootstrap,
+ new WebsocketConfig(GameConnectionWebSocket.class, WebsocketPaths.GAME_CONNECTIONS),
+ new WebsocketConfig(
+ PlayerConnectionWebSocket.class, WebsocketPaths.PLAYER_CONNECTIONS))
+ .enableEnvironmentVariablesInConfig()
+ .enableBetterJdbiExceptions();
+ }
+
+ @Override
+ public void run(final SpitfireServerConfig configuration, final Environment environment) {
+ final Jdbi jdbi =
+ new JdbiFactory()
+ .build(environment, configuration.getDatabase(), "postgresql-connection-pool");
+
+ LobbyModuleRowMappers.rowMappers().forEach(jdbi::registerRowMapper);
+
+ if (configuration.isLogSqlStatements()) {
+ JdbiLogging.registerSqlLogger(jdbi);
+ }
+
+ final LatestVersionModule latestVersionModule = new LatestVersionModule();
+ if (configuration.isLatestVersionFetcherEnabled()) {
+ log.info("Latest Engine Version Fetching running every 30 minutes");
+ environment
+ .lifecycle()
+ .manage(
+ latestVersionModule.buildRefreshSchedule(
+ configuration,
+ LatestVersionModule.RefreshConfiguration.builder()
+ .delay(Duration.ofSeconds(10L))
+ .period(Duration.ofMinutes(30L))
+ .build()));
+ } else {
+ log.info("Latest Engine Version Fetching is disabled");
+ }
+
+ serverConfiguration.registerRequestFilter(
+ environment, BannedPlayerFilter.newBannedPlayerFilter(jdbi));
+
+ final MetricRegistry metrics = new MetricRegistry();
+ AuthenticationConfiguration.enableAuthentication(
+ environment,
+ metrics,
+ ApiKeyAuthenticator.build(jdbi),
+ new RoleAuthorizer(),
+ AuthenticatedUser.class);
+
+ serverConfiguration.registerExceptionMappers(environment, List.of(new IllegalArgumentMapper()));
+
+ final var sessionIsBannedCheck = SessionBannedCheck.build(jdbi);
+ final var gameConnectionMessagingBus = new WebSocketMessagingBus();
+
+ GenericWebSocket.init(
+ GameConnectionWebSocket.class, gameConnectionMessagingBus, sessionIsBannedCheck);
+
+ final var playerConnectionMessagingBus = new WebSocketMessagingBus();
+ GenericWebSocket.init(
+ PlayerConnectionWebSocket.class, playerConnectionMessagingBus, sessionIsBannedCheck);
+
+ final var chatters = Chatters.build();
+ ChatMessagingService.build(chatters, jdbi).configure(playerConnectionMessagingBus);
+
+ final GameListing gameListing = GameListing.build(jdbi, playerConnectionMessagingBus);
+ List.of(
+ // lobby module controllers
+ AccessLogController.build(jdbi),
+ BadWordsController.build(jdbi),
+ CreateAccountController.build(jdbi),
+ DisconnectUserController.build(jdbi, chatters, playerConnectionMessagingBus),
+ ForgotPasswordController.build(configuration, jdbi),
+ GameChatHistoryController.build(jdbi),
+ GameHostingController.build(jdbi),
+ GameListingController.build(gameListing),
+ LobbyWatcherController.build(configuration, jdbi, gameListing),
+ LoginController.build(jdbi, chatters),
+ UsernameBanController.build(jdbi),
+ UserBanController.build(
+ jdbi, chatters, playerConnectionMessagingBus, gameConnectionMessagingBus),
+ ModeratorAuditHistoryController.build(jdbi),
+ ModeratorsController.build(jdbi),
+ MuteUserController.build(chatters),
+ PlayerInfoController.build(jdbi, chatters, gameListing),
+ PlayersInGameController.build(gameListing),
+ RemoteActionsController.build(jdbi, gameConnectionMessagingBus),
+ UpdateAccountController.build(jdbi),
+ LatestVersionController.build(latestVersionModule))
+ .forEach(controller -> environment.jersey().register(controller));
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/SpitfireServerConfig.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/SpitfireServerConfig.java
new file mode 100644
index 0000000..492dc83
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/SpitfireServerConfig.java
@@ -0,0 +1,77 @@
+package org.triplea.spitfire.server;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.dropwizard.Configuration;
+import io.dropwizard.db.DataSourceFactory;
+import java.net.URI;
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+import lombok.Getter;
+import lombok.Setter;
+import org.triplea.http.client.github.GithubApiClient;
+import org.triplea.modules.LobbyModuleConfig;
+import org.triplea.modules.latest.version.LatestVersionModuleConfig;
+
+/**
+ * This configuration class represents the configuration values in the server YML configuration. An
+ * instance of this class is created by DropWizard on launch and then is passed to the application
+ * class. Values can be injected into the application by using environment variables in the server
+ * YML configuration file.
+ */
+public class SpitfireServerConfig extends Configuration
+ implements LobbyModuleConfig, LatestVersionModuleConfig {
+
+ /**
+ * Flag that indicates if we are running in production. This can be used to verify we do not have
+ * any magic stubbing and to do any additional configuration checks to be really sure production
+ * is well configured. Because we vary configuration values between prod and test, there can be
+ * prod-only cases where we perhaps have something misconfigured, hence the risk we are trying to
+ * defend against.
+ */
+ @Getter(onMethod_ = {@JsonProperty, @Override})
+ @Setter(onMethod_ = {@JsonProperty})
+ private boolean gameHostConnectivityCheckEnabled;
+
+ @Getter(onMethod_ = {@JsonProperty})
+ @Setter(onMethod_ = {@JsonProperty})
+ private boolean logSqlStatements;
+
+ @Valid @NotNull @JsonProperty @Getter
+ private final DataSourceFactory database = new DataSourceFactory();
+
+ @Getter(onMethod_ = {@JsonProperty})
+ @Setter(onMethod_ = {@JsonProperty})
+ private String githubWebServiceUrl;
+
+ /** Webservice token, should be an API token for the TripleA builder bot account. */
+ @Getter(onMethod_ = {@JsonProperty})
+ @Setter(onMethod_ = {@JsonProperty})
+ private String githubApiToken;
+
+ @Getter(onMethod_ = {@JsonProperty})
+ @Setter(onMethod_ = {@JsonProperty})
+ private boolean errorReportToGithubEnabled;
+
+ @Getter(onMethod_ = {@JsonProperty})
+ @Setter(onMethod_ = {@JsonProperty})
+ private String githubGameOrg;
+
+ @Getter(onMethod_ = {@JsonProperty})
+ @Setter(onMethod_ = {@JsonProperty})
+ private String githubGameRepo;
+
+ @Getter(onMethod_ = {@JsonProperty})
+ @Setter(onMethod_ = {@JsonProperty})
+ private boolean latestVersionFetcherEnabled;
+
+ @Override
+ public GithubApiClient createGamesRepoGithubApiClient() {
+ return GithubApiClient.builder()
+ .stubbingModeEnabled(!errorReportToGithubEnabled)
+ .authToken(githubApiToken)
+ .uri(URI.create(githubWebServiceUrl))
+ .org(githubGameOrg)
+ .repo(githubGameRepo)
+ .build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/access/authentication/ApiKeyAuthenticator.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/access/authentication/ApiKeyAuthenticator.java
new file mode 100644
index 0000000..cd37fc2
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/access/authentication/ApiKeyAuthenticator.java
@@ -0,0 +1,53 @@
+package org.triplea.spitfire.server.access.authentication;
+
+import com.google.common.annotations.VisibleForTesting;
+import io.dropwizard.auth.Authenticator;
+import java.util.Optional;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.api.key.GameHostingApiKeyDaoWrapper;
+import org.triplea.db.dao.api.key.PlayerApiKeyDaoWrapper;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.domain.data.ApiKey;
+
+/**
+ * Verifies a 'bearer' token API key is valid. This means checking if the key is in database, if so
+ * we return an {@code AuthenticatedUser} otherwise optional. Anonymous users will have a null
+ * user-id and role of 'ANONYMOUS'.
+ */
+@AllArgsConstructor(access = AccessLevel.PACKAGE, onConstructor_ = @VisibleForTesting)
+public class ApiKeyAuthenticator implements Authenticator {
+
+ private final PlayerApiKeyDaoWrapper apiKeyDaoWrapper;
+ private final GameHostingApiKeyDaoWrapper gameHostingApiKeyDaoWrapper;
+
+ public static ApiKeyAuthenticator build(final Jdbi jdbi) {
+ return new ApiKeyAuthenticator(
+ PlayerApiKeyDaoWrapper.build(jdbi), GameHostingApiKeyDaoWrapper.build(jdbi));
+ }
+
+ @Override
+ public Optional authenticate(final String apiKey) {
+ final ApiKey key = ApiKey.of(apiKey);
+ return apiKeyDaoWrapper
+ .lookupByApiKey(key)
+ .map(
+ userData ->
+ AuthenticatedUser.builder()
+ .userId(userData.getUserId())
+ .name(userData.getUsername())
+ .userRole(userData.getUserRole())
+ .apiKey(key)
+ .build())
+ .or(
+ () ->
+ gameHostingApiKeyDaoWrapper.isKeyValid(key)
+ ? Optional.of(
+ AuthenticatedUser.builder() //
+ .userRole(UserRole.HOST)
+ .apiKey(key)
+ .build())
+ : Optional.empty());
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/access/authentication/AuthenticatedUser.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/access/authentication/AuthenticatedUser.java
new file mode 100644
index 0000000..c44bac3
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/access/authentication/AuthenticatedUser.java
@@ -0,0 +1,46 @@
+package org.triplea.spitfire.server.access.authentication;
+
+import com.google.common.base.Preconditions;
+import java.security.Principal;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.domain.data.ApiKey;
+
+/**
+ * AuthenticatedUser is anyone that has successfully logged in to the lobby, whether via anonymous
+ * account or registered account. Because we must extend Principle, we have a no-op 'getName' method
+ * that throws an {@code UnsupportedOperationException} if accessed.
+ */
+@Builder
+@EqualsAndHashCode
+public class AuthenticatedUser implements Principal {
+ @Getter @Nullable private final Integer userId;
+ @Getter @Nonnull private final String userRole;
+ @Getter @Nonnull private final ApiKey apiKey;
+ @Nullable private final String name;
+
+ @Nullable
+ @Override
+ public String getName() {
+ Preconditions.checkState(
+ (name == null) == userRole.equals(UserRole.HOST),
+ "All user roles will have a name except the 'HOST' role (lobby watcher)");
+ return name;
+ }
+
+ public int getUserIdOrThrow() {
+ Preconditions.checkState(
+ userId != null, "This method is called when we expect user id to not be null");
+ Preconditions.checkState(
+ !userRole.equals(UserRole.ANONYMOUS),
+ "Integrity check, anonymous user role implies null user id, userId = " + userId);
+ Preconditions.checkState(
+ !userRole.equals(UserRole.HOST),
+ "Integrity check, host user role implies null user id, userId = " + userId);
+ return userId;
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/access/authorization/BannedPlayerFilter.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/access/authorization/BannedPlayerFilter.java
new file mode 100644
index 0000000..29592fa
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/access/authorization/BannedPlayerFilter.java
@@ -0,0 +1,75 @@
+package org.triplea.spitfire.server.access.authorization;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import java.time.Clock;
+import java.time.Duration;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerRequestFilter;
+import javax.ws.rs.container.PreMatching;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+import javax.ws.rs.ext.Provider;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.RequiredArgsConstructor;
+import org.eclipse.jetty.http.HttpStatus;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.ban.BanLookupRecord;
+import org.triplea.db.dao.user.ban.UserBanDao;
+import org.triplea.dropwizard.common.IpAddressExtractor;
+import org.triplea.http.client.LobbyHttpClientConfig;
+import org.triplea.http.client.lobby.moderator.BanDurationFormatter;
+import org.triplea.spitfire.server.ResponseStatus;
+
+@Provider
+@PreMatching
+@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+@AllArgsConstructor(access = AccessLevel.PACKAGE, onConstructor_ = @VisibleForTesting)
+public class BannedPlayerFilter implements ContainerRequestFilter {
+
+ private final UserBanDao userBanDao;
+ private final Clock clock;
+
+ @Context private HttpServletRequest request;
+
+ public static BannedPlayerFilter newBannedPlayerFilter(final Jdbi jdbi) {
+ return new BannedPlayerFilter(jdbi.onDemand(UserBanDao.class), Clock.systemUTC());
+ }
+
+ @Override
+ public void filter(final ContainerRequestContext requestContext) {
+ if (Strings.emptyToNull(request.getHeader(LobbyHttpClientConfig.SYSTEM_ID_HEADER)) == null) {
+ // missing system id header, abort the request
+ requestContext.abortWith(
+ Response.status(Status.UNAUTHORIZED).entity("Invalid request").build());
+
+ } else {
+ // check if user is banned, if so abort the request
+ userBanDao
+ .lookupBan(
+ IpAddressExtractor.extractIpAddress(request),
+ request.getHeader(LobbyHttpClientConfig.SYSTEM_ID_HEADER))
+ .map(this::formatBanMessage)
+ .ifPresent(
+ banMessage ->
+ requestContext.abortWith(
+ Response.status(
+ ResponseStatus.builder()
+ .statusCode(HttpStatus.UNAUTHORIZED_401)
+ .reasonPhrase(banMessage)
+ .build())
+ .build()));
+ }
+ }
+
+ private String formatBanMessage(final BanLookupRecord banLookupRecord) {
+ final long banMinutes =
+ Duration.between(clock.instant(), banLookupRecord.getBanExpiry()).toMinutes();
+ final String banDuration = BanDurationFormatter.formatBanMinutes(banMinutes);
+
+ return String.format("Banned %s, Ban-ID: %s", banDuration, banLookupRecord.getPublicBanId());
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/access/authorization/RoleAuthorizer.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/access/authorization/RoleAuthorizer.java
new file mode 100644
index 0000000..b2450b3
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/access/authorization/RoleAuthorizer.java
@@ -0,0 +1,47 @@
+package org.triplea.spitfire.server.access.authorization;
+
+import io.dropwizard.auth.Authorizer;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+
+/**
+ * Performs authorization. Authorization happens after authentication and answers the question of:
+ * given an authenticated user, do they have permissions for a given role?
+ */
+public class RoleAuthorizer implements Authorizer {
+
+ /** Verifies a user is authorized to assume a requestedRole.
*/
+ @Override
+ public boolean authorize(final AuthenticatedUser user, final String requestedRole) {
+ switch (user.getUserRole()) {
+ case UserRole.ADMIN:
+ return adminAuthorizedFor(requestedRole);
+ case UserRole.MODERATOR:
+ return moderatorAuthorizedFor(requestedRole);
+ case UserRole.PLAYER:
+ return playerAuthorizedFor(requestedRole);
+ case UserRole.ANONYMOUS:
+ return anonymousAuthorizedFor(requestedRole);
+ case UserRole.HOST:
+ return requestedRole.equals(UserRole.HOST);
+ default:
+ throw new AssertionError("Unrecognized user role: " + user.getUserRole());
+ }
+ }
+
+ private static boolean adminAuthorizedFor(final String requestedRole) {
+ return requestedRole.equals(UserRole.ADMIN) || moderatorAuthorizedFor(requestedRole);
+ }
+
+ private static boolean moderatorAuthorizedFor(final String requestedRole) {
+ return requestedRole.equals(UserRole.MODERATOR) || playerAuthorizedFor(requestedRole);
+ }
+
+ private static boolean playerAuthorizedFor(final String requestedRole) {
+ return requestedRole.equals(UserRole.PLAYER) || anonymousAuthorizedFor(requestedRole);
+ }
+
+ private static boolean anonymousAuthorizedFor(final String requestedRole) {
+ return requestedRole.equals(UserRole.ANONYMOUS);
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/latest/version/LatestVersionController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/latest/version/LatestVersionController.java
new file mode 100644
index 0000000..ee6c6d2
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/latest/version/LatestVersionController.java
@@ -0,0 +1,36 @@
+package org.triplea.spitfire.server.controllers.latest.version;
+
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Response;
+import lombok.Builder;
+import org.triplea.http.client.latest.version.LatestVersionClient;
+import org.triplea.http.client.latest.version.LatestVersionResponse;
+import org.triplea.modules.latest.version.LatestVersionModule;
+import org.triplea.spitfire.server.HttpController;
+
+@Builder
+public class LatestVersionController extends HttpController {
+ private final LatestVersionModule latestVersionModule;
+
+ public static LatestVersionController build(final LatestVersionModule latestVersionModule) {
+ return new LatestVersionController(latestVersionModule);
+ }
+
+ @GET
+ @Path(LatestVersionClient.LATEST_VERSION_PATH)
+ public Response getLatestEngineVersion() {
+ return latestVersionModule
+ .getLatestVersion()
+ .map(
+ latest ->
+ Response.ok(
+ LatestVersionResponse.builder() //
+ .latestEngineVersion(latest)
+ .releaseNotesUrl("https://triplea-game.org/release_notes/")
+ .downloadUrl("https://triplea-game.org/download/")
+ .build())
+ .build())
+ .orElseGet(() -> Response.status(503, "Unable to fetch latest version").build());
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/GameHostingController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/GameHostingController.java
new file mode 100644
index 0000000..1fc055e
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/GameHostingController.java
@@ -0,0 +1,50 @@
+package org.triplea.spitfire.server.controllers.lobby;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Context;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.api.key.GameHostingApiKeyDaoWrapper;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.dropwizard.common.IpAddressExtractor;
+import org.triplea.http.client.lobby.game.hosting.request.GameHostingClient;
+import org.triplea.http.client.lobby.game.hosting.request.GameHostingResponse;
+import org.triplea.spitfire.server.HttpController;
+
+/**
+ * Provides an endpoint where an independent connection can be established, provides an API key to
+ * unauthenticated users that can then be used to post a game. Banning rules are verified to ensure
+ * banned users cannot post games.
+ */
+@Builder
+public class GameHostingController extends HttpController {
+
+ @Nonnull private final Function apiKeySupplier;
+
+ public static GameHostingController build(final Jdbi jdbi) {
+ final var gameHostingApiKeyDaoWrapper = GameHostingApiKeyDaoWrapper.build(jdbi);
+ return GameHostingController.builder() //
+ .apiKeySupplier(gameHostingApiKeyDaoWrapper::newGameHostKey)
+ .build();
+ }
+
+ @POST
+ @Path(GameHostingClient.GAME_HOSTING_REQUEST_PATH)
+ public GameHostingResponse hostingRequest(@Context final HttpServletRequest request) {
+ String remoteIp = IpAddressExtractor.extractIpAddress(request);
+ try {
+ return GameHostingResponse.builder()
+ .apiKey(apiKeySupplier.apply(InetAddress.getByName(remoteIp)).getValue())
+ .publicVisibleIp(remoteIp)
+ .build();
+ } catch (final UnknownHostException e) {
+ throw new IllegalArgumentException("Invalid IP address in request: " + remoteIp, e);
+ }
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/GameListingController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/GameListingController.java
new file mode 100644
index 0000000..b41e203
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/GameListingController.java
@@ -0,0 +1,53 @@
+package org.triplea.spitfire.server.controllers.lobby;
+
+import com.google.common.annotations.VisibleForTesting;
+import io.dropwizard.auth.Auth;
+import java.util.Collection;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Response;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.http.client.lobby.game.lobby.watcher.GameListingClient;
+import org.triplea.http.client.lobby.game.lobby.watcher.LobbyGameListing;
+import org.triplea.modules.game.listing.GameListing;
+import org.triplea.spitfire.server.HttpController;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+
+/** Controller with endpoints for posting, getting and removing games. */
+@Builder
+@AllArgsConstructor(
+ access = AccessLevel.PACKAGE,
+ onConstructor_ = {@VisibleForTesting})
+@RolesAllowed(UserRole.HOST)
+public class GameListingController extends HttpController {
+
+ private final GameListing gameListing;
+
+ public static GameListingController build(final GameListing gameListing) {
+ return GameListingController.builder() //
+ .gameListing(gameListing)
+ .build();
+ }
+
+ /** Returns a listing of the current games. */
+ @GET
+ @Path(GameListingClient.FETCH_GAMES_PATH)
+ @RolesAllowed(UserRole.ANONYMOUS)
+ public Collection fetchGames() {
+ return gameListing.getGames();
+ }
+
+ /** Moderator action to remove a game. */
+ @POST
+ @Path(GameListingClient.BOOT_GAME_PATH)
+ @RolesAllowed(UserRole.MODERATOR)
+ public Response bootGame(@Auth final AuthenticatedUser authenticatedUser, final String gameId) {
+ gameListing.bootGame(authenticatedUser.getUserIdOrThrow(), gameId);
+ return Response.ok().build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/LobbyWatcherController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/LobbyWatcherController.java
new file mode 100644
index 0000000..567345a
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/LobbyWatcherController.java
@@ -0,0 +1,189 @@
+package org.triplea.spitfire.server.controllers.lobby;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import io.dropwizard.auth.Auth;
+import javax.annotation.Nonnull;
+import javax.annotation.security.RolesAllowed;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Response;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.extern.slf4j.Slf4j;
+import org.eclipse.jetty.http.HttpStatus;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.domain.data.LobbyConstants;
+import org.triplea.domain.data.UserName;
+import org.triplea.http.client.lobby.game.lobby.watcher.ChatMessageUpload;
+import org.triplea.http.client.lobby.game.lobby.watcher.GamePostingRequest;
+import org.triplea.http.client.lobby.game.lobby.watcher.GamePostingResponse;
+import org.triplea.http.client.lobby.game.lobby.watcher.LobbyWatcherClient;
+import org.triplea.http.client.lobby.game.lobby.watcher.PlayerJoinedNotification;
+import org.triplea.http.client.lobby.game.lobby.watcher.PlayerLeftNotification;
+import org.triplea.http.client.lobby.game.lobby.watcher.UpdateGameRequest;
+import org.triplea.modules.LobbyModuleConfig;
+import org.triplea.modules.game.listing.GameListing;
+import org.triplea.modules.game.lobby.watcher.ChatUploadModule;
+import org.triplea.modules.game.lobby.watcher.GamePostingModule;
+import org.triplea.spitfire.server.HttpController;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+
+/** Controller with endpoints for posting, getting and removing games. */
+@Builder
+@AllArgsConstructor(
+ access = AccessLevel.PACKAGE,
+ onConstructor_ = {@VisibleForTesting})
+@RolesAllowed(UserRole.HOST)
+@Slf4j
+public class LobbyWatcherController extends HttpController {
+ @VisibleForTesting
+ public static final String TEST_ONLY_GAME_POSTING_PATH = "/test-only/lobby/post-game";
+
+ @Nonnull private final Boolean gameHostConnectivityCheckEnabled;
+ @Nonnull private final GameListing gameListing;
+ @Nonnull private final ChatUploadModule chatUploadModule;
+ @Nonnull private final GamePostingModule gamePostingModule;
+
+ public static LobbyWatcherController build(
+ final LobbyModuleConfig lobbyModuleConfig, final Jdbi jdbi, final GameListing gameListing) {
+ return LobbyWatcherController.builder()
+ .gameHostConnectivityCheckEnabled(lobbyModuleConfig.isGameHostConnectivityCheckEnabled())
+ .gameListing(gameListing)
+ .chatUploadModule(ChatUploadModule.build(jdbi, gameListing))
+ .gamePostingModule(GamePostingModule.build(gameListing))
+ .build();
+ }
+
+ /**
+ * Adds a game to the lobby listing. Responds with the gameId assigned to the new game. If we see
+ * duplicate posts, the same gameId will be returned.
+ */
+ @POST
+ @Path(LobbyWatcherClient.POST_GAME_PATH)
+ public GamePostingResponse postGame(
+ @Auth final AuthenticatedUser authenticatedUser,
+ final GamePostingRequest gamePostingRequest) {
+ Preconditions.checkArgument(gamePostingRequest != null);
+ Preconditions.checkArgument(gamePostingRequest.getLobbyGame() != null);
+
+ return gamePostingModule.postGame(authenticatedUser.getApiKey(), gamePostingRequest);
+ }
+
+ /**
+ * A endpoint available for non-prod-only that allows for integration tests to post games without
+ * actually hosting a game themselves (bypasses the reverse connectivity check).
+ */
+ @POST
+ @Path(TEST_ONLY_GAME_POSTING_PATH)
+ public Response postGameTestOnly(
+ @Auth final AuthenticatedUser authenticatedUser,
+ final GamePostingRequest gamePostingRequest) {
+ Preconditions.checkArgument(gamePostingRequest != null);
+ Preconditions.checkArgument(gamePostingRequest.getLobbyGame() != null);
+
+ return gameHostConnectivityCheckEnabled
+ ? Response.status(HttpStatus.NOT_FOUND_404).build()
+ : Response.ok()
+ .entity(
+ GamePostingResponse.builder()
+ .connectivityCheckSucceeded(true)
+ .gameId(gameListing.postGame(authenticatedUser.getApiKey(), gamePostingRequest))
+ .build())
+ .build();
+ }
+
+ /** Explicit remove of a game from the lobby. */
+ @POST
+ @Path(LobbyWatcherClient.REMOVE_GAME_PATH)
+ public Response removeGame(@Auth final AuthenticatedUser authenticatedUser, final String gameId) {
+ gameListing.removeGame(authenticatedUser.getApiKey(), gameId);
+ return Response.ok().build();
+ }
+
+ /**
+ * "Alive" endpoint to periodically invoked after a game has been posted to indicate the client is
+ * still hosting and is alive. If the endpoint is not invoked within a cutoff time then the game
+ * with the corresponding gameId will be unlisted. The return value indicates if the game has been
+ * kept alive, or false indicates the game was already removed and the client should re-post.
+ */
+ @POST
+ @Path(LobbyWatcherClient.KEEP_ALIVE_PATH)
+ public boolean keepAlive(@Auth final AuthenticatedUser authenticatedUser, final String gameId) {
+ return gameListing.keepAlive(authenticatedUser.getApiKey(), gameId);
+ }
+
+ /** Replaces an existing game with new game data details. */
+ @POST
+ @Path(LobbyWatcherClient.UPDATE_GAME_PATH)
+ public Response updateGame(
+ @Auth final AuthenticatedUser authenticatedUser, final UpdateGameRequest updateGameRequest) {
+ gameListing.updateGame(
+ authenticatedUser.getApiKey(),
+ updateGameRequest.getGameId(),
+ updateGameRequest.getGameData());
+ return Response.ok().build();
+ }
+
+ /** Endpoint used to consume and persist chat messages to database. */
+ @POST
+ @Path(LobbyWatcherClient.UPLOAD_CHAT_PATH)
+ @RolesAllowed(UserRole.HOST)
+ public Response uploadChatMessage(
+ @Context final HttpServletRequest request, final ChatMessageUpload chatMessageUpload) {
+ Preconditions.checkArgument(chatMessageUpload != null);
+ Preconditions.checkArgument(chatMessageUpload.getChatMessage() != null);
+ Preconditions.checkArgument(chatMessageUpload.getFromPlayer() != null);
+ Preconditions.checkArgument(chatMessageUpload.getGameId() != null);
+
+ Preconditions.checkArgument(
+ chatMessageUpload.getFromPlayer().length() <= LobbyConstants.USERNAME_MAX_LENGTH);
+
+ String authHeader = request.getHeader("Authorization");
+
+ if (!chatUploadModule.upload(
+ // truncate 'Bearer ' from the auth token if present
+ authHeader.startsWith("Bearer ") ? authHeader.substring("Bearer ".length()) : authHeader,
+ chatMessageUpload)) {
+ log.warn(
+ "Chat upload request from {} was rejected, "
+ + "gameID and API-key pair did not match any existing games.",
+ request.getRemoteHost());
+ }
+
+ return Response.ok().build();
+ }
+
+ @POST
+ @Path(LobbyWatcherClient.PLAYER_JOINED_PATH)
+ @RolesAllowed(UserRole.HOST)
+ public Response playerJoinedGame(
+ @Auth final AuthenticatedUser authenticatedUser,
+ final PlayerJoinedNotification playerJoinedNotification) {
+ gameListing.addPlayerToGame(
+ UserName.of(playerJoinedNotification.getPlayerName()),
+ authenticatedUser.getApiKey(),
+ playerJoinedNotification.getGameId());
+
+ return Response.ok().build();
+ }
+
+ @POST
+ @Path(LobbyWatcherClient.PLAYER_LEFT_PATH)
+ @RolesAllowed(UserRole.HOST)
+ public Response playerLeftGame(
+ @Auth final AuthenticatedUser authenticatedUser,
+ final PlayerLeftNotification playerLeftNotification) {
+
+ gameListing.removePlayerFromGame(
+ UserName.of(playerLeftNotification.getPlayerName()),
+ authenticatedUser.getApiKey(),
+ playerLeftNotification.getGameId());
+
+ return Response.ok().build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/PlayersInGameController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/PlayersInGameController.java
new file mode 100644
index 0000000..6b0e5df
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/PlayersInGameController.java
@@ -0,0 +1,32 @@
+package org.triplea.spitfire.server.controllers.lobby;
+
+import com.google.common.base.Preconditions;
+import io.dropwizard.auth.Auth;
+import java.util.Collection;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import lombok.AllArgsConstructor;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.http.client.lobby.player.PlayerLobbyActionsClient;
+import org.triplea.modules.game.listing.GameListing;
+import org.triplea.spitfire.server.HttpController;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+
+@RolesAllowed(UserRole.ANONYMOUS)
+@AllArgsConstructor
+public class PlayersInGameController extends HttpController {
+ private final GameListing gameListing;
+
+ public static PlayersInGameController build(final GameListing gameListing) {
+ return new PlayersInGameController(gameListing);
+ }
+
+ @POST
+ @Path(PlayerLobbyActionsClient.FETCH_PLAYERS_IN_GAME)
+ public Collection fetchPlayersInGame(
+ @Auth final AuthenticatedUser authenticatedUser, final String gameId) {
+ Preconditions.checkNotNull(gameId);
+ return gameListing.getPlayersInGame(gameId);
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/AccessLogController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/AccessLogController.java
new file mode 100644
index 0000000..d4f10a8
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/AccessLogController.java
@@ -0,0 +1,37 @@
+package org.triplea.spitfire.server.controllers.lobby.moderation;
+
+import com.google.common.base.Preconditions;
+import javax.annotation.Nonnull;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Response;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.http.client.lobby.moderator.toolbox.log.AccessLogRequest;
+import org.triplea.http.client.lobby.moderator.toolbox.log.ToolboxAccessLogClient;
+import org.triplea.modules.moderation.access.log.AccessLogService;
+import org.triplea.spitfire.server.HttpController;
+
+/** Controller to query the access log table, for us by moderators. */
+@Builder
+@RolesAllowed(UserRole.MODERATOR)
+public class AccessLogController extends HttpController {
+ @Nonnull private final AccessLogService accessLogService;
+
+ public static AccessLogController build(final Jdbi jdbi) {
+ return AccessLogController.builder() //
+ .accessLogService(AccessLogService.build(jdbi))
+ .build();
+ }
+
+ @POST
+ @Path(ToolboxAccessLogClient.FETCH_ACCESS_LOG_PATH)
+ public Response fetchAccessLog(final AccessLogRequest accessLogRequest) {
+ Preconditions.checkNotNull(accessLogRequest);
+ Preconditions.checkNotNull(accessLogRequest.getAccessLogSearchRequest());
+ Preconditions.checkNotNull(accessLogRequest.getPagingParams());
+ return Response.ok().entity(accessLogService.fetchAccessLog(accessLogRequest)).build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/BadWordsController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/BadWordsController.java
new file mode 100644
index 0000000..4a0bfcf
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/BadWordsController.java
@@ -0,0 +1,67 @@
+package org.triplea.spitfire.server.controllers.lobby.moderation;
+
+import com.google.common.base.Preconditions;
+import io.dropwizard.auth.Auth;
+import javax.annotation.Nonnull;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Response;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.http.client.lobby.moderator.toolbox.words.ToolboxBadWordsClient;
+import org.triplea.modules.moderation.bad.words.BadWordsService;
+import org.triplea.spitfire.server.HttpController;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+
+/** Controller for servicing moderator toolbox bad-words tab (provides CRUD operations). */
+@Builder
+@RolesAllowed(UserRole.MODERATOR)
+public class BadWordsController extends HttpController {
+ @Nonnull private final BadWordsService badWordsService;
+
+ public static BadWordsController build(final Jdbi jdbi) {
+ return BadWordsController.builder() //
+ .badWordsService(BadWordsService.build(jdbi))
+ .build();
+ }
+
+ /**
+ * Removes a bad word entry from the bad-word table.
+ *
+ * @param word The new bad word entry to remove. We expect this to exist in the table or else we
+ * return a 400.
+ */
+ @POST
+ @Path(ToolboxBadWordsClient.BAD_WORD_REMOVE_PATH)
+ public Response removeBadWord(
+ @Auth final AuthenticatedUser authenticatedUser, final String word) {
+ Preconditions.checkArgument(word != null && !word.isEmpty());
+ badWordsService.removeBadWord(authenticatedUser.getUserIdOrThrow(), word);
+ return Response.ok().entity("Removed bad word: " + word).build();
+ }
+
+ /**
+ * Adds a bad word entry to the bad-word table.
+ *
+ * @param word The new bad word entry to add.
+ */
+ @POST
+ @Path(ToolboxBadWordsClient.BAD_WORD_ADD_PATH)
+ public Response addBadWord(@Auth final AuthenticatedUser authenticatedUser, final String word) {
+ Preconditions.checkArgument(word != null && !word.isEmpty());
+ return badWordsService.addBadWord(authenticatedUser.getUserIdOrThrow(), word)
+ ? Response.ok().build()
+ : Response.status(400)
+ .entity(word + " was not added, it may already have been added")
+ .build();
+ }
+
+ @GET
+ @Path(ToolboxBadWordsClient.BAD_WORD_GET_PATH)
+ public Response getBadWords() {
+ return Response.status(200).entity(badWordsService.getBadWords()).build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/DisconnectUserController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/DisconnectUserController.java
new file mode 100644
index 0000000..1a9836a
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/DisconnectUserController.java
@@ -0,0 +1,44 @@
+package org.triplea.spitfire.server.controllers.lobby.moderation;
+
+import com.google.common.base.Preconditions;
+import io.dropwizard.auth.Auth;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Response;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.domain.data.PlayerChatId;
+import org.triplea.http.client.lobby.moderator.ModeratorLobbyClient;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.modules.moderation.disconnect.user.DisconnectUserAction;
+import org.triplea.spitfire.server.HttpController;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+import org.triplea.web.socket.WebSocketMessagingBus;
+
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@RolesAllowed(UserRole.MODERATOR)
+public class DisconnectUserController extends HttpController {
+
+ private final DisconnectUserAction disconnectUserAction;
+
+ public static DisconnectUserController build(
+ final Jdbi jdbi, final Chatters chatters, final WebSocketMessagingBus playerConnections) {
+ return new DisconnectUserController(
+ DisconnectUserAction.build(jdbi, chatters, playerConnections));
+ }
+
+ @POST
+ @Path(ModeratorLobbyClient.DISCONNECT_PLAYER_PATH)
+ public Response disconnectPlayer(
+ @Auth final AuthenticatedUser authenticatedUser, final String playerIdToBan) {
+ Preconditions.checkNotNull(playerIdToBan);
+
+ final boolean removed =
+ disconnectUserAction.disconnectPlayer(
+ authenticatedUser.getUserIdOrThrow(), PlayerChatId.of(playerIdToBan));
+ return Response.status(removed ? 200 : 400).build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/GameChatHistoryController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/GameChatHistoryController.java
new file mode 100644
index 0000000..5b1a40d
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/GameChatHistoryController.java
@@ -0,0 +1,32 @@
+package org.triplea.spitfire.server.controllers.lobby.moderation;
+
+import java.util.List;
+import java.util.function.Function;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.http.client.lobby.moderator.ChatHistoryMessage;
+import org.triplea.http.client.lobby.moderator.ModeratorLobbyClient;
+import org.triplea.modules.moderation.chat.history.FetchGameChatHistoryModule;
+import org.triplea.spitfire.server.HttpController;
+
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@RolesAllowed(UserRole.MODERATOR)
+public class GameChatHistoryController extends HttpController {
+
+ private final Function> fetchChatHistoryAction;
+
+ public static GameChatHistoryController build(final Jdbi jdbi) {
+ return new GameChatHistoryController(FetchGameChatHistoryModule.build(jdbi));
+ }
+
+ @POST
+ @Path(ModeratorLobbyClient.FETCH_GAME_CHAT_HISTORY)
+ public List fetchGameChatHistory(final String gameId) {
+ return fetchChatHistoryAction.apply(gameId);
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/ModeratorAuditHistoryController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/ModeratorAuditHistoryController.java
new file mode 100644
index 0000000..c359a4f
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/ModeratorAuditHistoryController.java
@@ -0,0 +1,49 @@
+package org.triplea.spitfire.server.controllers.lobby.moderation;
+
+import com.google.common.base.Preconditions;
+import java.util.List;
+import javax.annotation.Nonnull;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Response;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.http.client.lobby.moderator.toolbox.PagingParams;
+import org.triplea.http.client.lobby.moderator.toolbox.log.ModeratorEvent;
+import org.triplea.http.client.lobby.moderator.toolbox.log.ToolboxEventLogClient;
+import org.triplea.modules.moderation.audit.history.ModeratorAuditHistoryService;
+import org.triplea.spitfire.server.HttpController;
+
+/** Http server endpoints for accessing and returning moderator audit history rows. */
+@Builder
+@RolesAllowed(UserRole.MODERATOR)
+public class ModeratorAuditHistoryController extends HttpController {
+ @Nonnull private final ModeratorAuditHistoryService moderatorAuditHistoryService;
+
+ public static ModeratorAuditHistoryController build(final Jdbi jdbi) {
+ return ModeratorAuditHistoryController.builder()
+ .moderatorAuditHistoryService(ModeratorAuditHistoryService.build(jdbi))
+ .build();
+ }
+
+ /**
+ * Use this method to retrieve moderator audit history rows. Presents a paged interface.
+ *
+ * @param pagingParams Parameter JSON object for page number and page size.
+ */
+ @POST
+ @Path(ToolboxEventLogClient.AUDIT_HISTORY_PATH)
+ public Response lookupAuditHistory(final PagingParams pagingParams) {
+ Preconditions.checkArgument(pagingParams != null);
+ Preconditions.checkArgument(pagingParams.getRowNumber() >= 0);
+ Preconditions.checkArgument(pagingParams.getPageSize() > 0);
+
+ final List moderatorEvents =
+ moderatorAuditHistoryService.lookupHistory(
+ pagingParams.getRowNumber(), pagingParams.getPageSize());
+
+ return Response.status(200).entity(moderatorEvents).build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/ModeratorsController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/ModeratorsController.java
new file mode 100644
index 0000000..c11e146
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/ModeratorsController.java
@@ -0,0 +1,80 @@
+package org.triplea.spitfire.server.controllers.lobby.moderation;
+
+import io.dropwizard.auth.Auth;
+import javax.annotation.Nonnull;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Response;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.http.client.lobby.moderator.toolbox.management.ToolboxModeratorManagementClient;
+import org.triplea.modules.moderation.moderators.ModeratorsService;
+import org.triplea.spitfire.server.HttpController;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+
+/**
+ * Provides endpoint for moderator maintenance actions and to support the moderators toolbox
+ * 'moderators' tab. Actions include: adding moderators, removing moderators, and promoting
+ * moderators to 'super-mod'. Some actions are only allowed for super-mods.
+ */
+@Builder
+public class ModeratorsController extends HttpController {
+ @Nonnull private final ModeratorsService moderatorsService;
+
+ /** Factory method , instantiates {@code ModeratorsController} with dependencies. */
+ public static ModeratorsController build(final Jdbi jdbi) {
+ return ModeratorsController.builder() //
+ .moderatorsService(ModeratorsService.build(jdbi))
+ .build();
+ }
+
+ @POST
+ @Path(ToolboxModeratorManagementClient.CHECK_USER_EXISTS_PATH)
+ @RolesAllowed(UserRole.ADMIN)
+ public Response checkUserExists(final String username) {
+ return Response.ok().entity(moderatorsService.userExistsByName(username)).build();
+ }
+
+ @GET
+ @Path(ToolboxModeratorManagementClient.FETCH_MODERATORS_PATH)
+ @RolesAllowed(UserRole.MODERATOR)
+ public Response getModerators() {
+ return Response.ok().entity(moderatorsService.fetchModerators()).build();
+ }
+
+ @GET
+ @Path(ToolboxModeratorManagementClient.IS_ADMIN_PATH)
+ public Response isAdmin(@Auth final AuthenticatedUser authenticatedUser) {
+ return Response.ok().entity(authenticatedUser.getUserRole().equals(UserRole.ADMIN)).build();
+ }
+
+ @POST
+ @Path(ToolboxModeratorManagementClient.REMOVE_MOD_PATH)
+ @RolesAllowed(UserRole.ADMIN)
+ public Response removeMod(
+ @Auth final AuthenticatedUser authenticatedUser, final String moderatorName) {
+ moderatorsService.removeMod(authenticatedUser.getUserIdOrThrow(), moderatorName);
+ return Response.ok().build();
+ }
+
+ @POST
+ @Path(ToolboxModeratorManagementClient.ADD_ADMIN_PATH)
+ @RolesAllowed(UserRole.ADMIN)
+ public Response setAdmin(
+ @Auth final AuthenticatedUser authenticatedUser, final String moderatorName) {
+ moderatorsService.addAdmin(authenticatedUser.getUserIdOrThrow(), moderatorName);
+ return Response.ok().build();
+ }
+
+ @POST
+ @Path(ToolboxModeratorManagementClient.ADD_MODERATOR_PATH)
+ @RolesAllowed(UserRole.ADMIN)
+ public Response addModerator(
+ @Auth final AuthenticatedUser authenticatedUser, final String username) {
+ moderatorsService.addModerator(authenticatedUser.getUserIdOrThrow(), username);
+ return Response.ok().build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/MuteUserController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/MuteUserController.java
new file mode 100644
index 0000000..072e279
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/MuteUserController.java
@@ -0,0 +1,39 @@
+package org.triplea.spitfire.server.controllers.lobby.moderation;
+
+import com.google.common.base.Preconditions;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Response;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.domain.data.PlayerChatId;
+import org.triplea.http.client.lobby.moderator.ModeratorLobbyClient;
+import org.triplea.http.client.lobby.moderator.MuteUserRequest;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.spitfire.server.HttpController;
+
+@AllArgsConstructor
+@Builder
+@RolesAllowed(UserRole.MODERATOR)
+public class MuteUserController extends HttpController {
+
+ private final Chatters chatters;
+
+ public static MuteUserController build(final Chatters chatters) {
+ return new MuteUserController(chatters);
+ }
+
+ @POST
+ @Path(ModeratorLobbyClient.MUTE_USER)
+ public Response muteUser(final MuteUserRequest muteUserRequest) {
+ Preconditions.checkArgument(muteUserRequest != null);
+ Preconditions.checkArgument(muteUserRequest.getPlayerChatId() != null);
+ Preconditions.checkArgument(muteUserRequest.getMinutes() > 0);
+
+ chatters.mutePlayer(
+ PlayerChatId.of(muteUserRequest.getPlayerChatId()), muteUserRequest.getMinutes());
+ return Response.ok().build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/RemoteActionsController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/RemoteActionsController.java
new file mode 100644
index 0000000..d24b34b
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/RemoteActionsController.java
@@ -0,0 +1,58 @@
+package org.triplea.spitfire.server.controllers.lobby.moderation;
+
+import com.google.common.base.Preconditions;
+import io.dropwizard.auth.Auth;
+import javax.annotation.Nonnull;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Response;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.http.client.remote.actions.RemoteActionsClient;
+import org.triplea.java.ArgChecker;
+import org.triplea.java.IpAddressParser;
+import org.triplea.modules.moderation.remote.actions.RemoteActionsModule;
+import org.triplea.spitfire.server.HttpController;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+import org.triplea.web.socket.WebSocketMessagingBus;
+
+/**
+ * Endpoints for moderators to use to issue remote action commands that affect game-hosts, eg:
+ * requesting a server to shutdown.
+ */
+@Builder
+public class RemoteActionsController extends HttpController {
+ @Nonnull private final RemoteActionsModule remoteActionsModule;
+
+ public static RemoteActionsController build(
+ final Jdbi jdbi, final WebSocketMessagingBus gameMessagingBus) {
+ return RemoteActionsController.builder()
+ .remoteActionsModule(RemoteActionsModule.build(jdbi, gameMessagingBus))
+ .build();
+ }
+
+ @POST
+ @Path(RemoteActionsClient.SEND_SHUTDOWN_PATH)
+ @RolesAllowed(UserRole.MODERATOR)
+ public Response sendShutdownSignal(
+ @Auth final AuthenticatedUser authenticatedUser, final String gameId) {
+ Preconditions.checkNotNull(gameId);
+
+ remoteActionsModule.addGameIdForShutdown(authenticatedUser.getUserIdOrThrow(), gameId);
+ return Response.ok().build();
+ }
+
+ @POST
+ @Path(RemoteActionsClient.IS_PLAYER_BANNED_PATH)
+ @RolesAllowed(UserRole.HOST)
+ public Response isUserBanned(final String ipAddress) {
+ ArgChecker.checkNotEmpty(ipAddress);
+ Preconditions.checkArgument(IpAddressParser.isValid(ipAddress));
+
+ final boolean result = remoteActionsModule.isUserBanned(IpAddressParser.fromString(ipAddress));
+
+ return Response.ok().entity(result).build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/UserBanController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/UserBanController.java
new file mode 100644
index 0000000..17752e1
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/UserBanController.java
@@ -0,0 +1,89 @@
+package org.triplea.spitfire.server.controllers.lobby.moderation;
+
+import com.google.common.base.Preconditions;
+import io.dropwizard.auth.Auth;
+import javax.annotation.Nonnull;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Response;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.http.client.lobby.moderator.BanPlayerRequest;
+import org.triplea.http.client.lobby.moderator.ModeratorLobbyClient;
+import org.triplea.http.client.lobby.moderator.toolbox.banned.user.ToolboxUserBanClient;
+import org.triplea.http.client.lobby.moderator.toolbox.banned.user.UserBanParams;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.modules.moderation.ban.user.UserBanService;
+import org.triplea.spitfire.server.HttpController;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+import org.triplea.web.socket.WebSocketMessagingBus;
+
+/** Controller for endpoints to manage user bans, to be used by moderators. */
+@Builder
+@RolesAllowed(UserRole.MODERATOR)
+public class UserBanController extends HttpController {
+ @Nonnull private final UserBanService bannedUsersService;
+
+ public static UserBanController build(
+ final Jdbi jdbi,
+ final Chatters chatters,
+ final WebSocketMessagingBus chatMessagingBus,
+ final WebSocketMessagingBus gameMessagingBus) {
+ return UserBanController.builder()
+ .bannedUsersService(
+ UserBanService.builder()
+ .jdbi(jdbi)
+ .chatters(chatters)
+ .chatMessagingBus(chatMessagingBus)
+ .gameMessagingBus(gameMessagingBus)
+ .build())
+ .build();
+ }
+
+ @GET
+ @Path(ToolboxUserBanClient.GET_USER_BANS_PATH)
+ public Response getUserBans() {
+ return Response.ok().entity(bannedUsersService.getBannedUsers()).build();
+ }
+
+ @POST
+ @Path(ToolboxUserBanClient.REMOVE_USER_BAN_PATH)
+ public Response removeUserBan(
+ @Auth final AuthenticatedUser authenticatedUser, final String banId) {
+ Preconditions.checkArgument(banId != null);
+
+ final boolean removed =
+ bannedUsersService.removeUserBan(authenticatedUser.getUserIdOrThrow(), banId);
+ return Response.status(removed ? 200 : 400).build();
+ }
+
+ /** Endpoint to add a user ban. Returns 200 if the ban is added, 400 if not. */
+ @POST
+ @Path(ToolboxUserBanClient.BAN_USER_PATH)
+ public Response banUser(
+ @Auth final AuthenticatedUser authenticatedUser, final UserBanParams banUserParams) {
+ Preconditions.checkArgument(banUserParams != null);
+ Preconditions.checkArgument(banUserParams.getSystemId() != null);
+ Preconditions.checkArgument(banUserParams.getIp() != null);
+ Preconditions.checkArgument(banUserParams.getUsername() != null);
+ Preconditions.checkArgument(banUserParams.getMinutesToBan() > 0);
+
+ bannedUsersService.banUser(authenticatedUser.getUserIdOrThrow(), banUserParams);
+ return Response.ok().build();
+ }
+
+ @POST
+ @Path(ModeratorLobbyClient.BAN_PLAYER_PATH)
+ public Response banPlayer(
+ @Auth final AuthenticatedUser authenticatedUser, final BanPlayerRequest banPlayerRequest) {
+ Preconditions.checkNotNull(banPlayerRequest);
+ Preconditions.checkNotNull(banPlayerRequest.getPlayerChatId());
+ Preconditions.checkArgument(banPlayerRequest.getBanMinutes() > 0);
+
+ bannedUsersService.banUser(authenticatedUser.getUserIdOrThrow(), banPlayerRequest);
+ return Response.ok().build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/UsernameBanController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/UsernameBanController.java
new file mode 100644
index 0000000..c87cf9e
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/lobby/moderation/UsernameBanController.java
@@ -0,0 +1,61 @@
+package org.triplea.spitfire.server.controllers.lobby.moderation;
+
+import com.google.common.base.Preconditions;
+import io.dropwizard.auth.Auth;
+import javax.annotation.Nonnull;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Response;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.http.client.lobby.moderator.toolbox.banned.name.ToolboxUsernameBanClient;
+import org.triplea.modules.moderation.ban.name.UsernameBanService;
+import org.triplea.spitfire.server.HttpController;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+
+/** Endpoint for use by moderators to view, add and remove player username bans. */
+@Builder
+@RolesAllowed(UserRole.MODERATOR)
+public class UsernameBanController extends HttpController {
+ @Nonnull private final UsernameBanService bannedNamesService;
+
+ public static UsernameBanController build(final Jdbi jdbi) {
+ return UsernameBanController.builder()
+ .bannedNamesService(UsernameBanService.build(jdbi))
+ .build();
+ }
+
+ @POST
+ @Path(ToolboxUsernameBanClient.REMOVE_BANNED_USER_NAME_PATH)
+ public Response removeBannedUsername(
+ @Auth final AuthenticatedUser authenticatedUser, final String username) {
+ Preconditions.checkArgument(username != null && !username.isEmpty());
+ return Response.status(
+ bannedNamesService.removeUsernameBan(authenticatedUser.getUserIdOrThrow(), username)
+ ? 200
+ : 400)
+ .build();
+ }
+
+ @POST
+ @Path(ToolboxUsernameBanClient.ADD_BANNED_USER_NAME_PATH)
+ public Response addBannedUsername(
+ @Auth final AuthenticatedUser authenticatedUser, final String username) {
+ Preconditions.checkArgument(username != null && !username.isEmpty());
+ return Response.status(
+ bannedNamesService.addBannedUserName(
+ authenticatedUser.getUserIdOrThrow(), username.toUpperCase())
+ ? 200
+ : 400)
+ .build();
+ }
+
+ @GET
+ @Path(ToolboxUsernameBanClient.GET_BANNED_USER_NAMES_PATH)
+ public Response getBannedUsernames() {
+ return Response.status(200).entity(bannedNamesService.getBannedUserNames()).build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/CreateAccountController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/CreateAccountController.java
new file mode 100644
index 0000000..f98f7d8
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/CreateAccountController.java
@@ -0,0 +1,47 @@
+package org.triplea.spitfire.server.controllers.user.account;
+
+import com.google.common.base.Preconditions;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.domain.data.LobbyConstants;
+import org.triplea.http.client.lobby.login.CreateAccountRequest;
+import org.triplea.http.client.lobby.login.CreateAccountResponse;
+import org.triplea.http.client.lobby.login.LobbyLoginClient;
+import org.triplea.modules.user.account.create.CreateAccountModule;
+import org.triplea.spitfire.server.HttpController;
+
+@Builder
+public class CreateAccountController extends HttpController {
+
+ @Nonnull private final Function createAccountModule;
+
+ public static CreateAccountController build(final Jdbi jdbi) {
+ return CreateAccountController.builder()
+ .createAccountModule(CreateAccountModule.build(jdbi))
+ .build();
+ }
+
+ @POST
+ @Path(LobbyLoginClient.CREATE_ACCOUNT)
+ public CreateAccountResponse createAccount(final CreateAccountRequest createAccountRequest) {
+ Preconditions.checkArgument(createAccountRequest != null);
+ Preconditions.checkArgument(createAccountRequest.getUsername() != null);
+ Preconditions.checkArgument(
+ createAccountRequest.getUsername().length() <= LobbyConstants.USERNAME_MAX_LENGTH);
+ Preconditions.checkArgument(
+ createAccountRequest.getUsername().length() >= LobbyConstants.USERNAME_MIN_LENGTH);
+ Preconditions.checkArgument(createAccountRequest.getEmail() != null);
+ Preconditions.checkArgument(
+ createAccountRequest.getEmail().length() <= LobbyConstants.EMAIL_MAX_LENGTH);
+ Preconditions.checkArgument(createAccountRequest.getPassword() != null);
+ Preconditions.checkArgument(
+ createAccountRequest.getPassword().length() >= LobbyConstants.PASSWORD_MIN_LENGTH);
+ Preconditions.checkArgument(createAccountRequest.getEmail() != null);
+
+ return createAccountModule.apply(createAccountRequest);
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/ForgotPasswordController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/ForgotPasswordController.java
new file mode 100644
index 0000000..a032cea
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/ForgotPasswordController.java
@@ -0,0 +1,55 @@
+package org.triplea.spitfire.server.controllers.user.account;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.util.function.BiFunction;
+import javax.annotation.Nonnull;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Context;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.dropwizard.common.IpAddressExtractor;
+import org.triplea.http.client.forgot.password.ForgotPasswordClient;
+import org.triplea.http.client.forgot.password.ForgotPasswordRequest;
+import org.triplea.http.client.forgot.password.ForgotPasswordResponse;
+import org.triplea.modules.LobbyModuleConfig;
+import org.triplea.modules.forgot.password.ForgotPasswordModule;
+import org.triplea.spitfire.server.HttpController;
+
+/** Http controller that binds the error upload endpoint with the error report upload handler. */
+@Builder
+@AllArgsConstructor(
+ access = AccessLevel.PACKAGE,
+ onConstructor_ = {@VisibleForTesting})
+public class ForgotPasswordController extends HttpController {
+ @Nonnull private final BiFunction forgotPasswordModule;
+
+ public static ForgotPasswordController build(
+ final LobbyModuleConfig lobbyModuleConfig, final Jdbi jdbi) {
+ return ForgotPasswordController.builder()
+ .forgotPasswordModule(
+ ForgotPasswordModule.build(
+ lobbyModuleConfig.isGameHostConnectivityCheckEnabled(), jdbi))
+ .build();
+ }
+
+ @POST
+ @Path(ForgotPasswordClient.FORGOT_PASSWORD_PATH)
+ public ForgotPasswordResponse requestTempPassword(
+ @Context final HttpServletRequest request,
+ final ForgotPasswordRequest forgotPasswordRequest) {
+
+ if (forgotPasswordRequest.getUsername() == null || forgotPasswordRequest.getEmail() == null) {
+ throw new IllegalArgumentException("Missing username or email in request");
+ }
+
+ return ForgotPasswordResponse.builder()
+ .responseMessage(
+ forgotPasswordModule.apply(
+ IpAddressExtractor.extractIpAddress(request), forgotPasswordRequest))
+ .build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/LoginController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/LoginController.java
new file mode 100644
index 0000000..19b66bf
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/LoginController.java
@@ -0,0 +1,49 @@
+package org.triplea.spitfire.server.controllers.user.account;
+
+import com.google.common.base.Preconditions;
+import javax.annotation.Nonnull;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Context;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.domain.data.LobbyConstants;
+import org.triplea.dropwizard.common.IpAddressExtractor;
+import org.triplea.http.client.LobbyHttpClientConfig;
+import org.triplea.http.client.lobby.login.LobbyLoginClient;
+import org.triplea.http.client.lobby.login.LobbyLoginResponse;
+import org.triplea.http.client.lobby.login.LoginRequest;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.modules.user.account.login.LoginModule;
+import org.triplea.spitfire.server.HttpController;
+
+@Builder
+@AllArgsConstructor
+public class LoginController extends HttpController {
+ @Nonnull private final LoginModule loginModule;
+
+ public static LoginController build(final Jdbi jdbi, final Chatters chatters) {
+ return LoginController.builder() //
+ .loginModule(LoginModule.build(jdbi, chatters))
+ .build();
+ }
+
+ @POST
+ @Path(LobbyLoginClient.LOGIN_PATH)
+ public LobbyLoginResponse login(
+ @Context final HttpServletRequest request, final LoginRequest loginRequest) {
+ Preconditions.checkArgument(loginRequest != null);
+ Preconditions.checkArgument(loginRequest.getName() != null);
+ Preconditions.checkArgument(
+ loginRequest.getName().length() <= LobbyConstants.USERNAME_MAX_LENGTH);
+ Preconditions.checkArgument(
+ loginRequest.getName().length() >= LobbyConstants.USERNAME_MIN_LENGTH);
+
+ return loginModule.doLogin(
+ loginRequest,
+ request.getHeader(LobbyHttpClientConfig.SYSTEM_ID_HEADER),
+ IpAddressExtractor.extractIpAddress(request));
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/PlayerInfoController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/PlayerInfoController.java
new file mode 100644
index 0000000..e9b1399
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/PlayerInfoController.java
@@ -0,0 +1,41 @@
+package org.triplea.spitfire.server.controllers.user.account;
+
+import com.google.common.base.Preconditions;
+import io.dropwizard.auth.Auth;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.domain.data.PlayerChatId;
+import org.triplea.http.client.lobby.moderator.PlayerSummary;
+import org.triplea.http.client.lobby.player.PlayerLobbyActionsClient;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.modules.game.listing.GameListing;
+import org.triplea.modules.player.info.FetchPlayerInfoModule;
+import org.triplea.spitfire.server.HttpController;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@RolesAllowed(UserRole.ANONYMOUS)
+public class PlayerInfoController extends HttpController {
+
+ private final FetchPlayerInfoModule fetchPlayerInfoModule;
+
+ public static PlayerInfoController build(
+ final Jdbi jdbi, final Chatters chatters, final GameListing gameListing) {
+ return new PlayerInfoController(FetchPlayerInfoModule.build(jdbi, chatters, gameListing));
+ }
+
+ @POST
+ @Path(PlayerLobbyActionsClient.FETCH_PLAYER_INFORMATION)
+ public PlayerSummary fetchPlayerInfo(
+ @Auth final AuthenticatedUser authenticatedUser, final String playerId) {
+ Preconditions.checkNotNull(playerId);
+ return UserRole.isModerator(authenticatedUser.getUserRole())
+ ? fetchPlayerInfoModule.fetchPlayerInfoAsModerator(PlayerChatId.of(playerId))
+ : fetchPlayerInfoModule.fetchPlayerInfo(PlayerChatId.of(playerId));
+ }
+}
diff --git a/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/UpdateAccountController.java b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/UpdateAccountController.java
new file mode 100644
index 0000000..d5814a0
--- /dev/null
+++ b/server/dropwizard-server/src/main/java/org/triplea/spitfire/server/controllers/user/account/UpdateAccountController.java
@@ -0,0 +1,66 @@
+package org.triplea.spitfire.server.controllers.user.account;
+
+import com.google.common.base.Preconditions;
+import io.dropwizard.auth.Auth;
+import javax.annotation.Nonnull;
+import javax.annotation.security.RolesAllowed;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Response;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.http.client.lobby.user.account.FetchEmailResponse;
+import org.triplea.http.client.lobby.user.account.UserAccountClient;
+import org.triplea.java.ArgChecker;
+import org.triplea.modules.user.account.update.UpdateAccountService;
+import org.triplea.spitfire.server.HttpController;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+
+/** Controller providing endpoints for user account management. */
+@Builder
+public class UpdateAccountController extends HttpController {
+ @Nonnull private final UpdateAccountService userAccountService;
+
+ /** Instantiates controller with dependencies. */
+ public static UpdateAccountController build(final Jdbi jdbi) {
+ return UpdateAccountController.builder()
+ .userAccountService(UpdateAccountService.build(jdbi))
+ .build();
+ }
+
+ @POST
+ @Path(UserAccountClient.CHANGE_PASSWORD_PATH)
+ @RolesAllowed(UserRole.PLAYER)
+ public Response changePassword(
+ @Auth final AuthenticatedUser authenticatedUser, final String newPassword) {
+ ArgChecker.checkNotEmpty(newPassword);
+ Preconditions.checkArgument(authenticatedUser.getUserIdOrThrow() > 0);
+
+ userAccountService.changePassword(authenticatedUser.getUserIdOrThrow(), newPassword);
+ return Response.ok().build();
+ }
+
+ @GET
+ @Path(UserAccountClient.FETCH_EMAIL_PATH)
+ @RolesAllowed(UserRole.PLAYER)
+ public FetchEmailResponse fetchEmail(@Auth final AuthenticatedUser authenticatedUser) {
+ Preconditions.checkArgument(authenticatedUser.getUserIdOrThrow() > 0);
+
+ return new FetchEmailResponse(
+ userAccountService.fetchEmail(authenticatedUser.getUserIdOrThrow()));
+ }
+
+ @POST
+ @Path(UserAccountClient.CHANGE_EMAIL_PATH)
+ @RolesAllowed(UserRole.PLAYER)
+ public Response changeEmail(
+ @Auth final AuthenticatedUser authenticatedUser, final String newEmail) {
+ ArgChecker.checkNotEmpty(newEmail);
+ Preconditions.checkArgument(authenticatedUser.getUserIdOrThrow() > 0);
+
+ userAccountService.changeEmail(authenticatedUser.getUserIdOrThrow(), newEmail);
+ return Response.ok().build();
+ }
+}
diff --git a/server/dropwizard-server/src/main/resources/configuration.yml b/server/dropwizard-server/src/main/resources/configuration.yml
new file mode 100644
index 0000000..f6f08b6
--- /dev/null
+++ b/server/dropwizard-server/src/main/resources/configuration.yml
@@ -0,0 +1,55 @@
+githubApiToken: ${GITHUB_API_TOKEN:-}
+githubWebServiceUrl: https://api.github.com
+githubGameOrg: triplea-game
+
+latestVersionFetcherEnabled: ${LATEST_VERSION_FETCHER_ENABLED:-true}
+
+# this is to help guarantee that we do not accidentally use test configuration in prod.
+gameHostConnectivityCheckEnabled: ${GAME_HOST_CONNECTIVITY_CHECK_ENABLED:-false}
+
+# Whether to print out SQL statements as executed, useful for debugging.
+logSqlStatements: false
+
+database:
+ driverClass: org.postgresql.Driver
+ user: ${DATABASE_USER:-lobby_user}
+ password: ${DATABASE_PASSWORD:-lobby}
+ url: jdbc:postgresql://${DB_URL:-localhost:5432/lobby_db}
+ properties:
+ charSet: UTF-8
+ # the maximum amount of time to wait on an empty pool before throwing an exception
+ maxWaitForConnection: 1s
+
+ # the SQL query to run when validating a connection's liveness
+ validationQuery: select 1
+
+ # the minimum number of connections to keep open
+ minSize: 8
+
+ # the maximum number of connections to keep open
+ maxSize: 32
+
+ # whether or not idle connections should be validated
+ checkConnectionWhileIdle: false
+
+ # the amount of time to sleep between runs of the idle connection validation, abandoned cleaner and idle pool resizing
+ evictionInterval: 10s
+
+ # the minimum amount of time an connection must sit idle in the pool before it is eligible for eviction
+ minIdleTime: 1 minute
+
+
+logging:
+ # The default level of all loggers. Can be OFF, ERROR, WARN, INFO, DEBUG, TRACE, or ALL.
+ level: INFO
+ loggers:
+ # Set this to DEBUG to troubleshoot HTTP 400 "Unable to process JSON" errors.
+ io.dropwizard.jersey.jackson.JsonProcessingExceptionMapper: INFO
+
+server:
+ applicationConnectors:
+ - type: http
+ port: ${HTTP_PORT:-8080}
+ # useForwardedHeaders is important for when behind a reverse proxy (NGINX)
+ useForwardedHeaders: true
+ adminConnectors: []
diff --git a/server/dropwizard-server/src/test/java/org/triplea/dropwizard/common/IpAddressExtractorTest.java b/server/dropwizard-server/src/test/java/org/triplea/dropwizard/common/IpAddressExtractorTest.java
new file mode 100644
index 0000000..086d6e0
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/dropwizard/common/IpAddressExtractorTest.java
@@ -0,0 +1,47 @@
+package org.triplea.dropwizard.common;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.mockito.Mockito.when;
+
+import javax.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class IpAddressExtractorTest {
+
+ private static final String IPV4 = "127.0.0.1";
+ private static final String IPV6 = "3ffe:1900:4545:3:200:f8ff:fe21:67cf";
+
+ @Mock private HttpServletRequest httpServletRequest;
+
+ @Test
+ void ipv6() {
+ when(httpServletRequest.getRemoteAddr()).thenReturn(IPV6);
+ assertThat(
+ "Normal formatted IPv6 as input should return same value",
+ IpAddressExtractor.extractIpAddress(httpServletRequest),
+ is(IPV6));
+ }
+
+ @Test
+ void ipv6_WithBrackets() {
+ when(httpServletRequest.getRemoteAddr()).thenReturn("[" + IPV6 + "]");
+ assertThat(
+ "Square brackets on the IPv6 should be stripped",
+ IpAddressExtractor.extractIpAddress(httpServletRequest),
+ is(IPV6));
+ }
+
+ @Test
+ void ipv4() {
+ when(httpServletRequest.getRemoteAddr()).thenReturn(IPV4);
+ assertThat(
+ "IPv4 format is returned unaltered",
+ IpAddressExtractor.extractIpAddress(httpServletRequest),
+ is(IPV4));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/ControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/ControllerIntegrationTest.java
new file mode 100644
index 0000000..8743fa3
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/ControllerIntegrationTest.java
@@ -0,0 +1,114 @@
+package org.triplea.spitfire.server;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import com.google.common.base.Preconditions;
+import feign.FeignException;
+import java.util.Collection;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.domain.data.SystemIdLoader;
+import org.triplea.http.client.LobbyHttpClientConfig;
+import org.triplea.test.common.RequiresDatabase;
+
+@ExtendWith(SpitfireServerTestExtension.class)
+@ExtendWith(SpitfireDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@DataSet(value = ControllerIntegrationTest.DATA_SETS, useSequenceFiltering = false)
+@RequiresDatabase
+public abstract class ControllerIntegrationTest {
+
+ @BeforeAll
+ static void setup() {
+ LobbyHttpClientConfig.setConfig(
+ LobbyHttpClientConfig.builder()
+ .clientVersion("1.0")
+ .systemId(SystemIdLoader.load().getValue())
+ .build());
+ }
+
+ @AfterAll
+ static void tearDown() {
+ LobbyHttpClientConfig.setConfig(null);
+ }
+
+ /**
+ * All data sets used for controller integration tests. Note, we include all data even that which
+ * is not used because the tests rely on just a single static data set. If we were doing more
+ * thorough DB level testing we would want different variations of the same data. In this case we
+ * include everything to keep test configuration easy, notably so each test does not need to
+ * define the data it needs.
+ */
+ public static final String DATA_SETS =
+ "user_role.yml,"
+ + "lobby_user.yml,"
+ + "lobby_api_key.yml,"
+ + "access_log.yml,"
+ + "bad_word.yml,"
+ + "banned_username.yml,"
+ + "banned_user.yml,"
+ + "game_hosting_api_key.yml,"
+ + "map_index.yml,"
+ + "map_tag_value.yml,"
+ + "moderator_action_history.yml,"
+ + "temp_password_request.yml"
+ + "";
+
+ public static final ApiKey ADMIN = ApiKey.of("ADMIN");
+ public static final ApiKey MODERATOR = ApiKey.of("MODERATOR");
+ public static final ApiKey PLAYER = ApiKey.of("PLAYER");
+ public static final ApiKey ANONYMOUS = ApiKey.of("ANONYMOUS");
+ public static final ApiKey HOST = ApiKey.of("HOST");
+
+ public static final List NOT_MODERATORS = List.of(ANONYMOUS, PLAYER);
+ public static final List NOT_HOST = List.of(ANONYMOUS, PLAYER, ADMIN, MODERATOR);
+
+ /**
+ * Loops over a collection of api keys each one being used to create a client, we then iterate
+ * over each invocation and invoke it using each client. For each invocation we verify we get an
+ * {@code HttpInteractionException} with status '403' (not authorized).
+ *
+ * @param keys The API keys expected to not have authorization
+ * @param clientBuilder Function taking an API key and returning a client
+ * @param clientInvocations Consumer collection that accepts a client we constructed and should do
+ * an API call where we would expect a 403 using that client.
+ */
+ public static void assertNotAuthorized(
+ final Collection keys,
+ final Function clientBuilder,
+ final Consumer... clientInvocations) {
+ Preconditions.checkArgument(!keys.isEmpty());
+ Preconditions.checkArgument(clientInvocations.length > 0);
+
+ for (final ApiKey key : keys) {
+ final T client = clientBuilder.apply(key);
+
+ for (final Consumer invocation : clientInvocations) {
+ try {
+ invocation.accept(client);
+ fail("Invocation did not produce a 403");
+ } catch (final FeignException feignException) {
+ assertThat(feignException.status(), is(403));
+ }
+ }
+ }
+ }
+
+ public static void assertBadRequest(final Runnable invocation) {
+ try {
+ invocation.run();
+ fail("Invocation did not produce a 400");
+ } catch (final FeignException feignException) {
+ assertThat(feignException.status(), is(400));
+ }
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/DropwizardServerExtension.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/DropwizardServerExtension.java
new file mode 100644
index 0000000..3fe791b
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/DropwizardServerExtension.java
@@ -0,0 +1,77 @@
+package org.triplea.spitfire.server;
+
+import com.google.common.base.Preconditions;
+import io.dropwizard.Configuration;
+import io.dropwizard.testing.DropwizardTestSupport;
+import java.net.URI;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ParameterContext;
+import org.junit.jupiter.api.extension.ParameterResolutionException;
+import org.junit.jupiter.api.extension.ParameterResolver;
+
+/**
+ * Extension to start a dropwizard server. This extension can read the 'configuration.yml' of the
+ * server which allows access to database connectivity parameters. Tests can have several objects
+ * injected into them by declaring those objects as constructor or test method parameters. Those
+ * objects are:
+ *
+ *
+ * - URI - server URI of the running test server
+ *
- JDBI - jdbi instance
+ *
- JDBI on-demand class - any class (DAO classes) that can be instantiated via
+ * Jdbi.onDemand(Class)
+ *
- Server Configuration - the configuration class of the dropwizard server
+ *
+ *
+ * @param Server configuration type.
+ */
+public abstract class DropwizardServerExtension
+ implements BeforeAllCallback, ParameterResolver {
+
+ private static URI serverUri;
+
+ /**
+ * Implementations should return a *static* instance of DropwizardTestSupport. If returning a
+ * local instance, the test server may be turned off after a single test class is done executing
+ * and subsequent tests could fail due to server not being on. Example implementation
+ *
+ * {@code
+ * @Getter
+ * static DropwizardTestSupport testSupport =
+ * new DropwizardTestSupport<>(MapsServer.class, "configuration.yml")
+ * }
+ */
+ protected abstract DropwizardTestSupport getSupport();
+
+ @Override
+ public void beforeAll(final ExtensionContext context) throws Exception {
+ final DropwizardTestSupport support = getSupport();
+ support.before();
+
+ final String localUri = "http://localhost:" + support.getLocalPort();
+ serverUri = URI.create(localUri);
+ }
+
+ @Override
+ public boolean supportsParameter(
+ final ParameterContext parameterContext, final ExtensionContext extensionContext)
+ throws ParameterResolutionException {
+ return parameterContext.getParameter().getType().equals(URI.class)
+ || parameterContext
+ .getParameter()
+ .getType()
+ .equals(getSupport().getConfiguration().getClass());
+ }
+
+ @Override
+ public Object resolveParameter(
+ final ParameterContext parameterContext, final ExtensionContext extensionContext)
+ throws ParameterResolutionException {
+ if (parameterContext.getParameter().getType().equals(URI.class)) {
+ return Preconditions.checkNotNull(serverUri);
+ } else {
+ return getSupport().getConfiguration();
+ }
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/SpitfireDatabaseTestSupport.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/SpitfireDatabaseTestSupport.java
new file mode 100644
index 0000000..3590989
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/SpitfireDatabaseTestSupport.java
@@ -0,0 +1,18 @@
+package org.triplea.spitfire.server;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.jdbi.v3.core.mapper.RowMapperFactory;
+import org.triplea.db.LobbyModuleRowMappers;
+import org.triplea.spitfire.database.DatabaseTestSupport;
+
+public class SpitfireDatabaseTestSupport extends DatabaseTestSupport {
+
+ @Override
+ protected Collection rowMappers() {
+ final List rowMappers = new ArrayList<>();
+ rowMappers.addAll(LobbyModuleRowMappers.rowMappers());
+ return rowMappers;
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/SpitfireServerTestExtension.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/SpitfireServerTestExtension.java
new file mode 100644
index 0000000..d22b884
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/SpitfireServerTestExtension.java
@@ -0,0 +1,19 @@
+package org.triplea.spitfire.server;
+
+import io.dropwizard.testing.DropwizardTestSupport;
+
+/**
+ * Use with {@code @ExtendWith(SpitfireServerTestExtension.class)} Tests extended with this
+ * extension will launch a drop wizard server when the test starts up. If a server is already
+ * running at the start of a test, it will be re-used.
+ */
+public class SpitfireServerTestExtension extends DropwizardServerExtension {
+
+ private static final DropwizardTestSupport testSupport =
+ new DropwizardTestSupport<>(SpitfireServerApplication.class, "configuration.yml");
+
+ @Override
+ public DropwizardTestSupport getSupport() {
+ return testSupport;
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/TestData.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/TestData.java
new file mode 100644
index 0000000..0967df3
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/TestData.java
@@ -0,0 +1,25 @@
+package org.triplea.spitfire.server;
+
+import java.time.Instant;
+import lombok.experimental.UtilityClass;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.domain.data.LobbyGame;
+
+@UtilityClass
+public class TestData {
+ public static final ApiKey API_KEY = ApiKey.of("test");
+
+ public static final LobbyGame LOBBY_GAME =
+ LobbyGame.builder()
+ .hostAddress("127.0.0.1")
+ .hostPort(12)
+ .hostName("name")
+ .mapName("map")
+ .playerCount(3)
+ .gameRound(1)
+ .epochMilliTimeStarted(Instant.now().toEpochMilli())
+ .passworded(false)
+ .status("Waiting For Players")
+ .comments("comments")
+ .build();
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authentication/ApiKeyAuthenticatorTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authentication/ApiKeyAuthenticatorTest.java
new file mode 100644
index 0000000..db2310e
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authentication/ApiKeyAuthenticatorTest.java
@@ -0,0 +1,78 @@
+package org.triplea.spitfire.server.access.authentication;
+
+import static com.github.npathai.hamcrestopt.OptionalMatchers.isEmpty;
+import static com.github.npathai.hamcrestopt.OptionalMatchers.isPresentAndIs;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.Mockito.when;
+
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.triplea.db.dao.api.key.GameHostingApiKeyDaoWrapper;
+import org.triplea.db.dao.api.key.PlayerApiKeyDaoWrapper;
+import org.triplea.db.dao.api.key.PlayerApiKeyLookupRecord;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.spitfire.server.TestData;
+
+@ExtendWith(MockitoExtension.class)
+class ApiKeyAuthenticatorTest {
+ private static final ApiKey API_KEY = TestData.API_KEY;
+
+ private static final PlayerApiKeyLookupRecord PLAYER_DATA =
+ PlayerApiKeyLookupRecord.builder()
+ .username("player-name")
+ .userRole(UserRole.PLAYER)
+ .userId(100)
+ .apiKeyId(123)
+ .playerChatId("chat-id")
+ .build();
+
+ @Mock private PlayerApiKeyDaoWrapper apiKeyDao;
+ @Mock private GameHostingApiKeyDaoWrapper gameHostingApiKeyDaoWrapper;
+
+ @InjectMocks private ApiKeyAuthenticator authenticator;
+
+ @Test
+ void keyNotFound() {
+ when(apiKeyDao.lookupByApiKey(API_KEY)).thenReturn(Optional.empty());
+ when(gameHostingApiKeyDaoWrapper.isKeyValid(API_KEY)).thenReturn(false);
+
+ final Optional result = authenticator.authenticate(API_KEY.getValue());
+
+ assertThat(result, isEmpty());
+ }
+
+ @Test
+ void playerKeyFound() {
+ when(apiKeyDao.lookupByApiKey(API_KEY)).thenReturn(Optional.of(PLAYER_DATA));
+
+ final Optional result = authenticator.authenticate(API_KEY.getValue());
+
+ assertThat(
+ result,
+ isPresentAndIs(
+ AuthenticatedUser.builder()
+ .apiKey(API_KEY)
+ .userId(PLAYER_DATA.getUserId())
+ .name(PLAYER_DATA.getUsername())
+ .userRole(PLAYER_DATA.getUserRole())
+ .build()));
+ }
+
+ @Test
+ void hostKeyFound() {
+ when(apiKeyDao.lookupByApiKey(API_KEY)).thenReturn(Optional.empty());
+ when(gameHostingApiKeyDaoWrapper.isKeyValid(API_KEY)).thenReturn(true);
+
+ final Optional result = authenticator.authenticate(API_KEY.getValue());
+
+ assertThat(
+ result,
+ isPresentAndIs(
+ AuthenticatedUser.builder().apiKey(API_KEY).userRole(UserRole.HOST).build()));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authentication/AuthenticatedUserTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authentication/AuthenticatedUserTest.java
new file mode 100644
index 0000000..2b3e31e
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authentication/AuthenticatedUserTest.java
@@ -0,0 +1,124 @@
+package org.triplea.spitfire.server.access.authentication;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.List;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.spitfire.server.TestData;
+
+/**
+ * We're not testing a whole lot here, the 'custom' getters of AuthenticatedUser do state checks for
+ * validity. In this test we are running through invalid and valid states to ensure we have the
+ * checks correct.
+ */
+@SuppressWarnings("InnerClassMayBeStatic")
+class AuthenticatedUserTest {
+
+ private static final ApiKey API_KEY = TestData.API_KEY;
+ private static final String NAME = "name";
+ private static final int USER_ID = 10;
+
+ private static final List rolesWithUserNames =
+ List.of(UserRole.ADMIN, UserRole.MODERATOR, UserRole.PLAYER, UserRole.ANONYMOUS);
+
+ @Nested
+ class GetName {
+ @Test
+ void validStates() {
+ AuthenticatedUser.builder().userRole(UserRole.HOST).apiKey(API_KEY).build().getName();
+ rolesWithUserNames.forEach(
+ roleWithName ->
+ AuthenticatedUser.builder()
+ .name(NAME)
+ .userRole(roleWithName)
+ .apiKey(API_KEY)
+ .build()
+ .getName());
+ }
+
+ @Test
+ void invalidStateIsHostWithName() {
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ AuthenticatedUser.builder()
+ .userRole(UserRole.HOST)
+ .name(NAME)
+ .apiKey(API_KEY)
+ .build()
+ .getName());
+ }
+
+ @Test
+ void invalidStateIsNonHostWithoutName() {
+ // following invalid because name is null
+ rolesWithUserNames.forEach(
+ roleWithName ->
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ AuthenticatedUser.builder()
+ .userRole(roleWithName)
+ .apiKey(API_KEY)
+ .build()
+ .getName()));
+ }
+ }
+
+ @Nested
+ class GetUserIdOrThrow {
+ @Test
+ void validStates() {
+ // all named roles except anonymous and host can have a user id
+ AuthenticatedUser.builder()
+ .userId(USER_ID)
+ .userRole(UserRole.ADMIN)
+ .apiKey(API_KEY)
+ .build()
+ .getUserIdOrThrow();
+
+ AuthenticatedUser.builder()
+ .userId(USER_ID)
+ .userRole(UserRole.MODERATOR)
+ .apiKey(API_KEY)
+ .build()
+ .getUserIdOrThrow();
+
+ AuthenticatedUser.builder()
+ .userId(USER_ID)
+ .userRole(UserRole.PLAYER)
+ .apiKey(API_KEY)
+ .build()
+ .getUserIdOrThrow();
+ }
+
+ @Test
+ void hostWithUserIdIsInvalid() {
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ AuthenticatedUser.builder()
+ .userId(USER_ID)
+ .userRole(UserRole.HOST)
+ .apiKey(API_KEY)
+ .build()
+ .getUserIdOrThrow());
+ }
+
+ @Test
+ void anonymousWithUserIdIsInvalid() {
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ AuthenticatedUser.builder()
+ .userId(USER_ID)
+ .userRole(UserRole.ANONYMOUS)
+ .apiKey(API_KEY)
+ .build()
+ .getUserIdOrThrow());
+ }
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authorization/BannedPlayerFilterIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authorization/BannedPlayerFilterIntegrationTest.java
new file mode 100644
index 0000000..672f5ca
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authorization/BannedPlayerFilterIntegrationTest.java
@@ -0,0 +1,56 @@
+package org.triplea.spitfire.server.access.authorization;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import feign.FeignException;
+import java.net.URI;
+import java.util.Map;
+import lombok.AllArgsConstructor;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.LobbyHttpClientConfig;
+import org.triplea.http.client.lobby.login.LobbyLoginClient;
+import org.triplea.http.client.lobby.login.LobbyLoginResponse;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+/**
+ * In this test we'll verify that user-banned filter is configured. We'll do ban by system-id as
+ * that is easier to control and does not need us to ban localhost. We'll verify the ban against the
+ * login endpoint as that will be a common first endpoint for banned users to attempt to access
+ * after being booted and banned.
+ */
+@AllArgsConstructor
+class BannedPlayerFilterIntegrationTest extends ControllerIntegrationTest {
+ private static final String BANNED_SYSTEM_ID = "system-id";
+
+ private final URI localhost;
+
+ @Test
+ void banned() {
+ // we expect an exception because user is banned. If user were not banned
+ // then the login attempt would return result.
+ final FeignException exception =
+ Assertions.assertThrows(
+ FeignException.class,
+ () ->
+ LobbyLoginClient.newClient(localhost, headersWithSystemId(BANNED_SYSTEM_ID))
+ .login("user", null));
+
+ assertThat(exception.status(), is(401));
+ }
+
+ @Test
+ void notBanned() {
+ // a not-banned user should be able to login with anonymouse user account
+ final LobbyLoginResponse lobbyLoginResponse =
+ LobbyLoginClient.newClient(localhost, headersWithSystemId("any other system id"))
+ .login("user", null);
+ assertThat(lobbyLoginResponse.isSuccess(), is(true));
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private static Map headersWithSystemId(final String systemId) {
+ return Map.of(LobbyHttpClientConfig.SYSTEM_ID_HEADER, systemId);
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authorization/BannedPlayerFilterTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authorization/BannedPlayerFilterTest.java
new file mode 100644
index 0000000..9fc5ba3
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authorization/BannedPlayerFilterTest.java
@@ -0,0 +1,132 @@
+package org.triplea.spitfire.server.access.authorization;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.core.StringContains.containsString;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Optional;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.triplea.db.dao.user.ban.BanLookupRecord;
+import org.triplea.db.dao.user.ban.UserBanDao;
+import org.triplea.http.client.LobbyHttpClientConfig;
+
+@SuppressWarnings("SameParameterValue")
+@ExtendWith(MockitoExtension.class)
+class BannedPlayerFilterTest {
+
+ private static final Instant NOW_TIME = Instant.parse("2001-01-01T23:59:59.0Z");
+ private static final String BAN_ID = "public-id";
+ private static final String IP = "sample-ip";
+ private static final String SYSTEM_ID = "system-id";
+
+ @Mock private UserBanDao userBanDao;
+ @Mock private Clock clock;
+ @Mock private HttpServletRequest request;
+
+ @InjectMocks private BannedPlayerFilter bannedPlayerFilter;
+
+ @Mock private ContainerRequestContext containerRequestContext;
+
+ void givenIpAndSystemId() {
+ when(request.getRemoteAddr()).thenReturn(IP);
+ when(request.getHeader(LobbyHttpClientConfig.SYSTEM_ID_HEADER)).thenReturn(SYSTEM_ID);
+ }
+
+ @Nested
+ class NotBanned {
+ @Test
+ @DisplayName("IP and System ID are ok, verify request is *not* aborted")
+ void notBanned() {
+ givenIpAndSystemId();
+ givenIpIsNotBanned();
+
+ bannedPlayerFilter.filter(containerRequestContext);
+
+ verifyRequestIsAllowed();
+ }
+
+ private void givenIpIsNotBanned() {
+ when(userBanDao.lookupBan(IP, SYSTEM_ID)).thenReturn(Optional.empty());
+ }
+
+ private void verifyRequestIsAllowed() {
+ verify(containerRequestContext, never()).abortWith(any());
+ }
+ }
+
+ @Nested
+ class Banned {
+ @Test
+ @DisplayName("IP or System ID are banned, verify request is aborted")
+ void ipIsBanned() {
+ givenIpAndSystemId();
+ givenCurrentTimeIs(NOW_TIME);
+ givenBannedWithExpiryAndBanIdentifier(NOW_TIME.plus(5, ChronoUnit.MINUTES), BAN_ID);
+
+ bannedPlayerFilter.filter(containerRequestContext);
+
+ verifyForbiddenRequest();
+ }
+
+ private void givenCurrentTimeIs(final Instant nowTime) {
+ when(clock.instant()).thenReturn(nowTime);
+ }
+
+ private void givenBannedWithExpiryAndBanIdentifier(
+ final Instant banExpiry, final String banId) {
+ when(userBanDao.lookupBan(IP, SYSTEM_ID))
+ .thenReturn(
+ Optional.of(
+ BanLookupRecord.builder().banExpiry(banExpiry).publicBanId(banId).build()));
+ }
+
+ private void verifyForbiddenRequest() {
+ final ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(Response.class);
+ verify(containerRequestContext).abortWith(responseCaptor.capture());
+
+ final Response response = responseCaptor.getValue();
+ assertThat(response.getStatus(), is(Status.UNAUTHORIZED.getStatusCode()));
+ assertThat(response.getStatusInfo().getReasonPhrase(), containsString("5 minutes"));
+ assertThat(response.getStatusInfo().getReasonPhrase(), containsString(BAN_ID));
+ }
+ }
+
+ @Nested
+ class MissingSystemId {
+ @Test
+ @DisplayName("Missing system ID is a bad request and should be rejected")
+ void noSystemId() {
+ when(request.getHeader(LobbyHttpClientConfig.SYSTEM_ID_HEADER)).thenReturn(null);
+
+ bannedPlayerFilter.filter(containerRequestContext);
+
+ verifyForbiddenRequest();
+ }
+
+ private void verifyForbiddenRequest() {
+ final ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(Response.class);
+ verify(containerRequestContext).abortWith(responseCaptor.capture());
+
+ final Response response = responseCaptor.getValue();
+ assertThat(response.getStatus(), is(Status.UNAUTHORIZED.getStatusCode()));
+ }
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authorization/RoleAuthorizerTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authorization/RoleAuthorizerTest.java
new file mode 100644
index 0000000..d4e2993
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/access/authorization/RoleAuthorizerTest.java
@@ -0,0 +1,90 @@
+package org.triplea.spitfire.server.access.authorization;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.mockito.Mockito.when;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.spitfire.server.access.authentication.AuthenticatedUser;
+
+@ExtendWith(MockitoExtension.class)
+class RoleAuthorizerTest {
+
+ @Mock private AuthenticatedUser authenticatedUser;
+
+ private final RoleAuthorizer roleAuthorizer = new RoleAuthorizer();
+
+ @Test
+ void authenticateAsAdmin() {
+ givenUserAssumingEachRoleInSequence();
+
+ // first iteration, user is admin
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.ADMIN), is(true));
+ // second iteration, user is moderator
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.ADMIN), is(false));
+ // third iteration, user is player
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.ADMIN), is(false));
+ // fourth iteration, user is anonymous
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.ADMIN), is(false));
+ // fifth iteration, user is host
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.ADMIN), is(false));
+ }
+
+ /** Rotates user role on each authorization iteration. */
+ private void givenUserAssumingEachRoleInSequence() {
+ when(authenticatedUser.getUserRole())
+ .thenReturn(UserRole.ADMIN)
+ .thenReturn(UserRole.MODERATOR)
+ .thenReturn(UserRole.PLAYER)
+ .thenReturn(UserRole.ANONYMOUS)
+ .thenReturn(UserRole.HOST);
+ }
+
+ @Test
+ void authenticateAsModerator() {
+ givenUserAssumingEachRoleInSequence();
+
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.MODERATOR), is(true));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.MODERATOR), is(true));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.MODERATOR), is(false));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.MODERATOR), is(false));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.MODERATOR), is(false));
+ }
+
+ @Test
+ void authenticateAsPlayer() {
+ givenUserAssumingEachRoleInSequence();
+
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.PLAYER), is(true));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.PLAYER), is(true));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.PLAYER), is(true));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.PLAYER), is(false));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.PLAYER), is(false));
+ }
+
+ @Test
+ void authenticateAsAnonymous() {
+ givenUserAssumingEachRoleInSequence();
+
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.ANONYMOUS), is(true));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.ANONYMOUS), is(true));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.ANONYMOUS), is(true));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.ANONYMOUS), is(true));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.ANONYMOUS), is(false));
+ }
+
+ @Test
+ void authenticateAsHost() {
+ givenUserAssumingEachRoleInSequence();
+
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.HOST), is(false));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.HOST), is(false));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.HOST), is(false));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.HOST), is(false));
+ assertThat(roleAuthorizer.authorize(authenticatedUser, UserRole.HOST), is(true));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/AccessLogControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/AccessLogControllerIntegrationTest.java
new file mode 100644
index 0000000..564bc19
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/AccessLogControllerIntegrationTest.java
@@ -0,0 +1,100 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsEmptyCollection.empty;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsEqual.equalTo;
+import static org.hamcrest.core.IsNot.not;
+import static org.hamcrest.core.IsNull.notNullValue;
+
+import java.net.URI;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.moderator.toolbox.PagingParams;
+import org.triplea.http.client.lobby.moderator.toolbox.log.AccessLogSearchRequest;
+import org.triplea.http.client.lobby.moderator.toolbox.log.ToolboxAccessLogClient;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class AccessLogControllerIntegrationTest extends ControllerIntegrationTest {
+ private final URI localhost;
+ private final ToolboxAccessLogClient client;
+
+ AccessLogControllerIntegrationTest(final URI localhost) {
+ this.localhost = localhost;
+ this.client = ToolboxAccessLogClient.newClient(localhost, ControllerIntegrationTest.MODERATOR);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void mustBeAuthorized() {
+ assertNotAuthorized(
+ ControllerIntegrationTest.NOT_MODERATORS,
+ apiKey -> ToolboxAccessLogClient.newClient(localhost, apiKey),
+ client ->
+ client.getAccessLog(
+ AccessLogSearchRequest.builder().username("username").ip("ip").build(),
+ PagingParams.builder().pageSize(1).rowNumber(0).build()),
+ client -> client.getAccessLog(PagingParams.builder().pageSize(1).rowNumber(0).build()));
+ }
+
+ @Test
+ void getAccessLog() {
+ final var result = client.getAccessLog(PagingParams.builder().pageSize(1).rowNumber(0).build());
+
+ assertThat(result, is(not(empty())));
+ assertThat(result.get(0).getAccessDate(), is(notNullValue()));
+ assertThat(result.get(0).getSystemId(), is(notNullValue()));
+ assertThat(result.get(0).getUsername(), is(notNullValue()));
+ }
+
+ @Test
+ void getAccessLogUnauthorizedCase() {
+ assertNotAuthorized(
+ ControllerIntegrationTest.NOT_MODERATORS,
+ apiKey -> ToolboxAccessLogClient.newClient(localhost, apiKey),
+ client -> client.getAccessLog(PagingParams.builder().pageSize(1).rowNumber(0).build()));
+ }
+
+ /**
+ * First we do a fetch of access log and we pick the first record. We then search by username and
+ * IP address using data from the first record, which should guarantee that our search will return
+ * at least that one result (if not more).
+ */
+ @Test
+ void getAccessLogWithSearchParams() {
+ // first search for a user-name and IP that exist in the system
+ final var firstListing =
+ client.getAccessLog(PagingParams.builder().pageSize(1).rowNumber(0).build()).get(0);
+
+ final var result =
+ client.getAccessLog(
+ AccessLogSearchRequest.builder()
+ .username(firstListing.getUsername())
+ .ip(firstListing.getIp())
+ .build(),
+ PagingParams.builder().pageSize(1).rowNumber(0).build());
+
+ assertThat(
+ "We expect there to have been at least one match for sure", result, is(not(empty())));
+ assertThat(
+ "Username should match what we searched for",
+ result.get(0).getUsername(),
+ is(firstListing.getUsername()));
+ assertThat(
+ "IP should match what we searched for", result.get(0).getIp(), is(firstListing.getIp()));
+ }
+
+ @Test
+ void emptySearchShouldBeSameAsAllSearch() {
+ // do a search with empty search parameters (should return everything)
+ final var emptySearchResult =
+ client.getAccessLog(
+ AccessLogSearchRequest.builder().build(),
+ PagingParams.builder().pageSize(1).rowNumber(0).build());
+
+ // do a full listing of all records
+ final var allSearchResult =
+ client.getAccessLog(PagingParams.builder().pageSize(1).rowNumber(0).build());
+
+ assertThat(emptySearchResult, is(equalTo(allSearchResult)));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/BadWordsControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/BadWordsControllerIntegrationTest.java
new file mode 100644
index 0000000..eae4fd2
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/BadWordsControllerIntegrationTest.java
@@ -0,0 +1,56 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsEmptyCollection.empty;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsCollectionContaining.hasItem;
+import static org.hamcrest.core.IsNot.not;
+
+import java.net.URI;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.moderator.toolbox.words.ToolboxBadWordsClient;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class BadWordsControllerIntegrationTest extends ControllerIntegrationTest {
+
+ private final ToolboxBadWordsClient client;
+
+ BadWordsControllerIntegrationTest(final URI localhost) {
+ this.client = ToolboxBadWordsClient.newClient(localhost, ControllerIntegrationTest.MODERATOR);
+ }
+
+ @Test
+ void badRequests() {
+ assertBadRequest(() -> client.addBadWord(""));
+ assertBadRequest(() -> client.removeBadWord(""));
+ }
+
+ @Test
+ void listBadWords() {
+ assertThat(client.getBadWords(), is(not(empty())));
+ }
+
+ @Test
+ void removeBadWord() {
+ final List badWords = client.getBadWords();
+
+ // remember the first entry
+ final String firstBadWord = badWords.get(0);
+
+ // remove the first entry
+ client.removeBadWord(firstBadWord);
+
+ // make sure entry is removed from listing
+ assertThat(client.getBadWords(), not(hasItem(firstBadWord)));
+ }
+
+ @Test
+ void addBadWord() {
+ assertThat(client.getBadWords(), not(hasItem("bad-word-to-be-added")));
+
+ client.addBadWord("bad-word-to-be-added");
+
+ assertThat(client.getBadWords(), hasItem("bad-word-to-be-added"));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/CreateAccountControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/CreateAccountControllerIntegrationTest.java
new file mode 100644
index 0000000..fc98bea
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/CreateAccountControllerIntegrationTest.java
@@ -0,0 +1,56 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsNot.not;
+
+import java.net.URI;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.login.CreateAccountResponse;
+import org.triplea.http.client.lobby.login.LobbyLoginClient;
+import org.triplea.java.Sha512Hasher;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class CreateAccountControllerIntegrationTest extends ControllerIntegrationTest {
+ private static final String USERNAME = "user-name";
+ private static final String EMAIL = "email@email.com";
+ private static final String PASSWORD = Sha512Hasher.hashPasswordWithSalt("pass");
+
+ private static final String USERNAME_1 = "user-name_1";
+ private static final String EMAIL_1 = "email1@email.com";
+ private static final String PASSWORD_1 = Sha512Hasher.hashPasswordWithSalt("pass_1");
+
+ private final LobbyLoginClient client;
+
+ CreateAccountControllerIntegrationTest(final URI localhost) {
+ this.client = LobbyLoginClient.newClient(localhost);
+ }
+
+ @Test
+ void badRequests() {
+ assertBadRequest(() -> client.login(null, null));
+ assertBadRequest(() -> client.createAccount(null, null, null));
+ assertBadRequest(() -> client.createAccount("user", "email@email.com", null));
+ assertBadRequest(() -> client.createAccount("user", null, "password"));
+ assertBadRequest(() -> client.createAccount(null, "email@email.com", "password"));
+ }
+
+ @Test
+ void createAccountAndDoLogin() {
+ final CreateAccountResponse result = client.createAccount(USERNAME, EMAIL, PASSWORD);
+ assertThat(result, is(CreateAccountResponse.SUCCESS_RESPONSE));
+
+ // verify login with the new account
+ final var loginResponse = client.login(USERNAME, PASSWORD);
+ assertThat(loginResponse.isSuccess(), is(true));
+ }
+
+ @Test
+ void duplicateAccountCreateFails() {
+ client.createAccount(USERNAME_1, EMAIL_1, PASSWORD_1);
+
+ final CreateAccountResponse result = client.createAccount(USERNAME_1, EMAIL_1, PASSWORD_1);
+
+ assertThat(result, is(not(CreateAccountResponse.SUCCESS_RESPONSE)));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/DisconnectUserControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/DisconnectUserControllerIntegrationTest.java
new file mode 100644
index 0000000..f041d3e
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/DisconnectUserControllerIntegrationTest.java
@@ -0,0 +1,34 @@
+package org.triplea.spitfire.server.controllers;
+
+import java.net.URI;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.triplea.domain.data.PlayerChatId;
+import org.triplea.http.client.lobby.moderator.ModeratorLobbyClient;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class DisconnectUserControllerIntegrationTest extends ControllerIntegrationTest {
+ private static final PlayerChatId CHAT_ID = PlayerChatId.of("chat-id");
+ private final URI localhost;
+ private final ModeratorLobbyClient client;
+
+ DisconnectUserControllerIntegrationTest(final URI localhost) {
+ this.localhost = localhost;
+ this.client = ModeratorLobbyClient.newClient(localhost, ControllerIntegrationTest.MODERATOR);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void mustBeAuthorized() {
+ assertNotAuthorized(
+ ControllerIntegrationTest.NOT_MODERATORS,
+ apiKey -> ModeratorLobbyClient.newClient(localhost, apiKey),
+ client -> client.disconnectPlayer("chat-id"));
+ }
+
+ @Test
+ @DisplayName("Send disconnect request, verify we get a 400 for chat-id not found")
+ void disconnectPlayer() {
+ assertBadRequest(() -> client.disconnectPlayer(CHAT_ID.getValue()));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/ForgotPasswordControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/ForgotPasswordControllerIntegrationTest.java
new file mode 100644
index 0000000..73df643
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/ForgotPasswordControllerIntegrationTest.java
@@ -0,0 +1,27 @@
+package org.triplea.spitfire.server.controllers;
+
+import java.net.URI;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.forgot.password.ForgotPasswordClient;
+import org.triplea.http.client.forgot.password.ForgotPasswordRequest;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class ForgotPasswordControllerIntegrationTest extends ControllerIntegrationTest {
+ private final ForgotPasswordClient client;
+
+ ForgotPasswordControllerIntegrationTest(final URI localhost) {
+ this.client = ForgotPasswordClient.newClient(localhost);
+ }
+
+ @Test
+ void badArgs() {
+ assertBadRequest(
+ () -> client.sendForgotPasswordRequest(ForgotPasswordRequest.builder().build()));
+ }
+
+ @Test
+ void forgotPassword() {
+ client.sendForgotPasswordRequest(
+ ForgotPasswordRequest.builder().username("user").email("email").build());
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/GameChatHistoryControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/GameChatHistoryControllerIntegrationTest.java
new file mode 100644
index 0000000..74b3ed1
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/GameChatHistoryControllerIntegrationTest.java
@@ -0,0 +1,19 @@
+package org.triplea.spitfire.server.controllers;
+
+import java.net.URI;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.moderator.ModeratorLobbyClient;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class GameChatHistoryControllerIntegrationTest extends ControllerIntegrationTest {
+ private final ModeratorLobbyClient client;
+
+ GameChatHistoryControllerIntegrationTest(final URI localhost) {
+ client = ModeratorLobbyClient.newClient(localhost, ControllerIntegrationTest.MODERATOR);
+ }
+
+ @Test
+ void fetchGameChatHistory() {
+ client.fetchChatHistoryForGame("game-id");
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/GameHostingControllerTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/GameHostingControllerTest.java
new file mode 100644
index 0000000..03d7bf1
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/GameHostingControllerTest.java
@@ -0,0 +1,24 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import java.net.URI;
+import org.hamcrest.core.IsNull;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.game.hosting.request.GameHostingClient;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class GameHostingControllerTest extends ControllerIntegrationTest {
+ private final GameHostingClient client;
+
+ GameHostingControllerTest(final URI localhost) {
+ client = GameHostingClient.newClient(localhost);
+ }
+
+ @Test
+ void sendGameHostingRequest() {
+ final var result = client.sendGameHostingRequest();
+ assertThat(result, is(IsNull.notNullValue()));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/GameListingControllerTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/GameListingControllerTest.java
new file mode 100644
index 0000000..6c16946
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/GameListingControllerTest.java
@@ -0,0 +1,24 @@
+package org.triplea.spitfire.server.controllers;
+
+import java.net.URI;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.game.lobby.watcher.GameListingClient;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class GameListingControllerTest extends ControllerIntegrationTest {
+ private final GameListingClient client;
+
+ GameListingControllerTest(final URI localhost) {
+ client = GameListingClient.newClient(localhost, ControllerIntegrationTest.MODERATOR);
+ }
+
+ @Test
+ void fetchGames() {
+ client.fetchGameListing();
+ }
+
+ @Test
+ void bootGame() {
+ client.bootGame("game-id");
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/GameListingWebsocketIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/GameListingWebsocketIntegrationTest.java
new file mode 100644
index 0000000..b1125b8
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/GameListingWebsocketIntegrationTest.java
@@ -0,0 +1,137 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+
+import feign.Headers;
+import feign.RequestLine;
+import java.net.URI;
+import java.util.List;
+import java.util.function.Consumer;
+import lombok.RequiredArgsConstructor;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.triplea.domain.data.LobbyGame;
+import org.triplea.http.client.HttpClient;
+import org.triplea.http.client.HttpConstants;
+import org.triplea.http.client.lobby.AuthenticationHeaders;
+import org.triplea.http.client.lobby.game.lobby.watcher.GamePostingRequest;
+import org.triplea.http.client.lobby.game.lobby.watcher.GamePostingResponse;
+import org.triplea.http.client.lobby.game.lobby.watcher.LobbyGameListing;
+import org.triplea.http.client.lobby.game.lobby.watcher.LobbyWatcherClient;
+import org.triplea.http.client.web.socket.client.connections.PlayerToLobbyConnection;
+import org.triplea.http.client.web.socket.messages.envelopes.game.listing.LobbyGameRemovedMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.game.listing.LobbyGameUpdatedMessage;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+import org.triplea.spitfire.server.TestData;
+import org.triplea.spitfire.server.controllers.lobby.LobbyWatcherController;
+
+/*
+GameListingWebsocketIntegrationTest > Post a game, verify listener is notified FAILED
+ Wanted but not invoked:
+ gameUpdatedListener.accept(
+ LobbyGameListing(gameId=690deee5-8cf7-4815-82b3-d0cc9424fa53,
+ lobbyGame=LobbyGame(hostAddress=127.0.0.1, hostPort=12, hostName=name,
+ mapName=map, playerCount=3, gameRound=1, epochMilliTimeStarted=1599358874438,
+ mapVersion=1, passworded=false, status=Waiting For Players, comments=comments))
+ );
+ -> at org.triplea.modules.game.GameListingWebsocketIntegrationTest.verifyPostGame(
+ GameListingWebsocketIntegrationTest.java:94)
+ Actually, there were zero interactions with this mock.
+ at org.triplea.modules.game.GameListingWebsocketIntegrationTest.verifyPostGame(
+ GameListingWebsocketIntegrationTest.java:94)
+ */
+@Disabled // Disabled due to flakiness, the above error is frequently seen and needs to be resolved.
+@ExtendWith(MockitoExtension.class)
+@RequiredArgsConstructor
+class GameListingWebsocketIntegrationTest extends ControllerIntegrationTest {
+ private static final GamePostingRequest GAME_POSTING_REQUEST =
+ GamePostingRequest.builder().playerNames(List.of()).lobbyGame(TestData.LOBBY_GAME).build();
+
+ private final URI localhost;
+
+ @Mock private Consumer gameUpdatedListener;
+ @Mock private Consumer gameRemovedListener;
+
+ private LobbyWatcherClient lobbyWatcherClient;
+
+ private GamePostingTestOverrideClient gamePostingTestOverrideClient;
+
+ /**
+ * A special test-only HTTP client that can post games to lobby without a reverse connectivity
+ * check. This allows us to post games without actually hosting a game.
+ */
+ @Headers({HttpConstants.CONTENT_TYPE_JSON, HttpConstants.ACCEPT_JSON})
+ private interface GamePostingTestOverrideClient {
+ /** Posts a game, for test-only, returns a game-id from server. */
+ @RequestLine("POST " + LobbyWatcherController.TEST_ONLY_GAME_POSTING_PATH)
+ GamePostingResponse postGame(GamePostingRequest gamePostingRequest);
+ }
+
+ @BeforeEach
+ void setUp() {
+ gamePostingTestOverrideClient =
+ HttpClient.newClient(
+ GamePostingTestOverrideClient.class,
+ localhost,
+ new AuthenticationHeaders(ControllerIntegrationTest.HOST).createHeaders());
+
+ lobbyWatcherClient = LobbyWatcherClient.newClient(localhost, ControllerIntegrationTest.HOST);
+
+ final var playerToLobbyConnection =
+ new PlayerToLobbyConnection(
+ localhost,
+ ControllerIntegrationTest.PLAYER,
+ error -> {
+ throw new AssertionError(error);
+ });
+ playerToLobbyConnection.addMessageListener(
+ LobbyGameUpdatedMessage.TYPE,
+ messageContext -> gameUpdatedListener.accept(messageContext.getLobbyGameListing()));
+ playerToLobbyConnection.addMessageListener(
+ LobbyGameRemovedMessage.TYPE,
+ messageContext -> gameRemovedListener.accept(messageContext.getGameId()));
+ }
+
+ @Test
+ @DisplayName("Post a game, verify listener is notified")
+ void verifyPostGame() {
+ final String gameId = postGame();
+
+ verify(gameUpdatedListener, timeout(2000L))
+ .accept(
+ LobbyGameListing.builder()
+ .gameId(gameId)
+ .lobbyGame(GAME_POSTING_REQUEST.getLobbyGame())
+ .build());
+ }
+
+ private String postGame() {
+ return gamePostingTestOverrideClient.postGame(GAME_POSTING_REQUEST).getGameId();
+ }
+
+ @Test
+ @DisplayName("Post and then remove a game, verify remove listener is notified")
+ void removeGame() {
+ final String gameId = postGame();
+ lobbyWatcherClient.removeGame(gameId);
+
+ verify(gameRemovedListener, timeout(2000L).atLeastOnce()).accept(gameId);
+ }
+
+ @Test
+ @DisplayName("Post and then update a game, verify update listener is notified")
+ void gameUpdated() {
+ final String gameId = postGame();
+ final LobbyGame updatedGame = TestData.LOBBY_GAME.withComments("new comment");
+ lobbyWatcherClient.updateGame(gameId, updatedGame);
+
+ verify(gameUpdatedListener, timeout(2000L))
+ .accept(LobbyGameListing.builder().gameId(gameId).lobbyGame(updatedGame).build());
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/LobbyChatIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/LobbyChatIntegrationTest.java
new file mode 100644
index 0000000..a8f84ac
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/LobbyChatIntegrationTest.java
@@ -0,0 +1,257 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.collection.IsEmptyCollection.empty;
+import static org.hamcrest.core.IsCollectionContaining.hasItems;
+
+import java.net.URI;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.triplea.domain.data.ChatParticipant;
+import org.triplea.domain.data.UserName;
+import org.triplea.http.client.web.socket.client.connections.PlayerToLobbyConnection;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.ChatReceivedMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.ChatterListingMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.PlayerJoinedMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.PlayerLeftMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.PlayerSlapReceivedMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.PlayerStatusUpdateReceivedMessage;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+/**
+ * End-to-end test where we go through a chat sequence exercising all chat features. Runs through
+ * the following chat sequence:
+ *
+ *
+ * - Moderator joins
+ *
- Chatter joins
+ *
- Chatter speaks
+ *
- Chatter slaps moderator
+ *
- Chatter updates their status
+ *
- Chatter leaves
+ *
+ *
+ * Chat is very listener driven, for each of the above we expect various events to be received. To
+ * execute this test we will create a series of lists for each event respective to each player.
+ * After executing each event we'll wait for the expected list to gain an event (or timeout and
+ * fail). Then, after receiving an event, we'll verify the event data and then continue in the
+ * sequence.
+ *
+ * Of note, when a player joins they will receive two events, a 'join' event and a 'connected'
+ * event. The 'join' event should notify that they themselves have joined (all players receive
+ * this), and second, only they should receive a 'connected' event that informs them of all players
+ * that have joined. So we expect 'moderator' to be the only player in the connected list, when
+ * chatter joins we expect both moderator and chatter to be in the connected event list.
+ */
+@RequiredArgsConstructor
+class LobbyChatIntegrationTest extends ControllerIntegrationTest {
+ private static final int MESSAGE_TIMEOUT = 3000;
+
+ private static final String STATUS = "status";
+ private static final String MESSAGE = "sample";
+
+ private static final UserName MODERATOR_NAME = UserName.of("mod");
+ private static final ChatParticipant MODERATOR =
+ ChatParticipant.builder()
+ .userName(MODERATOR_NAME.getValue())
+ .isModerator(true)
+ .status("")
+ .playerChatId("moderator-chat-id")
+ .build();
+
+ private static final UserName CHATTER_NAME = UserName.of("chatter");
+ private static final ChatParticipant CHATTER =
+ ChatParticipant.builder()
+ .userName(CHATTER_NAME.getValue())
+ .isModerator(false)
+ .status("")
+ .playerChatId("player-chat-id")
+ .build();
+
+ private final URI localhost;
+
+ private final List modPlayerStatusEvents = new ArrayList<>();
+ private final List modPlayerLeftEvents = new ArrayList<>();
+ private final List modPlayerJoinedEvents = new ArrayList<>();
+ private final List modPlayerSlappedEvents = new ArrayList<>();
+ private final List modMessageEvents = new ArrayList<>();
+ private final List modConnectedEvents = new ArrayList<>();
+ private PlayerToLobbyConnection moderator;
+
+ private final List chatterPlayerStatusEvents =
+ new ArrayList<>();
+ private final List chatterPlayerLeftEvents = new ArrayList<>();
+ private final List chatterPlayerJoinedEvents = new ArrayList<>();
+ private final List chatterPlayerSlappedEvents = new ArrayList<>();
+ private final List chatterMessageEvents = new ArrayList<>();
+ private final List chatterConnectedEvents = new ArrayList<>();
+ private PlayerToLobbyConnection chatter;
+
+ private PlayerToLobbyConnection createModerator() {
+ final PlayerToLobbyConnection newModerator =
+ new PlayerToLobbyConnection(
+ localhost,
+ ControllerIntegrationTest.MODERATOR,
+ err -> {
+ throw new AssertionError("Error on moderator: " + err);
+ });
+ newModerator.addMessageListener(
+ PlayerStatusUpdateReceivedMessage.TYPE, modPlayerStatusEvents::add);
+ newModerator.addMessageListener(PlayerLeftMessage.TYPE, modPlayerLeftEvents::add);
+ newModerator.addMessageListener(PlayerJoinedMessage.TYPE, modPlayerJoinedEvents::add);
+ newModerator.addMessageListener(PlayerSlapReceivedMessage.TYPE, modPlayerSlappedEvents::add);
+ newModerator.addMessageListener(ChatReceivedMessage.TYPE, modMessageEvents::add);
+ newModerator.addMessageListener(ChatterListingMessage.TYPE, modConnectedEvents::add);
+ return newModerator;
+ }
+
+ private PlayerToLobbyConnection createChatter() {
+ final PlayerToLobbyConnection newChatter =
+ new PlayerToLobbyConnection(
+ localhost,
+ ControllerIntegrationTest.PLAYER,
+ err -> {
+ throw new AssertionError("Error on chatter: " + err);
+ });
+ newChatter.addMessageListener(
+ PlayerStatusUpdateReceivedMessage.TYPE, chatterPlayerStatusEvents::add);
+ newChatter.addMessageListener(PlayerLeftMessage.TYPE, chatterPlayerLeftEvents::add);
+ newChatter.addMessageListener(PlayerJoinedMessage.TYPE, chatterPlayerJoinedEvents::add);
+ newChatter.addMessageListener(PlayerSlapReceivedMessage.TYPE, chatterPlayerSlappedEvents::add);
+ newChatter.addMessageListener(ChatReceivedMessage.TYPE, chatterMessageEvents::add);
+ newChatter.addMessageListener(ChatterListingMessage.TYPE, chatterConnectedEvents::add);
+ return newChatter;
+ }
+
+ @Test
+ @DisplayName("Run through a chat sequence with two players exercising all chat functionality.")
+ void chatTest() {
+ moderator = createModerator();
+ moderatorConnects();
+ chatter = createChatter();
+ chatterConnects();
+ chatterChats();
+ chatterSlapsMod();
+ chatterUpdatesStatus();
+ chatterLeaves();
+ }
+
+ private void moderatorConnects() {
+ moderator.sendConnectToChatMessage();
+ // mod is notified that only 'mod' is in chat
+ verifyConnectedPlayers(modConnectedEvents, MODERATOR);
+ // mod should be notified of their own entry into chat
+ verifyPlayerJoinedEvent(modPlayerJoinedEvents, MODERATOR);
+ }
+
+ private void chatterConnects() {
+ chatter.sendConnectToChatMessage();
+ // chatter should be notified that both 'mod' and 'chatter' are in chat
+ verifyConnectedPlayers(chatterConnectedEvents, MODERATOR, CHATTER);
+ // chatter should be notified of their own entry into chat
+ verifyPlayerJoinedEvent(chatterPlayerJoinedEvents, CHATTER);
+ // wait for moderator to receive message that chatter joined
+ waitForMessage(modPlayerJoinedEvents, 2);
+ // moderator is notified that chatter has joined
+ assertThat(
+ modPlayerJoinedEvents.get(1).getChatParticipant().getUserName().getValue(),
+ is(CHATTER_NAME.getValue()));
+ assertThat(modPlayerJoinedEvents.get(1).getChatParticipant().isModerator(), is(false));
+ // moderator should *not* receive a connected event when chatter joins
+ assertThat(modConnectedEvents, hasSize(1));
+ }
+
+ private static void verifyConnectedPlayers(
+ final List connectedEvents, final ChatParticipant... participants) {
+ waitForMessage(connectedEvents);
+ assertThat(connectedEvents.get(0).getChatters(), hasItems(participants));
+ }
+
+ private static void verifyPlayerJoinedEvent(
+ final List playerJoinedEvents, final ChatParticipant expectedPlayer) {
+ waitForMessage(playerJoinedEvents);
+ assertThat(playerJoinedEvents.get(0).getChatParticipant(), is(expectedPlayer));
+ }
+
+ private void chatterChats() {
+ // chatter chats
+ chatter.sendChatMessage(MESSAGE);
+ // moderator should receive chat message from chatter
+ verifyChatMessageEvent(modMessageEvents, new ChatReceivedMessage(CHATTER_NAME, MESSAGE));
+ // chatter should receive their own chat message
+ verifyChatMessageEvent(chatterMessageEvents, new ChatReceivedMessage(CHATTER_NAME, MESSAGE));
+ }
+
+ private static void verifyChatMessageEvent(
+ final List chatMessageEvents,
+ final ChatReceivedMessage expectedChatMessage) {
+ waitForMessage(chatMessageEvents);
+ assertThat(chatMessageEvents.get(0), is(expectedChatMessage));
+ }
+
+ private void chatterSlapsMod() {
+ // chatter slaps mod
+ chatter.slapPlayer(MODERATOR_NAME);
+ // moderator is notified of the slap
+ waitForMessage(modPlayerSlappedEvents);
+
+ final PlayerSlapReceivedMessage moderatorSlapped =
+ PlayerSlapReceivedMessage.builder()
+ .slappingPlayer(CHATTER_NAME.getValue())
+ .slappedPlayer(MODERATOR_NAME.getValue())
+ .build();
+
+ assertThat(modPlayerSlappedEvents.get(0), is(moderatorSlapped));
+ // chatter is notified of the slap
+ waitForMessage(chatterPlayerSlappedEvents);
+ assertThat(chatterPlayerSlappedEvents.get(0), is(moderatorSlapped));
+ }
+
+ private void chatterUpdatesStatus() {
+
+ // chatter updates their status
+ chatter.updateStatus(STATUS);
+ // moderator is notified of the status update
+ waitForMessage(modPlayerStatusEvents);
+ assertThat(
+ modPlayerStatusEvents.get(0),
+ is(new PlayerStatusUpdateReceivedMessage(CHATTER_NAME, STATUS)));
+ waitForMessage(chatterPlayerStatusEvents);
+ assertThat(
+ chatterPlayerStatusEvents.get(0),
+ is(new PlayerStatusUpdateReceivedMessage(CHATTER_NAME, STATUS)));
+
+ // chatter is notified of their own status update
+
+ }
+
+ private void chatterLeaves() {
+ // chatter disconnects
+ chatter.close();
+ // chatter is disconnected before they can be notified of their own disconnect
+ assertThat(chatterPlayerLeftEvents, empty());
+
+ // moderator is notified chatter has left
+ waitForMessage(modPlayerLeftEvents);
+ assertThat(modPlayerLeftEvents.get(0).getUserName(), is(CHATTER_NAME));
+ }
+
+ private static void waitForMessage(final Collection messageBuffer) {
+ waitForMessage(messageBuffer, 1);
+ }
+
+ /** Does a busy wait loop until the given collection is at least a given size. */
+ private static void waitForMessage(final Collection messageBuffer, final int minCount) {
+ Awaitility.await()
+ .atMost(Duration.ofMillis(MESSAGE_TIMEOUT))
+ .until(() -> messageBuffer.size() >= minCount);
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/LobbyWatcherControllerTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/LobbyWatcherControllerTest.java
new file mode 100644
index 0000000..d8e13df
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/LobbyWatcherControllerTest.java
@@ -0,0 +1,88 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import java.net.URI;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.triplea.domain.data.UserName;
+import org.triplea.http.client.lobby.game.lobby.watcher.ChatUploadParams;
+import org.triplea.http.client.lobby.game.lobby.watcher.GamePostingRequest;
+import org.triplea.http.client.lobby.game.lobby.watcher.LobbyWatcherClient;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+import org.triplea.spitfire.server.TestData;
+
+class LobbyWatcherControllerTest extends ControllerIntegrationTest {
+ private static final GamePostingRequest GAME_POSTING_REQUEST =
+ GamePostingRequest.builder().playerNames(List.of()).lobbyGame(TestData.LOBBY_GAME).build();
+
+ private final URI localhost;
+ private final LobbyWatcherClient client;
+
+ LobbyWatcherControllerTest(final URI localhost) {
+ this.localhost = localhost;
+ client = LobbyWatcherClient.newClient(localhost, ControllerIntegrationTest.HOST);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void mustBeAuthorized() {
+ assertNotAuthorized(
+ ControllerIntegrationTest.NOT_HOST,
+ apiKey -> LobbyWatcherClient.newClient(localhost, apiKey),
+ client -> client.removeGame("game"),
+ client -> client.postGame(GAME_POSTING_REQUEST),
+ client -> client.sendKeepAlive("game-id"),
+ client ->
+ client.uploadChatMessage(
+ ChatUploadParams.builder()
+ .fromPlayer(UserName.of("player"))
+ .chatMessage("chat")
+ .gameId("game-id")
+ .build()),
+ client -> client.playerJoined("game-id", UserName.of("player-0")),
+ client -> client.playerLeft("game-id", UserName.of("player-0")));
+ }
+
+ @Test
+ void postGame() {
+ client.postGame(GAME_POSTING_REQUEST);
+ }
+
+ @Test
+ void removeGame() {
+ client.removeGame("game-id");
+ }
+
+ @Test
+ void keepAlive() {
+ final boolean result = client.sendKeepAlive("game-id");
+ assertThat(result, is(false));
+ }
+
+ @Test
+ void updateGame() {
+ client.postGame(GAME_POSTING_REQUEST);
+ }
+
+ @Test
+ void uploadChat() {
+ client.uploadChatMessage(
+ ChatUploadParams.builder()
+ .fromPlayer(UserName.of("player"))
+ .chatMessage("chat")
+ .gameId("game-id")
+ .build());
+ }
+
+ @Test
+ void notifyPlayerJoined() {
+ client.playerJoined("game-id", UserName.of("player-0"));
+ }
+
+ @Test
+ void notifyPlayerLeft() {
+ client.playerJoined("game-id", UserName.of("player-1"));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/LobbyWebsocketClientIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/LobbyWebsocketClientIntegrationTest.java
new file mode 100644
index 0000000..f73eabc
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/LobbyWebsocketClientIntegrationTest.java
@@ -0,0 +1,93 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+import java.net.URI;
+import java.time.Duration;
+import lombok.AllArgsConstructor;
+import org.awaitility.Awaitility;
+import org.java_websocket.client.WebSocketClient;
+import org.java_websocket.handshake.ServerHandshake;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.web.socket.WebsocketPaths;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+@Disabled // Disabled until this can be made more reliable, seeing failure listed below
+/*
+ ERROR [2020-09-06 01:52:03,515] org.triplea.web.socket.WebSocketMessagingBus: Error-id processing
+ websocket message, returning an error message to user. Error id:
+ fc66dc4b-61d8-4f87-9444-a00ba418c008
+ ! java.nio.channels.ClosedChannelException: null
+ ! at org.eclipse.jetty.io.WriteFlusher.onClose(WriteFlusher.java:492)
+ ! at org.eclipse.jetty.io.AbstractEndPoint.onClose(AbstractEndPoint.java:353)
+ ! at org.eclipse.jetty.io.ChannelEndPoint.onClose(ChannelEndPoint.java:215)
+ ! at org.eclipse.jetty.io.AbstractEndPoint.doOnClose(AbstractEndPoint.java:225)
+ ! at org.eclipse.jetty.io.AbstractEndPoint.shutdownOutput(AbstractEndPoint.java:157)
+ ! at org.eclipse.jetty.websocket.common.io.AbstractWebSocketConnection.disconnect
+ (AbstractWebSocketConnection.java:327)
+ ! at org.eclipse.jetty.websocket.common.io.DisconnectCallback.failed(DisconnectCallback.java:36)
+ ! at org.eclipse.jetty.websocket.common.io.AbstractWebSocketConnection.close
+ (AbstractWebSocketConnection.java:200)
+ ! at org.eclipse.jetty.websocket.common.io.AbstractWebSocketConnection.onFillable
+ (AbstractWebSocketConnection.java:452)
+ ! at org.eclipse.jetty.io.AbstractConnection$ReadCallback.succeeded(AbstractConnection.java:305)
+ ! at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:103)
+ ! at org.eclipse.jetty.io.ChannelEndPoint$2.run(ChannelEndPoint.java:117)
+ ! at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.runTask(EatWhatYouKill.java:333)
+ ! at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.doProduce(EatWhatYouKill.java:310)
+ ! at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.tryProduce(EatWhatYouKill.java:168)
+ ! at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.run(EatWhatYouKill.java:126)
+ ! at org.eclipse.jetty.util.thread.ReservedThreadExecutor$ReservedThread.run
+ (ReservedThreadExecutor.java:366)
+ ! at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:698)
+ ! at org.eclipse.jetty.util.thread.QueuedThreadPool$Runner.run(QueuedThreadPool.java:804)
+ ! at java.base/java.lang.Thread.run(Thread.java:834)
+ INFO [2020-09-06 01:52:03,521] org.triplea.dropwizard.test.DropwizardServerExtension:
+ Running database cleanup..
+*/
+@AllArgsConstructor
+class LobbyWebsocketClientIntegrationTest extends ControllerIntegrationTest {
+ private final URI localhost;
+
+ @Test
+ @DisplayName("Verify basic websocket operations: open, send, close")
+ void verifyConnectivity(final URI host) throws Exception {
+ final URI websocketUri = URI.create(host + WebsocketPaths.PLAYER_CONNECTIONS);
+
+ final WebSocketClient client =
+ new WebSocketClient(websocketUri) {
+ @Override
+ public void onOpen(final ServerHandshake serverHandshake) {}
+
+ @Override
+ public void onMessage(final String message) {}
+
+ @Override
+ public void onClose(final int code, final String reason, final boolean remote) {}
+
+ @Override
+ public void onError(final Exception ex) {}
+ };
+
+ assertThat(client.isOpen(), is(false));
+ client.connect();
+
+ Awaitility.await()
+ .pollDelay(Duration.ofMillis(10))
+ .atMost(Duration.ofSeconds(1))
+ .until(client::isOpen);
+ client.send("sending! Just to make sure there are no exception here.");
+
+ // small wait to process any responses
+ Thread.sleep(10);
+
+ client.close();
+ Awaitility.await()
+ .pollDelay(Duration.ofMillis(10))
+ .atMost(Duration.ofMillis(100))
+ .until(() -> !client.isOpen());
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/LoginControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/LoginControllerIntegrationTest.java
new file mode 100644
index 0000000..140406a
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/LoginControllerIntegrationTest.java
@@ -0,0 +1,53 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsNull.notNullValue;
+import static org.hamcrest.core.IsNull.nullValue;
+
+import java.net.URI;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.login.LobbyLoginClient;
+import org.triplea.http.client.lobby.login.LobbyLoginResponse;
+import org.triplea.java.Sha512Hasher;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class LoginControllerIntegrationTest extends ControllerIntegrationTest {
+ private static final String USERNAME = "player";
+ private static final String PASSWORD = Sha512Hasher.hashPasswordWithSalt("password");
+ private static final String TEMP_PASSWORD = Sha512Hasher.hashPasswordWithSalt("temp-password");
+ private static final String INVALID_PASSWORD = "invalid";
+
+ private final LobbyLoginClient client;
+
+ LoginControllerIntegrationTest(final URI localhost) {
+ client = LobbyLoginClient.newClient(localhost);
+ }
+
+ @Test
+ void invalidLogin() {
+ final LobbyLoginResponse response = client.login(USERNAME, INVALID_PASSWORD);
+
+ assertThat(response.getFailReason(), notNullValue());
+ assertThat(response.getApiKey(), nullValue());
+ assertThat(response.isPasswordChangeRequired(), is(false));
+ }
+
+ @Test
+ void validLogin() {
+ final LobbyLoginResponse response = client.login(USERNAME, PASSWORD);
+
+ assertThat(response.getFailReason(), nullValue());
+ assertThat(response.getApiKey(), notNullValue());
+ assertThat(response.isPasswordChangeRequired(), is(false));
+ }
+
+ @Test
+ void tempPasswordLogin() {
+ final LobbyLoginResponse response = client.login(USERNAME, TEMP_PASSWORD);
+
+ assertThat(response.getFailReason(), nullValue());
+ assertThat(response.getApiKey(), notNullValue());
+ assertThat(response.isPasswordChangeRequired(), is(true));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/ModeratorAuditHistoryControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/ModeratorAuditHistoryControllerIntegrationTest.java
new file mode 100644
index 0000000..fe0fa40
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/ModeratorAuditHistoryControllerIntegrationTest.java
@@ -0,0 +1,45 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsEmptyCollection.empty;
+import static org.hamcrest.core.IsNot.not;
+import static org.hamcrest.core.IsNull.notNullValue;
+
+import java.net.URI;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.moderator.toolbox.PagingParams;
+import org.triplea.http.client.lobby.moderator.toolbox.log.ModeratorEvent;
+import org.triplea.http.client.lobby.moderator.toolbox.log.ToolboxEventLogClient;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class ModeratorAuditHistoryControllerIntegrationTest extends ControllerIntegrationTest {
+ private final URI localhost;
+ private final ToolboxEventLogClient client;
+
+ ModeratorAuditHistoryControllerIntegrationTest(final URI localhost) {
+ this.localhost = localhost;
+ this.client = ToolboxEventLogClient.newClient(localhost, ControllerIntegrationTest.MODERATOR);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void mustBeAuthorized() {
+ assertNotAuthorized(
+ ControllerIntegrationTest.NOT_MODERATORS,
+ apiKey -> ToolboxEventLogClient.newClient(localhost, apiKey),
+ client ->
+ client.lookupModeratorEvents(PagingParams.builder().pageSize(1).rowNumber(0).build()));
+ }
+
+ @Test
+ void fetchHistory() {
+ final List response =
+ client.lookupModeratorEvents(PagingParams.builder().pageSize(1).rowNumber(0).build());
+ assertThat(response, not(empty()));
+ assertThat(response.get(0).getActionTarget(), notNullValue());
+ assertThat(response.get(0).getModeratorAction(), notNullValue());
+ assertThat(response.get(0).getDate(), notNullValue());
+ assertThat(response.get(0).getModeratorName(), notNullValue());
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/ModeratorsControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/ModeratorsControllerIntegrationTest.java
new file mode 100644
index 0000000..6dada5b
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/ModeratorsControllerIntegrationTest.java
@@ -0,0 +1,60 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import java.net.URI;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.moderator.toolbox.management.ToolboxModeratorManagementClient;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class ModeratorsControllerIntegrationTest extends ControllerIntegrationTest {
+ private final URI localhost;
+ private final ToolboxModeratorManagementClient playerClient;
+ private final ToolboxModeratorManagementClient moderatorClient;
+ private final ToolboxModeratorManagementClient adminClient;
+
+ ModeratorsControllerIntegrationTest(final URI localhost) {
+ this.localhost = localhost;
+ this.playerClient =
+ ToolboxModeratorManagementClient.newClient(localhost, ControllerIntegrationTest.PLAYER);
+ this.moderatorClient =
+ ToolboxModeratorManagementClient.newClient(localhost, ControllerIntegrationTest.MODERATOR);
+ this.adminClient =
+ ToolboxModeratorManagementClient.newClient(localhost, ControllerIntegrationTest.ADMIN);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void mustBeAuthorized() {
+ assertNotAuthorized(
+ List.of(ControllerIntegrationTest.PLAYER, ControllerIntegrationTest.MODERATOR),
+ apiKey -> ToolboxModeratorManagementClient.newClient(localhost, apiKey),
+ client -> client.addAdmin("admin"),
+ client -> client.addModerator("mod"),
+ client -> client.removeMod("mod"));
+ }
+
+ @Test
+ void isAdmin() {
+ assertThat(playerClient.isCurrentUserAdmin(), is(false));
+ assertThat(moderatorClient.isCurrentUserAdmin(), is(false));
+ assertThat(adminClient.isCurrentUserAdmin(), is(true));
+ }
+
+ @Test
+ void removeMod() {
+ adminClient.removeMod("mod");
+ }
+
+ @Test
+ void addMod() {
+ adminClient.addModerator("mod");
+ }
+
+ @Test
+ void setAdmin() {
+ adminClient.addAdmin("admin");
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/MuteUserControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/MuteUserControllerIntegrationTest.java
new file mode 100644
index 0000000..8e1ca50
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/MuteUserControllerIntegrationTest.java
@@ -0,0 +1,20 @@
+package org.triplea.spitfire.server.controllers;
+
+import java.net.URI;
+import org.junit.jupiter.api.Test;
+import org.triplea.domain.data.PlayerChatId;
+import org.triplea.http.client.lobby.moderator.ModeratorLobbyClient;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class MuteUserControllerIntegrationTest extends ControllerIntegrationTest {
+ private final ModeratorLobbyClient client;
+
+ MuteUserControllerIntegrationTest(final URI localhost) {
+ this.client = ModeratorLobbyClient.newClient(localhost, ControllerIntegrationTest.MODERATOR);
+ }
+
+ @Test
+ void muteUser() {
+ client.muteUser(PlayerChatId.of("chat-id"), 600);
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/PlayerInfoControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/PlayerInfoControllerIntegrationTest.java
new file mode 100644
index 0000000..7bc30e1
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/PlayerInfoControllerIntegrationTest.java
@@ -0,0 +1,59 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsNull.notNullValue;
+import static org.hamcrest.core.IsNull.nullValue;
+
+import java.net.URI;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.moderator.PlayerSummary;
+import org.triplea.http.client.lobby.player.PlayerLobbyActionsClient;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class PlayerInfoControllerIntegrationTest extends ControllerIntegrationTest {
+
+ private final PlayerLobbyActionsClient client;
+ private final PlayerLobbyActionsClient moderatorClient;
+
+ PlayerInfoControllerIntegrationTest(final URI localhost) {
+ client = PlayerLobbyActionsClient.newClient(localhost, ControllerIntegrationTest.ANONYMOUS);
+ moderatorClient =
+ PlayerLobbyActionsClient.newClient(localhost, ControllerIntegrationTest.MODERATOR);
+ }
+
+ @Test
+ @Disabled
+ /*
+ Disabled: non-deterministic test, needs work.
+ Failure message:
+ Reason: Bad Request
+ Http 400 - Bad Request: Player could not be found, have they left chat?
+ */
+ void fetchPlayerInfo() {
+ final PlayerSummary playerSummary = client.fetchPlayerInformation("chatter-chat-id2");
+
+ assertThat(playerSummary.getCurrentGames(), is(notNullValue()));
+ assertThat(playerSummary.getIp(), is(nullValue()));
+ assertThat(playerSummary.getSystemId(), is(nullValue()));
+ }
+
+ @Test
+ @Disabled
+ /*
+ Disabled: non-deterministic test, needs work.
+ Failure message:
+ Reason: Bad Request
+ Http 400 - Bad Request: Player could not be found, have they left chat?
+ */
+ void fetchPlayerInfoAsModerator() {
+ final PlayerSummary playerSummary = moderatorClient.fetchPlayerInformation("chatter-chat-id2");
+
+ assertThat(playerSummary.getIp(), is(notNullValue()));
+ assertThat(playerSummary.getRegistrationDateEpochMillis(), is(notNullValue()));
+ assertThat(playerSummary.getAliases(), is(notNullValue()));
+ assertThat(playerSummary.getBans(), is(notNullValue()));
+ assertThat(playerSummary.getSystemId(), is(notNullValue()));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/RemoteActionsControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/RemoteActionsControllerIntegrationTest.java
new file mode 100644
index 0000000..9748dd0
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/RemoteActionsControllerIntegrationTest.java
@@ -0,0 +1,66 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+import java.net.URI;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.remote.actions.RemoteActionsClient;
+import org.triplea.java.IpAddressParser;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class RemoteActionsControllerIntegrationTest extends ControllerIntegrationTest {
+ final URI localhost;
+ final RemoteActionsClient client;
+ final RemoteActionsClient hostClient;
+
+ RemoteActionsControllerIntegrationTest(final URI localhost) {
+ this.localhost = localhost;
+ client = RemoteActionsClient.newClient(localhost, ControllerIntegrationTest.MODERATOR);
+ hostClient = RemoteActionsClient.newClient(localhost, ControllerIntegrationTest.HOST);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void mustBeAuthorized() {
+ assertNotAuthorized(
+ ControllerIntegrationTest.NOT_MODERATORS,
+ apiKey -> RemoteActionsClient.newClient(localhost, apiKey),
+ client -> client.sendShutdownRequest("game-id"));
+
+ assertNotAuthorized(
+ ControllerIntegrationTest.NOT_HOST,
+ apiKey -> RemoteActionsClient.newClient(localhost, apiKey),
+ client -> client.checkIfPlayerIsBanned(IpAddressParser.fromString("3.3.3.3")));
+ }
+
+ @Test
+ void sendShutdownSignal() {
+ client.sendShutdownRequest("game-id");
+ }
+
+ @Test
+ @DisplayName("IP address is banned")
+ void userIsBanned() {
+ final boolean result = hostClient.checkIfPlayerIsBanned(IpAddressParser.fromString("1.1.1.1"));
+
+ assertThat(result, is(true));
+ }
+
+ @Test
+ @DisplayName("IP address has an expired ban")
+ void userWasBanned() {
+ final boolean result = hostClient.checkIfPlayerIsBanned(IpAddressParser.fromString("1.1.1.2"));
+
+ assertThat(result, is(false));
+ }
+
+ @Test
+ @DisplayName("IP address is not in ban table at all")
+ void userWasNeverBanned() {
+ final boolean result = hostClient.checkIfPlayerIsBanned(IpAddressParser.fromString("1.1.1.3"));
+
+ assertThat(result, is(false));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/UserAccountControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/UserAccountControllerIntegrationTest.java
new file mode 100644
index 0000000..b1e2c14
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/UserAccountControllerIntegrationTest.java
@@ -0,0 +1,52 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsNot.not;
+import static org.hamcrest.core.IsNull.notNullValue;
+
+import java.net.URI;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.user.account.UserAccountClient;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class UserAccountControllerIntegrationTest extends ControllerIntegrationTest {
+ private final URI localhost;
+ private final UserAccountClient client;
+
+ UserAccountControllerIntegrationTest(final URI localhost) {
+ this.localhost = localhost;
+ client = UserAccountClient.newClient(localhost, ControllerIntegrationTest.PLAYER);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void mustBeAuthorized() {
+ assertNotAuthorized(
+ List.of(ControllerIntegrationTest.ANONYMOUS),
+ apiKey -> UserAccountClient.newClient(localhost, apiKey),
+ UserAccountClient::fetchEmail,
+ client -> client.changeEmail("new-email"),
+ client -> client.changePassword("new-password"));
+ }
+
+ @Test
+ void changePassword() {
+ client.changePassword("password");
+ }
+
+ @Test
+ void fetchEmail() {
+ assertThat(client.fetchEmail(), notNullValue());
+ }
+
+ @Test
+ void changeEmail() {
+ assertThat(client.fetchEmail(), is(not("email@email-test.com")));
+
+ client.changeEmail("email@email-test.com");
+
+ assertThat(client.fetchEmail().getUserEmail(), is("email@email-test.com"));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/UserBanControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/UserBanControllerIntegrationTest.java
new file mode 100644
index 0000000..0a922d5
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/UserBanControllerIntegrationTest.java
@@ -0,0 +1,88 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsEmptyCollection.empty;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsNot.not;
+import static org.triplea.test.common.matchers.CollectionMatchers.containsMappedItem;
+import static org.triplea.test.common.matchers.CollectionMatchers.doesNotContainMappedItem;
+
+import java.net.URI;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.moderator.toolbox.banned.user.ToolboxUserBanClient;
+import org.triplea.http.client.lobby.moderator.toolbox.banned.user.UserBanData;
+import org.triplea.http.client.lobby.moderator.toolbox.banned.user.UserBanParams;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class UserBanControllerIntegrationTest extends ControllerIntegrationTest {
+
+ private final URI localhost;
+ private final ToolboxUserBanClient client;
+
+ UserBanControllerIntegrationTest(final URI localhost) {
+ this.localhost = localhost;
+ client = ToolboxUserBanClient.newClient(localhost, ControllerIntegrationTest.MODERATOR);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void mustBeAuthorized() {
+ assertNotAuthorized(
+ ControllerIntegrationTest.NOT_MODERATORS,
+ apiKey -> ToolboxUserBanClient.newClient(localhost, apiKey),
+ ToolboxUserBanClient::getUserBans,
+ client ->
+ client.banUser(
+ UserBanParams.builder()
+ .ip("ip")
+ .minutesToBan(10)
+ .systemId("system-id")
+ .username("username")
+ .build()),
+ client -> client.removeUserBan("some-username"));
+ }
+
+ @Test
+ void listUserBans() {
+ assertThat(client.getUserBans(), is(not(empty())));
+ }
+
+ /** Get list of banned users. Unban the first item. */
+ @Test
+ void removeUserNameBan() {
+ final UserBanData firstItem = client.getUserBans().get(0);
+
+ assertThat(
+ client.getUserBans(), containsMappedItem(UserBanData::getBanId, firstItem.getBanId()));
+
+ client.removeUserBan(firstItem.getBanId());
+
+ assertThat(
+ client.getUserBans(),
+ doesNotContainMappedItem(UserBanData::getBanId, firstItem.getBanId()));
+ }
+
+ /**
+ * Generate a mostly unique user name.
+ * Ensure user name is not already banned.
+ * Add user name to banned users.
+ * Verify banned users contains the new ban.
+ */
+ @Test
+ void addUserNameBan() {
+ final String userNameToBan = "user-name-to-ban-" + UUID.randomUUID().toString().substring(0, 5);
+ assertThat(
+ client.getUserBans(), doesNotContainMappedItem(UserBanData::getUsername, userNameToBan));
+
+ client.banUser(
+ UserBanParams.builder()
+ .username(userNameToBan)
+ .systemId("system-id")
+ .minutesToBan(10)
+ .ip("55.55.55.55")
+ .build());
+
+ assertThat(client.getUserBans(), containsMappedItem(UserBanData::getUsername, userNameToBan));
+ }
+}
diff --git a/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/UsernameBanControllerIntegrationTest.java b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/UsernameBanControllerIntegrationTest.java
new file mode 100644
index 0000000..e4cc9b6
--- /dev/null
+++ b/server/dropwizard-server/src/test/java/org/triplea/spitfire/server/controllers/UsernameBanControllerIntegrationTest.java
@@ -0,0 +1,93 @@
+package org.triplea.spitfire.server.controllers;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsEmptyCollection.empty;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsCollectionContaining.hasItem;
+import static org.hamcrest.core.IsNot.not;
+import static org.triplea.test.common.matchers.CollectionMatchers.containsMappedItem;
+import static org.triplea.test.common.matchers.CollectionMatchers.doesNotContainMappedItem;
+
+import java.net.URI;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.login.LobbyLoginClient;
+import org.triplea.http.client.lobby.login.LobbyLoginResponse;
+import org.triplea.http.client.lobby.moderator.toolbox.banned.name.ToolboxUsernameBanClient;
+import org.triplea.http.client.lobby.moderator.toolbox.banned.name.UsernameBanData;
+import org.triplea.spitfire.server.ControllerIntegrationTest;
+
+class UsernameBanControllerIntegrationTest extends ControllerIntegrationTest {
+ private final URI localhost;
+ private final ToolboxUsernameBanClient client;
+
+ UsernameBanControllerIntegrationTest(final URI localhost) {
+ this.localhost = localhost;
+ this.client =
+ ToolboxUsernameBanClient.newClient(localhost, ControllerIntegrationTest.MODERATOR);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void mustBeAuthorized() {
+ assertNotAuthorized(
+ ControllerIntegrationTest.NOT_MODERATORS,
+ apiKey -> ToolboxUsernameBanClient.newClient(localhost, apiKey),
+ ToolboxUsernameBanClient::getUsernameBans,
+ client -> client.addUsernameBan("some-username"),
+ client -> client.removeUsernameBan("some-username"));
+ }
+
+ @Test
+ void listBans() {
+ final List nameBans = client.getUsernameBans();
+ assertThat(nameBans, is(not(empty())));
+ }
+
+ @Test
+ void removeBan() {
+ final List nameBans = client.getUsernameBans();
+
+ // remember the first item
+ final UsernameBanData firstItem = nameBans.get(0);
+
+ // remove the first item
+ client.removeUsernameBan(firstItem.getBannedName());
+
+ // verify first item is removed
+ assertThat(client.getUsernameBans(), is(not(hasItem(firstItem))));
+ }
+
+ @Test
+ void addBan() {
+ assertThat(
+ "Make sure bans does not contain the item we will add",
+ client.getUsernameBans(),
+ doesNotContainMappedItem(
+ UsernameBanData::getBannedName, "username-that-is-now-banned".toUpperCase()));
+
+ client.addUsernameBan("username-that-is-now-banned");
+
+ assertThat(
+ "Bans should now contain the newly added item",
+ client.getUsernameBans(),
+ containsMappedItem(
+ UsernameBanData::getBannedName, "username-that-is-now-banned".toUpperCase()));
+ }
+
+ /**
+ * Do a login to verify we can login. Ban the name we used for login, then repeat the login and
+ * verify the login is not successful.
+ */
+ @Test
+ void usernameBanDisallowsLogin() {
+ LobbyLoginResponse loginResponse =
+ LobbyLoginClient.newClient(localhost).login("random-user", null);
+ assertThat("Verify our anonymous login worked", loginResponse.isSuccess(), is(true));
+
+ client.addUsernameBan("random-user");
+
+ loginResponse = LobbyLoginClient.newClient(localhost).login("random-user", null);
+ assertThat("Verify our anonymous login worked", loginResponse.isSuccess(), is(false));
+ }
+}
diff --git a/server/dropwizard-server/src/test/resources/datasets/access_log.yml b/server/dropwizard-server/src/test/resources/datasets/access_log.yml
new file mode 100644
index 0000000..ecdabdd
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/datasets/access_log.yml
@@ -0,0 +1,6 @@
+access_log:
+ - access_time: 2010-01-01 23:59:20.0
+ username: example
+ ip: 1.1.1.1
+ system_id: system-id
+ lobby_user_id: null
diff --git a/server/dropwizard-server/src/test/resources/datasets/bad_word.yml b/server/dropwizard-server/src/test/resources/datasets/bad_word.yml
new file mode 100644
index 0000000..cedcadb
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/datasets/bad_word.yml
@@ -0,0 +1,7 @@
+bad_word:
+ - date_created: 2010-01-01 23:59:20.0
+ word: bad
+ - date_created: 2010-01-01 23:59:20.0
+ word: awful
+ - date_created: 2010-01-01 23:59:20.0
+ word: not nice
diff --git a/server/dropwizard-server/src/test/resources/datasets/banned_user.yml b/server/dropwizard-server/src/test/resources/datasets/banned_user.yml
new file mode 100644
index 0000000..53bd552
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/datasets/banned_user.yml
@@ -0,0 +1,15 @@
+banned_user:
+ - id: 1000
+ public_id: xyz
+ username: banned
+ system_id: system-id
+ ip: 1.1.1.1
+ ban_expiry: 2100-01-01 23:59:20.0
+ date_created: 2010-01-01 23:59:20.0
+ - id: 1001
+ public_id: xyz2
+ username: banned2
+ system_id: system-id2
+ ip: 1.1.1.2
+ ban_expiry: 2000-01-01 23:59:20.0
+ date_created: 2000-01-01 23:59:20.0
diff --git a/server/dropwizard-server/src/test/resources/datasets/banned_username.yml b/server/dropwizard-server/src/test/resources/datasets/banned_username.yml
new file mode 100644
index 0000000..057b65a
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/datasets/banned_username.yml
@@ -0,0 +1,7 @@
+banned_username:
+ - date_created: 2010-01-01 23:59:20.0
+ username: bad
+ - date_created: 2010-01-01 23:59:20.0
+ username: awful
+ - date_created: 2010-01-01 23:59:20.0
+ username: not nice
diff --git a/server/dropwizard-server/src/test/resources/datasets/game_hosting_api_key.yml b/server/dropwizard-server/src/test/resources/datasets/game_hosting_api_key.yml
new file mode 100644
index 0000000..39cb3e9
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/datasets/game_hosting_api_key.yml
@@ -0,0 +1,6 @@
+game_hosting_api_key:
+ - id: 80000
+ key: 06dbbb9b6ac87d97f9acca120ae4784d0eaf6865ea99788a389a384da8ab0709e77af2bfe4f4e82c6e6d375ae256aa95c2fa99ce97ce65981cfd1340257a441a
+ # key = sha512(HOST)
+ ip: "127.0.0.1"
+ date_created: 2010-01-01 23:59:20.0
diff --git a/server/dropwizard-server/src/test/resources/datasets/lobby_api_key.yml b/server/dropwizard-server/src/test/resources/datasets/lobby_api_key.yml
new file mode 100644
index 0000000..2cca7e7
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/datasets/lobby_api_key.yml
@@ -0,0 +1,33 @@
+lobby_api_key:
+ - key: 238b90e6e2382ddafadc35266b2fa9a371fb3962b675ccab1b5538321f469070d0f3762f29b21ac7ad772eb6bd299d09f8e75d38ed8b7067965d5d5f26ebc3f5
+ # key = sha512(ADMIN)
+ username: "admin"
+ lobby_user_id: 5000
+ user_role_id: 1
+ player_chat_id: moderator-chat-id1
+ system_id: system-id1
+ ip: "127.0.0.1"
+ - key: b5c53a198c35646bc9d01654e353382733355a1378515507ff46e3e98e434aee97b5f24b558ca3a190eae4adbaa3c4aa3a85e6f75a93c707341e02acd810788e
+ # key = sha512(MODERATOR)
+ username: "mod"
+ lobby_user_id: 5001
+ user_role_id: 2
+ player_chat_id: chatter-chat-id2
+ system_id: system-id2
+ ip: "127.0.0.1"
+ - key: fd9e954ad9bff3693ac947a10fd3851faacf232f5f3613cbcc8fb42034db09ff7a7f344a26d27735346b396947a58e0b9950f991aeef0995f7c731274f034556
+ # key = sha512(PLAYER)
+ username: "chatter"
+ lobby_user_id: 5000
+ user_role_id: 3
+ player_chat_id: chatter-chat-id3
+ system_id: system-id3
+ ip: "127.0.0.1"
+ - key: c989409cb5c9fd74b66ec0a6c2d2a0f1166c2f7e379794bc7511119c53388baf60e37ef0b0f8f3b854283f832fc91147b63da46eb3cef22bc394946e34943a12
+ # key = sha512(ANONYMOUS)
+ username: "anonymous"
+ lobby_user_id:
+ user_role_id: 4
+ player_chat_id: chatter-chat-id4
+ system_id: system-id4
+ ip: "127.0.0.1"
diff --git a/server/dropwizard-server/src/test/resources/datasets/lobby_user.yml b/server/dropwizard-server/src/test/resources/datasets/lobby_user.yml
new file mode 100644
index 0000000..5debfcb
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/datasets/lobby_user.yml
@@ -0,0 +1,22 @@
+lobby_user:
+ - id: 5000
+ username: "admin"
+ email: "test@test.com"
+ bcrypt_password: $2a$01234_123456789_123456789_123456789_123456789_123456700_
+ user_role_id: 1
+ - id: 5001
+ username: "mod"
+ email: "test1@test.com"
+ bcrypt_password: $2a$01234_123456789_123456789_123456789_123456789_123456701_
+ user_role_id: 2
+ - id: 5002
+ username: "mod3"
+ email: "test3@test.com"
+ bcrypt_password: $2a$01234_123456789_123456789_123456789_123456789_123456702_
+ user_role_id: 2
+ - id: 5003
+ username: "player"
+ email: "test4@test.com"
+ # plaintext password == password
+ bcrypt_password: "$2a$10$RSkV60Ky7F7.ybGmiOEUcO/ynTyUZlLqSoXSQtliSrpFf7/WEe3QO"
+ user_role_id: 3
diff --git a/server/dropwizard-server/src/test/resources/datasets/map_index.yml b/server/dropwizard-server/src/test/resources/datasets/map_index.yml
new file mode 100644
index 0000000..cf32b32
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/datasets/map_index.yml
@@ -0,0 +1,19 @@
+map_index:
+ - id: 10
+ map_name: map-name
+ # Do not use "http:" in test data, DB rider logs a noisy warning about this.
+ # repo_url must otherwise begin with 'http' per column constraint
+ repo_url: 'http-map-repo-url'
+ description: description-repo-1
+ download_url: 'http-map-repo-url/archives/master.zip'
+ preview_image_url: 'http-preview-image-url'
+ download_size_bytes: 4000
+ last_commit_date: 2000-12-01 23:59:20.0
+ - id: 20
+ map_name: map-name-2
+ repo_url: 'http-map-repo-url-2'
+ description: description-repo-2
+ download_url: 'http-map-repo-url-2/archives/master.zip'
+ preview_image_url: 'http-preview-image-url-2'
+ download_size_bytes: 1000
+ last_commit_date: 2016-01-01 23:59:20.0
diff --git a/server/dropwizard-server/src/test/resources/datasets/map_tag_value.yml b/server/dropwizard-server/src/test/resources/datasets/map_tag_value.yml
new file mode 100644
index 0000000..6f1eef4
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/datasets/map_tag_value.yml
@@ -0,0 +1,35 @@
+map_tag:
+ - id: 0
+ name: Category
+ display_order: 1
+ - id: 1
+ name: Rating
+ display_order: 2
+
+map_tag_allowed_value:
+ - id: 0
+ map_tag_id: 0
+ value: BEST
+ - id: 1
+ map_tag_id: 0
+ value: GOOD
+ - id: 2
+ map_tag_id: 1
+ value: 1
+ - id: 3
+ map_tag_id: 1
+ value: 2
+
+map_tag_value:
+ - id: 0
+ map_tag_id: 0
+ map_index_id: 10
+ map_tag_allowed_value_id: 0
+ - id: 1
+ map_tag_id: 1
+ map_index_id: 10
+ map_tag_allowed_value_id: 3
+ - id: 2
+ map_tag_id: 0
+ map_index_id: 20
+ map_tag_allowed_value_id: 1
diff --git a/server/dropwizard-server/src/test/resources/datasets/moderator_action_history.yml b/server/dropwizard-server/src/test/resources/datasets/moderator_action_history.yml
new file mode 100644
index 0000000..c51ca9a
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/datasets/moderator_action_history.yml
@@ -0,0 +1,6 @@
+moderator_action_history:
+ - id: 1000
+ lobby_user_id: 5000
+ date_created: 2010-01-01 23:59:20.0
+ action_name: example
+ action_target: troll
diff --git a/server/dropwizard-server/src/test/resources/datasets/temp_password_request.yml b/server/dropwizard-server/src/test/resources/datasets/temp_password_request.yml
new file mode 100644
index 0000000..407fcc2
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/datasets/temp_password_request.yml
@@ -0,0 +1,6 @@
+temp_password_request:
+ - id: 1000
+ lobby_user_id: 5003
+ # pass == temp-password
+ temp_password: "$2a$10$sg3bZHeUQhx6TCI1KU0I.eJa9GLsz5LwMiyXbK9LQIq0mJc0PTIhm"
+ date_created: 2222-01-01 23:59:20.0
diff --git a/server/dropwizard-server/src/test/resources/datasets/user_role.yml b/server/dropwizard-server/src/test/resources/datasets/user_role.yml
new file mode 100644
index 0000000..41a7f54
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/datasets/user_role.yml
@@ -0,0 +1,11 @@
+user_role:
+ - id: 1
+ name: ADMIN
+ - id: 2
+ name: MODERATOR
+ - id: 3
+ name: PLAYER
+ - id: 4
+ name: ANONYMOUS
+ - id: 5
+ name: HOST
diff --git a/server/dropwizard-server/src/test/resources/db-cleanup.sql b/server/dropwizard-server/src/test/resources/db-cleanup.sql
new file mode 100644
index 0000000..80cd088
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/db-cleanup.sql
@@ -0,0 +1,16 @@
+-- Deletes table data in proper order
+
+delete from access_log;
+delete from bad_word;
+delete from banned_user;
+delete from banned_username;
+delete from game_chat_history;
+delete from lobby_game;
+delete from game_hosting_api_key;
+delete from lobby_chat_history;
+delete from lobby_api_key;
+delete from moderator_action_history;
+delete from temp_password_request;
+delete from temp_password_request_history;
+delete from lobby_user;
+delete from user_role;
diff --git a/server/dropwizard-server/src/test/resources/dbunit.yml b/server/dropwizard-server/src/test/resources/dbunit.yml
new file mode 100644
index 0000000..1f4bedc
--- /dev/null
+++ b/server/dropwizard-server/src/test/resources/dbunit.yml
@@ -0,0 +1,5 @@
+connectionConfig:
+ driver: "org.postgresql.Driver"
+ url: "jdbc:postgresql://localhost:5432/lobby_db"
+ user: "lobby_user"
+ password: "lobby"
diff --git a/server/latest-version-module/README.md b/server/latest-version-module/README.md
new file mode 100644
index 0000000..99c8e01
--- /dev/null
+++ b/server/latest-version-module/README.md
@@ -0,0 +1,10 @@
+# Latest Version Module
+
+This module provides a latest engine version web-API. This would typically be
+used by front-end clients to know if they are running an out of date engine version.
+
+The latest version data is obtained from Github's webservice API. More specifically
+we query Github's API for the 'tag name' of the 'latest release'. We then keep this
+data in an in-memory cache and then refresh the cache periodically. In particular
+we use a cache for a faster server response, but also to limit interactions with
+Github's API (it is rate limited).
diff --git a/server/latest-version-module/build.gradle b/server/latest-version-module/build.gradle
new file mode 100644
index 0000000..627a655
--- /dev/null
+++ b/server/latest-version-module/build.gradle
@@ -0,0 +1,12 @@
+plugins {
+ id "java"
+}
+
+dependencies {
+ implementation project(":http-clients:github-client")
+// implementation project(":lib:feign-common")
+// implementation project(":lib:java-extras")
+ implementation project(":server:server-lib")
+ implementation "io.dropwizard:dropwizard-core:$dropwizardVersion"
+// testImplementation project(":lib:test-common")
+}
diff --git a/server/latest-version-module/src/main/java/org/triplea/modules/latest/version/LatestVersionModule.java b/server/latest-version-module/src/main/java/org/triplea/modules/latest/version/LatestVersionModule.java
new file mode 100644
index 0000000..c9e76a7
--- /dev/null
+++ b/server/latest-version-module/src/main/java/org/triplea/modules/latest/version/LatestVersionModule.java
@@ -0,0 +1,50 @@
+package org.triplea.modules.latest.version;
+
+import io.dropwizard.lifecycle.Managed;
+import java.time.Duration;
+import java.util.Optional;
+import java.util.function.Supplier;
+import lombok.Builder;
+import lombok.Value;
+import lombok.extern.slf4j.Slf4j;
+import org.triplea.http.client.github.GithubApiClient;
+import org.triplea.server.lib.scheduled.tasks.ScheduledTask;
+
+@Slf4j
+public class LatestVersionModule {
+
+ private String latestVersion;
+
+ public Optional getLatestVersion() {
+ return Optional.ofNullable(latestVersion);
+ }
+
+ public void notifyLatest(final String newLatestVersion) {
+ log.info("Latest engine version set to: {}", newLatestVersion);
+ this.latestVersion = newLatestVersion;
+ }
+
+ @Builder
+ @Value
+ public static class RefreshConfiguration {
+ Duration delay;
+ Duration period;
+ }
+
+ public Managed buildRefreshSchedule(
+ final LatestVersionModuleConfig configuration,
+ final RefreshConfiguration refreshConfiguration) {
+
+ final GithubApiClient githubApiClient = configuration.createGamesRepoGithubApiClient();
+
+ final Supplier> githubLatestVersionFetcher =
+ githubApiClient::fetchLatestVersion;
+
+ return ScheduledTask.builder()
+ .taskName("Latest-Engine-Version-Fetcher")
+ .delay(refreshConfiguration.getDelay())
+ .period(refreshConfiguration.getPeriod())
+ .task(() -> githubLatestVersionFetcher.get().ifPresent(this::notifyLatest))
+ .build();
+ }
+}
diff --git a/server/latest-version-module/src/main/java/org/triplea/modules/latest/version/LatestVersionModuleConfig.java b/server/latest-version-module/src/main/java/org/triplea/modules/latest/version/LatestVersionModuleConfig.java
new file mode 100644
index 0000000..212a3c2
--- /dev/null
+++ b/server/latest-version-module/src/main/java/org/triplea/modules/latest/version/LatestVersionModuleConfig.java
@@ -0,0 +1,7 @@
+package org.triplea.modules.latest.version;
+
+import org.triplea.http.client.github.GithubApiClient;
+
+public interface LatestVersionModuleConfig {
+ GithubApiClient createGamesRepoGithubApiClient();
+}
diff --git a/server/lobby-module/README.md b/server/lobby-module/README.md
new file mode 100644
index 0000000..8ee2f88
--- /dev/null
+++ b/server/lobby-module/README.md
@@ -0,0 +1,189 @@
+# Http-Server
+
+## Strategic Fit and Overview
+
+Http-Server is an http/REST-like backend component to complement the lobby.
+The server is intended to provide a lightweight way to do client/server
+interactions without needing to use the Lobby java sockets interface.
+
+The server is intended to be run as a stand-alone process and may access
+the same database as the lobby.
+
+## Starting the server
+Execute the main method in `ServerApplication`. If no args are provided then
+defaults suitable for a development environment are used.
+
+### Environment Variables
+
+For full functionality, secrets are provided via environment variables and need
+to be set prior to launching the server. By default the server should be launchable
+without any additional configuration, but some elements of the system may not function
+without valid values.
+
+## Configuration
+
+Application configuration is obtained from `AppConfig.java` which is wired
+from a YML file that is specified at startup.
+
+Of note, a reference to `AppConfig` is passed to the main server application
+`ServerApplication` which can then wire those properties to any endpoint
+'controllers' that would need configuration values.
+
+## Typical Design of Endpoints
+
+Endpoints typically are powered by four types of classes.
+
+### (1) ControllerFactory class
+Wires up all dependencies and creates the controller class.
+
+### (2) Controller class
+Controller classes need to be registered in `ServerApplicaton.java`
+to be enabled.
+
+This class contains endpoint markups and receives HTTP requests.
+The controller methods should do quick/basic validation and then
+delegate as much as possible to a 'service' class.
+
+### (3) Service class
+The service class contains 'business' logic and should perform any database
+interactions. The service class is also a translation layer to aggregate
+and transform data from what we get database to what the front-end HTTP
+client expects.
+
+### (4) DAO class
+
+These classes live in the `lobby-db` subproject, they interact
+with database and should have simple input/output parameters.
+
+Any transaction methods can live there as a 'default' method.
+
+For example, password hashing should be done at the service layer
+(unless we can do it by DB function in SQL directly), and then
+the hashed password is passed to DAO for lookup.
+
+# Design Notes
+
+## API Keys for Moderators
+
+HTTP endpoints are publicly available, they can be found and attacked.
+Any endpoint that initiates a moderator action will accept
+headers for a moderator API key and a password for that key.
+
+Any such endpoints that take moderator keys should verify
+the keys and lock out IP addresses that make too many attempts.
+
+### Key Distribution
+
+Super-moderators can elevate users to moderator and generate
+a 'single-use-key'. This key is then provided to that moderator.
+
+The moderator can then 'register' the key, where they provide
+the single-use-key and a password. The backend verifies
+the single-use-key and then generates a new key. This is
+done so that only the moderator will then 'know' the value
+of their new key. The password provided is used as a salt.
+
+### API Key Password
+
+The purpose behind this is so that if an API key is compromised,
+it won't be useful unless the password is also compromised too.
+
+This means a moderator will need to have their OS data to
+be hacked and also a key logger or something that can scrape
+the password from in-memory of TripleA.
+
+The API key password is stored in-memory when TripleA launches
+and shall not be persisted anyways.
+
+API keys are stored in "client settings", which are stored
+with the OS and persist across TripleA installations.
+
+## API Key Rate Limiting
+
+Rate-limiting: of note, the backend implementation should be careful to apply rate limiting
+to any/all endpoints that take an API key so as to avoid brute-force attacks to try and crack
+an API key value.
+
+# WARNINGS!
+
+## Multiple SLF4J Bindings Not Allowed
+
+Dropwizard uses Logback and has a binding with SLF4J baked in. Additional SLF4J bindings
+should generate a warning, but will ultimately cause problems (when run from gradle) and drop
+wizard may fail to start with this error:
+
+```bash
+java.lang.IllegalStateException: Unable to acquire the logger context
+ at io.dropwizard.logging.LoggingUtil.getLoggerContext(LoggingUtil.java:46)
+```
+
+## Stream is already closed
+
+This can happen when the server side does not fail fast on incorrect input.
+This can be if we use headers that are missing or do not check that parameters are present.
+
+The stack trace indicating this will look like this:
+```bash
+ERROR [2019-06-06 05:07:22,247] org.glassfish.jersey.server.ServerRuntime$Responder: An I/O error has occurred while writing a response message entity to the container output stream.
+! java.lang.IllegalStateException: The output stream has already been closed.
+```
+
+The impact of this is:
+ - server thread hangs
+ - client hangs
+ - server does not shutdown cleanly
+
+This is bad as it could be used in a DDOS attack.
+
+### Prevention
+
+Essentially fail-fast:
+ - When looking for headers, verify headers exist or terminate the request
+ - Verify that all needed GET parameters are present or terminate the request
+
+To terminate the request, just throw a IllegalArgumentException, it'l be mapped to a 400.
+
+## 404 error, but endpoint is registered!?
+
+Make sure in addition to the `@Path` annotation on the endpoint method,
+ensure the controller class has a `@Path("")` annotation on it.
+
+## Lobby-db-dao
+
+This projects contains "DAO" code, data access layer.
+
+Contains Java code focused on executing SQL. Parameters passed to this
+layer should be as simple as possible so that this layer is as close
+to pure SQL as possible.
+
+### JDBI result mapper
+
+To return data objects from JDBI queries, register mappers in `JdbiDatabase.java`
+
+#### Patterns for result mappers
+
+(1) The mapped data object should have a static `buildResultMapper` function
+
+(2) Use constants to reference columns names
+
+#### Patterns for JDBI Result Data Objects
+
+##### Naming
+
+Naming: Suffixed `DaoData.java` so it is clear that these objects
+are coming from database.
+
+##### Do not couple front-end to the database
+
+Do not return DAO data objects to http clients. Typically the backend
+server layer will convert DAO data objects into a shared data object
+that is shared between front-end and backend. This way DB changes do
+not cascade to the front-end clients.
+
+### Design Pattern for Transactions
+
+ - Create a new interface; e.g.: `ModeratorKeyRegistrationDao.java`
+ - Add a default method with the `@Transaction` annotation.
+ - add a dummy select query so that JDBI sees the interface as valid
+ - pass the needed DAO objects as parameters to the default method
+ - use mockito mocks to test the method
diff --git a/server/lobby-module/build.gradle b/server/lobby-module/build.gradle
new file mode 100644
index 0000000..f6d019d
--- /dev/null
+++ b/server/lobby-module/build.gradle
@@ -0,0 +1,33 @@
+plugins {
+ id "java"
+}
+
+dependencies {
+ implementation "at.favre.lib:bcrypt:$bcryptVersion"
+ implementation "com.liveperson:dropwizard-websockets:$dropwizardWebsocketsVersion"
+ implementation "com.sun.mail:jakarta.mail:$jakartaMailVersion"
+ implementation "com.sun.xml.bind:jaxb-core:$jaxbCoreVersion"
+ implementation "com.sun.xml.bind:jaxb-impl:$jaxbImplVersion"
+ implementation "io.github.openfeign:feign-gson:$openFeignVersion"
+ implementation "javax.activation:activation:$javaxActivationVersion"
+ implementation "javax.xml.bind:jaxb-api:$jaxbApiVersion"
+ implementation "org.java-websocket:Java-WebSocket:$javaWebSocketVersion"
+ implementation "org.jdbi:jdbi3-core:$jdbiVersion"
+ implementation "org.jdbi:jdbi3-sqlobject:$jdbiVersion"
+// implementation project(':game-app:domain-data')
+// implementation project(':http-clients:github-client')
+// implementation project(':http-clients:lobby-client')
+// implementation project(':lib:feign-common')
+// implementation project(':lib:java-extras')
+// implementation project(':lib:websocket-client')
+// implementation project(':lib:websocket-server')
+ testImplementation "com.github.database-rider:rider-junit5:$databaseRiderVersion"
+ testImplementation "com.sun.mail:jakarta.mail:$jakartaMailVersion"
+ testImplementation "io.dropwizard:dropwizard-testing:$dropwizardVersion"
+ testImplementation "org.awaitility:awaitility:$awaitilityVersion"
+ testImplementation "org.java-websocket:Java-WebSocket:$javaWebsocketVersion"
+ testImplementation "uk.co.datumedge:hamcrest-json:$hamcrestJsonVersion"
+// testImplementation project(":lib:java-extras")
+// testImplementation project(":lib:test-common")
+ testImplementation project(":server:database-test-support")
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/LobbyModuleRowMappers.java b/server/lobby-module/src/main/java/org/triplea/db/LobbyModuleRowMappers.java
new file mode 100644
index 0000000..73594c6
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/LobbyModuleRowMappers.java
@@ -0,0 +1,46 @@
+package org.triplea.db;
+
+import java.util.List;
+import lombok.experimental.UtilityClass;
+import lombok.extern.slf4j.Slf4j;
+import org.jdbi.v3.core.mapper.RowMapperFactory;
+import org.jdbi.v3.core.mapper.reflect.ConstructorMapper;
+import org.triplea.db.dao.access.log.AccessLogRecord;
+import org.triplea.db.dao.api.key.PlayerApiKeyLookupRecord;
+import org.triplea.db.dao.api.key.PlayerIdentifiersByApiKeyLookup;
+import org.triplea.db.dao.moderator.ModeratorAuditHistoryRecord;
+import org.triplea.db.dao.moderator.ModeratorUserDaoData;
+import org.triplea.db.dao.moderator.chat.history.ChatHistoryRecord;
+import org.triplea.db.dao.moderator.player.info.PlayerAliasRecord;
+import org.triplea.db.dao.moderator.player.info.PlayerBanRecord;
+import org.triplea.db.dao.user.ban.BanLookupRecord;
+import org.triplea.db.dao.user.ban.UserBanRecord;
+import org.triplea.db.dao.user.history.PlayerHistoryRecord;
+import org.triplea.db.dao.user.role.UserRoleLookup;
+import org.triplea.db.dao.username.ban.UsernameBanRecord;
+
+/** Utility to get connections to the Postgres lobby database. */
+@Slf4j
+@UtilityClass
+public final class LobbyModuleRowMappers {
+ /**
+ * Returns all row mappers. These are classes that map result set values to corresponding return
+ * objects.
+ */
+ public static List rowMappers() {
+ return List.of(
+ ConstructorMapper.factory(AccessLogRecord.class),
+ ConstructorMapper.factory(BanLookupRecord.class),
+ ConstructorMapper.factory(ChatHistoryRecord.class),
+ ConstructorMapper.factory(ModeratorAuditHistoryRecord.class),
+ ConstructorMapper.factory(ModeratorUserDaoData.class),
+ ConstructorMapper.factory(PlayerAliasRecord.class),
+ ConstructorMapper.factory(PlayerApiKeyLookupRecord.class),
+ ConstructorMapper.factory(PlayerBanRecord.class),
+ ConstructorMapper.factory(PlayerHistoryRecord.class),
+ ConstructorMapper.factory(PlayerIdentifiersByApiKeyLookup.class),
+ ConstructorMapper.factory(UserBanRecord.class),
+ ConstructorMapper.factory(UsernameBanRecord.class),
+ ConstructorMapper.factory(UserRoleLookup.class));
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/access/log/AccessLogDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/access/log/AccessLogDao.java
new file mode 100644
index 0000000..ec70132
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/access/log/AccessLogDao.java
@@ -0,0 +1,44 @@
+package org.triplea.db.dao.access.log;
+
+import java.util.List;
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+
+/**
+ * Provides access to the access log table. This is a table that records user data as they enter the
+ * lobby. Useful for statistics and for banning.
+ */
+public interface AccessLogDao {
+
+ @SqlQuery(
+ "select"
+ + " access_time,"
+ + " username,"
+ + " ip,"
+ + " system_id,"
+ + " (lobby_user_id is not null) as registered"
+ + " from access_log"
+ + " where username like :username"
+ + " and host(ip) like :ip"
+ + " and system_id like :systemId"
+ + " order by access_time desc"
+ + " offset :rowOffset rows"
+ + " fetch next :rowCount rows only")
+ List fetchAccessLogRows(
+ @Bind("rowOffset") int rowOffset,
+ @Bind("rowCount") int rowCount,
+ @Bind("username") String username,
+ @Bind("ip") String ip,
+ @Bind("systemId") String systemId);
+
+ @SqlUpdate(
+ "insert into access_log(username, ip, system_id, lobby_user_id)\n"
+ + "values ("
+ + " :username,"
+ + " :ip::inet,"
+ + " :systemId,"
+ + " (select id from lobby_user where username = :username))")
+ int insertUserAccessRecord(
+ @Bind("username") String username, @Bind("ip") String ip, @Bind("systemId") String systemId);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/access/log/AccessLogRecord.java b/server/lobby-module/src/main/java/org/triplea/db/dao/access/log/AccessLogRecord.java
new file mode 100644
index 0000000..92acb6f
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/access/log/AccessLogRecord.java
@@ -0,0 +1,30 @@
+package org.triplea.db.dao.access.log;
+
+import java.time.Instant;
+import lombok.Builder;
+import lombok.Getter;
+import org.jdbi.v3.core.mapper.reflect.ColumnName;
+
+/** Return data when selecting lobby access history. */
+@Getter
+public class AccessLogRecord {
+ private final Instant accessTime;
+ private final String username;
+ private final String ip;
+ private final String systemId;
+ private final boolean registered;
+
+ @Builder
+ public AccessLogRecord(
+ @ColumnName("access_time") final Instant accessTime,
+ @ColumnName("username") final String username,
+ @ColumnName("ip") final String ip,
+ @ColumnName("system_id") final String systemId,
+ @ColumnName("registered") final boolean registered) {
+ this.accessTime = accessTime;
+ this.username = username;
+ this.ip = ip;
+ this.systemId = systemId;
+ this.registered = registered;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/ApiKeyHasher.java b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/ApiKeyHasher.java
new file mode 100644
index 0000000..2cdc2e9
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/ApiKeyHasher.java
@@ -0,0 +1,14 @@
+package org.triplea.db.dao.api.key;
+
+import com.google.common.base.Charsets;
+import com.google.common.hash.Hashing;
+import java.util.function.Function;
+import org.triplea.domain.data.ApiKey;
+
+@SuppressWarnings("UnstableApiUsage")
+public class ApiKeyHasher implements Function {
+ @Override
+ public String apply(final ApiKey apiKey) {
+ return Hashing.sha512().hashString(apiKey.getValue(), Charsets.UTF_8).toString();
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/GameHostingApiKeyDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/GameHostingApiKeyDao.java
new file mode 100644
index 0000000..f9275f8
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/GameHostingApiKeyDao.java
@@ -0,0 +1,13 @@
+package org.triplea.db.dao.api.key;
+
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+
+interface GameHostingApiKeyDao {
+ @SqlUpdate("insert into game_hosting_api_key(key, ip) values(:key, :ip::inet)")
+ int insertKey(@Bind("key") String key, @Bind("ip") String ip);
+
+ @SqlQuery("select exists (select * from game_hosting_api_key where key = :apiKey)")
+ boolean keyExists(@Bind("apiKey") String apiKey);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/GameHostingApiKeyDaoWrapper.java b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/GameHostingApiKeyDaoWrapper.java
new file mode 100644
index 0000000..26cb309
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/GameHostingApiKeyDaoWrapper.java
@@ -0,0 +1,44 @@
+package org.triplea.db.dao.api.key;
+
+import com.google.common.base.Preconditions;
+import java.net.InetAddress;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.java.Postconditions;
+
+/** Wrapper to abstract away DB details of how API key is stored and to provide convenience APIs. */
+@Builder
+public class GameHostingApiKeyDaoWrapper {
+
+ @Nonnull private final GameHostingApiKeyDao gameHostApiKeyDao;
+ @Nonnull private final Supplier keyMaker;
+
+ /** Hashing function so that we do not store plain-text API key values in database. */
+ @Nonnull private final Function keyHashingFunction;
+
+ public static GameHostingApiKeyDaoWrapper build(final Jdbi jdbi) {
+ return GameHostingApiKeyDaoWrapper.builder()
+ .gameHostApiKeyDao(jdbi.onDemand(GameHostingApiKeyDao.class))
+ .keyMaker(ApiKey::newKey)
+ .keyHashingFunction(new ApiKeyHasher())
+ .build();
+ }
+
+ public boolean isKeyValid(final ApiKey apiKey) {
+ return gameHostApiKeyDao.keyExists(keyHashingFunction.apply(apiKey));
+ }
+
+ /** Creates (and stores in DB) a new API key for 'host' connections (AKA: LobbyWatcher). */
+ public ApiKey newGameHostKey(final InetAddress ip) {
+ Preconditions.checkArgument(ip != null);
+ final ApiKey key = keyMaker.get();
+ final int insertCount =
+ gameHostApiKeyDao.insertKey(keyHashingFunction.apply(key), ip.getHostAddress());
+ Postconditions.assertState(insertCount == 1);
+ return key;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/PlayerApiKeyDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/PlayerApiKeyDao.java
new file mode 100644
index 0000000..04a45c3
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/PlayerApiKeyDao.java
@@ -0,0 +1,62 @@
+package org.triplea.db.dao.api.key;
+
+import java.util.Optional;
+import javax.annotation.Nullable;
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+
+/**
+ * Dao for interacting with api_key table. Api_key table stores keys that are generated on login.
+ * For non-anonymous accounts, the key is linked back to the players account which is used to
+ * determine the users 'Role'. Anonymous users are still granted API keys, they have no user id and
+ * given role 'ANONYMOUS'.
+ */
+interface PlayerApiKeyDao {
+ @SqlUpdate(
+ "insert into lobby_api_key("
+ + " username, lobby_user_id, user_role_id, player_chat_id, key, system_id, ip) "
+ + "values "
+ + "(:username, :userId, :userRoleId, :playerChatId, :apiKey, :systemId, :ip::inet)")
+ int storeKey(
+ @Bind("username") String username,
+ @Nullable @Bind("userId") Integer userId,
+ @Bind("userRoleId") int userRoleId,
+ @Bind("playerChatId") String playerChatId,
+ @Bind("apiKey") String key,
+ @Bind("systemId") String systemId,
+ @Bind("ip") String ipAddress);
+
+ @SqlQuery(
+ "select "
+ + " ak.lobby_user_id as user_id,"
+ + " ak.id as api_key_id,"
+ + " ak.username as username,"
+ + " ur.name as user_role,"
+ + " ak.player_chat_id as player_chat_id"
+ + " from lobby_api_key ak "
+ + " join user_role ur on ur.id = ak.user_role_id "
+ + " left join lobby_user lu on lu.id = ak.lobby_user_id "
+ + " where ak.key = :apiKey")
+ Optional lookupByApiKey(@Bind("apiKey") String apiKey);
+
+ @SqlUpdate("delete from lobby_api_key where date_created < (now() - '7 days'::interval)")
+ void deleteOldKeys();
+
+ @SqlQuery(
+ "select "
+ + " username,"
+ + " system_id,"
+ + " ip"
+ + " from lobby_api_key "
+ + " where player_chat_id = :playerChatId")
+ Optional lookupByPlayerChatId(
+ @Bind("playerChatId") String playerChatId);
+
+ @SqlQuery(
+ "select "
+ + " lobby_user_id"
+ + " from lobby_api_key "
+ + " where player_chat_id = :playerChatId")
+ Optional lookupPlayerIdByPlayerChatId(@Bind("playerChatId") String playerChatId);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/PlayerApiKeyDaoWrapper.java b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/PlayerApiKeyDaoWrapper.java
new file mode 100644
index 0000000..5d9a1c8
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/PlayerApiKeyDaoWrapper.java
@@ -0,0 +1,111 @@
+package org.triplea.db.dao.api.key;
+
+import com.google.common.base.Preconditions;
+import java.net.InetAddress;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.UserJdbiDao;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.db.dao.user.role.UserRoleDao;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.domain.data.PlayerChatId;
+import org.triplea.domain.data.SystemId;
+import org.triplea.domain.data.UserName;
+import org.triplea.java.Postconditions;
+
+/** Wrapper to abstract away DB details of how API key is stored and to provide convenience APIs. */
+@Builder
+public class PlayerApiKeyDaoWrapper {
+
+ @Nonnull private final PlayerApiKeyDao lobbyApiKeyDao;
+ @Nonnull private final UserJdbiDao userJdbiDao;
+ @Nonnull private final UserRoleDao userRoleDao;
+ @Nonnull private final Supplier keyMaker;
+
+ /** Hashing function so that we do not store plain-text API key values in database. */
+ @Nonnull private final Function keyHashingFunction;
+
+ public static PlayerApiKeyDaoWrapper build(final Jdbi jdbi) {
+ return PlayerApiKeyDaoWrapper.builder()
+ .lobbyApiKeyDao(jdbi.onDemand(PlayerApiKeyDao.class))
+ .userJdbiDao(jdbi.onDemand(UserJdbiDao.class))
+ .userRoleDao(jdbi.onDemand(UserRoleDao.class))
+ .keyMaker(ApiKey::newKey)
+ .keyHashingFunction(new ApiKeyHasher())
+ .build();
+ }
+
+ public Optional lookupByApiKey(final ApiKey apiKey) {
+ return lobbyApiKeyDao.lookupByApiKey(keyHashingFunction.apply(apiKey));
+ }
+
+ /** Creates (and stores in DB) a new API key for registered or anonymous users. */
+ public ApiKey newKey(
+ final UserName userName,
+ final InetAddress ip,
+ final SystemId systemId,
+ final PlayerChatId playerChatId) {
+ Preconditions.checkNotNull(userName);
+ Preconditions.checkNotNull(ip);
+
+ final ApiKey key = keyMaker.get();
+
+ userJdbiDao
+ .lookupUserIdAndRoleIdByUserName(userName.getValue())
+ .ifPresentOrElse(
+ userRoleLookup -> {
+ // insert key for registered user
+ insertKey(
+ userRoleLookup.getUserId(),
+ userName.getValue(),
+ key,
+ ip,
+ systemId,
+ playerChatId,
+ userRoleLookup.getUserRoleId());
+ },
+ () -> {
+ // insert key for anonymous user
+ final int anonymousUserRoleId = userRoleDao.lookupRoleId(UserRole.ANONYMOUS);
+ insertKey(
+ null, userName.getValue(), key, ip, systemId, playerChatId, anonymousUserRoleId);
+ });
+ return key;
+ }
+
+ private void insertKey(
+ @Nullable final Integer userId,
+ @Nullable final String username,
+ final ApiKey apiKey,
+ final InetAddress ipAddress,
+ final SystemId systemId,
+ final PlayerChatId playerChatId,
+ final int userRoleId) {
+ final String hashedKey = keyHashingFunction.apply(apiKey);
+
+ final int rowsInserted =
+ lobbyApiKeyDao.storeKey(
+ username,
+ userId,
+ userRoleId,
+ playerChatId.getValue(),
+ hashedKey,
+ systemId.getValue(),
+ ipAddress.getHostAddress());
+ Postconditions.assertState(rowsInserted == 1);
+ }
+
+ public Optional lookupPlayerByChatId(
+ final PlayerChatId playerChatId) {
+ return lobbyApiKeyDao.lookupByPlayerChatId(playerChatId.getValue());
+ }
+
+ public Optional lookupUserIdByChatId(final PlayerChatId playerChatId) {
+ return lobbyApiKeyDao.lookupPlayerIdByPlayerChatId(playerChatId.getValue());
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/PlayerApiKeyLookupRecord.java b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/PlayerApiKeyLookupRecord.java
new file mode 100644
index 0000000..81e0e06
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/PlayerApiKeyLookupRecord.java
@@ -0,0 +1,57 @@
+package org.triplea.db.dao.api.key;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import org.jdbi.v3.core.mapper.reflect.ColumnName;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.java.Postconditions;
+
+/** Maps ResultSet data when querying for a users API key. */
+@Getter
+@EqualsAndHashCode
+@ToString
+public class PlayerApiKeyLookupRecord {
+ private final int apiKeyId;
+ @Nullable private final Integer userId;
+ @Nonnull private final String username;
+ @Nonnull private final String playerChatId;
+ @Nonnull private final String userRole;
+
+ @Builder(toBuilder = true)
+ public PlayerApiKeyLookupRecord(
+ @ColumnName("api_key_id") final int apiKeyId,
+ @Nullable @ColumnName("user_id") final Integer userId,
+ @ColumnName("player_chat_id") final String playerChatId,
+ @ColumnName("user_role") final String userRole,
+ @ColumnName("username") final String username) {
+ this.apiKeyId = apiKeyId;
+ this.userId = userId;
+ this.playerChatId = playerChatId;
+ this.userRole = userRole;
+ this.username = username;
+
+ verifyState();
+ }
+
+ public Integer getUserId() {
+ return (userId == null || userId == 0) ? null : userId;
+ }
+
+ private void verifyState() {
+ Postconditions.assertState(!userRole.equals(UserRole.HOST));
+
+ if (userRole.equals(UserRole.ANONYMOUS)) {
+ Postconditions.assertState(userId == null || userId == 0);
+ } else {
+ Postconditions.assertState(
+ userId != null && userId > 0,
+ String.format(
+ "Non anonymouse users must have a user id, user id: %s, user role: %s",
+ userId, userRole));
+ }
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/PlayerIdentifiersByApiKeyLookup.java b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/PlayerIdentifiersByApiKeyLookup.java
new file mode 100644
index 0000000..1db2186
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/api/key/PlayerIdentifiersByApiKeyLookup.java
@@ -0,0 +1,26 @@
+package org.triplea.db.dao.api.key;
+
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import org.jdbi.v3.core.mapper.reflect.ColumnName;
+import org.triplea.domain.data.SystemId;
+import org.triplea.domain.data.UserName;
+
+@Getter
+@EqualsAndHashCode
+public class PlayerIdentifiersByApiKeyLookup {
+ private final UserName userName;
+ private final SystemId systemId;
+ private final String ip;
+
+ @Builder
+ public PlayerIdentifiersByApiKeyLookup(
+ @ColumnName("username") final String userName,
+ @ColumnName("system_id") final String systemId,
+ @ColumnName("ip") final String ip) {
+ this.userName = UserName.of(userName);
+ this.systemId = SystemId.of(systemId);
+ this.ip = ip;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/chat/history/LobbyChatHistoryDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/chat/history/LobbyChatHistoryDao.java
new file mode 100644
index 0000000..2295024
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/chat/history/LobbyChatHistoryDao.java
@@ -0,0 +1,34 @@
+package org.triplea.db.dao.chat.history;
+
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.ChatReceivedMessage;
+import org.triplea.java.StringUtils;
+
+/** Lobby chat history records lobby chat messages. */
+public interface LobbyChatHistoryDao {
+ int MESSAGE_COLUMN_LENGTH = 240;
+
+ default void recordMessage(ChatReceivedMessage chatReceivedMessage, int apiKeyId) {
+ insertMessage(
+ chatReceivedMessage.getSender().getValue(),
+ apiKeyId,
+ StringUtils.truncate(chatReceivedMessage.getMessage(), MESSAGE_COLUMN_LENGTH));
+ }
+
+ /**
+ * Stores a chat message record to database.
+ *
+ * @param username The name of the user that sent the chat message.
+ * @param apiKeyId The ID of the API key the user used to sign in to the lobby. This is useful for
+ * cross-referencing to know the users IP and system-id.
+ * @param message The chat message contents.
+ */
+ @SqlUpdate(
+ "insert into lobby_chat_history (username, lobby_api_key_id, message) "
+ + "values(:username, :apiKeyId, :message)")
+ void insertMessage(
+ @Bind("username") String username,
+ @Bind("apiKeyId") int apiKeyId,
+ @Bind("message") String message);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/lobby/games/LobbyGameDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/lobby/games/LobbyGameDao.java
new file mode 100644
index 0000000..e7ef061
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/lobby/games/LobbyGameDao.java
@@ -0,0 +1,60 @@
+package org.triplea.db.dao.lobby.games;
+
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+import org.triplea.db.dao.api.key.ApiKeyHasher;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.http.client.lobby.game.lobby.watcher.ChatMessageUpload;
+import org.triplea.http.client.lobby.game.lobby.watcher.LobbyGameListing;
+import org.triplea.java.Postconditions;
+import org.triplea.java.StringUtils;
+
+/**
+ * Game chat history table stores chat messages that have happened in games. This data is upload by
+ * game servers to the lobby and is then recorded in database.
+ */
+public interface LobbyGameDao {
+ int MESSAGE_COLUMN_LENGTH = 240;
+
+ default void insertLobbyGame(ApiKey apiKey, LobbyGameListing lobbyGameListing) {
+ final String hashedkey = new ApiKeyHasher().apply(apiKey);
+ final int insertCount =
+ insertLobbyGame(
+ lobbyGameListing.getLobbyGame().getHostName(), //
+ lobbyGameListing.getGameId(),
+ hashedkey);
+ Postconditions.assertState(
+ insertCount == 1, "Failed to insert lobby game: " + lobbyGameListing);
+ }
+
+ @SqlUpdate(
+ "insert into lobby_game(host_name, game_id, game_hosting_api_key_id) "
+ + "values ("
+ + " :hostName,"
+ + " :gameId,"
+ + " (select id from game_hosting_api_key where key = :apiKey))")
+ int insertLobbyGame(
+ @Bind("hostName") String hostName,
+ @Bind("gameId") String gameId,
+ @Bind("apiKey") String apiKey);
+
+ default void recordChat(final ChatMessageUpload chatMessageUpload) {
+ final int rowInsert =
+ insertChatMessage(
+ chatMessageUpload.getGameId(),
+ chatMessageUpload.getFromPlayer(),
+ StringUtils.truncate(chatMessageUpload.getChatMessage(), MESSAGE_COLUMN_LENGTH));
+ Postconditions.assertState(rowInsert == 1, "Failed to insert message: " + chatMessageUpload);
+ }
+
+ @SqlUpdate(
+ "insert into game_chat_history (lobby_game_id, username, message) "
+ + "values("
+ + " (select id from lobby_game where game_id = :gameId),"
+ + ":username, "
+ + ":message)")
+ int insertChatMessage(
+ @Bind("gameId") String gameId,
+ @Bind("username") String username,
+ @Bind("message") String message);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/BadWordsDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/BadWordsDao.java
new file mode 100644
index 0000000..d7308a3
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/BadWordsDao.java
@@ -0,0 +1,23 @@
+package org.triplea.db.dao.moderator;
+
+import java.util.List;
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+
+/** DAO interface for interacting with the badword table. Essentially provides CRUD operations. */
+public interface BadWordsDao {
+
+ @SqlQuery("select word from bad_word order by word")
+ List getBadWords();
+
+ @SqlUpdate("insert into bad_word (word) values (:word)")
+ int addBadWord(@Bind("word") String badWordToAdd);
+
+ @SqlUpdate("delete from bad_word where word = :word")
+ int removeBadWord(@Bind("word") String badWordToRemove);
+
+ @SqlQuery(
+ "select exists (select * from bad_word where lower(:word) like '%' || lower(word) || '%')")
+ boolean containsBadWord(@Bind("word") String word);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/ModeratorAuditHistoryDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/ModeratorAuditHistoryDao.java
new file mode 100644
index 0000000..972d2e0
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/ModeratorAuditHistoryDao.java
@@ -0,0 +1,80 @@
+package org.triplea.db.dao.moderator;
+
+import com.google.common.base.Preconditions;
+import java.util.List;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+
+/**
+ * Interface for adding new moderator audit records to database. These records keep track of which
+ * actions moderators have taken, who the target was and which moderator took the action.
+ */
+public interface ModeratorAuditHistoryDao {
+ /** The set of moderator actions. */
+ enum AuditAction {
+ BAN_MAC,
+ BAN_USERNAME,
+ REMOVE_USERNAME_BAN,
+ BOOT_GAME,
+ BOOT_USER_FROM_BOT,
+ BOOT_USER_FROM_LOBBY,
+ BAN_PLAYER_FROM_BOT,
+ ADD_BAD_WORD,
+ REMOVE_BAD_WORD,
+ BAN_USER,
+ REMOVE_USER_BAN,
+ ADD_MODERATOR,
+ REMOVE_MODERATOR,
+ ADD_SUPER_MOD,
+ DISCONNECT_USER,
+ DISCONNECT_GAME,
+ REMOTE_SHUTDOWN,
+ }
+
+ /** Parameters needed when adding an audit record. */
+ @Getter
+ @Builder
+ @ToString
+ @EqualsAndHashCode
+ final class AuditArgs {
+ @Nonnull private final Integer moderatorUserId;
+ @Nonnull private final AuditAction actionName;
+ @Nonnull private final String actionTarget;
+ }
+
+ default void addAuditRecord(AuditArgs auditArgs) {
+ final int rowsInserted =
+ insertAuditRecord(
+ auditArgs.moderatorUserId, auditArgs.actionName.toString(), auditArgs.actionTarget);
+ Preconditions.checkState(rowsInserted == 1);
+ }
+
+ @SqlUpdate(
+ "insert into moderator_action_history "
+ + " (lobby_user_id, action_name, action_target) "
+ + "values (:moderatorId, :actionName, :actionTarget)")
+ int insertAuditRecord(
+ @Bind("moderatorId") int moderatorId,
+ @Bind("actionName") String actionName,
+ @Bind("actionTarget") String actionTarget);
+
+ @SqlQuery(
+ "select"
+ + " h.date_created,"
+ + " u.username,"
+ + " h.action_name,"
+ + " h.action_target"
+ + " from moderator_action_history h"
+ + " join lobby_user u on u.id = h.lobby_user_id"
+ + " order by h.date_created desc"
+ + " offset :rowOffset rows"
+ + " fetch next :rowCount rows only")
+ List lookupHistoryItems(
+ @Bind("rowOffset") int rowOffset, @Bind("rowCount") int rowCount);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/ModeratorAuditHistoryRecord.java b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/ModeratorAuditHistoryRecord.java
new file mode 100644
index 0000000..72a7a40
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/ModeratorAuditHistoryRecord.java
@@ -0,0 +1,27 @@
+package org.triplea.db.dao.moderator;
+
+import java.time.Instant;
+import lombok.Builder;
+import lombok.Getter;
+import org.jdbi.v3.core.mapper.reflect.ColumnName;
+
+/** Return data when selecting moderator audit history. */
+@Getter
+public class ModeratorAuditHistoryRecord {
+ private final Instant dateCreated;
+ private final String username;
+ private final String actionName;
+ private final String actionTarget;
+
+ @Builder
+ public ModeratorAuditHistoryRecord(
+ @ColumnName("date_created") final Instant dateCreated,
+ @ColumnName("username") final String username,
+ @ColumnName("action_name") final String actionName,
+ @ColumnName("action_target") final String actionTarget) {
+ this.dateCreated = dateCreated;
+ this.username = username;
+ this.actionName = actionName;
+ this.actionTarget = actionTarget;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/ModeratorUserDaoData.java b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/ModeratorUserDaoData.java
new file mode 100644
index 0000000..b165c8b
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/ModeratorUserDaoData.java
@@ -0,0 +1,21 @@
+package org.triplea.db.dao.moderator;
+
+import java.time.Instant;
+import lombok.Builder;
+import lombok.Getter;
+import org.jdbi.v3.core.mapper.reflect.ColumnName;
+
+/** Return data when querying the user table for moderators. */
+@Getter
+public class ModeratorUserDaoData {
+ private final String username;
+ private final Instant lastLogin;
+
+ @Builder
+ public ModeratorUserDaoData(
+ @ColumnName("username") final String username,
+ @ColumnName("access_time") final Instant lastLogin) {
+ this.username = username;
+ this.lastLogin = lastLogin;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/ModeratorsDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/ModeratorsDao.java
new file mode 100644
index 0000000..e6608cc
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/ModeratorsDao.java
@@ -0,0 +1,35 @@
+package org.triplea.db.dao.moderator;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.customizer.BindList;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+import org.triplea.db.dao.user.role.UserRole;
+
+/** DAO for managing moderator users. */
+public interface ModeratorsDao {
+ @SqlQuery(
+ "select "
+ + " lu.username,"
+ + " max(al.access_time) access_time"
+ + " from lobby_user lu"
+ + " left join access_log al on al.lobby_user_id = lu.id"
+ + " join user_role ur on ur.id = lu.user_role_id"
+ + " where ur.name in ()"
+ + " group by lu.username"
+ + " order by lu.username")
+ List getUserByRole(@BindList("roles") Collection roles);
+
+ default List getModerators() {
+ return getUserByRole(Set.of(UserRole.MODERATOR, UserRole.ADMIN));
+ }
+
+ @SqlUpdate(
+ "update lobby_user"
+ + " set user_role_id = (select id from user_role where name = :role)"
+ + " where id = :userId")
+ int setRole(@Bind("userId") int userId, @Bind("role") String role);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/chat/history/ChatHistoryRecord.java b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/chat/history/ChatHistoryRecord.java
new file mode 100644
index 0000000..466fb78
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/chat/history/ChatHistoryRecord.java
@@ -0,0 +1,34 @@
+package org.triplea.db.dao.moderator.chat.history;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.time.Instant;
+import lombok.Builder;
+import lombok.Getter;
+import org.jdbi.v3.core.mapper.reflect.ColumnName;
+import org.triplea.http.client.lobby.moderator.ChatHistoryMessage;
+
+/** Represents most of a row of the game_chat_history table, who said what and when. */
+@Getter(onMethod_ = @VisibleForTesting)
+public class ChatHistoryRecord {
+ private final Instant date;
+ private final String username;
+ private final String message;
+
+ @Builder
+ public ChatHistoryRecord(
+ @ColumnName("date") final Instant date,
+ @ColumnName("username") final String username,
+ @ColumnName("message") final String message) {
+ this.date = date;
+ this.username = username;
+ this.message = message;
+ }
+
+ public ChatHistoryMessage toChatHistoryMessage() {
+ return ChatHistoryMessage.builder()
+ .epochMilliDate(date.toEpochMilli())
+ .username(username)
+ .message(message)
+ .build();
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/chat/history/GameChatHistoryDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/chat/history/GameChatHistoryDao.java
new file mode 100644
index 0000000..8727100
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/chat/history/GameChatHistoryDao.java
@@ -0,0 +1,24 @@
+package org.triplea.db.dao.moderator.chat.history;
+
+import java.util.List;
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+
+/**
+ * DAO to access history of in-game chat messages stored in database. Chat messages are stored for
+ * all lobby-connected games.
+ */
+public interface GameChatHistoryDao {
+
+ @SqlQuery(
+ "select "
+ + " gch.date,"
+ + " gch.username,"
+ + " gch.message"
+ + " from game_chat_history gch"
+ + " join lobby_game lg on lg.id = gch.lobby_game_id"
+ + " where"
+ + " lg.game_id = :gameId"
+ + " and date > (now() - '6 hour'::interval)")
+ List getChatHistory(@Bind("gameId") String gameId);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/player/info/PlayerAliasRecord.java b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/player/info/PlayerAliasRecord.java
new file mode 100644
index 0000000..c8da08d
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/player/info/PlayerAliasRecord.java
@@ -0,0 +1,41 @@
+package org.triplea.db.dao.moderator.player.info;
+
+import java.time.Instant;
+import lombok.Builder;
+import lombok.Getter;
+import org.jdbi.v3.core.mapper.reflect.ColumnName;
+import org.triplea.http.client.lobby.moderator.PlayerSummary.Alias;
+
+/**
+ * Represents all distinct matching rows (within the last N days) in the access log table with a
+ * matching IP or system ID. This should tell us each name, or aliases, that was presumably used by
+ * a given player.
+ */
+@Getter
+public class PlayerAliasRecord {
+ private final String username;
+ private final String ip;
+ private final String systemId;
+ private final Instant date;
+
+ @Builder
+ public PlayerAliasRecord(
+ @ColumnName("name") final String username,
+ @ColumnName("ip") final String ip,
+ @ColumnName("systemId") final String systemId,
+ @ColumnName("accessTime") final Instant accessTime) {
+ this.username = username;
+ this.ip = ip;
+ this.systemId = systemId;
+ this.date = accessTime;
+ }
+
+ public Alias toAlias() {
+ return Alias.builder()
+ .name(username)
+ .ip(ip)
+ .systemId(systemId)
+ .epochMilliDate(date.toEpochMilli())
+ .build();
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/player/info/PlayerBanRecord.java b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/player/info/PlayerBanRecord.java
new file mode 100644
index 0000000..e67d39a
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/player/info/PlayerBanRecord.java
@@ -0,0 +1,44 @@
+package org.triplea.db.dao.moderator.player.info;
+
+import java.time.Instant;
+import lombok.Builder;
+import lombok.Getter;
+import org.jdbi.v3.core.mapper.reflect.ColumnName;
+import org.triplea.http.client.lobby.moderator.PlayerSummary.BanInformation;
+
+/**
+ * Represents each row from the ban table where a given system id or IP was banned (within the last
+ * N days).
+ */
+@Getter
+public class PlayerBanRecord {
+ private final String username;
+ private final String ip;
+ private final String systemId;
+ private final Instant banStart;
+ private final Instant banEnd;
+
+ @Builder
+ public PlayerBanRecord(
+ @ColumnName("username") final String username,
+ @ColumnName("ip") final String ip,
+ @ColumnName("system_id") final String systemId,
+ @ColumnName("date_created") final Instant banStart,
+ @ColumnName("ban_expiry") final Instant banEnd) {
+ this.username = username;
+ this.ip = ip;
+ this.systemId = systemId;
+ this.banStart = banStart;
+ this.banEnd = banEnd;
+ }
+
+ public BanInformation toBanInformation() {
+ return BanInformation.builder()
+ .name(username)
+ .ip(ip)
+ .systemId(systemId)
+ .epochMilliStartDate(banStart.toEpochMilli())
+ .epochMillEndDate(banEnd.toEpochMilli())
+ .build();
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/player/info/PlayerInfoForModeratorDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/player/info/PlayerInfoForModeratorDao.java
new file mode 100644
index 0000000..94baa20
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/moderator/player/info/PlayerInfoForModeratorDao.java
@@ -0,0 +1,48 @@
+package org.triplea.db.dao.moderator.player.info;
+
+import java.util.List;
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+
+/**
+ * DAO used to lookup player correlation information for moderators. Answers questions such as
+ *
+ *
+ * - "how many times and was this player banned?"
+ *
- "which other names were used by this same IP and system-id? (presumably the same player)"
+ *
+ */
+public interface PlayerInfoForModeratorDao {
+ @SqlQuery(
+ "select distinct"
+ + " username as name,"
+ + " ip as ip,"
+ + " system_id as systemId,"
+ + " max(access_time) as accessTime"
+ + " from access_log"
+ + " where "
+ + " access_time > (now() - '14 day'::interval)"
+ + " and ("
+ + " ip = :ip::inet"
+ + " or system_id = :systemId"
+ + " )"
+ + " group by name, ip, systemId"
+ + " order by accessTime desc")
+ List lookupPlayerAliasRecords(
+ @Bind("systemId") String systemId, @Bind("ip") String ip);
+
+ @SqlQuery(
+ "select"
+ + " username,"
+ + " ip,"
+ + " system_id,"
+ + " date_created,"
+ + " ban_expiry"
+ + " from banned_user"
+ + " where "
+ + " ip = :ip::inet"
+ + " or system_id = :systemId"
+ + " order by ban_expiry desc")
+ List lookupPlayerBanRecords(
+ @Bind("systemId") String systemId, @Bind("ip") String ip);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/temp/password/TempPasswordDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/temp/password/TempPasswordDao.java
new file mode 100644
index 0000000..93bc0f4
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/temp/password/TempPasswordDao.java
@@ -0,0 +1,59 @@
+package org.triplea.db.dao.temp.password;
+
+import java.util.Optional;
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+import org.jdbi.v3.sqlobject.transaction.Transaction;
+
+/**
+ * DAO for CRUD operations on temp password table. A table that stores temporary passwords issued to
+ * them with the 'forgot password' feature.
+ */
+public interface TempPasswordDao {
+ String TEMP_PASSWORD_EXPIRATION = "1 day";
+
+ @SqlQuery(
+ "select temp_password"
+ + " from temp_password_request t"
+ + " join lobby_user lu on lu.id = t.lobby_user_id"
+ + " where lu.username = :username"
+ + " and t.date_created > (now() - '"
+ + TEMP_PASSWORD_EXPIRATION
+ + "'::interval)"
+ + " and t.date_invalidated is null")
+ Optional fetchTempPassword(@Bind("username") String username);
+
+ @SqlQuery("select id from lobby_user where username = :username")
+ Optional lookupUserIdByUsername(@Bind("username") String username);
+
+ @SqlUpdate(
+ "insert into temp_password_request"
+ + " (lobby_user_id, temp_password)"
+ + " values (:userId, :password)")
+ void insertPassword(@Bind("userId") int userId, @Bind("password") String password);
+
+ @SqlQuery("select id from lobby_user where username = :username and email = :email")
+ Optional lookupUserIdByUsernameAndEmail(
+ @Bind("username") String username, @Bind("email") String email);
+
+ @SqlUpdate(
+ "update temp_password_request"
+ + " set date_invalidated = now()"
+ + " where lobby_user_id = (select id from lobby_user where username = :playerName)"
+ + " and date_invalidated is null")
+ int invalidateTempPasswords(@Bind("playerName") String playerName);
+
+ @Transaction
+ default boolean insertTempPassword(
+ final String username, final String email, final String password) {
+ return lookupUserIdByUsernameAndEmail(username, email)
+ .map(
+ userId -> {
+ invalidateTempPasswords(username);
+ insertPassword(userId, password);
+ return true;
+ })
+ .orElse(false);
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/temp/password/TempPasswordHistoryDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/temp/password/TempPasswordHistoryDao.java
new file mode 100644
index 0000000..6835479
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/temp/password/TempPasswordHistoryDao.java
@@ -0,0 +1,31 @@
+package org.triplea.db.dao.temp.password;
+
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+
+/**
+ * DAO for CRUD operations on temp password history table. A table that stores a history of requests
+ * for temporary passwords.
+ */
+public interface TempPasswordHistoryDao {
+
+ /**
+ * Returns the number of temp password requests made in the last day from a particular IP address.
+ */
+ @SqlQuery(
+ "select count(*)"
+ + " from temp_password_request_history"
+ + " where inetaddress = :inetAddress::inet"
+ + " and date_created > (now() - '1 day'::interval)")
+ int countRequestsFromAddress(@Bind("inetAddress") String address);
+
+ /**
+ * Records a temp password request being made from a given IP address and for a given username.
+ */
+ @SqlUpdate(
+ "insert into temp_password_request_history(inetaddress, username)"
+ + " values(:inetaddress::inet, :username)")
+ void recordTempPasswordRequest(
+ @Bind("inetaddress") String address, @Bind("username") String username);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/user/UserJdbiDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/user/UserJdbiDao.java
new file mode 100644
index 0000000..8ff0a54
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/user/UserJdbiDao.java
@@ -0,0 +1,51 @@
+package org.triplea.db.dao.user;
+
+import java.util.Optional;
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.db.dao.user.role.UserRoleLookup;
+
+/** Data access object for the users table. */
+public interface UserJdbiDao {
+ @SqlQuery("select id from lobby_user where username = :username")
+ Optional lookupUserIdByName(@Bind("username") String username);
+
+ @SqlQuery("select bcrypt_password from lobby_user where username = :username")
+ Optional getPassword(@Bind("username") String username);
+
+ @SqlUpdate("update lobby_user set bcrypt_password = :newPassword where id = :userId")
+ int updatePassword(@Bind("userId") int userId, @Bind("newPassword") String newPassword);
+
+ @SqlQuery("select email from lobby_user where id = :userId")
+ String fetchEmail(@Bind("userId") int userId);
+
+ @SqlUpdate("update lobby_user set email = :newEmail where id = :userId")
+ int updateEmail(@Bind("userId") int userId, @Bind("newEmail") String newEmail);
+
+ @SqlQuery(
+ "select "
+ + " id,"
+ + " user_role_id"
+ + " from lobby_user"
+ + " where username = :username")
+ Optional lookupUserIdAndRoleIdByUserName(@Bind("username") String username);
+
+ @SqlQuery(
+ "select ur.name from user_role ur "
+ + "join lobby_user lu on lu.user_role_id = ur.id "
+ + "where lu.username = :username")
+ Optional lookupUserRoleByUserName(@Bind("username") String username);
+
+ @SqlUpdate(
+ "insert into lobby_user(username, email, bcrypt_password, user_role_id) "
+ + "select "
+ + ":username, :email, :password, (select id from user_role where name = '"
+ + UserRole.PLAYER
+ + "') as role_id")
+ int createUser(
+ @Bind("username") String username,
+ @Bind("email") String email,
+ @Bind("password") String password);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/user/ban/BanLookupRecord.java b/server/lobby-module/src/main/java/org/triplea/db/dao/user/ban/BanLookupRecord.java
new file mode 100644
index 0000000..37329fa
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/user/ban/BanLookupRecord.java
@@ -0,0 +1,27 @@
+package org.triplea.db.dao.user.ban;
+
+import java.time.Instant;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import org.jdbi.v3.core.mapper.reflect.ColumnName;
+
+/**
+ * Lookup record to determine if a player is banned. If so, gives enough information to inform the
+ * player of the ban duration and the 'public id' of the ban.
+ */
+@Getter
+@EqualsAndHashCode
+public class BanLookupRecord {
+
+ private final String publicBanId;
+ private final Instant banExpiry;
+
+ @Builder
+ public BanLookupRecord(
+ @ColumnName("public_id") final String publicBanId,
+ @ColumnName("ban_expiry") final Instant banExpiry) {
+ this.publicBanId = publicBanId;
+ this.banExpiry = banExpiry;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/user/ban/UserBanDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/user/ban/UserBanDao.java
new file mode 100644
index 0000000..2a4b35e
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/user/ban/UserBanDao.java
@@ -0,0 +1,62 @@
+package org.triplea.db.dao.user.ban;
+
+import java.util.List;
+import java.util.Optional;
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+
+/** DAO for managing user bans (CRUD operations). */
+public interface UserBanDao {
+ @SqlQuery(
+ "select "
+ + " public_id,"
+ + " username,"
+ + " system_id,"
+ + " ip,"
+ + " ban_expiry,"
+ + " date_created"
+ + " from banned_user"
+ + " where ban_expiry > now()"
+ + " order by date_created desc")
+ List lookupBans();
+
+ @SqlQuery(
+ "select "
+ + " public_id,"
+ + " ban_expiry"
+ + " from banned_user"
+ + " where (ip = :ip::inet or system_id = :systemId)"
+ + " and ban_expiry > now()"
+ + " order by ban_expiry desc "
+ + " limit 1")
+ Optional lookupBan(@Bind("ip") String ip, @Bind("systemId") String systemId);
+
+ @SqlQuery(
+ "select exists ("
+ + " select * "
+ + " from banned_user "
+ + " where ip = :ip::inet and ban_expiry > now()"
+ + ")")
+ boolean isBannedByIp(@Bind("ip") String ip);
+
+ @SqlQuery(
+ "select username" //
+ + " from banned_user"
+ + " where public_id = :banId")
+ Optional lookupUsernameByBanId(@Bind("banId") String banId);
+
+ @SqlUpdate("delete from banned_user where public_id = :banId")
+ int removeBan(@Bind("banId") String banId);
+
+ @SqlUpdate(
+ "insert into banned_user"
+ + "(public_id, username, system_id, ip, ban_expiry) values\n"
+ + "(:banId, :username, :systemId, :ip::inet, now() + :banMinutes * '1 minute'::interval)")
+ int addBan(
+ @Bind("banId") String banId,
+ @Bind("username") String username,
+ @Bind("systemId") String systemId,
+ @Bind("ip") String ip,
+ @Bind("banMinutes") long banMinutes);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/user/ban/UserBanRecord.java b/server/lobby-module/src/main/java/org/triplea/db/dao/user/ban/UserBanRecord.java
new file mode 100644
index 0000000..8db7af1
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/user/ban/UserBanRecord.java
@@ -0,0 +1,38 @@
+package org.triplea.db.dao.user.ban;
+
+import java.time.Instant;
+import lombok.Builder;
+import lombok.Getter;
+import org.jdbi.v3.core.mapper.reflect.ColumnName;
+
+/**
+ * Return data when querying about user bans. The public ban id is for public reference, this is a
+ * value we can show to banned users so they can report which ban is impacting them. With that
+ * information we have the ability to remove the ban without needing to ask them for mac or IP.
+ */
+@Getter
+public class UserBanRecord {
+
+ private final String publicBanId;
+ private final String username;
+ private final String systemId;
+ private final String ip;
+ private final Instant banExpiry;
+ private final Instant dateCreated;
+
+ @Builder
+ public UserBanRecord(
+ @ColumnName("public_id") final String publicBanId,
+ @ColumnName("username") final String username,
+ @ColumnName("system_id") final String systemId,
+ @ColumnName("ip") final String ip,
+ @ColumnName("ban_expiry") final Instant banExpiry,
+ @ColumnName("date_created") final Instant dateCreated) {
+ this.publicBanId = publicBanId;
+ this.username = username;
+ this.systemId = systemId;
+ this.ip = ip;
+ this.banExpiry = banExpiry;
+ this.dateCreated = dateCreated;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/user/history/PlayerHistoryDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/user/history/PlayerHistoryDao.java
new file mode 100644
index 0000000..7bc561f
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/user/history/PlayerHistoryDao.java
@@ -0,0 +1,16 @@
+package org.triplea.db.dao.user.history;
+
+import java.util.Optional;
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+
+/** DAO to look up a players stats. Intended to be used when getting player information. */
+public interface PlayerHistoryDao {
+
+ @SqlQuery(
+ "select"
+ + " date_created as date_registered"
+ + " from lobby_user"
+ + " where id = :userId")
+ Optional lookupPlayerHistoryByUserId(@Bind("userId") int userId);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/user/history/PlayerHistoryRecord.java b/server/lobby-module/src/main/java/org/triplea/db/dao/user/history/PlayerHistoryRecord.java
new file mode 100644
index 0000000..6207cff
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/user/history/PlayerHistoryRecord.java
@@ -0,0 +1,15 @@
+package org.triplea.db.dao.user.history;
+
+import java.time.Instant;
+import lombok.Getter;
+import org.jdbi.v3.core.mapper.reflect.ColumnName;
+
+@Getter
+public class PlayerHistoryRecord {
+
+ private final long registrationDate;
+
+ public PlayerHistoryRecord(@ColumnName("date_registered") final Instant registrationDate) {
+ this.registrationDate = registrationDate.toEpochMilli();
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/user/role/UserRole.java b/server/lobby-module/src/main/java/org/triplea/db/dao/user/role/UserRole.java
new file mode 100644
index 0000000..d62fad2
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/user/role/UserRole.java
@@ -0,0 +1,20 @@
+package org.triplea.db.dao.user.role;
+
+import lombok.experimental.UtilityClass;
+
+/**
+ * Class to hold constants representing the possible set of user role values.
+ * Note: this is not an enum to allow these values to be referenced from annotations.
+ */
+@UtilityClass
+public final class UserRole {
+ public static final String ADMIN = "ADMIN";
+ public static final String MODERATOR = "MODERATOR";
+ public static final String PLAYER = "PLAYER";
+ public static final String ANONYMOUS = "ANONYMOUS";
+ public static final String HOST = "HOST";
+
+ public static boolean isModerator(final String roleName) {
+ return ADMIN.equals(roleName) || MODERATOR.equals(roleName);
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/user/role/UserRoleDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/user/role/UserRoleDao.java
new file mode 100644
index 0000000..eaefd1f
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/user/role/UserRoleDao.java
@@ -0,0 +1,9 @@
+package org.triplea.db.dao.user.role;
+
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+
+public interface UserRoleDao {
+ @SqlQuery("select id from user_role where name = :userRole")
+ int lookupRoleId(@Bind("userRole") String userRole);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/user/role/UserRoleLookup.java b/server/lobby-module/src/main/java/org/triplea/db/dao/user/role/UserRoleLookup.java
new file mode 100644
index 0000000..d1a5ad7
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/user/role/UserRoleLookup.java
@@ -0,0 +1,20 @@
+package org.triplea.db.dao.user.role;
+
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import org.jdbi.v3.core.mapper.reflect.ColumnName;
+
+@Getter
+@EqualsAndHashCode
+public class UserRoleLookup {
+ private final int userId;
+ private final int userRoleId;
+
+ @Builder
+ public UserRoleLookup(
+ @ColumnName("id") final int userId, @ColumnName("user_role_id") final int userRoleId) {
+ this.userId = userId;
+ this.userRoleId = userRoleId;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/username/ban/UsernameBanDao.java b/server/lobby-module/src/main/java/org/triplea/db/dao/username/ban/UsernameBanDao.java
new file mode 100644
index 0000000..33c9abe
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/username/ban/UsernameBanDao.java
@@ -0,0 +1,34 @@
+package org.triplea.db.dao.username.ban;
+
+import java.util.List;
+import org.jdbi.v3.sqlobject.customizer.Bind;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+
+/** Interface with the banned_names table, these are exact match names not allowed in the lobby. */
+public interface UsernameBanDao {
+
+ @SqlQuery(
+ "select"
+ + " username,"
+ + " date_created"
+ + " from banned_username"
+ + " order by username asc")
+ List getBannedUserNames();
+
+ @SqlUpdate(
+ "insert into banned_username(username)\n"
+ + "values(:nameToBan)\n"
+ + "on conflict(username) do nothing")
+ int addBannedUserName(@Bind("nameToBan") String nameToBan);
+
+ @SqlUpdate("delete from banned_username where username = :nameToRemove")
+ int removeBannedUserName(@Bind("nameToRemove") String nameToRemove);
+
+ @SqlQuery(
+ "select exists ( "
+ + "select * "
+ + "from banned_username "
+ + "where username = upper(:playerName))")
+ boolean nameIsBanned(@Bind("playerName") String playerName);
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/db/dao/username/ban/UsernameBanRecord.java b/server/lobby-module/src/main/java/org/triplea/db/dao/username/ban/UsernameBanRecord.java
new file mode 100644
index 0000000..ffa2c0a
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/db/dao/username/ban/UsernameBanRecord.java
@@ -0,0 +1,21 @@
+package org.triplea.db.dao.username.ban;
+
+import java.time.Instant;
+import lombok.Builder;
+import lombok.Getter;
+import org.jdbi.v3.core.mapper.reflect.ColumnName;
+
+/** Return data when querying the banned username table. */
+@Getter
+public class UsernameBanRecord {
+ private final String username;
+ private final Instant dateCreated;
+
+ @Builder
+ public UsernameBanRecord(
+ @ColumnName("username") final String username,
+ @ColumnName("date_created") final Instant dateCreated) {
+ this.username = username;
+ this.dateCreated = dateCreated;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/LobbyModuleConfig.java b/server/lobby-module/src/main/java/org/triplea/modules/LobbyModuleConfig.java
new file mode 100644
index 0000000..5abd6db
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/LobbyModuleConfig.java
@@ -0,0 +1,18 @@
+package org.triplea.modules;
+
+import org.triplea.http.client.github.GithubApiClient;
+
+/**
+ * Interface for configuration parameters, typically these values will come from the servers
+ * 'configuration.yml' file.
+ */
+public interface LobbyModuleConfig {
+ /**
+ * When true we will do a reverse connectivity check to game hosts. Otherwise false will enable a
+ * special test only endpoint that still does authentication but will not do the reverse
+ * connectivity check.
+ */
+ boolean isGameHostConnectivityCheckEnabled();
+
+ GithubApiClient createGamesRepoGithubApiClient();
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/chat/ChatMessagingService.java b/server/lobby-module/src/main/java/org/triplea/modules/chat/ChatMessagingService.java
new file mode 100644
index 0000000..95d0d6b
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/chat/ChatMessagingService.java
@@ -0,0 +1,45 @@
+package org.triplea.modules.chat;
+
+import com.google.common.base.Preconditions;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.ChatSentMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.ConnectToChatMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.PlayerSlapSentMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.PlayerStatusUpdateSentMessage;
+import org.triplea.modules.chat.event.processing.ChatMessageListener;
+import org.triplea.modules.chat.event.processing.PlayerConnectedListener;
+import org.triplea.modules.chat.event.processing.PlayerLeftListener;
+import org.triplea.modules.chat.event.processing.SlapListener;
+import org.triplea.modules.chat.event.processing.StatusUpdateListener;
+import org.triplea.web.socket.WebSocketMessagingBus;
+
+@Builder
+public class ChatMessagingService {
+ private final PlayerConnectedListener playerConnectedListener;
+ private final ChatMessageListener chatMessageListener;
+ private final StatusUpdateListener statusUpdateListener;
+ private final SlapListener slapListener;
+ private final PlayerLeftListener playerLeftListener;
+
+ public static ChatMessagingService build(final Chatters chatters, final Jdbi jdbi) {
+ Preconditions.checkNotNull(chatters);
+ return ChatMessagingService.builder()
+ .playerConnectedListener(PlayerConnectedListener.build(chatters, jdbi))
+ .chatMessageListener(ChatMessageListener.build(chatters, jdbi))
+ .statusUpdateListener(new StatusUpdateListener(chatters))
+ .slapListener(new SlapListener(chatters))
+ .playerLeftListener(new PlayerLeftListener(chatters))
+ .build();
+ }
+
+ public void configure(final WebSocketMessagingBus playerConnectionMessagingBus) {
+ playerConnectionMessagingBus.addMessageListener(
+ ConnectToChatMessage.TYPE, playerConnectedListener);
+ playerConnectionMessagingBus.addMessageListener(ChatSentMessage.TYPE, chatMessageListener);
+ playerConnectionMessagingBus.addMessageListener(PlayerSlapSentMessage.TYPE, slapListener);
+ playerConnectionMessagingBus.addMessageListener(
+ PlayerStatusUpdateSentMessage.TYPE, statusUpdateListener);
+ playerConnectionMessagingBus.addSessionDisconnectListener(playerLeftListener);
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/chat/ChatterSession.java b/server/lobby-module/src/main/java/org/triplea/modules/chat/ChatterSession.java
new file mode 100644
index 0000000..74ab930
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/chat/ChatterSession.java
@@ -0,0 +1,18 @@
+package org.triplea.modules.chat;
+
+import java.net.InetAddress;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import org.triplea.domain.data.ChatParticipant;
+import org.triplea.web.socket.WebSocketSession;
+
+@Getter
+@Builder
+@EqualsAndHashCode
+public class ChatterSession {
+ private final ChatParticipant chatParticipant;
+ private final WebSocketSession session;
+ private final int apiKeyId;
+ private final InetAddress ip;
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/chat/Chatters.java b/server/lobby-module/src/main/java/org/triplea/modules/chat/Chatters.java
new file mode 100644
index 0000000..e2ee047
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/chat/Chatters.java
@@ -0,0 +1,185 @@
+package org.triplea.modules.chat;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.net.InetAddress;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+import javax.websocket.CloseReason;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.triplea.domain.data.ChatParticipant;
+import org.triplea.domain.data.PlayerChatId;
+import org.triplea.domain.data.UserName;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.ChatEventReceivedMessage;
+import org.triplea.web.socket.MessageBroadcaster;
+import org.triplea.web.socket.WebSocketSession;
+
+/** Keeps the current list of ChatParticipants and maps them to their websocket session. */
+@AllArgsConstructor
+public class Chatters {
+ @Getter(value = AccessLevel.PACKAGE, onMethod_ = @VisibleForTesting)
+ private final Map participants = new ConcurrentHashMap<>();
+
+ private final Map playerMutes = new HashMap<>();
+
+ public static Chatters build() {
+ return new Chatters();
+ }
+
+ public Optional lookupPlayerBySession(final WebSocketSession senderSession) {
+ return Optional.ofNullable(participants.get(senderSession.getId()));
+ }
+
+ public Optional lookupPlayerByChatId(final PlayerChatId playerChatId) {
+ return participants.values().stream()
+ .filter(
+ chatterSession ->
+ chatterSession.getChatParticipant().getPlayerChatId().equals(playerChatId))
+ .findAny();
+ }
+
+ public void connectPlayer(final ChatterSession chatterSession) {
+ participants.put(chatterSession.getSession().getId(), chatterSession);
+ }
+
+ public Collection getChatters() {
+ return participants.values().stream()
+ .map(ChatterSession::getChatParticipant)
+ .collect(Collectors.toList());
+ }
+
+ public Optional playerLeft(final WebSocketSession session) {
+ return Optional.ofNullable(participants.remove(session.getId()))
+ .map(ChatterSession::getChatParticipant)
+ .map(ChatParticipant::getUserName);
+ }
+
+ public boolean isPlayerConnected(final UserName userName) {
+ return participants.values().stream()
+ .map(ChatterSession::getChatParticipant)
+ .map(ChatParticipant::getUserName)
+ .anyMatch(userName::equals);
+ }
+
+ public Collection fetchOpenSessions() {
+ return participants.values().stream()
+ .map(ChatterSession::getSession)
+ .filter(WebSocketSession::isOpen)
+ .collect(Collectors.toSet());
+ }
+
+ /**
+ * Disconnects all sessions belonging to a given player identified by name. A disconnected session
+ * is closed, the closure will trigger a notification on the client of the disconnected player.
+ *
+ * @param userName The name of the player whose sessions will be disconnected.
+ * @param disconnectMessage Message that will be displayed to the disconnected player.
+ * @return True if any sessions were disconnected, false if none (indicating player was no longer
+ * in chat).
+ */
+ public boolean disconnectPlayerByName(final UserName userName, final String disconnectMessage) {
+ final Set sessions =
+ participants.values().stream()
+ .filter(
+ chatterSession ->
+ chatterSession.getChatParticipant().getUserName().equals(userName))
+ .map(ChatterSession::getSession)
+ .collect(Collectors.toSet());
+
+ disconnectSession(sessions, disconnectMessage);
+ return !sessions.isEmpty();
+ }
+
+ private void disconnectSession(
+ final Collection sessions, final String disconnectMessage) {
+ // Do session disconnects as its own step to avoid concurrent modification.
+ sessions.forEach(
+ session ->
+ session.close(
+ new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, disconnectMessage)));
+ }
+
+ /**
+ * Disconnects all sessions belonging to a given player identified by IP. A disconnected session
+ * is closed, the closure will trigger a notification on the client of the disconnected player.
+ *
+ * @param ip The IP address of the player whose sessions will be disconnected.
+ * @param disconnectMessage Message that will be displayed to the disconnected player.
+ * @return True if any sessions were disconnected, false if none (indicating player was no longer
+ * in chat).
+ */
+ public boolean disconnectIp(final InetAddress ip, final String disconnectMessage) {
+ final Set sessions =
+ participants.values().stream()
+ .filter(chatterSession -> chatterSession.getIp().equals(ip))
+ .map(ChatterSession::getSession)
+ .collect(Collectors.toSet());
+
+ disconnectSession(sessions, disconnectMessage);
+ return !sessions.isEmpty();
+ }
+
+ /**
+ * Checks if a given chatter is currently muted, if so returns the {@code Instant} when the mute
+ * expires otherwise returns an empty optional
+ */
+ public Optional getPlayerMuteExpiration(final InetAddress inetAddress) {
+ return getPlayerMuteExpiration(inetAddress, Clock.systemUTC());
+ }
+
+ @VisibleForTesting
+ Optional getPlayerMuteExpiration(final InetAddress inetAddress, final Clock clock) {
+ // if we have a mute
+ return Optional.ofNullable(
+ playerMutes.computeIfPresent(
+ inetAddress,
+ (address, existingBan) -> existingBan.isAfter(clock.instant()) ? existingBan : null));
+ }
+
+ public void mutePlayer(final PlayerChatId playerChatId, final long muteMinutes) {
+ mutePlayer(playerChatId, muteMinutes, Clock.systemUTC(), MessageBroadcaster.build());
+ }
+
+ @VisibleForTesting
+ void mutePlayer(
+ final PlayerChatId playerChatId,
+ final long muteMinutes,
+ final Clock clock,
+ final MessageBroadcaster messageBroadcaster) {
+ lookupPlayerByChatId(playerChatId)
+ .ifPresent(
+ chatterSession -> {
+ muteIpAddress(chatterSession.getIp(), muteMinutes, clock);
+ broadCastPlayerMutedMessageToAllPlayer(
+ chatterSession.getChatParticipant().getUserName(),
+ muteMinutes,
+ messageBroadcaster);
+ });
+ }
+
+ private void muteIpAddress(final InetAddress ip, final long muteMinutes, final Clock clock) {
+ final Instant muteUntil = clock.instant().plus(muteMinutes, ChronoUnit.MINUTES);
+ playerMutes.put(ip, muteUntil);
+ }
+
+ private void broadCastPlayerMutedMessageToAllPlayer(
+ final UserName userName,
+ final long muteMinutes,
+ final MessageBroadcaster messageBroadcaster) {
+ messageBroadcaster.accept(
+ fetchOpenSessions(),
+ new ChatEventReceivedMessage(
+ String.format(
+ "%s was muted by moderator for %s minutes", userName.getValue(), muteMinutes))
+ .toEnvelope());
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/ChatMessageListener.java b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/ChatMessageListener.java
new file mode 100644
index 0000000..7694d6b
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/ChatMessageListener.java
@@ -0,0 +1,74 @@
+package org.triplea.modules.chat.event.processing;
+
+import java.net.InetAddress;
+import java.time.Instant;
+import java.util.function.Consumer;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import lombok.extern.slf4j.Slf4j;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.chat.history.LobbyChatHistoryDao;
+import org.triplea.domain.data.ChatParticipant;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.ChatEventReceivedMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.ChatReceivedMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.ChatSentMessage;
+import org.triplea.java.concurrency.AsyncRunner;
+import org.triplea.modules.chat.ChatterSession;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.web.socket.WebSocketMessageContext;
+
+@Builder
+@Slf4j
+public class ChatMessageListener implements Consumer> {
+
+ @Nonnull private final Chatters chatters;
+ @Nonnull private final LobbyChatHistoryDao lobbyChatHistoryDao;
+
+ public static ChatMessageListener build(final Chatters chatters, final Jdbi jdbi) {
+ return ChatMessageListener.builder()
+ .chatters(chatters)
+ .lobbyChatHistoryDao(jdbi.onDemand(LobbyChatHistoryDao.class))
+ .build();
+ }
+
+ @Override
+ public void accept(final WebSocketMessageContext messageContext) {
+ final InetAddress chatterIp = messageContext.getSenderSession().getRemoteAddress();
+
+ chatters
+ .lookupPlayerBySession(messageContext.getSenderSession())
+ .ifPresent(
+ session ->
+ chatters
+ .getPlayerMuteExpiration(chatterIp)
+ .ifPresentOrElse(
+ expiry -> sendResponseToMutedPlayer(expiry, messageContext),
+ () -> recordAndBroadcastMessageToAllPlayers(session, messageContext)));
+ }
+
+ private void sendResponseToMutedPlayer(
+ final Instant muteExpiry, final WebSocketMessageContext messageContext) {
+ messageContext.sendResponse(
+ new ChatEventReceivedMessage(PlayerIsMutedMessage.build(muteExpiry)));
+ }
+
+ private void recordAndBroadcastMessageToAllPlayers(
+ final ChatterSession session, final WebSocketMessageContext messageContext) {
+ final var chatReceivedMessage =
+ convertMessage(session.getChatParticipant(), messageContext.getMessage().getChatMessage());
+ recordInHistory(chatReceivedMessage, session);
+ messageContext.broadcastMessage(chatReceivedMessage);
+ }
+
+ private static ChatReceivedMessage convertMessage(
+ final ChatParticipant sender, final String message) {
+ return new ChatReceivedMessage(sender.getUserName(), message);
+ }
+
+ private void recordInHistory(
+ final ChatReceivedMessage chatReceivedMessage, final ChatterSession session) {
+ AsyncRunner.runAsync(
+ () -> lobbyChatHistoryDao.recordMessage(chatReceivedMessage, session.getApiKeyId()))
+ .exceptionally(e -> log.error("Error recording chat message in database history table", e));
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/ChatParticipantAdapter.java b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/ChatParticipantAdapter.java
new file mode 100644
index 0000000..942ab23
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/ChatParticipantAdapter.java
@@ -0,0 +1,33 @@
+package org.triplea.modules.chat.event.processing;
+
+import java.util.function.BiFunction;
+import org.triplea.db.dao.api.key.PlayerApiKeyLookupRecord;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.domain.data.ChatParticipant;
+import org.triplea.modules.chat.ChatterSession;
+import org.triplea.web.socket.WebSocketSession;
+
+class ChatParticipantAdapter
+ implements BiFunction {
+
+ @Override
+ public ChatterSession apply(
+ final WebSocketSession session, final PlayerApiKeyLookupRecord apiKeyLookupRecord) {
+ return ChatterSession.builder()
+ .apiKeyId(apiKeyLookupRecord.getApiKeyId())
+ .chatParticipant(buildChatParticipant(apiKeyLookupRecord))
+ .session(session)
+ .ip(session.getRemoteAddress())
+ .build();
+ }
+
+ private ChatParticipant buildChatParticipant(final PlayerApiKeyLookupRecord apiKeyLookupRecord) {
+ return ChatParticipant.builder()
+ .userName(apiKeyLookupRecord.getUsername())
+ .isModerator(
+ apiKeyLookupRecord.getUserRole().equals(UserRole.ADMIN)
+ || apiKeyLookupRecord.getUserRole().equals(UserRole.MODERATOR))
+ .playerChatId(apiKeyLookupRecord.getPlayerChatId())
+ .build();
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/PlayerConnectedListener.java b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/PlayerConnectedListener.java
new file mode 100644
index 0000000..de18439
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/PlayerConnectedListener.java
@@ -0,0 +1,57 @@
+package org.triplea.modules.chat.event.processing;
+
+import java.util.function.Consumer;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.api.key.PlayerApiKeyDaoWrapper;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.ChatterListingMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.ConnectToChatMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.PlayerJoinedMessage;
+import org.triplea.modules.chat.ChatterSession;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.web.socket.WebSocketMessageContext;
+
+@Builder
+public class PlayerConnectedListener
+ implements Consumer> {
+
+ private final PlayerApiKeyDaoWrapper apiKeyDaoWrapper;
+ private final ChatParticipantAdapter chatParticipantAdapter;
+ private final Chatters chatters;
+
+ public static PlayerConnectedListener build(final Chatters chatters, final Jdbi jdbi) {
+ return PlayerConnectedListener.builder()
+ .apiKeyDaoWrapper(PlayerApiKeyDaoWrapper.build(jdbi))
+ .chatParticipantAdapter(new ChatParticipantAdapter())
+ .chatters(chatters)
+ .build();
+ }
+
+ @Override
+ public void accept(final WebSocketMessageContext context) {
+ // Make sure chatter has logged in (has a valid API key)
+ // Based on the API key we'll know if the player is a moderator.
+ apiKeyDaoWrapper
+ .lookupByApiKey(context.getMessage().getApiKey())
+ .ifPresent(
+ keyLookup -> {
+ final ChatterSession chatterSession =
+ chatParticipantAdapter.apply(context.getSenderSession(), keyLookup);
+ final boolean alreadyConnected =
+ chatters.isPlayerConnected(chatterSession.getChatParticipant().getUserName());
+
+ // connect the current chatter session if they are connected or not
+ chatters.connectPlayer(chatterSession);
+
+ context.sendResponse(new ChatterListingMessage(chatters.getChatters()));
+
+ // if the player was already connected, do not send a seemingly
+ // duplicate player joined message. Registered users can have multiple
+ // chat sessions and will appear under a single name.
+ if (!alreadyConnected) {
+ context.broadcastMessage(
+ new PlayerJoinedMessage(chatterSession.getChatParticipant()));
+ }
+ });
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/PlayerIsMutedMessage.java b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/PlayerIsMutedMessage.java
new file mode 100644
index 0000000..4da033d
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/PlayerIsMutedMessage.java
@@ -0,0 +1,37 @@
+package org.triplea.modules.chat.event.processing;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.function.Function;
+import lombok.Builder;
+import lombok.experimental.UtilityClass;
+
+@UtilityClass
+class PlayerIsMutedMessage {
+ private static final Function muteDurationFormatter =
+ MuteDurationRemainingCalculator.builder().build();
+
+ String build(final Instant muteExpiry) {
+ return "You have been muted, expiring in: " + muteDurationFormatter.apply(muteExpiry);
+ }
+
+ /**
+ * Calculates a string of how many minutes are remaining in a mute until expired, or if less than
+ * a minute then how many seconds are left.
+ */
+ @VisibleForTesting
+ @Builder
+ static class MuteDurationRemainingCalculator implements Function {
+ @Builder.Default private Clock clock = Clock.systemUTC();
+
+ @Override
+ public String apply(final Instant muteExpiry) {
+ final long minutes = Duration.between(clock.instant(), muteExpiry).toMinutes();
+ return minutes > 0
+ ? minutes + " minutes"
+ : Duration.between(clock.instant(), muteExpiry).toSeconds() + " seconds";
+ }
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/PlayerLeftListener.java b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/PlayerLeftListener.java
new file mode 100644
index 0000000..f35913c
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/PlayerLeftListener.java
@@ -0,0 +1,27 @@
+package org.triplea.modules.chat.event.processing;
+
+import java.util.function.BiConsumer;
+import lombok.AllArgsConstructor;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.PlayerLeftMessage;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.web.socket.WebSocketMessagingBus;
+import org.triplea.web.socket.WebSocketSession;
+
+@AllArgsConstructor
+public class PlayerLeftListener implements BiConsumer {
+
+ private final Chatters chatters;
+
+ @Override
+ public void accept(
+ final WebSocketMessagingBus webSocketMessagingBus, final WebSocketSession session) {
+ chatters
+ .playerLeft(session)
+ .ifPresent(
+ playerName -> {
+ if (!chatters.isPlayerConnected(playerName)) {
+ webSocketMessagingBus.broadcastMessage(new PlayerLeftMessage(playerName));
+ }
+ });
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/SlapListener.java b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/SlapListener.java
new file mode 100644
index 0000000..71e772a
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/SlapListener.java
@@ -0,0 +1,34 @@
+package org.triplea.modules.chat.event.processing;
+
+import java.util.function.Consumer;
+import lombok.AllArgsConstructor;
+import org.triplea.domain.data.ChatParticipant;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.PlayerSlapReceivedMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.PlayerSlapSentMessage;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.web.socket.WebSocketMessageContext;
+
+@AllArgsConstructor
+public class SlapListener implements Consumer> {
+ private final Chatters chatters;
+
+ @Override
+ public void accept(final WebSocketMessageContext messageContext) {
+ chatters
+ .lookupPlayerBySession(messageContext.getSenderSession())
+ .ifPresent(
+ chatterSession ->
+ broadCastSlapMessage(messageContext, chatterSession.getChatParticipant()));
+ }
+
+ private static void broadCastSlapMessage(
+ final WebSocketMessageContext messageContext,
+ final ChatParticipant slapper) {
+
+ messageContext.broadcastMessage(
+ PlayerSlapReceivedMessage.builder()
+ .slappingPlayer(slapper.getUserName().getValue())
+ .slappedPlayer(messageContext.getMessage().getSlappedPlayer())
+ .build());
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/StatusUpdateListener.java b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/StatusUpdateListener.java
new file mode 100644
index 0000000..87caa81
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/chat/event/processing/StatusUpdateListener.java
@@ -0,0 +1,34 @@
+package org.triplea.modules.chat.event.processing;
+
+import java.util.function.Consumer;
+import lombok.AllArgsConstructor;
+import org.triplea.domain.data.ChatParticipant;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.PlayerStatusUpdateReceivedMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.PlayerStatusUpdateSentMessage;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.web.socket.WebSocketMessageContext;
+
+@AllArgsConstructor
+public class StatusUpdateListener
+ implements Consumer> {
+
+ private final Chatters chatters;
+
+ @Override
+ public void accept(final WebSocketMessageContext messageContext) {
+ chatters
+ .lookupPlayerBySession(messageContext.getSenderSession())
+ .ifPresent(
+ chatterSession ->
+ broadcastStatusUpdateMessage(messageContext, chatterSession.getChatParticipant()));
+ }
+
+ private static void broadcastStatusUpdateMessage(
+ final WebSocketMessageContext messageContext,
+ final ChatParticipant chatParticipant) {
+
+ messageContext.broadcastMessage(
+ new PlayerStatusUpdateReceivedMessage(
+ chatParticipant.getUserName(), messageContext.getMessage().getStatus()));
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/ForgotPasswordModule.java b/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/ForgotPasswordModule.java
new file mode 100644
index 0000000..52197e6
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/ForgotPasswordModule.java
@@ -0,0 +1,79 @@
+package org.triplea.modules.forgot.password;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.temp.password.TempPasswordHistoryDao;
+import org.triplea.http.client.forgot.password.ForgotPasswordRequest;
+import org.triplea.java.StringUtils;
+
+/**
+ * Module to orchestrate generating a temporary password for a user, storing that password in the
+ * temp password table and sending that password in an email to the user.
+ */
+@Builder
+public class ForgotPasswordModule implements BiFunction {
+
+ @VisibleForTesting
+ static final String ERROR_TOO_MANY_REQUESTS = "Error, too many password reset attempts";
+
+ @VisibleForTesting
+ static final String ERROR_BAD_USER_OR_EMAIL =
+ "Error, temp password not generated - check username and email";
+
+ @VisibleForTesting
+ static final String SUCCESS_MESSAGE = "A temporary password has been sent to your email.";
+
+ @Nonnull private final BiConsumer passwordEmailSender;
+ @Nonnull private final PasswordGenerator passwordGenerator;
+ @Nonnull private final TempPasswordPersistence tempPasswordPersistence;
+ @Nonnull private final TempPasswordHistory tempPasswordHistory;
+
+ public static BiFunction build(
+ final boolean isProd, final Jdbi jdbi) {
+ return ForgotPasswordModule.builder()
+ .passwordEmailSender(PasswordEmailSender.builder().isProd(isProd).build())
+ .passwordGenerator(new PasswordGenerator())
+ .tempPasswordPersistence(TempPasswordPersistence.newInstance(jdbi))
+ .tempPasswordHistory(new TempPasswordHistory(jdbi.onDemand(TempPasswordHistoryDao.class)))
+ .build();
+ }
+
+ @Override
+ public String apply(final String inetAddress, final ForgotPasswordRequest forgotPasswordRequest) {
+ checkArgs(inetAddress, forgotPasswordRequest);
+
+ if (!tempPasswordHistory.allowRequestFromAddress(inetAddress)) {
+ return ERROR_TOO_MANY_REQUESTS;
+ }
+ tempPasswordHistory.recordTempPasswordRequest(inetAddress, forgotPasswordRequest.getUsername());
+
+ final String generatedPassword = passwordGenerator.generatePassword();
+ if (!tempPasswordPersistence.storeTempPassword(forgotPasswordRequest, generatedPassword)) {
+ return ERROR_BAD_USER_OR_EMAIL;
+ }
+ passwordEmailSender.accept(forgotPasswordRequest.getEmail(), generatedPassword);
+
+ return SUCCESS_MESSAGE;
+ }
+
+ private static void checkArgs(
+ final String inetAddress, final ForgotPasswordRequest forgotPasswordRequest) {
+ checkArgument(!StringUtils.isNullOrBlank(inetAddress));
+ try {
+ //noinspection ResultOfMethodCallIgnored
+ InetAddress.getAllByName(inetAddress);
+ } catch (final UnknownHostException e) {
+ throw new IllegalArgumentException("Invalid IP address: " + inetAddress);
+ }
+ checkArgument(!StringUtils.isNullOrBlank(forgotPasswordRequest.getEmail()));
+ checkArgument(!StringUtils.isNullOrBlank(forgotPasswordRequest.getUsername()));
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/PasswordEmailSender.java b/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/PasswordEmailSender.java
new file mode 100644
index 0000000..02a678a
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/PasswordEmailSender.java
@@ -0,0 +1,70 @@
+package org.triplea.modules.forgot.password;
+
+import jakarta.mail.Message;
+import jakarta.mail.MessagingException;
+import jakarta.mail.Session;
+import jakarta.mail.Transport;
+import jakarta.mail.internet.InternetAddress;
+import jakarta.mail.internet.MimeMessage;
+import java.io.UnsupportedEncodingException;
+import java.util.Properties;
+import java.util.function.BiConsumer;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.extern.slf4j.Slf4j;
+import org.triplea.db.dao.temp.password.TempPasswordDao;
+
+/** Sends a temporary password to a target user. */
+@AllArgsConstructor
+@Builder
+@Slf4j
+class PasswordEmailSender implements BiConsumer {
+ private static final String FROM = "no-reply@triplea-game.org";
+
+ private final boolean isProd;
+
+ @Override
+ public void accept(final String email, final String generatedPassword) {
+ if (!isProd) {
+ // do not send emails if not on prod
+ log.info(
+ String.format(
+ "Non-prod forgot password, email: %s, generated temp passsword: %s",
+ email, generatedPassword));
+ return;
+ }
+
+ final Properties props = new Properties();
+ final Session session = Session.getDefaultInstance(props, null);
+
+ try {
+ final Message message = new MimeMessage(session);
+ message.setFrom(new InternetAddress(FROM, "no-reply"));
+ message.addRecipient(Message.RecipientType.TO, new InternetAddress(email, email));
+ message.setSubject("Temporary Password");
+ message.setText(createMailBody(generatedPassword));
+ Transport.send(message);
+ } catch (final MessagingException e) {
+ throw new EmailSendFailure(e);
+ } catch (final UnsupportedEncodingException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private static String createMailBody(final String generatedPassword) {
+ return "Your TripleA temporary password is: "
+ + generatedPassword
+ + "\nUse this password to login to the TripleA lobby."
+ + "\nThis password may only be used once and will expire in "
+ + TempPasswordDao.TEMP_PASSWORD_EXPIRATION
+ + "\nAfter login you will be prompted to create a new password";
+ }
+
+ private static class EmailSendFailure extends RuntimeException {
+ private static final long serialVersionUID = -7190784731567635418L;
+
+ EmailSendFailure(final Exception e) {
+ super("Failed to send email", e);
+ }
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/PasswordGenerator.java b/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/PasswordGenerator.java
new file mode 100644
index 0000000..d7c3ee1
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/PasswordGenerator.java
@@ -0,0 +1,13 @@
+package org.triplea.modules.forgot.password;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.util.UUID;
+
+/** Generates a plaintext password to be stored as a temporary password. */
+class PasswordGenerator {
+ @VisibleForTesting static final int PASSWORD_LENGTH = 18;
+
+ String generatePassword() {
+ return UUID.randomUUID().toString().substring(0, PASSWORD_LENGTH);
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/TempPasswordHistory.java b/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/TempPasswordHistory.java
new file mode 100644
index 0000000..c0b0899
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/TempPasswordHistory.java
@@ -0,0 +1,25 @@
+package org.triplea.modules.forgot.password;
+
+import com.google.common.annotations.VisibleForTesting;
+import lombok.AllArgsConstructor;
+import org.triplea.db.dao.temp.password.TempPasswordHistoryDao;
+
+/**
+ * Class for accessing temp password history. The history is used for audit and rate limiting so a
+ * user cannot create unlimited temp passwords requests.
+ */
+@AllArgsConstructor
+public class TempPasswordHistory {
+ @VisibleForTesting static final int MAX_TEMP_PASSWORD_REQUESTS_PER_DAY = 3;
+
+ private final TempPasswordHistoryDao tempPasswordHistoryDao;
+
+ boolean allowRequestFromAddress(final String address) {
+ return tempPasswordHistoryDao.countRequestsFromAddress(address)
+ < MAX_TEMP_PASSWORD_REQUESTS_PER_DAY;
+ }
+
+ public void recordTempPasswordRequest(final String address, final String username) {
+ tempPasswordHistoryDao.recordTempPasswordRequest(address, username);
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/TempPasswordPersistence.java b/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/TempPasswordPersistence.java
new file mode 100644
index 0000000..89ef943
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/forgot/password/TempPasswordPersistence.java
@@ -0,0 +1,40 @@
+package org.triplea.modules.forgot.password;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.temp.password.TempPasswordDao;
+import org.triplea.http.client.forgot.password.ForgotPasswordRequest;
+import org.triplea.java.Sha512Hasher;
+import org.triplea.modules.user.account.PasswordBCrypter;
+
+/**
+ * Stores a user temporary password in database. When we generate a new temporary password, all
+ * existing temporary passwords are invalidated so that a user only has one temp password at a time.
+ */
+@AllArgsConstructor(
+ access = AccessLevel.PACKAGE,
+ onConstructor_ = {@VisibleForTesting})
+class TempPasswordPersistence {
+ @Nonnull private final TempPasswordDao tempPasswordDao;
+ @Nonnull private final Function passwordHasher;
+ @Nonnull private final Function hashedPasswordBcrypter;
+
+ static TempPasswordPersistence newInstance(final Jdbi jdbi) {
+ return new TempPasswordPersistence(
+ jdbi.onDemand(TempPasswordDao.class),
+ Sha512Hasher::hashPasswordWithSalt,
+ PasswordBCrypter::hashPassword);
+ }
+
+ boolean storeTempPassword(
+ final ForgotPasswordRequest forgotPasswordRequest, final String generatedPassword) {
+ final String hashedPass = passwordHasher.apply(generatedPassword);
+ final String tempPass = hashedPasswordBcrypter.apply(hashedPass);
+ return tempPasswordDao.insertTempPassword(
+ forgotPasswordRequest.getUsername(), forgotPasswordRequest.getEmail(), tempPass);
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/game/listing/GameListing.java b/server/lobby-module/src/main/java/org/triplea/modules/game/listing/GameListing.java
new file mode 100644
index 0000000..de1c0e2
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/game/listing/GameListing.java
@@ -0,0 +1,234 @@
+package org.triplea.modules.game.listing;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Multimap;
+import java.net.InetSocketAddress;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import javax.annotation.Nonnull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import lombok.extern.slf4j.Slf4j;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.lobby.games.LobbyGameDao;
+import org.triplea.db.dao.moderator.ModeratorAuditHistoryDao;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.domain.data.LobbyGame;
+import org.triplea.domain.data.UserName;
+import org.triplea.http.client.lobby.game.lobby.watcher.GameListingClient;
+import org.triplea.http.client.lobby.game.lobby.watcher.GamePostingRequest;
+import org.triplea.http.client.lobby.game.lobby.watcher.LobbyGameListing;
+import org.triplea.http.client.web.socket.messages.envelopes.game.listing.LobbyGameRemovedMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.game.listing.LobbyGameUpdatedMessage;
+import org.triplea.java.cache.ttl.ExpiringAfterWriteTtlCache;
+import org.triplea.java.cache.ttl.TtlCache;
+import org.triplea.web.socket.WebSocketMessagingBus;
+
+/**
+ * Class that stores the set of games in the lobby. Games are identified by a combination of two
+ * values, the api-key of the user that created the game and a UUID assigned when the game is
+ * posted. Keep in mind that the 'gameId' is a publicly known value. Therefore we use API key, for
+ * example, to ensure that other users can't invoke endpoints directly to remove the games of other
+ * players.
+ *
+ * Keep-Alive
+ *
+ * The game listing has a concept of 'keep-alive' where games, after posting, need to send
+ * 'keep-alive' messages periodically or they will be de-listed. If a game has been de-listed, and
+ * we get a 'keep-alive' message, the client will get a 'false' value back indicating they would
+ * need to (automatically) re-post their game. This way for example if the lobby were to be
+ * restarted, clients sending keep-alives would notice that their game is no longer listed and would
+ * automatically send a game post message to re-post their game.
+ *
+ * Moderator Boot Game
+ *
+ * The moderator boot is similar to remove game but there is no check for an API key, any moderator
+ * can boot any game.
+ */
+@Builder
+@Slf4j
+public class GameListing {
+ @Nonnull private final ModeratorAuditHistoryDao auditHistoryDao;
+ @Nonnull private final LobbyGameDao lobbyGameDao;
+ @Nonnull private final TtlCache games;
+ @Nonnull private final WebSocketMessagingBus playerMessagingBus;
+
+ /** Map of player names to the games they are in, both observing and playing. */
+ @Nonnull private final Multimap playerIsInGames = HashMultimap.create();
+
+ @AllArgsConstructor
+ @EqualsAndHashCode
+ @Getter
+ @VisibleForTesting
+ @ToString
+ static class GameId {
+ @Nonnull private final ApiKey apiKey;
+ @Nonnull private final String id;
+ }
+
+ public static GameListing build(final Jdbi jdbi, final WebSocketMessagingBus playerMessagingBus) {
+ return GameListing.builder()
+ .lobbyGameDao(jdbi.onDemand(LobbyGameDao.class))
+ .auditHistoryDao(jdbi.onDemand(ModeratorAuditHistoryDao.class))
+ .playerMessagingBus(playerMessagingBus)
+ .games(
+ new ExpiringAfterWriteTtlCache<>(
+ GameListingClient.KEEP_ALIVE_SECONDS,
+ TimeUnit.SECONDS,
+ new GameTtlExpiredListener(playerMessagingBus)))
+ .build();
+ }
+
+ /** Adds a game. */
+ public String postGame(final ApiKey apiKey, final GamePostingRequest gamePostingRequest) {
+ final String id = UUID.randomUUID().toString();
+ final GameId gameId = new GameId(apiKey, id);
+ games.put(gameId, gamePostingRequest.getLobbyGame());
+
+ Optional.ofNullable(gamePostingRequest.getPlayerNames())
+ .ifPresent(
+ names ->
+ names.forEach(playerName -> playerIsInGames.put(UserName.of(playerName), gameId)));
+ final var lobbyGameListing =
+ LobbyGameListing.builder().gameId(id).lobbyGame(gamePostingRequest.getLobbyGame()).build();
+ lobbyGameDao.insertLobbyGame(apiKey, lobbyGameListing);
+ playerMessagingBus.broadcastMessage(new LobbyGameUpdatedMessage(lobbyGameListing));
+ log.info("Posted game: {}", id);
+ return id;
+ }
+
+ /** Adds or updates a game. Returns true if game is updated, false if game was not found. */
+ public boolean updateGame(final ApiKey apiKey, final String id, final LobbyGame lobbyGame) {
+ final var listedGameId = new GameId(apiKey, id);
+ final LobbyGame existingValue = games.replace(listedGameId, lobbyGame).orElse(null);
+
+ if (existingValue != null) {
+ playerMessagingBus.broadcastMessage(
+ new LobbyGameUpdatedMessage(
+ LobbyGameListing.builder().gameId(id).lobbyGame(lobbyGame).build()));
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Removes a game from the active listing, any players marked as in the game are updated to no
+ * longer be listed as participating in that game.
+ */
+ public void removeGame(final ApiKey apiKey, final String id) {
+ log.info("Removing game: {}", id);
+ final GameId key = new GameId(apiKey, id);
+
+ final var gameEntries =
+ playerIsInGames.entries().stream()
+ .filter(entry -> entry.getValue().equals(key))
+ .collect(Collectors.toList());
+ gameEntries.forEach(entry -> playerIsInGames.remove(entry.getKey(), entry.getValue()));
+
+ games
+ .invalidate(key)
+ .ifPresent(value -> playerMessagingBus.broadcastMessage(new LobbyGameRemovedMessage(id)));
+ }
+
+ public List getGames() {
+ return games.asMap().entrySet().stream()
+ .map(
+ entry ->
+ LobbyGameListing.builder()
+ .gameId(entry.getKey().id)
+ .lobbyGame(entry.getValue())
+ .build())
+ .collect(Collectors.toList());
+ }
+
+ /** Checks if a given api-key and game-id pair are valid and match an active game. */
+ public boolean isValidApiKeyAndGameId(final ApiKey apiKey, final String gameId) {
+ return games.get(new GameId(apiKey, gameId)).isPresent();
+ }
+
+ /**
+ * If a game does not receive a 'keepAlive' in a timely manner, it is removed from the list.
+ *
+ * @return Return false to inform client game is not present. Client can respond by re-posting
+ * their game. Otherwise true indicates the game is present and the keep-alive period has been
+ * extended.
+ */
+ public boolean keepAlive(final ApiKey apiKey, final String id) {
+ return games.refresh(new GameId(apiKey, id));
+ }
+
+ /** Moderator action to remove a game. */
+ public void bootGame(final int moderatorId, final String id) {
+ games
+ .findEntryByKey(gameId -> gameId.id.equals(id))
+ .ifPresent(
+ gameToRemove -> {
+ final String hostName = gameToRemove.getValue().getHostName();
+ removeGame(gameToRemove.getKey().apiKey, gameToRemove.getKey().id);
+
+ log.info("Moderator {} booted game: {}, hosted by: {}", moderatorId, id, hostName);
+ auditHistoryDao.addAuditRecord(
+ ModeratorAuditHistoryDao.AuditArgs.builder()
+ .moderatorUserId(moderatorId)
+ .actionName(ModeratorAuditHistoryDao.AuditAction.BOOT_GAME)
+ .actionTarget(hostName)
+ .build());
+ });
+ }
+
+ public Optional getHostForGame(final ApiKey apiKey, final String id) {
+ return games
+ .get(new GameId(apiKey, id))
+ .map(
+ lobbyGame ->
+ new InetSocketAddress(lobbyGame.getHostAddress(), lobbyGame.getHostPort()));
+ }
+
+ public void addPlayerToGame(final UserName userName, final ApiKey apiKey, final String gameId) {
+ playerIsInGames.put(userName, new GameId(apiKey, gameId));
+ }
+
+ public void removePlayerFromGame(
+ final UserName userName, final ApiKey apiKey, final String gameId) {
+ playerIsInGames.remove(userName, new GameId(apiKey, gameId));
+ }
+
+ /**
+ * Gets the collection of active games (identified by hostname) that a player is playing in or has
+ * joined as an observer.
+ */
+ public Collection getGameNamesPlayerHasJoined(final UserName userName) {
+ final Collection expiredGames =
+ playerIsInGames.get(userName).stream()
+ .filter(gameId -> games.get(gameId).isEmpty())
+ .collect(Collectors.toList());
+ expiredGames.forEach(gameId -> playerIsInGames.remove(userName, gameId));
+
+ return playerIsInGames.get(userName).stream()
+ .map(gameId -> games.get(gameId).map(LobbyGame::getHostName).orElse(null))
+ .collect(Collectors.toList());
+ }
+
+ public Collection getPlayersInGame(final String gameId) {
+ return playerIsInGames.asMap().entrySet().stream()
+ .filter(playerIsInGame(gameId))
+ .map(Map.Entry::getKey)
+ .map(UserName::getValue)
+ .collect(Collectors.toList());
+ }
+
+ private Predicate>> playerIsInGame(final String gameId) {
+ return entry -> entry.getValue().stream().map(GameId::getId).anyMatch(gameId::equals);
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/game/listing/GameTtlExpiredListener.java b/server/lobby-module/src/main/java/org/triplea/modules/game/listing/GameTtlExpiredListener.java
new file mode 100644
index 0000000..744cbd6
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/game/listing/GameTtlExpiredListener.java
@@ -0,0 +1,18 @@
+package org.triplea.modules.game.listing;
+
+import java.util.function.BiConsumer;
+import lombok.AllArgsConstructor;
+import org.triplea.domain.data.LobbyGame;
+import org.triplea.http.client.web.socket.messages.envelopes.game.listing.LobbyGameRemovedMessage;
+import org.triplea.web.socket.WebSocketMessagingBus;
+
+@AllArgsConstructor
+class GameTtlExpiredListener implements BiConsumer {
+
+ private final WebSocketMessagingBus playerMessagingBus;
+
+ @Override
+ public void accept(final GameListing.GameId gameId, final LobbyGame removedEntry) {
+ playerMessagingBus.broadcastMessage(new LobbyGameRemovedMessage(gameId.getId()));
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/game/lobby/watcher/ChatUploadModule.java b/server/lobby-module/src/main/java/org/triplea/modules/game/lobby/watcher/ChatUploadModule.java
new file mode 100644
index 0000000..7e96a95
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/game/lobby/watcher/ChatUploadModule.java
@@ -0,0 +1,42 @@
+package org.triplea.modules.game.lobby.watcher;
+
+import java.util.function.BiPredicate;
+import lombok.AllArgsConstructor;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.lobby.games.LobbyGameDao;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.http.client.lobby.game.lobby.watcher.ChatMessageUpload;
+import org.triplea.modules.game.listing.GameListing;
+
+/**
+ * Responsible for inserting chat messages into database. Validates that a given request has a
+ * matching game-id and api-key pair, otherwise does a no-op. We need to be sure to validate an
+ * API-key and game-id match otherwise an attacker with an http client could try to insert incorrect
+ * data, game-id's are publicly known, but API-keys are not. Only the actual host should know the
+ * api-key.
+ *
+ * If the ID and API-Key pair are not valid, we will just drop the request and return a 200 If we
+ * return a 400 or some other error, we'll give a potential attacker a way to try and guess
+ * API-Keys.
+ */
+@AllArgsConstructor
+public class ChatUploadModule {
+ private final LobbyGameDao lobbyGameDao;
+ private final BiPredicate gameIdValidator;
+
+ public static ChatUploadModule build(final Jdbi jdbi, final GameListing gameListing) {
+ return new ChatUploadModule(
+ jdbi.onDemand(LobbyGameDao.class), gameListing::isValidApiKeyAndGameId);
+ }
+
+ public boolean upload(final String apiKey, final ChatMessageUpload chatMessageUpload) {
+ if (gameIdValidator.test(
+ // truncate 'Bearer ' from the apiKey if
+ ApiKey.of(apiKey.startsWith("Bearer ") ? apiKey.substring("Bearer ".length()) : apiKey),
+ chatMessageUpload.getGameId())) {
+ lobbyGameDao.recordChat(chatMessageUpload);
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/game/lobby/watcher/ConnectivityCheck.java b/server/lobby-module/src/main/java/org/triplea/modules/game/lobby/watcher/ConnectivityCheck.java
new file mode 100644
index 0000000..6ff2ce3
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/game/lobby/watcher/ConnectivityCheck.java
@@ -0,0 +1,35 @@
+package org.triplea.modules.game.lobby.watcher;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+
+/**
+ * Performs a 'reverse' connection back to a game host to ensure that they can be connected to from
+ * the public internet (required if others are to join their game).
+ */
+@AllArgsConstructor(access = AccessLevel.PACKAGE, onConstructor_ = @VisibleForTesting)
+class ConnectivityCheck {
+
+ private final Supplier socketSupplier;
+
+ ConnectivityCheck() {
+ this(Socket::new);
+ }
+
+ /** Checks if the lobby can create a connection to a given game. */
+ boolean canDoReverseConnect(final String gameHostAddress, final int port) {
+ final InetSocketAddress address = new InetSocketAddress(gameHostAddress, port);
+ try (Socket s = socketSupplier.get()) {
+ s.connect(address, (int) TimeUnit.SECONDS.toMillis(10));
+ return s.isConnected();
+ } catch (final IOException e) {
+ return false;
+ }
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/game/lobby/watcher/GamePostingModule.java b/server/lobby-module/src/main/java/org/triplea/modules/game/lobby/watcher/GamePostingModule.java
new file mode 100644
index 0000000..d086bfd
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/game/lobby/watcher/GamePostingModule.java
@@ -0,0 +1,42 @@
+package org.triplea.modules.game.lobby.watcher;
+
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.http.client.lobby.game.lobby.watcher.GamePostingRequest;
+import org.triplea.http.client.lobby.game.lobby.watcher.GamePostingResponse;
+import org.triplea.modules.game.listing.GameListing;
+
+/**
+ * Verifies that we can do a 'reverse' connection back to the requesting game host (this verifies
+ * that their network is well configured to accept connections), if so then we will add their game
+ * posting request to the list of available games.
+ */
+@Builder
+public class GamePostingModule {
+ @Nonnull private final GameListing gameListing;
+ @Nonnull private final ConnectivityCheck connectivityCheck;
+
+ public static GamePostingModule build(final GameListing gameListing) {
+
+ return GamePostingModule.builder()
+ .gameListing(gameListing)
+ .connectivityCheck(new ConnectivityCheck())
+ .build();
+ }
+
+ public GamePostingResponse postGame(
+ final ApiKey apiKey, final GamePostingRequest gamePostingRequest) {
+ final boolean canReverseConnect =
+ connectivityCheck.canDoReverseConnect(
+ gamePostingRequest.getLobbyGame().getHostAddress(),
+ gamePostingRequest.getLobbyGame().getHostPort());
+
+ return canReverseConnect
+ ? GamePostingResponse.builder()
+ .connectivityCheckSucceeded(true)
+ .gameId(gameListing.postGame(apiKey, gamePostingRequest))
+ .build()
+ : GamePostingResponse.builder().connectivityCheckSucceeded(false).build();
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/moderation/access/log/AccessLogService.java b/server/lobby-module/src/main/java/org/triplea/modules/moderation/access/log/AccessLogService.java
new file mode 100644
index 0000000..5d77fd7
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/moderation/access/log/AccessLogService.java
@@ -0,0 +1,42 @@
+package org.triplea.modules.moderation.access.log;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.access.log.AccessLogDao;
+import org.triplea.http.client.lobby.moderator.toolbox.log.AccessLogData;
+import org.triplea.http.client.lobby.moderator.toolbox.log.AccessLogRequest;
+
+@Builder
+public class AccessLogService {
+ @Nonnull private final AccessLogDao accessLogDao;
+
+ public static AccessLogService build(final Jdbi jdbi) {
+ return AccessLogService.builder() //
+ .accessLogDao(jdbi.onDemand(AccessLogDao.class))
+ .build();
+ }
+
+ public List fetchAccessLog(final AccessLogRequest accessLogRequest) {
+ return accessLogDao
+ .fetchAccessLogRows(
+ accessLogRequest.getPagingParams().getRowNumber(),
+ accessLogRequest.getPagingParams().getPageSize(),
+ accessLogRequest.getAccessLogSearchRequest().getUsername(),
+ accessLogRequest.getAccessLogSearchRequest().getIp(),
+ accessLogRequest.getAccessLogSearchRequest().getSystemId())
+ .stream()
+ .map(
+ daoData ->
+ AccessLogData.builder()
+ .accessDate(daoData.getAccessTime().toEpochMilli())
+ .username(daoData.getUsername())
+ .ip(daoData.getIp())
+ .systemId(daoData.getSystemId())
+ .registered(daoData.isRegistered())
+ .build())
+ .collect(Collectors.toList());
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/moderation/audit/history/ModeratorAuditHistoryService.java b/server/lobby-module/src/main/java/org/triplea/modules/moderation/audit/history/ModeratorAuditHistoryService.java
new file mode 100644
index 0000000..37e56d7
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/moderation/audit/history/ModeratorAuditHistoryService.java
@@ -0,0 +1,30 @@
+package org.triplea.modules.moderation.audit.history;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import lombok.AllArgsConstructor;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.moderator.ModeratorAuditHistoryDao;
+import org.triplea.http.client.lobby.moderator.toolbox.log.ModeratorEvent;
+
+@AllArgsConstructor
+public class ModeratorAuditHistoryService {
+ private final ModeratorAuditHistoryDao moderatorAuditHistoryDao;
+
+ public static ModeratorAuditHistoryService build(final Jdbi jdbi) {
+ return new ModeratorAuditHistoryService(jdbi.onDemand(ModeratorAuditHistoryDao.class));
+ }
+
+ public List lookupHistory(final int rowNumber, final int rowCount) {
+ return moderatorAuditHistoryDao.lookupHistoryItems(rowNumber, rowCount).stream()
+ .map(
+ item ->
+ ModeratorEvent.builder()
+ .date(item.getDateCreated().toEpochMilli())
+ .moderatorName(item.getUsername())
+ .moderatorAction(item.getActionName())
+ .actionTarget(item.getActionTarget())
+ .build())
+ .collect(Collectors.toList());
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/moderation/bad/words/BadWordsService.java b/server/lobby-module/src/main/java/org/triplea/modules/moderation/bad/words/BadWordsService.java
new file mode 100644
index 0000000..5018aee
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/moderation/bad/words/BadWordsService.java
@@ -0,0 +1,59 @@
+package org.triplea.modules.moderation.bad.words;
+
+import java.util.List;
+import lombok.AllArgsConstructor;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.moderator.BadWordsDao;
+import org.triplea.db.dao.moderator.ModeratorAuditHistoryDao;
+
+@AllArgsConstructor
+public class BadWordsService {
+ private final BadWordsDao badWordsDao;
+ private final ModeratorAuditHistoryDao moderatorAuditHistoryDao;
+
+ public static BadWordsService build(final Jdbi jdbi) {
+ return new BadWordsService(
+ jdbi.onDemand(BadWordsDao.class), jdbi.onDemand(ModeratorAuditHistoryDao.class));
+ }
+
+ /**
+ * Removes a bad word value from the bad-word table in database.
+ *
+ * @param moderatorUserId Database ID of the moderator requesting the action.
+ * @param badWord The value to be removed.
+ */
+ public void removeBadWord(final int moderatorUserId, final String badWord) {
+ badWordsDao.removeBadWord(badWord);
+ moderatorAuditHistoryDao.addAuditRecord(
+ ModeratorAuditHistoryDao.AuditArgs.builder()
+ .moderatorUserId(moderatorUserId)
+ .actionName(ModeratorAuditHistoryDao.AuditAction.REMOVE_BAD_WORD)
+ .actionTarget(badWord)
+ .build());
+ }
+
+ /**
+ * Adds a bad word value to the bad-word table.
+ *
+ * @param moderatorUserId Database ID of the moderator requesting the action.
+ * @param badWord The value to add.
+ * @return True if the value is added, false otherwise (eg: value might already exist in DB).
+ */
+ public boolean addBadWord(final int moderatorUserId, final String badWord) {
+ final boolean success = badWordsDao.addBadWord(badWord) == 1;
+ if (success) {
+ moderatorAuditHistoryDao.addAuditRecord(
+ ModeratorAuditHistoryDao.AuditArgs.builder()
+ .moderatorUserId(moderatorUserId)
+ .actionName(ModeratorAuditHistoryDao.AuditAction.ADD_BAD_WORD)
+ .actionTarget(badWord)
+ .build());
+ }
+ return success;
+ }
+
+ /** Returns the list of bad words present in the bad-word table. */
+ public List getBadWords() {
+ return badWordsDao.getBadWords();
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/moderation/ban/name/UsernameBanService.java b/server/lobby-module/src/main/java/org/triplea/modules/moderation/ban/name/UsernameBanService.java
new file mode 100644
index 0000000..a96cbe4
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/moderation/ban/name/UsernameBanService.java
@@ -0,0 +1,62 @@
+package org.triplea.modules.moderation.ban.name;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import javax.annotation.Nonnull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.moderator.ModeratorAuditHistoryDao;
+import org.triplea.db.dao.username.ban.UsernameBanDao;
+import org.triplea.http.client.lobby.moderator.toolbox.banned.name.UsernameBanData;
+
+@AllArgsConstructor
+@Builder
+public class UsernameBanService {
+ @Nonnull private final UsernameBanDao bannedUserNamesDao;
+ @Nonnull private final ModeratorAuditHistoryDao moderatorAuditHistoryDao;
+
+ public static UsernameBanService build(final Jdbi jdbi) {
+ return UsernameBanService.builder()
+ .bannedUserNamesDao(jdbi.onDemand(UsernameBanDao.class))
+ .moderatorAuditHistoryDao(jdbi.onDemand(ModeratorAuditHistoryDao.class))
+ .build();
+ }
+
+ public boolean removeUsernameBan(final int moderatorUserId, final String nameToUnBan) {
+ if (bannedUserNamesDao.removeBannedUserName(nameToUnBan) > 0) {
+ moderatorAuditHistoryDao.addAuditRecord(
+ ModeratorAuditHistoryDao.AuditArgs.builder()
+ .moderatorUserId(moderatorUserId)
+ .actionName(ModeratorAuditHistoryDao.AuditAction.REMOVE_USERNAME_BAN)
+ .actionTarget(nameToUnBan)
+ .build());
+ return true;
+ }
+ return false;
+ }
+
+ public boolean addBannedUserName(final int moderatorUserId, final String nameToBan) {
+ if (bannedUserNamesDao.addBannedUserName(nameToBan) == 1) {
+ moderatorAuditHistoryDao.addAuditRecord(
+ ModeratorAuditHistoryDao.AuditArgs.builder()
+ .moderatorUserId(moderatorUserId)
+ .actionName(ModeratorAuditHistoryDao.AuditAction.BAN_USERNAME)
+ .actionTarget(nameToBan)
+ .build());
+ return true;
+ }
+ return false;
+ }
+
+ public List getBannedUserNames() {
+ return bannedUserNamesDao.getBannedUserNames().stream()
+ .map(
+ data ->
+ UsernameBanData.builder()
+ .bannedName(data.getUsername())
+ .banDate(data.getDateCreated().toEpochMilli())
+ .build())
+ .collect(Collectors.toList());
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/moderation/ban/user/BannedPlayerEventHandler.java b/server/lobby-module/src/main/java/org/triplea/modules/moderation/ban/user/BannedPlayerEventHandler.java
new file mode 100644
index 0000000..5c4cb6b
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/moderation/ban/user/BannedPlayerEventHandler.java
@@ -0,0 +1,23 @@
+package org.triplea.modules.moderation.ban.user;
+
+import java.net.InetAddress;
+import java.util.Collection;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.triplea.web.socket.SessionSet;
+
+/**
+ * Provides a callback mechanism to close all sessions of a banned player (banned by IP). Each
+ * websocket will be provided a {@code SessionSet} where any new sessions will be registered, each
+ * such {@code SessionSet} is also registered here and then receives a callback to close any
+ * sessions for a banned IP.
+ */
+@Builder
+public class BannedPlayerEventHandler {
+
+ @Nonnull private final Collection sessionSets;
+
+ public void fireBannedEvent(final InetAddress bannedIp) {
+ sessionSets.forEach(sessionSet -> sessionSet.closeSessionsByIp(bannedIp));
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/moderation/ban/user/UserBanService.java b/server/lobby-module/src/main/java/org/triplea/modules/moderation/ban/user/UserBanService.java
new file mode 100644
index 0000000..b4c4d9f
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/moderation/ban/user/UserBanService.java
@@ -0,0 +1,164 @@
+package org.triplea.modules.moderation.ban.user;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.util.List;
+import java.util.UUID;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.api.key.PlayerApiKeyDaoWrapper;
+import org.triplea.db.dao.api.key.PlayerIdentifiersByApiKeyLookup;
+import org.triplea.db.dao.moderator.ModeratorAuditHistoryDao;
+import org.triplea.db.dao.user.ban.UserBanDao;
+import org.triplea.domain.data.PlayerChatId;
+import org.triplea.http.client.lobby.moderator.BanDurationFormatter;
+import org.triplea.http.client.lobby.moderator.BanPlayerRequest;
+import org.triplea.http.client.lobby.moderator.toolbox.banned.user.UserBanData;
+import org.triplea.http.client.lobby.moderator.toolbox.banned.user.UserBanParams;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.ChatEventReceivedMessage;
+import org.triplea.http.client.web.socket.messages.envelopes.remote.actions.PlayerBannedMessage;
+import org.triplea.java.IpAddressParser;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.web.socket.WebSocketMessagingBus;
+
+/**
+ * Service layer for managing user bans, get bans, add and remove. User bans are done by MAC and IP
+ * address, they are removed by the 'public ban id' that is assigned when a ban is issued.
+ */
+@AllArgsConstructor(access = AccessLevel.PACKAGE, onConstructor_ = @VisibleForTesting)
+public class UserBanService {
+
+ private final ModeratorAuditHistoryDao moderatorAuditHistoryDao;
+ private final UserBanDao userBanDao;
+ private final Supplier publicIdSupplier;
+ private final Chatters chatters;
+ private final PlayerApiKeyDaoWrapper apiKeyDaoWrapper;
+ private final WebSocketMessagingBus chatMessagingBus;
+ private final WebSocketMessagingBus gameMessagingBus;
+
+ @Builder
+ public UserBanService(
+ final Jdbi jdbi,
+ final Chatters chatters,
+ final WebSocketMessagingBus chatMessagingBus,
+ final WebSocketMessagingBus gameMessagingBus) {
+ moderatorAuditHistoryDao = jdbi.onDemand(ModeratorAuditHistoryDao.class);
+ userBanDao = jdbi.onDemand(UserBanDao.class);
+ publicIdSupplier = () -> UUID.randomUUID().toString();
+ this.chatters = chatters;
+ this.apiKeyDaoWrapper = PlayerApiKeyDaoWrapper.build(jdbi);
+ this.chatMessagingBus = chatMessagingBus;
+ this.gameMessagingBus = gameMessagingBus;
+ }
+
+ public List getBannedUsers() {
+ return userBanDao.lookupBans().stream()
+ .map(
+ daoData ->
+ UserBanData.builder()
+ .banId(daoData.getPublicBanId())
+ .username(daoData.getUsername())
+ .hashedMac(daoData.getSystemId())
+ .ip(daoData.getIp())
+ .banDate(daoData.getDateCreated().toEpochMilli())
+ .banExpiry(daoData.getBanExpiry().toEpochMilli())
+ .build())
+ .collect(Collectors.toList());
+ }
+
+ public boolean removeUserBan(final int moderatorId, final String banId) {
+ final String unbanName = userBanDao.lookupUsernameByBanId(banId).orElse(null);
+ if (userBanDao.removeBan(banId) != 1) {
+ return false;
+ }
+ if (unbanName == null) {
+ throw new IllegalStateException(
+ "Consistency error, unbanned "
+ + banId
+ + ", but "
+ + "there was no matching name for that ban.");
+ }
+
+ moderatorAuditHistoryDao.addAuditRecord(
+ ModeratorAuditHistoryDao.AuditArgs.builder()
+ .actionName(ModeratorAuditHistoryDao.AuditAction.REMOVE_USER_BAN)
+ .actionTarget(unbanName)
+ .moderatorUserId(moderatorId)
+ .build());
+ return true;
+ }
+
+ public void banUser(final int moderatorId, final BanPlayerRequest banPlayerRequest) {
+ apiKeyDaoWrapper
+ .lookupPlayerByChatId(PlayerChatId.of(banPlayerRequest.getPlayerChatId()))
+ .map(
+ gamePlayerLookup -> toUserBanParams(gamePlayerLookup, banPlayerRequest.getBanMinutes()))
+ .ifPresent(banUserParams -> banUser(moderatorId, banUserParams));
+ }
+
+ public void banUser(final int moderatorId, final UserBanParams userBanParams) {
+ persistUserBanToDatabase(userBanParams);
+
+ if (removePlayerFromChat(userBanParams)) {
+ broadcastToChattersPlayerBannedMessage(userBanParams);
+ }
+
+ // notify game hosts of the ban
+ gameMessagingBus.broadcastMessage(new PlayerBannedMessage(userBanParams.getIp()));
+
+ recordBanInModeratorAuditLog(moderatorId, userBanParams);
+ }
+
+ private static UserBanParams toUserBanParams(
+ final PlayerIdentifiersByApiKeyLookup gamePlayerLookup, final long banMinutes) {
+ return UserBanParams.builder()
+ .systemId(gamePlayerLookup.getSystemId().getValue())
+ .username(gamePlayerLookup.getUserName().getValue())
+ .ip(gamePlayerLookup.getIp())
+ .minutesToBan(banMinutes)
+ .build();
+ }
+
+ private void persistUserBanToDatabase(final UserBanParams userBanParams) {
+ if (userBanDao.addBan(
+ publicIdSupplier.get(),
+ userBanParams.getUsername(),
+ userBanParams.getSystemId(),
+ userBanParams.getIp(),
+ userBanParams.getMinutesToBan())
+ != 1) {
+ throw new IllegalStateException("Failed to insert ban record:" + userBanParams);
+ }
+ }
+
+ private boolean removePlayerFromChat(final UserBanParams userBanParams) {
+ return chatters.disconnectIp(
+ IpAddressParser.fromString(userBanParams.getIp()),
+ String.format(
+ "You have been banned for %s for violating lobby rules",
+ BanDurationFormatter.formatBanMinutes(userBanParams.getMinutesToBan())));
+ }
+
+ private void broadcastToChattersPlayerBannedMessage(final UserBanParams banUserParams) {
+ chatMessagingBus.broadcastMessage(
+ new ChatEventReceivedMessage(
+ String.format(
+ "%s violated lobby rules and was banned for %s",
+ banUserParams.getUsername(),
+ BanDurationFormatter.formatBanMinutes(banUserParams.getMinutesToBan()))));
+ }
+
+ private void recordBanInModeratorAuditLog(
+ final int moderatorId, final UserBanParams userBanParams) {
+ moderatorAuditHistoryDao.addAuditRecord(
+ ModeratorAuditHistoryDao.AuditArgs.builder()
+ .moderatorUserId(moderatorId)
+ .actionName(ModeratorAuditHistoryDao.AuditAction.BAN_USER)
+ .actionTarget(
+ userBanParams.getUsername() + " " + userBanParams.getMinutesToBan() + " minutes")
+ .build());
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/moderation/chat/history/FetchGameChatHistoryModule.java b/server/lobby-module/src/main/java/org/triplea/modules/moderation/chat/history/FetchGameChatHistoryModule.java
new file mode 100644
index 0000000..1b297d7
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/moderation/chat/history/FetchGameChatHistoryModule.java
@@ -0,0 +1,26 @@
+package org.triplea.modules.moderation.chat.history;
+
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import lombok.AllArgsConstructor;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.moderator.chat.history.ChatHistoryRecord;
+import org.triplea.db.dao.moderator.chat.history.GameChatHistoryDao;
+import org.triplea.http.client.lobby.moderator.ChatHistoryMessage;
+
+@AllArgsConstructor
+public class FetchGameChatHistoryModule implements Function> {
+ private final GameChatHistoryDao gameChatHistoryDao;
+
+ public static FetchGameChatHistoryModule build(final Jdbi jdbi) {
+ return new FetchGameChatHistoryModule(jdbi.onDemand(GameChatHistoryDao.class));
+ }
+
+ @Override
+ public List apply(final String gameId) {
+ return gameChatHistoryDao.getChatHistory(gameId).stream()
+ .map(ChatHistoryRecord::toChatHistoryMessage)
+ .collect(Collectors.toList());
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/moderation/disconnect/user/DisconnectUserAction.java b/server/lobby-module/src/main/java/org/triplea/modules/moderation/disconnect/user/DisconnectUserAction.java
new file mode 100644
index 0000000..e1740db
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/moderation/disconnect/user/DisconnectUserAction.java
@@ -0,0 +1,58 @@
+package org.triplea.modules.moderation.disconnect.user;
+
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.api.key.PlayerApiKeyDaoWrapper;
+import org.triplea.db.dao.api.key.PlayerIdentifiersByApiKeyLookup;
+import org.triplea.db.dao.moderator.ModeratorAuditHistoryDao;
+import org.triplea.domain.data.PlayerChatId;
+import org.triplea.http.client.web.socket.messages.envelopes.chat.ChatEventReceivedMessage;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.web.socket.WebSocketMessagingBus;
+
+@Builder
+public class DisconnectUserAction {
+
+ @Nonnull private final PlayerApiKeyDaoWrapper apiKeyDaoWrapper;
+ @Nonnull private final Chatters chatters;
+ @Nonnull private final WebSocketMessagingBus playerConnections;
+ @Nonnull private final ModeratorAuditHistoryDao moderatorAuditHistoryDao;
+
+ public static DisconnectUserAction build(
+ final Jdbi jdbi, final Chatters chatters, final WebSocketMessagingBus playerConnections) {
+ return DisconnectUserAction.builder()
+ .apiKeyDaoWrapper(PlayerApiKeyDaoWrapper.build(jdbi))
+ .chatters(chatters)
+ .playerConnections(playerConnections)
+ .moderatorAuditHistoryDao(jdbi.onDemand(ModeratorAuditHistoryDao.class))
+ .build();
+ }
+
+ /**
+ * Does a simple disconnect of a given player from chat, records an audit log entry, and notifies
+ * chatters of the disconnect.
+ */
+ public boolean disconnectPlayer(final int moderatorId, final PlayerChatId playerChatId) {
+ final PlayerIdentifiersByApiKeyLookup gamePlayerLookup =
+ apiKeyDaoWrapper.lookupPlayerByChatId(playerChatId).orElse(null);
+ if (gamePlayerLookup == null || !chatters.isPlayerConnected(gamePlayerLookup.getUserName())) {
+ return false;
+ }
+
+ if (chatters.disconnectPlayerByName(
+ gamePlayerLookup.getUserName(), "Disconnected by moderator")) {
+ playerConnections.broadcastMessage(
+ new ChatEventReceivedMessage(
+ gamePlayerLookup.getUserName() + " was disconnected by moderator"));
+ }
+
+ moderatorAuditHistoryDao.addAuditRecord(
+ ModeratorAuditHistoryDao.AuditArgs.builder()
+ .moderatorUserId(moderatorId)
+ .actionName(ModeratorAuditHistoryDao.AuditAction.DISCONNECT_USER)
+ .actionTarget(gamePlayerLookup.getUserName().toString())
+ .build());
+ return true;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/moderation/moderators/ModeratorsService.java b/server/lobby-module/src/main/java/org/triplea/modules/moderation/moderators/ModeratorsService.java
new file mode 100644
index 0000000..e6fee3b
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/moderation/moderators/ModeratorsService.java
@@ -0,0 +1,113 @@
+package org.triplea.modules.moderation.moderators;
+
+import com.google.common.base.Preconditions;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import lombok.extern.slf4j.Slf4j;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.moderator.ModeratorAuditHistoryDao;
+import org.triplea.db.dao.moderator.ModeratorsDao;
+import org.triplea.db.dao.user.UserJdbiDao;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.http.client.lobby.moderator.toolbox.management.ModeratorInfo;
+
+@Builder
+@Slf4j
+public class ModeratorsService {
+ @Nonnull private final ModeratorsDao moderatorsDao;
+ @Nonnull private final UserJdbiDao userJdbiDao;
+ @Nonnull private final ModeratorAuditHistoryDao moderatorAuditHistoryDao;
+
+ public static ModeratorsService build(final Jdbi jdbi) {
+ return ModeratorsService.builder()
+ .moderatorsDao(jdbi.onDemand(ModeratorsDao.class))
+ .userJdbiDao(jdbi.onDemand(UserJdbiDao.class))
+ .moderatorAuditHistoryDao(jdbi.onDemand(ModeratorAuditHistoryDao.class))
+ .build();
+ }
+
+ /** Returns a list of all users that are moderators. */
+ public List fetchModerators() {
+ return moderatorsDao.getModerators().stream()
+ .map(
+ userInfo ->
+ ModeratorInfo.builder()
+ .name(userInfo.getUsername())
+ .lastLoginEpochMillis(
+ Optional.ofNullable(userInfo.getLastLogin())
+ .map(Instant::toEpochMilli)
+ .orElse(null))
+ .build())
+ .collect(Collectors.toList());
+ }
+
+ /** Promotes a user to moderator. Can only be done by super-moderators. */
+ public void addModerator(final int moderatorIdRequesting, final String username) {
+ final int userId =
+ userJdbiDao
+ .lookupUserIdByName(username)
+ .orElseThrow(
+ () -> new IllegalArgumentException("Unable to find username: " + username));
+
+ Preconditions.checkState(moderatorsDao.setRole(userId, UserRole.MODERATOR) == 1);
+ moderatorAuditHistoryDao.addAuditRecord(
+ ModeratorAuditHistoryDao.AuditArgs.builder()
+ .moderatorUserId(moderatorIdRequesting)
+ .actionName(ModeratorAuditHistoryDao.AuditAction.ADD_MODERATOR)
+ .actionTarget(username)
+ .build());
+ log.info(username + " was promoted to moderator");
+ }
+
+ /** Removes moderator status from a user. Can only be done by super moderators. */
+ public void removeMod(final int moderatorIdRequesting, final String moderatorNameToRemove) {
+ final int userId =
+ userJdbiDao
+ .lookupUserIdByName(moderatorNameToRemove)
+ .orElseThrow(
+ () ->
+ new IllegalArgumentException(
+ "Failed to find moderator by user name: " + moderatorNameToRemove));
+
+ Preconditions.checkState(
+ moderatorsDao.setRole(userId, UserRole.PLAYER) == 1,
+ "Failed to remove moderator status for: " + moderatorNameToRemove);
+
+ moderatorAuditHistoryDao.addAuditRecord(
+ ModeratorAuditHistoryDao.AuditArgs.builder()
+ .moderatorUserId(moderatorIdRequesting)
+ .actionName(ModeratorAuditHistoryDao.AuditAction.REMOVE_MODERATOR)
+ .actionTarget(moderatorNameToRemove)
+ .build());
+ log.info(moderatorNameToRemove + " was removed from moderators");
+ }
+
+ /** Promotes a user to super-moderator. Can only be done by super moderators. */
+ public void addAdmin(final int moderatorIdRequesting, final String username) {
+ final int userId =
+ userJdbiDao
+ .lookupUserIdByName(username)
+ .orElseThrow(
+ () -> new IllegalArgumentException("Failed to find user by name: " + username));
+
+ Preconditions.checkState(
+ moderatorsDao.setRole(userId, UserRole.ADMIN) == 1,
+ "Failed to add super moderator status for: " + username);
+ moderatorAuditHistoryDao.addAuditRecord(
+ ModeratorAuditHistoryDao.AuditArgs.builder()
+ .moderatorUserId(moderatorIdRequesting)
+ .actionName(ModeratorAuditHistoryDao.AuditAction.ADD_SUPER_MOD)
+ .actionTarget(username)
+ .build());
+ log.info(username + " was promoted to super mod");
+ }
+
+ /** Checks if any user exists in DB by the given name. */
+ public boolean userExistsByName(final String username) {
+ return userJdbiDao.lookupUserIdByName(username).isPresent();
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/moderation/remote/actions/RemoteActionsModule.java b/server/lobby-module/src/main/java/org/triplea/modules/moderation/remote/actions/RemoteActionsModule.java
new file mode 100644
index 0000000..279d70b
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/moderation/remote/actions/RemoteActionsModule.java
@@ -0,0 +1,43 @@
+package org.triplea.modules.moderation.remote.actions;
+
+import java.net.InetAddress;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.moderator.ModeratorAuditHistoryDao;
+import org.triplea.db.dao.moderator.ModeratorAuditHistoryDao.AuditAction;
+import org.triplea.db.dao.moderator.ModeratorAuditHistoryDao.AuditArgs;
+import org.triplea.db.dao.user.ban.UserBanDao;
+import org.triplea.http.client.web.socket.messages.envelopes.remote.actions.ShutdownServerMessage;
+import org.triplea.web.socket.WebSocketMessagingBus;
+
+@Builder
+public class RemoteActionsModule {
+ @Nonnull private final UserBanDao userBanDao;
+ @Nonnull private final ModeratorAuditHistoryDao auditHistoryDao;
+ @Nonnull private final WebSocketMessagingBus gameMessagingBus;
+
+ public static RemoteActionsModule build(
+ final Jdbi jdbi, final WebSocketMessagingBus gameMessagingBus) {
+ return RemoteActionsModule.builder()
+ .auditHistoryDao(jdbi.onDemand(ModeratorAuditHistoryDao.class))
+ .userBanDao(jdbi.onDemand(UserBanDao.class))
+ .gameMessagingBus(gameMessagingBus)
+ .build();
+ }
+
+ public boolean isUserBanned(final InetAddress ip) {
+ return userBanDao.isBannedByIp(ip.getHostAddress());
+ }
+
+ public void addGameIdForShutdown(final int moderatorId, final String gameId) {
+ auditHistoryDao.addAuditRecord(
+ AuditArgs.builder()
+ .actionName(AuditAction.REMOTE_SHUTDOWN)
+ .actionTarget(gameId)
+ .moderatorUserId(moderatorId)
+ .build());
+
+ gameMessagingBus.broadcastMessage(new ShutdownServerMessage(gameId));
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/player/info/FetchPlayerInfoModule.java b/server/lobby-module/src/main/java/org/triplea/modules/player/info/FetchPlayerInfoModule.java
new file mode 100644
index 0000000..812b821
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/player/info/FetchPlayerInfoModule.java
@@ -0,0 +1,104 @@
+package org.triplea.modules.player.info;
+
+import java.util.Collection;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import lombok.AllArgsConstructor;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.api.key.PlayerApiKeyDaoWrapper;
+import org.triplea.db.dao.api.key.PlayerIdentifiersByApiKeyLookup;
+import org.triplea.db.dao.moderator.player.info.PlayerAliasRecord;
+import org.triplea.db.dao.moderator.player.info.PlayerBanRecord;
+import org.triplea.db.dao.moderator.player.info.PlayerInfoForModeratorDao;
+import org.triplea.db.dao.user.history.PlayerHistoryDao;
+import org.triplea.db.dao.user.history.PlayerHistoryRecord;
+import org.triplea.domain.data.PlayerChatId;
+import org.triplea.domain.data.SystemId;
+import org.triplea.http.client.lobby.moderator.PlayerSummary;
+import org.triplea.http.client.lobby.moderator.PlayerSummary.Alias;
+import org.triplea.http.client.lobby.moderator.PlayerSummary.BanInformation;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.modules.game.listing.GameListing;
+
+@AllArgsConstructor
+public class FetchPlayerInfoModule {
+ private final PlayerApiKeyDaoWrapper apiKeyDaoWrapper;
+ private final PlayerInfoForModeratorDao playerInfoForModeratorDao;
+ private final PlayerHistoryDao playerHistoryDao;
+ private final Chatters chatters;
+ private final GameListing gameListing;
+
+ public static FetchPlayerInfoModule build(
+ final Jdbi jdbi, final Chatters chatters, final GameListing gameListing) {
+ return new FetchPlayerInfoModule(
+ PlayerApiKeyDaoWrapper.build(jdbi),
+ jdbi.onDemand(PlayerInfoForModeratorDao.class),
+ jdbi.onDemand(PlayerHistoryDao.class),
+ chatters,
+ gameListing);
+ }
+
+ public PlayerSummary fetchPlayerInfoAsModerator(final PlayerChatId playerChatId) {
+ return fetchPlayerInfo(true, playerChatId);
+ }
+
+ public PlayerSummary fetchPlayerInfo(final PlayerChatId playerChatId) {
+ return fetchPlayerInfo(false, playerChatId);
+ }
+
+ private PlayerSummary fetchPlayerInfo(
+ final boolean isModerator, final PlayerChatId playerChatId) {
+ final var chatterSession =
+ chatters.lookupPlayerByChatId(playerChatId).orElseThrow(this::playerLeftChatException);
+
+ var playerSummaryBuilder =
+ PlayerSummary.builder()
+ .registrationDateEpochMillis(lookupRegistrationDate(playerChatId))
+ .currentGames(
+ gameListing.getGameNamesPlayerHasJoined(
+ chatterSession.getChatParticipant().getUserName()));
+
+ // if a moderator is requesting player data, then attach ban and aliases information
+ if (isModerator) {
+ final PlayerIdentifiersByApiKeyLookup gamePlayerLookup =
+ apiKeyDaoWrapper
+ .lookupPlayerByChatId(playerChatId)
+ .orElseThrow(this::playerLeftChatException);
+
+ playerSummaryBuilder =
+ playerSummaryBuilder
+ .systemId(gamePlayerLookup.getSystemId().getValue())
+ .ip(chatterSession.getIp().toString())
+ .aliases(
+ lookupPlayerAliases(gamePlayerLookup.getSystemId(), gamePlayerLookup.getIp()))
+ .bans(lookupPlayerBans(gamePlayerLookup.getSystemId(), gamePlayerLookup.getIp()));
+ }
+
+ return playerSummaryBuilder.build();
+ }
+
+ @Nullable
+ private Long lookupRegistrationDate(final PlayerChatId playerChatId) {
+ return apiKeyDaoWrapper
+ .lookupUserIdByChatId(playerChatId)
+ .flatMap(playerHistoryDao::lookupPlayerHistoryByUserId)
+ .map(PlayerHistoryRecord::getRegistrationDate)
+ .orElse(null);
+ }
+
+ private IllegalArgumentException playerLeftChatException() {
+ return new IllegalArgumentException("Player could not be found, have they left chat?");
+ }
+
+ private Collection lookupPlayerAliases(final SystemId systemId, final String ip) {
+ return playerInfoForModeratorDao.lookupPlayerAliasRecords(systemId.getValue(), ip).stream()
+ .map(PlayerAliasRecord::toAlias)
+ .collect(Collectors.toList());
+ }
+
+ private Collection lookupPlayerBans(final SystemId systemId, final String ip) {
+ return playerInfoForModeratorDao.lookupPlayerBanRecords(systemId.getValue(), ip).stream()
+ .map(PlayerBanRecord::toBanInformation)
+ .collect(Collectors.toList());
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/NameIsAvailableValidation.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/NameIsAvailableValidation.java
new file mode 100644
index 0000000..15026cc
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/NameIsAvailableValidation.java
@@ -0,0 +1,27 @@
+package org.triplea.modules.user.account;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.util.Optional;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.UserJdbiDao;
+
+@AllArgsConstructor(access = AccessLevel.PROTECTED, onConstructor_ = @VisibleForTesting)
+public class NameIsAvailableValidation implements Function> {
+
+ @Nonnull private final UserJdbiDao userJdbiDao;
+
+ public static NameIsAvailableValidation build(final Jdbi jdbi) {
+ return new NameIsAvailableValidation(jdbi.onDemand(UserJdbiDao.class));
+ }
+
+ @Override
+ public Optional apply(final String playerName) {
+ return userJdbiDao.lookupUserIdByName(playerName.trim()).isPresent()
+ ? Optional.of("That name is already taken, please choose another")
+ : Optional.empty();
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/NameValidation.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/NameValidation.java
new file mode 100644
index 0000000..a9cf04c
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/NameValidation.java
@@ -0,0 +1,41 @@
+package org.triplea.modules.user.account;
+
+import java.util.Optional;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.moderator.BadWordsDao;
+import org.triplea.db.dao.user.UserJdbiDao;
+import org.triplea.db.dao.username.ban.UsernameBanDao;
+import org.triplea.domain.data.UserName;
+
+@Builder
+public class NameValidation implements Function> {
+
+ @Nonnull private final Function> syntaxValidation;
+ @Nonnull private final BadWordsDao badWordsDao;
+ @Nonnull private final UserJdbiDao userJdbiDao;
+ @Nonnull private final UsernameBanDao usernameBanDao;
+
+ public static NameValidation build(final Jdbi jdbi) {
+ return NameValidation.builder()
+ .userJdbiDao(jdbi.onDemand(UserJdbiDao.class))
+ .syntaxValidation(name -> Optional.ofNullable(UserName.validate(name)))
+ .badWordsDao(jdbi.onDemand(BadWordsDao.class))
+ .usernameBanDao(jdbi.onDemand(UsernameBanDao.class))
+ .build();
+ }
+
+ @Override
+ public Optional apply(final String playerName) {
+ return syntaxValidation
+ .apply(playerName)
+ .or(
+ () ->
+ badWordsDao.containsBadWord(playerName)
+ || usernameBanDao.nameIsBanned(playerName.trim())
+ ? Optional.of("That is not a nice name")
+ : Optional.empty());
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/PasswordBCrypter.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/PasswordBCrypter.java
new file mode 100644
index 0000000..7305742
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/PasswordBCrypter.java
@@ -0,0 +1,34 @@
+package org.triplea.modules.user.account;
+
+import at.favre.lib.crypto.bcrypt.BCrypt;
+import at.favre.lib.crypto.bcrypt.LongPasswordStrategies;
+import lombok.experimental.UtilityClass;
+
+@UtilityClass
+public class PasswordBCrypter {
+ /**
+ * This is a helper method designed to simplify the bcrypt API and hide some of the constants
+ * involved. This method generates a hash with 10 rounds. This number is arbitrary and might
+ * increase at a later time.
+ *
+ * @param password The string to apply the bcrypt algorithm to.
+ * @return A hashed password using a randomly generated bcrypt salt.
+ */
+ public static String hashPassword(final String password) {
+ return BCrypt.with(LongPasswordStrategies.none()).hashToString(10, password.toCharArray());
+ }
+
+ /**
+ * Checks of the provided password does match the existing hash. NOTE: Any passwords longer than
+ * 72 bytes (UTF-8) will result in the same hash as the version trimmed to 72 bytes.
+ *
+ * @param password The password to check.
+ * @param hash The hash to verify the password against.
+ * @return True if the password matches the hash, false otherwise.
+ */
+ public static boolean verifyHash(final String password, final String hash) {
+ return BCrypt.verifyer(null, LongPasswordStrategies.none())
+ .verify(password.toCharArray(), hash.toCharArray())
+ .verified;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/AccountCreator.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/AccountCreator.java
new file mode 100644
index 0000000..2fd619d
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/AccountCreator.java
@@ -0,0 +1,39 @@
+package org.triplea.modules.user.account.create;
+
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.UserJdbiDao;
+import org.triplea.http.client.lobby.login.CreateAccountRequest;
+import org.triplea.http.client.lobby.login.CreateAccountResponse;
+import org.triplea.java.Postconditions;
+import org.triplea.modules.user.account.PasswordBCrypter;
+
+/**
+ * Responsible to execute a 'create account' request. We should already have validated the request
+ * and just need to store the new account in database.
+ */
+@Builder
+class AccountCreator implements Function {
+ @Nonnull private final UserJdbiDao userJdbiDao;
+ @Nonnull private final Function passwordEncryptor;
+
+ public static AccountCreator build(final Jdbi jdbi) {
+ return AccountCreator.builder()
+ .userJdbiDao(jdbi.onDemand(UserJdbiDao.class))
+ .passwordEncryptor(PasswordBCrypter::hashPassword)
+ .build();
+ }
+
+ @Override
+ public CreateAccountResponse apply(final CreateAccountRequest createAccountRequest) {
+ final String cryptedPassword = passwordEncryptor.apply(createAccountRequest.getPassword());
+
+ final int rowCount =
+ userJdbiDao.createUser(
+ createAccountRequest.getUsername(), createAccountRequest.getEmail(), cryptedPassword);
+ Postconditions.assertState(rowCount == 1);
+ return CreateAccountResponse.SUCCESS_RESPONSE;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/CreateAccountModule.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/CreateAccountModule.java
new file mode 100644
index 0000000..1cf4550
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/CreateAccountModule.java
@@ -0,0 +1,40 @@
+package org.triplea.modules.user.account.create;
+
+import java.util.Optional;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.http.client.lobby.login.CreateAccountRequest;
+import org.triplea.http.client.lobby.login.CreateAccountResponse;
+
+/**
+ * Imperative shell for creating a user account. Validates a request, if valid, creates a new user
+ * account in database.
+ */
+@Builder
+public class CreateAccountModule implements Function {
+
+ @Nonnull private final Function> createAccountValidation;
+ @Nonnull private final Function accountCreator;
+
+ public static CreateAccountModule build(final Jdbi jdbi) {
+ return CreateAccountModule.builder()
+ .accountCreator(AccountCreator.build(jdbi))
+ .createAccountValidation(CreateAccountValidation.build(jdbi))
+ .build();
+ }
+
+ @Override
+ public CreateAccountResponse apply(final CreateAccountRequest createAccountRequest) {
+ // create account if request is valid
+ return createAccountValidation
+ .apply(createAccountRequest)
+ .map(CreateAccountModule::createError)
+ .orElseGet(() -> accountCreator.apply(createAccountRequest));
+ }
+
+ private static CreateAccountResponse createError(final String errorMessage) {
+ return CreateAccountResponse.builder().errorMessage(errorMessage).build();
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/CreateAccountValidation.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/CreateAccountValidation.java
new file mode 100644
index 0000000..0a11b5c
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/CreateAccountValidation.java
@@ -0,0 +1,47 @@
+package org.triplea.modules.user.account.create;
+
+import java.util.Optional;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.http.client.lobby.login.CreateAccountRequest;
+import org.triplea.modules.user.account.NameIsAvailableValidation;
+import org.triplea.modules.user.account.NameValidation;
+
+/**
+ * Verifies that a new user account request is good-to-create. General validation rules are:
+ *
+ *
+ * - username is not already in database
+ *
- username is not banned and does not contain a word from 'bad words list'
+ *
- password is a min length
+ *
- email looks valid, contains an '@' sign
+ *
+ */
+@Builder
+class CreateAccountValidation implements Function> {
+
+ @Nonnull private final Function> nameValidator;
+ @Nonnull private final Function> nameIsAvailableValidator;
+ @Nonnull private final Function> emailValidator;
+ @Nonnull private final Function> passwordValidator;
+
+ public static CreateAccountValidation build(final Jdbi jdbi) {
+ return CreateAccountValidation.builder()
+ .nameValidator(NameValidation.build(jdbi))
+ .emailValidator(new EmailValidation())
+ .passwordValidator(new PasswordValidation())
+ .nameIsAvailableValidator(NameIsAvailableValidation.build(jdbi))
+ .build();
+ }
+
+ @Override
+ public Optional apply(final CreateAccountRequest createAccountRequest) {
+ return nameValidator
+ .apply(createAccountRequest.getUsername())
+ .or(() -> nameIsAvailableValidator.apply(createAccountRequest.getUsername()))
+ .or(() -> emailValidator.apply(createAccountRequest.getEmail()))
+ .or(() -> passwordValidator.apply(createAccountRequest.getPassword()));
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/EmailValidation.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/EmailValidation.java
new file mode 100644
index 0000000..e105595
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/EmailValidation.java
@@ -0,0 +1,15 @@
+package org.triplea.modules.user.account.create;
+
+import com.google.common.base.Strings;
+import java.util.Optional;
+import java.util.function.Function;
+import org.triplea.domain.data.PlayerEmailValidation;
+
+public class EmailValidation implements Function> {
+ @Override
+ public Optional apply(final String email) {
+ return !Strings.isNullOrEmpty(email) && PlayerEmailValidation.isValid(email)
+ ? Optional.empty()
+ : Optional.of("Invalid email address");
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/PasswordValidation.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/PasswordValidation.java
new file mode 100644
index 0000000..4e2e38b
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/create/PasswordValidation.java
@@ -0,0 +1,22 @@
+package org.triplea.modules.user.account.create;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import java.util.Optional;
+import java.util.function.Function;
+
+/** We expect password to be sent to server as a hash. Simply check that it is not too short. */
+public class PasswordValidation implements Function> {
+ /**
+ * Expected MIN_LENGTH is length of the shortest password a user can input on the client-side UI
+ * when creating a new account.
+ */
+ @VisibleForTesting static final int MIN_LENGTH = 3;
+
+ @Override
+ public Optional apply(final String password) {
+ return Strings.nullToEmpty(password).trim().length() < MIN_LENGTH
+ ? Optional.of("Password too short")
+ : Optional.empty();
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/AccessLogUpdater.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/AccessLogUpdater.java
new file mode 100644
index 0000000..19e020e
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/AccessLogUpdater.java
@@ -0,0 +1,31 @@
+package org.triplea.modules.user.account.login;
+
+import java.util.function.Consumer;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.access.log.AccessLogDao;
+import org.triplea.java.Postconditions;
+
+@Builder
+class AccessLogUpdater implements Consumer {
+
+ @Nonnull private final AccessLogDao accessLogDao;
+
+ public static AccessLogUpdater build(final Jdbi jdbi) {
+ return AccessLogUpdater.builder() //
+ .accessLogDao(jdbi.onDemand(AccessLogDao.class))
+ .build();
+ }
+
+ @Override
+ public void accept(final LoginRecord loginRecord) {
+ final int updateCount =
+ accessLogDao.insertUserAccessRecord(
+ loginRecord.getUserName().getValue(),
+ loginRecord.getIp(),
+ loginRecord.getSystemId().getValue());
+
+ Postconditions.assertState(updateCount == 1);
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/ApiKeyGenerator.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/ApiKeyGenerator.java
new file mode 100644
index 0000000..59bac22
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/ApiKeyGenerator.java
@@ -0,0 +1,36 @@
+package org.triplea.modules.user.account.login;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.api.key.PlayerApiKeyDaoWrapper;
+import org.triplea.domain.data.ApiKey;
+
+@Builder
+class ApiKeyGenerator implements Function {
+
+ @Nonnull private final PlayerApiKeyDaoWrapper apiKeyDaoWrapper;
+
+ public static ApiKeyGenerator build(final Jdbi jdbi) {
+ return ApiKeyGenerator.builder() //
+ .apiKeyDaoWrapper(PlayerApiKeyDaoWrapper.build(jdbi))
+ .build();
+ }
+
+ @Override
+ public ApiKey apply(final LoginRecord loginRecord) {
+ try {
+ return apiKeyDaoWrapper.newKey(
+ loginRecord.getUserName(),
+ InetAddress.getByName(loginRecord.getIp()),
+ loginRecord.getSystemId(),
+ loginRecord.getPlayerChatId());
+ } catch (final UnknownHostException e) {
+ throw new IllegalStateException(
+ "Unexpected exception for IP address: " + loginRecord.getIp(), e);
+ }
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/LobbyLoginMessageDao.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/LobbyLoginMessageDao.java
new file mode 100644
index 0000000..ebc8242
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/LobbyLoginMessageDao.java
@@ -0,0 +1,26 @@
+package org.triplea.modules.user.account.login;
+
+import java.util.function.Supplier;
+import lombok.AllArgsConstructor;
+import org.jdbi.v3.core.Jdbi;
+
+/** Fetches from database the lobby login message */
+@AllArgsConstructor
+public class LobbyLoginMessageDao implements Supplier {
+
+ private final Jdbi jdbi;
+
+ static LobbyLoginMessageDao build(final Jdbi jdbi) {
+ return new LobbyLoginMessageDao(jdbi);
+ }
+
+ @Override
+ public String get() {
+ return jdbi.withHandle(
+ handle ->
+ handle
+ .createQuery("select message from lobby_message") //
+ .mapTo(String.class)
+ .one());
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/LoginModule.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/LoginModule.java
new file mode 100644
index 0000000..40b5e40
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/LoginModule.java
@@ -0,0 +1,133 @@
+package org.triplea.modules.user.account.login;
+
+import com.google.common.base.Strings;
+import java.util.Optional;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.UserJdbiDao;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.domain.data.PlayerChatId;
+import org.triplea.domain.data.SystemId;
+import org.triplea.domain.data.UserName;
+import org.triplea.http.client.lobby.login.LobbyLoginResponse;
+import org.triplea.http.client.lobby.login.LoginRequest;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.modules.user.account.NameValidation;
+import org.triplea.modules.user.account.login.authorizer.anonymous.AnonymousLogin;
+import org.triplea.modules.user.account.login.authorizer.registered.PasswordCheck;
+import org.triplea.modules.user.account.login.authorizer.temp.password.TempPasswordLogin;
+
+@Builder
+public class LoginModule {
+ @Nonnull private final Predicate registeredLogin;
+ @Nonnull private final Predicate tempPasswordLogin;
+ @Nonnull private final Function> anonymousLogin;
+ @Nonnull private final Consumer accessLogUpdater;
+ @Nonnull private final Function apiKeyGenerator;
+ @Nonnull private final UserJdbiDao userJdbiDao;
+ @Nonnull private final Function> nameValidation;
+ @Nonnull private final Supplier lobbyLoginMessageDao;
+
+ public static LoginModule build(final Jdbi jdbi, final Chatters chatters) {
+ return LoginModule.builder()
+ .userJdbiDao(jdbi.onDemand(UserJdbiDao.class))
+ .accessLogUpdater(AccessLogUpdater.build(jdbi))
+ .apiKeyGenerator(ApiKeyGenerator.build(jdbi))
+ .anonymousLogin(AnonymousLogin.build(jdbi, chatters))
+ .tempPasswordLogin(TempPasswordLogin.build(jdbi))
+ .registeredLogin(PasswordCheck.build(jdbi))
+ .nameValidation(NameValidation.build(jdbi))
+ .lobbyLoginMessageDao(LobbyLoginMessageDao.build(jdbi))
+ .build();
+ }
+
+ public LobbyLoginResponse doLogin(
+ final LoginRequest loginRequest, final String systemId, final String ip) {
+
+ final Optional nameValidationError = nameValidation.apply(loginRequest.getName());
+ if (nameValidationError.isPresent()) {
+ return LobbyLoginResponse.builder().failReason(nameValidationError.get()).build();
+ }
+
+ if (Strings.isNullOrEmpty(loginRequest.getName()) || Strings.isNullOrEmpty(systemId)) {
+ return LobbyLoginResponse.builder().failReason("Invalid login request").build();
+ }
+
+ final SystemId playerSystemId = SystemId.of(systemId);
+ final String nameValidation = UserName.validate(loginRequest.getName());
+ if (nameValidation != null) {
+ return LobbyLoginResponse.builder().failReason("Invalid name: " + nameValidation).build();
+ }
+
+ final boolean hasPassword = !Strings.nullToEmpty(loginRequest.getPassword()).isEmpty();
+
+ // successful login case
+ if (hasPassword && registeredLogin.test(loginRequest)) {
+ final ApiKey apiKey =
+ recordLoginAndGenerateApiKey(loginRequest, playerSystemId, PlayerChatId.newId(), ip);
+ return LobbyLoginResponse.builder()
+ .apiKey(apiKey.getValue())
+ .moderator(isModerator(loginRequest.getName()))
+ .lobbyMessage(lobbyLoginMessageDao.get())
+ .build();
+ // successful login using a temp password
+ } else if (hasPassword && tempPasswordLogin.test(loginRequest)) {
+ final ApiKey apiKey =
+ recordLoginAndGenerateApiKey(loginRequest, playerSystemId, PlayerChatId.newId(), ip);
+ return LobbyLoginResponse.builder()
+ .apiKey(apiKey.getValue())
+ .passwordChangeRequired(true)
+ .moderator(isModerator(loginRequest.getName()))
+ .lobbyMessage(lobbyLoginMessageDao.get())
+ .build();
+ // bad password
+ } else if (hasPassword) {
+ return LobbyLoginResponse.builder()
+ .failReason("Invalid username and password combination")
+ .build();
+ // no password -> anonymous login
+ } else {
+ final Optional errorMessage =
+ anonymousLogin.apply(UserName.of(loginRequest.getName()));
+ if (errorMessage.isPresent()) {
+ return LobbyLoginResponse.builder().failReason(errorMessage.get()).build();
+ } else {
+ final ApiKey apiKey =
+ recordLoginAndGenerateApiKey(loginRequest, playerSystemId, PlayerChatId.newId(), ip);
+ return LobbyLoginResponse.builder()
+ .apiKey(apiKey.getValue())
+ .lobbyMessage(lobbyLoginMessageDao.get())
+ .build();
+ }
+ }
+ }
+
+ private ApiKey recordLoginAndGenerateApiKey(
+ final LoginRequest loginRequest,
+ final SystemId systemId,
+ final PlayerChatId playerchatId,
+ final String ip) {
+ final var loginRecord =
+ LoginRecord.builder()
+ .userName(UserName.of(loginRequest.getName()))
+ .systemId(systemId)
+ .playerChatId(playerchatId)
+ .ip(ip)
+ .build();
+ accessLogUpdater.accept(loginRecord);
+ return apiKeyGenerator.apply(loginRecord);
+ }
+
+ private boolean isModerator(final String username) {
+ return userJdbiDao
+ .lookupUserRoleByUserName(username)
+ .map(UserRole::isModerator)
+ .orElseThrow(() -> new AssertionError("Expected to find role for user: " + username));
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/LoginRecord.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/LoginRecord.java
new file mode 100644
index 0000000..b9086ed
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/LoginRecord.java
@@ -0,0 +1,17 @@
+package org.triplea.modules.user.account.login;
+
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import lombok.Value;
+import org.triplea.domain.data.PlayerChatId;
+import org.triplea.domain.data.SystemId;
+import org.triplea.domain.data.UserName;
+
+@Builder
+@Value
+public class LoginRecord {
+ @Nonnull private final UserName userName;
+ @Nonnull private String ip;
+ @Nonnull private final SystemId systemId;
+ @Nonnull private final PlayerChatId playerChatId;
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/authorizer/anonymous/AnonymousLogin.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/authorizer/anonymous/AnonymousLogin.java
new file mode 100644
index 0000000..6dade52
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/authorizer/anonymous/AnonymousLogin.java
@@ -0,0 +1,34 @@
+package org.triplea.modules.user.account.login.authorizer.anonymous;
+
+import com.google.common.base.Preconditions;
+import java.util.Optional;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.domain.data.UserName;
+import org.triplea.modules.chat.Chatters;
+import org.triplea.modules.user.account.NameIsAvailableValidation;
+
+@Builder
+public class AnonymousLogin implements Function> {
+ @Nonnull private final Chatters chatters;
+ @Nonnull private final Function> nameIsAvailableValidation;
+
+ public static Function> build(
+ final Jdbi jdbi, final Chatters chatters) {
+ return AnonymousLogin.builder()
+ .chatters(chatters)
+ .nameIsAvailableValidation(NameIsAvailableValidation.build(jdbi))
+ .build();
+ }
+
+ @Override
+ public Optional apply(final UserName userName) {
+ Preconditions.checkNotNull(userName);
+ return (!chatters.isPlayerConnected(userName)
+ && nameIsAvailableValidation.apply(userName.getValue()).isEmpty())
+ ? Optional.empty()
+ : Optional.of("Name is already in use, please choose another");
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/authorizer/registered/PasswordCheck.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/authorizer/registered/PasswordCheck.java
new file mode 100644
index 0000000..81a5ce3
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/authorizer/registered/PasswordCheck.java
@@ -0,0 +1,37 @@
+package org.triplea.modules.user.account.login.authorizer.registered;
+
+import com.google.common.base.Preconditions;
+import java.util.function.BiPredicate;
+import java.util.function.Predicate;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.UserJdbiDao;
+import org.triplea.http.client.lobby.login.LoginRequest;
+import org.triplea.java.ArgChecker;
+import org.triplea.modules.user.account.PasswordBCrypter;
+
+@Builder
+public class PasswordCheck implements Predicate {
+
+ @Nonnull private final UserJdbiDao userJdbiDao;
+ @Nonnull private final BiPredicate passwordVerifier;
+
+ public static PasswordCheck build(final Jdbi jdbi) {
+ return PasswordCheck.builder()
+ .passwordVerifier(PasswordBCrypter::verifyHash)
+ .userJdbiDao(jdbi.onDemand(UserJdbiDao.class))
+ .build();
+ }
+
+ @Override
+ public boolean test(final LoginRequest loginRequest) {
+ Preconditions.checkNotNull(loginRequest);
+ ArgChecker.checkNotEmpty(loginRequest.getName());
+ ArgChecker.checkNotEmpty(loginRequest.getPassword());
+ return userJdbiDao
+ .getPassword(loginRequest.getName())
+ .map(dbPassword -> passwordVerifier.test(loginRequest.getPassword(), dbPassword))
+ .orElse(false);
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/authorizer/temp/password/TempPasswordLogin.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/authorizer/temp/password/TempPasswordLogin.java
new file mode 100644
index 0000000..1b509f0
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/login/authorizer/temp/password/TempPasswordLogin.java
@@ -0,0 +1,44 @@
+package org.triplea.modules.user.account.login.authorizer.temp.password;
+
+import com.google.common.base.Preconditions;
+import java.util.function.BiPredicate;
+import java.util.function.Predicate;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.temp.password.TempPasswordDao;
+import org.triplea.http.client.lobby.login.LoginRequest;
+import org.triplea.modules.user.account.PasswordBCrypter;
+
+@Builder
+public class TempPasswordLogin implements Predicate {
+
+ @Nonnull private final TempPasswordDao tempPasswordDao;
+ @Nonnull private final BiPredicate passwordChecker;
+
+ public static TempPasswordLogin build(final Jdbi jdbi) {
+ return TempPasswordLogin.builder()
+ .tempPasswordDao(jdbi.onDemand(TempPasswordDao.class))
+ .passwordChecker(PasswordBCrypter::verifyHash)
+ .build();
+ }
+
+ @Override
+ public boolean test(final LoginRequest loginRequest) {
+ Preconditions.checkNotNull(loginRequest);
+ Preconditions.checkNotNull(loginRequest.getName());
+ Preconditions.checkNotNull(loginRequest.getPassword());
+ return tempPasswordDao
+ .fetchTempPassword(loginRequest.getName())
+ .map(
+ tempPassword -> {
+ if (passwordChecker.test(loginRequest.getPassword(), tempPassword)) {
+ tempPasswordDao.invalidateTempPasswords(loginRequest.getName());
+ return true;
+ } else {
+ return false;
+ }
+ })
+ .orElse(false);
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/modules/user/account/update/UpdateAccountService.java b/server/lobby-module/src/main/java/org/triplea/modules/user/account/update/UpdateAccountService.java
new file mode 100644
index 0000000..3c2f7e1
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/modules/user/account/update/UpdateAccountService.java
@@ -0,0 +1,39 @@
+package org.triplea.modules.user.account.update;
+
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+import lombok.Builder;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.UserJdbiDao;
+import org.triplea.modules.user.account.PasswordBCrypter;
+
+@Builder
+public class UpdateAccountService {
+
+ @Nonnull private final UserJdbiDao userJdbiDao;
+
+ @Nonnull private final Function passwordEncrpter;
+
+ public static UpdateAccountService build(final Jdbi jdbi) {
+ return UpdateAccountService.builder()
+ .userJdbiDao(jdbi.onDemand(UserJdbiDao.class))
+ .passwordEncrpter(PasswordBCrypter::hashPassword)
+ .build();
+ }
+
+ public void changePassword(final int userId, final String newPassword) {
+ final int updateCount = userJdbiDao.updatePassword(userId, passwordEncrpter.apply(newPassword));
+ assert updateCount == 1;
+ }
+
+ public String fetchEmail(final int userId) {
+ final String email = userJdbiDao.fetchEmail(userId);
+ assert email != null;
+ return email;
+ }
+
+ public void changeEmail(final int userId, final String newEmail) {
+ final int updateCount = userJdbiDao.updateEmail(userId, newEmail);
+ assert updateCount == 1;
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/web/socket/GameConnectionWebSocket.java b/server/lobby-module/src/main/java/org/triplea/web/socket/GameConnectionWebSocket.java
new file mode 100644
index 0000000..77b0fe7
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/web/socket/GameConnectionWebSocket.java
@@ -0,0 +1,33 @@
+package org.triplea.web.socket;
+
+import javax.websocket.CloseReason;
+import javax.websocket.OnClose;
+import javax.websocket.OnError;
+import javax.websocket.OnMessage;
+import javax.websocket.OnOpen;
+import javax.websocket.Session;
+import javax.websocket.server.ServerEndpoint;
+import org.triplea.http.client.web.socket.WebsocketPaths;
+
+@ServerEndpoint(WebsocketPaths.GAME_CONNECTIONS)
+public class GameConnectionWebSocket {
+ @OnOpen
+ public void onOpen(final Session session) {
+ GenericWebSocket.getInstance(this.getClass()).onOpen(session);
+ }
+
+ @OnMessage
+ public void onMessage(final Session session, final String message) {
+ GenericWebSocket.getInstance(this.getClass()).onMessage(session, message);
+ }
+
+ @OnClose
+ public void onClose(final Session session, final CloseReason closeReason) {
+ GenericWebSocket.getInstance(this.getClass()).onClose(session, closeReason);
+ }
+
+ @OnError
+ public void onError(final Session session, final Throwable throwable) {
+ GenericWebSocket.getInstance(this.getClass()).onError(session, throwable);
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/web/socket/PlayerConnectionWebSocket.java b/server/lobby-module/src/main/java/org/triplea/web/socket/PlayerConnectionWebSocket.java
new file mode 100644
index 0000000..2f59649
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/web/socket/PlayerConnectionWebSocket.java
@@ -0,0 +1,38 @@
+package org.triplea.web.socket;
+
+import javax.websocket.CloseReason;
+import javax.websocket.OnClose;
+import javax.websocket.OnError;
+import javax.websocket.OnMessage;
+import javax.websocket.OnOpen;
+import javax.websocket.Session;
+import javax.websocket.server.ServerEndpoint;
+import org.triplea.http.client.web.socket.WebsocketPaths;
+
+/**
+ * Handles chat connections. Largely delegates to {@see MessagingService}. A shared {@code
+ * MessagingService} is injected into each user session and is available from {@code Session}
+ * objects.
+ */
+@ServerEndpoint(WebsocketPaths.PLAYER_CONNECTIONS)
+public class PlayerConnectionWebSocket {
+ @OnOpen
+ public void onOpen(final Session session) {
+ GenericWebSocket.getInstance(this.getClass()).onOpen(session);
+ }
+
+ @OnMessage
+ public void onMessage(final Session session, final String message) {
+ GenericWebSocket.getInstance(this.getClass()).onMessage(session, message);
+ }
+
+ @OnClose
+ public void onClose(final Session session, final CloseReason closeReason) {
+ GenericWebSocket.getInstance(this.getClass()).onClose(session, closeReason);
+ }
+
+ @OnError
+ public void onError(final Session session, final Throwable throwable) {
+ GenericWebSocket.getInstance(this.getClass()).onError(session, throwable);
+ }
+}
diff --git a/server/lobby-module/src/main/java/org/triplea/web/socket/SessionBannedCheck.java b/server/lobby-module/src/main/java/org/triplea/web/socket/SessionBannedCheck.java
new file mode 100644
index 0000000..485c6ab
--- /dev/null
+++ b/server/lobby-module/src/main/java/org/triplea/web/socket/SessionBannedCheck.java
@@ -0,0 +1,24 @@
+package org.triplea.web.socket;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.net.InetAddress;
+import java.util.function.Predicate;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import org.jdbi.v3.core.Jdbi;
+import org.triplea.db.dao.user.ban.UserBanDao;
+
+@AllArgsConstructor(access = AccessLevel.PACKAGE, onConstructor_ = @VisibleForTesting)
+public class SessionBannedCheck implements Predicate {
+ private final UserBanDao userBanDao;
+
+ public static SessionBannedCheck build(final Jdbi jdbi) {
+ return new SessionBannedCheck(jdbi.onDemand(UserBanDao.class));
+ }
+
+ @Override
+ public boolean test(final InetAddress remoteAddress) {
+ final String ip = remoteAddress.getHostAddress();
+ return userBanDao.isBannedByIp(ip);
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/TestData.java b/server/lobby-module/src/test/java/org/triplea/TestData.java
new file mode 100644
index 0000000..781d50b
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/TestData.java
@@ -0,0 +1,25 @@
+package org.triplea;
+
+import java.time.Instant;
+import lombok.experimental.UtilityClass;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.domain.data.LobbyGame;
+
+@UtilityClass
+public class TestData {
+ public static final ApiKey API_KEY = ApiKey.of("test");
+
+ public static final LobbyGame LOBBY_GAME =
+ LobbyGame.builder()
+ .hostAddress("127.0.0.1")
+ .hostPort(12)
+ .hostName("name")
+ .mapName("map")
+ .playerCount(3)
+ .gameRound(1)
+ .epochMilliTimeStarted(Instant.now().toEpochMilli())
+ .passworded(false)
+ .status("Waiting For Players")
+ .comments("comments")
+ .build();
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/LobbyModuleDatabaseTestSupport.java b/server/lobby-module/src/test/java/org/triplea/db/LobbyModuleDatabaseTestSupport.java
new file mode 100644
index 0000000..e4fec0b
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/LobbyModuleDatabaseTestSupport.java
@@ -0,0 +1,12 @@
+package org.triplea.db;
+
+import java.util.Collection;
+import org.jdbi.v3.core.mapper.RowMapperFactory;
+import org.triplea.spitfire.database.DatabaseTestSupport;
+
+public class LobbyModuleDatabaseTestSupport extends DatabaseTestSupport {
+ @Override
+ protected Collection rowMappers() {
+ return LobbyModuleRowMappers.rowMappers();
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/access/log/AccessLogDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/access/log/AccessLogDaoTest.java
new file mode 100644
index 0000000..9f89cd6
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/access/log/AccessLogDaoTest.java
@@ -0,0 +1,123 @@
+package org.triplea.db.dao.access.log;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.collection.IsEmptyCollection.empty;
+import static org.hamcrest.core.Is.is;
+import static org.triplea.test.common.IsInstant.isInstant;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.core.api.dataset.ExpectedDataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.test.common.RequiresDatabase;
+
+@RequiredArgsConstructor
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@RequiresDatabase
+class AccessLogDaoTest {
+ private static final String EMPTY_ACCESS_LOG =
+ "access_log/user_role.yml,access_log/lobby_user.yml";
+ private static final String ACCESS_LOG_TABLES =
+ "access_log/user_role.yml,access_log/lobby_user.yml,access_log/access_log.yml";
+
+ private final AccessLogDao accessLogDao;
+
+ @Test
+ @DataSet(cleanBefore = true, value = EMPTY_ACCESS_LOG, useSequenceFiltering = false)
+ void emptyDataCase() {
+ assertThat(accessLogDao.fetchAccessLogRows(0, 1, "%", "%", "%"), is(empty()));
+ }
+
+ /**
+ * In this test we verify.: - records are returned in reverse chronological order - data values
+ * are as expected
+ */
+ @Test
+ @DataSet(value = ACCESS_LOG_TABLES, useSequenceFiltering = false)
+ void fetchTwoRows() {
+ List data = accessLogDao.fetchAccessLogRows(0, 1, "%", "%", "%");
+ assertThat(data, hasSize(1));
+
+ verifyIsRowWithSecondUsername(data.get(0));
+
+ data = accessLogDao.fetchAccessLogRows(1, 1, "%", "%", "%");
+ assertThat(data, hasSize(1));
+
+ verifyIsRowWithFirstUsername(data.get(0));
+ }
+
+ private void verifyIsRowWithSecondUsername(final AccessLogRecord accessLogRecord) {
+ assertThat(accessLogRecord.getAccessTime(), isInstant(2016, 1, 3, 23, 59, 20));
+ assertThat(accessLogRecord.getIp(), is("127.0.0.2"));
+ assertThat(accessLogRecord.getSystemId(), is("system-id2"));
+ assertThat(accessLogRecord.getUsername(), is("second"));
+ assertThat(accessLogRecord.isRegistered(), is(false));
+ }
+
+ private void verifyIsRowWithFirstUsername(final AccessLogRecord accessLogRecord) {
+ assertThat(accessLogRecord.getAccessTime(), isInstant(2016, 1, 1, 23, 59, 20));
+ assertThat(accessLogRecord.getIp(), is("127.0.0.1"));
+ assertThat(accessLogRecord.getSystemId(), is("system-id1"));
+ assertThat(accessLogRecord.getUsername(), is("first"));
+ assertThat(accessLogRecord.isRegistered(), is(true));
+ }
+
+ @Test
+ @DataSet(value = ACCESS_LOG_TABLES, useSequenceFiltering = false)
+ void searchForAllIdentifiesWithExactMatch() {
+ final List data =
+ accessLogDao.fetchAccessLogRows(0, 2, "first", "127.0.0.1", "system-id1");
+
+ assertThat(data, hasSize(1));
+ verifyIsRowWithFirstUsername(data.get(0));
+ }
+
+ @Test
+ @DataSet(value = ACCESS_LOG_TABLES, useSequenceFiltering = false)
+ void searchForSystemId() {
+ final List data =
+ accessLogDao.fetchAccessLogRows(0, 2, "%", "%", "system-id1");
+
+ assertThat(data, hasSize(1));
+ verifyIsRowWithFirstUsername(data.get(0));
+ }
+
+ @Test
+ @DataSet(value = ACCESS_LOG_TABLES, useSequenceFiltering = false)
+ void searchForUserName() {
+ final List data = accessLogDao.fetchAccessLogRows(0, 2, "first", "%", "%");
+
+ assertThat(data, hasSize(1));
+ verifyIsRowWithFirstUsername(data.get(0));
+ }
+
+ @Test
+ @DataSet(value = ACCESS_LOG_TABLES, useSequenceFiltering = false)
+ void searchForIp() {
+ final List data = accessLogDao.fetchAccessLogRows(0, 2, "%", "127.0.0.1", "%");
+
+ assertThat(data, hasSize(1));
+ verifyIsRowWithFirstUsername(data.get(0));
+ }
+
+ /** There are only 2 rows, requesting a row offset of '2' should yield no data. */
+ @Test
+ @DataSet(value = ACCESS_LOG_TABLES, useSequenceFiltering = false)
+ void requestingRowsOffDataSetReturnsNothing() {
+ assertThat(accessLogDao.fetchAccessLogRows(2, 1, "%", "%", "%"), is(empty()));
+ }
+
+ @Test
+ @DataSet(cleanBefore = true, value = EMPTY_ACCESS_LOG, useSequenceFiltering = false)
+ @ExpectedDataSet(value = "access_log/access_log_post_insert.yml", orderBy = "username")
+ void insertAccessLogRecords() {
+ accessLogDao.insertUserAccessRecord("anonymous", "127.0.0.50", "anonymous-system-id");
+ accessLogDao.insertUserAccessRecord("registered_user", "127.0.0.20", "registered-system-id");
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/GameHostingApiKeyDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/GameHostingApiKeyDaoTest.java
new file mode 100644
index 0000000..64851c9
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/GameHostingApiKeyDaoTest.java
@@ -0,0 +1,41 @@
+package org.triplea.db.dao.api.key;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.core.api.dataset.ExpectedDataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import lombok.RequiredArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.test.common.RequiresDatabase;
+
+@RequiredArgsConstructor
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@RequiresDatabase
+class GameHostingApiKeyDaoTest {
+
+ private final GameHostingApiKeyDao gameHostApiKeyDao;
+
+ @Test
+ @DataSet(value = "game_hosting_api_key/key_exists.yml", useSequenceFiltering = false)
+ void keyExists() {
+ assertThat(gameHostApiKeyDao.keyExists("game-hosting-key"), is(true));
+ }
+
+ @Test
+ @DataSet(value = "game_hosting_api_key/key_exists.yml", useSequenceFiltering = false)
+ void keyDoesNotExist() {
+ assertThat(gameHostApiKeyDao.keyExists("DNE"), is(false));
+ }
+
+ @Test
+ @DataSet(value = "game_hosting_api_key/insert_key_before.yml", useSequenceFiltering = false)
+ @ExpectedDataSet("game_hosting_api_key/insert_key_after.yml")
+ void insertKey() {
+ gameHostApiKeyDao.insertKey("game-hosting-api-key", "127.0.0.2");
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/GameHostingApiKeyDaoWrapperTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/GameHostingApiKeyDaoWrapperTest.java
new file mode 100644
index 0000000..7de627c
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/GameHostingApiKeyDaoWrapperTest.java
@@ -0,0 +1,62 @@
+package org.triplea.db.dao.api.key;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.mockito.Mockito.when;
+
+import java.util.function.Function;
+import java.util.function.Supplier;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.java.IpAddressParser;
+
+@ExtendWith(MockitoExtension.class)
+class GameHostingApiKeyDaoWrapperTest {
+
+ @Mock private GameHostingApiKeyDao gameHostApiKeyDao;
+
+ @Mock private Function keyHashingFunction;
+
+ @Mock private Supplier keyMaker;
+
+ @InjectMocks private GameHostingApiKeyDaoWrapper gameHostingApiKeyDaoWrapper;
+
+ @Nested
+ class IsKeyValid {
+ @Test
+ void valid() {
+ when(keyHashingFunction.apply(ApiKey.of("valid-key"))).thenReturn("hashed-valid-key");
+ when(gameHostApiKeyDao.keyExists("hashed-valid-key")).thenReturn(true);
+
+ assertThat(gameHostingApiKeyDaoWrapper.isKeyValid(ApiKey.of("valid-key")), is(true));
+ }
+
+ @Test
+ void notValid() {
+ when(keyHashingFunction.apply(ApiKey.of("not-valid-key"))).thenReturn("hashed-not-valid-key");
+ when(gameHostApiKeyDao.keyExists("hashed-not-valid-key")).thenReturn(false);
+
+ assertThat(gameHostingApiKeyDaoWrapper.isKeyValid(ApiKey.of("not-valid-key")), is(false));
+ }
+ }
+
+ @Nested
+ class NewGameHostKey {
+ @Test
+ void verifyCreatingNewKey() {
+ when(keyMaker.get()).thenReturn(ApiKey.of("api-key"));
+ when(keyHashingFunction.apply(ApiKey.of("api-key"))).thenReturn("hashed-key");
+ when(gameHostApiKeyDao.insertKey("hashed-key", "1.1.1.1")).thenReturn(1);
+
+ final ApiKey result =
+ gameHostingApiKeyDaoWrapper.newGameHostKey(IpAddressParser.fromString("1.1.1.1"));
+
+ assertThat(result, is(ApiKey.of("api-key")));
+ }
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/PlayerApiKeyDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/PlayerApiKeyDaoTest.java
new file mode 100644
index 0000000..b44cc1e
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/PlayerApiKeyDaoTest.java
@@ -0,0 +1,134 @@
+package org.triplea.db.dao.api.key;
+
+import static com.github.npathai.hamcrestopt.OptionalMatchers.isEmpty;
+import static com.github.npathai.hamcrestopt.OptionalMatchers.isPresentAndIs;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.core.api.dataset.ExpectedDataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.test.common.RequiresDatabase;
+
+@DataSet(
+ value =
+ "lobby_api_key/user_role.yml,"
+ + "lobby_api_key/lobby_user.yml,"
+ + "lobby_api_key/lobby_api_key.yml",
+ useSequenceFiltering = false)
+@RequiredArgsConstructor
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@RequiresDatabase
+class PlayerApiKeyDaoTest {
+
+ private static final int USER_ID = 50;
+
+ private static final PlayerApiKeyLookupRecord EXPECTED_MODERATOR_DATA =
+ PlayerApiKeyLookupRecord.builder()
+ .userId(USER_ID)
+ .username("registered-user")
+ .userRole(UserRole.MODERATOR)
+ .playerChatId("chat-id0")
+ .apiKeyId(1000)
+ .build();
+ private static final PlayerApiKeyLookupRecord EXPECTED_ANONYMOUS_DATA =
+ PlayerApiKeyLookupRecord.builder()
+ .username("some-other-name")
+ .userRole(UserRole.ANONYMOUS)
+ .playerChatId("chat-id1")
+ .apiKeyId(1001)
+ .build();
+
+ private final PlayerApiKeyDao playerApiKeyDao;
+
+ @Test
+ void keyNotFound() {
+ assertThat(playerApiKeyDao.lookupByApiKey("key-does-not-exist"), isEmpty());
+ }
+
+ @Test
+ void registeredUser() {
+ final Optional result = playerApiKeyDao.lookupByApiKey("zapi-key1");
+
+ assertThat(result, isPresentAndIs(EXPECTED_MODERATOR_DATA));
+ }
+
+ @Test
+ void anonymousUser() {
+ final Optional result = playerApiKeyDao.lookupByApiKey("zapi-key2");
+
+ assertThat(result, isPresentAndIs(EXPECTED_ANONYMOUS_DATA));
+ }
+
+ @Test
+ @DataSet(
+ value =
+ "lobby_api_key/user_role.yml,"
+ + "lobby_api_key/lobby_user.yml,"
+ + "lobby_api_key/empty_lobby_api_key.yml",
+ useSequenceFiltering = false)
+ @ExpectedDataSet(
+ value = "lobby_api_key/lobby_api_key_post_insert.yml",
+ orderBy = "key",
+ ignoreCols = {"id", "date_created"})
+ void storeKey() {
+ // name of the registered user and role_id do not have to strictly match what is in the
+ // lobby_user table, but we would expect it to match as we find user role id and user id by
+ // lookup from lobby_user table by username.
+ assertThat(
+ playerApiKeyDao.storeKey(
+ "registered-user-name",
+ 50,
+ 1,
+ "player-chat-id",
+ "registered-user-key",
+ "system-id",
+ "127.0.0.1"),
+ is(1));
+ }
+
+ @Test
+ @DataSet(
+ value = "lobby_api_key/user_role.yml, lobby_api_key/delete_old_keys_before.yml",
+ useSequenceFiltering = false)
+ @ExpectedDataSet(value = "lobby_api_key/delete_old_keys_after.yml", orderBy = "key")
+ void deleteOldKeys() {
+ playerApiKeyDao.deleteOldKeys();
+ }
+
+ @Test
+ void lookupByPlayerChatId() {
+ final Optional playerIdLookup =
+ playerApiKeyDao.lookupByPlayerChatId("chat-id0");
+
+ assertThat(
+ playerIdLookup,
+ isPresentAndIs(
+ PlayerIdentifiersByApiKeyLookup.builder()
+ .userName("registered-user")
+ .systemId("system-id0")
+ .ip("127.0.0.1")
+ .build()));
+ }
+
+ @Test
+ void foundCase() {
+ final Optional result = playerApiKeyDao.lookupPlayerIdByPlayerChatId("chat-id0");
+
+ assertThat(result, isPresentAndIs(50));
+ }
+
+ @Test
+ void notFoundCase() {
+ final Optional result = playerApiKeyDao.lookupPlayerIdByPlayerChatId("DNE");
+
+ assertThat(result, isEmpty());
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/PlayerApiKeyDaoWrapperTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/PlayerApiKeyDaoWrapperTest.java
new file mode 100644
index 0000000..6fe34dd
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/PlayerApiKeyDaoWrapperTest.java
@@ -0,0 +1,175 @@
+package org.triplea.db.dao.api.key;
+
+import static com.github.npathai.hamcrestopt.OptionalMatchers.isEmpty;
+import static com.github.npathai.hamcrestopt.OptionalMatchers.isPresent;
+import static com.github.npathai.hamcrestopt.OptionalMatchers.isPresentAndIs;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsSame.sameInstance;
+import static org.mockito.Mockito.when;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.triplea.db.dao.user.UserJdbiDao;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.db.dao.user.role.UserRoleDao;
+import org.triplea.db.dao.user.role.UserRoleLookup;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.domain.data.PlayerChatId;
+import org.triplea.domain.data.SystemId;
+import org.triplea.domain.data.UserName;
+
+@ExtendWith(MockitoExtension.class)
+class PlayerApiKeyDaoWrapperTest {
+
+ private static final ApiKey API_KEY = ApiKey.of("api-key");
+ private static final String HASHED_KEY = "Dead, rainy shores proud swashbuckler";
+ private static final UserName PLAYER_NAME = UserName.of("The_captain");
+ private static final PlayerChatId PLAYER_CHAT_ID = PlayerChatId.of("player-chat-id");
+ private static final SystemId SYSTEM_ID = SystemId.of("system-id");
+ private static final int ANONYMOUS_ROLE_ID = 123;
+ private static final PlayerIdentifiersByApiKeyLookup PLAYER_ID_LOOKUP =
+ PlayerIdentifiersByApiKeyLookup.builder()
+ .userName(PLAYER_NAME.getValue())
+ .systemId(SYSTEM_ID.getValue())
+ .ip("ip")
+ .build();
+
+ private static final UserRoleLookup USER_ROLE_LOOKUP =
+ UserRoleLookup.builder().userId(10).userRoleId(20).build();
+
+ private static final InetAddress IP;
+
+ static {
+ try {
+ IP = InetAddress.getLocalHost();
+ } catch (final UnknownHostException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ @Mock private PlayerApiKeyDao lobbyApiKeyDao;
+ @Mock private UserJdbiDao userJdbiDao;
+ @Mock private UserRoleDao userRoleDao;
+ @Mock private Supplier keyMaker;
+ @Mock private Function keyHashingFunction;
+
+ @InjectMocks private PlayerApiKeyDaoWrapper wrapper;
+
+ @Mock private PlayerApiKeyLookupRecord apiKeyUserData;
+
+ @Nested
+ class LookupByApiKey {
+ @SuppressWarnings("OptionalGetWithoutIsPresent")
+ @Test
+ void foundCase() {
+ givenKeyLookupResult(Optional.of(apiKeyUserData));
+
+ final Optional result = wrapper.lookupByApiKey(API_KEY);
+
+ assertThat(result, isPresent());
+ assertThat(result.get(), sameInstance(apiKeyUserData));
+ }
+
+ @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
+ private void givenKeyLookupResult(final Optional dataResult) {
+ when(keyHashingFunction.apply(API_KEY)).thenReturn(HASHED_KEY);
+ when(lobbyApiKeyDao.lookupByApiKey(HASHED_KEY)).thenReturn(dataResult);
+ }
+
+ @Test
+ void notFoundCase() {
+ givenKeyLookupResult(Optional.empty());
+
+ final Optional result = wrapper.lookupByApiKey(API_KEY);
+
+ assertThat(result, isEmpty());
+ }
+ }
+
+ @Nested
+ class NewKey {
+ @Test
+ void anonymousUserNewKey() {
+ when(keyMaker.get()).thenReturn(API_KEY);
+ when(keyHashingFunction.apply(API_KEY)).thenReturn(HASHED_KEY);
+ when(userJdbiDao.lookupUserIdAndRoleIdByUserName(PLAYER_NAME.getValue()))
+ .thenReturn(Optional.empty());
+ when(userRoleDao.lookupRoleId(UserRole.ANONYMOUS)).thenReturn(ANONYMOUS_ROLE_ID);
+ when(lobbyApiKeyDao.storeKey(
+ PLAYER_NAME.getValue(),
+ null,
+ ANONYMOUS_ROLE_ID,
+ PLAYER_CHAT_ID.getValue(),
+ HASHED_KEY,
+ SYSTEM_ID.getValue(),
+ IP.getHostAddress()))
+ .thenReturn(1);
+
+ final ApiKey result = wrapper.newKey(PLAYER_NAME, IP, SYSTEM_ID, PLAYER_CHAT_ID);
+
+ assertThat(result, is(API_KEY));
+ }
+
+ @Test
+ void registeredUserKey() {
+ when(keyMaker.get()).thenReturn(API_KEY);
+ when(keyHashingFunction.apply(API_KEY)).thenReturn(HASHED_KEY);
+ when(userJdbiDao.lookupUserIdAndRoleIdByUserName(PLAYER_NAME.getValue()))
+ .thenReturn(Optional.of(USER_ROLE_LOOKUP));
+ when(lobbyApiKeyDao.storeKey(
+ PLAYER_NAME.getValue(),
+ USER_ROLE_LOOKUP.getUserId(),
+ USER_ROLE_LOOKUP.getUserRoleId(),
+ PLAYER_CHAT_ID.getValue(),
+ HASHED_KEY,
+ SYSTEM_ID.getValue(),
+ IP.getHostAddress()))
+ .thenReturn(1);
+
+ final ApiKey result = wrapper.newKey(PLAYER_NAME, IP, SYSTEM_ID, PLAYER_CHAT_ID);
+
+ assertThat(result, is(API_KEY));
+ }
+ }
+
+ @Test
+ void lookupByPlayerChatId() {
+ when(lobbyApiKeyDao.lookupByPlayerChatId(PLAYER_CHAT_ID.getValue()))
+ .thenReturn(Optional.of(PLAYER_ID_LOOKUP));
+
+ final Optional result =
+ wrapper.lookupPlayerByChatId(PLAYER_CHAT_ID);
+
+ assertThat(result, isPresentAndIs(PLAYER_ID_LOOKUP));
+ }
+
+ @Test
+ void lookupUserByPlayerChatId() {
+ when(lobbyApiKeyDao.lookupPlayerIdByPlayerChatId(PLAYER_CHAT_ID.getValue()))
+ .thenReturn(Optional.of(123));
+
+ final Optional result = wrapper.lookupUserIdByChatId(PLAYER_CHAT_ID);
+
+ assertThat(result, isPresentAndIs(123));
+ }
+
+ @Test
+ void lookupUserByPlayerChatIdNotFoundCase() {
+ when(lobbyApiKeyDao.lookupPlayerIdByPlayerChatId(PLAYER_CHAT_ID.getValue()))
+ .thenReturn(Optional.empty());
+
+ final Optional result = wrapper.lookupUserIdByChatId(PLAYER_CHAT_ID);
+
+ assertThat(result, isEmpty());
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/PlayerApiKeyLookupRecordTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/PlayerApiKeyLookupRecordTest.java
new file mode 100644
index 0000000..b563361
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/api/key/PlayerApiKeyLookupRecordTest.java
@@ -0,0 +1,52 @@
+package org.triplea.db.dao.api.key;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.IsNull.nullValue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.List;
+import java.util.function.Supplier;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.triplea.db.dao.user.role.UserRole;
+
+class PlayerApiKeyLookupRecordTest {
+ private static final PlayerApiKeyLookupRecord API_KEY_LOOKUP_RECORD =
+ PlayerApiKeyLookupRecord.builder()
+ .userId(1)
+ .apiKeyId(1)
+ .playerChatId("chat-id")
+ .userRole("role")
+ .username("user-name")
+ .build();
+
+ @Test
+ void zeroUserIdIsMappedToNull() {
+ final PlayerApiKeyLookupRecord result =
+ API_KEY_LOOKUP_RECORD.toBuilder().userRole(UserRole.ANONYMOUS).userId(0).build();
+
+ assertThat(result.getUserId(), nullValue());
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void invalidStates(final Supplier apiKeyLookupRecord) {
+ assertThrows(AssertionError.class, apiKeyLookupRecord::get);
+ }
+
+ @SuppressWarnings("unused")
+ static List> invalidStates() {
+ return List.of(
+ // Non-Anonymous roles must have a user-id
+ () -> API_KEY_LOOKUP_RECORD.toBuilder().userId(0).userRole(UserRole.PLAYER).build(),
+ () -> API_KEY_LOOKUP_RECORD.toBuilder().userId(0).userRole(UserRole.MODERATOR).build(),
+ () -> API_KEY_LOOKUP_RECORD.toBuilder().userId(0).userRole(UserRole.ADMIN).build(),
+
+ // Anonymous role may not have a user-id
+ () -> API_KEY_LOOKUP_RECORD.toBuilder().userId(1).userRole(UserRole.ANONYMOUS).build(),
+
+ // Host role is not allowed (host API-keys are stored in a different table)
+ () -> API_KEY_LOOKUP_RECORD.toBuilder().userRole(UserRole.HOST).build());
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/chat/history/LobbyChatHistoryDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/chat/history/LobbyChatHistoryDaoTest.java
new file mode 100644
index 0000000..94414a3
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/chat/history/LobbyChatHistoryDaoTest.java
@@ -0,0 +1,31 @@
+package org.triplea.db.dao.chat.history;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.core.api.dataset.ExpectedDataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import lombok.RequiredArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.test.common.RequiresDatabase;
+
+@RequiredArgsConstructor
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@RequiresDatabase
+class LobbyChatHistoryDaoTest {
+
+ private final LobbyChatHistoryDao lobbyChatHistoryDao;
+
+ @Test
+ @DataSet(
+ value =
+ "lobby_chat_history/user_role.yml,"
+ + "lobby_chat_history/lobby_user.yml,"
+ + "lobby_chat_history/lobby_api_key.yml",
+ useSequenceFiltering = false)
+ @ExpectedDataSet("lobby_chat_history/lobby_chat_history_post_insert.yml")
+ void insertChatMessage() {
+ lobbyChatHistoryDao.insertMessage("username", 3000, "message");
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/lobby/games/LobbyGameDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/lobby/games/LobbyGameDaoTest.java
new file mode 100644
index 0000000..1a2fcdf
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/lobby/games/LobbyGameDaoTest.java
@@ -0,0 +1,48 @@
+package org.triplea.db.dao.lobby.games;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.core.api.dataset.ExpectedDataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import lombok.RequiredArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.TestData;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.domain.data.ApiKey;
+import org.triplea.http.client.lobby.game.lobby.watcher.ChatMessageUpload;
+import org.triplea.http.client.lobby.game.lobby.watcher.LobbyGameListing;
+import org.triplea.test.common.RequiresDatabase;
+
+@RequiredArgsConstructor
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@RequiresDatabase
+class LobbyGameDaoTest {
+ private final LobbyGameDao lobbyGameDao;
+
+ @Test
+ @DataSet(value = "lobby_games/game_hosting_api_key.yml", useSequenceFiltering = false)
+ @ExpectedDataSet("lobby_games/lobby_game_post_insert.yml")
+ void insertLobbyGame() {
+ lobbyGameDao.insertLobbyGame(
+ ApiKey.of("HOST"),
+ LobbyGameListing.builder() //
+ .gameId("game-id")
+ .lobbyGame(TestData.LOBBY_GAME)
+ .build());
+ }
+
+ @Test
+ @DataSet(
+ value = "lobby_games/game_hosting_api_key.yml, lobby_games/lobby_game.yml",
+ useSequenceFiltering = false)
+ @ExpectedDataSet("lobby_games/game_chat_history_post_insert.yml")
+ void insertChatMessage() {
+ lobbyGameDao.recordChat(
+ ChatMessageUpload.builder()
+ .gameId("gameid-100")
+ .fromPlayer("gameplayer")
+ .chatMessage("example message")
+ .build());
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/BadWordsDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/BadWordsDaoTest.java
new file mode 100644
index 0000000..2ccc6ca
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/BadWordsDaoTest.java
@@ -0,0 +1,69 @@
+package org.triplea.db.dao.moderator;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.core.api.dataset.ExpectedDataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.test.common.RequiresDatabase;
+
+@DataSet(value = "bad_words/bad_word.yml", useSequenceFiltering = false)
+@RequiredArgsConstructor
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@RequiresDatabase
+class BadWordsDaoTest {
+ private static final List expectedBadWords = List.of("aaa", "one", "two", "zzz");
+
+ private final BadWordsDao badWordsDao;
+
+ @Test
+ void getBadWords() {
+ assertThat(badWordsDao.getBadWords(), is(expectedBadWords));
+ }
+
+ @Test
+ @ExpectedDataSet(
+ value = "bad_words/bad_word_post_insert.yml",
+ orderBy = {"word"})
+ void addBadWord() {
+ assertThat(badWordsDao.addBadWord("new-bad-word"), is(1));
+ }
+
+ @Test
+ @ExpectedDataSet("bad_words/bad_word_post_remove.yml")
+ void removeBadWord() {
+ assertThat(badWordsDao.removeBadWord("not-present"), is(0));
+
+ expectedBadWords.forEach(badWord -> assertThat(badWordsDao.removeBadWord(badWord), is(1)));
+ }
+
+ @SuppressWarnings("unused")
+ private static List badWordContains() {
+ final List badWords = new ArrayList<>(List.of("zzZz", "_two_"));
+ badWords.addAll(expectedBadWords);
+ return badWords;
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void badWordContains(final String badWord) {
+ assertThat(badWordsDao.containsBadWord(badWord), is(true));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"zz", "", "some string not containing any bad words"})
+ void notBadWordContains(final String notInBadWords) {
+ assertThat(badWordsDao.containsBadWord(notInBadWords), is(false));
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/ModeratorAuditHistoryDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/ModeratorAuditHistoryDaoTest.java
new file mode 100644
index 0000000..09e30f0
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/ModeratorAuditHistoryDaoTest.java
@@ -0,0 +1,105 @@
+package org.triplea.db.dao.moderator;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.collection.IsEmptyCollection.empty;
+import static org.hamcrest.core.Is.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.triplea.test.common.IsInstant.isInstant;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.core.api.dataset.ExpectedDataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.jdbi.v3.core.statement.UnableToExecuteStatementException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.test.common.RequiresDatabase;
+
+@RequiredArgsConstructor
+@DataSet(
+ value =
+ "moderator_audit/user_role.yml,"
+ + "moderator_audit/lobby_user.yml,"
+ + "moderator_audit/moderator_action_history.yml",
+ useSequenceFiltering = false)
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@RequiresDatabase
+class ModeratorAuditHistoryDaoTest {
+
+ private static final int MODERATOR_ID = 900000;
+ private static final int MODERATOR_ID_DOES_NOT_EXIST = 1111;
+
+ private final ModeratorAuditHistoryDao moderatorAuditHistoryDao;
+
+ @Test
+ void addAuditRecordThrowsIfModeratorNameNotFound() {
+ assertThrows(
+ UnableToExecuteStatementException.class,
+ () ->
+ moderatorAuditHistoryDao.addAuditRecord(
+ ModeratorAuditHistoryDao.AuditArgs.builder()
+ .moderatorUserId(MODERATOR_ID_DOES_NOT_EXIST)
+ .actionTarget("any-value")
+ .actionName(ModeratorAuditHistoryDao.AuditAction.BAN_USERNAME)
+ .build()));
+ }
+
+ @Test
+ @DataSet(
+ value =
+ "moderator_audit/user_role.yml,"
+ + "moderator_audit/lobby_user.yml,"
+ + "moderator_audit/empty_moderator_action_history.yml",
+ useSequenceFiltering = false)
+ @ExpectedDataSet("moderator_audit/moderator_action_history_post_insert.yml")
+ void addAuditRecord() {
+ moderatorAuditHistoryDao.addAuditRecord(
+ ModeratorAuditHistoryDao.AuditArgs.builder()
+ .moderatorUserId(MODERATOR_ID)
+ .actionName(ModeratorAuditHistoryDao.AuditAction.BAN_USERNAME)
+ .actionTarget("ACTION_TARGET")
+ .build());
+ }
+
+ @Test
+ void selectHistory() {
+ List results = moderatorAuditHistoryDao.lookupHistoryItems(0, 3);
+
+ assertThat(results, hasSize(3));
+
+ assertThat(results.get(0).getUsername(), is("moderator2"));
+ assertThat(results.get(0).getDateCreated(), isInstant(2016, 1, 5, 23, 59, 20));
+ assertThat(results.get(0).getActionName(), is("BAN_USERNAME"));
+ assertThat(results.get(0).getActionTarget(), is("ACTION_TARGET5"));
+
+ assertThat(results.get(1).getUsername(), is("moderator1"));
+ assertThat(results.get(1).getDateCreated(), isInstant(2016, 1, 4, 23, 59, 20));
+ assertThat(results.get(1).getActionName(), is("BOOT_PLAYER"));
+ assertThat(results.get(1).getActionTarget(), is("ACTION_TARGET4"));
+
+ assertThat(results.get(2).getUsername(), is("moderator2"));
+ assertThat(results.get(2).getDateCreated(), isInstant(2016, 1, 3, 23, 59, 20));
+ assertThat(results.get(2).getActionName(), is("BAN_USERNAME"));
+ assertThat(results.get(2).getActionTarget(), is("ACTION_TARGET3"));
+
+ // there are only 5 records total
+ results = moderatorAuditHistoryDao.lookupHistoryItems(3, 3);
+ assertThat(results, hasSize(2));
+ assertThat(results.get(0).getUsername(), is("moderator2"));
+ assertThat(results.get(0).getDateCreated(), isInstant(2016, 1, 2, 23, 59, 20));
+ assertThat(results.get(0).getActionName(), is("MUTE_USERNAME"));
+ assertThat(results.get(0).getActionTarget(), is("ACTION_TARGET2"));
+
+ assertThat(results.get(1).getUsername(), is("moderator1"));
+ assertThat(results.get(1).getDateCreated(), isInstant(2016, 1, 1, 23, 59, 20));
+ assertThat(results.get(1).getActionName(), is("BAN_USERNAME"));
+ assertThat(results.get(1).getActionTarget(), is("ACTION_TARGET1"));
+
+ results = moderatorAuditHistoryDao.lookupHistoryItems(5, 3);
+ assertThat(results, is(empty()));
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/ModeratorsDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/ModeratorsDaoTest.java
new file mode 100644
index 0000000..d85bef0
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/ModeratorsDaoTest.java
@@ -0,0 +1,59 @@
+package org.triplea.db.dao.moderator;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsNull.nullValue;
+import static org.triplea.test.common.IsInstant.isInstant;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.core.api.dataset.ExpectedDataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.test.common.RequiresDatabase;
+
+@DataSet(
+ value = "moderators/user_role.yml, moderators/lobby_user.yml, moderators/access_log.yml",
+ useSequenceFiltering = false)
+@RequiredArgsConstructor
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@RequiresDatabase
+class ModeratorsDaoTest {
+
+ private static final int NOT_MODERATOR_ID = 100000;
+ private static final int MODERATOR_ID = 900000;
+ private static final int SUPER_MODERATOR_ID = 900001;
+
+ private final ModeratorsDao moderatorsDao;
+
+ @Test
+ void getModerators() {
+ final List moderators = moderatorsDao.getModerators();
+
+ assertThat(
+ "User dataset contains three players: an admin, moderator, and a player. We "
+ + "expect the two non-player users to be returned.",
+ moderators,
+ hasSize(2));
+
+ assertThat(moderators.get(0).getUsername(), is("moderator"));
+ assertThat(moderators.get(0).getLastLogin(), isInstant(2001, 1, 1, 23, 59, 20));
+
+ assertThat(moderators.get(1).getUsername(), is("Super! moderator"));
+ assertThat(moderators.get(1).getLastLogin(), nullValue());
+ }
+
+ @Test
+ @ExpectedDataSet("moderators/lobby_user_post_update_roles.yml")
+ void updateRoles() {
+ assertThat(moderatorsDao.setRole(NOT_MODERATOR_ID, UserRole.MODERATOR), is(1));
+ assertThat(moderatorsDao.setRole(MODERATOR_ID, UserRole.ADMIN), is(1));
+ assertThat(moderatorsDao.setRole(SUPER_MODERATOR_ID, UserRole.PLAYER), is(1));
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/chat/history/ChatHistoryRecordTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/chat/history/ChatHistoryRecordTest.java
new file mode 100644
index 0000000..d2b9279
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/chat/history/ChatHistoryRecordTest.java
@@ -0,0 +1,23 @@
+package org.triplea.db.dao.moderator.chat.history;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+
+class ChatHistoryRecordTest {
+
+ @Test
+ void toChatHistoryMessage() {
+ final var chatHistoryRecord =
+ ChatHistoryRecord.builder().date(Instant.now()).message("message").username("user").build();
+
+ final var chatHistoryMessage = chatHistoryRecord.toChatHistoryMessage();
+
+ assertThat(
+ chatHistoryMessage.getEpochMilliDate(), is(chatHistoryRecord.getDate().toEpochMilli()));
+ assertThat(chatHistoryMessage.getUsername(), is(chatHistoryRecord.getUsername()));
+ assertThat(chatHistoryMessage.getMessage(), is(chatHistoryRecord.getMessage()));
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/chat/history/GameChatHistoryDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/chat/history/GameChatHistoryDaoTest.java
new file mode 100644
index 0000000..1855849
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/chat/history/GameChatHistoryDaoTest.java
@@ -0,0 +1,101 @@
+package org.triplea.db.dao.moderator.chat.history;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsEmptyCollection.empty;
+import static org.hamcrest.core.Is.is;
+import static org.triplea.test.common.IsInstant.isInstant;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.hamcrest.collection.IsCollectionWithSize;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.test.common.RequiresDatabase;
+
+@DataSet(
+ value =
+ "game_chat_history/game_hosting_api_key.yml,"
+ + "game_chat_history/lobby_game.yml,"
+ + "game_chat_history/game_chat_history.yml",
+ useSequenceFiltering = false)
+@RequiredArgsConstructor
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@RequiresDatabase
+class GameChatHistoryDaoTest {
+ private static final String SIR_HOSTS_A_LOT = "sir_hosts_a_lot";
+ private static final String SIR_HOSTS_A_LITTLE = "sir_hosts_a_little";
+ private static final String PLAYER1 = "player1";
+
+ private final GameChatHistoryDao gameChatHistoryDao;
+
+ @Test
+ void gameDoesNotExist() {
+ assertThat(gameChatHistoryDao.getChatHistory("dne"), is(empty()));
+ }
+
+ @Test
+ @DisplayName("Chat history for a game with no chat messages should be empty")
+ void emptyChatHistory() {
+ assertThat(gameChatHistoryDao.getChatHistory("game-empty-chat"), is(empty()));
+ }
+
+ @Test
+ @DisplayName("Chat history does not select all chat messages, only recent past")
+ void oldChatMessagesAreFiltered() {
+ assertThat(gameChatHistoryDao.getChatHistory("game-far-past"), is(empty()));
+ }
+
+ @Test
+ void viewChatHistoryForSirHostALotsGame() {
+ final List chats = gameChatHistoryDao.getChatHistory("game-hosts-a-lot");
+
+ assertThat(chats, IsCollectionWithSize.hasSize(4));
+
+ int i = 0;
+ assertThat(chats.get(i).getUsername(), is(PLAYER1));
+ assertThat(chats.get(i).getDate(), isInstant(2100, 1, 1, 23, 0, 20));
+ assertThat(chats.get(i).getMessage(), is("Hello good sir"));
+
+ i++;
+ assertThat(chats.get(i).getUsername(), is(SIR_HOSTS_A_LOT));
+ assertThat(chats.get(i).getDate(), isInstant(2100, 1, 1, 23, 1, 20));
+ assertThat(chats.get(i).getMessage(), is("Why hello to you"));
+
+ i++;
+ assertThat(chats.get(i).getUsername(), is(PLAYER1));
+ assertThat(chats.get(i).getDate(), isInstant(2100, 1, 1, 23, 2, 20));
+ assertThat(chats.get(i).getMessage(), is("What a fine day it is my good sir"));
+
+ i++;
+ assertThat(chats.get(i).getUsername(), is(SIR_HOSTS_A_LOT));
+ assertThat(chats.get(i).getDate(), isInstant(2100, 1, 1, 23, 3, 20));
+ assertThat(chats.get(i).getMessage(), is("What a fine day it is indeed!"));
+ }
+
+ @Test
+ void viewChatHistoryForSirHostALittlesGame() {
+ final List chats = gameChatHistoryDao.getChatHistory("game-hosts-a-little");
+
+ assertThat(chats, IsCollectionWithSize.hasSize(3));
+
+ int i = 0;
+ assertThat(chats.get(i).getUsername(), is(SIR_HOSTS_A_LITTLE));
+ assertThat(chats.get(i).getDate(), isInstant(2100, 1, 1, 23, 1, 20));
+ assertThat(chats.get(i).getMessage(), is("hello!"));
+
+ i++;
+ assertThat(chats.get(i).getUsername(), is(SIR_HOSTS_A_LOT));
+ assertThat(chats.get(i).getDate(), isInstant(2100, 1, 1, 23, 2, 20));
+ assertThat(chats.get(i).getMessage(), is("join my game?"));
+
+ i++;
+ assertThat(chats.get(i).getUsername(), is(SIR_HOSTS_A_LITTLE));
+ assertThat(chats.get(i).getDate(), isInstant(2100, 1, 1, 23, 3, 20));
+ assertThat(chats.get(i).getMessage(), is("Maybe another day"));
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/player/info/PlayerAliasRecordTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/player/info/PlayerAliasRecordTest.java
new file mode 100644
index 0000000..f58da87
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/player/info/PlayerAliasRecordTest.java
@@ -0,0 +1,33 @@
+package org.triplea.db.dao.moderator.player.info;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.moderator.PlayerSummary.Alias;
+
+class PlayerAliasRecordTest {
+ private static final Instant DATE =
+ LocalDateTime.of(1990, 1, 1, 23, 59, 59) //
+ .toInstant(ZoneOffset.UTC);
+
+ @Test
+ void toAlias() {
+ final Alias alias =
+ PlayerAliasRecord.builder()
+ .accessTime(DATE)
+ .ip("1.1.1.1")
+ .systemId("system-id")
+ .username("name")
+ .build()
+ .toAlias();
+
+ assertThat(alias.getEpochMilliDate(), is(DATE.toEpochMilli()));
+ assertThat(alias.getIp(), is("1.1.1.1"));
+ assertThat(alias.getName(), is("name"));
+ assertThat(alias.getSystemId(), is("system-id"));
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/player/info/PlayerBanRecordTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/player/info/PlayerBanRecordTest.java
new file mode 100644
index 0000000..743560d
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/player/info/PlayerBanRecordTest.java
@@ -0,0 +1,37 @@
+package org.triplea.db.dao.moderator.player.info;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import org.junit.jupiter.api.Test;
+import org.triplea.http.client.lobby.moderator.PlayerSummary.BanInformation;
+
+class PlayerBanRecordTest {
+ private static final Instant START_DATE =
+ LocalDateTime.of(1990, 1, 1, 23, 59, 59) //
+ .toInstant(ZoneOffset.UTC);
+ private static final Instant END_DATE =
+ LocalDateTime.of(2010, 1, 1, 23, 59, 59) //
+ .toInstant(ZoneOffset.UTC);
+
+ @Test
+ void testToBanInformation() {
+ final BanInformation banInformation =
+ PlayerBanRecord.builder()
+ .banStart(START_DATE)
+ .banEnd(END_DATE)
+ .ip("1.1.1.1")
+ .systemId("system-id")
+ .username("name")
+ .build()
+ .toBanInformation();
+
+ assertThat(banInformation.getEpochMilliStartDate(), is(START_DATE.toEpochMilli()));
+ assertThat(banInformation.getEpochMillEndDate(), is(END_DATE.toEpochMilli()));
+ assertThat(banInformation.getIp(), is("1.1.1.1"));
+ assertThat(banInformation.getName(), is("name"));
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/player/info/PlayerInfoForModeratorDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/player/info/PlayerInfoForModeratorDaoTest.java
new file mode 100644
index 0000000..8e45e1f
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/moderator/player/info/PlayerInfoForModeratorDaoTest.java
@@ -0,0 +1,159 @@
+package org.triplea.db.dao.moderator.player.info;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.collection.IsEmptyCollection.empty;
+import static org.hamcrest.core.Is.is;
+import static org.triplea.test.common.IsInstant.isInstant;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.test.common.RequiresDatabase;
+
+@RequiredArgsConstructor
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@RequiresDatabase
+class PlayerInfoForModeratorDaoTest {
+
+ private final PlayerInfoForModeratorDao playerInfoForModeratorDao;
+
+ @Nested
+ @DataSet(value = "moderator_player_lookup/access_log.yml", useSequenceFiltering = false)
+ class LookupPlayerAliases {
+ @Test
+ void lookupEmptyCase() {
+ final List results =
+ playerInfoForModeratorDao.lookupPlayerAliasRecords("system-id-dne", "9.9.9.9");
+
+ assertThat(results, is(empty()));
+ }
+
+ @Test
+ void lookupByBothSystemIdAndIp() {
+ final List results =
+ playerInfoForModeratorDao.lookupPlayerAliasRecords("system-id", "1.1.1.1");
+
+ assertThat(
+ "There are 6 records in the dataset, "
+ + "we expect 4 to match, and 2 of them to be de-duped by name"
+ + "with only the most recent de-duped record returned",
+ results,
+ hasSize(3));
+
+ assertThat(results.get(0).getUsername(), is("name3"));
+ assertThat(results.get(0).getIp(), is("2.2.2.2"));
+ assertThat(results.get(0).getSystemId(), is("system-id"));
+ assertThat(results.get(0).getDate(), isInstant(2154, 1, 1, 23, 59, 20));
+
+ assertThat(results.get(1).getUsername(), is("name2"));
+ assertThat(results.get(1).getIp(), is("1.1.1.1"));
+ assertThat(results.get(1).getSystemId(), is("system-id2"));
+ assertThat(results.get(1).getDate(), isInstant(2152, 1, 1, 23, 59, 20));
+
+ assertThat(results.get(2).getUsername(), is("name1"));
+ assertThat(results.get(2).getIp(), is("1.1.1.1"));
+ assertThat(results.get(2).getSystemId(), is("system-id"));
+ assertThat(results.get(2).getDate(), isInstant(2151, 1, 1, 23, 59, 20));
+ }
+
+ @Test
+ void lookupWithOnlyIpMatching() {
+ final List results =
+ playerInfoForModeratorDao.lookupPlayerAliasRecords("system-id-dne", "2.2.2.2");
+
+ assertThat("We expect to only match the one record with IP 2.2.2.2", results, hasSize(1));
+
+ assertThat(results.get(0).getUsername(), is("name3"));
+ assertThat(results.get(0).getIp(), is("2.2.2.2"));
+ assertThat(results.get(0).getSystemId(), is("system-id"));
+ assertThat(results.get(0).getDate(), isInstant(2154, 1, 1, 23, 59, 20));
+ }
+
+ @Test
+ void lookupWithOnlySystemIdMatching() {
+ final List results =
+ playerInfoForModeratorDao.lookupPlayerAliasRecords("system-id2", "9.9.9.9");
+
+ assertThat(
+ "We expect to only match the 1 record with system 'system-id2'", results, hasSize(1));
+
+ assertThat(results.get(0).getUsername(), is("name2"));
+ assertThat(results.get(0).getIp(), is("1.1.1.1"));
+ assertThat(results.get(0).getSystemId(), is("system-id2"));
+ assertThat(results.get(0).getDate(), isInstant(2152, 1, 1, 23, 59, 20));
+ }
+ }
+
+ @Nested
+ @DataSet(value = "moderator_player_lookup/banned_user.yml", useSequenceFiltering = false)
+ class LookupPlayerBans {
+ @Test
+ void emptyLookupCase() {
+ final List results =
+ playerInfoForModeratorDao.lookupPlayerBanRecords("system-id-dne", "9.9.9.9");
+
+ assertThat(results, is(empty()));
+ }
+
+ @Test
+ void lookupByBothSystemIdAndIp() {
+ final List results =
+ playerInfoForModeratorDao.lookupPlayerBanRecords("system-id", "1.1.1.1");
+
+ assertThat(results, hasSize(3));
+
+ assertThat(results.get(0).getUsername(), is("name1"));
+ assertThat(results.get(0).getIp(), is("1.1.1.1"));
+ assertThat(results.get(0).getSystemId(), is("system-id"));
+ assertThat(results.get(0).getBanStart(), isInstant(2010, 1, 1, 23, 59, 20));
+ assertThat(results.get(0).getBanEnd(), isInstant(2100, 1, 1, 23, 59, 20));
+
+ assertThat(results.get(1).getUsername(), is("name2"));
+ assertThat(results.get(1).getIp(), is("1.1.1.1"));
+ assertThat(results.get(1).getSystemId(), is("system-id2"));
+ assertThat(results.get(1).getBanStart(), isInstant(2000, 1, 1, 23, 59, 20));
+ assertThat(results.get(1).getBanEnd(), isInstant(2050, 1, 1, 23, 59, 20));
+
+ assertThat(results.get(2).getUsername(), is("name2"));
+ assertThat(results.get(2).getIp(), is("2.2.2.2"));
+ assertThat(results.get(2).getSystemId(), is("system-id"));
+ assertThat(results.get(2).getBanStart(), isInstant(2000, 1, 1, 23, 59, 20));
+ assertThat(results.get(2).getBanEnd(), isInstant(2010, 1, 1, 23, 59, 20));
+ }
+
+ @Test
+ void lookupWithMatchByIpOnly() {
+ final List results =
+ playerInfoForModeratorDao.lookupPlayerBanRecords("system-id-dne", "2.2.2.2");
+
+ assertThat(results, hasSize(1));
+
+ assertThat(results.get(0).getUsername(), is("name2"));
+ assertThat(results.get(0).getIp(), is("2.2.2.2"));
+ assertThat(results.get(0).getSystemId(), is("system-id"));
+ assertThat(results.get(0).getBanStart(), isInstant(2000, 1, 1, 23, 59, 20));
+ assertThat(results.get(0).getBanEnd(), isInstant(2010, 1, 1, 23, 59, 20));
+ }
+
+ @Test
+ void lookupWithMatchBySystemIdOnly() {
+ final List results =
+ playerInfoForModeratorDao.lookupPlayerBanRecords("system-id2", "9.9.9.9");
+
+ assertThat(results, hasSize(1));
+
+ assertThat(results.get(0).getUsername(), is("name2"));
+ assertThat(results.get(0).getIp(), is("1.1.1.1"));
+ assertThat(results.get(0).getSystemId(), is("system-id2"));
+ assertThat(results.get(0).getBanStart(), isInstant(2000, 1, 1, 23, 59, 20));
+ assertThat(results.get(0).getBanEnd(), isInstant(2050, 1, 1, 23, 59, 20));
+ }
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/temp/password/TempPasswordDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/temp/password/TempPasswordDaoTest.java
new file mode 100644
index 0000000..89b3841
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/temp/password/TempPasswordDaoTest.java
@@ -0,0 +1,91 @@
+package org.triplea.db.dao.temp.password;
+
+import static com.github.npathai.hamcrestopt.OptionalMatchers.isEmpty;
+import static com.github.npathai.hamcrestopt.OptionalMatchers.isPresentAndIs;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import lombok.RequiredArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.test.common.RequiresDatabase;
+
+@DataSet(
+ value =
+ "temp_password/user_role.yml,"
+ + "temp_password/lobby_user.yml,"
+ + "temp_password/temp_password_request.yml",
+ useSequenceFiltering = false)
+@RequiredArgsConstructor
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@RequiresDatabase
+class TempPasswordDaoTest {
+
+ private static final String USERNAME = "username";
+ private static final String EMAIL = "email@";
+ private static final int USER_ID = 500000;
+
+ private static final String PASSWORD = "temp";
+ private static final String NEW_PASSWORD = "new-temp";
+
+ private final TempPasswordDao tempPasswordDao;
+
+ @Test
+ void fetchTempPassword() {
+ assertThat(tempPasswordDao.fetchTempPassword(USERNAME), isEmpty());
+ assertThat(tempPasswordDao.fetchTempPassword("DNE"), isEmpty());
+ tempPasswordDao.insertTempPassword(USERNAME, EMAIL, PASSWORD);
+ assertThat(tempPasswordDao.fetchTempPassword(USERNAME), isPresentAndIs(PASSWORD));
+ }
+
+ @Test
+ void lookupUserIdByUsernameAndEmail() {
+ assertThat(
+ tempPasswordDao.lookupUserIdByUsernameAndEmail(USERNAME, EMAIL), isPresentAndIs(USER_ID));
+ assertThat(tempPasswordDao.lookupUserIdByUsernameAndEmail("DNE", "DNE"), isEmpty());
+ }
+
+ @Test
+ void lookupUserIdByUsername() {
+ assertThat(tempPasswordDao.lookupUserIdByUsername(USERNAME), isPresentAndIs(USER_ID));
+ assertThat(tempPasswordDao.lookupUserIdByUsername("DNE"), isEmpty());
+ }
+
+ @Test
+ void insertPasswordReturnsFalseIfUserNotFound() {
+ assertThat(tempPasswordDao.insertTempPassword("DNE", "DNE", PASSWORD), is(false));
+ }
+
+ @Test
+ void insertTempPassword() {
+ assertThat(tempPasswordDao.insertTempPassword(USERNAME, EMAIL, NEW_PASSWORD), is(true));
+ assertThat(tempPasswordDao.fetchTempPassword(USERNAME), isPresentAndIs(NEW_PASSWORD));
+ }
+
+ @Test
+ void invalidateTempPasswordsForMissingNameDoesNothing() {
+ // verify that before we do any invalidation that indeed our known user has a temp password
+ assertThat(
+ tempPasswordDao.fetchTempPassword("user-with-temp-password"), isPresentAndIs(PASSWORD));
+
+ // invalidate password for some other user
+ tempPasswordDao.invalidateTempPasswords("DNE");
+
+ // expect the temp password for the known user to still exist
+ assertThat(
+ tempPasswordDao.fetchTempPassword("user-with-temp-password"), isPresentAndIs(PASSWORD));
+ }
+
+ @Test
+ void invalidatePassword() {
+ tempPasswordDao.invalidateTempPasswords("user-with-temp-password");
+ assertThat(
+ "With password invalidated, fetching temp password should return empty",
+ tempPasswordDao.fetchTempPassword("user-with-temp-password"),
+ isEmpty());
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/temp/password/TempPasswordHistoryDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/temp/password/TempPasswordHistoryDaoTest.java
new file mode 100644
index 0000000..c4f52c5
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/temp/password/TempPasswordHistoryDaoTest.java
@@ -0,0 +1,44 @@
+package org.triplea.db.dao.temp.password;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import lombok.RequiredArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.test.common.RequiresDatabase;
+
+@DataSet(
+ cleanBefore = true,
+ value = "temp_password_history/sample.yml",
+ useSequenceFiltering = false)
+@RequiredArgsConstructor
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@RequiresDatabase
+class TempPasswordHistoryDaoTest {
+
+ private static final String USERNAME = "username";
+
+ private final TempPasswordHistoryDao tempPasswordHistoryDao;
+
+ @Test
+ void verifyCountAndInsert() {
+
+ final String localhost = "127.0.0.1";
+ assertThat(tempPasswordHistoryDao.countRequestsFromAddress(localhost), is(0));
+
+ tempPasswordHistoryDao.recordTempPasswordRequest(localhost, USERNAME);
+ assertThat(tempPasswordHistoryDao.countRequestsFromAddress(localhost), is(1));
+
+ tempPasswordHistoryDao.recordTempPasswordRequest(localhost, USERNAME);
+ assertThat(tempPasswordHistoryDao.countRequestsFromAddress(localhost), is(2));
+
+ tempPasswordHistoryDao.recordTempPasswordRequest(localhost, "other-user");
+ assertThat(tempPasswordHistoryDao.countRequestsFromAddress(localhost), is(3));
+
+ final String otherAddress = "127.0.0.2";
+ assertThat(tempPasswordHistoryDao.countRequestsFromAddress(otherAddress), is(0));
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/user/UserJdbiDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/user/UserJdbiDaoTest.java
new file mode 100644
index 0000000..2afeaf7
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/user/UserJdbiDaoTest.java
@@ -0,0 +1,87 @@
+package org.triplea.db.dao.user;
+
+import static com.github.npathai.hamcrestopt.OptionalMatchers.isEmpty;
+import static com.github.npathai.hamcrestopt.OptionalMatchers.isPresentAndIs;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.core.api.dataset.ExpectedDataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import lombok.RequiredArgsConstructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.db.dao.user.role.UserRole;
+import org.triplea.db.dao.user.role.UserRoleLookup;
+import org.triplea.test.common.RequiresDatabase;
+
+@DataSet(value = "user/user_role.yml,user/lobby_user.yml", useSequenceFiltering = false)
+@RequiredArgsConstructor
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@RequiresDatabase
+class UserJdbiDaoTest {
+
+ private static final int USER_ID = 900000;
+ private static final String USERNAME = "user";
+ private static final String NEW_USERNAME = "new-user";
+
+ private static final String EMAIL = "user@email.com";
+ private static final String PASSWORD =
+ "$2a$56789_123456789_123456789_123456789_123456789_123456789_";
+ private static final String NEW_PASSWORD =
+ "$2a$abcde_123456789_123456789_123456789_123456789_123456789_";
+
+ private final UserJdbiDao userDao;
+
+ @Test
+ void lookupUserIdByName() {
+ assertThat(userDao.lookupUserIdByName("DNE"), isEmpty());
+ assertThat(userDao.lookupUserIdByName(USERNAME), isPresentAndIs(900000));
+ }
+
+ @Test
+ void getPassword() {
+ assertThat(userDao.getPassword(USERNAME), isPresentAndIs(PASSWORD));
+ assertThat(userDao.getPassword("DNE"), isEmpty());
+ }
+
+ @ExpectedDataSet("user/change_password_after.yml")
+ @Test
+ void updatePassword() {
+ assertThat(userDao.updatePassword(USER_ID, NEW_PASSWORD), is(1));
+ }
+
+ @Test
+ void fetchEmail() {
+ assertThat(userDao.fetchEmail(USER_ID), is("email@"));
+ }
+
+ @ExpectedDataSet("user/post_change_email.yml")
+ @Test
+ void updateEmail() {
+ userDao.updateEmail(USER_ID, "new-email@");
+ }
+
+ @DataSet(value = "user/user_role.yml,user/empty_lobby_user.yml", useSequenceFiltering = false)
+ @ExpectedDataSet(value = "user/create_user_after.yml", orderBy = "id", ignoreCols = "id")
+ @Test
+ void createUser() {
+ userDao.createUser(NEW_USERNAME, EMAIL, PASSWORD);
+ }
+
+ @Test
+ void lookupUserRoleIdByName() {
+ assertThat(userDao.lookupUserIdAndRoleIdByUserName("does-not-exist"), isEmpty());
+ assertThat(
+ userDao.lookupUserIdAndRoleIdByUserName(USERNAME),
+ isPresentAndIs(UserRoleLookup.builder().userId(USER_ID).userRoleId(1).build()));
+ }
+
+ @Test
+ void lookupUserRoleByUserName() {
+ assertThat(userDao.lookupUserRoleByUserName("does-not-exist"), isEmpty());
+ assertThat(userDao.lookupUserRoleByUserName(USERNAME), isPresentAndIs(UserRole.PLAYER));
+ }
+}
diff --git a/server/lobby-module/src/test/java/org/triplea/db/dao/user/ban/UserBanDaoTest.java b/server/lobby-module/src/test/java/org/triplea/db/dao/user/ban/UserBanDaoTest.java
new file mode 100644
index 0000000..ecf2d16
--- /dev/null
+++ b/server/lobby-module/src/test/java/org/triplea/db/dao/user/ban/UserBanDaoTest.java
@@ -0,0 +1,139 @@
+package org.triplea.db.dao.user.ban;
+
+import static com.github.npathai.hamcrestopt.OptionalMatchers.isEmpty;
+import static com.github.npathai.hamcrestopt.OptionalMatchers.isPresentAndIs;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.core.Is.is;
+import static org.triplea.test.common.IsInstant.isInstant;
+
+import com.github.database.rider.core.api.dataset.DataSet;
+import com.github.database.rider.core.api.dataset.ExpectedDataSet;
+import com.github.database.rider.junit5.DBUnitExtension;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.triplea.db.LobbyModuleDatabaseTestSupport;
+import org.triplea.test.common.RequiresDatabase;
+
+@RequiredArgsConstructor
+@ExtendWith(LobbyModuleDatabaseTestSupport.class)
+@ExtendWith(DBUnitExtension.class)
+@RequiresDatabase
+class UserBanDaoTest {
+ private final UserBanDao userBanDao;
+
+ @Nested
+ @DataSet(value = "user_ban/banned_by_ip.yml", useSequenceFiltering = false)
+ class IsBannedByIp {
+ @Test
+ void isBannedByIpPositiveCase() {
+ assertThat(userBanDao.isBannedByIp("127.0.0.1"), is(true));
+ }
+
+ @Test
+ void notBannedWhenIpNotPresent() {
+ assertThat(userBanDao.isBannedByIp("1.1.1.1"), is(false));
+ }
+
+ @Test
+ void notBannedWhenBanIsExpired() {
+ assertThat(userBanDao.isBannedByIp("127.0.0.2"), is(false));
+ }
+ }
+
+ @Nested
+ @DataSet(value = "user_ban/lookup_bans.yml", useSequenceFiltering = false)
+ class BanLookups {
+ @Test
+ @DisplayName("Verify retrieval of all current bans")
+ void lookupBans() {
+ final List result = userBanDao.lookupBans();
+
+ assertThat(result, hasSize(2));
+ assertThat(result.get(0).getBanExpiry(), isInstant(2100, 1, 1, 23, 59, 59));
+ assertThat(result.get(0).getDateCreated(), isInstant(2020, 1, 1, 23, 59, 59));
+ assertThat(result.get(0).getIp(), is("127.0.0.2"));
+ assertThat(result.get(0).getPublicBanId(), is("public-id2"));
+ assertThat(result.get(0).getSystemId(), is("system-id2"));
+ assertThat(result.get(0).getUsername(), is("username2"));
+
+ assertThat(result.get(1).getBanExpiry(), isInstant(2200, 1, 1, 23, 59, 59));
+ assertThat(result.get(1).getDateCreated(), isInstant(2010, 1, 1, 23, 59, 59));
+ assertThat(result.get(1).getIp(), is("127.0.0.1"));
+ assertThat(result.get(1).getPublicBanId(), is("public-id1"));
+ assertThat(result.get(1).getSystemId(), is("system-id1"));
+ assertThat(result.get(1).getUsername(), is("username1"));
+ }
+
+ @Test
+ @DisplayName("Verify ban lookup case with no results found")
+ void lookupBanEmptyCase() {
+ final Optional result = userBanDao.lookupBan("99.99.99.99", "system-id-DNE");
+
+ assertThat(result, isEmpty());
+ }
+
+ @Test
+ @DisplayName("Verify ban lookup by IP address")
+ void lookupBanRecordByIp() {
+ final Optional