ProxyController.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.constraints.Pattern;
import jasper.aop.ClearIdle;
import jasper.domain.Ref;
import jasper.domain.proj.HasOrigin;
import jasper.errors.NotFoundException;
import jasper.service.ProxyService;
import jasper.service.dto.RefDto;
import org.hibernate.validator.constraints.Length;
import org.springframework.beans.factory.annotation.Autowired;
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.DeleteMapping;
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.RequestHeader;
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.servlet.mvc.method.annotation.StreamingResponseBody;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import static jasper.domain.Ref.URL_LEN;
import static jasper.domain.proj.HasOrigin.ORIGIN_LEN;
import static java.lang.Long.parseLong;
import static org.apache.commons.io.FilenameUtils.getName;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM;
import static org.springframework.http.MediaType.parseMediaType;
@ClearIdle
@RestController
@RequestMapping("api/v1/proxy")
@Validated
@Tag(name = "Proxy")
@ApiResponses({
@ApiResponse(responseCode = "500", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
@ApiResponse(responseCode = "503", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
})
public class ProxyController {
@Autowired
ProxyService proxyService;
@ApiResponses({
@ApiResponse(responseCode = "200"),
})
@GetMapping({"prefetch", "prefetch/{filename:.+}"})
ResponseEntity<String> preFetch(
@RequestParam @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url,
@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
@RequestParam(defaultValue = "false") boolean thumbnail,
@PathVariable(required = false) String filename
) {
proxyService.preFetch(url, origin, thumbnail);
return ResponseEntity.noContent()
.cacheControl(CacheControl.maxAge(100, TimeUnit.DAYS).cachePrivate())
.build();
}
@ApiResponses({
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "206"),
@ApiResponse(responseCode = "404"),
@ApiResponse(responseCode = "416", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
})
@GetMapping({"", "{filename:.+}"})
ResponseEntity<StreamingResponseBody> fetch(
@RequestHeader(value = HttpHeaders.RANGE, required = false) String rangeHeader,
@RequestParam @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url,
@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
@RequestParam(defaultValue = "false") boolean thumbnail,
@PathVariable(required = false) String filename
) {
var is = proxyService.fetch(url, origin, thumbnail);
if (is == null) throw new NotFoundException(url);
var ref = proxyService.stat(url, origin, thumbnail);
var cache = proxyService.cache(url, origin, thumbnail);
if (isBlank(filename) || filename.equals(url)) {
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 contentLength = cache != null ? cache.getContentLength() : null;
var contentType = cache != null && isNotBlank(cache.getMimeType())
? parseMediaType(cache.getMimeType())
: APPLICATION_OCTET_STREAM;
var contentDisposition = "inline; filename*=UTF-8''" +
URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
if (rangeHeader != null && contentLength != null && rangeHeader.startsWith("bytes=")) {
try {
return handleRangeRequest(is, rangeHeader, contentLength, contentType, contentDisposition);
} catch (NumberFormatException e) {
// RFC 7233 Section 3.1: Ignore syntactically invalid range headers and return full content
// Fall through to return full content below
}
}
var responseBuilder = ResponseEntity.ok();
if (contentLength != null) {
responseBuilder.header(HttpHeaders.ACCEPT_RANGES, "bytes");
responseBuilder.contentLength(contentLength);
} else {
responseBuilder.header(HttpHeaders.ACCEPT_RANGES, "none");
}
return responseBuilder
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.contentType(contentType)
.cacheControl(CacheControl.maxAge(100, TimeUnit.DAYS).cachePrivate())
.body(outputStream -> streamContent(is, outputStream, 0, null));
}
private ResponseEntity<StreamingResponseBody> handleRangeRequest(InputStream is, String rangeHeader, long contentLength, MediaType contentType, String contentDisposition) throws NumberFormatException {
// Parse "bytes=start-end" (end is optional)
var rangeValue = rangeHeader.substring("bytes=".length());
var ranges = rangeValue.split("-");
var start = isBlank(ranges[0])
? contentLength - parseLong(ranges[1])
: parseLong(ranges[0]);
var end = ranges.length > 1 && isNotBlank(ranges[0]) && isNotBlank(ranges[1])
? parseLong(ranges[1])
: contentLength - 1;
// RFC 7233: If end >= contentLength, adjust to contentLength - 1
if (end >= contentLength) {
end = contentLength - 1;
}
// RFC 7233: If suffix-byte-range-spec exceeds content length, clamp start to 0
if (start < 0) {
start = 0;
}
// Only return 416 if start is beyond content or start > end after adjustment
if (start >= contentLength || start > end) {
try { is.close(); } catch (IOException ignored) { }
return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
.header(HttpHeaders.CONTENT_RANGE, "bytes */" + contentLength)
.build();
}
long rangeStart = start;
long rangeLength = end - start + 1;
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + contentLength)
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.contentLength(rangeLength)
.contentType(contentType)
.cacheControl(CacheControl.maxAge(100, TimeUnit.DAYS).cachePrivate())
.body(outputStream -> streamContent(is, outputStream, rangeStart, rangeLength));
}
private void streamContent(InputStream is, OutputStream outputStream, long skip, Long length) throws IOException {
try (is) {
if (skip > 0) {
is.skipNBytes(skip);
}
var buffer = new byte[64 * 1024];
int bytesRead;
var remaining = length != null ? length : Long.MAX_VALUE;
while (remaining > 0 && (bytesRead = is.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) {
outputStream.write(buffer, 0, bytesRead);
remaining -= bytesRead;
}
}
}
@ApiResponses({
@ApiResponse(responseCode = "201"),
})
@ResponseStatus(HttpStatus.CREATED)
@PostMapping
RefDto save(
@RequestParam(required = false) String title,
@RequestParam(required = false) String mime,
@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin,
InputStream data
) throws IOException {
return proxyService.save(origin, title, data, mime);
}
@ApiResponses({
@ApiResponse(responseCode = "204"),
})
@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping
void clearDeleted(
@RequestParam(defaultValue = "") @Length(max = ORIGIN_LEN) @Pattern(regexp = HasOrigin.REGEX) String origin
) {
proxyService.clearDeleted(origin);
}
}