ReplicateController.java

package jasper.web.rest;

import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Pattern;
import jasper.client.JasperClient;
import jasper.component.ConfigCache;
import jasper.config.Props;
import jasper.domain.Ext;
import jasper.domain.Plugin;
import jasper.domain.Ref;
import jasper.domain.Ref_;
import jasper.domain.Template;
import jasper.domain.User;
import jasper.domain.proj.HasOrigin;
import jasper.errors.NotFoundException;
import jasper.errors.TooLargeException;
import jasper.repository.filter.RefFilter;
import jasper.repository.filter.TagFilter;
import jasper.service.ExtService;
import jasper.service.PluginService;
import jasper.service.ProxyService;
import jasper.service.RefService;
import jasper.service.TemplateService;
import jasper.service.UserService;
import jasper.service.dto.DtoMapper;
import jasper.service.dto.ExtDto;
import jasper.service.dto.PluginDto;
import jasper.service.dto.RefReplDto;
import jasper.service.dto.TemplateDto;
import jasper.service.dto.UserDto;
import org.hibernate.validator.constraints.Length;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.TimeUnit;

import static jasper.client.JasperClient.jasperHeaders;
import static jasper.domain.Ref.URL_LEN;
import static jasper.domain.proj.HasOrigin.ORIGIN_LEN;
import static jasper.repository.filter.Query.QUERY_LEN;
import static org.apache.commons.io.FilenameUtils.getName;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.springframework.data.domain.Sort.by;

@CrossOrigin
@RestController
@RequestMapping("pub/api/v1/repl")
@Validated
@Tag(name = "Repl")
@ApiResponses({
	@ApiResponse(responseCode = "400", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
	@ApiResponse(responseCode = "403", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
})
public class ReplicateController {
	private static final Logger logger = LoggerFactory.getLogger(ReplicateController.class);

	@Autowired
	Props props;

	@Autowired
	ConfigCache configs;

	@Autowired
	JasperClient jasperClient;

	@Autowired
	DtoMapper mapper;

	@Autowired
	RefService refService;

	@Autowired
	ExtService extService;

	@Autowired
	PluginService pluginService;

	@Autowired
	TemplateService templateService;

	@Autowired
	UserService userService;

	@Autowired
	ProxyService proxyService;

	@ApiResponses({
		@ApiResponse(responseCode = "200"),
	})
	@GetMapping("ref")
	List<RefReplDto> ref(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
		@RequestParam(required = false) @Length(max = QUERY_LEN) @Pattern(regexp = RefFilter.QUERY) String query,
		@RequestParam(required = false) Instant modifiedAfter,
		@RequestParam(defaultValue = "500") int size
	) {
		if (size > configs.root().getMaxReplEntityBatch()) throw new TooLargeException(size, configs.root().getMaxReplEntityBatch());
		return refService.page(
				RefFilter.builder()
					.origin(origin)
					.query(query)
					.modifiedAfter(modifiedAfter)
					.build(),
				PageRequest.of(0, size, by(Ref_.MODIFIED)))
			.map(mapper::dtoToRepl)
			.getContent();
	}

	@ApiResponses({
		@ApiResponse(responseCode = "200"),
	})
	@GetMapping("ref/cursor")
	Instant refCursor(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin
	) {
		return refService.cursor(origin);
	}

	@ApiResponses({
		@ApiResponse(responseCode = "204"),
	})
	@PostMapping("ref")
	void refPush(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
		@RequestBody @Valid List<Ref> refs
	) {
		logger.debug("Receiving push of {} refs", refs.size());
		RuntimeException first = null;
		for (var ref : refs) {
			try {
				ref.setOrigin(origin);
				refService.push(ref);
			} catch (RuntimeException e) {
				// TODO: Ignore auth errors?
				first = first == null ? e : first;
			}
		}
		if (first != null) throw first;
	}

	@ApiResponses({
		@ApiResponse(responseCode = "200"),
	})
	@GetMapping("ext")
	List<ExtDto> ext(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
		@RequestParam(required = false) @Length(max = QUERY_LEN) @Pattern(regexp = RefFilter.QUERY) String query,
		@RequestParam(required = false) Instant modifiedAfter,
		@RequestParam(defaultValue = "500") int size
	) {
		if (size > configs.root().getMaxReplEntityBatch()) throw new TooLargeException(size, configs.root().getMaxReplEntityBatch());
		return extService.page(
				TagFilter.builder()
					.origin(origin)
					.query(query)
					.modifiedAfter(modifiedAfter)
					.build(),
				PageRequest.of(0, size, by(Ref_.MODIFIED)))
			.getContent();
	}

	@ApiResponses({
		@ApiResponse(responseCode = "200"),
	})
	@GetMapping("ext/cursor")
	Instant extCursor(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin
	) {
		return extService.cursor(origin);
	}

	@ApiResponses({
		@ApiResponse(responseCode = "204"),
	})
	@PostMapping("ext")
	void extPush(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
		@RequestBody @Valid List<Ext> exts
	) {
		logger.debug("Receiving push of {} exts", exts.size());
		RuntimeException first = null;
		for (var ext : exts) {
			try {
				ext.setOrigin(origin);
				extService.push(ext);
			} catch (RuntimeException e) {
				// TODO: Ignore auth errors?
				first = first == null ? e : first;
			}
		}
		if (first != null) throw first;
	}

	@ApiResponses({
		@ApiResponse(responseCode = "200"),
	})
	@GetMapping("user")
	List<UserDto> user(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
		@RequestParam(required = false) @Length(max = QUERY_LEN) @Pattern(regexp = RefFilter.QUERY) String query,
		@RequestParam(required = false) Instant modifiedAfter,
		@RequestParam(defaultValue = "500") int size
	) {
		if (size > configs.root().getMaxReplEntityBatch()) throw new TooLargeException(size, configs.root().getMaxReplEntityBatch());
		return userService.page(
				TagFilter.builder()
					.origin(origin)
					.query(query)
					.modifiedAfter(modifiedAfter)
					.build(),
				PageRequest.of(0, size, by(Ref_.MODIFIED)))
			.getContent();
	}

	@ApiResponses({
		@ApiResponse(responseCode = "200"),
	})
	@GetMapping("user/cursor")
	Instant userCursor(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin
	) {
		return userService.cursor(origin);
	}

	@ApiResponses({
		@ApiResponse(responseCode = "204"),
	})
	@PostMapping("user")
	void userPush(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
		@RequestBody @Valid List<User> users
	) {
		logger.debug("Receiving push of {} users", users.size());
		RuntimeException first = null;
		for (var user : users) {
			try {
				user.setOrigin(origin);
				userService.push(user);
			} catch (RuntimeException e) {
				// TODO: Ignore auth errors?
				first = first == null ? e : first;
			}
		}
		if (first != null) throw first;
	}

	@ApiResponses({
		@ApiResponse(responseCode = "200"),
	})
	@GetMapping("plugin")
	List<PluginDto> plugin(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
		@RequestParam(required = false) @Length(max = QUERY_LEN) @Pattern(regexp = RefFilter.QUERY) String query,
		@RequestParam(required = false) Instant modifiedAfter,
		@RequestParam(defaultValue = "500") int size
	) {
		if (size > configs.root().getMaxReplEntityBatch()) throw new TooLargeException(size, configs.root().getMaxReplEntityBatch());
		return pluginService.page(
				TagFilter.builder()
					.origin(origin)
					.query(query)
					.modifiedAfter(modifiedAfter)
					.build(),
				PageRequest.of(0, size, by(Ref_.MODIFIED)))
			.getContent();
	}

	@ApiResponses({
		@ApiResponse(responseCode = "200"),
	})
	@GetMapping("plugin/cursor")
	Instant pluginCursor(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin
	) {
		return pluginService.cursor(origin);
	}

	@ApiResponses({
		@ApiResponse(responseCode = "204"),
	})
	@PostMapping("plugin")
	void pluginPush(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
		@RequestBody @Valid List<Plugin> plugins
	) {
		logger.debug("Receiving push of {} plugins", plugins.size());
		RuntimeException first = null;
		for (var plugin : plugins) {
			try {
				plugin.setOrigin(origin);
				pluginService.push(plugin);
			} catch (RuntimeException e) {
				// TODO: Ignore auth errors?
				first = first == null ? e : first;
			}
		}
		if (first != null) throw first;
	}

	@ApiResponses({
		@ApiResponse(responseCode = "200"),
	})
	@GetMapping("template")
	List<TemplateDto> template(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
		@RequestParam(required = false) @Length(max = QUERY_LEN) @Pattern(regexp = RefFilter.QUERY) String query,
		@RequestParam(required = false) Instant modifiedAfter,
		@RequestParam(defaultValue = "500") int size
	) {
		if (size > configs.root().getMaxReplEntityBatch()) throw new TooLargeException(size, configs.root().getMaxReplEntityBatch());
		return templateService.page(
				TagFilter.builder()
					.origin(origin)
					.query(query)
					.modifiedAfter(modifiedAfter)
					.build(),
				PageRequest.of(0, size, by(Ref_.MODIFIED)))
			.getContent();
	}

	@ApiResponses({
		@ApiResponse(responseCode = "200"),
	})
	@GetMapping("template/cursor")
	Instant templateCursor(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin
	) {
		return templateService.cursor(origin);
	}

	@ApiResponses({
		@ApiResponse(responseCode = "204"),
	})
	@PostMapping("template")
	void templatePush(
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
		@RequestBody @Valid List<Template> templates
	) {
		logger.debug("Receiving push of {} templates", templates.size());
		RuntimeException first = null;
		for (var template : templates) {
			try {
				template.setOrigin(origin);
				templateService.push(template);
			} catch (RuntimeException e) {
				// TODO: Ignore auth errors?
				first = first == null ? e : first;
			}
		}
		if (first != null) throw first;
	}

	@ApiResponses({
		@ApiResponse(responseCode = "200"),
		@ApiResponse(responseCode = "404"),
		@ApiResponse(responseCode = "500", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
	})
	@GetMapping("cache")
	ResponseEntity<StreamingResponseBody> fetch(
		WebRequest request,
		@RequestParam @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url,
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin
	) throws URISyntaxException, IOException {
		InputStream is;
		if (isNotBlank(props.getCacheApi())) {
			is = jasperClient.fetch(new URI(props.getCacheApi()), jasperHeaders(request), url, origin).getBody().getInputStream();
		} else {
			is = proxyService.fetchIfExists(url, origin);
		}
		if (is == null) throw new NotFoundException(url);
		var ref = proxyService.stat(url, origin, false);
		String filename = "file";
		try {
			filename
				= isNotBlank(getName(new URI(url).getPath())) ? getName(new URI(url).getPath())
				: ref != null && isNotBlank(ref.getTitle()) ? ref.getTitle()
				: filename;
		} catch (URISyntaxException ignored) { }
		var response = ResponseEntity.ok();
		var cache = proxyService.cache(url, origin, false);
		if (cache != null && cache.getContentLength() != null) response.contentLength(cache.getContentLength());
		return response
			.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"))
			.contentType(cache != null && isNotBlank(cache.getMimeType()) ? MediaType.parseMediaType(cache.getMimeType()) : MediaType.APPLICATION_OCTET_STREAM)
			.cacheControl(CacheControl.maxAge(100, TimeUnit.DAYS).cachePrivate())
			.body(outputStream -> {
				try (is) {
					byte[] buffer = new byte[64 * 1024];
					int bytesRead;
					while ((bytesRead = is.read(buffer)) != -1) {
						outputStream.write(buffer, 0, bytesRead);
					}
				}
			});
	}

	@ApiResponses({
		@ApiResponse(responseCode = "201"),
		@ApiResponse(responseCode = "500", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
	})
	@ResponseStatus(HttpStatus.CREATED)
	@PutMapping("cache")
	void push(
		WebRequest request,
		@RequestParam @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url,
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
		InputStream data
	) throws IOException, URISyntaxException {
		if (isNotBlank(props.getCacheApi())) {
			jasperClient.push(new URI(props.getCacheApi()), jasperHeaders(request), url, origin, data.readAllBytes());
		} else {
			proxyService.push(url, origin, data);
		}
	}

	@ApiResponses({
		@ApiResponse(responseCode = "201"),
		@ApiResponse(responseCode = "500", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
	})
	@ResponseStatus(HttpStatus.CREATED)
	@PostMapping("cache")
	RefReplDto save(
		WebRequest request,
		@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
		@RequestParam(required = false) String title,
		@RequestParam(required = false) String mime,
		InputStream data
	) throws IOException, URISyntaxException {
		if (isNotBlank(props.getCacheApi())) {
			return jasperClient.save(new URI(props.getCacheApi()), jasperHeaders(request), origin, title, mime, data.readAllBytes());
		} else {
			return mapper.dtoToRepl(proxyService.save(origin, title, data, mime));
		}
	}
}