# filebrowser-ng A self-hosted web file browser. One static binary, one SQLite file, no external services. ![Screenshot of the file browser](docs/screenshot.png) ## Features - **Browsing**: grid and list view, breadcrumbs, sizes and dates, multi-select with shift and ctrl, context menu. - **File operations**: upload files and folders, create folders and files, rename, move, copy, delete. Download a folder as `zip`, `tar`, `tar.gz`, or `tar.zst`, streamed without a temporary file. - **Preview**: images, video, audio, and PDF inline. Video and audio support range requests, so seeking works. - **Thumbnails**: images and videos show a small preview in grid view. - **Text editor**: CodeMirror 6 with syntax highlighting for common languages. Saves back to disk in writable folders. - **Search**: by name, by content, or both. Results stream in as they are found and the search can be stopped at any time. Hidden and gitignored files are included. - **Users and folders**: each user gets one or more root folders, each read-write or read-only. Admins manage users, and can deactivate an account without deleting it. - **Shares**: public links to a file or folder, with an optional expiry. A writable share gives the link holder the same operations as a read-write folder. An admin setting turns writable shares on or off globally, and a read-only folder cannot be shared writable. Deleting, renaming, or moving an item revokes its shares. See [Shares](#shares). - **WebDAV**: mount your folders, or a share, in a file manager. Per-client app passwords keep the account password out of the mount. See [WebDAV](#webdav). - **Calendars and contacts**: CalDAV and CardDAV for calendar and contact apps. Sharing between users, meeting invitations between users, rooms and resources, public feed links, import and export. See [Calendars and contacts](#calendars-and-contacts). - **UI**: light, dark, or system theme. English, German, and French. Optional single-click open. - **Passkeys**: sign in with a fingerprint, a face, or a security key. Everyone manages their own under Settings → Security, and can require a password *and* a passkey, or drop the password entirely. See [Sign-in](#sign-in). - **Security**: Argon2 password hashes, HttpOnly session cookies with a 30-day lifetime, growing delay on failed logins per user name. ## Setup The first visit shows a setup page that creates the admin account. The admin's root is the `--root` folder. Further users and folders are created under **Users**. ### Container ```bash docker compose up -d # or: podman compose up -d ``` `compose.yml` builds the image and mounts two host folders: | Host path | Container path | Content | |--------------------|-----------------------|-------------------------------| | `./data` | `/data` | The browsed files | | `./filebrowser-db` | `/var/lib/filebrowser` | SQLite database: users, shares, settings | Both folders are created on first start. The image is Alpine plus the static binary and exposes port 8080. ### Binary ```bash filebrowser-ng --root /srv/files --db /var/lib/filebrowser/db.sqlite --bind 0.0.0.0 ``` | Flag | Default | Description | |---------------|-------------|------------------------------------------------------------------| | `--root` | _(required)_ | Folder the server may access. All user folders are inside it. | | `--db` | _(required)_ | SQLite file. Created when missing. | | `--port` | `8080` | Listen port | | `--bind` | `127.0.0.1` | Listen address. `0.0.0.0` exposes the server beyond localhost. | | `--root-name` | folder name | Display name of the root folder. Also `FILEBROWSER_ROOT_NAME`. | | `--public-url` | off | Public base URL, e.g. `https://files.example.com`. Share and WebDAV links are built from it instead of the browser's address, and an `https` scheme marks cookies `Secure` and binds passkeys to that origin. Also `FILEBROWSER_PUBLIC_URL`. | | `--cache` | off | Folder for the thumbnail cache. Turns thumbnails on. Also `FILEBROWSER_CACHE`. | Log level comes from `RUST_LOG` (`error`, `warn`, `info`, `debug`, `trace`). ### Reverse proxy The server speaks plain HTTP. Put a TLS-terminating proxy in front of it and set `--public-url` to the address the browser uses, e.g. `FILEBROWSER_PUBLIC_URL=https://files.example.com` in `compose.yml`. The server cannot see the proxy's TLS by itself, so without that URL it assumes plain HTTP: cookies lose `Secure` and passkeys are bound to the wrong origin. Search uses server-sent events, so the proxy must not buffer responses on `/api/search`. ## Thumbnails Grid view shows a small preview of an image or video instead of an icon. Point `--cache` at a folder to turn them on: ```bash filebrowser-ng --root /srv/files --db /var/lib/filebrowser/db.sqlite \ --cache /var/cache/filebrowser ``` Notes: - Videos need `ffmpeg` on the server. The container image ships with it. Without it, images still get thumbnails and videos keep their icon. - The cache is disposable. You can delete the folder at any time; the server makes the thumbnails again as they are needed. ## Shares A share is a link to a **path**, not to the account that made it. Things that end one: deleting, renaming or moving the item through the web UI (this also revokes the shares on anything inside it), setting an expiry, deleting the share under **Shares**, and deleting the creator's account. Two admin actions do **not** end a share, so do them together with a revoke: - **Deactivating an account.** Its logins and sessions stop immediately, but a link that user made keeps serving — and a writable link keeps accepting writes from whoever holds it. Delete the account, or the share, to close it. - **Taking a folder away from a user.** A share stays open on a folder its creator can no longer open. Nor does a change the server did not make: a file moved or replaced over SSH, or by any other process, keeps the shares that name its path — the server only sees the operations it performs itself. Under **Shares**, a signed-in user sees the links they made. An admin sees every link on the server, in one section per account, and can end any of them — including the links of an account that can no longer sign in, whose section is marked as deactivated. That listing is admin-only because it is a list of working credentials: every row's copy button produces the live link. Holding a share token is access, so treat that view (and the tokens copied from it) accordingly. ## Sign-in Every account starts with a password. Settings → Security adds passkeys and decides how the two combine. | Requirement | What signs you in | |-------------|-------------------| | Password or passkey | Either one alone. This is the default. | | Password and passkey | Both, in either order. | Once a passkey exists the password can go, leaving a passkey-only account. No change here asks for the current password, because a passkey-only account has none; the session is the gate, and every change signs the account out everywhere else. The login button asks the browser for any passkey it holds for this site, so no user name is needed. Older security keys cannot do that and need the name typed in first; the settings list marks those with "needs your user name". Passkeys need a domain name. Set `--public-url` behind a proxy that rewrites `Host`, and note that a bare IP address will not work at all. Password sign-in still works on such a host, but an account requiring both factors does not. If `--public-url` names an address other than the one in the browser's URL bar, every passkey operation fails with "Passkeys are set up for a different address" and the server log names both addresses side by side. > [!NOTE] > The account password is not what a mount should carry. HTTP Basic sends a > password and nothing else, so an account that requires both factors cannot > use it at all. Create an app password instead, under Settings → Security. Admins recover a locked-out account by setting a new password for it. That also deletes the account's passkeys and its app passwords, and drops any two-factor requirement, so the new password is the one way back in. ## WebDAV Two mount points. Your permissions are the same as in the web UI. | URL | Contents | Credentials | |-----|----------|-------------| | `https://host/dav` | Every root folder the user has, one collection each | Account name and an app password, or the account password (see below) | | `https://host/dav-share/` | One public share | None, or the share password | Mount it with the file manager you already have: ```bash # Linux (davfs2) sudo mount -t davfs https://host/dav /mnt/files # Linux (GNOME/KDE), macOS Finder: Go → Connect to Server davs://host/dav ``` App passwords, under Settings → Security, are the credential to mount with. One per client, shown once, good for WebDAV only. Revoke one from that list and its mount stops on the next request. Changing your own password leaves them working, the same way it leaves your passkeys alone; an admin reset deletes them all. Notes: - The mount ignores the user name next to an app password: the secret already says which account it belongs to. - Under `/dav` each of your folders is one entry, named as it is in the web UI. Two folders with the same name get a number added. - A share of a single file cannot be mounted. Share a folder or open it in the web UI instead. - A browser opens the same URLs. A folder shows a plain index, a file downloads. Hidden files are left out of that index but a mount still shows them. - If another client has a file locked, your write waits or fails. An abandoned lock clears after ten minutes, and a server restart clears all of them. - If two people save the same file at once, the last save wins. ## Calendars and contacts Calendar and contact apps connect over CalDAV and CardDAV. Examples are Apple Calendar and Contacts, Thunderbird, and DAVx5 on Android. | Setting | Value | |---------|-------| | Server URL | `https://host`. Apps find the rest through `/.well-known/caldav` and `/.well-known/carddav`. If an app asks for a full URL, use `https://host/pim/`. | | User name | Your account name | | Password | An app password, created under Settings → Security | - Apple Calendar and Contacts only connect over HTTPS, and iOS rejects a self-signed certificate without SAN entries. Put the server behind a reverse proxy with a real certificate. - Apps look for `/.well-known/caldav` at the root of the host. Under a sub-path such as `https://host/files/` that lookup fails, so give apps the full URL there (`https://host/files/pim/`). - Thunderbird: set the calendar's email identity to "None". Otherwise Thunderbird names your real email as organizer. The server does not know that address, so it invites no one. Thunderbird sends no email either, because the server announces that it schedules. | URL | Content | |-----|---------| | `/pim/principals//` | An account, room or resource | | `/pim/calendars//` | Your calendars, the birthday calendar, the scheduling inbox, and calendars lent to you | | `/pim/addressbooks//` | Your address books, the system address book, and address books lent to you | Every account starts with a calendar named "Calendar" and an address book named "Contacts". New invitations land in the oldest calendar that takes events, so that calendar cannot be deleted. The **system address book** (`system`) lists every active account, room and resource on the server. It is read-only and built by the server. The **birthday calendar** (`birthdays`) shows the birthdays and anniversaries of the contacts in your own address books, as yearly all-day events. Lent address books and the system address book do not count. It is read-only and built by the server, marked as free time, and not part of your free-busy time. A birthday is named "🎂 Name", an anniversary "💍 Name", with the year in brackets when it is known. A February 29 shows on February 28 in other years. **vCard versions**: address books announce vCard 3.0. Apple Contacts reads groups and companies only in that form. A contact stored as vCard 4.0 is returned as 3.0, with groups as `X-ADDRESSBOOKSERVER-KIND` and `X-ADDRESSBOOKSERVER-MEMBER`, unless the app asks for 4.0 (`Accept: text/vcard; version=4.0`, or `version="4.0"` in a report). The stored bytes do not change, and neither does the ETag. **Contact photos**: `GET /api/pim/collections//objects//photo` returns the photo of a contact you can read as a WebP of at most 256 pixels, like a file thumbnail. With the thumbnail cache (`--cache`) the result is kept; without it the photo is made on each request. Only a photo stored inside the contact counts. A photo given as a web address is never fetched. ### Web UI The sidebar has a Calendar and a Contacts page. Each lists your own collections, the generated ones and the ones lent to you. The color square hides or shows a collection. A click on a name opens its dialog: name, color, description, "show as free", sharing, public links, import, download and delete. "Connect an app" shows the addresses and user name for apps. Admins find rooms and resources on the Users page, and every public feed on the Shares page. The Calendar page has three views: - **Month**: six weeks, starting on Sunday in English and on Monday in German and French. Events that last a day or more are bars across the days, timed events show their start. A day with more events than fit shows "+N more". A click on a day lists its events below the grid. On a phone the days show colored dots instead, and the list below the grid shows the chosen day. PageUp and PageDown turn the month. - **Agenda**: the next 30 days, day by day, 30 more per click. - **Invitations**: the invitations you have not answered, with Accept, Maybe and Decline. The count shows on the tab. A click on an event opens its details: time, repetition, place, organizer, attendees with their answers and the description. For an invitation it also has the three answers; for one date of a series you pick that date or the whole series. The answer goes to the organizer like one from an app. Times show in the browser's time zone. The URL holds the view, the month and the open event, for example `#/calendar/month/2026-10?open=...`. The Contacts page searches all visible address books and shows one contact at a time, with its photo, addresses and notes. On a phone the list and the contact take turns. The views use JSON endpoints, so the browser never parses iCalendar or vCard: `/api/pim/instances` (occurrences in a range of at most 400 days), `/api/pim/collections//objects/` (one event or contact), `/api/pim/contacts` (search) and `/api/pim/invitations` (unanswered invitations; `POST` answers one like an app would). ### Sharing You can lend a calendar or address book to another account. It then shows up in that account's list as "Name (owner)". There are three levels: | Level | The other account may | |-------|-----------------------| | `ro` | Read | | `rw` | Also add, change and delete entries | | `rw+schedule` | Also send and answer invitations in your name | With plain `rw`, a change that would send an invitation or an answer in your name is refused. With `rw+schedule`, the messages name the other account as the sender (`SENT-BY`). Only the owner can rename a collection or change its color. In the web UI, open the collection from the Calendar or Contacts page. The JSON API is `GET /api/pim/collections`, then `GET` or `POST /api/pim/collections//shares` with `{"user": "", "mode": "rw"}`, and `DELETE /api/pim/collections//shares/` to end a loan. ### Public feeds The owner of a calendar or address book can publish it as a link. Anyone with the link can read it without an account. | Link | Content | |------|---------| | `/feed/.ics` | The whole calendar as one iCalendar file. Events you marked private or confidential show as "Busy" only. | | `/feed/.ics`, busy only | Only the times of events, each named "Busy". Descriptions, attendees, alarms, tasks and journals are left out, and so are free and cancelled events. Event IDs are replaced by hashes. | | `/feed/.vcf` | All contacts of an address book as one vCard file | A link can have an expiry date and a password. It stops working when the owner revokes it, when it expires, and when the collection or its owner is deleted. A password cannot be changed. Revoke the link and create a new one, which gets a new token. Subscribing to a calendar link: - Apple Calendar, Thunderbird, Evolution and GNOME Calendar subscribe to the URL directly, as `https://…` or `webcal://…`. - On Android, use ICSx⁵. DAVx5 itself does not subscribe to iCalendar links. - Google Calendar and Outlook.com fetch the file from their own servers. The URL must be reachable from the internet. They cannot send a password, so a protected link does not work there. - The feed asks apps to refresh every hour. Apps may use their own interval. A password is sent with HTTP Basic. The user name is ignored, but some apps refuse an empty one. Type any name there. A `.vcf` link is for a download or a one-time import. No common contacts app subscribes to a vCard link. To share contacts with another account and keep them in sync, lend the address book instead (see Sharing). The JSON API: `GET` and `POST /api/pim/collections//links`, with `{"busy_only": true, "expires_at": "", "password": ""}` (all optional), and `DELETE /api/pim/collections//links/`. The answer holds the link's path. Admins see every link with its owner at `GET /api/admin/pim-links`, and revoke one with `DELETE /api/admin/pim-links/`. ### Import and export `POST /api/pim/collections//import` imports an `.ics` file into a calendar, or a `.vcf` file into an address book. The body is the file itself. You need write access: your own collection, or a `rw` or `rw+schedule` loan. - The file is split into one entry per UID. Changed instances of a repeating event stay with their event. - Each entry gets the same checks as an upload from an app. An entry that fails is skipped. The answer counts created, updated and skipped entries, and names the first 100 skipped ones with the reason. - An entry whose UID the collection already holds replaces that entry. An entry without UID gets one derived from its content, so importing the same file twice updates instead of duplicating. - A meeting whose UID another of your calendars already holds is skipped. RFC 6638 allows one copy per UID. - An import never sends invitations or answers, also not for meetings with attendees. A later change in an app schedules as usual. - The file's calendar name and `X-WR-TIMEZONE` are dropped. Times without a zone then follow the calendar's own time zone. `GET /api/pim/collections//export` downloads a calendar or address book as one file, with every event in full, private ones included. Lent collections can be exported too, and so are the birthday calendar (id `-1`) and the system address book (id `0`, also at `/api/pim/system/export`). In the web UI, each collection's dialog has "Import file…" and "Download". ### Invitations Invitations work between accounts, rooms and resources of this server. The server delivers them itself. Every invitee gets a copy in their calendar and a message in their inbox. Answers flow back to the organizer the same way. The server sends no email. Addresses use the reserved `.invalid` domain, so nothing can reach anyone outside by mistake: | Principal | Address | |-----------|---------| | Account | `@filebrowser.invalid` | | Room | `@rooms.filebrowser.invalid` | | Resource | `@resources.filebrowser.invalid` | A name with characters an email address cannot hold keeps them percent-encoded: account `marc@example.com` becomes `marc%40example.com@filebrowser.invalid`. A dot is encoded too when it would lead, trail or repeat. An outside address in an invitation is kept, but marked as not delivered (status 5.2). Apps find the people on the server through the system address book or their attendee search. ### Rooms and resources Admins manage rooms and resources on the Users page, or with `GET` and `POST /api/admin/rooms` and `PUT` and `DELETE /api/admin/rooms/`. A room's name is fixed, because it is part of its address. Its display name can change. Rooms and accounts share one set of names. Everyone can read a room's bookings. The server answers a room's invitations itself. An instance that overlaps an existing booking is declined. Everything else is accepted. For a repeating meeting, only the instances that collide are declined. ### Deleting an account, room or resource The server removes the deleted one from everyone else's events in the same step. Its address becomes `-@deleted.filebrowser.invalid`, which reaches no one, and its attendee entries show status 3.7. The display name stays. Copies of meetings it organized are marked cancelled. Apps pick up the change at their next sync. A new account with the same name is a different person to the server: it gets nothing that was meant for the old one. ### Limits - One calendar entry or contact can be 10 MiB. An XML request can be 1 MiB. An imported file can be 20 MiB. Files that are not UTF-8 are read as Latin-1. - An inbox keeps its newest 100 messages. The meetings themselves stay in the calendar. - A repeating event returned as single instances can have at most 10,000 in one request. - A repeating rule is followed for at most 1,000,000 occurrences per request. Only extreme rules reach this, such as every minute for years. Such an event counts as matching every time range. - A room checks a repeating meeting without end for the next two years. Anything else is checked to its end, at most ten years ahead. Later instances are accepted without a check. - One lock serializes all writes of calendar entries and contacts on the server. That is fine for a small server. - The system address book and the birthday calendar have no change history. After any change to accounts or rooms, or to a birthday, apps download them again in full. The birthday calendar is rebuilt from all your contacts on each request. Not supported: - Email invitations (iMIP), in either direction. - Sharing from inside a calendar app: neither the `ACL` method nor Apple's `calendarserver-sharing` invite flow. Sharing works through the API above, and a lent calendar appears without an accept step. - Apple's delegation (calendar proxies). - Delegating a meeting seat to someone else, and progress replies on assigned tasks. - Calendars other than the Gregorian one (RFC 7529). [`pimdav/README.md`](pimdav/README.md) describes how the server reads the RFCs in detail, including every case where they leave room. ## Development Requirements: Rust 1.90 or newer with the `wasm32-unknown-unknown` target, [trunk](https://trunk.rs) 0.21, [bun](https://bun.sh), [just](https://github.com/casey/just), and OpenSSL headers (`webauthn-rs` links it; on Alpine that is `openssl-dev` plus `openssl-libs-static`). ```bash rustup target add wasm32-unknown-unknown cargo install trunk ``` | Command | Effect | |------------------|------------------------------------------------------------------------| | `just dev` | Backend on :8081 with a dev root under `.dev/` | | `just dev-web` | Frontend on :8080 with hot reload, proxying `/api` to :8081 | | `just build` | Release binary with the frontend embedded, `target/release/filebrowser-ng` | | `just run` | Build and run the release binary against the dev folders | | `just test` | Server, API type and pimdav tests | | `just e2e` | Build the real frontend and run the server suite against the embedded build | | `just lint` | `cargo fmt --check` and clippy with warnings denied | | `just reset-db` | Delete the dev database | ### Layout | Crate | Content | |-------------|------------------------------------------------------------------| | `server` | axum HTTP server, SQLite via rusqlite, file and archive handling, embedded frontend | | `web` | Leptos client-side app compiled to WebAssembly | | `api-types` | Request and response types plus route constants shared by both | | `pimdav` | CalDAV and CardDAV logic without I/O, see [its README](pimdav/README.md) | The frontend build lands in `web/dist`, is copied to `server/dist`, and is compiled into the binary with the `embedded` feature. `web/cm6.js` is the vendored CodeMirror bundle, built by bun from `web/cm/wrapper.js`. Both are build artifacts and not committed. Tests live in `server/tests` and run against an in-process server with a temporary root. CI runs on Hearthforge, see `.hearthforge-ci.toml`. No test drives a real authenticator, so nothing here verifies a WebAuthn signature — that is `webauthn-rs`' own job. `server/tests/api_passkeys.rs` covers everything around it: which credential combinations the server accepts, which it refuses, and that a half-finished sign-in never becomes a session. ## License [AGPL-3.0-or-later](LICENSE.md).