From 367b483a72ecd247aec110bf3e00f8448b73dbe3 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 16 Mar 2026 12:58:35 +0000 Subject: [PATCH] feat: bitcoin-ui CSS fix, HTTPS proxy support, deploy script improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bitcoin UI: - Replace cdn.tailwindcss.com with locally bundled tailwind.css (CSP blocks external scripts) - Make all asset paths relative for nginx proxy compatibility - Add bitcoin-ui build/deploy to deploy-to-target.sh (was missing entirely) - Use --network host (bitcoin-ui proxies Bitcoin RPC at 127.0.0.1:8332) HTTPS mixed content fix: - Add HTTPS_PROXY_PATHS in AppSession.vue — when parent page is HTTPS, iframe loads through nginx proxy instead of direct HTTP port - Prevents browser blocking HTTP iframes inside HTTPS pages - All Tailscale servers use HTTPS, this was breaking all app iframes Deploy & first-boot improvements: - first-boot-containers.sh auto-detects disk size for pruning vs txindex - first-boot-containers.sh checks fallback source path for UI containers - Added mempool-electrs to APP_PORTS mapping - ElectrumX container creation in first-boot - Podman doctor/fix/uptime skills added Also includes: session persistence, identity management, LND transactions, ElectrumX status UI, nostr-provider improvements, Web5 enhancements Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/podman-doctor/SKILL.md | 156 ++ .../references/common-failures.md | 55 + .../podman-doctor/references/port-map.md | 71 + .claude/skills/podman-fix/SKILL.md | 219 ++ .claude/skills/podman-uptime/SKILL.md | 309 +++ core/archipelago/src/api/handler.rs | 1 + core/archipelago/src/api/rpc/container.rs | 47 +- core/archipelago/src/api/rpc/identity.rs | 91 +- core/archipelago/src/api/rpc/lnd.rs | 123 + core/archipelago/src/api/rpc/mod.rs | 72 +- core/archipelago/src/api/rpc/package.rs | 58 +- core/archipelago/src/api/rpc/system.rs | 76 +- .../src/container/docker_packages.rs | 16 +- core/archipelago/src/electrs_status.rs | 44 +- core/archipelago/src/health_monitor.rs | 2 +- core/archipelago/src/identity_manager.rs | 98 + core/archipelago/src/session.rs | 190 +- core/container/src/podman_client.rs | 47 +- docker/bitcoin-ui/Dockerfile | 1 + docker/bitcoin-ui/index.html | 8 +- docker/bitcoin-ui/tailwind.css | 210 ++ docker/electrs-ui/Dockerfile | 1 + docker/electrs-ui/index.html | 273 +- docker/electrs-ui/qrcode.js | 2297 +++++++++++++++++ docs/MASTER_PLAN.md | 71 + image-recipe/build-auto-installer-iso.sh | 6 +- image-recipe/configs/nginx-archipelago.conf | 6 +- .../archipelago-https-app-proxies.conf | 2 +- neode-ui/dev-dist/sw.js | 2 +- neode-ui/package-lock.json | 511 ++++ neode-ui/package.json | 1 + .../assets/img/app-icons/electrumx.webp | Bin 0 -> 42822 bytes neode-ui/public/nostr-provider.js | 191 +- .../src/components/NostrIdentityPicker.vue | 36 +- neode-ui/src/router/index.ts | 10 +- neode-ui/src/stores/appLauncher.ts | 154 +- neode-ui/src/stores/container.ts | 59 +- neode-ui/src/style.css | 100 + neode-ui/src/views/AppDetails.vue | 7 +- neode-ui/src/views/AppSession.vue | 225 +- neode-ui/src/views/Dashboard.vue | 37 +- neode-ui/src/views/Home.vue | 30 +- neode-ui/src/views/Login.vue | 18 +- neode-ui/src/views/MarketplaceAppDetails.vue | 6 +- neode-ui/src/views/OnboardingDid.vue | 14 +- neode-ui/src/views/OnboardingVerify.vue | 2 +- neode-ui/src/views/Web5.vue | 540 +++- scripts/deploy-to-target.sh | 113 +- scripts/first-boot-containers.sh | 69 +- 49 files changed, 6180 insertions(+), 495 deletions(-) create mode 100644 .claude/skills/podman-doctor/SKILL.md create mode 100644 .claude/skills/podman-doctor/references/common-failures.md create mode 100644 .claude/skills/podman-doctor/references/port-map.md create mode 100644 .claude/skills/podman-fix/SKILL.md create mode 100644 .claude/skills/podman-uptime/SKILL.md create mode 100644 docker/bitcoin-ui/tailwind.css create mode 100644 docker/electrs-ui/qrcode.js create mode 100644 docs/MASTER_PLAN.md create mode 100644 neode-ui/public/assets/img/app-icons/electrumx.webp diff --git a/.claude/skills/podman-doctor/SKILL.md b/.claude/skills/podman-doctor/SKILL.md new file mode 100644 index 00000000..44d13bc0 --- /dev/null +++ b/.claude/skills/podman-doctor/SKILL.md @@ -0,0 +1,156 @@ +--- +name: podman-doctor +description: > + Comprehensive Podman container diagnostic for Archipelago. Audits all running containers, + port mappings, network connectivity, health status, restart policies, and config consistency + across all 4 layers (backend Rust, Podman runtime, Nginx proxy, frontend routing). + Use when asked to "diagnose containers", "check podman", "why is app not working", + "container health check", "port not reachable", "audit containers", "podman status", + or when any container/app is misbehaving. +allowed-tools: Bash Read Glob Grep +--- + +# Podman Doctor — Container Infrastructure Diagnostics + +Systematic diagnostic for Archipelago's Podman container stack. Catches port conflicts, network misconfigurations, health failures, missing restart policies, and config drift across all layers. + +**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` + +If $ARGUMENTS is provided, focus diagnosis on that specific app/container. Otherwise run full audit. + +## Workflow + +### Step 1: Gather Runtime State + +Run these on the server: + +```bash +# All containers with status, ports, networks +sudo podman ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Networks}}" + +# Check for port conflicts on known ports +sudo ss -tlnp | grep -E ":(80|443|3000|4080|5678|8080|8081|8082|8083|8085|8096|8123|8173|8174|8175|8240|8332|8333|8334|8888|9735|10009|11434|23000|50001)\b" +``` + +### Step 2: Check Restart Policies + +Every container MUST have `--restart unless-stopped`. This is the #1 cause of downtime after reboots. + +```bash +for c in $(sudo podman ps -a --format "{{.Names}}"); do + echo -n "$c: " + sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}" +done +``` + +**Red flag**: `no` or empty = container won't survive reboot. + +### Step 3: Verify Port Mapping Consistency + +Cross-reference these 4 layers — mismatches between ANY two cause "app not loading" bugs: + +**Layer 1 — Backend Config (Rust)**: Read `core/archipelago/src/api/rpc/package.rs`, look at `get_app_config()` port mappings. + +**Layer 2 — Podman Runtime**: `sudo podman ps --format "{{.Names}}: {{.Ports}}"` + +**Layer 3 — Nginx Proxy**: Read these for `/app/{id}/` location blocks: +- `image-recipe/configs/nginx-archipelago.conf` (HTTP) +- `image-recipe/configs/snippets/archipelago-https-app-proxies.conf` (HTTPS) + +**Layer 4 — Frontend Routing**: Read `neode-ui/src/stores/appLauncher.ts` — `PORT_TO_APP_ID` map. + +| Symptom | Root Cause | +|---------|-----------| +| App iframe shows 502/504 | Nginx proxies to wrong port, or container not running | +| App loads wrong content | Port collision — two containers on same host port | +| Works on port but not /app/ path | Missing nginx location block | +| Frontend can't find app | PORT_TO_APP_ID missing in appLauncher.ts | + +### Step 4: Network Connectivity Audit + +```bash +# Networks and their containers +sudo podman network ls +sudo podman network inspect archy-net 2>/dev/null || echo "WARNING: archy-net missing!" +``` + +**Must be on archy-net**: bitcoin-knots, lnd, electrs, mempool, btcpay-server, nbxplorer, fedimint, fedimint-gateway, nostr-rs-relay, indeedhub, ollama, open-webui + +**Must NOT be on archy-net**: grafana, nextcloud, filebrowser, vaultwarden, bitcoin-ui, lnd-ui, tailscale (host network) + +### Step 5: Health Check Status + +```bash +# Containers with health checks — are they passing? +for c in $(sudo podman ps --format "{{.Names}}"); do + health=$(sudo podman inspect "$c" --format "{{.State.Health.Status}}" 2>/dev/null) + if [ -n "$health" ] && [ "$health" != "" ]; then + echo "$c: $health" + fi +done + +# Containers WITHOUT health checks (gap in monitoring) +for c in $(sudo podman ps --format "{{.Names}}"); do + hc=$(sudo podman inspect "$c" --format "{{.Config.Healthcheck}}" 2>/dev/null) + if [ "$hc" = "" ] || [ -z "$hc" ]; then + echo "NO HEALTHCHECK: $c" + fi +done +``` + +### Step 6: Resource & Failure Analysis + +```bash +# Resource usage +sudo podman stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}" + +# Recent deaths (last 24h) +sudo podman events --filter event=died --since 24h 2>/dev/null | tail -20 + +# OOM kills +sudo podman ps -a --format "{{.Names}}" | while read c; do + oom=$(sudo podman inspect "$c" --format "{{.State.OOMKilled}}" 2>/dev/null) + [ "$oom" = "true" ] && echo "OOM KILLED: $c" +done + +# Non-zero exits +sudo podman ps -a --filter status=exited --format "{{.Names}}\t{{.Status}}" +``` + +### Step 7: Systemd Integration + +```bash +systemctl is-active archipelago nginx +systemctl list-units --type=service | grep -i podman +systemctl list-timers --all | grep -i -E "podman|container|archipelago" +``` + +### Step 8: Generate Report + +Produce a structured report: + +``` +## Container Diagnostic Report + +### Summary +- Total containers: X running, Y stopped, Z unhealthy +- Port conflicts: [list or "none"] +- Missing restart policies: [list or "none"] +- Network issues: [list or "none"] +- Health check gaps: [list] + +### Critical Issues (fix immediately) +1. ... + +### Warnings (fix soon) +1. ... + +### Recommended Actions +1. ... +``` + +After diagnosis, suggest running `/podman-fix` for any issues found. + +## Port Reference + +See `references/port-map.md` for the canonical port assignment table across all 4 layers. diff --git a/.claude/skills/podman-doctor/references/common-failures.md b/.claude/skills/podman-doctor/references/common-failures.md new file mode 100644 index 00000000..75f2afb0 --- /dev/null +++ b/.claude/skills/podman-doctor/references/common-failures.md @@ -0,0 +1,55 @@ +# Common Podman Failure Patterns + +## Container Won't Start + +| Error | Cause | Fix | +|-------|-------|-----| +| `exec format error` | Binary built on wrong arch | Rebuild on the Linux server | +| `address already in use` | Port conflict | `ss -tlnp \| grep :PORT` to find offender | +| `permission denied` | Missing capability or read-only root | Check `get_app_capabilities()`, add tmpfs | +| `OCI runtime error` | Corrupt container state | `podman rm -f NAME && recreate` | +| `image not known` | Image not pulled | `podman pull IMAGE:TAG` | +| `no such network` | Network missing | `podman network create archy-net` | + +## Container Starts But App Unreachable + +| Symptom | Check Layer | Fix | +|---------|------------|-----| +| Direct port works, /app/ doesn't | Nginx config | Add `/app/{id}/` location block | +| Neither works | Podman ports | `podman port NAME` — verify mapping exists | +| Port mapped but refused | Container logs | App crashing internally — check logs | +| Works sometimes | Resources | Check OOM kills, CPU, disk space | +| 502 Bad Gateway | Nginx→Container | Wrong port in proxy_pass or container restarted | + +## Container Keeps Dying + +| Pattern | Cause | Fix | +|---------|-------|-----| +| Exits immediately (code 1) | Config error | Check `podman logs NAME` | +| Dies after minutes | OOM killed | Increase `--memory` limit | +| Dies when dep restarts | No restart policy | Add `--restart unless-stopped` | +| Crash loop | Repeated crash | Fix root cause, don't just restart | + +## Network Issues + +| Problem | Cause | Fix | +|---------|-------|-----| +| Can't resolve container names | Not on archy-net | Recreate with `--network=archy-net` | +| Can't reach internet | DNS missing | Add `--dns 1.1.1.1` | +| Container-to-container timeout | Different networks | Put both on same network | + +## Capability Reference + +| Capability | Apps That Need It | Failure Mode | +|-----------|------------------|-------------| +| CHOWN | nextcloud, homeassistant, btcpay, jellyfin, portainer | Can't chown during setup | +| SETUID/SETGID | nextcloud, homeassistant, btcpay, jellyfin | Can't switch to service user | +| DAC_OVERRIDE | nextcloud, homeassistant, btcpay | Can't access cross-UID files | +| FOWNER | bitcoin-knots, lnd, fedimint | Can't modify data dir perms | +| NET_BIND_SERVICE | nginx-proxy-manager, vaultwarden | Can't bind ports <1024 | + +## Read-Only Safe Apps + +Only these 8 apps can run with `--read-only`: searxng, grafana, filebrowser, electrs, nostr-rs-relay, ollama, indeedhub + +All others need writable root or will fail silently. diff --git a/.claude/skills/podman-doctor/references/port-map.md b/.claude/skills/podman-doctor/references/port-map.md new file mode 100644 index 00000000..ad960883 --- /dev/null +++ b/.claude/skills/podman-doctor/references/port-map.md @@ -0,0 +1,71 @@ +# Archipelago Canonical Port Map + +All port assignments across the 4 configuration layers. When adding or debugging an app, every row must be consistent across all columns. + +## Bitcoin Stack + +| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map | +|-----|-------------|-------------------|---------|------------|-------------| +| bitcoin-knots | 8332, 8333 | 8332, 8333 | archy-net | /app/bitcoin-knots/ | 8332→bitcoin-knots | +| bitcoin-ui | 8334 | 80 | bridge | /app/bitcoin-ui/ | 8334→bitcoin-knots | +| electrs | 50001 | 50001 | archy-net | /app/electrs/ | 50001→electrs | +| lnd | 9735, 10009, 8080 | 9735, 10009, 8080 | archy-net | /app/lnd/ | 10009→lnd | +| lnd-ui (RTL) | 8081 | 80 | bridge | /app/lnd-ui/ | 8081→lnd | + +## Lightning & Payment + +| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map | +|-----|-------------|-------------------|---------|------------|-------------| +| btcpay-server | 23000 | 49392 | archy-net | /app/btcpay/ | 23000→btcpay-server | +| nbxplorer | 24444 | 32838 | archy-net | N/A (internal) | N/A | +| fedimint | 8173, 8174, 8175 | 8173, 8174, 8175 | archy-net | /app/fedimint/ | 8174→fedimint | +| fedimint-gateway | 8175 | 8175 | archy-net | /app/fedimint-gateway/ | 8175→fedimint-gateway | + +## Explorer & Monitoring + +| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map | +|-----|-------------|-------------------|---------|------------|-------------| +| mempool | 4080 | 8080 | archy-net | /app/mempool/ | 4080→mempool | +| grafana | 3000 | 3000 | bridge | /app/grafana/ | 3000→grafana (new tab) | + +## Self-Hosted Apps + +| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map | +|-----|-------------|-------------------|---------|------------|-------------| +| nextcloud | 8085 | 80 | bridge | /app/nextcloud/ | 8085→nextcloud | +| vaultwarden | 8082 | 80 | bridge | /app/vaultwarden/ | 8082→vaultwarden (new tab) | +| filebrowser | 8083 | 80 | bridge | /app/filebrowser/ | 8083→filebrowser | +| searxng | 8888 | 8080 | bridge | /app/searxng/ | 8888→searxng | +| photoprism | 2342 | 2342 | bridge | /app/photoprism/ | 2342→photoprism (new tab) | +| jellyfin | 8096 | 8096 | bridge | /app/jellyfin/ | 8096→jellyfin | +| homeassistant | 8123 | 8123 | bridge | /app/homeassistant/ | 8123→homeassistant (new tab) | +| ollama | 11434 | 11434 | archy-net | /app/ollama/ | 11434→ollama | +| open-webui | 3080 | 8080 | archy-net | /app/open-webui/ | 3080→open-webui | + +## Nostr & Social + +| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map | +|-----|-------------|-------------------|---------|------------|-------------| +| nostr-rs-relay | 7000 | 8080 | archy-net | /app/nostr-rs-relay/ | 7000→nostr-rs-relay | +| indeedhub | 3001 | 3000 | archy-net | /app/indeedhub/ | 3001→indeedhub | + +## System + +| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map | +|-----|-------------|-------------------|---------|------------|-------------| +| tailscale | 8240 | 8240 | host | /app/tailscale/ | N/A | +| nginx-proxy-manager | 81, 8443 | 81, 443 | bridge | N/A | 81→nginx-proxy-manager | + +## Multi-Container Stacks + +**Immich**: immich-server (2283), immich-postgres (internal 5432), immich-redis (internal 6379) — all on immich-net +**Penpot**: penpot-frontend (9001→80), penpot-backend, penpot-exporter, penpot-postgres, penpot-mailcatch — all on penpot-net +**Mempool**: mempool (4080→8080), mempool-db (internal 3306) — on archy-net +**BTCPay**: btcpay-server (23000→49392), nbxplorer (24444→32838), btcpay-postgres (internal 5432) — on archy-net + +## Key Notes + +- **archy-net apps** resolve each other by container name (e.g., `bitcoin-knots:8332`) +- **bridge apps** are standalone — access services via host IP/port +- **host network** (tailscale only) — shares host namespace, no port mapping +- **New tab apps**: btcpay (23000), grafana (3000), vaultwarden (8082), photoprism (2342), homeassistant (8123) — X-Frame-Options blocks iframe diff --git a/.claude/skills/podman-fix/SKILL.md b/.claude/skills/podman-fix/SKILL.md new file mode 100644 index 00000000..e3b3e413 --- /dev/null +++ b/.claude/skills/podman-fix/SKILL.md @@ -0,0 +1,219 @@ +--- +name: podman-fix +description: > + Fix Podman container issues on Archipelago — restart failed containers, repair port bindings, + fix network connectivity, add missing restart policies, and resolve config drift. + Use when asked to "fix container", "restart app", "fix port mapping", "container not working", + "app won't start", "fix podman", "repair container", "container down", or after /podman-doctor + identifies issues to fix. +allowed-tools: Bash Read Edit Write Glob Grep +--- + +# Podman Fix — Container Remediation + +Targeted fix workflow for Podman container issues on Archipelago. Given a specific problem (from /podman-doctor or user report), diagnose the root cause and fix it. + +**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` + +If $ARGUMENTS is provided, fix that specific app/issue. Otherwise ask what needs fixing. + +## Fix Procedures + +### Fix 1: Container Not Running + +```bash +# Check why it stopped +sudo podman logs --tail 50 CONTAINER_NAME +sudo podman inspect CONTAINER_NAME --format "{{.State.ExitCode}} {{.State.Error}}" + +# If clean exit or crash — just restart +sudo podman start CONTAINER_NAME + +# If corrupt state — remove and recreate +sudo podman rm -f CONTAINER_NAME +# Then recreate using the install flow (trigger from UI or re-run creation command) +``` + +**If container keeps crashing**: check logs for the actual error. Common causes: +- Missing config file → check if volume mount has the config +- Wrong permissions → `chown -R` the data directory +- Dependency not ready → start dependency first, wait, then start this container + +### Fix 2: Missing Restart Policy + +The most common uptime killer. Fix for ALL containers at once: + +```bash +# Fix a single container +sudo podman update --restart unless-stopped CONTAINER_NAME + +# Fix ALL containers that have no restart policy +for c in $(sudo podman ps -a --format "{{.Names}}"); do + policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}") + if [ "$policy" = "no" ] || [ -z "$policy" ]; then + echo "Fixing restart policy for: $c" + sudo podman update --restart unless-stopped "$c" + fi +done +``` + +**Also update the Rust source** so new installs get it right: +- Check `core/archipelago/src/api/rpc/package.rs` `get_app_config()` for the app +- Ensure `--restart` flag is in the podman run args + +### Fix 3: Port Mapping Issues + +#### Port conflict (address already in use) +```bash +# Find what's using the port +sudo ss -tlnp | grep :PORT_NUMBER + +# If it's another container, either change one's port or stop the conflicting one +sudo podman stop CONFLICTING_CONTAINER + +# If it's a host process +sudo kill PID # or stop the service +``` + +#### Port not mapped (container running but port unreachable) +```bash +# Check current port mappings +sudo podman port CONTAINER_NAME + +# Can't add ports to running container — must recreate +sudo podman stop CONTAINER_NAME +sudo podman rm CONTAINER_NAME +# Recreate with correct -p flags (use the Rust install flow or manual podman run) +``` + +#### Nginx proxy missing or wrong +Read and fix the nginx config: +- HTTP: `image-recipe/configs/nginx-archipelago.conf` +- HTTPS: `image-recipe/configs/snippets/archipelago-https-app-proxies.conf` + +Add a location block: +```nginx +location /app/APP_ID/ { + proxy_pass http://127.0.0.1:HOST_PORT/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + # Hide X-Frame-Options so it works in our iframe + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; +} +``` + +After editing nginx config, deploy and reload: +```bash +# On server +sudo nginx -t && sudo systemctl reload nginx +``` + +#### Frontend routing missing +Edit `neode-ui/src/stores/appLauncher.ts`: +- Add entry to `PORT_TO_APP_ID` map +- If app blocks iframes, add port to the new-tab list in `resolveAppIdFromUrl()` + +### Fix 4: Network Issues + +#### Container not on archy-net (can't resolve other containers) +```bash +# Connect to archy-net without recreating +sudo podman network connect archy-net CONTAINER_NAME + +# Verify +sudo podman inspect CONTAINER_NAME --format "{{.NetworkSettings.Networks}}" +``` + +#### archy-net doesn't exist +```bash +sudo podman network create archy-net +# Then reconnect all containers that need it +``` + +#### DNS not working inside container +```bash +# Test DNS from inside container +sudo podman exec CONTAINER_NAME nslookup bitcoin-knots 2>/dev/null || \ +sudo podman exec CONTAINER_NAME ping -c1 bitcoin-knots + +# If DNS fails, recreate container with explicit DNS +# Add --dns 1.1.1.1 to the podman run command +``` + +### Fix 5: Health Check Issues + +#### Add missing health check to running container +Can't add to running container — must recreate with health check flags: +```bash +# Example for a web app +sudo podman run ... \ + --health-cmd "curl -f http://localhost:PORT/health || exit 1" \ + --health-interval 30s \ + --health-timeout 5s \ + --health-retries 3 \ + --health-start-period 60s \ + IMAGE +``` + +#### Fix unhealthy container +```bash +# See what the health check is actually running +sudo podman inspect CONTAINER_NAME --format "{{.Config.Healthcheck.Test}}" + +# Run the health check manually to see the error +sudo podman exec CONTAINER_NAME HEALTH_CHECK_COMMAND + +# Common fixes: +# - curl not installed in container → use wget or nc instead +# - Wrong port in health check → fix the check command +# - App takes too long to start → increase --health-start-period +``` + +### Fix 6: Permission/Capability Issues + +```bash +# Check what capabilities container has +sudo podman inspect CONTAINER_NAME --format "{{.HostConfig.CapAdd}}" + +# If missing required caps, must recreate with correct --cap-add flags +# Refer to the capability reference in /podman-doctor references + +# Fix data directory permissions +sudo chown -R 1000:1000 /var/lib/archipelago/APP_NAME/ +``` + +### Fix 7: Full Config Consistency Fix + +When port map is inconsistent across layers, fix ALL layers: + +1. **Decide the correct port** (usually what's in package.rs) +2. **Fix Podman**: recreate container with correct `-p` flags +3. **Fix Nginx**: update location block's `proxy_pass` port +4. **Fix Frontend**: update `PORT_TO_APP_ID` in appLauncher.ts +5. **Deploy**: `./scripts/deploy-to-target.sh --live` +6. **Verify**: `curl -I http://192.168.1.228/app/APP_ID/` + +## After Fixing + +Always verify the fix: +```bash +# Container running? +sudo podman ps --filter name=CONTAINER_NAME + +# Port reachable? +curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:PORT/ + +# Via nginx proxy? +curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/app/APP_ID/ + +# Health check passing? +sudo podman inspect CONTAINER_NAME --format "{{.State.Health.Status}}" +``` + +Run `/podman-doctor` again to confirm all issues are resolved. diff --git a/.claude/skills/podman-uptime/SKILL.md b/.claude/skills/podman-uptime/SKILL.md new file mode 100644 index 00000000..5b080e56 --- /dev/null +++ b/.claude/skills/podman-uptime/SKILL.md @@ -0,0 +1,309 @@ +--- +name: podman-uptime +description: > + Ensure 100% container uptime on Archipelago. Sets up systemd watchdog timers, verifies + restart policies, creates health check monitors, and configures auto-recovery for all + containers. Use when asked to "ensure uptime", "containers keep dying", "auto-restart", + "watchdog", "container monitoring", "uptime guarantee", "keep containers running", + "survive reboot", or to harden container reliability. +allowed-tools: Bash Read Edit Write Glob Grep +--- + +# Podman Uptime — Container Reliability Guardian + +Ensures every Archipelago container survives reboots, recovers from crashes, and stays healthy. Sets up the three layers of uptime defense: restart policies, systemd watchdog, and health-based auto-recovery. + +**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` + +## Layer 1: Restart Policies (Survive Reboots) + +Every container MUST have `--restart unless-stopped`. This is non-negotiable. + +### Audit and fix all containers + +```bash +# Audit +for c in $(sudo podman ps -a --format "{{.Names}}"); do + policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}") + echo "$c: $policy" +done + +# Fix any with "no" or empty policy +for c in $(sudo podman ps -a --format "{{.Names}}"); do + policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}") + if [ "$policy" = "no" ] || [ -z "$policy" ]; then + echo "Fixing: $c" + sudo podman update --restart unless-stopped "$c" + fi +done +``` + +### Ensure podman auto-starts containers on boot + +```bash +# Enable podman-restart service (restarts containers with restart policy on boot) +sudo systemctl enable podman-restart.service 2>/dev/null || true + +# If podman-restart doesn't exist, create it +cat <<'EOF' | sudo tee /etc/systemd/system/podman-restart.service +[Unit] +Description=Podman Start All Containers With Restart Policy +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/podman start --all --filter restart-policy=unless-stopped +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable podman-restart.service +``` + +## Layer 2: Systemd Watchdog (Detect and Recover) + +Create a systemd timer that checks container health every 2 minutes and restarts unhealthy or stopped containers. + +### Create the watchdog script + +```bash +cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-container-watchdog.sh +#!/bin/bash +# Archipelago Container Watchdog +# Checks all containers and restarts any that are stopped or unhealthy + +LOG_TAG="container-watchdog" + +# Restart any stopped containers that should be running (have restart policy) +for c in $(sudo podman ps -a --filter status=exited --filter restart-policy=unless-stopped --format "{{.Names}}"); do + logger -t "$LOG_TAG" "Restarting stopped container: $c" + sudo podman start "$c" 2>&1 | logger -t "$LOG_TAG" +done + +# Restart unhealthy containers +for c in $(sudo podman ps --filter health=unhealthy --format "{{.Names}}"); do + logger -t "$LOG_TAG" "Restarting unhealthy container: $c" + sudo podman restart "$c" 2>&1 | logger -t "$LOG_TAG" +done + +# Check for containers in "created" state (never started) +for c in $(sudo podman ps -a --filter status=created --format "{{.Names}}"); do + logger -t "$LOG_TAG" "Starting created container: $c" + sudo podman start "$c" 2>&1 | logger -t "$LOG_TAG" +done +SCRIPT + +sudo chmod +x /usr/local/bin/archipelago-container-watchdog.sh +``` + +### Create the systemd timer + +```bash +# Service unit +cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-watchdog.service +[Unit] +Description=Archipelago Container Watchdog +After=podman-restart.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/archipelago-container-watchdog.sh +EOF + +# Timer unit — runs every 2 minutes +cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-watchdog.timer +[Unit] +Description=Run Archipelago Container Watchdog every 2 minutes + +[Timer] +OnBootSec=120 +OnUnitActiveSec=120 +AccuracySec=30 + +[Install] +WantedBy=timers.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable --now archipelago-watchdog.timer +``` + +### Verify watchdog is running + +```bash +sudo systemctl status archipelago-watchdog.timer +sudo systemctl list-timers | grep archipelago +# Check watchdog logs +sudo journalctl -t container-watchdog --since "1 hour ago" --no-pager +``` + +## Layer 3: Dependency-Aware Startup Order + +Some containers depend on others. The watchdog handles restarts, but initial boot order matters. + +### Create ordered startup script + +```bash +cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-ordered-start.sh +#!/bin/bash +# Ordered container startup for Archipelago +# Respects dependency chain: bitcoin → electrs/lnd → mempool/btcpay + +LOG_TAG="ordered-start" + +wait_for_container() { + local name=$1 + local max_wait=${2:-60} + local waited=0 + while [ $waited -lt $max_wait ]; do + status=$(sudo podman inspect "$name" --format "{{.State.Running}}" 2>/dev/null) + if [ "$status" = "true" ]; then + logger -t "$LOG_TAG" "$name is running" + return 0 + fi + sleep 5 + waited=$((waited + 5)) + done + logger -t "$LOG_TAG" "WARNING: $name not running after ${max_wait}s" + return 1 +} + +# Tier 0: Infrastructure +logger -t "$LOG_TAG" "Starting Tier 0: Infrastructure" +sudo podman start tailscale 2>/dev/null + +# Tier 1: Bitcoin (foundation) +logger -t "$LOG_TAG" "Starting Tier 1: Bitcoin" +sudo podman start bitcoin-knots 2>/dev/null +wait_for_container bitcoin-knots 120 + +# Tier 2: Bitcoin-dependent services +logger -t "$LOG_TAG" "Starting Tier 2: Bitcoin-dependent" +sudo podman start electrs 2>/dev/null +sudo podman start lnd 2>/dev/null +wait_for_container electrs 90 +wait_for_container lnd 90 + +# Tier 3: Services depending on Tier 2 +logger -t "$LOG_TAG" "Starting Tier 3: Second-order dependencies" +sudo podman start mempool-db 2>/dev/null +sleep 5 +sudo podman start mempool 2>/dev/null +sudo podman start nbxplorer 2>/dev/null +sleep 10 +sudo podman start btcpay-server 2>/dev/null +sudo podman start btcpay-postgres 2>/dev/null + +# Tier 4: Independent apps (start all remaining) +logger -t "$LOG_TAG" "Starting Tier 4: Independent apps" +sudo podman start --all 2>/dev/null + +# Tier 5: UI containers (need parent apps running first) +logger -t "$LOG_TAG" "Starting Tier 5: UI containers" +sudo podman start bitcoin-ui 2>/dev/null +sudo podman start lnd-ui 2>/dev/null + +logger -t "$LOG_TAG" "Startup sequence complete" +SCRIPT + +sudo chmod +x /usr/local/bin/archipelago-ordered-start.sh +``` + +### Wire into boot sequence + +```bash +cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-containers.service +[Unit] +Description=Archipelago Ordered Container Startup +After=network-online.target podman.service +Wants=network-online.target +Before=archipelago.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/archipelago-ordered-start.sh +RemainAfterExit=yes +TimeoutStartSec=300 + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable archipelago-containers.service +``` + +## Verification Checklist + +After setting up all 3 layers, verify: + +```bash +echo "=== Layer 1: Restart Policies ===" +for c in $(sudo podman ps -a --format "{{.Names}}"); do + policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}") + echo " $c: $policy" +done + +echo "" +echo "=== Layer 2: Watchdog Timer ===" +sudo systemctl is-active archipelago-watchdog.timer +sudo systemctl list-timers | grep archipelago + +echo "" +echo "=== Layer 3: Boot Services ===" +sudo systemctl is-enabled podman-restart.service 2>/dev/null || echo "podman-restart: not found" +sudo systemctl is-enabled archipelago-containers.service 2>/dev/null || echo "ordered-start: not found" +sudo systemctl is-enabled archipelago-watchdog.timer 2>/dev/null || echo "watchdog: not found" + +echo "" +echo "=== Container Health Summary ===" +total=$(sudo podman ps -a --format "{{.Names}}" | wc -l) +running=$(sudo podman ps --format "{{.Names}}" | wc -l) +stopped=$((total - running)) +unhealthy=$(sudo podman ps --filter health=unhealthy --format "{{.Names}}" | wc -l) +echo " Total: $total | Running: $running | Stopped: $stopped | Unhealthy: $unhealthy" +``` + +## Reboot Test + +The ultimate uptime test — reboot the server and verify everything comes back: + +```bash +# Before reboot: record running containers +sudo podman ps --format "{{.Names}}" | sort > /tmp/before-reboot.txt + +# Reboot +sudo reboot + +# After reboot (wait ~3 minutes, then SSH back in): +sudo podman ps --format "{{.Names}}" | sort > /tmp/after-reboot.txt + +# Compare +diff /tmp/before-reboot.txt /tmp/after-reboot.txt +# Should show no differences +``` + +## Monitoring + +Check uptime status anytime: +```bash +# Quick status +sudo podman ps -a --format "table {{.Names}}\t{{.Status}}" | sort + +# Watchdog activity +sudo journalctl -t container-watchdog --since "24 hours ago" --no-pager + +# Container events (starts, stops, deaths) +sudo podman events --since 24h --filter event=start --filter event=stop --filter event=died 2>/dev/null | tail -30 +``` + +## Integration + +- Run `/podman-doctor` first to identify issues +- Run `/podman-fix` for specific container repairs +- Run `/podman-uptime` to set up permanent reliability infrastructure +- Add to ISO build: copy watchdog scripts to `image-recipe/configs/` and enable in first-boot diff --git a/core/archipelago/src/api/handler.rs b/core/archipelago/src/api/handler.rs index 6f2cefb2..5e3def58 100644 --- a/core/archipelago/src/api/handler.rs +++ b/core/archipelago/src/api/handler.rs @@ -118,6 +118,7 @@ impl ApiHandler { // WebSocket upgrade — validate session before upgrading if method == Method::GET && path == "/ws/db" { if !self.is_authenticated(req.headers()).await { + tracing::warn!("401 WebSocket /ws/db — session invalid or missing"); return Ok(Self::unauthorized()); } return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await; diff --git a/core/archipelago/src/api/rpc/container.rs b/core/archipelago/src/api/rpc/container.rs index aee1539c..005c7095 100644 --- a/core/archipelago/src/api/rpc/container.rs +++ b/core/archipelago/src/api/rpc/container.rs @@ -176,23 +176,58 @@ impl RpcHandler { let name = c.get("Names").and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or(""); - // Determine lan_address based on container name + // Map container name to its UI port (lan_address) let lan_address = match name { - "bitcoin-knots" => Some("http://localhost:8334"), - "lnd" => Some("http://localhost:8081"), + "bitcoin-knots" | "bitcoin-ui" => Some("http://localhost:8334"), + "lnd" | "archy-lnd-ui" => Some("http://localhost:8081"), "tailscale" => Some("http://localhost:8240"), + "homeassistant" => Some("http://localhost:8123"), + "archy-mempool-web" | "mempool" => Some("http://localhost:4080"), + "btcpay-server" => Some("http://localhost:23000"), + "grafana" => Some("http://localhost:3000"), + "searxng" => Some("http://localhost:8888"), + "ollama" => Some("http://localhost:11434"), + "onlyoffice" => Some("http://localhost:9980"), + "penpot" => Some("http://localhost:9001"), + "nextcloud" => Some("http://localhost:8085"), + "vaultwarden" => Some("http://localhost:8082"), + "jellyfin" => Some("http://localhost:8096"), + "photoprism" => Some("http://localhost:2342"), + "immich_server" | "immich" => Some("http://localhost:2283"), + "filebrowser" => Some("http://localhost:8083"), + "nginx-proxy-manager" => Some("http://localhost:81"), + "portainer" => Some("http://localhost:9000"), + "uptime-kuma" => Some("http://localhost:3001"), + "fedimint" => Some("http://localhost:8175"), + "fedimint-gateway" => Some("http://localhost:8176"), + "nostr-rs-relay" => Some("http://localhost:18081"), + "indeedhub" => Some("http://localhost:7777"), + "dwn" => Some("http://localhost:3100"), + "endurain" => Some("http://localhost:8080"), + "electrs" | "archy-electrs-ui" => Some("http://localhost:50002"), _ => None, }; + // Parse ports from podman JSON (field is "host_port" in snake_case) + let ports: Vec = c.get("Ports") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter().filter_map(|p| { + let host = p.get("host_port").and_then(|v| v.as_u64())?; + let container = p.get("container_port").and_then(|v| v.as_u64())?; + let proto = p.get("protocol").and_then(|v| v.as_str()).unwrap_or("tcp"); + Some(format!("0.0.0.0:{}->{}/{}", host, container, proto)) + }).collect() + }) + .unwrap_or_default(); + serde_json::json!({ "id": c.get("Id").and_then(|v| v.as_str()).unwrap_or(""), "name": name, "state": mapped_state, "image": c.get("Image").and_then(|v| v.as_str()).unwrap_or(""), "created": c.get("Created").and_then(|v| v.as_str()).unwrap_or(""), - "ports": c.get("Ports").and_then(|v| v.as_array()).map(|a| - a.iter().filter_map(|p| p.get("hostPort").and_then(|v| v.as_u64()).map(|p| p.to_string())).collect::>() - ).unwrap_or_default(), + "ports": ports, "lan_address": lan_address, }) }) diff --git a/core/archipelago/src/api/rpc/identity.rs b/core/archipelago/src/api/rpc/identity.rs index c3b83f46..e1a86fc9 100644 --- a/core/archipelago/src/api/rpc/identity.rs +++ b/core/archipelago/src/api/rpc/identity.rs @@ -1,7 +1,7 @@ //! RPC handlers for multi-identity management. use super::RpcHandler; -use crate::identity_manager::{IdentityManager, IdentityPurpose}; +use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose}; use crate::network::did_dht; use anyhow::{Context, Result}; use nostr_sdk::ToBech32; @@ -43,6 +43,7 @@ impl RpcHandler { "is_default": is_default, "nostr_pubkey": id.nostr_pubkey, "nostr_npub": id.nostr_npub, + "profile": id.profile, }) }) .collect(); @@ -650,6 +651,94 @@ impl RpcHandler { })) } + /// Update profile metadata for an identity. + pub(super) async fn handle_identity_update_profile( + &self, + params: Option, + ) -> Result { + let params = params.unwrap_or_default(); + let id = params.get("id").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?; + validate_identity_id(id)?; + + let profile = IdentityProfile { + display_name: params.get("display_name").and_then(|v| v.as_str()).map(String::from), + about: params.get("about").and_then(|v| v.as_str()).map(String::from), + picture: params.get("picture").and_then(|v| v.as_str()).map(String::from), + banner: params.get("banner").and_then(|v| v.as_str()).map(String::from), + website: params.get("website").and_then(|v| v.as_str()).map(String::from), + nip05: params.get("nip05").and_then(|v| v.as_str()).map(String::from), + lud16: params.get("lud16").and_then(|v| v.as_str()).map(String::from), + }; + + let manager = IdentityManager::new(&self.config.data_dir).await?; + manager.update_profile(id, profile).await?; + + Ok(serde_json::json!({ "ok": true })) + } + + /// Publish kind 0 (metadata) profile to the local Nostr relay. + pub(super) async fn handle_identity_publish_profile( + &self, + params: Option, + ) -> Result { + let params = params.unwrap_or_default(); + let id = params.get("id").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?; + validate_identity_id(id)?; + + let relay_url = params.get("relay") + .and_then(|v| v.as_str()) + .unwrap_or("ws://localhost:18081"); + + let manager = IdentityManager::new(&self.config.data_dir).await?; + let event_id = manager.publish_profile(id, relay_url).await?; + + Ok(serde_json::json!({ + "event_id": event_id, + "relay": relay_url, + "published": true, + })) + } + + /// Export private keys for an identity — REQUIRES password verification. + pub(super) async fn handle_identity_export_keys( + &self, + params: Option, + ) -> Result { + let params = params.unwrap_or_default(); + let id = params + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?; + let password = params + .get("password") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?; + validate_identity_id(id)?; + + // Verify password against auth system + if !self.auth_manager.verify_password(password).await? { + anyhow::bail!("Invalid password"); + } + + let manager = IdentityManager::new(&self.config.data_dir).await?; + let keys = manager.export_keys(id).await?; + let record = manager.get(id).await?; + + Ok(serde_json::json!({ + "id": record.id, + "name": record.name, + "pubkey": record.pubkey_hex, + "did": record.did, + "nostr_pubkey": record.nostr_pubkey, + "nostr_npub": record.nostr_npub, + "ed25519_secret_hex": keys["ed25519_secret_hex"], + "nostr_secret_hex": keys["nostr_secret_hex"], + "nostr_nsec": keys["nostr_nsec"], + })) + } + /// identity.dht-status — Check if an identity's did:dht is published and resolvable. pub(super) async fn handle_identity_dht_status( &self, diff --git a/core/archipelago/src/api/rpc/lnd.rs b/core/archipelago/src/api/rpc/lnd.rs index f218838b..0b054b6d 100644 --- a/core/archipelago/src/api/rpc/lnd.rs +++ b/core/archipelago/src/api/rpc/lnd.rs @@ -674,6 +674,129 @@ impl RpcHandler { "broadcast": true, })) } + + /// List on-chain transactions from LND. + /// Returns all transactions, with incoming (amount > 0) flagged. + pub(super) async fn handle_lnd_gettransactions(&self) -> Result { + let (client, macaroon_hex) = self.lnd_client().await?; + + let resp = client + .get("https://127.0.0.1:8080/v1/transactions") + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await + .context("LND REST connection failed")?; + + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .await + .context("Failed to parse transactions response")?; + + if !status.is_success() { + let msg = body + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return Err(anyhow::anyhow!("Failed to list transactions: {}", msg)); + } + + let empty_vec = vec![]; + let raw_txs = body + .get("transactions") + .and_then(|v| v.as_array()) + .unwrap_or(&empty_vec); + + let mut transactions: Vec = Vec::new(); + for tx in raw_txs { + let amount: i64 = tx + .get("amount") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .or_else(|| tx.get("amount").and_then(|v| v.as_i64())) + .unwrap_or(0); + + let num_confirmations: i64 = tx + .get("num_confirmations") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + let tx_hash = tx + .get("tx_hash") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let time_stamp: i64 = tx + .get("time_stamp") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .or_else(|| tx.get("time_stamp").and_then(|v| v.as_i64())) + .unwrap_or(0); + + let total_fees: i64 = tx + .get("total_fees") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .or_else(|| tx.get("total_fees").and_then(|v| v.as_i64())) + .unwrap_or(0); + + let dest_addresses: Vec = tx + .get("dest_addresses") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|a| a.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + let label = tx + .get("label") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let block_height: i64 = tx + .get("block_height") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + let direction = if amount > 0 { "incoming" } else { "outgoing" }; + + transactions.push(serde_json::json!({ + "tx_hash": tx_hash, + "amount_sats": amount.abs(), + "direction": direction, + "num_confirmations": num_confirmations, + "time_stamp": time_stamp, + "total_fees": total_fees, + "dest_addresses": dest_addresses, + "label": label, + "block_height": block_height, + })); + } + + // Sort by timestamp descending (most recent first) + transactions.sort_by(|a, b| { + let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0); + let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0); + tb.cmp(&ta) + }); + + let incoming_pending: usize = transactions + .iter() + .filter(|t| { + t.get("direction").and_then(|v| v.as_str()) == Some("incoming") + && t.get("num_confirmations").and_then(|v| v.as_i64()) == Some(0) + }) + .count(); + + Ok(serde_json::json!({ + "transactions": transactions, + "incoming_pending_count": incoming_pending, + })) + } } // Channel types diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index a77adff7..2cd019d4 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -35,7 +35,7 @@ use crate::config::Config; use crate::container::DevContainerOrchestrator; use crate::monitoring::MetricsStore; use crate::port_allocator::PortAllocator; -use crate::session::{self, EndpointRateLimiter, LoginRateLimiter, SessionStore}; +use crate::session::{self, EndpointRateLimiter, LoginRateLimiter, SessionStore, REMEMBER_TTL}; use crate::state::StateManager; use anyhow::{Context, Result}; use hyper::{Request, Response, StatusCode}; @@ -221,12 +221,30 @@ impl RpcHandler { // Enforce authentication for non-allowlisted methods let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str()); + let mut new_session_cookies: Option<(String, String)> = None; // (session, csrf) if auto-restored if !is_unauthenticated { - let authenticated = match &session_token { + let mut authenticated = match &session_token { Some(token) => self.session_store.validate(token).await, None => false, }; + + // If session invalid, try remember-me token to auto-restore session if !authenticated { + if let Some(remember) = extract_cookie(&parts.headers, "remember") { + if crate::session::SessionStore::validate_remember_token(&remember) { + // Auto-create a new session from the remember-me token + let new_token = self.session_store.create().await; + let new_csrf = generate_csrf_token(); + tracing::info!("Auto-restored session from remember-me token"); + new_session_cookies = Some((new_token, new_csrf)); + authenticated = true; + } + } + } + + if !authenticated { + let reason = if session_token.is_none() { "no session cookie" } else { "invalid/expired token" }; + tracing::warn!(method = %rpc_req.method, reason, "401 Unauthorized — rejecting RPC call"); let rpc_resp = RpcResponse { result: None, error: Some(RpcError { @@ -269,7 +287,8 @@ impl RpcHandler { } // CSRF protection: validate X-CSRF-Token header for authenticated methods - if !is_unauthenticated { + // Skip CSRF check if session was just auto-restored from remember-me (new CSRF will be set in response) + if !is_unauthenticated && new_session_cookies.is_none() { let csrf_cookie = extract_csrf_cookie(&parts.headers); let csrf_header = parts .headers @@ -280,6 +299,12 @@ impl RpcHandler { match (&csrf_cookie, &csrf_header) { (Some(cookie), Some(header)) if cookie == header => { /* valid */ } _ => { + tracing::warn!( + method = %rpc_req.method, + has_cookie = csrf_cookie.is_some(), + has_header = csrf_header.is_some(), + "403 CSRF mismatch — rejecting RPC call" + ); let rpc_resp = RpcResponse { result: None, error: Some(RpcError { @@ -445,6 +470,7 @@ impl RpcHandler { "lnd.payinvoice" => self.handle_lnd_payinvoice(params).await, "lnd.create-psbt" => self.handle_lnd_create_psbt(params).await, "lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await, + "lnd.gettransactions" => self.handle_lnd_gettransactions().await, // Multi-identity management "identity.list" => self.handle_identity_list(params).await, @@ -461,6 +487,9 @@ impl RpcHandler { "identity.resolve-dht-did" => self.handle_identity_resolve_dht_did(params).await, "identity.refresh-dht-did" => self.handle_identity_refresh_dht_did(params).await, "identity.dht-status" => self.handle_identity_dht_status(params).await, + "identity.update-profile" => self.handle_identity_update_profile(params).await, + "identity.publish-profile" => self.handle_identity_publish_profile(params).await, + "identity.export-keys" => self.handle_identity_export_keys(params).await, "identity.create-nostr-key" => self.handle_identity_create_nostr_key(params).await, "identity.nostr-sign" => self.handle_identity_nostr_sign(params).await, "identity.nostr-encrypt-nip04" => self.handle_identity_nostr_encrypt_nip04(params).await, @@ -778,6 +807,7 @@ impl RpcHandler { // No 2FA: create a full session immediately let token = self.session_store.create().await; let csrf_token = generate_csrf_token(); + let remember_token = self.session_store.create_remember_token(); response.headers_mut().append( "Set-Cookie", format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix()) @@ -790,6 +820,13 @@ impl RpcHandler { .parse() .unwrap(), ); + // Remember-me: HMAC-signed, survives backend restarts (30-day TTL) + response.headers_mut().append( + "Set-Cookie", + format!("remember={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, self.cookie_suffix()) + .parse() + .unwrap(), + ); } } @@ -844,6 +881,23 @@ impl RpcHandler { ); } + // If session was auto-restored from remember-me, set new cookies on the response + if let Some((new_session, new_csrf)) = new_session_cookies { + let suffix = self.cookie_suffix(); + response.headers_mut().append( + "Set-Cookie", + format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", new_session, suffix) + .parse() + .unwrap(), + ); + response.headers_mut().append( + "Set-Cookie", + format!("csrf_token={}; SameSite=Strict; Path=/{}", new_csrf, suffix) + .parse() + .unwrap(), + ); + } + Ok(response) } @@ -864,13 +918,14 @@ fn generate_csrf_token() -> String { hex::encode(bytes) } -/// Extract the csrf_token cookie value from headers. -fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option { +/// Extract a named cookie value from headers. +fn extract_cookie(headers: &hyper::HeaderMap, name: &str) -> Option { + let prefix = format!("{}=", name); for value in headers.get_all("cookie") { if let Ok(s) = value.to_str() { for part in s.split(';') { let part = part.trim(); - if let Some(val) = part.strip_prefix("csrf_token=") { + if let Some(val) = part.strip_prefix(&prefix) { let val = val.trim(); if !val.is_empty() { return Some(val.to_string()); @@ -882,6 +937,11 @@ fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option { None } +/// Extract the csrf_token cookie value from headers. +fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option { + extract_cookie(headers, "csrf_token") +} + /// Extract the client IP from request headers (X-Real-IP or X-Forwarded-For). fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr { headers diff --git a/core/archipelago/src/api/rpc/package.rs b/core/archipelago/src/api/rpc/package.rs index 120d9f7c..fbd7426d 100644 --- a/core/archipelago/src/api/rpc/package.rs +++ b/core/archipelago/src/api/rpc/package.rs @@ -58,13 +58,13 @@ impl RpcHandler { }) }; let has_bitcoin = is_running(&["bitcoin-knots", "bitcoin-core", "bitcoin"]); - let has_electrs = is_running(&["mempool-electrs", "electrs"]); + let has_electrumx = is_running(&["electrumx", "mempool-electrs", "electrs"]); has_lnd = is_running(&["lnd"]); match package_id { - "mempool-electrs" | "electrs" if !has_bitcoin => { + "electrumx" | "mempool-electrs" | "electrs" if !has_bitcoin => { return Err(anyhow::anyhow!( - "Electrs requires a running Bitcoin node (Bitcoin Knots). Please install and start Bitcoin Knots first." + "ElectrumX requires a running Bitcoin node (Bitcoin Knots). Please install and start Bitcoin Knots first." )); } "lnd" if !has_bitcoin => { @@ -77,10 +77,10 @@ impl RpcHandler { "BTCPay Server requires a running Bitcoin node (Bitcoin Knots). Please install and start Bitcoin Knots first." )); } - "mempool" | "mempool-web" if !has_bitcoin || !has_electrs => { + "mempool" | "mempool-web" if !has_bitcoin || !has_electrumx => { let mut missing = vec![]; if !has_bitcoin { missing.push("Bitcoin Knots"); } - if !has_electrs { missing.push("Electrs"); } + if !has_electrumx { missing.push("ElectrumX"); } return Err(anyhow::anyhow!( "Mempool requires {} to be running. Please install and start {} first.", missing.join(" and "), @@ -179,8 +179,11 @@ impl RpcHandler { debug!("Using local image: {}", docker_image); } - // Normalize container name: "electrs" alias -> "mempool-electrs" - let container_name = if package_id == "electrs" { "mempool-electrs" } else { package_id }; + // Normalize container name: legacy "electrs"/"mempool-electrs" aliases -> "electrumx" + let container_name = match package_id { + "electrs" | "mempool-electrs" => "electrumx", + _ => package_id, + }; // Create and start container with security constraints let mut run_args = vec![ @@ -234,7 +237,7 @@ impl RpcHandler { package_id, "bitcoin-knots" | "bitcoin" | "bitcoin-core" | "lnd" - | "mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web" + | "mempool" | "mempool-web" | "mempool-api" | "electrumx" | "mempool-electrs" | "electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web" | "btcpay-server" | "btcpayserver" | "archy-btcpay-db" | "archy-nbxplorer" | "nbxplorer" | "fedimint" | "fedimint-gateway" ); @@ -669,10 +672,10 @@ printtoconsole=1\n"; vec![format!("archy-{}", package_id)] } else { let order: &[&str] = match package_id { - "mempool" | "mempool-web" => &["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"], + "mempool" | "mempool-web" => &["archy-mempool-db", "mysql-mempool", "electrumx", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"], "immich" => &["immich_postgres", "immich_redis", "immich_server"], "penpot" | "penpot-frontend" => &["penpot-postgres", "penpot-valkey", "penpot-backend", "penpot-exporter", "penpot-frontend"], - _ => &["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"], + _ => &["archy-mempool-db", "mysql-mempool", "electrumx", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"], }; let mut sorted = containers; sorted.sort_by_key(|c| order.iter().position(|o| *o == c).unwrap_or(99)); @@ -1114,6 +1117,7 @@ async fn get_containers_for_app(package_id: &str) -> Result> { let patterns: Vec = match package_id { "mempool" | "mempool-web" => { vec![ + "electrumx".into(), "mempool-electrs".into(), "mempool-api".into(), "archy-mempool-api".into(), @@ -1160,6 +1164,7 @@ fn get_data_dirs_for_app(package_id: &str) -> Vec { "mempool" | "mempool-web" => vec![ format!("{}/mempool", base), format!("{}/mysql-mempool", base), + format!("{}/electrumx", base), format!("{}/mempool-electrs", base), ], "fedimint" => vec![format!("{}/fedimint", base), format!("{}/fedimint-gateway", base)], @@ -1300,6 +1305,7 @@ fn is_readonly_compatible(app_id: &str) -> bool { "searxng" | "grafana" | "filebrowser" + | "electrumx" | "mempool-electrs" | "electrs" | "nostr-rs-relay" @@ -1332,8 +1338,8 @@ fn get_health_check_args(app_id: &str) -> Vec { "curl -sf http://localhost:8080/ || exit 1", "30s", "3", ), - "mempool-electrs" | "electrs" => ( - "curl -sf http://localhost:50001/ || exit 1", + "electrumx" | "mempool-electrs" | "electrs" => ( + "curl -sf http://localhost:8000/ || exit 1", "60s", "3", ), "nextcloud" => ( @@ -1420,7 +1426,7 @@ fn get_memory_limit(app_id: &str) -> &'static str { "ollama" => "4g", // Medium apps "lnd" => "512m", - "mempool-electrs" | "electrs" => "1g", + "electrumx" | "mempool-electrs" | "electrs" => "1g", "nextcloud" => "1g", "immich_server" | "immich" => "1g", "btcpay-server" | "btcpayserver" => "1g", @@ -1507,7 +1513,7 @@ fn get_app_config( vec!["/var/lib/archipelago/mempool:/data".to_string()], vec![ "MEMPOOL_BACKEND=electrum".to_string(), - "ELECTRUM_HOST=mempool-electrs".to_string(), + "ELECTRUM_HOST=electrumx".to_string(), "ELECTRUM_PORT=50001".to_string(), "ELECTRUM_TLS_ENABLED=false".to_string(), format!("CORE_RPC_HOST={}", host_ip), @@ -1523,26 +1529,20 @@ fn get_app_config( None, None, ), - "mempool-electrs" | "electrs" => { + "electrumx" | "mempool-electrs" | "electrs" => { // Detect which bitcoin container is running for archy-net DNS resolution let bitcoin_host = detect_bitcoin_container_name(); ( vec!["50001:50001".to_string()], - vec!["/var/lib/archipelago/mempool-electrs:/data".to_string()], - vec![], + vec!["/var/lib/archipelago/electrumx:/data".to_string()], + vec![ + format!("DAEMON_URL=http://archipelago:archipelago123@{}:8332/", bitcoin_host), + "COIN=Bitcoin".to_string(), + "DB_DIRECTORY=/data".to_string(), + "SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(), + ], + None, None, - Some(vec![ - "--daemon-rpc-addr".to_string(), - format!("{}:8332", bitcoin_host), - "--cookie".to_string(), - "archipelago:archipelago123".to_string(), - "--jsonrpc-import".to_string(), - "--electrum-rpc-addr".to_string(), - "0.0.0.0:50001".to_string(), - "--db-dir".to_string(), - "/data".to_string(), - "--lightmode".to_string(), - ]), ) }, "mysql-mempool" => ( diff --git a/core/archipelago/src/api/rpc/system.rs b/core/archipelago/src/api/rpc/system.rs index b66a8929..263e8236 100644 --- a/core/archipelago/src/api/rpc/system.rs +++ b/core/archipelago/src/api/rpc/system.rs @@ -592,8 +592,8 @@ async fn read_temperatures() -> Result> { } impl RpcHandler { - /// system.factory-reset — Wipe all user data and restart. - /// Preserves container images and node_key (hardware identity). + /// system.factory-reset — Wipe all user data, remove containers, and restart. + /// Only preserves the data_dir itself (recreated empty on restart). pub(super) async fn handle_system_factory_reset( &self, params: Option, @@ -609,53 +609,67 @@ impl RpcHandler { anyhow::bail!("Factory reset requires {{ \"confirm\": true }}"); } - tracing::warn!("Factory reset initiated — wiping user data"); + tracing::warn!("Factory reset initiated — wiping ALL user data and containers"); let data_dir = &self.config.data_dir; - // Stop all running containers + // 1. Stop and remove ALL containers (force) let client = archipelago_container::PodmanClient::new("archipelago".to_string()); if let Ok(containers) = client.list_containers().await { for c in &containers { + tracing::info!("Factory reset: removing container {}", c.name); let _ = client.stop_container(&c.name).await; + let _ = client.remove_container(&c.name).await; } } - // Delete user data (preserving node_key and container images) - let files_to_remove = [ - "user.json", - "onboarding.json", - "peers.json", - "server-name", - ]; - for f in &files_to_remove { - let path = data_dir.join(f); - if path.exists() { - let _ = tokio::fs::remove_file(&path).await; + // 2. Remove all container images + tracing::info!("Factory reset: pruning all container images"); + let _ = tokio::process::Command::new("sudo") + .args(["-u", "archipelago", "podman", "rmi", "--all", "--force"]) + .output() + .await; + + // 3. Prune volumes and build cache + let _ = tokio::process::Command::new("sudo") + .args(["-u", "archipelago", "podman", "volume", "prune", "-f"]) + .output() + .await; + let _ = tokio::process::Command::new("sudo") + .args(["-u", "archipelago", "podman", "system", "prune", "-af"]) + .output() + .await; + + // 4. Wipe the entire data directory contents + // Delete everything inside data_dir, then recreate the empty dir. + if let Ok(mut entries) = tokio::fs::read_dir(data_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Skip the tor directory (managed by system debian-tor user) + if name_str == "tor" { + continue; + } + + tracing::info!("Factory reset: removing {}", path.display()); + if path.is_dir() { + let _ = tokio::fs::remove_dir_all(&path).await; + } else { + let _ = tokio::fs::remove_file(&path).await; + } } } - let dirs_to_remove = [ - "identities", - "credentials", - "did-cache", - "dwn", - ]; - for d in &dirs_to_remove { - let path = data_dir.join(d); - if path.exists() { - let _ = tokio::fs::remove_dir_all(&path).await; - } - } - - // Clear all sessions + // 5. Clear all sessions self.session_store.invalidate_all_except("").await; - tracing::warn!("Factory reset complete — restarting service"); + tracing::warn!("Factory reset complete — all data wiped, restarting service"); // Restart the service via systemd tokio::spawn(async { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; let _ = std::process::Command::new("sudo") .args(["systemctl", "restart", "archipelago"]) .spawn(); diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 12194c72..ad6bbfbe 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -139,9 +139,9 @@ impl DockerPackageScanner { } else if app_id == "indeedhub" { debug!("Using IndeedHub: http://localhost:7777"); Some("http://localhost:7777".to_string()) - } else if app_id == "mempool-electrs" || app_id == "electrs" { - // Electrs UI runs on host at port 50002 - debug!("Using electrs-ui for mempool-electrs: http://localhost:50002"); + } else if app_id == "electrumx" || app_id == "mempool-electrs" || app_id == "electrs" { + // ElectrumX UI runs on host at port 50002 + debug!("Using electrumx-ui for electrumx: http://localhost:50002"); Some("http://localhost:50002".to_string()) } else { // Extract port from the main container @@ -240,7 +240,7 @@ fn get_app_tier(app_id: &str) -> &'static str { // Core: required for basic Bitcoin node "bitcoin" | "bitcoin-core" | "bitcoin-knots" => "core", "lnd" => "core", - "mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "electrs" => "core", + "mempool" | "mempool-web" | "mempool-api" | "electrumx" | "mempool-electrs" | "electrs" => "core", "btcpay" | "btcpay-server" | "btcpayserver" => "core", "dwn" => "core", "filebrowser" => "core", @@ -329,11 +329,11 @@ fn get_app_metadata(app_id: &str) -> AppMetadata { repo: "https://github.com/mempool/mempool".to_string(), tier: "", }, - "mempool-electrs" | "electrs" => AppMetadata { - title: "Electrs".to_string(), - description: "Electrum protocol indexer for Bitcoin. Powers Mempool and other Electrum clients.".to_string(), + "electrumx" | "mempool-electrs" | "electrs" => AppMetadata { + title: "ElectrumX".to_string(), + description: "ElectrumX server — full Electrum protocol indexer for Bitcoin. Powers Mempool and Electrum wallets.".to_string(), icon: "/assets/img/app-icons/electrs.svg".to_string(), - repo: "https://github.com/romanz/electrs".to_string(), + repo: "https://github.com/spesmilo/electrumx".to_string(), tier: "", }, "ollama" => AppMetadata { diff --git a/core/archipelago/src/electrs_status.rs b/core/archipelago/src/electrs_status.rs index b3c957e9..9cf53cf5 100644 --- a/core/archipelago/src/electrs_status.rs +++ b/core/archipelago/src/electrs_status.rs @@ -1,4 +1,4 @@ -//! Electrs sync status: fetches indexed height from Electrum RPC and network height from Bitcoin Core. +//! ElectrumX sync status: fetches indexed height from Electrum RPC and network height from Bitcoin Core. use anyhow::{Context, Result}; use serde::Serialize; @@ -6,12 +6,12 @@ use std::io::{BufRead, BufReader, Write}; use std::net::TcpStream; use std::time::Duration; -const ELECTRS_HOST: &str = "127.0.0.1"; -const ELECTRS_PORT: u16 = 50001; +const ELECTRUMX_HOST: &str = "127.0.0.1"; +const ELECTRUMX_PORT: u16 = 50001; const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/"; -const ELECTRS_DATA_DIR: &str = "/var/lib/archipelago/mempool-electrs"; -// Approximate final index size in bytes for mainnet with --lightmode (~35GB) -const ESTIMATED_FULL_INDEX_BYTES: f64 = 35_000_000_000.0; +const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx"; +// Approximate final index size in bytes for mainnet (~55GB for ElectrumX full index) +const ESTIMATED_FULL_INDEX_BYTES: f64 = 55_000_000_000.0; /// Build Bitcoin RPC Basic auth header from env vars. /// Falls back to cookie auth file if env vars are not set. @@ -61,10 +61,10 @@ fn format_bytes(bytes: u64) -> String { } } -/// Fetch electrs indexed height via Electrum protocol (TCP JSON-RPC). -fn electrs_indexed_height() -> Result { - let mut stream = TcpStream::connect((ELECTRS_HOST, ELECTRS_PORT)) - .context("Failed to connect to electrs")?; +/// Fetch ElectrumX indexed height via Electrum protocol (TCP JSON-RPC). +fn electrumx_indexed_height() -> Result { + let mut stream = TcpStream::connect((ELECTRUMX_HOST, ELECTRUMX_PORT)) + .context("Failed to connect to ElectrumX")?; stream .set_read_timeout(Some(Duration::from_secs(5))) .context("set_read_timeout")?; @@ -83,11 +83,11 @@ fn electrs_indexed_height() -> Result { reader.read_line(&mut line)?; let line = line.trim(); if line.is_empty() { - anyhow::bail!("Empty response from electrs"); + anyhow::bail!("Empty response from ElectrumX"); } let json: serde_json::Value = serde_json::from_str(line)?; - // blockchain.numblocks.subscribe returns result as number; headers.subscribe returns {block_height: N} + // blockchain.numblocks.subscribe returns result as number; headers.subscribe returns {block_height: N, hex: ...} let height = json .get("result") .and_then(|r| r.as_u64()) @@ -96,7 +96,13 @@ fn electrs_indexed_height() -> Result { .and_then(|r| r.get("block_height")) .and_then(|h| h.as_u64()) }) - .context("Missing height in electrs response")?; + .or_else(|| { + // ElectrumX returns {"result": {"height": N, "hex": "..."}} + json.get("result") + .and_then(|r| r.get("height")) + .and_then(|h| h.as_u64()) + }) + .context("Missing height in ElectrumX response")?; Ok(height) } @@ -130,10 +136,10 @@ async fn bitcoin_network_height() -> Result { Ok(height) } -/// Get electrs sync status. Runs blocking electrs call in spawn_blocking. +/// Get ElectrumX sync status. Runs blocking ElectrumX call in spawn_blocking. pub async fn get_electrs_sync_status() -> ElectrsSyncStatus { // Get index data size (non-blocking, fast filesystem stat) - let data_bytes = dir_size_bytes(ELECTRS_DATA_DIR); + let data_bytes = dir_size_bytes(ELECTRUMX_DATA_DIR); let index_size = if data_bytes > 0 { Some(format_bytes(data_bytes)) } else { @@ -154,10 +160,10 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus { } }; - let indexed_height = match tokio::task::spawn_blocking(electrs_indexed_height).await { + let indexed_height = match tokio::task::spawn_blocking(electrumx_indexed_height).await { Ok(Ok(h)) => h, Ok(Err(e)) => { - // Electrs doesn't listen on 50001 until indexing completes (can take hours) + // ElectrumX may not be ready on 50001 during initial sync let err_msg = e.to_string(); let (status, error) = if err_msg.contains("connect") || err_msg.contains("Connection refused") { // Estimate progress from data directory size @@ -170,12 +176,12 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus { ( "indexing".to_string(), Some(format!( - "Building index ({} / ~35 GB estimated). Electrum RPC will be available when complete.", + "Building index ({} / ~55 GB estimated). Electrum RPC will be available when complete.", size_str )), ) } else { - ("error".to_string(), Some(format!("Electrs: {}", e))) + ("error".to_string(), Some(format!("ElectrumX: {}", e))) }; // Use estimated progress when indexing let progress_pct = if status == "indexing" && data_bytes > 0 { diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index 824db797..8379e82e 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -46,7 +46,7 @@ fn container_tier(name: &str) -> StartupTier { "bitcoin-knots" | "bitcoin-core" | "bitcoin" => StartupTier::CoreInfra, // Tier 2: Dependent services - "lnd" | "mempool-electrs" | "electrs" | "nbxplorer" => StartupTier::DependentService, + "lnd" | "electrumx" | "mempool-electrs" | "electrs" | "nbxplorer" => StartupTier::DependentService, // Tier 4: Frontend/UI "mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index 6b8c5cd7..f7a2aab9 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -48,6 +48,28 @@ pub struct IdentityRecord { pub nostr_pubkey: Option, /// Nostr public key in bech32 npub format (NIP-19) pub nostr_npub: Option, + /// Nostr profile metadata (NIP-01 kind 0) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, +} + +/// Nostr profile metadata fields (NIP-01 kind 0 + NIP-24 extra fields). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct IdentityProfile { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub picture: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub banner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub website: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nip05: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lud16: Option, } /// On-disk format for identity storage (includes secret key bytes). @@ -64,6 +86,9 @@ struct IdentityFile { nostr_secret_hex: Option, #[serde(default)] nostr_pubkey_hex: Option, + /// Nostr profile metadata + #[serde(default)] + profile: Option, } pub struct IdentityManager { @@ -123,6 +148,7 @@ impl IdentityManager { created_at: created_at.clone(), nostr_secret_hex: None, nostr_pubkey_hex: None, + profile: None, }; let file_path = self.identities_dir.join(format!("{}.json", id)); @@ -345,6 +371,77 @@ impl IdentityManager { .context("NIP-44 decryption failed") } + /// Update the profile metadata for an identity. + pub async fn update_profile(&self, id: &str, profile: IdentityProfile) -> Result<()> { + let file_path = self.identities_dir.join(format!("{}.json", id)); + if !file_path.exists() { + return Err(anyhow::anyhow!("Identity not found: {}", id)); + } + let data = fs::read(&file_path).await.context("Failed to read identity file")?; + let mut file: IdentityFile = serde_json::from_slice(&data).context("Failed to parse identity file")?; + file.profile = Some(profile); + let json = serde_json::to_string_pretty(&file).context("Failed to serialize identity")?; + fs::write(&file_path, json.as_bytes()).await.context("Failed to write identity file")?; + Ok(()) + } + + /// Publish kind 0 (metadata) event to a Nostr relay. + pub async fn publish_profile(&self, id: &str, relay_url: &str) -> Result { + let record = self.get(id).await?; + let keys = self.load_nostr_keys(id).await?; + let profile = record.profile.unwrap_or_default(); + + // Build kind 0 content JSON (NIP-01 + NIP-24) + let mut content = serde_json::Map::new(); + content.insert("name".to_string(), serde_json::json!(record.name)); + if let Some(v) = &profile.display_name { content.insert("display_name".to_string(), serde_json::json!(v)); } + if let Some(v) = &profile.about { content.insert("about".to_string(), serde_json::json!(v)); } + if let Some(v) = &profile.picture { content.insert("picture".to_string(), serde_json::json!(v)); } + if let Some(v) = &profile.banner { content.insert("banner".to_string(), serde_json::json!(v)); } + if let Some(v) = &profile.website { content.insert("website".to_string(), serde_json::json!(v)); } + if let Some(v) = &profile.nip05 { content.insert("nip05".to_string(), serde_json::json!(v)); } + if let Some(v) = &profile.lud16 { content.insert("lud16".to_string(), serde_json::json!(v)); } + + let content_str = serde_json::to_string(&content).context("Failed to serialize profile content")?; + + let client = nostr_sdk::Client::new(keys); + client.add_relay(relay_url).await.context("Failed to add relay")?; + client.connect().await; + + let builder = nostr_sdk::prelude::EventBuilder::new( + nostr_sdk::prelude::Kind::Metadata, + &content_str, + ); + let output = client.send_event_builder(builder).await.context("Failed to publish profile")?; + client.disconnect().await; + + Ok(output.id().to_hex()) + } + + /// Export all keys for an identity (SENSITIVE — only call after password verification). + pub async fn export_keys(&self, id: &str) -> Result { + let file_path = self.identities_dir.join(format!("{}.json", id)); + if !file_path.exists() { + return Err(anyhow::anyhow!("Identity not found: {}", id)); + } + let data = fs::read(&file_path).await.context("Failed to read identity file")?; + let file: IdentityFile = serde_json::from_slice(&data).context("Failed to parse identity file")?; + + let ed25519_secret_hex = hex::encode(&file.secret_key); + + let nostr_nsec = file.nostr_secret_hex.as_ref().and_then(|h| { + nostr_sdk::SecretKey::from_hex(h) + .ok() + .and_then(|sk| sk.to_bech32().ok()) + }); + + Ok(serde_json::json!({ + "ed25519_secret_hex": ed25519_secret_hex, + "nostr_secret_hex": file.nostr_secret_hex, + "nostr_nsec": nostr_nsec, + })) + } + // --- internal helpers --- } @@ -395,6 +492,7 @@ impl IdentityManager { created_at: file.created_at, nostr_pubkey: file.nostr_pubkey_hex, nostr_npub, + profile: file.profile, }) } diff --git a/core/archipelago/src/session.rs b/core/archipelago/src/session.rs index 8fa438d4..3ee1b712 100644 --- a/core/archipelago/src/session.rs +++ b/core/archipelago/src/session.rs @@ -1,15 +1,22 @@ +use hmac::{Hmac, Mac}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::net::IpAddr; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::{Instant, SystemTime}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::RwLock; use zeroize::Zeroize; +type HmacSha256 = Hmac; + const FULL_SESSION_TTL: u64 = 86400; // 24 hours of inactivity const PENDING_SESSION_TTL: u64 = 300; // 5 minutes const MAX_TOTP_ATTEMPTS: u8 = 5; -const MAX_CONCURRENT_SESSIONS: usize = 5; +const MAX_CONCURRENT_SESSIONS: usize = 20; +const SESSIONS_FILE: &str = "/var/lib/archipelago/sessions.json"; +const REMEMBER_SECRET_FILE: &str = "/var/lib/archipelago/remember_secret"; +pub const REMEMBER_TTL: u64 = 30 * 24 * 3600; // 30 days #[derive(Clone)] enum SessionType { @@ -38,13 +45,106 @@ struct Session { #[derive(Clone)] pub struct SessionStore { sessions: Arc>>, + persist_path: PathBuf, +} + +/// On-disk representation of a persisted session (only Full sessions, no TOTP secrets). +#[derive(serde::Serialize, serde::Deserialize)] +struct PersistedSession { + hash_hex: String, + created_at: u64, // Unix timestamp + last_activity: u64, // Unix timestamp } impl SessionStore { pub fn new() -> Self { - Self { - sessions: Arc::new(RwLock::new(HashMap::new())), + let persist_path = PathBuf::from(SESSIONS_FILE); + let sessions = Self::load_from_disk(&persist_path); + let count = sessions.len(); + if count > 0 { + tracing::info!("Restored {} sessions from disk", count); } + Self { + sessions: Arc::new(RwLock::new(sessions)), + persist_path, + } + } + + /// Load persisted sessions from disk (only Full sessions). + fn load_from_disk(path: &Path) -> HashMap<[u8; 32], Session> { + let mut map = HashMap::new(); + let data = match std::fs::read_to_string(path) { + Ok(d) => d, + Err(_) => return map, + }; + let persisted: Vec = match serde_json::from_str(&data) { + Ok(v) => v, + Err(e) => { + tracing::warn!("Failed to parse sessions file: {}", e); + return map; + } + }; + let now_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + for p in persisted { + // Skip expired sessions + if now_unix.saturating_sub(p.last_activity) >= FULL_SESSION_TTL { + continue; + } + let hash = match hex::decode(&p.hash_hex) { + Ok(h) if h.len() == 32 => { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&h); + arr + } + _ => continue, + }; + let created_at = UNIX_EPOCH + std::time::Duration::from_secs(p.created_at); + let last_activity = UNIX_EPOCH + std::time::Duration::from_secs(p.last_activity); + map.insert(hash, Session { + created_at, + last_activity, + session_type: SessionType::Full, + }); + } + map + } + + /// Save all Full sessions to disk. Called after mutations. + fn save_to_disk_sync(sessions: &HashMap<[u8; 32], Session>, path: &Path) { + let persisted: Vec = sessions + .iter() + .filter(|(_, s)| matches!(s.session_type, SessionType::Full)) + .map(|(hash, s)| PersistedSession { + hash_hex: hex::encode(hash), + created_at: s.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(), + last_activity: s.last_activity.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(), + }) + .collect(); + if let Ok(json) = serde_json::to_string(&persisted) { + let _ = std::fs::write(path, json); + } + } + + /// Async wrapper for save — spawns to avoid blocking RPC. + fn schedule_save(&self, sessions: &HashMap<[u8; 32], Session>) { + let persisted: Vec = sessions + .iter() + .filter(|(_, s)| matches!(s.session_type, SessionType::Full)) + .map(|(hash, s)| PersistedSession { + hash_hex: hex::encode(hash), + created_at: s.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(), + last_activity: s.last_activity.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(), + }) + .collect(); + let path = self.persist_path.clone(); + tokio::spawn(async move { + if let Ok(json) = serde_json::to_string(&persisted) { + let _ = tokio::fs::write(path, json).await; + } + }); } /// Create a full (authenticated) session. Returns the plaintext token. @@ -63,6 +163,9 @@ impl SessionStore { let mut sessions = self.sessions.write().await; self.evict_if_over_limit(&mut sessions); sessions.insert(hash, session); + // Sync save — must complete before returning the token to the client. + // Async save risks losing the session if the process is killed (e.g., deploy restart). + Self::save_to_disk_sync(&sessions, &self.persist_path); token } @@ -140,12 +243,15 @@ impl SessionStore { let now = SystemTime::now(); session.created_at = now; session.last_activity = now; + Self::save_to_disk_sync(&sessions, &self.persist_path); } } pub async fn remove(&self, token: &str) { let hash = hash_token(token); - self.sessions.write().await.remove(&hash); + let mut sessions = self.sessions.write().await; + sessions.remove(&hash); + Self::save_to_disk_sync(&sessions, &self.persist_path); } /// Invalidate all sessions except the one matching the given token. @@ -154,6 +260,7 @@ impl SessionStore { let keep_hash = hash_token(keep_token); let mut sessions = self.sessions.write().await; sessions.retain(|hash, _| *hash == keep_hash); + Self::save_to_disk_sync(&sessions, &self.persist_path); } /// Rotate a session: invalidate the old token and create a new one. @@ -175,6 +282,7 @@ impl SessionStore { session_type: SessionType::Full, }, ); + Self::save_to_disk_sync(&sessions, &self.persist_path); new_token } @@ -222,6 +330,78 @@ impl SessionStore { }) .count() } + + // ── Remember-me token ────────────────────────────────────────────── + // HMAC-signed token that survives backend restarts. Secret is on disk. + // Format: "timestamp_hex:hmac_hex" + + /// Create a remember-me token. Returns the cookie value. + pub fn create_remember_token(&self) -> String { + let secret = Self::load_or_create_remember_secret(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let ts_hex = hex::encode(now.to_be_bytes()); + let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key"); + mac.update(format!("remember:{}", ts_hex).as_bytes()); + let sig = hex::encode(mac.finalize().into_bytes()); + format!("{}:{}", ts_hex, sig) + } + + /// Validate a remember-me token. Returns true if valid and not expired. + pub fn validate_remember_token(token: &str) -> bool { + let secret = match std::fs::read(REMEMBER_SECRET_FILE) { + Ok(s) if s.len() == 32 => s, + _ => return false, + }; + let parts: Vec<&str> = token.splitn(2, ':').collect(); + if parts.len() != 2 { + return false; + } + let ts_hex = parts[0]; + let sig_hex = parts[1]; + + // Verify HMAC + let mut mac = match HmacSha256::new_from_slice(&secret) { + Ok(m) => m, + Err(_) => return false, + }; + mac.update(format!("remember:{}", ts_hex).as_bytes()); + let expected_sig = match hex::decode(sig_hex) { + Ok(s) => s, + Err(_) => return false, + }; + if mac.verify_slice(&expected_sig).is_err() { + return false; + } + + // Check expiry + let ts_bytes = match hex::decode(ts_hex) { + Ok(b) if b.len() == 8 => { + let mut arr = [0u8; 8]; + arr.copy_from_slice(&b); + u64::from_be_bytes(arr) + } + _ => return false, + }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now.saturating_sub(ts_bytes) < REMEMBER_TTL + } + + fn load_or_create_remember_secret() -> Vec { + if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) { + if secret.len() == 32 { + return secret; + } + } + let secret: [u8; 32] = rand::random(); + let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret); + secret.to_vec() + } } fn hash_token(token: &str) -> [u8; 32] { diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index ca6ef83b..12bed8f3 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -66,6 +66,41 @@ impl PodmanClient { } } + /// Map container name to its UI launch URL + fn lan_address_for(name: &str) -> Option { + let url = match name { + "bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334", + "lnd" | "archy-lnd-ui" => "http://localhost:8081", + "tailscale" => "http://localhost:8240", + "homeassistant" => "http://localhost:8123", + "archy-mempool-web" | "mempool" => "http://localhost:4080", + "btcpay-server" => "http://localhost:23000", + "grafana" => "http://localhost:3000", + "searxng" => "http://localhost:8888", + "ollama" => "http://localhost:11434", + "onlyoffice" => "http://localhost:9980", + "penpot" => "http://localhost:9001", + "nextcloud" => "http://localhost:8085", + "vaultwarden" => "http://localhost:8082", + "jellyfin" => "http://localhost:8096", + "photoprism" => "http://localhost:2342", + "immich_server" | "immich" => "http://localhost:2283", + "filebrowser" => "http://localhost:8083", + "nginx-proxy-manager" => "http://localhost:81", + "portainer" => "http://localhost:9000", + "uptime-kuma" => "http://localhost:3001", + "fedimint" => "http://localhost:8175", + "fedimint-gateway" => "http://localhost:8176", + "nostr-rs-relay" => "http://localhost:18081", + "indeedhub" => "http://localhost:7777", + "dwn" => "http://localhost:3100", + "endurain" => "http://localhost:8080", + "electrs" | "archy-electrs-ui" => "http://localhost:50002", + _ => return None, + }; + Some(url.to_string()) + } + fn podman_async(&self) -> TokioCommand { // Always use sudo podman to access system-wide containers let mut cmd = TokioCommand::new("sudo"); @@ -348,6 +383,7 @@ impl PodmanClient { vec![] }; + let lan_address = Self::lan_address_for(&name); result.push(ContainerStatus { id: container["Id"].as_str().unwrap_or("").to_string(), name, @@ -355,7 +391,7 @@ impl PodmanClient { image: container["Image"].as_str().unwrap_or("").to_string(), created: container["Created"].as_str().unwrap_or("").to_string(), ports, - lan_address: None, // Set by docker_packages scanner + lan_address, }); } } else { @@ -365,7 +401,7 @@ impl PodmanClient { if line.trim().is_empty() { continue; } - + if let Ok(container) = serde_json::from_str::(line) { // Handle both Names as array and Names as string let name = if let Some(names_array) = container["Names"].as_array() { @@ -373,7 +409,7 @@ impl PodmanClient { } else { container["Names"].as_str().unwrap_or("").to_string() }; - + // Parse ports from the Ports array let ports = if let Some(ports_array) = container["Ports"].as_array() { ports_array.iter().filter_map(|port| { @@ -390,7 +426,8 @@ impl PodmanClient { } else { vec![] }; - + + let lan_address = Self::lan_address_for(&name); result.push(ContainerStatus { id: container["Id"].as_str().unwrap_or("").to_string(), name, @@ -398,7 +435,7 @@ impl PodmanClient { image: container["Image"].as_str().unwrap_or("").to_string(), created: container["Created"].as_str().unwrap_or("").to_string(), ports, - lan_address: None, // Set by docker_packages scanner + lan_address, }); } } diff --git a/docker/bitcoin-ui/Dockerfile b/docker/bitcoin-ui/Dockerfile index 777dd66d..a01b98cf 100644 --- a/docker/bitcoin-ui/Dockerfile +++ b/docker/bitcoin-ui/Dockerfile @@ -2,6 +2,7 @@ FROM docker.io/library/nginx:alpine # Copy the static UI COPY index.html /usr/share/nginx/html/ +COPY tailwind.css /usr/share/nginx/html/ # Create assets directories first RUN mkdir -p /usr/share/nginx/html/assets/img/app-icons && \ diff --git a/docker/bitcoin-ui/index.html b/docker/bitcoin-ui/index.html index fc039a0c..ebdd0e13 100644 --- a/docker/bitcoin-ui/index.html +++ b/docker/bitcoin-ui/index.html @@ -7,7 +7,7 @@ Bitcoin Knots - Archipelago - + @@ -76,6 +98,7 @@
+
@@ -84,8 +107,11 @@
-

Electrs

-

Bitcoin Electrum indexer for Mempool & Electrum clients

+
+

ElectrumX

+ v1.18.0 +
+

Bitcoin Electrum server for wallet connections

@@ -97,7 +123,8 @@
-
+ +
@@ -139,17 +166,215 @@
+ + +
+

Connect Your Wallet

+

Use the following details to connect your wallet or application to ElectrumX.

+ +
+ + +
+ + +
+
+
+
+
Address
+
+ - + +
+
+
+
+
Port
+
+ 50001 + +
+
+
+
SSL
+
+ Disabled + +
+
+
+
+
+ + + + +
+ Connect using Sparrow Wallet, Electrum, or any Electrum-protocol compatible wallet. Set the server to the address and port shown above with SSL disabled. +
+
+ diff --git a/docker/electrs-ui/qrcode.js b/docker/electrs-ui/qrcode.js new file mode 100644 index 00000000..df13f829 --- /dev/null +++ b/docker/electrs-ui/qrcode.js @@ -0,0 +1,2297 @@ +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript +// +// Copyright (c) 2009 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +var qrcode = function() { + + //--------------------------------------------------------------------- + // qrcode + //--------------------------------------------------------------------- + + /** + * qrcode + * @param typeNumber 1 to 40 + * @param errorCorrectionLevel 'L','M','Q','H' + */ + var qrcode = function(typeNumber, errorCorrectionLevel) { + + var PAD0 = 0xEC; + var PAD1 = 0x11; + + var _typeNumber = typeNumber; + var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + var _modules = null; + var _moduleCount = 0; + var _dataCache = null; + var _dataList = []; + + var _this = {}; + + var makeImpl = function(test, maskPattern) { + + _moduleCount = _typeNumber * 4 + 17; + _modules = function(moduleCount) { + var modules = new Array(moduleCount); + for (var row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (var col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + }(_moduleCount); + + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + + mapData(_dataCache, maskPattern); + }; + + var setupPositionProbePattern = function(row, col) { + + for (var r = -1; r <= 7; r += 1) { + + if (row + r <= -1 || _moduleCount <= row + r) continue; + + for (var c = -1; c <= 7; c += 1) { + + if (col + c <= -1 || _moduleCount <= col + c) continue; + + if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) + || (0 <= c && c <= 6 && (r == 0 || r == 6) ) + || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + + var getBestMaskPattern = function() { + + var minLostPoint = 0; + var pattern = 0; + + for (var i = 0; i < 8; i += 1) { + + makeImpl(true, i); + + var lostPoint = QRUtil.getLostPoint(_this); + + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + }; + + var setupTimingPattern = function() { + + for (var r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = (r % 2 == 0); + } + + for (var c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = (c % 2 == 0); + } + }; + + var setupPositionAdjustPattern = function() { + + var pos = QRUtil.getPatternPosition(_typeNumber); + + for (var i = 0; i < pos.length; i += 1) { + + for (var j = 0; j < pos.length; j += 1) { + + var row = pos[i]; + var col = pos[j]; + + if (_modules[row][col] != null) { + continue; + } + + for (var r = -2; r <= 2; r += 1) { + + for (var c = -2; c <= 2; c += 1) { + + if (r == -2 || r == 2 || c == -2 || c == 2 + || (r == 0 && c == 0) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + + var setupTypeNumber = function(test) { + + var bits = QRUtil.getBCHTypeNumber(_typeNumber); + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + + var setupTypeInfo = function(test, maskPattern) { + + var data = (_errorCorrectionLevel << 3) | maskPattern; + var bits = QRUtil.getBCHTypeInfo(data); + + // vertical + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + + // horizontal + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + + // fixed module + _modules[_moduleCount - 8][8] = (!test); + }; + + var mapData = function(data, maskPattern) { + + var inc = -1; + var row = _moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + var maskFunc = QRUtil.getMaskFunction(maskPattern); + + for (var col = _moduleCount - 1; col > 0; col -= 2) { + + if (col == 6) col -= 1; + + while (true) { + + for (var c = 0; c < 2; c += 1) { + + if (_modules[row][col - c] == null) { + + var dark = false; + + if (byteIndex < data.length) { + dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); + } + + var mask = maskFunc(row, col - c); + + if (mask) { + dark = !dark; + } + + _modules[row][col - c] = dark; + bitIndex -= 1; + + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + + var createBytes = function(buffer, rsBlocks) { + + var offset = 0; + + var maxDcCount = 0; + var maxEcCount = 0; + + var dcdata = new Array(rsBlocks.length); + var ecdata = new Array(rsBlocks.length); + + for (var r = 0; r < rsBlocks.length; r += 1) { + + var dcCount = rsBlocks[r].dataCount; + var ecCount = rsBlocks[r].totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (var i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + + var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + + var modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (var i = 0; i < ecdata[r].length; i += 1) { + var modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; + } + } + + var totalCodeCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + + var data = new Array(totalCodeCount); + var index = 0; + + for (var i = 0; i < maxDcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + + for (var i = 0; i < maxEcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + + return data; + }; + + var createData = function(typeNumber, errorCorrectionLevel, dataList) { + + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); + + var buffer = qrBitBuffer(); + + for (var i = 0; i < dataList.length; i += 1) { + var data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + // calc num max data. + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw 'code length overflow. (' + + buffer.getLengthInBits() + + '>' + + totalDataCount * 8 + + ')'; + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + + // padding + while (true) { + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + + return createBytes(buffer, rsBlocks); + }; + + _this.addData = function(data, mode) { + + mode = mode || 'Byte'; + + var newData = null; + + switch(mode) { + case 'Numeric' : + newData = qrNumber(data); + break; + case 'Alphanumeric' : + newData = qrAlphaNum(data); + break; + case 'Byte' : + newData = qr8BitByte(data); + break; + case 'Kanji' : + newData = qrKanji(data); + break; + default : + throw 'mode:' + mode; + } + + _dataList.push(newData); + _dataCache = null; + }; + + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + ',' + col; + } + return _modules[row][col]; + }; + + _this.getModuleCount = function() { + return _moduleCount; + }; + + _this.make = function() { + if (_typeNumber < 1) { + var typeNumber = 1; + + for (; typeNumber < 40; typeNumber++) { + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); + var buffer = qrBitBuffer(); + + for (var i = 0; i < _dataList.length; i++) { + var data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + + _typeNumber = typeNumber; + } + + makeImpl(false, getBestMaskPattern() ); + }; + + _this.createTableTag = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var qrHtml = ''; + + qrHtml += ''; + qrHtml += ''; + + for (var r = 0; r < _this.getModuleCount(); r += 1) { + + qrHtml += ''; + + for (var c = 0; c < _this.getModuleCount(); c += 1) { + qrHtml += ''; + } + + qrHtml += ''; + qrHtml += '
'; + } + + qrHtml += '
'; + + return qrHtml; + }; + + _this.createSvgTag = function(cellSize, margin, alt, title) { + + var opts = {}; + if (typeof arguments[0] == 'object') { + // Called by options. + opts = arguments[0]; + // overwrite cellSize and margin. + cellSize = opts.cellSize; + margin = opts.margin; + alt = opts.alt; + title = opts.title; + } + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + // Compose alt property surrogate + alt = (typeof alt === 'string') ? {text: alt} : alt || {}; + alt.text = alt.text || null; + alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; + + // Compose title property surrogate + title = (typeof title === 'string') ? {text: title} : title || {}; + title.text = title.text || null; + title.id = (title.text) ? title.id || 'qrcode-title' : null; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var c, mc, r, mr, qrSvg='', rect; + + rect = 'l' + cellSize + ',0 0,' + cellSize + + ' -' + cellSize + ',0 0,-' + cellSize + 'z '; + + qrSvg += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + qrSvg += ''; + + return qrSvg; + }; + + _this.createDataURL = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + return createDataURL(size, size, function(x, y) { + if (min <= x && x < max && min <= y && y < max) { + var c = Math.floor( (x - min) / cellSize); + var r = Math.floor( (y - min) / cellSize); + return _this.isDark(r, c)? 0 : 1; + } else { + return 1; + } + } ); + }; + + _this.createImgTag = function(cellSize, margin, alt) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + + var img = ''; + img += '': escaped += '>'; break; + case '&': escaped += '&'; break; + case '"': escaped += '"'; break; + default : escaped += c; break; + } + } + return escaped; + }; + + var _createHalfASCII = function(margin) { + var cellSize = 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r1, r2, p; + + var blocks = { + '██': '█', + '█ ': '▀', + ' █': '▄', + ' ': ' ' + }; + + var blocksLastLineNoMargin = { + '██': '▀', + '█ ': '▀', + ' █': ' ', + ' ': ' ' + }; + + var ascii = ''; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = '█'; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = ' '; + } + + if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += ' '; + } + else { + p += '█'; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; + } + + ascii += '\n'; + } + + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + + if (cellSize < 2) { + return _createHalfASCII(margin); + } + + cellSize -= 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r, p; + + var white = Array(cellSize+1).join('██'); + var black = Array(cellSize+1).join(' '); + + var ascii = ''; + var line = ''; + for (y = 0; y < size; y += 1) { + r = Math.floor( (y - min) / cellSize); + line = ''; + for (x = 0; x < size; x += 1) { + p = 1; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + line += p ? white : black; + } + + for (r = 0; r < cellSize; r += 1) { + ascii += line + '\n'; + } + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + var length = _this.getModuleCount(); + for (var row = 0; row < length; row++) { + for (var col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; + context.fillRect(col * cellSize, row * cellSize, cellSize, cellSize); + } + } + } + + return _this; + }; + + //--------------------------------------------------------------------- + // qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytesFuncs = { + 'default' : function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + bytes.push(c & 0xff); + } + return bytes; + } + }; + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; + + //--------------------------------------------------------------------- + // qrcode.createStringToBytes + //--------------------------------------------------------------------- + + /** + * @param unicodeData base64 string of byte array. + * [16bit Unicode],[16bit Bytes], ... + * @param numChars + */ + qrcode.createStringToBytes = function(unicodeData, numChars) { + + // create conversion map. + + var unicodeMap = function() { + + var bin = base64DecodeInputStream(unicodeData); + var read = function() { + var b = bin.read(); + if (b == -1) throw 'eof'; + return b; + }; + + var count = 0; + var unicodeMap = {}; + while (true) { + var b0 = bin.read(); + if (b0 == -1) break; + var b1 = read(); + var b2 = read(); + var b3 = read(); + var k = String.fromCharCode( (b0 << 8) | b1); + var v = (b2 << 8) | b3; + unicodeMap[k] = v; + count += 1; + } + if (count != numChars) { + throw count + ' != ' + numChars; + } + + return unicodeMap; + }(); + + var unknownChar = '?'.charCodeAt(0); + + return function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + var b = unicodeMap[s.charAt(i)]; + if (typeof b == 'number') { + if ( (b & 0xff) == b) { + // 1byte + bytes.push(b); + } else { + // 2bytes + bytes.push(b >>> 8); + bytes.push(b & 0xff); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; + }; + + //--------------------------------------------------------------------- + // QRMode + //--------------------------------------------------------------------- + + var QRMode = { + MODE_NUMBER : 1 << 0, + MODE_ALPHA_NUM : 1 << 1, + MODE_8BIT_BYTE : 1 << 2, + MODE_KANJI : 1 << 3 + }; + + //--------------------------------------------------------------------- + // QRErrorCorrectionLevel + //--------------------------------------------------------------------- + + var QRErrorCorrectionLevel = { + L : 1, + M : 0, + Q : 3, + H : 2 + }; + + //--------------------------------------------------------------------- + // QRMaskPattern + //--------------------------------------------------------------------- + + var QRMaskPattern = { + PATTERN000 : 0, + PATTERN001 : 1, + PATTERN010 : 2, + PATTERN011 : 3, + PATTERN100 : 4, + PATTERN101 : 5, + PATTERN110 : 6, + PATTERN111 : 7 + }; + + //--------------------------------------------------------------------- + // QRUtil + //--------------------------------------------------------------------- + + var QRUtil = function() { + + var PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); + var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); + var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + + var _this = {}; + + var getBCHDigit = function(data) { + var digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + + _this.getBCHTypeInfo = function(data) { + var d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); + } + return ( (data << 10) | d) ^ G15_MASK; + }; + + _this.getBCHTypeNumber = function(data) { + var d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); + } + return (data << 12) | d; + }; + + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + + _this.getMaskFunction = function(maskPattern) { + + switch (maskPattern) { + + case QRMaskPattern.PATTERN000 : + return function(i, j) { return (i + j) % 2 == 0; }; + case QRMaskPattern.PATTERN001 : + return function(i, j) { return i % 2 == 0; }; + case QRMaskPattern.PATTERN010 : + return function(i, j) { return j % 3 == 0; }; + case QRMaskPattern.PATTERN011 : + return function(i, j) { return (i + j) % 3 == 0; }; + case QRMaskPattern.PATTERN100 : + return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; + case QRMaskPattern.PATTERN101 : + return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; + case QRMaskPattern.PATTERN110 : + return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; + case QRMaskPattern.PATTERN111 : + return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; + + default : + throw 'bad maskPattern:' + maskPattern; + } + }; + + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + var a = qrPolynomial([1], 0); + for (var i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); + } + return a; + }; + + _this.getLengthInBits = function(mode, type) { + + if (1 <= type && type < 10) { + + // 1 - 9 + + switch(mode) { + case QRMode.MODE_NUMBER : return 10; + case QRMode.MODE_ALPHA_NUM : return 9; + case QRMode.MODE_8BIT_BYTE : return 8; + case QRMode.MODE_KANJI : return 8; + default : + throw 'mode:' + mode; + } + + } else if (type < 27) { + + // 10 - 26 + + switch(mode) { + case QRMode.MODE_NUMBER : return 12; + case QRMode.MODE_ALPHA_NUM : return 11; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 10; + default : + throw 'mode:' + mode; + } + + } else if (type < 41) { + + // 27 - 40 + + switch(mode) { + case QRMode.MODE_NUMBER : return 14; + case QRMode.MODE_ALPHA_NUM : return 13; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 12; + default : + throw 'mode:' + mode; + } + + } else { + throw 'type:' + type; + } + }; + + _this.getLostPoint = function(qrcode) { + + var moduleCount = qrcode.getModuleCount(); + + var lostPoint = 0; + + // LEVEL1 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount; col += 1) { + + var sameCount = 0; + var dark = qrcode.isDark(row, col); + + for (var r = -1; r <= 1; r += 1) { + + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + + for (var c = -1; c <= 1; c += 1) { + + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + + if (r == 0 && c == 0) { + continue; + } + + if (dark == qrcode.isDark(row + r, col + c) ) { + sameCount += 1; + } + } + } + + if (sameCount > 5) { + lostPoint += (3 + sameCount - 5); + } + } + }; + + // LEVEL2 + + for (var row = 0; row < moduleCount - 1; row += 1) { + for (var col = 0; col < moduleCount - 1; col += 1) { + var count = 0; + if (qrcode.isDark(row, col) ) count += 1; + if (qrcode.isDark(row + 1, col) ) count += 1; + if (qrcode.isDark(row, col + 1) ) count += 1; + if (qrcode.isDark(row + 1, col + 1) ) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + + // LEVEL3 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount - 6; col += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row, col + 1) + && qrcode.isDark(row, col + 2) + && qrcode.isDark(row, col + 3) + && qrcode.isDark(row, col + 4) + && !qrcode.isDark(row, col + 5) + && qrcode.isDark(row, col + 6) ) { + lostPoint += 40; + } + } + } + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount - 6; row += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row + 1, col) + && qrcode.isDark(row + 2, col) + && qrcode.isDark(row + 3, col) + && qrcode.isDark(row + 4, col) + && !qrcode.isDark(row + 5, col) + && qrcode.isDark(row + 6, col) ) { + lostPoint += 40; + } + } + } + + // LEVEL4 + + var darkCount = 0; + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount; row += 1) { + if (qrcode.isDark(row, col) ) { + darkCount += 1; + } + } + } + + var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // QRMath + //--------------------------------------------------------------------- + + var QRMath = function() { + + var EXP_TABLE = new Array(256); + var LOG_TABLE = new Array(256); + + // initialize tables + for (var i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (var i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] + ^ EXP_TABLE[i - 5] + ^ EXP_TABLE[i - 6] + ^ EXP_TABLE[i - 8]; + } + for (var i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i] ] = i; + } + + var _this = {}; + + _this.glog = function(n) { + + if (n < 1) { + throw 'glog(' + n + ')'; + } + + return LOG_TABLE[n]; + }; + + _this.gexp = function(n) { + + while (n < 0) { + n += 255; + } + + while (n >= 256) { + n -= 255; + } + + return EXP_TABLE[n]; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrPolynomial + //--------------------------------------------------------------------- + + function qrPolynomial(num, shift) { + + if (typeof num.length == 'undefined') { + throw num.length + '/' + shift; + } + + var _num = function() { + var offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + var _num = new Array(num.length - offset + shift); + for (var i = 0; i < num.length - offset; i += 1) { + _num[i] = num[i + offset]; + } + return _num; + }(); + + var _this = {}; + + _this.getAt = function(index) { + return _num[index]; + }; + + _this.getLength = function() { + return _num.length; + }; + + _this.multiply = function(e) { + + var num = new Array(_this.getLength() + e.getLength() - 1); + + for (var i = 0; i < _this.getLength(); i += 1) { + for (var j = 0; j < e.getLength(); j += 1) { + num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); + } + } + + return qrPolynomial(num, 0); + }; + + _this.mod = function(e) { + + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + + var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); + + var num = new Array(_this.getLength() ); + for (var i = 0; i < _this.getLength(); i += 1) { + num[i] = _this.getAt(i); + } + + for (var i = 0; i < e.getLength(); i += 1) { + num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); + } + + // recursive call + return qrPolynomial(num, 0).mod(e); + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // QRRSBlock + //--------------------------------------------------------------------- + + var QRRSBlock = function() { + + var RS_BLOCK_TABLE = [ + + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + + var qrRSBlock = function(totalCount, dataCount) { + var _this = {}; + _this.totalCount = totalCount; + _this.dataCount = dataCount; + return _this; + }; + + var _this = {}; + + var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + + switch(errorCorrectionLevel) { + case QRErrorCorrectionLevel.L : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default : + return undefined; + } + }; + + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + + var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + + if (typeof rsBlock == 'undefined') { + throw 'bad rs block @ typeNumber:' + typeNumber + + '/errorCorrectionLevel:' + errorCorrectionLevel; + } + + var length = rsBlock.length / 3; + + var list = []; + + for (var i = 0; i < length; i += 1) { + + var count = rsBlock[i * 3 + 0]; + var totalCount = rsBlock[i * 3 + 1]; + var dataCount = rsBlock[i * 3 + 2]; + + for (var j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount) ); + } + } + + return list; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrBitBuffer + //--------------------------------------------------------------------- + + var qrBitBuffer = function() { + + var _buffer = []; + var _length = 0; + + var _this = {}; + + _this.getBuffer = function() { + return _buffer; + }; + + _this.getAt = function(index) { + var bufIndex = Math.floor(index / 8); + return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; + }; + + _this.put = function(num, length) { + for (var i = 0; i < length; i += 1) { + _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); + } + }; + + _this.getLengthInBits = function() { + return _length; + }; + + _this.putBit = function(bit) { + + var bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + + if (bit) { + _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); + } + + _length += 1; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrNumber + //--------------------------------------------------------------------- + + var qrNumber = function(data) { + + var _mode = QRMode.MODE_NUMBER; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var data = _data; + + var i = 0; + + while (i + 2 < data.length) { + buffer.put(strToNum(data.substring(i, i + 3) ), 10); + i += 3; + } + + if (i < data.length) { + if (data.length - i == 1) { + buffer.put(strToNum(data.substring(i, i + 1) ), 4); + } else if (data.length - i == 2) { + buffer.put(strToNum(data.substring(i, i + 2) ), 7); + } + } + }; + + var strToNum = function(s) { + var num = 0; + for (var i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i) ); + } + return num; + }; + + var chatToNum = function(c) { + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } + throw 'illegal char :' + c; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrAlphaNum + //--------------------------------------------------------------------- + + var qrAlphaNum = function(data) { + + var _mode = QRMode.MODE_ALPHA_NUM; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var s = _data; + + var i = 0; + + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i) ) * 45 + + getCode(s.charAt(i + 1) ), 11); + i += 2; + } + + if (i < s.length) { + buffer.put(getCode(s.charAt(i) ), 6); + } + }; + + var getCode = function(c) { + + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } else if ('A' <= c && c <= 'Z') { + return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; + } else { + switch (c) { + case ' ' : return 36; + case '$' : return 37; + case '%' : return 38; + case '*' : return 39; + case '+' : return 40; + case '-' : return 41; + case '.' : return 42; + case '/' : return 43; + case ':' : return 44; + default : + throw 'illegal char :' + c; + } + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qr8BitByte + //--------------------------------------------------------------------- + + var qr8BitByte = function(data) { + + var _mode = QRMode.MODE_8BIT_BYTE; + var _data = data; + var _bytes = qrcode.stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _bytes.length; + }; + + _this.write = function(buffer) { + for (var i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrKanji + //--------------------------------------------------------------------- + + var qrKanji = function(data) { + + var _mode = QRMode.MODE_KANJI; + var _data = data; + + var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; + if (!stringToBytes) { + throw 'sjis not supported.'; + } + !function(c, code) { + // self test for sjis support. + var test = stringToBytes(c); + if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { + throw 'sjis not supported.'; + } + }('\u53cb', 0x9746); + + var _bytes = stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + + _this.write = function(buffer) { + + var data = _bytes; + + var i = 0; + + while (i + 1 < data.length) { + + var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); + + if (0x8140 <= c && c <= 0x9FFC) { + c -= 0x8140; + } else if (0xE040 <= c && c <= 0xEBBF) { + c -= 0xC140; + } else { + throw 'illegal char at ' + (i + 1) + '/' + c; + } + + c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); + + buffer.put(c, 13); + + i += 2; + } + + if (i < data.length) { + throw 'illegal char at ' + (i + 1); + } + }; + + return _this; + }; + + //===================================================================== + // GIF Support etc. + // + + //--------------------------------------------------------------------- + // byteArrayOutputStream + //--------------------------------------------------------------------- + + var byteArrayOutputStream = function() { + + var _bytes = []; + + var _this = {}; + + _this.writeByte = function(b) { + _bytes.push(b & 0xff); + }; + + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (var i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + + _this.writeString = function(s) { + for (var i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i) ); + } + }; + + _this.toByteArray = function() { + return _bytes; + }; + + _this.toString = function() { + var s = ''; + s += '['; + for (var i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ','; + } + s += _bytes[i]; + } + s += ']'; + return s; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64EncodeOutputStream + //--------------------------------------------------------------------- + + var base64EncodeOutputStream = function() { + + var _buffer = 0; + var _buflen = 0; + var _length = 0; + var _base64 = ''; + + var _this = {}; + + var writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 0x3f) ); + }; + + var encode = function(n) { + if (n < 0) { + // error. + } else if (n < 26) { + return 0x41 + n; + } else if (n < 52) { + return 0x61 + (n - 26); + } else if (n < 62) { + return 0x30 + (n - 52); + } else if (n == 62) { + return 0x2b; + } else if (n == 63) { + return 0x2f; + } + throw 'n:' + n; + }; + + _this.writeByte = function(n) { + + _buffer = (_buffer << 8) | (n & 0xff); + _buflen += 8; + _length += 1; + + while (_buflen >= 6) { + writeEncoded(_buffer >>> (_buflen - 6) ); + _buflen -= 6; + } + }; + + _this.flush = function() { + + if (_buflen > 0) { + writeEncoded(_buffer << (6 - _buflen) ); + _buffer = 0; + _buflen = 0; + } + + if (_length % 3 != 0) { + // padding + var padlen = 3 - _length % 3; + for (var i = 0; i < padlen; i += 1) { + _base64 += '='; + } + } + }; + + _this.toString = function() { + return _base64; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64DecodeInputStream + //--------------------------------------------------------------------- + + var base64DecodeInputStream = function(str) { + + var _str = str; + var _pos = 0; + var _buffer = 0; + var _buflen = 0; + + var _this = {}; + + _this.read = function() { + + while (_buflen < 8) { + + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw 'unexpected end of file./' + _buflen; + } + + var c = _str.charAt(_pos); + _pos += 1; + + if (c == '=') { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/) ) { + // ignore if whitespace. + continue; + } + + _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); + _buflen += 6; + } + + var n = (_buffer >>> (_buflen - 8) ) & 0xff; + _buflen -= 8; + return n; + }; + + var decode = function(c) { + if (0x41 <= c && c <= 0x5a) { + return c - 0x41; + } else if (0x61 <= c && c <= 0x7a) { + return c - 0x61 + 26; + } else if (0x30 <= c && c <= 0x39) { + return c - 0x30 + 52; + } else if (c == 0x2b) { + return 62; + } else if (c == 0x2f) { + return 63; + } else { + throw 'c:' + c; + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // gifImage (B/W) + //--------------------------------------------------------------------- + + var gifImage = function(width, height) { + + var _width = width; + var _height = height; + var _data = new Array(width * height); + + var _this = {}; + + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + + _this.write = function(out) { + + //--------------------------------- + // GIF Signature + + out.writeString('GIF87a'); + + //--------------------------------- + // Screen Descriptor + + out.writeShort(_width); + out.writeShort(_height); + + out.writeByte(0x80); // 2bit + out.writeByte(0); + out.writeByte(0); + + //--------------------------------- + // Global Color Map + + // black + out.writeByte(0x00); + out.writeByte(0x00); + out.writeByte(0x00); + + // white + out.writeByte(0xff); + out.writeByte(0xff); + out.writeByte(0xff); + + //--------------------------------- + // Image Descriptor + + out.writeString(','); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + + //--------------------------------- + // Local Color Map + + //--------------------------------- + // Raster Data + + var lzwMinCodeSize = 2; + var raster = getLZWRaster(lzwMinCodeSize); + + out.writeByte(lzwMinCodeSize); + + var offset = 0; + + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0x00); + + //--------------------------------- + // GIF Terminator + out.writeString(';'); + }; + + var bitOutputStream = function(out) { + + var _out = out; + var _bitLength = 0; + var _bitBuffer = 0; + + var _this = {}; + + _this.write = function(data, length) { + + if ( (data >>> length) != 0) { + throw 'length over'; + } + + while (_bitLength + length >= 8) { + _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); + length -= (8 - _bitLength); + data >>>= (8 - _bitLength); + _bitBuffer = 0; + _bitLength = 0; + } + + _bitBuffer = (data << _bitLength) | _bitBuffer; + _bitLength = _bitLength + length; + }; + + _this.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + + return _this; + }; + + var getLZWRaster = function(lzwMinCodeSize) { + + var clearCode = 1 << lzwMinCodeSize; + var endCode = (1 << lzwMinCodeSize) + 1; + var bitLength = lzwMinCodeSize + 1; + + // Setup LZWTable + var table = lzwTable(); + + for (var i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i) ); + } + table.add(String.fromCharCode(clearCode) ); + table.add(String.fromCharCode(endCode) ); + + var byteOut = byteArrayOutputStream(); + var bitOut = bitOutputStream(byteOut); + + // clear code + bitOut.write(clearCode, bitLength); + + var dataIndex = 0; + + var s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + while (dataIndex < _data.length) { + + var c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + if (table.contains(s + c) ) { + + s = s + c; + + } else { + + bitOut.write(table.indexOf(s), bitLength); + + if (table.size() < 0xfff) { + + if (table.size() == (1 << bitLength) ) { + bitLength += 1; + } + + table.add(s + c); + } + + s = c; + } + } + + bitOut.write(table.indexOf(s), bitLength); + + // end code + bitOut.write(endCode, bitLength); + + bitOut.flush(); + + return byteOut.toByteArray(); + }; + + var lzwTable = function() { + + var _map = {}; + var _size = 0; + + var _this = {}; + + _this.add = function(key) { + if (_this.contains(key) ) { + throw 'dup key:' + key; + } + _map[key] = _size; + _size += 1; + }; + + _this.size = function() { + return _size; + }; + + _this.indexOf = function(key) { + return _map[key]; + }; + + _this.contains = function(key) { + return typeof _map[key] != 'undefined'; + }; + + return _this; + }; + + return _this; + }; + + var createDataURL = function(width, height, getPixel) { + var gif = gifImage(width, height); + for (var y = 0; y < height; y += 1) { + for (var x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y) ); + } + } + + var b = byteArrayOutputStream(); + gif.write(b); + + var base64 = base64EncodeOutputStream(); + var bytes = b.toByteArray(); + for (var i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + + return 'data:image/gif;base64,' + base64; + }; + + //--------------------------------------------------------------------- + // returns qrcode function. + + return qrcode; +}(); + +// multibyte support +!function() { + + qrcode.stringToBytesFuncs['UTF-8'] = function(s) { + // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + function toUTF8Array(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; + } + return toUTF8Array(s); + }; + +}(); + +(function (factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } +}(function () { + return qrcode; +})); diff --git a/docs/MASTER_PLAN.md b/docs/MASTER_PLAN.md new file mode 100644 index 00000000..774a9c49 --- /dev/null +++ b/docs/MASTER_PLAN.md @@ -0,0 +1,71 @@ +# MASTER PLAN + +> Archipelago project task tracking and roadmap. + +## Roadmap + +| ID | Title | Priority | Status | Dependencies | +|----|-------|----------|--------|--------------| +| **BUG-1** | **Random logout / CSRF mismatch** | **P0** | PLANNED | - | +| **TASK-2** | **Roll incoming-tx into deploy & ISO** | **P2** | PLANNED | - | +| **BUG-3** | **IndeedHub WebSocket spam in console** | **P2** | PLANNED | - | + +## Active Work + +### BUG-1: Random logout / CSRF mismatch (PLANNED) +**Priority**: P0 — Critical +**Status**: PLANNED (2026-03-15) + +Sessions expire unexpectedly during normal use. Backend sessions now persist to disk (`/var/lib/archipelago/sessions.json`) but CSRF token mismatch (403) still causes logouts. Need to investigate CSRF token lifecycle and fix the mismatch between cookie and header values. + +**Root cause analysis so far**: +- Sessions were purely in-memory — fixed with disk persistence +- CSRF validation compares cookie value vs `X-CSRF-Token` header — both present but don't match +- Log: `403 CSRF mismatch — rejecting RPC call ... has_cookie=true has_header=true` +- Possible cause: cookie value rotated (e.g., new login in another tab) but frontend cached old value + +**Key files**: +- `core/archipelago/src/session.rs` — session store (now persisted) +- `core/archipelago/src/api/rpc/mod.rs:273-307` — CSRF validation +- `neode-ui/src/api/rpc-client.ts:18-45` — frontend CSRF extraction from cookie + +**Tasks**: +- [ ] Investigate CSRF token rotation — when/why cookie and header diverge +- [ ] Add logging to CSRF validation to capture actual cookie vs header values +- [ ] Consider returning CSRF token in response body (not just cookie) for explicit client storage +- [ ] Test multi-tab scenario where one tab's login rotates the CSRF token +- [ ] Verify session persistence survives deploys (second deploy test) + +### TASK-2: Roll incoming-tx into deploy & ISO (PLANNED) +**Priority**: P2 — Medium +**Status**: PLANNED (2026-03-16) + +The incoming transactions feature (lnd.gettransactions RPC + wallet badge UI + auto-refresh) is working on .228. Roll changes into deploy-to-target.sh and build-auto-installer-iso.sh so fresh installs and deploys get it automatically. Do not break existing changes. + +**Key files changed**: +- `core/archipelago/src/api/rpc/lnd.rs` — new `handle_lnd_gettransactions` method +- `core/archipelago/src/api/rpc/mod.rs` — registered `lnd.gettransactions` route +- `neode-ui/src/views/Web5.vue` — incoming tx badge, panel, auto-refresh polling +- `neode-ui/src/style.css` — incoming-tx-badge, incoming-tx-row, incoming-tx-slide classes + +**Tasks**: +- [ ] Verify changes are already captured by existing deploy (backend build + frontend build) +- [ ] Ensure ISO build captures the updated Rust binary and frontend dist +- [ ] Test that no existing deploy/build logic is broken + +### BUG-3: IndeedHub WebSocket spam in console (PLANNED) +**Priority**: P2 — Medium +**Status**: PLANNED (2026-03-16) + +`ws://localhost:7777/` connection refused fills browser console endlessly when IndeedHub is loaded in iframe. IndeedHub's compiled frontend bundle hardcodes `localhost` for WebSocket connections. When loaded from a remote host, `localhost` resolves to the user's machine, not the server. + +**Root cause**: IndeedHub's Next.js build bakes `localhost:7777` into the WebSocket URL. The nginx WebSocket proxy at `/app/indeedhub/ws/` exists but is unused because IndeedHub loads via direct port 7777, not through the proxy path. + +**Tasks**: +- [ ] Rebuild IndeedHub with `NEXT_PUBLIC_WS_URL` env var pointing to relative URL or actual server address +- [ ] Alternatively, configure IndeedHub to use relative WebSocket URLs (`/ws/` instead of `ws://localhost:7777/`) +- [ ] Test that WebSocket reconnection works after the fix + +## Completed + + diff --git a/image-recipe/build-auto-installer-iso.sh b/image-recipe/build-auto-installer-iso.sh index ab4069e8..c3caf5eb 100755 --- a/image-recipe/build-auto-installer-iso.sh +++ b/image-recipe/build-auto-installer-iso.sh @@ -590,7 +590,7 @@ docker.io/nicolasdorier/nbxplorer:2.6.0 nbxplorer.tar docker.io/library/postgres:15-alpine postgres-btcpay.tar docker.io/mempool/frontend:v2.5.0 mempool-frontend.tar docker.io/mempool/backend:v2.5.0 mempool-backend.tar -docker.io/mempool/electrs:latest mempool-electrs.tar +docker.io/lukechilds/electrumx:v1.18.0 electrumx.tar docker.io/library/mariadb:10.11 mariadb-mempool.tar docker.io/fedimint/fedimintd:v0.10.0 fedimint.tar docker.io/fedimint/gatewayd:v0.10.0 fedimint-gateway.tar @@ -735,7 +735,7 @@ import json services = [ {"name": "archipelago", "local_port": 80, "enabled": True}, {"name": "bitcoin", "local_port": 8333, "enabled": True}, - {"name": "electrs", "local_port": 50001, "enabled": True}, + {"name": "electrumx", "local_port": 50001, "enabled": True}, {"name": "lnd", "local_port": 9735, "enabled": True}, {"name": "btcpay", "local_port": 23000, "enabled": True}, {"name": "mempool", "local_port": 4080, "enabled": True}, @@ -757,7 +757,7 @@ HiddenServicePort 80 127.0.0.1:80 HiddenServiceDir $TOR_DIR/hidden_service_bitcoin HiddenServicePort 8333 127.0.0.1:8333 -HiddenServiceDir $TOR_DIR/hidden_service_electrs +HiddenServiceDir $TOR_DIR/hidden_service_electrumx HiddenServicePort 50001 127.0.0.1:50001 HiddenServiceDir $TOR_DIR/hidden_service_lnd diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index e0a38581..103f0eea 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -11,7 +11,7 @@ server { add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src *" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:*; frame-src *" always; # AIUI SPA (Chat mode iframe) # Use =404 fallback instead of index.html to prevent serving HTML with wrong @@ -487,7 +487,7 @@ server { sub_filter_once on; sub_filter '' ''; } - location /app/electrs/ { + location /app/electrumx/ { proxy_pass http://127.0.0.1:50002/; proxy_http_version 1.1; proxy_set_header Host $host; @@ -675,7 +675,7 @@ server { add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src *" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:*; frame-src *" always; # AIUI SPA (Chat mode iframe) location /aiui/ { diff --git a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf index a0e9b874..e5fd9097 100644 --- a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf +++ b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf @@ -218,7 +218,7 @@ location /app/bitcoin-ui/ { sub_filter_once on; sub_filter '' ''; } -location /app/electrs/ { +location /app/electrumx/ { proxy_pass http://127.0.0.1:50002/; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js index f12010f6..c45c77fa 100644 --- a/neode-ui/dev-dist/sw.js +++ b/neode-ui/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.6f1usind3cc" + "revision": "0.tmc04bnmkho" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index fc503e2e..845aa190 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -29,6 +29,7 @@ "concurrently": "^9.1.2", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "dockerode": "^4.0.9", "express": "^4.21.2", "jsdom": "^25.0.1", "postcss": "^8.5.6", @@ -1688,6 +1689,13 @@ "node": ">=6.9.0" } }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -2275,6 +2283,58 @@ "node": ">=18" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -2804,6 +2864,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2876,6 +2947,80 @@ "node": ">=18" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@quansync/fs": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", @@ -4312,6 +4457,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4480,6 +4635,27 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -4493,6 +4669,16 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4515,6 +4701,18 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -4615,6 +4813,31 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4622,6 +4845,16 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -4818,6 +5051,13 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -5135,6 +5375,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5872,6 +6127,41 @@ "dev": true, "license": "MIT" }, + "node_modules/docker-modem": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", + "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.9.tgz", + "integrity": "sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.6", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5960,6 +6250,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -6512,6 +6812,13 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -6955,6 +7262,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -7791,6 +8119,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -7805,6 +8140,13 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -7988,6 +8330,13 @@ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8014,6 +8363,14 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -8159,6 +8516,16 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -8563,6 +8930,31 @@ "dev": true, "license": "ISC" }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -8577,6 +8969,17 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8687,6 +9090,21 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -9485,6 +9903,31 @@ "node": ">=0.10.0" } }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -9523,6 +9966,16 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -9878,6 +10331,36 @@ "node": ">=14.0.0" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -10203,6 +10686,13 @@ "dev": true, "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, + "license": "Unlicense" + }, "node_modules/type-fest": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", @@ -10526,6 +11016,20 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -11626,6 +12130,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", diff --git a/neode-ui/package.json b/neode-ui/package.json index a01a6fab..0e515d33 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -44,6 +44,7 @@ "concurrently": "^9.1.2", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "dockerode": "^4.0.9", "express": "^4.21.2", "jsdom": "^25.0.1", "postcss": "^8.5.6", diff --git a/neode-ui/public/assets/img/app-icons/electrumx.webp b/neode-ui/public/assets/img/app-icons/electrumx.webp new file mode 100644 index 0000000000000000000000000000000000000000..4d05b2d2fbfdba728483bc618982c9d05c09fcfe GIT binary patch literal 42822 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 literal 0 HcmV?d00001 diff --git a/neode-ui/public/nostr-provider.js b/neode-ui/public/nostr-provider.js index ec91a598..fc124bd9 100644 --- a/neode-ui/public/nostr-provider.js +++ b/neode-ui/public/nostr-provider.js @@ -1,79 +1,160 @@ /** - * NIP-07 Nostr Provider Shim + * NIP-07 Nostr Provider Shim — Archipelago * - * Injected into proxied iframe apps via nginx sub_filter. - * Implements window.nostr interface (getPublicKey, signEvent) - * by communicating with the parent Archipelago frame via postMessage. - * - * Security: validates postMessage origin, never exposes secret key. + * Provides window.nostr (NIP-07) for iframe apps. + * Auto sign-in: does NIP-98 auth directly then reloads so the app + * picks up the valid session. Shows a loading overlay during auth. */ (function () { 'use strict'; - - // Only inject if we're inside an iframe + if (window.__archipelagoNostr) return; + window.__archipelagoNostr = true; if (window === window.top) return; - // Don't override existing NIP-07 extensions - if (window.nostr) return; - - var pending = {}; - var nextId = 1; + var pending = {}, nextId = 1; function request(method, params) { return new Promise(function (resolve, reject) { var id = nextId++; pending[id] = { resolve: resolve, reject: reject }; - window.parent.postMessage( - { type: 'nostr-request', id: id, method: method, params: params || {} }, - '*' - ); - // Timeout after 30 seconds - setTimeout(function () { - if (pending[id]) { - pending[id].reject(new Error('NIP-07 request timed out')); - delete pending[id]; - } - }, 30000); + window.parent.postMessage({ type: 'nostr-request', id: id, method: method, params: params || {} }, '*'); + setTimeout(function () { if (pending[id]) { pending[id].reject(new Error('NIP-07 timeout')); delete pending[id]; } }, 30000); }); } - window.addEventListener('message', function (event) { - if (!event.data || event.data.type !== 'nostr-response') return; - var handler = pending[event.data.id]; - if (!handler) return; - delete pending[event.data.id]; - if (event.data.error) { - handler.reject(new Error(event.data.error)); - } else { - handler.resolve(event.data.result); - } + window.addEventListener('message', function (e) { + if (!e.data || e.data.type !== 'nostr-response') return; + var h = pending[e.data.id]; if (!h) return; delete pending[e.data.id]; + e.data.error ? h.reject(new Error(e.data.error)) : h.resolve(e.data.result); }); window.nostr = { - getPublicKey: function () { - return request('getPublicKey'); - }, - signEvent: function (event) { - return request('signEvent', { event: event }); - }, - getRelays: function () { - return request('getRelays'); - }, + getPublicKey: function () { return request('getPublicKey'); }, + signEvent: function (ev) { return request('signEvent', { event: ev }); }, + sign: function (ev) { return request('signEvent', { event: ev }); }, + getRelays: function () { return request('getRelays'); }, nip04: { - encrypt: function (pubkey, plaintext) { - return request('nip04.encrypt', { pubkey: pubkey, plaintext: plaintext }); - }, - decrypt: function (pubkey, ciphertext) { - return request('nip04.decrypt', { pubkey: pubkey, ciphertext: ciphertext }); - }, + encrypt: function (pk, pt) { return request('nip04.encrypt', { pubkey: pk, plaintext: pt }); }, + decrypt: function (pk, ct) { return request('nip04.decrypt', { pubkey: pk, ciphertext: ct }); }, }, nip44: { - encrypt: function (pubkey, plaintext) { - return request('nip44.encrypt', { pubkey: pubkey, plaintext: plaintext }); - }, - decrypt: function (pubkey, ciphertext) { - return request('nip44.decrypt', { pubkey: pubkey, ciphertext: ciphertext }); - }, + encrypt: function (pk, pt) { return request('nip44.encrypt', { pubkey: pk, plaintext: pt }); }, + decrypt: function (pk, ct) { return request('nip44.decrypt', { pubkey: pk, ciphertext: ct }); }, }, }; + + // --- Loading Overlay --- + var overlay = null; + + function showLoader(message) { + if (overlay) return; + overlay = document.createElement('div'); + overlay.id = 'archipelago-auth-overlay'; + overlay.innerHTML = + '
' + + '' + + '' + + '' + + '' + + '
' + (message || 'Signing in...') + '
' + + '
'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);'; + var style = document.createElement('style'); + style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}'; + document.head.appendChild(style); + document.body.appendChild(overlay); + } + + function updateLoader(message) { + if (!overlay) return; + var txt = overlay.querySelector('div > div'); + if (txt) txt.textContent = message; + } + + function hideLoader() { + if (overlay) { overlay.remove(); overlay = null; } + } + + // --- Direct NIP-98 Auth --- + var authDone = false; + + function doNip98Auth(pubkey) { + if (authDone) return; + authDone = true; + + var apiBase = '/api'; + var healthUrl = window.location.origin + apiBase + '/nostr-auth/health'; + var sessionUrl = window.location.origin + apiBase + '/auth/nostr/session'; + + // 1. Check if API backend is reachable (3s timeout) + var hc = new AbortController(); + var ht = setTimeout(function () { hc.abort(); }, 3000); + + fetch(healthUrl, { signal: hc.signal }).then(function (r) { + clearTimeout(ht); + if (!r.ok) throw new Error('Health ' + r.status); + + // 2. API is up — show loader and do NIP-98 + showLoader('Signing in with Nostr...'); + var now = Math.floor(Date.now() / 1000); + var event = { + kind: 27235, created_at: now, content: '', pubkey: pubkey, + tags: [['u', sessionUrl], ['method', 'POST']] + }; + console.log('[nostr-provider] NIP-98: signing for', sessionUrl); + return window.nostr.signEvent(event); + + }).then(function (signed) { + updateLoader('Creating session...'); + var ac = new AbortController(); + setTimeout(function () { ac.abort(); }, 10000); + return fetch(sessionUrl, { + method: 'POST', + headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) }, + signal: ac.signal + }); + + }).then(function (res) { + console.log('[nostr-provider] NIP-98: response', res.status); + if (!res.ok) throw new Error('Auth failed: ' + res.status); + return res.json(); + + }).then(function (data) { + if (data.accessToken) { + sessionStorage.setItem('nostr_token', data.accessToken); + sessionStorage.setItem('nostr_pubkey', pubkey); + if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken); + updateLoader('Signed in! Loading...'); + console.log('[nostr-provider] NIP-98: success, reloading...'); + setTimeout(function () { window.location.reload(); }, 400); + } else { + hideLoader(); authDone = false; + } + + }).catch(function (err) { + hideLoader(); authDone = false; + var msg = err.message || String(err); + if (msg.indexOf('abort') > -1) msg = 'API timeout'; + console.warn('[nostr-provider] NIP-98 skipped:', msg); + }); + } + + // Listen for identity from parent Archipelago frame + window.addEventListener('message', function (e) { + if (!e.data || e.data.type !== 'archipelago:identity') return; + var pk = e.data.nostr_pubkey; + console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none'); + if (!pk) return; + + // Skip if already signed in with a real token (not mock) + try { + var token = sessionStorage.getItem('nostr_token'); + if (token && token.indexOf('mock-') === -1) { + console.log('[nostr-provider] Already signed in with real token'); + return; + } + } catch (x) {} + + setTimeout(function () { doNip98Auth(pk); }, 1500); + }); })(); diff --git a/neode-ui/src/components/NostrIdentityPicker.vue b/neode-ui/src/components/NostrIdentityPicker.vue index 9b7e8d14..2f28d470 100644 --- a/neode-ui/src/components/NostrIdentityPicker.vue +++ b/neode-ui/src/components/NostrIdentityPicker.vue @@ -6,13 +6,16 @@ class="fixed inset-0 z-[3100] flex items-center justify-center p-4" @click="$emit('cancel')" > - -
+ +
-
+
@@ -61,15 +64,18 @@ v-for="identity in identities" :key="identity.id" type="button" - class="w-full text-left p-3 rounded-lg border transition-all duration-200" + role="radio" + :aria-checked="selectedId === identity.id" + :aria-label="`Identity: ${identity.name}`" + class="w-full text-left p-3 rounded-lg transition-all duration-200" :class="selectedId === identity.id - ? 'bg-white/8 border-white/25' - : 'bg-white/3 border-white/8 hover:bg-white/6 hover:border-white/15'" + ? 'bg-white/10 ring-1 ring-white/20' + : 'bg-white/[0.03] hover:bg-white/[0.06]'" @click="selectedId = identity.id" >
{{ identity.name.charAt(0).toUpperCase() }} @@ -85,10 +91,10 @@
-
-
+
+
-
+
@@ -104,8 +110,8 @@ :disabled="!selectedId || !hasNostrKey" class="flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed" :class="selectedId && hasNostrKey - ? 'bg-white/10 border border-white/25 text-white hover:bg-white/15' - : 'bg-white/3 border border-white/8 text-white/40'" + ? 'bg-white/10 text-white hover:bg-white/15' + : 'bg-white/[0.03] text-white/40'" > Authenticate @@ -193,9 +199,9 @@ function truncateNpub(npub: string): string { function avatarClasses(purpose: string): string { switch (purpose) { - case 'business': return 'bg-blue-500/15 text-blue-400 border-blue-500/25' - case 'anonymous': return 'bg-purple-500/15 text-purple-400 border-purple-500/25' - default: return 'bg-white/10 text-white/80 border-white/20' + case 'business': return 'bg-blue-500/15 text-blue-400' + case 'anonymous': return 'bg-purple-500/15 text-purple-400' + default: return 'bg-white/10 text-white/80' } } diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index 944285fd..f448748e 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -222,16 +222,18 @@ router.beforeEach(async (to, _from, next) => { // If authenticated and visiting /login: show login immediately, validate in background. // This prevents endless spinner on mobile when checkSession hangs (slow/unreachable network). if (to.path === '/login' && store.isAuthenticated) { + // Redirect back to intended page (from ?redirect= query) or default to home + const redirectTo = (to.query.redirect as string) || '/dashboard' if (store.needsSessionValidation()) { next() checkSessionWithTimeout(store).then((valid) => { if (valid) { - router.replace({ name: 'home' }).catch(() => {}) + router.replace(redirectTo).catch(() => {}) } }) return } - next({ name: 'home' }) + next(redirectTo) return } next() @@ -245,7 +247,7 @@ router.beforeEach(async (to, _from, next) => { next() store.checkSession().then((valid) => { if (!valid) { - router.replace('/login').catch(() => {}) + router.replace({ path: '/login', query: { redirect: to.fullPath } }).catch(() => {}) } }) return @@ -258,7 +260,7 @@ router.beforeEach(async (to, _from, next) => { next() return } - next('/login') + next({ path: '/login', query: { redirect: to.fullPath } }) return } diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 2e045169..0a462d52 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -3,97 +3,59 @@ import { ref, watch } from 'vue' import { rpcClient } from '@/api/rpc-client' import router from '@/router' -/** Hostnames of external sites that block iframes via X-Frame-Options or CSP. - * These always open in a new tab. Other external sites load directly in the iframe. */ -/** Legacy: these used to open in new tabs. Now all apps go through AppSession. */ -const IFRAME_BLOCKED_HOSTS: string[] = [] - -/** External site proxy paths — disabled. External URLs load directly in the iframe - * via their standard https:// URL. The /ext/ subpath approach broke SPAs. */ -const EXTERNAL_PROXY_PATH: Record = {} +/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */ +const NEW_TAB_PORTS = new Set([ + '23000', // BTCPay — X-Frame-Options: DENY + '3000', // Grafana — X-Frame-Options: deny + '2342', // PhotoPrism — X-Frame-Options: DENY + '8123', // Home Assistant — X-Frame-Options: SAMEORIGIN + '8082', // Vaultwarden — X-Frame-Options: SAMEORIGIN + '8085', // Nextcloud — X-Frame-Options: SAMEORIGIN + '3001', // Uptime Kuma — X-Frame-Options: SAMEORIGIN + '9001', // Penpot — not reachable + // IndeedHub (7777) uses proxy path for NIP-07 nostr-provider.js — NOT new tab +]) function mustOpenInNewTab(url: string): boolean { try { const u = new URL(url) - // External sites that block iframes - if (IFRAME_BLOCKED_HOSTS.some(h => u.hostname === h || u.hostname.endsWith(`.${h}`))) { - return true - } - // Local apps with X-Frame-Options or CSP frame-ancestors blocking iframes - if ( - u.port === '23000' || // BTCPay — X-Frame-Options: DENY - u.port === '3000' || // Grafana — X-Frame-Options: deny - u.port === '8082' || // Vaultwarden — X-Frame-Options: SAMEORIGIN + CSP frame-ancestors - u.port === '2342' || // PhotoPrism — X-Frame-Options: DENY + CSP frame-ancestors: 'none' - u.port === '8085' || // Nextcloud — X-Frame-Options: SAMEORIGIN - u.port === '3001' || // Uptime Kuma — X-Frame-Options: SAMEORIGIN - u.port === '8123' // Home Assistant — X-Frame-Options: SAMEORIGIN - ) { - return true - } - return false + return NEW_TAB_PORTS.has(u.port) } catch { return false } } -/** Port → proxy path for apps (nginx strips X-Frame-Options + avoids mixed content) */ -const PORT_TO_PROXY: Record = { - '81': '/app/nginx-proxy-manager/', - '3000': '/app/grafana/', - '3001': '/app/uptime-kuma/', - '8080': '/app/endurain/', - '8081': '/app/lnd/', - '8082': '/app/vaultwarden/', - '8083': '/app/filebrowser/', - '8085': '/app/nextcloud/', - '8096': '/app/jellyfin/', - '8123': '/app/homeassistant/', - '8240': '/app/tailscale/', - '8334': '/app/bitcoin-ui/', - '8888': '/app/searxng/', - '9000': '/app/portainer/', - '9001': '/app/penpot/', - '9980': '/app/onlyoffice/', - '11434': '/app/ollama/', - '2283': '/app/immich/', - '23000': '/app/btcpay/', - '2342': '/app/photoprism/', - '4080': '/app/mempool/', -'8175': '/app/fedimint/', - '8176': '/app/fedimint-gateway/', - '3100': '/app/dwn/', - '18081': '/app/nostr-rs-relay/', - '7777': '/app/indeedhub/', +/** Port → app ID for resolving URLs to AppSession routes */ +const PORT_TO_APP_ID: Record = { + '81': 'nginx-proxy-manager', + '3000': 'grafana', + '3001': 'uptime-kuma', + '8080': 'endurain', + '8081': 'lnd', + '8082': 'vaultwarden', + '8083': 'filebrowser', + '8085': 'nextcloud', + '8096': 'jellyfin', + '8123': 'homeassistant', + '8240': 'tailscale', + '8334': 'bitcoin-knots', + '8888': 'searxng', + '9000': 'portainer', + '9001': 'penpot', + '9980': 'onlyoffice', + '11434': 'ollama', + '2283': 'immich', + '23000': 'btcpay-server', + '2342': 'photoprism', + '4080': 'mempool', + '8175': 'fedimint', + '8176': 'fedimint-gateway', + '3100': 'dwn', + '18081': 'nostr-rs-relay', + '7777': 'indeedhub', + '50002': 'electrumx', } -/** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content. - * On HTTP, direct port URLs are used — they avoid subpath routing issues - * (apps' root-relative asset paths like /static/main.js break under /app/xxx/). - * On HTTPS, must proxy to avoid mixed-content blocks; nginx also strips X-Frame-Options. - */ -function toEmbeddableUrl(url: string): string { - try { - const u = new URL(url) - const origin = window.location.origin - - // External sites proxied through nginx path-based locations - const extPath = EXTERNAL_PROXY_PATH[u.hostname] - if (extPath) { - return `${origin}${extPath}` - } - - const proxyPath = PORT_TO_PROXY[u.port] - const sameHost = u.hostname === window.location.hostname - const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:' - if (proxyPath && sameHost && needsProxy) { - return `${origin}${proxyPath}` - } - } catch { - /* ignore */ - } - return url -} const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins' @@ -121,6 +83,8 @@ export interface NostrConsentRequest { reject: () => void } +const DISPLAY_MODE_KEY = 'archipelago_app_display_mode' + export const useAppLauncherStore = defineStore('appLauncher', () => { const isOpen = ref(false) const url = ref('') @@ -129,9 +93,22 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { const showConsent = ref(false) let previousActiveElement: HTMLElement | null = null - /** Open app in full-page session view (preferred — no iframe subpath issues) */ + /** Active app in panel mode (store-based, no route change) */ + const panelAppId = ref(null) + + /** Open app in session view — panel mode uses store, overlay/fullscreen uses route */ function openSession(appId: string) { - router.push({ name: 'app-session', params: { appId } }) + const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel' + if (mode === 'panel') { + panelAppId.value = appId + } else { + panelAppId.value = null + router.push({ name: 'app-session', params: { appId } }) + } + } + + function closePanel() { + panelAppId.value = null } /** Legacy: open app in iframe overlay (kept for backward compat) */ @@ -142,13 +119,13 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { openSession(resolvedId) return } + // Apps that block iframes — open directly in new tab if (payload.openInNewTab || mustOpenInNewTab(payload.url)) { window.open(payload.url, '_blank', 'noopener,noreferrer') return } - const embeddableUrl = toEmbeddableUrl(payload.url) previousActiveElement = (document.activeElement as HTMLElement) || null - url.value = embeddableUrl + url.value = payload.url title.value = payload.title isOpen.value = true } @@ -158,11 +135,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { try { const u = new URL(urlStr) // Check port-based apps - for (const [port, proxyPath] of Object.entries(PORT_TO_PROXY)) { - if (u.port === port) { - return proxyPath.replace('/app/', '').replace(/\/$/, '') - } - } + const appId = PORT_TO_APP_ID[u.port] + if (appId) return appId // Check external URLs const EXTERNAL_APP_HOSTS: Record = { 'botfights.net': 'botfights', @@ -326,6 +300,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { open, openSession, close, + closePanel, + panelAppId, showConsent, consentRequest, approveConsent, diff --git a/neode-ui/src/stores/container.ts b/neode-ui/src/stores/container.ts index 617f381b..1750a742 100644 --- a/neode-ui/src/stores/container.ts +++ b/neode-ui/src/stores/container.ts @@ -16,6 +16,17 @@ export interface BundledApp { lan_address?: string // Runtime launch URL from backend } +/** Map bundled app ID to the podman container name(s) used for status matching. + * Some apps have a different container name than their app ID, or use a + * separate UI container (e.g., bitcoin-knots node → bitcoin-ui web container). */ +const CONTAINER_NAME_MAP: Record = { + 'bitcoin-knots': ['bitcoin-knots', 'bitcoin-ui'], + 'lnd': ['lnd', 'archy-lnd-ui'], + 'btcpay-server': ['btcpay-server'], + 'mempool': ['archy-mempool-web'], + 'electrumx': ['archy-electrs-ui', 'electrumx', 'mempool-electrs'], +} + export const BUNDLED_APPS: BundledApp[] = [ { id: 'bitcoin-knots', @@ -23,7 +34,7 @@ export const BUNDLED_APPS: BundledApp[] = [ image: 'localhost/bitcoinknots/bitcoin:29', description: 'Full Bitcoin node with additional features', icon: '₿', - ports: [{ host: 8332, container: 8332 }, { host: 8333, container: 8333 }], + ports: [{ host: 8334, container: 80 }], volumes: [{ host: '/var/lib/archipelago/bitcoin', container: '/data' }], category: 'bitcoin', }, @@ -33,7 +44,7 @@ export const BUNDLED_APPS: BundledApp[] = [ image: 'docker.io/lightninglabs/lnd:v0.18.4-beta', description: 'Lightning Network Daemon for fast Bitcoin payments', icon: '⚡', - ports: [{ host: 9735, container: 9735 }, { host: 10009, container: 10009 }], + ports: [{ host: 8081, container: 80 }], volumes: [{ host: '/var/lib/archipelago/lnd', container: '/root/.lnd' }], category: 'lightning', }, @@ -48,12 +59,12 @@ export const BUNDLED_APPS: BundledApp[] = [ category: 'home', }, { - id: 'btcpayserver', + id: 'btcpay-server', name: 'BTCPay Server', image: 'docker.io/btcpayserver/btcpayserver:latest', description: 'Self-hosted Bitcoin payment processor', icon: '💳', - ports: [{ host: 23000, container: 23000 }], + ports: [{ host: 23000, container: 49392 }], volumes: [{ host: '/var/lib/archipelago/btcpay', container: '/datadir' }], category: 'bitcoin', }, @@ -63,30 +74,10 @@ export const BUNDLED_APPS: BundledApp[] = [ image: 'docker.io/mempool/frontend:latest', description: 'Bitcoin blockchain and mempool visualizer', icon: '🔍', - ports: [{ host: 8080, container: 8080 }], + ports: [{ host: 4080, container: 8080 }], volumes: [{ host: '/var/lib/archipelago/mempool', container: '/data' }], category: 'bitcoin', }, - { - id: 'nostr-rs-relay', - name: 'Nostr Relay (RS)', - image: 'docker.io/scsibug/nostr-rs-relay:latest', - description: 'Rust-based Nostr relay for decentralized social', - icon: '🦩', - ports: [{ host: 8008, container: 8080 }], - volumes: [{ host: '/var/lib/archipelago/nostr-rs', container: '/usr/src/app/db' }], - category: 'other', - }, - { - id: 'strfry', - name: 'Strfry Relay', - image: 'docker.io/hoytech/strfry:latest', - description: 'High-performance Nostr relay', - icon: '⚡', - ports: [{ host: 7777, container: 7777 }], - volumes: [{ host: '/var/lib/archipelago/strfry', container: '/app/strfry-db' }], - category: 'other', - }, { id: 'tailscale', name: 'Tailscale VPN', @@ -124,14 +115,18 @@ export const useContainerStore = defineStore('container', () => { healthStatus.value[appId] || 'unknown' ) - // Get container for a bundled app (matches by name) + // Get container for a bundled app (matches by explicit name map, then by exact name) const getContainerForApp = computed(() => (appId: string) => { - return containers.value.find(c => - c.name === appId || - c.name.includes(appId) || - c.name === `archipelago-${appId}` || - c.name === `archipelago-${appId}-dev` - ) + const nameList = CONTAINER_NAME_MAP[appId] + if (nameList) { + // Try each known container name in priority order + for (const n of nameList) { + const found = containers.value.find(c => c.name === n) + if (found) return found + } + } + // Fallback: exact match on app ID + return containers.value.find(c => c.name === appId) }) // Check if an app is currently loading (starting/stopping) diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index 164f43ff..2c360150 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -462,6 +462,106 @@ input[type="radio"]:active + * { transform: translateX(1rem); } + /* Incoming Transactions badge */ + .incoming-tx-badge { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.25); + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + color: #4ade80; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + white-space: nowrap; + } + + .incoming-tx-badge:hover { + background: rgba(34, 197, 94, 0.2); + border-color: rgba(34, 197, 94, 0.4); + transform: translateY(-1px); + } + + .incoming-tx-ping { + position: absolute; + top: -2px; + right: -2px; + width: 8px; + height: 8px; + background: #4ade80; + border-radius: 9999px; + animation: incoming-pulse 2s ease-in-out infinite; + } + + @keyframes incoming-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.5); } + } + + /* Incoming transaction row */ + .incoming-tx-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: rgba(0, 0, 0, 0.2); + cursor: pointer; + transition: all 0.2s ease; + } + + .incoming-tx-row:hover { + background: rgba(34, 197, 94, 0.08); + } + + .incoming-tx-icon { + width: 1.75rem; + height: 1.75rem; + border-radius: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .incoming-tx-icon-pending { + background: rgba(234, 179, 8, 0.15); + color: #facc15; + } + + .incoming-tx-icon-confirmed { + background: rgba(34, 197, 94, 0.15); + color: #4ade80; + } + + /* Slide-down transition for incoming tx panel */ + .incoming-tx-slide-enter-active { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .incoming-tx-slide-leave-active { + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + } + + .incoming-tx-slide-enter-from, + .incoming-tx-slide-leave-to { + opacity: 0; + max-height: 0; + transform: translateY(-8px); + margin-bottom: 0; + } + + .incoming-tx-slide-enter-to, + .incoming-tx-slide-leave-from { + opacity: 1; + max-height: 500px; + transform: translateY(0); + } + /* BANNED: gradient-card, gradient-card-dark, gradient-button Use .glass-card or .path-option-card for containers. Use .glass-button for all buttons. diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index 983122c2..882288c0 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -526,8 +526,9 @@ const ROUTE_TO_PACKAGE_KEY: Record = { 'uptime-kuma': 'uptime-kuma', tailscale: 'tailscale', indeedhub: 'indeedhub', - electrs: 'mempool-electrs', - 'mempool-electrs': 'mempool-electrs', + electrumx: 'electrumx', + electrs: 'electrumx', + 'mempool-electrs': 'electrumx', } /** Backend may register under variant container names */ @@ -536,7 +537,7 @@ const PACKAGE_ALIASES: Record = { nextcloud: ['nextcloud-aio', 'nextcloud-server'], 'mempool-web': ['archy-mempool-web'], indeedhub: ['indeedhub-build_app_1'], - electrs: ['mempool-electrs', 'archy-electrs'], + electrumx: ['mempool-electrs', 'electrs', 'archy-electrs'], } function resolvePackageKey(routeId: string): string { diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index c3e75ebc..e1a14d09 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -1,9 +1,9 @@