Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion docs-developers/architecture/release-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ The multi-stage `Dockerfile`:
with `ARG WEB_IMAGE=ghcr.io/kodestar/audiosilo-web:latest`;
- uses `alpine:3.24` for the final stage, with **ffmpeg installed from apk**.
Unlike the native binaries, the Docker image does bundle ffmpeg. The stage also
has a `PUID` and `PGID` entrypoint and presets `AUDIOSILO_WEB_DIR=/app/web`.
has a `PUID` and `PGID` entrypoint, which gives that user its folders and checks
them (see [Folder permissions](../server/configuration.md#under-docker)),
and presets `AUDIOSILO_WEB_DIR=/app/web`.

Because the image pins one specific web build, the server and its bundled player
always ship as a **known-compatible pair**. Native apps talking to any server
Expand Down
1 change: 1 addition & 0 deletions docs-developers/contributing/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ mappings:
| `pkg/query`, `pkg/query/querytest`, `pkg/release` in audiosilo-meta, or the artifact schema | [meta/overview.md](../meta/overview.md#public-go-packages) + [meta/data-model.md](../meta/data-model.md#the-compiled-artifact-and-schema-versioning) + the [cross-repo contract](../architecture/cross-repo-contract.md#13-community-metadata-a-three-repo-seam) (mirror-mode servers read the artifact directly) |
| Meta schemas, `metaserve` API, or intake tooling (`route.yml`, the intake denylist included) | [meta developer pages](../meta/overview.md) (data model, API, contributing) - and the [cross-repo contract](../architecture/cross-repo-contract.md) when the server's `/meta` envelope is affected, and `/users/community/contributing-from-your-server` when the limits that hold a Bridge's submission change |
| meta.audiosilo.app site UI | `/users/community/meta-site` + the `meta/` screenshots (+ [meta/overview.md](../meta/overview.md) when it is a site-architecture change) |
| The Docker entrypoint (`docker-entrypoint.sh`: `PUID`/`PGID`, the chown and the folder checks) or the folder permission wording (`internal/fsaccess`, a backup's `permission_denied`) | [configuration.md](../server/configuration.md#folder-permissions) + `/users/troubleshooting#permission-denied` (it quotes the log lines verbatim) + the install pages (`/users/getting-started/quickstart-docker`, `install-synology`, `install-unraid`) |
| Build / release / distribution | [release-pipeline.md](../architecture/release-pipeline.md) + [releasing.md](./releasing.md) (+ `/users/getting-started/install-unraid` when the native archives or the LinuxServer.io image's template, paths or defaults change) |
| A capability flag | [server/api/index.md](../server/api/index.md) + the feature's user page |
| The shared design tokens (`audiosilo-server/admin-ui/src/styles/globals.css`: colours, font stacks, shadows, motion) | Section 2 (shared tokens) of this site's `src/css/custom.css`, copied across verbatim (the dark block stays inside its `@media screen` wrapper), then a look at the built site in light and dark |
Expand Down
1 change: 1 addition & 0 deletions docs-developers/frontend/chromecast.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ The pure rules are `cast-media.ts`:
| `codec` | The book needs the transcoder and the server has none. |
| `no-connection` | The book's server isn't signed in. |
| `episode` | A podcast episode, which never casts. |
| `proxy` | The address in use is the origin of the connection's [proxy headers](state-and-data.md#proxy-headers) (`behindProxy`). The receiver fetches by itself and can't send them, so the proxy would turn it away. |

- **`castNeedsTranscode`** is true for the server's `direct_playable: false`, or for a
codec the Default Media Receiver doesn't play (`CAST_UNPLAYABLE_CODECS`: ALAC, the
Expand Down
16 changes: 16 additions & 0 deletions docs-developers/frontend/connect-and-addresses.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ counted by `StepDots` (`CONNECT_STEPS`): Start, Sign in and Ready.
[`lan_url`](../server/configuration.md#home-address-lan_url)).
- The server's error.

#### Proxy headers on the connect screen

Off the served player, a **Proxy headers** toggle opens a `ProxyHeadersEditor`
for the address as the listener typed it (`typedUrl`, never the field after the
flow rewrites it to a plain-http fallback). The set is held in memory as the
session's `pendingProxyHeaders`, and only for an https origin. The flow's bare
clients are `connectClient(base)` (`src/api/connect-client.ts`) and
`probeServerId`, through `connectProxyHeaders(url)`: the pending set for that
origin, else a signed-in connection's set for it (a second person on the same
server). A reconnect's sign-in (`ownId`) takes only the pending set or that
connection's own set, never another connection's. The scanner hands a code back
to a new `ConnectStart`, which takes up the pending set, so a QR code pairs with
the headers when its address is the field's. The flow's layout drops the pending
set when the flow is left, and a sign-in stores it with the connection (see
[Proxy headers](state-and-data.md#proxy-headers)).

#### Pairing links, QR codes and invite links

Native offers **Scan a QR code** (`/connect/scan`, `expo-camera`). Web offers **I have a
Expand Down
51 changes: 50 additions & 1 deletion docs-developers/frontend/state-and-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ endpoints themselves.
server keeps the stored app when a request has no header.

See [Client identification](../server/api/index.md#client-identification-x-audiosilo-client).
- **Proxy headers.** A connection's [proxy headers](#proxy-headers) are the
constructor's sixth argument. `request()` and every hand-built request send
them, `authHeaders()` hands them to the media layers with the token, and
`proxyHeadersFor(url)` gives them alone for a URL that carries `?token=`.
- **Path-addressed everything.** Content calls are
`GET /libraries/{id}/{item,chapters,cover,stream}?path=…` and the like. The path
rides as a query parameter, never a URL segment, because encoded slashes are a
Expand Down Expand Up @@ -1546,6 +1550,51 @@ Tokens are stripped before the metadata is persisted.
`serverId` on a connection persisted before that field existed. It runs after
[the launch reset](#the-launch-reset), so it never reads wrongly-keyed records.

#### Proxy headers

A connection to a server behind a sign-in proxy (Cloudflare Access, Authentik,
Authelia) can carry custom headers, `Connection.proxyHeaders` (`{ origin,
headers }`). The rules are in `src/lib/proxy-headers.ts` and the storage in
`src/lib/proxy-headers-store.ts`. They are credentials, kept like the token:

- **Storage.** They live in memory and in secure-store under
`audiosilo.proxy-headers.<id>` (see [Storage layers](#storage-layers)). `persist`
strips them and keeps only the non-secret `hasProxyHeaders` marker, and
`hydrate` reads them beside the token for a marked connection only. They never
reach AsyncStorage, the query cache, a URL or a log line.
- **Lifetime.** They go wherever the token goes: sign-out, a retired identity,
the auth reset, a connection `hydrate` drops. The identity migration copies them
in its token step (`copyProxyHeaders`). `removeConnection` deletes the token
first, so a failed delete stops the removal with the headers still kept. A new
connection to a server another connection here reaches with headers takes a
copy of that set under its own id.
- **One https origin.** A set names the origin (scheme, host and port) it was
entered for. `proxyHeaderRecord` is the one rule: a request to that origin
carries the set, and any other URL gets none: a plain-http URL, another port,
or the home address unless the set was entered for it (an https home address
is one of the Account card's choices). A set for an http origin is refused at entry and dropped
at load (`cleanProxyHeaders`). Reserved names (`Authorization`, `Cookie`,
`Host`, `Range`, `Content-Type`, `Content-Length`, `Connection`,
`Transfer-Encoding`, `X-AudioSilo-Client`) and values with control characters
are refused the same way.
- **Web.** Only a request same-origin with the page carries them. The server's
CORS lists a fixed set of request headers, so a custom one cross-origin would
fail its preflight. `<img>` and `<audio>` can't carry them and rely on the
proxy's browser sign-in.
- **Redirects** are the platform HTTP stack's to follow. On Android (checked
2026-11-14 on a Pixel 6a, a GET 302 from https to another https host) every
stack (okhttp fetch and downloads, expo-image, Media3) sends the custom headers
on to the new origin and drops `Authorization`; iOS is unchecked. The origin rule
covers only the first request (follow-up fe#295). The UI says "sent with requests
to &lt;origin&gt;" and never claims more (`src/i18n/locales.test.ts` refuses
"nowhere else", "go only" and "https only" in every locale).
- **Not carried:** Chromecast (`castBlockReason` `proxy`), the watch apps (the
pair message is unchanged) and calendar apps (the `releases.ics` link).

The connect flow and the Account card that edit them are in
[Connect and home/away addresses](connect-and-addresses.md#proxy-headers-on-the-connect-screen)
and [You and settings](you-and-settings.md#account-srccomponentsaccount).

#### Signing in: `setSession`

`setSession({ serverId, user, ... })` refuses an empty `server_id`. Then it adds
Expand Down Expand Up @@ -2197,7 +2246,7 @@ There are two deliberate tiers. Know which one you are writing to.
| Layer | Module | Backing | Used for |
|---|---|---|---|
| Plain | `src/lib/storage.ts` (`getItem`/`setItem`/`removeItem`, JSON-serialized) | AsyncStorage on native; localStorage on web, through AsyncStorage's web shim | connection metadata, settings, language pref, downloads registry, progress mirror and queue, the listening outbox, open span and sync state, device id |
| Secret | `src/lib/secure-store.ts` (`getSecure`/`setSecure`/`deleteSecure`) | **expo-secure-store** (iOS Keychain, Android Keystore) on native. On iOS it uses the `audiosilo.tokens.afu` keychain service, readable after first unlock (see [Native integrations](native-integrations.md#tokens-on-a-locked-phone-srclibsecure-storets)). localStorage on web, where SecureStore doesn't exist. | **session tokens** (`audiosilo.token.<connectionId>`), plus profile PIN hashes (`audiosilo.profilePin.<profileId>`) and the device's copy of an upcoming calendar link (`audiosilo.upcoming-calendar.<connectionId>`) |
| Secret | `src/lib/secure-store.ts` (`getSecure`/`setSecure`/`deleteSecure`) | **expo-secure-store** (iOS Keychain, Android Keystore) on native. On iOS it uses the `audiosilo.tokens.afu` keychain service, readable after first unlock (see [Native integrations](native-integrations.md#tokens-on-a-locked-phone-srclibsecure-storets)). localStorage on web, where SecureStore doesn't exist. | **session tokens** (`audiosilo.token.<connectionId>`), plus profile PIN hashes (`audiosilo.profilePin.<profileId>`), the device's copy of an upcoming calendar link (`audiosilo.upcoming-calendar.<connectionId>`) and a connection's [proxy headers](#proxy-headers) (`audiosilo.proxy-headers.<connectionId>`) |

The split exists because these values are secrets. Hardware-backed storage on
native is worth the extra API for them, while everything else is non-sensitive
Expand Down
1 change: 1 addition & 0 deletions docs-developers/frontend/you-and-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ Each block is gated on its capability, so an older server shows less, quietly:
| Password: set or change in a dialog that lifts above the keyboard | `PasswordCard`, `PasswordDialog`, `usePasswordEditor` | hidden for a demo account |
| Pair another device: `api.pair()`, the QR, a countdown from arrival (`PAIRING_TTL_MS`, the server's 10-minute `pairingTTL`), Copy link on web, Share on native, Make a new code once expired | `PairDeviceCard`, `usePairing` | hidden for a kid's account (`isKidAccount`) |
| The watch (Apple Watch on iOS, Wear OS watch on Android, worded by `watchWording`): Install on watch (Android), Set up / Send again, signed in as, the listening it has to send, last sync, and a link to its books in Downloads (`openDownloads`; the books themselves are `WatchSection`, [Apple Watch](apple-watch.md#the-watch-in-downloads), [Wear OS](wear-os.md#the-phone-in-downloads)) | `WatchCard`, `src/watch/` | iOS or Android with a paired watch (`watchLink.supported`, `watchCardVisible`); hidden for a kid's or demo account |
| Proxy headers: the connection's set, names only (values never shown again; an edited row keeps a stored value as "Kept as it is" unless one is typed), Send to among the connection's https origins (`proxyOriginChoices`), Save and Remove headers, the https-only caption when there is none, and what can't carry them (see [Proxy headers](state-and-data.md#proxy-headers)) | `ProxyHeadersCard` | not gated on flags, as it matters most when the proxy refuses everything; for a demo account only when it has a set |
| Signed-in devices: sessions only, this device first (`signedInSessions`), with app, platform, last seen and a glyph (see [Signed-in devices](#signed-in-devices)) | `DevicesSection` | `my_devices` |
| Listening sync: how many listens wait to reach this server, when this device last synced, and the "Couldn't sync" list (see [Listening sync](#listening-sync)) | `ListeningSyncCard`, `useListeningSync` (`src/playback/use-listening-sync.ts`) | `listening_sync`, or anything still waiting or listed (a server rolled back keeps showing what it left) |
| Personal API keys: list, create in a dialog, reveal once, revoke with a confirm | `ApiKeysSection`, `useApiKeysManager`, `ApiKeyCreatedModal` | `api_keys` (a notice when it's `false`); hidden for a demo or kid's account |
Expand Down
7 changes: 6 additions & 1 deletion docs-developers/server/backups-and-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ push out the copy of the database as the older server left it.
- whether a backup is being made;
- `last`: the newest attempt since the server started, with a short `error`
(`disk_full`, `permission_denied` or `failed`). The cause is in the log.
A backup checks its folder with a probe file of its own before `VACUUM INTO`
(`fsaccess.CheckWrite`), since SQLite words a refused folder as "unable to
open database file". A refusal at any step (the check, the chmod, the rename)
is `permission_denied`, and `Result.Detail` (not on the wire) holds the
[`fsaccess`](configuration.md#the-servers-own-checks) sentence with the fix.
- `latest`: the newest backup in the folder that isn't a before-restore or
before-upgrade copy, whenever it was made;
- `next`: `null` when the schedule is off.
Expand Down Expand Up @@ -324,7 +329,7 @@ subscribed to its kind. A nil `*Service` does nothing, so callers don't check.
| `new_device` | every pairing exchange (`POST /auth/exchange`), and a password sign-in (`POST /auth/login`) unless an earlier session of the same person already carried its `device_id` (`auth.IssueSession`). A sign-in without a `device_id` always counts. Never for demo accounts. | `user`, `device` (the name the client sent, one plain line of at most 100 characters), `app` (from `X-AudioSilo-Client`, may be empty) |
| `invite_redeemed` | a pairing exchange whose token came from an invite code | `user` (whose invite), `device` |
| `update_available` | `updates.Checker.OnAvailable` after a check that finds a newer release. Announced **once per version** (`dedup_key` is the version). | `version`, `name`, `url` (the release page) |
| `backup_failed` | `backup.Service.OnFailure` (scheduled or manual) | `trigger`, `error` (`disk_full`, `permission_denied`, `failed`) |
| `backup_failed` | `backup.Service.OnFailure` (scheduled or manual) | `trigger`, `error` (`disk_full`, `permission_denied`, `failed`), and for `permission_denied` a `detail` sentence (the folder, the server's user, its owner and the fix) that the notification's text uses |
| `bridge_job_finished` | `bridge.Service.OnTransition` (wired by `API.SetBridge`): a [Bridge](../bridge/index.md) job moved **into** `done` | `job_id`, `job_kind`, `state`, `library`, `library_id`, `path`, `title`, plus per-kind fields. See [Bridge event data](#bridge-event-data). |
| `bridge_job_failed` | the same hook, for a job that moved **into** `failed` or into a notable `needs_attention`. See [Bridge event data](#bridge-event-data). | as `bridge_job_finished`, plus `code` and `detail` |
| `book_released` | the [upcoming releases](api/reference.md#upcoming-releases) job (`upcoming.Runner`, wired to `API.announceReleases`). See [`book_released`](#book_released). | `count`, `titles` (the first five, each `Title (Series #position)`, or `Title (Series)` without a position) |
Expand Down
51 changes: 50 additions & 1 deletion docs-developers/server/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,7 @@ by the settings table in `internal/config/settings.go` (`fields`: each entry's
| `AUDIOSILO_SESSION_DAYS` | `activity.session_days` | integer, 30-3650 |
| `AUDIOSILO_BACKUP_SCHEDULE` | `backups.schedule` | `""`, `daily:HH:MM` or `weekly:DAY:HH:MM` |
| `AUDIOSILO_BACKUP_KEEP` | `backups.keep` | integer, 1-365 |
| `AUDIOSILO_BACKUP_DIR` | `backups.dir` | absolute path |
| `AUDIOSILO_BACKUP_DIR` | `backups.dir` | absolute path; the Docker entrypoint also gives the folder to `PUID`/`PGID` (see [Under Docker](#under-docker)) |
| `AUDIOSILO_MODULES_PODCASTS_ENABLED` | `modules.podcasts.enabled` | `strconv.ParseBool` (`true`/`1`/…); locks the console toggle when set |
| `AUDIOSILO_MODULES_CONTRIBUTIONS_ENABLED` | `modules.contributions.enabled` | `strconv.ParseBool` (`true`/`1`/…); each module's toggle is `AUDIOSILO_MODULES_<ID>_ENABLED` |
| `AUDIOSILO_PODCAST_DISCOVERY_ENABLED` | `podcasts.discovery.enabled` | `strconv.ParseBool` (`false`/`0`/… turns it off); read at start |
Expand Down Expand Up @@ -1252,3 +1252,52 @@ tools.
| `<data>/restore.json` | A restore waiting for the next start (removed when it is applied or refused) |
| `<data>/restore-result.json` | How the last restore went |
| `<data>/audiosilo.db.before-restore-<time>` | Only when a restore couldn't copy the database it replaced: that database's files, renamed aside |

## Folder permissions

The server needs read and write access to its data and backups folders and read
access to its libraries. It checks them itself on every install, and the Docker
image's entrypoint checks them once more before the server starts.

### The server's own checks

The server words a refused folder in `internal/fsaccess`. Before
`config.Load` and `store.Open`, the launcher writes a probe file in the data
folder and opens `audiosilo.db`, `-wal` and `-shm` read-write. A refusal stops
the start with `the data folder isn't usable: ...`, naming the path, the user
`uid:gid`, the owner and the fix, rather than SQLite's "unable to open database
file". The fix (`fsaccess.Hint`) is `PUID` and `PGID` set to the owner only in a
container (`/.dockerenv` or `/run/.containerenv`); outside one it is running the
server as the owner. It never advises running as root. It warns `backups will fail` at start when
the backups folder exists and refuses it (`backup.Service.CheckFolder`). A
refused backup is `permission_denied` (see
[Backups](backups-and-notifications.md#status)), and a library root the server
can't list is [unavailable](scanner.md#the-unavailable-root-guard).

### Under Docker

The image's `docker-entrypoint.sh` starts as root, gives the server's user its
folders, checks them as that user, then drops to it with `su-exec PUID:PGID`
(default `1000:1000`). The server runs as root only when `PUID` is `0`.
`su-exec` sets only that group, so access a host user has through another group
doesn't reach the server.

- **Ownership.** It runs `chown -R` on the data folder (`--data`, `/data` by
default). When `AUDIOSILO_BACKUP_DIR` is an absolute path, it gives the user
that folder and only AudioSilo's own files in it (`audiosilo-*.db` and
`.audiosilo-*`, regular files, `chown -h`), since a backups share may hold other
apps' files. A path that is the container's root, however spelled, or that
holds a `..`, is skipped. The data folder's `chown -R` crosses into a share
mounted under it, so a backups share belongs at a path of its own, never
`/data/backups`.
- **Checks.** As the server's user, it writes a `mktemp` file in the data and
backups folders and reads one entry of every other mounted folder (the
audiobooks, by the rule of the setup's mount hints). For a folder that fails
it prints the folder, the user, the folder's owner and the fix (`PUID` and
`PGID` set to the owner, or giving the user access), and carries on. Every
check and every step on the backups folder is bounded by `timeout 5`. A check
cut off says the folder didn't answer, with no owner advice.
- **Under `--user`** (compose `user:`) it skips the chown, runs the same checks,
and its fix names `user:` instead of `PUID` and `PGID`.

`scripts/test-entrypoint.sh` tests the entrypoint in Docker. CI doesn't run it.
Loading
Loading