Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# Agent Instructions

## GitHub CLI (`gh`) Usage
When running `gh` commands in this project via an automated agent environment, ensure you bypass the default `GITHUB_TOKEN` environment variable. The agent environment may have an invalid `GITHUB_TOKEN` set, which `gh` prioritizes over valid keyring credentials, resulting in an `HTTP 401: Bad credentials` error.
## Project Context
When working on this project, always read the [`README.md`](README.md) file to obtain full context on project architecture, setup instructions, and features.

## GitHub CLI (`gh`) & Git Usage
- Always run `git` commands (e.g., `git status`, `git diff`, `git add`, `git commit`, `git push`) and `gh` commands unsandboxed (setting `BypassSandbox: true` when calling `run_command`) to ensure git hooks, local tools, and remote repository authentication work without sandbox errors.
- When running `gh` commands in this project via an automated agent environment, ensure you bypass the default `GITHUB_TOKEN` environment variable. The agent environment may have an invalid `GITHUB_TOKEN` set, which `gh` prioritizes over valid keyring credentials, resulting in an `HTTP 401: Bad credentials` error.

**Workaround:** Prefix `gh` commands with `env -u GITHUB_TOKEN` to force the CLI to use the valid keyring authentication.

Expand All @@ -23,3 +27,11 @@ env -u GITHUB_TOKEN gh pr create --title "..." --body "..."
- Upgraded to Uppy v5.
- Do **not** call `uppy.run()`. This method was deprecated/removed. Simply instantiating Uppy with `new Uppy()` and using plugins compiles and registers events automatically.
- CSS styles must be imported from `@uppy/core/css/style.min.css` and `@uppy/dashboard/css/style.min.css` rather than the old `dist` structure or the legacy unified `uppy` css bundle.

### 3. String Comparisons & Avoiding Deprecated StringUtils
- Do not use deprecated `StringUtils` comparison methods such as `StringUtils.equals(...)` or `StringUtils.equalsIgnoreCase(...)`.
- Always use `org.apache.commons.lang3.Strings.CS` for case-sensitive operations (e.g., `Strings.CS.equals(...)`, `Strings.CS.startsWith(...)`) and `org.apache.commons.lang3.Strings.CI` for case-insensitive operations (e.g., `Strings.CI.equals(...)`, `Strings.CI.startsWith(...)`).

### 4. Maven Build Execution & Unsandboxed Mode
- Always run Maven build commands unsandboxed (e.g., setting `BypassSandbox: true` when calling `run_command`) to allow access to local Maven repository (`~/.m2`) and dependency resolution.

7 changes: 6 additions & 1 deletion spring-boot-rest/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@
<dependency>
<groupId>me.desair.tus</groupId>
<artifactId>tus-java-server</artifactId>
<version>1.0.0-3.2</version>
<version>2.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.18.0</version>
</dependency>
<dependency>
<groupId>io.tus.java.client</groupId>
Expand Down
17 changes: 15 additions & 2 deletions spring-boot-rest/src/main/java/me/desair/spring/tus/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.ContextRefreshedEvent;
Expand All @@ -22,13 +23,17 @@ public class App implements ApplicationListener<ContextRefreshedEvent> {
@Value("${tus.server.data.directory}")
protected String tusDataPath;

@Value("#{servletContext.contextPath}")
@Value("${server.servlet.context-path:/test}")
private String servletContextPath;

@Value("${tus.server.max-upload-size:1073741824}")
private Long maxUploadSize;

@Override
public void onApplicationEvent(@NonNull ContextRefreshedEvent event) {
LOG.info("=======================================");
LOG.info("App running with active profiles: {}", springProfilesActive);
LOG.info("Max upload size configured: {} bytes", maxUploadSize);
LOG.info("=======================================");
}

Expand All @@ -40,8 +45,16 @@ public static void main(String[] args) {
public TusFileUploadService tusFileUploadService() {
return new TusFileUploadService()
.withStoragePath(tusDataPath)
.withDownloadFeature()
.withUploadUri(servletContextPath + "/api/upload")
.withMinAppendSize(32L)
.withMaxUploadSize(maxUploadSize)
.withThreadLocalCache(true);
}

@Bean
public TomcatServletWebServerFactory tomcatFactory(TusFileUploadService tusFileUploadService) {
TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
factory.addContextValves(new TusInterimResponseTomcatValve(tusFileUploadService));
return factory;
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package me.desair.spring.tus;

import java.io.IOException;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import me.desair.tus.server.TusFileUploadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
Expand All @@ -14,19 +12,28 @@

@Controller
@RequestMapping(value = "/api/upload")
//access Cros
// access Cros
@CrossOrigin(origins = "*")
public class FileUploadController {

@Autowired
private TusFileUploadService tusFileUploadService;

@RequestMapping(value = {"", "/**"}, method = {RequestMethod.POST, RequestMethod.PATCH, RequestMethod.HEAD,
RequestMethod.DELETE, RequestMethod.OPTIONS, RequestMethod.GET})
public void processUpload(final HttpServletRequest servletRequest, final HttpServletResponse servletResponse) throws IOException {
tusFileUploadService.process(servletRequest, servletResponse);
//access response header Location,Upload-Offset,Upload-length
servletResponse.addHeader("Access-Control-Expose-Headers","Location,Upload-Offset,Upload-Length");
}
@Autowired private TusFileUploadService tusFileUploadService;

@RequestMapping(
value = {"", "/**"},
method = {
RequestMethod.POST,
RequestMethod.PUT,
RequestMethod.PATCH,
RequestMethod.HEAD,
RequestMethod.DELETE,
RequestMethod.OPTIONS,
RequestMethod.GET
})
public void processUpload(
final HttpServletRequest servletRequest, final HttpServletResponse servletResponse)
throws IOException {
tusFileUploadService.process(servletRequest, servletResponse);
servletResponse.addHeader(
"Access-Control-Expose-Headers", "Location,Upload-Offset,Upload-Length");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package me.desair.spring.tus;

import jakarta.servlet.ServletException;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import me.desair.tus.server.TusFileUploadService;
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.catalina.valves.ValveBase;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Tomcat Valve implementation that inspects incoming HTTP requests using {@link
* TusFileUploadService#getRawInterimResponse(jakarta.servlet.http.HttpServletRequest, String)} and
* writes raw HTTP 104 interim response frames directly to Tomcat's underlying TCP socket before
* servlet execution.
*
* <h2>Architectural Reasoning & Container Limitations</h2>
*
* <p>Standard Servlet specification (Jakarta Servlet 6.0) and Tomcat Catalina container APIs
* enforce a strict HTTP response lifecycle (Status Line &rarr; Headers &rarr; Response Body) via
* {@link jakarta.servlet.http.HttpServletResponse} and {@link jakarta.servlet.ServletOutputStream}.
* Attempting to write bytes via standard output streams will treat those bytes as part of the
* response body after committing status code 200, rather than emitting preliminary 1xx
* informational response lines (such as {@code HTTP/1.1 104 Resumption}).
*
* <p>While Tomcat provides internal APIs like {@code Response#sendAcknowledgement()} (via Coyote
* {@code ActionCode.ACK}), that mechanism is hardcoded exclusively for HTTP 100 Continue frames.
* Tomcat 10 / Servlet 6.0 does not expose a public, non-reflective API for writing arbitrary raw
* 1xx interim status frames directly to the TCP socket before servlet dispatch.
*
* <h2>Reflection & Performance Optimization</h2>
*
* <p>To emit raw HTTP 104 frames, this valve retrieves Tomcat's low-level {@code SocketWrapperBase}
* from Coyote's internal {@code Http11OutputBuffer}. To eliminate per-request reflection overhead,
* target {@link Field} and {@link Method} instances are resolved and cached statically upon first
* execution.
*
* <h2>Production Best Practices & Upgrade Path</h2>
*
* <ul>
* <li><b>Edge Proxies / Gateways:</b> In production environments, preliminary 1xx responses (such
* as 103 Early Hints or 104 Resumption) are typically emitted at the Edge Proxy / API Gateway
* layer (e.g. Nginx, HAProxy, Envoy, Cloudflare), which operates directly on raw TCP streams
* without risking servlet container state machine desynchronization.
* <li><b>Servlet 6.2 / Tomcat 12+:</b> Standardized APIs like {@code
* HttpServletResponse#sendEarlyHints()} in future Servlet specifications provide native 1xx
* support, allowing reflection-free interim response emission once adopted.
* </ul>
*/
public class TusInterimResponseTomcatValve extends ValveBase {

private static final Logger LOG = LoggerFactory.getLogger(TusInterimResponseTomcatValve.class);

// Cached reflection fields and methods to avoid per-request lookup overhead
private static Field outputBufferField;
private static Field socketWrapperField;
private static Method writeMethod;
private static Method flushMethod;

private final TusFileUploadService tusFileUploadService;

/**
* Constructs a new Tomcat Valve for emitting HTTP 104 interim responses.
*
* @param tusFileUploadService the upload service used to generate raw interim response frames
*/
public TusInterimResponseTomcatValve(TusFileUploadService tusFileUploadService) {
this.tusFileUploadService = tusFileUploadService;
}

@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
if (tusFileUploadService != null) {
try {
// Step 1: Inspect the incoming request to check if a 104 interim response frame is needed
String rawInterimResponse =
tusFileUploadService.getRawInterimResponse(request.getRequest(), null);

if (rawInterimResponse != null) {
byte[] bytes = rawInterimResponse.getBytes(StandardCharsets.UTF_8);

// Step 2: Write raw HTTP 104 bytes directly to Tomcat's underlying SocketWrapperBase
boolean written = writeToSocketWrapper(response, bytes);
if (written) {
LOG.debug(
"Emitted raw HTTP 104 Interim Response via Tomcat SocketWrapper for request URI:"
+ " {}",
request.getRequestURI());
} else {
LOG.warn("Could not obtain Tomcat SocketWrapper to emit 104 interim response");
}
}
} catch (Exception e) {
LOG.warn("Error emitting HTTP 104 interim response in Tomcat Valve", e);
}
}

// Step 3: Continue normal request processing down Tomcat's pipeline to the target servlet
getNext().invoke(request, response);
}

/**
* Writes raw bytes directly to Tomcat's underlying {@code SocketWrapperBase}.
*
* <p>ponytail: Tomcat encapsulates SocketWrapperBase inside Http11OutputBuffer without exposing a
* public raw 1xx writing API in Tomcat 10. Reflection lookups are cached to eliminate per-request
* reflection overhead. Upgrade path: Replace with Tomcat 12+ sendEarlyHints / Servlet 6.2 1xx
* APIs once standard.
*
* @param response Tomcat's Catalina Response connector object
* @param bytes the UTF-8 encoded HTTP 104 frame bytes to write
* @return true if successfully written and flushed to the socket; false otherwise
*/
private boolean writeToSocketWrapper(Response response, byte[] bytes) {
try {
// Access low-level Coyote response object
org.apache.coyote.Response coyoteResponse = response.getCoyoteResponse();

// Extract low-level output buffer object (e.g. Http11OutputBuffer)
Object outputBuffer = getOutputBuffer(coyoteResponse);
if (outputBuffer != null) {

// Extract SocketWrapperBase instance from output buffer
Object socketWrapper = getSocketWrapper(outputBuffer);
if (socketWrapper != null) {

// Invoke socketWrapper.write(...) and socketWrapper.flush(...)
return invokeWriteAndFlush(socketWrapper, bytes);
}
}
} catch (Exception e) {
LOG.warn("Failed to write to Tomcat SocketWrapperBase via reflection", e);
}
return false;
}

/**
* Resolves and caches the private {@code outputBuffer} field on {@link
* org.apache.coyote.Response}.
*
* @param coyoteResponse the low-level Coyote response instance
* @return the internal output buffer object, or null if unresolvable
* @throws Exception if reflection access fails
*/
private Object getOutputBuffer(org.apache.coyote.Response coyoteResponse) throws Exception {
if (outputBufferField == null) {
Field field = org.apache.coyote.Response.class.getDeclaredField("outputBuffer");
field.setAccessible(true);
outputBufferField = field;
}
return outputBufferField.get(coyoteResponse);
}

/**
* Traverses the class hierarchy of the output buffer to resolve and cache the {@code
* socketWrapper} field.
*
* @param outputBuffer the output buffer instance (e.g., Http11OutputBuffer)
* @return the SocketWrapperBase instance, or null if unresolvable
* @throws Exception if reflection access fails
*/
private Object getSocketWrapper(Object outputBuffer) throws Exception {
if (socketWrapperField == null
|| !socketWrapperField.getDeclaringClass().isAssignableFrom(outputBuffer.getClass())) {
Field field = findDeclaredField(outputBuffer.getClass(), "socketWrapper");
if (field != null) {
field.setAccessible(true);
socketWrapperField = field;
}
}
return socketWrapperField != null ? socketWrapperField.get(outputBuffer) : null;
}

/**
* Resolves and invokes {@code write(boolean block, byte[] b, int off, int len)} and {@code
* flush(boolean block)} on Tomcat's low-level {@code SocketWrapperBase}.
*
* @param socketWrapper the SocketWrapperBase instance
* @param bytes the raw HTTP bytes to transmit
* @return true if write and flush succeeded
* @throws Exception if reflection invocation fails
*/
private boolean invokeWriteAndFlush(Object socketWrapper, byte[] bytes) throws Exception {
if (writeMethod == null) {
// Resolve SocketWrapperBase.write(boolean block, byte[] b, int off, int len)
writeMethod =
socketWrapper
.getClass()
.getMethod("write", boolean.class, byte[].class, int.class, int.class);
}
if (flushMethod == null) {
// Resolve SocketWrapperBase.flush(boolean block)
flushMethod = socketWrapper.getClass().getMethod("flush", boolean.class);
}

// Write bytes synchronously (block = true) directly into socket buffer
writeMethod.invoke(socketWrapper, true, bytes, 0, bytes.length);

// Flush socket buffer immediately to send raw 104 frame across the wire
flushMethod.invoke(socketWrapper, true);
return true;
}

/**
* Helper utility to search for a declared field up through a class inheritance hierarchy.
*
* @param clazz the target class
* @param fieldName the name of the field to find
* @return the Field instance if found, or null
*/
private static Field findDeclaredField(Class<?> clazz, String fieldName) {
Class<?> current = clazz;
while (current != null) {
try {
return current.getDeclaredField(fieldName);
} catch (NoSuchFieldException e) {
current = current.getSuperclass();
}
}
return null;
}
}
Loading
Loading