use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex;
use chrono::{DateTime, SecondsFormat, Utc};
use once_cell::sync::Lazy;
use rand::RngCore;
use regex::Regex;
use crate::envelope::{sign_invoice_envelope, sign_receipt, SignInvoiceOpts, SignReceiptOpts};
use crate::error::Error;
use crate::lightning::{InvoiceCreateRequest, LightningNode};
use crate::replay::ReplayCache;
use crate::token::{issue_token, verify_token};
static AUTH_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^L402\s+([^:\s]+):([0-9a-fA-F]+)$").unwrap());
#[derive(Debug, Clone, Default)]
pub struct PaywallResponse {
pub status: u16,
pub headers: HashMap<String, String>,
pub body: Option<Vec<u8>>,
pub json: Option<serde_json::Value>,
}
impl PaywallResponse {
pub fn header(&self, name: &str) -> Option<&str> {
let lower = name.to_ascii_lowercase();
self.headers
.iter()
.find(|(k, _)| k.to_ascii_lowercase() == lower)
.map(|(_, v)| v.as_str())
}
}
pub type InnerHandler = Arc<
dyn (Fn(
String,
HashMap<String, String>,
) -> Pin<Box<dyn Future<Output = Result<PaywallResponse, Error>> + Send>>)
+ Send
+ Sync,
>;
pub struct PaywallOptions {
pub server_did: String,
pub server_private_key: [u8; 32],
pub price_msat: u64,
pub resource: String,
pub lightning: Arc<dyn LightningNode>,
pub token_secret: Vec<u8>,
pub invoice_ttl_seconds: u64,
pub now: Box<dyn Fn() -> DateTime<Utc> + Send + Sync>,
pub replay: Option<Arc<ReplayCache>>,
}
impl PaywallOptions {
pub fn new(
server_did: impl Into<String>,
server_private_key: [u8; 32],
price_msat: u64,
resource: impl Into<String>,
lightning: Arc<dyn LightningNode>,
token_secret: Vec<u8>,
) -> Self {
Self {
server_did: server_did.into(),
server_private_key,
price_msat,
resource: resource.into(),
lightning,
token_secret,
invoice_ttl_seconds: 300,
now: Box::new(Utc::now),
replay: None,
}
}
}
pub struct Paywall {
opts: PaywallOptions,
replay: Arc<ReplayCache>,
issued: Mutex<HashMap<String, String>>, }
impl Paywall {
pub fn new(opts: PaywallOptions) -> Self {
let replay = opts
.replay
.clone()
.unwrap_or_else(|| Arc::new(ReplayCache::default()));
Self {
opts,
replay,
issued: Mutex::new(HashMap::new()),
}
}
pub async fn process_request(
&self,
path: &str,
headers: HashMap<String, String>,
inner: Option<InnerHandler>,
) -> Result<PaywallResponse, Error> {
let auth = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("authorization"))
.map(|(_, v)| v.clone());
let Some(auth) = auth else {
return self.challenge().await;
};
let Some(captures) = AUTH_RE.captures(&auth) else {
return self.challenge().await;
};
let token = captures.get(1).unwrap().as_str();
let preimage_hex = captures.get(2).unwrap().as_str();
let payload = match verify_token(token, &self.opts.token_secret).await {
Ok(p) => p,
Err(_) => return self.challenge().await,
};
if self.replay.is_used(&payload.payment_hash) {
return Ok(PaywallResponse {
status: 401,
json: Some(serde_json::json!({ "error": "preimage replayed" })),
..Default::default()
});
}
let lookup = self
.opts
.lightning
.lookup_invoice(&payload.payment_hash)
.await?;
if !lookup.settled || lookup.preimage.is_none() {
return Ok(PaywallResponse {
status: 401,
json: Some(serde_json::json!({ "error": "invoice not settled" })),
..Default::default()
});
}
let presented =
hex::decode(preimage_hex).map_err(|e| Error::Paywall(format!("preimage hex: {e}")))?;
let stored = lookup.preimage.unwrap();
if !constant_time_eq(&presented, &stored) {
return Ok(PaywallResponse {
status: 401,
json: Some(
serde_json::json!({ "error": "preimage does not match settled invoice" }),
),
..Default::default()
});
}
let expires_ms = parse_iso_ms(&payload.expires_at)?;
self.replay.mark_used(&payload.payment_hash, expires_ms);
let mut inner_resp = if let Some(inner) = inner {
(inner)(path.to_string(), headers).await?
} else {
PaywallResponse {
status: 200,
..Default::default()
}
};
let bolt11 = {
let guard = self.issued.lock().unwrap();
guard.get(&payload.payment_hash).cloned()
};
if let Some(bolt11) = bolt11 {
let paid_at = iso((self.opts.now)());
let receipt = sign_receipt(SignReceiptOpts {
bolt11: &bolt11,
did: &self.opts.server_did,
private_key: &self.opts.server_private_key,
preimage: &presented,
resource: &self.opts.resource,
paid_at: &paid_at,
})
.await?;
inner_resp
.headers
.insert("x-payment-receipt".into(), receipt);
}
Ok(inner_resp)
}
async fn challenge(&self) -> Result<PaywallResponse, Error> {
let ttl = self.opts.invoice_ttl_seconds;
let invoice = self
.opts
.lightning
.create_invoice(InvoiceCreateRequest {
amount_msat: self.opts.price_msat,
memo: None,
expiry_seconds: Some(ttl),
})
.await?;
self.issued
.lock()
.unwrap()
.insert(invoice.payment_hash.clone(), invoice.bolt11.clone());
let now = (self.opts.now)();
let expires_at_dt = now + chrono::Duration::seconds(ttl as i64);
let expires_at = iso(expires_at_dt);
let mut nonce = [0u8; 16];
rand::thread_rng().fill_bytes(&mut nonce);
let envelope = sign_invoice_envelope(SignInvoiceOpts {
bolt11: &invoice.bolt11,
did: &self.opts.server_did,
private_key: &self.opts.server_private_key,
price_msat: self.opts.price_msat,
resource: &self.opts.resource,
expires_at: &expires_at,
nonce: &nonce,
})
.await?;
let token =
issue_token(&invoice.payment_hash, &expires_at, &self.opts.token_secret).await?;
let mut headers = HashMap::new();
headers.insert(
"www-authenticate".into(),
format!("L402 macaroon=\"{token}\", invoice=\"{}\"", invoice.bolt11),
);
headers.insert("x-did-invoice".into(), envelope);
Ok(PaywallResponse {
status: 402,
headers,
body: None,
json: None,
})
}
}
fn iso(dt: DateTime<Utc>) -> String {
dt.to_rfc3339_opts(SecondsFormat::Millis, true)
}
fn parse_iso_ms(s: &str) -> Result<u64, Error> {
let dt = DateTime::parse_from_rfc3339(s).map_err(|e| Error::Paywall(format!("iso: {e}")))?;
Ok(dt.with_timezone(&Utc).timestamp_millis() as u64)
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut d = 0u8;
for i in 0..a.len() {
d |= a[i] ^ b[i];
}
d == 0
}