2026-04-18 17:23:46 -04:00
use super ::build_response ;
2026-03-22 03:30:21 +00:00
use crate ::config ::Config ;
2026-04-18 17:23:46 -04:00
use crate ::content_server ;
2026-03-22 03:30:21 +00:00
use anyhow ::Result ;
use hyper ::{ Response , StatusCode };
2026-04-18 17:23:46 -04:00
use super ::{ is_valid_app_id , ApiHandler };
2026-03-22 03:30:21 +00:00
impl ApiHandler {
pub ( super ) async fn handle_content_catalog ( config : & Config ) -> Result < Response < hyper ::Body >> {
match content_server ::load_catalog ( & config . data_dir ). await {
Ok ( catalog ) => {
// Only expose public metadata for available items
let items : Vec < serde_json ::Value > = catalog
. items
. iter ()
. filter ( | i | ! matches! ( i . availability , content_server ::Availability ::Nobody ))
. map ( | i | {
serde_json ::json! ({
"id" : i . id ,
"filename" : i . filename ,
"mime_type" : i . mime_type ,
"size_bytes" : i . size_bytes ,
"description" : i . description ,
"access" : i . access ,
})
})
. collect ();
2026-04-18 17:23:46 -04:00
let body =
serde_json ::to_vec ( & serde_json ::json! ({ "items" : items })). unwrap_or_default ();
Ok ( build_response (
StatusCode ::OK ,
"application/json" ,
hyper ::Body ::from ( body ),
))
2026-03-22 03:30:21 +00:00
}
Err ( e ) => {
let body = serde_json ::json! ({ "error" : e . to_string () });
let body_bytes = serde_json ::to_vec ( & body ). unwrap_or_default ();
2026-04-18 17:23:46 -04:00
Ok ( build_response (
StatusCode ::INTERNAL_SERVER_ERROR ,
"application/json" ,
hyper ::Body ::from ( body_bytes ),
))
2026-03-22 03:30:21 +00:00
}
}
}
pub ( super ) async fn handle_content_request (
path : & str ,
headers : & hyper ::HeaderMap ,
config : & Config ,
) -> Result < Response < hyper ::Body >> {
let content_id = path . strip_prefix ( "/content/" ). unwrap_or ( "" );
if content_id . is_empty () || ! is_valid_app_id ( content_id ) {
2026-04-18 17:23:46 -04:00
return Ok ( build_response (
StatusCode ::BAD_REQUEST ,
"text/plain" ,
hyper ::Body ::from ( "Invalid content ID" ),
));
2026-03-22 03:30:21 +00:00
}
// Extract payment token from X-Payment-Token header
let payment_token = headers
. get ( "x-payment-token" )
. and_then ( | v | v . to_str (). ok ())
. map ( | s | s . to_string ());
2026-06-17 19:21:07 -04:00
// Extract a paid-entitlement gate token from X-Invoice-Hash (Lightning)
// or X-Onchain-Address (on-chain) — both authorize the download if this
// node issued+settled them, and both resolve against the same shared
// entitlement store keyed by the token string (#46).
let invoice_hash = headers
. get ( "x-invoice-hash" )
. and_then ( | v | v . to_str (). ok ())
. map ( | s | s . to_string ())
. or_else ( || {
headers
. get ( "x-onchain-address" )
. and_then ( | v | v . to_str (). ok ())
. map ( | s | s . to_string ())
});
2026-03-22 03:30:21 +00:00
// Extract federation peer DID from X-Federation-DID header
let peer_did = headers
. get ( "x-federation-did" )
. and_then ( | v | v . to_str (). ok ())
. map ( | s | s . to_string ());
// Parse Range header for streaming support
let range = headers
. get ( "range" )
. and_then ( | v | v . to_str (). ok ())
. and_then ( content_server ::parse_range_header );
match content_server ::serve_content (
& config . data_dir ,
content_id ,
payment_token . as_deref (),
2026-06-17 19:21:07 -04:00
invoice_hash . as_deref (),
2026-03-22 03:30:21 +00:00
peer_did . as_deref (),
range ,
)
. await
{
Ok ( content_server ::ServeResult ::Ok ( bytes , mime_type )) => {
let len = bytes . len ();
Ok ( Response ::builder ()
. status ( StatusCode ::OK )
. header ( "Content-Type" , mime_type )
. header ( "Content-Length" , len . to_string ())
. header ( "Accept-Ranges" , "bytes" )
. body ( hyper ::Body ::from ( bytes ))
. unwrap ())
}
Ok ( content_server ::ServeResult ::Partial {
bytes ,
mime_type ,
start ,
end ,
total ,
2026-04-18 17:23:46 -04:00
}) => Ok ( Response ::builder ()
. status ( StatusCode ::PARTIAL_CONTENT )
. header ( "Content-Type" , mime_type )
. header ( "Content-Length" , bytes . len (). to_string ())
. header (
"Content-Range" ,
format! ( "bytes {} - {} / {} " , start , end , total ),
)
. header ( "Accept-Ranges" , "bytes" )
. body ( hyper ::Body ::from ( bytes ))
. unwrap ()),
2026-03-22 03:30:21 +00:00
Ok ( content_server ::ServeResult ::PaymentRequired ( price_sats )) => {
let body = serde_json ::json! ({
"error" : "Payment required" ,
"price_sats" : price_sats ,
"payment_header" : "X-Payment-Token" ,
});
let body_bytes = serde_json ::to_vec ( & body ). unwrap_or_default ();
2026-04-18 17:23:46 -04:00
Ok ( build_response (
StatusCode ::PAYMENT_REQUIRED ,
"application/json" ,
hyper ::Body ::from ( body_bytes ),
))
2026-03-22 03:30:21 +00:00
}
2026-04-18 17:23:46 -04:00
Ok ( content_server ::ServeResult ::Forbidden ) => Ok ( build_response (
StatusCode ::FORBIDDEN ,
"application/json" ,
2026-06-19 05:03:18 -04:00
hyper ::Body ::from (
r #"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"# ,
),
2026-04-18 17:23:46 -04:00
)),
Ok ( content_server ::ServeResult ::NotFound ) | Err ( _ ) => Ok ( build_response (
StatusCode ::NOT_FOUND ,
"text/plain" ,
hyper ::Body ::from ( "Content not found" ),
)),
2026-03-22 03:30:21 +00:00
}
}
2026-04-11 23:11:41 -04:00
2026-06-17 19:21:07 -04:00
/// Seller side (#46): mint a Lightning invoice for a paid catalog item so a
/// buyer can pay from any external wallet. Path: GET /content/{id}/invoice.
/// Records a pending entitlement keyed by the invoice's payment hash.
2026-06-17 19:50:46 -04:00
pub ( super ) async fn handle_content_invoice ( & self , path : & str ) -> Result < Response < hyper ::Body >> {
2026-06-17 19:21:07 -04:00
let content_id = path
. strip_prefix ( "/content/" )
. and_then ( | s | s . strip_suffix ( "/invoice" ))
. unwrap_or ( "" );
if content_id . is_empty () || ! is_valid_app_id ( content_id ) {
return Ok ( build_response (
StatusCode ::BAD_REQUEST ,
"text/plain" ,
hyper ::Body ::from ( "Invalid content ID" ),
));
}
let catalog = content_server ::load_catalog ( & self . config . data_dir )
. await
. unwrap_or_default ();
let item = match catalog . items . iter (). find ( | i | i . id == content_id ) {
Some ( i ) => i ,
None => {
return Ok ( build_response (
StatusCode ::NOT_FOUND ,
"text/plain" ,
hyper ::Body ::from ( "Content not found" ),
))
}
};
let price_sats = match & item . access {
2026-07-23 16:15:11 -04:00
content_server ::AccessControl ::Paid { price_sats , .. } => * price_sats ,
2026-06-17 19:21:07 -04:00
_ => {
// Not a paid item — no invoice to issue.
return Ok ( build_response (
StatusCode ::BAD_REQUEST ,
"application/json" ,
hyper ::Body ::from ( r #"{"error":"Item is not paid"}"# ),
));
}
};
2026-07-23 16:15:11 -04:00
if ! content_server ::method_accepted ( & item . access , "lightning" ) {
return Ok ( build_response (
StatusCode ::BAD_REQUEST ,
"application/json" ,
hyper ::Body ::from ( r #"{"error":"The seller does not accept Lightning for this item"}"# ),
));
}
2026-06-17 19:21:07 -04:00
let memo = format! ( "Archipelago peer file {content_id} " );
match self
. rpc_handler
. create_invoice ( price_sats as i64 , & memo )
. await
{
Ok (( bolt11 , payment_hash )) if ! payment_hash . is_empty () => {
crate ::content_invoice ::record_pending ( & payment_hash , content_id , price_sats ). await ;
let body = serde_json ::json! ({
"bolt11" : bolt11 ,
"payment_hash" : payment_hash ,
"price_sats" : price_sats ,
});
Ok ( build_response (
StatusCode ::OK ,
"application/json" ,
hyper ::Body ::from ( serde_json ::to_vec ( & body ). unwrap_or_default ()),
))
}
Ok ( _ ) => Ok ( build_response (
StatusCode ::INTERNAL_SERVER_ERROR ,
"application/json" ,
hyper ::Body ::from ( r #"{"error":"Invoice missing payment hash"}"# ),
)),
Err ( e ) => {
2026-06-18 09:23:56 -04:00
// Surface the FULL error chain ({:#}) — the generic top-level
// message hid the real cause (e.g. the LND REST connection
// failing), which made this 503 undiagnosable.
tracing ::warn! ( "content invoice creation failed: {e:#}" );
2026-06-17 19:21:07 -04:00
let body = serde_json ::json! ({
2026-06-18 09:23:56 -04:00
"error" : format ! ( "Could not create invoice: {e:#}" )
2026-06-17 19:21:07 -04:00
});
Ok ( build_response (
StatusCode ::SERVICE_UNAVAILABLE ,
"application/json" ,
hyper ::Body ::from ( serde_json ::to_vec ( & body ). unwrap_or_default ()),
))
}
}
}
/// Seller side (#46): report whether a previously-issued invoice has settled.
/// Path: GET /content/{id}/invoice-status/{payment_hash}. On settlement the
/// entitlement is marked paid so the buyer can then download the file.
pub ( super ) async fn handle_content_invoice_status (
& self ,
path : & str ,
) -> Result < Response < hyper ::Body >> {
let rest = path . strip_prefix ( "/content/" ). unwrap_or ( "" );
let ( content_id , payment_hash ) = match rest . split_once ( "/invoice-status/" ) {
Some (( id , hash )) => ( id , hash ),
None => {
return Ok ( build_response (
StatusCode ::BAD_REQUEST ,
"text/plain" ,
hyper ::Body ::from ( "Invalid request" ),
))
}
};
if content_id . is_empty () || ! is_valid_app_id ( content_id ) || payment_hash . is_empty () {
return Ok ( build_response (
StatusCode ::BAD_REQUEST ,
"text/plain" ,
hyper ::Body ::from ( "Invalid request" ),
));
}
// The hash must be one we issued for exactly this content item.
match crate ::content_invoice ::lookup ( payment_hash ). await {
Some (( cid , _ )) if cid == content_id => {}
_ => {
return Ok ( build_response (
StatusCode ::NOT_FOUND ,
"application/json" ,
hyper ::Body ::from ( r #"{"error":"Unknown invoice"}"# ),
))
}
}
// Already paid? Otherwise ask our LND and persist the result.
let mut paid = crate ::content_invoice ::is_paid_for ( payment_hash , content_id ). await ;
if ! paid {
if let Ok ( true ) = self . rpc_handler . invoice_is_settled ( payment_hash ). await {
crate ::content_invoice ::mark_paid ( payment_hash ). await ;
paid = true ;
}
}
let body = serde_json ::json! ({ "paid" : paid });
Ok ( build_response (
StatusCode ::OK ,
"application/json" ,
hyper ::Body ::from ( serde_json ::to_vec ( & body ). unwrap_or_default ()),
))
}
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
/// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a
/// pending entitlement keyed by the address; price doubles as expected amount.
2026-06-17 19:50:46 -04:00
pub ( super ) async fn handle_content_onchain ( & self , path : & str ) -> Result < Response < hyper ::Body >> {
2026-06-17 19:21:07 -04:00
let content_id = path
. strip_prefix ( "/content/" )
. and_then ( | s | s . strip_suffix ( "/onchain" ))
. unwrap_or ( "" );
if content_id . is_empty () || ! is_valid_app_id ( content_id ) {
return Ok ( build_response (
StatusCode ::BAD_REQUEST ,
"text/plain" ,
hyper ::Body ::from ( "Invalid content ID" ),
));
}
let catalog = content_server ::load_catalog ( & self . config . data_dir )
. await
. unwrap_or_default ();
let price_sats = match catalog . items . iter (). find ( | i | i . id == content_id ) {
Some ( i ) => match & i . access {
2026-07-23 16:15:11 -04:00
content_server ::AccessControl ::Paid { price_sats , .. } => {
if ! content_server ::method_accepted ( & i . access , "onchain" ) {
return Ok ( build_response (
StatusCode ::BAD_REQUEST ,
"application/json" ,
hyper ::Body ::from (
r #"{"error":"The seller does not accept on-chain payment for this item"}"# ,
),
));
}
* price_sats
}
2026-06-17 19:21:07 -04:00
_ => {
return Ok ( build_response (
StatusCode ::BAD_REQUEST ,
"application/json" ,
hyper ::Body ::from ( r #"{"error":"Item is not paid"}"# ),
))
}
},
None => {
return Ok ( build_response (
StatusCode ::NOT_FOUND ,
"text/plain" ,
hyper ::Body ::from ( "Content not found" ),
))
}
};
match self . rpc_handler . new_onchain_address (). await {
Ok ( address ) if ! address . is_empty () => {
crate ::content_invoice ::record_pending ( & address , content_id , price_sats ). await ;
let body = serde_json ::json! ({
"address" : address ,
"amount_sats" : price_sats ,
});
Ok ( build_response (
StatusCode ::OK ,
"application/json" ,
hyper ::Body ::from ( serde_json ::to_vec ( & body ). unwrap_or_default ()),
))
}
_ => {
let body = serde_json ::json! ({
"error" : "Could not generate an on-chain address (is the wallet ready?)"
});
Ok ( build_response (
StatusCode ::SERVICE_UNAVAILABLE ,
"application/json" ,
hyper ::Body ::from ( serde_json ::to_vec ( & body ). unwrap_or_default ()),
))
}
}
}
/// Seller side (#46): report whether an on-chain payment to a previously-
/// issued address has arrived (>= price, >= 1 conf). Path:
/// GET /content/{id}/onchain-status/{address}. Marks the entitlement paid.
pub ( super ) async fn handle_content_onchain_status (
& self ,
path : & str ,
) -> Result < Response < hyper ::Body >> {
let rest = path . strip_prefix ( "/content/" ). unwrap_or ( "" );
let ( content_id , address ) = match rest . split_once ( "/onchain-status/" ) {
Some (( id , addr )) => ( id , addr ),
None => {
return Ok ( build_response (
StatusCode ::BAD_REQUEST ,
"text/plain" ,
hyper ::Body ::from ( "Invalid request" ),
))
}
};
if content_id . is_empty () || ! is_valid_app_id ( content_id ) || address . is_empty () {
return Ok ( build_response (
StatusCode ::BAD_REQUEST ,
"text/plain" ,
hyper ::Body ::from ( "Invalid request" ),
));
}
// The address must be one we issued for exactly this content item.
let price = match crate ::content_invoice ::lookup ( address ). await {
Some (( cid , price )) if cid == content_id => price ,
_ => {
return Ok ( build_response (
StatusCode ::NOT_FOUND ,
"application/json" ,
hyper ::Body ::from ( r #"{"error":"Unknown address"}"# ),
))
}
};
let mut paid = crate ::content_invoice ::is_paid_for ( address , content_id ). await ;
if ! paid {
if let Ok ( true ) = self . rpc_handler . onchain_received ( address , price ). await {
crate ::content_invoice ::mark_paid ( address ). await ;
paid = true ;
}
}
let body = serde_json ::json! ({ "paid" : paid });
Ok ( build_response (
StatusCode ::OK ,
"application/json" ,
hyper ::Body ::from ( serde_json ::to_vec ( & body ). unwrap_or_default ()),
))
}
2026-04-11 23:11:41 -04:00
/// Serve a degraded preview of paid content (blurred image or first 2% of video).
pub ( super ) async fn handle_content_preview (
path : & str ,
config : & Config ,
) -> Result < Response < hyper ::Body >> {
// Path format: /content/{id}/preview
let content_id = path
. strip_prefix ( "/content/" )
. and_then ( | s | s . strip_suffix ( "/preview" ))
. unwrap_or ( "" );
if content_id . is_empty () || ! is_valid_app_id ( content_id ) {
2026-04-18 17:23:46 -04:00
return Ok ( build_response (
StatusCode ::BAD_REQUEST ,
"text/plain" ,
hyper ::Body ::from ( "Invalid content ID" ),
));
2026-04-11 23:11:41 -04:00
}
match content_server ::serve_content_preview ( & config . data_dir , content_id ). await {
Ok ( content_server ::PreviewResult ::FullContent ( bytes , mime_type )) => {
let len = bytes . len ();
Ok ( Response ::builder ()
. status ( StatusCode ::OK )
. header ( "Content-Type" , mime_type )
. header ( "Content-Length" , len . to_string ())
. body ( hyper ::Body ::from ( bytes ))
. unwrap ())
}
Ok ( content_server ::PreviewResult ::BlurPreview ( bytes , mime_type )) => {
let len = bytes . len ();
Ok ( Response ::builder ()
. status ( StatusCode ::OK )
. header ( "Content-Type" , mime_type )
. header ( "Content-Length" , len . to_string ())
. header ( "X-Content-Preview" , "blur" )
. body ( hyper ::Body ::from ( bytes ))
. unwrap ())
}
Ok ( content_server ::PreviewResult ::TruncatedPreview ( bytes , mime_type , total_size )) => {
let len = bytes . len ();
Ok ( Response ::builder ()
. status ( StatusCode ::OK )
. header ( "Content-Type" , mime_type )
. header ( "Content-Length" , len . to_string ())
. header ( "X-Content-Preview" , "truncated" )
. header ( "X-Content-Total-Size" , total_size . to_string ())
. body ( hyper ::Body ::from ( bytes ))
. unwrap ())
}
2026-06-17 19:21:07 -04:00
Ok ( content_server ::PreviewResult ::PreviewUnavailable ) => Ok ( Response ::builder ()
. status ( StatusCode ::UNSUPPORTED_MEDIA_TYPE )
. header ( "Content-Type" , "text/plain" )
. header ( "X-Content-Preview" , "unavailable" )
. body ( hyper ::Body ::from (
"Preview unavailable for this media (needs re-encoding)" ,
))
. unwrap ()),
2026-04-18 17:23:46 -04:00
Ok ( content_server ::PreviewResult ::NotFound ) | Err ( _ ) => Ok ( build_response (
StatusCode ::NOT_FOUND ,
"text/plain" ,
hyper ::Body ::from ( "Preview not available" ),
)),
2026-04-11 23:11:41 -04:00
}
}
2026-03-22 03:30:21 +00:00
}