diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b880195c..7a19b1ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -176,6 +176,21 @@ jobs: with: components: clippy - 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 # Mirrors the `wasm-check` justfile recipe. The wasm crate is a @@ -306,6 +321,18 @@ jobs: - uses: dtolnay/rust-toolchain@stable - 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 # Applies every migration + seeds the integration-test admin row. # Same script used by `just test-integration` locally. @@ -460,6 +487,79 @@ jobs: path: tests/api/storage/ 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: name: WebDAV RFC 4918 — litmus (59/59) needs: build diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml new file mode 100644 index 00000000..a0eb3072 --- /dev/null +++ b/.github/workflows/release-binaries.yml @@ -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 }} diff --git a/Cargo.lock b/Cargo.lock index 1156a1ae..c662e987 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "adobe-cmap-parser" version = "0.4.1" @@ -160,7 +166,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -171,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1172,6 +1178,16 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -1962,6 +1978,12 @@ dependencies = [ "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]] name = "dashmap" version = "6.2.1" @@ -2267,7 +2289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2884,6 +2906,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "group" version = "0.13.0" @@ -3285,7 +3320,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -3507,6 +3542,39 @@ dependencies = [ "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]] name = "indexmap" version = "1.9.3" @@ -3603,7 +3671,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3802,6 +3870,30 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "libloading" version = "0.9.0" @@ -4278,6 +4370,15 @@ dependencies = [ "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]] name = "nom" version = "7.1.3" @@ -4318,7 +4419,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4597,6 +4698,7 @@ dependencies = [ "rand_core 0.6.4", "rayon", "reqwest", + "rust-embed", "serde", "serde_json", "sha2 0.11.0", @@ -5006,6 +5108,28 @@ dependencies = [ "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]] name = "proc-macro-utils" version = "0.10.0" @@ -5140,7 +5264,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.40", - "socket2 0.5.10", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -5177,7 +5301,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -5558,6 +5682,12 @@ dependencies = [ "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]] name = "rmp" version = "0.8.15" @@ -5597,6 +5727,44 @@ dependencies = [ "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]] name = "rust-stemmers" version = "1.2.0" @@ -5651,7 +5819,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6174,7 +6342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6686,7 +6854,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8154,7 +8322,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6f3d85ba..f1a21208 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,24 @@ version = "0.8.7" edition = "2024" 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--.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] mimalloc = { version = "0.1.52", default-features = false } @@ -47,6 +65,25 @@ futures = "0.3.32" async-stream = "0.3.6" async-trait = "0.1.89" 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"] } thiserror = "2.0.18" 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. # Run with: `cargo bench --features bench` / `cargo run --release --features bench --example bench_thumbnails_mem`. 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] criterion = "0.5" @@ -186,19 +248,12 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] } [[bin]] name = "generate-openapi" path = "src/bin/generate-openapi.rs" - -[[bin]] -name = "migrate-nfc-filenames" -path = "src/bin/migrate-nfc-filenames.rs" - -[[bin]] -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`). +# Dev-only: regenerates `resources/gen/openapi.json` from the utoipa +# `#[utoipa::path]` annotations in the API handlers. Gated behind the +# `dev_tools` feature so `cargo build --release --bins` (and the prod +# Dockerfile) skip it entirely — end users have no reason to run it. +# Invoked by `just openapi`, which passes `--features dev_tools`. +required-features = ["dev_tools"] [[bin]] 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 # after opaque_substrate.hurl to cover the parts Hurl can't (OPRF # 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]] 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 # scenarios Hurl can't express (per-request fresh jti/iat, replay # 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]] name = "load-seed" @@ -938,11 +997,20 @@ opt-level = 1 debug = "line-tables-only" split-debuginfo = "unpacked" # Incremental compilation caches per-function IR fingerprints so a -# small edit only recompiles what changed. On a single-crate rebuild -# (oxicloud is one crate) the savings are modest — worth < the ~7 GB -# incremental/ cache costs on disk. Rust-analyzer uses `cargo check`, -# which has its own cache, so LSP responsiveness is unaffected. -incremental = false +# small edit only recompiles what changed. The `target/incremental/` +# cache costs ~7 GB per profile, but at the current codebase size a +# full rebuild is ~10 minutes and an incremental single-file edit is +# seconds — the disk is worth it and then some. Rust-analyzer's own +# `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] lto = "fat" diff --git a/Dockerfile b/Dockerfile index 0f86c094..39c1f1a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,11 +42,8 @@ COPY build.rs ./ # Create a minimal project to download and cache dependencies RUN mkdir -p src/bin && \ 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 && \ - 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-* # ─── Stage 3: Build the application ────────────────────────────────────────── @@ -86,7 +83,7 @@ RUN DATABASE_URL="${DATABASE_URL}" \ GITHUB_SHA="${GITHUB_SHA}" \ GITHUB_REF_NAME="${GITHUB_REF_NAME}" \ 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 # below (build.rs has no asset pipeline — it only injects git metadata). 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}" \ cargo build --release && \ mkdir -p /app/bin && \ - 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 + cp target/release/oxicloud /app/bin/oxicloud # ─── Stage 3c: Select the builder & normalise the binary path ───────────────── # 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 -# the two shipped binaries from the builder-specific ${BIN_DIR} into a single -# stable path (/app/release) so the runtime stage's COPYs are independent of -# which builder ran. `static-dist` already lives at /app/static-dist in both +# the shipped binary from the builder-specific ${BIN_DIR} into a single stable +# path (/app/release) so the runtime stage's COPY is independent of which +# builder ran. `static-dist` already lives at /app/static-dist in both # 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 ` rather than in standalone `oxicloud-cli` / +# `migrate-nfc-filenames` bins. See docs/plan/bundled-binary.md § 1b. FROM ${BUILDER} AS app ARG BIN_DIR 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 ────────────────────────────────────────── FROM alpine:3.24.0 @@ -163,21 +163,18 @@ RUN apk --no-cache upgrade && \ addgroup -g 1001 -S 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 ` rather than as standalone helper bins: +# +# docker run --rm oxicloud opaque setup # print OPAQUE ServerSetup +# docker exec 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/ -# Ship the NFC filename migration binary alongside the server so -# operators can run it inside the container without a separate Rust -# toolchain — `docker exec 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 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 RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \ chmod 755 /usr/local/bin/entrypoint.sh diff --git a/README.md b/README.md index 0ff14ec5..b5b5450a 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,16 @@ docker compose up -d 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 Requires Rust 1.93+ and PostgreSQL. diff --git a/build.rs b/build.rs index 282199e0..28ce7298 100644 --- a/build.rs +++ b/build.rs @@ -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`). -//! There is no Rust-side asset pipeline: the frontend is built by Vite into -//! `static-dist/` and served directly by the web layer (`interfaces::web`). +//! The frontend is built by Vite into `static-dist/` at the repo root and +//! 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::path::Path; use std::process::Command; fn main() { println!("cargo:rerun-if-changed=build.rs"); 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() + ); + } } // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/docs/architecture/backend-storage.md b/docs/architecture/backend-storage.md index cf22ff17..a7b1d1b1 100644 --- a/docs/architecture/backend-storage.md +++ b/docs/architecture/backend-storage.md @@ -26,7 +26,7 @@ probe, and lifecycle behaviour are uniform. Every entry is declared in `OXICLOUD_STORAGE_ENTRIES` (comma-separated list of names). The active entry is stored in `admin_settings` and -switched via `oxicloud --select-storage ` on the command line +switched via `oxicloud storage select ` on the command line or automatically at the end of a successful `backend_migration`. Non-active entries stay reachable through the multi-entry API (test, audit, migrate-into). @@ -119,7 +119,7 @@ Rendered visually via `xxd -l 15 `: Fingerprints are rendered the same colon-hex form (`15:f3:…:50`) everywhere they appear: boot log, admin panel pair chain, `xxd` -inspection, `oxicloud --fingerprint ` CLI, and the rotate / +inspection, `oxicloud storage fingerprint ` CLI, and the rotate / migration audit lines. That means an admin can cross-reference by 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 retries (walk short-circuits on head-format matches → cheap re-attempt), fixes the source, or explicitly accepts the partial -via `oxicloud --select-storage `. +via `oxicloud storage select `. --- diff --git a/docs/config/admin-settings.md b/docs/config/admin-settings.md index 0dc1f082..589eac3a 100644 --- a/docs/config/admin-settings.md +++ b/docs/config/admin-settings.md @@ -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: ``` -oxicloud --select-storage +oxicloud storage select ``` This one-shot repair command re-runs the same env-parse the server does at boot, verifies `` 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. diff --git a/docs/config/authentication.md b/docs/config/authentication.md index 967bc0b7..1b8b8e97 100644 --- a/docs/config/authentication.md +++ b/docs/config/authentication.md @@ -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: ```bash # 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: - 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. 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. diff --git a/docs/config/env.md b/docs/config/env.md index ffc09e5f..ec158e84 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -58,7 +58,7 @@ OPAQUE (RFC 9807) is a zero-knowledge password-authenticated key exchange: the p | 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_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_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. | @@ -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_SEARCH` | `true` | Full-text and metadata search | | `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_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. | @@ -131,7 +133,7 @@ Each declared name `` 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`). - 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 ` — 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 ` — verify + UPDATE DB + exit. **Example** — two entries, local disk plus an S3 target for planned migration: diff --git a/docs/guide/backend-storage.md b/docs/guide/backend-storage.md index 19d04bdd..07bafce2 100644 --- a/docs/guide/backend-storage.md +++ b/docs/guide/backend-storage.md @@ -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 OXICLOUD_STORAGE_ENTRIES. Available: [local_main]. […] -oxicloud --select-storage +oxicloud storage select ``` 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. diff --git a/docs/install/binary.md b/docs/install/binary.md new file mode 100644 index 00000000..9d29b139 --- /dev/null +++ b/docs/install/binary.md @@ -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--x86_64-unknown-linux-musl.tar.gz` | +| Linux ARM64 (Raspberry Pi 4/5, Ampere, Graviton, ARM servers) | `oxicloud--aarch64-unknown-linux-musl.tar.gz` | +| macOS Apple Silicon (M-series) | `oxicloud--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--.tar.gz +cd oxicloud--/ +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--.tar.gz +cd oxicloud--/ + +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. diff --git a/docs/plan/benchmake-and-performance-tracking.md b/docs/plan/benchmake-and-performance-tracking.md index 46f40f17..d4bcdc39 100644 --- a/docs/plan/benchmake-and-performance-tracking.md +++ b/docs/plan/benchmake-and-performance-tracking.md @@ -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` -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:** ``` @@ -158,7 +160,7 @@ Matches existing recipe naming (`test-*`, `front-*`, `api-test`). - `.github/workflows/load-nightly.yml`, `load-smoke.yml` **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 - `.gitignore` — add `tests/load/results/*.json` and `tests/load/storage/` diff --git a/docs/plan/bundled-binary.md b/docs/plan/bundled-binary.md new file mode 100644 index 00000000..d72f790a --- /dev/null +++ b/docs/plan/bundled-binary.md @@ -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-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 + migrate-nfc-filenames --dry-run` becomes `docker exec + oxicloud-cli migrate nfc-filenames --dry-run` (intermediate) then + `docker exec 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 oxicloud-cli + ` become `docker exec oxicloud + `. 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-/ +├── 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-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 `/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 `