A small file sharing app built only on Bun's built-in APIs. Files are streamed into an S3 bucket (MinIO locally) as they are uploaded, image uploads get a WebP thumbnail, every file has a presigned download link, and a whole folder can be downloaded as one archive. File metadata lives in a local SQLite database.
You need Docker for the local S3 bucket and Bun 1.4 or newer.
bun install
cp .env.example .env
docker compose up -d # MinIO on :9000 (API) and :9001 (console), bucket "files" created for you
bun run dev # http://localhost:3000 with hot reloadOther scripts:
bun start # run without hot reload
bun test # unit tests; the integration test runs too when S3_ENDPOINT is set
docker compose down -v # stop MinIO and delete its dataTo use different host ports for MinIO set MINIO_PORT and MINIO_CONSOLE_PORT before docker compose up, and point S3_ENDPOINT at the new API port.
Environment variables (see .env.example):
| Variable | Default | Meaning |
|---|---|---|
PORT |
3000 |
HTTP port |
S3_ENDPOINT |
http://localhost:9000 |
S3 compatible endpoint (MinIO, R2, AWS...) |
S3_BUCKET |
files |
Bucket name |
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY |
minioadmin |
Credentials |
S3_REGION |
us-east-1 |
Region used for signing |
MAX_UPLOAD_BYTES |
52428800 |
Largest accepted upload request (50 MB) |
DATABASE_PATH |
data/files.sqlite |
Where file metadata is stored |
Presigned download URLs point at S3_ENDPOINT, so that host has to be reachable from the browser. With the bundled Docker setup that is http://localhost:9000, which works when you open the app on the same machine.
| Route | What it does |
|---|---|
GET / |
The page: upload form, folder filter, file list with thumbnails |
POST /api/upload |
Multipart form with files (one or more) and optional folder; streams each file into S3 |
GET /api/files?folder= |
List files, optionally for one folder, plus the list of folders |
GET /api/files/:id |
Metadata for one file |
DELETE /api/files/:id |
Remove the object, its thumbnail and the metadata row |
GET /api/files/:id/url |
JSON with a presigned GET URL valid for one hour |
GET /d/:id |
302 redirect to that presigned URL |
GET /api/files/:id/thumb |
200px wide WebP thumbnail, streamed from the bucket |
GET /api/folders/:folder/archive |
All files in the folder as <folder>.tar.gz |
Bun.Archive writes tar archives (optionally gzipped) and does not produce zip files, so the folder download is a .tar.gz. Every desktop OS can open those.
| Bun API | Where |
|---|---|
Bun.serve with routes and typed req.params |
src/server.ts:20 |
| HTML import served as a bundled page | src/server.ts:1, src/server.ts:26 |
new S3Client({ endpoint, bucket, ... }) |
src/storage.ts:10 |
s3.file(key).writer() multipart streaming upload |
src/storage.ts:25 |
s3.write(key, bytes, { type }) |
src/storage.ts:44 |
s3.presign(key, { method, expiresIn, contentDisposition }) |
src/storage.ts:58 |
s3.delete(key) |
src/storage.ts:66 |
s3.file(key).stream() as a Response body |
src/server.ts:76 |
s3.file(key).bytes() |
src/server.ts:141 |
Bun.Image resize and WebP encode |
src/storage.ts:40 |
Bun.Archive with gzip |
src/server.ts:143 |
Bun.randomUUIDv7() |
src/server.ts:115 |
bun:sqlite with prepared statements |
src/db.ts:21, src/db.ts:38 |
Response.redirect to a presigned URL |
src/server.ts:67 |