diff --git a/.gitignore b/.gitignore
index e3dd0d877..2e9ed933d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,3 +51,4 @@ gradle-app.setting
# temporary server files
tmp
+static/
diff --git a/.idea/codeStyleSettings.xml b/.idea/codeStyleSettings.xml
index ec81b4b9a..b018c82fa 100644
--- a/.idea/codeStyleSettings.xml
+++ b/.idea/codeStyleSettings.xml
@@ -12,6 +12,7 @@
+
diff --git a/.travis.yml b/.travis.yml
index d0285daef..999563b52 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -13,9 +13,6 @@ before_install:
- curl -L https://github.com/docker/compose/releases/download/1.10.0/docker-compose-`uname -s`-`uname -m` > /tmp/docker-compose
- chmod +x /tmp/docker-compose
- sudo mv /tmp/docker-compose /usr/local/bin
- - curl -sL https://github.com/jpm4j/jpm4j.installers/raw/master/dist/biz.aQute.jpm.run.jar >jpm4j.jar
- - java -jar jpm4j.jar -u init
- - ~/jpm/bin/jpm install com.codacy:codacy-coverage-reporter:assembly
install:
- git clone https://github.com/FAForever/faf-stack.git
@@ -30,7 +27,7 @@ after_success:
./gradlew pushDockerImage;
fi
- if [ "${TRAVIS_BRANCH}" == "develop" ]; then
- ~/jpm/bin/codacy-coverage-reporter -l Java -r build/reports/jacoco/test/jacocoTestReport.xml;
+ ./gradlew jacocoTestReport sendCoverageToCodacy;
fi
before_cache:
diff --git a/build.gradle b/build.gradle
index aa3c96345..52ce8573c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -17,7 +17,7 @@ apply plugin: 'org.springframework.boot'
apply plugin: 'propdeps'
group = 'micheljung'
-version = '0.3.6'
+version = '0.4.0'
sourceCompatibility = 1.8
targetCompatibility = 1.8
@@ -26,6 +26,7 @@ repositories {
mavenCentral()
maven { url "http://repo.jenkins-ci.org/public/" }
maven { url "https://jitpack.io" }
+ maven { url "http://dl.bintray.com/typesafe/maven-releases" }
}
compileJava.dependsOn(processResources)
@@ -57,6 +58,18 @@ jacocoTestReport {
}
}
+// CODACY
+
+configurations {
+ codacy
+}
+
+task sendCoverageToCodacy(type: JavaExec, dependsOn: jacocoTestReport) {
+ main = "com.codacy.CodacyCoverageReporter"
+ classpath = configurations.codacy
+ args = ["-l", "Java", "-r", "${buildDir}/reports/jacoco/test/jacocoTestReport.xml"]
+}
+
// DOCKER
apply plugin: 'com.bmuschko.docker-remote-api'
@@ -180,4 +193,6 @@ dependencies {
testCompile("org.springframework.restdocs:spring-restdocs-mockmvc")
testCompile("org.springframework.security:spring-security-test")
testCompile("com.h2database:h2:${h2Version}")
+
+ codacy("com.github.codacy:codacy-coverage-reporter:-SNAPSHOT")
}
diff --git a/gradle.properties b/gradle.properties
index 31ce22ed8..59ed9d4a4 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -23,7 +23,7 @@ junitAddonsVersion=1.4
zohhakVersion=1.1.1
githubApiVersion=1.84
jgitVersionn=4.5.0.201609210915-r
-fafCommonsVersion=76fb583d146082f3db68c0bece38a3ee28ead8e6
+fafCommonsVersion=81da093d61b937a2433a5c14d39cf66c4b5f20f2
h2Version=1.4.193
jacksonDatatypeJsr310Version=2.8.6
mockitoVersion=2.7.0
diff --git a/readme.md b/readme.md
index abb0fdcba..0cca073e4 100644
--- a/readme.md
+++ b/readme.md
@@ -1,66 +1,39 @@
# Spring Boot based FAF-API Prototype
-#### master
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/12eecd69a3cf4f6c96ffa043a7d70198)](https://www.codacy.com/app/micheljung/faf-java-api?utm_source=github.com&utm_medium=referral&utm_content=micheljung/faf-java-api&utm_campaign=badger)
-[![Build Status](https://travis-ci.org/FAForever/downlords-faf-client.svg?branch=master)](https://travis-ci.org/FAForever/downlords-faf-client)
-
-#### develop
[![Build Status](https://travis-ci.org/FAForever/faf-java-api.svg?branch=master)](https://travis-ci.org/FAForever/faf-java-api)
[![Coverage Status](https://coveralls.io/repos/github/FAForever/faf-java-api/badge.svg?branch=develop)](https://coveralls.io/github/FAForever/faf-java-api?branch=develop)
This is a prototype of a Spring Boot based API application for Forged Alliance Forever.
-## How to run from source
+## How to run
+
+### From source
+
+In order to run the application from source code:
1. Clone the repository
-1. Import the project into IntelliJ
+1. Import the project into IntelliJ. For some reason, IntelliJ deletes launch configurations after import. Please revert such deleted files first (Version Control (Alt+F9) -> Local Changes)
1. Configure your JDK 8 if you haven't already
1. Make sure you have the _IntelliJ Lombok plugin_ installed
+1. Set up a [FAF database](https://github.com/FAForever/db).
1. Launch `FafApiApplication`
-## How to run from binary
-
- Either check out the source code and execute the run configuration `FafApiApplication`, or run it directly
- from the published Docker image like so:
-
-```
-docker run --name faf-api \
- -e DATABASE_ADDRESS=faf-db:3306 \
- -e DATABASE_USERNAME=root \
- -e DATABASE_PASSWORD=banana \
- -e DATABASE_NAME=faf_test \
- -e JWT_SECRET=banana \
- --link faf-db
- -d micheljung/faf-api:0.2.0-SNAPSHOT
-```
-
-To run in production, you probably want to create an environment file (e.g. `env.list`):
+### From binary
-```
-DATABASE_ADDRESS=stable_faf-db_1
-DATABASE_USERNAME=faf_lobby
-DATABASE_PASSWORD=password
-DATABASE_NAME=faf_lobby
-API_PROFILE=prod
-JWT_SECRET=
-```
+Given the number of required configuration values, it's easiest to run the API using `faf-stack`:
-And run with:
-```
-docker run --name faf-api \
- --env-file ./env.list \
- -d micheljung/faf-api:0.2.0-SNAPSHOT
-```
+ docker-compose up -d faf-api
## Sample routes
-* [List event definitions](http://localhost:8080/data/event_definition)
-* [List 5 maps with more than 8 players](http://localhost:8080/data/map_version?filter=(maxPlayers=gt=8)&page[size]=5)
-* [List UI mods, sorted by last updated ascending](http://localhost:8080/data/mod_version?filter=(type=='UI')&sort=-updateTime)
-* List players: [http://localhost:8080/data/player](http://localhost:8080/data/player)
-* List player events: [http://localhost:8080/data/player_event](http://localhost:8080/data/player_event)
-* List replays: [http://localhost:8080/data/map](http://localhost:8080/data/map)
-* API documentation: [http://localhost:8080/swagger-ui.html](http://localhost:8080/swagger-ui.html)
+* [API documentation](http://localhost:8010)
+* [List event definitions](http://localhost:8010/data/event)
+* [List 5 maps with more than 8 players](http://localhost:8010/data/mapVersion?filter=(maxPlayers=gt=8)&page[size]=5)
+* [List UI mods, sorted by last updated ascending](http://localhost:8010/data/modVersion?filter=(type=='UI')&sort=-updateTime)
+* [List all players](http://localhost:8010/data/player)
+* [List events of players](http://localhost:8010/data/playerEvent)
+* [List replays](http://localhost:8010/data/game?filter=(endTime=isnull=true))
## Technology Stack
@@ -68,6 +41,7 @@ This project uses:
* Java 8 as the programming language
* [Spring Boot](https://projects.spring.io/spring-boot/) as a framework
+* [Hibernate ORM](http://hibernate.org/orm/) as ORM mapper
* [Elide](http://elide.io/) to serve [JSON-API](http://jsonapi.org/) conform data
* [Gradle](https://gradle.org/) as a build automation tool
* [Docker](https://www.docker.com/) to deploy and run the application
diff --git a/src/main/java/com/faforever/api/config/FafApiProperties.java b/src/main/java/com/faforever/api/config/FafApiProperties.java
index 630e6073b..fb792e26a 100644
--- a/src/main/java/com/faforever/api/config/FafApiProperties.java
+++ b/src/main/java/com/faforever/api/config/FafApiProperties.java
@@ -64,7 +64,7 @@ public static class Map {
*/
private String downloadUrlFormat;
/**
- * The directory in which map files are stored.
+ * The directory in which uploaded map files are stored.
*/
private Path targetDirectory = Paths.get("static/maps");
/**
@@ -93,6 +93,14 @@ public static class Map {
public static class Mod {
private String previewUrlFormat;
private String downloadUrlFormat;
+ /** Allowed file extensions of uploaded mods. */
+ private String[] allowedExtensions = {"zip"};
+ /** The directory in which uploaded mod files are stored. */
+ private Path targetDirectory = Paths.get("static/mods");
+ /** The directory in which thumbnails of uploaded mod files are stored. */
+ private Path thumbnailTargetDirectory = Paths.get("static/mod_thumbnails");
+ /** The maximum allowed length of a mod's name. */
+ private int maxNameLength = 100;
}
@Data
diff --git a/src/main/java/com/faforever/api/config/GlobalControllerExceptionHandler.java b/src/main/java/com/faforever/api/config/GlobalControllerExceptionHandler.java
index b100fb087..bb4d10fc6 100644
--- a/src/main/java/com/faforever/api/config/GlobalControllerExceptionHandler.java
+++ b/src/main/java/com/faforever/api/config/GlobalControllerExceptionHandler.java
@@ -18,6 +18,7 @@
import java.util.Map;
@ControllerAdvice
+// TODO implement a proper JSON-API error response
class GlobalControllerExceptionHandler {
private Map errorResponse(String title, String message) {
ImmutableMap error = ImmutableMap.of(
@@ -33,11 +34,6 @@ public Map processValidationException(ValidationException
return errorResponse(ErrorCode.VALIDATION_FAILED.getTitle(), ex.getMessage());
}
- private String replaceArgs(String message, Object[] args) {
- // http://stackoverflow.com/a/10995800
- return MessageFormat.format(message.replace("'", "''"), args);
- }
-
@ExceptionHandler(ApiException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
@ResponseBody
@@ -45,7 +41,7 @@ public ErrorResponse processApiException(ApiException ex) {
ErrorResponse response = new ErrorResponse();
Arrays.stream(ex.getErrors()).forEach(error -> {
ErrorCode code = error.getErrorCode();
- response.addError(new ErrorResult(code.getTitle(), replaceArgs(code.getDetail(), error.getArgs())));
+ response.addError(new ErrorResult(code.getTitle(), MessageFormat.format(code.getDetail(), error.getArgs())));
});
return response;
}
diff --git a/src/main/java/com/faforever/api/data/domain/Map.java b/src/main/java/com/faforever/api/data/domain/Map.java
index 6468d980d..62e68c0b0 100644
--- a/src/main/java/com/faforever/api/data/domain/Map.java
+++ b/src/main/java/com/faforever/api/data/domain/Map.java
@@ -15,6 +15,7 @@
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
@@ -47,7 +48,7 @@ public class Map {
private MapVersion latestVersion;
@Id
- @GeneratedValue
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
public int getId() {
return id;
diff --git a/src/main/java/com/faforever/api/data/domain/Mod.java b/src/main/java/com/faforever/api/data/domain/Mod.java
index 35969a418..a3008e75a 100644
--- a/src/main/java/com/faforever/api/data/domain/Mod.java
+++ b/src/main/java/com/faforever/api/data/domain/Mod.java
@@ -2,18 +2,27 @@
import com.yahoo.elide.annotation.Include;
import lombok.Setter;
+import org.hibernate.annotations.Formula;
import org.hibernate.annotations.Immutable;
import org.hibernate.annotations.JoinColumnOrFormula;
import org.hibernate.annotations.JoinColumnsOrFormulas;
import org.hibernate.annotations.JoinFormula;
+import org.hibernate.validator.constraints.NotEmpty;
+import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
import javax.persistence.Id;
+import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.OneToMany;
import javax.persistence.Table;
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
import java.time.OffsetDateTime;
import java.util.List;
@@ -31,34 +40,48 @@ public class Mod {
private OffsetDateTime updateTime;
private List versions;
private ModVersion latestVersion;
+ private Player uploader;
@Id
@Column(name = "id")
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
public Integer getId() {
return id;
}
@Column(name = "display_name")
+ @Size(max = 100)
+ @NotNull
public String getDisplayName() {
return displayName;
}
@Column(name = "author")
+ @Size(max = 100)
+ @NotNull
public String getAuthor() {
return author;
}
+ @ManyToOne
+ @JoinColumn(name = "uploader")
+ public Player getUploader() {
+ return uploader;
+ }
+
@Column(name = "create_time")
public OffsetDateTime getCreateTime() {
return createTime;
}
- @Column(name = "update_time")
+ @Formula(value = "(SELECT mod_version.update_time FROM mod_version WHERE mod_version.mod_id = id ORDER BY mod_version.version DESC LIMIT 1)")
public OffsetDateTime getUpdateTime() {
return updateTime;
}
- @OneToMany(mappedBy = "mod")
+ @OneToMany(mappedBy = "mod", cascade = CascadeType.ALL, orphanRemoval = true)
+ @NotEmpty
+ @Valid
public List getVersions() {
return versions;
}
diff --git a/src/main/java/com/faforever/api/data/domain/ModVersion.java b/src/main/java/com/faforever/api/data/domain/ModVersion.java
index 42f9aa45e..eb799a307 100644
--- a/src/main/java/com/faforever/api/data/domain/ModVersion.java
+++ b/src/main/java/com/faforever/api/data/domain/ModVersion.java
@@ -1,13 +1,18 @@
package com.faforever.api.data.domain;
+import com.faforever.api.data.listeners.ModVersionEnricher;
import com.yahoo.elide.annotation.ComputedAttribute;
+import com.yahoo.elide.annotation.Exclude;
import com.yahoo.elide.annotation.Include;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
+import javax.persistence.EntityListeners;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
@@ -19,9 +24,10 @@
@Table(name = "mod_version")
@Include(rootLevel = true, type = "modVersion")
@Setter
+@EntityListeners(ModVersionEnricher.class)
public class ModVersion {
- private int id;
+ private Integer id;
private String uid;
private ModType type;
private String description;
@@ -38,7 +44,8 @@ public class ModVersion {
@Id
@Column(name = "id")
- public int getId() {
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ public Integer getId() {
return id;
}
@@ -69,6 +76,8 @@ public String getFilename() {
}
@Column(name = "icon")
+ // Excluded since I see no reason why this is even stored in the database.
+ @Exclude
public String getIcon() {
return icon;
}
diff --git a/src/main/java/com/faforever/api/data/domain/Player.java b/src/main/java/com/faforever/api/data/domain/Player.java
index 213bd4fd4..4e3e19bdb 100644
--- a/src/main/java/com/faforever/api/data/domain/Player.java
+++ b/src/main/java/com/faforever/api/data/domain/Player.java
@@ -48,4 +48,9 @@ public Clan getClan() {
}
return null;
}
+
+ @Override
+ public String toString() {
+ return "Player(" + getId() + ", " + getLogin() + ")";
+ }
}
diff --git a/src/main/java/com/faforever/api/data/listeners/ModVersionEnricher.java b/src/main/java/com/faforever/api/data/listeners/ModVersionEnricher.java
index ae39aa351..106b73694 100644
--- a/src/main/java/com/faforever/api/data/listeners/ModVersionEnricher.java
+++ b/src/main/java/com/faforever/api/data/listeners/ModVersionEnricher.java
@@ -7,6 +7,8 @@
import javax.inject.Inject;
import javax.persistence.PostLoad;
+import static com.faforever.api.mod.ModService.MOD_PATH_PREFIX;
+
@Component
public class ModVersionEnricher {
@@ -20,7 +22,13 @@ public void init(FafApiProperties apiProperties) {
@PostLoad
public void enhance(ModVersion modVersion) {
String filename = modVersion.getFilename();
- modVersion.setThumbnailUrl(String.format(apiProperties.getMod().getPreviewUrlFormat(), filename.replace("mods/", "").replace(".zip", ".png")));
- modVersion.setDownloadUrl(String.format(apiProperties.getMod().getDownloadUrlFormat(), filename.replace("mods/", "")));
+ modVersion.setThumbnailUrl(String.format(
+ apiProperties.getMod().getPreviewUrlFormat(),
+ filename.replace(MOD_PATH_PREFIX, "").replace(".zip", ".png")
+ ));
+ modVersion.setDownloadUrl(String.format(
+ apiProperties.getMod().getDownloadUrlFormat(),
+ filename.replace(MOD_PATH_PREFIX, "")
+ ));
}
}
diff --git a/src/main/java/com/faforever/api/deployment/LegacyFeaturedModDeploymentTask.java b/src/main/java/com/faforever/api/deployment/LegacyFeaturedModDeploymentTask.java
index dd280594a..35f75d221 100644
--- a/src/main/java/com/faforever/api/deployment/LegacyFeaturedModDeploymentTask.java
+++ b/src/main/java/com/faforever/api/deployment/LegacyFeaturedModDeploymentTask.java
@@ -119,7 +119,7 @@ private Optional createPatchedExe(short version, Map
}
private short readModVersion(Path modPath) {
- return (short) Integer.parseInt(new ModReader().extractModInfo(modPath).getVersion().toString());
+ return (short) Integer.parseInt(new ModReader().readDirectory(modPath).getVersion().toString());
}
private void verifyVersion(int version, boolean replaceExisting, String modName) {
diff --git a/src/main/java/com/faforever/api/error/ErrorCode.java b/src/main/java/com/faforever/api/error/ErrorCode.java
index 23573b49d..165da9257 100644
--- a/src/main/java/com/faforever/api/error/ErrorCode.java
+++ b/src/main/java/com/faforever/api/error/ErrorCode.java
@@ -7,33 +7,35 @@ public enum ErrorCode {
ACHIEVEMENT_NOT_INCREMENTAL(100, "Invalid operation", "Only incremental achievements can be incremented. Achievement ID: {0}."),
ACHIEVEMENT_NOT_STANDARD(101, "Invalid operation", "Only standard achievements can be unlocked directly. Achievement ID: {0}."),
UPLOAD_FILE_MISSING(102, "Missing file", "A file has to be provided as parameter 'file'."),
- PARAMETER_MISSING(103, "Missing parameter", "A parameter '{0}' has to be provided."),
+ PARAMETER_MISSING(103, "Missing parameter", "A parameter ''{0}'' has to be provided."),
UPLOAD_INVALID_FILE_EXTENSION(104, "Invalid file extension", "File must have the following extension: {0}."),
MAP_NAME_TOO_LONG(105, "Invalid map name", "The map name must not exceed {0} characters, was: {1}"),
MAP_NOT_ORIGINAL_AUTHOR(106, "Permission denied", "Only the original author is allowed to upload new versions of map: {0}."),
- MAP_VERSION_EXISTS(107, "Duplicate map version", "Map '{0}' with version '{1}' already exists."),
- MAP_NAME_CONFLICT(108, "Name clash", "Another map with file name '{0}' already exists."),
+ MAP_VERSION_EXISTS(107, "Duplicate map version", "Map ''{0}'' with version ''{1}'' already exists."),
+ MAP_NAME_CONFLICT(108, "Name clash", "Another map with file name ''{0}'' already exists."),
MAP_NAME_MISSING(109, "Missing map name", "The scenario file must specify a map name."),
MAP_DESCRIPTION_MISSING(110, "Missing description", "The scenario file must specify a map description."),
MAP_FIRST_TEAM_FFA(111, "Invalid team name", "The name of the first team has to be 'FFA'."),
MAP_TYPE_MISSING(112, "Missing map type", "The scenario file must specify a map type."),
MAP_SIZE_MISSING(113, "Missing map size", "The scenario file must specify a map size."),
MAP_VERSION_MISSING(114, "Missing map version", "The scenario file must specify a map version."),
- QUERY_INVALID_SORT_FIELD(115, "Invalid sort field", "Sorting by '{0}' is not supported"),
+ QUERY_INVALID_SORT_FIELD(115, "Invalid sort field", "Sorting by ''{0}'' is not supported"),
QUERY_INVALID_PAGE_SIZE(116, "Invalid page size", "Page size is not valid: {0}"),
QUERY_INVALID_PAGE_NUMBER(117, "Invalid page number", "Page number is not valid: {0}"),
MOD_NAME_MISSING(118, "Missing mod name", "The file mod_info.lua must contain a property 'name'."),
MOD_UID_MISSING(119, "Missing mod UID", "The file mod_info.lua must contain a property 'uid'."),
MOD_VERSION_MISSING(120, "Missing mod version", "The file mod_info.lua must contain a property 'version'."),
MOD_DESCRIPTION_MISSING(121, "Missing mod description", "The file mod_info.lua must contain a property 'description'."),
+ /** @deprecated if it's missing we're just assuming {@code false}, just like {@code selectable}. */
+ @Deprecated
MOD_UI_ONLY_MISSING(122, "Missing mod type", "The file mod_info.lua must contain a property 'ui_only'."),
MOD_NAME_TOO_LONG(123, "Invalid mod name", "The mod name must not exceed {0} characters, was: {1}"),
MOD_NOT_ORIGINAL_AUTHOR(124, "Permission denied", "Only the original author is allowed to upload new versions of mod: {0}."),
- MOD_VERSION_EXISTS(125, "Duplicate mod version", "Mod '{0}' with version '{1}' already exists."),
+ MOD_VERSION_EXISTS(125, "Duplicate mod version", "Mod ''{0}'' with version ''{1}'' already exists."),
MOD_AUTHOR_MISSING(126, "Missing mod author", "The file mod_info.lua must contain a property 'author'."),
QUERY_INVALID_RATING_TYPE(127, "Invalid rating type", "Rating type is not valid: {0}. Please pick '1v1' or 'global'."),
LOGIN_DENIED_BANNED(128, "Login denied", "You are currently banned: {0}"),
- MOD_NAME_CONFLICT(129, "Name clash", "Another mod with file name '{0}' already exists."),
+ MOD_NAME_CONFLICT(129, "Name clash", "Another mod with file name ''{0}'' already exists."),
INVALID_EMAIL(130, "Invalid account data", "The entered email-address is invalid: {0}"),
INVALID_USERNAME(131, "Invalid account data", "The entered username is invalid: {0}"),
USERNAME_TAKEN(132, "Invalid account data", "The entered username is already in use: {0}"),
@@ -46,23 +48,23 @@ public enum ErrorCode {
USERNAME_CHANGE_TOO_EARLY(139, "Username change not allowed", "Only one name change per 30 days is allowed. {0} more days to go."),
EMAIL_CHANGE_FAILED(140, "Email change failed", "An unknown error happened while updating the database."),
STEAM_ID_UNCHANGEABLE(141, "Linking to Steam failed", "Your account is already bound to another Steam ID."),
- UNKNOWN_FEATURED_MOD(142, "Unknown featured mod", "There is no featured mod with ID '{}'."),
+ UNKNOWN_FEATURED_MOD(142, "Unknown featured mod", "There is no featured mod with ID ''{0}''."),
MAP_SCENARIO_LUA_MISSING(143, "Invalid Map File", "Zip file does not contain a *_scenario.lua"),
MAP_MISSING_MAP_FOLDER_INSIDE_ZIP(144, "No folder inside Zip", "Zip file must contain a folder with all map data"),
- MAP_FILE_INSIDE_ZIP_MISSING(145, "File is missing", "Cannot find needed file with pattern '{0}' inside zip file"),
+ MAP_FILE_INSIDE_ZIP_MISSING(145, "File is missing", "Cannot find needed file with pattern ''{0}'' inside zip file"),
MAP_NO_VALID_JSON_METADATA(146, "No valid json", "Metadata json is not valid"),
- MAP_RENAME_FAILED(147, "Cannot rename to correct name failed ", "Cannot rename file '{0}'"),
+ MAP_RENAME_FAILED(147, "Cannot rename to correct name failed ", "Cannot rename file ''{0}''"),
MAP_INVALID_ZIP(148, "Invalid zip file", "The zip file should only contain one folder at the root level"),
- CLAN_CREATE_CREATOR_IS_IN_A_CLAN(201, "You are already in a clan", "Clan creator is already member of a clan"),
- CLAN_ACCEPT_TOKEN_EXPIRE(203, "Token Expire", "The Invitation Link expire"),
- CLAN_ACCEPT_WRONG_PLAYER(204, "Wrong Player", "Your are not the invited player"),
- CLAN_ACCEPT_PLAYER_IN_A_CLAN(205, "Player is in a clan", "You are already in a clan"),
- CLAN_NOT_LEADER(206, "You Permission", "You are not the leader of the clan"),
- CLAN_NOT_EXISTS(207, "Cannot find Clan", "Clan with id '{0}' is not available"),
- CLAN_GENERATE_LINK_PLAYER_NOT_FOUND(208, "Player not found", "Cannot find player with id '{0}' who should be invited to the clan"),
- CLAN_NAME_EXISTS(209, "Clan Name already in use", "The clan name '{0}' is already in use. Please choose a different clan name."),
- CLAN_TAG_EXISTS(210, "Clan Tag already in use", "The clan tag '{0}' is already in use. Please choose a different clan tag."),
- VALIDATION_FAILED(900, "Validation failed", "{0}");
+ CLAN_CREATE_CREATOR_IS_IN_A_CLAN(149, "You are already in a clan", "Clan creator is already member of a clan"),
+ CLAN_ACCEPT_TOKEN_EXPIRE(150, "Token Expire", "The Invitation Link expire"),
+ CLAN_ACCEPT_WRONG_PLAYER(151, "Wrong Player", "Your are not the invited player"),
+ CLAN_ACCEPT_PLAYER_IN_A_CLAN(152, "Player is in a clan", "You are already in a clan"),
+ CLAN_NOT_LEADER(153, "You Permission", "You are not the leader of the clan"),
+ CLAN_NOT_EXISTS(154, "Cannot find Clan", "Clan with id ''{0}'' is not available"),
+ CLAN_GENERATE_LINK_PLAYER_NOT_FOUND(155, "Player not found", "Cannot find player with id ''{0}'' who should be invited to the clan"),
+ CLAN_NAME_EXISTS(156, "Clan Name already in use", "The clan name ''{0}'' is already in use. Please choose a different clan name."),
+ CLAN_TAG_EXISTS(157, "Clan Tag already in use", "The clan tag ''{0}'' is already in use. Please choose a different clan tag."),
+ VALIDATION_FAILED(158, "Validation failed", "{0}");
private final int code;
private final String title;
diff --git a/src/main/java/com/faforever/api/map/MapService.java b/src/main/java/com/faforever/api/map/MapService.java
index 341985f49..3478731c8 100644
--- a/src/main/java/com/faforever/api/map/MapService.java
+++ b/src/main/java/com/faforever/api/map/MapService.java
@@ -20,6 +20,7 @@
import org.luaj.vm2.LuaValue;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.Assert;
import org.springframework.util.FileSystemUtils;
import javax.inject.Inject;
@@ -63,12 +64,9 @@ public MapService(FafApiProperties fafApiProperties, MapRepository mapRepository
@Transactional
@SneakyThrows
void uploadMap(byte[] mapData, String mapFilename, Player author, boolean isRanked) {
- if (author == null) {
- throw new ProgrammingError("'author' cannot be null");
- }
- if (mapData.length <= 0) {
- throw new IllegalArgumentException("'mapData' is empty");
- }
+ Assert.notNull(author, "'author' must not be null");
+ Assert.isTrue(mapData.length > 0, "'mapData' must not be empty");
+
MapUploadData progressData = new MapUploadData()
.setBaseDir(contentService.createTempDir())
.setUploadFileName(mapFilename)
@@ -91,7 +89,7 @@ void uploadMap(byte[] mapData, String mapFilename, Player author, boolean isRank
generatePreview(progressData);
zipMapData(progressData);
- cleanup(progressData);
+ assert cleanup(progressData);
}
@SneakyThrows
@@ -329,7 +327,6 @@ private void generateImage(Path target, Path baseDir, int size) {
JavaFxUtil.writeImage(image, target, "png");
}
-
private boolean cleanup(MapUploadData mapData) {
return FileSystemUtils.deleteRecursively(mapData.getBaseDir().toFile());
}
diff --git a/src/main/java/com/faforever/api/map/MapsController.java b/src/main/java/com/faforever/api/map/MapsController.java
index 6cc807d16..531b9ba3a 100644
--- a/src/main/java/com/faforever/api/map/MapsController.java
+++ b/src/main/java/com/faforever/api/map/MapsController.java
@@ -49,7 +49,6 @@ public MapsController(MapService mapService, FafApiProperties fafApiProperties,
public void uploadMap(@RequestParam("file") MultipartFile file,
@RequestParam("metadata") String jsonString,
Authentication authentication) throws IOException {
- Player player = playerService.getPlayer(authentication);
if (file == null) {
throw new ApiException(new Error(ErrorCode.UPLOAD_FILE_MISSING));
}
@@ -67,6 +66,7 @@ public void uploadMap(@RequestParam("file") MultipartFile file,
throw new ApiException(new Error(ErrorCode.MAP_NO_VALID_JSON_METADATA));
}
+ Player player = playerService.getPlayer(authentication);
mapService.uploadMap(file.getBytes(), file.getOriginalFilename(), player, ranked);
}
}
diff --git a/src/main/java/com/faforever/api/mod/ModRepository.java b/src/main/java/com/faforever/api/mod/ModRepository.java
new file mode 100644
index 000000000..5075a48a0
--- /dev/null
+++ b/src/main/java/com/faforever/api/mod/ModRepository.java
@@ -0,0 +1,33 @@
+package com.faforever.api.mod;
+
+import com.faforever.api.data.domain.Mod;
+import com.faforever.api.data.domain.Player;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+@Repository
+@Transactional(propagation = Propagation.MANDATORY)
+public interface ModRepository extends JpaRepository {
+
+// @Query("select case when(count(m) > 0) then true else false end " +
+// "from Mod m where lower(m.displayName) = lower(:displayName) and m.uploader <> :uploader")
+// boolean modExistsByDifferentUser(@Param("displayName") String displayName, @Param("uploader") User user);
+
+ boolean existsByDisplayNameIgnoreCaseAndUploaderIsNot(String displayName, Player uploader);
+
+ /**
+ * @deprecated get rid of this as soon as proper review mechanisms are in place.
+ */
+ @Deprecated
+ @Query(value = "INSERT INTO mod_stats (mod_id, likers) " +
+ "SELECT id, '' FROM `mod` " +
+ "WHERE lower(display_name) = lower(:displayName)" +
+ "AND NOT EXISTS (SELECT mod_id FROM mod_stats WHERE mod_id = id)", nativeQuery = true)
+ @Modifying
+ void insertModStats(@Param("displayName") String displayName);
+}
diff --git a/src/main/java/com/faforever/api/mod/ModService.java b/src/main/java/com/faforever/api/mod/ModService.java
new file mode 100644
index 000000000..ee773eeea
--- /dev/null
+++ b/src/main/java/com/faforever/api/mod/ModService.java
@@ -0,0 +1,198 @@
+package com.faforever.api.mod;
+
+import com.faforever.api.config.FafApiProperties;
+import com.faforever.api.data.domain.Mod;
+import com.faforever.api.data.domain.ModType;
+import com.faforever.api.data.domain.ModVersion;
+import com.faforever.api.data.domain.Player;
+import com.faforever.api.error.ApiException;
+import com.faforever.api.error.Error;
+import com.faforever.api.error.ErrorCode;
+import com.faforever.commons.mod.ModReader;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.jetbrains.annotations.Nullable;
+import org.springframework.data.domain.Example;
+import org.springframework.data.domain.ExampleMatcher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.text.Normalizer;
+import java.text.Normalizer.Form;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+@Service
+@Slf4j
+public class ModService {
+
+ /** Legacy path prefix put in front of every mod file. This should be eliminated ASAP. */
+ public static final String MOD_PATH_PREFIX = "mods/";
+ private final FafApiProperties properties;
+ private final ModRepository modRepository;
+ private final ModVersionRepository modVersionRepository;
+
+ public ModService(FafApiProperties properties, ModRepository modRepository, ModVersionRepository modVersionRepository) {
+ this.properties = properties;
+ this.modRepository = modRepository;
+ this.modVersionRepository = modVersionRepository;
+ }
+
+ @SneakyThrows
+ @Transactional
+ public void processUploadedMod(Path uploadedFile, Player uploader) {
+ log.debug("Player '{}' uploaded a mod", uploader);
+ ModReader modReader = new ModReader();
+ com.faforever.commons.mod.Mod modInfo = modReader.readZip(uploadedFile);
+ validateModInfo(modInfo);
+
+ log.debug("Mod uploaded by user '{}' is valid: {}", uploader, modInfo);
+
+ String displayName = modInfo.getName().trim();
+ short version = (short) Integer.parseInt(modInfo.getVersion().toString());
+
+ if (!canUploadMod(displayName, uploader)) {
+ throw new ApiException(new Error(ErrorCode.MOD_NOT_ORIGINAL_AUTHOR));
+ }
+
+ if (modExists(displayName, version)) {
+ throw new ApiException(new Error(ErrorCode.MOD_VERSION_EXISTS));
+ }
+
+ String zipFileName = generateZipFileName(displayName, version);
+ Path targetPath = properties.getMod().getTargetDirectory().resolve(zipFileName);
+ if (Files.exists(targetPath)) {
+ throw new ApiException(new Error(ErrorCode.MOD_NAME_CONFLICT, zipFileName));
+ }
+
+ Optional thumbnailPath = extractThumbnail(uploadedFile, version, displayName, modInfo.getIcon());
+
+ log.debug("Moving uploaded mod '{}' to: {}", modInfo.getName(), targetPath);
+ Files.createDirectories(targetPath.getParent());
+ Files.move(uploadedFile, targetPath);
+
+ try {
+ store(modInfo, thumbnailPath, uploader, zipFileName);
+ } catch (Exception exception) {
+ try {
+ Files.delete(targetPath);
+ } catch (IOException ioException) {
+ log.warn("Could not delete file " + targetPath, ioException);
+ }
+ throw exception;
+ }
+ }
+
+ private boolean modExists(String displayName, short version) {
+ ModVersion probe = new ModVersion()
+ .setVersion(version)
+ .setMod(new Mod()
+ .setDisplayName(displayName)
+ );
+ return modVersionRepository.exists(Example.of(probe, ExampleMatcher.matching().withIgnoreCase()));
+ }
+
+ private boolean canUploadMod(String displayName, Player uploader) {
+ return !modRepository.existsByDisplayNameIgnoreCaseAndUploaderIsNot(displayName, uploader);
+ }
+
+ private void validateModInfo(com.faforever.commons.mod.Mod modInfo) {
+ List errors = new ArrayList<>();
+ String name = modInfo.getName();
+ if (name == null) {
+ errors.add(new Error(ErrorCode.MOD_NAME_MISSING));
+ } else if (name.length() > properties.getMod().getMaxNameLength()) {
+ errors.add(new Error(ErrorCode.MOD_NAME_TOO_LONG));
+ }
+ if (modInfo.getUid() == null) {
+ errors.add(new Error(ErrorCode.MOD_UID_MISSING));
+ }
+ if (modInfo.getVersion() == null) {
+ errors.add(new Error(ErrorCode.MOD_VERSION_MISSING));
+ }
+ if (modInfo.getDescription() == null) {
+ errors.add(new Error(ErrorCode.MOD_DESCRIPTION_MISSING));
+ }
+ if (modInfo.getAuthor() == null) {
+ errors.add(new Error(ErrorCode.MOD_AUTHOR_MISSING));
+ }
+
+ if (!errors.isEmpty()) {
+ throw new ApiException(errors.toArray(new Error[errors.size()]));
+ }
+ }
+
+ @SneakyThrows
+ @Nullable
+ private Optional extractThumbnail(Path modZipFile, short version, String displayName, String icon) {
+ if (icon == null) {
+ return Optional.empty();
+ }
+
+ try (ZipFile zipFile = new ZipFile(modZipFile.toFile(), ZipFile.OPEN_READ)) {
+ ZipEntry entry = zipFile.getEntry(icon.replace("/mods/", ""));
+ if (entry == null) {
+ return Optional.empty();
+ }
+
+ String thumbnailFileName = generateThumbnailFileName(displayName, version);
+ Path targetPath = properties.getMod().getThumbnailTargetDirectory().resolve(thumbnailFileName);
+
+ log.debug("Extracting thumbnail of mod '{}' to: {}", displayName, targetPath);
+ Files.createDirectories(targetPath.getParent());
+ try (InputStream inputStream = new BufferedInputStream(zipFile.getInputStream(entry))) {
+ Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ return Optional.of(targetPath);
+ }
+ }
+
+ private String generateThumbnailFileName(String name, short version) {
+ return generateFolderName(name, version) + ".png";
+ }
+
+ private String generateZipFileName(String displayName, short version) {
+ return generateFolderName(displayName, version) + ".zip";
+ }
+
+ private String generateFolderName(String displayName, short version) {
+ return String.format("%s.v%04d", generateFileName(displayName), version);
+ }
+
+ private String generateFileName(String displayName) {
+ return Normalizer.normalize(displayName.toLowerCase(Locale.US)
+ .replace("..", ".")
+ .replaceAll("[/\\\\ ]", "_"),
+ Form.NFKC);
+ }
+
+ private void store(com.faforever.commons.mod.Mod modInfo, Optional thumbnailPath, Player uploader, String zipFileName) {
+ Mod mod = modRepository.save(new Mod()
+ .setAuthor(modInfo.getAuthor())
+ .setDisplayName(modInfo.getName())
+ .setUploader(uploader));
+
+ modVersionRepository.save(new ModVersion()
+ .setUid(modInfo.getUid())
+ .setType(modInfo.isUiOnly() ? ModType.UI : ModType.SIM)
+ .setDescription(modInfo.getDescription())
+ .setVersion((short) Integer.parseInt(modInfo.getVersion().toString()))
+ .setFilename(MOD_PATH_PREFIX + zipFileName)
+ .setIcon(thumbnailPath.map(path -> path.getFileName().toString()).orElse(null))
+ .setMod(mod)
+ );
+
+ modRepository.insertModStats(mod.getDisplayName());
+ }
+}
diff --git a/src/main/java/com/faforever/api/mod/ModVersionRepository.java b/src/main/java/com/faforever/api/mod/ModVersionRepository.java
new file mode 100644
index 000000000..6f0642f3c
--- /dev/null
+++ b/src/main/java/com/faforever/api/mod/ModVersionRepository.java
@@ -0,0 +1,9 @@
+package com.faforever.api.mod;
+
+import com.faforever.api.data.domain.ModVersion;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface ModVersionRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/faforever/api/mod/ModsController.java b/src/main/java/com/faforever/api/mod/ModsController.java
new file mode 100644
index 000000000..17a784327
--- /dev/null
+++ b/src/main/java/com/faforever/api/mod/ModsController.java
@@ -0,0 +1,53 @@
+package com.faforever.api.mod;
+
+import com.faforever.api.config.FafApiProperties;
+import com.faforever.api.error.ApiException;
+import com.faforever.api.error.Error;
+import com.faforever.api.error.ErrorCode;
+import com.faforever.api.player.PlayerService;
+import com.google.common.io.Files;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Arrays;
+
+@RestController
+@RequestMapping(path = "/mods")
+public class ModsController {
+
+ private final PlayerService playerService;
+ private final ModService modService;
+ private final FafApiProperties fafApiProperties;
+
+ public ModsController(PlayerService playerService, ModService modService, FafApiProperties fafApiProperties) {
+ this.playerService = playerService;
+ this.modService = modService;
+ this.fafApiProperties = fafApiProperties;
+ }
+
+ @ApiOperation("Uploads a mod")
+ @RequestMapping(path = "/upload", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
+ public void uploadMod(@RequestParam("file") MultipartFile file, Authentication authentication) throws IOException {
+ if (file == null) {
+ throw new ApiException(new Error(ErrorCode.UPLOAD_FILE_MISSING));
+ }
+
+ String extension = Files.getFileExtension(file.getOriginalFilename());
+ if (Arrays.stream(fafApiProperties.getMod().getAllowedExtensions()).noneMatch(extension::equals)) {
+ throw new ApiException(new Error(ErrorCode.UPLOAD_INVALID_FILE_EXTENSION, fafApiProperties.getMap().getAllowedExtensions()));
+ }
+
+ Path tempFile = java.nio.file.Files.createTempFile("mod", ".tmp");
+ file.transferTo(tempFile.getFileName().toFile());
+
+ modService.processUploadedMod(tempFile, playerService.getPlayer(authentication));
+ }
+}
diff --git a/src/main/java/com/faforever/api/player/PlayerService.java b/src/main/java/com/faforever/api/player/PlayerService.java
index d7880913e..8ae6a5028 100644
--- a/src/main/java/com/faforever/api/player/PlayerService.java
+++ b/src/main/java/com/faforever/api/player/PlayerService.java
@@ -17,7 +17,6 @@ public PlayerService(PlayerRepository playerRepository) {
this.playerRepository = playerRepository;
}
-
public Player getPlayer(Authentication authentication) {
if (authentication != null
&& authentication.getPrincipal() != null
diff --git a/src/main/java/com/faforever/api/user/UserRepository.java b/src/main/java/com/faforever/api/user/UserRepository.java
index 98af03049..bbef5c2b9 100644
--- a/src/main/java/com/faforever/api/user/UserRepository.java
+++ b/src/main/java/com/faforever/api/user/UserRepository.java
@@ -6,6 +6,5 @@
@Repository
public interface UserRepository extends JpaRepository {
-
User findOneByLoginIgnoreCase(String login);
}
diff --git a/src/main/resources/config/application-dev.yml b/src/main/resources/config/application-dev.yml
index dfeaa7f8e..de6a04d6b 100644
--- a/src/main/resources/config/application-dev.yml
+++ b/src/main/resources/config/application-dev.yml
@@ -6,12 +6,12 @@ faf-api:
target-directory: ${MAP_UPLOAD_PATH:build/cache/map/maps}
directory-preview-path-small: ${MAP_PREVIEW_PATH_SMALL:build/cache/map_previews/small}
directory-preview-path-large: ${MAP_PREVIEW_PATH_LARGE:build/cache/map_previews/large}
- small-previews-url-format: ${MAP_SMALL_PREVIEWS_URL_FORMAT:http://test.content.faforever.com/faf/vault/map_previews/small/%s.png}
- large-previews-url-format: ${MAP_LARGE_PREVIEWS_URL_FORMAT:http://test.content.faforever.com/faf/vault/map_previews/large/%s.png}
- download-url-format: ${MAP_DOWNLOAD_URL_FORMAT:http://test.content.faforever.com/faf/vault/maps/%s.zip}
+ small-previews-url-format: ${MAP_SMALL_PREVIEWS_URL_FORMAT:http://test.content.faforever.com/faf/vault/map_previews/small/%s}
+ large-previews-url-format: ${MAP_LARGE_PREVIEWS_URL_FORMAT:http://test.content.faforever.com/faf/vault/map_previews/large/%s}
+ download-url-format: ${MAP_DOWNLOAD_URL_FORMAT:http://test.content.faforever.com/faf/vault/maps/%s}
mod:
- download-url-format: ${MOD_DOWNLOAD_URL_FORMAT:http://test.content.faforever.com/faf/vault/maps/%s.zip}
- preview-url-format: ${MOD_PREVIEW_URL_FORMAT:http://test.content.faforever.com/faf/vault/mods/%s.zip}
+ download-url-format: ${MOD_DOWNLOAD_URL_FORMAT:http://test.content.faforever.com/faf/vault/mods/%s}
+ preview-url-format: ${MOD_PREVIEW_URL_FORMAT:http://test.content.faforever.com/faf/vault/mods/%s}
replay:
download-url-format: ${REPLAY_DOWNLOAD_URL_FORMAT:http://content.test.faforever.com/faf/vault/replay_vault/replay.php?id=%s}
featured-mods:
diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml
index 0d626ea71..dbd72daa9 100644
--- a/src/main/resources/config/application.yml
+++ b/src/main/resources/config/application.yml
@@ -26,6 +26,11 @@ spring:
WRITE_DATES_AS_TIMESTAMPS: false
profiles:
active: ${API_PROFILE:dev}
+ http:
+ multipart:
+ max-file-size: 300MB
+ max-request-size: 300MB
+ file-size-threshold: 5MB
server:
# Mind that this is configured in the docker compose file as well (that is, in the gradle script that generates it)
diff --git a/src/test/java/com/faforever/api/config/ErrorTest.java b/src/test/java/com/faforever/api/config/ErrorTest.java
index 60ad81408..03cd9e979 100644
--- a/src/test/java/com/faforever/api/config/ErrorTest.java
+++ b/src/test/java/com/faforever/api/config/ErrorTest.java
@@ -28,7 +28,7 @@ public void testErrorFormatting() {
@Test
public void testErrorFormattingNull() {
- ApiException ex = new ApiException(new Error(ErrorCode.CLAN_NAME_EXISTS, null));
+ ApiException ex = new ApiException(new Error(ErrorCode.CLAN_NAME_EXISTS));
ErrorResponse response = instance.processApiException(ex);
assertEquals(1, response.getErrors().size());
assertEquals("Clan Name already in use", response.getErrors().get(0).getTitle());
diff --git a/src/test/java/com/faforever/api/map/MapControllerTest.java b/src/test/java/com/faforever/api/map/MapsControllerTest.java
similarity index 96%
rename from src/test/java/com/faforever/api/map/MapControllerTest.java
rename to src/test/java/com/faforever/api/map/MapsControllerTest.java
index e31fb3c0c..bec0b581c 100644
--- a/src/test/java/com/faforever/api/map/MapControllerTest.java
+++ b/src/test/java/com/faforever/api/map/MapsControllerTest.java
@@ -26,7 +26,7 @@
@RunWith(SpringRunner.class)
@WebMvcTest(MapsController.class)
@Import(TestWebSecurityConfig.class)
-public class MapControllerTest {
+public class MapsControllerTest {
private MockMvc mvc;
@MockBean
@@ -85,6 +85,6 @@ public void successUpload() throws Exception {
}
private InputStream loadMapResourceAsStream(String filename) {
- return MapControllerTest.class.getResourceAsStream("/maps/" + filename);
+ return MapsControllerTest.class.getResourceAsStream("/maps/" + filename);
}
}
diff --git a/src/test/java/com/faforever/api/mod/ModServiceTest.java b/src/test/java/com/faforever/api/mod/ModServiceTest.java
new file mode 100644
index 000000000..0b18c2bb8
--- /dev/null
+++ b/src/test/java/com/faforever/api/mod/ModServiceTest.java
@@ -0,0 +1,91 @@
+package com.faforever.api.mod;
+
+import com.faforever.api.config.FafApiProperties;
+import com.faforever.api.data.domain.Mod;
+import com.faforever.api.data.domain.ModVersion;
+import com.faforever.api.data.domain.Player;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.io.BufferedInputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.nullValue;
+import static org.junit.Assert.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ModServiceTest {
+
+ private static final String TEST_MOD = "/mods/No Friendly Fire.zip";
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private ModService instance;
+
+ @Mock
+ private ModRepository modRepository;
+ @Mock
+ private ModVersionRepository modVersionRepository;
+
+ @Before
+ public void setUp() throws Exception {
+ FafApiProperties properties = new FafApiProperties();
+ properties.getMod().setTargetDirectory(temporaryFolder.getRoot().toPath().resolve("mods"));
+ properties.getMod().setThumbnailTargetDirectory(temporaryFolder.getRoot().toPath().resolve("thumbnails"));
+
+ when(modRepository.save(any(Mod.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ instance = new ModService(properties, modRepository, modVersionRepository);
+ }
+
+ @Test
+ public void processUploadedMod() throws Exception {
+ Path uploadedFile = temporaryFolder.getRoot().toPath().resolve("uploaded-mod.zip");
+ try (InputStream inputStream = new BufferedInputStream(getClass().getResourceAsStream(TEST_MOD))) {
+ Files.copy(inputStream, uploadedFile);
+ }
+
+ Player uploader = new Player();
+
+ instance.processUploadedMod(uploadedFile, uploader);
+
+ assertThat(Files.exists(temporaryFolder.getRoot().toPath().resolve("mods/no_friendly_fire.v0003.zip")), is(true));
+ assertThat(Files.exists(temporaryFolder.getRoot().toPath().resolve("thumbnails/no_friendly_fire.v0003.png")), is(true));
+
+ ArgumentCaptor modCaptor = ArgumentCaptor.forClass(Mod.class);
+ verify(modRepository).save(modCaptor.capture());
+ Mod savedMod = modCaptor.getValue();
+
+ assertThat(savedMod.getId(), is(nullValue()));
+ assertThat(savedMod.getAuthor(), is("IceDreamer"));
+ assertThat(savedMod.getDisplayName(), is("No Friendly Fire"));
+ assertThat(savedMod.getUploader(), is(uploader));
+
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(ModVersion.class);
+ verify(modVersionRepository).save(versionCaptor.capture());
+ ModVersion savedModVersion = versionCaptor.getValue();
+
+ assertThat(savedModVersion.getId(), is(nullValue()));
+ assertThat(savedModVersion.getIcon(), is("no_friendly_fire.v0003.png"));
+ assertThat(savedModVersion.getFilename(), is("mods/no_friendly_fire.v0003.zip"));
+ assertThat(savedModVersion.getUid(), is("26778D4E-BA75-5CC2-CBA8-63795BDE74AA"));
+ assertThat(savedModVersion.getDescription(), is("All friendly fire, including between allies, is turned off."));
+ assertThat(savedModVersion.getMod(), is(savedMod));
+ assertThat(savedModVersion.isRanked(), is(false));
+ assertThat(savedModVersion.isHidden(), is(false));
+ }
+
+ // TODO test error cases
+}
diff --git a/src/test/resources/mods/No Friendly Fire.zip b/src/test/resources/mods/No Friendly Fire.zip
new file mode 100644
index 000000000..b2aaeb480
Binary files /dev/null and b/src/test/resources/mods/No Friendly Fire.zip differ