Disposable File Links with Quarkus
Ship a self-destructing S3-style file service in fifteen minutes with Quarkus, MinIO, REST, and plain Java.
Ever dropped a file into Slack just to delete it a minute later because it should only live long enough for a teammate to grab it? Let’s automate that throw-away workflow with Quarkus, keeping the API footprint lean and the storage transient.
Project bootstrap
Quarkus requires Java 17 or newer, so make sure your JDK is up-to-date. The framework itself moves quickly. 3.24.2 is current as of today (quarkus.io), so we’ll target that release.
With the Quarkus CLI installed (gu install quarkus
or brew install quarkus
), create a fresh project and pull in the REST and MinIO extensions in one go:
quarkus create app com.example:temporary-file-sharing \
--extension="rest-jackson,quarkus-minio" \
--no-code
cd temporary-file-sharing
If you prefer pure Maven, the equivalent is:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=temporary-file-sharing \
-Dextensions="rest-jackson,quarkus-minio"
cd temporary-file-sharing
The quarkus-minio
artifact is maintained in Quarkiverse and plugs the official MinIO Java SDK into Quarkus’ dependency injection and Dev Services machinery.
Grab the complete project from my Github repository!
Configuration
Dev Services will spin up a disposable MinIO container the moment you hit mvn quarkus:dev
. Add only what you actually need to src/main/resources/application.properties
:
minio.bucket=temporary-files
Tracking downloads in-memory
We just need to remember how many times each file has been served. An immutable data class keeps the state tidy:
package com.example;
import java.util.concurrent.atomic.AtomicInteger;
public record FileMetadata(
String originalName,
String objectName,
int allowedDownloads,
AtomicInteger counter) {
boolean stillDownloadable() {
return counter.get() < allowedDownloads;
}
void markServed() {
counter.incrementAndGet();
}
}
AtomicInteger
removes the need for manual synchronization and keeps the record thread-safe without extra boilerplate.
A tiny storage service
package com.example;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.minio.MinioClient;
import io.minio.RemoveObjectArgs;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class FileStorageService {
@Inject
MinioClient minio;
@ConfigProperty(name = "minio.bucket")
String bucket;
private final Map<String, FileMetadata> store = new ConcurrentHashMap<>();
void put(String id, FileMetadata meta) {
store.put(id, meta);
}
Optional<FileMetadata> get(String id) {
return Optional.ofNullable(store.get(id));
}
void delete(String id) {
get(id).ifPresent(meta -> {
try {
minio.removeObject(
RemoveObjectArgs.builder()
.bucket(bucket)
.object(meta.objectName())
.build());
} catch (Exception e) {
// log and swallow; cleanup best-effort
}
store.remove(id);
});
}
}
We keep it in memory because persistence is outside today’s scope; swap the map for your database when the time comes.
REST resource
Let’s rename the originally scaffolded GreetingResource
and replace its content with below:
package com.example;
import java.net.URI;
import java.nio.file.Files;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import io.minio.GetPresignedObjectUrlArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.http.Method;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/")
public class FileResource {
@Inject
MinioClient minio;
@Inject
FileStorageService files;
@ConfigProperty(name = "quarkus.minio.bucket")
String bucket;
@POST
@Path("upload")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response upload(@RestForm("file") FileUpload file, @RestForm String downloadsStr) throws Exception {
String id = UUID.randomUUID().toString();
String objectName = id + "-" + file.fileName();
minio.putObject(
PutObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.stream(Files.newInputStream(file.filePath()), file.size(), -1)
.contentType(
Optional.ofNullable(file.contentType())
.orElse("application/octet-stream"))
.build());
Integer downloads;
try {
downloads = Integer.parseInt(downloadsStr);
} catch (NumberFormatException e) {
System.out.println("Invalid number format: " + downloadsStr);
downloads = 1; // Default to 1 download
}
files.put(id, new FileMetadata(
file.fileName(),
objectName,
downloads,
new AtomicInteger()));
return Response.ok("http://localhost:8080/download/" + id).build();
}
@GET
@Path("download/{id}")
public Response download(@PathParam("id") String id) {
return files.get(id)
.map(meta -> meta.stillDownloadable()
? redirectViaPresignedUrl(meta)
: expired(id))
.orElse(Response.status(Response.Status.NOT_FOUND).entity("File not found").build());
}
private Response redirectViaPresignedUrl(FileMetadata meta) {
try {
meta.markServed();
String url = minio.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucket)
.object(meta.objectName())
.expiry(5, TimeUnit.MINUTES)
.build());
return Response.seeOther(URI.create(url)).build();
} catch (Exception e) {
return Response.serverError().build();
}
}
private Response expired(String id) {
files.delete(id);
return Response.status(Response.Status.NOT_FOUND).entity("This link has expired").build();
}
}
A successful upload returns an URL that callers can hit directly. The download handler serves until allowedDownloads
is reached, then wipes both the object and its metadata.
Try it
Start dev mode:
./mvnw quarkus:dev
Quarkus logs will show a MinIO container booting. Navigate to the Quarkus Dev UI and go to the MiniO admin console to add the bucket:
In a new terminal:
echo "temporary content" > test.txt
curl -F file=@test.txt -F downloads=2 http://localhost:8080/upload
The response is the one-time link. Fetch it twice with curl -L
, then hit it a third time to verify the 404 and confirm the object disappeared from MinIO’s console.
Where next
Persist the metadata in Postgres, secure the endpoints with OIDC, or let users pick the expiry strategy. The core pattern, object storage plus a tiny in-memory counter, scales from a quick-and-dirty prototype to production with minimal friction.
Happy shredding.