Skip to content

Commit 6c6fa3c

Browse files
committed
Allow Mod upload via S3
1 parent 1e0f51b commit 6c6fa3c

10 files changed

Lines changed: 273 additions & 54 deletions

File tree

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ dependencies {
184184
implementation("org.jsoup:jsoup:1.21.1")
185185
implementation("com.github.jasminb:jsonapi-converter:0.14")
186186
implementation("commons-codec:commons-codec:1.18.0")
187+
implementation("software.amazon.awssdk:s3:2.31.68")
187188

188189
// Required library for FafTokenService approach (called by nimbus-jwt)
189190
runtimeOnly("org.bouncycastle:bcpkix-jdk15on:1.70")

compose.yaml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
services:
2+
# Set up the faf db
3+
faf-db:
4+
image: mariadb:11
5+
environment:
6+
MARIADB_DATABASE: faf
7+
MARIADB_USER: faf-api
8+
MARIADB_PASSWORD: banana
9+
MARIADB_ROOT_PASSWORD: banana
10+
healthcheck:
11+
test: [ "CMD", "healthcheck.sh", "--connect", "--innodb_initialized" ]
12+
interval: 10s
13+
timeout: 5s
14+
retries: 5
15+
ports:
16+
- "3306:3306"
17+
18+
faf-db-migrations:
19+
image: faforever/faf-db-migrations:v136
20+
command: migrate
21+
environment:
22+
FLYWAY_URL: jdbc:mysql://faf-db/faf?useSSL=false
23+
FLYWAY_USER: root
24+
FLYWAY_PASSWORD: banana
25+
FLYWAY_DATABASE: faf
26+
depends_on:
27+
faf-db:
28+
condition: service_healthy
29+
30+
faf-db-testdata:
31+
image: mariadb:11
32+
entrypoint: sh -c "apt-get update && apt-get install -y curl && curl -s https://raw.githubusercontent.com/FAForever/db/refs/heads/develop/test-data.sql | mariadb -h faf-db -uroot -pbanana -D faf"
33+
depends_on:
34+
faf-db-migrations:
35+
condition: service_completed_successfully
36+
37+
minio:
38+
image: docker.io/bitnami/minio:2025
39+
ports:
40+
- '9000:9000'
41+
- '9001:9001'
42+
environment:
43+
MINIO_ROOT_USER: admin
44+
MINIO_ROOT_PASSWORD: banana123
45+
MINIO_DEFAULT_BUCKETS: user-uploads
46+
MINIO_SCHEME: http
47+
healthcheck:
48+
test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/ready" ]
49+
interval: 2s
50+
timeout: 2s
51+
retries: 15
52+
53+
rabbitmq:
54+
image: rabbitmq:3.13-management
55+
environment:
56+
RABBITMQ_DEFAULT_VHOST: /faf-core
57+
RABBITMQ_DEFAULT_USER: faf-api
58+
RABBITMQ_DEFAULT_PASS: banana
59+
ports:
60+
- "5672:5672"

src/main/java/com/faforever/api/config/FafApiProperties.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public class FafApiProperties {
4242
private Recaptcha recaptcha = new Recaptcha();
4343
private Monitoring monitoring = new Monitoring();
4444
private Coturn coturn = new Coturn();
45+
private S3 s3 = new S3();
4546

4647
@Data
4748
public static class OAuth2 {
@@ -298,4 +299,12 @@ public static class Monitoring {
298299
public static class Coturn {
299300
private int tokenLifetimeSeconds = 86400;
300301
}
302+
303+
@Data
304+
public static class S3 {
305+
private String endpoint;
306+
private String userUploadBucket;
307+
private String accessKey;
308+
private String secretKey;
309+
}
301310
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.faforever.api.config;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
7+
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
8+
import software.amazon.awssdk.regions.Region;
9+
import software.amazon.awssdk.services.s3.S3Client;
10+
import software.amazon.awssdk.services.s3.S3Configuration;
11+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
12+
13+
import java.net.URI;
14+
15+
@Configuration
16+
@RequiredArgsConstructor
17+
public class S3Config {
18+
19+
private final FafApiProperties properties;
20+
21+
@Bean
22+
public S3Client s3Client() {
23+
return S3Client.builder()
24+
.endpointOverride(URI.create(properties.getS3().getEndpoint()))
25+
.region(Region.EU_CENTRAL_1) // region must be non-null but is ignored by some S3-compatible services
26+
.credentialsProvider(StaticCredentialsProvider.create(
27+
AwsBasicCredentials.create(properties.getS3().getAccessKey(), properties.getS3().getSecretKey())
28+
))
29+
.serviceConfiguration(S3Configuration.builder()
30+
.pathStyleAccessEnabled(true) // prevents putting the bucket name as subdomain
31+
.build())
32+
.build();
33+
}
34+
35+
@Bean
36+
public S3Presigner s3Presigner() {
37+
return S3Presigner.builder()
38+
.endpointOverride(URI.create(properties.getS3().getEndpoint()))
39+
.region(Region.EU_CENTRAL_1) // region must be non-null but is ignored by some S3-compatible services
40+
.credentialsProvider(StaticCredentialsProvider.create(
41+
AwsBasicCredentials.create(properties.getS3().getAccessKey(), properties.getS3().getSecretKey())
42+
))
43+
.serviceConfiguration(S3Configuration.builder()
44+
.pathStyleAccessEnabled(true) // prevents putting the bucket name as subdomain
45+
.build())
46+
.build();
47+
}
48+
}

src/main/java/com/faforever/api/mod/ModService.java

Lines changed: 70 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@
3131
import org.springframework.stereotype.Service;
3232
import org.springframework.transaction.annotation.Transactional;
3333
import org.springframework.web.client.HttpClientErrorException;
34+
import software.amazon.awssdk.core.sync.ResponseTransformer;
35+
import software.amazon.awssdk.services.s3.S3Client;
36+
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
37+
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
38+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
39+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
40+
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
41+
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
3442

3543
import java.io.BufferedInputStream;
3644
import java.io.IOException;
@@ -40,11 +48,13 @@
4048
import java.nio.file.Files;
4149
import java.nio.file.Path;
4250
import java.nio.file.StandardCopyOption;
51+
import java.time.Duration;
4352
import java.util.ArrayList;
4453
import java.util.Enumeration;
4554
import java.util.List;
4655
import java.util.Optional;
4756
import java.util.Set;
57+
import java.util.UUID;
4858
import java.util.zip.ZipEntry;
4959
import java.util.zip.ZipFile;
5060

@@ -67,6 +77,55 @@ public class ModService {
6777
private final ModRepository modRepository;
6878
private final ModVersionRepository modVersionRepository;
6979
private final LicenseRepository licenseRepository;
80+
private final S3Client s3Client;
81+
private final S3Presigner s3Presigner;
82+
83+
private String getBucketKey(int userId, UUID requestId) {
84+
return "%s-mod-%s".formatted(userId, requestId);
85+
}
86+
87+
public String getPresignedS3Url(Player uploader, UUID requestId) {
88+
log.info("User {} requested presigned url for mod upload, request id {}", uploader.getId(), requestId);
89+
90+
checkUploaderVaultBan(uploader);
91+
92+
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
93+
.bucket(properties.getS3().getUserUploadBucket())
94+
.key(getBucketKey(uploader.getId(), requestId))
95+
.build();
96+
97+
PutObjectPresignRequest putObjectPresignRequest = PutObjectPresignRequest.builder()
98+
.signatureDuration(Duration.ofHours(1))
99+
.putObjectRequest(putObjectRequest)
100+
.build();
101+
102+
PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(putObjectPresignRequest);
103+
104+
return presignedRequest.url().toString();
105+
}
106+
107+
public Path getModFromS3Location(Player uploader, UUID requestId) throws IOException {
108+
Path tempDir = Files.createTempDirectory("mod-download");
109+
String bucketKey = getBucketKey(uploader.getId(), requestId);
110+
Path tempFile = tempDir.resolve(bucketKey + ".zip");
111+
112+
checkUploaderVaultBan(uploader);
113+
114+
GetObjectRequest request = GetObjectRequest.builder()
115+
.bucket(properties.getS3().getUserUploadBucket())
116+
.key(bucketKey)
117+
.build();
118+
119+
s3Client.getObject(request, ResponseTransformer.toFile(tempFile));
120+
121+
return tempFile;
122+
}
123+
124+
public void deleteModFromS3Location(Player uploader, UUID requestId) throws IOException {
125+
String bucketKey = getBucketKey(uploader.getId(), requestId);
126+
127+
s3Client.deleteObject(DeleteObjectRequest.builder().bucket(properties.getS3().getUserUploadBucket()).key(bucketKey).build());
128+
}
70129

71130
@SneakyThrows
72131
@Transactional
@@ -94,8 +153,7 @@ public void processUploadedMod(Path uploadedFile, String originalFilename, Playe
94153
short version = (short) Integer.parseInt(modInfo.getVersion().toString());
95154

96155
if (!canUploadMod(displayName, uploader)) {
97-
Mod mod = modRepository.findOneByDisplayName(displayName)
98-
.orElseThrow(() -> new IllegalStateException("Mod could not be found"));
156+
Mod mod = modRepository.findOneByDisplayName(displayName).orElseThrow(() -> new IllegalStateException("Mod could not be found"));
99157
throw new ApiException(new Error(ErrorCode.MOD_NOT_ORIGINAL_AUTHOR, mod.getAuthor(), displayName));
100158
}
101159

@@ -159,9 +217,7 @@ private void validateZipFileSafety(Path uploadedFile) {
159217

160218
try {
161219
// Unzipping directory already invokes the checks we want to perform
162-
Unzipper.from(uploadedFile)
163-
.to(tempDirectory)
164-
.unzip();
220+
Unzipper.from(uploadedFile).to(tempDirectory).unzip();
165221

166222
} finally {
167223
log.debug("Delete unzipped files in folder {}", tempDirectory);
@@ -188,11 +244,7 @@ private void validateModStructure(Path uploadedFile) {
188244
}
189245

190246
private boolean modExists(String displayName, short version) {
191-
ModVersion probe = new ModVersion()
192-
.setVersion(version)
193-
.setMod(new Mod()
194-
.setDisplayName(displayName)
195-
);
247+
ModVersion probe = new ModVersion().setVersion(version).setMod(new Mod().setDisplayName(displayName));
196248

197249
return modVersionRepository.exists(Example.of(probe, ExampleMatcher.matching()));
198250
}
@@ -202,14 +254,10 @@ private boolean modUidExists(String uuid) {
202254
}
203255

204256
private void checkUploaderVaultBan(Player uploader) {
205-
uploader.getActiveBanOf(BanLevel.VAULT)
206-
.ifPresent((banInfo) -> {
207-
String message = banInfo.getDuration() == BanDurationType.PERMANENT ?
208-
"You are permanently banned from uploading mods to the vault." :
209-
format("You are banned from uploading mods to the vault until {0}.", banInfo.getExpiresAt());
210-
throw HttpClientErrorException.create(message, HttpStatus.FORBIDDEN, "Upload forbidden",
211-
HttpHeaders.EMPTY, null, null);
212-
});
257+
uploader.getActiveBanOf(BanLevel.VAULT).ifPresent((banInfo) -> {
258+
String message = banInfo.getDuration() == BanDurationType.PERMANENT ? "You are permanently banned from uploading mods to the vault." : format("You are banned from uploading mods to the vault until {0}.", banInfo.getExpiresAt());
259+
throw HttpClientErrorException.create(message, HttpStatus.FORBIDDEN, "Upload forbidden", HttpHeaders.EMPTY, null, null);
260+
});
213261
}
214262

215263
private boolean canUploadMod(String displayName, Player uploader) {
@@ -248,7 +296,7 @@ private void validateModInfo(com.faforever.commons.mod.Mod modInfo) {
248296
final Integer versionInt = Ints.tryParse(modVersion.toString());
249297
if (versionInt == null) {
250298
errors.add(new Error(ErrorCode.MOD_VERSION_NOT_A_NUMBER, modVersion.toString()));
251-
} else if (!isModVersionValidRange(versionInt)){
299+
} else if (!isModVersionValidRange(versionInt)) {
252300
errors.add(new Error(ErrorCode.MOD_VERSION_INVALID_RANGE, MOD_VERSION_MIN_VALUE, MOD_VERSION_MAX_VALUE));
253301
}
254302
}
@@ -307,22 +355,10 @@ private String generateFolderName(String displayName, short version) {
307355
}
308356

309357
private void store(com.faforever.commons.mod.Mod modInfo, Optional<Path> thumbnailPath, Player uploader, String zipFileName, Integer licenseId, String repositoryUrl) {
310-
ModVersion modVersion = new ModVersion()
311-
.setUid(modInfo.getUid())
312-
.setType(modInfo.isUiOnly() ? ModType.UI : ModType.SIM)
313-
.setDescription(modInfo.getDescription())
314-
.setVersion((short) Integer.parseInt(modInfo.getVersion().toString()))
315-
.setFilename(MOD_PATH_PREFIX + zipFileName)
316-
.setIcon(thumbnailPath.map(path -> path.getFileName().toString()).orElse(null));
358+
ModVersion modVersion = 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));
317359

318360
License newLicense = getLicenseOrDefault(licenseId);
319-
Mod mod = modRepository.findOneByDisplayName(modInfo.getName())
320-
.orElse(new Mod()
321-
.setAuthor(modInfo.getAuthor())
322-
.setDisplayName(modInfo.getName())
323-
.setUploader(uploader)
324-
.setLicense(newLicense)
325-
.setRecommended(false));
361+
Mod mod = modRepository.findOneByDisplayName(modInfo.getName()).orElse(new Mod().setAuthor(modInfo.getAuthor()).setDisplayName(modInfo.getName()).setUploader(uploader).setLicense(newLicense).setRecommended(false));
326362

327363
if (newLicense.isLessPermissiveThan(mod.getLicense())) {
328364
throw ApiException.of(ErrorCode.LESS_PERMISSIVE_LICENSE);
@@ -336,8 +372,6 @@ private void store(com.faforever.commons.mod.Mod modInfo, Optional<Path> thumbna
336372
}
337373

338374
public License getLicenseOrDefault(Integer licenseId) {
339-
return Optional.ofNullable(licenseId)
340-
.flatMap(licenseRepository::findById)
341-
.orElseGet(() -> licenseRepository.getReferenceById(properties.getMod().getDefaultLicenseId()));
375+
return Optional.ofNullable(licenseId).flatMap(licenseRepository::findById).orElseGet(() -> licenseRepository.getReferenceById(properties.getMod().getDefaultLicenseId()));
342376
}
343377
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
package com.faforever.api.mod;
22

3-
public record ModUploadMetadata(Integer licenseId, String repositoryUrl) {
3+
import java.util.UUID;
4+
5+
public record ModUploadMetadata(
6+
UUID requestId,
7+
Integer licenseId,
8+
String repositoryUrl
9+
) {
410
}

0 commit comments

Comments
 (0)