Add ServiceOnly annotation.
This commit is contained in:
parent
52be976286
commit
ae8595db07
|
@ -0,0 +1,39 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.config;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.method.HandlerMethod;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interceptor that checks that requests to endpoints annotated with
|
||||||
|
* {@link ServiceOnly} have a valid service secret header value.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ServiceAccessInterceptor implements HandlerInterceptor {
|
||||||
|
public static final String HEADER_NAME = "X-Gymboard-Service-Secret";
|
||||||
|
|
||||||
|
@Value("${app.service-secret}")
|
||||||
|
private String serviceSecret;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||||
|
Method handlerMethod = ((HandlerMethod) handler).getMethod();
|
||||||
|
Class<?> handlerClass = handlerMethod.getDeclaringClass();
|
||||||
|
|
||||||
|
ServiceOnly methodAnnotation = handlerMethod.getAnnotation(ServiceOnly.class);
|
||||||
|
ServiceOnly classAnnotation = handlerClass.getAnnotation(ServiceOnly.class);
|
||||||
|
if (methodAnnotation != null || classAnnotation != null) {
|
||||||
|
String secret = request.getHeader(HEADER_NAME);
|
||||||
|
if (secret == null || !secret.trim().equals(serviceSecret)) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.config;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotation that can be applied to a controller or controller method to
|
||||||
|
* restrict access to only requests from another service that provide a
|
||||||
|
* legitimate service secret.
|
||||||
|
* @see ServiceAccessInterceptor
|
||||||
|
*/
|
||||||
|
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface ServiceOnly {}
|
|
@ -22,9 +22,11 @@ public class WebComponents {
|
||||||
|
|
||||||
@Value("${app.cdn-origin}")
|
@Value("${app.cdn-origin}")
|
||||||
private String cdnOrigin;
|
private String cdnOrigin;
|
||||||
|
@Value("${app.cdn-secret}")
|
||||||
|
private String cdnSecret;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CdnClient cdnClient() {
|
public CdnClient cdnClient() {
|
||||||
return new CdnClient(cdnOrigin);
|
return new CdnClient(cdnOrigin, cdnSecret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.service.cdn_client;
|
package nl.andrewlalis.gymboard_api.domains.api.service.cdn_client;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import nl.andrewlalis.gymboard_api.config.ServiceAccessInterceptor;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
@ -13,17 +14,19 @@ import java.time.Duration;
|
||||||
public class CdnClient {
|
public class CdnClient {
|
||||||
private final HttpClient httpClient;
|
private final HttpClient httpClient;
|
||||||
private final String baseUrl;
|
private final String baseUrl;
|
||||||
|
private final String cdnSecret;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public final UploadsClient uploads;
|
public final UploadsClient uploads;
|
||||||
public final FilesClient files;
|
public final FilesClient files;
|
||||||
|
|
||||||
public CdnClient(String baseUrl) {
|
public CdnClient(String baseUrl, String cdnSecret) {
|
||||||
this.httpClient = HttpClient.newBuilder()
|
this.httpClient = HttpClient.newBuilder()
|
||||||
.connectTimeout(Duration.ofSeconds(3))
|
.connectTimeout(Duration.ofSeconds(3))
|
||||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
.build();
|
.build();
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
|
this.cdnSecret = cdnSecret;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = new ObjectMapper();
|
||||||
this.uploads = new UploadsClient(this);
|
this.uploads = new UploadsClient(this);
|
||||||
this.files = new FilesClient(this);
|
this.files = new FilesClient(this);
|
||||||
|
@ -32,6 +35,7 @@ public class CdnClient {
|
||||||
public <T> T get(String urlPath, Class<T> responseType) throws IOException, InterruptedException {
|
public <T> T get(String urlPath, Class<T> responseType) throws IOException, InterruptedException {
|
||||||
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
|
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
|
||||||
.GET()
|
.GET()
|
||||||
|
.header(ServiceAccessInterceptor.HEADER_NAME, cdnSecret)
|
||||||
.build();
|
.build();
|
||||||
HttpResponse<String> response = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||||
if (response.statusCode() == 200) {
|
if (response.statusCode() == 200) {
|
||||||
|
@ -48,6 +52,7 @@ public class CdnClient {
|
||||||
.POST(HttpRequest.BodyPublishers.ofFile(filePath))
|
.POST(HttpRequest.BodyPublishers.ofFile(filePath))
|
||||||
.header("Content-Type", contentType)
|
.header("Content-Type", contentType)
|
||||||
.header("X-Gymboard-Filename", filePath.getFileName().toString())
|
.header("X-Gymboard-Filename", filePath.getFileName().toString())
|
||||||
|
.header(ServiceAccessInterceptor.HEADER_NAME, cdnSecret)
|
||||||
.build();
|
.build();
|
||||||
HttpResponse<String> response = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||||
return objectMapper.readValue(response.body(), responseType);
|
return objectMapper.readValue(response.body(), responseType);
|
||||||
|
@ -56,6 +61,7 @@ public class CdnClient {
|
||||||
public void post(String urlPath) throws IOException, InterruptedException {
|
public void post(String urlPath) throws IOException, InterruptedException {
|
||||||
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
|
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
|
||||||
.POST(HttpRequest.BodyPublishers.noBody())
|
.POST(HttpRequest.BodyPublishers.noBody())
|
||||||
|
.header(ServiceAccessInterceptor.HEADER_NAME, cdnSecret)
|
||||||
.build();
|
.build();
|
||||||
HttpResponse<Void> response = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
|
HttpResponse<Void> response = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() != 200) {
|
||||||
|
@ -65,7 +71,9 @@ public class CdnClient {
|
||||||
|
|
||||||
public void delete(String urlPath) throws IOException, InterruptedException {
|
public void delete(String urlPath) throws IOException, InterruptedException {
|
||||||
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
|
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
|
||||||
.DELETE().build();
|
.DELETE()
|
||||||
|
.header(ServiceAccessInterceptor.HEADER_NAME, cdnSecret)
|
||||||
|
.build();
|
||||||
HttpResponse<Void> response = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
|
HttpResponse<Void> response = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
|
||||||
if (response.statusCode() >= 400) {
|
if (response.statusCode() >= 400) {
|
||||||
throw new IOException("Request failed with code " + response.statusCode());
|
throw new IOException("Request failed with code " + response.statusCode());
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.submission.controller;
|
package nl.andrewlalis.gymboard_api.domains.submission.controller;
|
||||||
|
|
||||||
|
import nl.andrewlalis.gymboard_api.config.ServiceOnly;
|
||||||
import nl.andrewlalis.gymboard_api.domains.submission.dto.SubmissionResponse;
|
import nl.andrewlalis.gymboard_api.domains.submission.dto.SubmissionResponse;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.VideoProcessingCompletePayload;
|
import nl.andrewlalis.gymboard_api.domains.api.dto.VideoProcessingCompletePayload;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
|
import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
|
||||||
|
@ -28,9 +29,8 @@ public class SubmissionController {
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(path = "/video-processing-complete")
|
@PostMapping(path = "/video-processing-complete") @ServiceOnly
|
||||||
public ResponseEntity<Void> handleVideoProcessingComplete(@RequestBody VideoProcessingCompletePayload taskStatus) {
|
public ResponseEntity<Void> handleVideoProcessingComplete(@RequestBody VideoProcessingCompletePayload taskStatus) {
|
||||||
// TODO: Validate that the request came ONLY from the CDN service.
|
|
||||||
submissionService.handleVideoProcessingComplete(taskStatus);
|
submissionService.handleVideoProcessingComplete(taskStatus);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,6 @@ import nl.andrewlalis.gymboard_api.domains.submission.model.SubmissionProperties
|
||||||
import nl.andrewlalis.gymboard_api.util.ULID;
|
import nl.andrewlalis.gymboard_api.util.ULID;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
import org.springframework.data.util.Pair;
|
import org.springframework.data.util.Pair;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
@ -37,16 +36,15 @@ public class SampleSubmissionGenerator implements SampleDataGenerator {
|
||||||
private final ExerciseRepository exerciseRepository;
|
private final ExerciseRepository exerciseRepository;
|
||||||
private final SubmissionRepository submissionRepository;
|
private final SubmissionRepository submissionRepository;
|
||||||
private final ULID ulid;
|
private final ULID ulid;
|
||||||
|
private final CdnClient cdnClient;
|
||||||
|
|
||||||
@Value("${app.cdn-origin}")
|
public SampleSubmissionGenerator(GymRepository gymRepository, UserRepository userRepository, ExerciseRepository exerciseRepository, SubmissionRepository submissionRepository, ULID ulid, CdnClient cdnClient) {
|
||||||
private String cdnOrigin;
|
|
||||||
|
|
||||||
public SampleSubmissionGenerator(GymRepository gymRepository, UserRepository userRepository, ExerciseRepository exerciseRepository, SubmissionRepository submissionRepository, ULID ulid) {
|
|
||||||
this.gymRepository = gymRepository;
|
this.gymRepository = gymRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.exerciseRepository = exerciseRepository;
|
this.exerciseRepository = exerciseRepository;
|
||||||
this.submissionRepository = submissionRepository;
|
this.submissionRepository = submissionRepository;
|
||||||
this.ulid = ulid;
|
this.ulid = ulid;
|
||||||
|
this.cdnClient = cdnClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -143,8 +141,6 @@ public class SampleSubmissionGenerator implements SampleDataGenerator {
|
||||||
* @throws Exception If an error occurs.
|
* @throws Exception If an error occurs.
|
||||||
*/
|
*/
|
||||||
private Map<Long, Pair<String, String>> generateUploads() throws Exception {
|
private Map<Long, Pair<String, String>> generateUploads() throws Exception {
|
||||||
final CdnClient cdnClient = new CdnClient(cdnOrigin);
|
|
||||||
|
|
||||||
List<Long> taskIds = new ArrayList<>();
|
List<Long> taskIds = new ArrayList<>();
|
||||||
taskIds.add(cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_curl.mp4"), "video/mp4"));
|
taskIds.add(cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_curl.mp4"), "video/mp4"));
|
||||||
taskIds.add(cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_ohp.mp4"), "video/mp4"));
|
taskIds.add(cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_ohp.mp4"), "video/mp4"));
|
||||||
|
|
|
@ -15,7 +15,9 @@ spring.mail.protocol=smtp
|
||||||
spring.mail.properties.mail.smtp.timeout=10000
|
spring.mail.properties.mail.smtp.timeout=10000
|
||||||
|
|
||||||
app.auth.private-key-location=./private_key.der
|
app.auth.private-key-location=./private_key.der
|
||||||
|
app.service-secret=testing
|
||||||
app.web-origin=http://localhost:9000
|
app.web-origin=http://localhost:9000
|
||||||
app.cdn-origin=http://localhost:8082
|
app.cdn-origin=http://localhost:8082
|
||||||
|
app.cdn-secret=testing
|
||||||
|
|
||||||
#logging.level.root=DEBUG
|
#logging.level.root=DEBUG
|
||||||
|
|
|
@ -14,6 +14,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
import org.springframework.web.filter.CorsFilter;
|
import org.springframework.web.filter.CorsFilter;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
|
@ -21,12 +23,23 @@ import java.util.concurrent.Executors;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
public class Config {
|
public class Config implements WebMvcConfigurer {
|
||||||
@Value("${app.web-origin}")
|
@Value("${app.web-origin}")
|
||||||
private String webOrigin;
|
private String webOrigin;
|
||||||
@Value("${app.api-origin}")
|
@Value("${app.api-origin}")
|
||||||
private String apiOrigin;
|
private String apiOrigin;
|
||||||
|
|
||||||
|
private final ServiceAccessInterceptor serviceAccessInterceptor;
|
||||||
|
|
||||||
|
public Config(ServiceAccessInterceptor serviceAccessInterceptor) {
|
||||||
|
this.serviceAccessInterceptor = serviceAccessInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(serviceAccessInterceptor);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CorsFilter corsFilter() {
|
public CorsFilter corsFilter() {
|
||||||
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.method.HandlerMethod;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interceptor that checks that requests to endpoints annotated with
|
||||||
|
* {@link ServiceOnly} have a valid service secret header value.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ServiceAccessInterceptor implements HandlerInterceptor {
|
||||||
|
private static final String HEADER_NAME = "X-Gymboard-Service-Secret";
|
||||||
|
|
||||||
|
@Value("${app.service-secret}")
|
||||||
|
private String serviceSecret;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||||
|
Method handlerMethod = ((HandlerMethod) handler).getMethod();
|
||||||
|
Class<?> handlerClass = handlerMethod.getDeclaringClass();
|
||||||
|
|
||||||
|
ServiceOnly methodAnnotation = handlerMethod.getAnnotation(ServiceOnly.class);
|
||||||
|
ServiceOnly classAnnotation = handlerClass.getAnnotation(ServiceOnly.class);
|
||||||
|
if (methodAnnotation != null || classAnnotation != null) {
|
||||||
|
String secret = request.getHeader(HEADER_NAME);
|
||||||
|
if (secret == null || !secret.trim().equals(serviceSecret)) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotation that can be applied to a controller or controller method to
|
||||||
|
* restrict access to only requests from another service that provide a
|
||||||
|
* legitimate service secret.
|
||||||
|
* @see ServiceAccessInterceptor
|
||||||
|
*/
|
||||||
|
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface ServiceOnly {}
|
|
@ -1,6 +1,7 @@
|
||||||
package nl.andrewlalis.gymboardcdn.files;
|
package nl.andrewlalis.gymboardcdn.files;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import nl.andrewlalis.gymboardcdn.ServiceOnly;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
@ -37,9 +38,8 @@ public class FileController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping(path = "/files/{id}")
|
@DeleteMapping(path = "/files/{id}") @ServiceOnly
|
||||||
public void deleteFile(@PathVariable String id) {
|
public void deleteFile(@PathVariable String id) {
|
||||||
// TODO: Secure this so only API can access it!
|
|
||||||
try {
|
try {
|
||||||
fileStorageService.delete(id);
|
fileStorageService.delete(id);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package nl.andrewlalis.gymboardcdn.uploads.api;
|
package nl.andrewlalis.gymboardcdn.uploads.api;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import nl.andrewlalis.gymboardcdn.ServiceOnly;
|
||||||
import nl.andrewlalis.gymboardcdn.uploads.service.UploadService;
|
import nl.andrewlalis.gymboardcdn.uploads.service.UploadService;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
@ -20,7 +21,7 @@ public class UploadController {
|
||||||
return uploadService.processableVideoUpload(request);
|
return uploadService.processableVideoUpload(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(path = "/uploads/video/{taskId}/start")
|
@PostMapping(path = "/uploads/video/{taskId}/start") @ServiceOnly
|
||||||
public void startVideoProcessing(@PathVariable long taskId) {
|
public void startVideoProcessing(@PathVariable long taskId) {
|
||||||
uploadService.startVideoProcessing(taskId);
|
uploadService.startVideoProcessing(taskId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.model;
|
||||||
|
|
||||||
|
public record VideoProcessingTaskStatusUpdate(
|
||||||
|
long taskId,
|
||||||
|
String status,
|
||||||
|
String videoFileId,
|
||||||
|
String thumbnailFileId
|
||||||
|
) {
|
||||||
|
public VideoProcessingTaskStatusUpdate(VideoProcessingTask task) {
|
||||||
|
this(task.getId(), task.getStatus().name(), task.getVideoFileId(), task.getThumbnailFileId());
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,11 +2,11 @@ package nl.andrewlalis.gymboardcdn.uploads.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
||||||
import nl.andrewlalis.gymboardcdn.files.FileMetadata;
|
import nl.andrewlalis.gymboardcdn.files.FileMetadata;
|
||||||
import nl.andrewlalis.gymboardcdn.files.FileStorageService;
|
import nl.andrewlalis.gymboardcdn.files.FileStorageService;
|
||||||
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTask;
|
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTask;
|
||||||
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskRepository;
|
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskRepository;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskStatusUpdate;
|
||||||
import nl.andrewlalis.gymboardcdn.uploads.service.process.ThumbnailGenerator;
|
import nl.andrewlalis.gymboardcdn.uploads.service.process.ThumbnailGenerator;
|
||||||
import nl.andrewlalis.gymboardcdn.uploads.service.process.VideoProcessor;
|
import nl.andrewlalis.gymboardcdn.uploads.service.process.VideoProcessor;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
@ -42,6 +42,9 @@ public class VideoProcessingService {
|
||||||
@Value("${app.api-origin}")
|
@Value("${app.api-origin}")
|
||||||
private String apiOrigin;
|
private String apiOrigin;
|
||||||
|
|
||||||
|
@Value("${app.api-secret}")
|
||||||
|
private String apiSecret;
|
||||||
|
|
||||||
public VideoProcessingService(Executor videoProcessingExecutor,
|
public VideoProcessingService(Executor videoProcessingExecutor,
|
||||||
VideoProcessingTaskRepository taskRepo,
|
VideoProcessingTaskRepository taskRepo,
|
||||||
FileStorageService fileStorageService,
|
FileStorageService fileStorageService,
|
||||||
|
@ -80,15 +83,18 @@ public class VideoProcessingService {
|
||||||
for (var task : oldTasks) {
|
for (var task : oldTasks) {
|
||||||
if (task.getStatus() == VideoProcessingTask.Status.COMPLETED) {
|
if (task.getStatus() == VideoProcessingTask.Status.COMPLETED) {
|
||||||
log.info("Deleting completed task {}.", task.getId());
|
log.info("Deleting completed task {}.", task.getId());
|
||||||
|
deleteAllTaskFiles(task);
|
||||||
taskRepo.delete(task);
|
taskRepo.delete(task);
|
||||||
} else if (task.getStatus() == VideoProcessingTask.Status.FAILED) {
|
} else if (task.getStatus() == VideoProcessingTask.Status.FAILED) {
|
||||||
log.info("Deleting failed task {}.", task.getId());
|
log.info("Deleting failed task {}.", task.getId());
|
||||||
taskRepo.delete(task);
|
taskRepo.delete(task);
|
||||||
} else if (task.getStatus() == VideoProcessingTask.Status.IN_PROGRESS) {
|
} else if (task.getStatus() == VideoProcessingTask.Status.IN_PROGRESS) {
|
||||||
log.info("Task {} was in progress for too long; deleting.", task.getId());
|
log.info("Task {} was in progress for too long; deleting.", task.getId());
|
||||||
|
deleteAllTaskFiles(task);
|
||||||
taskRepo.delete(task);
|
taskRepo.delete(task);
|
||||||
} else if (task.getStatus() == VideoProcessingTask.Status.WAITING) {
|
} else if (task.getStatus() == VideoProcessingTask.Status.WAITING) {
|
||||||
log.info("Task {} was waiting for too long; deleting.", task.getId());
|
log.info("Task {} was waiting for too long; deleting.", task.getId());
|
||||||
|
deleteAllTaskFiles(task);
|
||||||
taskRepo.delete(task);
|
taskRepo.delete(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -156,32 +162,21 @@ public class VideoProcessingService {
|
||||||
log.error("Failed to copy processed video to final storage location.", e);
|
log.error("Failed to copy processed video to final storage location.", e);
|
||||||
updateTask(task, VideoProcessingTask.Status.FAILED);
|
updateTask(task, VideoProcessingTask.Status.FAILED);
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
deleteAllTaskFiles(task);
|
||||||
fileStorageService.delete(task.getUploadFileId());
|
|
||||||
Files.deleteIfExists(rawUploadFile);
|
|
||||||
Files.deleteIfExists(videoFile);
|
|
||||||
Files.deleteIfExists(thumbnailFile);
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("Couldn't delete temporary output files for uploaded video {}", uploadFile);
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends an update message to the Gymboard API when a task finishes its
|
* Sends an update message to the Gymboard API when a task finishes its
|
||||||
* processing.
|
* processing. Note that Gymboard API will also eventually poll the CDN's
|
||||||
|
* own API to get task status if we fail to send it, so there's some
|
||||||
|
* redundancy built-in.
|
||||||
* @param task The task to send.
|
* @param task The task to send.
|
||||||
*/
|
*/
|
||||||
private void sendTaskCompleteToApi(VideoProcessingTask task) {
|
private void sendTaskCompleteToApi(VideoProcessingTask task) {
|
||||||
ObjectNode obj = objectMapper.createObjectNode();
|
|
||||||
obj.put("taskId", task.getId());
|
|
||||||
obj.put("status", task.getStatus().name());
|
|
||||||
obj.put("videoFileId", task.getVideoFileId());
|
|
||||||
obj.put("thumbnailFileId", task.getThumbnailFileId());
|
|
||||||
String json;
|
String json;
|
||||||
try {
|
try {
|
||||||
json = objectMapper.writeValueAsString(obj);
|
json = objectMapper.writeValueAsString(new VideoProcessingTaskStatusUpdate(task));
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
log.error("JSON error while sending task data to API for task " + task.getId(), e);
|
log.error("JSON error while sending task data to API for task " + task.getId(), e);
|
||||||
return;
|
return;
|
||||||
|
@ -189,6 +184,7 @@ public class VideoProcessingService {
|
||||||
HttpClient httpClient = HttpClient.newBuilder().build();
|
HttpClient httpClient = HttpClient.newBuilder().build();
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(apiOrigin + "/submissions/video-processing-complete"))
|
HttpRequest request = HttpRequest.newBuilder(URI.create(apiOrigin + "/submissions/video-processing-complete"))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Gymboard-Service-Secret", apiSecret)
|
||||||
.timeout(Duration.ofSeconds(3))
|
.timeout(Duration.ofSeconds(3))
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(json))
|
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||||
.build();
|
.build();
|
||||||
|
@ -201,4 +197,33 @@ public class VideoProcessingService {
|
||||||
log.error("Failed to send HTTP request to API.", e);
|
log.error("Failed to send HTTP request to API.", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to delete all temporary files related to a task's
|
||||||
|
* processing operations. If the task is FAILED, then files are kept for
|
||||||
|
* debugging purposes.
|
||||||
|
* @param task The task to delete files for.
|
||||||
|
*/
|
||||||
|
private void deleteAllTaskFiles(VideoProcessingTask task) {
|
||||||
|
if (task.getStatus() == VideoProcessingTask.Status.FAILED) {
|
||||||
|
log.warn("Retaining files for failed task {}, upload id {}.", task.getId(), task.getUploadFileId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path dir = fileStorageService.getStoragePathForFile(task.getUploadFileId()).getParent();
|
||||||
|
try (var s = Files.list(dir)) {
|
||||||
|
var files = s.toList();
|
||||||
|
for (var file : files) {
|
||||||
|
String filename = file.getFileName().toString().strip();
|
||||||
|
if (Files.isRegularFile(file) && filename.startsWith(task.getUploadFileId())) {
|
||||||
|
try {
|
||||||
|
Files.delete(file);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to delete file " + file + " related to task " + task.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to list files in " + dir + " when deleting files for task " + task.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,9 @@ spring.jpa.hibernate.ddl-auto=update
|
||||||
|
|
||||||
server.port=8082
|
server.port=8082
|
||||||
|
|
||||||
|
# A secret header token that other services must provide to use service-only endpoints.
|
||||||
|
app.service-secret=testing
|
||||||
|
|
||||||
app.web-origin=http://localhost:9000
|
app.web-origin=http://localhost:9000
|
||||||
app.api-origin=http://localhost:8080
|
app.api-origin=http://localhost:8080
|
||||||
app.files.storage-dir=./cdn-files/
|
app.api-secret=testing
|
||||||
app.files.temp-dir=./cdn-files/tmp/
|
|
||||||
|
|
Loading…
Reference in New Issue