ec2b21d8f7
Closes the gap from :2 where Connect Zaprite swapped the
in-memory provider but BTCPay would silently re-take active on
the next daemon restart (because the boot-time loader picked
BTCPay first whenever btcpay_config was present, regardless of
operator intent).
What changed:
**New settings key `active_payment_provider`** in the existing
settings table. Records the operator's last explicit choice
('btcpay' | 'zaprite' | NULL = no preference). Both
btcpay_config and zaprite_config can coexist; the flag is what
determines which one the daemon loads.
**Boot-time loader respects the preference.** main.rs now reads
the flag at startup. If set to 'zaprite', Zaprite wins; if set to
'btcpay', BTCPay wins; if unset (legacy installs), falls back to
the previous BTCPay-first ordering. Cross-load fallbacks log a
WARN and try the other provider — operators with a stale flag
pointing at a wiped config don't boot unconfigured.
**Connect endpoints write the preference.**
- finish_connect (BTCPay) now sets the flag to 'btcpay' on
successful authorize-callback completion.
- ZapriteAuthorize::connect now sets the flag to 'zaprite' on
successful API-key validation.
- Both Disconnect endpoints clear the flag IF it pointed at the
provider being disconnected — but leave it alone if it pointed
at the OTHER provider (different operator intent).
**New endpoints for fast switching without re-Connect:**
- GET /v1/admin/payment-provider/status — both configs' state +
current preference + runtime active provider, in one call.
- POST /v1/admin/payment-provider/activate { provider: "btcpay" |
"zaprite" } — flips the active provider and the flag together,
without going through the full Connect flow. 400 if the named
provider isn't configured (operator must run Connect first).
**New StartOS Actions** under existing groups:
- "Activate BTCPay" (in BTCPay group)
- "Activate Zaprite" (in Zaprite group)
Both call the new activate endpoint. Operators with both
providers configured can flip back and forth in one click.
**Test:** payment_provider_preference_round_trip pre-seeds both
configs, walks through Activate-Zaprite → Activate-BTCPay →
attempt-Activate-on-wiped-config → bad-provider-name → manual
write/read of the preference key. Pins the contract.
Test count: 42 (was 41; +1).
Migration not needed — settings table from 0005 already has the
key/value/updated_at shape we need.
444 lines
16 KiB
Rust
444 lines
16 KiB
Rust
//! 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<dyn
|
|
//! PaymentProvider>` 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<AppState>,
|
|
headers: HeaderMap,
|
|
) -> AppResult<Json<ConnectResp>> {
|
|
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::<Vec<_>>()
|
|
.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<String>,
|
|
// 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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CallbackQuery {
|
|
pub state: String,
|
|
}
|
|
|
|
/// The real callback endpoint — POST form-encoded.
|
|
pub async fn callback(
|
|
State(state): State<AppState>,
|
|
Query(q): Query<CallbackQuery>,
|
|
Form(form): Form<CallbackForm>,
|
|
) -> AppResult<Response> {
|
|
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<String>,
|
|
/// Error message if BTCPay declined / operator clicked "Deny".
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
pub async fn callback_get(
|
|
State(state): State<AppState>,
|
|
Query(q): Query<CallbackGetQuery>,
|
|
) -> Response {
|
|
if let Some(err) = q.error {
|
|
return Html(format!(
|
|
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
|
|
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!(
|
|
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
|
|
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<AppState>,
|
|
headers: HeaderMap,
|
|
) -> AppResult<Json<Value>> {
|
|
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<AppState>,
|
|
headers: HeaderMap,
|
|
) -> AppResult<Json<Value>> {
|
|
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#"<!doctype html><html><head><meta charset="utf-8"><title>BTCPay connected</title>
|
|
<style>body{{font-family:system-ui,sans-serif;max-width:480px;margin:4rem auto;padding:1rem;line-height:1.5}}
|
|
h2{{color:#0a7}}</style></head>
|
|
<body><h2>✓ {msg}</h2></body></html>"#,
|
|
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<AppState>,
|
|
headers: HeaderMap,
|
|
) -> AppResult<Json<Value>> {
|
|
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<String> = 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,
|
|
})))
|
|
}
|