security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul

Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation

Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)

UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet

Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-19 12:44:31 +00:00
parent 28763c4f09
commit 84a56c80de
77 changed files with 2485 additions and 966 deletions

View File

@ -5,6 +5,7 @@
- [deploy-automation.md](deploy-automation.md) — Deploy script automation TODOs (API key, AIUI nginx, swap) - [deploy-automation.md](deploy-automation.md) — Deploy script automation TODOs (API key, AIUI nginx, swap)
## Servers & Deploy ## Servers & Deploy
- [project_environments.md](project_environments.md) — Four environments: dev mode, dev server/prod, demo
- [tailscale_servers.md](tailscale_servers.md) — Tailscale server details (archipelago-2, archipelago-3) - [tailscale_servers.md](tailscale_servers.md) — Tailscale server details (archipelago-2, archipelago-3)
- [reference_tailscale_nodes.md](reference_tailscale_nodes.md) — All node IPs and SSH commands - [reference_tailscale_nodes.md](reference_tailscale_nodes.md) — All node IPs and SSH commands
- [second-server.md](second-server.md) — Second dev server (archipelago-2 via Tailscale) - [second-server.md](second-server.md) — Second dev server (archipelago-2 via Tailscale)

View File

@ -0,0 +1,21 @@
---
name: Four Environments
description: Clear distinction between dev mode (local mock), dev server (228), demo (Portainer), and prod (same as dev server)
type: project
---
Four distinct environments — use correct terminology:
| Name | What | Where | Backend | Deploy |
|------|------|-------|---------|--------|
| **Dev mode** | Local macOS, mock backend | `localhost:8100` | `mock-backend.js` on `:5959` | `npm run dev:mock` |
| **Dev server / Prod** | Primary build/test/live server | `192.168.1.228` (+ fleet) | Real Rust backend + Podman | `deploy-to-target.sh --live` |
| **Demo** | Public demo instance | Remote server | Mock Node.js via Docker | Portainer Stacks / `docker-compose.demo.yml` |
- Dev server and prod are the SAME machine (192.168.1.228) — "prod" just means "the live deployment"
- Demo is completely separate — user deploys via Portainer UI, Claude has no SSH access
- Dev mode is local-only, no containers needed, fastest iteration
**Why:** User corrected ambiguous usage of "dev servers (prod)" — these are the same thing, not two separate environments.
**How to apply:** Always say "dev mode" for local mock, "dev server" or "prod" for 228, "demo" for the Portainer instance. Never conflate them.

View File

@ -0,0 +1,78 @@
---
name: Repo Cleanup & Dev Environment Overhaul (2026-03-18)
description: Major session — repo cleanup to archy-archive, demo seeding, dev-start.sh rewrite, ThunderHub/Fedimint/ecash, Podman install, wallet mock endpoints
type: project
---
## What Was Done
### 1. Repo Cleanup
- Moved ~200 files (docs, scripts, loops, legacy Docker UIs, duplicate videos) to `~/Projects/archy-archive/` (outside repo)
- Kept: all active docs (BETA-PROGRESS, MASTER_PLAN, architecture, ADRs, api-reference, developer-guide, troubleshooting, operations-runbook), all source code, active scripts
- Three "user's call" docs kept: `multi-node-architecture.md`, `marketplace-protocol.md`, `app-developer-guide.md`
### 2. docker-compose.yml Switched from Regtest to Signet
- All Bitcoin/LND/Fedimint containers now use **signet** (not regtest)
- Ports updated: RPC 38332, P2P 38333
- Removed archived `bitcoin-ui` and `lnd-ui` nginx services (referenced deleted `docker/` dir)
- Added ThunderHub service (port 3010) to main compose
### 3. New Testnet Compose (`docker-compose.testnet.yml`)
- Standalone signet stack: bitcoind + LND + ThunderHub + Fedimint
- Config at `testnet/thunderhub-config.yaml`
- README at `testnet/README.md` with faucet links and commands
### 4. Mock Backend Enhancements (`neode-ui/mock-backend.js`)
- **Container socket auto-detection**: tries `DOCKER_HOST` → Podman TMPDIR socket → Docker socket → null (simulation). No more `/var/run/docker.sock` spam
- **8 static dev apps** (was 6): added ThunderHub (port 3010) and Fedimint (port 8175)
- **25+ new RPC endpoints**: lnd.getinfo, lnd.newaddress, lnd.createinvoice, lnd.payinvoice, lnd.sendcoins, lnd.listchannels, lnd.openchannel, lnd.closechannel, wallet.ecash-balance, wallet.ecash-send, wallet.ecash-receive, wallet.ecash-history, wallet.networking-profits, bitcoin.getinfo, system.stats, update.status, network.list-requests, dev.faucet, etc.
- **Fedimint version** synced to 0.10.0, port fixed from 8174 → 8175
- **5 realistic notifications** (was empty array)
- **Mock ThunderHub UI** at `/app/thunderhub/` — full HTML dashboard
### 5. Dev Scripts Fixed
- `neode-ui/start-dev.sh`: removed broken `start-docker-apps.sh` call, fixed EAGAIN via safe `while read` loop
- `neode-ui/stop-dev.sh`: removed broken `stop-docker-apps.sh` call
- `neode-ui/package.json`: removed stale `prebuild`, added `--raw` to concurrently (fixes EAGAIN pgrep spawn)
- `scripts/dev-start.sh`: complete rewrite with 8 options including boot mode and testnet stack
### 6. ThunderHub Added Everywhere
- Icon: `neode-ui/public/assets/img/app-icons/thunderhub.svg`
- Mock backend: portMappings, marketplaceMetadata, staticDevApps, marketplace.get()
- Marketplace.vue: getCuratedAppList(), recommended tier
- appLauncher.ts: PORT_TO_APP_ID `'3010': 'thunderhub'`
### 7. Podman Installed on Mac
- `podman 5.8.1` + `podman-compose 1.5.0` via Homebrew
- Machine initialized and running
### 8. Home Wallet Card
- Fixed `lnd.getinfo` response to include `balance_sats` and `channel_balance_sats`
- Fixed `lnd.gettransactions` to use `amount_sats` and include `incoming_pending_count`
- Added **Faucet button** (green) — calls `dev.faucet` RPC
- Grid changed from 3-col to 4-col (Send, Receive, Faucet, Web5)
### 9. Developer Onboarding Docs
- `neode-ui/README.md`: full rewrite
- `neode-ui/DEV-SCRIPTS.md`: updated with actual 8 static apps
## Current State / Resume Here
- **`npm start` works** — no Docker needed, all wallet actions mocked, 8 apps visible
- **Send/Receive modals** open from Home wallet card — if still issues, check browser console
- **Faucet button** calls dev.faucet and refreshes balances
- **Not yet tested**: `podman-compose -f docker-compose.testnet.yml up` (signet sync ~10 min)
- **Not yet committed** — all changes are local, uncommitted
- **Demo prod server** not redeployed — push changes then redeploy via Portainer
## Key Files Modified This Session
- `neode-ui/mock-backend.js` (major — container socket, 25+ RPC endpoints, ThunderHub mock UI)
- `neode-ui/src/views/Home.vue` (faucet button, 4-col grid)
- `neode-ui/src/views/Marketplace.vue` (ThunderHub entry)
- `neode-ui/src/stores/appLauncher.ts` (ThunderHub port)
- `neode-ui/start-dev.sh`, `neode-ui/stop-dev.sh`, `neode-ui/package.json`
- `scripts/dev-start.sh` (complete rewrite)
- `docker-compose.yml` (regtest→signet, ThunderHub, removed archived UIs)
- `docker-compose.testnet.yml` (new)
- `testnet/thunderhub-config.yaml`, `testnet/README.md` (new)
- `neode-ui/public/assets/img/app-icons/thunderhub.svg` (new)
- `neode-ui/README.md`, `neode-ui/DEV-SCRIPTS.md` (rewrites)

View File

@ -7,6 +7,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.3.0] - 2026-03-19
### Security
#### Pentest Remediation (33 findings, all addressed)
- **Critical**: Backend now binds to 127.0.0.1 only — no more direct LAN access to port 5678
- **Critical**: Fixed path traversal in Tor service management that could allow `sudo rm -rf` on arbitrary directories
- **Critical**: Fixed unauthenticated file read/delete via DWN recordId path traversal
- **High**: Federation peers now require cryptographic signature — unsigned peers rejected
- **High**: Login redirect XSS vulnerability fixed with proper URL validation
- **High**: Viewer role restricted to read-only node methods (was granting sign/export access)
- **High**: Backup restore/verify now validates IDs against path traversal
- **High**: Tar archive extraction validates every entry path (prevents tar slip attacks)
- **High**: S3 backup endpoints require HTTPS and reject private IP ranges
- **Medium**: Remember-me token secret now uses cryptographic random (not machine-id)
- **Medium**: Destructive operations (factory reset, onboarding reset) now require password re-verification
- **Medium**: Session token rotated after TOTP verification (prevents interception reuse)
- **Medium**: Webhook URL validation hardened against IPv6 bypass, DNS rebinding, redirect chains
- **Low**: CORS localhost:8100 only included in dev mode
- **Low**: CSP `unsafe-inline` removed from `script-src`
- **Low**: Content filenames validated against path separators and hidden file prefixes
- **Low**: Nostr relay URLs restricted to `wss://` with private IP rejection
- **Low**: Onion address validation enforces v3 format (56 base32 chars)
- **Low**: Router detection restricted to private IP ranges only
#### Nginx Authentication
- Fixed session cookie name mismatch (`session_id``session`) across all nginx auth checks
- LND Connect info endpoint now properly authenticated
### Container Reliability
#### Memory Limits (prevents OOM crashes)
- All 37 containers in `first-boot-containers.sh` now have `--memory=` limits
- Automatic RAM tier detection — reduced limits on 8GB machines
- Prevents a single runaway container from crashing the entire system
#### Smart Container States
- New `exited` state distinguishes crashed containers from intentionally stopped ones
- Crashed containers show red "crashed" badge with restart button
- Health-aware status: "healthy" (green), "starting up" (yellow spinner), "unhealthy" (orange pulse)
- Restart button added next to Stop on running containers
#### Crash Recovery Improvements
- Boot recovery and health monitor now coordinate via shared flag (no more restart cascade)
- User-stopped containers tracked in `user-stopped.json` — survive reboots without auto-restart
- Boot recovery uses tiered ordering: databases → core → services → apps → UIs
- Health monitor waits for boot recovery to complete before starting checks
### UI Improvements
#### Home Dashboard
- Wallet card now matches Web5 wallet display
- New Transactions modal with full history (incoming/outgoing, amounts, confirmations)
- Transactions button in header — switches to "Incoming" badge when pending transactions exist
- Dev faucet button (dev mode only) with mutable wallet state
- Fixed system stats crash (`cpu_usage_percent` field name mismatch)
#### Apps & App Details
- Container restart button (icon) next to Stop on all running apps
- Exited/crashed containers show "Restart" instead of "Start" with red styling
- Removed broken sticky header from Apps page
- Health-aware status badges throughout
#### Mesh, Cloud, Settings & More
- Mesh view overhaul with improved layout
- Glass button styling updates across components
- New BaseModal and ToggleSwitch components
- Updated translations (English + Spanish)
- Spotlight search improvements
### Infrastructure
#### LND Connect
- Tor hidden service now exposes LND REST port (8080) for remote wallet connections
- Fixed in ISO build script, deploy script, and live servers
#### Dev Environment
- Mock backend has mutable wallet state (faucet/send/receive actually change balances)
- Testnet stack option auto-starts Podman machine on macOS
- Boot mode simulation for testing startup screens
## [1.2.0] - 2026-03-14 ## [1.2.0] - 2026-03-14
### Fixed ### Fixed

View File

@ -77,11 +77,14 @@ impl ApiHandler {
/// Allowed CORS origins derived from the config host IP. /// Allowed CORS origins derived from the config host IP.
fn allowed_origins(&self) -> Vec<String> { fn allowed_origins(&self) -> Vec<String> {
vec![ let mut origins = vec![
format!("http://{}", self.config.host_ip), format!("http://{}", self.config.host_ip),
format!("https://{}", self.config.host_ip), format!("https://{}", self.config.host_ip),
"http://localhost:8100".to_string(), // Vite dev server ];
] if self.config.dev_mode {
origins.push("http://localhost:8100".to_string()); // Vite dev server
}
origins
} }
/// Validate the Origin header against allowed origins. /// Validate the Origin header against allowed origins.

View File

@ -76,7 +76,21 @@ impl RpcHandler {
Ok(serde_json::json!(complete)) Ok(serde_json::json!(complete))
} }
pub(super) async fn handle_auth_reset_onboarding(&self) -> Result<serde_json::Value> { pub(super) async fn handle_auth_reset_onboarding(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
return Err(anyhow::anyhow!("Password Incorrect"));
}
self.auth_manager.reset_onboarding().await?; self.auth_manager.reset_onboarding().await?;
Ok(serde_json::json!(true)) Ok(serde_json::json!(true))
} }

View File

@ -1,8 +1,61 @@
use super::RpcHandler; use super::RpcHandler;
use crate::backup::full; use crate::backup::full;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::net::IpAddr;
use tracing::info; use tracing::info;
/// Validate an S3 endpoint URL: require https, reject private/loopback IPs and localhost.
fn validate_s3_endpoint(endpoint: &str) -> Result<()> {
// Require HTTPS scheme
if !endpoint.starts_with("https://") {
anyhow::bail!("S3 endpoint must use https://");
}
// Extract host from URL (strip scheme, path, port)
let after_scheme = &endpoint["https://".len()..];
let host_port = after_scheme.split('/').next().unwrap_or("");
// Strip port if present (handle IPv6 bracket notation)
let host = if host_port.starts_with('[') {
// IPv6: [::1]:443
host_port.split(']').next().unwrap_or("").trim_start_matches('[')
} else {
host_port.split(':').next().unwrap_or("")
};
if host.is_empty() {
anyhow::bail!("S3 endpoint missing host");
}
// Reject localhost
if host == "localhost" || host.ends_with(".localhost") {
anyhow::bail!("S3 endpoint must not point to localhost");
}
// Parse as IP and reject private/reserved ranges
if let Ok(ip) = host.parse::<IpAddr>() {
let is_private = match ip {
IpAddr::V4(v4) => {
v4.is_loopback() // 127.0.0.0/8
|| v4.octets()[0] == 10 // 10.0.0.0/8
|| (v4.octets()[0] == 172 && (v4.octets()[1] & 0xf0) == 16) // 172.16.0.0/12
|| (v4.octets()[0] == 192 && v4.octets()[1] == 168) // 192.168.0.0/16
|| (v4.octets()[0] == 169 && v4.octets()[1] == 254) // 169.254.0.0/16
|| v4.is_unspecified() // 0.0.0.0
}
IpAddr::V6(v6) => {
v6.is_loopback() // ::1
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7
|| v6.is_unspecified() // ::
}
};
if is_private {
anyhow::bail!("S3 endpoint must not point to a private or reserved IP address");
}
}
Ok(())
}
impl RpcHandler { impl RpcHandler {
/// Create a full encrypted backup. Params: { passphrase, description? } /// Create a full encrypted backup. Params: { passphrase, description? }
pub(super) async fn handle_backup_create( pub(super) async fn handle_backup_create(
@ -55,6 +108,11 @@ impl RpcHandler {
.as_str() .as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?; .ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
// Validate backup ID to prevent path traversal
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
anyhow::bail!("Invalid backup ID");
}
let result = full::verify_backup(&self.config.data_dir, id, passphrase).await?; let result = full::verify_backup(&self.config.data_dir, id, passphrase).await?;
Ok(serde_json::json!({ Ok(serde_json::json!({
@ -78,6 +136,11 @@ impl RpcHandler {
.as_str() .as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?; .ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
// Validate backup ID to prevent path traversal
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
anyhow::bail!("Invalid backup ID");
}
full::restore_full_backup(&self.config.data_dir, id, passphrase).await?; full::restore_full_backup(&self.config.data_dir, id, passphrase).await?;
Ok(serde_json::json!({ "restored": true, "id": id })) Ok(serde_json::json!({ "restored": true, "id": id }))
@ -183,6 +246,9 @@ impl RpcHandler {
anyhow::bail!("Invalid backup ID"); anyhow::bail!("Invalid backup ID");
} }
// Validate endpoint to prevent SSRF against internal services
validate_s3_endpoint(endpoint)?;
let bak_path = full::backup_file_path(&self.config.data_dir, id); let bak_path = full::backup_file_path(&self.config.data_dir, id);
if !bak_path.exists() { if !bak_path.exists() {
anyhow::bail!("Backup not found: {}", id); anyhow::bail!("Backup not found: {}", id);
@ -255,6 +321,9 @@ impl RpcHandler {
anyhow::bail!("Invalid backup ID"); anyhow::bail!("Invalid backup ID");
} }
// Validate endpoint to prevent SSRF against internal services
validate_s3_endpoint(endpoint)?;
let key = format!("archipelago-backups/{}.tar.gz.enc", id); let key = format!("archipelago-backups/{}.tar.gz.enc", id);
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key); let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);

View File

@ -4,6 +4,16 @@ use crate::network::dwn_store::DwnStore;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use tracing::debug; use tracing::debug;
/// Validate a v3 Tor onion address.
/// Must be exactly 62 chars: 56 base32 characters (a-z, 2-7) followed by ".onion".
fn is_valid_v3_onion(addr: &str) -> bool {
if addr.len() != 62 || !addr.ends_with(".onion") {
return false;
}
let prefix = &addr[..56];
prefix.chars().all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c))
}
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1"; const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
impl RpcHandler { impl RpcHandler {
@ -25,10 +35,16 @@ impl RpcHandler {
.get("filename") .get("filename")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing filename"))?; .ok_or_else(|| anyhow::anyhow!("Missing filename"))?;
// Prevent path traversal // Validate filename: prevent path traversal, hidden files, and excessive length
if filename.contains("..") || filename.contains('\0') { if filename.contains("..") || filename.contains('\0') || filename.contains('/') || filename.contains('\\') {
anyhow::bail!("Invalid filename: path traversal not allowed"); anyhow::bail!("Invalid filename: path traversal not allowed");
} }
if filename.starts_with('.') {
anyhow::bail!("Invalid filename: hidden files not allowed");
}
if filename.is_empty() || filename.len() > 255 {
anyhow::bail!("Invalid filename: must be 1-255 characters");
}
let mime_type = params let mime_type = params
.get("mime_type") .get("mime_type")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@ -191,8 +207,9 @@ impl RpcHandler {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
if !onion.ends_with(".onion") || onion.len() < 10 { // Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total
return Err(anyhow::anyhow!("Invalid onion address")); if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
} }
let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050") let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
@ -252,9 +269,9 @@ impl RpcHandler {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
// Validate onion address format // Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total
if !onion.ends_with(".onion") || onion.len() < 10 { if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid onion address")); return Err(anyhow::anyhow!("Invalid v3 onion address"));
} }
// Connect via Tor SOCKS proxy to the peer's content catalog endpoint // Connect via Tor SOCKS proxy to the peer's content catalog endpoint

View File

@ -371,7 +371,8 @@ impl RpcHandler {
} }
} }
None => { None => {
tracing::warn!(peer_did = %did, "Peer-joined without signature — accepting but unverified"); tracing::warn!(peer_did = %did, "Rejected peer-joined: missing signature");
anyhow::bail!("Missing signature — all federation peers must be cryptographically verified");
} }
} }

View File

@ -442,7 +442,7 @@ impl RpcHandler {
"auth.changePassword" => self.handle_auth_change_password(params, &session_token).await, "auth.changePassword" => self.handle_auth_change_password(params, &session_token).await,
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await, "auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await, "auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
"auth.resetOnboarding" => self.handle_auth_reset_onboarding().await, "auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
// Container orchestration (for Archipelago-managed containers) // Container orchestration (for Archipelago-managed containers)
"container-install" => self.handle_container_install(params).await, "container-install" => self.handle_container_install(params).await,
@ -789,7 +789,7 @@ impl RpcHandler {
self.metrics_store.record_rpc_latency(elapsed_ms).await; self.metrics_store.record_rpc_latency(elapsed_ms).await;
// Build response (cache successful results for cacheable methods) // Build response (cache successful results for cacheable methods)
let rpc_resp = match result { let mut rpc_resp = match result {
Ok(data) => { Ok(data) => {
if is_cacheable { if is_cacheable {
self.response_cache.set(rpc_req.method.clone(), data.clone()).await; self.response_cache.set(rpc_req.method.clone(), data.clone()).await;
@ -893,8 +893,62 @@ impl RpcHandler {
} }
} }
// On successful TOTP verification, the session is already upgraded to full // On successful TOTP verification, set the rotated session cookie
// (handled inside handle_login_totp/handle_login_backup) if (rpc_req.method == "auth.login.totp" || rpc_req.method == "auth.login.backup")
&& rpc_resp.error.is_none()
{
// Extract token (clone to release immutable borrow before mutable borrow below)
let new_token_opt = rpc_resp
.result
.as_ref()
.and_then(|r| r.get("new_session_token"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(new_token) = new_token_opt {
let csrf_token = derive_csrf_token(&new_token);
let remember_token = self.session_store.create_remember_token();
response.headers_mut().append(
"Set-Cookie",
format!(
"session={}; HttpOnly; SameSite=Strict; Path=/{}",
new_token,
self.cookie_suffix()
)
.parse()
.unwrap(),
);
response.headers_mut().append(
"Set-Cookie",
format!(
"csrf_token={}; SameSite=Strict; Path=/{}",
csrf_token,
self.cookie_suffix()
)
.parse()
.unwrap(),
);
response.headers_mut().append(
"Set-Cookie",
format!(
"remember={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}{}",
remember_token,
REMEMBER_TTL,
self.cookie_suffix()
)
.parse()
.unwrap(),
);
// Strip the token from the response body — don't leak it to JS
if let Some(result) = rpc_resp.result.as_mut() {
if let Some(obj) = result.as_object_mut() {
obj.remove("new_session_token");
}
}
let body_bytes = serde_json::to_vec(&rpc_resp).unwrap_or_default();
*response.body_mut() = hyper::Body::from(body_bytes);
}
}
// On password change, rotate the session token for the caller // On password change, rotate the session token for the caller
if rpc_req.method == "auth.changePassword" && rpc_resp.error.is_none() { if rpc_req.method == "auth.changePassword" && rpc_resp.error.is_none() {

View File

@ -686,6 +686,12 @@ printtoconsole=1\n", rpc_pass);
sorted sorted
}; };
// Clear user-stopped flag — user explicitly started this app
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await;
for name in &to_start {
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
}
for name in to_start { for name in to_start {
let _ = tokio::process::Command::new("podman") let _ = tokio::process::Command::new("podman")
.args(["start", &name]) .args(["start", &name])
@ -707,9 +713,13 @@ printtoconsole=1\n", rpc_pass);
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?; .ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?; validate_app_id(package_id)?;
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
let containers = get_containers_for_app(package_id).await?; let containers = get_containers_for_app(package_id).await?;
if containers.is_empty() { if containers.is_empty() {
let container_name = format!("archy-{}", package_id); let container_name = format!("archy-{}", package_id);
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, &container_name).await;
let _ = tokio::process::Command::new("podman") let _ = tokio::process::Command::new("podman")
.args(["stop", &container_name]) .args(["stop", &container_name])
.output() .output()
@ -717,6 +727,9 @@ printtoconsole=1\n", rpc_pass);
return Ok(serde_json::Value::Null); return Ok(serde_json::Value::Null);
} }
for name in &containers {
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
}
for name in containers { for name in containers {
let _ = tokio::process::Command::new("podman") let _ = tokio::process::Command::new("podman")
.args(["stop", &name]) .args(["stop", &name])
@ -1025,6 +1038,7 @@ printtoconsole=1\n", rpc_pass);
fn create_installing_entry(package_id: &str) -> PackageDataEntry { fn create_installing_entry(package_id: &str) -> PackageDataEntry {
PackageDataEntry { PackageDataEntry {
state: PackageState::Installing, state: PackageState::Installing,
health: None,
static_files: StaticFiles { static_files: StaticFiles {
license: String::new(), license: String::new(),
instructions: String::new(), instructions: String::new(),

View File

@ -609,6 +609,18 @@ impl RpcHandler {
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}"); anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
} }
// Require password re-authentication for destructive operations
let password = params
.as_ref()
.and_then(|p| p.get("password"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
return Err(anyhow::anyhow!("Password Incorrect"));
}
tracing::warn!("Factory reset initiated — wiping ALL user data and containers"); tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
let data_dir = &self.config.data_dir; let data_dir = &self.config.data_dir;

View File

@ -118,6 +118,11 @@ impl RpcHandler {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?; .ok_or_else(|| anyhow::anyhow!("Missing name"))?;
// Validate name to prevent path traversal
if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
}
let onion = read_onion_address(name); let onion = read_onion_address(name);
Ok(serde_json::json!({ "name": name, "onion_address": onion })) Ok(serde_json::json!({ "name": name, "onion_address": onion }))
} }
@ -134,6 +139,11 @@ impl RpcHandler {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing name"))?; .ok_or_else(|| anyhow::anyhow!("Missing name"))?;
// Validate name to prevent path traversal
if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
}
let base = tor_data_dir(); let base = tor_data_dir();
let service_dir = format!("{}/hidden_service_{}", base, name); let service_dir = format!("{}/hidden_service_{}", base, name);
@ -277,6 +287,12 @@ impl RpcHandler {
.get("app_id") .get("app_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
// Validate app_id to prevent path traversal
if app_id.is_empty() || app_id.len() > 64 || !app_id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return Err(anyhow::anyhow!("Invalid app_id (alphanumeric, hyphens, underscores only)"));
}
let enabled = params let enabled = params
.get("enabled") .get("enabled")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())

View File

@ -192,10 +192,11 @@ impl RpcHandler {
let _ = self.auth_manager.update_totp(data).await; let _ = self.auth_manager.update_totp(data).await;
} }
// Upgrade pending session to full // Upgrade pending session to full (rotates token)
self.session_store.upgrade_to_full(token).await; let new_token = self.session_store.upgrade_to_full(token).await
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
Ok(serde_json::json!({ "success": true })) Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
} }
None => { None => {
anyhow::bail!("Invalid code. Please try again."); anyhow::bail!("Invalid code. Please try again.");
@ -241,13 +242,14 @@ impl RpcHandler {
totp_data.backup_codes.remove(idx); totp_data.backup_codes.remove(idx);
self.auth_manager.update_totp(totp_data).await?; self.auth_manager.update_totp(totp_data).await?;
// Upgrade pending session to full // Upgrade pending session to full (rotates token)
self.session_store.upgrade_to_full(token).await; let new_token = self.session_store.upgrade_to_full(token).await
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
tracing::info!("Login via backup code (codes remaining: {})", tracing::info!("Login via backup code (codes remaining: {})",
self.auth_manager.get_totp_data().await?.map(|d| d.backup_codes.len()).unwrap_or(0)); self.auth_manager.get_totp_data().await?.map(|d| d.backup_codes.len()).unwrap_or(0));
Ok(serde_json::json!({ "success": true })) Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
} }
None => { None => {
anyhow::bail!("Invalid backup code"); anyhow::bail!("Invalid backup code");

View File

@ -3,6 +3,90 @@ use crate::webhooks;
use anyhow::Result; use anyhow::Result;
use tracing::info; use tracing::info;
/// Check if a hostname/IP points to a private or internal address.
/// Handles: IPv4, IPv6 (including mapped IPv4 like ::ffff:127.0.0.1),
/// decimal/octal IP representations, and well-known internal hostnames.
fn is_webhook_host_private(host: &str) -> bool {
// Strip IPv6 brackets if present
let h = host.trim_start_matches('[').trim_end_matches(']');
// Check well-known internal hostnames
let lower = h.to_lowercase();
if lower == "localhost"
|| lower == "localhost.localdomain"
|| lower.ends_with(".local")
|| lower.ends_with(".internal")
|| lower == "metadata.google.internal"
|| lower == "169.254.169.254"
{
return true;
}
// Try to parse as IP address
if let Ok(ip) = h.parse::<std::net::IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.octets()[0] == 100 && (64..=127).contains(&v4.octets()[1]) // CGNAT
}
std::net::IpAddr::V6(v6) => {
if v6.is_loopback() || v6.is_unspecified() {
return true;
}
// Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
if let Some(v4) = v6.to_ipv4_mapped() {
return v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified();
}
// Unique local (fd00::/8, fc00::/7)
let segments = v6.segments();
(segments[0] & 0xfe00) == 0xfc00
|| (segments[0] & 0xffc0) == 0xfe80 // link-local
}
};
}
// Detect decimal IP notation (e.g., "2130706433" = 127.0.0.1)
if let Ok(decimal) = h.parse::<u32>() {
let octets = decimal.to_be_bytes();
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
}
// Detect octal IP notation (e.g., "0177.0.0.1" = 127.0.0.1)
if h.contains('.') {
let parts: Vec<&str> = h.split('.').collect();
if parts.len() == 4 {
let mut octets = [0u8; 4];
let mut all_ok = true;
for (i, part) in parts.iter().enumerate() {
let val = if part.starts_with("0x") || part.starts_with("0X") {
u64::from_str_radix(part.trim_start_matches("0x").trim_start_matches("0X"), 16).ok()
} else if part.starts_with('0') && part.len() > 1 {
u64::from_str_radix(part, 8).ok()
} else {
part.parse::<u64>().ok()
};
match val {
Some(v) if v <= 255 => octets[i] = v as u8,
_ => { all_ok = false; break; }
}
}
if all_ok {
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
}
}
}
false
}
impl RpcHandler { impl RpcHandler {
/// webhook.get-config — Get current webhook configuration. /// webhook.get-config — Get current webhook configuration.
pub(super) async fn handle_webhook_get_config(&self) -> Result<serde_json::Value> { pub(super) async fn handle_webhook_get_config(&self) -> Result<serde_json::Value> {
@ -28,37 +112,35 @@ impl RpcHandler {
config.enabled = enabled; config.enabled = enabled;
} }
if let Some(url) = params.get("url").and_then(|v| v.as_str()) { if let Some(url) = params.get("url").and_then(|v| v.as_str()) {
// Validate webhook URL scheme and reject obviously dangerous targets // Validate webhook URL scheme and reject dangerous targets
if !url.is_empty() { if !url.is_empty() {
if !url.starts_with("https://") && !url.starts_with("http://") {
anyhow::bail!("Webhook URL must use HTTP(S)");
}
if !self.config.dev_mode && !url.starts_with("https://") {
anyhow::bail!("Webhook URL must use HTTPS in production");
}
// Extract host portion and reject private/internal addresses
let host_part = url
.trim_start_matches("https://")
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
let is_private = host_part == "localhost"
|| host_part == "127.0.0.1"
|| host_part == "::1"
|| host_part.starts_with("10.")
|| host_part.starts_with("172.")
|| host_part.starts_with("192.168.")
|| host_part.starts_with("169.254.");
if is_private && !self.config.dev_mode {
anyhow::bail!("Webhook URL must not point to private/local addresses");
}
if url.len() > 2048 { if url.len() > 2048 {
anyhow::bail!("Webhook URL too long"); anyhow::bail!("Webhook URL too long");
} }
// Parse URL properly to handle edge cases (IPv6, userinfo, etc.)
let parsed = reqwest::Url::parse(url)
.map_err(|_| anyhow::anyhow!("Invalid webhook URL"))?;
// Require https:// in production
if !self.config.dev_mode && parsed.scheme() != "https" {
anyhow::bail!("Webhook URL must use HTTPS in production");
}
if parsed.scheme() != "https" && parsed.scheme() != "http" {
anyhow::bail!("Webhook URL must use HTTP(S)");
}
// Reject URLs with userinfo (user:pass@host) — can be used for credential smuggling
if parsed.username() != "" || parsed.password().is_some() {
anyhow::bail!("Webhook URL must not contain credentials");
}
// Extract and validate the host
let host = parsed.host_str().unwrap_or("");
if host.is_empty() {
anyhow::bail!("Webhook URL must have a valid host");
}
// Reject private/internal addresses (handle IPv4, IPv6, decimal/octal IPs, hostnames)
let is_private = is_webhook_host_private(host);
if is_private && !self.config.dev_mode {
anyhow::bail!("Webhook URL must not point to private/local addresses");
}
} }
config.url = url.to_string(); config.url = url.to_string();
} }

View File

@ -39,7 +39,9 @@ impl UserRole {
|| method == "system.temperature" || method == "system.temperature"
|| method == "system.disk-status" || method == "system.disk-status"
|| method == "system.detect-usb-devices" || method == "system.detect-usb-devices"
|| method.starts_with("node.") || method == "node.did"
|| method == "node.tor-address"
|| method == "node.nostr-pubkey"
|| method.starts_with("federation.list") || method.starts_with("federation.list")
|| method.starts_with("dwn.status") || method.starts_with("dwn.status")
|| method.starts_with("dwn.list") || method.starts_with("dwn.list")

View File

@ -435,10 +435,46 @@ fn create_tar_gz(data_dir: &Path) -> Result<Vec<u8>> {
fn extract_tar_gz(data_dir: &Path, tar_gz_data: &[u8]) -> Result<()> { fn extract_tar_gz(data_dir: &Path, tar_gz_data: &[u8]) -> Result<()> {
let gz = GzDecoder::new(tar_gz_data); let gz = GzDecoder::new(tar_gz_data);
let mut archive = Archive::new(gz); let mut archive = Archive::new(gz);
let canonical_base = data_dir
.canonicalize()
.context("Failed to canonicalize data_dir")?;
archive for entry_result in archive.entries().context("Failed to read tar entries")? {
.unpack(data_dir) let mut entry = entry_result.context("Failed to read tar entry")?;
.context("Failed to extract backup archive")?; let entry_path = entry.path().context("Failed to get entry path")?.to_path_buf();
// Reject entries with path traversal components
for component in entry_path.components() {
if matches!(component, std::path::Component::ParentDir) {
anyhow::bail!(
"Tar entry contains path traversal: {}",
entry_path.display()
);
}
}
let target = data_dir.join(&entry_path);
// Verify the resolved path stays within data_dir
// For new files that don't exist yet, check the parent directory
let check_path = if target.exists() {
target.canonicalize()?
} else if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
parent.canonicalize()?.join(target.file_name().unwrap_or_default())
} else {
target.clone()
};
if !check_path.starts_with(&canonical_base) {
anyhow::bail!(
"Tar entry escapes target directory: {}",
entry_path.display()
);
}
entry
.unpack(&target)
.with_context(|| format!("Failed to extract: {}", entry_path.display()))?;
}
debug!("Backup extracted to {:?}", data_dir); debug!("Backup extracted to {:?}", data_dir);
Ok(()) Ok(())

View File

@ -217,7 +217,7 @@ mod tests {
fn test_default_config_values() { fn test_default_config_values() {
let config = Config::default(); let config = Config::default();
assert_eq!(config.data_dir, PathBuf::from("/var/lib/archipelago")); assert_eq!(config.data_dir, PathBuf::from("/var/lib/archipelago"));
assert_eq!(config.bind_host, "0.0.0.0"); assert_eq!(config.bind_host, "127.0.0.1");
assert_eq!(config.bind_port, 5678); assert_eq!(config.bind_port, 5678);
assert_eq!(config.log_level, "info"); assert_eq!(config.log_level, "info");
assert_eq!(config.host_ip, "127.0.0.1"); assert_eq!(config.host_ip, "127.0.0.1");

View File

@ -145,6 +145,7 @@ impl DockerPackageScanner {
let package = PackageDataEntry { let package = PackageDataEntry {
state: package_state.clone(), state: package_state.clone(),
health: container.health.clone(),
static_files: StaticFiles { static_files: StaticFiles {
license: "MIT".to_string(), license: "MIT".to_string(),
instructions: metadata.description.clone(), instructions: metadata.description.clone(),
@ -592,9 +593,8 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) { fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
match container_state { match container_state {
ContainerState::Running => (PackageState::Running, ServiceStatus::Running), ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
ContainerState::Stopped | ContainerState::Exited => { ContainerState::Stopped => (PackageState::Stopped, ServiceStatus::Stopped),
(PackageState::Stopped, ServiceStatus::Stopped) ContainerState::Exited => (PackageState::Exited, ServiceStatus::Stopped),
}
ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped), ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped),
ContainerState::Paused => (PackageState::Stopped, ServiceStatus::Stopped), ContainerState::Paused => (PackageState::Stopped, ServiceStatus::Stopped),
ContainerState::Unknown(_) => (PackageState::Stopped, ServiceStatus::Stopped), ContainerState::Unknown(_) => (PackageState::Stopped, ServiceStatus::Stopped),
@ -607,6 +607,7 @@ fn package_state_str(state: &PackageState) -> &str {
PackageState::Installed => "installed", PackageState::Installed => "installed",
PackageState::Stopping => "stopping", PackageState::Stopping => "stopping",
PackageState::Stopped => "stopped", PackageState::Stopped => "stopped",
PackageState::Exited => "exited",
PackageState::Starting => "starting", PackageState::Starting => "starting",
PackageState::Running => "running", PackageState::Running => "running",
PackageState::Restarting => "restarting", PackageState::Restarting => "restarting",

View File

@ -11,11 +11,64 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::fs; use tokio::fs;
use tracing::{info, warn}; use tracing::{info, warn};
const PID_FILE: &str = "archipelago.pid"; const PID_FILE: &str = "archipelago.pid";
const CONTAINER_STATE_FILE: &str = "running-containers.json"; const CONTAINER_STATE_FILE: &str = "running-containers.json";
const USER_STOPPED_FILE: &str = "user-stopped.json";
/// Shared flag: true once boot recovery is complete. Health monitor should wait for this.
pub static RECOVERY_COMPLETE: AtomicBool = AtomicBool::new(false);
/// Mark boot recovery as complete. Call after crash recovery + start_stopped_containers finish.
pub fn mark_recovery_complete() {
RECOVERY_COMPLETE.store(true, Ordering::SeqCst);
info!("Boot recovery complete — health monitor may proceed");
}
/// Check if boot recovery is done.
pub fn is_recovery_complete() -> bool {
RECOVERY_COMPLETE.load(Ordering::SeqCst)
}
// ── User-stopped tracking ───────────────────────────────────────────────
// When a user explicitly stops a container via the UI, we record it here
// so crash recovery and health monitor don't auto-restart it.
/// Load the set of user-stopped containers from disk.
pub async fn load_user_stopped(data_dir: &Path) -> std::collections::HashSet<String> {
let path = data_dir.join(USER_STOPPED_FILE);
match fs::read_to_string(&path).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => std::collections::HashSet::new(),
}
}
/// Save the set of user-stopped containers to disk.
pub async fn save_user_stopped(data_dir: &Path, stopped: &std::collections::HashSet<String>) {
let path = data_dir.join(USER_STOPPED_FILE);
if let Ok(json) = serde_json::to_string_pretty(stopped) {
let _ = fs::write(&path, json).await;
}
}
/// Mark a container as user-stopped (won't be auto-restarted).
pub async fn mark_user_stopped(data_dir: &Path, name: &str) {
let mut stopped = load_user_stopped(data_dir).await;
stopped.insert(name.to_string());
save_user_stopped(data_dir, &stopped).await;
}
/// Clear user-stopped flag (container was manually started by user).
pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
let mut stopped = load_user_stopped(data_dir).await;
if stopped.remove(name) {
save_user_stopped(data_dir, &stopped).await;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunningContainerRecord { pub struct RunningContainerRecord {
@ -241,7 +294,8 @@ fn is_process_running(pid: u32) -> bool {
/// Start all stopped containers that were previously installed. /// Start all stopped containers that were previously installed.
/// Runs on every startup to ensure containers come back after clean reboots. /// Runs on every startup to ensure containers come back after clean reboots.
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones. /// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
pub async fn start_stopped_containers() -> RecoveryReport { /// Skips containers that the user intentionally stopped via the UI.
pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
let output = match tokio::time::timeout( let output = match tokio::time::timeout(
std::time::Duration::from_secs(30), std::time::Duration::from_secs(30),
tokio::process::Command::new("podman") tokio::process::Command::new("podman")
@ -257,7 +311,7 @@ pub async fn start_stopped_containers() -> RecoveryReport {
} }
}; };
let names: Vec<String> = match output { let all_names: Vec<String> = match output {
Ok(o) if o.status.success() => { Ok(o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout) String::from_utf8_lossy(&o.stdout)
.lines() .lines()
@ -268,17 +322,52 @@ pub async fn start_stopped_containers() -> RecoveryReport {
_ => Vec::new(), _ => Vec::new(),
}; };
if all_names.is_empty() {
return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() };
}
// Filter out user-stopped containers
let user_stopped = load_user_stopped(data_dir).await;
let names: Vec<String> = all_names.into_iter()
.filter(|n| {
if user_stopped.contains(n) {
info!("Skipping user-stopped container: {}", n);
false
} else {
true
}
})
.collect();
if names.is_empty() { if names.is_empty() {
return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() }; return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() };
} }
info!("Starting {} stopped containers after boot...", names.len()); // Sort by startup tier: databases first, then core, then dependent services, then apps
let records: Vec<RunningContainerRecord> = names.iter() let mut records: Vec<RunningContainerRecord> = names.iter()
.map(|n| RunningContainerRecord { name: n.clone(), image: String::new() }) .map(|n| RunningContainerRecord { name: n.clone(), image: String::new() })
.collect(); .collect();
records.sort_by_key(|r| container_boot_tier(&r.name));
info!("Starting {} stopped containers after boot (skipped {} user-stopped)...",
records.len(), user_stopped.len());
recover_containers(&records).await recover_containers(&records).await
} }
/// Simple tier ordering for boot recovery (mirrors health_monitor tiers).
fn container_boot_tier(name: &str) -> u8 {
let id = name.strip_prefix("archy-").unwrap_or(name);
match id {
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres"
| "immich_redis" | "penpot-valkey" => 0,
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 1,
"lnd" | "electrumx" | "mempool-electrs" | "electrs" | "nbxplorer" => 2,
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui"
| "penpot-frontend" | "penpot-exporter" => 4,
_ => 3,
}
}
/// Spawn a background task that periodically saves the container snapshot. /// Spawn a background task that periodically saves the container snapshot.
pub fn spawn_snapshot_task(data_dir: PathBuf) { pub fn spawn_snapshot_task(data_dir: PathBuf) {
tokio::spawn(async move { tokio::spawn(async move {

View File

@ -102,6 +102,7 @@ pub enum PackageState {
Installed, Installed,
Stopping, Stopping,
Stopped, Stopped,
Exited,
Starting, Starting,
Running, Running,
Restarting, Restarting,
@ -117,6 +118,9 @@ pub enum PackageState {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PackageDataEntry { pub struct PackageDataEntry {
pub state: PackageState, pub state: PackageState,
/// Container health: "healthy", "unhealthy", "starting", or null
#[serde(skip_serializing_if = "Option::is_none")]
pub health: Option<String>,
#[serde(rename = "static-files")] #[serde(rename = "static-files")]
pub static_files: StaticFiles, pub static_files: StaticFiles,
pub manifest: Manifest, pub manifest: Manifest,

View File

@ -350,8 +350,26 @@ async fn restart_container(name: &str) -> bool {
/// Spawn the health monitor background task. /// Spawn the health monitor background task.
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) { pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
tokio::spawn(async move { tokio::spawn(async move {
// Wait 2 minutes for containers to start up // Wait for boot recovery to complete before starting health checks.
tokio::time::sleep(std::time::Duration::from_secs(120)).await; // This prevents the health monitor from fighting with crash_recovery
// which is starting containers in tier order.
info!("Health monitor: waiting for boot recovery to complete...");
let wait_start = std::time::Instant::now();
loop {
if crate::crash_recovery::is_recovery_complete() {
break;
}
// Safety timeout: start anyway after 5 minutes even if recovery hangs
if wait_start.elapsed().as_secs() > 300 {
warn!("Health monitor: boot recovery did not complete within 5 minutes, starting anyway");
break;
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
// Additional cooldown after recovery to let containers stabilize
info!("Health monitor: recovery done, waiting 60s for containers to stabilize...");
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
info!("Health monitor: starting health checks");
let mut tracker = RestartTracker::new(); let mut tracker = RestartTracker::new();
let mut mem_tracker = MemoryTracker::new(); let mut mem_tracker = MemoryTracker::new();
@ -378,6 +396,9 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
continue; continue;
} }
// Load user-stopped list to skip intentionally stopped containers
let user_stopped = crate::crash_recovery::load_user_stopped(&data_dir).await;
// Sort containers by startup tier so databases restart before dependent services // Sort containers by startup tier so databases restart before dependent services
let mut unhealthy: Vec<&ContainerHealth> = Vec::new(); let mut unhealthy: Vec<&ContainerHealth> = Vec::new();
let mut state_changed = false; let mut state_changed = false;
@ -392,6 +413,11 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
continue; continue;
} }
if container.state == "exited" || container.state == "stopped" { if container.state == "exited" || container.state == "stopped" {
// Skip user-stopped containers
if user_stopped.contains(&container.name) {
debug!("Skipping user-stopped container: {}", container.name);
continue;
}
unhealthy.push(container); unhealthy.push(container);
} }
} }

View File

@ -92,13 +92,17 @@ async fn main() -> Result<()> {
} }
// Start any stopped containers (handles clean reboot) // Start any stopped containers (handles clean reboot)
let boot_report = crash_recovery::start_stopped_containers().await; // Skips user-stopped containers, uses tier ordering
let boot_report = crash_recovery::start_stopped_containers(&data_dir).await;
if boot_report.total > 0 { if boot_report.total > 0 {
info!( info!(
"🔄 Boot startup: {}/{} containers started (failed: {:?})", "🔄 Boot startup: {}/{} containers started (failed: {:?})",
boot_report.recovered, boot_report.total, boot_report.failed boot_report.recovered, boot_report.total, boot_report.failed
); );
} }
// Signal to health monitor that boot recovery is done
crash_recovery::mark_recovery_complete();
}); });
} }

View File

@ -121,8 +121,24 @@ impl DwnStore {
Ok(message) Ok(message)
} }
/// Validate a record ID to prevent path traversal.
fn validate_record_id(record_id: &str) -> Result<()> {
if record_id.is_empty()
|| record_id.len() > 128
|| !record_id
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(anyhow::anyhow!(
"Invalid record ID (alphanumeric, hyphens, underscores only)"
));
}
Ok(())
}
/// Read a message by record ID. /// Read a message by record ID.
pub async fn read_message(&self, record_id: &str) -> Result<Option<DwnMessage>> { pub async fn read_message(&self, record_id: &str) -> Result<Option<DwnMessage>> {
Self::validate_record_id(record_id)?;
let path = self.messages_dir.join(format!("{}.json", record_id)); let path = self.messages_dir.join(format!("{}.json", record_id));
if !path.exists() { if !path.exists() {
return Ok(None); return Ok(None);
@ -137,6 +153,7 @@ impl DwnStore {
/// Delete a message by record ID. /// Delete a message by record ID.
pub async fn delete_message(&self, record_id: &str) -> Result<bool> { pub async fn delete_message(&self, record_id: &str) -> Result<bool> {
Self::validate_record_id(record_id)?;
let path = self.messages_dir.join(format!("{}.json", record_id)); let path = self.messages_dir.join(format!("{}.json", record_id));
if !path.exists() { if !path.exists() {
return Ok(false); return Ok(false);

View File

@ -322,11 +322,37 @@ pub async fn save_router_config(data_dir: &Path, config: &RouterConfig) -> Resul
fs::write(&path, data).await.context("Writing router config") fs::write(&path, data).await.context("Writing router config")
} }
/// Validate that an IP string is a private/LAN address (not public, not localhost).
fn is_valid_private_ip(ip_str: &str) -> bool {
let ip: std::net::IpAddr = match ip_str.parse() {
Ok(ip) => ip,
Err(_) => return false, // Reject hostnames
};
match ip {
std::net::IpAddr::V4(v4) => {
// Allow only RFC1918 private ranges, reject localhost and public
let octets = v4.octets();
let is_10 = octets[0] == 10;
let is_172_private = octets[0] == 172 && (16..=31).contains(&octets[1]);
let is_192_168 = octets[0] == 192 && octets[1] == 168;
is_10 || is_172_private || is_192_168
}
std::net::IpAddr::V6(_) => false, // Reject IPv6 for gateway detection
}
}
/// Detect router type by probing common endpoints on the gateway. /// Detect router type by probing common endpoints on the gateway.
pub async fn detect_router_type(gateway_ip: &str) -> RouterType { pub async fn detect_router_type(gateway_ip: &str) -> RouterType {
// Validate that gateway is a private IP — prevent SSRF to arbitrary hosts
if !is_valid_private_ip(gateway_ip) {
tracing::warn!(gateway = gateway_ip, "Rejected non-private gateway IP");
return RouterType::Unknown;
}
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5)) .timeout(std::time::Duration::from_secs(5))
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(true)
.redirect(reqwest::redirect::Policy::none())
.build() .build()
.unwrap_or_default(); .unwrap_or_default();

View File

@ -158,17 +158,77 @@ pub async fn get_stats(data_dir: &Path) -> Result<RelayStats> {
}) })
} }
/// Normalize a relay URL (ensure wss:// prefix). /// Normalize and validate a relay URL.
/// Only allows wss:// scheme (not ws://) for security.
/// Rejects URLs pointing to private/internal IPs.
fn normalize_relay_url(url: &str) -> Result<String> { fn normalize_relay_url(url: &str) -> Result<String> {
let trimmed = url.trim(); let trimmed = url.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Err(anyhow::anyhow!("Relay URL cannot be empty")); return Err(anyhow::anyhow!("Relay URL cannot be empty"));
} }
if trimmed.starts_with("wss://") || trimmed.starts_with("ws://") { if trimmed.len() > 2048 {
Ok(trimmed.to_string()) return Err(anyhow::anyhow!("Relay URL too long"));
} else {
Ok(format!("wss://{}", trimmed))
} }
// Apply wss:// prefix if no scheme
let with_scheme = if trimmed.starts_with("wss://") || trimmed.starts_with("ws://") {
trimmed.to_string()
} else {
format!("wss://{}", trimmed)
};
// Only allow wss:// scheme for security
if !with_scheme.starts_with("wss://") {
return Err(anyhow::anyhow!("Relay URL must use wss:// scheme"));
}
// Extract host portion for SSRF validation
let host = with_scheme
.strip_prefix("wss://")
.unwrap_or("")
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
if host.is_empty() {
return Err(anyhow::anyhow!("Relay URL must have a valid host"));
}
// Reject private/internal addresses
if is_relay_host_private(host) {
return Err(anyhow::anyhow!("Relay URL must not point to private/local addresses"));
}
Ok(with_scheme)
}
/// Check if a relay host points to a private or internal address.
fn is_relay_host_private(host: &str) -> bool {
let lower = host.to_lowercase();
if lower == "localhost" || lower == "localhost.localdomain" || lower.ends_with(".local") {
return true;
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
}
std::net::IpAddr::V6(v6) => {
if v6.is_loopback() || v6.is_unspecified() {
return true;
}
if let Some(v4) = v6.to_ipv4_mapped() {
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
}
let segments = v6.segments();
(segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80
}
};
}
false
} }
#[cfg(test)] #[cfg(test)]
@ -183,9 +243,9 @@ mod tests {
} }
#[test] #[test]
fn test_normalize_relay_url_with_ws() { fn test_normalize_relay_url_rejects_ws() {
let result = normalize_relay_url("ws://relay.example.com").unwrap(); let result = normalize_relay_url("ws://relay.example.com");
assert_eq!(result, "ws://relay.example.com"); assert!(result.is_err(), "ws:// scheme should be rejected — only wss:// is allowed");
} }
#[test] #[test]
@ -209,6 +269,14 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn test_normalize_relay_url_rejects_private_ips() {
assert!(normalize_relay_url("wss://127.0.0.1").is_err());
assert!(normalize_relay_url("wss://localhost").is_err());
assert!(normalize_relay_url("wss://192.168.1.1").is_err());
assert!(normalize_relay_url("wss://10.0.0.1").is_err());
}
#[test] #[test]
fn test_seed_defaults_has_expected_count() { fn test_seed_defaults_has_expected_count() {
let store = seed_defaults(); let store = seed_defaults();

View File

@ -1,4 +1,5 @@
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use rand::RngCore;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::HashMap; use std::collections::HashMap;
use std::net::IpAddr; use std::net::IpAddr;
@ -234,16 +235,31 @@ impl SessionStore {
None None
} }
/// Upgrade a pending session to a full session. /// Upgrade a pending session to a full session with token rotation.
pub async fn upgrade_to_full(&self, token: &str) { /// Deletes the old pending session and creates a new full session with a fresh token.
let hash = hash_token(token); /// Returns the new plaintext token so the caller can set it as the new cookie.
pub async fn upgrade_to_full(&self, token: &str) -> Option<String> {
let old_hash = hash_token(token);
let mut sessions = self.sessions.write().await; let mut sessions = self.sessions.write().await;
if let Some(session) = sessions.get_mut(&hash) { // Only upgrade if the old session exists and is pending
session.session_type = SessionType::Full; if sessions.remove(&old_hash).is_some() {
let new_token_bytes: [u8; 32] = rand::random();
let new_token = hex::encode(new_token_bytes);
let new_hash = hash_token(&new_token);
let now = SystemTime::now(); let now = SystemTime::now();
session.created_at = now; self.evict_if_over_limit(&mut sessions);
session.last_activity = now; sessions.insert(
new_hash,
Session {
created_at: now,
last_activity: now,
session_type: SessionType::Full,
},
);
Self::save_to_disk_sync(&sessions, &self.persist_path); Self::save_to_disk_sync(&sessions, &self.persist_path);
Some(new_token)
} else {
None
} }
} }
@ -393,25 +409,21 @@ impl SessionStore {
} }
pub fn load_or_create_remember_secret() -> Vec<u8> { pub fn load_or_create_remember_secret() -> Vec<u8> {
// Try existing secret file first (backwards compatibility) // Try existing secret file first
if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) { if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) {
if secret.len() == 32 { if secret.len() == 32 {
return secret; return secret;
} }
} }
// Derive a deterministic secret from machine-id so it survives restarts // Generate a cryptographically random 32-byte secret on first boot
// without storing plaintext key material let mut secret = [0u8; 32];
let machine_id = std::fs::read_to_string("/etc/machine-id") rand::rngs::OsRng.fill_bytes(&mut secret);
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string()); // Ensure parent directory exists
let salt = b"archipelago-remember-me-v1"; if let Some(parent) = std::path::Path::new(REMEMBER_SECRET_FILE).parent() {
let mut hasher = sha2::Sha256::new(); let _ = std::fs::create_dir_all(parent);
use sha2::Digest; }
hasher.update(machine_id.trim().as_bytes()); let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret);
hasher.update(salt); secret.to_vec()
let secret = hasher.finalize();
let secret_vec = secret.to_vec();
let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret_vec);
secret_vec
} }
} }
@ -605,9 +617,15 @@ mod tests {
let got = store.get_pending_secret(&token).await; let got = store.get_pending_secret(&token).await;
assert_eq!(got, Some(secret)); assert_eq!(got, Some(secret));
// Upgrade to full // Upgrade to full — returns a new rotated token
store.upgrade_to_full(&token).await; let new_token = store.upgrade_to_full(&token).await;
assert!(store.validate(&token).await); assert!(new_token.is_some());
let new_token = new_token.unwrap();
// Old token should be invalid (rotated)
assert!(!store.validate(&token).await);
// New token should be valid
assert!(store.validate(&new_token).await);
} }
#[tokio::test] #[tokio::test]

View File

@ -124,6 +124,7 @@ async fn send_http_webhook(
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build() .build()
.context("Failed to create HTTP client")?; .context("Failed to create HTTP client")?;

View File

@ -20,12 +20,26 @@ pub struct ContainerStatus {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub state: ContainerState, pub state: ContainerState,
pub health: Option<String>, // "healthy", "unhealthy", "starting", or None if no healthcheck
pub started_at: Option<String>,
pub image: String, pub image: String,
pub created: String, pub created: String,
pub ports: Vec<String>, pub ports: Vec<String>,
pub lan_address: Option<String>, // Launch URL for UI access pub lan_address: Option<String>, // Launch URL for UI access
} }
/// Parse health status from podman's Status string (e.g., "Up 5 minutes (healthy)")
fn parse_health_from_status(status: &str) -> Option<String> {
if let Some(start) = status.rfind('(') {
if let Some(end) = status.rfind(')') {
if start < end {
return Some(status[start + 1..end].to_string());
}
}
}
None
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ContainerState { pub enum ContainerState {
Created, Created,
@ -301,6 +315,8 @@ impl PodmanClient {
id: parts[0].to_string(), id: parts[0].to_string(),
name: parts[1].to_string(), name: parts[1].to_string(),
state: ContainerState::from(parts[2]), state: ContainerState::from(parts[2]),
health: None,
started_at: None,
image: parts[3].to_string(), image: parts[3].to_string(),
created: parts[4].to_string(), created: parts[4].to_string(),
ports: vec![], // TODO: Parse ports from parts[5] ports: vec![], // TODO: Parse ports from parts[5]
@ -387,10 +403,15 @@ impl PodmanClient {
}; };
let lan_address = Self::lan_address_for(&name); let lan_address = Self::lan_address_for(&name);
let status_str = container["Status"].as_str().unwrap_or("");
let health = parse_health_from_status(status_str);
let started_at = container["StartedAt"].as_str().or_else(|| container["Started"].as_str()).map(|s| s.to_string());
result.push(ContainerStatus { result.push(ContainerStatus {
id: container["Id"].as_str().unwrap_or("").to_string(), id: container["Id"].as_str().unwrap_or("").to_string(),
name, name,
state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")), state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")),
health,
started_at,
image: container["Image"].as_str().unwrap_or("").to_string(), image: container["Image"].as_str().unwrap_or("").to_string(),
created: container["Created"].as_str().unwrap_or("").to_string(), created: container["Created"].as_str().unwrap_or("").to_string(),
ports, ports,
@ -431,10 +452,15 @@ impl PodmanClient {
}; };
let lan_address = Self::lan_address_for(&name); let lan_address = Self::lan_address_for(&name);
let status_str = container["Status"].as_str().unwrap_or("");
let health = parse_health_from_status(status_str);
let started_at = container["StartedAt"].as_str().or_else(|| container["Started"].as_str()).map(|s| s.to_string());
result.push(ContainerStatus { result.push(ContainerStatus {
id: container["Id"].as_str().unwrap_or("").to_string(), id: container["Id"].as_str().unwrap_or("").to_string(),
name, name,
state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")), state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")),
health,
started_at,
image: container["Image"].as_str().unwrap_or("").to_string(), image: container["Image"].as_str().unwrap_or("").to_string(),
created: container["Created"].as_str().unwrap_or("").to_string(), created: container["Created"].as_str().unwrap_or("").to_string(),
ports, ports,

View File

@ -284,6 +284,8 @@ impl ContainerRuntime for DockerRuntime {
id: parts[0].to_string(), id: parts[0].to_string(),
name: parts[1].to_string(), name: parts[1].to_string(),
state: crate::podman_client::ContainerState::from(parts[2]), state: crate::podman_client::ContainerState::from(parts[2]),
health: None,
started_at: None,
image: parts[3].to_string(), image: parts[3].to_string(),
created: parts[4].to_string(), created: parts[4].to_string(),
ports: vec![], ports: vec![],
@ -356,6 +358,8 @@ impl ContainerRuntime for DockerRuntime {
state: ContainerState::from( state: ContainerState::from(
container["State"].as_str().unwrap_or("unknown") container["State"].as_str().unwrap_or("unknown")
), ),
health: None,
started_at: None,
image: container["Image"].as_str().unwrap_or("").to_string(), image: container["Image"].as_str().unwrap_or("").to_string(),
created: container["CreatedAt"].as_str().unwrap_or("").to_string(), created: container["CreatedAt"].as_str().unwrap_or("").to_string(),
ports, ports,

View File

@ -773,6 +773,7 @@ HiddenServicePort 50001 127.0.0.1:50001
HiddenServiceDir $TOR_DIR/hidden_service_lnd HiddenServiceDir $TOR_DIR/hidden_service_lnd
HiddenServicePort 9735 127.0.0.1:9735 HiddenServicePort 9735 127.0.0.1:9735
HiddenServicePort 8080 127.0.0.1:8080
HiddenServiceDir $TOR_DIR/hidden_service_btcpay HiddenServiceDir $TOR_DIR/hidden_service_btcpay
HiddenServicePort 23000 127.0.0.1:23000 HiddenServicePort 23000 127.0.0.1:23000

View File

@ -17,7 +17,7 @@ server {
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-DNS-Prefetch-Control "off" always; add_header X-DNS-Prefetch-Control "off" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
# AIUI SPA (Chat mode iframe) # AIUI SPA (Chat mode iframe)
# Use =404 fallback instead of index.html to prevent serving HTML with wrong # Use =404 fallback instead of index.html to prevent serving HTML with wrong
@ -37,7 +37,7 @@ server {
# AIUI Claude API proxy — requires valid session cookie # AIUI Claude API proxy — requires valid session cookie
location /aiui/api/claude/ { location /aiui/api/claude/ {
if ($cookie_session_id = "") { if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}'; return 401 '{"error":"Unauthorized"}';
} }
proxy_pass http://127.0.0.1:3142/; proxy_pass http://127.0.0.1:3142/;
@ -54,7 +54,7 @@ server {
# AIUI OpenRouter API proxy — requires valid session cookie # AIUI OpenRouter API proxy — requires valid session cookie
location /aiui/api/openrouter/ { location /aiui/api/openrouter/ {
if ($cookie_session_id = "") { if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}'; return 401 '{"error":"Unauthorized"}';
} }
proxy_pass https://openrouter.ai/api/; proxy_pass https://openrouter.ai/api/;
@ -69,7 +69,7 @@ server {
# AIUI Ollama (local AI) proxy — localhost:11434 # AIUI Ollama (local AI) proxy — localhost:11434
location /aiui/api/ollama/ { location /aiui/api/ollama/ {
if ($cookie_session_id = "") { if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}'; return 401 '{"error":"Unauthorized"}';
} }
proxy_pass http://127.0.0.1:11434/; proxy_pass http://127.0.0.1:11434/;
@ -85,7 +85,7 @@ server {
# AIUI web search proxy — SearXNG on port 8888 # AIUI web search proxy — SearXNG on port 8888
location /aiui/api/web-search { location /aiui/api/web-search {
if ($cookie_session_id = "") { if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}'; return 401 '{"error":"Unauthorized"}';
} }
proxy_pass http://127.0.0.1:8888/search; proxy_pass http://127.0.0.1:8888/search;
@ -154,7 +154,7 @@ server {
location /lnd-connect-info { location /lnd-connect-info {
# Requires authenticated session — exposes LND admin macaroon # Requires authenticated session — exposes LND admin macaroon
if ($cookie_session_id = "") { return 401; } if ($cookie_session = "") { return 401; }
proxy_pass http://127.0.0.1:5678/lnd-connect-info; proxy_pass http://127.0.0.1:5678/lnd-connect-info;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
@ -725,7 +725,7 @@ server {
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-DNS-Prefetch-Control "off" always; add_header X-DNS-Prefetch-Control "off" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
# AIUI SPA (Chat mode iframe) # AIUI SPA (Chat mode iframe)
location /aiui/ { location /aiui/ {
@ -735,7 +735,7 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate";
} }
location /aiui/api/claude/ { location /aiui/api/claude/ {
if ($cookie_session_id = "") { if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}'; return 401 '{"error":"Unauthorized"}';
} }
proxy_pass http://127.0.0.1:3142/; proxy_pass http://127.0.0.1:3142/;
@ -750,7 +750,7 @@ server {
proxy_send_timeout 120s; proxy_send_timeout 120s;
} }
location /aiui/api/ollama/ { location /aiui/api/ollama/ {
if ($cookie_session_id = "") { if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}'; return 401 '{"error":"Unauthorized"}';
} }
proxy_pass http://127.0.0.1:11434/; proxy_pass http://127.0.0.1:11434/;
@ -764,7 +764,7 @@ server {
# Connection header managed by nginx default # Connection header managed by nginx default
} }
location /aiui/api/openrouter/ { location /aiui/api/openrouter/ {
if ($cookie_session_id = "") { if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}'; return 401 '{"error":"Unauthorized"}';
} }
proxy_pass https://openrouter.ai/api/; proxy_pass https://openrouter.ai/api/;
@ -808,7 +808,7 @@ server {
location /lnd-connect-info { location /lnd-connect-info {
# Requires authenticated session — exposes LND admin macaroon # Requires authenticated session — exposes LND admin macaroon
if ($cookie_session_id = "") { return 401; } if ($cookie_session = "") { return 401; }
proxy_pass http://127.0.0.1:5678/lnd-connect-info; proxy_pass http://127.0.0.1:5678/lnd-connect-info;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.g6vfn35hb3c" "revision": "0.3ur9h1c6gak"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@ -343,6 +343,7 @@ async function getDockerContainers() {
version: '1.0.0', version: '1.0.0',
status: isRunning ? 'running' : 'stopped', status: isRunning ? 'running' : 'stopped',
state: isRunning ? 'running' : 'stopped', state: isRunning ? 'running' : 'stopped',
health: isRunning ? 'healthy' : null,
'static-files': { 'static-files': {
license: 'MIT', license: 'MIT',
instructions: metadata.description, instructions: metadata.description,

View File

@ -0,0 +1,3 @@
<svg width="1631" height="1624" viewBox="0 0 1631 1624" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 534 B

View File

@ -141,15 +141,15 @@
</div> </div>
</div> </div>
<div v-if="paymentError" class="mb-3 p-2 bg-red-500/15 border border-red-500/20 rounded-lg"> <div v-if="paymentError" class="mb-3 alert-error">
<p class="text-red-400 text-xs">{{ paymentError }}</p> <p class="text-xs">{{ paymentError }}</p>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<button @click="rejectPayment" class="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70 hover:bg-white/10 transition-colors"> <button @click="rejectPayment" class="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70 hover:bg-white/10 transition-colors">
Deny Deny
</button> </button>
<button @click="approvePayment" :disabled="paymentProcessing" class="flex-1 px-4 py-2.5 bg-orange-500/20 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-300 hover:bg-orange-500/30 transition-colors disabled:opacity-50"> <button @click="approvePayment" :disabled="paymentProcessing" class="flex-1 px-4 py-2.5 glass-button glass-button-warning rounded-lg text-sm font-medium disabled:opacity-50">
{{ paymentProcessing ? 'Paying...' : 'Approve' }} {{ paymentProcessing ? 'Paying...' : 'Approve' }}
</button> </button>
</div> </div>

View File

@ -0,0 +1,89 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 flex items-center justify-center p-4"
:class="zClass"
@click.self="close"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<div
ref="modalRef"
class="glass-card p-6 w-full relative z-10"
:class="[maxWidth, contentClass]"
role="dialog"
aria-modal="true"
@click.stop
>
<div class="flex items-start justify-between gap-4 mb-4 shrink-0">
<h3 class="text-xl font-semibold text-white">{{ title }}</h3>
<button
@click="close"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<slot />
<slot name="footer" />
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const props = withDefaults(defineProps<{
show: boolean
title: string
maxWidth?: string
zIndex?: string
contentClass?: string
}>(), {
maxWidth: 'max-w-md',
zIndex: 'z-[3000]',
contentClass: '',
})
const emit = defineEmits<{
close: []
}>()
const modalRef = ref<HTMLElement | null>(null)
const zClass = computed(() => props.zIndex)
function close() {
emit('close')
}
useModalKeyboard(modalRef, computed(() => props.show), close)
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.2s ease;
}
.modal-enter-from .glass-card {
transform: scale(0.95);
}
.modal-leave-to .glass-card {
transform: scale(0.95);
}
</style>

View File

@ -12,7 +12,18 @@
:style="{ '--card-stagger': idx }" :style="{ '--card-stagger': idx }"
> >
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
<div class="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0"> <!-- App icons for goals with required apps, emoji fallback otherwise -->
<div v-if="goalAppIcons(goal).length > 0" class="flex items-center gap-1.5 shrink-0">
<img
v-for="icon in goalAppIcons(goal)"
:key="icon.appId"
:src="icon.url"
:alt="icon.appId"
class="w-8 h-8 rounded-lg object-contain bg-white/5 border border-white/10 p-0.5"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
</div>
<div v-else class="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<span class="text-xl">{{ goalIcon(goal.icon) }}</span> <span class="text-xl">{{ goalIcon(goal.icon) }}</span>
</div> </div>
<span class="goal-status-badge" :class="statusBadgeClass(goal.id)"> <span class="goal-status-badge" :class="statusBadgeClass(goal.id)">
@ -28,7 +39,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-white/40">{{ goal.estimatedTime }}</span> <span class="text-xs text-white/40">{{ goal.estimatedTime }}</span>
<span class="text-xs text-white/50 flex items-center gap-1"> <span class="text-xs text-white/50 flex items-center gap-1">
{{ goal.difficulty === 'beginner' ? 'Beginner' : 'Intermediate' }} {{ goal.difficulty === 'beginner' ? t('easyHome.beginner') : t('easyHome.intermediate') }}
</span> </span>
</div> </div>
</RouterLink> </RouterLink>
@ -37,18 +48,40 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { GOALS } from '@/data/goals' import { GOALS } from '@/data/goals'
import { useGoalStore } from '@/stores/goals' import { useGoalStore } from '@/stores/goals'
import type { GoalDefinition } from '@/types/goals'
defineProps<{ defineProps<{
show: boolean show: boolean
animate: boolean animate: boolean
}>() }>()
const { t } = useI18n()
const goalStore = useGoalStore() const goalStore = useGoalStore()
const goals = GOALS const goals = GOALS
const goalStatuses = goalStore.goalStatuses const goalStatuses = goalStore.goalStatuses
/** Map appId to its icon file path under /assets/img/app-icons/ */
const APP_ICON_MAP: Record<string, string> = {
'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp',
lnd: '/assets/img/app-icons/lnd.svg',
'btcpay-server': '/assets/img/app-icons/btcpay-server.png',
immich: '/assets/img/app-icons/immich.png',
nextcloud: '/assets/img/app-icons/nextcloud.webp',
fedimint: '/assets/img/app-icons/fedimint.png',
mempool: '/assets/img/app-icons/mempool.webp',
electrs: '/assets/img/app-icons/electrs.svg',
'nostr-rs-relay': '/assets/img/app-icons/nostr-rs-relay.svg',
}
function goalAppIcons(goal: GoalDefinition): { appId: string; url: string }[] {
return goal.requiredApps
.filter((appId) => APP_ICON_MAP[appId] !== undefined)
.map((appId) => ({ appId, url: APP_ICON_MAP[appId] as string }))
}
function goalIcon(icon: string): string { function goalIcon(icon: string): string {
const icons: Record<string, string> = { const icons: Record<string, string> = {
shop: '🏪', shop: '🏪',
@ -64,9 +97,9 @@ function goalIcon(icon: string): string {
function statusLabel(goalId: string): string { function statusLabel(goalId: string): string {
const status = goalStatuses[goalId] const status = goalStatuses[goalId]
if (status === 'completed') return 'Done' if (status === 'completed') return t('easyHome.done')
if (status === 'in-progress') return 'In Progress' if (status === 'in-progress') return t('easyHome.inProgress')
return 'Start' return t('easyHome.start')
} }
function statusBadgeClass(goalId: string): string { function statusBadgeClass(goalId: string): string {

View File

@ -1,29 +1,5 @@
<template> <template>
<Teleport to="body"> <BaseModal :show="show" :title="title" max-width="max-w-lg" content-class="max-h-[80vh] overflow-y-auto" @close="$emit('close')">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="$emit('close')"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="modalRef"
@click.stop
class="glass-card p-6 max-w-lg w-full relative z-10 max-h-[80vh] overflow-y-auto"
>
<div class="flex items-start justify-between gap-4 mb-4">
<h3 class="text-xl font-semibold text-white">{{ title }}</h3>
<button
@click="$emit('close')"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="text-white/80 prose prose-invert max-w-none"> <div class="text-white/80 prose prose-invert max-w-none">
<p class="whitespace-pre-wrap">{{ content }}</p> <p class="whitespace-pre-wrap">{{ content }}</p>
</div> </div>
@ -39,27 +15,20 @@
</svg> </svg>
</router-link> </router-link>
</div> </div>
</div> </BaseModal>
</div>
</Transition>
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import BaseModal from '@/components/BaseModal.vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const props = defineProps<{ defineProps<{
show: boolean show: boolean
title: string title: string
content: string content: string
relatedPath?: string relatedPath?: string
}>() }>()
const emit = defineEmits<{ defineEmits<{
close: [] close: []
}>() }>()
const modalRef = ref<HTMLElement | null>(null)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('close'))
</script> </script>

View File

@ -1,59 +1,33 @@
<template> <template>
<Teleport to="body"> <BaseModal :show="showUpdatePrompt" title="Update Available" z-index="z-[9999]" @close="dismissUpdate">
<Transition name="modal">
<div
v-if="showUpdatePrompt"
class="fixed inset-0 z-[9999] flex items-center justify-center p-4"
@click.self="dismissUpdate"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="modalRef"
class="glass-card p-6 max-w-md w-full relative z-10"
@click.stop
>
<div class="flex items-start justify-between gap-4 mb-4">
<h3 class="text-xl font-semibold text-white">Update Available</h3>
<button
@click="dismissUpdate"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Dismiss"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p class="text-white/80 mb-6"> <p class="text-white/80 mb-6">
A new version of Archipelago is available. Update now to get the latest features and fixes. A new version of Archipelago is available. Update now to get the latest features and fixes.
</p> </p>
<div class="flex gap-3 justify-end"> <template #footer>
<button <div class="flex gap-3 justify-end">
@click="dismissUpdate" <button
class="px-4 py-2 glass-button rounded-lg text-sm font-medium" @click="dismissUpdate"
> class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
Later >
</button> Later
<button </button>
@click="handleUpdate" <button
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium" @click="handleUpdate"
> class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
Update Now >
</button> Update Now
</div> </button>
</div> </div>
</div> </template>
</Transition> </BaseModal>
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard' import BaseModal from '@/components/BaseModal.vue'
const showUpdatePrompt = ref(false) const showUpdatePrompt = ref(false)
let updateCallback: (() => Promise<void>) | null = null let updateCallback: (() => Promise<void>) | null = null
const modalRef = ref<HTMLElement | null>(null)
onMounted(() => { onMounted(() => {
// Listen for service worker updates // Listen for service worker updates
@ -106,8 +80,6 @@ onMounted(() => {
} }
}) })
useModalKeyboard(modalRef, showUpdatePrompt, dismissUpdate)
function dismissUpdate() { function dismissUpdate() {
showUpdatePrompt.value = false showUpdatePrompt.value = false
} }

View File

@ -1,9 +1,5 @@
<template> <template>
<Teleport to="body"> <BaseModal :show="show" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="close" @keydown.escape="close">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true">
<h2 class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
<!-- Method tabs --> <!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg"> <div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button <button
@ -12,24 +8,24 @@
@click="receiveMethod = m" @click="receiveMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors" class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'" :class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : m }}</button> >{{ m === 'onchain' ? t('receiveBitcoin.onChain') : m === 'lightning' ? t('receiveBitcoin.lightning') : t('receiveBitcoin.ecash') }}</button>
</div> </div>
<!-- Lightning --> <!-- Lightning -->
<div v-if="receiveMethod === 'lightning'"> <div v-if="receiveMethod === 'lightning'">
<div class="mb-3"> <div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label> <label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.amountSats') }}</label>
<input v-model.number="invoiceAmount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model.number="invoiceAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Memo (optional)</label> <label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.memoOptional') }}</label>
<input v-model="invoiceMemo" type="text" placeholder="Payment for..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="invoiceMemo" type="text" :placeholder="t('receiveBitcoin.memoPlaceholder')" class="w-full input-glass" />
</div> </div>
<div v-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center"> <div v-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="lightningQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas> <canvas ref="lightningQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-1">Invoice (share with sender):</p> <p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.invoiceShareLabel') }}</p>
<p class="text-xs font-mono text-white/80 break-all">{{ invoiceResult }}</p> <p class="text-xs font-mono text-white/80 break-all">{{ invoiceResult }}</p>
<button @click="copyText(invoiceResult)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button> <button @click="copyText(invoiceResult)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div> </div>
</div> </div>
@ -37,9 +33,9 @@
<div v-if="receiveMethod === 'onchain'"> <div v-if="receiveMethod === 'onchain'">
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center"> <div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas> <canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">Your Bitcoin address:</p> <p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p> <p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<button @click="copyText(onchainAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button> <button @click="copyText(onchainAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div> </div>
<div v-else class="mb-3 text-center"> <div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p> <p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
@ -49,29 +45,28 @@
<!-- Ecash --> <!-- Ecash -->
<div v-if="receiveMethod === 'ecash'"> <div v-if="receiveMethod === 'ecash'">
<div class="mb-3"> <div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Paste ecash token</label> <label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
<textarea v-model="ecashToken" rows="3" placeholder="cashuSend_..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"></textarea> <textarea v-model="ecashToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass font-mono"></textarea>
</div> </div>
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div> <div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
</div> </div>
<div v-if="error" class="mb-3 text-xs text-red-400">{{ error }}</div> <div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3"> <div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button> <button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="receive" :disabled="processing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-green-500/20 border-green-500/30 disabled:opacity-50"> <button @click="receive" :disabled="processing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }} {{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : t('receiveBitcoin.receive') }}
</button> </button>
</div> </div>
</div> </BaseModal>
</div>
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick } from 'vue' import { ref, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
const { t } = useI18n() const { t } = useI18n()
@ -120,7 +115,7 @@ async function receive() {
error.value = '' error.value = ''
try { try {
if (receiveMethod.value === 'lightning') { if (receiveMethod.value === 'lightning') {
if (!invoiceAmount.value) { error.value = 'Enter an amount'; return } if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
const res = await rpcClient.call<{ payment_request: string }>({ const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.addinvoice', method: 'lnd.addinvoice',
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined }, params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
@ -132,12 +127,12 @@ async function receive() {
onchainAddress.value = res.address onchainAddress.value = res.address
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:')) nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
} else { } else {
if (!ecashToken.value.trim()) { error.value = 'Paste an ecash token'; return } if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
await rpcClient.call<{ amount_sats: number }>({ await rpcClient.call<{ amount_sats: number }>({
method: 'wallet.ecash-receive', method: 'wallet.ecash-receive',
params: { token: ecashToken.value.trim() }, params: { token: ecashToken.value.trim() },
}) })
ecashResult.value = 'Token received successfully!' ecashResult.value = t('receiveBitcoin.tokenReceivedSuccess')
emit('received') emit('received')
} }
} catch (err: unknown) { } catch (err: unknown) {

View File

@ -1,9 +1,5 @@
<template> <template>
<Teleport to="body"> <BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="close" @keydown.escape="close">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true">
<h2 class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
<!-- Method tabs --> <!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg"> <div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button <button
@ -12,55 +8,54 @@
@click="sendMethod = m" @click="sendMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors" class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'" :class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : m }}</button> >{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : t('sendBitcoin.auto') }}</button>
</div> </div>
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg"> <div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-xs text-white/50">Auto-selects method based on amount: ecash &lt; 1k sats, Lightning 1k500k, on-chain &gt; 500k</p> <p class="text-xs text-white/50">{{ t('sendBitcoin.autoMethodDesc') }}</p>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label> <label class="text-white/60 text-sm block mb-1">{{ t('sendBitcoin.amountSats') }}</label>
<input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div> </div>
<div v-if="effectiveMethod !== 'ecash'" class="mb-3"> <div v-if="effectiveMethod !== 'ecash'" class="mb-3">
<label class="text-white/60 text-sm block mb-1"> <label class="text-white/60 text-sm block mb-1">
{{ effectiveMethod === 'lightning' ? 'Lightning Invoice (BOLT11)' : 'Bitcoin Address' }} {{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : t('sendBitcoin.bitcoinAddress') }}
</label> </label>
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-white/30"></textarea> <textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div> </div>
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg"> <div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Token (share with recipient):</p> <p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p> <p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button> <button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div> </div>
<div v-if="resultTxid" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg"> <div v-if="resultTxid" class="mb-3 alert-success">
<p class="text-green-400 text-xs">Sent! TX: {{ resultTxid }}</p> <p class="text-xs">{{ t('sendBitcoin.sentTx', { txid: resultTxid }) }}</p>
</div> </div>
<div v-if="resultHash" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg"> <div v-if="resultHash" class="mb-3 alert-success">
<p class="text-green-400 text-xs">Paid! Hash: {{ resultHash }}</p> <p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
</div> </div>
<div v-if="error" class="mb-3 text-xs text-red-400">{{ error }}</div> <div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3"> <div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button> <button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="send" :disabled="processing || !amount" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50"> <button @click="send" :disabled="processing || !amount" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? 'Sending...' : 'Send' }} {{ processing ? t('common.sending') : t('common.send') }}
</button> </button>
</div> </div>
</div> </BaseModal>
</div>
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
const { t } = useI18n() const { t } = useI18n()

View File

@ -0,0 +1,25 @@
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="modelValue ? 'bg-orange-500' : 'bg-white/15'"
@click="$emit('update:modelValue', !modelValue)"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="modelValue ? 'translate-x-5' : 'translate-x-1'"
/>
</button>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>

View File

@ -0,0 +1,116 @@
<template>
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[90vh] flex flex-col" @close="close">
<div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
</div>
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5">
<div
v-for="tx in transactions"
:key="tx.tx_hash"
class="flex items-center justify-between gap-3 py-3 hover:bg-white/5 rounded-lg px-2 cursor-pointer transition-colors"
@click="openInMempool(tx.tx_hash)"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div
class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
:class="tx.direction === 'incoming'
? (tx.num_confirmations === 0 ? 'bg-yellow-500/15' : 'bg-green-500/15')
: 'bg-red-500/10'"
>
<svg
class="w-4 h-4"
:class="tx.direction === 'incoming'
? (tx.num_confirmations === 0 ? 'text-yellow-400' : 'text-green-400')
: 'text-red-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path v-if="tx.direction === 'incoming'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium"
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
>
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ Math.abs(tx.amount_sats).toLocaleString() }} sats
</span>
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.num_confirmations === 0
? 'bg-yellow-500/15 text-yellow-400'
: tx.num_confirmations < 3
? 'bg-green-500/15 text-green-400'
: 'bg-white/10 text-white/50'"
>
{{ tx.num_confirmations === 0 ? t('transactions.unconfirmed') : t('transactions.confirmations', { count: tx.num_confirmations }) }}
</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p>
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</div>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue'
interface WalletTransaction {
tx_hash: string
amount_sats: number
direction: 'incoming' | 'outgoing'
num_confirmations: number
time_stamp: number
total_fees: number
dest_addresses: string[]
label: string
block_height: number
}
defineProps<{
show: boolean
transactions: WalletTransaction[]
}>()
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const router = useRouter()
function close() {
emit('close')
}
function openInMempool(txHash: string) {
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } })
}
function formatTxTime(timestamp: number): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return t('transactions.justNow')
if (diffMins < 60) return t('transactions.minutesAgo', { count: diffMins })
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return t('transactions.hoursAgo', { count: diffHours })
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) return t('transactions.daysAgo', { count: diffDays })
return date.toLocaleDateString()
}
</script>

View File

@ -28,10 +28,7 @@
<p class="text-sm font-medium text-white/90">Share this {{ isDir ? 'folder' : 'file' }}</p> <p class="text-sm font-medium text-white/90">Share this {{ isDir ? 'folder' : 'file' }}</p>
<p class="text-xs text-white/50 mt-0.5">Make visible to connected peers</p> <p class="text-xs text-white/50 mt-0.5">Make visible to connected peers</p>
</div> </div>
<label class="share-toggle"> <ToggleSwitch v-model="shared" />
<input type="checkbox" v-model="shared" />
<span class="share-toggle-slider"></span>
</label>
</div> </div>
<!-- Access Type (only when shared) --> <!-- Access Type (only when shared) -->
@ -126,6 +123,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const props = defineProps<{ const props = defineProps<{
filename: string filename: string

View File

@ -703,5 +703,53 @@
"containers": "Containers", "containers": "Containers",
"goToLogin": "Go to Login", "goToLogin": "Go to Login",
"lastChecked": "Last checked: {time}" "lastChecked": "Last checked: {time}"
},
"receiveBitcoin": {
"onChain": "On-Chain",
"lightning": "Lightning",
"ecash": "Ecash",
"amountSats": "Amount (sats)",
"memoOptional": "Memo (optional)",
"memoPlaceholder": "Payment for...",
"invoiceShareLabel": "Invoice (share with sender):",
"yourBitcoinAddress": "Your Bitcoin address:",
"pasteEcashToken": "Paste ecash token",
"processing": "Processing...",
"generateAddress": "Generate Address",
"createInvoice": "Create Invoice",
"receive": "Receive",
"enterAnAmount": "Enter an amount",
"pasteAnEcashToken": "Paste an ecash token",
"tokenReceivedSuccess": "Token received successfully!"
},
"sendBitcoin": {
"onChain": "On-chain",
"auto": "Auto",
"lightning": "Lightning",
"ecash": "Ecash",
"autoMethodDesc": "Auto-selects method based on amount: ecash < 1k sats, Lightning 1k\u2013500k, on-chain > 500k",
"amountSats": "Amount (sats)",
"lightningInvoice": "Lightning Invoice (BOLT11)",
"bitcoinAddress": "Bitcoin Address",
"tokenShareLabel": "Token (share with recipient):",
"sentTx": "Sent! TX: {txid}",
"paidHash": "Paid! Hash: {hash}"
},
"transactions": {
"title": "Transactions",
"noTransactionsYet": "No transactions yet",
"unconfirmed": "Unconfirmed",
"confirmations": "{count} conf",
"justNow": "just now",
"minutesAgo": "{count}m ago",
"hoursAgo": "{count}h ago",
"daysAgo": "{count}d ago"
},
"easyHome": {
"beginner": "Beginner",
"intermediate": "Intermediate",
"done": "Done",
"inProgress": "In Progress",
"start": "Start"
} }
} }

View File

@ -702,5 +702,53 @@
"containers": "Contenedores", "containers": "Contenedores",
"goToLogin": "Ir a inicio de sesi\u00f3n", "goToLogin": "Ir a inicio de sesi\u00f3n",
"lastChecked": "\u00daltima verificaci\u00f3n: {time}" "lastChecked": "\u00daltima verificaci\u00f3n: {time}"
},
"receiveBitcoin": {
"onChain": "On-Chain",
"lightning": "Lightning",
"ecash": "Ecash",
"amountSats": "Monto (sats)",
"memoOptional": "Nota (opcional)",
"memoPlaceholder": "Pago por...",
"invoiceShareLabel": "Factura (compartir con el remitente):",
"yourBitcoinAddress": "Su direcci\u00f3n Bitcoin:",
"pasteEcashToken": "Pegar token Ecash",
"processing": "Procesando...",
"generateAddress": "Generar direcci\u00f3n",
"createInvoice": "Crear factura",
"receive": "Recibir",
"enterAnAmount": "Ingrese un monto",
"pasteAnEcashToken": "Pegue un token Ecash",
"tokenReceivedSuccess": "\u00a1Token recibido exitosamente!"
},
"sendBitcoin": {
"onChain": "On-chain",
"auto": "Auto",
"lightning": "Lightning",
"ecash": "Ecash",
"autoMethodDesc": "Selecci\u00f3n autom\u00e1tica seg\u00fan monto: ecash < 1k sats, Lightning 1k\u2013500k, on-chain > 500k",
"amountSats": "Monto (sats)",
"lightningInvoice": "Factura Lightning (BOLT11)",
"bitcoinAddress": "Direcci\u00f3n Bitcoin",
"tokenShareLabel": "Token (compartir con el destinatario):",
"sentTx": "\u00a1Enviado! TX: {txid}",
"paidHash": "\u00a1Pagado! Hash: {hash}"
},
"transactions": {
"title": "Transacciones",
"noTransactionsYet": "A\u00fan no hay transacciones",
"unconfirmed": "Sin confirmar",
"confirmations": "{count} conf",
"justNow": "justo ahora",
"minutesAgo": "hace {count}m",
"hoursAgo": "hace {count}h",
"daysAgo": "hace {count}d"
},
"easyHome": {
"beginner": "Principiante",
"intermediate": "Intermedio",
"done": "Listo",
"inProgress": "En progreso",
"start": "Iniciar"
} }
} }

View File

@ -218,7 +218,7 @@ async function checkSessionWithTimeout(store: ReturnType<typeof useAppStore>): P
* Navigation Guard * Navigation Guard
* Handles authentication and onboarding flow routing * Handles authentication and onboarding flow routing
*/ */
function isLocalRedirect(path: unknown): path is string { export function isLocalRedirect(path: unknown): path is string {
if (typeof path !== 'string') return false if (typeof path !== 'string') return false
try { try {
if (path.startsWith('//') || path.includes('://')) return false if (path.startsWith('//') || path.includes('://')) return false

View File

@ -92,8 +92,9 @@ export const useAIPermissionsStore = defineStore('aiPermissions', () => {
try { try {
const stored = localStorage.getItem(STORAGE_KEY) const stored = localStorage.getItem(STORAGE_KEY)
if (stored) { if (stored) {
const parsed = JSON.parse(stored) as AIContextCategory[] const parsed: unknown = JSON.parse(stored)
return new Set(parsed.filter(c => AI_PERMISSION_CATEGORIES.some(cat => cat.id === c))) if (!Array.isArray(parsed)) return new Set()
return new Set(parsed.filter((c: unknown) => typeof c === 'string' && AI_PERMISSION_CATEGORIES.some(cat => cat.id === c)) as AIContextCategory[])
} }
} catch (e) { } catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load AI permissions from storage', e) if (import.meta.env.DEV) console.warn('Failed to load AI permissions from storage', e)

View File

@ -63,7 +63,10 @@ const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
function getApprovedOrigins(): Set<string> { function getApprovedOrigins(): Set<string> {
try { try {
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY) const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
return stored ? new Set(JSON.parse(stored) as string[]) : new Set() if (!stored) return new Set()
const parsed: unknown = JSON.parse(stored)
if (!Array.isArray(parsed)) return new Set()
return new Set(parsed.filter((s: unknown) => typeof s === 'string'))
} catch { } catch {
return new Set() return new Set()
} }
@ -205,8 +208,11 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
try { try {
const stored = localStorage.getItem(appKey) const stored = localStorage.getItem(appKey)
if (stored) { if (stored) {
const parsed = JSON.parse(stored) as { id?: string } const parsed: unknown = JSON.parse(stored)
appIdentityId = parsed.id || null if (typeof parsed === 'object' && parsed !== null && 'id' in parsed) {
const idVal = (parsed as Record<string, unknown>).id
appIdentityId = typeof idVal === 'string' ? idVal : null
}
} }
} catch { /* ignore */ } } catch { /* ignore */ }

View File

@ -23,8 +23,17 @@ export const useSpotlightStore = defineStore('spotlight', () => {
try { try {
const raw = localStorage.getItem(RECENT_ITEMS_KEY) const raw = localStorage.getItem(RECENT_ITEMS_KEY)
if (raw) { if (raw) {
const parsed = JSON.parse(raw) as RecentItem[] const parsed: unknown = JSON.parse(raw)
recentItems.value = parsed.slice(0, MAX_RECENT_ITEMS) if (!Array.isArray(parsed)) {
recentItems.value = []
return
}
recentItems.value = parsed
.filter((item: unknown) =>
typeof item === 'object' && item !== null &&
'id' in item && 'label' in item && 'type' in item && 'timestamp' in item
)
.slice(0, MAX_RECENT_ITEMS) as RecentItem[]
} else { } else {
recentItems.value = [] recentItems.value = []
} }

View File

@ -55,7 +55,7 @@
/* Mobile touch targets — ensure tappable elements meet 44px minimum */ /* Mobile touch targets — ensure tappable elements meet 44px minimum */
@media (max-width: 767px) { @media (max-width: 767px) {
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]) { button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]):not([class*="w-10"]):not([class*="w-11"]):not([class*="w-12"]) {
min-height: 44px; min-height: 44px;
} }
} }
@ -81,7 +81,6 @@ button:active:not(:disabled),
[role="button"]:active, [role="button"]:active,
a.glass-card:active, a.glass-card:active,
a.goal-card:active, a.goal-card:active,
.info-card-button:active,
.path-action-button:active { .path-action-button:active {
transform: scale(0.97) !important; transform: scale(0.97) !important;
transition: transform 0.1s ease !important; transition: transform 0.1s ease !important;
@ -244,18 +243,21 @@ input[type="radio"]:active + * {
position: relative; position: relative;
} }
/* On mobile browsers, cap chat height to the dynamic viewport to prevent
content extending behind browser chrome (address bar / toolbar). */
@media (max-width: 767px) {
.chat-fullscreen {
max-height: 100vh;
max-height: 100dvh;
}
}
.chat-mode-pill { .chat-mode-pill {
display: none;
position: absolute; position: absolute;
top: 2.25rem; top: 2.25rem;
right: 1.25rem; right: 1.25rem;
z-index: 10; z-index: 10;
} }
@media (min-width: 768px) {
.chat-mode-pill {
display: flex;
}
}
.chat-iframe { .chat-iframe {
flex: 1; flex: 1;
@ -265,10 +267,15 @@ input[type="radio"]:active + * {
background: transparent; background: transparent;
} }
/* On mobile, shrink iframe height so AIUI ends above the Archipelago tab bar */ /* On mobile, shrink iframe height so AIUI ends above the Archipelago tab bar.
Use dvh (dynamic viewport height) instead of 100% on a normal mobile browser,
100% resolves through the parent chain to the large viewport (100vh) which
is taller than the visible area when the browser chrome is showing. dvh
tracks the actual visible viewport. */
@media (max-width: 767px) { @media (max-width: 767px) {
.chat-iframe-mobile { .chat-iframe-mobile {
height: calc(100% - var(--mobile-tab-bar-height, 72px)) !important; height: calc(100vh - var(--mobile-tab-bar-height, 72px)) !important;
height: calc(100dvh - var(--mobile-tab-bar-height, 72px)) !important;
flex: none; flex: none;
} }
} }
@ -441,6 +448,112 @@ input[type="radio"]:active + * {
min-height: 36px; min-height: 36px;
} }
/* Glass button color variants */
.glass-button-warning {
background: rgba(251, 146, 60, 0.2);
border: 1px solid rgba(251, 146, 60, 0.3);
color: #fdba74;
}
.glass-button-warning:hover {
background: rgba(251, 146, 60, 0.3);
}
.glass-button-danger {
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.glass-button-danger:hover {
background: rgba(239, 68, 68, 0.3);
}
.glass-button-success {
background: rgba(74, 222, 128, 0.2);
border: 1px solid rgba(74, 222, 128, 0.4);
color: #bbf7d0;
}
.glass-button-success:hover {
background: rgba(74, 222, 128, 0.3);
}
/* Status badges — inline colored pills */
.status-success {
background: rgba(74, 222, 128, 0.2);
color: #4ade80;
}
.status-error {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.status-warning {
background: rgba(251, 146, 60, 0.2);
color: #fb923c;
}
.status-info {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
/* Alert banners — padded containers with border */
.alert-success {
padding: 0.5rem 0.75rem;
background: rgba(74, 222, 128, 0.1);
border: 1px solid rgba(74, 222, 128, 0.2);
border-radius: 0.5rem;
color: #bbf7d0;
font-size: 0.875rem;
}
.alert-error {
padding: 0.5rem 0.75rem;
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 0.5rem;
color: #fca5a5;
font-size: 0.875rem;
}
.alert-warning {
padding: 0.5rem 0.75rem;
background: rgba(251, 146, 60, 0.1);
border: 1px solid rgba(251, 146, 60, 0.2);
border-radius: 0.5rem;
color: #fdba74;
font-size: 0.875rem;
}
.alert-info {
padding: 0.5rem 0.75rem;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 0.5rem;
color: #93c5fd;
font-size: 0.875rem;
}
/* Form input focus ring */
.input-glass {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
color: rgba(255, 255, 255, 0.9);
font-size: 0.875rem;
transition: border-color 0.2s ease;
}
.input-glass:focus {
outline: none;
border-color: #fb923c;
box-shadow: 0 0 0 1px #fb923c;
}
/* Toast - glassmorphic, top-right */ /* Toast - glassmorphic, top-right */
.toast-glass { .toast-glass {
background-color: rgba(0, 0, 0, 0.65); background-color: rgba(0, 0, 0, 0.65);
@ -593,22 +706,6 @@ input[type="radio"]:active + * {
z-index: 1; z-index: 1;
} }
/* Gradient border container for large content areas */
.gradient-border-container {
position: relative;
border-radius: 1.5rem;
padding: 4px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.1) 50%, rgba(0, 0, 0, 0.6) 100%);
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
}
.gradient-border-container-inner {
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border-radius: 1.25rem;
}
/* Choose Your Path - Main Container */ /* Choose Your Path - Main Container */
.path-glass-container { .path-glass-container {
width: calc(100% - 48px); width: calc(100% - 48px);
@ -876,6 +973,28 @@ input[type="radio"]:active + * {
} }
} }
/* Modal transition (Vue <Transition name="modal">) — shared across all modal components */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .glass-card,
.modal-leave-to .glass-card {
transform: scale(0.95);
opacity: 0;
}
/* Background image */ /* Background image */
body { body {
margin: 0; margin: 0;
@ -958,10 +1077,10 @@ iframe.iframe-scrollbar-hide {
} }
@keyframes caretBlink { @keyframes caretBlink {
0%, 100% { 0%, 100% {
border-right-color: #00ffff; border-right-color: #fbbf24;
} }
50% { 50% {
border-right-color: transparent; border-right-color: transparent;
} }
} }
@ -999,15 +1118,6 @@ iframe.iframe-scrollbar-hide {
} }
} }
@keyframes caretBlink {
0%, 100% {
border-right-color: #fbbf24;
}
50% {
border-right-color: transparent;
}
}
/* Splash screen styles */ /* Splash screen styles */
.splash-complete .login-card { .splash-complete .login-card {
animation: fadeUpIn 900ms cubic-bezier(0.22, 1, 0.36, 1) 120ms both; animation: fadeUpIn 900ms cubic-bezier(0.22, 1, 0.36, 1) 120ms both;
@ -1065,9 +1175,9 @@ body::after {
animation-fill-mode: backwards; animation-fill-mode: backwards;
} }
/* Dashboard: full viewport width, no letterboxing */ /* Dashboard: full viewport width, no letterboxing, no body scroll */
body.dashboard-active { body.dashboard-active {
overflow-x: hidden; overflow: hidden;
width: 100%; width: 100%;
} }
@ -1590,46 +1700,6 @@ html:has(body.video-background-active)::before {
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
} }
/* Toggle switch */
.share-toggle {
position: relative;
display: inline-flex;
width: 2.75rem;
height: 1.5rem;
flex-shrink: 0;
cursor: pointer;
}
.share-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.share-toggle-slider {
position: absolute;
inset: 0;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.15);
transition: background-color 0.2s ease;
}
.share-toggle-slider::before {
content: '';
position: absolute;
width: 1.125rem;
height: 1.125rem;
left: 0.1875rem;
bottom: 0.1875rem;
border-radius: 9999px;
background: white;
transition: transform 0.2s ease;
}
.share-toggle input:checked + .share-toggle-slider {
background: #fb923c;
}
.share-toggle input:checked + .share-toggle-slider::before {
transform: translateX(1.25rem);
}
/* Access type options */ /* Access type options */
.share-access-options { .share-access-options {
display: grid; display: grid;
@ -1823,44 +1893,6 @@ html:has(body.video-background-active)::before {
.monitoring-bar-danger { .monitoring-bar-danger {
background: #ef4444; background: #ef4444;
} }
.monitoring-alert-toggle {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
flex-shrink: 0;
}
.monitoring-alert-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.monitoring-alert-toggle-slider {
position: absolute;
cursor: pointer;
inset: 0;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
transition: background 0.2s ease;
}
.monitoring-alert-toggle-slider::before {
content: '';
position: absolute;
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background: rgba(255, 255, 255, 0.6);
border-radius: 50%;
transition: transform 0.2s ease;
}
.monitoring-alert-toggle input:checked + .monitoring-alert-toggle-slider {
background: rgba(74, 222, 128, 0.4);
}
.monitoring-alert-toggle input:checked + .monitoring-alert-toggle-slider::before {
transform: translateX(16px);
background: #4ade80;
}
.monitoring-threshold-input { .monitoring-threshold-input {
width: 60px; width: 60px;
padding: 4px 8px; padding: 4px 8px;
@ -1872,42 +1904,3 @@ html:has(body.video-background-active)::before {
text-align: right; text-align: right;
} }
/* Toggle switch for Tor services and similar on/off controls */
.tor-toggle-label {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
cursor: pointer;
}
.tor-toggle-input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.tor-toggle-slider {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
transition: background 0.3s ease;
}
.tor-toggle-slider::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 2px;
top: 2px;
background: rgba(255, 255, 255, 0.6);
border-radius: 50%;
transition: transform 0.3s ease, background 0.3s ease;
}
.tor-toggle-input:checked + .tor-toggle-slider {
background: rgba(251, 146, 60, 0.4);
}
.tor-toggle-input:checked + .tor-toggle-slider::before {
transform: translateX(16px);
background: #fb923c;
}

View File

@ -79,6 +79,7 @@ export type PackageState = typeof PackageState[keyof typeof PackageState]
export interface PackageDataEntry { export interface PackageDataEntry {
state: PackageState state: PackageState
health?: string | null // "healthy", "unhealthy", "starting", or null
'static-files'?: { 'static-files'?: {
license: string license: string
instructions: string instructions: string

View File

@ -41,10 +41,10 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <span
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium" class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium"
:class="getStatusClass(pkg.state)" :class="getStatusClass(pkg.state, pkg.health)"
> >
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(pkg.state)"></span> <span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health)"></span>
{{ pkg.state }} {{ getStatusLabel(pkg.state, pkg.health) }}
</span> </span>
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span> <span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
</div> </div>
@ -74,14 +74,15 @@
</button> </button>
<template v-if="!isWebOnly"> <template v-if="!isWebOnly">
<button <button
v-if="pkg.state === 'stopped'" v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
@click="startApp" @click="startApp"
class="px-4 py-2.5 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center gap-2" class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg> </svg>
{{ t('common.start') }} {{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
</button> </button>
<button <button
@click="restartApp" @click="restartApp"
@ -105,7 +106,7 @@
</button> </button>
<button <button
@click="uninstallApp" @click="uninstallApp"
class="px-4 py-2.5 bg-red-600/20 border border-red-600/40 rounded-lg text-red-300 text-sm font-medium hover:bg-red-600/30 transition-colors flex items-center gap-2" class="px-4 py-2.5 glass-button glass-button-danger rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
@ -135,10 +136,10 @@
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span <span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="getStatusClass(pkg.state)" :class="getStatusClass(pkg.state, pkg.health)"
> >
<span class="w-1.5 h-1.5 rounded-full mr-1" :class="getStatusDotClass(pkg.state)"></span> <span class="w-1.5 h-1.5 rounded-full mr-1" :class="getStatusDotClass(pkg.state, pkg.health)"></span>
{{ pkg.state }} {{ getStatusLabel(pkg.state, pkg.health) }}
</span> </span>
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span> <span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
</div> </div>
@ -148,7 +149,7 @@
<button <button
v-if="!isWebOnly" v-if="!isWebOnly"
@click="uninstallApp" @click="uninstallApp"
class="flex-shrink-0 w-10 h-10 rounded-lg bg-red-600/20 border border-red-600/40 text-red-300 hover:bg-red-600/30 transition-colors flex items-center justify-center" class="flex-shrink-0 w-10 h-10 rounded-lg glass-button glass-button-danger transition-colors flex items-center justify-center"
:title="t('common.uninstall')" :title="t('common.uninstall')"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -172,14 +173,15 @@
</button> </button>
<template v-if="!isWebOnly"> <template v-if="!isWebOnly">
<button <button
v-if="pkg.state === 'stopped'" v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
@click="startApp" @click="startApp"
class="px-4 py-2.5 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center justify-center gap-2" class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2"
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg> </svg>
{{ t('common.start') }} {{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
</button> </button>
<button <button
v-if="pkg.state === 'running'" v-if="pkg.state === 'running'"
@ -194,7 +196,7 @@
</button> </button>
<button <button
@click="restartApp" @click="restartApp"
:class="[canLaunch && (pkg.state === 'stopped' || pkg.state === 'running') ? 'col-span-2' : '']" :class="[canLaunch && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'running') ? 'col-span-2' : '']"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center justify-center gap-2" class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center justify-center gap-2"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -447,7 +449,7 @@
</button> </button>
<button <button
@click="confirmUninstall" @click="confirmUninstall"
class="w-full md:w-auto px-6 py-3 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors" class="w-full md:w-auto px-6 py-3 glass-button glass-button-danger rounded-lg text-sm font-medium"
> >
{{ t('common.uninstall') }} {{ t('common.uninstall') }}
</button> </button>
@ -460,7 +462,7 @@
<!-- Action error toast --> <!-- Action error toast -->
<Transition name="fade"> <Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive"> <div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3"> <div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
<span>{{ actionError }}</span> <span>{{ actionError }}</span>
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">&times;</button> <button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div> </div>
@ -898,12 +900,16 @@ async function uninstallApp() {
showUninstallModal() showUninstallModal()
} }
function getStatusClass(state: PackageState): string { function getStatusClass(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200 border border-orange-500/30'
switch (state) { switch (state) {
case PackageState.Running: case PackageState.Running:
return 'bg-green-500/20 text-green-200 border border-green-500/30' return 'bg-green-500/20 text-green-200 border border-green-500/30'
case PackageState.Stopped: case PackageState.Stopped:
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30' return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
case PackageState.Exited:
return 'bg-red-500/20 text-red-200 border border-red-500/30'
case PackageState.Starting: case PackageState.Starting:
case PackageState.Stopping: case PackageState.Stopping:
case PackageState.Restarting: case PackageState.Restarting:
@ -915,12 +921,16 @@ function getStatusClass(state: PackageState): string {
} }
} }
function getStatusDotClass(state: PackageState): string { function getStatusDotClass(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-400 animate-pulse'
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-400 animate-pulse'
switch (state) { switch (state) {
case PackageState.Running: case PackageState.Running:
return 'bg-green-400' return 'bg-green-400'
case PackageState.Stopped: case PackageState.Stopped:
return 'bg-gray-400' return 'bg-gray-400'
case PackageState.Exited:
return 'bg-red-400 animate-pulse'
case PackageState.Starting: case PackageState.Starting:
case PackageState.Stopping: case PackageState.Stopping:
case PackageState.Restarting: case PackageState.Restarting:
@ -931,6 +941,14 @@ function getStatusDotClass(state: PackageState): string {
return 'bg-gray-400' return 'bg-gray-400'
} }
} }
function getStatusLabel(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'starting up'
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
if (state === PackageState.Running && health === 'healthy') return 'healthy'
if (state === PackageState.Exited) return 'crashed'
return state
}
</script> </script>
<style scoped> <style scoped>

View File

@ -555,7 +555,7 @@ function handleBackdropClick() {
function closeSession() { function closeSession() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {}) if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
if (isInlinePanel.value) emit('close') if (isInlinePanel.value) emit('close')
else router.push({ name: 'apps' }) else router.back()
} }
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {

View File

@ -40,7 +40,7 @@
</div> </div>
<!-- Loading Skeleton --> <!-- Loading Skeleton -->
<div v-if="!store.isConnected && sortedPackageEntries.length === 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6"> <div v-if="!store.isConnected && sortedPackageEntries.length === 0 && !connectionError" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
<div v-for="i in 3" :key="i" class="glass-card p-6 animate-pulse"> <div v-for="i in 3" :key="i" class="glass-card p-6 animate-pulse">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="w-16 h-16 rounded-lg bg-white/10"></div> <div class="w-16 h-16 rounded-lg bg-white/10"></div>
@ -56,6 +56,19 @@
</div> </div>
</div> </div>
<!-- Connection Error -->
<div v-else-if="connectionError && sortedPackageEntries.length === 0" class="text-center py-12 pb-6">
<div class="glass-card p-8 max-w-md mx-auto">
<div class="alert-error mb-4">{{ connectionError }}</div>
<button
@click="connectionError = ''; store.connectWebSocket()"
class="glass-button px-6 py-3 rounded-lg font-medium"
>
Retry Connection
</button>
</div>
</div>
<!-- Empty State --> <!-- Empty State -->
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6"> <div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
<div class="glass-card p-12 max-w-md mx-auto"> <div class="glass-card p-12 max-w-md mx-auto">
@ -136,10 +149,10 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <span
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium" class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
:class="getStatusClass(pkg.state)" :class="getStatusClass(pkg.state, pkg.health)"
> >
<svg <svg
v-if="pkg.state === 'starting' || pkg.state === 'installing' || pkg.state === 'stopping' || pkg.state === 'restarting'" v-if="pkg.state === 'starting' || pkg.state === 'installing' || pkg.state === 'stopping' || pkg.state === 'restarting' || (pkg.state === 'running' && pkg.health === 'starting')"
class="animate-spin h-3 w-3" class="animate-spin h-3 w-3"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
@ -148,7 +161,8 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
{{ pkg.state }} <span v-if="pkg.state === 'running' && pkg.health === 'unhealthy'" class="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"></span>
{{ getStatusLabel(pkg.state, pkg.health) }}
</span> </span>
<span class="text-xs text-white/50"> <span class="text-xs text-white/50">
v{{ pkg.manifest.version }} v{{ pkg.manifest.version }}
@ -171,14 +185,14 @@
<button <button
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited')" v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited')"
@click.stop="startApp(id as string)" @click.stop="startApp(id as string)"
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center justify-center gap-2" class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium flex items-center justify-center gap-2"
> >
<span>{{ t('common.start') }}</span> <span>{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}</span>
</button> </button>
<button <button
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')" v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')"
disabled disabled
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2" class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
> >
<svg class="animate-spin h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@ -193,6 +207,16 @@
> >
<span>{{ t('common.stop') }}</span> <span>{{ t('common.stop') }}</span>
</button> </button>
<button
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting')"
@click.stop="restartApp(id as string)"
class="px-2.5 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
:title="t('common.restart')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button <button
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')" v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')"
disabled disabled
@ -249,7 +273,7 @@
<button <button
@click="confirmUninstall" @click="confirmUninstall"
:disabled="uninstalling" :disabled="uninstalling"
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" class="px-4 py-2 glass-button glass-button-danger rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
<svg <svg
v-if="uninstalling" v-if="uninstalling"
@ -274,7 +298,7 @@
<!-- Action error toast --> <!-- Action error toast -->
<Transition name="fade"> <Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive"> <div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3"> <div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
<span>{{ actionError }}</span> <span>{{ actionError }}</span>
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">&times;</button> <button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div> </div>
@ -284,7 +308,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onBeforeUnmount } from 'vue' import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router' import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
@ -377,6 +401,20 @@ const categoriesWithApps = computed(() => {
}) })
// Connection error state show after timeout if backend never connects
const connectionError = ref('')
let connectionTimer: ReturnType<typeof setTimeout> | undefined
onMounted(() => {
if (!store.isConnected) {
connectionTimer = setTimeout(() => {
if (!store.isConnected && sortedPackageEntries.value.length === 0) {
connectionError.value = 'Unable to connect to server. Check that the backend is running.'
}
}, 15000)
}
})
// Track loading states for each app action // Track loading states for each app action
const loadingActions = ref<Record<string, boolean>>({}) const loadingActions = ref<Record<string, boolean>>({})
@ -523,12 +561,16 @@ function launchApp(id: string) {
useAppLauncherStore().openSession(id) useAppLauncherStore().openSession(id)
} }
function getStatusClass(state: PackageState): string { function getStatusClass(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200'
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200'
switch (state) { switch (state) {
case PackageState.Running: case PackageState.Running:
return 'bg-green-500/20 text-green-200' return 'bg-green-500/20 text-green-200'
case PackageState.Stopped: case PackageState.Stopped:
return 'bg-gray-500/20 text-gray-200' return 'bg-gray-500/20 text-gray-200'
case PackageState.Exited:
return 'bg-red-500/20 text-red-200'
case PackageState.Starting: case PackageState.Starting:
case PackageState.Stopping: case PackageState.Stopping:
case PackageState.Restarting: case PackageState.Restarting:
@ -540,6 +582,14 @@ function getStatusClass(state: PackageState): string {
} }
} }
function getStatusLabel(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'starting up'
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
if (state === PackageState.Running && health === 'healthy') return 'healthy'
if (state === PackageState.Exited) return 'crashed'
return state
}
function goToApp(id: string) { function goToApp(id: string) {
router.push(`/dashboard/apps/${id}`).catch(() => {}) router.push(`/dashboard/apps/${id}`).catch(() => {})
} }
@ -578,9 +628,26 @@ async function stopApp(id: string) {
} }
} }
async function restartApp(id: string) {
loadingActions.value[id] = true
try {
await store.restartPackage(id)
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
actionTimers.set(id, setTimeout(() => {
loadingActions.value[id] = false
actionTimers.delete(id)
}, 8000))
} catch (err) {
if (import.meta.env.DEV) console.error('Failed to restart app:', err)
showActionError(`Failed to restart app: ${err instanceof Error ? err.message : 'Unknown error'}`)
loadingActions.value[id] = false
}
}
onBeforeUnmount(() => { onBeforeUnmount(() => {
for (const t of actionTimers.values()) clearTimeout(t) for (const t of actionTimers.values()) clearTimeout(t)
actionTimers.clear() actionTimers.clear()
if (connectionTimer) clearTimeout(connectionTimer)
}) })
@ -638,25 +705,3 @@ function handleImageError(e: Event) {
</script> </script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .glass-card,
.modal-leave-to .glass-card {
transform: scale(0.95);
opacity: 0;
}
</style>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="chat-fullscreen"> <div class="chat-fullscreen">
<!-- Close button (desktop: top-right pill) --> <!-- Close button (desktop: top-right pill) -->
<div class="chat-mode-pill flex"> <div class="chat-mode-pill hidden md:flex">
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat"> <button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />

View File

@ -114,6 +114,11 @@
</div> </div>
</div> </div>
<!-- Error State -->
<div v-if="loadError" class="alert-error mb-4">
{{ loadError }}
</div>
<!-- Not Installed Hint --> <!-- Not Installed Hint -->
<div v-if="!fileBrowserRunning" class="glass-card p-8 mt-6 text-center"> <div v-if="!fileBrowserRunning" class="glass-card p-8 mt-6 text-center">
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p> <p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
@ -146,6 +151,7 @@ interface PeerNode {
const peerNodes = ref<PeerNode[]>([]) const peerNodes = ref<PeerNode[]>([])
const peersLoading = ref(true) const peersLoading = ref(true)
const loadError = ref('')
const APP_ALIASES: Record<string, string[]> = { const APP_ALIASES: Record<string, string[]> = {
immich: ['immich_server', 'immich-server'], immich: ['immich_server', 'immich-server'],
@ -244,7 +250,8 @@ async function loadCounts() {
} }
} }
} catch (e) { } catch (e) {
if (import.meta.env.DEV) console.warn('FileBrowser count loading failed silently', e) loadError.value = e instanceof Error ? e.message : 'Failed to load file counts'
if (import.meta.env.DEV) console.warn('FileBrowser count loading failed', e)
} finally { } finally {
countsLoading.value = false countsLoading.value = false
} }
@ -260,8 +267,9 @@ async function loadPeers() {
try { try {
const result = await rpcClient.federationListNodes() const result = await rpcClient.federationListNodes()
peerNodes.value = result?.nodes ?? [] peerNodes.value = result?.nodes ?? []
} catch { } catch (e) {
peerNodes.value = [] peerNodes.value = []
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
} finally { } finally {
peersLoading.value = false peersLoading.value = false
} }

View File

@ -69,6 +69,12 @@
</RouterLink> </RouterLink>
</div> </div>
<!-- Cloud Store Error -->
<div v-else-if="cloudStore.error" class="glass-card p-6 flex-1 flex flex-col items-center justify-center text-center">
<div class="alert-error mb-4">{{ cloudStore.error }}</div>
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="cloudStore.refresh()">Retry</button>
</div>
<!-- Native File Browser (for FileBrowser-backed sections) --> <!-- Native File Browser (for FileBrowser-backed sections) -->
<div <div
v-else-if="useNativeUI" v-else-if="useNativeUI"

View File

@ -86,7 +86,10 @@
@click="appLauncher.closePanel()" @click="appLauncher.closePanel()"
:style="{ '--nav-stagger': idx }" :style="{ '--nav-stagger': idx }"
> >
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg v-if="item.icon === 'web5'" class="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 1631 1624">
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" />
</svg>
<svg v-else class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
v-for="(path, index) in getIconPath(item.icon)" v-for="(path, index) in getIconPath(item.icon)"
:key="index" :key="index"
@ -247,7 +250,8 @@
<div :key="route.path" class="view-wrapper"> <div :key="route.path" class="view-wrapper">
<div <div
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'" v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
class="h-full" :class="['h-full', mobileTabPaddingTop ? 'overflow-y-auto' : '']"
:style="mobileTabPaddingTop ? { paddingTop: (mobileTabPaddingTop + 16) + 'px' } : undefined"
> >
<component :is="Component" /> <component :is="Component" />
</div> </div>
@ -304,7 +308,10 @@
}" }"
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'" :exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
> >
<svg class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg v-if="item.icon === 'web5'" class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="currentColor" viewBox="0 0 1631 1624">
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" />
</svg>
<svg v-else class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
v-for="(path, index) in getIconPath(item.icon)" v-for="(path, index) in getIconPath(item.icon)"
:key="index" :key="index"

View File

@ -304,7 +304,7 @@
<div v-if="!confirmRemove"> <div v-if="!confirmRemove">
<button <button
@click="confirmRemove = true" @click="confirmRemove = true"
class="w-full mt-4 px-4 py-2 rounded text-sm text-red-400 border border-red-400/30 hover:bg-red-400/10 transition-colors" class="w-full mt-4 px-4 py-2 rounded text-sm glass-button glass-button-danger transition-colors"
> >
Remove from Federation Remove from Federation
</button> </button>
@ -318,7 +318,7 @@
>Cancel</button> >Cancel</button>
<button <button
@click="removeNode(selectedNode!.did)" @click="removeNode(selectedNode!.did)"
class="flex-1 px-3 py-1.5 rounded text-sm text-red-400 border border-red-400/30 hover:bg-red-400/10 transition-colors font-medium" class="flex-1 px-3 py-1.5 rounded text-sm glass-button glass-button-danger transition-colors font-medium"
>Confirm Remove</button> >Confirm Remove</button>
</div> </div>
</div> </div>

View File

@ -80,6 +80,15 @@
</div> </div>
</div> </div>
<!-- App icon -->
<img
v-if="stepIconUrl(step)"
:src="stepIconUrl(step)"
:alt="step.title"
class="w-7 h-7 rounded-md object-contain shrink-0 mt-0.5"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
<!-- Step content --> <!-- Step content -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-white/90 mb-1">{{ step.title }}</h3> <h3 class="text-base font-semibold text-white/90 mb-1">{{ step.title }}</h3>
@ -141,7 +150,7 @@
<!-- Action error toast --> <!-- Action error toast -->
<Transition name="fade"> <Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive"> <div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3"> <div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
<span>{{ actionError }}</span> <span>{{ actionError }}</span>
<button @click="actionError = ''" class="text-red-300 hover:text-white shrink-0">&times;</button> <button @click="actionError = ''" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div> </div>
@ -159,6 +168,24 @@ import { useGoalStore } from '@/stores/goals'
import { getGoalById } from '@/data/goals' import { getGoalById } from '@/data/goals'
import type { GoalStep } from '@/types/goals' import type { GoalStep } from '@/types/goals'
/** Map appId to its icon file path under /assets/img/app-icons/ */
const APP_ICON_MAP: Record<string, string> = {
'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp',
lnd: '/assets/img/app-icons/lnd.svg',
'btcpay-server': '/assets/img/app-icons/btcpay-server.png',
immich: '/assets/img/app-icons/immich.png',
nextcloud: '/assets/img/app-icons/nextcloud.webp',
fedimint: '/assets/img/app-icons/fedimint.png',
mempool: '/assets/img/app-icons/mempool.webp',
electrs: '/assets/img/app-icons/electrs.svg',
'nostr-rs-relay': '/assets/img/app-icons/nostr-rs-relay.svg',
}
function stepIconUrl(step: GoalStep): string | undefined {
if (!step.appId) return undefined
return APP_ICON_MAP[step.appId]
}
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

View File

@ -12,7 +12,7 @@
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="kiosk-status-pill" :class="isConnected ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'"> <div class="kiosk-status-pill" :class="isConnected ? 'status-success' : 'status-error'">
<div class="w-2 h-2 rounded-full" :class="isConnected ? 'bg-green-400' : 'bg-red-400'"></div> <div class="w-2 h-2 rounded-full" :class="isConnected ? 'bg-green-400' : 'bg-red-400'"></div>
{{ isConnected ? t('kiosk.online') : t('kiosk.offline') }} {{ isConnected ? t('kiosk.online') : t('kiosk.offline') }}
</div> </div>

View File

@ -47,7 +47,7 @@
<button @click="refreshDiagnostics" class="glass-button px-4 py-2 rounded-lg text-sm flex-1"> <button @click="refreshDiagnostics" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">
{{ t('common.refresh') }} {{ t('common.refresh') }}
</button> </button>
<button @click="goToLogin" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30"> <button @click="goToLogin" class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex-1">
{{ t('kioskRecovery.goToLogin') }} {{ t('kioskRecovery.goToLogin') }}
</button> </button>
</div> </div>

View File

@ -204,6 +204,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { isLocalRedirect } from '../router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import AnimatedLogo from '@/components/AnimatedLogo.vue' import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
@ -217,7 +218,11 @@ const router = useRouter()
const currentRoute = useRoute() const currentRoute = useRoute()
/** After login, redirect to the intended page or default to home */ /** After login, redirect to the intended page or default to home */
const loginRedirectTo = computed(() => (currentRoute.query.redirect as string) || '/dashboard') const loginRedirectTo = computed(() => {
const redirect = currentRoute.query.redirect as string
if (redirect && isLocalRedirect(redirect)) return redirect
return '/dashboard'
})
const store = useAppStore() const store = useAppStore()
const loginTransition = useLoginTransitionStore() const loginTransition = useLoginTransitionStore()

View File

@ -116,6 +116,12 @@
<!-- Scrollable Apps Section --> <!-- Scrollable Apps Section -->
<div class="pb-8"> <div class="pb-8">
<!-- Community Load Error -->
<div v-if="communityError" class="alert-error mb-4">
{{ communityError }}
<button @click="loadCommunityMarketplace()" class="ml-2 underline hover:no-underline">Retry</button>
</div>
<!-- Apps Grid --> <!-- Apps Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div <div
@ -1288,30 +1294,6 @@ function handleImageError(event: Event) {
overflow: hidden; overflow: hidden;
} }
/* Modal transition animations */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease;
}
.modal-enter-from .glass-card {
transform: scale(0.95);
}
.modal-leave-to .glass-card {
transform: scale(0.95);
}
/* Custom scrollbar styling for apps section */ /* Custom scrollbar styling for apps section */
.marketplace-container ::-webkit-scrollbar { .marketplace-container ::-webkit-scrollbar {
width: 8px; width: 8px;

View File

@ -4,11 +4,21 @@ import { useMeshStore } from '@/stores/mesh'
import { useTransportStore } from '@/stores/transport' import { useTransportStore } from '@/stores/transport'
import type { MeshPeer, SessionStatus } from '@/stores/mesh' import type { MeshPeer, SessionStatus } from '@/stores/mesh'
import AnimatedLogo from '@/components/AnimatedLogo.vue' import AnimatedLogo from '@/components/AnimatedLogo.vue'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
const mesh = useMeshStore() const mesh = useMeshStore()
const transport = useTransportStore() const transport = useTransportStore()
// Responsive layout breakpoints
const isWideDesktop = ref(window.innerWidth >= 1536)
const isMobile = ref(window.innerWidth < 1280)
function handleResize() {
isWideDesktop.value = window.innerWidth >= 1536
isMobile.value = window.innerWidth < 1280
}
// Active chat: either a peer or a channel // Active chat: either a peer or a channel
const activeChatPeer = ref<MeshPeer | null>(null) const activeChatPeer = ref<MeshPeer | null>(null)
const activeChatChannel = ref<{ index: number; name: string } | null>(null) const activeChatChannel = ref<{ index: number; name: string } | null>(null)
@ -18,6 +28,7 @@ const broadcasting = ref(false)
const configuring = ref(false) const configuring = ref(false)
const connectingDevice = ref<string | null>(null) const connectingDevice = ref<string | null>(null)
const chatScrollEl = ref<HTMLElement | null>(null) const chatScrollEl = ref<HTMLElement | null>(null)
const mobileShowChat = ref(false)
let pollInterval: ReturnType<typeof setInterval> | null = null let pollInterval: ReturnType<typeof setInterval> | null = null
// The Public channel (always available on Meshcore) // The Public channel (always available on Meshcore)
@ -43,6 +54,27 @@ const deadmanInterval = ref('21600')
const deadmanEnabled = ref(false) const deadmanEnabled = ref(false)
const deadmanCustomMsg = ref('') const deadmanCustomMsg = ref('')
// Tools tab for 3rd column on wide desktop and mobile below-chat
const toolsTab = ref<'bitcoin' | 'deadman'>('bitcoin')
// Panel visibility computeds
const showChatPanel = computed(() =>
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
)
// On wide desktop + mobile first view: tools use their own tab bar
const showBitcoinPanel = computed(() => {
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'bitcoin'
return activeTab.value === 'bitcoin'
})
const showDeadmanPanel = computed(() => {
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'deadman'
return activeTab.value === 'deadman'
})
// Mobile tools: show on first view (peers), hide when in chat
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value)
// Medium desktop: show 3-tab bar. Wide + mobile: hidden (tools has own tab bar)
const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value)
// Fetch session status when active peer changes // Fetch session status when active peer changes
watch(() => activeChatPeer.value, async (peer) => { watch(() => activeChatPeer.value, async (peer) => {
if (peer) { if (peer) {
@ -184,6 +216,7 @@ function formatTimeRemaining(secs: number): string {
} }
onMounted(async () => { onMounted(async () => {
window.addEventListener('resize', handleResize)
await Promise.all([mesh.refreshAll(), transport.fetchStatus()]) await Promise.all([mesh.refreshAll(), transport.fetchStatus()])
// Sync deadman UI state from server // Sync deadman UI state from server
if (mesh.deadmanStatus) { if (mesh.deadmanStatus) {
@ -200,6 +233,7 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', handleResize)
if (pollInterval) clearInterval(pollInterval) if (pollInterval) clearInterval(pollInterval)
}) })
@ -253,6 +287,7 @@ function openChat(peer: MeshPeer) {
sendError.value = '' sendError.value = ''
messageText.value = '' messageText.value = ''
activeTab.value = 'chat' activeTab.value = 'chat'
mobileShowChat.value = true
mesh.markChatRead(peer.contact_id) mesh.markChatRead(peer.contact_id)
nextTick(() => scrollChatToBottom()) nextTick(() => scrollChatToBottom())
} }
@ -263,12 +298,14 @@ function openChannelChat(channel: { index: number; name: string }) {
sendError.value = '' sendError.value = ''
messageText.value = '' messageText.value = ''
activeTab.value = 'chat' activeTab.value = 'chat'
mobileShowChat.value = true
nextTick(() => scrollChatToBottom()) nextTick(() => scrollChatToBottom())
} }
function closeChat() { function closeChat() {
activeChatPeer.value = null activeChatPeer.value = null
activeChatChannel.value = null activeChatChannel.value = null
mobileShowChat.value = false
mesh.clearViewingChat() mesh.clearViewingChat()
} }
@ -360,10 +397,10 @@ function truncatePubkey(hex: string | null): string {
<!-- Error banner --> <!-- Error banner -->
<div v-if="mesh.error" class="mesh-error">{{ mesh.error }}</div> <div v-if="mesh.error" class="mesh-error">{{ mesh.error }}</div>
<!-- Two-column layout (desktop) / single-column (mobile) --> <!-- Responsive column layout: 3-col (wide), 2-col (medium), 1-col (mobile) -->
<div class="mesh-columns"> <div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop }">
<!-- LEFT COLUMN: Status + Peers --> <!-- LEFT COLUMN: Status + Peers -->
<div class="mesh-left"> <div class="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
<!-- Device Status --> <!-- Device Status -->
<div class="glass-card mesh-status-card"> <div class="glass-card mesh-status-card">
<div class="mesh-status-header"> <div class="mesh-status-header">
@ -499,9 +536,9 @@ function truncatePubkey(hex: string | null): string {
</div> </div>
<!-- RIGHT COLUMN: Tabbed panels --> <!-- RIGHT COLUMN: Tabbed panels -->
<div class="mesh-right"> <div class="mesh-right" :class="{ 'mobile-hidden': !mobileShowChat }">
<!-- Tab bar --> <!-- Tab bar (medium desktop only) -->
<div class="mesh-tab-bar"> <div v-if="showTabBar" class="mesh-tab-bar">
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button> <button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
<button class="mesh-tab" :class="{ active: activeTab === 'bitcoin' }" @click="activeTab = 'bitcoin'"> <button class="mesh-tab" :class="{ active: activeTab === 'bitcoin' }" @click="activeTab = 'bitcoin'">
Off-Grid Bitcoin Off-Grid Bitcoin
@ -513,146 +550,8 @@ function truncatePubkey(hex: string | null): string {
</button> </button>
</div> </div>
<!-- Off-Grid Bitcoin Panel --> <!-- Chat Panel (before tools so it gets grid-column 2 on wide) -->
<div v-if="activeTab === 'bitcoin'" class="glass-card mesh-bitcoin-panel"> <div v-if="showChatPanel" class="glass-card mesh-chat-card">
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
<!-- Relay status notification -->
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') || relayResult.includes('Failed') ? 'error' : 'success'">
{{ relayResult }}
</div>
<!-- Block Headers -->
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
<span class="mesh-bitcoin-label">Latest Block</span>
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
</div>
<div v-if="mesh.blockHeaders.length" class="mesh-block-list">
<div v-for="h in mesh.blockHeaders.slice(0, 2)" :key="h.height" class="mesh-block-row">
<span class="mesh-block-height">#{{ h.height.toLocaleString() }}</span>
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
</div>
</div>
</div>
<!-- On-Chain / Lightning tabs -->
<div class="mesh-send-tabs">
<button class="mesh-send-tab" :class="{ active: sendTab === 'onchain' }" @click="sendTab = 'onchain'">On-Chain</button>
<button class="mesh-send-tab" :class="{ active: sendTab === 'lightning' }" @click="sendTab = 'lightning'">Lightning</button>
</div>
<!-- On-Chain tab -->
<div v-if="sendTab === 'onchain'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
{{ relayingTx ? 'Sending...' : 'Send via Mesh' }}
</button>
<details class="mesh-bitcoin-advanced">
<summary class="mesh-bitcoin-label">Raw TX Relay</summary>
<div style="margin-top: 8px;">
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
</button>
</div>
</details>
</div>
<!-- Lightning tab -->
<div v-if="sendTab === 'lightning'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
</button>
</div>
</div>
<!-- Dead Man's Switch Panel -->
<div v-if="activeTab === 'deadman'" class="glass-card mesh-deadman-panel">
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
<!-- Status -->
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
</div>
<div v-if="mesh.deadmanStatus.dead_man_enabled && !mesh.deadmanStatus.triggered" class="mesh-deadman-timer">
{{ formatTimeRemaining(mesh.deadmanStatus.time_remaining_secs) }}
</div>
<div v-if="deadmanCustomMsg || mesh.deadmanStatus.dead_man_enabled" class="mesh-deadman-message">
{{ deadmanCustomMsg || 'Dead man\'s switch triggered — operator unresponsive' }}
</div>
<button v-if="mesh.deadmanStatus.dead_man_enabled" class="glass-button mesh-deadman-checkin-btn" @click="handleDeadmanCheckin">
I'm OK Check In
</button>
</div>
<!-- Configuration -->
<div class="mesh-deadman-config">
<label class="mesh-deadman-toggle">
<input v-model="deadmanEnabled" type="checkbox" @change="handleDeadmanToggle" />
<span>{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</span>
</label>
<template v-if="deadmanEnabled">
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Trigger Interval</label>
<select v-model="deadmanInterval" class="mesh-bitcoin-input mesh-bitcoin-input-sm">
<option value="3600">1 hour</option>
<option value="21600">6 hours</option>
<option value="43200">12 hours</option>
<option value="86400">24 hours</option>
</select>
</div>
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Alert Message</label>
<input v-model="deadmanCustomMsg" class="mesh-bitcoin-input" placeholder="Dead man's switch triggered — operator unresponsive" />
</div>
<div class="mesh-deadman-info">
<span v-if="mesh.deadmanStatus?.has_gps" class="mesh-deadman-info-item">GPS: included</span>
<span class="mesh-deadman-info-item">Contacts: {{ mesh.deadmanStatus?.emergency_contacts ?? 0 }}</span>
</div>
<button class="glass-button" :disabled="deadmanConfiguring" @click="handleDeadmanConfigure">
{{ deadmanConfiguring ? 'Saving...' : 'Save Configuration' }}
</button>
</template>
</div>
</div>
<!-- Chat Panel (existing) -->
<div v-if="activeTab === 'chat'" class="glass-card mesh-chat-card">
<!-- No chat selected --> <!-- No chat selected -->
<div v-if="!hasActiveChat" class="mesh-chat-empty"> <div v-if="!hasActiveChat" class="mesh-chat-empty">
<div class="mesh-chat-empty-icon">&#x1F4E1;</div> <div class="mesh-chat-empty-icon">&#x1F4E1;</div>
@ -758,12 +657,228 @@ function truncatePubkey(hex: string | null): string {
{{ mesh.sending ? '...' : 'Send' }} {{ mesh.sending ? '...' : 'Send' }}
</button> </button>
</div> </div>
<div class="mesh-chat-compose-meta">
<span>{{ messageText.length }}/160</span>
</div>
</div> </div>
</template> </template>
</div> </div>
<!-- Off-Grid Bitcoin + Dead Man's Switch panels -->
<div class="mesh-tools-wrapper">
<!-- Tools tab bar (wide desktop only mobile has its own outside mesh-right) -->
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
Off-Grid Bitcoin
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
</div>
<!-- Off-Grid Bitcoin Panel -->
<div v-if="showBitcoinPanel" class="glass-card mesh-bitcoin-panel">
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
<!-- Relay status notification -->
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') || relayResult.includes('Failed') ? 'error' : 'success'">
{{ relayResult }}
</div>
<!-- Block Headers -->
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
<span class="mesh-bitcoin-label">Latest Block</span>
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
</div>
<div v-if="mesh.blockHeaders.length" class="mesh-block-list">
<div v-for="h in mesh.blockHeaders.slice(0, 2)" :key="h.height" class="mesh-block-row">
<span class="mesh-block-height">#{{ h.height.toLocaleString() }}</span>
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
</div>
</div>
</div>
<!-- On-Chain / Lightning tabs -->
<div class="mesh-send-tabs">
<button class="mesh-send-tab" :class="{ active: sendTab === 'onchain' }" @click="sendTab = 'onchain'">On-Chain</button>
<button class="mesh-send-tab" :class="{ active: sendTab === 'lightning' }" @click="sendTab = 'lightning'">Lightning</button>
</div>
<!-- On-Chain tab -->
<div v-if="sendTab === 'onchain'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
{{ relayingTx ? 'Sending...' : 'Send via Mesh' }}
</button>
<details class="mesh-bitcoin-advanced">
<summary class="mesh-bitcoin-label">Raw TX Relay</summary>
<div style="margin-top: 8px;">
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
</button>
</div>
</details>
</div>
<!-- Lightning tab -->
<div v-if="sendTab === 'lightning'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
</button>
</div>
</div>
<!-- Dead Man's Switch Panel -->
<div v-if="showDeadmanPanel" class="glass-card mesh-deadman-panel">
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
<!-- Status -->
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
</div>
<div v-if="mesh.deadmanStatus.dead_man_enabled && !mesh.deadmanStatus.triggered" class="mesh-deadman-timer">
{{ formatTimeRemaining(mesh.deadmanStatus.time_remaining_secs) }}
</div>
<div v-if="deadmanCustomMsg || mesh.deadmanStatus.dead_man_enabled" class="mesh-deadman-message">
{{ deadmanCustomMsg || 'Dead man\'s switch triggered — operator unresponsive' }}
</div>
<button v-if="mesh.deadmanStatus.dead_man_enabled" class="glass-button mesh-deadman-checkin-btn" @click="handleDeadmanCheckin">
I'm OK Check In
</button>
</div>
<!-- Configuration -->
<div class="mesh-deadman-config">
<button
@click="deadmanEnabled = !deadmanEnabled; handleDeadmanToggle()"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left mb-3"
:class="deadmanEnabled
? 'bg-white/10 border-orange-500/40'
: 'bg-black/20 border-white/10 hover:border-white/20'"
>
<svg class="w-5 h-5 shrink-0" :class="deadmanEnabled ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="deadmanEnabled ? 'text-white/95' : 'text-white/70'">{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</p>
<p class="text-xs text-white/50 mt-0.5">Auto-alerts your contacts if you don't check in</p>
</div>
<ToggleSwitch :model-value="deadmanEnabled" @click.stop @update:model-value="deadmanEnabled = $event; handleDeadmanToggle()" />
</button>
<template v-if="deadmanEnabled">
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Trigger Interval</label>
<select v-model="deadmanInterval" class="mesh-bitcoin-input mesh-bitcoin-input-sm">
<option value="3600">1 hour</option>
<option value="21600">6 hours</option>
<option value="43200">12 hours</option>
<option value="86400">24 hours</option>
</select>
</div>
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Alert Message</label>
<input v-model="deadmanCustomMsg" class="mesh-bitcoin-input" placeholder="Dead man's switch triggered — operator unresponsive" />
</div>
<div class="mesh-deadman-info">
<span v-if="mesh.deadmanStatus?.has_gps" class="mesh-deadman-info-item">GPS: included</span>
<span class="mesh-deadman-info-item">Contacts: {{ mesh.deadmanStatus?.emergency_contacts ?? 0 }}</span>
</div>
<button class="glass-button" :disabled="deadmanConfiguring" @click="handleDeadmanConfigure">
{{ deadmanConfiguring ? 'Saving...' : 'Save Configuration' }}
</button>
</template>
</div>
</div>
</div><!-- /.mesh-tools-wrapper -->
</div>
<!-- Mobile tools: show under peers list on first view -->
<div v-if="showMobileTools" class="mesh-mobile-tools">
<div class="mesh-tools-tab-bar">
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
Off-Grid Bitcoin
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
</div>
<div v-if="showBitcoinPanel" class="glass-card mesh-bitcoin-panel">
<!-- Reuse same content via a shared approach - for now inline -->
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
<span class="mesh-bitcoin-label">Latest Block</span>
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
</div>
</div>
</div>
<div v-if="showDeadmanPanel" class="glass-card mesh-deadman-panel">
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
</div>
</div>
<div class="mesh-deadman-config">
<button
@click="deadmanEnabled = !deadmanEnabled; handleDeadmanToggle()"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left"
:class="deadmanEnabled
? 'bg-white/10 border-orange-500/40'
: 'bg-black/20 border-white/10 hover:border-white/20'"
>
<svg class="w-5 h-5 shrink-0" :class="deadmanEnabled ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="deadmanEnabled ? 'text-white/95' : 'text-white/70'">{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</p>
<p class="text-xs text-white/50 mt-0.5">Auto-alerts your contacts if you don't check in</p>
</div>
<ToggleSwitch :model-value="deadmanEnabled" @click.stop @update:model-value="deadmanEnabled = $event; handleDeadmanToggle()" />
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -772,7 +887,7 @@ function truncatePubkey(hex: string | null): string {
<style scoped> <style scoped>
.mesh-view { .mesh-view {
padding: 24px; padding: 24px;
max-width: 1200px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -873,6 +988,65 @@ function truncatePubkey(hex: string | null): string {
overflow: hidden; overflow: hidden;
} }
/* Tools wrapper: holds Off-Grid Bitcoin + Dead Man panels */
.mesh-tools-wrapper {
display: contents;
}
/* Tools tab bar: hidden by default (medium desktop uses main tab bar) */
.mesh-tools-tab-bar {
display: none;
}
/* ─── Wide desktop: 3-column layout ─── */
.mesh-columns-wide {
display: grid;
grid-template-columns: 340px 1fr 1fr;
gap: 16px;
}
.mesh-columns-wide .mesh-left {
grid-column: 1;
width: auto;
}
.mesh-columns-wide .mesh-right {
display: contents;
}
.mesh-columns-wide .mesh-chat-card {
grid-column: 2;
grid-row: 1;
min-height: 0;
overflow: hidden;
}
.mesh-columns-wide .mesh-tools-wrapper {
grid-column: 3;
grid-row: 1;
display: flex;
flex-direction: column;
gap: 0;
min-height: 0;
overflow-y: auto;
}
.mesh-columns-wide .mesh-tools-tab-bar {
display: flex;
gap: 2px;
background: rgba(0,0,0,0.3);
border-radius: 10px;
padding: 3px;
flex-shrink: 0;
margin-bottom: 12px;
}
/* Hide main tab bar and mobile back button on wide desktop */
.mesh-columns-wide .mesh-mobile-back-btn,
.mesh-columns-wide .mesh-tab-bar {
display: none;
}
/* ─── Status card ─── */ /* ─── Status card ─── */
.mesh-status-card { padding: 16px; flex-shrink: 0; } .mesh-status-card { padding: 16px; flex-shrink: 0; }
@ -1408,12 +1582,17 @@ function truncatePubkey(hex: string | null): string {
margin-top: 4px; margin-top: 4px;
} }
/* ─── Mobile: keep single column ─── */ /* ─── Mobile back button (hidden on desktop) ─── */
@media (max-width: 768px) { .mesh-mobile-back-btn {
display: none;
}
/* ─── Mobile: single column with panel switching ─── */
@media (max-width: 1279px) {
.mesh-view { .mesh-view {
height: auto; height: auto;
overflow: visible; overflow: visible;
padding: 0 12px 12px 12px; padding: 0 12px 100px 12px; /* bottom padding clears tab bar */
} }
.mesh-columns { .mesh-columns {
@ -1427,7 +1606,44 @@ function truncatePubkey(hex: string | null): string {
} }
.mesh-right { .mesh-right {
min-height: 400px; min-height: auto;
overflow: visible;
}
/* Chat takes available viewport height minus tab bars */
.mesh-chat-card {
min-height: 60dvh;
max-height: 75dvh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Hide tools-wrapper inside mesh-right (shown via mesh-mobile-tools instead) */
.mesh-tools-wrapper {
display: none !important;
}
/* Tools section under peers — fixed height so no jump on tab switch */
.mesh-mobile-tools {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.mesh-mobile-tools .mesh-tools-tab-bar {
display: flex;
gap: 2px;
background: rgba(0,0,0,0.3);
border-radius: 10px;
padding: 3px;
}
/* Fixed-height panel container so switching tabs doesn't resize */
.mesh-mobile-tools .mesh-bitcoin-panel,
.mesh-mobile-tools .mesh-deadman-panel {
min-height: 320px;
} }
.mesh-status-grid { .mesh-status-grid {
@ -1437,6 +1653,23 @@ function truncatePubkey(hex: string | null): string {
.mesh-chat-back { .mesh-chat-back {
display: block; display: block;
} }
/* Hide panel on mobile when toggled */
.mobile-hidden {
display: none !important;
}
/* Bitcoin and deadman panels should not flex-grow on mobile */
.mesh-bitcoin-panel,
.mesh-deadman-panel {
flex: none;
cursor: pointer;
flex-shrink: 0;
}
.mesh-mobile-back-btn:hover {
color: rgba(255, 255, 255, 0.9);
}
} }
/* ─── Session badge ─── */ /* ─── Session badge ─── */
@ -1571,7 +1804,7 @@ function truncatePubkey(hex: string | null): string {
} }
.mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.3); } .mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.3); }
.mesh-bitcoin-input:focus { outline: none; border-color: rgba(251,146,60,0.4); } .mesh-bitcoin-input:focus { outline: none; border-color: rgba(251,146,60,0.4); }
.mesh-bitcoin-input-sm { max-width: 200px; } .mesh-bitcoin-input-sm { width: 100%; }
.mesh-relay-mode { .mesh-relay-mode {
display: flex; display: flex;
gap: 8px; gap: 8px;

View File

@ -102,14 +102,7 @@
:key="rule.kind" :key="rule.kind"
class="flex items-center gap-3 p-3 bg-white/5 rounded-lg" class="flex items-center gap-3 p-3 bg-white/5 rounded-lg"
> >
<label class="monitoring-alert-toggle"> <ToggleSwitch :model-value="rule.enabled" @update:model-value="toggleAlertRule(rule.kind, $event)" />
<input
type="checkbox"
:checked="rule.enabled"
@change="toggleAlertRule(rule.kind, !rule.enabled)"
/>
<span class="monitoring-alert-toggle-slider"></span>
</label>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm text-white">{{ ruleLabel(rule.kind) }}</p> <p class="text-sm text-white">{{ ruleLabel(rule.kind) }}</p>
<p class="text-xs text-white/40">{{ rule.description }}</p> <p class="text-xs text-white/40">{{ rule.description }}</p>
@ -223,6 +216,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import LineChart from '@/components/LineChart.vue' import LineChart from '@/components/LineChart.vue'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import type { ChartDataset } from '@/components/LineChart.vue' import type { ChartDataset } from '@/components/LineChart.vue'
interface SystemMetrics { interface SystemMetrics {

View File

@ -47,7 +47,7 @@
<!-- Error --> <!-- Error -->
<div v-else-if="catalogError" class="glass-card p-6"> <div v-else-if="catalogError" class="glass-card p-6">
<p class="text-red-400 text-sm mb-3">{{ catalogError }}</p> <div class="alert-error mb-4">{{ catalogError }}</div>
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="loadCatalog">Retry</button> <button class="glass-button px-4 py-2 rounded-lg text-sm" @click="loadCatalog">Retry</button>
</div> </div>

View File

@ -88,16 +88,7 @@
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p> <p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
</div> </div>
</div> </div>
<button <ToggleSwitch v-model="autoSyncEnabled" />
@click="toggleAutoSync"
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors shrink-0"
:class="autoSyncEnabled ? 'bg-green-500' : 'bg-white/20'"
>
<span
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform shadow"
:class="autoSyncEnabled ? 'translate-x-7' : 'translate-x-1'"
/>
</button>
</div> </div>
</div> </div>
@ -123,7 +114,7 @@
</div> </div>
<!-- Overview Cards --> <!-- Overview Cards -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
<!-- Local Network Card --> <!-- Local Network Card -->
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col"> <div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
<div class="flex items-start gap-4 mb-4 shrink-0"> <div class="flex items-start gap-4 mb-4 shrink-0">
@ -284,92 +275,86 @@
</div> </div>
</div> </div>
<!-- Network Interfaces --> <div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
<div class="glass-card p-6 mb-6"> <!-- Network Interfaces -->
<div class="flex items-center justify-between mb-4"> <div class="glass-card p-6">
<div> <div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2> <div>
<p class="text-sm text-white/60">Detected hardware and virtual interfaces</p> <h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
</div> <p class="text-sm text-white/60">Detected hardware and virtual interfaces</p>
<button </div>
v-if="wifiAvailable" <button
@click="showWifiModal = true" v-if="wifiAvailable"
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors" @click="showWifiModal = true"
> class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
Scan WiFi
</button>
</div>
<template v-if="interfacesLoading">
<div class="space-y-3">
<div v-for="i in 3" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-14"></div>
</div>
</template>
<template v-else>
<div class="space-y-3">
<div
v-for="iface in physicalInterfaces"
:key="iface.name"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
> >
<div class="flex items-center gap-3"> Scan WiFi
<div class="w-2 h-2 rounded-full" :class="iface.state === 'up' ? 'bg-green-400' : 'bg-white/30'"></div> </button>
<div> </div>
<p class="text-sm font-medium text-white">{{ iface.name }}</p>
<p class="text-xs text-white/50">{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} &middot; {{ iface.mac }}</p> <template v-if="interfacesLoading">
<div class="space-y-3">
<div v-for="i in 3" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-14"></div>
</div>
</template>
<template v-else>
<div class="space-y-3">
<div
v-for="iface in physicalInterfaces"
:key="iface.name"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="iface.state === 'up' ? 'bg-green-400' : 'bg-white/30'"></div>
<div>
<p class="text-sm font-medium text-white">{{ iface.name }}</p>
<p class="text-xs text-white/50">{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} &middot; {{ iface.mac }}</p>
</div>
</div>
<div class="text-right">
<p v-if="iface.ipv4.length > 0" class="text-sm text-white/80">{{ iface.ipv4[0] }}</p>
<p v-else class="text-sm text-white/40">No IP</p>
</div> </div>
</div> </div>
<div class="text-right"> <p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
<p v-if="iface.ipv4.length > 0" class="text-sm text-white/80">{{ iface.ipv4[0] }}</p>
<p v-else class="text-sm text-white/40">No IP</p>
</div>
</div> </div>
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p> </template>
</div>
</template>
</div>
<!-- Tor Services -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white/96">Tor Services</h2>
<p class="text-sm text-white/60 mt-1">Manage hidden service addresses for your node and apps</p>
</div>
<button @click="loadTorServices" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
</div> </div>
<div v-if="torServicesLoading && torServices.length === 0" class="text-sm text-white/40 py-4 text-center">Loading Tor services...</div>
<div v-else-if="torServices.length === 0" class="text-sm text-white/40 py-4 text-center">No Tor services configured</div> <!-- Tor Services -->
<div v-else class="space-y-2"> <div class="glass-card p-6">
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3"> <div class="flex items-center justify-between mb-4">
<div class="flex-1 min-w-0"> <div>
<p class="text-white text-sm font-medium">{{ svc.name }}</p> <h2 class="text-xl font-semibold text-white/96">Tor Services</h2>
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="copyTorAddress(svc.onion_address)">{{ svc.onion_address }}</p> <p class="text-sm text-white/60 mt-1">Manage hidden service addresses for your node and apps</p>
<p v-else class="text-white/30 text-xs">No .onion address</p>
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <button @click="loadTorServices" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<button <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
v-if="svc.onion_address && svc.enabled" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
@click="rotateService(svc.name)" </svg>
:disabled="torRotating === svc.name" Refresh
class="glass-button px-3 py-1.5 rounded-lg text-xs" </button>
> </div>
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }} <div v-if="torServicesLoading && torServices.length === 0" class="text-sm text-white/40 py-4 text-center">Loading Tor services...</div>
</button> <div v-else-if="torServices.length === 0" class="text-sm text-white/40 py-4 text-center">No Tor services configured</div>
<label class="tor-toggle-label"> <div v-else class="space-y-2">
<input <div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
type="checkbox" <div class="flex-1 min-w-0">
:checked="svc.enabled" <p class="text-white text-sm font-medium">{{ svc.name }}</p>
@change="toggleTorApp(svc.name, !svc.enabled)" <p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="copyTorAddress(svc.onion_address)">{{ svc.onion_address }}</p>
class="tor-toggle-input" <p v-else class="text-white/30 text-xs">No .onion address</p>
/> </div>
<span class="tor-toggle-slider"></span> <div class="flex items-center gap-2 shrink-0">
</label> <button
v-if="svc.onion_address && svc.enabled"
@click="rotateService(svc.name)"
:disabled="torRotating === svc.name"
class="glass-button px-3 py-1.5 rounded-lg text-xs"
>
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
</button>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="toggleTorApp(svc.name, $event)" />
</div>
</div> </div>
</div> </div>
</div> </div>
@ -512,6 +497,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
// Connected nodes // Connected nodes
const connectedNodes = ref(0) const connectedNodes = ref(0)
@ -904,10 +890,6 @@ async function checkConnectivity() {
} }
} }
function toggleAutoSync() {
autoSyncEnabled.value = !autoSyncEnabled.value
}
const logsToast = ref('') const logsToast = ref('')
function viewLogs() { function viewLogs() {

View File

@ -247,7 +247,7 @@
<div data-controller-container tabindex="0" class="mb-6"> <div data-controller-container tabindex="0" class="mb-6">
<button <button
@click="showChangePasswordModal = true" @click="showChangePasswordModal = true"
class="w-full flex items-center justify-center gap-2 mb-4 px-4 py-2 rounded-lg border border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10 transition-colors" class="w-full flex items-center justify-center gap-2 mb-4 px-4 py-2 rounded-lg glass-button glass-button-warning font-medium"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
@ -341,7 +341,7 @@
</div> </div>
<span <span
class="text-xs font-semibold px-2 py-1 rounded-full" class="text-xs font-semibold px-2 py-1 rounded-full"
:class="totpEnabled ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-white/50'" :class="totpEnabled ? 'status-success' : 'bg-white/10 text-white/50'"
> >
{{ totpEnabled ? t('common.enabled') : t('common.disabled') }} {{ totpEnabled ? t('common.enabled') : t('common.disabled') }}
</span> </span>
@ -349,7 +349,7 @@
<button <button
v-if="!totpEnabled" v-if="!totpEnabled"
@click="showTotpSetupModal = true" @click="showTotpSetupModal = true"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10 transition-colors" class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg glass-button glass-button-warning font-medium"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
@ -359,7 +359,7 @@
<button <button
v-else v-else
@click="showTotpDisableModal = true" @click="showTotpDisableModal = true"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-red-500/50 text-red-400 font-medium hover:bg-red-500/10 transition-colors" class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg glass-button glass-button-danger font-medium"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
@ -617,7 +617,7 @@
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors" class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors"
:class="claudeConnected :class="claudeConnected
? 'border-white/20 text-white/70 hover:bg-white/5' ? 'border-white/20 text-white/70 hover:bg-white/5'
: 'border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10'" : 'glass-button-warning font-medium'"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
@ -674,15 +674,7 @@
<p class="text-sm font-medium" :class="aiPermissions.allEnabled ? 'text-white/95' : 'text-white/70'">{{ t('common.enableAll') }}</p> <p class="text-sm font-medium" :class="aiPermissions.allEnabled ? 'text-white/95' : 'text-white/70'">{{ t('common.enableAll') }}</p>
<p class="text-xs text-white/50 mt-0.5">{{ t('settings.enableAllDesc') }}</p> <p class="text-xs text-white/50 mt-0.5">{{ t('settings.enableAllDesc') }}</p>
</div> </div>
<div <ToggleSwitch :model-value="aiPermissions.allEnabled" @update:model-value="aiPermissions.allEnabled ? aiPermissions.disableAll() : aiPermissions.enableAll()" @click.stop />
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="aiPermissions.allEnabled ? 'bg-orange-500' : 'bg-white/15'"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="aiPermissions.allEnabled ? 'translate-x-5' : 'translate-x-1'"
/>
</div>
</button> </button>
<div class="space-y-5"> <div class="space-y-5">
@ -705,15 +697,7 @@
<p class="text-sm font-medium" :class="aiPermissions.isEnabled(cat.id) ? 'text-white/95' : 'text-white/70'">{{ cat.label }}</p> <p class="text-sm font-medium" :class="aiPermissions.isEnabled(cat.id) ? 'text-white/95' : 'text-white/70'">{{ cat.label }}</p>
<p class="text-xs text-white/50 mt-0.5">{{ cat.description }}</p> <p class="text-xs text-white/50 mt-0.5">{{ cat.description }}</p>
</div> </div>
<div <ToggleSwitch :model-value="aiPermissions.isEnabled(cat.id)" @update:model-value="aiPermissions.toggle(cat.id)" @click.stop />
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="aiPermissions.isEnabled(cat.id) ? 'bg-orange-500' : 'bg-white/15'"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="aiPermissions.isEnabled(cat.id) ? 'translate-x-5' : 'translate-x-1'"
/>
</div>
</button> </button>
</div> </div>
</div> </div>
@ -744,22 +728,7 @@
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.webhookNotifications') }}</h2> <h2 class="text-xl font-semibold text-white/96">{{ t('settings.webhookNotifications') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.webhookNotificationsDesc') }}</p> <p class="text-sm text-white/60 mt-1">{{ t('settings.webhookNotificationsDesc') }}</p>
</div> </div>
<div class="flex items-center gap-3"> <ToggleSwitch :model-value="webhookConfig.enabled" @update:model-value="toggleWebhookEnabled" />
<button
@click="toggleWebhookEnabled"
role="switch"
:aria-checked="webhookConfig.enabled"
:aria-label="webhookConfig.enabled ? t('settings.disableWebhooks') : t('settings.enableWebhooks')"
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="webhookConfig.enabled ? 'bg-orange-500' : 'bg-white/15'"
:title="webhookConfig.enabled ? t('settings.disableWebhooks') : t('settings.enableWebhooks')"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="webhookConfig.enabled ? 'translate-x-5' : 'translate-x-1'"
/>
</button>
</div>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
@ -824,7 +793,7 @@
<button <button
@click="saveWebhookConfig" @click="saveWebhookConfig"
:disabled="savingWebhook" :disabled="savingWebhook"
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 bg-orange-500/20 border-orange-500/30 disabled:opacity-50" class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 disabled:opacity-50"
> >
{{ savingWebhook ? t('settings.savingWebhook') : t('common.saveConfiguration') }} {{ savingWebhook ? t('settings.savingWebhook') : t('common.saveConfiguration') }}
</button> </button>
@ -839,7 +808,7 @@
</div> </div>
<!-- Webhook status message --> <!-- Webhook status message -->
<div v-if="webhookStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="webhookStatusType === 'error' ? 'bg-red-500/15 text-red-300' : 'bg-green-500/15 text-green-300'"> <div v-if="webhookStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="webhookStatusType === 'error' ? 'alert-error' : 'alert-success'">
{{ webhookStatusMsg }} {{ webhookStatusMsg }}
</div> </div>
</div> </div>
@ -855,7 +824,7 @@
@click="toggleTelemetry" @click="toggleTelemetry"
:disabled="telemetryLoading" :disabled="telemetryLoading"
class="shrink-0 ml-4 px-4 py-2 rounded-lg text-sm font-medium transition-colors" class="shrink-0 ml-4 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
:class="telemetryEnabled ? 'bg-green-500/20 text-green-300 border border-green-500/30 hover:bg-green-500/30' : 'glass-button'" :class="telemetryEnabled ? 'glass-button glass-button-success' : 'glass-button'"
> >
{{ telemetryLoading ? '...' : telemetryEnabled ? 'Enabled' : 'Enable' }} {{ telemetryLoading ? '...' : telemetryEnabled ? 'Enabled' : 'Enable' }}
</button> </button>
@ -906,7 +875,7 @@
</div> </div>
<!-- Backup status message --> <!-- Backup status message -->
<div v-if="backupStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="backupStatusType === 'error' ? 'bg-red-500/15 text-red-300' : 'bg-green-500/15 text-green-300'"> <div v-if="backupStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="backupStatusType === 'error' ? 'alert-error' : 'alert-success'">
{{ backupStatusMsg }} {{ backupStatusMsg }}
</div> </div>
</div> </div>
@ -928,7 +897,7 @@
</div> </div>
<div class="flex gap-3 mt-5"> <div class="flex gap-3 mt-5">
<button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button> <button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
<button @click="createBackup" :disabled="creatingBackup || !backupPassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30 disabled:opacity-50"> <button @click="createBackup" :disabled="creatingBackup || !backupPassphrase" class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex-1 disabled:opacity-50">
{{ creatingBackup ? t('settings.creatingBackup') : t('settings.createBackup') }} {{ creatingBackup ? t('settings.creatingBackup') : t('settings.createBackup') }}
</button> </button>
</div> </div>
@ -948,7 +917,7 @@
</div> </div>
<div class="flex gap-3 mt-5"> <div class="flex gap-3 mt-5">
<button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button> <button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
<button @click="restoreBackup" :disabled="restoringBackup || !restorePassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-red-500/20 border-red-500/30 disabled:opacity-50"> <button @click="restoreBackup" :disabled="restoringBackup || !restorePassphrase" class="glass-button glass-button-danger px-4 py-2 rounded-lg text-sm flex-1 disabled:opacity-50">
{{ restoringBackup ? t('common.restoring') : t('common.restore') }} {{ restoringBackup ? t('common.restoring') : t('common.restore') }}
</button> </button>
</div> </div>
@ -979,7 +948,7 @@
Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen. Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.
</p> </p>
<button <button
class="glass-button text-red-400 border-red-500/30 hover:border-red-500/50" class="glass-button glass-button-danger"
@click="showFactoryResetConfirm = true" @click="showFactoryResetConfirm = true"
> >
Factory Reset Factory Reset
@ -997,7 +966,7 @@
<div class="flex gap-3 justify-end"> <div class="flex gap-3 justify-end">
<button class="glass-button" @click="showFactoryResetConfirm = false">Cancel</button> <button class="glass-button" @click="showFactoryResetConfirm = false">Cancel</button>
<button <button
class="glass-button text-red-400 border-red-500/30" class="glass-button glass-button-danger"
:disabled="factoryResetLoading" :disabled="factoryResetLoading"
@click="performFactoryReset" @click="performFactoryReset"
> >
@ -1020,6 +989,7 @@ import { useAppStore } from '../stores/app'
import { useUIModeStore } from '@/stores/uiMode' import { useUIModeStore } from '@/stores/uiMode'
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions' import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
import ControllerIndicator from '@/components/ControllerIndicator.vue' import ControllerIndicator from '@/components/ControllerIndicator.vue'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useModalKeyboard } from '@/composables/useModalKeyboard' import { useModalKeyboard } from '@/composables/useModalKeyboard'
import type { UIMode } from '@/types/api' import type { UIMode } from '@/types/api'

View File

@ -34,16 +34,16 @@
<p v-else class="text-xs text-white/60 capitalize">{{ didStatus }}</p> <p v-else class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
</div> </div>
</div> </div>
<div v-if="userDid" class="flex gap-2"> <div v-if="userDid" class="flex gap-2 mt-auto">
<button <button
@click="copyDid" @click="copyDid"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors" class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
> >
{{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }} {{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }}
</button> </button>
<button <button
@click="showDidDocument" @click="showDidDocument"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors" class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
> >
{{ t('web5.viewDidDocument') }} {{ t('web5.viewDidDocument') }}
</button> </button>
@ -52,7 +52,7 @@
v-else v-else
@click="createDID" @click="createDID"
:disabled="creatingDid" :disabled="creatingDid"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50" class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
> >
{{ creatingDid ? t('web5.creatingDid') : t('web5.createDid') }} {{ creatingDid ? t('web5.creatingDid') : t('web5.createDid') }}
</button> </button>
@ -70,17 +70,17 @@
<p v-else class="text-xs text-white/60">Not published</p> <p v-else class="text-xs text-white/60">Not published</p>
</div> </div>
</div> </div>
<div v-if="dhtDid" class="flex gap-2"> <div v-if="dhtDid" class="flex gap-2 mt-auto">
<button <button
@click="copyDhtDid" @click="copyDhtDid"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors" class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
> >
{{ dhtDidCopied ? 'Copied!' : 'Copy' }} {{ dhtDidCopied ? 'Copied!' : 'Copy' }}
</button> </button>
<button <button
@click="refreshDhtDid" @click="refreshDhtDid"
:disabled="publishingDht" :disabled="publishingDht"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50" class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
> >
{{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }} {{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }}
</button> </button>
@ -89,7 +89,7 @@
v-else-if="userDid" v-else-if="userDid"
@click="publishDhtDid" @click="publishDhtDid"
:disabled="publishingDht" :disabled="publishingDht"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50" class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
> >
{{ publishingDht ? 'Publishing...' : 'Publish to DHT' }} {{ publishingDht ? 'Publishing...' : 'Publish to DHT' }}
</button> </button>
@ -109,7 +109,7 @@
</div> </div>
<button <button
@click="connectWallet" @click="connectWallet"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50" class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="connectingWallet" :disabled="connectingWallet"
> >
{{ connectingWallet ? t('common.connecting') : walletConnected ? t('common.disconnect') : t('common.connect') }} {{ connectingWallet ? t('common.connecting') : walletConnected ? t('common.disconnect') : t('common.connect') }}
@ -130,7 +130,7 @@
</div> </div>
<button <button
@click="manageRelays" @click="manageRelays"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors" class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
> >
{{ t('common.manage') }} {{ t('common.manage') }}
</button> </button>
@ -148,18 +148,18 @@
<p class="text-xs text-white/60">{{ t('web5.peersKnown', { count: connectedNodesCount }) }}</p> <p class="text-xs text-white/60">{{ t('web5.peersKnown', { count: connectedNodesCount }) }}</p>
</div> </div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2 mt-auto">
<button <button
@click="router.push('/dashboard/server/federation')" @click="router.push('/dashboard/server/federation')"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors" class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
> >
{{ t('web5.findNodes') }} Nodes
</button> </button>
<button <button
@click="showSendMessageModal = true" @click="showSendMessageModal = true"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors" class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
> >
{{ t('web5.sendMessage') }} Message
</button> </button>
</div> </div>
</div> </div>
@ -167,7 +167,7 @@
</div> </div>
<!-- Hardware Wallet Detected Banner --> <!-- Hardware Wallet Detected Banner -->
<div v-if="detectedHwWallets.length > 0" class="mb-6 p-4 bg-orange-500/10 border border-orange-500/20 rounded-xl flex items-center gap-3"> <div v-if="detectedHwWallets.length > 0" class="mb-6 alert-warning flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0"> <div class="w-8 h-8 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
@ -413,6 +413,8 @@
</div> </div>
</transition> </transition>
<div v-if="walletError" class="alert-error mb-3">{{ walletError }}</div>
<div class="space-y-3 flex-1 min-h-0"> <div class="space-y-3 flex-1 min-h-0">
<!-- On-chain Balance --> <!-- On-chain Balance -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg"> <div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
@ -587,8 +589,11 @@
</div> </div>
</div> </div>
<!-- Connected Nodes + Shared Content grid -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
<!-- Connected Nodes (P2P over Tor) --> <!-- Connected Nodes (P2P over Tor) -->
<div ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 mb-8 scroll-mt-24"> <div ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 scroll-mt-24 flex flex-col">
<!-- Desktop: side-by-side layout --> <!-- Desktop: side-by-side layout -->
<div class="hidden md:flex items-start gap-4 mb-4"> <div class="hidden md:flex items-start gap-4 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center"> <div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
@ -758,11 +763,12 @@
</div> </div>
</div> </div>
<div class="mt-auto pt-4">
<button <button
v-if="nodesContainerTab === 'peers'" v-if="nodesContainerTab === 'peers'"
@click="discoverAndAddPeers" @click="discoverAndAddPeers"
:disabled="discovering" :disabled="discovering"
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50" class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
> >
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }} {{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
</button> </button>
@ -770,7 +776,7 @@
v-else-if="nodesContainerTab === 'messages'" v-else-if="nodesContainerTab === 'messages'"
@click="loadReceivedMessages" @click="loadReceivedMessages"
:disabled="loadingMessages" :disabled="loadingMessages"
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50" class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
> >
{{ loadingMessages ? t('common.loading') : t('web5.refreshMessages') }} {{ loadingMessages ? t('common.loading') : t('web5.refreshMessages') }}
</button> </button>
@ -778,14 +784,15 @@
v-else v-else
@click="loadConnectionRequests" @click="loadConnectionRequests"
:disabled="loadingRequests" :disabled="loadingRequests"
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50" class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
> >
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }} {{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
</button> </button>
</div>
</div> </div>
<!-- Shared Content --> <!-- Shared Content -->
<div class="glass-card p-6 mb-8"> <div class="glass-card p-6">
<!-- Desktop: side-by-side --> <!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4"> <div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@ -835,6 +842,29 @@
</div> </div>
</div> </div>
<!-- Browse Peer Selector -->
<div class="mb-4">
<div class="flex items-center gap-3">
<select
v-model="browsePeerOnion"
class="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white text-sm border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
>
<option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
</option>
</select>
<button
@click="browsePeerContent"
:disabled="!browsePeerOnion || browsingPeerContent"
class="glass-button glass-button-sm px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ browsingPeerContent ? t('common.loading') : t('web5.browse') }}
</button>
</div>
<p v-if="browsePeerError" class="text-xs text-red-400 mt-2">{{ browsePeerError }}</p>
</div>
<!-- Tabs: My Content | Browse Peers --> <!-- Tabs: My Content | Browse Peers -->
<div class="flex gap-1 mb-4 border-b border-white/10"> <div class="flex gap-1 mb-4 border-b border-white/10">
<button <button
@ -941,29 +971,6 @@
<!-- Browse Peers tab --> <!-- Browse Peers tab -->
<div v-show="contentTab === 'browse'"> <div v-show="contentTab === 'browse'">
<!-- Peer Selector -->
<div class="mb-4">
<div class="flex items-center gap-3">
<select
v-model="browsePeerOnion"
class="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white text-sm border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
>
<option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
</option>
</select>
<button
@click="browsePeerContent"
:disabled="!browsePeerOnion || browsingPeerContent"
class="glass-button glass-button-sm px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ browsingPeerContent ? t('common.loading') : t('web5.browse') }}
</button>
</div>
<p v-if="browsePeerError" class="text-xs text-red-400 mt-2">{{ browsePeerError }}</p>
</div>
<!-- Peer Content Loading --> <!-- Peer Content Loading -->
<div v-if="browsingPeerContent" class="py-4 text-center"> <div v-if="browsingPeerContent" class="py-4 text-center">
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
@ -1038,6 +1045,8 @@
</div> </div>
</div> </div>
</div> <!-- end Connected Nodes + Shared Content grid -->
<!-- Content Streaming Player --> <!-- Content Streaming Player -->
<Teleport to="body"> <Teleport to="body">
<div v-if="streamingItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closePlayer" @keydown.escape="closePlayer"> <div v-if="streamingItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closePlayer" @keydown.escape="closePlayer">
@ -1087,8 +1096,8 @@
</div> </div>
<!-- Player Error --> <!-- Player Error -->
<div v-if="playerError" class="mt-3 p-3 bg-red-500/10 border border-red-500/20 rounded-lg"> <div v-if="playerError" class="mt-3 alert-error">
<p class="text-red-400 text-sm">{{ playerError }}</p> <p>{{ playerError }}</p>
<p class="text-white/50 text-xs mt-1">This may be a Tor-only resource. Copy the URL to use with a Tor-enabled media player.</p> <p class="text-white/50 text-xs mt-1">This may be a Tor-only resource. Copy the URL to use with a Tor-enabled media player.</p>
</div> </div>
@ -1118,15 +1127,15 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="text-white/60 text-sm block mb-1">Filename</label> <label class="text-white/60 text-sm block mb-1">Filename</label>
<input v-model="newContentFilename" type="text" placeholder="my-file.mp3" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="newContentFilename" type="text" placeholder="my-file.mp3" class="w-full input-glass" />
</div> </div>
<div> <div>
<label class="text-white/60 text-sm block mb-1">MIME Type</label> <label class="text-white/60 text-sm block mb-1">MIME Type</label>
<input v-model="newContentMimeType" type="text" placeholder="audio/mpeg" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="newContentMimeType" type="text" placeholder="audio/mpeg" class="w-full input-glass" />
</div> </div>
<div> <div>
<label class="text-white/60 text-sm block mb-1">Description (optional)</label> <label class="text-white/60 text-sm block mb-1">Description (optional)</label>
<input v-model="newContentDescription" type="text" placeholder="A short description" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="newContentDescription" type="text" placeholder="A short description" class="w-full input-glass" />
</div> </div>
<div> <div>
<label class="text-white/60 text-sm block mb-2">Access</label> <label class="text-white/60 text-sm block mb-2">Access</label>
@ -1144,16 +1153,16 @@
</div> </div>
<div v-if="newContentAccess === 'paid'"> <div v-if="newContentAccess === 'paid'">
<label class="text-white/60 text-sm block mb-1">Price (sats)</label> <label class="text-white/60 text-sm block mb-1">Price (sats)</label>
<input v-model.number="newContentPrice" type="number" min="1" placeholder="100" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model.number="newContentPrice" type="number" min="1" placeholder="100" class="w-full input-glass" />
<p v-if="newContentPrice > 0" class="text-xs text-orange-400/80 mt-1">Peers will pay {{ newContentPrice }} sats to access this</p> <p v-if="newContentPrice > 0" class="text-xs text-orange-400/80 mt-1">Peers will pay {{ newContentPrice }} sats to access this</p>
</div> </div>
</div> </div>
<div v-if="addContentError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg"> <div v-if="addContentError" class="mt-3 alert-error">
<p class="text-red-300 text-xs">{{ addContentError }}</p> <p class="text-xs">{{ addContentError }}</p>
</div> </div>
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button @click="showAddContentModal = false; addContentError = ''" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button> <button @click="showAddContentModal = false; addContentError = ''" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="addContentItem" :disabled="addingContent || !newContentFilename.trim()" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50"> <button @click="addContentItem" :disabled="addingContent || !newContentFilename.trim()" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ addingContent ? 'Adding...' : 'Add' }} {{ addingContent ? 'Adding...' : 'Add' }}
</button> </button>
</div> </div>
@ -1161,8 +1170,11 @@
</div> </div>
</Teleport> </Teleport>
<!-- Identities + DWN grid -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
<!-- Identity Management --> <!-- Identity Management -->
<div class="glass-card p-6 mb-8"> <div class="glass-card p-6">
<!-- Desktop: side-by-side --> <!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4"> <div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@ -1302,7 +1314,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="text-white/60 text-sm block mb-1">Name</label> <label class="text-white/60 text-sm block mb-1">Name</label>
<input v-model="newIdentityName" type="text" placeholder="Personal" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="newIdentityName" type="text" placeholder="Personal" class="w-full input-glass" />
</div> </div>
<div> <div>
<label class="text-white/60 text-sm block mb-1">Purpose</label> <label class="text-white/60 text-sm block mb-1">Purpose</label>
@ -1317,8 +1329,8 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="createIdentityError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg"> <div v-if="createIdentityError" class="mt-3 alert-error">
<p class="text-red-300 text-xs">{{ createIdentityError }}</p> <p class="text-xs">{{ createIdentityError }}</p>
</div> </div>
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button @click="showCreateIdentityModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button> <button @click="showCreateIdentityModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
@ -1338,7 +1350,7 @@
<p class="text-white/60 text-sm mb-4">{{ t('web5.deleteIdentityConfirm') }}</p> <p class="text-white/60 text-sm mb-4">{{ t('web5.deleteIdentityConfirm') }}</p>
<div class="flex gap-3"> <div class="flex gap-3">
<button @click="deleteIdentityTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button> <button @click="deleteIdentityTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="deleteIdentity" :disabled="deletingIdentity" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/20 border-red-500/30"> <button @click="deleteIdentity" :disabled="deletingIdentity" class="flex-1 glass-button glass-button-danger px-4 py-2 rounded-lg text-sm font-medium">
{{ deletingIdentity ? t('web5.deleting') : t('common.delete') }} {{ deletingIdentity ? t('web5.deleting') : t('common.delete') }}
</button> </button>
</div> </div>
@ -1430,7 +1442,7 @@
v-model="keyViewerPassword" v-model="keyViewerPassword"
type="password" type="password"
placeholder="Password" placeholder="Password"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" class="flex-1 input-glass"
@keydown.enter="unlockPrivateKeys" @keydown.enter="unlockPrivateKeys"
/> />
<button <button
@ -1507,41 +1519,41 @@
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="text-white/60 text-xs block mb-1">Display Name</label> <label class="text-white/60 text-xs block mb-1">Display Name</label>
<input v-model="profileForm.display_name" type="text" :placeholder="profileEditorIdentity.name" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="profileForm.display_name" type="text" :placeholder="profileEditorIdentity.name" class="w-full input-glass" />
</div> </div>
<div> <div>
<label class="text-white/60 text-xs block mb-1">About / Bio</label> <label class="text-white/60 text-xs block mb-1">About / Bio</label>
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30 resize-none"></textarea> <textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full input-glass resize-none"></textarea>
</div> </div>
<div> <div>
<label class="text-white/60 text-xs block mb-1">Profile Picture URL</label> <label class="text-white/60 text-xs block mb-1">Profile Picture URL</label>
<input v-model="profileForm.picture" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="profileForm.picture" type="url" placeholder="https://..." class="w-full input-glass" />
</div> </div>
<div> <div>
<label class="text-white/60 text-xs block mb-1">Banner Image URL</label> <label class="text-white/60 text-xs block mb-1">Banner Image URL</label>
<input v-model="profileForm.banner" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="profileForm.banner" type="url" placeholder="https://..." class="w-full input-glass" />
</div> </div>
<div> <div>
<label class="text-white/60 text-xs block mb-1">Website</label> <label class="text-white/60 text-xs block mb-1">Website</label>
<input v-model="profileForm.website" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="profileForm.website" type="url" placeholder="https://..." class="w-full input-glass" />
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<label class="text-white/60 text-xs block mb-1">NIP-05 (Nostr address)</label> <label class="text-white/60 text-xs block mb-1">NIP-05 (Nostr address)</label>
<input v-model="profileForm.nip05" type="text" placeholder="you@domain.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="profileForm.nip05" type="text" placeholder="you@domain.com" class="w-full input-glass" />
</div> </div>
<div> <div>
<label class="text-white/60 text-xs block mb-1">Lightning Address (LUD-16)</label> <label class="text-white/60 text-xs block mb-1">Lightning Address (LUD-16)</label>
<input v-model="profileForm.lud16" type="text" placeholder="you@getalby.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="profileForm.lud16" type="text" placeholder="you@getalby.com" class="w-full input-glass" />
</div> </div>
</div> </div>
</div> </div>
<div v-if="profileError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg"> <div v-if="profileError" class="mt-3 alert-error">
<p class="text-red-300 text-xs">{{ profileError }}</p> <p class="text-xs">{{ profileError }}</p>
</div> </div>
<div v-if="profileSuccess" class="mt-3 p-2 bg-green-500/20 border border-green-500/30 rounded-lg"> <div v-if="profileSuccess" class="mt-3 alert-success">
<p class="text-green-300 text-xs">{{ profileSuccess }}</p> <p class="text-xs">{{ profileSuccess }}</p>
</div> </div>
<div class="flex gap-3 mt-5"> <div class="flex gap-3 mt-5">
@ -1549,7 +1561,7 @@
<button @click="saveProfile" :disabled="profileSaving" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium"> <button @click="saveProfile" :disabled="profileSaving" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium">
{{ profileSaving ? 'Saving...' : 'Save' }} {{ profileSaving ? 'Saving...' : 'Save' }}
</button> </button>
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30"> <button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">
{{ profilePublishing ? 'Publishing...' : 'Save & Publish' }} {{ profilePublishing ? 'Publishing...' : 'Save & Publish' }}
</button> </button>
</div> </div>
@ -1582,7 +1594,7 @@
<!-- Amount --> <!-- Amount -->
<div class="mb-3"> <div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label> <label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="unifiedSendAmount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model.number="unifiedSendAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div> </div>
<!-- Destination (varies by method) --> <!-- Destination (varies by method) -->
@ -1590,7 +1602,7 @@
<label class="text-white/60 text-sm block mb-1"> <label class="text-white/60 text-sm block mb-1">
{{ effectiveSendMethod === 'lightning' ? 'Lightning Invoice (BOLT11)' : 'Bitcoin Address' }} {{ effectiveSendMethod === 'lightning' ? 'Lightning Invoice (BOLT11)' : 'Bitcoin Address' }}
</label> </label>
<textarea v-model="unifiedSendDest" rows="2" :placeholder="effectiveSendMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-white/30"></textarea> <textarea v-model="unifiedSendDest" rows="2" :placeholder="effectiveSendMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div> </div>
<!-- Ecash token output --> <!-- Ecash token output -->
@ -1635,7 +1647,7 @@
</div> </div>
<!-- Mesh Relay Prompt shown when offline --> <!-- Mesh Relay Prompt shown when offline -->
<div v-if="showMeshRelayPrompt" class="mb-3 p-4 bg-orange-500/10 border border-orange-500/30 rounded-lg"> <div v-if="showMeshRelayPrompt" class="mb-3 alert-warning">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<span class="text-lg">&#x1F4E1;</span> <span class="text-lg">&#x1F4E1;</span>
<p class="text-orange-300 text-sm font-medium">You are offline</p> <p class="text-orange-300 text-sm font-medium">You are offline</p>
@ -1643,12 +1655,12 @@
<p class="text-white/70 text-xs mb-3">Send this transaction via mesh radio? It will be relayed by the nearest internet-connected node and you'll receive confirmation updates.</p> <p class="text-white/70 text-xs mb-3">Send this transaction via mesh radio? It will be relayed by the nearest internet-connected node and you'll receive confirmation updates.</p>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="dismissMeshRelayPrompt" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs">Cancel</button> <button @click="dismissMeshRelayPrompt" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs">Cancel</button>
<button @click="handleMeshRelaySend" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium bg-orange-500/20 border-orange-500/30">Send via Mesh</button> <button @click="handleMeshRelaySend" class="flex-1 glass-button glass-button-warning px-3 py-2 rounded-lg text-xs font-medium">Send via Mesh</button>
</div> </div>
</div> </div>
<!-- Mesh Relay Status --> <!-- Mesh Relay Status -->
<div v-if="meshRelayActive" class="mb-3 p-3 bg-orange-500/10 border border-orange-500/20 rounded-lg"> <div v-if="meshRelayActive" class="mb-3 alert-warning">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<svg class="animate-spin h-3 w-3 text-orange-400" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-3 w-3 text-orange-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@ -1660,23 +1672,23 @@
</div> </div>
<!-- On-chain txid result --> <!-- On-chain txid result -->
<div v-if="sendResultTxid" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg"> <div v-if="sendResultTxid" class="mb-3 alert-success">
<p class="text-green-400 text-xs">Sent! TX: {{ sendResultTxid }}</p> <p class="text-xs">Sent! TX: {{ sendResultTxid }}</p>
</div> </div>
<!-- Lightning payment result --> <!-- Lightning payment result -->
<div v-if="sendResultHash" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg"> <div v-if="sendResultHash" class="mb-3 alert-success">
<p class="text-green-400 text-xs">Paid! Hash: {{ sendResultHash }}</p> <p class="text-xs">Paid! Hash: {{ sendResultHash }}</p>
</div> </div>
<div v-if="unifiedSendError" class="mb-3 text-xs text-red-400">{{ unifiedSendError }}</div> <div v-if="unifiedSendError" class="mb-3 text-xs text-red-400">{{ unifiedSendError }}</div>
<div class="flex gap-3"> <div class="flex gap-3">
<button @click="closeUnifiedSendModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button> <button @click="closeUnifiedSendModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button v-if="psbtStep === 'created'" @click="finalizePsbt" :disabled="unifiedSendProcessing || !signedPsbtInput.trim()" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50"> <button v-if="psbtStep === 'created'" @click="finalizePsbt" :disabled="unifiedSendProcessing || !signedPsbtInput.trim()" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ unifiedSendProcessing ? 'Broadcasting...' : 'Broadcast' }} {{ unifiedSendProcessing ? 'Broadcasting...' : 'Broadcast' }}
</button> </button>
<button v-else @click="unifiedSend" :disabled="unifiedSendProcessing || !unifiedSendAmount" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50"> <button v-else @click="unifiedSend" :disabled="unifiedSendProcessing || !unifiedSendAmount" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ unifiedSendProcessing ? 'Sending...' : (useHardwareWallet && effectiveSendMethod === 'onchain' ? 'Create PSBT' : 'Send') }} {{ unifiedSendProcessing ? 'Sending...' : (useHardwareWallet && effectiveSendMethod === 'onchain' ? 'Create PSBT' : 'Send') }}
</button> </button>
</div> </div>
@ -1705,11 +1717,11 @@
<div v-if="receiveMethod === 'lightning'"> <div v-if="receiveMethod === 'lightning'">
<div class="mb-3"> <div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label> <label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="receiveInvoiceAmount" type="number" min="1" placeholder="1000" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model.number="receiveInvoiceAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Memo (optional)</label> <label class="text-white/60 text-sm block mb-1">Memo (optional)</label>
<input v-model="receiveInvoiceMemo" type="text" placeholder="Payment for..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="receiveInvoiceMemo" type="text" placeholder="Payment for..." class="w-full input-glass" />
</div> </div>
<div v-if="receiveInvoiceResult" class="mb-3 p-2 bg-white/5 rounded-lg"> <div v-if="receiveInvoiceResult" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Invoice (share with sender):</p> <p class="text-white/50 text-xs mb-1">Invoice (share with sender):</p>
@ -1735,7 +1747,7 @@
<div v-if="receiveMethod === 'ecash'"> <div v-if="receiveMethod === 'ecash'">
<div class="mb-3"> <div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Paste ecash token</label> <label class="text-white/60 text-sm block mb-1">Paste ecash token</label>
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuSend_..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"></textarea> <textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass"></textarea>
</div> </div>
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div> <div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
</div> </div>
@ -1744,7 +1756,7 @@
<div class="flex gap-3"> <div class="flex gap-3">
<button @click="closeUnifiedReceiveModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button> <button @click="closeUnifiedReceiveModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="unifiedReceive" :disabled="unifiedReceiveProcessing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-green-500/20 border-green-500/30 disabled:opacity-50"> <button @click="unifiedReceive" :disabled="unifiedReceiveProcessing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ unifiedReceiveProcessing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }} {{ unifiedReceiveProcessing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }}
</button> </button>
</div> </div>
@ -1753,7 +1765,7 @@
</Teleport> </Teleport>
<!-- Decentralized Web Node (DWN) --> <!-- Decentralized Web Node (DWN) -->
<div class="glass-card p-6 mb-8"> <div class="glass-card p-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center"> <div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
@ -1907,6 +1919,8 @@
</template> </template>
</div> </div>
</div> <!-- end Identities + DWN grid -->
<!-- Verifiable Credentials --> <!-- Verifiable Credentials -->
<div class="glass-card p-6 mb-8"> <div class="glass-card p-6 mb-8">
<!-- Desktop: side-by-side --> <!-- Desktop: side-by-side -->
@ -2018,16 +2032,16 @@
<div class="grid grid-cols-2 gap-3 mb-3"> <div class="grid grid-cols-2 gap-3 mb-3">
<div> <div>
<label class="text-white/60 text-xs block mb-1">Username</label> <label class="text-white/60 text-xs block mb-1">Username</label>
<input v-model="newDomainName" type="text" placeholder="satoshi" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="newDomainName" type="text" placeholder="satoshi" class="w-full input-glass" />
</div> </div>
<div> <div>
<label class="text-white/60 text-xs block mb-1">Domain</label> <label class="text-white/60 text-xs block mb-1">Domain</label>
<input v-model="newDomainDomain" type="text" placeholder="example.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="newDomainDomain" type="text" placeholder="example.com" class="w-full input-glass" />
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="text-white/60 text-xs block mb-1">Link to Identity</label> <label class="text-white/60 text-xs block mb-1">Link to Identity</label>
<select v-model="newDomainIdentityId" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"> <select v-model="newDomainIdentityId" class="w-full input-glass">
<option value="" disabled>Select identity...</option> <option value="" disabled>Select identity...</option>
<option v-for="id in managedIdentities" :key="id.id" :value="id.id">{{ id.name }} ({{ (id.did || '').slice(0, 24) }}...)</option> <option v-for="id in managedIdentities" :key="id.id" :value="id.id">{{ id.name }} ({{ (id.did || '').slice(0, 24) }}...)</option>
</select> </select>
@ -2042,7 +2056,7 @@
<div class="border-t border-white/10 pt-4 mt-4"> <div class="border-t border-white/10 pt-4 mt-4">
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.verifyNip05') }}</h3> <h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.verifyNip05') }}</h3>
<div class="flex gap-2"> <div class="flex gap-2">
<input v-model="verifyNip05Input" type="text" placeholder="user@domain.com" class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" /> <input v-model="verifyNip05Input" type="text" placeholder="user@domain.com" class="flex-1 input-glass" />
<button @click="verifyNip05" :disabled="nip05Verifying || !verifyNip05Input.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"> <button @click="verifyNip05" :disabled="nip05Verifying || !verifyNip05Input.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ nip05Verifying ? '...' : 'Verify' }} {{ nip05Verifying ? '...' : 'Verify' }}
</button> </button>
@ -2093,7 +2107,7 @@
<div class="border-t border-white/10 pt-4"> <div class="border-t border-white/10 pt-4">
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.addRelay') }}</h3> <h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.addRelay') }}</h3>
<div class="flex gap-2"> <div class="flex gap-2">
<input v-model="newRelayUrl" type="text" :placeholder="t('web5.relayUrlPlaceholder')" class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" @keyup.enter="addNostrRelay" /> <input v-model="newRelayUrl" type="text" :placeholder="t('web5.relayUrlPlaceholder')" class="flex-1 input-glass" @keyup.enter="addNostrRelay" />
<button @click="addNostrRelay" :disabled="!newRelayUrl.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"> <button @click="addNostrRelay" :disabled="!newRelayUrl.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
Add Add
</button> </button>
@ -2541,6 +2555,7 @@ const walletConnected = ref(false)
const connectingWallet = ref(false) const connectingWallet = ref(false)
const lndOnchainBalance = ref(0) const lndOnchainBalance = ref(0)
const lndChannelBalance = ref(0) const lndChannelBalance = ref(0)
const walletError = ref('')
// Incoming Transactions // Incoming Transactions
interface WalletTransaction { interface WalletTransaction {
@ -2567,13 +2582,15 @@ async function loadTransactions() {
try { try {
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' }) const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' })
walletTransactions.value = res.transactions || [] walletTransactions.value = res.transactions || []
walletError.value = ''
// Auto-show panel when new unconfirmed incoming txs appear // Auto-show panel when new unconfirmed incoming txs appear
const pending = res.incoming_pending_count || 0 const pending = res.incoming_pending_count || 0
if (pending > 0 && !showIncomingTxPanel.value) { if (pending > 0 && !showIncomingTxPanel.value) {
showIncomingTxPanel.value = true showIncomingTxPanel.value = true
} }
} catch { } catch (e) {
walletTransactions.value = [] walletTransactions.value = []
walletError.value = e instanceof Error ? e.message : 'Failed to load transactions'
} }
} }
@ -3876,10 +3893,12 @@ async function loadLndBalances() {
lndOnchainBalance.value = res.balance_sats || 0 lndOnchainBalance.value = res.balance_sats || 0
lndChannelBalance.value = res.channel_balance_sats || 0 lndChannelBalance.value = res.channel_balance_sats || 0
walletConnected.value = true walletConnected.value = true
} catch { walletError.value = ''
} catch (e) {
walletConnected.value = false walletConnected.value = false
lndOnchainBalance.value = 0 lndOnchainBalance.value = 0
lndChannelBalance.value = 0 lndChannelBalance.value = 0
walletError.value = e instanceof Error ? e.message : 'Failed to load wallet balances'
} }
} }

View File

@ -130,7 +130,7 @@
v-model="openForm.peerUri" v-model="openForm.peerUri"
type="text" type="text"
placeholder="pubkey@host:port" placeholder="pubkey@host:port"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" class="w-full input-glass"
/> />
<p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p> <p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p>
</div> </div>
@ -141,14 +141,14 @@
type="number" type="number"
min="20000" min="20000"
placeholder="100000" placeholder="100000"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" class="w-full input-glass"
/> />
<p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p> <p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p>
</div> </div>
</div> </div>
<div v-if="openError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg"> <div v-if="openError" class="mt-3 alert-error">
<p class="text-red-300 text-xs">{{ openError }}</p> <p class="text-xs">{{ openError }}</p>
</div> </div>
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
@ -169,15 +169,15 @@
<div class="glass-card p-6 w-full max-w-sm mx-4"> <div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2> <h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p> <p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
<div v-if="closeError" class="mb-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg"> <div v-if="closeError" class="mb-3 alert-error">
<p class="text-red-300 text-xs">{{ closeError }}</p> <p class="text-xs">{{ closeError }}</p>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button> <button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button <button
@click="closeChannel" @click="closeChannel"
:disabled="closingChannel" :disabled="closingChannel"
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/20 border-red-500/30" class="flex-1 glass-button glass-button-danger px-4 py-2 rounded-lg text-sm font-medium"
> >
{{ closingChannel ? 'Closing...' : 'Close' }} {{ closingChannel ? 'Closing...' : 'Close' }}
</button> </button>

View File

@ -1192,17 +1192,21 @@ lines = ["SocksPort 9050", "ControlPort 0", ""]
try: try:
with open("/var/lib/archipelago/tor/services.json") as f: with open("/var/lib/archipelago/tor/services.json") as f:
cfg = json.load(f) cfg = json.load(f)
extra_ports = {"lnd": [8080]} # LND REST API over Tor
for svc in cfg.get("services", []): for svc in cfg.get("services", []):
if svc.get("enabled", True): if svc.get("enabled", True):
n = svc["name"] n = svc["name"]
p = svc["local_port"] p = svc["local_port"]
lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n) lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n)
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p)) lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
for ep in extra_ports.get(n, []):
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (ep, ep))
lines.append("") lines.append("")
except Exception: except Exception:
for n, p in [("archipelago",80),("bitcoin",8333),("electrumx",50001),("lnd",9735),("btcpay",23000),("mempool",4080),("fedimint",8175)]: for n, ports in [("archipelago",[80]),("bitcoin",[8333]),("electrumx",[50001]),("lnd",[9735,8080]),("btcpay",[23000]),("mempool",[4080]),("fedimint",[8175])]:
lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n) lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n)
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p)) for p in ports:
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
lines.append("") lines.append("")
with open("/etc/tor/torrc", "w") as f: with open("/etc/tor/torrc", "w") as f:
f.write("\n".join(lines) + "\n") f.write("\n".join(lines) + "\n")

View File

@ -156,24 +156,38 @@ case $choice in
fi fi
if [ -z "$RUNTIME" ]; then if [ -z "$RUNTIME" ]; then
echo ""
echo "No working container runtime detected."
echo ""
if command -v podman &>/dev/null; then if command -v podman &>/dev/null; then
echo "Podman is installed but the machine isn't running:" echo " Podman machine not running — starting it..."
echo " podman machine start" if ! podman machine ls --format '{{.Name}}' 2>/dev/null | grep -q .; then
echo " No Podman machine found — initializing..."
podman machine init
fi
podman machine start
if podman ps &>/dev/null; then
if command -v podman-compose &>/dev/null; then
RUNTIME="podman"
COMPOSE="podman-compose"
else
RUNTIME="podman"
COMPOSE="podman compose"
fi
else
echo " Failed to start Podman machine."
exit 1
fi
elif command -v docker &>/dev/null; then elif command -v docker &>/dev/null; then
echo ""
echo "Docker is installed but the daemon isn't running." echo "Docker is installed but the daemon isn't running."
echo "Start Docker Desktop and try again." echo "Start Docker Desktop and try again."
exit 1
else else
echo "Install Docker Desktop or Podman:" echo ""
echo " brew install --cask docker" echo "No container runtime found. Install one:"
echo " # or"
echo " brew install podman podman-compose" echo " brew install podman podman-compose"
echo " podman machine init && podman machine start" echo " # or"
echo " brew install --cask docker"
exit 1
fi fi
echo ""
exit 1
fi fi
echo " Using: $RUNTIME" echo " Using: $RUNTIME"

View File

@ -109,9 +109,97 @@ else
log "Swap already configured" log "Swap already configured"
fi fi
# Rootless podman prerequisites (run as root, configures for archipelago user)
log "Setting up rootless podman prerequisites..."
# Allow binding to ports >= 80 (rootless default is 1024)
if ! grep -q "unprivileged_port_start=80" /etc/sysctl.d/99-rootless-podman.conf 2>/dev/null; then
echo "net.ipv4.ip_unprivileged_port_start=80" > /etc/sysctl.d/99-rootless-podman.conf
sysctl -p /etc/sysctl.d/99-rootless-podman.conf 2>/dev/null
log " Rootless port binding enabled (>=80)"
fi
# Linger for container persistence after logout
if [ "$(loginctl show-user archipelago 2>/dev/null | grep Linger)" != "Linger=yes" ]; then
loginctl enable-linger archipelago 2>/dev/null
log " Linger enabled for archipelago user"
fi
# Ensure subuid/subgid mappings
grep -q "^archipelago:" /etc/subuid 2>/dev/null || {
echo "archipelago:100000:65536" >> /etc/subuid
echo "archipelago:100000:65536" >> /etc/subgid
log " subuid/subgid configured"
}
# Ensure /etc/hosts is readable (rootless podman needs it)
chmod 644 /etc/hosts 2>/dev/null
# Ensure network exists (matches deploy) # Ensure network exists (matches deploy)
$DOCKER network create archy-net 2>/dev/null || true $DOCKER network create archy-net 2>/dev/null || true
# Rootless podman UID mapping: fix data dir ownership so container processes
# can write. Rootless podman maps container UIDs via subuid (container UID N
# → host UID 100000+N). Must run BEFORE container creation.
log "Fixing rootless podman UID mapping..."
# Containers running as root (UID 0 → host UID 100000)
for dir in lnd electrumx btcpay nbxplorer immich jellyfin vaultwarden \
home-assistant fedimint fedimint-gateway photoprism ollama filebrowser \
nextcloud uptime-kuma onlyoffice nginx-proxy-manager portainer nostr-rs-relay; do
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100000:100000 "/var/lib/archipelago/$dir" 2>/dev/null
done
# Bitcoin Knots: container UID 101 → host UID 100101
[ -d /var/lib/archipelago/bitcoin ] && chown -R 100101:100101 /var/lib/archipelago/bitcoin 2>/dev/null
# Postgres: container UID 70 → host UID 100070
for dir in postgres-btcpay immich-db penpot-postgres; do
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100070:100070 "/var/lib/archipelago/$dir" 2>/dev/null
done
# MariaDB: container UID 999 → host UID 100999
for dir in mempool mysql-mempool; do
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100999:100999 "/var/lib/archipelago/$dir" 2>/dev/null
done
# Grafana: container UID 472 → host UID 100472
[ -d /var/lib/archipelago/grafana ] && chown -R 100472:100472 /var/lib/archipelago/grafana 2>/dev/null
log "UID mapping done"
# ── Memory limits per container ──────────────────────────────────────────
# Matches core/archipelago/src/api/rpc/package.rs get_memory_limit()
# Prevents a single runaway container from OOMing the whole system.
TOTAL_MEM_MB=$(($(awk '/MemTotal/{print $2}' /proc/meminfo) / 1024))
LOW_MEM=false
[ "$TOTAL_MEM_MB" -lt 12000 ] && LOW_MEM=true && log "Low-memory system (${TOTAL_MEM_MB}MB) — reducing limits"
mem_limit() {
case "$1" in
bitcoin-knots) $LOW_MEM && echo "1g" || echo "2g";;
onlyoffice) $LOW_MEM && echo "1g" || echo "2g";;
ollama) $LOW_MEM && echo "1g" || echo "4g";;
lnd) echo "512m";;
electrumx) echo "1g";;
nextcloud) echo "1g";;
immich_server) echo "1g";;
btcpay-server) echo "1g";;
homeassistant) echo "512m";;
fedimint) echo "512m";;
fedimint-gateway) echo "512m";;
photoprism) $LOW_MEM && echo "512m" || echo "1g";;
mempool-api) echo "512m";;
jellyfin) echo "1g";;
searxng) echo "512m";;
archy-btcpay-db) echo "512m";;
archy-nbxplorer) echo "512m";;
archy-mempool-db) echo "512m";;
archy-mempool-web) echo "256m";;
grafana) echo "256m";;
vaultwarden) echo "256m";;
uptime-kuma) echo "256m";;
filebrowser) echo "256m";;
portainer) echo "256m";;
nginx-proxy-manager) echo "256m";;
immich_postgres) echo "256m";;
immich_redis) echo "128m";;
tailscale) echo "256m";;
indeedhub|archy-bitcoin-ui|archy-lnd-ui|archy-electrs-ui) echo "128m";;
*) echo "512m";;
esac
}
# ── Tier 1: Databases & Core Infrastructure ────────────────────────────── # ── Tier 1: Databases & Core Infrastructure ──────────────────────────────
log "=== Tier 1: Databases & Core Infrastructure ===" log "=== Tier 1: Databases & Core Infrastructure ==="
@ -130,14 +218,14 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|arch
BTC_DBCACHE=4096 BTC_DBCACHE=4096
log " Large disk (${DISK_GB}GB) — enabling txindex" log " Large disk (${DISK_GB}GB) — enabling txindex"
fi fi
if $DOCKER run -d --name bitcoin-knots --restart unless-stopped --network archy-net \ if $DOCKER run -d --name bitcoin-knots --restart unless-stopped --memory=$(mem_limit bitcoin-knots) --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 8332:8332 -p 8333:8333 \ -p 8332:8332 -p 8333:8333 \
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \ -v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
docker.io/bitcoinknots/bitcoin:latest \ docker.io/bitcoinknots/bitcoin:latest \
-server=1 $BTC_EXTRA_ARGS \ -server=1 $BTC_EXTRA_ARGS \
-rpcallowip=127.0.0.1/32 -rpcallowip=10.88.0.0/16 -rpcbind=0.0.0.0:8332 \ -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 \
-rpcuser=$BITCOIN_RPC_USER -rpcpassword=$BITCOIN_RPC_PASS \ -rpcuser=$BITCOIN_RPC_USER -rpcpassword=$BITCOIN_RPC_PASS \
-proxy=127.0.0.1:9050 -listen=1 -bind=0.0.0.0:8333 \ -proxy=127.0.0.1:9050 -listen=1 -bind=0.0.0.0:8333 \
-dbcache=$BTC_DBCACHE 2>>"$LOG"; then -dbcache=$BTC_DBCACHE 2>>"$LOG"; then
@ -163,7 +251,7 @@ fi
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-mempool-db|mysql-mempool'; then if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-mempool-db|mysql-mempool'; then
log "Creating mysql-mempool..." log "Creating mysql-mempool..."
mkdir -p /var/lib/archipelago/mysql-mempool mkdir -p /var/lib/archipelago/mysql-mempool
$DOCKER run -d --name archy-mempool-db --restart unless-stopped --network archy-net \ $DOCKER run -d --name archy-mempool-db --restart unless-stopped --memory=$(mem_limit archy-mempool-db) --network archy-net \
-v /var/lib/archipelago/mysql-mempool:/var/lib/mysql \ -v /var/lib/archipelago/mysql-mempool:/var/lib/mysql \
-e MYSQL_DATABASE=mempool -e MYSQL_USER=mempool -e MYSQL_PASSWORD=$MEMPOOL_DB_PASS \ -e MYSQL_DATABASE=mempool -e MYSQL_USER=mempool -e MYSQL_PASSWORD=$MEMPOOL_DB_PASS \
-e MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASS \ -e MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASS \
@ -180,7 +268,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then
else else
log "Creating electrumx..." log "Creating electrumx..."
mkdir -p /var/lib/archipelago/electrumx mkdir -p /var/lib/archipelago/electrumx
$DOCKER run -d --name electrumx --restart unless-stopped --network archy-net \ $DOCKER run -d --name electrumx --restart unless-stopped --memory=$(mem_limit electrumx) --network archy-net \
-p 50001:50001 -v /var/lib/archipelago/electrumx:/data \ -p 50001:50001 -v /var/lib/archipelago/electrumx:/data \
-e DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/ \ -e DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/ \
-e COIN=Bitcoin -e DB_DIRECTORY=/data \ -e COIN=Bitcoin -e DB_DIRECTORY=/data \
@ -192,7 +280,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-api; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-api; then
log "Creating mempool-api..." log "Creating mempool-api..."
mkdir -p /var/lib/archipelago/mempool mkdir -p /var/lib/archipelago/mempool
$DOCKER run -d --name mempool-api --restart unless-stopped --network archy-net \ $DOCKER run -d --name mempool-api --restart unless-stopped --memory=$(mem_limit mempool-api) --network archy-net \
-p 8999:8999 -v /var/lib/archipelago/mempool:/data \ -p 8999:8999 -v /var/lib/archipelago/mempool:/data \
-e MEMPOOL_BACKEND=electrum -e ELECTRUM_HOST=electrumx -e ELECTRUM_PORT=50001 \ -e MEMPOOL_BACKEND=electrum -e ELECTRUM_HOST=electrumx -e ELECTRUM_PORT=50001 \
-e ELECTRUM_TLS_ENABLED=false -e CORE_RPC_HOST="$TARGET_IP" -e CORE_RPC_PORT=8332 \ -e ELECTRUM_TLS_ENABLED=false -e CORE_RPC_HOST="$TARGET_IP" -e CORE_RPC_PORT=8332 \
@ -204,7 +292,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-mempool-web|mempool-web'; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-mempool-web|mempool-web'; then
log "Creating mempool frontend..." log "Creating mempool frontend..."
$DOCKER run -d --name archy-mempool-web --restart unless-stopped --network archy-net \ $DOCKER run -d --name archy-mempool-web --restart unless-stopped --memory=$(mem_limit archy-mempool-web) --network archy-net \
-p 4080:8080 -e FRONTEND_HTTP_PORT=8080 -e BACKEND_MAINNET_HTTP_HOST=mempool-api \ -p 4080:8080 -e FRONTEND_HTTP_PORT=8080 -e BACKEND_MAINNET_HTTP_HOST=mempool-api \
docker.io/mempool/frontend:v2.5.0 2>>"$LOG" || true docker.io/mempool/frontend:v2.5.0 2>>"$LOG" || true
fi fi
@ -231,7 +319,7 @@ fi
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-btcpay-db|postgres-btcpay'; then if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-btcpay-db|postgres-btcpay'; then
log "Creating PostgreSQL for BTCPay..." log "Creating PostgreSQL for BTCPay..."
mkdir -p /var/lib/archipelago/postgres-btcpay mkdir -p /var/lib/archipelago/postgres-btcpay
$DOCKER run -d --name archy-btcpay-db --restart unless-stopped --network archy-net \ $DOCKER run -d --name archy-btcpay-db --restart unless-stopped --memory=$(mem_limit archy-btcpay-db) --network archy-net \
-v /var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data \ -v /var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data \
-e POSTGRES_DB=btcpay -e POSTGRES_USER=btcpay -e POSTGRES_PASSWORD=$BTCPAY_DB_PASS \ -e POSTGRES_DB=btcpay -e POSTGRES_USER=btcpay -e POSTGRES_PASSWORD=$BTCPAY_DB_PASS \
docker.io/postgres:15-alpine 2>>"$LOG" || true docker.io/postgres:15-alpine 2>>"$LOG" || true
@ -249,7 +337,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-nbxplorer; the
else else
log "Creating NBXplorer..." log "Creating NBXplorer..."
mkdir -p /var/lib/archipelago/nbxplorer mkdir -p /var/lib/archipelago/nbxplorer
$DOCKER run -d --name archy-nbxplorer --restart unless-stopped --network archy-net \ $DOCKER run -d --name archy-nbxplorer --restart unless-stopped --memory=$(mem_limit archy-nbxplorer) --network archy-net \
-p 32838:32838 -v /var/lib/archipelago/nbxplorer:/data \ -p 32838:32838 -v /var/lib/archipelago/nbxplorer:/data \
-e NBXPLORER_DATADIR=/data -e NBXPLORER_NETWORK=mainnet -e NBXPLORER_CHAINS=btc \ -e NBXPLORER_DATADIR=/data -e NBXPLORER_NETWORK=mainnet -e NBXPLORER_CHAINS=btc \
-e NBXPLORER_BIND=0.0.0.0:32838 -e NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 \ -e NBXPLORER_BIND=0.0.0.0:32838 -e NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 \
@ -262,7 +350,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then
log "Creating BTCPay Server..." log "Creating BTCPay Server..."
mkdir -p /var/lib/archipelago/btcpay mkdir -p /var/lib/archipelago/btcpay
$DOCKER run -d --name btcpay-server --restart unless-stopped --network archy-net \ $DOCKER run -d --name btcpay-server --restart unless-stopped --memory=$(mem_limit btcpay-server) --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 23000:49392 -v /var/lib/archipelago/btcpay:/datadir \ -p 23000:49392 -v /var/lib/archipelago/btcpay:/datadir \
@ -312,7 +400,7 @@ autopilot.active=false
LNDCONF LNDCONF
log "LND config created (archy-net → bitcoin-knots:8332, rpcpolling)" log "LND config created (archy-net → bitcoin-knots:8332, rpcpolling)"
fi fi
$DOCKER run -d --name lnd --restart unless-stopped --network archy-net \ $DOCKER run -d --name lnd --restart unless-stopped --memory=$(mem_limit lnd) --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 9735:9735 -p 10009:10009 -p 8080:8080 \ -p 9735:9735 -p 10009:10009 -p 8080:8080 \
@ -324,7 +412,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint; then
log "Creating Fedimint..." log "Creating Fedimint..."
mkdir -p /var/lib/archipelago/fedimint mkdir -p /var/lib/archipelago/fedimint
$DOCKER run -d --name fedimint --restart unless-stopped --network archy-net \ $DOCKER run -d --name fedimint --restart unless-stopped --memory=$(mem_limit fedimint) --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 8173:8173 -p 8174:8174 -p 8175:8175 \ -p 8173:8173 -p 8174:8174 -p 8175:8175 \
@ -346,7 +434,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint-gateway; th
LND_MACAROON=/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon LND_MACAROON=/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon
if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^lnd$' && [ -f "$LND_CERT" ] && [ -f "$LND_MACAROON" ]; then if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^lnd$' && [ -f "$LND_CERT" ] && [ -f "$LND_MACAROON" ]; then
log " LND detected — using lnd mode" log " LND detected — using lnd mode"
$DOCKER run -d --name fedimint-gateway --restart unless-stopped --network archy-net \ $DOCKER run -d --name fedimint-gateway --restart unless-stopped --memory=$(mem_limit fedimint-gateway) --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 8176:8176 \ -p 8176:8176 \
@ -361,7 +449,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint-gateway; th
lnd --lnd-rpc-host "$TARGET_IP":10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/admin.macaroon 2>>"$LOG" || true lnd --lnd-rpc-host "$TARGET_IP":10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/admin.macaroon 2>>"$LOG" || true
else else
log " No LND found — using ldk (built-in Lightning)" log " No LND found — using ldk (built-in Lightning)"
$DOCKER run -d --name fedimint-gateway --restart unless-stopped --network archy-net \ $DOCKER run -d --name fedimint-gateway --restart unless-stopped --memory=$(mem_limit fedimint-gateway) --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 8176:8176 -p 9737:9737 \ -p 8176:8176 -p 9737:9737 \
@ -383,7 +471,7 @@ sleep 5 # Let core services stabilize
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'homeassistant|home-assistant'; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'homeassistant|home-assistant'; then
log "Creating Home Assistant..." log "Creating Home Assistant..."
mkdir -p /var/lib/archipelago/home-assistant mkdir -p /var/lib/archipelago/home-assistant
$DOCKER run -d --name homeassistant --restart unless-stopped \ $DOCKER run -d --name homeassistant --restart unless-stopped --memory=$(mem_limit homeassistant) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 8123:8123 -v /var/lib/archipelago/home-assistant:/config \ -p 8123:8123 -v /var/lib/archipelago/home-assistant:/config \
@ -396,7 +484,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q grafana; then
log "Creating Grafana..." log "Creating Grafana..."
mkdir -p /var/lib/archipelago/grafana mkdir -p /var/lib/archipelago/grafana
chown 472:472 /var/lib/archipelago/grafana 2>/dev/null || true chown 472:472 /var/lib/archipelago/grafana 2>/dev/null || true
$DOCKER run -d --name grafana --restart unless-stopped \ $DOCKER run -d --name grafana --restart unless-stopped --memory=$(mem_limit grafana) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \ --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \ --read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \
@ -407,7 +495,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q uptime-kuma; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q uptime-kuma; then
log "Creating Uptime Kuma..." log "Creating Uptime Kuma..."
mkdir -p /var/lib/archipelago/uptime-kuma mkdir -p /var/lib/archipelago/uptime-kuma
$DOCKER run -d --name uptime-kuma --restart unless-stopped \ $DOCKER run -d --name uptime-kuma --restart unless-stopped --memory=$(mem_limit uptime-kuma) \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID \ --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 3001:3001 -v /var/lib/archipelago/uptime-kuma:/app/data \ -p 3001:3001 -v /var/lib/archipelago/uptime-kuma:/app/data \
@ -417,7 +505,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q jellyfin; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q jellyfin; then
log "Creating Jellyfin..." log "Creating Jellyfin..."
mkdir -p /var/lib/archipelago/jellyfin/config /var/lib/archipelago/jellyfin/cache mkdir -p /var/lib/archipelago/jellyfin/config /var/lib/archipelago/jellyfin/cache
$DOCKER run -d --name jellyfin --restart unless-stopped \ $DOCKER run -d --name jellyfin --restart unless-stopped --memory=$(mem_limit jellyfin) \
--cap-drop ALL --security-opt no-new-privileges:true \ --cap-drop ALL --security-opt no-new-privileges:true \
-p 8096:8096 \ -p 8096:8096 \
-v /var/lib/archipelago/jellyfin/config:/config \ -v /var/lib/archipelago/jellyfin/config:/config \
@ -427,7 +515,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q photoprism; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q photoprism; then
log "Creating PhotoPrism..." log "Creating PhotoPrism..."
mkdir -p /var/lib/archipelago/photoprism mkdir -p /var/lib/archipelago/photoprism
$DOCKER run -d --name photoprism --restart unless-stopped \ $DOCKER run -d --name photoprism --restart unless-stopped --memory=$(mem_limit photoprism) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \ --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 2342:2342 -v /var/lib/archipelago/photoprism:/photoprism/storage \ -p 2342:2342 -v /var/lib/archipelago/photoprism:/photoprism/storage \
@ -437,7 +525,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q ollama; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q ollama; then
log "Creating Ollama..." log "Creating Ollama..."
mkdir -p /var/lib/archipelago/ollama mkdir -p /var/lib/archipelago/ollama
$DOCKER run -d --name ollama --restart unless-stopped \ $DOCKER run -d --name ollama --restart unless-stopped --memory=$(mem_limit ollama) \
--cap-drop ALL --security-opt no-new-privileges:true \ --cap-drop ALL --security-opt no-new-privileges:true \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \ --read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \
-p 11434:11434 -v /var/lib/archipelago/ollama:/root/.ollama \ -p 11434:11434 -v /var/lib/archipelago/ollama:/root/.ollama \
@ -446,7 +534,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q vaultwarden; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q vaultwarden; then
log "Creating Vaultwarden..." log "Creating Vaultwarden..."
mkdir -p /var/lib/archipelago/vaultwarden mkdir -p /var/lib/archipelago/vaultwarden
$DOCKER run -d --name vaultwarden --restart unless-stopped \ $DOCKER run -d --name vaultwarden --restart unless-stopped --memory=$(mem_limit vaultwarden) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \ --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 8082:80 -v /var/lib/archipelago/vaultwarden:/data \ -p 8082:80 -v /var/lib/archipelago/vaultwarden:/data \
@ -455,7 +543,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nextcloud; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nextcloud; then
log "Creating Nextcloud..." log "Creating Nextcloud..."
mkdir -p /var/lib/archipelago/nextcloud mkdir -p /var/lib/archipelago/nextcloud
$DOCKER run -d --name nextcloud --restart unless-stopped \ $DOCKER run -d --name nextcloud --restart unless-stopped --memory=$(mem_limit nextcloud) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 8085:80 -v /var/lib/archipelago/nextcloud:/var/www/html \ -p 8085:80 -v /var/lib/archipelago/nextcloud:/var/www/html \
@ -463,7 +551,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nextcloud; then
fi fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q searxng; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q searxng; then
log "Creating SearXNG..." log "Creating SearXNG..."
$DOCKER run -d --name searxng --restart unless-stopped \ $DOCKER run -d --name searxng --restart unless-stopped --memory=$(mem_limit searxng) \
--cap-drop ALL --security-opt no-new-privileges:true \ --cap-drop ALL --security-opt no-new-privileges:true \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \ --read-only --tmpfs /tmp:rw,noexec,nosuid,size=256m --tmpfs /run:rw,noexec,nosuid,size=64m \
-p 8888:8080 \ -p 8888:8080 \
@ -471,7 +559,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q searxng; then
fi fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q onlyoffice; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q onlyoffice; then
log "Creating OnlyOffice..." log "Creating OnlyOffice..."
$DOCKER run -d --name onlyoffice --restart unless-stopped \ $DOCKER run -d --name onlyoffice --restart unless-stopped --memory=$(mem_limit onlyoffice) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 9980:80 \ -p 9980:80 \
@ -480,14 +568,14 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q filebrowser; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q filebrowser; then
log "Creating File Browser..." log "Creating File Browser..."
mkdir -p /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-db mkdir -p /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-db
$DOCKER run -d --name filebrowser --restart unless-stopped \ $DOCKER run -d --name filebrowser --restart unless-stopped --memory=$(mem_limit filebrowser) \
-p 8083:80 -v /var/lib/archipelago/filebrowser:/srv \ -p 8083:80 -v /var/lib/archipelago/filebrowser:/srv \
docker.io/filebrowser/filebrowser:v2.27.0 2>>"$LOG" || true docker.io/filebrowser/filebrowser:v2.27.0 2>>"$LOG" || true
fi fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nginx-proxy-manager; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nginx-proxy-manager; then
log "Creating Nginx Proxy Manager..." log "Creating Nginx Proxy Manager..."
mkdir -p /var/lib/archipelago/nginx-proxy-manager/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt mkdir -p /var/lib/archipelago/nginx-proxy-manager/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt
$DOCKER run -d --name nginx-proxy-manager --restart unless-stopped \ $DOCKER run -d --name nginx-proxy-manager --restart unless-stopped --memory=$(mem_limit nginx-proxy-manager) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \ --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 81:81 -p 8084:80 -p 8443:443 \ -p 81:81 -p 8084:80 -p 8443:443 \
@ -498,7 +586,7 @@ fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q portainer; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q portainer; then
log "Creating Portainer..." log "Creating Portainer..."
mkdir -p /var/lib/archipelago/portainer mkdir -p /var/lib/archipelago/portainer
$DOCKER run -d --name portainer --restart unless-stopped \ $DOCKER run -d --name portainer --restart unless-stopped --memory=$(mem_limit portainer) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 9000:9000 \ -p 9000:9000 \
@ -510,7 +598,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q tailscale; then
log "Creating Tailscale..." log "Creating Tailscale..."
mkdir -p /var/lib/archipelago/tailscale mkdir -p /var/lib/archipelago/tailscale
# Tailscale needs NET_ADMIN + NET_RAW + TUN device (no --privileged) # Tailscale needs NET_ADMIN + NET_RAW + TUN device (no --privileged)
$DOCKER run -d --name tailscale --restart unless-stopped \ $DOCKER run -d --name tailscale --restart unless-stopped --memory=$(mem_limit tailscale) \
--network host \ --network host \
--cap-drop=ALL \ --cap-drop=ALL \
--cap-add=NET_ADMIN \ --cap-add=NET_ADMIN \
@ -537,7 +625,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
mkdir -p /var/lib/archipelago/immich /var/lib/archipelago/immich-db mkdir -p /var/lib/archipelago/immich /var/lib/archipelago/immich-db
$DOCKER network create immich-net 2>/dev/null || true $DOCKER network create immich-net 2>/dev/null || true
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q immich_postgres; then if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q immich_postgres; then
$DOCKER run -d --name immich_postgres --restart unless-stopped --network immich-net \ $DOCKER run -d --name immich_postgres --restart unless-stopped --memory=$(mem_limit immich_postgres) --network immich-net \
-v /var/lib/archipelago/immich-db:/var/lib/postgresql/data \ -v /var/lib/archipelago/immich-db:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=$IMMICH_DB_PASS -e POSTGRES_USER=postgres -e POSTGRES_DB=immich \ -e POSTGRES_PASSWORD=$IMMICH_DB_PASS -e POSTGRES_USER=postgres -e POSTGRES_DB=immich \
ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 2>>"$LOG" || true ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 2>>"$LOG" || true
@ -548,12 +636,12 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
done done
fi fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_redis; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_redis; then
$DOCKER run -d --name immich_redis --restart unless-stopped --network immich-net \ $DOCKER run -d --name immich_redis --restart unless-stopped --memory=$(mem_limit immich_redis) --network immich-net \
docker.io/valkey/valkey:7-alpine 2>>"$LOG" || true docker.io/valkey/valkey:7-alpine 2>>"$LOG" || true
sleep 2 sleep 2
fi fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
$DOCKER run -d --name immich_server --restart unless-stopped --network immich-net \ $DOCKER run -d --name immich_server --restart unless-stopped --memory=$(mem_limit immich_server) --network immich-net \
-p 2283:2283 -v /var/lib/archipelago/immich:/usr/src/app/upload \ -p 2283:2283 -v /var/lib/archipelago/immich:/usr/src/app/upload \
-e DB_HOSTNAME=immich_postgres -e DB_USERNAME=postgres -e DB_PASSWORD=$IMMICH_DB_PASS \ -e DB_HOSTNAME=immich_postgres -e DB_USERNAME=postgres -e DB_PASSWORD=$IMMICH_DB_PASS \
-e DB_DATABASE_NAME=immich -e REDIS_HOSTNAME=immich_redis \ -e DB_DATABASE_NAME=immich -e REDIS_HOSTNAME=immich_redis \
@ -568,20 +656,20 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; the
mkdir -p /var/lib/archipelago/penpot-assets /var/lib/archipelago/penpot-postgres mkdir -p /var/lib/archipelago/penpot-assets /var/lib/archipelago/penpot-postgres
$DOCKER network create penpot-net 2>/dev/null || true $DOCKER network create penpot-net 2>/dev/null || true
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q penpot-postgres; then if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q penpot-postgres; then
$DOCKER run -d --name penpot-postgres --restart unless-stopped --network penpot-net \ $DOCKER run -d --name penpot-postgres --restart unless-stopped --memory=$(mem_limit penpot-postgres) --network penpot-net \
-v /var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data \ -v /var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data \
-e POSTGRES_DB=penpot -e POSTGRES_USER=penpot -e POSTGRES_PASSWORD=$PENPOT_DB_PASS \ -e POSTGRES_DB=penpot -e POSTGRES_USER=penpot -e POSTGRES_PASSWORD=$PENPOT_DB_PASS \
docker.io/postgres:15 2>>"$LOG" || true docker.io/postgres:15 2>>"$LOG" || true
sleep 5 sleep 5
fi fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-valkey; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-valkey; then
$DOCKER run -d --name penpot-valkey --restart unless-stopped --network penpot-net \ $DOCKER run -d --name penpot-valkey --restart unless-stopped --memory=$(mem_limit penpot-valkey) --network penpot-net \
-e VALKEY_EXTRA_FLAGS="--maxmemory 128mb --maxmemory-policy volatile-lfu" \ -e VALKEY_EXTRA_FLAGS="--maxmemory 128mb --maxmemory-policy volatile-lfu" \
docker.io/valkey/valkey:8.1 2>>"$LOG" || true docker.io/valkey/valkey:8.1 2>>"$LOG" || true
sleep 3 sleep 3
fi fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-backend; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-backend; then
$DOCKER run -d --name penpot-backend --restart unless-stopped --network penpot-net \ $DOCKER run -d --name penpot-backend --restart unless-stopped --memory=$(mem_limit penpot-backend) --network penpot-net \
-v /var/lib/archipelago/penpot-assets:/opt/data/assets \ -v /var/lib/archipelago/penpot-assets:/opt/data/assets \
-e PENPOT_PUBLIC_URI="http://${TARGET_IP}:9001" \ -e PENPOT_PUBLIC_URI="http://${TARGET_IP}:9001" \
-e PENPOT_SECRET_KEY=archipelago-penpot-secret-key-change-in-production \ -e PENPOT_SECRET_KEY=archipelago-penpot-secret-key-change-in-production \
@ -595,7 +683,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; the
sleep 5 sleep 5
fi fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-exporter; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-exporter; then
$DOCKER run -d --name penpot-exporter --restart unless-stopped --network penpot-net \ $DOCKER run -d --name penpot-exporter --restart unless-stopped --memory=$(mem_limit penpot-exporter) --network penpot-net \
-e PENPOT_SECRET_KEY=archipelago-penpot-secret-key-change-in-production \ -e PENPOT_SECRET_KEY=archipelago-penpot-secret-key-change-in-production \
-e PENPOT_PUBLIC_URI=http://penpot-frontend:8080 \ -e PENPOT_PUBLIC_URI=http://penpot-frontend:8080 \
-e PENPOT_REDIS_URI=redis://penpot-valkey/0 \ -e PENPOT_REDIS_URI=redis://penpot-valkey/0 \
@ -603,7 +691,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; the
sleep 2 sleep 2
fi fi
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q penpot-frontend; then
$DOCKER run -d --name penpot-frontend --restart unless-stopped --network penpot-net \ $DOCKER run -d --name penpot-frontend --restart unless-stopped --memory=$(mem_limit penpot-frontend) --network penpot-net \
-p 9001:8080 -v /var/lib/archipelago/penpot-assets:/opt/data/assets \ -p 9001:8080 -v /var/lib/archipelago/penpot-assets:/opt/data/assets \
-e PENPOT_PUBLIC_URI="http://${TARGET_IP}:9001" \ -e PENPOT_PUBLIC_URI="http://${TARGET_IP}:9001" \
-e PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies \ -e PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies \
@ -617,7 +705,7 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'nos
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nostr-rs-relay; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nostr-rs-relay; then
log "Creating nostr-rs-relay..." log "Creating nostr-rs-relay..."
mkdir -p /var/lib/archipelago/nostr-rs-relay mkdir -p /var/lib/archipelago/nostr-rs-relay
$DOCKER run -d --name nostr-rs-relay --restart unless-stopped \ $DOCKER run -d --name nostr-rs-relay --restart unless-stopped --memory=$(mem_limit nostr-rs-relay) \
-p 7047:7047 -v /var/lib/archipelago/nostr-rs-relay:/data \ -p 7047:7047 -v /var/lib/archipelago/nostr-rs-relay:/data \
scsibug/nostr-rs-relay:latest 2>>"$LOG" || true scsibug/nostr-rs-relay:latest 2>>"$LOG" || true
fi fi
@ -626,7 +714,7 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'str
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q strfry; then if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q strfry; then
log "Creating strfry..." log "Creating strfry..."
mkdir -p /var/lib/archipelago/strfry mkdir -p /var/lib/archipelago/strfry
$DOCKER run -d --name strfry --restart unless-stopped \ $DOCKER run -d --name strfry --restart unless-stopped --memory=$(mem_limit strfry) \
-p 7777:7777 -v /var/lib/archipelago/strfry:/data \ -p 7777:7777 -v /var/lib/archipelago/strfry:/data \
hoytech/strfry:latest 2>>"$LOG" || true hoytech/strfry:latest 2>>"$LOG" || true
fi fi
@ -644,7 +732,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q indeedhub; then
fi fi
if [ -n "$INDEEDHUB_IMAGE" ]; then if [ -n "$INDEEDHUB_IMAGE" ]; then
log "Creating Indeehub from $INDEEDHUB_IMAGE..." log "Creating Indeehub from $INDEEDHUB_IMAGE..."
$DOCKER run -d --name indeedhub --restart unless-stopped \ $DOCKER run -d --name indeedhub --restart unless-stopped --memory=$(mem_limit indeedhub) \
--cap-drop ALL --security-opt no-new-privileges:true \ --cap-drop ALL --security-opt no-new-privileges:true \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m --tmpfs /app/.next/cache:rw,noexec,nosuid,size=128m \ --read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m --tmpfs /app/.next/cache:rw,noexec,nosuid,size=128m \
-p 7777:7777 \ -p 7777:7777 \

View File

@ -21,13 +21,38 @@ chmod 755 "$SSL_DIR"
# Generate self-signed cert if missing (valid 365 days) # Generate self-signed cert if missing (valid 365 days)
# SAN includes common dev IPs so cert works when accessing via IP # SAN includes common dev IPs so cert works when accessing via IP
# Build dynamic SAN with all node IPs (LAN + Tailscale + loopback)
SAN_IPS="DNS:archipelago.local,DNS:localhost,IP:127.0.0.1"
# Add all IPv4 addresses on this machine (LAN, Tailscale, etc.)
for ip in $(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.' | grep -v '^127\.'); do
SAN_IPS="$SAN_IPS,IP:$ip"
done
# Always include common LAN IPs as fallback
for ip in 192.168.1.228 192.168.1.198 10.0.0.1; do
echo "$SAN_IPS" | grep -q "$ip" || SAN_IPS="$SAN_IPS,IP:$ip"
done
# Regenerate cert if missing OR if current cert doesn't include this node's primary IP
REGEN=false
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
REGEN=true
else
# Check if cert has this node's primary IP
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$MY_IP" ] && ! openssl x509 -in "$CERT" -noout -text 2>/dev/null | grep -q "$MY_IP"; then
echo " Certificate missing this node's IP ($MY_IP) — regenerating..."
REGEN=true
fi
fi
if [ "$REGEN" = true ]; then
echo "Generating self-signed certificate for PWA (HTTPS)..." echo "Generating self-signed certificate for PWA (HTTPS)..."
echo " SAN: $SAN_IPS"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$KEY" \ -keyout "$KEY" \
-out "$CERT" \ -out "$CERT" \
-subj "/CN=archipelago.local/O=Archipelago/C=US" \ -subj "/CN=archipelago.local/O=Archipelago/C=US" \
-addext "subjectAltName=DNS:archipelago.local,DNS:localhost,IP:127.0.0.1,IP:192.168.1.228,IP:192.168.1.198,IP:10.0.0.1" -addext "subjectAltName=$SAN_IPS"
chmod 644 "$CERT" chmod 644 "$CERT"
chmod 600 "$KEY" chmod 600 "$KEY"
echo " Certificate created at $CERT" echo " Certificate created at $CERT"
@ -74,8 +99,9 @@ fi
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null; then if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null; then
echo "HTTPS already configured in nginx." echo "HTTPS already configured in nginx."
nginx -t 2>/dev/null && systemctl reload nginx nginx -t 2>/dev/null && systemctl reload nginx
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
echo "" echo ""
echo "PWA: Use https://192.168.1.228 (not http) - accept cert once, then Install app." echo "PWA: Use https://${MY_IP:-192.168.1.228} (not http) - accept cert once, then Install app."
exit 0 exit 0
fi fi
@ -250,4 +276,5 @@ echo "Added HTTPS (port 443) to nginx config."
# Test and reload # Test and reload
nginx -t && systemctl reload nginx nginx -t && systemctl reload nginx
echo "" echo ""
echo "HTTPS enabled. PWA install: https://192.168.1.228 (accept the certificate warning once, then Install app)." MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
echo "HTTPS enabled. PWA install: https://${MY_IP:-192.168.1.228} (accept the certificate warning once, then Install app)."