waitingSubmissions = exerciseSubmissionRepository.findAllByStatus(ExerciseSubmission.Status.WAITING);
- for (var submission : waitingSubmissions) {
- taskExecutor.execute(() -> processSubmission(submission.getId()));
- }
- }
-
- /**
- * Asynchronous task that's started after a submission is submitted, which
- * handles video processing and anything else that might need to be done
- * before the submission can be marked as COMPLETED.
- *
- * Note: This method is intentionally NOT transactional, since it may
- * have a long duration, and we want real-time status updates.
- *
- * @param submissionId The submission's id.
- */
- private void processSubmission(String submissionId) {
- log.info("Starting processing of submission {}.", submissionId);
- // First try and fetch the submission.
- Optional optionalSubmission = exerciseSubmissionRepository.findById(submissionId);
- if (optionalSubmission.isEmpty()) {
- log.warn("Submission id {} is not associated with a submission.", submissionId);
- return;
- }
- ExerciseSubmission submission = optionalSubmission.get();
- if (submission.getStatus() != ExerciseSubmission.Status.WAITING) {
- log.warn("Submission {} cannot be processed because its status {} is not WAITING.", submission.getId(), submission.getStatus());
- return;
- }
-
- // Set the status to processing.
- submission.setStatus(ExerciseSubmission.Status.PROCESSING);
- exerciseSubmissionRepository.saveAndFlush(submission);
-
- // Then try and fetch the temporary video file associated with it.
- Optional optionalTempFile = tempFileRepository.findBySubmission(submission);
- if (optionalTempFile.isEmpty()) {
- log.warn("Submission {} failed because the temporary video file couldn't be found.", submission.getId());
- submission.setStatus(ExerciseSubmission.Status.FAILED);
- exerciseSubmissionRepository.save(submission);
- return;
- }
- ExerciseSubmissionTempFile tempFile = optionalTempFile.get();
- Path tempFilePath = Path.of(tempFile.getPath());
- if (!Files.exists(tempFilePath) || !Files.isReadable(tempFilePath)) {
- log.error("Submission {} failed because the temporary video file {} isn't readable.", submission.getId(), tempFilePath);
- submission.setStatus(ExerciseSubmission.Status.FAILED);
- exerciseSubmissionRepository.saveAndFlush(submission);
- return;
- }
-
- // Now we can try to process the video file into a compressed format that can be stored in the DB.
- Path dir = UploadService.SUBMISSION_TEMP_FILE_DIR;
- String tempFileName = tempFilePath.getFileName().toString();
- String tempFileBaseName = tempFileName.substring(0, tempFileName.length() - ".tmp".length());
- Path outFilePath = dir.resolve(tempFileBaseName + "-out.mp4");
- StoredFile file;
- try {
- processVideo(dir, tempFilePath, outFilePath);
- file = fileRepository.save(new StoredFile(
- "compressed.mp4",
- "video/mp4",
- Files.size(outFilePath),
- Files.readAllBytes(outFilePath)
- ));
- } catch (Exception e) {
- log.error("""
- Video processing failed for submission {}:
- Input file: {}
- Output file: {}
- Exception message: {}""",
- submission.getId(),
- tempFilePath,
- outFilePath,
- e.getMessage()
- );
- submission.setStatus(ExerciseSubmission.Status.FAILED);
- exerciseSubmissionRepository.saveAndFlush(submission);
- return;
- }
-
- // After we've saved the processed file, we can link it to the submission, and set the submission's status.
- videoFileRepository.save(new ExerciseSubmissionVideoFile(
- submission,
- file
- ));
- submission.setStatus(ExerciseSubmission.Status.COMPLETED);
- submission.setComplete(true);
- exerciseSubmissionRepository.save(submission);
- // And delete the temporary files.
- try {
- Files.delete(tempFilePath);
- Files.delete(outFilePath);
- tempFileRepository.delete(tempFile);
- } catch (IOException e) {
- log.error("Couldn't delete temporary files after submission completed.", e);
- }
- log.info("Processing of submission {} complete.", submission.getId());
- }
-
- /**
- * Uses the `ffmpeg` system command to process a raw input video and produce
- * a compressed, reduced-size output video that's ready for usage in the
- * application.
- * @param dir The working directory.
- * @param inFile The input file to read from.
- * @param outFile The output file to write to. MUST have a ".mp4" extension.
- * @throws IOException If a filesystem error occurs.
- * @throws CommandFailedException If the ffmpeg command fails.
- * @throws InterruptedException If the ffmpeg command is interrupted.
- */
- private void processVideo(Path dir, Path inFile, Path outFile) throws IOException, InterruptedException {
- Path tmpStdout = Files.createTempFile(dir, "stdout-", ".log");
- Path tmpStderr = Files.createTempFile(dir, "stderr-", ".log");
- final String[] command = {
- "ffmpeg", "-i", inFile.getFileName().toString(),
- "-vf", "scale=640x480:flags=lanczos",
- "-vcodec", "libx264",
- "-crf", "28",
- outFile.getFileName().toString()
- };
-
- long startSize = Files.size(inFile);
- Instant startTime = Instant.now();
-
- Process ffmpegProcess = new ProcessBuilder()
- .command(command)
- .redirectOutput(tmpStdout.toFile())
- .redirectError(tmpStderr.toFile())
- .directory(dir.toFile())
- .start();
- int result = ffmpegProcess.waitFor();
- if (result != 0) throw new CommandFailedException(command, result, tmpStdout, tmpStderr);
-
- long endSize = Files.size(outFile);
- Duration dur = Duration.between(startTime, Instant.now());
- double reductionFactor = startSize / (double) endSize;
- String reductionFactorStr = String.format("%.3f%%", reductionFactor * 100);
- log.info("Processed video from {} bytes to {} bytes in {} seconds, {} reduction.", startSize, endSize, dur.getSeconds(), reductionFactorStr);
-
- // Delete the logs if everything was successful.
- Files.deleteIfExists(tmpStdout);
- Files.deleteIfExists(tmpStderr);
- }
-
- @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES)
- public void removeOldUploadedFiles() {
- // First remove any temp files older than 10 minutes.
- LocalDateTime cutoff = LocalDateTime.now().minusMinutes(10);
- var tempFiles = tempFileRepository.findAllByCreatedAtBefore(cutoff);
- for (var file : tempFiles) {
- try {
- Files.deleteIfExists(Path.of(file.getPath()));
- tempFileRepository.delete(file);
- log.info("Removed temporary submission file {} at {}.", file.getId(), file.getPath());
- } catch (IOException e) {
- log.error(String.format("Could not delete submission temp file %d at %s.", file.getId(), file.getPath()), e);
- }
- }
-
- // Then remove any files in the directory which don't correspond to a valid file in the db.
- if (Files.notExists(UploadService.SUBMISSION_TEMP_FILE_DIR)) return;
- try (var s = Files.list(UploadService.SUBMISSION_TEMP_FILE_DIR)) {
- for (var path : s.toList()) {
- if (!tempFileRepository.existsByPath(path.toString())) {
- try {
- Files.delete(path);
- } catch (IOException e) {
- log.error("Couldn't delete orphan temp file: " + path, e);
- }
- }
- }
- } catch (IOException e) {
- log.error("Couldn't get list of temp files.", e);
- }
- }
-}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/SampleDataLoader.java
similarity index 71%
rename from gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java
rename to gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/SampleDataLoader.java
index 9433701..3a3d491 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/SampleDataLoader.java
@@ -1,4 +1,4 @@
-package nl.andrewlalis.gymboard_api.model;
+package nl.andrewlalis.gymboard_api.util;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload;
@@ -9,20 +9,24 @@ import nl.andrewlalis.gymboard_api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.auth.RoleRepository;
import nl.andrewlalis.gymboard_api.dao.auth.UserRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository;
+import nl.andrewlalis.gymboard_api.model.City;
+import nl.andrewlalis.gymboard_api.model.Country;
+import nl.andrewlalis.gymboard_api.model.GeoPoint;
+import nl.andrewlalis.gymboard_api.model.Gym;
import nl.andrewlalis.gymboard_api.model.auth.Role;
import nl.andrewlalis.gymboard_api.model.auth.User;
import nl.andrewlalis.gymboard_api.model.exercise.Exercise;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
-import nl.andrewlalis.gymboard_api.service.UploadService;
import nl.andrewlalis.gymboard_api.service.auth.UserService;
+import nl.andrewlalis.gymboard_api.service.cdn_client.CdnClient;
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
-import org.springframework.mock.web.MockMultipartFile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@@ -31,7 +35,8 @@ import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.util.function.Consumer;
+import java.util.HashSet;
+import java.util.Set;
/**
* Simple component that loads sample data that's useful when testing the application.
@@ -44,25 +49,25 @@ public class SampleDataLoader implements ApplicationListener {
exerciseRepository.save(new Exercise(record.get(0), record.get(1)));
});
@@ -108,6 +113,13 @@ public class SampleDataLoader implements ApplicationListener videoIds = new HashSet<>();
+
loadCsv("submissions", record -> {
var exercise = exerciseRepository.findById(record.get(0)).orElseThrow();
BigDecimal weight = new BigDecimal(record.get(1));
@@ -117,26 +129,34 @@ public class SampleDataLoader implements ApplicationListener removalSet = new HashSet<>();
+ for (var videoId : videoIds) {
+ String status = cdnClient.uploads.getVideoProcessingStatus(videoId).status();
+ if (status.equalsIgnoreCase("COMPLETED") || status.equalsIgnoreCase("FAILED")) {
+ removalSet.add(videoId);
+ }
+ }
+ videoIds.removeAll(removalSet);
+ Thread.sleep(1000);
+ }
+
loadCsv("users", record -> {
String email = record.get(0);
String password = record.get(1);
@@ -156,12 +176,21 @@ public class SampleDataLoader implements ApplicationListener recordConsumer) throws IOException {
+ @FunctionalInterface
+ interface ThrowableConsumer {
+ void accept(T item) throws Exception;
+ }
+
+ private void loadCsv(String csvName, ThrowableConsumer recordConsumer) throws IOException {
String path = "sample_data/" + csvName + ".csv";
log.info("Loading data from {}...", path);
var reader = new FileReader(path);
for (var record : CSVFormat.DEFAULT.parse(reader)) {
- recordConsumer.accept(record);
+ try {
+ recordConsumer.accept(record);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
}
}
}
diff --git a/gymboard-api/src/main/resources/application-development.properties b/gymboard-api/src/main/resources/application-development.properties
index a891663..580e6ab 100644
--- a/gymboard-api/src/main/resources/application-development.properties
+++ b/gymboard-api/src/main/resources/application-development.properties
@@ -16,3 +16,4 @@ spring.mail.properties.mail.smtp.timeout=10000
app.auth.private-key-location=./private_key.der
app.web-origin=http://localhost:9000
+app.cdn-origin=http://localhost:8082
diff --git a/gymboard-cdn/.gitignore b/gymboard-cdn/.gitignore
index 549e00a..15848db 100644
--- a/gymboard-cdn/.gitignore
+++ b/gymboard-cdn/.gitignore
@@ -31,3 +31,5 @@ build/
### VS Code ###
.vscode/
+
+cdn-files/
diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/Config.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/Config.java
index eb8a190..46b1121 100644
--- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/Config.java
+++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/Config.java
@@ -1,5 +1,6 @@
package nl.andrewlalis.gymboardcdn;
+import nl.andrewlalis.gymboardcdn.util.ULID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -14,6 +15,8 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
public class Config {
@Value("${app.web-origin}")
private String webOrigin;
+ @Value("${app.api-origin}")
+ private String apiOrigin;
/**
* Defines the CORS configuration for this API, which is to say that we
@@ -27,11 +30,16 @@ public class Config {
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
final CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
- // Don't do this in production, use a proper list of allowed origins
config.addAllowedOriginPattern(webOrigin);
+ config.addAllowedOriginPattern(apiOrigin);
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/**", config);
return source;
}
+
+ @Bean
+ public ULID ulid() {
+ return new ULID();
+ }
}
diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileMetadataResponse.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileMetadataResponse.java
new file mode 100644
index 0000000..6846cfa
--- /dev/null
+++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileMetadataResponse.java
@@ -0,0 +1,9 @@
+package nl.andrewlalis.gymboardcdn.api;
+
+public record FileMetadataResponse(
+ String filename,
+ String mimeType,
+ long size,
+ String uploadedAt,
+ boolean availableForDownload
+) {}
diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileUploadResponse.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileUploadResponse.java
index d120fb4..29ffb6b 100644
--- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileUploadResponse.java
+++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileUploadResponse.java
@@ -1,5 +1,5 @@
package nl.andrewlalis.gymboardcdn.api;
public record FileUploadResponse(
- String identifier
+ String id
) {}
diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/UploadController.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/UploadController.java
index daee8ac..fe8ede2 100644
--- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/UploadController.java
+++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/UploadController.java
@@ -1,9 +1,12 @@
package nl.andrewlalis.gymboardcdn.api;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboardcdn.service.UploadService;
-import org.springframework.http.MediaType;
-import org.springframework.web.bind.annotation.*;
-import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RestController;
@RestController
public class UploadController {
@@ -13,13 +16,23 @@ public class UploadController {
this.uploadService = uploadService;
}
- @PostMapping(path = "/uploads/video", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
- public FileUploadResponse uploadVideo(@RequestParam MultipartFile file) {
- return uploadService.processableVideoUpload(file);
+ @PostMapping(path = "/uploads/video", consumes = {"video/mp4"})
+ public FileUploadResponse uploadVideo(HttpServletRequest request) {
+ return uploadService.processableVideoUpload(request);
}
- @GetMapping(path = "/uploads/video/{identifier}/status")
- public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable String identifier) {
- return uploadService.getVideoProcessingStatus(identifier);
+ @GetMapping(path = "/uploads/video/{id}/status")
+ public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable String id) {
+ return uploadService.getVideoProcessingStatus(id);
+ }
+
+ @GetMapping(path = "/files/{id}")
+ public void getFile(@PathVariable String id, HttpServletResponse response) {
+ uploadService.streamFile(id, response);
+ }
+
+ @GetMapping(path = "/files/{id}/metadata")
+ public FileMetadataResponse getFileMetadata(@PathVariable String id) {
+ return uploadService.getFileMetadata(id);
}
}
diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/StoredFile.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/StoredFile.java
index b19fc62..8b87382 100644
--- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/StoredFile.java
+++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/StoredFile.java
@@ -1,6 +1,9 @@
package nl.andrewlalis.gymboardcdn.model;
-import jakarta.persistence.*;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@@ -8,9 +11,12 @@ import java.time.LocalDateTime;
@Entity
@Table(name = "stored_file")
public class StoredFile {
+ /**
+ * ULID-based unique file identifier.
+ */
@Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
+ @Column(nullable = false, updatable = false, length = 26)
+ private String id;
@CreationTimestamp
private LocalDateTime createdAt;
@@ -27,13 +33,6 @@ public class StoredFile {
@Column(nullable = false, updatable = false)
private String name;
- /**
- * The internal id that's used to find this file wherever it's placed on
- * our service's storage. It is universally unique.
- */
- @Column(nullable = false, updatable = false, unique = true)
- private String identifier;
-
/**
* The type of the file.
*/
@@ -48,15 +47,15 @@ public class StoredFile {
public StoredFile() {}
- public StoredFile(String name, String identifier, String mimeType, long size, LocalDateTime uploadedAt) {
+ public StoredFile(String id, String name, String mimeType, long size, LocalDateTime uploadedAt) {
+ this.id = id;
this.name = name;
- this.identifier = identifier;
this.mimeType = mimeType;
this.size = size;
this.uploadedAt = uploadedAt;
}
- public Long getId() {
+ public String getId() {
return id;
}
@@ -68,10 +67,6 @@ public class StoredFile {
return name;
}
- public String getIdentifier() {
- return identifier;
- }
-
public String getMimeType() {
return mimeType;
}
diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/StoredFileRepository.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/StoredFileRepository.java
index 10a8d11..d4da008 100644
--- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/StoredFileRepository.java
+++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/StoredFileRepository.java
@@ -3,10 +3,6 @@ package nl.andrewlalis.gymboardcdn.model;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
-import java.util.Optional;
-
@Repository
-public interface StoredFileRepository extends JpaRepository {
- Optional findByIdentifier(String identifier);
- boolean existsByIdentifier(String identifier);
+public interface StoredFileRepository extends JpaRepository {
}
diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/VideoProcessingTask.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/VideoProcessingTask.java
index d8fc7ea..a18752f 100644
--- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/VideoProcessingTask.java
+++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/VideoProcessingTask.java
@@ -46,7 +46,7 @@ public class VideoProcessingTask {
* The identifier that will be used to identify the final video, if it
* is processed successfully.
*/
- @Column(nullable = false)
+ @Column(nullable = false, updatable = false, length = 26)
private String videoIdentifier;
public VideoProcessingTask() {}
diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/FileService.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/FileService.java
index 7bbc614..9e6d56e 100644
--- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/FileService.java
+++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/FileService.java
@@ -1,7 +1,7 @@
package nl.andrewlalis.gymboardcdn.service;
-import nl.andrewlalis.gymboardcdn.model.StoredFileRepository;
-import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository;
+import nl.andrewlalis.gymboardcdn.model.StoredFile;
+import nl.andrewlalis.gymboardcdn.util.ULID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -13,7 +13,6 @@ import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
-import java.util.Random;
/**
* The service that manages storing and retrieving files from a base filesystem.
@@ -28,52 +27,29 @@ public class FileService {
@Value("${app.files.temp-dir}")
private String tempDir;
- private final StoredFileRepository storedFileRepository;
- private final VideoProcessingTaskRepository videoProcessingTaskRepository;
+ private final ULID ulid;
- public FileService(StoredFileRepository storedFileRepository, VideoProcessingTaskRepository videoProcessingTaskRepository) {
- this.storedFileRepository = storedFileRepository;
- this.videoProcessingTaskRepository = videoProcessingTaskRepository;
+ public FileService(ULID ulid) {
+ this.ulid = ulid;
}
- public Path getStorageDirForTime(LocalDateTime time) throws IOException {
- Path dir = getStorageDir()
+ public Path getStoragePathForFile(StoredFile file) throws IOException {
+ LocalDateTime time = file.getUploadedAt();
+ Path dir = Path.of(storageDir)
.resolve(Integer.toString(time.getYear()))
.resolve(Integer.toString(time.getMonthValue()))
.resolve(Integer.toString(time.getDayOfMonth()));
if (Files.notExists(dir)) Files.createDirectories(dir);
- return dir;
+ return dir.resolve(file.getId());
}
public String createNewFileIdentifier() {
- String ident = generateRandomIdentifier();
- int attempts = 0;
- while (storedFileRepository.existsByIdentifier(ident) || videoProcessingTaskRepository.existsByVideoIdentifier(ident)) {
- ident = generateRandomIdentifier();
- attempts++;
- if (attempts > 10) {
- log.warn("Took more than 10 attempts to generate a unique file identifier.");
- }
- if (attempts > 100) {
- log.error("Couldn't generate a unique file identifier after 100 attempts. Quitting!");
- throw new RuntimeException("Couldn't generate a unique file identifier.");
- }
- }
- return ident;
+ return ulid.nextULID();
}
- private String generateRandomIdentifier() {
- StringBuilder sb = new StringBuilder(9);
- String alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
- Random rand = new Random();
- for (int i = 0; i < 9; i++) sb.append(alphabet.charAt(rand.nextInt(alphabet.length())));
- return sb.toString();
- }
-
- public Path saveToTempFile(MultipartFile file) throws IOException {
+ public Path saveToTempFile(InputStream in, String filename) throws IOException {
Path tempDir = getTempDir();
String suffix = null;
- String filename = file.getOriginalFilename();
if (filename != null) {
int idx = filename.lastIndexOf('.');
if (idx >= 0) {
@@ -81,14 +57,12 @@ public class FileService {
}
}
Path tempFile = Files.createTempFile(tempDir, null, suffix);
- file.transferTo(tempFile);
+ try (var out = Files.newOutputStream(tempFile)) {
+ in.transferTo(out);
+ }
return tempFile;
}
- public Path saveToStorage(String filename, InputStream in) throws IOException {
- throw new RuntimeException("Not implemented!");
- }
-
private Path getStorageDir() throws IOException {
Path dir = Path.of(storageDir);
if (Files.notExists(dir)) {
diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/UploadService.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/UploadService.java
index 784379d..df551cd 100644
--- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/UploadService.java
+++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/UploadService.java
@@ -1,7 +1,11 @@
package nl.andrewlalis.gymboardcdn.service;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import nl.andrewlalis.gymboardcdn.api.FileMetadataResponse;
import nl.andrewlalis.gymboardcdn.api.FileUploadResponse;
import nl.andrewlalis.gymboardcdn.api.VideoProcessingTaskStatusResponse;
+import nl.andrewlalis.gymboardcdn.model.StoredFile;
import nl.andrewlalis.gymboardcdn.model.StoredFileRepository;
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask;
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository;
@@ -14,7 +18,9 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
+import java.nio.file.Files;
import java.nio.file.Path;
+import java.time.format.DateTimeFormatter;
@Service
public class UploadService {
@@ -32,11 +38,20 @@ public class UploadService {
this.fileService = fileService;
}
+ /**
+ * Handles uploading of a processable video file that will be processed
+ * before being stored in the system.
+ * @param request The request from which we can read the file.
+ * @return A response that contains an identifier that can be used to check
+ * the status of the video processing, and eventually fetch the video.
+ */
@Transactional
- public FileUploadResponse processableVideoUpload(MultipartFile file) {
+ public FileUploadResponse processableVideoUpload(HttpServletRequest request) {
Path tempFile;
+ String filename = request.getHeader("X-Gymboard-Filename");
+ if (filename == null) filename = "unnamed.mp4";
try {
- tempFile = fileService.saveToTempFile(file);
+ tempFile = fileService.saveToTempFile(request.getInputStream(), filename);
} catch (IOException e) {
log.error("Failed to save video upload to temp file.", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
@@ -44,17 +59,64 @@ public class UploadService {
String identifier = fileService.createNewFileIdentifier();
videoTaskRepository.save(new VideoProcessingTask(
VideoProcessingTask.Status.WAITING,
- file.getOriginalFilename(),
+ filename,
tempFile.toString(),
identifier
));
return new FileUploadResponse(identifier);
}
+ /**
+ * Gets the status of a video processing task.
+ * @param id The video identifier.
+ * @return The status of the video processing task.
+ */
@Transactional(readOnly = true)
- public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String identifier) {
- VideoProcessingTask task = videoTaskRepository.findByVideoIdentifier(identifier)
+ public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String id) {
+ VideoProcessingTask task = videoTaskRepository.findByVideoIdentifier(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return new VideoProcessingTaskStatusResponse(task.getStatus().name());
}
+
+ /**
+ * Streams the contents of a stored file to a client via the Http response.
+ * @param id The file's unique identifier.
+ * @param response The response to stream the content to.
+ */
+ @Transactional(readOnly = true)
+ public void streamFile(String id, HttpServletResponse response) {
+ StoredFile file = storedFileRepository.findById(id)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
+ response.setContentType(file.getMimeType());
+ response.setContentLengthLong(file.getSize());
+ try {
+ Path filePath = fileService.getStoragePathForFile(file);
+ try (var in = Files.newInputStream(filePath)) {
+ in.transferTo(response.getOutputStream());
+ }
+ } catch (IOException e) {
+ log.error("Failed to write file to response.", e);
+ throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ @Transactional(readOnly = true)
+ public FileMetadataResponse getFileMetadata(String id) {
+ StoredFile file = storedFileRepository.findById(id)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
+ try {
+ Path filePath = fileService.getStoragePathForFile(file);
+ boolean exists = Files.exists(filePath);
+ return new FileMetadataResponse(
+ file.getName(),
+ file.getMimeType(),
+ file.getSize(),
+ file.getUploadedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
+ exists
+ );
+ } catch (IOException e) {
+ log.error("Couldn't get path to stored file.", e);
+ throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+ }
}
diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/VideoProcessingService.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/VideoProcessingService.java
index d3e5799..66bddce 100644
--- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/VideoProcessingService.java
+++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/VideoProcessingService.java
@@ -14,7 +14,6 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
-import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
@@ -77,21 +76,21 @@ public class VideoProcessingService {
}
// And finally, copy the output to the final location.
- LocalDateTime uploadedAt = task.getCreatedAt();
try {
- Path finalFilePath = fileService.getStorageDirForTime(uploadedAt)
- .resolve(task.getVideoIdentifier());
+ StoredFile storedFile = new StoredFile(
+ task.getVideoIdentifier(),
+ task.getFilename(),
+ "video/mp4",
+ Files.size(ffmpegOutputFile),
+ task.getCreatedAt()
+ );
+ Path finalFilePath = fileService.getStoragePathForFile(storedFile);
Files.move(ffmpegOutputFile, finalFilePath);
Files.deleteIfExists(tempFile);
Files.deleteIfExists(ffmpegOutputFile);
- storedFileRepository.saveAndFlush(new StoredFile(
- task.getFilename(),
- task.getVideoIdentifier(),
- "video/mp4",
- Files.size(ffmpegOutputFile),
- uploadedAt
- ));
+ storedFileRepository.saveAndFlush(storedFile);
updateTask(task, VideoProcessingTask.Status.COMPLETED);
+ log.info("Finished processing video {}.", task.getVideoIdentifier());
} catch (IOException e) {
log.error("Failed to copy processed video to final storage location.", e);
updateTask(task, VideoProcessingTask.Status.FAILED);
@@ -113,7 +112,8 @@ public class VideoProcessingService {
Path tmpStdout = Files.createTempFile(dir, "stdout-", ".log");
Path tmpStderr = Files.createTempFile(dir, "stderr-", ".log");
final String[] command = {
- "ffmpeg", "-i", inFile.getFileName().toString(),
+ "ffmpeg",
+ "-i", inFile.getFileName().toString(),
"-vf", "scale=640x480:flags=lanczos",
"-vcodec", "libx264",
"-crf", "28",
diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/util/ULID.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/util/ULID.java
new file mode 100644
index 0000000..1819795
--- /dev/null
+++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/util/ULID.java
@@ -0,0 +1,456 @@
+package nl.andrewlalis.gymboardcdn.util;
+
+/*
+ * sulky-modules - several general-purpose modules.
+ * Copyright (C) 2007-2019 Joern Huxhorn
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+/*
+ * Copyright 2007-2019 Joern Huxhorn
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.io.Serializable;
+import java.security.SecureRandom;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Random;
+
+/*
+ * https://github.com/ulid/spec
+ */
+@SuppressWarnings("PMD.ShortClassName")
+public class ULID
+{
+ private static final char[] ENCODING_CHARS = {
+ '0','1','2','3','4','5','6','7','8','9',
+ 'A','B','C','D','E','F','G','H','J','K',
+ 'M','N','P','Q','R','S','T','V','W','X',
+ 'Y','Z',
+ };
+
+ private static final byte[] DECODING_CHARS = {
+ // 0
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ // 8
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ // 16
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ // 24
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ // 32
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ // 40
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ // 48
+ 0, 1, 2, 3, 4, 5, 6, 7,
+ // 56
+ 8, 9, -1, -1, -1, -1, -1, -1,
+ // 64
+ -1, 10, 11, 12, 13, 14, 15, 16,
+ // 72
+ 17, 1, 18, 19, 1, 20, 21, 0,
+ // 80
+ 22, 23, 24, 25, 26, -1, 27, 28,
+ // 88
+ 29, 30, 31, -1, -1, -1, -1, -1,
+ // 96
+ -1, 10, 11, 12, 13, 14, 15, 16,
+ // 104
+ 17, 1, 18, 19, 1, 20, 21, 0,
+ // 112
+ 22, 23, 24, 25, 26, -1, 27, 28,
+ // 120
+ 29, 30, 31,
+ };
+
+ private static final int MASK = 0x1F;
+ private static final int MASK_BITS = 5;
+ private static final long TIMESTAMP_OVERFLOW_MASK = 0xFFFF_0000_0000_0000L;
+ private static final long TIMESTAMP_MSB_MASK = 0xFFFF_FFFF_FFFF_0000L;
+ private static final long RANDOM_MSB_MASK = 0xFFFFL;
+
+ private final Random random;
+
+ public ULID()
+ {
+ this(new SecureRandom());
+ }
+
+ public ULID(Random random)
+ {
+ Objects.requireNonNull(random, "random must not be null!");
+ this.random = random;
+ }
+
+ public void appendULID(StringBuilder stringBuilder)
+ {
+ Objects.requireNonNull(stringBuilder, "stringBuilder must not be null!");
+ internalAppendULID(stringBuilder, System.currentTimeMillis(), random);
+ }
+
+ public String nextULID()
+ {
+ return nextULID(System.currentTimeMillis());
+ }
+
+ public String nextULID(long timestamp)
+ {
+ return internalUIDString(timestamp, random);
+ }
+
+ public Value nextValue()
+ {
+ return nextValue(System.currentTimeMillis());
+ }
+
+ public Value nextValue(long timestamp)
+ {
+ return internalNextValue(timestamp, random);
+ }
+
+ /**
+ * Returns the next monotonic value. If an overflow happened while incrementing
+ * the random part of the given previous ULID value then the returned value will
+ * have a zero random part.
+ *
+ * @param previousUlid the previous ULID value.
+ * @return the next monotonic value.
+ */
+ public Value nextMonotonicValue(Value previousUlid)
+ {
+ return nextMonotonicValue(previousUlid, System.currentTimeMillis());
+ }
+
+ /**
+ * Returns the next monotonic value. If an overflow happened while incrementing
+ * the random part of the given previous ULID value then the returned value will
+ * have a zero random part.
+ *
+ * @param previousUlid the previous ULID value.
+ * @param timestamp the timestamp of the next ULID value.
+ * @return the next monotonic value.
+ */
+ public Value nextMonotonicValue(Value previousUlid, long timestamp)
+ {
+ Objects.requireNonNull(previousUlid, "previousUlid must not be null!");
+ if(previousUlid.timestamp() == timestamp)
+ {
+ return previousUlid.increment();
+ }
+ return nextValue(timestamp);
+ }
+
+ /**
+ * Returns the next monotonic value or empty if an overflow happened while incrementing
+ * the random part of the given previous ULID value.
+ *
+ * @param previousUlid the previous ULID value.
+ * @return the next monotonic value or empty if an overflow happened.
+ */
+ public Optional nextStrictlyMonotonicValue(Value previousUlid)
+ {
+ return nextStrictlyMonotonicValue(previousUlid, System.currentTimeMillis());
+ }
+
+ /**
+ * Returns the next monotonic value or empty if an overflow happened while incrementing
+ * the random part of the given previous ULID value.
+ *
+ * @param previousUlid the previous ULID value.
+ * @param timestamp the timestamp of the next ULID value.
+ * @return the next monotonic value or empty if an overflow happened.
+ */
+ public Optional nextStrictlyMonotonicValue(Value previousUlid, long timestamp)
+ {
+ Value result = nextMonotonicValue(previousUlid, timestamp);
+ if(result.compareTo(previousUlid) < 1)
+ {
+ return Optional.empty();
+ }
+ return Optional.of(result);
+ }
+
+ public static Value parseULID(String ulidString)
+ {
+ Objects.requireNonNull(ulidString, "ulidString must not be null!");
+ if(ulidString.length() != 26)
+ {
+ throw new IllegalArgumentException("ulidString must be exactly 26 chars long.");
+ }
+
+ String timeString = ulidString.substring(0, 10);
+ long time = internalParseCrockford(timeString);
+ if ((time & TIMESTAMP_OVERFLOW_MASK) != 0)
+ {
+ throw new IllegalArgumentException("ulidString must not exceed '7ZZZZZZZZZZZZZZZZZZZZZZZZZ'!");
+ }
+ String part1String = ulidString.substring(10, 18);
+ String part2String = ulidString.substring(18);
+ long part1 = internalParseCrockford(part1String);
+ long part2 = internalParseCrockford(part2String);
+
+ long most = (time << 16) | (part1 >>> 24);
+ long least = part2 | (part1 << 40);
+ return new Value(most, least);
+ }
+
+ public static Value fromBytes(byte[] data)
+ {
+ Objects.requireNonNull(data, "data must not be null!");
+ if(data.length != 16)
+ {
+ throw new IllegalArgumentException("data must be 16 bytes in length!");
+ }
+ long mostSignificantBits = 0;
+ long leastSignificantBits = 0;
+ for (int i=0; i<8; i++)
+ {
+ mostSignificantBits = (mostSignificantBits << 8) | (data[i] & 0xff);
+ }
+ for (int i=8; i<16; i++)
+ {
+ leastSignificantBits = (leastSignificantBits << 8) | (data[i] & 0xff);
+ }
+ return new Value(mostSignificantBits, leastSignificantBits);
+ }
+
+ public static class Value
+ implements Comparable, Serializable
+ {
+ private static final long serialVersionUID = -3563159514112487717L;
+
+ /*
+ * The most significant 64 bits of this ULID.
+ */
+ private final long mostSignificantBits;
+
+ /*
+ * The least significant 64 bits of this ULID.
+ */
+ private final long leastSignificantBits;
+
+ public Value(long mostSignificantBits, long leastSignificantBits)
+ {
+ this.mostSignificantBits = mostSignificantBits;
+ this.leastSignificantBits = leastSignificantBits;
+ }
+
+ /**
+ * Returns the most significant 64 bits of this ULID's 128 bit value.
+ *
+ * @return The most significant 64 bits of this ULID's 128 bit value
+ */
+ public long getMostSignificantBits() {
+ return mostSignificantBits;
+ }
+
+ /**
+ * Returns the least significant 64 bits of this ULID's 128 bit value.
+ *
+ * @return The least significant 64 bits of this ULID's 128 bit value
+ */
+ public long getLeastSignificantBits() {
+ return leastSignificantBits;
+ }
+
+
+ public long timestamp()
+ {
+ return mostSignificantBits >>> 16;
+ }
+
+ public byte[] toBytes()
+ {
+ byte[] result=new byte[16];
+ for (int i=0; i<8; i++)
+ {
+ result[i] = (byte)((mostSignificantBits >> ((7-i)*8)) & 0xFF);
+ }
+ for (int i=8; i<16; i++)
+ {
+ result[i] = (byte)((leastSignificantBits >> ((15-i)*8)) & 0xFF);
+ }
+
+ return result;
+ }
+
+ public Value increment()
+ {
+ long lsb = leastSignificantBits;
+ if(lsb != 0xFFFF_FFFF_FFFF_FFFFL)
+ {
+ return new Value(mostSignificantBits, lsb+1);
+ }
+ long msb = mostSignificantBits;
+ if((msb & RANDOM_MSB_MASK) != RANDOM_MSB_MASK)
+ {
+ return new Value(msb + 1, 0);
+ }
+ return new Value(msb & TIMESTAMP_MSB_MASK, 0);
+ }
+
+ @Override
+ public int hashCode() {
+ long hilo = mostSignificantBits ^ leastSignificantBits;
+ return ((int)(hilo >> 32)) ^ (int) hilo;
+ }
+
+ @Override
+ public boolean equals(Object o)
+ {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ Value value = (Value) o;
+
+ return mostSignificantBits == value.mostSignificantBits
+ && leastSignificantBits == value.leastSignificantBits;
+ }
+
+ @Override
+ public int compareTo(Value val)
+ {
+ // The ordering is intentionally set up so that the ULIDs
+ // can simply be numerically compared as two numbers
+ return (this.mostSignificantBits < val.mostSignificantBits ? -1 :
+ (this.mostSignificantBits > val.mostSignificantBits ? 1 :
+ (this.leastSignificantBits < val.leastSignificantBits ? -1 :
+ (this.leastSignificantBits > val.leastSignificantBits ? 1 :
+ 0))));
+ }
+
+ @Override
+ public String toString()
+ {
+ char[] buffer = new char[26];
+
+ internalWriteCrockford(buffer, timestamp(), 10, 0);
+ long value = ((mostSignificantBits & 0xFFFFL) << 24);
+ long interim = (leastSignificantBits >>> 40);
+ value = value | interim;
+ internalWriteCrockford(buffer, value, 8, 10);
+ internalWriteCrockford(buffer, leastSignificantBits, 8, 18);
+
+ return new String(buffer);
+ }
+ }
+
+ /*
+ * http://crockford.com/wrmg/base32.html
+ */
+ static void internalAppendCrockford(StringBuilder builder, long value, int count)
+ {
+ for(int i = count-1; i >= 0; i--)
+ {
+ int index = (int)((value >>> (i * MASK_BITS)) & MASK);
+ builder.append(ENCODING_CHARS[index]);
+ }
+ }
+
+ static long internalParseCrockford(String input)
+ {
+ Objects.requireNonNull(input, "input must not be null!");
+ int length = input.length();
+ if(length > 12)
+ {
+ throw new IllegalArgumentException("input length must not exceed 12 but was "+length+"!");
+ }
+
+ long result = 0;
+ for(int i=0;i>> ((count - i - 1) * MASK_BITS)) & MASK);
+ buffer[offset+i] = ENCODING_CHARS[index];
+ }
+ }
+
+ static String internalUIDString(long timestamp, Random random)
+ {
+ checkTimestamp(timestamp);
+
+ char[] buffer = new char[26];
+
+ internalWriteCrockford(buffer, timestamp, 10, 0);
+ // could use nextBytes(byte[] bytes) instead
+ internalWriteCrockford(buffer, random.nextLong(), 8, 10);
+ internalWriteCrockford(buffer, random.nextLong(), 8, 18);
+
+ return new String(buffer);
+ }
+
+ static void internalAppendULID(StringBuilder builder, long timestamp, Random random)
+ {
+ checkTimestamp(timestamp);
+
+ internalAppendCrockford(builder, timestamp, 10);
+ // could use nextBytes(byte[] bytes) instead
+ internalAppendCrockford(builder, random.nextLong(), 8);
+ internalAppendCrockford(builder, random.nextLong(), 8);
+ }
+
+ static Value internalNextValue(long timestamp, Random random)
+ {
+ checkTimestamp(timestamp);
+ // could use nextBytes(byte[] bytes) instead
+ long mostSignificantBits = random.nextLong();
+ long leastSignificantBits = random.nextLong();
+ mostSignificantBits &= 0xFFFF;
+ mostSignificantBits |= (timestamp << 16);
+ return new Value(mostSignificantBits, leastSignificantBits);
+ }
+
+ private static void checkTimestamp(long timestamp)
+ {
+ if((timestamp & TIMESTAMP_OVERFLOW_MASK) != 0)
+ {
+ throw new IllegalArgumentException("ULID does not support timestamps after +10889-08-02T05:31:50.655Z!");
+ }
+ }
+}
diff --git a/gymboard-cdn/src/main/resources/application-development.properties b/gymboard-cdn/src/main/resources/application-development.properties
index 7b50361..543e510 100644
--- a/gymboard-cdn/src/main/resources/application-development.properties
+++ b/gymboard-cdn/src/main/resources/application-development.properties
@@ -7,5 +7,6 @@ spring.jpa.hibernate.ddl-auto=update
server.port=8082
app.web-origin=http://localhost:9000
+app.api-origin=http://localhost:8080
app.files.storage-dir=./cdn-files/
app.files.temp-dir=./cdn-files/tmp/
diff --git a/gymboard-cdn/src/test/resources/application.properties b/gymboard-cdn/src/test/resources/application.properties
index 9195bbe..b735bf1 100644
--- a/gymboard-cdn/src/test/resources/application.properties
+++ b/gymboard-cdn/src/test/resources/application.properties
@@ -12,5 +12,6 @@ spring.jpa.hibernate.ddl-auto=update
server.port=8082
app.web-origin=http://localhost:9000
+app.api-origin=http://localhost:8080
app.files.storage-dir=./test-cdn-files/
app.files.temp-dir=./test-cdn-files/tmp/