Dropbin File Sharing API
Build a file upload and sharing service that streams multipart uploads into MinIO without buffering, sniffs content types, verifies checksums and hands out expiring share links backed by presigned URLs.
Revise first: File I/O & SerializationJava SecurityREST API Design, Validation & ControllerAdviceJava Testing (JUnit, Mockito)
File handling is where InputStream stops being an abstraction and becomes the reason your service survives a 4 GB upload on a 512 MB container. You will build an API where small files stream through the service straight into S3-compatible storage (MinIO locally), large files go directly from the client to storage through presigned URLs, and every file can be shared through an expiring link. The rule for the whole project: no code path may call readAllBytes() on user data, and the only large allocation is one fixed upload part buffer.
Target behaviour
# small file: streamed through the service
curl -F "file=@report.pdf" -H "Authorization: Bearer $T" localhost:8080/v1/files
# 201 {"id":"f_91c","name":"report.pdf","size":48213,"contentType":"application/pdf",
# "sha256":"9f2c...","status":"PENDING_SCAN"}
# the scan hook flips the status asynchronously
curl -H "Authorization: Bearer $T" localhost:8080/v1/files/f_91c
# 200 {"id":"f_91c","status":"CLEAN",...}
# oversize upload is cut off while streaming
curl -F "file=@4gb.iso" -H "Authorization: Bearer $T" localhost:8080/v1/files
# 413 {"error":"payload_too_large","limitBytes":104857600}
# large file: ask for a presigned URL, upload directly, then confirm
curl -XPOST -d '{"name":"video.mp4","size":734003200,"sha256":"ab12..."}' \
-H "Content-Type: application/json" -H "Authorization: Bearer $T" localhost:8080/v1/files/presign
# 200 {"id":"f_a07","uploadUrl":"http://localhost:9000/dropbin/u1/f_a07?X-Amz-...","expiresInSeconds":900}
curl -XPOST -H "Authorization: Bearer $T" localhost:8080/v1/files/f_a07/complete
# 200 {"status":"PENDING_SCAN"} (409 checksum_mismatch if bytes differ)
# expiring share link
curl -XPOST -d '{"ttlSeconds":3600,"maxDownloads":1}' -H "Content-Type: application/json" \
-H "Authorization: Bearer $T" localhost:8080/v1/files/f_91c/share
# 201 {"url":"http://localhost:8080/s/Zk3...","expiresAt":"2026-10-10T11:00:00Z"}
curl -i localhost:8080/s/Zk3... # 302 Location: <presigned GET, 60 s>
curl -i localhost:8080/s/Zk3... # 410 Gone (download budget used)Architecture
client --multipart--> [UploadController] --InputStream--> [S3BlobStore] --parts--> MinIO
| | sha256 + size + Tika sniff (8 MiB buffer) ^
|--presign--> [FileService] --presignPutObject-------------------------------------+
|--PUT bytes directly to MinIO (never touches the service)
| PostgreSQL: file, share_link
| | event after commit
| [ScanHook] --> status CLEAN | INFECTED
'--GET /s/{token}--> [ShareController] --302--> presigned GET (60 s)Proxying bytes through the service costs bandwidth, memory and request timeouts, so use it only for small files where you must inspect content. Presigned URLs push the transfer to storage, and the service still decides who may upload, how big, and when the object is trusted. Share links are random database tokens rather than signed blobs because you want revocation and a download counter.
The core of it
Spring parses multipart eagerly into temp files before your controller runs. Turn that off and read the request stream yourself with Commons FileUpload 2, whose iterator hands you each part as a live stream.
spring.servlet.multipart.enabled=false
server.tomcat.max-swallow-size=-1
dropbin.max-upload-bytes=104857600@PostMapping("/v1/files")
public ResponseEntity<FileView> upload(HttpServletRequest req, @AuthenticationPrincipal Jwt jwt)
throws IOException {
if (!JakartaServletFileUpload.isMultipartContent(req)) throw new BadRequestException("multipart required");
var it = new JakartaServletFileUpload<>().getItemIterator(req);
while (it.hasNext()) {
FileItemInput item = it.next();
if (item.isFormField() || !"file".equals(item.getFieldName())) continue;
String key = jwt.getSubject() + "/" + UUID.randomUUID(); // never the client filename
StoredObject obj = store.put(key, item.getInputStream(), maxBytes);
return ResponseEntity.status(201).body(files.register(jwt.getSubject(), key,
sanitize(item.getName()), obj));
}
throw new BadRequestException("no file part");
}S3 needs the content length up front, but a streamed upload does not know it. Use S3 multipart upload: fill one buffer, upload it as a part, repeat. Hash, count and sniff on the way through, in the same single pass.
public StoredObject put(String key, InputStream in, long max) throws IOException {
MessageDigest sha = newSha256();
byte[] buf = new byte[PART_SIZE]; // 8 MiB, the only big allocation
String uploadId = s3.createMultipartUpload(b -> b.bucket(bucket).key(key)).uploadId();
List<CompletedPart> parts = new ArrayList<>();
long total = 0; String type = null; int n;
try {
while ((n = in.readNBytes(buf, 0, buf.length)) > 0) { // fills the buffer, unlike read()
total += n;
if (total > max) throw new PayloadTooLargeException(max);
if (type == null) type = sniffer.detect(buf, n);
sha.update(buf, 0, n);
int no = parts.size() + 1;
String etag = s3.uploadPart(b -> b.bucket(bucket).key(key).uploadId(uploadId).partNumber(no),
RequestBody.fromInputStream(new ByteArrayInputStream(buf, 0, n), n)).eTag();
parts.add(CompletedPart.builder().partNumber(no).eTag(etag).build());
}
if (total == 0) throw new BadRequestException("empty file");
s3.completeMultipartUpload(b -> b.bucket(bucket).key(key).uploadId(uploadId)
.multipartUpload(m -> m.parts(parts)));
return new StoredObject(total, HexFormat.of().formatHex(sha.digest()), type);
} catch (IOException | RuntimeException e) {
s3.abortMultipartUpload(b -> b.bucket(bucket).key(key).uploadId(uploadId));
throw e;
}
}Content type comes from the bytes: ContentSniffer runs Tika on the first 8 KB of the first part and rejects anything outside an allowlist with 415; the multipart header the client controls is ignored.
For large files the service only mints a URL. Pin the size and content type into the signature so the client cannot swap them.
public PresignedUpload presign(String owner, PresignRequest r) {
String key = owner + "/" + UUID.randomUUID();
var put = PutObjectRequest.builder().bucket(bucket).key(key)
.contentLength(r.size()).contentType("application/octet-stream").build();
var signed = presigner.presignPutObject(b -> b.signatureDuration(Duration.ofMinutes(15))
.putObjectRequest(put));
files.savePending(owner, key, r.name(), r.size(), r.sha256()); // status PENDING_UPLOAD
return new PresignedUpload(signed.url().toString(), signed.signedHeaders());
}/complete then streams the stored object once through SHA-256 and compares with the declared digest. Sniff the first bytes at the same time, so presigned uploads face the same allowlist as proxied ones.
Share links are rows, not signed blobs. A random 256-bit token is stored hashed; the download counter is decremented atomically so two simultaneous clicks cannot both win.
CREATE TABLE share_link (
token_hash CHAR(64) PRIMARY KEY,
file_id VARCHAR(40) NOT NULL REFERENCES stored_file(id),
expires_at TIMESTAMPTZ NOT NULL,
downloads_left INT NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT false
);@Modifying
@Query("""
update ShareLink s set s.downloadsLeft = s.downloadsLeft - 1
where s.tokenHash = :hash and s.revoked = false
and s.expiresAt > :now and s.downloadsLeft > 0""")
int consume(@Param("hash") String hash, @Param("now") Instant now); // 1 = granted, 0 = 410The public route consumes one download and redirects to a short-lived presigned GET that forces a download.
@GetMapping("/s/{token}")
public ResponseEntity<Void> open(@PathVariable String token) {
ShareLink link = shares.consume(token).orElseThrow(GoneException::new); // 410
var get = GetObjectRequest.builder().bucket(bucket).key(link.file().getObjectKey())
.responseContentDisposition("attachment; filename*=UTF-8''" + encode(link.file().getName()))
.responseContentType("application/octet-stream").build();
URI url = URI.create(presigner.presignGetObject(b -> b.signatureDuration(Duration.ofSeconds(60))
.getObjectRequest(get)).url().toString());
return ResponseEntity.status(HttpStatus.FOUND).location(url).build();
}The virus-scan hook is an interface plus an after-commit event, so the real ClamAV client can replace the stub later. Files are not downloadable or shareable until CLEAN.
public interface ScanHook { ScanResult scan(String objectKey); }
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onRegistered(FileRegistered e) {
ScanResult r = hook.scan(e.objectKey());
files.markScanned(e.fileId(), r.clean() ? Status.CLEAN : Status.INFECTED);
}MinIO differs from AWS in the client setup: build S3Client and S3Presigner with endpointOverride(...), a fixed region, static credentials and pathStyleAccessEnabled(true).
Testing
Run the real store against a MinIO container and feed it a generated stream so no test ever holds the file in memory.
@Testcontainers
class S3BlobStoreIT {
@Container static MinIOContainer minio = new MinIOContainer("minio/minio:latest");
S3BlobStore store;
@BeforeEach void setUp() { store = TestStores.create(minio.getS3URL(), minio.getUserName(), minio.getPassword()); }
@Test
void streamsFortyMegabytesWithCorrectDigest() throws Exception {
long size = 40L * 1024 * 1024;
MessageDigest expected = MessageDigest.getInstance("SHA-256");
InputStream in = new DigestInputStream(new PatternInputStream(size), expected);
StoredObject obj = store.put("u1/big", in, 100L * 1024 * 1024);
assertThat(obj.size()).isEqualTo(size);
assertThat(obj.sha256()).isEqualTo(HexFormat.of().formatHex(expected.digest()));
}
@Test
void abortsWhenOverLimit() {
assertThatThrownBy(() -> store.put("u1/huge", new PatternInputStream(30L << 20), 10L << 20))
.isInstanceOf(PayloadTooLargeException.class);
assertThat(store.listIncompleteUploads()).isEmpty(); // aborted, no orphan parts
}
}PatternInputStream is a tiny InputStream that emits a repeating byte pattern for size bytes. Add a controller-level test with MockMvc that posts a .html file disguised as image/png and expects 415.
Bugs you will hit
- Multipart still buffers to disk. Without
spring.servlet.multipart.enabled=false, Spring parses the body before your controller runs, writes it to temp storage and enforces its own limits. Disable it and read the stream; confirm withlson the temp directory during a large upload. - Parts under 5 MiB fail with
EntityTooSmall.InputStream.read(buf)may return a short count even mid-stream, so every part is tiny. UsereadNBytes(buf, 0, len), which blocks until the buffer is full or the stream ends; only the last part may be small. - Presigned URL returns
SignatureDoesNotMatch. The signature covers the host. If the service signs withhttp://minio:9000(Docker network name) but the browser callslocalhost:9000, it fails. Configure a separate public endpoint for the presigner, and send exactly the signed headers. - Client sees a connection reset instead of 413. When you reject mid-body, Tomcat closes the connection once the swallow limit is exceeded, so the client never reads your response. Raise
server.tomcat.max-swallow-size, and also checkContent-Lengthup front to reject early. - Trusting the filename or type.
item.getName()can be../../etc/passwdor contain control characters, and a.htmlserved inline from your domain is stored XSS. Generate object keys yourself, keep the sanitised name as metadata only, and always serve withContent-Disposition: attachmentandX-Content-Type-Options: nosniff.
Interview angle
- How do you accept a multi-gigabyte upload without running out of memory? Never materialise the body. Read the request as a stream, push fixed-size parts to object storage, and compute hash and size incrementally. Memory is one buffer per concurrent upload, so concurrency is bounded by buffer size times active uploads.
- Why presigned URLs instead of proxying? The bytes bypass your servers, so bandwidth, memory and timeout limits no longer apply. You keep control through short expiry, signed size and type, a pending state and a
/completeverification step. - How do you make share links safe? Unguessable random tokens (at least 128 bits) stored hashed, server-side expiry, an atomic download counter, revocation, and a short-lived storage URL so the permanent object location is never exposed.
- How would you add resumable uploads? Either S3 multipart with the client uploading presigned parts and the server tracking part ETags, or a tus-style
PATCHwith an offset header where the server rejects any chunk whose offset differs from its stored length.
Milestones
Done when
- A 1 GB upload through the service never raises heap usage above the part buffer plus normal overhead.
- A filename of
../../etc/passwdis stored under a server-generated key and cannot escape the user prefix. - Uploaded HTML downloads as an attachment and never renders inline.
- Parallel redemptions of a one-time link yield exactly one 302 and the rest 410.
- Abandoned multipart uploads and pending rows are cleaned up, leaving no orphan objects.
- The storage tests pass against a real MinIO container in CI.
Stretch goals
- Implement resumable chunked uploads with an offset-checked
PATCHand per-chunk checksums. - Replace the stub scan with ClamAV in a sidecar container using the INSTREAM protocol, quarantining on detection.
- Add per-user quotas with a
GET /v1/usageendpoint, enforced atomically when files register. - Deduplicate by SHA-256 with reference counting so identical files share one object.