diff --git a/Cargo.lock b/Cargo.lock
index 161f9cf6e..c91114e95 100644
Binary files a/Cargo.lock and b/Cargo.lock differ
diff --git a/Cargo.toml b/Cargo.toml
index 70d6e1c0f..d1751929d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,7 +7,7 @@ members = [
# "apps/mobile/modules/sd-core/android/crate",
# "apps/mobile/modules/sd-core/core",
# "apps/mobile/modules/sd-core/ios/crate",
- # "apps/server",
+ "apps/server",
"apps/cli",
"apps/gpui-photo-grid",
"apps/tauri/sd-tauri-core",
diff --git a/apps/server/.dockerignore b/apps/server/.dockerignore
new file mode 100644
index 000000000..bb2219dad
--- /dev/null
+++ b/apps/server/.dockerignore
@@ -0,0 +1,68 @@
+# Git
+.git
+.gitignore
+.gitattributes
+
+# Build artifacts
+target/
+**/target/
+dist/
+**/dist/
+build/
+**/build/
+
+# Dependencies
+node_modules/
+**/node_modules/
+.pnpm-store/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.DS_Store
+
+# Logs
+*.log
+logs/
+
+# Test files
+coverage/
+.nyc_output/
+
+# Documentation (not needed in container)
+docs/
+*.md
+!README.md
+
+# CI/CD
+.github/
+.gitlab-ci.yml
+.travis.yml
+
+# Development files
+.env
+.env.local
+.env.*.local
+
+# Rust
+**/*.rs.bk
+Cargo.lock.orig
+
+# macOS
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Linux
+*~
+.fuse_hidden*
+.directory
+.Trash-*
+
+# Windows
+Thumbs.db
+ehthumbs.db
+Desktop.ini
diff --git a/apps/server/ARCHITECTURE.md b/apps/server/ARCHITECTURE.md
new file mode 100644
index 000000000..4f26432db
--- /dev/null
+++ b/apps/server/ARCHITECTURE.md
@@ -0,0 +1,380 @@
+# Spacedrive Server Architecture
+
+## Overview
+
+The Spacedrive Server is a production-ready HTTP server that embeds the Spacedrive daemon and serves the web interface. It's designed for headless deployments, NAS systems, and container environments.
+
+## Design Principles
+
+1. **Embedded Daemon** - No separate process management needed
+2. **Single Binary** - Web assets bundled via `include_dir` when built with `--features assets`
+3. **Platform Abstraction** - Uses same `@sd/interface` as Tauri, with web-specific platform impl
+4. **Security First** - HTTP Basic Auth for all endpoints (except health check)
+5. **Container Native** - Docker-first design with distroless runtime image
+
+## Components
+
+### 1. HTTP Server (`apps/server/src/main.rs`)
+
+Built with Axum, provides:
+- **`GET /health`** - Health check (no auth required)
+- **`POST /rpc`** - JSON-RPC proxy to daemon Unix socket
+- **`GET /*`** - Static asset serving (SPA fallback to index.html)
+
+**Flow:**
+```
+Browser → HTTP Request → Axum Router → Basic Auth Middleware → Handler
+ ↓
+ ┌─────────────────────────┴────────┐
+ ↓ ↓
+ Static Assets RPC Proxy
+ (serve from ↓
+ ASSETS_DIR) Unix Socket → Daemon
+```
+
+### 2. Embedded Daemon
+
+Unlike Tauri (which spawns `sd-daemon` as a child process), the server runs the daemon in-process:
+
+```rust
+tokio::spawn(async move {
+ sd_core::infra::daemon::bootstrap::start_default_server(
+ socket_path,
+ data_dir,
+ enable_p2p,
+ ).await
+});
+```
+
+**Benefits:**
+- Single container image
+- Simplified lifecycle management
+- Shared memory space (more efficient)
+- Graceful shutdown via `tokio::select!`
+
+**Daemon lifecycle:**
+1. Check if socket already exists (reuse existing daemon)
+2. If not, spawn daemon in background task
+3. Wait for socket file to appear (max 3s)
+4. Return handle for graceful shutdown
+
+### 3. Web Client (`apps/web/`)
+
+Minimal React app using `@sd/interface`:
+
+```tsx
+// apps/web/src/main.tsx
+
+
+
+```
+
+**Platform implementation:**
+```typescript
+// apps/web/src/platform.ts
+export const platform: Platform = {
+ platform: "web",
+ openLink(url) { window.open(url) },
+ confirm(msg, cb) { cb(window.confirm(msg)) },
+ // No native file pickers, daemon control, etc.
+};
+```
+
+**Build process:**
+1. Vite bundles React app → `apps/web/dist/`
+2. `build.rs` runs `pnpm build` before compiling server
+3. `include_dir!` macro embeds `dist/` into binary at compile time
+4. Axum serves embedded files from memory
+
+### 4. RPC Proxy
+
+Browsers can't connect to Unix sockets, so the server proxies:
+
+```
+Browser Server Daemon
+ │ │ │
+ │ POST /rpc │ │
+ ├────────────────────>│ │
+ │ (JSON-RPC) │ Unix Socket Write │
+ │ ├────────────────────────>│
+ │ │ │
+ │ │ Unix Socket Read │
+ │ │<────────────────────────┤
+ │ 200 OK │ │
+ │<────────────────────┤ │
+ │ (JSON-RPC result) │ │
+```
+
+**Implementation:**
+```rust
+async fn daemon_rpc(
+ State(state): State,
+ Json(payload): Json,
+) -> Result, (StatusCode, String)> {
+ let mut stream = UnixStream::connect(&state.socket_path).await?;
+ stream.write_all(format!("{}\n", serde_json::to_string(&payload)?).as_bytes()).await?;
+
+ let mut reader = BufReader::new(stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).await?;
+
+ Ok(Json(serde_json::from_str(&response)?))
+}
+```
+
+## Comparison: Server vs Tauri vs CLI
+
+| Aspect | Server | Tauri | CLI |
+|--------|--------|-------|-----|
+| **Process Model** | Embedded daemon | Spawned daemon | Connects to daemon |
+| **UI** | Web (React in browser) | WebView (React) | Terminal (TUI) |
+| **Daemon Communication** | Unix socket (proxied) | Unix socket (direct) | Unix socket (direct) |
+| **Platform Abstraction** | `platform: "web"` | `platform: "tauri"` | N/A |
+| **Access Model** | Remote (HTTP) | Local only | Local only |
+| **Auth** | HTTP Basic Auth | Not needed | Not needed |
+| **Deployment** | Docker, systemd | App bundle | Binary |
+
+## Authentication Flow
+
+```
+1. Browser makes request without auth
+ ↓
+2. basic_auth middleware checks state.auth
+ ↓
+3. If empty → allow (auth disabled)
+ If populated → require Basic Auth header
+ ↓
+4. Extract credentials from Authorization header
+ ↓
+5. Compare with state.auth HashMap
+ ↓
+6. Match → proceed to handler
+ No match → 401 Unauthorized
+```
+
+**Security considerations:**
+- Credentials stored in memory as `SecStr` (zeroed on drop)
+- Basic Auth over HTTPS recommended for production
+- Socket file has filesystem permissions (only accessible to server user)
+
+## Docker Architecture
+
+### Multi-stage Build
+
+```dockerfile
+# Stage 1: Builder (Debian + Rust + Node)
+FROM debian:bookworm-slim AS builder
+RUN install Rust, Node, pnpm
+COPY workspace
+RUN pnpm build (web)
+RUN cargo build --release --features assets
+
+# Stage 2: Runtime (Distroless)
+FROM gcr.io/distroless/cc-debian12:nonroot
+COPY --from=builder /build/target/release/sd-server
+ENTRYPOINT ["/usr/bin/sd-server"]
+```
+
+**Benefits:**
+- **Small image** - Distroless base (~50MB vs ~1GB for full Debian)
+- **Secure** - No shell, no package manager, minimal attack surface
+- **Fast** - Cached layers for dependencies
+- **Reproducible** - Locked versions via `Cargo.lock` and `pnpm-lock.yaml`
+
+### Volume Mounts
+
+```yaml
+volumes:
+ - spacedrive-data:/data # Persistent library data
+ - /mnt/storage:/storage:ro # Optional: Read-only media access
+```
+
+**Data layout:**
+```
+/data/
+├── daemon/
+│ └── daemon.sock # Unix socket for RPC
+├── libraries/
+│ └── *.sdlibrary/ # SQLite databases
+│ ├── library.db
+│ └── sidecars/ # Thumbnails, previews
+├── logs/
+│ ├── daemon.log
+│ └── indexing.log
+└── current_library_id.txt
+```
+
+## Development vs Production
+
+### Development Mode
+
+```bash
+# Terminal 1: Web dev server (hot reload)
+cd apps/web
+pnpm dev # → http://localhost:3000
+
+# Terminal 2: API server
+cargo run -p sd-server
+# → http://localhost:8080
+# Vite proxies /rpc to 8080
+```
+
+**Workflow:**
+1. Edit React components → Vite hot reloads
+2. Edit server code → `cargo run` rebuilds
+3. No need to rebuild web assets during development
+
+### Production Build
+
+```bash
+# Build with bundled assets
+cargo build --release -p sd-server --features assets
+
+# Single binary contains:
+# - Axum HTTP server
+# - Embedded daemon
+# - Bundled web UI (React app)
+```
+
+**Deployment:**
+```bash
+./target/release/sd-server \
+ --data-dir /var/lib/spacedrive \
+ --port 8080
+```
+
+## Platform Abstraction
+
+Both Tauri and Web use `@sd/interface`, but with different platform implementations:
+
+### Tauri Platform (`apps/tauri/src/platform.ts`)
+
+```typescript
+{
+ platform: "tauri",
+ openDirectoryPickerDialog: async () => open({ directory: true }),
+ revealFile: async (path) => invoke("reveal_file", { path }),
+ getCurrentLibraryId: async () => invoke("get_current_library_id"),
+ getDaemonStatus: async () => invoke("get_daemon_status"),
+ // Full native capabilities
+}
+```
+
+### Web Platform (`apps/web/src/platform.ts`)
+
+```typescript
+{
+ platform: "web",
+ openLink: (url) => window.open(url),
+ confirm: (msg, cb) => cb(window.confirm(msg)),
+ // Minimal browser-only capabilities
+}
+```
+
+**Interface components adapt:**
+```tsx
+function FilePickerButton() {
+ const platform = usePlatform();
+
+ if (platform.platform === "tauri") {
+ // Show native picker button
+ return ;
+ } else {
+ // Web: no native picker, show manual path input
+ return ;
+ }
+}
+```
+
+## Error Handling
+
+### HTTP Errors
+
+```rust
+async fn daemon_rpc(...) -> Result, (StatusCode, String)> {
+ let stream = UnixStream::connect(&socket_path)
+ .await
+ .map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, format!("Daemon not available: {}", e)))?;
+ // ...
+}
+```
+
+**Responses:**
+- `503 Service Unavailable` - Daemon not running
+- `400 Bad Request` - Invalid JSON
+- `500 Internal Server Error` - RPC failed
+- `401 Unauthorized` - Auth failed
+
+### Daemon Errors
+
+Daemon errors are passed through RPC response:
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "error": {
+ "code": -32603,
+ "message": "Library not found"
+ }
+}
+```
+
+## Performance Considerations
+
+1. **Static Assets** - Served from memory (embedded via `include_dir`)
+2. **Socket Pooling** - Each RPC request opens new socket (TODO: connection pool)
+3. **Async I/O** - Tokio runtime handles concurrent requests
+4. **Graceful Shutdown** - Waits for in-flight requests before terminating
+
+## Future Enhancements
+
+1. **WebSocket Support** - Real-time event streaming (vs polling)
+2. **HTTPS** - TLS termination (currently expects reverse proxy)
+3. **Connection Pool** - Reuse Unix sockets for RPC
+4. **Multi-tenancy** - Separate libraries per user
+5. **SSE Events** - Server-sent events for daemon notifications
+
+## Security Model
+
+**Trust Boundaries:**
+```
+Internet ←[TLS]→ Reverse Proxy ←[HTTP+Auth]→ Server ←[Unix Socket]→ Daemon
+ (❌) (✅) (✅) (✅) (✅)
+```
+
+**Assumptions:**
+- Server runs on trusted network OR behind reverse proxy with TLS
+- Unix socket accessible only to server process (filesystem permissions)
+- HTTP Basic Auth sufficient for home/NAS use
+- For public internet: Use nginx/Caddy with Let's Encrypt
+
+## Monitoring
+
+**Health Check:**
+```bash
+curl http://localhost:8080/health
+# → "OK"
+```
+
+**Logs:**
+```bash
+# Docker
+docker logs spacedrive -f
+
+# Systemd
+journalctl -u spacedrive -f
+
+# Native
+RUST_LOG=debug ./sd-server
+```
+
+**Metrics:** (TODO)
+- Request count/latency
+- Daemon socket errors
+- Active connections
+
+## Related Documentation
+
+- [README.md](./README.md) - Setup and usage
+- [../../docs/core/architecture.md](../../docs/core/architecture.md) - Core VDFS design
+- [../tauri/DAEMON_SETUP.md](../tauri/DAEMON_SETUP.md) - Tauri daemon integration
diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml
new file mode 100644
index 000000000..d949e337d
--- /dev/null
+++ b/apps/server/Cargo.toml
@@ -0,0 +1,50 @@
+[package]
+name = "sd-server"
+version = "0.1.0"
+edition = "2021"
+
+[features]
+default = []
+# Include bundled web assets (production builds)
+assets = []
+
+[dependencies]
+# Spacedrive core
+sd-core = { path = "../../core", features = ["ffmpeg", "heif"] }
+
+# HTTP server
+axum = "0.7"
+axum-extra = { version = "0.9", features = ["typed-header"] }
+http = "1.1"
+tokio = { version = "1", features = ["rt-multi-thread", "signal", "sync", "io-util"] }
+tower = "0.4"
+tower-http = { version = "0.5", features = ["fs", "cors"] }
+
+# Auth
+secstr = "0.5"
+
+# Static assets
+include_dir = "0.7"
+mime_guess = "2.0"
+
+# Serialization
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
+# Logging
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+
+# Error handling
+anyhow = "1"
+thiserror = "1"
+
+# CLI
+clap = { version = "4", features = ["derive"] }
+
+# Dev dependencies
+tempfile = "3"
+
+[[bin]]
+name = "sd-server"
+path = "src/main.rs"
diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile
new file mode 100644
index 000000000..00416eabf
--- /dev/null
+++ b/apps/server/Dockerfile
@@ -0,0 +1,100 @@
+# Spacedrive Server Docker Image
+# Multi-stage build for minimal production image
+
+ARG RUST_VERSION=1.83
+ARG NODE_VERSION=22
+
+#--
+# Base image with common dependencies
+#--
+FROM debian:bookworm-slim AS base
+
+# Configure apt for non-interactive use and caching
+RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
+RUN rm -f /etc/apt/apt.conf.d/docker-clean
+
+RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \
+ apt-get update && apt-get upgrade -y
+
+#--
+# Build environment
+#--
+FROM base AS builder
+
+# Install build dependencies
+RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \
+ apt-get install -y \
+ build-essential \
+ curl \
+ git \
+ pkg-config \
+ libssl-dev \
+ ca-certificates
+
+# Install Rust
+RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
+ENV PATH="/root/.cargo/bin:$PATH"
+
+# Install Node.js and pnpm
+RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \
+ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
+ echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \
+ apt-get update && \
+ apt-get install -y nodejs
+RUN npm install -g pnpm@latest
+
+WORKDIR /build
+
+# Copy workspace configuration
+COPY Cargo.toml Cargo.lock ./
+COPY .cargo ./.cargo
+
+# Copy source code
+COPY core ./core
+COPY crates ./crates
+COPY apps/server ./apps/server
+COPY apps/web ./apps/web
+COPY packages ./packages
+
+# Build web assets first
+WORKDIR /build/apps/web
+RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
+ pnpm install --frozen-lockfile
+RUN pnpm build
+
+# Build server with embedded assets
+WORKDIR /build
+RUN --mount=type=cache,target=/root/.cargo/registry \
+ --mount=type=cache,target=/root/.cargo/git \
+ --mount=type=cache,target=/build/target \
+ cargo build --release --features assets -p sd-server && \
+ cp target/release/sd-server /usr/local/bin/sd-server
+
+#--
+# Runtime image
+#--
+FROM gcr.io/distroless/cc-debian12:nonroot
+
+# Copy binary
+COPY --from=builder /usr/local/bin/sd-server /usr/bin/sd-server
+
+# Environment
+ENV DATA_DIR=/data \
+ PORT=8080 \
+ RUST_LOG=info,sd_core=debug
+
+# Expose HTTP port
+EXPOSE 8080
+
+# Expose P2P port
+EXPOSE 7373
+
+# Volume for persistent data
+VOLUME ["/data"]
+
+# Run as non-root user
+USER nonroot:nonroot
+
+# Start server
+ENTRYPOINT ["/usr/bin/sd-server"]
+CMD ["--data-dir", "/data"]
diff --git a/apps/server/README.md b/apps/server/README.md
new file mode 100644
index 000000000..5b35e2f75
--- /dev/null
+++ b/apps/server/README.md
@@ -0,0 +1,319 @@
+# Spacedrive Server
+
+HTTP server for Spacedrive with embedded daemon and web interface.
+
+## Overview
+
+`sd-server` runs the Spacedrive daemon and serves a web interface over HTTP. Perfect for:
+- **NAS deployments** (TrueNAS, Unraid, Synology, etc.)
+- **Headless servers**
+- **Remote access** to your Spacedrive libraries
+- **Docker/container environments**
+
+## Architecture
+
+```
+┌─────────────────────────────────────────┐
+│ sd-server (HTTP Server) │
+│ ┌───────────────────────────────────┐ │
+│ │ Axum HTTP Server (Port 8080) │ │
+│ │ ├─ /health (healthcheck) │ │
+│ │ ├─ /rpc (proxy to daemon) │ │
+│ │ └─ /* (web UI assets) │ │
+│ └───────────────────────────────────┘ │
+│ ↓ │
+│ ┌───────────────────────────────────┐ │
+│ │ Embedded Daemon │ │
+│ │ (Unix socket: daemon.sock) │ │
+│ │ ├─ Core VDFS │ │
+│ │ ├─ Indexing │ │
+│ │ ├─ P2P Networking │ │
+│ │ └─ File Operations │ │
+│ └───────────────────────────────────┘ │
+└─────────────────────────────────────────┘
+```
+
+Unlike Tauri (desktop app), the server:
+- Embeds the daemon instead of spawning a separate process
+- Serves the web UI as static assets (when built with `--features assets`)
+- Proxies RPC requests from browser to daemon via Unix socket
+- Provides basic auth for security
+
+## Quick Start
+
+### Development (without Docker)
+
+1. **Build the server:**
+ ```bash
+ # Without web assets (for API-only usage)
+ cargo build -p sd-server
+
+ # With bundled web UI
+ cargo build -p sd-server --features assets
+ ```
+
+2. **Run the server:**
+ ```bash
+ # Development mode (creates temp data dir)
+ cargo run -p sd-server
+
+ # Production mode (requires DATA_DIR)
+ DATA_DIR=/path/to/data cargo run -p sd-server --release --features assets
+ ```
+
+3. **Access the web UI:**
+ - Open http://localhost:8080
+ - Default auth: disabled in dev mode
+
+### Docker Deployment (Recommended)
+
+Perfect for TrueNAS, Unraid, or any Docker-compatible NAS.
+
+1. **Create a `.env` file:**
+ ```bash
+ # REQUIRED: Set your credentials
+ SD_AUTH=admin:your-secure-password
+
+ # Optional: Change port
+ PORT=8080
+
+ # Optional: Disable auth (NOT RECOMMENDED)
+ # SD_AUTH=disabled
+ ```
+
+2. **Start with docker-compose:**
+ ```bash
+ cd apps/server
+ docker-compose up -d
+ ```
+
+3. **Access the server:**
+ - Navigate to `http://your-nas-ip:8080`
+ - Login with credentials from `.env`
+
+## Configuration
+
+### Environment Variables
+
+| Variable | Description | Default | Required |
+|----------|-------------|---------|----------|
+| `DATA_DIR` | Path to Spacedrive data directory | `/data` (in Docker) | Yes (production) |
+| `PORT` | HTTP server port | `8080` | No |
+| `SD_AUTH` | Authentication credentials (format: `user:pass,user2:pass2`) | None | Recommended |
+| `SD_P2P` | Enable P2P networking | `true` | No |
+| `RUST_LOG` | Log level | `info,sd_core=debug` | No |
+
+### Authentication
+
+**IMPORTANT:** Always set `SD_AUTH` in production!
+
+```bash
+# Single user
+SD_AUTH=admin:securepassword123
+
+# Multiple users
+SD_AUTH=admin:pass1,user:pass2,readonly:pass3
+
+# Disable (NOT RECOMMENDED - only for trusted networks)
+SD_AUTH=disabled
+```
+
+Uses HTTP Basic Authentication. The server will return `401 Unauthorized` if credentials don't match.
+
+### Data Storage
+
+The server stores all data in `DATA_DIR`:
+```
+$DATA_DIR/
+├── daemon/
+│ └── daemon.sock # Unix socket for RPC
+├── libraries/
+│ └── *.sdlibrary/ # Library databases
+├── logs/ # Application logs
+└── current_library_id.txt # Last opened library
+```
+
+**Docker volumes:** Mounted at `/data` inside the container.
+
+## TrueNAS Setup
+
+### Using TrueNAS SCALE (Docker)
+
+1. **Navigate to Apps** in TrueNAS web UI
+2. **Click "Launch Docker Image"**
+3. **Configure:**
+ - **Image:** Build locally or use pre-built image
+ - **Port:** Map `8080` to host
+ - **Volume:** Mount `/mnt/pool/spacedrive` to `/data`
+ - **Environment:**
+ - `SD_AUTH=admin:yourpassword`
+ - `TZ=America/New_York` (your timezone)
+
+4. **Add storage pools** (optional):
+ - Mount your datasets as read-only volumes
+ - Example: `/mnt/tank/photos` → `/photos` in container
+
+### Manual Docker Run
+
+```bash
+docker run -d \
+ --name spacedrive \
+ -p 8080:8080 \
+ -p 7373:7373 \
+ -v /mnt/pool/spacedrive:/data \
+ -v /mnt/pool/media:/media:ro \
+ -e SD_AUTH=admin:password \
+ -e TZ=UTC \
+ --restart unless-stopped \
+ spacedrive/server:latest
+```
+
+## Building
+
+### With Web Assets (Production)
+
+```bash
+# Build everything (web UI + server)
+cargo build --release -p sd-server --features assets
+
+# The binary includes bundled web assets
+./target/release/sd-server --data-dir /path/to/data
+```
+
+### Without Assets (API Only)
+
+```bash
+# Build server without web UI
+cargo build --release -p sd-server
+
+# Serve API endpoints only
+./target/release/sd-server --data-dir /path/to/data
+```
+
+In this mode, you can connect with:
+- `sd-cli` (CLI client)
+- Custom HTTP clients via `/rpc`
+- Tauri desktop app configured to connect to this server
+
+## Development Workflow
+
+1. **Run web dev server:**
+ ```bash
+ cd apps/web
+ pnpm dev
+ ```
+ This starts Vite on http://localhost:3000 with hot reload.
+
+2. **Run API server:**
+ ```bash
+ cargo run -p sd-server
+ ```
+ This starts the HTTP server on http://localhost:8080.
+
+3. **Develop:**
+ - Edit React components in `apps/web/src`
+ - Edit server code in `apps/server/src`
+ - Vite proxies `/rpc` requests to the server
+
+## API Endpoints
+
+### `GET /health`
+Health check endpoint.
+
+**Response:** `200 OK` with body `"OK"`
+
+### `POST /rpc`
+JSON-RPC proxy to daemon.
+
+**Request:**
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "query:libraries.list",
+ "params": { "include_stats": false }
+}
+```
+
+**Response:**
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": [...]
+}
+```
+
+### `GET /*` (with `--features assets`)
+Serves the bundled web UI. All non-API routes fallback to `index.html` for SPA routing.
+
+## Comparison: Server vs Tauri
+
+| Feature | Server | Tauri |
+|---------|--------|-------|
+| **Platform** | Linux/Docker | macOS/Windows/Linux |
+| **UI** | Web (React in browser) | Native webview |
+| **Daemon** | Embedded in process | Spawned as child process |
+| **Access** | Remote over HTTP | Local only |
+| **Auth** | HTTP Basic Auth | Not needed (local) |
+| **Use Case** | NAS, headless servers | Desktop workstations |
+
+Both use the same Spacedrive core and `@sd/interface` package!
+
+## Troubleshooting
+
+### Server won't start
+- Check `DATA_DIR` exists and is writable
+- Verify port 8080 is not in use: `lsof -i :8080`
+- Check logs: `RUST_LOG=debug cargo run -p sd-server`
+
+### Can't connect to daemon
+- Ensure `daemon.sock` exists in `$DATA_DIR/daemon/`
+- Check daemon logs in `$DATA_DIR/logs/`
+- Try removing stale socket: `rm $DATA_DIR/daemon/daemon.sock`
+
+### Authentication failing
+- Verify `SD_AUTH` format: `username:password`
+- Check browser is sending Basic Auth header
+- Test with curl:
+ ```bash
+ curl -u admin:password http://localhost:8080/health
+ ```
+
+### Docker build failing
+- Ensure you're building from repository root:
+ ```bash
+ docker build -f apps/server/Dockerfile .
+ ```
+- Check Docker has enough memory (4GB+ recommended)
+
+## Contributing
+
+The server app is part of the Spacedrive v2 monorepo.
+
+**Project structure:**
+```
+apps/server/
+├── src/
+│ └── main.rs # Server implementation
+├── Cargo.toml # Dependencies
+├── build.rs # Web bundling script
+├── Dockerfile # Container image
+└── docker-compose.yml # Docker setup
+
+apps/web/
+├── src/
+│ ├── main.tsx # Web entry point
+│ └── platform.ts # Web platform implementation
+├── package.json
+└── vite.config.ts
+```
+
+**Making changes:**
+1. Server code: Edit `apps/server/src/main.rs`
+2. Web UI: Edit `apps/web/src/*` (uses `@sd/interface`)
+3. Platform integration: Edit `apps/web/src/platform.ts`
+
+## License
+
+AGPL-3.0 - See LICENSE file in repository root.
diff --git a/apps/server/TRUENAS_SETUP.md b/apps/server/TRUENAS_SETUP.md
new file mode 100644
index 000000000..1f4be8f1d
--- /dev/null
+++ b/apps/server/TRUENAS_SETUP.md
@@ -0,0 +1,406 @@
+# TrueNAS SCALE Deployment Guide
+
+Quick reference for deploying Spacedrive Server on TrueNAS SCALE using the GUI.
+
+## Quick Start
+
+### 1. Build and Transfer (on your Mac)
+
+```bash
+cd ~/Projects/spacedrive/apps/server
+
+# Build for TrueNAS
+./build-for-truenas.sh
+
+# Transfer to TrueNAS (replace with your IP)
+scp spacedrive-server-*.tar.gz root@192.168.1.100:/tmp/
+```
+
+### 2. Load Image (on TrueNAS)
+
+```bash
+# SSH into TrueNAS
+ssh root@192.168.1.100
+
+# Load the image
+gunzip -c /tmp/spacedrive-server-*.tar.gz | docker load
+
+# Verify
+docker images | grep spacedrive
+# Should show: spacedrive-server latest ...
+```
+
+### 3. Deploy via GUI
+
+Go to: **Apps** → **Discover Apps** → **Launch Docker Image**
+
+---
+
+## GUI Configuration
+
+### Container Images
+
+| Field | Value |
+|-------|-------|
+| Image Repository | `spacedrive-server` |
+| Image Tag | `latest` |
+| **Image Pull Policy** | **Never** ⚠️ (Use local image!) |
+
+### Container Settings
+
+| Field | Value |
+|-------|-------|
+| Container Name | `spacedrive` |
+| Restart Policy | Unless Stopped |
+
+### Networking
+
+#### Port Forwarding
+
+| Container Port | Protocol | Node Port | Description |
+|---------------|----------|-----------|-------------|
+| `8080` | TCP | `8080` | Web UI & API |
+| `7373` | TCP | `7373` | P2P Networking |
+
+**Access your server at:** `http://TRUENAS-IP:8080`
+
+### Storage
+
+#### Primary Data Volume
+
+| Field | Value |
+|-------|-------|
+| Type | Host Path (or ixVolume) |
+| Host Path | `/mnt/your-pool/spacedrive` |
+| Mount Path | `/data` |
+| Read Only | ❌ (needs write access) |
+
+**This stores:**
+- Library databases (`.sdlibrary/`)
+- Daemon socket
+- Logs
+- Thumbnails/sidecars
+
+#### Optional: Media Access
+
+Mount your existing media as read-only:
+
+| Field | Value |
+|-------|-------|
+| Type | Host Path |
+| Host Path | `/mnt/your-pool/media` |
+| Mount Path | `/media` |
+| Read Only | ✅ |
+
+Repeat for other datasets:
+- `/mnt/your-pool/photos` → `/photos`
+- `/mnt/your-pool/documents` → `/documents`
+
+### Environment Variables
+
+**Required:**
+
+| Name | Value | Description |
+|------|-------|-------------|
+| `SD_AUTH` | `admin:CHANGE_THIS_PASSWORD` | Authentication (username:password) |
+
+**Recommended:**
+
+| Name | Value | Description |
+|------|-------|-------------|
+| `TZ` | `America/New_York` | Your timezone |
+| `RUST_LOG` | `info,sd_core=debug` | Log level |
+
+**Optional:**
+
+| Name | Value | Description |
+|------|-------|-------------|
+| `PORT` | `8080` | HTTP port (if you want to change it) |
+| `SD_P2P` | `true` | Enable P2P (default: true) |
+
+### Health Check (Optional)
+
+| Field | Value |
+|-------|-------|
+| Type | HTTP |
+| Port | `8080` |
+| Path | `/health` |
+| Initial Delay | `30` seconds |
+| Timeout | `10` seconds |
+| Period | `30` seconds |
+
+### Resource Limits (Optional)
+
+**Recommended for NAS stability:**
+
+| Resource | Limit | Reservation |
+|----------|-------|-------------|
+| Memory | `2 GB` | `512 MB` |
+| CPU | - | - |
+
+---
+
+## Post-Installation
+
+### 1. Verify Container is Running
+
+In TrueNAS GUI:
+- **Apps** → **Installed** → Should see **spacedrive** with green status
+
+Or via shell:
+```bash
+docker ps | grep spacedrive
+```
+
+### 2. Check Logs
+
+In GUI: Click **spacedrive** → **Logs**
+
+Or via shell:
+```bash
+docker logs spacedrive
+```
+
+Should see:
+```
+🚀 Spacedrive Server listening on http://localhost:8080
+✓ Daemon started successfully
+```
+
+### 3. Access Web UI
+
+Open browser: `http://YOUR-TRUENAS-IP:8080`
+
+Login with credentials from `SD_AUTH`:
+- Username: `admin`
+- Password: (whatever you set)
+
+---
+
+## Updating the Server
+
+When you rebuild on your Mac:
+
+### 1. Build new image
+
+```bash
+cd ~/Projects/spacedrive/apps/server
+./build-for-truenas.sh
+```
+
+### 2. Transfer and load
+
+```bash
+# Transfer new tar
+scp spacedrive-server-*.tar.gz root@TRUENAS-IP:/tmp/
+
+# SSH and load
+ssh root@TRUENAS-IP
+gunzip -c /tmp/spacedrive-server-*.tar.gz | docker load
+```
+
+### 3. Restart container in GUI
+
+**Apps** → **Installed** → **spacedrive** → **Stop** → **Start**
+
+Or via shell:
+```bash
+docker restart spacedrive
+```
+
+The container will use the updated `spacedrive-server:latest` image.
+
+---
+
+## Troubleshooting
+
+### Container won't start
+
+**Check logs:**
+```bash
+docker logs spacedrive
+```
+
+**Common issues:**
+- Permission denied on `/data` → Check host path exists and is writable
+- Port already in use → Change `8080` to something else
+- Auth error → Verify `SD_AUTH` format is `username:password`
+
+### Can't connect to web UI
+
+1. Verify container is running: `docker ps | grep spacedrive`
+2. Check port mapping: Should show `0.0.0.0:8080->8080/tcp`
+3. Test from TrueNAS shell: `curl http://localhost:8080/health`
+4. Check firewall rules (TrueNAS should allow by default)
+
+### Daemon not starting
+
+**Check socket:**
+```bash
+docker exec spacedrive ls -la /data/daemon/
+```
+
+Should see `daemon.sock`
+
+**Check daemon logs:**
+```bash
+docker exec spacedrive cat /data/logs/daemon.log
+```
+
+### Wrong architecture error
+
+Make sure you built with `--platform linux/amd64`:
+```bash
+docker inspect spacedrive-server:latest | grep Architecture
+# Should show: "Architecture": "amd64"
+```
+
+If not, rebuild:
+```bash
+./build-for-truenas.sh
+```
+
+---
+
+## File Locations
+
+**On TrueNAS host:**
+```
+/mnt/your-pool/spacedrive/
+├── daemon/
+│ └── daemon.sock # Unix socket
+├── libraries/
+│ └── My Library.sdlibrary/
+│ ├── library.db # SQLite database
+│ └── sidecars/ # Thumbnails
+└── logs/
+ └── daemon.log
+```
+
+**Inside container:**
+```
+/data/ # Maps to host path above
+/media/ # Your media mounts (if configured)
+/photos/
+/documents/
+```
+
+---
+
+## Advanced Configuration
+
+### Custom Port
+
+If port 8080 is taken:
+
+1. Change environment variable: `PORT=9000`
+2. Update port forwarding: `9000 → 9000`
+3. Access at: `http://TRUENAS-IP:9000`
+
+### Multiple Instances
+
+Run multiple Spacedrive instances with different data dirs:
+
+**Instance 1 (Personal):**
+- Container name: `spacedrive-personal`
+- Host path: `/mnt/pool/spacedrive-personal`
+- Ports: `8080:8080`, `7373:7373`
+
+**Instance 2 (Work):**
+- Container name: `spacedrive-work`
+- Host path: `/mnt/pool/spacedrive-work`
+- Ports: `8081:8080`, `7374:7373`
+- Env: `INSTANCE=work`
+
+### Reverse Proxy (HTTPS)
+
+If you want HTTPS access, put behind nginx/Caddy:
+
+**Caddy example:**
+```
+spacedrive.yourdomain.com {
+ reverse_proxy localhost:8080
+ basicauth {
+ admin $2a$14$... # hashed password
+ }
+}
+```
+
+---
+
+## Security Notes
+
+**⚠️ IMPORTANT:**
+- **Always set SD_AUTH** - never use `SD_AUTH=disabled` on a network-accessible server
+- **Use strong passwords** - not `admin:changeme`
+- Consider **firewall rules** if exposing to internet
+- Run behind **reverse proxy with HTTPS** for public access
+- **Read-only mounts** for media you don't want Spacedrive to modify
+
+**Network access:**
+- `8080` → Web UI (needs auth)
+- `7373` → P2P (encrypted via QUIC/TLS)
+
+---
+
+## Backup
+
+**What to backup:**
+```
+/mnt/your-pool/spacedrive/libraries/
+```
+
+This contains your library databases and metadata.
+
+**How:**
+- TrueNAS snapshots (recommended)
+- Or periodic `rsync`/`tar` backup
+
+**Not needed:**
+- `daemon.sock` (recreated on start)
+- `logs/` (optional)
+
+---
+
+## Support
+
+**View logs:**
+```bash
+docker logs -f spacedrive
+```
+
+**Shell access:**
+```bash
+docker exec -it spacedrive sh
+```
+
+**Check daemon status:**
+```bash
+curl -u admin:yourpassword http://TRUENAS-IP:8080/health
+```
+
+Should return: `OK`
+
+---
+
+## Quick Reference Card
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ TrueNAS SCALE: Spacedrive Server │
+├─────────────────────────────────────────────────────────────┤
+│ │
+│ Image: spacedrive-server:latest │
+│ Pull: Never (use local image) │
+│ │
+│ Ports: 8080 (Web UI), 7373 (P2P) │
+│ │
+│ Volume: /mnt/pool/spacedrive → /data │
+│ │
+│ Env: SD_AUTH=admin:password (REQUIRED) │
+│ TZ=America/New_York │
+│ │
+│ Access: http://TRUENAS-IP:8080 │
+│ │
+└─────────────────────────────────────────────────────────────┘
+```
diff --git a/apps/server/build-for-truenas.sh b/apps/server/build-for-truenas.sh
new file mode 100755
index 000000000..f7d662b1c
--- /dev/null
+++ b/apps/server/build-for-truenas.sh
@@ -0,0 +1,126 @@
+#!/bin/bash
+# Build Spacedrive Server for TrueNAS deployment
+# Usage: ./build-for-truenas.sh [truenas-ip]
+
+set -e
+
+TRUENAS_IP="${1:-}"
+IMAGE_NAME="spacedrive-server"
+IMAGE_TAG="latest"
+TAR_FILE="spacedrive-server-$(date +%Y%m%d-%H%M%S).tar.gz"
+
+echo "🏗️ Building Spacedrive Server for linux/amd64..."
+echo ""
+
+# Check if buildx is available
+if ! docker buildx version &> /dev/null; then
+ echo "❌ docker buildx not found. Installing..."
+ docker buildx create --use
+fi
+
+# Build the image for linux/amd64 (TrueNAS architecture)
+echo "📦 Building Docker image..."
+cd ../.. # Go to repository root
+
+docker buildx build \
+ --platform linux/amd64 \
+ -f apps/server/Dockerfile \
+ -t ${IMAGE_NAME}:${IMAGE_TAG} \
+ --load \
+ .
+
+echo ""
+echo "✅ Build complete!"
+echo ""
+
+# Save the image to a tar file
+echo "💾 Saving image to ${TAR_FILE}..."
+docker save ${IMAGE_NAME}:${IMAGE_TAG} | gzip > "apps/server/${TAR_FILE}"
+
+IMAGE_SIZE=$(du -h "apps/server/${TAR_FILE}" | cut -f1)
+echo "✅ Image saved: apps/server/${TAR_FILE} (${IMAGE_SIZE})"
+echo ""
+
+# If TrueNAS IP provided, offer to transfer
+if [ -n "$TRUENAS_IP" ]; then
+ echo "📤 Transfer to TrueNAS?"
+ echo " Target: root@${TRUENAS_IP}"
+ echo ""
+ read -p "Continue? (y/n) " -n 1 -r
+ echo
+
+ if [[ $REPLY =~ ^[Yy]$ ]]; then
+ echo "Transferring..."
+ scp "apps/server/${TAR_FILE}" "root@${TRUENAS_IP}:/tmp/"
+
+ echo ""
+ echo "✅ Transfer complete!"
+ echo ""
+ echo "📋 Next steps on TrueNAS:"
+ echo " 1. SSH into TrueNAS: ssh root@${TRUENAS_IP}"
+ echo " 2. Load the image: gunzip -c /tmp/${TAR_FILE} | docker load"
+ echo " 3. Deploy via TrueNAS SCALE Apps UI (see instructions below)"
+ echo ""
+ fi
+else
+ echo "📋 Manual deployment steps:"
+ echo ""
+ echo "1. Transfer the image to TrueNAS:"
+ echo " scp apps/server/${TAR_FILE} root@YOUR-TRUENAS-IP:/tmp/"
+ echo ""
+ echo "2. SSH into TrueNAS:"
+ echo " ssh root@YOUR-TRUENAS-IP"
+ echo ""
+ echo "3. Load the image:"
+ echo " gunzip -c /tmp/${TAR_FILE} | docker load"
+ echo ""
+ echo "4. Verify it loaded:"
+ echo " docker images | grep spacedrive"
+ echo ""
+fi
+
+echo "═══════════════════════════════════════════════════════════════"
+echo "🎯 TrueNAS SCALE GUI Deployment Instructions"
+echo "═══════════════════════════════════════════════════════════════"
+echo ""
+echo "After loading the image on TrueNAS:"
+echo ""
+echo "1. Go to: Apps → Discover Apps → Launch Docker Image"
+echo ""
+echo "2. Container Settings:"
+echo " • Image Repository: ${IMAGE_NAME}"
+echo " • Image Tag: ${IMAGE_TAG}"
+echo " • Image Pull Policy: Never (important - use local image!)"
+echo " • Container Name: spacedrive"
+echo ""
+echo "3. Port Forwarding:"
+echo " • Container Port: 8080 → Node Port: 8080"
+echo " • Container Port: 7373 → Node Port: 7373"
+echo ""
+echo "4. Storage (Host Path Volumes):"
+echo " • Host Path: /mnt/YOUR-POOL/spacedrive"
+echo " Mount Path: /data"
+echo " Type: ixVolume (or Host Path)"
+echo ""
+echo " Optional - Mount your media:"
+echo " • Host Path: /mnt/YOUR-POOL/media"
+echo " Mount Path: /media"
+echo " Read Only: ✓"
+echo ""
+echo "5. Environment Variables:"
+echo " • SD_AUTH = admin:changeme (CHANGE THIS!)"
+echo " • TZ = America/New_York (your timezone)"
+echo " • RUST_LOG = info,sd_core=debug"
+echo ""
+echo "6. Health Check (optional but recommended):"
+echo " • Type: HTTP"
+echo " • Port: 8080"
+echo " • Path: /health"
+echo ""
+echo "7. Click 'Install'"
+echo ""
+echo "═══════════════════════════════════════════════════════════════"
+echo ""
+echo "🌐 Access: http://YOUR-TRUENAS-IP:8080"
+echo "🔐 Login with credentials from SD_AUTH"
+echo ""
diff --git a/apps/server/build.rs b/apps/server/build.rs
new file mode 100644
index 000000000..7aaea3db6
--- /dev/null
+++ b/apps/server/build.rs
@@ -0,0 +1,50 @@
+fn main() {
+ // Only build web assets if the assets feature is enabled
+ #[cfg(feature = "assets")]
+ {
+ use std::process::Command;
+ println!("cargo:rerun-if-changed=../web/src");
+ println!("cargo:rerun-if-changed=../web/index.html");
+ println!("cargo:rerun-if-changed=../web/package.json");
+
+ // Build the web app
+ let web_dir = std::env::current_dir()
+ .expect("Failed to get current dir")
+ .join("../web");
+
+ // Check if pnpm is available
+ let pnpm_check = Command::new("pnpm")
+ .arg("--version")
+ .output();
+
+ if pnpm_check.is_err() {
+ panic!("pnpm is required to build web assets. Install it with: npm install -g pnpm");
+ }
+
+ // Install dependencies
+ println!("Installing web dependencies...");
+ let install = Command::new("pnpm")
+ .arg("install")
+ .current_dir(&web_dir)
+ .status()
+ .expect("Failed to run pnpm install");
+
+ if !install.success() {
+ panic!("pnpm install failed");
+ }
+
+ // Build the web app
+ println!("Building web app...");
+ let build = Command::new("pnpm")
+ .arg("build")
+ .current_dir(&web_dir)
+ .status()
+ .expect("Failed to run pnpm build");
+
+ if !build.success() {
+ panic!("pnpm build failed");
+ }
+
+ println!("Web assets built successfully");
+ }
+}
diff --git a/apps/server/docker-compose.yml b/apps/server/docker-compose.yml
new file mode 100644
index 000000000..1f29edb65
--- /dev/null
+++ b/apps/server/docker-compose.yml
@@ -0,0 +1,60 @@
+services:
+ spacedrive:
+ build:
+ context: ../..
+ dockerfile: apps/server/Dockerfile
+ container_name: spacedrive-server
+ restart: unless-stopped
+
+ # Ports
+ ports:
+ - "8080:8080" # HTTP server
+ - "7373:7373" # P2P networking
+
+ # Volumes
+ volumes:
+ - spacedrive-data:/data
+ # Optional: Mount your file systems here
+ # - /mnt/storage:/storage:ro
+
+ # Environment variables
+ environment:
+ # Data directory (inside container)
+ - DATA_DIR=/data
+
+ # HTTP server port
+ - PORT=8080
+
+ # Authentication (REQUIRED for security!)
+ # Format: "username:password,username2:password2"
+ # Or set to "disabled" to disable auth (NOT RECOMMENDED)
+ - SD_AUTH=${SD_AUTH:-admin:changeme}
+
+ # Enable P2P networking
+ - SD_P2P=true
+
+ # Logging
+ - RUST_LOG=info,sd_core=debug
+
+ # Timezone
+ - TZ=UTC
+
+ # Health check
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+
+ # Resource limits (adjust as needed)
+ deploy:
+ resources:
+ limits:
+ memory: 2G
+ reservations:
+ memory: 512M
+
+volumes:
+ spacedrive-data:
+ driver: local
diff --git a/apps/server/docker-run.sh b/apps/server/docker-run.sh
new file mode 100755
index 000000000..53941bac9
--- /dev/null
+++ b/apps/server/docker-run.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+# Quick start script for running Spacedrive Server in Docker
+# Usage: ./docker-run.sh
+
+set -e
+
+echo "🚀 Starting Spacedrive Server with Docker Compose..."
+
+# Check if .env exists
+if [ ! -f .env ]; then
+ echo "⚠️ No .env file found. Creating from template..."
+ cp .env.example .env
+ echo ""
+ echo "📝 IMPORTANT: Edit .env and set your SD_AUTH credentials!"
+ echo " Default is 'admin:changeme' - please change this."
+ echo ""
+ read -p "Press enter to continue or Ctrl+C to abort..."
+fi
+
+# Check if docker-compose is available
+if ! command -v docker-compose &> /dev/null; then
+ if ! command -v docker &> /dev/null; then
+ echo "❌ Docker not found. Please install Docker first."
+ exit 1
+ fi
+ # Try docker compose (newer syntax)
+ COMPOSE_CMD="docker compose"
+else
+ COMPOSE_CMD="docker-compose"
+fi
+
+echo "🏗️ Building and starting container..."
+$COMPOSE_CMD up -d --build
+
+echo ""
+echo "✅ Spacedrive Server is running!"
+echo ""
+echo "📍 Access your server at: http://localhost:8080"
+echo "🔐 Login credentials: Check your .env file (SD_AUTH)"
+echo ""
+echo "Useful commands:"
+echo " - View logs: $COMPOSE_CMD logs -f spacedrive"
+echo " - Stop server: $COMPOSE_CMD down"
+echo " - Restart: $COMPOSE_CMD restart"
+echo " - Shell access: $COMPOSE_CMD exec spacedrive sh"
+echo ""
diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs
new file mode 100644
index 000000000..3428a0946
--- /dev/null
+++ b/apps/server/src/main.rs
@@ -0,0 +1,427 @@
+use axum::{
+ body::Body,
+ extract::{FromRequestParts, Request, State},
+ http::{header, HeaderValue, StatusCode},
+ middleware::{self, Next},
+ response::{IntoResponse, Response},
+ routing::{get, post},
+ Json, Router,
+};
+use axum_extra::{headers::authorization::Basic, headers::Authorization, TypedHeader};
+use clap::Parser;
+use secstr::SecStr;
+use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc};
+use tokio::{
+ io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
+ net::UnixStream,
+ signal,
+ sync::RwLock,
+};
+use tracing::{info, warn};
+
+#[cfg(feature = "assets")]
+static ASSETS_DIR: include_dir::Dir<'static> =
+ include_dir::include_dir!("$CARGO_MANIFEST_DIR/../web/dist");
+
+#[derive(Clone)]
+struct AppState {
+ auth: HashMap,
+ socket_path: PathBuf,
+}
+
+/// Basic auth middleware
+async fn basic_auth(State(state): State, request: Request, next: Next) -> Response {
+ let request = if !state.auth.is_empty() {
+ let (mut parts, body) = request.into_parts();
+
+ let Ok(TypedHeader(Authorization(hdr))) =
+ TypedHeader::>::from_request_parts(&mut parts, &()).await
+ else {
+ return Response::builder()
+ .status(401)
+ .header("WWW-Authenticate", "Basic realm=\"Spacedrive\"")
+ .body("Unauthorized".into_response().into_body())
+ .expect("hardcoded response will be valid");
+ };
+ let request = Request::from_parts(parts, body);
+
+ if state
+ .auth
+ .get(hdr.username())
+ .map(|pass| *pass == SecStr::from(hdr.password()))
+ != Some(true)
+ {
+ return Response::builder()
+ .status(401)
+ .header("WWW-Authenticate", "Basic realm=\"Spacedrive\"")
+ .body("Unauthorized".into_response().into_body())
+ .expect("hardcoded response will be valid");
+ }
+
+ request
+ } else {
+ request
+ };
+
+ next.run(request).await
+}
+
+/// Health check endpoint
+async fn health() -> &'static str {
+ "OK"
+}
+
+/// Proxy RPC requests to the daemon via Unix socket
+async fn daemon_rpc(
+ State(state): State,
+ Json(payload): Json,
+) -> Result, (StatusCode, String)> {
+ // Connect to daemon
+ let mut stream = UnixStream::connect(&state.socket_path)
+ .await
+ .map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, format!("Daemon not available: {}", e)))?;
+
+ // Send request
+ let request_line = serde_json::to_string(&payload)
+ .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)))?;
+
+ stream
+ .write_all(format!("{}\n", request_line).as_bytes())
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Write failed: {}", e)))?;
+
+ // Read response
+ let mut reader = BufReader::new(stream);
+ let mut response_line = String::new();
+
+ reader
+ .read_line(&mut response_line)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)))?;
+
+ // Parse and return
+ let response: serde_json::Value = serde_json::from_str(&response_line)
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid response: {}", e)))?;
+
+ Ok(Json(response))
+}
+
+#[cfg(feature = "assets")]
+async fn serve_asset(path: String) -> Response {
+ let path = path.trim_start_matches('/');
+
+ match ASSETS_DIR.get_file(path) {
+ Some(file) => Response::builder()
+ .status(StatusCode::OK)
+ .header(
+ header::CONTENT_TYPE,
+ HeaderValue::from_str(
+ mime_guess::from_path(path)
+ .first_or_text_plain()
+ .as_ref(),
+ )
+ .unwrap(),
+ )
+ .body(Body::from(file.contents()))
+ .unwrap(),
+ None => {
+ // Fallback to index.html for SPA routing
+ match ASSETS_DIR.get_file("index.html") {
+ Some(file) => Response::builder()
+ .status(StatusCode::OK)
+ .header(
+ header::CONTENT_TYPE,
+ HeaderValue::from_str("text/html").unwrap(),
+ )
+ .body(Body::from(file.contents()))
+ .unwrap(),
+ None => Response::builder()
+ .status(StatusCode::NOT_FOUND)
+ .body(Body::empty())
+ .unwrap(),
+ }
+ }
+ }
+}
+
+#[cfg(feature = "assets")]
+async fn serve_index() -> Response {
+ match ASSETS_DIR.get_file("index.html") {
+ Some(file) => Response::builder()
+ .status(StatusCode::OK)
+ .header(
+ header::CONTENT_TYPE,
+ HeaderValue::from_str("text/html").unwrap(),
+ )
+ .body(Body::from(file.contents()))
+ .unwrap(),
+ None => Response::builder()
+ .status(StatusCode::NOT_FOUND)
+ .body(Body::empty())
+ .unwrap(),
+ }
+}
+
+#[derive(Parser, Debug)]
+#[command(name = "spacedrive-server", about = "Spacedrive HTTP server")]
+struct Args {
+ /// Path to spacedrive data directory
+ #[arg(long, env = "DATA_DIR")]
+ data_dir: Option,
+
+ /// Port to bind HTTP server (default: 8080)
+ #[arg(long, env = "PORT", default_value = "8080")]
+ port: u16,
+
+ /// Authentication credentials (format: "username:password,username2:password2")
+ /// Set to "disabled" to disable auth (not recommended in production)
+ #[arg(long, env = "SD_AUTH")]
+ auth: Option,
+
+ /// Daemon instance name (for running multiple instances)
+ #[arg(long)]
+ instance: Option,
+
+ /// Enable P2P networking
+ #[arg(long, env = "SD_P2P", default_value = "true")]
+ p2p: bool,
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ // Initialize logging
+ tracing_subscriber::fmt()
+ .with_env_filter(
+ tracing_subscriber::EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| "info,sd_core=debug,sd_server=debug".into()),
+ )
+ .init();
+
+ let args = Args::parse();
+
+ // Resolve data directory
+ let base_data_dir = args
+ .data_dir
+ .unwrap_or_else(|| {
+ #[cfg(not(debug_assertions))]
+ {
+ std::env::var("DATA_DIR")
+ .expect("DATA_DIR must be set in production")
+ .into()
+ }
+ #[cfg(debug_assertions)]
+ {
+ std::env::var("DATA_DIR")
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| {
+ let temp = tempfile::tempdir().expect("Failed to create temp dir");
+ temp.path().to_path_buf()
+ })
+ }
+ });
+
+ // Calculate instance-specific paths
+ let (data_dir, socket_path) = if let Some(instance) = &args.instance {
+ let instance_data_dir = base_data_dir.join("instances").join(instance);
+ let socket_path = base_data_dir
+ .join("daemon")
+ .join(format!("daemon-{}.sock", instance));
+ (instance_data_dir, socket_path)
+ } else {
+ let socket_path = base_data_dir.join("daemon/daemon.sock");
+ (base_data_dir.clone(), socket_path)
+ };
+
+ info!("Data directory: {:?}", data_dir);
+ info!("Socket path: {:?}", socket_path);
+
+ // Parse authentication
+ let (auth, _disabled) = parse_auth(args.auth.as_deref());
+
+ // Require credentials in production builds (unless explicitly disabled)
+ #[cfg(not(debug_assertions))]
+ if auth.is_empty() && !_disabled {
+ warn!("The 'SD_AUTH' environment variable is not set!");
+ warn!("If you want to disable auth set 'SD_AUTH=disabled', or");
+ warn!("Provide your credentials in the following format 'SD_AUTH=username:password,username2:password2'");
+ std::process::exit(1);
+ }
+
+ // Ensure daemon directory exists
+ if let Some(parent) = socket_path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+
+ // Start the daemon if not already running
+ let daemon_handle = start_daemon_if_needed(socket_path.clone(), data_dir.clone(), args.p2p).await?;
+
+ // Build HTTP router
+ let state = AppState {
+ auth,
+ socket_path: socket_path.clone(),
+ };
+
+ let app = Router::new()
+ .route("/health", get(health))
+ .route("/rpc", post(daemon_rpc));
+
+ // Add asset serving routes if assets feature is enabled
+ #[cfg(feature = "assets")]
+ let app = app
+ .route("/", get(serve_index))
+ .route("/*path", get(|axum::extract::Path(path): axum::extract::Path| serve_asset(path)));
+
+ #[cfg(not(feature = "assets"))]
+ let app = app.route("/", get(|| async { "Spacedrive Server - Use with web client" }));
+
+ let app = app
+ .fallback(|| async {
+ (
+ StatusCode::NOT_FOUND,
+ "404 Not Found: We're past the event horizon...",
+ )
+ })
+ .layer(middleware::from_fn_with_state(state.clone(), basic_auth))
+ .with_state(state);
+
+ // Bind server
+ let mut addr = "[::]:8080".parse::().unwrap();
+ addr.set_port(args.port);
+
+ info!("🚀 Spacedrive Server listening on http://localhost:{}", args.port);
+ #[cfg(feature = "assets")]
+ info!("📦 Serving bundled web assets");
+ #[cfg(not(feature = "assets"))]
+ info!("📦 Asset serving disabled (use --features assets)");
+
+ // Setup graceful shutdown
+ let shutdown_signal = shutdown_signal(daemon_handle);
+
+ // Start server
+ let listener = tokio::net::TcpListener::bind(addr).await?;
+ axum::serve(listener, app)
+ .with_graceful_shutdown(shutdown_signal)
+ .await?;
+
+ Ok(())
+}
+
+/// Parse authentication credentials from env var
+fn parse_auth(auth_str: Option<&str>) -> (HashMap, bool) {
+ let Some(input) = auth_str else {
+ return (HashMap::new(), false);
+ };
+
+ if input == "disabled" {
+ return (HashMap::new(), true);
+ }
+
+ let credentials = input
+ .split(',')
+ .enumerate()
+ .filter_map(|(i, s)| {
+ if s.is_empty() {
+ return None;
+ }
+
+ let mut parts = s.split(':');
+ let result = parts
+ .next()
+ .and_then(|user| parts.next().map(|pass| (user.to_string(), SecStr::from(pass))));
+
+ if result.is_none() {
+ warn!("Found invalid credential {i}. Skipping...");
+ }
+ result
+ })
+ .collect();
+
+ (credentials, false)
+}
+
+/// Start the daemon if it's not already running
+async fn start_daemon_if_needed(
+ socket_path: PathBuf,
+ data_dir: PathBuf,
+ enable_p2p: bool,
+) -> Result