//! BTCPay one-click authorize flow. //! //! Instead of making the operator generate an API key by hand and paste it //! into a form, we use BTCPay's "authorize" redirect flow: //! //! 1. Operator clicks "Connect BTCPay" in StartOS — the wrapper action //! calls `POST /v1/admin/btcpay/connect` (with the admin bearer token) //! and gets back a BTCPay URL to open in the operator's browser. //! 2. The operator, already logged into BTCPay on the same box, sees a //! consent page listing the permissions this service is requesting. They //! click **Authorize**. //! 3. BTCPay POSTs back to our `/v1/btcpay/authorize/callback` with the //! newly-minted API key and the store(s) it was scoped to. //! 4. We persist the key, pick the target store, register the webhook (with //! a freshly-generated secret), and save everything in `btcpay_config`. //! 5. From that moment on, the `BtcpayProvider` (held as an `Arc` in `AppState.payment`) is populated //! and purchase / webhook endpoints work. //! //! If the callback fails for any reason, the operator is shown an error page //! and can retry. The admin endpoint requires the admin bearer token; the //! callback path uses the CSRF `state` token to tie a callback back to the //! issuing operator session. use crate::api::{admin::require_admin, AppState}; use crate::btcpay::client::{self as btcpay_client, BtcpayClient}; use crate::btcpay::config as btcpay_cfg; use crate::error::{AppError, AppResult}; use crate::payment::btcpay::BtcpayProvider; use std::sync::Arc; use axum::{ extract::{Query, State}, http::{HeaderMap, StatusCode}, response::{Html, IntoResponse, Redirect, Response}, Form, Json, }; use data_encoding::BASE32_NOPAD; use rand::RngCore; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; /// Permissions we request on the authorize page. Each is namespaced by /// `btcpay.store.*` which means BTCPay will prompt the operator to pick /// which store(s) to grant. const REQUESTED_PERMISSIONS: &[&str] = &[ "btcpay.store.canviewstoresettings", "btcpay.store.canmodifystoresettings", // to register the webhook "btcpay.store.canviewinvoices", "btcpay.store.cancreateinvoice", "btcpay.store.canmodifyinvoices", ]; #[derive(Debug, Serialize)] pub struct ConnectResp { /// URL the operator should open in their browser to authorize. pub authorize_url: String, /// CSRF state token tied to this round trip. pub state: String, } /// Admin endpoint: starts a connect round trip. Returns the BTCPay authorize /// URL for the StartOS wrapper action to open in the operator's browser. pub async fn start_connect( State(state): State, headers: HeaderMap, ) -> AppResult> { require_admin(&state, &headers)?; // Idempotency: if BTCPay is already connected, refuse to issue a new // authorize URL. Re-clicking Connect today produces a duplicate // webhook subscription on BTCPay, which results in every payment // event being delivered to Keysat twice. Make the operator go // through Disconnect first if they really want to re-authorize. if let Ok(Some(existing)) = btcpay_cfg::load(&state.db).await { return Err(AppError::Conflict(format!( "BTCPay is already connected (store {}). Run 'Disconnect BTCPay' first if you need to re-authorize.", existing.store_id, ))); } // Random 20-byte token, base32-encoded, for the CSRF `state` parameter. let mut raw = [0u8; 20]; rand::thread_rng().fill_bytes(&mut raw); let state_token = BASE32_NOPAD.encode(&raw); btcpay_cfg::record_authorize_state(&state.db, &state_token) .await .map_err(AppError::Internal)?; // Construct the authorize URL per BTCPay's docs. // https://docs.btcpayserver.org/API/Greenfield/v1/#api-keys // // CSRF state must travel inside the `redirect` URL itself, NOT as a // separate query param on the outer authorize URL. Empirical // observation against BTCPay: arbitrary query params on the // authorize URL are NOT forwarded to the redirect target. The // redirect URL is preserved verbatim, so any params we encode INTO // it survive the round-trip. let redirect = format!( "{}/v1/btcpay/authorize/callback?state={}", state.config.public_base_url, urlencoding::encode(&state_token), ); let perm_params = REQUESTED_PERMISSIONS .iter() .map(|p| format!("permissions={}", urlencoding::encode(p))) .collect::>() .join("&"); // The authorize URL is followed by the operator's BROWSER, so the host // must be reachable from outside the container. Use the explicit // `btcpay_browser_url` if the wrapper provided it; fall back to // `btcpay_url` only for dev/local setups (where they're the same). let authorize_base = state .config .btcpay_browser_url .as_deref() .unwrap_or(&state.config.btcpay_url); let authorize_url = format!( "{}/api-keys/authorize?applicationName={}&applicationIdentifier={}&strict=true&selectiveStores=true&redirect={}&{perm_params}", authorize_base, urlencoding::encode("Keysat"), urlencoding::encode("keysat"), urlencoding::encode(&redirect), ); Ok(Json(ConnectResp { authorize_url, state: state_token, })) } /// Fields BTCPay sends back on the callback. BTCPay POSTs `apiKey`, /// `userId`, and `permissions[]` as a form body. It also preserves any /// query-string parameters on the redirect URL — we use that for `state`. #[derive(Debug, Deserialize)] pub struct CallbackForm { #[serde(rename = "apiKey")] pub api_key: String, #[serde(rename = "userId")] pub user_id: Option, // BTCPay posts `permissions` one-per-occurrence; serde_urlencoded turns // that into a repeated string. We don't actually need to parse them // individually — we just re-verify via list_stores. #[serde(default)] pub permissions: Vec, } #[derive(Debug, Deserialize)] pub struct CallbackQuery { pub state: String, } /// The real callback endpoint — POST form-encoded. pub async fn callback( State(state): State, Query(q): Query, Form(form): Form, ) -> AppResult { finish_connect(&state, &q.state, &form.api_key).await?; Ok(success_page("BTCPay connected successfully. You can close this tab and return to StartOS.")) } /// Some BTCPay deployments send the apiKey back as a query string on a GET. /// Handle that too for robustness. #[derive(Debug, Deserialize)] pub struct CallbackGetQuery { pub state: String, #[serde(rename = "apiKey")] pub api_key: Option, /// Error message if BTCPay declined / operator clicked "Deny". pub error: Option, } pub async fn callback_get( State(state): State, Query(q): Query, ) -> Response { if let Some(err) = q.error { return Html(format!( "

BTCPay authorization failed

{}

", html_escape::encode_text(&err) )) .into_response(); } let Some(api_key) = q.api_key else { // Some installs POST; in that case a bare GET with no apiKey is // possible if the operator refreshes the tab. Redirect to root. return Redirect::to("/").into_response(); }; match finish_connect(&state, &q.state, &api_key).await { Ok(()) => success_page( "BTCPay connected successfully. You can close this tab and return to StartOS.", ), Err(e) => Html(format!( "

BTCPay authorization failed

{}

", html_escape::encode_text(&e.to_string()) )) .into_response(), } } /// Admin endpoint: list payment methods configured on the connected /// BTCPay store. Proxies to BTCPay's `/api/v1/stores/{id}/payment-methods`. /// Used by the wrapper / future web UI to surface a "no wallet /// configured" state. pub async fn payment_methods( State(state): State, headers: HeaderMap, ) -> AppResult> { require_admin(&state, &headers)?; let cfg = btcpay_cfg::load(&state.db) .await .map_err(AppError::Internal)? .ok_or(AppError::BtcpayNotConfigured)?; let methods = btcpay_client::list_payment_methods(&cfg.base_url, &cfg.api_key, &cfg.store_id) .await .map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e}")))?; // Return both the raw array for callers that want detail, and a // boolean summary for the common "is anything configured?" check. let count = methods.len(); Ok(Json(json!({ "store_id": cfg.store_id, "count": count, "methods": methods, }))) } /// Admin endpoint: report current BTCPay connection status. pub async fn status( State(state): State, headers: HeaderMap, ) -> AppResult> { require_admin(&state, &headers)?; let cfg = btcpay_cfg::load(&state.db).await.map_err(AppError::Internal)?; Ok(Json(match cfg { None => json!({ "connected": false }), Some(c) => json!({ "connected": true, "store_id": c.store_id, "webhook_id": c.webhook_id, "base_url": c.base_url, }), })) } // --- internals --- async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> AppResult<()> { btcpay_cfg::consume_authorize_state(&state.db, state_token) .await .map_err(|_| AppError::Unauthorized)?; let base_url = &state.config.btcpay_url; // Enumerate stores the key has access to. With `selectiveStores=true` // the operator picked specific stores during authorize; we pick the // first one that the key can see. let stores = btcpay_client::list_stores(base_url, api_key) .await .map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e}")))?; let store = stores .into_iter() .next() .ok_or_else(|| AppError::BadRequest( "The authorized API key has access to zero stores. Re-run connect and pick a store.".into() ))?; // Generate a strong webhook secret, then register the webhook on BTCPay. let mut raw_secret = [0u8; 32]; rand::thread_rng().fill_bytes(&mut raw_secret); let webhook_secret = BASE32_NOPAD.encode(&raw_secret); let callback_url = format!("{}/v1/btcpay/webhook", state.config.public_base_url); let created_webhook = btcpay_client::create_webhook( base_url, api_key, &store.id, &callback_url, &webhook_secret, ) .await .map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e}")))?; // Persist. let cfg = btcpay_cfg::BtcpayConfig { base_url: base_url.clone(), api_key: api_key.to_string(), store_id: store.id.clone(), webhook_id: Some(created_webhook.id.clone()), webhook_secret: webhook_secret.clone(), }; btcpay_cfg::save(&state.db, &cfg) .await .map_err(AppError::Internal)?; // Swap runtime — wrap a fresh BtcpayProvider into the // PaymentProvider trait object held by AppState. Pass the // public-facing BTCPay URL too so that checkout URLs returned to // buyers get rewritten from the internal Docker hostname to a // browser-reachable host. let client = BtcpayClient::new(base_url, api_key, &store.id); let provider = Arc::new( BtcpayProvider::new(client, webhook_secret) .with_public_base(state.config.btcpay_public_url.clone()), ); state.set_payment_provider(provider).await; // Persist active-provider preference so the boot-time loader // picks BTCPay on next restart even if Zaprite's config row // is also still in the DB. Failure here is non-fatal (BTCPay // is the historical default, so the fallback loader picks it // anyway) but logged. if let Err(e) = crate::payment::write_active_provider_preference( &state.db, crate::payment::ProviderKind::Btcpay, ) .await { tracing::warn!(error = %e, "failed to record BTCPay as active payment provider"); } tracing::info!( store = %store.id, store_name = %store.name, webhook_id = %created_webhook.id, "BTCPay connected via authorize flow" ); Ok(()) } fn success_page(msg: &str) -> Response { let body = format!( r#"BTCPay connected

✓ {msg}

"#, msg = html_escape::encode_text(msg) ); (StatusCode::OK, Html(body)).into_response() } /// Admin endpoint: disconnect BTCPay. Best-effort revocation of the /// webhook + API key on BTCPay's side, then unconditional clear of the /// local config row. If BTCPay is unreachable, the local state is still /// cleared and the operator gets a warning to clean up BTCPay manually. pub async fn disconnect( State(state): State, headers: HeaderMap, ) -> AppResult> { let actor_hash = require_admin(&state, &headers)?; let (ip, ua) = crate::api::admin::request_context(&headers); let cfg = btcpay_cfg::load(&state.db) .await .map_err(AppError::Internal)?; let Some(cfg) = cfg else { return Ok(Json(json!({ "ok": true, "noop": true, "message": "BTCPay was not connected; nothing to do.", }))); }; // Capture metadata for the response BEFORE we clear local state. let store_id = cfg.store_id.clone(); let webhook_id = cfg.webhook_id.clone(); // Best-effort remote cleanup. We DON'T short-circuit if either of // these calls fails — the operator's intent is to disconnect, and // leaving local state pointing at a remote we no longer trust is // worse than leaving orphan state on the BTCPay side. Any failures // are surfaced in the response so the operator can manually clean // up on BTCPay if needed. let mut warnings: Vec = Vec::new(); if let Some(webhook_id) = webhook_id.as_deref() { if let Err(e) = btcpay_client::delete_webhook( &cfg.base_url, &cfg.api_key, &cfg.store_id, webhook_id, ) .await { warnings.push(format!( "Could not delete BTCPay webhook {webhook_id}: {e}. \ You may want to manually delete it in BTCPay's store webhook settings." )); } } if let Err(e) = btcpay_client::revoke_api_key(&cfg.base_url, &cfg.api_key).await { warnings.push(format!( "Could not revoke BTCPay API key: {e}. \ You may want to manually revoke it in BTCPay's account API-keys page." )); } btcpay_cfg::clear(&state.db) .await .map_err(AppError::Internal)?; // Replace the runtime payment provider so subsequent purchase // attempts return BtcpayNotConfigured cleanly. state.clear_payment_provider().await; // If BTCPay was the recorded active-provider preference, clear // it. Don't blindly clear if it was Zaprite — different operator // intent. if matches!( crate::payment::read_active_provider_preference(&state.db).await, Some(crate::payment::ProviderKind::Btcpay) ) { let _ = crate::db::repo::settings_set( &state.db, crate::payment::SETTING_ACTIVE_PROVIDER, None, ) .await; } let _ = crate::db::repo::insert_audit( &state.db, "admin_api_key", Some(&actor_hash), "btcpay.disconnect", Some("btcpay_config"), None, ip.as_deref(), ua.as_deref(), &json!({ "store_id": store_id, "webhook_id": webhook_id }), ) .await; Ok(Json(json!({ "ok": true, "noop": false, "store_id": store_id, "webhook_id": webhook_id, "warnings": warnings, }))) }