Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8ad87c3
feat(db): add shares schema and queries
jsametz Aug 11, 2026
728922e
feat(api): add share creation endpoint
jsametz Aug 11, 2026
b58af84
feat(api): add get enpoints for listing shares and get share
jsametz Aug 11, 2026
3635dd5
feat(api): add share revoking endpoint
jsametz Aug 11, 2026
6c5789c
feat(api): add list all share for an upload endpoint
jsametz Aug 11, 2026
c504487
feat(api): add max access count and mandatory expiry to share
jsametz Aug 12, 2026
ff3ec76
feat(api): replace standalone shares with packages for file sending
jsametz Aug 12, 2026
0b7a547
feat(api): add package verification and per-file download limits
jsametz Aug 12, 2026
abaf21f
feat(frontend): add initial package sending UI
jsametz Aug 13, 2026
18f2cc9
fix(frontend): add a catch to verifyPassword that sets verifyError on…
jsametz Aug 13, 2026
358db3a
fix(frontend): fix typos and header for usePackages.ts
jsametz Aug 13, 2026
1749c6f
fix(frontend): stop deleting completed uploads from tus server on send
jsametz Aug 13, 2026
d75a27b
fix(frontend): keyboard-accessible toggle for email notification
jsametz Aug 13, 2026
37649a3
fix(frontend): remove redundant package fetch
jsametz Aug 13, 2026
fd89bba
fix(frontend): fixed time inconsistency and synchronization bugs
jsametz Aug 14, 2026
bcae167
fix(api): show per-file download counts instead of package-wide sum
jsametz Aug 14, 2026
1a8174f
feat(frontend): add dynamic download counter
jsametz Aug 14, 2026
3328bdc
fix(frontend): qol fixes
jsametz Aug 14, 2026
67a1fee
feat(frontend): add sender name
jsametz Aug 14, 2026
e730ace
feat(frontend): handle exhausted download limits in recipient and sen…
jsametz Aug 14, 2026
cd63ea0
feat(s3): add S3 storage backend for Send uploads and downloads
jsametz Aug 14, 2026
b3c254d
fix(s3): correct Send upload routing and download limit enforcement
jsametz Aug 14, 2026
4b1f4d3
feat(mail): notify package recipients by email
jsametz Aug 17, 2026
9715da3
feat(api): add package access requests and package extension
jsametz Aug 18, 2026
c1cf3c2
fix(send): remove minus max downloads
jsametz Aug 18, 2026
b74ff5b
feat(send): restrict access requests to package recipients
jsametz Aug 18, 2026
55b715a
feat(mail): add notification to download
jsametz Aug 19, 2026
d82bb99
fix(notification): fix wording in templates
jsametz Aug 19, 2026
28dc845
feat(send): pagination
jsametz Aug 19, 2026
cfc030d
feat(send): enforce permanent package expiry after 90 days
jsametz Aug 19, 2026
ed2ef81
feat(send): bundle package downloads into prepared zip artifacts
jsametz Aug 20, 2026
67d207a
feat(home): add navigation links for Upload and Send
jsametz Aug 20, 2026
5e4e7f0
feat(send): support auto-opening of package cards and improve package…
jsametz Aug 20, 2026
be34fd5
feat(send): add PackageFileManifest component to display package file…
jsametz Aug 20, 2026
92f67a6
feat(packages): increase default page size for package fetching
jsametz Aug 20, 2026
0a4fd6f
feat(send): enhance package focus handling and add warning styles
jsametz Aug 21, 2026
cc5fa5a
Add A test
KillerX Aug 21, 2026
ac1932a
refactor(db): use named parameters in all sqlc query files
KillerX Aug 24, 2026
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
40 changes: 40 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,46 @@ TARGET_1_DIR=./uploads
# TARGET_2_NAME=Processed
# TARGET_2_DIR=/srv/uploads/processed

# --- S3 for Send (optional) ---
# When S3_BUCKET is set, files sent via Send are stored in S3 and recipients
# download straight from S3 via presigned URLs, bypassing this server. Leave
# unset to keep Send writing to a local target directory instead.
# S3_BUCKET=filebox-send-sandbox
# S3_KEY_PREFIX=send/
# AWS_REGION=eu-north-1
# Credentials for the dedicated IAM user (not a personal/SSO identity).
# Omit both when running on AWS with an attached instance/task role.
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=

# --- Email (optional) ---
# Leave MAIL_SMTP_HOST unset to disable delivery: FileBox logs what it would
# have sent and carries on. A link origin must be set for mail to be sent.
# MAIL_FROM_ADDRESS is both the envelope sender and the From address — never a
# user's own address. The sharing user rides in Reply-To, and their name in the
# From display name ("John Doe (via FileBox)"), so SPF/DKIM stay aligned.
# MAIL_SMTP_HOST=smtp.bcc.no
# MAIL_SMTP_PORT=587
# MAIL_SMTP_USER=
# MAIL_SMTP_PASS=
# starttls (default, port 587) | implicit (port 465) | none (local dev only)
# MAIL_SMTP_TLS=starttls
# MAIL_FROM_ADDRESS=filebox@bcc.no
# MAIL_FROM_NAME=FileBox
# Origin used in recipient links. Defaults to BASE_URL. Set it separately when
# the two differ — e.g. BASE_URL is this server (and the OAuth redirect) while a
# recipient opens the Vite dev server. Dev builds (make dev) fall back to
# http://localhost:8091; production builds fail to start if mail is on and
# neither is set, rather than mailing out links to the wrong host.
# MAIL_LINK_BASE_URL=http://localhost:8091

# Local dev with Mailpit (make mailpit):
# MAIL_SMTP_HOST=localhost
# MAIL_SMTP_PORT=1025
# MAIL_SMTP_TLS=none
# MAIL_FROM_ADDRESS=filebox@localhost
# MAIL_LINK_BASE_URL=http://localhost:8091

# --- OAuth (optional) ---
# Leave every OIDC_* var unset to run in guest-only mode.
# SESSION_KEY is required only when at least one provider is configured.
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: all build build-linux generate dev frontend frontend-dev clean
.PHONY: all build build-linux generate dev frontend frontend-dev mailpit clean

all: generate frontend build

Expand Down Expand Up @@ -29,6 +29,11 @@ frontend:
frontend-dev:
cd frontend && pnpm run dev

# Local SMTP catch-all for development: SMTP on :1025, web UI on :8025.
# Nothing it receives ever leaves the machine.
mailpit:
docker run --rm -p 1025:1025 -p 8025:8025 axllent/mailpit:v1.30.7

# Clean build artifacts
clean:
rm -f filebox filebox-linux-amd64
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A Go service that speaks the [TUS resumable upload protocol](https://tus.io/) in
- Client-supplied SHA-256 verified after upload completes
- Per-user upload history tracked in SQLite (duration, bandwidth, offset, status)
- Multiple named upload targets, each bound to a filesystem directory
- Background ZIP64 preparation for large Send packages, with live progress and restart recovery
- Strict filename validation to prevent directory-traversal attacks
- Optional OAuth (OpenID Connect) sign-in with BCC Login and/or Microsoft Entra ID; falls back to guest mode when unconfigured
- Goose migrations embedded in the binary, applied automatically on startup
Expand All @@ -31,6 +32,10 @@ All configuration is via environment variables.
| `BASE_URL` | _(empty)_ | Absolute base URL used to build TUS upload URLs and OAuth callback URLs when behind a reverse proxy (e.g. `https://upload.example.com`). |
| `TARGET_N_NAME` | — | Name of upload target `N` (starting at 1). Referenced by the client via the TUS `target` metadata field. |
| `TARGET_N_DIR` | — | Filesystem directory for target `N`. Must exist and be a directory. Completed uploads are moved here. |
| `S3_BUCKET` | _(empty)_ | When set, Send uploads are stored in this S3 bucket and recipients download via presigned URLs. Unset disables S3; Send then writes to a local target. See [S3 storage for Send](#s3-storage-for-send). |
| `S3_KEY_PREFIX` | `send/` | Key prefix for objects written to `S3_BUCKET`. A trailing `/` is added if missing. |
| `AWS_REGION` | — | Region of `S3_BUCKET`. Required when `S3_BUCKET` is set. |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | — | Credentials for the app's IAM user. Optional when running on AWS with an attached instance/task role. |
| `SESSION_KEY` | — | 32+ byte secret used for session storage. Required only when at least one OAuth provider is configured. |
| `BOOTSTRAP_ADMIN_EMAIL` | — | Optional. On startup, if the `users` table is empty, seeds an admin grant for this email (all targets, admin flag). Ignored once any user has signed in. See [Bootstrapping the first admin](#bootstrapping-the-first-admin). |
| `OIDC_BCC_*` / `OIDC_AZURE_*` | — | See [Authentication](#authentication). All `OIDC_*` variables are optional; OAuth is disabled when none are set. |
Expand All @@ -46,6 +51,35 @@ TARGET_2_NAME=Processed
TARGET_2_DIR=/srv/uploads/processed
```

### S3 storage for Send

Files shared through **Send** can be stored in S3 rather than on the server's own disk, so that neither the upload's final resting place nor the recipients' download traffic touches local infrastructure. Set `S3_BUCKET` (plus `AWS_REGION` and credentials) to enable it; leave `S3_BUCKET` unset and Send behaves as before, writing into a local target directory.

How it works when enabled:

- Uploads still arrive over TUS and are assembled in `UPLOAD_DIR/.tmp`, so resumability is unchanged. Once complete, the SHA-256 is verified **before** the transfer, and the file is then streamed to S3 (multipart for large files) and removed from the temp directory.
- Send uploads are tagged with the reserved target name `s3` instead of a configured target. This name is never a row in the `targets` table, so it can't be created, renamed, or deleted from the admin UI.
- Object keys are `<S3_KEY_PREFIX><uploadID>/<filename>`. Namespacing by upload ID means same-named files never collide, and the key is derivable from the `uploads` row — so S3-backed shares need no extra columns.
- Generated ZIPs use `<S3_KEY_PREFIX>packages/<packageID>/artifacts/<artifactID>.zip`. They are streamed with 16 MiB multipart parts, so a 100 GiB non-seekable archive stays below S3's 10,000-part limit without requiring 100 GiB of local staging space.
- `GET /api/artifacts/{id}` performs all package checks (preparation, revocation, expiry, per-artifact download limit, and verification), records the access, and then responds `302` to a presigned S3 URL valid for 5 minutes. Old `/api/shares/{id}` links delegate to this policy path. The bucket itself stays entirely private.

### Send archive policy

All thresholds are binary GiB (`1 GiB = 2^30 bytes`), and the 100 GiB limit applies to the finished ZIP including its headers:

- 10 files or fewer stay as individual downloads.
- More than 10 files whose combined source size is at most 100 GiB are prepared as one ZIP. If ZIP envelope bytes would cross the strict limit, the planner safely splits or leaves an otherwise-unpackable source direct.
- Above 100 GiB total, each source smaller than 10 GiB is packed into ordered ZIP parts no larger than 100 GiB, while sources of 10 GiB or more stay as individual downloads.

Archives use ZIP64 with Store (no compression), which avoids spending CPU recompressing media and supports files over 4 GiB. Duplicate and legacy filenames are made safe and unique inside each archive. Original source objects are retained; package previews continue to list them, while only the planned ZIP/direct artifacts are downloadable. Preparation runs in the background, is restart-safe, and reports byte and percentage progress in both the sender and recipient views. Recipient email is delayed until the complete artifact set is ready. `maxDownloads` is enforced independently per downloadable artifact, so one ZIP download consumes one ZIP allowance.

Completed files selected in the Send form are remembered by server upload ID. Reloading the page restores that exact draft selection without uploading the bytes again. Removing a restored row only removes it from the draft; it does not delete the stored source.

Without S3, generated archives are published atomically under `UPLOAD_DIR/.archives/<packageID>/`.

See [Package archive validation](docs/package-archive-validation.md) for the recorded local end-to-end scenarios, including real 90 GiB and split 96/24 GiB archive downloads and integrity checks.


## HTTP API

### JSON API
Expand Down Expand Up @@ -138,6 +172,8 @@ OAuth sign-in establishes identity but **does not yet gate access**. Any visitor

## Deployment

FileBox's SQLite database and background preparation queue assume one active server process per database. Do not overlap instances during a rolling deployment or point multiple replicas at the same DB/storage paths; stop the old process before starting the replacement.

Two reference files ship in the repo:

- `filebox.service` — a hardened systemd unit (`NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome=true`, explicit `ReadWritePaths`). Adjust `Environment=` lines and `ReadWritePaths=` to match your install.
Expand Down
8 changes: 8 additions & 0 deletions cmd/server/linkorigin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//go:build !dev

package main

// Empty in production: guessing an origin would mail real recipients a link to
// the wrong host, and a sent mail can't be recalled. Set MAIL_LINK_BASE_URL or
// BASE_URL explicitly.
const devLinkOrigin = ""
7 changes: 7 additions & 0 deletions cmd/server/linkorigin_dev.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//go:build dev

package main

// Where a recipient opens a share link in development: the dev binary embeds no
// frontend (see embed_dev.go), so the SPA lives on the Vite dev server.
const devLinkOrigin = "http://localhost:8091"
37 changes: 35 additions & 2 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"filebox/internal/config"
dbpkg "filebox/internal/db"
db "filebox/internal/db/gen"
"filebox/internal/mail"
"filebox/internal/objectstore"
"filebox/internal/server"

"github.com/joho/godotenv"
Expand Down Expand Up @@ -48,7 +50,7 @@ func main() {
log.Fatalf("failed to create upload directory: %v", err)
}

database, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
database, err := sql.Open("sqlite", dbpkg.SQLiteDSN(dbPath))
if err != nil {
log.Fatalf("failed to open database: %v", err)
}
Expand Down Expand Up @@ -90,14 +92,45 @@ func main() {
log.Println("OAuth disabled (no OIDC_* env vars set) — running in guest-only mode")
}

// Send uploads go to S3 when a bucket is configured, else to a local target.
objectStore, err := objectstore.NewFromEnv(context.Background())
if err != nil {
log.Fatalf("failed to initialise S3 object store: %v", err)
}
if objectStore != nil {
log.Printf("S3 enabled for Send uploads (bucket: %s)", objectStore.Bucket())
} else {
log.Println("S3 disabled (no S3_BUCKET set) — Send uploads use local targets")
}

// Unconfigured mail is valid (NoopSender just logs), but a configured relay
// without BASE_URL would mail links that go nowhere — so that's fatal.
mailer, err := mail.NewFromEnv()
if err != nil {
log.Fatalf("failed to initialise mail: %v", err)
}
// Recipient links default to BASE_URL, but differ in dev: BASE_URL is this
// server's origin, while a recipient opens the Vite dev server. Dev builds
// fall back to Vite so local testing needs no configuration.
mailBaseURL := envOr("MAIL_LINK_BASE_URL", baseURL)
if mailBaseURL == "" {
mailBaseURL = devLinkOrigin // set only in dev builds
}
if mail.IsEnabled(mailer) && mailBaseURL == "" {
log.Fatalf("mail is configured but neither MAIL_LINK_BASE_URL nor BASE_URL is set — recipient links would point at the wrong host (dev builds fall back to the Vite dev server; production must set one explicitly)")
}
if mail.IsEnabled(mailer) {
log.Printf("mail: recipient links point at %s", mailBaseURL)
}

var frontendFS fs.FS
if ef := embeddedFrontend(); ef != nil {
if sub, err := fs.Sub(ef, "frontend_dist"); err == nil {
frontendFS = sub
}
}

srv, err := server.New(queries, uploadDir, baseURL, frontendFS, authManager, sessionStore)
srv, err := server.New(queries, uploadDir, baseURL, mailBaseURL, frontendFS, authManager, sessionStore, objectStore, mailer)
if err != nil {
log.Fatalf("failed to create server: %v", err)
}
Expand Down
76 changes: 76 additions & 0 deletions development.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,79 @@ Per `CLAUDE.md`:
## Filename and path safety

Filenames are validated twice: in the TUS `PreUploadCreateCallback` (so bad names are rejected before any bytes are accepted) and again immediately before the final rename. The validator rejects — rather than silently strips — any of: empty names, `.` and `..`, NUL bytes, and any path separator (`/` or `\`). Before renaming into a target, the server also recomputes the relative path with `filepath.Rel` and refuses the operation if it escapes the target directory.

## Email

Transactional mail (share notifications now, expiry-extension requests later) lives in `internal/mail`. Delivery is off unless `MAIL_SMTP_HOST` is set — `NewFromEnv` returns a `NoopSender` that logs the message instead of sending it, so dev and CI never put mail on the wire.

Three headers do three different jobs, and the split is deliberate:

- **Envelope sender** (`MAIL_FROM_ADDRESS`) — fixed service address. SPF checks this against the relay, and bounces return here, so it is never a user's own address.
- **`From:`** — the same service address, with the sharing user's name in the display part: `"John Doe (via FileBox)" <filebox@bcc.no>`. Keeps DMARC alignment while the recipient still sees who shared.
- **`Reply-To:`** — the sharing user. A recipient hitting reply reaches a human, not an unattended mailbox.

### Local testing with Mailpit

```bash
make mailpit # SMTP on :1025, web UI on http://localhost:8025
```

Point `.env` at it:

```bash
MAIL_SMTP_HOST=localhost
MAIL_SMTP_PORT=1025
MAIL_SMTP_TLS=none
MAIL_FROM_ADDRESS=filebox@localhost
MAIL_LINK_BASE_URL=http://localhost:8091 # where a recipient opens the link
```

`MAIL_LINK_BASE_URL` exists because the two origins differ in dev: `BASE_URL` is the Go server (and the OAuth redirect URI registered with the provider), while a recipient opens the Vite dev server on `:8091`. In production one `BASE_URL` covers both and this can stay unset.

`make dev` builds with the `dev` tag and falls back to `http://localhost:8091` when neither is set, so local testing needs no extra config. Production builds have **no** fallback and refuse to start if mail is configured without an origin — guessing would mail real recipients a link to the wrong host, and a sent mail cannot be recalled.

Creating a package in the UI now mails every recipient. Sending happens in a background goroutine, so package creation never blocks on the relay; the outcome lands on `package_recipients`:

```bash
sqlite3 filebox.db "SELECT email, sent_at, send_error FROM package_recipients ORDER BY id DESC LIMIT 5;"
```

Or send a rendered sample without running the app:

```bash
MAILPIT_SMTP=localhost:1025 go test ./internal/mail/ -run Mailpit -v
```

Mailpit captures everything and delivers nothing onward, so it is safe to point at real-looking addresses. Check both the HTML and plain-text tabs — clients that refuse HTML fall back to the text part.

### The logo

`frontend/public/logo-email.png` is a raster of `AppLogo.vue` (SVG does not render in any mail client), baked to `--ink` `#e7ecf5` and served unauthenticated from the app origin — in production by the embedded frontend, in dev by the Vite server, which is one more reason `MAIL_LINK_BASE_URL` points there. It is referenced as an absolute URL built by `mail.LogoURL`.

Two deliberate properties: `alt=""` (the wordmark beside it already says FileBox, so a blocked image degrades to the wordmark rather than showing the name twice), and `width`/`height` attributes, since Outlook ignores CSS sizing. No other images are used — every one is a blockable request, and the file rows read fine without icons.

Regenerate it after changing `AppLogo.vue`:

```bash
# 45x52 = 2x the 23x26 display size, transparent background
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless \
--default-background-color=00000000 --window-size=45,52 \
--screenshot=frontend/public/logo-email.png file://<wrapper.html with the SVG at 45x52>
```

### Templates

`internal/mail/templates/*.tmpl`, embedded via `go:embed`, one `.html.tmpl` + `.txt.tmpl` pair per mail. The HTML pair renders through `html/template`, so sender-supplied text is escaped; the text pair uses `text/template`.

The share notification mirrors the recipient page it links to — the `.public-card` block in `PackageDownloadScreen.vue` — down to the wording ("<name> sent you a package"), the file list with `fmtBytes` sizes, and the primary button.

Two constraints shape the markup:

- **Tables and inline styles.** Outlook ignores `<style>` blocks and Gmail strips external CSS, so every colour and spacing value is inline. Padding goes on a `<td>`, never on a `<table>` — Outlook's Word engine drops the latter.
- **Design tokens are hard-coded hex.** No mail client understands CSS custom properties or `oklch()`, so `assets/send.css` tokens are resolved to hex and listed in a comment at the top of the HTML template. **Changing a token in `send.css` does not update the mail** — update both, and `TestNotificationMatchesRecipientPageDesign` will tell you if the hex drifts out of the template.

Preview the rendered design in a browser without a mail server:

```bash
MAIL_PREVIEW_DIR=/tmp/fb go test ./internal/mail/ -run Preview && open /tmp/fb/share.html
```
Loading