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