mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9422d4de66 | ||
|
|
b4da7639e3 |
@@ -10,7 +10,6 @@ import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
@@ -27,6 +26,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileBackedResource;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
@@ -85,7 +85,10 @@ public class InternalApiClient {
|
||||
*
|
||||
* @param endpointPath API path (e.g. {@code /api/v1/general/rotate-pdf})
|
||||
* @param body multipart form body (fileInput + parameters)
|
||||
* @return response with the result file as a {@link TempFileResource} body
|
||||
* @return response whose body is an unmanaged {@link TempFileBackedResource}. The backing temp
|
||||
* file survives {@code InputStream#close()} so callers can read it multiple times (e.g.
|
||||
* chain it into the next pipeline step). Cleanup is handled by the pipeline's registered
|
||||
* temp-file tracker or the background registry sweep.
|
||||
*/
|
||||
public ResponseEntity<Resource> post(String endpointPath, MultiValueMap<String, Object> body) {
|
||||
validateUrl(endpointPath);
|
||||
@@ -114,7 +117,8 @@ public class InternalApiClient {
|
||||
tempFile.getPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
String filename = extractFilename(response.getHeaders());
|
||||
TempFileResource resource = new TempFileResource(tempFile, filename);
|
||||
TempFileBackedResource resource =
|
||||
TempFileBackedResource.unmanaged(tempFile, filename);
|
||||
return ResponseEntity.status(response.getStatusCode())
|
||||
.headers(response.getHeaders())
|
||||
.body(resource);
|
||||
@@ -184,35 +188,4 @@ public class InternalApiClient {
|
||||
"Internal API dispatch not permitted for endpoint: " + endpointPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link FileSystemResource} that holds a reference to its backing {@link TempFile}.
|
||||
*
|
||||
* <p>If a display filename is supplied (typically parsed from the upstream response's {@code
|
||||
* Content-Disposition} header), it is returned from {@link #getFilename()} instead of the
|
||||
* underlying temp file's path-based name.
|
||||
*/
|
||||
public static class TempFileResource extends FileSystemResource {
|
||||
private final TempFile tempFile;
|
||||
private final String displayFilename;
|
||||
|
||||
public TempFileResource(TempFile tempFile) {
|
||||
this(tempFile, null);
|
||||
}
|
||||
|
||||
public TempFileResource(TempFile tempFile, String displayFilename) {
|
||||
super(tempFile.getFile());
|
||||
this.tempFile = tempFile;
|
||||
this.displayFilename = displayFilename;
|
||||
}
|
||||
|
||||
public TempFile getTempFile() {
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return displayFilename != null ? displayFilename : super.getFilename();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* {@link Resource} backed by a {@link TempFile}, with an optional auto-delete-on-close lifecycle.
|
||||
*
|
||||
* <p>Two flavours are exposed via static factories:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #managed(TempFile)} — single-use. {@link #getInputStream()} wraps the stream so the
|
||||
* backing {@link TempFile} is deleted when the stream is closed. Intended for terminal
|
||||
* consumers (e.g. Spring's {@code ResourceHttpMessageConverter} writing an HTTP response
|
||||
* body). Callers that need to re-read the body must copy it first.
|
||||
* <li>{@link #unmanaged(TempFile, String)} — multi-use. {@link #getInputStream()} returns the
|
||||
* plain file stream without attaching cleanup. The backing file lives until an external owner
|
||||
* (typically {@link TempFileRegistry}'s background sweep or an explicit pipeline tracker)
|
||||
* deletes it. Intended for intermediate results that need to be read more than once — for
|
||||
* example the body of an internal API call that will be streamed as input into the next
|
||||
* pipeline step.
|
||||
* </ul>
|
||||
*
|
||||
* <p>When a display filename is supplied (typically parsed from an upstream response's {@code
|
||||
* Content-Disposition} header), it is returned from {@link #getFilename()} instead of the
|
||||
* underlying temp file's path-based name.
|
||||
*
|
||||
* <p><b>Managed-mode failure handling:</b> if {@code super.getInputStream()} throws while opening
|
||||
* the file, the backing {@link TempFile} is closed before the exception propagates so we never leak
|
||||
* temp files along the error path. Read failures mid-body are logged and rethrown — they do not
|
||||
* suppress the cleanup performed on {@link InputStream#close()}.
|
||||
*/
|
||||
@Slf4j
|
||||
public class TempFileBackedResource extends FileSystemResource {
|
||||
|
||||
private final TempFile tempFile;
|
||||
private final String displayFilename;
|
||||
private final boolean autoDeleteOnClose;
|
||||
|
||||
/**
|
||||
* Create a managed resource whose backing temp file is deleted when the returned input stream
|
||||
* is closed.
|
||||
*/
|
||||
public static TempFileBackedResource managed(TempFile tempFile) {
|
||||
return new TempFileBackedResource(tempFile, null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an unmanaged resource. The returned input stream leaves the backing temp file in place
|
||||
* on close; lifetime is the caller's responsibility.
|
||||
*/
|
||||
public static TempFileBackedResource unmanaged(TempFile tempFile) {
|
||||
return new TempFileBackedResource(tempFile, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an unmanaged resource with a display filename returned from {@link #getFilename()}.
|
||||
*/
|
||||
public static TempFileBackedResource unmanaged(TempFile tempFile, String displayFilename) {
|
||||
return new TempFileBackedResource(tempFile, displayFilename, false);
|
||||
}
|
||||
|
||||
private TempFileBackedResource(
|
||||
TempFile tempFile, String displayFilename, boolean autoDeleteOnClose) {
|
||||
super(tempFile.getFile());
|
||||
this.tempFile = tempFile;
|
||||
this.displayFilename = displayFilename;
|
||||
this.autoDeleteOnClose = autoDeleteOnClose;
|
||||
}
|
||||
|
||||
public TempFile getTempFile() {
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return displayFilename != null ? displayFilename : super.getFilename();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() throws IOException {
|
||||
if (!autoDeleteOnClose) {
|
||||
return super.getInputStream();
|
||||
}
|
||||
InputStream source;
|
||||
try {
|
||||
source = super.getInputStream();
|
||||
} catch (IOException e) {
|
||||
// Opening the input stream already failed; make sure we don't leak the temp file.
|
||||
try {
|
||||
tempFile.close();
|
||||
} catch (Exception closeEx) {
|
||||
e.addSuppressed(closeEx);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return new ClosingInputStream(source, tempFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream wrapper that deletes its backing {@link TempFile} on close. Logs — but does not
|
||||
* swallow — any IOException that happens while reading the body, so upstream handlers can
|
||||
* surface the failure to the client.
|
||||
*/
|
||||
private static final class ClosingInputStream extends FilterInputStream {
|
||||
|
||||
private final TempFile tempFile;
|
||||
private boolean closed;
|
||||
|
||||
ClosingInputStream(InputStream delegate, TempFile tempFile) {
|
||||
super(delegate);
|
||||
this.tempFile = tempFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
try {
|
||||
return super.read();
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Failed to read temp response body {} while streaming to client",
|
||||
tempFile.getAbsolutePath(),
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) throws IOException {
|
||||
try {
|
||||
return super.read(b, off, len);
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Failed to read temp response body {} while streaming to client",
|
||||
tempFile.getAbsolutePath(),
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
try {
|
||||
super.close();
|
||||
} finally {
|
||||
try {
|
||||
tempFile.close();
|
||||
} catch (Exception closeEx) {
|
||||
log.warn(
|
||||
"Failed to clean up temp file {} after streaming response",
|
||||
tempFile.getAbsolutePath(),
|
||||
closeEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URLEncoder;
|
||||
@@ -10,7 +9,6 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -20,9 +18,6 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class WebResponseUtils {
|
||||
|
||||
public static ResponseEntity<byte[]> baosToWebResponse(
|
||||
@@ -125,12 +120,12 @@ public class WebResponseUtils {
|
||||
/**
|
||||
* Convert a {@link TempFile} into a web response with an explicit media type.
|
||||
*
|
||||
* <p>The returned {@link ResponseEntity} carries a {@link ManagedTempFileResource} as its body.
|
||||
* Spring's {@code ResourceHttpMessageConverter} calls {@link Resource#getInputStream()} once
|
||||
* and closes the returned stream after writing — at which point the underlying {@link TempFile}
|
||||
* is deleted. Everything runs synchronously on the request thread, so write failures propagate
|
||||
* through normal Spring error handling and are logged, rather than silently truncating the
|
||||
* response.
|
||||
* <p>The returned {@link ResponseEntity} carries a {@link TempFileBackedResource} (managed
|
||||
* flavour) as its body. Spring's {@code ResourceHttpMessageConverter} calls {@link
|
||||
* Resource#getInputStream()} once and closes the returned stream after writing — at which point
|
||||
* the underlying {@link TempFile} is deleted. Everything runs synchronously on the request
|
||||
* thread, so write failures propagate through normal Spring error handling and are logged,
|
||||
* rather than silently truncating the response.
|
||||
*
|
||||
* @param outputTempFile The temporary file to be sent as a response.
|
||||
* @param docName The name of the document.
|
||||
@@ -148,7 +143,7 @@ public class WebResponseUtils {
|
||||
headers.setContentLength(len);
|
||||
headers.setContentDispositionFormData("attachment", encodeAttachmentName(docName));
|
||||
|
||||
Resource body = new ManagedTempFileResource(outputTempFile);
|
||||
Resource body = TempFileBackedResource.managed(outputTempFile);
|
||||
return new ResponseEntity<>(body, headers, HttpStatus.OK);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
try {
|
||||
@@ -166,108 +161,4 @@ public class WebResponseUtils {
|
||||
.matcher(URLEncoder.encode(docName, StandardCharsets.UTF_8))
|
||||
.replaceAll("%20");
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link Resource} backed by a {@link TempFile}. The underlying temp file is deleted when the
|
||||
* response {@code InputStream} is closed — i.e. after Spring has finished writing the body. Any
|
||||
* {@link IOException} during the copy is logged via {@link ClosingInputStream} and propagates
|
||||
* through Spring's normal error path. Since response headers are committed before the body
|
||||
* transfer begins, a mid-body failure manifests as a server-side log entry plus an aborted
|
||||
* connection rather than a silently-truncated success — which is the behaviour this class was
|
||||
* added to restore.
|
||||
*
|
||||
* <p><b>Single-use contract:</b> {@link #getInputStream()} is intended to be called once by
|
||||
* Spring's {@code ResourceHttpMessageConverter} on the normal write path. After the returned
|
||||
* stream is closed the backing temp file is deleted, so subsequent {@code getInputStream()}
|
||||
* calls will either see a deleted file (tests that mock {@link TempFile#close()} are an
|
||||
* exception) or fail at read time. Callers that need to re-read the body must copy it first.
|
||||
*/
|
||||
public static final class ManagedTempFileResource extends FileSystemResource {
|
||||
|
||||
private final TempFile tempFile;
|
||||
|
||||
public ManagedTempFileResource(TempFile tempFile) {
|
||||
super(tempFile.getFile());
|
||||
this.tempFile = tempFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() throws IOException {
|
||||
InputStream source;
|
||||
try {
|
||||
source = super.getInputStream();
|
||||
} catch (IOException e) {
|
||||
// Opening the input stream already failed; make sure we don't leak the temp file.
|
||||
try {
|
||||
tempFile.close();
|
||||
} catch (Exception closeEx) {
|
||||
e.addSuppressed(closeEx);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return new ClosingInputStream(source, tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream wrapper that deletes its backing {@link TempFile} on close. Logs — but does not
|
||||
* swallow — any IOException that happens while reading the body, so upstream handlers can
|
||||
* surface the failure to the client.
|
||||
*/
|
||||
private static final class ClosingInputStream extends FilterInputStream {
|
||||
|
||||
private final TempFile tempFile;
|
||||
private boolean closed;
|
||||
|
||||
ClosingInputStream(InputStream delegate, TempFile tempFile) {
|
||||
super(delegate);
|
||||
this.tempFile = tempFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
try {
|
||||
return super.read();
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Failed to read temp response body {} while streaming to client",
|
||||
tempFile.getAbsolutePath(),
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) throws IOException {
|
||||
try {
|
||||
return super.read(b, off, len);
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Failed to read temp response body {} while streaming to client",
|
||||
tempFile.getAbsolutePath(),
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
try {
|
||||
super.close();
|
||||
} finally {
|
||||
try {
|
||||
tempFile.close();
|
||||
} catch (Exception closeEx) {
|
||||
log.warn(
|
||||
"Failed to clean up temp file {} after streaming response",
|
||||
tempFile.getAbsolutePath(),
|
||||
closeEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,8 +123,7 @@ class WebResponseUtilsTest {
|
||||
File backing = tempFile.getFile();
|
||||
assertTrue(backing.exists(), "precondition: backing file should exist");
|
||||
|
||||
WebResponseUtils.ManagedTempFileResource resource =
|
||||
new WebResponseUtils.ManagedTempFileResource(tempFile);
|
||||
TempFileBackedResource resource = TempFileBackedResource.managed(tempFile);
|
||||
|
||||
byte[] readBack;
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
@@ -139,8 +138,7 @@ class WebResponseUtilsTest {
|
||||
|
||||
@Test
|
||||
void closingInputStream_propagatesReadFailure() throws IOException {
|
||||
// ManagedTempFileResource is final, so we can't swap in a mock underlying stream.
|
||||
// Instead: open the resource's stream, close the inner FileInputStream early
|
||||
// Open the resource's stream, close the inner FileInputStream early
|
||||
// (by closing the outer stream once), then confirm reading the now-closed stream
|
||||
// throws — exercising ClosingInputStream's read() catch/log/rethrow path. Finally
|
||||
// confirm the temp file is still cleaned up on close().
|
||||
@@ -149,8 +147,7 @@ class WebResponseUtilsTest {
|
||||
File backing = tempFile.getFile();
|
||||
assertTrue(backing.exists());
|
||||
|
||||
WebResponseUtils.ManagedTempFileResource resource =
|
||||
new WebResponseUtils.ManagedTempFileResource(tempFile);
|
||||
TempFileBackedResource resource = TempFileBackedResource.managed(tempFile);
|
||||
|
||||
InputStream in = resource.getInputStream();
|
||||
// Pre-close the underlying stream to guarantee read() throws.
|
||||
@@ -188,12 +185,39 @@ class WebResponseUtilsTest {
|
||||
spying.getFile().delete(),
|
||||
"precondition: delete backing so super.getInputStream() fails");
|
||||
|
||||
WebResponseUtils.ManagedTempFileResource resource =
|
||||
new WebResponseUtils.ManagedTempFileResource(spying);
|
||||
TempFileBackedResource resource = TempFileBackedResource.managed(spying);
|
||||
|
||||
assertThrows(IOException.class, resource::getInputStream);
|
||||
assertTrue(
|
||||
closed.get(),
|
||||
"tempFile.close() must run when super.getInputStream() fails on open");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmanagedTempFileResource_leavesBackingFileIntactAfterClose() throws IOException {
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile(".bin");
|
||||
byte[] payload = "intermediate-pipeline-result".getBytes(StandardCharsets.UTF_8);
|
||||
Files.write(tempFile.getPath(), payload);
|
||||
File backing = tempFile.getFile();
|
||||
|
||||
TempFileBackedResource resource = TempFileBackedResource.unmanaged(tempFile, "result.bin");
|
||||
|
||||
byte[] firstRead;
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
firstRead = in.readAllBytes();
|
||||
}
|
||||
assertArrayEquals(payload, firstRead);
|
||||
assertTrue(backing.exists(), "unmanaged resource must not delete backing file on close");
|
||||
|
||||
// Second read — must still work because the unmanaged stream doesn't destroy state.
|
||||
byte[] secondRead;
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
secondRead = in.readAllBytes();
|
||||
}
|
||||
assertArrayEquals(payload, secondRead);
|
||||
assertTrue(backing.exists());
|
||||
|
||||
// displayFilename is returned from getFilename().
|
||||
assertEquals("result.bin", resource.getFilename());
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -38,6 +38,7 @@ import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileBackedResource;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -166,7 +167,7 @@ public class ConvertPdfJsonController {
|
||||
.header("X-Job-Id", scopedJobKey)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.contentLength(Files.size(tempOut.getPath()))
|
||||
.body(new WebResponseUtils.ManagedTempFileResource(tempOut));
|
||||
.body(TempFileBackedResource.managed(tempOut));
|
||||
} catch (IOException | RuntimeException e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
|
||||
+4
-5
@@ -29,6 +29,7 @@ import stirling.software.SPDF.model.PipelineOperation;
|
||||
import stirling.software.SPDF.model.PipelineResult;
|
||||
import stirling.software.SPDF.service.ApiDocService;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.util.TempFileBackedResource;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.ZipExtractionUtils;
|
||||
|
||||
@@ -125,8 +126,7 @@ public class PipelineProcessor {
|
||||
// this
|
||||
// file
|
||||
if (response.getBody()
|
||||
instanceof
|
||||
InternalApiClient.TempFileResource tempFileResource) {
|
||||
instanceof TempFileBackedResource tempFileResource) {
|
||||
result.addTempFile(tempFileResource.getTempFile());
|
||||
}
|
||||
|
||||
@@ -204,8 +204,7 @@ public class PipelineProcessor {
|
||||
}
|
||||
}
|
||||
ResponseEntity<Resource> response = internalApiClient.post(operation, body);
|
||||
if (response.getBody()
|
||||
instanceof InternalApiClient.TempFileResource tempFileResource) {
|
||||
if (response.getBody() instanceof TempFileBackedResource tempFileResource) {
|
||||
result.addTempFile(tempFileResource.getTempFile());
|
||||
}
|
||||
// Handle the response
|
||||
@@ -284,7 +283,7 @@ public class PipelineProcessor {
|
||||
response.getBody(), tempFileManager, result::addTempFile));
|
||||
} else {
|
||||
final Resource tempResource = response.getBody();
|
||||
if (tempResource instanceof InternalApiClient.TempFileResource tfr) {
|
||||
if (tempResource instanceof TempFileBackedResource tfr) {
|
||||
result.addTempFile(tfr.getTempFile());
|
||||
}
|
||||
Resource outputResource =
|
||||
|
||||
Reference in New Issue
Block a user