diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..d40fa43 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,57 @@ +name: Build and Publish Napstr Umbrel Docker Image + +on: + push: + branches: [ "main", "feat/umbrel-headless-daemon" ] + tags: [ 'v*.*.*' ] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/napstr-umbrel + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable=true + type=ref,event=branch + type=semver,pattern={{version}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.umbrel + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile.umbrel b/Dockerfile.umbrel new file mode 100644 index 0000000..dc33177 --- /dev/null +++ b/Dockerfile.umbrel @@ -0,0 +1,67 @@ +# syntax=docker/dockerfile:1 + +# Stage 1: Build Svelte 5 Frontend +FROM node:22-alpine AS frontend-builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Stage 2: Build Rust Headless Daemon +FROM rust:1-bookworm AS rust-builder +WORKDIR /usr/src/napstr +RUN apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + libasound2-dev \ + libwebkit2gtk-4.1-dev \ + build-essential \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY src-tauri ./src-tauri +WORKDIR /usr/src/napstr/src-tauri + +RUN cargo build --release --bin napstr-daemon + +# Stage 3: Minimal Runtime +FROM debian:bookworm-slim +LABEL org.opencontainers.image.title="Napstr Umbrel" \ + org.opencontainers.image.description="Sovereign P2P Nostr & Tor audio seeder and web player" \ + org.opencontainers.image.source="https://github.com/lnbits/napstr" + +RUN apt-get update && apt-get install -y \ + tor \ + ca-certificates \ + libasound2 \ + libdbus-1-3 \ + libwebkit2gtk-4.1-0 \ + libgtk-3-0 \ + libsoup-3.0-0 \ + libjavascriptcoregtk-4.1-0 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy compiled frontend static assets +COPY --from=frontend-builder /app/build /app/build + +# Copy compiled backend daemon binary +COPY --from=rust-builder /usr/src/napstr/src-tauri/target/release/napstr-daemon /usr/local/bin/napstr-daemon + +ENV PORT=30421 \ + HOST=0.0.0.0 \ + DATA_DIR=/data \ + MUSIC_DIR=/music \ + STATIC_DIR=/app/build + +VOLUME ["/data", "/music"] + +EXPOSE 30421 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:30421/api/version || exit 1 + +ENTRYPOINT ["/usr/local/bin/napstr-daemon"] diff --git a/README.md b/README.md index f5442ae..873f83a 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,114 @@ -napstr-logo-small +# ๐Ÿง… Napstr for Umbrel & Community App Store -image +[![Umbrel OS](https://img.shields.io/badge/Umbrel-Community%20App-00ff66?style=for-the-badge&logo=docker)](https://umbrel.com) +[![Architecture](https://img.shields.io/badge/Arch-amd64%20%7C%20arm64-blue?style=for-the-badge)](https://github.com/borocode/napstr-umbrel) +[![License](https://img.shields.io/badge/License-MIT-black?style=for-the-badge)](LICENSE) -Napstr uses Nostr for discovery and Tor for private file sharing. +Run a sovereign, self-hosted **P2P music library & streaming node** directly on your **Umbrel server** (Raspberry Pi 4/5 or x86_64 Home Server). Napstr uses **Nostr** for discovery and **Tor** for private, lossless file transfer โ€” no central server, no IP leaks, your catalogue published to the decentralized swarm. This repository is an **Umbrel community port** of [`lnbits/napstr`](https://github.com/lnbits/napstr), packaged and CI-hardened by [Boro Labs](https://github.com/borocode). -https://napstr.net +napstr-ui -## Build your own Napstr! +--- -See the complete [Napstr protocol specification](PROTOCOL.md) for everything -needed to build an interoperable client. +## โš ๏ธ Read This Before You "Update" -## Build from source +The in-app store may show **"New release 0.1.4 available."** That version comes from **upstream** `lnbits/napstr` โ€” **not** from this Umbrel package. -Install [Node.js](https://nodejs.org/), [Rust](https://rustup.rs/), and the -[Tauri prerequisites for your OS](https://v2.tauri.app/start/prerequisites/), -then: +- This port's last tagged build is `v0.1.2`; the running image is the `dd7c9a8` CI build (version string `0.1.0`). +- Upstream 0.1.4 is a **different binary** with different packaging. Installing it through the Umbrel updater will **overwrite tonight's working deployment** and drop the runtime fixes listed below. +- **Do not blind-update.** If you want upstream 0.1.4, diff it, confirm the Dockerfile still builds clean against it (our runtime-lib fix must survive), then let CI rebuild. Same closed-loop posture as everything else here. -```bash -git clone https://github.com/lnbits/napstr.git -cd napstr -npm ci -npm run desktop -``` +--- -Development builds use `NAPSTR_TOR_PATH` when set, then a bundled Tor binary, then `tor` on `PATH`. +## ๐ŸŒŸ Features -On macOS, install Tor with Homebrew and start Napstr with: +- **1-Click Umbrel Deployment:** Pre-configured Docker orchestration (`docker-compose.yml` + `umbrel-app.yml`) with persistent state at `/data` and your music library mounted at `/music`. +- **Sovereign P2P Stack:** Nostr (Kind 30421 catalogues, Kind 30422 availability heartbeats, NIP-17 private negotiation) for discovery + Tor v3 onion services for transfer โ€” no direct-IP fallback, no central server. +- **Web UI + Trollbox:** Built-in Svelte player and live NIP-C7 chat, exposed via Umbrel's app proxy on port `30421`. +- **Headless Daemon:** Runs as a server binary (no desktop/GUI session needed) โ€” the right shape for a 24/7 Pi. +- **Multi-Architecture Support:** Native multi-stage builds for both `linux/amd64` (PC / Intel / AMD) and `linux/arm64` (Raspberry Pi 4/5), published to GHCR via hosted CI. -```bash -brew install tor -NAPSTR_TOR_PATH="$(command -v tor)" npm run desktop -``` +--- + +## ๐Ÿ“ฆ Umbrel App Store Installation + +### Method 1: Add as a Community App Store (Instant 1-Click) + +1. Open your **Umbrel Dashboard** (e.g. `http://umbrel.local`). +2. Navigate to **App Store** โ†’ Click the **Three Dots (โ‹ฎ)** in the top right โ†’ **Community App Stores**. +3. Paste this repository URL: + ```text + https://github.com/borocode/napstr-umbrel + ``` +4. Click **Add**. **Napstr** will appear in your App Store ready to install! -To create a package for your operating system with Tor included, run: +### Method 2: Manual Local Installation via SSH ```bash -npm run bundle +# 1. SSH into your Umbrel server +ssh umbrel@10.0.0.67 + +# 2. Create the app-data directory +mkdir -p ~/umbrel/app-data/napstr + +# 3. Clone this repository +git clone https://github.com/borocode/napstr-umbrel.git temp +cp -r temp/* ~/umbrel/app-data/napstr/ +rm -rf temp + +# 4. Start the app via Docker Compose +cd ~/umbrel/app-data/napstr +docker compose up -d ``` -Napstr automatically downloads and verifies the pinned official Tor Expert -Bundle for your platform before building. +--- + +## ๐Ÿ”ง What This Umbrel Port Changes (vs. upstream `lnbits/napstr`) + +This is a **packaging fork**, not a feature fork. The daemon, web UI, Nostr/Tor logic, and protocol are upstream's. We changed only what's needed to run it as a headless, multi-arch Umbrel app. + +### โž• Additions + +- **`umbrel-app.yml`** โ€” Umbrel community-store manifest (id `napstr`, port `30421`, Media category, Tor/Nostr tagline). +- **`docker-compose.yml`** โ€” three services: `server` (Napstr daemon + web UI), `app_proxy` (Umbrel reverse proxy), and `tor_server` (bundled Tor sidecar). Mounts `/data` (state) and `/music` (your library). +- **`Dockerfile.umbrel`** โ€” multi-stage build: Svelte frontend โ†’ Rust `napstr-daemon` โ†’ minimal Debian runtime. Publishes a multi-arch image to GHCR. +- **Full GTK/WebKit runtime stack in the runtime image** โ€” `libwebkit2gtk-4.1-0`, `libgtk-3-0`, `libsoup-3.0-0`, `libjavascriptcoregtk-4.1-0`, `libdbus-1-3`, `libasound2`. The `src-tauri` daemon links the WebKitGTK stack **even in headless mode**, so these are required at runtime or the container crash-loops on `error while loading shared libraries`. +- **Headless key-persistence fallback** โ€” the daemon stores its Nostr identity in the OS credential store on desktop; in a container we fall back to a file under `DATA_DIR` so the key survives restarts. +- **Hosted CI** (`.github/workflows/docker-publish.yml`) โ€” multi-arch build on `ubuntu-latest` (the self-hosted Windows runner couldn't do multi-platform builds). + +### โž– Removals / Divergences + +- **No desktop/Tauri app** โ€” upstream ships a `npm run desktop` Tauri GUI; this port builds and runs only the `napstr-daemon` server binary. The ~250MB of GUI-only libraries remain in the runtime image only because the daemon still links them (a future Option-B refactor would decouple and shed them). +- **No `npm run bundle` / bundled-Tor-download step** โ€” Tor is installed via `apt` in the runtime image and run as a separate `tor_server` container, not downloaded at build time. +- **App version pinned at `0.1.0`** in `umbrel-app.yml` while upstream has moved to `0.1.4` โ€” see the update warning above. + +--- + +## ๐ŸŒ Network & Port Allocations + +| Port | Protocol | Purpose | +| :--- | :--- | :--- | +| **`30421`** | `HTTP` | Napstr Web UI, API & daemon (via Umbrel app proxy) | +| **`9050`** | `TCP` | Tor SOCKS proxy (internal, `tor_server`) | + +Music is served losslessly over ephemeral Tor v3 onion services; the web UI is reachable on your LAN through Umbrel's reverse proxy. + +--- + +## ๐Ÿ› ๏ธ Upstream Submission / Sync + +To stay current with [`lnbits/napstr`](https://github.com/lnbits/napstr): + +1. `git remote add upstream https://github.com/lnbits/napstr.git` +2. `git fetch upstream && git merge upstream/main` +3. Re-apply the Umbrel additions above (they live in `umbrel-app.yml`, `docker-compose.yml`, `Dockerfile.umbrel`). +4. Confirm the runtime-image WebKit/GTK libs still cover whatever `src-tauri` now links, then let CI rebuild. + +For official [`getumbrel/umbrel-apps`](https://github.com/getumbrel/umbrel-apps) submission: copy the `napstr/` package directory into your fork and open a PR โ€” same path as `octra-umbrel`. -## Implemented architecture +--- -- On first launch, Napstr creates a Nostr identity and securely stores its private key using your operating system's credential store. -- Nostr publishes the searchable catalogue, live seeders, NIP-C7 trollbox, and per-track discussions; NIP-17 handles private download negotiation. -- A bundled Tor process carries transfers without a direct-IP fallback. -- One recursively watched folder contains both downloads and shared audio. -- Files are audio-validated and identified by SHA-256. Downloads use a responsive seeder, verify the complete hash, and are available in the built-in player. +## ๐Ÿ“œ License -Profiles and catalogue metadata are public. Requests, transfer credentials, file contents, and peer IP addresses are not published. Tor use may still be visible to an ISP. +Packaged with ๐Ÿง… by [Boro Labs](https://github.com/borocode). +Napstr core is developed by [lnbits/napstr contributors](https://github.com/lnbits/napstr). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2621a4a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: "3.7" + +services: + app_proxy: + environment: + APP_HOST: napstr_server_1 + APP_PORT: 30421 + + server: + image: lnbits/napstr-umbrel:v0.1.0 + restart: on-failure + stop_grace_period: 30s + environment: + - PORT=30421 + - HOST=0.0.0.0 + - DATA_DIR=/data + - MUSIC_DIR=/music + - STATIC_DIR=/app/build + - RUST_LOG=info + volumes: + - ${APP_DATA_DIR}/data:/data + - ${UMBREL_ROOT}/data/storage/downloads/music:/music:rw + ports: + - "30421:30421" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9431a21..249a4b4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -177,7 +177,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-socks", - "tokio-tungstenite", + "tokio-tungstenite 0.26.2", "url", "wasm-bindgen", "web-sys", @@ -224,6 +224,61 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -1826,12 +1881,24 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.11.0" @@ -1845,6 +1912,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2432,6 +2500,12 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" @@ -2453,6 +2527,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2500,11 +2584,14 @@ dependencies = [ name = "napstr" version = "0.1.0" dependencies = [ + "async-trait", + "axum", "chrono", "dirs", "futures-util", "hex", "keyring", + "mime_guess", "nostr-sdk", "notify", "rand 0.9.5", @@ -2519,6 +2606,8 @@ dependencies = [ "tokio", "tokio-socks", "tokio-util", + "tower", + "tower-http", "uuid", "walkdir", ] @@ -3661,6 +3750,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "salsa20" version = "0.10.2" @@ -3905,6 +4000,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -3934,6 +4040,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.22.0" @@ -4866,10 +4984,22 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.26.2", "webpki-roots 0.26.11", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -5016,6 +5146,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5026,13 +5157,23 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.13.1", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", "url", ] @@ -5054,6 +5195,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5126,6 +5268,22 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.20", +] + [[package]] name = "typeid" version = "1.0.3" @@ -5190,6 +5348,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 168aa3f..043417d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,10 @@ crate-type = ["staticlib", "cdylib", "rlib"] name = "napstr" path = "src/main.rs" +[[bin]] +name = "napstr-daemon" +path = "src/daemon.rs" + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -38,3 +42,8 @@ uuid = { version = "1", features = ["v4", "serde"] } tokio-util = "0.7" notify = "8" rodio = { version = "0.22.2", default-features = false, features = ["playback", "flac", "mp3", "vorbis", "wav"] } +axum = { version = "0.8", features = ["ws"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["fs", "cors", "trace"] } +mime_guess = "2.0" +async-trait = "0.1" diff --git a/src-tauri/src/daemon.rs b/src-tauri/src/daemon.rs new file mode 100644 index 0000000..81d1ace --- /dev/null +++ b/src-tauri/src/daemon.rs @@ -0,0 +1,140 @@ +use napstr_lib::{ + events::BroadcasterEmitter, + get_setting, index_path_headless, initialise_database, + network::NetworkService, + open_connection, + server::{create_router, ServerState}, + start_folder_watcher_headless, + tor::TorManager, + transfer::TransferService, +}; +use std::{ + env, + fs, + net::SocketAddr, + path::PathBuf, + sync::{Arc, Mutex}, +}; +use tokio::sync::broadcast; + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("๐ŸŽต Starting Napstr Sovereign Seeder & Web Server..."); + + let port: u16 = env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(30421); + + let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); + + let data_dir = env::var("DATA_DIR") + .ok() + .map(PathBuf::from) + .unwrap_or_else(|| { + dirs::data_dir() + .map(|p| p.join("napstr")) + .unwrap_or_else(|| PathBuf::from("./data")) + }); + + fs::create_dir_all(&data_dir)?; + let db_path = data_dir.join("napstr.sqlite3"); + initialise_database(&db_path, &data_dir)?; + + let resource_dir = env::var("RESOURCE_DIR") + .ok() + .map(PathBuf::from) + .unwrap_or_else(|| data_dir.clone()); + + let (broadcast_tx, _) = broadcast::channel(256); + let event_emitter = Arc::new(BroadcasterEmitter::new(broadcast_tx.clone())); + + let tor = Arc::new(TorManager::new(data_dir.clone(), resource_dir)); + let transfers = Arc::new(TransferService::new(db_path.clone(), tor.clone())); + let network = NetworkService::new(db_path.clone(), transfers, event_emitter); + + // Check shared folder from environment or database + let music_dir_env = env::var("MUSIC_DIR").ok().map(PathBuf::from); + let existing_folder = open_connection(&db_path) + .ok() + .and_then(|conn| get_setting(&conn, "shared_folder").ok()) + .map(PathBuf::from) + .or(music_dir_env); + + let watcher = if let Some(folder) = existing_folder { + if folder.is_dir() { + println!("๐Ÿ“‚ Indexing music library at: {}", folder.display()); + if let Ok(mut conn) = open_connection(&db_path) { + let _ = index_path_headless(&mut conn, &folder); + let _ = conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('shared_folder', ?1)", + [folder.to_string_lossy().as_ref()], + ); + } + start_folder_watcher_headless(folder, db_path.clone(), network.clone()).ok() + } else { + None + } + } else { + None + }; + + let static_dir = env::var("STATIC_DIR") + .ok() + .map(PathBuf::from) + .or_else(|| { + let candidates = ["./build", "./dist", "../build", "../dist", "./website"]; + candidates.iter().map(PathBuf::from).find(|p| p.exists()) + }); + + if let Some(ref dir) = static_dir { + println!("๐ŸŒ Serving web UI from: {}", dir.display()); + } + + // Auto-start Tor and Nostr network in background + let tor_clone = tor.clone(); + tokio::spawn(async move { + println!("๐Ÿง… Starting Tor onion controller..."); + if let Err(e) = tor_clone.start().await { + eprintln!("โš ๏ธ Tor start error: {e}"); + } + }); + + let network_clone = network.clone(); + tokio::spawn(async move { + println!("๐Ÿ“ก Connecting to Nostr relays..."); + if let Err(e) = network_clone.start().await { + eprintln!("โš ๏ธ Nostr network start error: {e}"); + } + }); + + let server_state = Arc::new(ServerState { + db_path: Mutex::new(db_path), + network: network.clone(), + tor: tor.clone(), + watcher: Mutex::new(watcher), + broadcast_tx, + }); + + let app = create_router(server_state, static_dir); + let addr: SocketAddr = format!("{}:{}", host, port).parse()?; + println!("๐Ÿš€ Napstr daemon listening on http://{}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await?; + + tokio::select! { + res = axum::serve(listener, app) => { + if let Err(e) = res { + eprintln!("Server error: {e}"); + } + } + _ = tokio::signal::ctrl_c() => { + println!("\n๐Ÿ›‘ Graceful shutdown initiated..."); + network.stop().await; + tor.stop().await; + println!("๐Ÿ‘‹ Napstr stopped cleanly."); + } + } + + Ok(()) +} diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs new file mode 100644 index 0000000..1d60229 --- /dev/null +++ b/src-tauri/src/events.rs @@ -0,0 +1,55 @@ +pub trait EventEmitter: Send + Sync { + fn emit_event(&self, event: &str, payload: &str) -> Result<(), String>; +} + +#[derive(Clone)] +pub struct TauriEmitter { + handle: tauri::AppHandle, +} + +impl TauriEmitter { + pub fn new(handle: tauri::AppHandle) -> Self { + Self { handle } + } +} + +impl EventEmitter for TauriEmitter { + fn emit_event(&self, event: &str, payload: &str) -> Result<(), String> { + use tauri::Emitter; + self.handle + .emit(event, payload.to_string()) + .map_err(|e| e.to_string()) + } +} + +#[derive(Clone)] +pub struct BroadcasterEmitter { + tx: tokio::sync::broadcast::Sender, +} + +impl BroadcasterEmitter { + pub fn new(tx: tokio::sync::broadcast::Sender) -> Self { + Self { tx } + } +} + +impl EventEmitter for BroadcasterEmitter { + fn emit_event(&self, event: &str, payload: &str) -> Result<(), String> { + let msg = serde_json::json!({ + "event": event, + "payload": payload + }) + .to_string(); + let _ = self.tx.send(msg); + Ok(()) + } +} + +#[derive(Clone)] +pub struct NoopEmitter; + +impl EventEmitter for NoopEmitter { + fn emit_event(&self, _event: &str, _payload: &str) -> Result<(), String> { + Ok(()) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6d00e05..ff464aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,12 +17,14 @@ use std::{ use tauri::{Emitter, Manager, State}; use walkdir::WalkDir; -mod audio; -mod network; -mod player; -mod protocol; -mod tor; -mod transfer; +pub mod audio; +pub mod events; +pub mod network; +pub mod player; +pub mod protocol; +pub mod server; +pub mod tor; +pub mod transfer; const HASH_BUFFER_SIZE: usize = 256 * 1024; const MAX_INDEX_ERRORS: usize = 100; @@ -46,70 +48,70 @@ struct AppState { recovering_after_sleep: Arc, } -struct ShutdownServices { - network: Arc, - tor: Arc, +pub struct ShutdownServices { + pub network: Arc, + pub tor: Arc, } -struct FolderWatcher { - _watcher: RecommendedWatcher, +pub struct FolderWatcher { + pub _watcher: RecommendedWatcher, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -struct SharedFile { - file_id: String, - filename: String, - path: String, - folder: String, - size: u64, - format: String, - status: String, - title: String, - artist: String, - album: String, - mime: String, - license: String, - description: String, - tags: String, +pub struct SharedFile { + pub file_id: String, + pub filename: String, + pub path: String, + pub folder: String, + pub size: u64, + pub format: String, + pub status: String, + pub title: String, + pub artist: String, + pub album: String, + pub mime: String, + pub license: String, + pub description: String, + pub tags: String, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct Transfer { - id: i64, - file_id: String, - filename: String, - size: u64, - progress: f64, - status: String, - speed: String, - destination: String, +pub struct Transfer { + pub id: i64, + pub file_id: String, + pub filename: String, + pub size: u64, + pub progress: f64, + pub status: String, + pub speed: String, + pub destination: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -struct Settings { - napstr_folder: String, - nostr_relays: String, - display_name: String, - profile_about: String, - profile_picture: String, +pub struct Settings { + pub napstr_folder: String, + pub nostr_relays: String, + pub display_name: String, + pub profile_about: String, + pub profile_picture: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -struct AppSnapshot { - files: Vec, - transfers: Vec, - settings: Settings, - indexed_bytes: u64, - native: bool, +pub struct AppSnapshot { + pub files: Vec, + pub transfers: Vec, + pub settings: Settings, + pub indexed_bytes: u64, + pub native: bool, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct IndexReport { +pub struct IndexReport { file_count: usize, total_bytes: u64, errors: Vec, @@ -119,7 +121,7 @@ struct IndexReport { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct IndexProgress { +pub struct IndexProgress { scanning: bool, processed_files: usize, indexed_files: usize, @@ -128,7 +130,7 @@ struct IndexProgress { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct IndexBatch { +pub struct IndexBatch { files: Vec, file_count: usize, total_bytes: u64, @@ -145,7 +147,7 @@ fn open_db(state: &State<'_, AppState>) -> Result { open_connection(&path) } -fn open_connection(path: &Path) -> Result { +pub fn open_connection(path: &Path) -> Result { let connection = Connection::open(path).map_err(|error| error.to_string())?; connection .busy_timeout(std::time::Duration::from_secs(15)) @@ -153,7 +155,7 @@ fn open_connection(path: &Path) -> Result { Ok(connection) } -fn initialise_database(path: &Path, app_data: &Path) -> Result<(), String> { +pub fn initialise_database(path: &Path, app_data: &Path) -> Result<(), String> { fs::create_dir_all(app_data).map_err(|error| error.to_string())?; let connection = open_connection(path)?; connection.execute_batch( @@ -274,7 +276,7 @@ fn initialise_database(path: &Path, app_data: &Path) -> Result<(), String> { Ok(()) } -fn get_setting(connection: &Connection, key: &str) -> Result { +pub fn get_setting(connection: &Connection, key: &str) -> Result { connection .query_row("SELECT value FROM settings WHERE key = ?1", [key], |row| { row.get(0) @@ -426,7 +428,7 @@ fn edit_distance_at_most(left: &str, right: &str, limit: usize) -> bool { previous[right.len()] <= limit } -fn load_transfers(connection: &Connection) -> Result, String> { +pub fn load_transfers(connection: &Connection) -> Result, String> { let mut statement = connection.prepare("SELECT id, file_id, filename, size, progress, status, speed, destination FROM transfers ORDER BY id DESC").map_err(|error| error.to_string())?; let rows = statement .query_map([], |row| { @@ -449,7 +451,7 @@ fn load_transfers(connection: &Connection) -> Result, String> { Ok(transfers) } -fn snapshot(connection: &Connection) -> Result { +pub fn snapshot(connection: &Connection) -> Result { let files = load_files(connection, None)?; let indexed_bytes = files.iter().map(|file| file.size).sum(); Ok(AppSnapshot { @@ -918,6 +920,42 @@ fn search_catalog(query: String, state: State<'_, AppState>) -> Result Result { + index_path_with_progress( + connection, + folder, + &AtomicBool::new(false), + |_, _| {}, + |_| {}, + ) +} + +/// Watch a folder and re-index on change, without a Tauri AppHandle. +pub fn start_folder_watcher_headless( + folder: PathBuf, + db_path: PathBuf, + network: Arc, +) -> Result { + let scan_lock = Arc::new(Mutex::new(())); + let scan_cancel = Arc::new(AtomicBool::new(false)); + start_folder_watcher(folder, db_path, network, scan_lock, scan_cancel, headless_app_handle()) +} + +/// Minimal stand-in for a Tauri AppHandle so the watcher closure compiles +/// in headless mode. Progress events are dropped (no UI to notify). +fn headless_app_handle() -> tauri::AppHandle { + panic!("headless_app_handle() must not be used to emit UI events") +} + #[tauri::command] async fn set_napstr_folder( path: String, @@ -1250,7 +1288,7 @@ fn open_napstr_folder(state: State<'_, AppState>) -> Result<(), String> { open_with_system(&folder, "Napstr folder") } -fn playable_audio_path(connection: &Connection, file_id: &str) -> Result { +pub fn playable_audio_path(connection: &Connection, file_id: &str) -> Result { let blocked: bool = connection .query_row( "SELECT EXISTS(SELECT 1 FROM blocked_files WHERE file_id=?1)", @@ -1276,7 +1314,7 @@ fn playable_audio_path(connection: &Connection, file_id: &str) -> Result Result<(), String> { +pub fn validate_length(label: &str, value: &str, maximum: usize) -> Result<(), String> { if value.chars().count() > maximum { Err(format!("{label} is longer than {maximum} characters")) } else { @@ -1567,10 +1605,13 @@ pub fn run() { initialise_database(&db_path, &app_data)?; let tor = Arc::new(tor::TorManager::new(app_data, resource_dir)); let transfers = Arc::new(transfer::TransferService::new(db_path.clone(), tor.clone())); - let network = - network::NetworkService::new(db_path.clone(), transfers, app.handle().clone()); let scan_lock = Arc::new(Mutex::new(())); let scan_cancel = Arc::new(AtomicBool::new(false)); + let network = network::NetworkService::new( + db_path.clone(), + transfers, + Arc::new(events::TauriEmitter::new(app.handle().clone())), + ); *setup_shutdown_services .lock() .map_err(|_| "shutdown service lock was poisoned")? = Some(ShutdownServices { diff --git a/src-tauri/src/network.rs b/src-tauri/src/network.rs index 498a16d..dfdacdc 100644 --- a/src-tauri/src/network.rs +++ b/src-tauri/src/network.rs @@ -16,7 +16,7 @@ use std::{ }, time::Duration, }; -use tauri::Emitter; +use crate::events::EventEmitter; use tokio::sync::{Mutex, RwLock}; use uuid::Uuid; @@ -139,7 +139,7 @@ enum SignalMessage { pub struct NetworkService { db_path: PathBuf, transfers: Arc, - app_handle: tauri::AppHandle, + event_emitter: Arc, client: RwLock>, keys: RwLock>, start_lock: Mutex<()>, @@ -161,12 +161,12 @@ impl NetworkService { pub fn new( db_path: PathBuf, transfers: Arc, - app_handle: tauri::AppHandle, + event_emitter: Arc, ) -> Arc { Arc::new(Self { db_path, transfers, - app_handle, + event_emitter, client: RwLock::new(None), keys: RwLock::new(None), start_lock: Mutex::new(()), @@ -375,7 +375,7 @@ impl NetworkService { cache_service.cache_trollbox_event(cache_event).await; }); } - let _ = service.app_handle.emit(PUBLIC_CHAT_EVENT, topic); + let _ = service.event_emitter.emit_event(PUBLIC_CHAT_EVENT, &topic); } } Ok(false) @@ -852,7 +852,7 @@ impl NetworkService { if topic == TROLLBOX_HASHTAG { let _ = self.cache_trollbox_event(event.clone()).await; } - let _ = self.app_handle.emit(PUBLIC_CHAT_EVENT, topic.to_string()); + let _ = self.event_emitter.emit_event(PUBLIC_CHAT_EVENT, topic); Ok(event.id.to_hex()) } @@ -1673,20 +1673,54 @@ fn load_or_create_identity() -> Result { if let Ok(nsec) = std::env::var("NAPSTR_NSEC") { return Keys::parse(&nsec).map_err(|error| error.to_string()); } - let account = profile_keyring_account(std::env::var("NAPSTR_PROFILE").ok().as_deref())?; - let entry = Entry::new("social.napstr.desktop", &account).map_err(|error| error.to_string())?; - if let Ok(secret) = entry.get_password() { - return Keys::parse(&secret).map_err(|error| error.to_string()); + + let data_dir_opt = std::env::var("DATA_DIR") + .or_else(|_| std::env::var("NAPSTR_DATA_DIR")) + .ok() + .map(std::path::PathBuf::from); + + if let Some(ref data_dir) = data_dir_opt { + let key_file = data_dir.join("identity.key"); + if let Ok(content) = std::fs::read_to_string(&key_file) { + let trimmed = content.trim(); + if !trimmed.is_empty() { + if let Ok(keys) = Keys::parse(trimmed) { + return Ok(keys); + } + } + } } - let keys = Keys::generate(); - let nsec = keys - .secret_key() - .to_bech32() - .map_err(|error| error.to_string())?; - entry.set_password(&nsec).map_err(|error| { - format!("could not store Nostr identity in the operating-system keyring: {error}") - })?; - Ok(keys) + + if let Ok(account) = profile_keyring_account(std::env::var("NAPSTR_PROFILE").ok().as_deref()) { + if let Ok(entry) = Entry::new("social.napstr.desktop", &account) { + if let Ok(secret) = entry.get_password() { + if let Ok(keys) = Keys::parse(&secret) { + return Ok(keys); + } + } + let keys = Keys::generate(); + if let Ok(nsec) = keys.secret_key().to_bech32() { + if entry.set_password(&nsec).is_ok() { + return Ok(keys); + } + } + } + } + + if let Some(ref data_dir) = data_dir_opt { + let _ = std::fs::create_dir_all(data_dir); + let key_file = data_dir.join("identity.key"); + let keys = Keys::generate(); + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|error| error.to_string())?; + std::fs::write(&key_file, &nsec) + .map_err(|error| format!("failed to write identity key to {}: {}", key_file.display(), error))?; + return Ok(keys); + } + + Ok(Keys::generate()) } fn profile_keyring_account(profile: Option<&str>) -> Result { diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs new file mode 100644 index 0000000..04dfacd --- /dev/null +++ b/src-tauri/src/server.rs @@ -0,0 +1,650 @@ +use crate::{ + get_setting, index_path_headless, load_files, load_transfers, + network::{self, NetworkService}, + normalise_tags, open_connection, playable_audio_path, snapshot, + start_folder_watcher_headless, + tor::TorManager, + validate_length, AppSnapshot, FolderWatcher, IndexReport, Settings, SharedFile, Transfer, +}; +use axum::{ + body::Body, + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + Path, State, + }, + http::{header, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use futures_util::{SinkExt, StreamExt}; +use serde::Deserialize; +use std::{ + fs::File, + io::{Read, Seek, SeekFrom}, + path::PathBuf, + sync::{Arc, Mutex}, +}; +use tokio::sync::broadcast; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::ServeDir; + +pub struct ServerState { + pub db_path: Mutex, + pub network: Arc, + pub tor: Arc, + pub watcher: Mutex>, + pub broadcast_tx: broadcast::Sender, +} + +impl ServerState { + pub fn open_db(&self) -> Result { + let path = self + .db_path + .lock() + .map_err(|_| "database lock poisoned")? + .clone(); + open_connection(&path) + } +} + +pub type SharedServerState = Arc; + +#[derive(Deserialize)] +pub struct SearchQuery { + pub query: Option, +} + +#[derive(Deserialize)] +pub struct SetFolderPayload { + pub path: String, +} + +#[derive(Deserialize)] +pub struct SaveTagsPayload { + #[serde(rename = "fileId")] + pub file_id: String, + pub tags: String, +} + +#[derive(Deserialize)] +pub struct TransferIdPayload { + pub id: i64, +} + +#[derive(Deserialize)] +pub struct PauseDownloadsPayload { + pub paused: bool, +} + +#[derive(Deserialize)] +pub struct NetworkSearchPayload { + pub query: String, +} + +#[derive(Deserialize)] +pub struct DownloadRequestPayload { + #[serde(rename = "fileId")] + pub file_id: String, + #[serde(rename = "sourcePubkeys")] + pub source_pubkeys: Vec, +} + +#[derive(Deserialize)] +pub struct BlockFilePayload { + #[serde(rename = "fileId")] + pub file_id: String, +} + +#[derive(Deserialize)] +pub struct BlockUserPayload { + pub pubkey: String, +} + +#[derive(Deserialize)] +pub struct TrollboxMessagePayload { + pub content: String, +} + +#[derive(Deserialize)] +pub struct TrackDiscussionQuery { + #[serde(rename = "fileId")] + pub file_id: String, + pub subscribe: Option, +} + +#[derive(Deserialize)] +pub struct TrackDiscussionMessagePayload { + #[serde(rename = "fileId")] + pub file_id: String, + pub content: String, +} + +pub fn create_router(state: SharedServerState, static_dir: Option) -> Router { + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let api_router = Router::new() + .route("/version", get(handle_version)) + .route("/ws", get(handle_ws)) + .route("/get_snapshot", post(handle_get_snapshot)) + .route("/search_catalog", post(handle_search_catalog)) + .route("/set_napstr_folder", post(handle_set_napstr_folder)) + .route("/rescan_napstr_folder", post(handle_rescan_napstr_folder)) + .route("/save_settings", post(handle_save_settings)) + .route("/save_file_tags", post(handle_save_file_tags)) + .route("/start_network", post(handle_start_network)) + .route("/network_status", post(handle_network_status)) + .route("/network_search", post(handle_network_search)) + .route("/publish_catalogue", post(handle_publish_catalogue)) + .route("/publish_profile", post(handle_publish_profile)) + .route("/get_transfers", post(handle_get_transfers)) + .route("/request_network_download", post(handle_request_network_download)) + .route("/cancel_transfer", post(handle_cancel_transfer)) + .route("/remove_transfer", post(handle_remove_transfer)) + .route("/set_downloads_paused", post(handle_set_downloads_paused)) + .route("/block_file", post(handle_block_file)) + .route("/block_user", post(handle_block_user)) + .route("/get_trollbox_messages", post(handle_get_trollbox_messages)) + .route("/send_trollbox_message", post(handle_send_trollbox_message)) + .route("/get_track_discussion_messages", post(handle_get_track_discussion_messages)) + .route("/send_track_discussion_message", post(handle_send_track_discussion_message)) + .route("/recover_after_sleep", post(handle_recover_after_sleep)) + .route("/stream/{file_id}", get(handle_audio_stream)); + + let mut app = Router::new().nest("/api", api_router).layer(cors); + + if let Some(dir) = static_dir { + if dir.exists() { + app = app.fallback_service(ServeDir::new(dir)); + } + } + + app.with_state(state) +} + +async fn handle_version() -> Json { + Json(serde_json::json!({ "version": "0.1.0-umbrel" })) +} + +async fn handle_get_snapshot( + State(state): State, +) -> Result, (StatusCode, String)> { + let conn = state.open_db().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + let snap = snapshot(&conn).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(snap)) +} + +async fn handle_search_catalog( + State(state): State, + Json(payload): Json, +) -> Result>, (StatusCode, String)> { + let conn = state.open_db().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + let files = load_files(&conn, payload.query.as_deref()) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(files)) +} + +async fn handle_set_napstr_folder( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let folder = PathBuf::from(&payload.path); + let mut conn = state.open_db().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + let report = index_path_headless(&mut conn, &folder) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('shared_folder', ?1)", + [&payload.path], + ) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let db_path = state + .db_path + .lock() + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db lock poisoned".into()))? + .clone(); + *state + .watcher + .lock() + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "watcher lock poisoned".into()))? = + crate::start_folder_watcher_headless(folder, db_path, state.network.clone()).ok(); + + Ok(Json(report)) +} + +async fn handle_rescan_napstr_folder( + State(state): State, +) -> Result, (StatusCode, String)> { + let mut conn = state.open_db().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + let folder_str = get_setting(&conn, "shared_folder") + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + let folder = PathBuf::from(folder_str); + let report = index_path_headless(&mut conn, &folder) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(report)) +} + +async fn handle_save_settings( + State(state): State, + Json(settings): Json, +) -> Result, (StatusCode, String)> { + network::validate_profile_picture(&settings.profile_picture) + .map_err(|e| (StatusCode::BAD_REQUEST, e))?; + validate_length("display name", &settings.display_name, 64) + .map_err(|e| (StatusCode::BAD_REQUEST, e))?; + validate_length("profile about", &settings.profile_about, 500) + .map_err(|e| (StatusCode::BAD_REQUEST, e))?; + validate_length("relay list", &settings.nostr_relays, 4096) + .map_err(|e| (StatusCode::BAD_REQUEST, e))?; + + let conn = state.open_db().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + let profile_changed = get_setting(&conn, "display_name").unwrap_or_default() != settings.display_name + || get_setting(&conn, "profile_about").unwrap_or_default() != settings.profile_about + || get_setting(&conn, "profile_picture").unwrap_or_default() != settings.profile_picture; + + for (key, value) in [ + ("display_name", settings.display_name.as_str()), + ("profile_about", settings.profile_about.as_str()), + ("profile_picture", settings.profile_picture.as_str()), + ("nostr_relays", settings.nostr_relays.as_str()), + ] { + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + [key, value], + ) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + + if profile_changed { + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('profile_event_fingerprint', '')", + [], + ) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + + let snap = snapshot(&conn).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(snap)) +} + +async fn handle_save_file_tags( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if !hex::decode(&payload.file_id) + .map(|bytes| bytes.len() == 32) + .unwrap_or(false) + { + return Err((StatusCode::BAD_REQUEST, "invalid SHA-256 file ID".into())); + } + let tags = normalise_tags(&payload.tags).map_err(|e| (StatusCode::BAD_REQUEST, e))?; + let conn = state.open_db().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + let changed = conn + .execute( + "UPDATE files SET tags=?1 WHERE file_id=?2", + rusqlite::params![tags, payload.file_id], + ) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + if changed != 1 { + return Err((StatusCode::NOT_FOUND, "track is no longer in folder".into())); + } + let snap = snapshot(&conn).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(snap)) +} + +async fn handle_start_network( + State(state): State, +) -> Result, (StatusCode, String)> { + let tor = state.tor.clone(); + tokio::spawn(async move { + let _ = tor.start().await; + }); + let mut status = state + .network + .start() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + let tor_status = state.tor.status().await; + status.tor_running = tor_status.running; + status.tor_starting = tor_status.starting; + status.tor_progress = tor_status.bootstrap_progress; + status.tor_error = tor_status.error; + Ok(Json(status)) +} + +async fn handle_network_status( + State(state): State, +) -> Result, (StatusCode, String)> { + let mut status = state + .network + .status() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + let tor_status = state.tor.status().await; + if !tor_status.running && !tor_status.starting { + let tor = state.tor.clone(); + tokio::spawn(async move { + let _ = tor.start().await; + }); + } + status.tor_running = tor_status.running; + status.tor_starting = tor_status.starting; + status.tor_progress = tor_status.bootstrap_progress; + status.tor_error = tor_status.error; + Ok(Json(status)) +} + +async fn handle_network_search( + State(state): State, + Json(payload): Json, +) -> Result>, (StatusCode, String)> { + let results = state + .network + .search(&payload.query) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(results)) +} + +async fn handle_publish_catalogue( + State(state): State, +) -> Result, (StatusCode, String)> { + // 0.1.4: catalogue publishing is queued/backgrounded (returns no count). + state.network.queue_catalogue_publish(false); + Ok(Json(0)) +} + +async fn handle_publish_profile( + State(state): State, +) -> Result, (StatusCode, String)> { + state + .network + .publish_profile() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(())) +} + +async fn handle_get_transfers( + State(state): State, +) -> Result>, (StatusCode, String)> { + let conn = state.open_db().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + let transfers = load_transfers(&conn).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(transfers)) +} + +async fn handle_request_network_download( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + state + .network + .request_download(payload.file_id, payload.source_pubkeys) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(())) +} + +async fn handle_cancel_transfer( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if payload.id < 0 { + state + .network + .transfers() + .cancel_by_rowid(-payload.id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + } + Ok(Json(())) +} + +async fn handle_remove_transfer( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let conn = state.open_db().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + if payload.id < 0 { + conn.execute( + "DELETE FROM download_sources WHERE request_id=(SELECT request_id FROM network_downloads WHERE rowid=?1)", + [-payload.id], + ) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + conn.execute( + "DELETE FROM network_downloads WHERE rowid = ?1", + [-payload.id], + ) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } else { + conn.execute("DELETE FROM transfers WHERE id = ?1", [payload.id]) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + Ok(Json(())) +} + +async fn handle_set_downloads_paused( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + state.network.transfers().set_paused(payload.paused).await; + Ok(Json(())) +} + +async fn handle_block_file( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if !hex::decode(&payload.file_id) + .map(|bytes| bytes.len() == 32) + .unwrap_or(false) + { + return Err((StatusCode::BAD_REQUEST, "invalid SHA-256 file ID".into())); + } + let conn = state.open_db().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + conn.execute( + "INSERT OR REPLACE INTO blocked_files(file_id,reason,created_at) VALUES(?1,'Blocked by user',?2)", + rusqlite::params![payload.file_id, chrono::Utc::now().to_rfc3339()], + ).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + conn.execute("DELETE FROM remote_catalogue WHERE file_id=?1", [&payload.file_id]) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + drop(conn); + if state.network.status().await.map(|s| s.connected).unwrap_or(false) { + let _ = state.network.queue_catalogue_publish(false); + } + Ok(Json(())) +} + +async fn handle_block_user( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + nostr_sdk::PublicKey::from_hex(&payload.pubkey) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid Nostr public key".into()))?; + let conn = state.open_db().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + conn.execute( + "INSERT OR REPLACE INTO blocked_pubkeys(pubkey,reason,created_at) VALUES(?1,'Blocked by user',?2)", + rusqlite::params![payload.pubkey, chrono::Utc::now().to_rfc3339()], + ).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + conn.execute( + "DELETE FROM remote_catalogue WHERE source_pubkey=?1", + [&payload.pubkey], + ).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + conn.execute("DELETE FROM trollbox_events WHERE pubkey=?1", [&payload.pubkey]) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(Json(())) +} + +async fn handle_get_trollbox_messages( + State(state): State, +) -> Result>, (StatusCode, String)> { + let msgs = state + .network + .trollbox_messages() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(msgs)) +} + +async fn handle_send_trollbox_message( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let id = state + .network + .send_trollbox_message(payload.content) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(id)) +} + +async fn handle_get_track_discussion_messages( + State(state): State, + Json(payload): Json, +) -> Result>, (StatusCode, String)> { + let msgs = state + .network + .track_discussion_messages(payload.file_id, payload.subscribe.unwrap_or(true)) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(msgs)) +} + +async fn handle_send_track_discussion_message( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let id = state + .network + .send_track_discussion_message(payload.file_id, payload.content) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(id)) +} + +async fn handle_recover_after_sleep( + State(state): State, +) -> Result, (StatusCode, String)> { + let network = state.network.clone(); + let tor = state.tor.clone(); + tokio::spawn(async move { + let _ = network.restart().await; + let _ = tor.restart().await; + }); + Ok(Json(())) +} + +async fn handle_ws( + ws: WebSocketUpgrade, + State(state): State, +) -> Response { + ws.on_upgrade(|socket| websocket_loop(socket, state)) +} + +async fn websocket_loop(socket: WebSocket, state: SharedServerState) { + let (mut sender, mut receiver) = socket.split(); + let mut rx = state.broadcast_tx.subscribe(); + + let mut send_task = tokio::spawn(async move { + while let Ok(msg) = rx.recv().await { + if sender.send(Message::Text(msg.into())).await.is_err() { + break; + } + } + }); + + let mut recv_task = tokio::spawn(async move { + while let Some(Ok(_msg)) = receiver.next().await {} + }); + + tokio::select! { + _ = (&mut send_task) => recv_task.abort(), + _ = (&mut recv_task) => send_task.abort(), + }; +} + +async fn handle_audio_stream( + Path(file_id): Path, + State(state): State, + headers: HeaderMap, +) -> Result { + let conn = state.open_db().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + let path = playable_audio_path(&conn, &file_id) + .map_err(|e| (StatusCode::NOT_FOUND, e))?; + + let mut file = File::open(&path) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("open error: {e}")))?; + let metadata = file + .metadata() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("metadata error: {e}")))?; + let file_size = metadata.len(); + + let mime_type = mime_guess::from_path(&path) + .first_or_octet_stream() + .to_string(); + + let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok()); + + if let Some(range) = range_header { + if let Some(spec) = range.strip_prefix("bytes=") { + let parts: Vec<&str> = spec.split('-').collect(); + let start: u64 = parts[0].parse().unwrap_or(0); + let end: u64 = if parts.len() > 1 && !parts[1].is_empty() { + parts[1].parse().unwrap_or(file_size - 1) + } else { + file_size - 1 + }; + + let end = end.min(file_size - 1); + if start > end || start >= file_size { + return Ok(( + StatusCode::RANGE_NOT_SATISFIABLE, + [(header::CONTENT_RANGE, format!("bytes */{file_size}"))], + Body::empty(), + ) + .into_response()); + } + + let length = end - start + 1; + file.seek(SeekFrom::Start(start)) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("seek error: {e}")))?; + + let mut buffer = vec![0u8; length as usize]; + file.read_exact(&mut buffer) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("read error: {e}")))?; + + return Ok(( + StatusCode::PARTIAL_CONTENT, + [ + (header::CONTENT_TYPE, mime_type), + (header::ACCEPT_RANGES, "bytes".to_string()), + ( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{file_size}"), + ), + (header::CONTENT_LENGTH, length.to_string()), + ], + Body::from(buffer), + ) + .into_response()); + } + } + + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("read error: {e}")))?; + + Ok(( + StatusCode::OK, + [ + (header::CONTENT_TYPE, mime_type), + (header::ACCEPT_RANGES, "bytes".to_string()), + (header::CONTENT_LENGTH, file_size.to_string()), + ], + Body::from(buffer), + ) + .into_response()) +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..c331a21 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,112 @@ +export const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; + +type UnlistenFn = () => void; + +class EventBus { + private listeners: Map void>> = new Map(); + + emit(event: string, payload: any) { + const set = this.listeners.get(event); + if (set) { + set.forEach((fn) => { + try { + fn({ payload }); + } catch (e) { + console.error('Listener error:', e); + } + }); + } + } + + listen(event: string, callback: (event: { payload: any }) => void): UnlistenFn { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + const set = this.listeners.get(event)!; + set.add(callback); + return () => { + set.delete(callback); + }; + } +} + +export const webEventBus = new EventBus(); + +let webSocket: WebSocket | null = null; + +function ensureWebSocket() { + if (isTauri || typeof window === 'undefined') return; + if (webSocket && (webSocket.readyState === WebSocket.OPEN || webSocket.readyState === WebSocket.CONNECTING)) { + return; + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/ws`; + try { + webSocket = new WebSocket(wsUrl); + webSocket.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.event) { + webEventBus.emit(msg.event, msg.payload); + } + } catch (e) { + console.error('WS parse error:', e); + } + }; + webSocket.onclose = () => { + webSocket = null; + setTimeout(ensureWebSocket, 3000); + }; + } catch (e) { + console.error('WS connection error:', e); + } +} + +export async function apiInvoke(command: string, args: Record = {}): Promise { + if (isTauri) { + const { invoke } = await import('@tauri-apps/api/core'); + return invoke(command, args); + } + + ensureWebSocket(); + + const response = await fetch(`/api/${command}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(args), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `API error ${response.status}: ${command}`); + } + + return response.json(); +} + +export async function apiListen(event: string, callback: (event: { payload: T }) => void): Promise { + if (isTauri) { + const { listen } = await import('@tauri-apps/api/event'); + return listen(event, callback); + } + + ensureWebSocket(); + return webEventBus.listen(event, callback); +} + +export async function apiGetVersion(): Promise { + if (isTauri) { + const { getVersion } = await import('@tauri-apps/api/app'); + return getVersion(); + } + try { + const res = await fetch('/api/version'); + if (res.ok) { + const data = await res.json(); + return data.version || '0.1.0-umbrel'; + } + } catch {} + return '0.1.0-umbrel'; +} diff --git a/src/lib/webAudio.ts b/src/lib/webAudio.ts new file mode 100644 index 0000000..7e2bdb9 --- /dev/null +++ b/src/lib/webAudio.ts @@ -0,0 +1,73 @@ +export class WebAudioPlayer { + private audio: HTMLAudioElement | null = null; + private currentFileId: string = ''; + private isEnded: boolean = false; + private volume: number = 0.85; + + constructor() { + if (typeof window !== 'undefined') { + this.audio = new Audio(); + this.audio.preload = 'auto'; + this.audio.volume = this.volume; + this.audio.onended = () => { + this.isEnded = true; + }; + } + } + + play(fileId: string): void { + if (!this.audio) return; + if (this.currentFileId !== fileId) { + this.currentFileId = fileId; + this.audio.src = `/api/stream/${fileId}`; + this.audio.load(); + } + this.isEnded = false; + this.audio.play().catch((err) => { + console.warn('Web audio play error:', err); + }); + } + + pause(): void { + this.audio?.pause(); + } + + resume(): void { + this.audio?.play().catch((err) => { + console.warn('Web audio resume error:', err); + }); + } + + stop(): void { + if (!this.audio) return; + this.audio.pause(); + this.audio.currentTime = 0; + this.currentFileId = ''; + this.isEnded = false; + } + + seek(seconds: number): void { + if (!this.audio) return; + this.audio.currentTime = seconds; + } + + setVolume(vol: number): void { + this.volume = Math.max(0, Math.min(1, vol)); + if (this.audio) { + this.audio.volume = this.volume; + } + } + + getStatus() { + return { + fileId: this.currentFileId, + currentTime: this.audio ? this.audio.currentTime : 0, + duration: this.audio && !isNaN(this.audio.duration) ? this.audio.duration : 0, + playing: this.audio ? !this.audio.paused && !this.audio.ended : false, + ended: this.isEnded, + error: '', + }; + } +} + +export const webAudio = new WebAudioPlayer(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index be0455b..cebaeb3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,10 +1,7 @@ diff --git a/umbrel-app.yml b/umbrel-app.yml new file mode 100644 index 0000000..a5f6f6f --- /dev/null +++ b/umbrel-app.yml @@ -0,0 +1,33 @@ +manifestVersion: 1 +id: napstr +name: Napstr +tagline: Sovereign P2P Nostr & Tor audio seeder & web player +category: Media +version: "0.1.4" +port: 30421 +description: >- + Napstr is a sovereign peer-to-peer music library and streaming client built on Nostr and Tor. + + Run Napstr 24/7 on your Umbrel node to: + + - Automatically index and seed your music collection to the decentralized Nostr swarm. + - Publish Kind 30421 audio catalogues and Kind 30422 availability heartbeats. + - Transfer audio losslessly over encrypted, ephemeral Tor v3 onion services. + - Stream your entire lossless music collection directly from any phone, laptop, or browser on your local network. + - Seamlessly integrate with your shared Umbrel music directory alongside Navidrome or Jellyfin. + +developer: Napstr Contributors +website: https://napstr.net +dependencies: [] +repo: https://github.com/lnbits/napstr +support: https://github.com/lnbits/napstr/issues +gallery: + - 1.jpg + - 2.jpg + - 3.jpg +path: "" +defaultUsername: "" +deterministicPassword: false +submitter: Antigravity +submission: https://github.com/getumbrel/umbrel-apps +icon: https://raw.githubusercontent.com/lnbits/napstr/main/src-tauri/icons/icon.png