- Go 77.7%
- JavaScript 17.2%
- CSS 3.1%
- HTML 1.5%
- Shell 0.5%
previewItems passed sortCompare directly as the array comparator, but sortCompare expects the active sort as a third argument, so opening any image/video/audio/pdf from the Files view threw "sort is undefined". Pass state.sort instead; lightbox prev/next now also follows the table's active column order. |
||
|---|---|---|
| cmd/trove | ||
| docs | ||
| internal | ||
| scripts | ||
| .env.example | ||
| .gitignore | ||
| build-and-push.sh | ||
| docker-compose.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| README.md | ||
| todo.md | ||
| trove.example.yaml | ||
trove
A lightweight, self-hosted, encrypted cloud for a family or small group. One Go binary, SQLite metadata, Docker deployment. Files are encrypted at rest with a per-user key hierarchy — no server master secret.
Everything below is implemented: multi-user accounts with Argon2id auth, envelope encryption, WebDAV over encrypted storage, an embedded web UI (browse, upload/download, zip, gallery), a per-user trash, user-to-user sharing, public share links, a client-side encrypted vault, and a built-in PIM web UI (calendars, tasks, contacts) backed by the CalDAV/CardDAV-compatible data model, plus CalDAV/CardDAV protocol endpoints for external clients (see Status).
Features
- Encryption at rest — ChaCha20-Poly1305, per-file keys, envelope-encrypted storage. Disk theft, stolen backups, or VPS snapshots reveal nothing.
- Multi-user accounts — password verification doubles as the key-unwrap step (one Argon2id call per login). The first admin is created in the web UI on first boot, and admins invite new users with shareable links.
- Recovery phrase — a per-user backup phrase shown once at setup; used to reset a forgotten password without losing any data.
- WebDAV (
/dav/) — mount your files from any WebDAV client (rclone, davfs2, Cyberduck, etc.). - Embedded web UI (
/) — no build step, vanilla JS: login, browse, upload/download with progress, mkdir, rename, move, delete, zip download, a photo/video gallery view for the current folder plus a library-wide Photos view (in the navbar) grouped by month, and a Trash view (restore, delete forever, empty) in the navbar. Grid tiles and the Photos view are backed by server-side thumbnails and a server-side media index, so even thousands of images load as fast as a normal cloud gallery. The Photos view also groups by location (EXIF GPS, offline reverse-geocoded labels) and by user-created albums with multi-select add/remove. Albums can also contain whole folders: every photo placed in a folder (now or later, recursively) automatically joins the album. A Settings view (gear in the navbar) lets you change your display name and account password. - Encrypted vault — a client-side encrypted, server-blind vault: the passphrase is derived with PBKDF2 in the browser, a master key wraps per-file AES-GCM keys, and the server only ever stores ciphertext — it never sees the passphrase or any key. Change the vault passphrase from the Vault view; it is fully separate from your account and never touched by a password reset.
- Trash —
DELETEmoves items to a per-user trash instead of permanently deleting them. Restore (with automaticname (n)renaming on conflict), delete-forever, empty-trash, and a background purge honoringTROVE_TRASH_RETENTION(default 30d). Trash/restore are raw ciphertext renames — no re-encryption, keyless metadata. - Storage quotas — per-user quotas enforced on WebDAV uploads and trash
restores (HTTP 507 when exceeded), charged to the folder owner for writes
into folders shared with you. Admins set quotas in the Users view or via the
API;
TROVE_DEFAULT_QUOTAsets the quota for new users. Trash does not count toward usage. - Public share links — read-only links to a file or folder, served as a
self-contained web page (
/s/<token>) with browse, download, inline preview, and zip download. Optional password (bcrypt, IP rate-limited) and expiry, instant revocation, and read-only WebDAV (/s/<token>/dav) for mounting the link as a network drive. Capability tokens are never logged. - PIM web UI — calendars (month view + timed grid, recurring events),
tasks (VTODO) and contacts (address books), all in the navbar. Backed by the
CalDAV/CardDAV data model (byte-preserving encrypted
.ics/.vcfper-user storage) and served to external clients by the CalDAV/CardDAV protocol layer below. - Office editing (WOPI) — optional integration with an external online
office suite such as Collabora CODE: an Edit online button appears on
documents (
.odt,.docx,.xlsx, ...), opening them in the editor with real-time co-editing when several users have access. The server acts as a WOPI host for its encrypted files; see Office editing.
Security model
Threat model: protect data at rest. This is not zero-knowledge — the server must decrypt to serve clients, so a plaintext key exists in memory only during a request.
Key hierarchy:
password --Argon2id--> KEK --wraps--> Identity private key (X25519, per user)
recovery phrase --Argon2id--> KEK2 --wraps--> Identity private key
Identity public key (stored) --ECIES--> FileKey (per file) | content encrypted
v
ChaCha20-Poly1305, 64KB chunks
- Identity keypair (X25519, per user): the private key is sealed under the password/recovery KEKs; the public key is stored and used to seal FileKeys to the account via ECIES (ephemeral key + HKDF + ChaCha20-Poly1305).
- FileKey (256-bit random, per file) encrypts content and is wrapped for the owner and for each share grant/link.
- The single KDF output is split into a 16-byte verification tag and a 32-byte KEK — one Argon2id call both authenticates and unlocks the private key.
- No cross-request caching by default: KEK and private key are zeroized after
each request. Optional opt-in short-TTL key cache via
TROVE_KEY_CACHE_TTL. - Directory listings read plaintext headers only — no key derivation on stat.
- Brute-force protection: login, recovery, setup, invite, and share-link
endpoints are rate-limited with exponential backoff keyed by username and
by IP. The client IP is the TCP peer address;
X-Forwarded-Foris honored only for connections from proxies listed inTROVE_TRUSTED_PROXIES, so clients cannot spoof their way to fresh rate-limit buckets. Login limits are tunable viaTROVE_LOGIN_MAX_ATTEMPTSandTROVE_LOGIN_LOCKOUT. All responses carry strict security headers (CSPscript-src 'self',X-Frame-Options: DENY,Referrer-Policy,X-Content-Type-Options). - WOPI editor sessions: the access token is a random capability sealed under its own key (like share links); it carries only the file's keys, expires on a sliding idle TTL with a hard cap, is delivered by form POST (never in a URL), and dies with the file or account. The office suite itself sees plaintext over its own connection to trove — run both behind TLS.
| Attacker has | Can decrypt |
|---|---|
| Disk only | Nothing |
| Disk + password | That user's account (inherent to Level 1) |
Getting started
Local build
Requires Go 1.26+.
go build -o trove ./cmd/trove
export TROVE_DATA_DIR=./data
export TROVE_PORT=8080
./trove serve
Alternatively, point it at a YAML config file (see trove.example.yaml):
./trove serve --config /etc/trove/trove.yaml
On first boot, open http://localhost:8080 and follow the Set up trove screen to create the first admin account. Save the one-time recovery phrase it shows — it is the only way to reset a forgotten password.
Docker
docker compose up --build -d
Data lives in the bind-mounted ./data directory. On first boot, open the web
UI to create the admin account.
Configuration
Configuration can come from an optional YAML file (--config) and/or
environment variables. Precedence is defaults < YAML file < environment
variables, so TROVE_* always wins when both are set.
A fully commented template lives in trove.example.yaml; pass it to the
server with ./trove serve --config /path/to/trove.yaml. Without --config
(and no TROVE_CONFIG env var), only environment variables are used. The
YAML keys below mirror each variable name (data_dir, port, ...), with the
same value syntax: Go-style durations plus a Nd day suffix, and byte counts
with optional suffixes (1GiB, 2MB).
| Variable | Default | Description |
|---|---|---|
TROVE_DATA_DIR |
data |
Directory for ciphertext files and the SQLite DB. |
TROVE_PORT |
8080 |
HTTP listen port. |
TROVE_KEY_CACHE_TTL |
0 (off) |
Optional short-TTL private-key cache for sync-heavy users. |
TROVE_TRASH_RETENTION |
30d |
How long trashed items are kept before the background purge deletes them. Set to 0 to keep items forever. |
TROVE_DEFAULT_QUOTA |
0 (unlimited) |
Storage quota granted to newly created users, in bytes. Suffixes are supported (1GiB, 2MB); 0 or unlimited means no limit. Admins can change any user's quota in the Users view. |
TROVE_THUMB_SIZE |
256 |
Longest edge (px) of gallery thumbnail tiles, in 16..4096. |
TROVE_THUMB_MAX_PX |
200000000 |
Pixel budget for images to be thumbnailed; larger images show a placeholder and stay downloadable (guards against memory exhaustion). |
TROVE_THUMB_TTL |
30d |
How long cached thumbnails are kept before the background sweeper reclaims them. |
TROVE_ARGON2_MEMORY |
19 |
Argon2id memory cost, in MiB. |
TROVE_ARGON2_TIME |
2 |
Argon2id time cost. |
TROVE_ARGON2_THREADS |
1 |
Argon2id parallelism. |
TROVE_LOGIN_MAX_ATTEMPTS |
5 |
Failed logins allowed before a username/IP is temporarily locked out. |
TROVE_LOGIN_LOCKOUT |
5m |
Base lockout duration for login throttling (per-attempt backoff scales it). |
TROVE_TRUSTED_PROXIES |
(empty) | Comma-separated proxy IPs/CIDRs (e.g. 127.0.0.1,::1,10.0.0.0/8) whose X-Forwarded-For header is trusted when deriving client IPs for rate limiting. Leave unset when not behind a reverse proxy; clients then cannot spoof the header. |
TROVE_WOPI_ENABLED |
false |
Enable the WOPI host and the Edit online button (requires both URLs below). |
TROVE_WOPI_CLIENT_URL |
(empty) | Base URL of the WOPI client / office suite (e.g. http://code:9980). Discovery is fetched from <url>/hosting/discovery. |
TROVE_WOPI_PUBLIC_URL |
(empty) | The URL the office suite uses to reach trove's /wopi/ endpoints. Must be reachable from the editor container — localhost will not work. |
TROVE_WOPI_TOKEN_TTL |
2h |
How long an editor session token stays valid without editor activity; each request slides it forward. |
TROVE_WOPI_TOKEN_MAX_TTL |
24h |
Hard cap on a session token's total lifetime from issuance, however active the session. |
Serve over HTTPS when exposing beyond localhost (e.g. a Caddy/Traefik
reverse proxy); the app itself serves plain HTTP. WebDAV and CalDAV clients
typically require TLS. When behind a reverse proxy, set TROVE_TRUSTED_PROXIES
to the proxy's address so rate limiting keys on real client IPs.
Backups
Backing up trove is a matter of copying one self-contained directory:
TROVE_DATA_DIR (default data). It holds the SQLite database (trove.db)
with all accounts, keys, shares, and metadata, plus the encrypted file blobs
in per-user blobs/ subdirectories.
- Exclude
.thumbs/(a rebuildable thumbnail cache, regenerated on demand) andtmp/(transient in-progress uploads) to save space. - Confidentiality: everything on disk is ciphertext, so a backup can be stored anywhere — offsite storage, another machine, or a public bucket leaks nothing.
- Consistency: the database runs in SQLite WAL mode. Never hot-copy just
trove.db(or its-wal/-shmcompanions non-atomically). Use one of:- a filesystem snapshot — btrfs/zfs/LVM snapshot of the data directory, or a snapshot-capable backup tool (restic, borg, kopia). The whole tree is captured atomically and SQLite replays the WAL on the next open; this works while the server is running; or
- stop-then-copy —
docker compose stop(or SIGTERM; the server shuts down cleanly and closes the database), copy or tar the data directory, then start trove again.
- Restore — extract the backup into a fresh directory and point
TROVE_DATA_DIRat it (or restore it in place). Trash, quotas, shares, the PIM data, and the client-side vault are all covered automatically; missing thumbnails are regenerated.
Usage
Web UI
Log in with your account, then:
- Browse files with the table view or the gallery grid.
- Upload with drag-and-click (
Upload), download files, and download folders as a zip (Zip). Galleryshows the current folder's photos/videos as a grid;Photosshows every photo/video across your account, grouped by month. The Photos sidebar lists your albums (with live photo counts) and locations. Folders are added to albums from the file browser: the album button on a folder row lets you pick which albums it belongs to, and every photo inside it (including future uploads and subfolders) counts automatically. Membership is keyed on a file's stable id, so a member follows the file when it's moved and survives a trip through the trash (restored members come back); it is dropped only when the file is deleted forever.Trashshows your deleted files and folders. Restore an item back to its original location (renamed with a(n)suffix if that path is now taken), delete it forever, or empty the whole trash. Items older thanTROVE_TRASH_RETENTIONare purged automatically.Sharedshows folders and files other people shared with you (each underShared/<owner>/<name>in the file listing) plus the shares you've created; the Share button on a file or folder shares it with another user, and you can revoke a share from the Shared view at any time. Public share links are created and revoked in the same view.- Calendar — month view with a timed-event grid; create/edit/delete events
(recurring events repeat via RRULE: daily/weekly/monthly/yearly,
INTERVAL,COUNT,UNTIL,BYDAYwith ordinals like2TU/-1FR,BYMONTH,BYMONTHDAY,BYSETPOS,WKST,BYHOUR/BYMINUTE/BYSECOND). Tasks shows your todos with a checkbox and due date; completing a task marks the VTODOSTATUS:COMPLETED/COMPLETED. Contacts — address books with name/email/phone/org, plus a raw-vCard editor. Refreshre-reads the current view.- Users (admins only) — list users and delete accounts, create invite
links, and revoke them. Anyone with a valid invite link can create an
account at
/invite/<token>. Each user row shows storage usage and lets you edit the quota (or set unlimited). - Settings — change your display name and your account password. Changing the password keeps your files intact (the private key is re-wrapped) but WebDAV/CalDAV/CardDAV clients must re-authenticate with the new password. The vault passphrase is separate and is changed from the Vault view; because the vault is encrypted in your browser, changing it requires the current passphrase and only ever touches the vault, never your account.
- Uploading more than your quota returns 507 Insufficient Storage; if your trash contains items, a restore that would exceed your quota is rejected too.
WebDAV
The WebDAV root is /dav/. Example with rclone:
rclone config create trove webdav \
url=http://localhost:8080/dav \
vendor=other \
user=alice \
pass=<base64-encoded-password>
rclone mount trove: /mnt/trove
trove supports the protocol pieces a sync client needs:
- Content-hash ETags — files uploaded via WebDAV carry a strong ETag that
is the SHA-256 of the plaintext content, so a client can compare and detect
changes without downloading. Pre-existing files (created before hashes were
stored) get their hash backfilled on the first full download; until then
they fall back to a stat-derived ETag (
mtime-size). Because the hash is derived from the plaintext, it is recoverable only while the user's metadata key is unwrapped, and stays encrypted at rest. X-OC-Mtime— send the local modification time (unix seconds) on PUT and trove preserves it, so clients can keep mtimes in sync.- Conditional PUT —
If-MatchandIf-None-Match(including*) are honored on uploads and answered with 412 Precondition Failed, giving clients an atomic overwrite-if-unchanged primitive for conflict-free sync.
CalDAV / CardDAV
Calendars, tasks, and contacts are served to standard clients at /caldav/
and /carddav/ (RFC 4791 / RFC 6352), with sync-collection (RFC 6578),
calendar-query, and addressbook-query/multiget. Point a CalDAV/CardDAV
client (DAVx5, Thunderbird, Apple Calendar) at:
url: https://your-host/caldav/
username: alice
password: <password>
Attendees on events are stored byte-exact (kept intact through CalDAV sync) and are viewable/editable in the web UI's event editor (name, email, partstat, role, RSVP). trove does not implement iTIP scheduling and never sends invitations itself: in Thunderbird, enable "Prefer client-side email scheduling" per calendar so invites go out via your email identity instead.
Office editing (WOPI)
With TROVE_WOPI_ENABLED=true and an office suite such as
Collabora CODE reachable, documents get an
Edit online button in the web UI. The server implements the WOPI host
protocol (/wopi/): CheckFileInfo, GetFile/PutFile, and the WOPI lock model,
so the editor can read and write the encrypted file like any other client.
Saves are re-encrypted with a fresh per-file key; wraps for the owner, every
user-to-user share covering the file, and every public link are preserved, so
all existing access keeps working after an edit. Content-hash ETags are
maintained, and quota is charged to the folder owner as usual.
# trove.yaml
wopi:
enabled: true
client_url: http://code:9980 # where trove fetches discovery from
public_url: https://cloud.example.com # where CODE reaches trove
Setting up Collabora CODE
The bundled compose file ships a Collabora sidecar under
the office profile:
TROVE_WOPI_ENABLED=true \
TROVE_WOPI_CLIENT_URL=http://code:9980 \
TROVE_WOPI_PUBLIC_URL=https://cloud.example.com \
COLLABORA_HOSTS='https://cloud\.example\.com' \
CODE_EXTRA_PARAMS="--o:ssl.enable=false --o:ssl.termination=true" \
COLLABORA_SERVER_NAME=office.example.com \
docker compose --profile office up -d
Three parties must be able to reach each other, each governed by one setting:
| Direction | Carries | Setting |
|---|---|---|
| browser → CODE | the editor launch URL built from discovery | TROVE_WOPI_CLIENT_URL |
| CODE → trove | CheckFileInfo / GetFile / PutFile / lock calls against the WOPISrc | TROVE_WOPI_PUBLIC_URL |
| trove → CODE | the discovery document (/hosting/discovery) |
TROVE_WOPI_CLIENT_URL |
localhost is almost never right for TROVE_WOPI_PUBLIC_URL: it is resolved
inside the CODE container, where nothing listens on trove's port. Use the
address at which trove is (or will be) published.
Alias groups — CODE refuses WOPI hosts it was not configured to trust via
aliasgroup1, exposed as COLLABORA_HOSTS. Entries are regular expressions of
complete origins including scheme and port (https://cloud\.example\.com,
http://192\.168\.0\.106:8080); several aliases may be given space-separated
in one group. The compose default https://.* trusts any https host but
rejects plain-HTTP setups — override it when testing without TLS. An alias
mismatch is the classic cause of an editor tab that fails immediately with a
"session expired"-style error before a single request reaches trove.
Plain HTTP vs TLS — what CODE writes into its discovery document follows its own ssl settings, not the proxy in front of it:
- direct HTTP access (browser reaches CODE itself):
--o:ssl.enable=false --o:ssl.termination=false(the compose default); - behind a TLS-terminating proxy:
--o:ssl.enable=false --o:ssl.termination=trueplusCOLLABORA_SERVER_NAME=<proxy's public host>, so launch URLs come out ashttps/wss.
Network topologies
Everything in one compose project behind a proxy (production). Both
containers share the compose network: remove the sidecar's
network_mode: host and restore a ports mapping ("9980:9980"), then point
trove's client_url at http://code:9980. Terminate TLS once for both
origins and set TROVE_WOPI_PUBLIC_URL=https://cloud.example.com,
COLLABORA_HOSTS='https://cloud\.example\.com',
COLLABORA_SERVER_NAME=office.example.com, and the termination flag above.
Keep both origins on the same scheme or browsers will block the handoff as
mixed content.
Native trove binary + containerized CODE on one machine. This is what the
shipped compose file does out of the box: the sidecar runs with
network_mode: host because rootless podman/Docker otherwise refuse traffic
from the container to the host's LAN IP at the NAT/firewall boundary — even
with the port open — which surfaces as "session expired". With host networking
set TROVE_WOPI_CLIENT_URL=http://<LAN-IP-or-host>:9980 (this exact origin is
what the browser opens) and TROVE_WOPI_PUBLIC_URL=http://<LAN-IP>:<port>
(the origin CODE calls back on). For same-machine use plain localhost
works in both places since host networking puts CODE outside the NAT.
Separate machines. Same rules, no shortcuts: client_url must be CODE's
address as seen by browsers, public_url trove's address as seen by CODE, and
that second origin must match a COLLABORA_HOSTS pattern.
Troubleshooting
Watch both sides: docker compose --profile office logs code, and trove's
log, which records every callback that reaches /wopi/ as
wopi request method=… path=… status=…. The CODE image ships no shell, so to
probe connectivity run a throwaway curlimages/curl container attached to the
same network rather than docker exec.
| Symptom | Likely cause |
|---|---|
Editor tab fails immediately ("session expired"); no wopi request lines in trove's log |
CODE refused the WOPISrc: COLLABORA_HOSTS doesn't cover the TROVE_WOPI_PUBLIC_URL origin, or CODE cannot reach that URL at all |
| Document loads but saves fail ("Could not upload document"); trove logs only GETs, never POSTs | The session was downgraded read-only because the pre-save lock was rejected — check CODE's log for the reason and re-open the file for a fresh token |
Discovery/launch URLs say http:// though you browse over https:// |
CODE advertises its own ssl mode: set --o:ssl.termination=true and COLLABORA_SERVER_NAME |
Browser console shows a CSP form-action violation during handoff |
TROVE_WOPI_CLIENT_URL differs from the origin actually browsed (scheme/host/port must match exactly) |
| Instant connection refused from CODE to a native trove on the same host | Rootless container NAT/firewall boundary; give CODE network_mode: host or publish trove on all interfaces and open the port |
Notes:
- Co-editing — sessions are scoped to the document (not the user), so two users who open the same shared file join one live editing session.
- Token delivery — access tokens are handed to the browser via form POST into a new tab: they never appear in URLs, history, or Referer headers, and they expire (sliding idle TTL with a hard cap) and can be revoked by deleting the file or the account.
- Caveats — shares granted while a document is open only cover versions
saved after the next session start; concurrent WebDAV and editor saves are
last-write-wins (the WOPI lock protocol arbitrates editors only); a session
left idle past
TROVE_WOPI_TOKEN_TTLneeds a page reload.
CLI
trove serve Run the server
trove version Print version
trove help Show help
User management happens in the web UI (first-boot setup, invite links).
Development
go test ./...
go vet ./...
Layout:
cmd/trove— CLI entry point (serve,--configflag).internal/config— env/YAML configuration, Argon2id tuning.internal/db— SQLite schema and migrations.internal/auth— user + key management, invite links, Argon2id verifier, Basic-auth middleware.internal/crypto— envelope encryption, header format, chunked AEAD, recovery phrase.internal/store— the encrypted flat-blob store and node index backing all files, directories, and the per-user trash.internal/dav— WebDAV server over encrypted storage.internal/share— user-to-user shares and public share links (grants, links, revocation, read-only link WebDAV).internal/gallery— server-side thumbnails (encrypted./.thumbscache) and the account-wide media index behind the Photos view.internal/pim— CalDAV/CardDAV data model over encrypted.ics/.vcfstorage (calendars, events, recurrence, todos, address books, contacts) plus the protocol endpoints at/caldav/and/carddav/.internal/album— user photo albums (lightweight bookkeeping over media paths and folder members; no file moves).internal/geo— offline reverse geocoding for Photos location labels (no third-party calls).internal/limiter— brute-force rate limiting with exponential backoff.internal/vault— server-blind index behind the client-side encrypted vault.internal/quota— per-user quota checks and storage-usage accounting.internal/wopi— WOPI host for external office suites: session tokens, editor discovery, CheckFileInfo/GetFile/PutFile, lock arbitration.internal/server— HTTP routes, JSON API, embedded web UI, trash.
Status
| Area | Status |
|---|---|
| Server skeleton, config, Docker | Done |
| Users & auth (Argon2id, web first-boot setup, invite links, recovery) | Done |
| Encryption core (envelope, chunked AEAD, zeroization) | Done |
| WebDAV over encryption | Done |
| Web UI (browse, upload/download, zip, gallery) | Done |
| Trash (delete→trash, restore, purge, retention) | Done |
| Storage quotas (per-user, admin-managed, WebDAV enforcement) | Done |
User-to-user shares (shared Shared/ namespace, revoke, UI) |
Done |
| Public share links (browse/download/zip, password, expiry, revoke, WebDAV) | Done |
| Gallery at scale (server-side thumbnails, encrypted cache, media index) | Done |
| PIM web UI (calendars, tasks, contacts) | Done |
| CalDAV / CardDAV protocol endpoints (external clients) | Done |
| Client-side encrypted vault | Done |
| Photos locations & albums | Done |
WebDAV sync protocol (hash ETags, X-OC-Mtime, conditional PUT) |
Done |
| Office editing (WOPI host, Collabora CODE, co-editing) | Done |
License
MIT — see LICENSE.