Merge pull request #695 from EdouardVanbelle/ci/bundled-binaries
ci: bundled binaries
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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>`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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/`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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}`."
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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
@@ -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..."
|
||||||
|
|||||||
Executable
+278
@@ -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 ✅"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user