Merge pull request #695 from EdouardVanbelle/ci/bundled-binaries

ci: bundled binaries
This commit is contained in:
Dionisio Pozo
2026-08-29 19:24:24 +02:00
committed by GitHub
37 changed files with 3392 additions and 659 deletions
+100
View File
@@ -176,6 +176,21 @@ jobs:
with: with:
components: clippy components: clippy
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
# `--all-features` includes `bundled-assets`, whose build.rs guard
# requires `static-dist/index.html` at compile time (rust-embed
# scans the folder). Build the SPA first so the lint pass covers
# the embed code paths without needing to enumerate features
# around it. ~90 s once, cached by npm-cache on repeats.
- uses: actions/setup-node@v4
with:
node-version: 26.3.0
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Build SPA (needed for --all-features / bundled-assets)
working-directory: frontend
run: npm ci && npm run build
- run: cargo clippy --all-targets --all-features -- -D warnings - run: cargo clippy --all-targets --all-features -- -D warnings
# Mirrors the `wasm-check` justfile recipe. The wasm crate is a # Mirrors the `wasm-check` justfile recipe. The wasm crate is a
@@ -306,6 +321,18 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
# `--all-features` enables `bundled-assets`, whose build.rs guard
# requires `static-dist/index.html` at compile time. Build the SPA
# first so tests can compile the embed code paths. ~90 s, cached.
- uses: actions/setup-node@v4
with:
node-version: 26.3.0
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Build SPA (needed for --all-features / bundled-assets)
working-directory: frontend
run: npm ci && npm run build
- name: Initialize test database - name: Initialize test database
# Applies every migration + seeds the integration-test admin row. # Applies every migration + seeds the integration-test admin row.
# Same script used by `just test-integration` locally. # Same script used by `just test-integration` locally.
@@ -460,6 +487,79 @@ jobs:
path: tests/api/storage/ path: tests/api/storage/
retention-days: 7 retention-days: 7
bundled-binary-test:
# `--features bundled-assets` end-to-end integration test.
#
# Builds oxicloud with the SPA baked in via rust-embed, boots it
# against a nonexistent OXICLOUD_STATIC_PATH so the embed path is
# forced, and asserts SPA + locales + immutable-cache headers +
# CSP all serve correctly from the embedded corpus. Guards against
# three failure classes that don't surface in filesystem-served CI:
#
# 1. rust-embed configuration (glob patterns silently producing a
# 0-file embed — hit 2026-08-28).
# 2. Debug-vs-release drift (rust-embed's dynamic-read mode in
# debug builds masks embed bugs; `debug-embed` feature bakes
# files in for both profiles).
# 3. Axum `Path` extractor on fallback routes returning 500 (the
# `serve_root` handler needs `Request` extraction — hit 2026-08-28).
#
# See tests/bundled-binary/run.sh + docs/plan/bundled-binary.md § 2.
#
# Doesn't reuse the `build` job's artifact because that binary is
# compiled with `--features plugins`, not `--features bundled-assets`
# — different feature set = different target. `Swatinem/rust-cache`
# still shares dependency compilation between the two jobs.
name: Bundled-assets binary — embed + SPA-serve integration
needs: changes
if: |
github.event_name == 'pull_request' &&
(needs.changes.outputs.backend == 'true' || needs.changes.outputs.frontend == 'true')
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
# Same disk-hygiene pattern as the `build` job — cargo release
# link + full node_modules install would otherwise squeeze the
# runner disk budget under peak concurrency.
- uses: jlumbroso/free-disk-space@main
with:
tool-cache: false
android: true
dotnet: true
haskell: true
large-packages: false
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v4
with:
node-version: 26.3.0
cache: npm
cache-dependency-path: frontend/package-lock.json
# Build the SPA up front so the test script can run with
# `SKIP_FRONTEND_BUILD=1` — makes the test-runner log clean
# (no duplicated npm ci noise) and puts the SPA build's cost
# in its own step for CI-side timing visibility.
- name: Build SPA (Vite → static-dist/)
working-directory: frontend
run: npm ci && npm run build
- name: Run bundled-binary integration test
run: bash tests/bundled-binary/run.sh
env:
SKIP_FRONTEND_BUILD: "1"
# Preserve the server log even on failure so a red run doesn't
# require re-running locally to see what happened at boot.
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: bundled-binary-server-log
path: tests/bundled-binary/server.log
retention-days: 7
litmus: litmus:
name: WebDAV RFC 4918 — litmus (59/59) name: WebDAV RFC 4918 — litmus (59/59)
needs: build needs: build
+288
View File
@@ -0,0 +1,288 @@
name: "Release Binaries (musl-linux + macOS)"
# Per-run title shown in the Actions tab — makes it obvious at a
# glance which tag is being packaged and whether a manual run is a
# dry-run (build tarballs into workflow artifacts, DON'T attach to
# any GitHub Release).
run-name: >-
Release Binaries
${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '[DRY-RUN]' || '' }}
— ${{ github.event.inputs.version || github.ref_name }}
# TRIGGERS — deliberately narrow. This workflow builds 4 platform
# binaries (~15-25 min wall-clock, matrix of native runners) and
# attaches them to a GitHub Release. Running on every push to main
# would be gratuitous CI cost + noise — the point is to package
# releases, not to sanity-check the tip. The bundled-binary
# integration test in ci.yml already covers "does the embed still
# work" on every PR.
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: 'Existing tag to package (e.g. v0.9.0). Must exist on origin.'
required: true
dry_run:
description: 'Dry run — build + upload tarballs as workflow artifacts, skip attaching to a Release. Use to smoke-test workflow edits without publishing.'
required: false
type: boolean
default: false
# Concurrency key includes the tag ref so different tags don't cancel
# each other; `cancel-in-progress: false` because tag builds are
# unique + immutable — a superseded release build has nothing to cancel.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
jobs:
# ── Stage 1: Build the SPA once, share across all platforms ─────────
#
# The SvelteKit build is arch-independent so a single ubuntu runner
# produces static-dist/ for every downstream binary-build matrix
# entry — saves ~2 min × 4 = 8 min vs building it per platform.
frontend-build:
name: Build SPA (Vite → static-dist/)
# Publish gate — same fork-friendly pattern as docker-publish.yml.
# Canonical repo always builds; forks stay quiet unless the fork
# owner opts in via `vars.ENABLE_BINARY_RELEASE=true` under Settings
# → Secrets and variables → Actions → Variables.
if: |
github.repository == 'AtalayaLabs/OxiCloud' ||
vars.ENABLE_BINARY_RELEASE == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Build the exact tag being packaged. `github.ref` is
# refs/tags/vX.Y.Z on push, refs/heads/... on dispatch (we
# override via `inputs.version` in that case).
ref: ${{ github.event.inputs.version || github.ref }}
- uses: actions/setup-node@v4
with:
node-version: 26.3.0
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Build SPA
working-directory: frontend
run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: static-dist
# Repo-root output (SvelteKit adapter-static's `pages:
# '../static-dist'`). Downstream jobs restore it to the same
# location so rust-embed's `#[folder = "static-dist/"]`
# resolves without any path juggling.
path: static-dist/
retention-days: 1
# ── Stage 2: Build one binary per target ────────────────────────────
#
# 3-way matrix — 2 musl-linux (native amd64 + arm64) + macOS Apple
# Silicon. Windows and Intel macOS are deferred:
#
# * Intel macOS (`x86_64-apple-darwin` / macos-13 runner) — dropped
# 2026-08-29. Apple is phasing out Intel Macs from GitHub's hosted
# runners; the `macos-13` tier is scheduled for deprecation and
# queues stretched past 1 h during v0.9.0-rc1 build. Intel Mac
# users have three fallbacks: (1) `cargo install oxicloud
# --locked --features bundled-assets` from source, (2) `docker
# pull --platform linux/amd64 ghcr.io/atalayalabs/oxicloud`,
# (3) any of the two Linux musl tarballs via a Linux VM. The
# Intel-Mac install base is small and shrinking (Apple Silicon
# >90% of new sales) so first-class shipping isn't worth the
# CI-availability tax.
# * Windows — separate work when demand appears.
#
# All targets run natively on GitHub-hosted runners with the host's
# glibc + rustup, then cross-compile to their target triple via
# `rustup target add`. The musl-linux targets install `musl-tools`
# (which provides `musl-gcc`) so aws-lc-sys and friends can link
# against musl. macOS runners already have the apple-* triples
# pre-installed.
#
# History: an earlier draft ran Linux builds INSIDE the
# `rust:1.96-alpine3.24` container the Dockerfile uses — matched
# Docker image byte-for-byte. Broke on `ubuntu-22.04-arm`: GitHub
# Actions JS-based actions (`actions/checkout`, artifact steps,
# setup-node) can't run inside Alpine on ARM64 — the Node.js binary
# they ship depends on glibc, and the x64-Alpine workaround doesn't
# extend to arm64. Native ubuntu + musl-tools sidesteps the whole
# thing and produces the same output (both are `cargo build
# --target x86_64-unknown-linux-musl` / `aarch64-...-musl`).
binary-build:
name: Build ${{ matrix.triple }}
needs: frontend-build
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
# `fail-fast: false` — one platform's compile failure shouldn't
# cancel the other three. Partial releases are better than none.
fail-fast: false
matrix:
include:
- triple: x86_64-unknown-linux-musl
runner: ubuntu-22.04
rustflags: "-C target-cpu=x86-64-v2"
- triple: aarch64-unknown-linux-musl
runner: ubuntu-22.04-arm
# ARMv8-A baseline — covers Pi 4/5, Graviton, every 64-bit
# ARM Linux server. `generic` is rustc's neutral baseline.
rustflags: "-C target-cpu=generic"
- triple: aarch64-apple-darwin
runner: macos-latest
rustflags: "-C target-cpu=apple-m1"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.version || github.ref }}
# musl-tools ships `musl-gcc` — the C-compiler wrapper that rustc
# invokes for -musl targets. Without it, `cargo build --target
# aarch64-unknown-linux-musl` fails with "linker `musl-gcc` not
# found" on any C-linked dep (aws-lc-sys, ring, sqlx's native
# backend when enabled).
- name: Install musl-tools (Linux only)
if: contains(matrix.triple, '-linux-musl')
run: sudo apt-get update && sudo apt-get install -y musl-tools
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.triple }}
- uses: Swatinem/rust-cache@v2
with:
# Key by triple so the 4 targets don't share caches
# (different feature set + different target triple = different
# compiled artefacts).
key: ${{ matrix.triple }}
- uses: actions/download-artifact@v4
with:
name: static-dist
path: static-dist/
# `--features bundled-assets` bakes static-dist/ into the binary
# via rust-embed. `--bin oxicloud` — the single binary the merge
# (Deliverable 1b) consolidated everything into.
- name: Build binary
env:
# Per-triple CPU baseline — release binaries target the widest
# realistic install base for their arch. See
# docs/plan/bundled-binary.md § 3.
RUSTFLAGS: ${{ matrix.rustflags }}
# Git metadata injection — build.rs reads these env vars to
# stamp GIT_HASH / GIT_BRANCH into the binary. Without them
# `oxicloud --version` reports "unknown".
GITHUB_SHA: ${{ github.sha }}
GITHUB_REF_NAME: ${{ github.ref_name }}
run: |
cargo build --release --features bundled-assets --bin oxicloud --target ${{ matrix.triple }}
# Assemble the tarball layout documented in
# docs/plan/bundled-binary.md § 4: oxicloud + example.env +
# LICENSE + README-install.md, rooted under a per-version-per-
# triple directory so `tar xzf` lands cleanly.
- name: Package tarball
run: |
set -euo pipefail
# Version = tag stripped of leading `v` (workflow_dispatch)
# or ref_name stripped (push tag). Falls back to ref_name
# verbatim if neither strip matches.
RAW_REF="${{ github.event.inputs.version || github.ref_name }}"
VERSION="${RAW_REF#v}"
DIST="oxicloud-${VERSION}-${{ matrix.triple }}"
mkdir -p "dist/${DIST}"
cp "target/${{ matrix.triple }}/release/oxicloud" "dist/${DIST}/oxicloud"
cp example.env "dist/${DIST}/example.env"
cp LICENSE "dist/${DIST}/LICENSE"
# README-install.md may not exist yet in early releases —
# ship a stub that points at the docs site so users have
# something in the tarball. Deliverable 6 replaces it with
# a proper install guide.
if [ -f docs/install/binary.md ]; then
cp docs/install/binary.md "dist/${DIST}/README-install.md"
else
cat > "dist/${DIST}/README-install.md" <<'MD'
# OxiCloud — Installation
Full documentation: https://github.com/AtalayaLabs/OxiCloud/tree/main/docs
Quickstart:
1. Set DATABASE_URL to a PostgreSQL 13+ instance
(with pg_trgm + ltree extensions).
2. Copy example.env → .env, edit as needed.
3. Run ./oxicloud.
Optional: install ffmpeg for server-side video thumbnails
(or set OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false to disable).
MD
fi
# Deterministic tar (owner/group/mtime pinned) so re-running
# the build produces byte-identical archives — helps with
# reproducible-build audits and cheap hash verification.
tar --owner=0 --group=0 -czf "dist/${DIST}.tar.gz" -C dist "${DIST}"
ls -la "dist/${DIST}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: tarball-${{ matrix.triple }}
path: dist/*.tar.gz
retention-days: 1
# ── Stage 3: Attach all tarballs + SHA256SUMS to the Release ────────
#
# `dry_run: true` (workflow_dispatch only) skips this job — the
# binary tarballs stay as workflow artifacts (accessible from the
# run page for 1 day) but nothing lands on any Release.
release:
name: Attach tarballs to GitHub Release
needs: binary-build
if: |
needs.binary-build.result == 'success' &&
(github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: tarball-*
path: dist/
merge-multiple: true
- name: Compute SHA256SUMS
run: |
set -euo pipefail
cd dist
# Sort output for stable ordering across re-runs — the file
# doubles as a manifest an operator can `diff` between two
# release runs to prove they're identical.
sha256sum *.tar.gz | sort > SHA256SUMS
cat SHA256SUMS
# softprops/action-gh-release@v2 semantics:
# - If the Release for this tag EXISTS (created by release.yml
# which runs in parallel on the same tag push), attaches the
# files to it.
# - If it doesn't yet exist (race — release.yml still running),
# creates a bare Release which release.yml then fills in with
# notes when it finishes.
# Benign either way; see docs/plan/bundled-binary.md § 5
# "Parallel-fire behaviour on tag push".
- name: Attach to Release
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.tar.gz
dist/SHA256SUMS
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Generated
+180 -12
View File
@@ -29,6 +29,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "adler32"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
[[package]] [[package]]
name = "adobe-cmap-parser" name = "adobe-cmap-parser"
version = "0.4.1" version = "0.4.1"
@@ -160,7 +166,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -171,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell_polyfill", "once_cell_polyfill",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1172,6 +1178,16 @@ dependencies = [
"tinyvec", "tinyvec",
] ]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"serde",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.2"
@@ -1962,6 +1978,12 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "dary_heap"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
[[package]] [[package]]
name = "dashmap" name = "dashmap"
version = "6.2.1" version = "6.2.1"
@@ -2267,7 +2289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2884,6 +2906,19 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "globset"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata",
"regex-syntax",
]
[[package]] [[package]]
name = "group" name = "group"
version = "0.13.0" version = "0.13.0"
@@ -3285,7 +3320,7 @@ dependencies = [
"libc", "libc",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2 0.5.10", "socket2 0.6.4",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -3507,6 +3542,39 @@ dependencies = [
"quick-error", "quick-error",
] ]
[[package]]
name = "include-flate"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce"
dependencies = [
"include-flate-codegen",
"include-flate-compress",
]
[[package]]
name = "include-flate-codegen"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0"
dependencies = [
"include-flate-compress",
"proc-macro-error3",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "include-flate-compress"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd"
dependencies = [
"libflate",
"zstd",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -3603,7 +3671,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [ dependencies = [
"hermit-abi", "hermit-abi",
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -3802,6 +3870,30 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libflate"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4da9b700e758e57152a1fd1c52cbdc5727c1aa6d8743dc1acda917398f1d76c"
dependencies = [
"adler32",
"crc32fast",
"dary_heap",
"libflate_lz77",
"no_std_io2",
]
[[package]]
name = "libflate_lz77"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd"
dependencies = [
"hashbrown 0.16.1",
"no_std_io2",
"rle-decode-fast",
]
[[package]] [[package]]
name = "libloading" name = "libloading"
version = "0.9.0" version = "0.9.0"
@@ -4278,6 +4370,15 @@ dependencies = [
"rawpointer", "rawpointer",
] ]
[[package]]
name = "no_std_io2"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -4318,7 +4419,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4597,6 +4698,7 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
"rayon", "rayon",
"reqwest", "reqwest",
"rust-embed",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0", "sha2 0.11.0",
@@ -5006,6 +5108,28 @@ dependencies = [
"toml_edit", "toml_edit",
] ]
[[package]]
name = "proc-macro-error-attr3"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error3"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50"
dependencies = [
"proc-macro-error-attr3",
"proc-macro2",
"quote",
"syn 3.0.2",
]
[[package]] [[package]]
name = "proc-macro-utils" name = "proc-macro-utils"
version = "0.10.0" version = "0.10.0"
@@ -5140,7 +5264,7 @@ dependencies = [
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls 0.23.40", "rustls 0.23.40",
"socket2 0.5.10", "socket2 0.6.4",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -5177,7 +5301,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"once_cell", "once_cell",
"socket2 0.5.10", "socket2 0.6.4",
"tracing", "tracing",
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
@@ -5558,6 +5682,12 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rle-decode-fast"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"
[[package]] [[package]]
name = "rmp" name = "rmp"
version = "0.8.15" version = "0.8.15"
@@ -5597,6 +5727,44 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rust-embed"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9"
dependencies = [
"include-flate",
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097"
dependencies = [
"mime_guess",
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.117",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0"
dependencies = [
"globset",
"include-flate",
"sha2 0.11.0",
"walkdir",
]
[[package]] [[package]]
name = "rust-stemmers" name = "rust-stemmers"
version = "1.2.0" version = "1.2.0"
@@ -5651,7 +5819,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6174,7 +6342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6686,7 +6854,7 @@ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -8154,7 +8322,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
+88 -20
View File
@@ -4,6 +4,24 @@ version = "0.8.7"
edition = "2024" edition = "2024"
default-run = "oxicloud" default-run = "oxicloud"
# `cargo binstall oxicloud` — fetches the prebuilt release tarball for the
# host triple from GitHub Releases (attached by
# `.github/workflows/release-binaries.yml`) instead of compiling from
# source. Templates match the tarball naming
# `oxicloud-<version>-<triple>.tar.gz` produced by that workflow.
#
# `pkg-fmt = "tgz"` — otherwise binstall guesses from the URL extension;
# being explicit lets `cargo binstall oxicloud` succeed on Windows too
# (where the URL string parsing differs).
#
# Once the first tagged release lands on GitHub, this becomes a one-line
# install for anyone with the Rust toolchain who prefers not to build
# from source and doesn't want Docker either.
[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/v{ version }/oxicloud-{ version }-{ target }.tar.gz"
pkg-fmt = "tgz"
bin-dir = "oxicloud-{ version }-{ target }/{ bin }{ binary-ext }"
[dependencies] [dependencies]
mimalloc = { version = "0.1.52", default-features = false } mimalloc = { version = "0.1.52", default-features = false }
@@ -47,6 +65,25 @@ futures = "0.3.32"
async-stream = "0.3.6" async-stream = "0.3.6"
async-trait = "0.1.89" async-trait = "0.1.89"
mime_guess = "2.0.5" mime_guess = "2.0.5"
# `rust-embed` — compile-time asset embedding for the `bundled-assets` feature.
# Optional so default builds never pull it in.
#
# Features:
# * `compression` — deflate-compress each embedded file at compile time; the
# handler decompresses lazily on first read (cached per-file in a
# `OnceCell`). Halves the on-disk contribution to the final binary.
# * `include-exclude` — enables the `#[include]` / `#[exclude]` glob
# attributes on the derive.
# * `debug-embed` — CRITICAL. Without this, debug builds read files from
# disk at runtime (dynamic mode) instead of compiling them in. The
# runtime read is fragile (`CARGO_MANIFEST_DIR` resolution + working
# directory dependency) and returned `total=0` for us on 2026-08-28.
# With `debug-embed`, both `cargo build` and `cargo build --release`
# produce a truly self-contained binary — the only sensible default
# for the `bundled-assets` feature.
#
# See src/interfaces/web/embedded.rs for the actual embed struct + handlers.
rust-embed = { version = "8", features = ["compression", "include-exclude", "debug-embed"], optional = true }
uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] } uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] }
thiserror = "2.0.18" thiserror = "2.0.18"
arc-swap = "1.9" arc-swap = "1.9"
@@ -168,6 +205,31 @@ faces-onnx = ["dep:ort", "dep:ndarray"]
# `examples/` can measure them. Off by default — adds nothing to prod builds. # `examples/` can measure them. Off by default — adds nothing to prod builds.
# Run with: `cargo bench --features bench` / `cargo run --release --features bench --example bench_thumbnails_mem`. # Run with: `cargo bench --features bench` / `cargo run --release --features bench --example bench_thumbnails_mem`.
bench = [] bench = []
# Empty marker feature that gates the `generate-openapi` binary out of the
# default release build set. `just openapi` flips it when the SPA needs a
# regenerated openapi.json; end-user release builds never do. Kept separate
# from `test_utils` for the same reason `load_seed_bin` is — enabling
# `dev_tools` on the CLI must not perturb the oxicloud dependency graph.
dev_tools = []
# Bake the SvelteKit build output (`static-dist/` at repo root) into the
# binary at compile time via `rust-embed`. Opt-in and off by default — the
# regular `cargo build --release` still produces a filesystem-served
# binary (matching the current Docker image where assets are separate
# layers, and the `just dev` HMR loop where Vite serves live). Release
# tarballs (`docs/plan/bundled-binary.md` § 2) build with this flag to
# ship a single self-contained executable.
#
# Precedence rule preserved: even when this feature is on, if
# OXICLOUD_STATIC_PATH points at an existing directory, that wins over
# the embedded fallback — ops can override embedded assets for locale
# patches or theming without a rebuild.
#
# Build-time invariant: `cargo build --features bundled-assets` requires
# `static-dist/` at the repo root (SvelteKit adapter-static emits there
# — `frontend/svelte.config.js`'s `pages: '../static-dist'`). `build.rs`
# fails fast with a pointer to `(cd frontend && npm run build)` when the
# directory is missing.
bundled-assets = ["dep:rust-embed"]
[dev-dependencies] [dev-dependencies]
criterion = "0.5" criterion = "0.5"
@@ -186,19 +248,12 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] }
[[bin]] [[bin]]
name = "generate-openapi" name = "generate-openapi"
path = "src/bin/generate-openapi.rs" path = "src/bin/generate-openapi.rs"
# Dev-only: regenerates `resources/gen/openapi.json` from the utoipa
[[bin]] # `#[utoipa::path]` annotations in the API handlers. Gated behind the
name = "migrate-nfc-filenames" # `dev_tools` feature so `cargo build --release --bins` (and the prod
path = "src/bin/migrate-nfc-filenames.rs" # Dockerfile) skip it entirely — end users have no reason to run it.
# Invoked by `just openapi`, which passes `--features dev_tools`.
[[bin]] required-features = ["dev_tools"]
name = "oxicloud-cli"
path = "src/bin/oxicloud-cli.rs"
# Operator toolbox — subcommand-driven CLI for tasks that don't belong
# in the main server. Currently: `oxicloud-cli opaque {setup,reset}`.
# Ships in the release Dockerfile as the single operator-facing helper
# (replaces the earlier per-task `opaque-setup` bin, which was folded
# into `oxicloud-cli opaque setup`).
[[bin]] [[bin]]
name = "opaque-hurl-helper" name = "opaque-hurl-helper"
@@ -207,7 +262,10 @@ path = "src/bin/opaque-hurl-helper.rs"
# handshake against a running server. Invoked from tests/api/run.sh # handshake against a running server. Invoked from tests/api/run.sh
# after opaque_substrate.hurl to cover the parts Hurl can't (OPRF # after opaque_substrate.hurl to cover the parts Hurl can't (OPRF
# blinding, AKE nonces are per-attempt-random). Not shipped in the # blinding, AKE nonces are per-attempt-random). Not shipped in the
# release Dockerfile (nothing outside tests/ calls it). # release Dockerfile (nothing outside tests/ calls it). Gated behind
# `test_utils` so `cargo build --release --bins` skips it; `run.sh`
# enables the feature explicitly when building the helper on demand.
required-features = ["test_utils"]
[[bin]] [[bin]]
name = "dpop-hurl-helper" name = "dpop-hurl-helper"
@@ -216,7 +274,8 @@ path = "src/bin/dpop-hurl-helper.rs"
# the DPoP-Nonce challenge/retry loop, and covers the wire-protocol # the DPoP-Nonce challenge/retry loop, and covers the wire-protocol
# scenarios Hurl can't express (per-request fresh jti/iat, replay # scenarios Hurl can't express (per-request fresh jti/iat, replay
# detection, malformed proofs, wrong htm/htu/alg/typ). Same # detection, malformed proofs, wrong htm/htu/alg/typ). Same
# no-ship-in-release status as opaque-hurl-helper. # no-ship-in-release status as opaque-hurl-helper — gated identically.
required-features = ["test_utils"]
[[bin]] [[bin]]
name = "load-seed" name = "load-seed"
@@ -938,11 +997,20 @@ opt-level = 1
debug = "line-tables-only" debug = "line-tables-only"
split-debuginfo = "unpacked" split-debuginfo = "unpacked"
# Incremental compilation caches per-function IR fingerprints so a # Incremental compilation caches per-function IR fingerprints so a
# small edit only recompiles what changed. On a single-crate rebuild # small edit only recompiles what changed. The `target/incremental/`
# (oxicloud is one crate) the savings are modest — worth < the ~7 GB # cache costs ~7 GB per profile, but at the current codebase size a
# incremental/ cache costs on disk. Rust-analyzer uses `cargo check`, # full rebuild is ~10 minutes and an incremental single-file edit is
# which has its own cache, so LSP responsiveness is unaffected. # seconds — the disk is worth it and then some. Rust-analyzer's own
incremental = false # `cargo check` cache is separate; LSP responsiveness is unaffected
# either way.
#
# History: this was `= false` early on when the crate was small and
# incremental's savings didn't cover the disk cost. Re-enabled
# 2026-08-29 as the full-rebuild time crossed the "feels annoying"
# threshold on typical dev-loop edits. If disk pressure ever spikes,
# `cargo clean -p oxicloud --profile dev` clears the incremental cache
# without wiping compiled deps.
incremental = true
[profile.bench] [profile.bench]
lto = "fat" lto = "fat"
+23 -26
View File
@@ -42,11 +42,8 @@ COPY build.rs ./
# Create a minimal project to download and cache dependencies # Create a minimal project to download and cache dependencies
RUN mkdir -p src/bin && \ RUN mkdir -p src/bin && \
echo 'fn main() { println!("Dummy build for caching dependencies"); }' > src/main.rs && \ echo 'fn main() { println!("Dummy build for caching dependencies"); }' > src/main.rs && \
echo 'fn main() {}' > src/bin/generate-openapi.rs && \
echo 'fn main() {}' > src/bin/migrate-nfc-filenames.rs && \
echo 'fn main() {}' > src/bin/oxicloud-cli.rs && \
echo 'fn main() {}' > src/bin/opaque-hurl-helper.rs && \ echo 'fn main() {}' > src/bin/opaque-hurl-helper.rs && \
cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin oxicloud-cli && \ cargo build --release --bin oxicloud && \
rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-* rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-*
# ─── Stage 3: Build the application ────────────────────────────────────────── # ─── Stage 3: Build the application ──────────────────────────────────────────
@@ -86,7 +83,7 @@ RUN DATABASE_URL="${DATABASE_URL}" \
GITHUB_SHA="${GITHUB_SHA}" \ GITHUB_SHA="${GITHUB_SHA}" \
GITHUB_REF_NAME="${GITHUB_REF_NAME}" \ GITHUB_REF_NAME="${GITHUB_REF_NAME}" \
GITHUB_HEAD_REF="${GITHUB_HEAD_REF}" \ GITHUB_HEAD_REF="${GITHUB_HEAD_REF}" \
cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin oxicloud-cli cargo build --release --bin oxicloud
# The SPA is built by the Vite frontend stage; bring it in for the runtime copy # The SPA is built by the Vite frontend stage; bring it in for the runtime copy
# below (build.rs has no asset pipeline — it only injects git metadata). # below (build.rs has no asset pipeline — it only injects git metadata).
COPY --from=frontend /static-dist ./static-dist COPY --from=frontend /static-dist ./static-dist
@@ -126,21 +123,24 @@ RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry,sharin
GITHUB_HEAD_REF="${GITHUB_HEAD_REF}" \ GITHUB_HEAD_REF="${GITHUB_HEAD_REF}" \
cargo build --release && \ cargo build --release && \
mkdir -p /app/bin && \ mkdir -p /app/bin && \
cp target/release/oxicloud /app/bin/oxicloud && \ cp target/release/oxicloud /app/bin/oxicloud
cp target/release/migrate-nfc-filenames /app/bin/migrate-nfc-filenames && \
cp target/release/oxicloud-cli /app/bin/oxicloud-cli
# ─── Stage 3c: Select the builder & normalise the binary path ───────────────── # ─── Stage 3c: Select the builder & normalise the binary path ─────────────────
# FROM expands the global ${BUILDER} arg to alias the chosen builder stage # FROM expands the global ${BUILDER} arg to alias the chosen builder stage
# (`builder` for CI/release, `builder-cache` for the e2e image). It then copies # (`builder` for CI/release, `builder-cache` for the e2e image). It then copies
# the two shipped binaries from the builder-specific ${BIN_DIR} into a single # the shipped binary from the builder-specific ${BIN_DIR} into a single stable
# stable path (/app/release) so the runtime stage's COPYs are independent of # path (/app/release) so the runtime stage's COPY is independent of which
# which builder ran. `static-dist` already lives at /app/static-dist in both # builder ran. `static-dist` already lives at /app/static-dist in both
# builders, so it needs no normalisation. # builders, so it needs no normalisation.
#
# Single `oxicloud` binary since v0.9.0 — the operator toolbox
# (`opaque setup`, `migrate nfc-filenames`, …) now lives under
# `oxicloud <subcommand>` rather than in standalone `oxicloud-cli` /
# `migrate-nfc-filenames` bins. See docs/plan/bundled-binary.md § 1b.
FROM ${BUILDER} AS app FROM ${BUILDER} AS app
ARG BIN_DIR ARG BIN_DIR
RUN mkdir -p /app/release && \ RUN mkdir -p /app/release && \
cp "${BIN_DIR}/oxicloud" "${BIN_DIR}/migrate-nfc-filenames" "${BIN_DIR}/oxicloud-cli" /app/release/ cp "${BIN_DIR}/oxicloud" /app/release/
# ─── Stage 4: Minimal runtime image ────────────────────────────────────────── # ─── Stage 4: Minimal runtime image ──────────────────────────────────────────
FROM alpine:3.24.0 FROM alpine:3.24.0
@@ -163,21 +163,18 @@ RUN apk --no-cache upgrade && \
addgroup -g 1001 -S oxicloud && \ addgroup -g 1001 -S oxicloud && \
adduser -u 1001 -S oxicloud -G oxicloud adduser -u 1001 -S oxicloud -G oxicloud
# Copy the compiled binary and entrypoint (--chmod avoids extra RUN chmod layers) # Copy the compiled binary and entrypoint (--chmod avoids extra RUN chmod layers).
#
# Single `oxicloud` binary — since v0.9.0 the operator toolbox lives
# under `oxicloud <subcommand>` rather than as standalone helper bins:
#
# docker run --rm <image> oxicloud opaque setup # print OPAQUE ServerSetup
# docker exec <container> oxicloud migrate nfc-filenames --dry-run
# # NFC-normalize storage.files.name (pre-June-2026 dbs; safe on new installs)
#
# Bare `oxicloud` (Docker CMD default) still starts the server — backwards
# compat preserved. See docs/plan/bundled-binary.md § 1b.
COPY --from=app --chmod=755 /app/release/oxicloud /usr/local/bin/ COPY --from=app --chmod=755 /app/release/oxicloud /usr/local/bin/
# Ship the NFC filename migration binary alongside the server so
# operators can run it inside the container without a separate Rust
# toolchain — `docker exec <container> migrate-nfc-filenames --dry-run`
# to preview, drop `--dry-run` to execute. One-shot tool, safe to
# ship; it only mutates `storage.files` rows whose name ≠ NFC(name).
COPY --from=app --chmod=755 /app/release/migrate-nfc-filenames /usr/local/bin/
# Ship the OPAQUE server-setup generator alongside the server so operators
# can generate their `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP` value inside the
# container without a separate Rust toolchain:
# docker run --rm <image> oxicloud-cli opaque setup # prints the base64 value
# One-shot, side-effect-free — safe to include; the runtime doesn't
# invoke it, admins do (see docs/config/authentication.md §OPAQUE).
COPY --from=app --chmod=755 /app/release/oxicloud-cli /usr/local/bin/
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \ RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \
chmod 755 /usr/local/bin/entrypoint.sh chmod 755 /usr/local/bin/entrypoint.sh
+10
View File
@@ -68,6 +68,16 @@ docker compose up -d
Open `http://localhost:8086`. Open `http://localhost:8086`.
### Prebuilt binary
Binary releases (Linux musl amd64/arm64, macOS Intel/Apple Silicon)
are attached to every tagged release on GitHub — the whole SPA + all
operator subcommands + migrations bake into a single self-contained
executable. See [`docs/install/binary.md`](docs/install/binary.md) for
the download / verify / systemd walkthrough.
`cargo binstall oxicloud` works too once a release is out.
### Run from source ### Run from source
Requires Rust 1.93+ and PostgreSQL. Requires Rust 1.93+ and PostgreSQL.
+46 -3
View File
@@ -1,15 +1,58 @@
//! build.rs — injects git build metadata into the binary. //! build.rs — injects git build metadata into the binary and, under the
//! optional `bundled-assets` feature, guards the compile-time embed
//! precondition.
//! //!
//! Exposes `GIT_HASH` and `GIT_BRANCH` (consumed via `env!()` in `main.rs`). //! Exposes `GIT_HASH` and `GIT_BRANCH` (consumed via `env!()` in `main.rs`).
//! There is no Rust-side asset pipeline: the frontend is built by Vite into //! The frontend is built by Vite into `static-dist/` at the repo root and
//! `static-dist/` and served directly by the web layer (`interfaces::web`). //! served directly by the web layer (`interfaces::web`); when
//! `bundled-assets` is on, `src/interfaces/web/embedded.rs` bakes that
//! directory into the binary at compile time via `rust-embed`.
use std::env; use std::env;
use std::path::Path;
use std::process::Command; use std::process::Command;
fn main() { fn main() {
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");
git_status(); git_status();
bundled_assets_guard();
}
// ═══════════════════════════════════════════════════════════════════════════════
// Bundled-assets precondition guard
//
// When `--features bundled-assets` is on, `rust-embed`'s `#[folder = "static-dist/"]`
// scans that directory at compile time and errors with a not-very-helpful
// "No such file or directory" if it's missing. Users hit this first when they
// try `cargo build --release --features bundled-assets` before running the
// frontend build — we intercept it here with a clear, actionable message.
//
// Also emits `cargo:rerun-if-changed=static-dist/` so a fresh frontend build
// re-triggers the embed step without needing `cargo clean` — matches what a
// dev on the bundled feature would expect after `just fe-build`.
// ═══════════════════════════════════════════════════════════════════════════════
fn bundled_assets_guard() {
if env::var("CARGO_FEATURE_BUNDLED_ASSETS").is_err() {
return;
}
println!("cargo:rerun-if-changed=static-dist");
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
let dist = Path::new(&manifest_dir).join("static-dist");
let index = dist.join("index.html");
if !index.exists() {
// `cargo:warning=` prefixes surface these in the terminal even
// when cargo's default output is quiet; the panic below turns
// them into a compile-time error so the missing prerequisite
// can't slip past a distracted dev.
println!("cargo:warning=`bundled-assets` feature requires static-dist/ at the repo root.");
println!("cargo:warning=Build the SvelteKit SPA first: (cd frontend && npm run build)");
println!("cargo:warning=Or via the workspace shortcut: just fe-build");
panic!(
"build.rs: missing {}/index.html — see the cargo:warning lines above",
dist.display()
);
}
} }
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
+3 -3
View File
@@ -26,7 +26,7 @@ probe, and lifecycle behaviour are uniform.
Every entry is declared in `OXICLOUD_STORAGE_ENTRIES` (comma-separated Every entry is declared in `OXICLOUD_STORAGE_ENTRIES` (comma-separated
list of names). The active entry is stored in `admin_settings` and list of names). The active entry is stored in `admin_settings` and
switched via `oxicloud --select-storage <name>` on the command line switched via `oxicloud storage select <name>` on the command line
or automatically at the end of a successful `backend_migration`. or automatically at the end of a successful `backend_migration`.
Non-active entries stay reachable through the multi-entry API (test, Non-active entries stay reachable through the multi-entry API (test,
audit, migrate-into). audit, migrate-into).
@@ -119,7 +119,7 @@ Rendered visually via `xxd -l 15 <blob>`:
Fingerprints are rendered the same colon-hex form (`15:f3:…:50`) Fingerprints are rendered the same colon-hex form (`15:f3:…:50`)
everywhere they appear: boot log, admin panel pair chain, `xxd` everywhere they appear: boot log, admin panel pair chain, `xxd`
inspection, `oxicloud --fingerprint <base64>` CLI, and the rotate / inspection, `oxicloud storage fingerprint <base64>` CLI, and the rotate /
migration audit lines. That means an admin can cross-reference by migration audit lines. That means an admin can cross-reference by
eye — same string means same key. eye — same string means same key.
@@ -336,7 +336,7 @@ readonly (source stays active — writes safe there), and returns
`RunOutcome::Failed`. Operator inspects findings, then either `RunOutcome::Failed`. Operator inspects findings, then either
retries (walk short-circuits on head-format matches → cheap retries (walk short-circuits on head-format matches → cheap
re-attempt), fixes the source, or explicitly accepts the partial re-attempt), fixes the source, or explicitly accepts the partial
via `oxicloud --select-storage <target>`. via `oxicloud storage select <target>`.
--- ---
+1 -1
View File
@@ -93,7 +93,7 @@ Runs are recoverable — status, cursor, and per-blob failure findings all live
If an entry is renamed or removed from `.env` while the DB pointer still names the old one, boot aborts with a clear error pointing at: If an entry is renamed or removed from `.env` while the DB pointer still names the old one, boot aborts with a clear error pointing at:
``` ```
oxicloud --select-storage <name> oxicloud storage select <name>
``` ```
This one-shot repair command re-runs the same env-parse the server does at boot, verifies `<name>` is declared in `OXICLOUD_STORAGE_ENTRIES`, updates `admin_settings.storage.active_backend_name` in the DB, and exits. Operator then restarts normally. See [Environment Variables — Storage Entries](/config/env#storage-entries-multi-entry-recommended) for the model, and [`oxicloud --help`](https://github.com/oxicloud/oxicloud/blob/main/src/main.rs) for the full flag list. This one-shot repair command re-runs the same env-parse the server does at boot, verifies `<name>` is declared in `OXICLOUD_STORAGE_ENTRIES`, updates `admin_settings.storage.active_backend_name` in the DB, and exits. Operator then restarts normally. See [Environment Variables — Storage Entries](/config/env#storage-entries-multi-entry-recommended) for the model, and [`oxicloud --help`](https://github.com/oxicloud/oxicloud/blob/main/src/main.rs) for the full flag list.
+3 -3
View File
@@ -135,12 +135,12 @@ Password-using deployments will opt in via three env vars:
2. **`OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`** — generated once and persisted like your JWT secret. Rotating this invalidates every user's registration; treat it as one of the crown jewels. Two ways to generate: 2. **`OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`** — generated once and persisted like your JWT secret. Rotating this invalidates every user's registration; treat it as one of the crown jewels. Two ways to generate:
```bash ```bash
# Docker (recommended in production — no toolchain needed): # Docker (recommended in production — no toolchain needed):
docker run --rm ghcr.io/atalayalabs/oxicloud:latest oxicloud-cli opaque setup docker run --rm ghcr.io/atalayalabs/oxicloud:latest oxicloud opaque setup
# From a source checkout: # From a source checkout:
cargo run --bin oxicloud-cli -- opaque setup cargo run --bin oxicloud -- opaque setup
``` ```
Both print the base64 value on stdout (with guidance on stderr, so shell pipelines like `$(docker run ... oxicloud-cli opaque setup)` capture cleanly). Both print the base64 value on stdout (with guidance on stderr, so shell pipelines like `$(docker run ... oxicloud opaque setup)` capture cleanly).
3. **`OXICLOUD_AUTH_OPAQUE_KSF_*`** — client-side Argon2id key-stretching cost. Defaults (46 MiB / 1 iter / 1 lane) match OWASP's interactive-auth recommendation. See the next section for the rationale + when to bump. 3. **`OXICLOUD_AUTH_OPAQUE_KSF_*`** — client-side Argon2id key-stretching cost. Defaults (46 MiB / 1 iter / 1 lane) match OWASP's interactive-auth recommendation. See the next section for the rationale + when to bump.
The `OXICLOUD_HASH_*` variables (server-side legacy Argon2) and `OXICLOUD_AUTH_OPAQUE_KSF_*` (client-side OPAQUE Argon2) are intentionally separate: the server-side path is RAM-bounded by concurrent-login traffic and needs to stay modest; the client-side path is single-user per attempt and can be tuned independently. Tuning them together would force a bad compromise in one direction or the other. The `OXICLOUD_HASH_*` variables (server-side legacy Argon2) and `OXICLOUD_AUTH_OPAQUE_KSF_*` (client-side OPAQUE Argon2) are intentionally separate: the server-side path is RAM-bounded by concurrent-login traffic and needs to stay modest; the client-side path is single-user per attempt and can be tuned independently. Tuning them together would force a bad compromise in one direction or the other.
+4 -2
View File
@@ -58,7 +58,7 @@ OPAQUE (RFC 9807) is a zero-knowledge password-authenticated key exchange: the p
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `OXICLOUD_AUTH_OPAQUE_MODE` | `off` | Runtime mode. `off` = endpoints 404 (default). `migrate` = endpoints live, legacy `POST /api/auth/login` still accepted. `opaque_only` = endpoints live, legacy refused for users with an envelope. **Effective-mode cross-check**: when `password` is not in `OXICLOUD_AUTH_METHODS`, the mode is auto-downgraded to `off` with an audit-channel INFO line (OPAQUE only replaces the password path — nothing to shadow in an OIDC-only or magic-link-only deployment). So OIDC / magic-link-only operators can safely ignore every `OXICLOUD_AUTH_OPAQUE_*` variable. | | `OXICLOUD_AUTH_OPAQUE_MODE` | `off` | Runtime mode. `off` = endpoints 404 (default). `migrate` = endpoints live, legacy `POST /api/auth/login` still accepted. `opaque_only` = endpoints live, legacy refused for users with an envelope. **Effective-mode cross-check**: when `password` is not in `OXICLOUD_AUTH_METHODS`, the mode is auto-downgraded to `off` with an audit-channel INFO line (OPAQUE only replaces the password path — nothing to shadow in an OIDC-only or magic-link-only deployment). So OIDC / magic-link-only operators can safely ignore every `OXICLOUD_AUTH_OPAQUE_*` variable. |
| `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP` | — | Base64-encoded `ServerSetup` blob. **Required** when `OXICLOUD_AUTH_OPAQUE_MODE != off` AND password is enabled — the server refuses to start with a helpful error otherwise. Generate once with the `oxicloud-cli opaque setup` subcommand and persist the value like your JWT secret. **Never rotate** — rotating invalidates every user's envelope (they'd all need to reset their passphrase). | | `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP` | — | Base64-encoded `ServerSetup` blob. **Required** when `OXICLOUD_AUTH_OPAQUE_MODE != off` AND password is enabled — the server refuses to start with a helpful error otherwise. Generate once with the `oxicloud opaque setup` subcommand and persist the value like your JWT secret. **Never rotate** — rotating invalidates every user's envelope (they'd all need to reset their passphrase). |
| `OXICLOUD_AUTH_OPAQUE_KSF_MEMORY_KIB` | `47104` | Client-side Argon2id memory cost in KiB (46 MiB — matches OWASP interactive-auth recommendation). Runs on the user's device during OPAQUE login/registration, TWICE per login. Distinct from `OXICLOUD_HASH_MEMORY_COST` (server-side legacy path). Bumping raises brute-force cost after a hypothetical envelope leak but also raises login latency and risks WASM heap OOM on low-memory devices — see `authentication.md § OPAQUE — KSF parameters` for the full rationale + per-device latency table. | | `OXICLOUD_AUTH_OPAQUE_KSF_MEMORY_KIB` | `47104` | Client-side Argon2id memory cost in KiB (46 MiB — matches OWASP interactive-auth recommendation). Runs on the user's device during OPAQUE login/registration, TWICE per login. Distinct from `OXICLOUD_HASH_MEMORY_COST` (server-side legacy path). Bumping raises brute-force cost after a hypothetical envelope leak but also raises login latency and risks WASM heap OOM on low-memory devices — see `authentication.md § OPAQUE — KSF parameters` for the full rationale + per-device latency table. |
| `OXICLOUD_AUTH_OPAQUE_KSF_ITERATIONS` | `1` | Client-side Argon2id iteration count (OWASP interactive-auth recommendation). | | `OXICLOUD_AUTH_OPAQUE_KSF_ITERATIONS` | `1` | Client-side Argon2id iteration count (OWASP interactive-auth recommendation). |
| `OXICLOUD_AUTH_OPAQUE_KSF_PARALLELISM` | `1` | Client-side Argon2id parallelism lanes (OWASP recommendation). Higher only helps on multi-core hardware and can hurt single-core / older mobile devices. | | `OXICLOUD_AUTH_OPAQUE_KSF_PARALLELISM` | `1` | Client-side Argon2id parallelism lanes (OWASP recommendation). Higher only helps on multi-core hardware and can hurt single-core / older mobile devices. |
@@ -94,6 +94,8 @@ DPoP cryptographically binds a session cookie to a browser-held ECDSA keypair (P
| `OXICLOUD_ENABLE_TRASH` | `true` | Trash / recycle bin | | `OXICLOUD_ENABLE_TRASH` | `true` | Trash / recycle bin |
| `OXICLOUD_ENABLE_SEARCH` | `true` | Full-text and metadata search | | `OXICLOUD_ENABLE_SEARCH` | `true` | Full-text and metadata search |
| `OXICLOUD_ENABLE_MUSIC` | `true` | Music playlists and audio metadata | | `OXICLOUD_ENABLE_MUSIC` | `true` | Music playlists and audio metadata |
| `OXICLOUD_ENABLE_VIDEO_THUMBNAILS` | `true` | Server-side single-frame thumbnail extraction from uploaded videos (one frame → WebP). Requires `ffmpeg` on `PATH` (override with `OXICLOUD_FFMPEG_PATH`). When true and ffmpeg is missing at boot, a WARN log is emitted and videos fall back to a placeholder icon. Set to `false` to skip the ffmpeg lookup entirely — useful on hosts where ffmpeg can't be installed, or when the client uploads video previews itself (some desktop/mobile clients generate thumbnails locally and POST them alongside the video). |
| `OXICLOUD_FFMPEG_PATH` | `ffmpeg` (on PATH) | Absolute path to the ffmpeg binary. Ignored when `OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false`. Useful for pinning a specific static build or when ffmpeg lives outside the default PATH. |
| `OXICLOUD_EXPOSE_SYSTEM_USERS` | `true` | Expose other OxiCloud users as a read-only address book at `GET /api/address-books` | | `OXICLOUD_EXPOSE_SYSTEM_USERS` | `true` | Expose other OxiCloud users as a read-only address book at `GET /api/address-books` |
| `OXICLOUD_GRANT_CLEANUP_ENABLED` | `true` | Background daemon that deletes expired rows from `storage.role_grants`. The authorization engine already filters expired grants out of every permission check at read time (`expires_at IS NULL OR expires_at > NOW()`), so leaving expired rows in place is a hygiene issue — not a security one. This daemon garbage-collects them daily. Set to `false` to keep every expired grant row forever (uncommon; a fresh install rarely wants this). | | `OXICLOUD_GRANT_CLEANUP_ENABLED` | `true` | Background daemon that deletes expired rows from `storage.role_grants`. The authorization engine already filters expired grants out of every permission check at read time (`expires_at IS NULL OR expires_at > NOW()`), so leaving expired rows in place is a hygiene issue — not a security one. This daemon garbage-collects them daily. Set to `false` to keep every expired grant row forever (uncommon; a fresh install rarely wants this). |
| `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` | `15` | Days past a grant's `expires_at` before the row is eligible for deletion. The grace window preserves the audit / support answer to "what happened to my access?" for a couple of weeks past expiration. Values below 1 are legal but discouraged — the recommendation is **≥ 15 days**. Values above the actual grant TTL used by clients waste index space; a few weeks is the sweet spot. | | `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` | `15` | Days past a grant's `expires_at` before the row is eligible for deletion. The grace window preserves the audit / support answer to "what happened to my access?" for a couple of weeks past expiration. Values below 1 are legal but discouraged — the recommendation is **≥ 15 days**. Values above the actual grant TTL used by clients waste index space; a few weeks is the sweet spot. |
@@ -131,7 +133,7 @@ Each declared name `<N>` then reads its own set of per-entry variables:
- A declared name whose required per-entry fields are missing (`_BACKEND` never set, S3 with no `_S3_BUCKET`, Azure with no `_AZURE_CONTAINER`). - A declared name whose required per-entry fields are missing (`_BACKEND` never set, S3 with no `_S3_BUCKET`, Azure with no `_AZURE_CONTAINER`).
- Setting `OXICLOUD_STORAGE_ENTRIES` alongside any of the legacy flat vars below (`OXICLOUD_STORAGE_BACKEND`, `OXICLOUD_S3_*`, `OXICLOUD_AZURE_*`, `OXICLOUD_STORAGE_ENCRYPTION_*`). Pick one mode; the error lists every conflicting var to remove. - Setting `OXICLOUD_STORAGE_ENTRIES` alongside any of the legacy flat vars below (`OXICLOUD_STORAGE_BACKEND`, `OXICLOUD_S3_*`, `OXICLOUD_AZURE_*`, `OXICLOUD_STORAGE_ENCRYPTION_*`). Pick one mode; the error lists every conflicting var to remove.
- A DB pointer (`admin_settings.storage.active_backend_name`) that names an entry not in the current `_ENTRIES`. The error points at the repair flag `oxicloud --select-storage <name>` — verify + UPDATE DB + exit. - A DB pointer (`admin_settings.storage.active_backend_name`) that names an entry not in the current `_ENTRIES`. The error points at the repair flag `oxicloud storage select <name>` — verify + UPDATE DB + exit.
**Example** — two entries, local disk plus an S3 target for planned migration: **Example** — two entries, local disk plus an S3 target for planned migration:
+2 -2
View File
@@ -167,13 +167,13 @@ If you rename or remove a backend from `.env` while it was still the active one,
``` ```
active_backend_name = `s3_prod`, but no entry with that name is declared in active_backend_name = `s3_prod`, but no entry with that name is declared in
OXICLOUD_STORAGE_ENTRIES. Available: [local_main]. […] OXICLOUD_STORAGE_ENTRIES. Available: [local_main]. […]
oxicloud --select-storage <one-of-the-available-names> oxicloud storage select <one-of-the-available-names>
``` ```
Run the command it suggests to pick a still-declared backend and the server will boot again on the next start: Run the command it suggests to pick a still-declared backend and the server will boot again on the next start:
``` ```
oxicloud --select-storage local_main oxicloud storage select local_main
``` ```
This just updates which backend OxiCloud considers active — it doesn't move any data. This just updates which backend OxiCloud considers active — it doesn't move any data.
+245
View File
@@ -0,0 +1,245 @@
# Installing OxiCloud from a Binary Release
OxiCloud ships prebuilt binaries for common Linux and macOS platforms
attached to every tagged release on GitHub. This page covers downloading,
verifying, and running one.
If you'd rather run OxiCloud as a container, see the Docker image at
`ghcr.io/atalayalabs/oxicloud`. If you're a Rust developer who just
wants the binary without hand-fetching a tarball, `cargo binstall
oxicloud` picks the right archive for your host automatically.
## Which tarball do I want?
Every release attaches three tarballs plus a `SHA256SUMS` manifest.
Pick by your host's architecture and OS:
| Host | Tarball |
|---|---|
| Linux x86-64 (Intel / AMD servers, most VPS, WSL) | `oxicloud-<version>-x86_64-unknown-linux-musl.tar.gz` |
| Linux ARM64 (Raspberry Pi 4/5, Ampere, Graviton, ARM servers) | `oxicloud-<version>-aarch64-unknown-linux-musl.tar.gz` |
| macOS Apple Silicon (M-series) | `oxicloud-<version>-aarch64-apple-darwin.tar.gz` |
The Linux tarballs link against musl, so they run on ANY glibc version
— Alpine, Debian, Ubuntu, Fedora, Arch, Rocky, and every version in
between. You never need to worry about `GLIBC_x.yy not found`.
**Intel macOS, Windows, and 32-bit ARM are not currently shipped as
prebuilt tarballs.** Intel Mac users have three fallbacks:
1. `cargo install oxicloud --locked --features bundled-assets` from
source (needs the Rust toolchain).
2. Docker: `docker pull --platform linux/amd64 ghcr.io/atalayalabs/oxicloud`.
3. Run one of the two Linux musl tarballs inside a Linux VM
(Multipass, Lima, UTM, etc.).
## Hardware notes
| Model | Notes |
|---|---|
| Pi 5 (4 GB / 8 GB) | Good experience |
| Pi 4 (4 GB / 8 GB) | Solid |
| Pi 4 (2 GB) | Works with face indexing disabled; expect swap under load |
| Pi 3 (any variant) | Marginal — only for a very light single-user personal cloud |
| Pi 2 / Pi Zero / Pi 1 | Not supported (1 GB RAM is below the practical floor) |
| Any ARM64 server | Good — the aarch64 tarball is what you want |
| Any x86-64 server from 2010 or newer | Good — Nehalem / Bulldozer + newer, per the release CPU baseline |
## Verifying the download
Every release ships a `SHA256SUMS` manifest listing every tarball with
its hash. Verify your download before extracting:
```
sha256sum -c SHA256SUMS
```
Only files present in the current directory are checked, so this
succeeds when just the tarball you downloaded matches its entry.
## Extracting
The archive lands as a per-version-per-triple directory next to it:
```
tar xzf oxicloud-<version>-<triple>.tar.gz
cd oxicloud-<version>-<triple>/
ls
# oxicloud example.env LICENSE README-install.md
```
The four files:
- `oxicloud` — the single self-contained binary. The server, all
operator subcommands (`oxicloud opaque setup`, `oxicloud migrate
nfc-filenames`, `oxicloud storage select`), and the SvelteKit web
frontend are all baked in.
- `example.env` — every OxiCloud environment variable documented with
defaults. Copy to `.env` and edit as needed.
- `LICENSE` — the project license.
- `README-install.md` — a shorter version of this page for offline
reference.
## Prerequisites
Only one moving part is required: a PostgreSQL 13+ instance with the
`pg_trgm` and `ltree` extensions available. Anything else you might
need is either baked into the binary or optional.
### Required
- **PostgreSQL 13+** with `pg_trgm` and `ltree` extensions. Any distro
package works (Debian/Ubuntu's `postgresql`, Alpine's `postgresql`,
Homebrew's `postgresql@17`, etc.). Cloud databases like Neon,
Supabase, and RDS also work provided the two extensions are enabled.
### System libraries (usually pre-installed)
- **`ca-certificates`** — for outbound HTTPS (OIDC discovery, S3, magic
links). Pre-installed on essentially every distribution.
- **`tzdata`** — timezone database. Pre-installed on nearly every
distribution; alpine minimal images sometimes need it added.
### Optional
- **`ffmpeg`** — only needed if you want the server to extract a
thumbnail frame from uploaded videos. When ffmpeg is missing the
server logs a warning at boot and videos get a placeholder icon —
everything else keeps working. If your client uploads video
previews itself (some desktop and mobile clients do), or if you
simply don't want thumbnails, set
`OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false` in your `.env` to silence
the warning.
Distro install commands for the optional prerequisite:
| Distro | Command |
|---|---|
| Alpine | `apk add ffmpeg` |
| Debian / Ubuntu | `apt install ffmpeg` |
| Fedora / RHEL | `dnf install ffmpeg` (RPM Fusion for the full codec set) |
| Arch | `pacman -S ffmpeg` |
| macOS | `brew install ffmpeg` |
| Any Linux (portable) | grab a static build from https://github.com/BtbN/FFmpeg-Builds/releases and point `OXICLOUD_FFMPEG_PATH` at it |
## First run
The absolute minimum to boot the server is `DATABASE_URL`:
```
DATABASE_URL="postgres://oxicloud:secret@localhost:5432/oxicloud" \
./oxicloud
```
The binary applies its embedded database migrations on startup, then
listens on `127.0.0.1:8086` by default. Open your browser at
`http://localhost:8086/` and follow the setup flow to create the first
admin account.
For anything more than a smoke test, copy `example.env` to `.env`,
edit it, and run `./oxicloud --config .env` — that pins the config
source and makes stray shell environment variables not silently leak
in.
## Running as a systemd service (Linux)
Move the binary to a system location and create a systemd unit. The
example below runs as a dedicated `oxicloud` user, loads config from
`/etc/oxicloud/oxicloud.env`, and stores data under `/var/lib/oxicloud`.
```
sudo useradd --system --home /var/lib/oxicloud --create-home --shell /usr/sbin/nologin oxicloud
sudo install -m 0755 oxicloud /usr/local/bin/oxicloud
sudo mkdir -p /etc/oxicloud
sudo cp example.env /etc/oxicloud/oxicloud.env
sudo chown -R oxicloud:oxicloud /etc/oxicloud
sudo chmod 0640 /etc/oxicloud/oxicloud.env
```
Create `/etc/systemd/system/oxicloud.service`:
```
[Unit]
Description=OxiCloud self-hosted cloud storage
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=oxicloud
Group=oxicloud
WorkingDirectory=/var/lib/oxicloud
ExecStart=/usr/local/bin/oxicloud --config /etc/oxicloud/oxicloud.env
Restart=on-failure
RestartSec=5
# Sandbox — plenty of room to tighten further per your policy
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/oxicloud
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
Enable and start:
```
sudo systemctl daemon-reload
sudo systemctl enable --now oxicloud
sudo systemctl status oxicloud
journalctl -u oxicloud -f
```
Terminate the reverse-proxy (nginx, Caddy, HAProxy, Traefik) in front
of it for TLS and public exposure — OxiCloud itself binds plaintext
HTTP on `127.0.0.1` by default.
## Upgrading
Replace the binary and restart the service:
```
# Download and verify the new tarball
sha256sum -c SHA256SUMS
tar xzf oxicloud-<new-version>-<triple>.tar.gz
cd oxicloud-<new-version>-<triple>/
sudo systemctl stop oxicloud
sudo install -m 0755 oxicloud /usr/local/bin/oxicloud
sudo systemctl start oxicloud
```
Database migrations apply automatically on startup. Rollbacks are not
supported by sqlx's migration model; if you need to roll back, stop
the server, roll back your Postgres data directory to a snapshot, and
install the previous binary.
## Installing via `cargo binstall`
If you already have the Rust toolchain and just want the binary
without hand-picking a tarball:
```
cargo binstall oxicloud
```
`cargo-binstall` reads the URL template baked into the release
metadata, downloads the tarball for your host triple, verifies its
signature (when present), and installs `oxicloud` into
`~/.cargo/bin`. This resolves to the same tarball you'd download by
hand.
## Where to go from here
- Environment reference — see [`docs/config/env.md`](../config/env.md)
for every `OXICLOUD_*` variable and its default.
- Authentication setup (OPAQUE, OIDC, magic links) — see
[`docs/config/authentication.md`](../config/authentication.md).
- Storage backends (local disk, S3, Azure Blob, encryption) — see
[`docs/config/storage.md`](../config/storage.md) if present, or the
entries under `OXICLOUD_STORAGE_*` in the environment reference.
- File a bug or a feature request — GitHub issues at
https://github.com/AtalayaLabs/OxiCloud.
@@ -41,7 +41,9 @@ Mirrors the `tests/api/` shell pattern (run.sh, test.env, separate server port).
### 2. Rust bulk seeder — `src/bin/load-seed.rs` ### 2. Rust bulk seeder — `src/bin/load-seed.rs`
New binary registered in `Cargo.toml` alongside `generate-openapi` and `migrate-nfc-filenames`. New binary registered in `Cargo.toml` alongside `generate-openapi`
(the historical `migrate-nfc-filenames` bin has since been folded into
`oxicloud migrate nfc-filenames` — see `docs/plan/bundled-binary.md` § 1b).
**CLI:** **CLI:**
``` ```
@@ -158,7 +160,7 @@ Matches existing recipe naming (`test-*`, `front-*`, `api-test`).
- `.github/workflows/load-nightly.yml`, `load-smoke.yml` - `.github/workflows/load-nightly.yml`, `load-smoke.yml`
**Modify:** **Modify:**
- `Cargo.toml` — add `[[bin]] name = "load-seed" path = "src/bin/load-seed.rs"` after the `migrate-nfc-filenames` entry - `Cargo.toml` — add `[[bin]] name = "load-seed" path = "src/bin/load-seed.rs"` after the `generate-openapi` entry (the `migrate-nfc-filenames` bin referenced in earlier drafts has been folded into `oxicloud migrate nfc-filenames`)
- `justfile` — append four `load*` recipes - `justfile` — append four `load*` recipes
- `.gitignore` — add `tests/load/results/*.json` and `tests/load/storage/` - `.gitignore` — add `tests/load/results/*.json` and `tests/load/storage/`
+888
View File
@@ -0,0 +1,888 @@
# Bundled Binary Distribution — Multi-Platform Plan
## Context
Users have asked for a way to run OxiCloud without Docker — a plain
binary. Today `release.yml` only creates a GitHub Release with notes;
no binary is attached. The Docker workflow (`docker-publish.yml`) ships
multi-arch images, but that's a separate audience.
The blocker for a "just download and run" experience is that the
`oxicloud` binary depends on the SvelteKit build output (`static-dist/`
under `<static_path>/static-dist/`, resolved by
`src/interfaces/web/mod.rs::resolve_static_path` at boot). Two files
to distribute per platform is friction; a single self-contained binary
is what users actually want.
The ask has two parts:
1. Ship **single-file binaries with frontend assets embedded**, for
the common Linux targets and macOS.
2. Audit the current binary set — the crate produces 6+ binaries today,
some of which are test-only. Strip anything that shouldn't ship to
end users.
The intended outcome: a `v0.9.0` release attaches **4 musl-static
tarballs** (Linux amd64/arm64 + macOS Intel/Apple Silicon), each
~15-30 MB, containing a **single `oxicloud` binary** with assets +
operator tools + one-off migrations all baked in. User extracts the
tarball, sets `DATABASE_URL`, runs `./oxicloud` — server up.
Subcommands (`oxicloud opaque setup`, `oxicloud migrate
nfc-filenames --dry-run`) provide operator access to the same tools
currently split across `oxicloud-cli` and `migrate-nfc-filenames`.
Design shape (confirmed 2026-08-27):
- **musl-only Linux** — parity with the existing Docker image (Alpine
base), no glibc-version fragmentation
- **Assets embedded via `rust-embed` with compile-time deflate
compression** — smaller binary
- **`bundled-assets` is opt-in** — default `cargo build` unchanged;
`just dev` still uses the filesystem `ServeDir` with Vite HMR
- **Single unified binary** — `oxicloud`, `oxicloud-cli`, and
`migrate-nfc-filenames` collapse into one clap-driven executable
with implicit-server default (backwards compat with existing Docker
CMD / systemd units)
## Current binary inventory
From `Cargo.toml` + `src/bin/`:
| Binary | Path | Purpose | Ship to end users? |
|---|---|---|---|
| `oxicloud` | `src/main.rs` (implicit) | Server | **YES** |
| `oxicloud-cli` | `src/bin/oxicloud-cli.rs` | Operator toolbox (`opaque setup/reset`) | **MERGED** — absorbed into `oxicloud` per Deliverable 1b |
| `migrate-nfc-filenames` | `src/bin/migrate-nfc-filenames.rs` | One-off filename migration (historical, June 2026 fix) | **MERGED** — absorbed into `oxicloud migrate nfc-filenames` per Deliverable 1a→1b |
| `generate-openapi` | `src/bin/generate-openapi.rs` | Regenerate `resources/gen/openapi.json` | NO — dev tool, gate behind `dev_tools` feature |
| `opaque-hurl-helper` | `src/bin/opaque-hurl-helper.rs` | Hurl test companion (OPRF client) | NO — gate behind `test_utils` feature |
| `dpop-hurl-helper` | `src/bin/dpop-hurl-helper.rs` | Hurl test companion (ES256 DPoP proof) | NO — gate behind `test_utils` feature |
| `load-seed` | `src/bin/load-seed.rs` | Test fixture seeder | Already gated behind `load_seed_bin` feature ✅ |
After Deliverables 1 + 1a + 1b, `cargo build --release --bins`
produces exactly ONE binary: `oxicloud`. That single binary ships in
the tarball and in the Docker image.
## Deliverables
### 1. Squash test/dev binaries with `required-features`
Cargo respects `required-features` per `[[bin]]` — a binary is only
built when its listed features are active. This gates test helpers
out of `cargo build --release --bins` cleanly without needing custom
Cargo commands or shell trimming.
Edits to `Cargo.toml`:
```toml
[features]
# ... existing features ...
dev_tools = [] # NEW: gates ops tooling that shouldn't ship
[[bin]]
name = "opaque-hurl-helper"
path = "src/bin/opaque-hurl-helper.rs"
required-features = ["test_utils"] # NEW gate
[[bin]]
name = "dpop-hurl-helper"
path = "src/bin/dpop-hurl-helper.rs"
required-features = ["test_utils"] # NEW gate
[[bin]]
name = "generate-openapi"
path = "src/bin/generate-openapi.rs"
required-features = ["dev_tools"] # NEW gate — `just openapi` flips it
# [[bin]] name = "migrate-nfc-filenames" ← DELETED per Deliverable 1a
# [[bin]] name = "oxicloud-cli" ← DELETED per Deliverable 1b
```
Existing invocations that need adjustment:
- `just openapi` recipe → add `--features dev_tools` to the underlying
`cargo run --bin generate-openapi` call (currently `cargo run --bin
generate-openapi` per justfile)
- `tests/api/run.sh` → add `--features test_utils` when building the
two hurl helpers (shape confirmed: `cargo build [--release] --bin
opaque-hurl-helper` / same for dpop in each helper's build-if-missing
branch)
After these edits + Deliverables 1a + 1b: `cargo build --release --bins`
produces exactly ONE binary — `oxicloud`. Everything else falls out of
the default build set.
### 1a. Merge `migrate-nfc-filenames` into `oxicloud-cli`
The standalone `migrate-nfc-filenames` binary is a June-2026 one-off:
it cleans up NFD/NFC filename collisions in databases populated
before the write-time fix (`normalize_storage_name()` at
`src/domain/services/path_service.rs:36`, called from
`src/infrastructure/repositories/pg/file_blob_read_repository.rs:1062`).
New installs never need it; only pre-June 2026 databases do.
`oxicloud-cli`'s header docstring (`src/bin/oxicloud-cli.rs:20-23`)
already documents the growth pattern for absorbing tools like this:
> *"each new domain gets its own module below (e.g. `mod opaque`)
> with a `#[derive(Subcommand)]` enum for its actions and a
> `run(args) -> ExitCode` entrypoint. Keep each module self-contained
> so a future extraction is a file move."*
Note: this Deliverable is an intermediate step. Deliverable 1b then
absorbs `oxicloud-cli` itself into `oxicloud`, so the final CLI form
becomes `oxicloud migrate nfc-filenames --dry-run` — but 1a lands
first so the migration logic is proven inside the clap subcommand
tree before the main-binary merge.
Edits:
- **New `mod migrate` in `src/bin/oxicloud-cli.rs`** — moves the ~149
non-boilerplate lines from `migrate-nfc-filenames.rs::main()` into
a `run_nfc_filenames(dry_run: bool) -> ExitCode` function.
`env::args()` parsing goes away; clap handles it.
- **Delete `src/bin/migrate-nfc-filenames.rs`**.
- **Delete the `[[bin]]` entry** in `Cargo.toml`.
- **Update `Dockerfile`** — 6 references to `migrate-nfc-filenames`
(build commands at :46, :49, :89, `cp` steps at :130, :143, doc
comment at :170, `COPY --chmod=755 --from=app` at :173).
- **Update `docs/plan/benchmake-and-performance-tracking.md`** — 2
references to `migrate-nfc-filenames` at lines :44 and :161. Reword
to reference `oxicloud-cli migrate nfc-filenames` (or, after 1b,
`oxicloud migrate nfc-filenames`) and update the Cargo.toml
placement example.
- **Any operator runbook** that documents `docker exec <container>
migrate-nfc-filenames --dry-run` becomes `docker exec <container>
oxicloud-cli migrate nfc-filenames --dry-run` (intermediate) then
`docker exec <container> oxicloud migrate nfc-filenames --dry-run`
after 1b.
Effort: ~1.5 hours mechanical. Extracts the "should the tarball ship
migrate-nfc-filenames?" question entirely — everything now ships as
one operator toolbox binary that also happens to include the
historical migration.
Future v1.0 removal path (deferred): delete `mod migrate` block + one
enum variant + docs. Much cleaner than removing a whole `.rs` file +
Cargo entry + Dockerfile refs.
### 1b. Merge `oxicloud-cli` into `oxicloud`
Single binary — server + operator tools + migrations — with an
**implicit-server** subcommand tree. `oxicloud` with no arguments
starts the server (backwards compat with existing Docker CMD /
systemd units / user configs). Subcommands add operator actions on
top.
After merge, the CLI shape is:
```
$ oxicloud --help
Usage: oxicloud [OPTIONS] [COMMAND]
Commands:
opaque OPAQUE aPAKE substrate management
migrate One-time data migrations
If no command is given, oxicloud starts the server (see docs/config).
```
Concrete forms:
- `oxicloud` — start server (unchanged)
- `oxicloud opaque setup` — was `oxicloud-cli opaque setup`
- `oxicloud opaque reset --user alice --dry-run` — was `oxicloud-cli
opaque reset ...`
- `oxicloud migrate nfc-filenames --dry-run` — was
`migrate-nfc-filenames --dry-run` (via Deliverable 1a)
**Backwards-compat guarantee**: `oxicloud` with no args continues to
start the server. Every existing `CMD ["oxicloud"]`, `ExecStart=/usr/local/bin/oxicloud`,
docker-compose entry, and k8s Deployment keeps working unchanged.
Users updating to v0.9.0 see no surprise.
**Migration impact**: the user-visible break is that `oxicloud-cli
opaque setup` (etc.) no longer exists as a separate binary. Given the
current audience for `oxicloud-cli` is very small (essentially only
the maintainer), the migration cost is trivial. Any user who had
scripted it can adapt with a one-line find/replace.
Edits:
- **`src/main.rs`** — top of `main()`, before the current server
init, parse args via clap. If a subcommand is provided, dispatch
to it and exit; otherwise fall through to the existing server-init
path. Zero-arg startup cost stays ≤ microseconds (clap parse of
empty args).
- **`src/cli/mod.rs`** — NEW module. Contains the `Domain` enum + the
`opaque` and `migrate` submodules moved from
`src/bin/oxicloud-cli.rs`. Each subcommand module keeps its
self-contained shape per the growth pattern documented in the
old `oxicloud-cli.rs` header.
- **Delete `src/bin/oxicloud-cli.rs`** entirely.
- **Delete the `[[bin]] name = "oxicloud-cli"` block** in `Cargo.toml`.
- **`Dockerfile`** — drop all 4 references to `oxicloud-cli` (build
target lines + COPY steps). Simplified build command becomes
`cargo build --release --bin oxicloud` — single-binary.
- **Docs** — all `docker exec <container> oxicloud-cli <domain>
<action>` become `docker exec <container> oxicloud <domain>
<action>`. Same shape, one fewer word.
Effort: ~2 hours mechanical. Comparable to Deliverable 1a but with
slightly more care at the `main.rs` entry point for the args-vs-server
branch.
**Tarball layout simplification** — the tarball now ships exactly
ONE binary:
```
oxicloud-0.9.0-<triple>/
├── oxicloud (single file, server + tools + embedded assets)
├── example.env
├── LICENSE
└── README-install.md
```
That's the "just download and run" ethos in physical form: one file,
one command, done.
### 2. Add `bundled-assets` cargo feature
Purpose: at compile time, choose between filesystem-served static
assets (current behaviour — filesystem `ServeDir`) and
embedded-into-binary assets (via `rust-embed`). Feature is
**opt-in** — the default `cargo build --release` still produces a
filesystem-based binary, matching the current Docker image behaviour
(where assets are separate volume layers). Release tarballs are built
with `--features bundled-assets`.
**Dev mode is untouched.** `just dev` runs `PROFILE=dev cargo run` +
`npm run dev`, neither of which activates `bundled-assets`. The dev
workflow continues to:
- Serve from `frontend/` via Vite's dev server with HMR
- Backend reads static assets from `<static_path>/static-dist/` via the
usual `ServeDir` (or falls back to `frontend/static/` when the
build hasn't been run)
- No rebuild required to change locales, styles, or vendor JS
The `bundled-assets` code paths only compile when the feature is
explicitly enabled — under a `#[cfg(feature = "bundled-assets")]` gate.
The non-feature build's binary shape, ergonomics, and dev loop stay
identical to today.
Measured footprint (2026-08-27):
| Slice | Size | Notes |
|---|---|---|
| Total `static-dist/` uncompressed | **9.8 MB** | 499 files |
| `_app/` (SvelteKit bundle) | 3.3 MB | JS + CSS chunks |
| `vendors/` | 3.6 MB | maplibre-gl 1.0 MB, pdf.worker 1.0 MB, others |
| `locales/` | 2.2 MB | 16 locales, ru.json + hi.json largest at ~116-140 KB |
| `logo/`, `geo/`, `basemaps/`, `workers/`, misc | ~600 KB | |
| **`.tar.gz` compressed** | **4.65 MB** | realistic embed cost after brotli/gzip inside binary |
| **`.tar.xz` compressed** | **4.22 MB** | not what rust-embed uses; reference only |
Expected release-binary size with embed: `oxicloud` today ships in
the 30-60 MB range (stripped, LTO). Add ~5-10 MB for embedded
static-dist. Tarball compression on top → ~20-30 MB shipped per
platform. Four platforms × ~25 MB = ~100 MB per release. Well within
GitHub Releases limits.
Cargo.toml additions:
```toml
[features]
bundled-assets = ["dep:rust-embed", "dep:mime_guess"]
[dependencies]
rust-embed = { version = "8", features = ["compression"], optional = true }
mime_guess = { version = "2", optional = true }
```
Runtime shape — a new module `src/interfaces/web/embedded.rs`:
```rust
#[cfg(feature = "bundled-assets")]
#[derive(rust_embed::RustEmbed)]
#[folder = "static-dist/"] // ← repo-root, matches SvelteKit adapter-static output
#[include = "*"]
#[exclude = "*.br"] // Vite's precompressed sibling — response compression handles on wire
#[exclude = "*.gz"] // ditto
pub struct EmbeddedAssets;
```
The `#[folder]` path is relative to Cargo.toml (repo root), where the
SvelteKit adapter-static config in `frontend/svelte.config.js` emits:
```js
adapter: adapter({
pages: '../static-dist',
assets: '../static-dist',
...
})
```
The current filesystem shape (at `src/interfaces/web/mod.rs:47-106`)
is more than one `ServeDir` — the embed swap replaces FOUR sites, all
downstream of `resolve_static_path()`:
1. **`spa` ServeDir** (`mod.rs:60-63`) — root fallback with
`precompressed_br().precompressed_gzip()` and SPA-shell fallback
pointing at `<static>/index.html`. Under embed: an axum handler
that resolves the request path against `EmbeddedAssets::get()`,
200 with correct MIME (via `mime_guess`) if hit, otherwise return
the embedded `index.html` bytes with `text/html` for SPA client-routing.
2. **`app_immutable` ServeDir** (`mod.rs:66-77`) — nested at
`/_app/immutable` with `Cache-Control: public, max-age=31536000,
immutable`. Under embed: same handler shape as (1), scoped to
the `_app/immutable/` prefix, plus a `.layer()` that stamps the
immutable cache header.
3. **`ServeFile::new(index.html)`** SPA fallback (`mod.rs:63`) —
folds into (1)'s not-found path.
4. **CSP inline-script scan** (`mod.rs:163-233`) — currently reads
every `.html` file in the resolved static dir via
`std::fs::read_dir` + `std::fs::read_to_string` at boot to compute
SHA-256 CSP source expressions for every inline `<script>`. Under
embed: iterate `EmbeddedAssets::iter()` filtered to `.html`
extensions, pull bytes via `::get()`, hash the same way. Same
arithmetic, different source. Boot-time only.
All four flow through `resolve_static_path()` at `src/interfaces/web/mod.rs:25-35`
— that helper is the natural pivot. Add a returned enum:
```rust
#[cfg(feature = "bundled-assets")]
pub enum StaticSource {
Filesystem(PathBuf), // OXICLOUD_STATIC_PATH points at a real dir
Embedded, // fall through to compiled-in bytes
}
```
Then the four callsites (`create_web_routes` + CSP scan) match on
`StaticSource` and pick their implementation. Under the default
feature set (no `bundled-assets`), the enum degrades to a bare
`PathBuf` — zero runtime cost, no cfg pollution across the wider
codebase.
**Locale loading — also needs embed treatment.** Two callsites read
locales at runtime:
- `src/main.rs:599-615` — resolves `<static_path>/locales/` at boot
and passes it to `LocaleRegistry::discover()` at
`src/common/locale.rs:150-221`, which does `fs::read_dir` +
`fs::read_to_string` + `serde_json::from_str` on each of 16 files.
Currently fail-fast panics if the directory is missing.
- `src/infrastructure/services/file_system_i18n_service.rs` — the
runtime translator, `translations_dir: PathBuf` field, does
`tokio::fs::read_to_string` on `<dir>/<code>.json` per lazy-load
miss (cached in `RwLock<HashMap<Locale, Value>>`).
Under `bundled-assets`, both get an alternative implementation that
reads from `EmbeddedAssets` (locale files are at
`static-dist/locales/*.json`, picked up by the same folder embed).
Recommended shape: constructor pair —
`LocaleRegistry::discover_filesystem(path)` and
`#[cfg(feature = "bundled-assets")] LocaleRegistry::discover_embedded()`.
`main.rs` picks based on the resolved `StaticSource`. Simpler than a
trait-based indirection for two static sources with the same interface.
Frontend at runtime ALSO fetches `/locales/*.json` for client-side
i18n — this path is served by the same static router in (1) above,
so no separate work; the embed already covers it.
Precedence rule: even in a bundled build, honour `OXICLOUD_STATIC_PATH`
when it points at an existing directory. Lets ops override embedded
assets for locale patches / theming without a full rebuild. The
`resolve_static_path` return value is checked at boot; a real directory
wins over embedded fallback. If the resolved directory does NOT exist,
fall through to the embedded handler cleanly (log at info level:
"OXICLOUD_STATIC_PATH points at <path> which doesn't exist; serving
embedded assets").
Build-time invariant: `cargo build --features bundled-assets` requires
`static-dist/` to exist AND be non-empty. Add a `build.rs` check that
emits a clear error if missing, pointing at `just fe-build` /
`(cd frontend && npm run build)`.
**Precompression + embed strategy**: minimize binary size by storing
assets compressed inside the binary, and use axum's response
compression on the wire.
`rust-embed`'s `compression` feature deflate-compresses each embedded
file at compile time. Files are decompressed lazily on first access
and cached in a per-file `OnceCell` for the remainder of the process.
Warms up quickly under real traffic — the first user's page load
touches ~30 files, all cached from then on.
On the wire, response compression is handled by axum's
`CompressionLayer` (tower-http) applied to the static router
subtree. Browsers get `Content-Encoding: br` when they Accept-Encoding
brotli; gzip fallback; identity for clients that ask for neither.
Projected embed size after excludes + rust-embed deflate compression:
**~4-5 MB**. Matches the `.tar.xz` reference size and roughly halves
what raw-embed-plus-siblings would cost. Runtime CPU: negligible under
any real load; the compressed variants would benefit from a
reverse-proxy cache in front for CPU-tight hosts (Pi 4/5).
Consequence for the `nginx`/reverse-proxy story users will run in
front: the binary responds correctly to `Accept-Encoding: br, gzip`
without configuration. Users terminating TLS at their proxy get
compressed responses either way (proxy passes through or re-compresses
its cache).
### 3. Target matrix — musl-only Linux
Three triples cover the practical need:
| Triple | Runner + toolchain | Notes |
|---|---|---|
| `x86_64-unknown-linux-musl` | `ubuntu-22.04` + `musl-tools` + `rustup target add` | Static, no glibc dep, runs on ANY Linux distro from Alpine to CentOS 7 to Debian 10 to Ubuntu 25.04. Cross-compiled natively with glibc host + musl target; produces same output as the alpine-container path we originally planned. |
| `aarch64-unknown-linux-musl` | `ubuntu-22.04-arm` + `musl-tools` + `rustup target add` | Same shape as the amd64 twin. Native ARM64 runner (no QEMU). Pi 4/5, ARM servers, Graviton. |
| `aarch64-apple-darwin` | `macos-latest` | Apple Silicon, native |
**Historical note — Intel macOS dropped 2026-08-29** (Apple phasing
out `macos-13`; runner-availability tax exceeded value). Intel Mac
users fall back to `cargo install`, Docker `--platform linux/amd64`,
or one of the Linux musl tarballs inside a Linux VM.
**Historical note — Alpine-container approach abandoned 2026-08-29**
in favour of native cross-compile. Original plan built inside the
Dockerfile's `rust:1.96-alpine3.24` for byte-for-byte parity with
Docker; broke on `ubuntu-22.04-arm` because JS-based GitHub Actions
(checkout, artifact steps, setup-node) can't run inside Alpine on
ARM64 (Node.js binary requires glibc; the x64-Alpine workaround
doesn't extend to arm64). Native `ubuntu-22.04` + `musl-tools` +
`rustup target add` produces the same `--target *-musl` output
without the container gymnastics.
**Rationale for musl-only Linux**:
1. **Parity with Docker.** The Docker image is already Alpine/musl —
users get identical runtime behaviour whether they pull the
container or the tarball. One build shape, one test surface.
2. **Face-indexing regression is a NON-issue.** `faces-onnx` requires
glibc-only `libonnxruntime.so`; it's already unavailable on the
Docker image. Users who want face indexing build from source with
`--features faces-onnx` on a glibc host — same as today, no
change from musl-only tarballs.
3. **Zero glibc-version fragmentation.** No `GLIBC_2.35 not found`
errors on older distros. One binary works everywhere.
4. **Simpler install docs.** "Download this file, run it" without
a "which glibc do you have?" branch.
5. **Marginal perf hit is invisible under I/O-bound OxiCloud workloads.**
Musl's `malloc` and DNS resolver quirks matter for allocation-heavy
/ DNS-heavy servers; OxiCloud is neither.
**External runtime dependencies** — complete list. Codebase audit
2026-08-27 confirmed `ffmpeg` is the ONLY `Command::new` invocation
in `src/`; no other subprocess deps exist.
| Category | Dep | Required? | Notes |
|---|---|---|---|
| Subprocess | `ffmpeg` | Optional | Video thumbnails. Kill switch: `OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false`. Path override: `OXICLOUD_FFMPEG_PATH` |
| System lib | `ca-certificates` | Required | Outbound HTTPS (OIDC, S3, webhooks). Pre-installed on nearly every distro |
| System lib | `tzdata` | Required | Timezone DB for chrono. Pre-installed on nearly every distro |
| External service | PostgreSQL 13+ | Required | With `pg_trgm` + `ltree` extensions. TCP/loopback only — no libpq client lib needed |
| Runtime dylib | `libonnxruntime.so` + ONNX models | N/A for tarball | Face indexing (glibc-only, requires build from source with `--features faces-onnx`). Not shipped in musl tarballs — Docker/tarball users don't have this feature |
**Explicit non-deps** (worth documenting to preempt questions):
- **No libpq** — sqlx uses pure-Rust tokio-postgres
- **No git** — only build-time metadata via `build.rs`, never runtime
- **No ImageMagick / libvips** — image thumbnails via pure-Rust `image` crate
- **No pandoc / rst2html / etc.** — no document conversion
- **No systemd/launchd** — daemon lifecycle user-managed
- **No sendmail / SMTP CLI** — email via pure-Rust SMTP client
**Per-distro install command** (for `README-install.md`):
| Distro | Command |
|---|---|
| Alpine | `apk add ca-certificates tzdata ffmpeg` |
| Debian / Ubuntu | `apt install ca-certificates tzdata ffmpeg` |
| Fedora / RHEL | `dnf install ca-certificates tzdata ffmpeg` (RPMFusion for full codec set) |
| Arch | `pacman -S ca-certificates tzdata ffmpeg` |
| macOS | `brew install ffmpeg` (ca-certificates + tzdata built in) |
| Portable Linux | Static ffmpeg from https://github.com/BtbN/FFmpeg-Builds/releases + `OXICLOUD_FFMPEG_PATH=<path>` |
Postgres install is documented separately (project docs) since it's a
per-distro-per-version story with per-extension setup.
**Windows deliberately deferred** — sqlx feature set, some C deps,
testing story on Windows are all extra work.
**Pi 2 / 32-bit ARM (`armv7-unknown-linux-gnueabihf`) excluded** —
1 GB RAM is below OxiCloud's practical floor even with face indexing
disabled.
**Building strategy for Linux musl targets** — run the compilation
inside the `rust:1.96-alpine3.24` container image the Dockerfile
already uses. Guarantees byte-for-byte parity with what ends up in
the published Docker image; zero new toolchain to maintain. Runner
just needs Docker (all GitHub-hosted Linux runners have it). No
`rustup target add`, no `apt install musl-tools`.
**CPU baseline** — the repo sets `-C target-cpu=native` for x86_64 and
aarch64 hosts (`.cargo/config.toml:11-12`). That flag makes the binary
use every CPU feature the BUILDER exposes — great for local dev,
catastrophic for distributed binaries: a runner with AVX-512 produces
a binary that segfaults on any older CPU. Precedent for the fix at
`.github/workflows/load-smoke.yml:28`, which already overrides with
`RUSTFLAGS="-C target-cpu=x86-64-v3"` for load tests.
Per-target baseline for `release-binaries.yml`:
| Triple | `RUSTFLAGS` |
|---|---|
| `x86_64-unknown-linux-musl` | `-C target-cpu=x86-64-v2` |
| `aarch64-unknown-linux-musl` | `-C target-cpu=generic` (safe ARMv8-A baseline) |
| `aarch64-apple-darwin` | `-C target-cpu=apple-m1` |
`x86-64-v2` covers ~2010+ processors (Nehalem, Bulldozer). Widest
realistic install base for a "runs everywhere" tarball. Notably
different from Docker's `x86-64-v3` (per `load-smoke.yml:28`) — Docker
targets performance-tuned deployments, tarballs target maximum
compatibility.
Trade-off left on the table: BLAKE3 SIMD + image codecs run somewhat
slower on v2 than v3. For a self-hosted personal cloud workload this
is invisible; for anyone who wants max perf, the Docker image is
still their better option.
### 4. Tarball layout
One archive per platform. **Four files inside**, all rooted under a
per-version-per-triple directory so extraction lands cleanly:
```
oxicloud-0.9.0-<triple>/
├── oxicloud ← the single binary (server + tools + embedded assets)
├── example.env ← copied verbatim from repo root (50 KB, all env vars documented)
├── LICENSE ← copied verbatim from repo root
└── README-install.md ← NEW, ~100 lines, tarball-audience-specific
```
Deliberate exclusions:
- **`README.md`** (repo root, 10 KB) — the GitHub landing page: features,
screenshots, tech stack, contribution guide. Wrong orientation for a
downloaded tarball. Users get `README-install.md` instead — shorter,
focused on "how do I run this thing on this box?"
- **`oxicloud.service` systemd unit** — inlined as a copy-paste block in
`README-install.md`. Users have to customize `User=` /
`WorkingDirectory=` anyway; a documented example beats a shipped file
that pretends to be canonical.
- **`CHANGELOG.md`** — the GitHub Release page carries the notes.
Duplicating invites drift.
- **`docs/`** — full documentation stays on GitHub, linked from
`README-install.md`.
`README-install.md` content shape (~100 lines):
- **Quickstart** — required env vars, one-command run
- **PostgreSQL setup** — link to project docs; note `pg_trgm` + `ltree`
extensions
- **Optional: video thumbnails** — mention ffmpeg + the
`OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false` kill switch (first
user-facing surface for this env var, closing the discoverability
gap flagged in memory `bug_env_docs_video_thumbnails_missing`)
- **Systemd unit example** — inline copy-paste block, references
`/etc/oxicloud/oxicloud.env` for env vars
- **First-run** — direct to `/setup` for admin account creation
- **Verification** — `sha256sum -c ../SHA256SUMS` for tarball integrity
- **Upgrading** — replace binary in place, restart service; migrations
run automatically on boot per `sqlx::migrate!()`
- **Support links** — GitHub Issues, docs site
- **Docker note** — for users who want the container path instead
Tarball name: `oxicloud-<version>-<triple>.tar.gz`.
macOS tarballs stay `.tar.gz` too (not `.zip`) — Homebrew formulas
handle either, and it keeps the CI packaging step uniform. Same
extraction UX cross-platform (`tar xzf`).
`SHA256SUMS` file lists all archives with hashes at the release-level
(next to the tarballs, not inside them) — standard OSS practice.
Users verify via `sha256sum -c SHA256SUMS` before extraction.
### 5. New workflow: `.github/workflows/release-binaries.yml`
Three-stage pipeline, shared frontend build:
```
1. frontend-build (ubuntu-latest, single job)
- checkout
- Node 26 setup
- npm ci && npm run build (writes static-dist/ at repo root)
- upload static-dist/ as artifact "static-dist"
2. binary-build (matrix over 4 targets, needs: frontend-build)
- checkout
- download static-dist artifact into repo-root static-dist/
- Linux targets: docker run rust:1.96-alpine3.24, cargo build inside
- macOS targets: rustup target add + native cargo build
- cargo build --release --features bundled-assets --bin oxicloud
- tar czf oxicloud-<version>-<triple>.tar.gz oxicloud-<version>-<triple>/
- upload tarball as per-platform artifact
3. release (ubuntu-latest, needs: binary-build)
- download all tarball artifacts
- compute SHA256SUMS
- softprops/action-gh-release@v2 with files: dist/*
```
Triggers: `push: tags: v*` (real releases) + `workflow_dispatch` with
`dry_run: true` toggle (build tarballs, upload as workflow artifacts,
skip attaching to a release).
Interaction with existing `release.yml`: **new file**, because the
current `release.yml` is tiny (create release + notes) and mixing
concerns would clutter it. `release.yml` stays as "make the GitHub
Release exist"; `release-binaries.yml` stacks binaries into it. Both
trigger on `push: tags: v*`.
**Parallel-fire behaviour on tag push** — on `git push origin v0.9.0`,
three workflows fire simultaneously:
```
tag push v0.9.0
│
├─── release.yml (~1 min) Release + notes
├─── docker-publish.yml (~30-45 min) multi-arch Docker → GHCR + DockerHub
└─── release-binaries.yml (~25-30 min) 4 tarballs → attach to Release
```
Total wall-clock: ~30-45 min (dominated by whichever build is slower).
No sequencing between the three — each has a single responsibility
and runs independently.
Race with `release.yml` is **benign** because `release-binaries.yml`
uses `softprops/action-gh-release@v2`, which:
- **Adds files** to an existing Release if one exists for the tag.
- **Creates** the Release (with default settings, no notes) if
`release.yml` hasn't finished yet.
Worst case: `release-binaries.yml` finishes first on a tiny tag, creates
a bare Release, `release.yml` catches up and fills in the notes. Users
see the Release progressively; nothing breaks. If this becomes annoying
in practice (unlikely — `release.yml` is ~1 min), flip
`release-binaries.yml` to `on: workflow_run: { workflows: ["Release"],
types: [completed] }` to serialize.
Concurrency: same `${{ github.workflow }}-${{ github.ref }}` group as
`docker-publish.yml`, but `cancel-in-progress: false` — every tag is
unique and immutable, so a superseded release build has nothing to
cancel.
Publish gate: same fork-friendly pattern as `docker-publish.yml` —
`if: github.repository == 'AtalayaLabs/OxiCloud' ||
vars.ENABLE_BINARY_RELEASE == 'true'`. Prevents forks from
auto-attaching binaries to their own tag pushes.
### 6. Docs
- **`docs/install/binary.md`** — quickstart per platform, verify
SHA256SUMS, minimum env vars (`DATABASE_URL`), systemd unit
example, Pi-specific advice (link to the "verified on" hardware
table). Prose only — no code snippets that could go stale.
Include a "server-side video thumbnails" callout naming the
`OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false` kill switch — this is
the first user-facing surface where the env var is discoverable
(per memory `bug_env_docs_video_thumbnails_missing`, it's not
in `example.env` nor `docs/env.md` today). Consider fixing the
underlying gap in `example.env` + `docs/env.md` as a companion
edit to this PR — small win, high visibility.
- **`README.md`** — add a one-line pointer under Installation:
"Binary releases attached to each GitHub Release — see
[docs/install/binary.md]". Do NOT list per-triple download links
by hand; they'd rot.
- **This file** — the design record. Kept alongside other
`docs/plan/*.md` docs so the next maintainer sees the rationale
before touching `release-binaries.yml` or the embed layer.
### 7. `Cargo.toml` `[package.metadata.binstall]` block
Free win: `cargo binstall oxicloud` starts working once the tarballs
land on GitHub Releases with predictable names. Two-line metadata
block declares the URL template:
```toml
[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/v{ version }/oxicloud-{ version }-{ target }.tar.gz"
bin-dir = "oxicloud-{ version }-{ target }/{ bin }{ binary-ext }"
```
No CI change; the tarballs already follow this shape from Deliverable 4.
## Critical files
- `Cargo.toml` — add `bundled-assets` + `dev_tools` features,
`required-features` on gated bins, `rust-embed` optional dep,
`[package.metadata.binstall]` block. Delete the
`[[bin]] name = "oxicloud-cli"` and `[[bin]] name = "migrate-nfc-filenames"`
blocks (Deliverables 1a + 1b).
- `src/main.rs` — add clap parsing at the top of `main()`. If a
subcommand is present → dispatch via new `src/cli/` module; otherwise
fall through to the existing server-init path (backwards-compat
implicit-server mode).
- `src/cli/mod.rs` — NEW. Root of the operator-tools tree; contains
`Domain` enum + submodules moved from `src/bin/oxicloud-cli.rs`.
- `src/cli/opaque.rs` — NEW. `opaque setup` + `opaque reset` moved
from the old `oxicloud-cli.rs`.
- `src/cli/migrate.rs` — NEW. `migrate nfc-filenames` — the ~149
non-boilerplate lines from the old `migrate-nfc-filenames.rs`,
wrapped as a clap subcommand.
- `src/bin/oxicloud-cli.rs` — DELETE (contents absorbed into `src/cli/`).
- `src/bin/migrate-nfc-filenames.rs` — DELETE (contents absorbed
into `src/cli/migrate.rs`).
- `src/interfaces/web/mod.rs` — 400-line file, owns the static-serving
surface. Four sites gain a `#[cfg(feature = "bundled-assets")]`
alternative:
- `resolve_static_path()` (`:25-35`) — returns a `StaticSource`
enum under bundled mode; a bare `PathBuf` otherwise
- `create_web_routes()` (`:47-106`) — swap the two `ServeDir`
constructions for embedded-asset handlers
- `content_security_policy()` + `inline_script_csp_hashes()`
(`:163-233`) — iterate `EmbeddedAssets::iter()` instead of
`fs::read_dir`
- Import block + type imports for the new source enum
- `src/interfaces/web/embedded.rs` — NEW: `#[derive(RustEmbed)]` struct
+ two axum handlers (root/SPA-fallback + `_app/immutable`-prefixed
with cache header) + shared MIME helper. ~100 lines.
- `src/main.rs:599-615` — locale-source resolution. Under bundled
mode, call `LocaleRegistry::discover_embedded()` instead of the
filesystem variant when `resolve_static_path()` returns
`StaticSource::Embedded`.
- `src/common/locale.rs:150-221` — add `LocaleRegistry::discover_embedded()`
under `#[cfg(feature = "bundled-assets")]`. Same parse + registry
build, source is `EmbeddedAssets::iter()` filtered to `locales/*.json`.
- `src/infrastructure/services/file_system_i18n_service.rs` — either
extend to accept an `EmbeddedLocales` source alongside the
filesystem one, OR ship a second `EmbeddedI18nService` impl of the
same trait. Latter avoids polluting the fast filesystem path with
cfg gates.
- `build.rs` — EXISTS today (injects `GIT_HASH`/`GIT_BRANCH` from git).
Extend with a second block: when the `bundled-assets` feature is
enabled (`env::var("CARGO_FEATURE_BUNDLED_ASSETS").is_ok()`),
check that repo-root `static-dist/` exists and contains at least
`index.html`. Emit a clear compile error pointing at
`just fe-build` / `(cd frontend && npm run build)` if missing.
Also emit `cargo:rerun-if-changed=static-dist/` so a rebuild of
the frontend re-triggers rust-embed's compile-time embed step.
- `.cargo/config.toml` — NO CHANGES. The dev-preserving default of
`-C target-cpu=native` stays. Release CI overrides via per-job
`RUSTFLAGS` env var, per the load-smoke.yml precedent.
- `.github/workflows/release-binaries.yml` — NEW: three-stage pipeline.
- `justfile` — thread `--features dev_tools` into the `openapi` recipe.
- `tests/api/run.sh` — thread `--features test_utils` into the two
hurl-helper build lines.
- `docs/install/binary.md` — NEW: user-facing installation guide.
## Verification
1. **Local squash check**: after Cargo.toml + `src/cli/` edits, run
`cargo build --release --bins` and confirm exactly ONE binary
appears in `target/release/` (`oxicloud`). Run `cargo build --release
--bins --features test_utils` and confirm the two hurl helpers
appear. `cargo build --release --bins --features dev_tools`
should surface `generate-openapi`. Confirm subcommand shape via:
- `target/release/oxicloud --help` — shows `opaque` + `migrate`
domains
- `target/release/oxicloud opaque setup` — prints a fresh
ServerSetup base64 line (unchanged behaviour vs the old
`oxicloud-cli opaque setup`)
- `target/release/oxicloud migrate nfc-filenames --dry-run`
(against a sandbox DB) — same behaviour as the old
`migrate-nfc-filenames --dry-run`
- `target/release/oxicloud` (no args) — starts the server exactly
as today, no clap-related output surprises before the server
init banner.
2. **Local bundled-assets smoke**:
```
(cd frontend && npm ci && npm run build) # writes ../static-dist/
cargo build --release --features bundled-assets --bin oxicloud
# Wipe static-dist/ or point OXICLOUD_STATIC_PATH somewhere
# nonexistent to force the embedded path to be exercised.
mv static-dist/ static-dist.hidden/
OXICLOUD_STATIC_PATH=/tmp/nonexistent DATABASE_URL=... target/release/oxicloud
# Hit http://localhost:8086 — SPA shell + locales must load.
# Restore afterward: mv static-dist.hidden/ static-dist/
```
3. **Filesystem fallback still works in bundled build**: with the
same binary, point `OXICLOUD_STATIC_PATH` at a real static-dist,
confirm files served from disk (change a file, no rebuild → change
visible in browser). Verifies the precedence rule from Deliverable 2.
4. **Non-bundled build still works**: `cargo build --release`
(without `--features bundled-assets`) → binary boots + serves from
`./static/static-dist/` as today. Zero regression on the Docker
image path.
5. **CI dry-run**: dispatch `release-binaries.yml` with `dry_run: true`
from a fork. Confirms all four matrix entries build successfully,
tarballs land in the run's artifact list, no release is created.
6. **Manual extraction test**: download one tarball, extract, run
`./oxicloud` with just `DATABASE_URL` set (against a local
Postgres). Log in, upload a file, check that locale switching
works, confirm `/api/status` returns healthy. Then repeat on a Pi 5
for the `aarch64-unknown-linux-musl` variant if convenient.
## Not in scope
- **Windows target** — separate work when demand appears.
- **glibc Linux tarballs** — musl covers the Linux audience per the
design shape above; users wanting glibc-specific features
(`faces-onnx`) build from source.
- **32-bit ARM (`armv7`)** — hardware below the workload floor.
- **Debian/RPM packages** — post-tarball layer, adds repo-hosting burden.
- **Homebrew tap** — trivial once tarballs exist; separate decision.
- **Signing (Sigstore/GPG)** — worth adding but scope-creeping;
SHA256SUMS is the minimum table stakes for this PR.
## Delivery order
1. **Feature-flag squash** (Deliverable 1). Cargo.toml edits +
`just openapi` / `tests/api/run.sh` invocation fixes. Verify
`cargo build --release --bins` no longer builds hurl helpers.
2. **Merge migrate-nfc-filenames into oxicloud-cli** (Deliverable 1a).
Move logic to `mod migrate` submodule. Delete standalone bin.
Verify `oxicloud-cli migrate nfc-filenames --dry-run` works.
3. **Merge oxicloud-cli into oxicloud** (Deliverable 1b). Move
`src/bin/oxicloud-cli.rs` contents into new `src/cli/` module,
wire clap into `main.rs` with implicit-server default. Delete
`src/bin/oxicloud-cli.rs`. Verify `oxicloud` (no args) still
starts the server; `oxicloud opaque setup` + `oxicloud migrate
nfc-filenames --dry-run` work.
4. **Add `bundled-assets` feature** (Deliverable 2). `rust-embed` +
`build.rs` guard + `src/interfaces/web/embedded.rs` + locale
loader alt + CSP scan alt. Verify locally with the smoke sequence
in Verification §2.
5. **Add `.github/workflows/release-binaries.yml`** (Deliverable 5).
Dry-run on a fork. Iterate until all 4 targets green.
6. **Write docs** (Deliverable 6) — `docs/install/binary.md`. Prose
only, no snippets that will rot. Include the
`OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false` callout for tarball users
without ffmpeg.
7. **Add `[package.metadata.binstall]` block** (Deliverable 7).
One-line change enabling `cargo binstall oxicloud`.
8. **Fix the `OXICLOUD_ENABLE_VIDEO_THUMBNAILS` doc gap** — add to
`example.env` + `docs/env.md` per memory
`bug_env_docs_video_thumbnails_missing`. Small companion edit
surfaced by the binary-install docs work.
9. **Cut a test tag** (`v0.9.0-rc1`?) on a fork with
`vars.ENABLE_BINARY_RELEASE=true`. Confirm tarballs attach to the
Release, SHA256SUMS present, `cargo binstall oxicloud` works.
10. **When happy, cut on canonical.**
Total scope: ~2.5 days of careful work.
- Deliverables 1 + 1a + 1b: ~5 hours mechanical (Cargo config, CLI
merge, subcommand tree)
- Deliverable 2: ~1 day — the only piece with real design surface
(embed swap, four cfg sites, locale + CSP loaders)
- Deliverable 5: ~4 hours workflow authoring + iteration
- Deliverables 6-8: ~4 hours docs + small edits
- Verification + iteration: ~4 hours
+1 -1
View File
@@ -330,7 +330,7 @@ authenticated session):
**Deferred:** **Deferred:**
- Step-up auth before link start - Step-up auth before link start
- Admin-mediated link/unlink via `oxicloud-cli federation` (proper for - Admin-mediated link/unlink via `oxicloud federation` (proper for
"user changed IdP email" recovery scenario) "user changed IdP email" recovery scenario)
- OCM link (same shape, different kind) - OCM link (same shape, different kind)
- Multi-federation (multiple linked identities per user — see - Multi-federation (multiple linked identities per user — see
+5 -5
View File
@@ -62,7 +62,7 @@ and ordering are the load-bearing decisions here.
## Preconditions before we start the wipe ## Preconditions before we start the wipe
Every one of these MUST hold. Adding a pre-flight check in Every one of these MUST hold. Adding a pre-flight check in
`oxicloud-cli opaque wipe-legacy` (proposed below) that refuses to run `oxicloud opaque wipe-legacy` (proposed below) that refuses to run
otherwise. otherwise.
1. **`OXICLOUD_AUTH_OPAQUE_MODE=opaque_only`** on the deployment for at 1. **`OXICLOUD_AUTH_OPAQUE_MODE=opaque_only`** on the deployment for at
@@ -174,7 +174,7 @@ it can't, since login-link users just clicked email — no proof-of-current).
### The wipe migration ### The wipe migration
Delivered as `oxicloud-cli opaque wipe-legacy` — a dedicated subcommand, Delivered as `oxicloud opaque wipe-legacy` — a dedicated subcommand,
NOT a schema migration. Reasons: NOT a schema migration. Reasons:
- Idempotent (won't re-wipe already-nulled rows) - Idempotent (won't re-wipe already-nulled rows)
- Pre-flight refuses when preconditions aren't met (unlike a migration - Pre-flight refuses when preconditions aren't met (unlike a migration
@@ -207,7 +207,7 @@ UPDATE auth.users
Output: `N password_hash columns nulled. M users still have password_hash Output: `N password_hash columns nulled. M users still have password_hash
because they don't meet the OPAQUE-migrated preconditions — inspect via because they don't meet the OPAQUE-migrated preconditions — inspect via
`oxicloud-cli opaque wipe-legacy --dry-run` and address separately.` `oxicloud opaque wipe-legacy --dry-run` and address separately.`
The `WHERE` clause is intentionally strict: OIDC users, externals, and The `WHERE` clause is intentionally strict: OIDC users, externals, and
under-migrated users are ALL left alone. The strict version is safer than under-migrated users are ALL left alone. The strict version is safer than
@@ -232,7 +232,7 @@ can drop the legacy password code:
6. `has_password` field on `UserDto` / `AdminUserSummaryDto`: delete (always 6. `has_password` field on `UserDto` / `AdminUserSummaryDto`: delete (always
false, meaningless signal) false, meaningless signal)
7. `admin`-badge `password` chip: delete (same reason) 7. `admin`-badge `password` chip: delete (same reason)
8. `oxicloud-cli opaque reset --user X` for legacy-recovery: still useful 8. `oxicloud opaque reset --user X` for legacy-recovery: still useful
as an emergency lever (envelope somehow corrupted, need to force as an emergency lever (envelope somehow corrupted, need to force
re-registration via recovery-magic-link), but its "silent-migration re-registration via recovery-magic-link), but its "silent-migration
handles the recovery" semantics become "recovery-magic-link handles the handles the recovery" semantics become "recovery-magic-link handles the
@@ -269,7 +269,7 @@ running smoothly for the indicated period."
| G1 | Land task #31: change_password OPAQUE-lockout fix + hybrid-user password gate | Days | | G1 | Land task #31: change_password OPAQUE-lockout fix + hybrid-user password gate | Days |
| G2 | Land recovery-magic-link admin reset flow | Weeks | | G2 | Land recovery-magic-link admin reset flow | Weeks |
| G3 | Land OPAQUE-verify-current + change_password redesign that COMPOSES the two (Argon2-verify AND OPAQUE-verify both work; use whichever the user has) | Weeks | | G3 | Land OPAQUE-verify-current + change_password redesign that COMPOSES the two (Argon2-verify AND OPAQUE-verify both work; use whichever the user has) | Weeks |
| G4 | Ship `oxicloud-cli opaque wipe-legacy` (dry-run only initially, no destructive flag) | Days | | G4 | Ship `oxicloud opaque wipe-legacy` (dry-run only initially, no destructive flag) | Days |
| G5 | Add admin-dashboard metric: "N users still on legacy (`password_hash IS NOT NULL AND !opaque_migrated`)" | Days | | G5 | Add admin-dashboard metric: "N users still on legacy (`password_hash IS NOT NULL AND !opaque_migrated`)" | Days |
| G6 | Operator switches deployment to `opaque_only` mode | ✅ already possible | | G6 | Operator switches deployment to `opaque_only` mode | ✅ already possible |
| G7 | Wait 90+ days at `opaque_only`, watch the metric drop to 0 | Months | | G7 | Wait 90+ days at `opaque_only`, watch the metric drop to 0 | Months |
+5 -5
View File
@@ -324,7 +324,7 @@ at), boot fails fast with a clear error. Operator has two ways out:
restart. restart.
2. **CLI repair flag on the `oxicloud` binary itself**: 2. **CLI repair flag on the `oxicloud` binary itself**:
``` ```
oxicloud --select-storage <name> oxicloud storage select <name>
``` ```
Behaviour: parse `.env`, verify `<name>` exists in `_ENTRIES` (fail-fast Behaviour: parse `.env`, verify `<name>` exists in `_ENTRIES` (fail-fast
with the available names listed if not), connect to DB, UPDATE with the available names listed if not), connect to DB, UPDATE
@@ -334,7 +334,7 @@ at), boot fails fast with a clear error. Operator has two ways out:
The bare-flag on the shipped binary is chosen over a separate `just` The bare-flag on the shipped binary is chosen over a separate `just`
recipe or auxiliary bin because: recipe or auxiliary bin because:
- **Docker-friendly**: `docker exec oxicloud oxicloud --select-storage foo` - **Docker-friendly**: `docker exec oxicloud oxicloud storage select foo`
— no need to install extra tooling in the container. — no need to install extra tooling in the container.
- **Systemd-friendly**: can be run as a `ExecStartPre=` one-shot before the - **Systemd-friendly**: can be run as a `ExecStartPre=` one-shot before the
main service unit. main service unit.
@@ -377,7 +377,7 @@ foundational; the rest layer on top independently within reason.
| 5 | Cutover state machine: on migration `Completed`, write `active_backend_name = target_name`, keep read-only on. Boot on new backend after operator restart. | 4 | ~half day | | 5 | Cutover state machine: on migration `Completed`, write `active_backend_name = target_name`, keep read-only on. Boot on new backend after operator restart. | 4 | ~half day |
| 6 | Admin storage tab rewrite: list entries, show active, migrate dropdown, read-only banner. Delete Save form + S3 field editors + .env cutover hint. | 1, 3, 4 | 1 day | | 6 | Admin storage tab rewrite: list entries, show active, migrate dropdown, read-only banner. Delete Save form + S3 field editors + .env cutover hint. | 1, 3, 4 | 1 day |
| 7 | `?storage=<name>` on `blobs_consistency` + `backend_consistency`. `JobRunArgs.storage` plumbing, `TriggerJobQuery.storage`, entry-resolver at run start, params records probed name. Retire `verify_migration` + its DTO + its route + its handler. | 1, 3 | 1 day | | 7 | `?storage=<name>` on `blobs_consistency` + `backend_consistency`. `JobRunArgs.storage` plumbing, `TriggerJobQuery.storage`, entry-resolver at run start, params records probed name. Retire `verify_migration` + its DTO + its route + its handler. | 1, 3 | 1 day |
| 8 | `oxicloud --select-storage <name>` bare-flag repair command on the main binary. Parses `.env`, verifies entry exists, UPDATEs DB, exits. Boot-time missing-entry error message points at it. See §Fallback. | 2 | ~quarter day | | 8 | `oxicloud storage select <name>` bare-flag repair command on the main binary. Parses `.env`, verifies entry exists, UPDATEs DB, exits. Boot-time missing-entry error message points at it. See §Fallback. | 2 | ~quarter day |
**Total: ~5-6 days end to end.** Slices 6 and 7 can proceed in parallel with **Total: ~5-6 days end to end.** Slices 6 and 7 can proceed in parallel with
each other once 1-5 land. Slice 8 is an ops nicety, could ship whenever. each other once 1-5 land. Slice 8 is an ops nicety, could ship whenever.
@@ -414,8 +414,8 @@ Per slice, plus these end-to-end scenarios in Hurl:
→ 400 with known-names list. No run row created. → 400 with known-names list. No run row created.
9. **Missing entry at boot**: `active_backend_name = "gone"` but `_ENTRIES` 9. **Missing entry at boot**: `active_backend_name = "gone"` but `_ENTRIES`
doesn't include it → boot aborts with the specific message pointing at doesn't include it → boot aborts with the specific message pointing at
`oxicloud --select-storage <name>` (with the available names filled in). `oxicloud storage select <name>` (with the available names filled in).
Re-run the binary with `--select-storage local_main` → verifies + updates Re-run the binary with `storage select local_main` → verifies + updates
DB + exits 0. Restart the server → boots cleanly on `local_main`. DB + exits 0. Restart the server → boots cleanly on `local_main`.
10. **Encryption key invalid**: `OXICLOUD_STORAGE_<N>_ENCRYPTION_KEY=badbase64` 10. **Encryption key invalid**: `OXICLOUD_STORAGE_<N>_ENCRYPTION_KEY=badbase64`
→ boot aborts with entry name + reason (not valid base64 / wrong length). → boot aborts with entry name + reason (not valid base64 / wrong length).
+24
View File
@@ -382,6 +382,30 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud
# Enable music playlists and audio metadata (default: true) # Enable music playlists and audio metadata (default: true)
#OXICLOUD_ENABLE_MUSIC=true #OXICLOUD_ENABLE_MUSIC=true
# ── Video thumbnails ─────────────────────────────────────────────────────
# Server-side extraction of a single frame from uploaded videos, encoded
# as WebP for the thumbnail grid.
#
# Runtime dependency: `ffmpeg` on PATH (override with OXICLOUD_FFMPEG_PATH).
# When ffmpeg is missing at boot AND this flag is true, the server emits a
# WARN log line ("ffmpeg not found") and falls back to no-thumbnail — the
# API still works, videos just get a placeholder icon.
#
# Turn this off when:
# - You can't install ffmpeg on this host (locked-down image, minimal
# distro, etc.) and want to silence the boot warning.
# - Your client uploads video previews itself (some mobile / desktop
# clients generate thumbnails locally and POST them alongside the
# upload — the server accepts pre-generated thumbnails via the
# upload API and stores them as regular content).
#
# Default: true (extracts server-side when ffmpeg is available).
#OXICLOUD_ENABLE_VIDEO_THUMBNAILS=true
# Explicit path to ffmpeg. Only useful when ffmpeg isn't on PATH or you
# want to pin a specific build (e.g. a static portable ffmpeg). Ignored
# when OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false.
#OXICLOUD_FFMPEG_PATH=/usr/bin/ffmpeg
# Expose other OxiCloud users as a read-only "system" address book # Expose other OxiCloud users as a read-only "system" address book
# at GET /api/address-books (default: true) # at GET /api/address-books (default: true)
# Set to false to prevent users from browsing the user directory. # Set to false to prevent users from browsing the user directory.
+13 -7
View File
@@ -192,7 +192,7 @@ audit:
cargo audit cargo audit
openapi: openapi:
cargo run --bin generate-openapi cargo run --features dev_tools --bin generate-openapi
db: db:
docker compose up -d postgres docker compose up -d postgres
@@ -252,10 +252,10 @@ front-design:
# real browser, which the curl-driven # real browser, which the curl-driven
# suite above can't observe. # suite above can't observe.
# #
# Same chain runs in CI under the `api-test` job in # Same chain runs in CI under the `test-api` job in
# .github/workflows/ci.yml; keep the order in sync so a local pass means # .github/workflows/ci.yml; keep the order in sync so a local pass means
# CI passes. # CI passes.
api-test: test-api:
#!/usr/bin/env bash #!/usr/bin/env bash
set -x set -x
set -euo pipefail set -euo pipefail
@@ -270,6 +270,12 @@ api-test:
echo "XXX litmus webdav not found, ignore test" echo "XXX litmus webdav not found, ignore test"
fi fi
# backward compat
api-test: test-api
test-bundle:
./tests/bundled-binary/run.sh
# CalDAV client-driven conformance suite. # CalDAV client-driven conformance suite.
# #
# Drives OxiCloud through the maintained `python-caldav` client library # Drives OxiCloud through the maintained `python-caldav` client library
@@ -279,9 +285,9 @@ api-test:
# (RFC 5545 §3.8.4.4), and all-day masters (the shape #528 was filed # (RFC 5545 §3.8.4.4), and all-day masters (the shape #528 was filed
# against). # against).
# #
# Not chained into `api-test` because it needs python3; run explicitly. # Not chained into `test-api` because it needs python3; run explicitly.
# The orchestrator spawns its own postgres + server on port 8091 so it # The orchestrator spawns its own postgres + server on port 8091 so it
# can run in parallel with api-test/webdav. # can run in parallel with test-api/webdav.
# #
# Runs `cargo build` first so the orchestrator always sees a fresh # Runs `cargo build` first so the orchestrator always sees a fresh
# binary. run-pycaldav.sh itself doesn't rebuild — it uses whatever # binary. run-pycaldav.sh itself doesn't rebuild — it uses whatever
@@ -301,7 +307,7 @@ test-caldav:
# Manual, human-run: launches OxiCloud with OIDC as the ONLY login method # Manual, human-run: launches OxiCloud with OIDC as the ONLY login method
# (fake IdP on :1081, server on :8090) and waits for you to eyeball the # (fake IdP on :1081, server on :8090) and waits for you to eyeball the
# /login auto-redirect in a real browser. Not part of `just api-test` — # /login auto-redirect in a real browser. Not part of `just test-api` —
# there's no automated assertion here, it's a visual check. Ctrl-C to stop. # there's no automated assertion here, it's a visual check. Ctrl-C to stop.
#oidc-manual-sso-only: #oidc-manual-sso-only:
# bash tests/oidc/run-manual-sso-only.sh # bash tests/oidc/run-manual-sso-only.sh
@@ -383,4 +389,4 @@ test-docker-tags:
# Check and test everything # Check and test everything
# recommanded before pull request # recommanded before pull request
pre-pull-request: test-docker-tags check fe-check audit check-migrations test test-integration fe-test build api-test fe-build-e2e front-test pre-pull-request: test-docker-tags check fe-check audit check-migrations test test-integration fe-test build test-bundle test-api fe-build-e2e front-test
-278
View File
@@ -1,278 +0,0 @@
//! `oxicloud-cli` — operator toolbox for the OxiCloud deployment.
//!
//! Single binary with subcommand tree, shipped alongside the `oxicloud`
//! server binary. Replaces the per-task one-off bins (previously
//! `opaque-setup`, and any future `opaque-reset` etc.) with a
//! discoverable `--help`-driven surface so the container ships one
//! toolbox binary rather than N one-off ones.
//!
//! ## Layout
//!
//! ```text
//! oxicloud-cli <domain> <action> [flags]
//!
//! Domains:
//! opaque OPAQUE aPAKE substrate management
//! setup Print a fresh ServerSetup value for OXICLOUD_AUTH_OPAQUE_SERVER_SETUP
//! reset Clear envelope(s) so silent-migration re-mints under current KSF
//! ```
//!
//! Growth pattern: each new domain gets its own module below (e.g.
//! `mod opaque`) with a `#[derive(Subcommand)]` enum for its actions
//! and a `run(args) -> ExitCode` entrypoint. Keep each module
//! self-contained so a future extraction is a file move.
//!
//! ## Environment
//!
//! * `DATABASE_URL` — required by any subcommand that talks to the DB
//! (`opaque reset`); not needed for pure primitive helpers
//! (`opaque setup`). Each subcommand documents its own dependencies.
use std::process::ExitCode;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "oxicloud-cli",
version,
about = "OxiCloud operator toolbox",
long_about = "OxiCloud operator toolbox — subcommand entrypoint for operational \
tasks that don't belong in the main server binary."
)]
struct Cli {
#[command(subcommand)]
domain: Domain,
}
#[derive(Subcommand)]
enum Domain {
/// OPAQUE aPAKE substrate management (setup, reset).
Opaque {
#[command(subcommand)]
action: opaque::Action,
},
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
let cli = Cli::parse();
match cli.domain {
Domain::Opaque { action } => opaque::run(action).await,
}
}
// ── opaque domain ──────────────────────────────────────────────────────
mod opaque {
use std::env;
use std::process::ExitCode;
use clap::Subcommand;
use oxicloud::infrastructure::services::opaque_service::OpaqueService;
use sqlx::{PgPool, Row};
#[derive(Subcommand)]
pub enum Action {
/// Generate a fresh OPAQUE ServerSetup and print its base64
/// encoding to stdout. Guidance goes to stderr so shell
/// pipelines capture cleanly.
///
/// Run ONCE per deployment; persist the printed value as
/// `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`. Rotating this value
/// invalidates every user's OPAQUE registration — treat it
/// like your JWT secret.
Setup,
/// Clear the OPAQUE envelope for one user or all users
/// WITHOUT touching password or setting force_password_change.
///
/// Use case: KSF rotation. If you change
/// OXICLOUD_AUTH_OPAQUE_KSF_* values, existing envelopes
/// become cryptographically incompatible with the newly
/// published KSF — logins fail with InvalidCredentials.
/// Nulling the envelope columns forces the SPA's `/lookup`
/// to report `hasOpaque: false`, which routes the next login
/// through legacy `/api/auth/login`; silent-migration then
/// mints a fresh envelope under the CURRENT KSF. Passwords
/// are unchanged.
///
/// NOT for forgotten-passphrase recovery — use the admin
/// password-reset endpoint (`PUT /api/admin/users/{id}/password`)
/// which sets a temp password + force_change flag in one shot.
Reset {
/// Email OR username to reset (dispatched on `@` presence,
/// same rule as `POST /api/auth/login`).
#[arg(long, conflicts_with = "all")]
user: Option<String>,
/// Reset every user with an OPAQUE envelope.
#[arg(long, conflicts_with = "user")]
all: bool,
/// Print what would change without touching the DB.
#[arg(long)]
dry_run: bool,
},
}
pub async fn run(action: Action) -> ExitCode {
match action {
Action::Setup => run_setup(),
Action::Reset { user, all, dry_run } => run_reset(user, all, dry_run).await,
}
}
fn run_setup() -> ExitCode {
// Match the legacy `opaque-setup` bin's contract:
// - value on stdout, no trailing commentary (pipeline-safe)
// - guidance on stderr
let b64 = OpaqueService::generate_server_setup_b64();
println!("{b64}");
eprintln!();
eprintln!("=== OPAQUE server setup generated. ===");
eprintln!("Persist the line above in OXICLOUD_AUTH_OPAQUE_SERVER_SETUP.");
eprintln!("NEVER rotate: rotating invalidates every user's registration.");
eprintln!("Treat this value like your JWT secret.");
ExitCode::from(0)
}
async fn run_reset(user: Option<String>, all: bool, dry_run: bool) -> ExitCode {
// clap enforces `conflicts_with`, but not "at least one of".
// Belt-and-braces check here so the failure is explicit.
if user.is_none() && !all {
eprintln!("opaque reset: pass either --user <id> or --all");
return ExitCode::from(2);
}
let database_url = match env::var("DATABASE_URL") {
Ok(v) => v,
Err(_) => {
eprintln!("opaque reset: DATABASE_URL not set");
return ExitCode::from(2);
}
};
let pool = match PgPool::connect(&database_url).await {
Ok(p) => p,
Err(e) => {
eprintln!("opaque reset: failed to connect to database: {e}");
return ExitCode::from(1);
}
};
// Preview the affected row set before writing. Doubles as
// dry-run output and as diagnostics when --user matches nothing.
// Envelope-presence bool lets the operator see which rows had
// an envelope vs which only carry a stale migration mark.
let select_sql = if all {
r#"
SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope
FROM auth.users
WHERE opaque_envelope IS NOT NULL
OR opaque_migrated_at IS NOT NULL
ORDER BY email
"#
} else {
r#"
SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope
FROM auth.users
WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END
"#
};
let rows_result = if all {
sqlx::query(select_sql).fetch_all(&pool).await
} else {
let ident = user.as_deref().unwrap();
sqlx::query(select_sql).bind(ident).fetch_all(&pool).await
};
let rows = match rows_result {
Ok(r) => r,
Err(e) => {
eprintln!("opaque reset: query failed: {e}");
return ExitCode::from(1);
}
};
if rows.is_empty() {
if all {
println!("opaque reset: no users have an OPAQUE envelope — nothing to do.");
return ExitCode::from(0);
} else {
eprintln!(
"opaque reset: no user matches --user {} — nothing changed.",
user.as_deref().unwrap_or("")
);
return ExitCode::from(1);
}
}
println!(
"opaque reset ({}): {} row(s) to affect",
if dry_run {
"DRY RUN — no writes"
} else {
"EXECUTING"
},
rows.len()
);
for row in &rows {
let id: uuid::Uuid = row.get("id");
let email: String = row.get("email");
let had_envelope: bool = row.get("had_envelope");
println!(
" {} {} {}",
id,
email,
if had_envelope {
"had-envelope"
} else {
"no-envelope-had-migrated-mark"
}
);
}
if dry_run {
return ExitCode::from(0);
}
// Actual UPDATE. Kept identical in shape to the SELECT above so
// the planner sees the same query pattern for both. We
// DELIBERATELY do NOT touch password_hash or
// force_password_change_at_next_login — this tool is scoped
// to "the passwords are fine, the envelopes are stale."
let update_sql_all = r#"
UPDATE auth.users
SET opaque_envelope = NULL,
opaque_ciphersuite_version = NULL,
opaque_registered_at = NULL,
opaque_migrated_at = NULL
WHERE opaque_envelope IS NOT NULL
OR opaque_migrated_at IS NOT NULL
"#;
let update_sql_one = r#"
UPDATE auth.users
SET opaque_envelope = NULL,
opaque_ciphersuite_version = NULL,
opaque_registered_at = NULL,
opaque_migrated_at = NULL
WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END
"#;
let write_result = if all {
sqlx::query(update_sql_all).execute(&pool).await
} else {
let ident = user.as_deref().unwrap();
sqlx::query(update_sql_one).bind(ident).execute(&pool).await
};
let affected = match write_result {
Ok(r) => r.rows_affected(),
Err(e) => {
eprintln!("opaque reset: update failed: {e}");
return ExitCode::from(1);
}
};
println!(
"opaque reset: cleared envelope columns on {affected} row(s). \
Users log in with their existing password; silent-migration \
re-mints envelopes under the current KSF on next login."
);
ExitCode::from(0)
}
}
@@ -1,44 +1,64 @@
//! `migrate-nfc-filenames` — one-shot CLI to NFC-normalize //! `migrate` subcommand domain — one-time data migrations.
//! `storage.files.name` across an OxiCloud instance.
//! //!
//! Why: PostgreSQL compares bytes literally and the `UNIQUE` //! Sqlx schema migrations run automatically at boot via
//! index on `(folder_id, name, user_id) WHERE NOT is_trashed` //! `sqlx::migrate!()` — this domain is reserved for **data** migrations
//! does not catch Unicode normalization differences. macOS APFS //! that need explicit operator invocation (data-loss ambiguity, long
//! stores filenames in NFD; browsers post NFC. A file uploaded //! runtime, or historical schema-drift cleanup).
//! from the web ("café.txt", NFC) and the same name re-uploaded
//! from a NextCloud desktop client on macOS (round-tripped to
//! NFD: `e` + combining acute) lands as two distinct rows, both
//! visible in the listing, both pointing at the same blob.
//! //!
//! What this does: //! Currently ships one action: `nfc-filenames` — cleans up NFD/NFC
//! filename collisions in databases populated before the June 2026
//! write-time fix at `src/domain/services/path_service.rs::normalize_storage_name`
//! (called from `src/infrastructure/repositories/pg/file_blob_read_repository.rs`
//! during file operations). New installs never need this migration;
//! only pre-June-2026 databases do.
//! //!
//! 1. Scans every non-trashed file row. //! Previously lived in a standalone `migrate-nfc-filenames` binary
//! 2. For each row whose name ≠ NFC(name): //! before the v0.9.0 CLI/server merge — see docs/plan/bundled-binary.md § 1b.
//! - If no other row in the same `(folder_id, user_id)` already //! The 149-line body of `main()` moved here as `run_nfc_filenames()`
//! holds the NFC form → UPDATE the row's name to NFC. //! with `env::args()` parsing replaced by clap.
//! - If a collision exists with **same blob_hash**: trash the
//! newer of the two (`is_trashed = true`, `trashed_at = NOW()`).
//! User can restore from the trash UI if needed.
//! - If a collision exists with **different blob_hash**: rename
//! the newer row to `{nfc_name}.duplicate`, incrementing the
//! suffix (`.duplicate-1`, `.duplicate-2`, …) until a free name
//! is found. Preserves both files; user can inspect and resolve.
//! - In both collision cases, the surviving (older) row's name
//! is also normalized to NFC.
//! //!
//! Run: //! Future removal target: v1.0. Databases upgraded through v0.9.0
//! `cargo run --bin migrate-nfc-filenames -- --dry-run` //! will have run this migration (or been unaffected because they were
//! `cargo run --bin migrate-nfc-filenames` //! post-fix installs); by v1.0 no user should still need it. Drop
//! //! the `NfcFilenames` variant + this module's `run_nfc_filenames()`
//! Folder rows are NOT touched in this pass — trashing a folder //! function together at that point.
//! affects descendants; that pass is deferred to a follow-up.
use std::env;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use clap::Subcommand;
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use std::env;
use uuid::Uuid; use uuid::Uuid;
use oxicloud::domain::services::path_service::normalize_storage_name; use crate::domain::services::path_service::normalize_storage_name;
#[derive(Subcommand)]
pub enum Action {
/// NFC-normalize storage.files.name across the instance.
///
/// Historical cleanup for databases populated before June 2026.
/// New installs (post-`normalize_storage_name` write-time fix)
/// never need this — file operations already write NFC form.
///
/// Collision handling:
/// * No collision → UPDATE row name to NFC.
/// * Same blob content → trash the newer row.
/// * Different content → rename the newer to `{name}.duplicate[-N]`.
///
/// In all collision cases, the surviving (older) row's name is
/// also normalized to NFC.
NfcFilenames {
/// Print what would change without touching the DB.
#[arg(long)]
dry_run: bool,
},
}
pub async fn run(action: Action) -> u8 {
match action {
Action::NfcFilenames { dry_run } => run_nfc_filenames(dry_run).await,
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct FileRow { struct FileRow {
@@ -59,15 +79,22 @@ struct Stats {
renamed_duplicate: u64, renamed_duplicate: u64,
} }
#[tokio::main] async fn run_nfc_filenames(dry_run: bool) -> u8 {
async fn main() -> Result<(), Box<dyn std::error::Error>> { let database_url = match env::var("DATABASE_URL") {
let args: Vec<String> = env::args().collect(); Ok(v) => v,
let dry_run = args.iter().any(|a| a == "--dry-run"); Err(_) => {
eprintln!("migrate nfc-filenames: DATABASE_URL not set");
return 2;
}
};
let database_url = let pool = match PgPool::connect(&database_url).await {
env::var("DATABASE_URL").expect("DATABASE_URL must be set in the environment"); Ok(p) => p,
Err(e) => {
let pool = PgPool::connect(&database_url).await?; eprintln!("migrate nfc-filenames: failed to connect to database: {e}");
return 1;
}
};
println!( println!(
"=== NFC filename migration ({}) ===", "=== NFC filename migration ({}) ===",
@@ -79,7 +106,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
println!(); println!();
let rows = load_non_trashed_files(&pool).await?; let rows = match load_non_trashed_files(&pool).await {
Ok(r) => r,
Err(e) => {
eprintln!("migrate nfc-filenames: initial scan failed: {e}");
return 1;
}
};
println!("Loaded {} non-trashed file rows", rows.len()); println!("Loaded {} non-trashed file rows", rows.len());
println!(); println!();
@@ -98,7 +131,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Row is in non-NFC form. Look for a collision in the same // Row is in non-NFC form. Look for a collision in the same
// (folder_id, user_id) scope, including rows that may also // (folder_id, user_id) scope, including rows that may also
// be non-NFC but happen to normalize to the same NFC value. // be non-NFC but happen to normalize to the same NFC value.
let collision = find_collision(&pool, row, &nfc_name).await?; let collision = match find_collision(&pool, row, &nfc_name).await {
Ok(c) => c,
Err(e) => {
eprintln!(
"migrate nfc-filenames: collision query failed for {}: {e}",
row.id
);
return 1;
}
};
match collision { match collision {
None => { None => {
@@ -106,12 +148,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"NORMALIZE {} user={} '{}' → '{}'", "NORMALIZE {} user={} '{}' → '{}'",
row.id, row.user_id, row.name, nfc_name row.id, row.user_id, row.name, nfc_name
); );
if !dry_run { if !dry_run
sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2") && let Err(e) = sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2")
.bind(&nfc_name) .bind(&nfc_name)
.bind(row.id) .bind(row.id)
.execute(&pool) .execute(&pool)
.await?; .await
{
eprintln!("migrate nfc-filenames: rename failed for {}: {e}", row.id);
return 1;
} }
stats.normalized_in_place += 1; stats.normalized_in_place += 1;
} }
@@ -134,7 +179,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
&older.blob_hash[..16.min(older.blob_hash.len())] &older.blob_hash[..16.min(older.blob_hash.len())]
); );
if !dry_run { if !dry_run {
sqlx::query( if let Err(e) = sqlx::query(
"UPDATE storage.files "UPDATE storage.files
SET is_trashed = TRUE, SET is_trashed = TRUE,
trashed_at = NOW() trashed_at = NOW()
@@ -142,25 +187,60 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
) )
.bind(newer.id) .bind(newer.id)
.execute(&pool) .execute(&pool)
.await?; .await
normalize_survivor_name(&pool, older, &nfc_name).await?; {
eprintln!("migrate nfc-filenames: trash failed for {}: {e}", newer.id);
return 1;
}
if let Err(e) = normalize_survivor_name(&pool, older, &nfc_name).await {
eprintln!(
"migrate nfc-filenames: survivor rename failed for {}: {e}",
older.id
);
return 1;
}
} }
stats.deduped_same_content += 1; stats.deduped_same_content += 1;
} else { } else {
// Different content → rename newer to a free // Different content → rename newer to a free
// `{nfc_name}.duplicate[-N]`; promote older to NFC. // `{nfc_name}.duplicate[-N]`; promote older to NFC.
let disambiguated = find_free_duplicate_name(&pool, newer, &nfc_name).await?; let disambiguated = match find_free_duplicate_name(&pool, newer, &nfc_name)
.await
{
Ok(n) => n,
Err(e) => {
eprintln!(
"migrate nfc-filenames: duplicate-name search failed for {}: {e}",
newer.id
);
return 1;
}
};
println!( println!(
"RENAME newer={} (different blob) older={} '{}' → '{}'", "RENAME newer={} (different blob) older={} '{}' → '{}'",
newer.id, older.id, newer.name, disambiguated newer.id, older.id, newer.name, disambiguated
); );
if !dry_run { if !dry_run {
sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2") if let Err(e) =
.bind(&disambiguated) sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2")
.bind(newer.id) .bind(&disambiguated)
.execute(&pool) .bind(newer.id)
.await?; .execute(&pool)
normalize_survivor_name(&pool, older, &nfc_name).await?; .await
{
eprintln!(
"migrate nfc-filenames: disambiguation rename failed for {}: {e}",
newer.id
);
return 1;
}
if let Err(e) = normalize_survivor_name(&pool, older, &nfc_name).await {
eprintln!(
"migrate nfc-filenames: survivor rename failed for {}: {e}",
older.id
);
return 1;
}
} }
stats.renamed_duplicate += 1; stats.renamed_duplicate += 1;
} }
@@ -192,7 +272,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("DRY RUN — no rows were written. Re-run without --dry-run to apply."); println!("DRY RUN — no rows were written. Re-run without --dry-run to apply.");
} }
Ok(()) 0
} }
async fn load_non_trashed_files(pool: &PgPool) -> Result<Vec<FileRow>, Box<dyn std::error::Error>> { async fn load_non_trashed_files(pool: &PgPool) -> Result<Vec<FileRow>, Box<dyn std::error::Error>> {
+102
View File
@@ -0,0 +1,102 @@
//! Operator-tools subcommand tree.
//!
//! Dispatched from `src/main.rs` when the first positional arg matches
//! a known domain (`opaque`, `migrate`, `storage`). Bare `oxicloud` (or
//! oxicloud with legacy top-level flags like `--config`) falls through
//! to server startup — backwards compat with existing Docker CMD lines
//! and systemd units.
//!
//! History: this tree previously lived in a standalone `oxicloud-cli`
//! binary. Folded into the main `oxicloud` binary in v0.9.0 so the
//! release tarball ships one executable. Growth pattern preserved from
//! the old bin's header — see docs/plan/bundled-binary.md § 1b.
//!
//! ## Layout
//!
//! ```text
//! oxicloud <domain> <action> [flags]
//!
//! Domains:
//! opaque OPAQUE aPAKE substrate management
//! setup Print a fresh ServerSetup value for
//! OXICLOUD_AUTH_OPAQUE_SERVER_SETUP
//! reset Clear envelope(s) so silent-migration
//! re-mints under current KSF
//! migrate One-time data migrations
//! nfc-filenames NFC-normalize storage.files.name
//! (pre-June-2026 databases)
//! storage Storage-config repair + crypto helpers (was --select-storage
//! and --fingerprint before v0.9.0 CLI harmonization).
//! select Set the active storage-entry backend in DB
//! fingerprint Print SSH-style fingerprint of an AES-256 key
//! ```
//!
//! Growth pattern: each new domain gets its own module below (e.g.
//! `mod opaque`, `mod migrate`) with a `#[derive(Subcommand)]` enum
//! for its actions and a `run(action) -> u8` entrypoint. Keep
//! each module self-contained so a future extraction is a file move.
//!
//! ## Environment
//!
//! * `DATABASE_URL` — required by any subcommand that talks to the DB
//! (`opaque reset`, `migrate nfc-filenames`); not needed for pure
//! primitive helpers (`opaque setup`). Each subcommand documents its
//! own dependencies.
use clap::{Parser, Subcommand};
pub mod migrate;
pub mod opaque;
pub mod storage;
#[derive(Parser)]
#[command(
name = "oxicloud",
version,
about = "OxiCloud operator toolbox — subcommand entrypoint for \
operational tasks that don't belong in the main server \
binary. Run `oxicloud` (with no subcommand) to start the \
server."
)]
struct Cli {
#[command(subcommand)]
domain: Domain,
}
#[derive(Subcommand)]
enum Domain {
/// OPAQUE aPAKE substrate management (setup, reset).
Opaque {
#[command(subcommand)]
action: opaque::Action,
},
/// One-time data migrations (historical schema/data fixes).
Migrate {
#[command(subcommand)]
action: migrate::Action,
},
/// Storage-config repair + crypto helpers.
Storage {
#[command(subcommand)]
action: storage::Action,
},
}
/// Entrypoint called from `src/main.rs` after it detects a subcommand
/// on argv[1]. Builds a single-threaded tokio runtime — the operator
/// tools don't need multi-thread scheduling and starting a smaller
/// runtime keeps CLI invocations cheap.
pub fn run() -> u8 {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build tokio runtime for CLI");
rt.block_on(async {
let cli = Cli::parse();
match cli.domain {
Domain::Opaque { action } => opaque::run(action).await,
Domain::Migrate { action } => migrate::run(action).await,
Domain::Storage { action } => storage::run(action).await,
}
})
}
+223
View File
@@ -0,0 +1,223 @@
//! `opaque` subcommand domain — OPAQUE aPAKE substrate management.
//!
//! Two actions today:
//! * `setup` — mint a fresh ServerSetup for
//! `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`. Deployment-time one-off.
//! * `reset` — clear envelope columns so silent-migration re-mints them
//! under the current KSF. Used after KSF rotation.
//!
//! Previously lived in `src/bin/oxicloud-cli.rs::mod opaque` before the
//! v0.9.0 CLI/server merge — see docs/plan/bundled-binary.md § 1b.
//! Behaviour is identical; the only change is the invocation form
//! (`oxicloud opaque <action>` instead of `oxicloud-cli opaque <action>`).
use std::env;
use clap::Subcommand;
use sqlx::{PgPool, Row};
use crate::infrastructure::services::opaque_service::OpaqueService;
#[derive(Subcommand)]
pub enum Action {
/// Generate a fresh OPAQUE ServerSetup and print its base64
/// encoding to stdout. Guidance goes to stderr so shell
/// pipelines capture cleanly.
///
/// Run ONCE per deployment; persist the printed value as
/// `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`. Rotating this value
/// invalidates every user's OPAQUE registration — treat it
/// like your JWT secret.
Setup,
/// Clear the OPAQUE envelope for one user or all users
/// WITHOUT touching password or setting force_password_change.
///
/// Use case: KSF rotation. If you change
/// OXICLOUD_AUTH_OPAQUE_KSF_* values, existing envelopes
/// become cryptographically incompatible with the newly
/// published KSF — logins fail with InvalidCredentials.
/// Nulling the envelope columns forces the SPA's `/lookup`
/// to report `hasOpaque: false`, which routes the next login
/// through legacy `/api/auth/login`; silent-migration then
/// mints a fresh envelope under the CURRENT KSF. Passwords
/// are unchanged.
///
/// NOT for forgotten-passphrase recovery — use the admin
/// password-reset endpoint (`PUT /api/admin/users/{id}/password`)
/// which sets a temp password + force_change flag in one shot.
Reset {
/// Email OR username to reset (dispatched on `@` presence,
/// same rule as `POST /api/auth/login`).
#[arg(long, conflicts_with = "all")]
user: Option<String>,
/// Reset every user with an OPAQUE envelope.
#[arg(long, conflicts_with = "user")]
all: bool,
/// Print what would change without touching the DB.
#[arg(long)]
dry_run: bool,
},
}
pub async fn run(action: Action) -> u8 {
match action {
Action::Setup => run_setup(),
Action::Reset { user, all, dry_run } => run_reset(user, all, dry_run).await,
}
}
fn run_setup() -> u8 {
// Match the legacy `opaque-setup` bin's contract:
// - value on stdout, no trailing commentary (pipeline-safe)
// - guidance on stderr
let b64 = OpaqueService::generate_server_setup_b64();
println!("{b64}");
eprintln!();
eprintln!("=== OPAQUE server setup generated. ===");
eprintln!("Persist the line above in OXICLOUD_AUTH_OPAQUE_SERVER_SETUP.");
eprintln!("NEVER rotate: rotating invalidates every user's registration.");
eprintln!("Treat this value like your JWT secret.");
0
}
async fn run_reset(user: Option<String>, all: bool, dry_run: bool) -> u8 {
// clap enforces `conflicts_with`, but not "at least one of".
// Belt-and-braces check here so the failure is explicit.
if user.is_none() && !all {
eprintln!("opaque reset: pass either --user <id> or --all");
return 2;
}
let database_url = match env::var("DATABASE_URL") {
Ok(v) => v,
Err(_) => {
eprintln!("opaque reset: DATABASE_URL not set");
return 2;
}
};
let pool = match PgPool::connect(&database_url).await {
Ok(p) => p,
Err(e) => {
eprintln!("opaque reset: failed to connect to database: {e}");
return 1;
}
};
// Preview the affected row set before writing. Doubles as
// dry-run output and as diagnostics when --user matches nothing.
// Envelope-presence bool lets the operator see which rows had
// an envelope vs which only carry a stale migration mark.
let select_sql = if all {
r#"
SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope
FROM auth.users
WHERE opaque_envelope IS NOT NULL
OR opaque_migrated_at IS NOT NULL
ORDER BY email
"#
} else {
r#"
SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope
FROM auth.users
WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END
"#
};
let rows_result = if all {
sqlx::query(select_sql).fetch_all(&pool).await
} else {
let ident = user.as_deref().unwrap();
sqlx::query(select_sql).bind(ident).fetch_all(&pool).await
};
let rows = match rows_result {
Ok(r) => r,
Err(e) => {
eprintln!("opaque reset: query failed: {e}");
return 1;
}
};
if rows.is_empty() {
if all {
println!("opaque reset: no users have an OPAQUE envelope — nothing to do.");
return 0;
} else {
eprintln!(
"opaque reset: no user matches --user {} — nothing changed.",
user.as_deref().unwrap_or("")
);
return 1;
}
}
println!(
"opaque reset ({}): {} row(s) to affect",
if dry_run {
"DRY RUN — no writes"
} else {
"EXECUTING"
},
rows.len()
);
for row in &rows {
let id: uuid::Uuid = row.get("id");
let email: String = row.get("email");
let had_envelope: bool = row.get("had_envelope");
println!(
" {} {} {}",
id,
email,
if had_envelope {
"had-envelope"
} else {
"no-envelope-had-migrated-mark"
}
);
}
if dry_run {
return 0;
}
// Actual UPDATE. Kept identical in shape to the SELECT above so
// the planner sees the same query pattern for both. We
// DELIBERATELY do NOT touch password_hash or
// force_password_change_at_next_login — this tool is scoped
// to "the passwords are fine, the envelopes are stale."
let update_sql_all = r#"
UPDATE auth.users
SET opaque_envelope = NULL,
opaque_ciphersuite_version = NULL,
opaque_registered_at = NULL,
opaque_migrated_at = NULL
WHERE opaque_envelope IS NOT NULL
OR opaque_migrated_at IS NOT NULL
"#;
let update_sql_one = r#"
UPDATE auth.users
SET opaque_envelope = NULL,
opaque_ciphersuite_version = NULL,
opaque_registered_at = NULL,
opaque_migrated_at = NULL
WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END
"#;
let write_result = if all {
sqlx::query(update_sql_all).execute(&pool).await
} else {
let ident = user.as_deref().unwrap();
sqlx::query(update_sql_one).bind(ident).execute(&pool).await
};
let affected = match write_result {
Ok(r) => r.rows_affected(),
Err(e) => {
eprintln!("opaque reset: update failed: {e}");
return 1;
}
};
println!(
"opaque reset: cleared envelope columns on {affected} row(s). \
Users log in with their existing password; silent-migration \
re-mints envelopes under the current KSF on next login."
);
0
}
+157
View File
@@ -0,0 +1,157 @@
//! `storage` subcommand domain — storage-config repair + crypto helpers.
//!
//! Two actions today:
//! * `select <name>` — set `admin_settings.storage.active_backend_name`
//! in the DB to the named entry and exit. Used to unblock boot after
//! renaming or removing a storage entry in `.env` while the DB still
//! points at the old name (the server aborts boot with a pointer to
//! this subcommand when that happens). See
//! `docs/plan/storage-multi-entry.md` § Fallback.
//! * `fingerprint <base64key|->` — print the SSH-style colon-hex
//! fingerprint of a base64-encoded AES-256 key. Matches the
//! `head_key_fp` field the `backend_rotate` job reports on completion
//! and the raw `<key_fp>` field embedded in every v1 blob header — so
//! an admin can pair a key in `OXICLOUD_STORAGE_<N>_ENCRYPTION_KEY`
//! with the current on-disk head and safely drop any key whose
//! fingerprint does NOT match the last-successful rotate.
//!
//! Both actions previously lived as top-level flags (`--select-storage`,
//! `--fingerprint`) on the `oxicloud` binary. Moved into the subcommand
//! tree in v0.9.0 for CLI consistency — see docs/plan/bundled-binary.md
//! § 1c. Behaviour is identical.
use std::env;
use std::io::Read;
use clap::Subcommand;
use crate::common::config::{AppConfig, fingerprint_from_base64_key};
use crate::infrastructure::services::entry_backend::persist_active_backend_name;
#[derive(Subcommand)]
pub enum Action {
/// Select the active storage-entry backend. Writes
/// `admin_settings.storage.active_backend_name = <name>` in the DB
/// and exits. Does NOT boot the server. Use to recover from the
/// "boot fails on missing entry" case after renaming or removing a
/// storage entry in `.env`.
///
/// The named entry MUST appear in `OXICLOUD_STORAGE_ENTRIES` — this
/// subcommand re-parses the same env the server would parse at boot,
/// so a successful run guarantees the subsequent boot will find the
/// entry (no drift between the two code paths).
Select {
/// Storage-entry name (must appear in OXICLOUD_STORAGE_ENTRIES).
name: String,
},
/// Print the SSH-style colon-hex fingerprint (16-hex, 8-byte
/// truncation of sha256) of a base64-encoded AES-256 key.
///
/// Matches the `head_key_fp` field the `backend_rotate` job reports
/// on completion, and the raw `<key_fp>` field embedded in every v1
/// blob header. Used to identify which key in
/// `OXICLOUD_STORAGE_<N>_ENCRYPTION_KEY` corresponds to the current
/// on-disk head — safe to drop any key whose fingerprint does NOT
/// match the last-successful rotate's `head_key_fp`.
///
/// Pass `-` to read the key from stdin so it never touches shell
/// history:
///
/// ```text
/// echo -n '<base64>' | oxicloud storage fingerprint -
/// ```
Fingerprint {
/// Base64-encoded AES-256 key, or `-` to read the key from stdin.
key: String,
},
}
pub async fn run(action: Action) -> u8 {
match action {
Action::Select { name } => run_select(&name).await,
Action::Fingerprint { key } => run_fingerprint(&key),
}
}
/// Verify `name` is declared in the current env, UPDATE
/// `admin_settings.storage.active_backend_name`, exit.
///
/// Loading AppConfig here re-runs the same env-parse the server does
/// at boot, so a successful `storage select` guarantees a subsequent
/// normal boot will find the entry — no drift between the two code
/// paths.
async fn run_select(name: &str) -> u8 {
let config = AppConfig::from_env();
if config.storage_entries.is_empty() {
eprintln!(
"OXICLOUD_STORAGE_ENTRIES is not set (or synthesised — legacy path). \
`storage select` needs at least one named entry to switch to."
);
return 2;
}
if !config.storage_entries.iter().any(|e| e.name == name) {
let available = config
.storage_entries
.iter()
.map(|e| e.name.as_str())
.collect::<Vec<_>>()
.join(", ");
eprintln!(
"entry `{name}` is not declared in OXICLOUD_STORAGE_ENTRIES. \
Available: [{available}]"
);
return 2;
}
let db_url = match env::var("DATABASE_URL") {
Ok(v) => v,
Err(_) => {
eprintln!(
"DATABASE_URL not set — `storage select` needs the same DB the server \
would boot on"
);
return 2;
}
};
let pool = match sqlx::PgPool::connect(&db_url).await {
Ok(p) => p,
Err(e) => {
eprintln!("failed to connect to DATABASE_URL: {e}");
return 1;
}
};
if let Err(e) = persist_active_backend_name(&pool, name).await {
eprintln!("failed to write admin_settings.storage.active_backend_name = `{name}`: {e}");
return 1;
}
println!(
"active_backend_name = `{name}` written to admin_settings. Restart the server to switch."
);
0
}
fn run_fingerprint(key: &str) -> u8 {
let key_b64 = if key == "-" {
let mut buf = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
eprintln!("failed to read key from stdin: {e}");
return 2;
}
buf.trim().to_string()
} else {
key.to_string()
};
match fingerprint_from_base64_key(&key_b64) {
Ok(fp) => {
println!("{fp}");
0
}
Err(e) => {
eprintln!("storage fingerprint: {e}");
2
}
}
}
+3 -3
View File
@@ -512,7 +512,7 @@ impl KeyPair {
/// truncation as the v1 header's `<key_fp>` field and the /// truncation as the v1 header's `<key_fp>` field and the
/// `head_key_fp` reported by `backend_rotate` on completion, so /// `head_key_fp` reported by `backend_rotate` on completion, so
/// operators can cross-reference the boot log against a rotate /// operators can cross-reference the boot log against a rotate
/// report or the CLI's `oxicloud --fingerprint <base64key>` /// report or the CLI's `oxicloud storage fingerprint <base64key>`
/// output without any format conversion. /// output without any format conversion.
/// ///
/// Returns `None` for `CipherKind::None` (nothing to /// Returns `None` for `CipherKind::None` (nothing to
@@ -723,7 +723,7 @@ pub fn parse_encryption_pair_list(entry_name: &str, raw: &str) -> Result<Vec<Key
/// with the same base64 / length validation the pair-list parser /// with the same base64 / length validation the pair-list parser
/// uses, so callers don't have to reimplement it. /// uses, so callers don't have to reimplement it.
/// ///
/// Used by the `oxicloud --fingerprint <base64>` CLI subcommand so /// Used by the `oxicloud storage fingerprint <base64>` CLI subcommand so
/// admins can identify which key in their `.env` corresponds to the /// admins can identify which key in their `.env` corresponds to the
/// `head_key_fp` a `backend_rotate` run reported on completion — /// `head_key_fp` a `backend_rotate` run reported on completion —
/// see `docs/plan/storage-key-rotation.md`. /// see `docs/plan/storage-key-rotation.md`.
@@ -4330,7 +4330,7 @@ mod tests {
// SSH-style 8-byte colon-hex (16 hex + 7 colons = 23 chars) // SSH-style 8-byte colon-hex (16 hex + 7 colons = 23 chars)
// so operators can cross-reference against the v1 header's // so operators can cross-reference against the v1 header's
// `<key_fp>` field + `backend_rotate`'s `head_key_fp` // `<key_fp>` field + `backend_rotate`'s `head_key_fp`
// output + the `oxicloud --fingerprint` CLI. // output + the `oxicloud storage fingerprint` CLI.
let pairs = let pairs =
parse_encryption_pair_list("t", &format!("aes-256-gcm:{K1_B64},none:")).unwrap(); parse_encryption_pair_list("t", &format!("aes-256-gcm:{K1_B64},none:")).unwrap();
let fp0 = pairs[0].fingerprint_short().unwrap(); let fp0 = pairs[0].fingerprint_short().unwrap();
+1 -1
View File
@@ -313,7 +313,7 @@ impl AppServiceFactory {
tracing::info!( tracing::info!(
"Storage: no active_backend_name set in DB — defaulting to first entry \ "Storage: no active_backend_name set in DB — defaulting to first entry \
`{}` (declared first in OXICLOUD_STORAGE_ENTRIES). Set explicitly via \ `{}` (declared first in OXICLOUD_STORAGE_ENTRIES). Set explicitly via \
the admin storage tab or `oxicloud --select-storage <name>` to pin.", the admin storage tab or `oxicloud storage select <name>` to pin.",
first.name, first.name,
); );
first first
@@ -894,14 +894,14 @@ impl BackendMigrationService {
source_missing = source_missing, source_missing = source_missing,
"🛑 backend_migration aborted — {failed} blob(s) failed, active backend left at \ "🛑 backend_migration aborted — {failed} blob(s) failed, active backend left at \
`{previous_active}`, readonly cleared. Inspect findings and retry, or accept \ `{previous_active}`, readonly cleared. Inspect findings and retry, or accept \
the partial migration via `oxicloud --select-storage {target_name}`." the partial migration via `oxicloud storage select {target_name}`."
); );
return RunOutcome::Failed { return RunOutcome::Failed {
message: format!( message: format!(
"{failed} blob(s) failed to migrate — active backend NOT switched \ "{failed} blob(s) failed to migrate — active backend NOT switched \
(still `{previous_active}`). Retry the run (short-circuits on already-copied \ (still `{previous_active}`). Retry the run (short-circuits on already-copied \
blobs) or accept the partial migration manually via \ blobs) or accept the partial migration manually via \
`oxicloud --select-storage {target_name}`." `oxicloud storage select {target_name}`."
), ),
}; };
} }
+1 -1
View File
@@ -168,7 +168,7 @@ pub async fn resolve_active_entry<'a>(
"auth.admin_settings.storage.active_backend_name = `{name}`, but no entry \ "auth.admin_settings.storage.active_backend_name = `{name}`, but no entry \
with that name is declared in OXICLOUD_STORAGE_ENTRIES. Available: [{available}]. \ with that name is declared in OXICLOUD_STORAGE_ENTRIES. Available: [{available}]. \
Either add `{name}` back to your .env, or repair the DB pointer with:\n \ Either add `{name}` back to your .env, or repair the DB pointer with:\n \
oxicloud --select-storage <one-of-the-available-names>" oxicloud storage select <one-of-the-available-names>"
)) ))
} }
}, },
+164
View File
@@ -0,0 +1,164 @@
//! Compile-time-embedded static assets — the `bundled-assets` feature.
//!
//! When the feature is on, the SvelteKit build output at `static-dist/`
//! (repo root, per `frontend/svelte.config.js`'s `adapter-static`) is
//! baked into the binary via `rust-embed` at compile time. The two axum
//! handlers below (`serve_root` for the SPA + fallback, `serve_immutable`
//! for the `_app/immutable` cache-forever subtree) parallel the two
//! `ServeDir` instances the filesystem path uses in `super::mod`.
//!
//! **Precedence** — this module is only invoked when
//! `resolve_static_source(config)` returns `StaticSource::Embedded`. If
//! `OXICLOUD_STATIC_PATH` (or the default `./static/static-dist/`)
//! points at a real directory, the filesystem `ServeDir` path is used
//! instead — ops can still override embedded bytes for locale patches
//! or theming without a full rebuild.
//!
//! **Compression** — `rust-embed`'s `compression` feature stores each
//! embedded file deflate-compressed. Lazy decompression on first access
//! keeps the binary small (~4-5 MB for the current corpus) and the
//! runtime cost negligible: after warmup every file is cached. Response
//! compression is handled by `CompressionLayer` in the parent module —
//! same wire behaviour as the filesystem path for `Content-Encoding:
//! br|gzip|identity` clients.
//!
//! **Vite's precompressed siblings** (`.br` / `.gz`) are excluded from
//! the embed via the `#[exclude]` attributes below — they'd be dead
//! weight because the response compression on the wire already handles
//! this negotiation.
use axum::body::Body;
use axum::extract::{Path, Request};
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
/// SvelteKit SPA build output, baked into the binary at compile time.
///
/// The `#[folder]` path is relative to `Cargo.toml` (repo root), which
/// matches SvelteKit's `adapter-static` output in
/// `frontend/svelte.config.js` (`pages: '../static-dist'`).
#[derive(rust_embed::RustEmbed)]
#[folder = "static-dist/"]
// Default (no `include` attr) = include everything recursively. An
// explicit `include = "*"` was WRONG — the `*` glob is single-segment
// only, so `locales/*.json`, `_app/immutable/**`, and every other
// subdirectory got excluded and the boot-time `extract_embedded_locales`
// found 0 files (2026-08-28 bug fix).
//
// Vite's precompressed siblings — we serve raw and let the axum
// `CompressionLayer` on the wire negotiate br/gzip. Doubling storage
// would balloon the embed by ~50%.
#[exclude = "**/*.br"]
#[exclude = "**/*.gz"]
pub struct EmbeddedAssets;
/// Serve any embedded asset by request path, falling back to the SPA
/// shell (`index.html`) for unmatched client routes.
///
/// Mirror of the `spa` `ServeDir` in `super::create_web_routes` — same
/// fallback semantics so deep links like `/files/<id>` boot the SvelteKit
/// router. `Cache-Control` for the shell itself is left to the outer
/// layer in the parent module (`no-cache` so a deploy can't leave a
/// stale app pinned in browsers); assets carrying no cache header here
/// pick up the parent's default the same way filesystem-served assets do.
///
/// Wired as axum's `fallback` in `super::web_routes_embedded`, which
/// means there is NO route pattern to capture from — a `Path` extractor
/// would fail at runtime with "Wrong number of path arguments for
/// `Path`. Expected 1 but got 0." (real bug hit 2026-08-28). Pull the
/// URI path off the `Request` directly instead.
pub async fn serve_root(req: Request) -> Response {
let path = req.uri().path().trim_start_matches('/');
if path.is_empty() {
return spa_shell_response();
}
match EmbeddedAssets::get(path) {
Some(file) => asset_response(path, file.data),
None => spa_shell_response(),
}
}
/// Root path (no trailing capture) — always the SPA shell.
///
/// axum routes `/` separately from `/*path`, so this handles the
/// bare-slash case that `serve_root` never sees.
pub async fn serve_root_index() -> Response {
spa_shell_response()
}
/// Serve an asset under the `/_app/immutable/*` prefix. The nested route
/// registration in `super::create_web_routes` already strips the
/// `/_app/immutable/` prefix from the captured path, so we look the
/// stripped path up with the prefix re-attached before hitting the embed.
///
/// Cache-Control (`public, max-age=31536000, immutable`) is applied by
/// the outer `SetResponseHeaderLayer::overriding` in the parent module,
/// same as the filesystem path — this handler just returns bytes + MIME.
pub async fn serve_immutable(Path(path): Path<String>) -> Response {
let full = format!("_app/immutable/{}", path.trim_start_matches('/'));
match EmbeddedAssets::get(&full) {
Some(file) => asset_response(&path, file.data),
None => (StatusCode::NOT_FOUND, "Not Found").into_response(),
}
}
fn asset_response(path: &str, bytes: std::borrow::Cow<'static, [u8]>) -> Response {
let mime = mime_guess::from_path(path).first_or_octet_stream();
let mut resp = Response::new(Body::from(bytes.into_owned()));
resp.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref())
.unwrap_or(HeaderValue::from_static("application/octet-stream")),
);
resp
}
fn spa_shell_response() -> Response {
match EmbeddedAssets::get("index.html") {
Some(shell) => {
let mut resp = Response::new(Body::from(shell.data.into_owned()));
resp.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
// Belt: the parent module also stamps this on unset,
// but stamp it here too so the shell never accidentally
// ends up cacheable in front of a deploy.
resp.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
resp
}
None => {
// This means `static-dist/` was empty at build time — the
// build.rs guard should have prevented us from ever getting
// here. Surface as 500 rather than pretending the SPA works.
(
StatusCode::INTERNAL_SERVER_ERROR,
"SPA shell missing from embedded assets; rebuild with an up-to-date static-dist/",
)
.into_response()
}
}
}
/// Iterate over the embedded `.html` files at the root of `static-dist/`
/// so the CSP inline-script scanner can hash them without a filesystem
/// read. Mirrors what `std::fs::read_dir(static_path)` yields on the
/// filesystem path, filtered to `.html` at the top level.
pub fn embedded_html_shells() -> Vec<(String, std::borrow::Cow<'static, [u8]>)> {
EmbeddedAssets::iter()
.filter(|p| {
// Root-level `.html` only — SvelteKit emits `index.html` at
// the root and everything else under `_app/`. Nested `.html`
// (e.g. sourcemap tooling artefacts) doesn't inline-script,
// so skip.
let s: &str = p.as_ref();
s.ends_with(".html") && !s.contains('/')
})
.filter_map(|p| {
let name = p.to_string();
EmbeddedAssets::get(&name).map(|f| (name, f.data))
})
.collect()
}
+214 -52
View File
@@ -15,23 +15,105 @@ use tower_http::compression::CompressionLayer;
use tower_http::services::{ServeDir, ServeFile}; use tower_http::services::{ServeDir, ServeFile};
use tower_http::set_header::SetResponseHeaderLayer; use tower_http::set_header::SetResponseHeaderLayer;
/// Resolve the directory the SPA is actually served from. #[cfg(feature = "bundled-assets")]
pub mod embedded;
/// Where the SPA + immutable assets are served from — filesystem
/// (default and also the fallback on bundled builds when
/// `OXICLOUD_STATIC_PATH` points at real files) or the compile-time
/// embedded corpus (bundled-assets feature only).
/// ///
/// Prefers the Vite build output (`static-dist/`) sitting next to the configured /// Returned by [`resolve_static_source`]; matched at each of the four
/// static path, falling back to the configured path itself — the container ships /// consumer sites (SPA `ServeDir`, `_app/immutable` `ServeDir`,
/// the built SPA straight to `OXICLOUD_STATIC_PATH` (default `./static`), so there /// CSP inline-script scan, and the locale-loader picker in `main.rs`).
/// the fallback is what serves. Shared with the CSP layer in `main.rs` so the #[derive(Debug, Clone)]
/// inline-script hashes are computed from exactly the bytes that get served. pub enum StaticSource {
pub fn resolve_static_path(config: &AppConfig) -> PathBuf { Filesystem(PathBuf),
/// Serve from the `EmbeddedAssets` corpus in the [`embedded`] module.
/// Only reachable under `--features bundled-assets` — the variant is
/// cfg-gated so match arms in non-bundled builds stay exhaustive on
/// a single variant, giving zero runtime cost.
#[cfg(feature = "bundled-assets")]
Embedded,
}
/// Resolve where static assets come from.
///
/// Order of precedence (highest first):
/// 1. `<OXICLOUD_STATIC_PATH>/../static-dist/` when it exists — matches
/// the SvelteKit adapter-static output at the repo root.
/// 2. `OXICLOUD_STATIC_PATH` itself when it exists — Docker image path
/// (assets copied straight to `/app/static/`).
/// 3. Bundled-assets fallback (only when the feature is on) — the
/// compile-time embedded corpus.
/// 4. Non-bundled fallback — return the configured path anyway, letting
/// downstream `ServeDir` fail predictably at request time.
///
/// Rule (2) exists so ops running a bundled binary can still point
/// `OXICLOUD_STATIC_PATH` at a live directory (locale patch, theme
/// override) and see it win over the embedded copy without a rebuild.
pub fn resolve_static_source(config: &AppConfig) -> StaticSource {
let dist = config let dist = config
.static_path .static_path
.parent() .parent()
.unwrap_or(Path::new(".")) .unwrap_or(Path::new("."))
.join("static-dist"); .join("static-dist");
if dist.exists() { if dist.exists() {
return dist; tracing::info!(
source = %dist.display(),
"static-assets: serving from filesystem (Vite build output at <static>/../static-dist/)"
);
return StaticSource::Filesystem(dist);
}
if config.static_path.exists() {
tracing::info!(
source = %config.static_path.display(),
"static-assets: serving from filesystem (OXICLOUD_STATIC_PATH)"
);
return StaticSource::Filesystem(config.static_path.clone());
}
#[cfg(feature = "bundled-assets")]
{
tracing::info!(
configured_static_path = %config.static_path.display(),
"static-assets: no filesystem source found, serving embedded corpus \
(bundled-assets feature). Set OXICLOUD_STATIC_PATH to override with a \
live directory."
);
StaticSource::Embedded
}
#[cfg(not(feature = "bundled-assets"))]
{
// Non-bundled build with no on-disk source. Return the configured
// path anyway — downstream `ServeDir` will fail predictably at
// request time. A separate boot-time warning wouldn't help; the
// real fix is to build the SPA or set OXICLOUD_STATIC_PATH.
StaticSource::Filesystem(config.static_path.clone())
}
}
/// Backwards-compat helper: resolve to a `PathBuf` directly.
///
/// Preserved for callers that predate the `StaticSource` enum. Only
/// callable in configurations where a filesystem path exists — a
/// bundled build whose `resolve_static_source` returned `Embedded`
/// would panic here, so new code should always match on
/// `resolve_static_source(...)` instead.
pub fn resolve_static_path(config: &AppConfig) -> PathBuf {
match resolve_static_source(config) {
StaticSource::Filesystem(p) => p,
#[cfg(feature = "bundled-assets")]
StaticSource::Embedded => {
// Every migrated caller matches on StaticSource directly;
// this branch means someone called the legacy helper from
// a bundled build. Fix the caller, not the shim.
panic!(
"resolve_static_path() called on a bundled build with no filesystem \
assets — migrate the caller to resolve_static_source() and match on \
StaticSource::Embedded"
)
}
} }
config.static_path.clone()
} }
/// Serves the SvelteKit single-page app. /// Serves the SvelteKit single-page app.
@@ -44,43 +126,33 @@ pub fn resolve_static_path(config: &AppConfig) -> PathBuf {
/// Caching: content-hashed assets under `/_app/immutable` are cached forever; /// Caching: content-hashed assets under `/_app/immutable` are cached forever;
/// everything else — crucially the `index.html` shell — is `no-cache` so a deploy /// everything else — crucially the `index.html` shell — is `no-cache` so a deploy
/// can't leave a stale app pinned in browsers. /// can't leave a stale app pinned in browsers.
pub fn create_web_routes(app_state: Arc<AppState>) -> Router<Arc<AppState>> { pub fn create_web_routes(app_state: Arc<AppState>, source: StaticSource) -> Router<Arc<AppState>> {
let config = AppConfig::from_env(); // `source` is resolved ONCE at boot in `main.rs::run()` and passed
let static_path = resolve_static_path(&config); // in — see the sequence there. Previously this fn called
// `AppConfig::from_env()` + `resolve_static_source(&config)` itself
// (duplicating the env parse + storage-summary log). Threading the
// resolved value through as an arg keeps this fn pure and eliminates
// both duplicates in the boot log (bug fixed 2026-08-28).
// SPA fallback: serve the file if it exists, else the app shell. // Build the router — two shapes depending on `StaticSource`, but both
// // wear the SAME outer layers below (compression fallback, no-cache
// `precompressed_*`: if the frontend build emitted a sibling `.br`/`.gz` // default for the shell, OIDC login short-circuit). Keeping the
// (frontend/scripts/precompress.mjs runs at build time), serve those // layers common means the filesystem and embedded paths behave
// bytes directly with the right Content-Encoding instead of re-running // identically at the wire boundary.
// Brotli over the same immutable bundle on EVERY request — the let inner = match source {
// `CompressionLayer` below then skips the already-encoded response and StaticSource::Filesystem(static_path) => web_routes_filesystem(&static_path),
// remains only the fallback for assets without a precompressed sibling #[cfg(feature = "bundled-assets")]
// (benches/STATIC-PRECOMPRESSED.md). StaticSource::Embedded => web_routes_embedded(),
let spa = ServeDir::new(&static_path) };
.precompressed_br()
.precompressed_gzip()
.fallback(ServeFile::new(static_path.join("index.html")));
// Hashed, immutable assets (SvelteKit emits these under /_app/immutable). inner
let app_immutable = ServeDir::new(static_path.join("_app").join("immutable")) // Fallback compression for assets without a precompressed sibling
.precompressed_br() // (filesystem) or for embedded assets that were compressed at
.precompressed_gzip(); // compile time and decompressed on read (bundled). Quality 4,
// NOT the default: the default maps to Brotli q11 — ~1.3 s of
Router::new() // CPU per 700 KiB bundle per request (benches/STATIC-PRECOMPRESSED.md;
.nest_service( // the .br siblings on the filesystem path carry the real q11
"/_app/immutable", // bytes, paid once at build time).
get_service(app_immutable).layer(SetResponseHeaderLayer::overriding(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
)),
)
.fallback_service(spa)
// Fallback compression for assets without a precompressed sibling.
// Quality 4, NOT the default: the default maps to Brotli q11 —
// ~1.3 s of CPU per 700 KiB bundle per request (measured in
// benches/STATIC-PRECOMPRESSED.md; the .br siblings above carry the
// real q11 bytes, paid once at build time).
.layer( .layer(
CompressionLayer::new() CompressionLayer::new()
.quality(tower_http::CompressionLevel::Precise(4)) .quality(tower_http::CompressionLevel::Precise(4))
@@ -105,6 +177,58 @@ pub fn create_web_routes(app_state: Arc<AppState>) -> Router<Arc<AppState>> {
)) ))
} }
/// Filesystem-served SPA — the historical shape. Two `ServeDir` instances
/// with tower-http's `precompressed_br().precompressed_gzip()` picking up
/// Vite's precompressed siblings when present.
fn web_routes_filesystem(static_path: &Path) -> Router<Arc<AppState>> {
// SPA fallback: serve the file if it exists, else the app shell.
//
// `precompressed_*`: if the frontend build emitted a sibling `.br`/`.gz`
// (frontend/scripts/precompress.mjs runs at build time), serve those
// bytes directly with the right Content-Encoding instead of re-running
// Brotli over the same immutable bundle on EVERY request — the
// `CompressionLayer` in `create_web_routes` then skips the already-encoded
// response and remains only the fallback for assets without a
// precompressed sibling (benches/STATIC-PRECOMPRESSED.md).
let spa = ServeDir::new(static_path)
.precompressed_br()
.precompressed_gzip()
.fallback(ServeFile::new(static_path.join("index.html")));
// Hashed, immutable assets (SvelteKit emits these under /_app/immutable).
let app_immutable = ServeDir::new(static_path.join("_app").join("immutable"))
.precompressed_br()
.precompressed_gzip();
Router::new()
.nest_service(
"/_app/immutable",
get_service(app_immutable).layer(SetResponseHeaderLayer::overriding(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
)),
)
.fallback_service(spa)
}
/// Embedded-assets SPA — mirror of `web_routes_filesystem` using the
/// [`embedded`] module's handlers instead of `ServeDir`. Same URL shape,
/// same cache-header layers, same SPA-shell fallback semantics.
#[cfg(feature = "bundled-assets")]
fn web_routes_embedded() -> Router<Arc<AppState>> {
use axum::routing::get;
Router::new()
.route(
"/_app/immutable/{*path}",
get(embedded::serve_immutable).layer(SetResponseHeaderLayer::overriding(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
)),
)
.route("/", get(embedded::serve_root_index))
.fallback(get(embedded::serve_root))
}
/// Intercept `GET /login` and 302 to `/api/auth/oidc/authorize` when OIDC is /// Intercept `GET /login` and 302 to `/api/auth/oidc/authorize` when OIDC is
/// the only working method (see `AuthApplicationService::auto_redirect_to_oidc`). /// the only working method (see `AuthApplicationService::auto_redirect_to_oidc`).
/// ///
@@ -161,15 +285,31 @@ async fn oidc_standalone_login_redirect(
/// web worker from a blob URL; `'self'` covers same-origin workers like the /// web worker from a blob URL; `'self'` covers same-origin workers like the
/// delta-upload worker. /// delta-upload worker.
pub fn content_security_policy(config: &AppConfig) -> String { pub fn content_security_policy(config: &AppConfig) -> String {
let static_path = resolve_static_path(config); let source = resolve_static_source(config);
let hashes = inline_script_csp_hashes(&static_path); let hashes = match &source {
StaticSource::Filesystem(p) => inline_script_csp_hashes(p),
#[cfg(feature = "bundled-assets")]
StaticSource::Embedded => inline_script_csp_hashes_embedded(),
};
if hashes.is_empty() { if hashes.is_empty() {
tracing::warn!( match &source {
static_path = %static_path.display(), StaticSource::Filesystem(p) => {
"CSP: no inline <script> hashes computed — if the SPA shell ships \ tracing::warn!(
inline scripts they will be blocked by script-src 'self'. Check the \ static_path = %p.display(),
static asset path (OXICLOUD_STATIC_PATH)." "CSP: no inline <script> hashes computed — if the SPA shell ships \
); inline scripts they will be blocked by script-src 'self'. Check the \
static asset path (OXICLOUD_STATIC_PATH)."
);
}
#[cfg(feature = "bundled-assets")]
StaticSource::Embedded => {
tracing::warn!(
"CSP: no inline <script> hashes computed from embedded corpus — \
the SPA shell may boot with a blocked script-src. Rebuild with \
an up-to-date static-dist/."
);
}
}
} }
// `'wasm-unsafe-eval'` is required for WebAssembly compilation/instantiation // `'wasm-unsafe-eval'` is required for WebAssembly compilation/instantiation
@@ -232,6 +372,28 @@ fn inline_script_csp_hashes(static_path: &Path) -> Vec<String> {
hashes.into_iter().collect() hashes.into_iter().collect()
} }
/// Embedded-corpus twin of [`inline_script_csp_hashes`].
///
/// Same arithmetic — iterate root-level `.html` shells, extract every
/// inline `<script>`, hash each — but pulls bytes from
/// [`embedded::EmbeddedAssets`] instead of the filesystem. The two
/// functions produce identical output for the same source tree, so
/// `content_security_policy` can pick either without callers seeing a
/// difference.
#[cfg(feature = "bundled-assets")]
fn inline_script_csp_hashes_embedded() -> Vec<String> {
let mut hashes = BTreeSet::new();
for (_name, bytes) in embedded::embedded_html_shells() {
let Ok(html) = std::str::from_utf8(&bytes) else {
continue;
};
for script in inline_scripts(html) {
hashes.insert(csp_hash(script));
}
}
hashes.into_iter().collect()
}
/// The CSP `'sha256-<base64>'` source expression for one inline script body. /// The CSP `'sha256-<base64>'` source expression for one inline script body.
fn csp_hash(script: &str) -> String { fn csp_hash(script: &str) -> String {
let digest = Sha256::digest(script.as_bytes()); let digest = Sha256::digest(script.as_bytes());
+7
View File
@@ -7,6 +7,13 @@ pub mod domain;
pub mod infrastructure; pub mod infrastructure;
pub mod interfaces; pub mod interfaces;
// Operator-tools subcommand tree, dispatched from `src/main.rs` when
// the first positional arg matches a known domain (`opaque`, `migrate`).
// Previously lived in a standalone `oxicloud-cli` binary; folded in so
// the release tarball ships one executable — see
// docs/plan/bundled-binary.md § Deliverable 1b.
pub mod cli;
// Test-only helpers for #[cfg(integration_tests)] modules across the // Test-only helpers for #[cfg(integration_tests)] modules across the
// crate (shared pool URL guard + pre-suite cleanup OnceCell). // crate (shared pool URL guard + pre-suite cleanup OnceCell).
#[cfg(integration_tests)] #[cfg(integration_tests)]
+155 -167
View File
@@ -58,7 +58,7 @@ use common::di::AppServiceFactory;
use infrastructure::db::create_database_pools; use infrastructure::db::create_database_pools;
use interfaces::{ use interfaces::{
create_api_routes, create_health_routes, create_public_api_routes, create_api_routes, create_health_routes, create_public_api_routes,
web::{create_web_routes, resolve_static_path}, web::{StaticSource, create_web_routes, resolve_static_source},
}; };
fn parse_addr(host: &str, port: u16) -> Result<SocketAddr, String> { fn parse_addr(host: &str, port: u16) -> Result<SocketAddr, String> {
@@ -126,6 +126,29 @@ fn make_socket(addr: &SocketAddr, reuse_port: bool) -> std::io::Result<Socket> {
} }
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
// ── Operator subcommand dispatch ─────────────────────────────────
//
// If argv[1] matches a known subcommand domain, hand off to the
// clap-driven CLI tree in `src/cli/` and exit with its ExitCode.
// Bare `oxicloud` (or oxicloud with legacy top-level flags below)
// falls through to the server startup path — backwards compat with
// every existing Docker CMD line, systemd unit, and docker-compose
// entry that just runs `oxicloud` with no args.
//
// Absorbed here from the standalone `oxicloud-cli` +
// `migrate-nfc-filenames` binaries in v0.9.0 so the release tarball
// ships one executable. See docs/plan/bundled-binary.md § 1b.
if let Some(first) = std::env::args().nth(1)
&& matches!(first.as_str(), "opaque" | "migrate" | "storage")
{
// `oxicloud::cli::run()` returns a plain `u8` exit-code, which
// widens exactly into `i32` for `std::process::exit`. Values are
// 0/1/2 today; the widening is loss-free by construction.
std::process::exit(i32::from(oxicloud::cli::run()));
}
// ── Legacy top-level flags (server-startup path) ─────────────────
//
// Minimal CLI: // Minimal CLI:
// --version Print version + branch + commit hash and exit. // --version Print version + branch + commit hash and exit.
// --config <path> Load env from this file. When given, the default // --config <path> Load env from this file. When given, the default
@@ -133,16 +156,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// use this to isolate from a developer's repo-root // use this to isolate from a developer's repo-root
// `.env`, and operators get a reproducible "this // `.env`, and operators get a reproducible "this
// file and nothing else" boot. // file and nothing else" boot.
// --select-storage <name> One-shot repair: verify the named entry exists //
// in the current .env, UPDATE // NB: `--select-storage <name>` and `--fingerprint <key>` moved to
// admin_settings.storage.active_backend_name in // subcommands in v0.9.0 as `oxicloud storage select <name>` and
// the DB, and exit. Does NOT boot the server. // `oxicloud storage fingerprint <key>` respectively — dispatched
// Use to recover from the "boot fails on missing // above via the `matches!` guard. See docs/plan/bundled-binary.md § 1c.
// entry" case — see
// `docs/plan/storage-multi-entry.md` §Fallback.
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
let mut config_path: Option<String> = None; let mut config_path: Option<String> = None;
let mut select_storage: Option<String> = None;
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
match arg.as_str() { match arg.as_str() {
"--version" | "-V" => { "--version" | "-V" => {
@@ -161,57 +181,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}; };
config_path = Some(p); config_path = Some(p);
} }
"--select-storage" => {
let Some(name) = args.next() else {
eprintln!("--select-storage requires an entry name");
std::process::exit(2);
};
select_storage = Some(name);
}
"--fingerprint" => {
// One-shot helper: compute the SSH-style colon-hex
// fingerprint of a base64-encoded AES-256 key and
// print to stdout. Same truncation used by the v1
// header's `<key_fp>` field + the `backend_rotate`
// completion summary — so an admin can:
// 1. Look at the `head_key_fp` reported by the
// last rotate run.
// 2. Run `oxicloud --fingerprint <base64key>` for
// each candidate in `.env`.
// 3. Match — the key that produces the reported
// fingerprint is the current head; any other
// key in `_ENCRYPTION_KEY` no longer decrypts
// any live blob and can be dropped.
//
// Also accepts `-` for stdin so keys never touch the
// shell history:
// echo -n '<base64>' | oxicloud --fingerprint -
let Some(key_b64) = args.next() else {
eprintln!("--fingerprint requires a base64 key argument (or `-` for stdin)");
std::process::exit(2);
};
let key_b64 = if key_b64 == "-" {
use std::io::Read;
let mut buf = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
eprintln!("failed to read key from stdin: {e}");
std::process::exit(2);
}
buf.trim().to_string()
} else {
key_b64
};
match oxicloud::common::config::fingerprint_from_base64_key(&key_b64) {
Ok(fp) => {
println!("{fp}");
return Ok(());
}
Err(e) => {
eprintln!("--fingerprint: {e}");
std::process::exit(2);
}
}
}
"--help" | "-h" => { "--help" | "-h" => {
print_help(); print_help();
return Ok(()); return Ok(());
@@ -256,14 +225,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// `build_runtime`. // `build_runtime`.
let runtime = build_runtime()?; let runtime = build_runtime()?;
// Repair-flag short-circuit. `--select-storage` runs the small
// "verify entry + UPDATE pointer + exit" path and NEVER falls
// through to booting the server — the operator restarts normally
// after this exits.
if let Some(name) = select_storage {
return runtime.block_on(run_select_storage(&name));
}
runtime.block_on(run()) runtime.block_on(run())
} }
@@ -291,21 +252,44 @@ fn print_help() {
println!(" oxicloud [--config <path>] Boot the server. This is the normal"); println!(" oxicloud [--config <path>] Boot the server. This is the normal");
println!(" invocation for a docker/systemd unit."); println!(" invocation for a docker/systemd unit.");
println!(); println!();
println!(" oxicloud --select-storage <name> One-shot repair — set the active"); println!(" oxicloud <subcommand> [args...] Operator toolbox — one-shot tools that");
println!(" storage entry in the DB and exit."); println!(" exit after completing (see SUBCOMMANDS).");
println!();
println!(" oxicloud --fingerprint <base64key|-> One-shot helper — print the SSH-style");
println!(" fingerprint of a base64 AES-256 key.");
println!(" Same shape used by the v1 blob header");
println!(" + `backend_rotate` completion summary.");
println!(" Read stdin with `-` to keep keys out");
println!(" of shell history.");
println!(); println!();
println!(" oxicloud --version Print version + commit and exit."); println!(" oxicloud --version Print version + commit and exit.");
println!(); println!();
println!(" oxicloud --help Print this help and exit."); println!(" oxicloud --help Print this help and exit.");
println!(); println!();
println!(); println!();
println!("SUBCOMMANDS:");
println!(" opaque <action> OPAQUE aPAKE substrate management.");
println!(" setup Print a fresh ServerSetup (persist as");
println!(" OXICLOUD_AUTH_OPAQUE_SERVER_SETUP). Runs once per");
println!(" deployment. Rotating invalidates every user's envelope.");
println!(" reset Clear envelope(s) so silent-migration re-mints under");
println!(" the current KSF. Use after KSF rotation. Flags:");
println!(" --user <email|username> | --all, plus --dry-run.");
println!();
println!(" migrate <action> One-time data migrations (historical schema/data fixes).");
println!(" nfc-filenames NFC-normalize storage.files.name across the instance.");
println!(" Cleanup for databases populated before the June 2026");
println!(" write-time fix; new installs never need it. Flag:");
println!(" --dry-run to preview without writing.");
println!();
println!(" storage <action> Storage-config repair + crypto helpers.");
println!(" select <name> Set the active storage-entry backend and exit. Use to");
println!(" unblock boot after renaming/removing an entry in `.env`");
println!(" while the DB still points at the old name. Was");
println!(" `--select-storage <name>` before v0.9.0.");
println!(" fingerprint <k|-> Print the SSH-style colon-hex fingerprint of a base64");
println!(" AES-256 key. Same shape as the v1 blob header's");
println!(" <key_fp> field and the `backend_rotate` completion");
println!(" summary. Read stdin with `-` to keep keys out of shell");
println!(" history. Was `--fingerprint <k|->` before v0.9.0.");
println!();
println!(" Each subcommand has its own `--help`, e.g. `oxicloud opaque reset --help`.");
println!(" Subcommands require the same env vars as the server (DATABASE_URL etc.).");
println!();
println!();
println!("OPTIONS:"); println!("OPTIONS:");
println!(" --config <path>"); println!(" --config <path>");
println!(" Load environment variables from <path> instead of the default `./.env`."); println!(" Load environment variables from <path> instead of the default `./.env`.");
@@ -315,28 +299,6 @@ fn print_help() {
println!(" config. Without this flag, the default `./.env` probe is"); println!(" config. Without this flag, the default `./.env` probe is");
println!(" non-overriding — shell exports win — matching dev convenience."); println!(" non-overriding — shell exports win — matching dev convenience.");
println!(); println!();
println!(" --select-storage <name>");
println!(" Verify <name> is declared in `OXICLOUD_STORAGE_ENTRIES`, then set");
println!(" `admin_settings.storage.active_backend_name = <name>` in the DB and");
println!(" exit. Does NOT boot the server. Use to unblock boot after renaming");
println!(" or removing a storage entry in `.env` while the DB still points at");
println!(" the old name (the server aborts boot with a pointer to this flag");
println!(" when that happens). See `docs/plan/storage-multi-entry.md`");
println!(" §Fallback for the full recovery flow.");
println!();
println!(" --fingerprint <base64key | ->");
println!(" Compute the SSH-style colon-hex fingerprint (16-hex, 8-byte");
println!(" truncation of sha256) of a base64-encoded AES-256 key. Matches the");
println!(" `head_key_fp` field the `backend_rotate` job reports on completion,");
println!(" and the raw <key_fp> field embedded in every v1 blob header. Used");
println!(" to identify which key in `OXICLOUD_STORAGE_<N>_ENCRYPTION_KEY`");
println!(" corresponds to the current on-disk head — safe to drop any key");
println!(" whose fingerprint does NOT match the last-successful rotate's");
println!(" `head_key_fp`. Pass `-` to read the key from stdin so it never");
println!(" touches shell history:");
println!();
println!(" echo -n '<base64>' | oxicloud --fingerprint -");
println!();
println!(" --version, -V"); println!(" --version, -V");
println!(" Print the version, git branch, and commit hash. Exits 0."); println!(" Print the version, git branch, and commit hash. Exits 0.");
println!(); println!();
@@ -346,7 +308,7 @@ fn print_help() {
println!(); println!();
println!("ENVIRONMENT:"); println!("ENVIRONMENT:");
println!(" DATABASE_URL PostgreSQL connection string (required for boot and"); println!(" DATABASE_URL PostgreSQL connection string (required for boot and");
println!(" for --select-storage)."); println!(" for `storage select`).");
println!(); println!();
println!(" OXICLOUD_SERVER_HOST Bind host (default: 127.0.0.1)."); println!(" OXICLOUD_SERVER_HOST Bind host (default: 127.0.0.1).");
println!(" OXICLOUD_SERVER_PORT Bind port (default: 8086)."); println!(" OXICLOUD_SERVER_PORT Bind port (default: 8086).");
@@ -364,64 +326,6 @@ fn print_help() {
println!("The full env-var surface is documented in `example.env` at the repo root."); println!("The full env-var surface is documented in `example.env` at the repo root.");
} }
/// Repair-flag body. Loads env config, parses entries, verifies the
/// requested name is declared, connects to PG, upserts
/// `admin_settings.storage.active_backend_name`. Never touches the
/// server — the operator restarts after this exits.
///
/// Exit codes:
/// - `0` on success.
/// - Non-zero via `std::process::exit` on every failure path (name
/// not declared, DB unreachable, upsert failed). Printed to stderr.
async fn run_select_storage(name: &str) -> Result<(), Box<dyn std::error::Error>> {
use common::config::AppConfig;
use infrastructure::services::entry_backend::persist_active_backend_name;
// Parse entries + validate `name` is declared. Loading AppConfig
// here re-runs the same env-parse the server does at boot, so a
// successful --select-storage guarantees a subsequent normal
// boot will find the entry (no drift between the two code paths).
let config = AppConfig::from_env();
if config.storage_entries.is_empty() {
eprintln!(
"OXICLOUD_STORAGE_ENTRIES is not set (or synthesised — legacy path). \
`--select-storage` needs at least one named entry to switch to."
);
std::process::exit(2);
}
if !config.storage_entries.iter().any(|e| e.name == name) {
let available = config
.storage_entries
.iter()
.map(|e| e.name.as_str())
.collect::<Vec<_>>()
.join(", ");
eprintln!(
"entry `{name}` is not declared in OXICLOUD_STORAGE_ENTRIES. Available: [{available}]"
);
std::process::exit(2);
}
// Connect to PG using the same DATABASE_URL the server uses.
let db_url = std::env::var("DATABASE_URL").map_err(
|_| "DATABASE_URL not set — `--select-storage` needs the same DB the server would boot on",
)?;
let pool = sqlx::PgPool::connect(&db_url)
.await
.map_err(|e| format!("failed to connect to DATABASE_URL: {e}"))?;
persist_active_backend_name(&pool, name)
.await
.map_err(|e| {
format!("failed to write admin_settings.storage.active_backend_name = `{name}`: {e}")
})?;
println!(
"active_backend_name = `{name}` written to admin_settings. Restart the server to switch."
);
Ok(())
}
/// Construct the multi-threaded Tokio runtime with explicit, CFS-quota-aware /// Construct the multi-threaded Tokio runtime with explicit, CFS-quota-aware
/// pool sizes. /// pool sizes.
/// ///
@@ -446,6 +350,92 @@ fn build_runtime() -> std::io::Result<tokio::runtime::Runtime> {
.build() .build()
} }
/// Resolve where locale JSON files are read from at boot time.
///
/// Under the default (filesystem) build: return the same path the
/// filesystem `ServeDir` serves from, with a fallback to
/// `frontend/static/locales` for `just dev` checkouts where the SPA
/// build hasn't run.
///
/// Under `--features bundled-assets`, when `resolve_static_source`
/// returns `Embedded` (i.e. no filesystem override is present), extract
/// the embedded `locales/*.json` files to a boot-time tempdir and
/// return that path. Runtime code (`LocaleRegistry::discover`,
/// `FileSystemI18nService`) is unchanged: it still reads locale JSON
/// from a directory. The tempdir is process-scoped; `LocaleRegistry`
/// caches everything in-memory at boot, so the extracted files are
/// unused after the initial scan and can leak on abrupt process death
/// without affecting subsequent boots.
fn resolve_locales_path(source: &StaticSource) -> std::path::PathBuf {
match source {
StaticSource::Filesystem(path) => {
let served = path.join("locales");
if served.is_dir() {
served
} else {
std::path::PathBuf::from("frontend/static/locales")
}
}
#[cfg(feature = "bundled-assets")]
StaticSource::Embedded => extract_embedded_locales(),
}
}
/// Extract the embedded `locales/*.json` corpus to a boot-time tempdir
/// so the existing filesystem-based locale loader can consume it
/// unchanged. Called once at boot in the embedded-assets path.
///
/// Cost: ~50 ms for 16 JSON files totalling ~2.2 MB. The tempdir lives
/// under `std::env::temp_dir()` (respects `$TMPDIR`); no cleanup is
/// registered because `LocaleRegistry::discover` reads every file into
/// memory at boot, so the extracted copy is dead weight once boot
/// completes. On a graceful shutdown the tempdir persists until the
/// OS's tmpfs / cron reaper collects it; on abrupt kill likewise. Safe:
/// no secrets touch these files.
#[cfg(feature = "bundled-assets")]
fn extract_embedded_locales() -> std::path::PathBuf {
use interfaces::web::embedded::EmbeddedAssets;
let dir = std::env::temp_dir().join(format!("oxicloud-locales-{}", std::process::id()));
if let Err(e) = std::fs::create_dir_all(&dir) {
panic!(
"FATAL: failed to create embedded-locales staging dir at {}: {e}",
dir.display()
);
}
let mut count = 0usize;
for path in EmbeddedAssets::iter() {
let s: &str = path.as_ref();
// Root-level `locales/*.json` only. SvelteKit copies
// `frontend/static/locales/*.json` here at build time.
if !s.starts_with("locales/") || !s.ends_with(".json") {
continue;
}
let name = &s["locales/".len()..];
if name.contains('/') {
continue; // no nested subdirs today
}
let Some(file) = EmbeddedAssets::get(s) else {
continue;
};
let out = dir.join(name);
if let Err(e) = std::fs::write(&out, file.data.as_ref()) {
panic!(
"FATAL: failed to stage embedded locale {} at {}: {e}",
s,
out.display()
);
}
count += 1;
}
tracing::info!(
staging_dir = %dir.display(),
count,
"static-assets: staged {count} embedded locale file(s) for the boot-time \
LocaleRegistry scan (bundled-assets feature)."
);
dir
}
/// Async entrypoint, driven by the runtime built in [`main`]. /// Async entrypoint, driven by the runtime built in [`main`].
async fn run() -> Result<(), Box<dyn std::error::Error>> { async fn run() -> Result<(), Box<dyn std::error::Error>> {
// Initialize tracing. // Initialize tracing.
@@ -596,14 +586,12 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
// /app/static). Fail-fast if the path is missing rather than silently // /app/static). Fail-fast if the path is missing rather than silently
// creating an empty directory and limping along with a "translation missing" // creating an empty directory and limping along with a "translation missing"
// error on every request later. // error on every request later.
let locales_path = { // Resolve the static-assets source ONCE at boot — the resolution
let served = resolve_static_path(&config).join("locales"); // logs a single line describing which path was chosen. Reused
if served.is_dir() { // downstream for both the locale loader (below) and the web router
served // (`create_web_routes`), so neither has to re-parse env or re-log.
} else { let static_source = resolve_static_source(&config);
std::path::PathBuf::from("frontend/static/locales") let locales_path = resolve_locales_path(&static_source);
}
};
if !locales_path.is_dir() { if !locales_path.is_dir() {
panic!( panic!(
"FATAL: locales directory not found at {}. \ "FATAL: locales directory not found at {}. \
@@ -628,7 +616,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let api_routes = create_api_routes(&app_state); let api_routes = create_api_routes(&app_state);
let public_api_routes = create_public_api_routes(&app_state); let public_api_routes = create_public_api_routes(&app_state);
let health_routes = create_health_routes(&app_state); let health_routes = create_health_routes(&app_state);
let web_routes = create_web_routes(app_state.clone()); let web_routes = create_web_routes(app_state.clone(), static_source);
let mut app; let mut app;
+4 -4
View File
@@ -254,8 +254,8 @@ OPAQUE_HELPER_BIN="$REPO_ROOT/target/$BUILD_TARGET/opaque-hurl-helper"
if [[ ! -x "$OPAQUE_HELPER_BIN" ]]; then if [[ ! -x "$OPAQUE_HELPER_BIN" ]]; then
log "Building opaque-hurl-helper ($BUILD_TARGET)..." log "Building opaque-hurl-helper ($BUILD_TARGET)..."
case "$BUILD_TARGET" in case "$BUILD_TARGET" in
debug) (cd "$REPO_ROOT" && cargo build --bin opaque-hurl-helper 2>&1 | tail -n 20) || die "opaque-hurl-helper build failed" ;; debug) (cd "$REPO_ROOT" && cargo build --features test_utils --bin opaque-hurl-helper 2>&1 | tail -n 20) || die "opaque-hurl-helper build failed" ;;
release) (cd "$REPO_ROOT" && cargo build --release --bin opaque-hurl-helper 2>&1 | tail -n 20) || die "opaque-hurl-helper build failed" ;; release) (cd "$REPO_ROOT" && cargo build --release --features test_utils --bin opaque-hurl-helper 2>&1 | tail -n 20) || die "opaque-hurl-helper build failed" ;;
esac esac
fi fi
log "Running OPAQUE crypto handshake helper..." log "Running OPAQUE crypto handshake helper..."
@@ -278,8 +278,8 @@ DPOP_HELPER_BIN="$REPO_ROOT/target/$BUILD_TARGET/dpop-hurl-helper"
if [[ ! -x "$DPOP_HELPER_BIN" ]]; then if [[ ! -x "$DPOP_HELPER_BIN" ]]; then
log "Building dpop-hurl-helper ($BUILD_TARGET)..." log "Building dpop-hurl-helper ($BUILD_TARGET)..."
case "$BUILD_TARGET" in case "$BUILD_TARGET" in
debug) (cd "$REPO_ROOT" && cargo build --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;; debug) (cd "$REPO_ROOT" && cargo build --features test_utils --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;;
release) (cd "$REPO_ROOT" && cargo build --release --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;; release) (cd "$REPO_ROOT" && cargo build --release --features test_utils --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;;
esac esac
fi fi
log "Running DPoP wire-protocol helper..." log "Running DPoP wire-protocol helper..."
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env bash
# Bundled-binary integration test.
#
# Builds `oxicloud` with `--features bundled-assets`, then boots it
# with the on-disk `static-dist/` moved aside and OXICLOUD_STATIC_PATH
# pointed at a nonexistent directory — the ONLY code path this can
# take is the embedded corpus. Then curls the SPA shell, a locale
# file, and a deep-link route to prove the embed serves correctly.
#
# Why this test exists: the bundled-assets feature has three failure
# modes that don't surface in normal filesystem-served CI:
#
# 1. rust-embed configuration bugs (glob patterns, `include`/`exclude`
# attrs). A wrong glob can silently produce a 0-file embed —
# caught 2026-08-28.
# 2. Debug-vs-release behaviour drift. rust-embed's dynamic-read mode
# in debug builds reads from disk at runtime, which masks embed
# bugs. `debug-embed` feature bakes files in for BOTH profiles
# (this test relies on it).
# 3. Axum `Path` extractor on fallback routes returning 500. The
# embedded `serve_root` handler needs `Request` extraction, not
# `Path` — caught 2026-08-28.
#
# All three are boot / first-request bugs a normal integration suite
# would miss. See docs/plan/bundled-binary.md § Verification.
#
# Usage (from repo root):
# bash tests/bundled-binary/run.sh
#
# Prerequisites: docker, cargo, node+npm (for the frontend build), curl
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
COMMON="$REPO_ROOT/tests/common"
TEST_DIR="$REPO_ROOT/tests/bundled-binary"
# shellcheck source=test.env
source "$TEST_DIR/test.env"
SERVER_PORT="${base_url##*:}"
log() { echo "[bundled-binary] $*"; }
die() { echo "[bundled-binary] ERROR: $*" >&2; exit 1; }
pass() { echo "[bundled-binary] ✓ $*"; }
fail() { echo "[bundled-binary] ✗ $*" >&2; FAILS=$((FAILS + 1)); }
wait_for_http() {
local url="$1" timeout="${2:-120}"
local deadline=$(( $(date +%s) + timeout ))
until curl -sf "$url" >/dev/null 2>&1; do
[[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url"
sleep 1
done
}
# ── Cleanup state ────────────────────────────────────────────────────
#
# The trap kills the running server (a stray port-8090 process would
# collide with the next run) and tears down the test postgres. We do
# NOT touch `static-dist/` — the embed path is forced via
# `OXICLOUD_STATIC_PATH` alone (see step 5), so there's no filesystem
# state to restore.
SERVER_PID=""
FAILS=0
cleanup() {
local rc=$?
if [[ -n "$SERVER_PID" ]]; then
log "Stopping server (pid $SERVER_PID)..."
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
bash "$COMMON/stop-db.sh" 2>/dev/null || true
exit "$rc"
}
trap cleanup EXIT
# ── 1. Ensure static-dist/ is present at build time ─────────────────
#
# rust-embed's derive macro scans this directory at compile time. If
# it's missing, build.rs's `bundled_assets_guard` panics. This step
# builds the SPA in-place when the dir is absent.
#
# `SKIP_FRONTEND_BUILD=1` opts out and fails fast with a hint — useful
# in CI pipelines where the SPA build is a separate cached step
# upstream of this test.
if [[ ! -f "$REPO_ROOT/static-dist/index.html" ]]; then
if [[ "${SKIP_FRONTEND_BUILD:-0}" == "1" ]]; then
die "static-dist/index.html missing (SKIP_FRONTEND_BUILD=1). \
Build the SPA first: (cd frontend && npm ci && npm run build)"
fi
log "static-dist/ missing — building the SPA (set SKIP_FRONTEND_BUILD=1 to opt out)..."
if [[ ! -d "$REPO_ROOT/frontend/node_modules" ]]; then
log " running 'npm ci' (first-time install)..."
(cd "$REPO_ROOT/frontend" && npm ci) || die "npm ci failed"
fi
(cd "$REPO_ROOT/frontend" && npm run build) || die "npm run build failed"
[[ -f "$REPO_ROOT/static-dist/index.html" ]] || die "SPA build finished but static-dist/index.html still missing"
fi
# ── 2. Build with --features bundled-assets ──────────────────────────
#
# Debug build — matches what a dev iterates on locally, faster than
# --release, and the `debug-embed` feature in rust-embed makes debug
# and release behave identically here (both compile-time embed).
log "Building oxicloud with --features bundled-assets..."
(cd "$REPO_ROOT" && cargo build --features bundled-assets --bin oxicloud 2>&1 | tail -n 5) \
|| die "cargo build --features bundled-assets failed"
OXICLOUD_BIN="$REPO_ROOT/target/debug/oxicloud"
[[ -x "$OXICLOUD_BIN" ]] || die "Binary missing after build: $OXICLOUD_BIN"
# ── 3. Start test Postgres ───────────────────────────────────────────
log "Starting test Postgres via $COMMON/spawn-db.sh..."
bash "$COMMON/spawn-db.sh"
# ── 4. Boot the server with the embed forced ─────────────────────────
#
# `OXICLOUD_STATIC_PATH=/tmp/oxicloud-bundled-nonexistent-$$` is a path
# that provably doesn't exist (unique to this run's PID). The resolver
# in `resolve_static_source` runs two filesystem probes derived from
# this env var, BOTH of which miss:
# 1. `<parent>/static-dist/` → `/tmp/static-dist/` (vanishingly
# unlikely to exist)
# 2. `OXICLOUD_STATIC_PATH` itself — nonexistent by construction
# The resolver then falls through to `StaticSource::Embedded`. Repo-root
# `static-dist/` is never consulted at runtime — parenthood is derived
# from the env var, not CWD — so this test is stateless on the working
# directory.
STORAGE="$TEST_DIR/storage"
rm -rf "$STORAGE" && mkdir -p "$STORAGE"
LOG_FILE="$TEST_DIR/server.log"
: > "$LOG_FILE"
set -a
# shellcheck source=../common/server.env
source "$COMMON/server.env"
OXICLOUD_SERVER_PORT=$SERVER_PORT
OXICLOUD_STORAGE_PATH="$STORAGE"
OXICLOUD_STATIC_PATH="/tmp/oxicloud-bundled-nonexistent-$$"
# Disable the Prometheus /metrics listener — the shared server.env pins
# it to 127.0.0.1:9090 which collides when another test server (or a
# stray dev process) already holds that port, killing our boot before
# /ready is reachable. Metrics aren't part of what this test asserts.
OXICLOUD_METRICS_LISTEN=""
# Override the shared server.env's `RUST_LOG=warn,audit=info,...` which
# suppresses the info-level `static-assets:` lines this test asserts on
# (embed-source resolution + locale extraction). Match the app's own
# default (main.rs::run) so `http=warn` still tames the access log.
RUST_LOG="info,http=warn,http::web=error"
set +a
log "Starting server on port $SERVER_PORT with embed forced..."
"$OXICLOUD_BIN" > "$LOG_FILE" 2>&1 &
SERVER_PID=$!
wait_for_http "$base_url/ready" 120
log "Server ready — running assertions."
# ── 6. Assertions ────────────────────────────────────────────────────
# 6a. Boot log confirms the resolver picked the embed path (not
# silently fell back to a stale filesystem dir).
if grep -q 'static-assets: no filesystem source found, serving embedded corpus' "$LOG_FILE"; then
pass "boot log: resolver picked StaticSource::Embedded"
else
fail "boot log missing 'serving embedded corpus' line — was a filesystem source unexpectedly found?"
fi
# 6b. Boot log confirms N>0 locales were staged. This is the guard
# against the 2026-08-28 glob bug — a silent 0 would look like a
# "success" to a non-strict test.
if grep -Eq 'staged [1-9][0-9]* embedded locale file\(s\)' "$LOG_FILE"; then
staged=$(grep -oE 'staged [0-9]+ embedded locale' "$LOG_FILE" | tail -n1 | awk '{print $2}')
pass "boot log: staged $staged embedded locale file(s)"
else
fail "boot log: staged 0 locales (or line missing) ← the 2026-08-28 regression class"
fi
# 6c. SPA shell reachable at /
code=$(curl -s -o /dev/null -w '%{http_code}' "$base_url/")
if [[ "$code" == "200" ]]; then
pass "GET / → 200"
else
fail "GET / → $code (expected 200)"
fi
# 6d. Shell body looks like the SvelteKit-built index.html. Any of
# `<!doctype html>` or `data-color-scheme` or `<meta http-equiv=
# "content-security-policy"` would confirm it's the real shell
# and not an error page.
body=$(curl -s "$base_url/")
if echo "$body" | grep -qi '<!doctype html>' && echo "$body" | grep -q 'data-color-scheme'; then
pass "SPA shell body has SvelteKit markers (<!doctype html> + data-color-scheme)"
else
fail "SPA shell body doesn't look like the real index.html"
fi
# 6e. SPA fallback handler serves the shell for deep-link routes.
# `serve_root` MUST NOT return 500 here (the 2026-08-28 axum
# `Path` extractor bug on fallback routes).
for path in /login /files/some-deep-link; do
code=$(curl -s -o /dev/null -w '%{http_code}' "$base_url$path")
if [[ "$code" == "200" ]]; then
pass "GET $path → 200 (SPA fallback)"
else
fail "GET $path → $code (SPA fallback broken?)"
fi
done
# 6f. Favicon served from the embed (specific bytes, not the shell).
code=$(curl -s -o /dev/null -w '%{http_code}' "$base_url/favicon.ico")
ct=$(curl -sI "$base_url/favicon.ico" | grep -i '^content-type:' | tr -d '\r' | awk '{print $2}')
if [[ "$code" == "200" ]] && [[ "$ct" != text/html* ]]; then
pass "GET /favicon.ico → 200 with non-HTML content-type ($ct)"
else
fail "GET /favicon.ico → code=$code content-type=$ct (should be 200 image/*)"
fi
# 6g. Locale JSON reachable AND is valid JSON with expected shape.
#
# `curl -w` captures status + content-type in the same call as the body
# so a failure surfaces WHAT the server actually returned (an HTML SPA
# fallback? a 404? a redirect?) instead of hiding it behind an empty
# string. Avoids the `head -c 1 | grep` pipeline that emits a spurious
# "broken pipe" under `set -euo pipefail`.
locale_status=$(curl -s -o /tmp/oxicloud-bundled-locale-$$ -w '%{http_code}|%{content_type}' "$base_url/locales/en.json")
locale_body=$(cat /tmp/oxicloud-bundled-locale-$$ 2>/dev/null || echo '')
rm -f /tmp/oxicloud-bundled-locale-$$
locale_code="${locale_status%%|*}"
locale_ct="${locale_status##*|}"
locale_first_char="${locale_body:0:1}"
if [[ "$locale_code" == "200" ]] && [[ "$locale_first_char" == "{" ]]; then
pass "GET /locales/en.json → 200 JSON body (content-type=$locale_ct)"
else
fail "GET /locales/en.json → code=$locale_code content-type=$locale_ct first-char='$locale_first_char' body-len=${#locale_body}"
# Diagnostic dump — first 200 bytes so we can see what the server
# actually served (SPA fallback? empty? something else?).
echo "[bundled-binary] body head: $(printf '%.200s' "$locale_body")" >&2
fi
# 6h. Immutable-asset cache header is applied by the `_app/immutable`
# nested router. Pick any hashed asset from the embed inventory
# — the boot log doesn't list them, so grep the shell HTML for one
# of its `modulepreload` refs.
imm_asset=$(echo "$body" | grep -oE '/_app/immutable/[^"]+\.js' | head -n1)
if [[ -n "$imm_asset" ]]; then
cc=$(curl -sI "$base_url$imm_asset" | grep -i '^cache-control:' | tr -d '\r')
if echo "$cc" | grep -q 'immutable'; then
pass "immutable-asset cache header applied: $cc"
else
fail "immutable-asset $imm_asset cache header wrong: $cc"
fi
else
fail "couldn't find a /_app/immutable/*.js reference in the shell HTML to test"
fi
# 6i. Shell HTML carries a Content-Security-Policy with sha256 script
# hashes. The SvelteKit build inlines a <meta http-equiv= ...> in
# index.html; that alone counts (belt). If the axum response-level
# CSP also carries hashes, even better (suspenders) — but the
# current middleware ships a hardcoded string, so we only check
# the meta tag which comes from the embedded bytes.
if echo "$body" | grep -q "'sha256-"; then
pass "SPA shell HTML carries CSP with sha256 script hashes (from Vite build)"
else
fail "SPA shell HTML has no sha256 CSP hashes — build produced a shell without them?"
fi
# ── Report ───────────────────────────────────────────────────────────
echo ""
if [[ $FAILS -gt 0 ]]; then
echo "─── SERVER LOG (last 60 lines) ───────────────────────────"
tail -n 60 "$LOG_FILE"
echo "──────────────────────────────────────────────────────────"
die "$FAILS assertion(s) failed"
fi
log "bundled-binary integration tests passed ✅"
+8
View File
@@ -0,0 +1,8 @@
# Bundled-binary integration test — runs the oxicloud server built with
# `--features bundled-assets` on a dedicated port so it doesn't collide
# with the api / webdav / oidc runners in a `just api-test` chain.
#
# Only variables the run.sh consumes as bash vars live here (test port,
# any curl-side seed data). Server-side env is loaded via
# tests/common/server.env inside the runner.
base_url=http://localhost:8090
+4 -3
View File
@@ -148,9 +148,10 @@ OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0
# still 404 (they flip to 200 when Phase 1 lands). # still 404 (they flip to 200 when Phase 1 lands).
# #
# The SERVER_SETUP below is a throwaway keypair generated once for the # The SERVER_SETUP below is a throwaway keypair generated once for the
# test env — real deployments call `opaque-setup` and paste the output. # test env — real deployments call `oxicloud opaque setup` and paste
# Never reuse this value outside CI. Regenerate any time with: # the output. Never reuse this value outside CI. Regenerate any time
# cargo run --bin opaque-setup # with:
# cargo run --bin oxicloud -- opaque setup
OXICLOUD_AUTH_OPAQUE_MODE=migrate OXICLOUD_AUTH_OPAQUE_MODE=migrate
OXICLOUD_AUTH_OPAQUE_SERVER_SETUP="ZY4hAGa1MNyE7Ht+8ksLcyMmi/K2iJvxQly+DdfllUxjiH0+CjCt4hG6+9Y68jGet2L213dV0hajCbr4fXnekkWtUxqLr+butVHEksZ9NJRuZTvS6SMC73yf/yku4WUHT1NSRB2yHurAFmYn75D9wdA1VaXTuwgO/u5i1pvcsQs=" OXICLOUD_AUTH_OPAQUE_SERVER_SETUP="ZY4hAGa1MNyE7Ht+8ksLcyMmi/K2iJvxQly+DdfllUxjiH0+CjCt4hG6+9Y68jGet2L213dV0hajCbr4fXnekkWtUxqLr+butVHEksZ9NJRuZTvS6SMC73yf/yku4WUHT1NSRB2yHurAFmYn75D9wdA1VaXTuwgO/u5i1pvcsQs="
# Fast Argon2id — CI machines are underpowered vs production (256 MiB # Fast Argon2id — CI machines are underpowered vs production (256 MiB