1#![doc = include_str!("../README.md")]
107#![deny(unsafe_code)]
108#![warn(rust_2018_idioms)]
109
110pub mod cli;
112
113mod handlers;
118
119mod mcp;
122
123pub mod mempool;
128
129pub mod trail_store;
135
136use std::collections::HashMap;
137use std::net::{IpAddr, Ipv4Addr};
138use std::path::{Path, PathBuf};
139use std::sync::{Arc, Mutex};
140use std::time::{Duration, Instant};
141
142use actix_web::body::{BoxBody, EitherBody};
143use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
144use actix_web::http::{header, StatusCode};
145use actix_web::middleware::{NormalizePath, TrailingSlash};
146use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
147use bytes::Bytes;
148use futures_util::future::{ready, LocalBoxFuture, Ready};
149use percent_encoding::percent_decode_str;
150use serde::Deserialize;
151use solid_pod_rs::{
152 auth::{nip98, replay::ReplayStore},
156 config::sources::parse_size,
157 interop,
158 ldp::{self, LdpContainerOps, PatchCreateOutcome},
159 mashlib::{self, MashlibConfig},
160 provision,
161 security::DotfileAllowlist,
162 storage::Storage,
163 wac::{
164 self, conditions::RequestContext, effective_acl_target, parse_jsonld_acl,
165 parser::parse_turtle_acl, protected_resource_for_acl, AccessMode,
166 },
167 PodError,
168};
169
170const _: () = solid_pod_rs::auth::nip98::assert_schnorr_verification_enabled();
182
183static NIP98_REPLAY: std::sync::LazyLock<solid_pod_rs::auth::replay::Nip98ReplayCache> =
190 std::sync::LazyLock::new(solid_pod_rs::auth::replay::Nip98ReplayCache::from_env);
191
192#[derive(Clone)]
198pub struct AppState {
199 pub storage: Arc<dyn Storage>,
200 pub dotfiles: Arc<DotfileAllowlist>,
201 pub body_cap: usize,
202 pub nodeinfo: NodeInfoMeta,
203 pub mashlib: MashlibConfig,
204 pub mashlib_cdn: Option<String>,
207 pub pay_config: solid_pod_rs::payments::PayConfig,
210 pub data_root: Option<PathBuf>,
215 pub pod_create_limiter: Arc<PodCreateLimiter>,
217 pub allowed_origins: Vec<String>,
224 pub admin_key: Option<String>,
229 pub mcp_enabled: bool,
234 pub mempool_url: Option<String>,
240 pub deposit_txo_standin_enabled: bool,
253}
254
255#[derive(Clone, Debug)]
257pub struct NodeInfoMeta {
258 pub software_name: String,
259 pub software_version: String,
260 pub open_registrations: bool,
261 pub total_users: u64,
262 pub base_url: String,
263}
264
265impl Default for NodeInfoMeta {
266 fn default() -> Self {
267 Self {
268 software_name: "solid-pod-rs-server".to_string(),
269 software_version: env!("CARGO_PKG_VERSION").to_string(),
270 open_registrations: false,
271 total_users: 0,
272 base_url: "http://localhost".to_string(),
273 }
274 }
275}
276
277pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
280
281pub fn body_cap_from_env() -> usize {
286 match std::env::var("JSS_MAX_REQUEST_BODY").or_else(|_| std::env::var("JSS_BODY_LIMIT")) {
287 Ok(v) => parse_size(&v)
288 .map(|u| u as usize)
289 .unwrap_or(DEFAULT_BODY_CAP),
290 Err(_) => DEFAULT_BODY_CAP,
291 }
292}
293
294impl AppState {
295 pub fn new(storage: Arc<dyn Storage>) -> Self {
298 Self {
299 storage,
300 dotfiles: Arc::new(DotfileAllowlist::from_env()),
301 body_cap: body_cap_from_env(),
302 nodeinfo: NodeInfoMeta::default(),
303 mashlib: MashlibConfig::default(),
304 mashlib_cdn: None,
305 pay_config: solid_pod_rs::payments::PayConfig::default(),
306 data_root: None,
307 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
308 allowed_origins: Vec::new(),
309 admin_key: None,
310 mcp_enabled: false,
311 mempool_url: None,
312 deposit_txo_standin_enabled: false,
315 }
316 }
317}
318
319#[derive(Debug)]
321pub struct PodCreateLimiter {
322 hits: Mutex<HashMap<IpAddr, Instant>>,
323 window: Duration,
324}
325
326impl Default for PodCreateLimiter {
327 fn default() -> Self {
328 Self {
329 hits: Mutex::new(HashMap::new()),
330 window: Duration::from_secs(24 * 60 * 60),
331 }
332 }
333}
334
335impl PodCreateLimiter {
336 fn check(&self, ip: IpAddr) -> Result<(), u64> {
337 let now = Instant::now();
338 let mut hits = self.hits.lock().unwrap();
339 if let Some(last) = hits.get(&ip).copied() {
340 let elapsed = now.saturating_duration_since(last);
341 if elapsed < self.window {
342 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
343 }
344 }
345 hits.insert(ip, now);
346 Ok(())
347 }
348}
349
350pub(crate) fn to_actix(e: PodError) -> ActixError {
355 match e {
356 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
357 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
358 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
359 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
360 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
361 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
362 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
363 }
364}
365
366pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
378 let header_val = req
379 .headers()
380 .get(header::AUTHORIZATION)
381 .and_then(|v| v.to_str().ok())?;
382 let url = {
393 let conn = req.connection_info();
394 format!("{}://{}{}", conn.scheme(), conn.host(), req.uri().path())
395 };
396 let now = std::time::SystemTime::now()
397 .duration_since(std::time::UNIX_EPOCH)
398 .map(|d| d.as_secs())
399 .unwrap_or(0);
400 let verified = nip98::verify_at(header_val, &url, req.method().as_str(), None, now).ok()?;
401
402 if NIP98_REPLAY
406 .check_and_record(&verified.event_id)
407 .await
408 .is_err()
409 {
410 tracing::warn!(
411 pubkey = %verified.pubkey,
412 method = %req.method(),
413 "NIP-98 replay rejected: token id already used within window"
414 );
415 return None;
416 }
417
418 Some(verified.pubkey)
419}
420
421pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
422 pubkey.map(|pk| format!("did:nostr:{pk}"))
423}
424
425fn req_origin(req: &HttpRequest) -> Option<&str> {
434 req.headers()
435 .get(header::ORIGIN)
436 .and_then(|v| v.to_str().ok())
437}
438
439pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
443
444async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
461 let did = agent_uri?;
462 let balance = match storage.get(WEBLEDGER_PATH).await {
463 Ok((bytes, _meta)) => {
464 match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
465 Ok(ledger) => ledger.get_balance(did),
466 Err(_) => 0,
470 }
471 }
472 Err(_) => 0,
475 };
476 Some(balance)
477}
478
479fn accept_includes_html(accept: &str) -> bool {
487 accept.split(',').any(|entry| {
488 let mime = entry.split(';').next().unwrap_or("").trim();
489 mime.eq_ignore_ascii_case("text/html")
490 })
491}
492
493fn proposed_acl_keeps_caller_control(
512 body: &[u8],
513 content_type: &str,
514 caller: Option<&str>,
515) -> bool {
516 let doc = match parse_jsonld_acl(body) {
517 Ok(d) => Some(d),
518 Err(_) => {
519 let ct = content_type.to_ascii_lowercase();
520 let text = std::str::from_utf8(body).unwrap_or("");
521 let looks_turtle = ct.starts_with("text/turtle")
522 || ct.starts_with("application/turtle")
523 || ct.starts_with("application/x-turtle")
524 || ct.starts_with("application/n-triples")
525 || text.contains("@prefix")
526 || text.contains("acl:Authorization")
527 || text.contains("auth/acl#Authorization");
531 if looks_turtle {
532 parse_turtle_acl(text).ok()
533 } else {
534 None
535 }
536 }
537 };
538 let Some(doc) = doc else {
539 return true;
541 };
542 let Some(graph) = doc.graph.as_ref() else {
543 return false;
544 };
545 graph.iter().any(|auth| {
546 let grants_control = ids_of_acl_field(&auth.mode)
547 .iter()
548 .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
549 if !grants_control {
550 return false;
551 }
552 let agents = ids_of_acl_field(&auth.agent);
553 if let Some(web_id) = caller {
554 if agents.contains(&web_id) {
555 return true;
556 }
557 }
558 let classes = ids_of_acl_field(&auth.agent_class);
559 if classes
560 .iter()
561 .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
562 {
563 return true;
564 }
565 if caller.is_some()
566 && classes.iter().any(|c| {
567 *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
568 || *c == "acl:AuthenticatedAgent"
569 })
570 {
571 return true;
572 }
573 false
574 })
575}
576
577fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
579 match field {
580 None => Vec::new(),
581 Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
582 Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
583 }
584}
585
586#[cfg_attr(not(test), allow(dead_code))]
593async fn enforce_write(
594 state: &AppState,
595 path: &str,
596 mode: AccessMode,
597 agent_uri: Option<&str>,
598) -> Result<(), ActixError> {
599 enforce_write_ctx(state, path, mode, agent_uri, None).await
600}
601
602async fn enforce_write_ctx(
610 state: &AppState,
611 path: &str,
612 mode: AccessMode,
613 agent_uri: Option<&str>,
614 request_origin: Option<&str>,
615) -> Result<(), ActixError> {
616 let origin = request_origin.and_then(wac::Origin::parse);
617 let (resource, eff_mode) = effective_acl_target(path, mode);
629
630 let acl_doc = match find_effective_acl_dyn(&*state.storage, &resource).await {
635 Ok(doc) => doc,
636 Err(e) => return Err(to_actix(e)),
637 };
638
639 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
644
645 let ctx = RequestContext {
646 web_id: agent_uri,
647 client_id: None,
648 issuer: None,
649 payment_balance_sats,
650 };
651 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
652 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
653 let granted = wac::evaluate_access_ctx_with_registry(
654 acl_doc.as_ref(),
655 &ctx,
656 &resource,
657 eff_mode,
658 origin.as_ref(),
659 &groups,
660 ®istry,
661 );
662 if !granted {
663 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
664 }
665 if resource.as_str() == path {
672 charge_granted_payment(
678 state,
679 acl_doc.as_ref(),
680 &ctx,
681 &resource,
682 eff_mode,
683 &groups,
684 ®istry,
685 )
686 .await?;
687 }
688 Ok(())
689}
690
691async fn charge_granted_payment(
700 state: &AppState,
701 acl_doc: Option<&wac::AclDocument>,
702 ctx: &RequestContext<'_>,
703 path: &str,
704 mode: AccessMode,
705 groups: &wac::StaticGroupMembership,
706 registry: &wac::conditions::ConditionRegistry,
707) -> Result<(), ActixError> {
708 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
709 if cost == 0 {
710 return Ok(());
711 }
712 if let Some(did) = ctx.web_id {
713 if debit_ledger(&*state.storage, did, cost).await.is_err() {
714 return Err(acl_denial(acl_doc, ctx.web_id, path));
715 }
716 }
717 Ok(())
718}
719
720fn acl_denial(
726 acl_doc: Option<&wac::AclDocument>,
727 agent_uri: Option<&str>,
728 path: &str,
729) -> ActixError {
730 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
731 let (status, body, unauthenticated) = if agent_uri.is_none() {
732 (StatusCode::UNAUTHORIZED, "authentication required", true)
733 } else {
734 (StatusCode::FORBIDDEN, "access forbidden", false)
735 };
736 let mut rsp = HttpResponse::new(status);
737 rsp.headers_mut().insert(
738 header::HeaderName::from_static("wac-allow"),
739 header::HeaderValue::from_str(&allow_header)
740 .unwrap_or(header::HeaderValue::from_static("")),
741 );
742 if unauthenticated {
743 rsp.headers_mut().insert(
750 header::WWW_AUTHENTICATE,
751 header::HeaderValue::from_static(
752 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
753 ),
754 );
755 }
756 actix_web::error::InternalError::from_response(body, rsp).into()
757}
758
759#[cfg_attr(not(test), allow(dead_code))]
770async fn enforce_read(
771 state: &AppState,
772 path: &str,
773 agent_uri: Option<&str>,
774) -> Result<(), ActixError> {
775 enforce_read_ctx(state, path, agent_uri, None).await
776}
777
778async fn enforce_read_ctx(
781 state: &AppState,
782 path: &str,
783 agent_uri: Option<&str>,
784 request_origin: Option<&str>,
785) -> Result<(), ActixError> {
786 let origin = request_origin.and_then(wac::Origin::parse);
787 let (resource, eff_mode) = effective_acl_target(path, AccessMode::Read);
799 let acl_doc = match find_effective_acl_dyn(&*state.storage, &resource).await {
800 Ok(doc) => doc,
801 Err(e) => return Err(to_actix(e)),
802 };
803 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
804 let ctx = RequestContext {
805 web_id: agent_uri,
806 client_id: None,
807 issuer: None,
808 payment_balance_sats,
809 };
810 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
811 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
812 let granted = wac::evaluate_access_ctx_with_registry(
813 acl_doc.as_ref(),
814 &ctx,
815 &resource,
816 eff_mode,
817 origin.as_ref(),
818 &groups,
819 ®istry,
820 );
821 if !granted {
822 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
823 }
824 if resource.as_str() == path {
828 charge_granted_payment(
833 state,
834 acl_doc.as_ref(),
835 &ctx,
836 &resource,
837 eff_mode,
838 &groups,
839 ®istry,
840 )
841 .await?;
842 }
843 Ok(())
844}
845
846async fn debit_ledger(
855 storage: &dyn Storage,
856 did: &str,
857 cost: u64,
858) -> Result<(), solid_pod_rs::payments::PaymentError> {
859 use solid_pod_rs::payments::{PaymentError, WebLedger};
860
861 let (bytes, _meta) = storage
862 .get(WEBLEDGER_PATH)
863 .await
864 .map_err(|e| PaymentError::Store(e.to_string()))?;
865 let mut ledger: WebLedger = serde_json::from_slice(&bytes)
866 .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
867 ledger.debit(did, cost)?;
868 let body = serde_json::to_vec(&ledger)
869 .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
870 storage
871 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
872 .await
873 .map_err(|e| PaymentError::Store(e.to_string()))?;
874 Ok(())
875}
876
877fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
882 let links = ldp::link_headers(path).join(", ");
883 if let Ok(value) = header::HeaderValue::from_str(&links) {
884 rsp.headers_mut()
885 .insert(header::HeaderName::from_static("link"), value);
886 }
887}
888
889fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
890 if let Ok(v) = header::HeaderValue::from_str(header_value) {
891 rsp.headers_mut()
892 .insert(header::HeaderName::from_static("wac-allow"), v);
893 }
894}
895
896fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
897 let ws_base = base_url
898 .replacen("https://", "wss://", 1)
899 .replacen("http://", "ws://", 1);
900 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
901 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
902 rsp.headers_mut()
903 .insert(header::HeaderName::from_static("updates-via"), v);
904 }
905}
906
907async fn handle_get(
908 req: HttpRequest,
909 state: web::Data<AppState>,
910) -> Result<HttpResponse, ActixError> {
911 let path = req.uri().path().to_string();
912
913 if path.contains('*') {
914 return handle_glob_get(req, state).await;
915 }
916
917 let auth_pk = extract_pubkey(&req).await;
918 let agent = agent_uri(auth_pk.as_ref());
919
920 enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
925
926 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
927
928 if ldp::is_container(&path) {
929 let accept = req
930 .headers()
931 .get(header::ACCEPT)
932 .and_then(|v| v.to_str().ok())
933 .unwrap_or("");
934
935 if accept_includes_html(accept) {
941 let index_path = format!("{path}index.html");
942 if let Ok((body, _meta)) = state.storage.get(&index_path).await {
943 let mut rsp = HttpResponse::Ok()
944 .content_type("text/html; charset=utf-8")
945 .body(body.to_vec());
946 set_wac_allow(&mut rsp, &wac_allow);
947 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
948 set_link_headers(&mut rsp, &path);
949 return Ok(rsp);
950 }
951 }
952
953 let v = state
954 .storage
955 .container_representation(&path)
956 .await
957 .map_err(to_actix)?;
958
959 let sec_fetch_dest = req
961 .headers()
962 .get("sec-fetch-dest")
963 .and_then(|v| v.to_str().ok());
964 if mashlib::should_serve(
965 accept,
966 sec_fetch_dest,
967 "application/ld+json",
968 state.mashlib.enabled,
969 ) {
970 let json_ld = serde_json::to_string(&v).ok();
971 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
972 let mut rsp = HttpResponse::Ok()
973 .content_type("text/html; charset=utf-8")
974 .insert_header(("X-Frame-Options", "DENY"))
975 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
976 .insert_header(("Cache-Control", "no-store"))
977 .body(html);
978 set_wac_allow(&mut rsp, &wac_allow);
979 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
980 set_link_headers(&mut rsp, &path);
981 return Ok(rsp);
982 }
983
984 let mut rsp = HttpResponse::Ok().json(v);
985 rsp.headers_mut().insert(
986 header::CONTENT_TYPE,
987 header::HeaderValue::from_static("application/ld+json"),
988 );
989 set_wac_allow(&mut rsp, &wac_allow);
990 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
991 set_link_headers(&mut rsp, &path);
992 return Ok(rsp);
993 }
994
995 match state.storage.get(&path).await {
996 Ok((body, meta)) => {
997 let accept = req
999 .headers()
1000 .get(header::ACCEPT)
1001 .and_then(|v| v.to_str().ok())
1002 .unwrap_or("");
1003 let sec_fetch_dest = req
1004 .headers()
1005 .get("sec-fetch-dest")
1006 .and_then(|v| v.to_str().ok());
1007 if mashlib::should_serve(
1008 accept,
1009 sec_fetch_dest,
1010 &meta.content_type,
1011 state.mashlib.enabled,
1012 ) {
1013 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
1014 std::str::from_utf8(&body).ok().map(|s| s.to_string())
1015 } else {
1016 None
1017 };
1018 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
1019 let mut rsp = HttpResponse::Ok()
1020 .content_type("text/html; charset=utf-8")
1021 .insert_header(("X-Frame-Options", "DENY"))
1022 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1023 .insert_header(("Cache-Control", "no-store"))
1024 .body(html);
1025 set_wac_allow(&mut rsp, &wac_allow);
1026 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1027 set_link_headers(&mut rsp, &path);
1028 return Ok(rsp);
1029 }
1030
1031 if let Some((negotiated_body, negotiated_ct)) =
1039 rdf_content_negotiate(&body, &meta.content_type, accept)
1040 {
1041 let mut rsp = HttpResponse::Ok().body(negotiated_body);
1042 rsp.headers_mut().insert(
1043 header::CONTENT_TYPE,
1044 header::HeaderValue::from_str(negotiated_ct)
1045 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
1046 );
1047 rsp.headers_mut()
1048 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
1049 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1050 rsp.headers_mut().insert(header::ETAG, etag);
1051 }
1052 set_wac_allow(&mut rsp, &wac_allow);
1053 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1054 set_link_headers(&mut rsp, &path);
1055 return Ok(rsp);
1056 }
1057
1058 let mut rsp = HttpResponse::Ok().body(body.to_vec());
1059 rsp.headers_mut().insert(
1060 header::CONTENT_TYPE,
1061 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
1062 header::HeaderValue::from_static("application/octet-stream")
1063 }),
1064 );
1065 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1066 rsp.headers_mut().insert(header::ETAG, etag);
1067 }
1068 set_wac_allow(&mut rsp, &wac_allow);
1069 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1070 set_link_headers(&mut rsp, &path);
1071 Ok(rsp)
1072 }
1073 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1074 Err(e) => Err(to_actix(e)),
1075 }
1076}
1077
1078fn has_basic_container_link(req: &HttpRequest) -> bool {
1079 req.headers()
1080 .get_all(header::LINK)
1081 .filter_map(|v| v.to_str().ok())
1082 .any(|v| {
1083 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
1084 })
1085}
1086
1087async fn handle_put(
1088 req: HttpRequest,
1089 body: web::Bytes,
1090 state: web::Data<AppState>,
1091) -> Result<HttpResponse, ActixError> {
1092 let path = req.uri().path().to_string();
1093
1094 if ldp::is_container(&path) {
1095 if has_basic_container_link(&req) {
1096 let auth_pk = extract_pubkey(&req).await;
1097 let agent = agent_uri(auth_pk.as_ref());
1098 enforce_write_ctx(
1099 &state,
1100 &path,
1101 AccessMode::Write,
1102 agent.as_deref(),
1103 req_origin(&req),
1104 )
1105 .await?;
1106 let meta = state
1107 .storage
1108 .create_container(&path)
1109 .await
1110 .map_err(to_actix)?;
1111 let mut rsp = HttpResponse::Created().finish();
1112 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1113 rsp.headers_mut().insert(header::ETAG, etag);
1114 }
1115 set_link_headers(&mut rsp, &path);
1116 return Ok(rsp);
1117 }
1118 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
1119 }
1120
1121 let auth_pk = extract_pubkey(&req).await;
1122 let agent = agent_uri(auth_pk.as_ref());
1123 enforce_write_ctx(
1124 &state,
1125 &path,
1126 AccessMode::Write,
1127 agent.as_deref(),
1128 req_origin(&req),
1129 )
1130 .await?;
1131
1132 let ct = req
1133 .headers()
1134 .get(header::CONTENT_TYPE)
1135 .and_then(|v| v.to_str().ok())
1136 .unwrap_or("application/octet-stream");
1137
1138 if protected_resource_for_acl(&path).is_some()
1143 && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
1144 {
1145 return Ok(HttpResponse::Conflict().body(
1146 "refused: the proposed ACL would not grant Control to the caller \
1147 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1148 ));
1149 }
1150
1151 let meta = state
1152 .storage
1153 .put(&path, Bytes::from(body.to_vec()), ct)
1154 .await
1155 .map_err(to_actix)?;
1156 git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
1160 let mut rsp = HttpResponse::Created().finish();
1161 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1162 rsp.headers_mut().insert(header::ETAG, etag);
1163 }
1164 set_link_headers(&mut rsp, &path);
1165 Ok(rsp)
1166}
1167
1168async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
1175 if !storage.exists(target).await.unwrap_or(false) {
1176 return target.to_string();
1177 }
1178 let seg_start = target.rfind('/').map(|s| s + 1).unwrap_or(0);
1181 let (stem, ext) = match target.rfind('.') {
1182 Some(dot) if dot > seg_start => (&target[..dot], &target[dot..]),
1183 _ => (target, ""),
1184 };
1185 for n in 1..10_000u32 {
1186 let candidate = format!("{stem}-{n}{ext}");
1187 if !storage.exists(&candidate).await.unwrap_or(false) {
1188 return candidate;
1189 }
1190 }
1191 use std::hash::{Hash, Hasher};
1192 let mut h = std::collections::hash_map::DefaultHasher::new();
1193 target.hash(&mut h);
1194 format!("{stem}-{:x}{ext}", h.finish())
1195}
1196
1197async fn handle_post(
1198 req: HttpRequest,
1199 body: web::Bytes,
1200 state: web::Data<AppState>,
1201) -> Result<HttpResponse, ActixError> {
1202 let path = req.uri().path().to_string();
1203 let auth_pk = extract_pubkey(&req).await;
1206 let agent = agent_uri(auth_pk.as_ref());
1207 enforce_write_ctx(
1208 &state,
1209 &path,
1210 AccessMode::Append,
1211 agent.as_deref(),
1212 req_origin(&req),
1213 )
1214 .await?;
1215
1216 let slug = req
1217 .headers()
1218 .get(header::HeaderName::from_static("slug"))
1219 .and_then(|v| v.to_str().ok());
1220 let mut target = match ldp::resolve_slug(&path, slug) {
1221 Ok(p) => p,
1222 Err(e) => return Err(to_actix(e)),
1223 };
1224 let ct = req
1225 .headers()
1226 .get(header::CONTENT_TYPE)
1227 .and_then(|v| v.to_str().ok())
1228 .unwrap_or("application/octet-stream");
1229
1230 if protected_resource_for_acl(&target).is_some() {
1239 enforce_write_ctx(
1240 &state,
1241 &target,
1242 AccessMode::Write,
1243 agent.as_deref(),
1244 req_origin(&req),
1245 )
1246 .await?;
1247 if !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref()) {
1248 return Ok(HttpResponse::Conflict().body(
1249 "refused: the proposed ACL would not grant Control to the caller \
1250 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1251 ));
1252 }
1253 } else {
1254 target = mint_unique_target(&*state.storage, &target).await;
1260 }
1261
1262 let meta = state
1263 .storage
1264 .put(&target, Bytes::from(body.to_vec()), ct)
1265 .await
1266 .map_err(to_actix)?;
1267 git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1270 let mut rsp = HttpResponse::Created().finish();
1271 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1272 rsp.headers_mut().insert(header::LOCATION, loc);
1273 }
1274 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1275 rsp.headers_mut().insert(header::ETAG, etag);
1276 }
1277 set_link_headers(&mut rsp, &target);
1278 Ok(rsp)
1279}
1280
1281async fn handle_patch(
1282 req: HttpRequest,
1283 body: web::Bytes,
1284 state: web::Data<AppState>,
1285) -> Result<HttpResponse, ActixError> {
1286 let path = req.uri().path().to_string();
1287 if ldp::is_container(&path) {
1288 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1289 }
1290 let auth_pk = extract_pubkey(&req).await;
1291 let agent = agent_uri(auth_pk.as_ref());
1292 enforce_write_ctx(
1298 &state,
1299 &path,
1300 AccessMode::Write,
1301 agent.as_deref(),
1302 req_origin(&req),
1303 )
1304 .await?;
1305
1306 let ct = req
1307 .headers()
1308 .get(header::CONTENT_TYPE)
1309 .and_then(|v| v.to_str().ok())
1310 .unwrap_or("");
1311 let dialect = match ldp::patch_dialect_from_mime(ct) {
1312 Some(d) => d,
1313 None => {
1314 return Ok(HttpResponse::UnsupportedMediaType()
1315 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1316 }
1317 };
1318 let body_str = match std::str::from_utf8(&body) {
1319 Ok(s) => s.to_string(),
1320 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1321 };
1322
1323 let existing = state.storage.get(&path).await;
1325 match existing {
1326 Ok((current_body, meta)) => {
1327 let out = match dialect {
1337 ldp::PatchDialect::N3 => {
1338 let seed = seed_graph_from_patch_target(¤t_body)?;
1339 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1340 }
1341 ldp::PatchDialect::SparqlUpdate => {
1342 let seed = seed_graph_from_patch_target(¤t_body)?;
1343 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1344 }
1345 ldp::PatchDialect::JsonPatch => {
1346 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1347 Ok(v) => v,
1348 Err(_) => serde_json::json!({}),
1349 };
1350 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1351 Ok(v) => v,
1352 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1353 };
1354 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1355 let bytes = serde_json::to_vec(&json)
1356 .map_err(PodError::from)
1357 .map_err(to_actix)?;
1358 let _ = state
1359 .storage
1360 .put(&path, Bytes::from(bytes), &meta.content_type)
1361 .await
1362 .map_err(to_actix)?;
1363 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1364 return Ok(HttpResponse::NoContent().finish());
1365 }
1366 };
1367 let outcome = out?;
1368 let serialised = graph_to_turtle(&outcome.graph);
1371 if protected_resource_for_acl(&path).is_some()
1377 && !proposed_acl_keeps_caller_control(
1378 serialised.as_bytes(),
1379 "application/n-triples",
1380 agent.as_deref(),
1381 )
1382 {
1383 return Ok(HttpResponse::Conflict().body(
1384 "refused: the patched ACL would not grant Control to the caller \
1385 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1386 ));
1387 }
1388 let _ = state
1389 .storage
1390 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1391 .await
1392 .map_err(to_actix)?;
1393 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1394 Ok(HttpResponse::NoContent().finish())
1395 }
1396 Err(PodError::NotFound(_)) => {
1397 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
1399 let PatchCreateOutcome::Created { graph, .. } = create else {
1400 return Err(to_actix(PodError::Unsupported(
1401 "unexpected patch outcome on absent resource".into(),
1402 )));
1403 };
1404 let serialised = graph_to_turtle(&graph);
1405 if protected_resource_for_acl(&path).is_some()
1407 && !proposed_acl_keeps_caller_control(
1408 serialised.as_bytes(),
1409 "application/n-triples",
1410 agent.as_deref(),
1411 )
1412 {
1413 return Ok(HttpResponse::Conflict().body(
1414 "refused: the patched ACL would not grant Control to the caller \
1415 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1416 ));
1417 }
1418 let _ = state
1419 .storage
1420 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1421 .await
1422 .map_err(to_actix)?;
1423 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1424 Ok(HttpResponse::Created().finish())
1425 }
1426 Err(e) => Err(to_actix(e)),
1427 }
1428}
1429
1430fn patch_parse_err(e: PodError) -> ActixError {
1434 match e {
1435 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
1436 actix_web::error::ErrorBadRequest(msg)
1437 }
1438 other => to_actix(other),
1439 }
1440}
1441
1442fn graph_to_turtle(g: &ldp::Graph) -> String {
1446 g.to_ntriples()
1447}
1448
1449fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
1456 let mut best: Option<(f32, ldp::RdfFormat)> = None;
1457 for entry in accept.split(',') {
1458 let entry = entry.trim();
1459 if entry.is_empty() {
1460 continue;
1461 }
1462 let mut parts = entry.split(';').map(|s| s.trim());
1463 let mime = match parts.next() {
1464 Some(m) => m,
1465 None => continue,
1466 };
1467 let mut q: f32 = 1.0;
1468 for token in parts {
1469 if let Some(v) = token.strip_prefix("q=") {
1470 if let Ok(parsed) = v.parse::<f32>() {
1471 q = parsed;
1472 }
1473 }
1474 }
1475 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
1478 match best {
1479 None => best = Some((q, format)),
1480 Some((bq, _)) if q > bq => best = Some((q, format)),
1481 _ => {}
1482 }
1483 }
1484 }
1485 best.map(|(_, f)| f)
1486}
1487
1488fn rdf_content_negotiate(
1504 body: &[u8],
1505 stored_ct: &str,
1506 accept: &str,
1507) -> Option<(Vec<u8>, &'static str)> {
1508 if accept.trim().is_empty() {
1509 return None;
1510 }
1511 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
1512 let target = best_explicit_rdf_format(accept)?;
1513 if target == stored_format {
1514 return None;
1515 }
1516 let text = std::str::from_utf8(body).ok()?;
1517 let graph = ldp::Graph::parse_ntriples(text).ok()?;
1518 match target {
1519 ldp::RdfFormat::Turtle => Some((
1522 graph.to_ntriples().into_bytes(),
1523 ldp::RdfFormat::Turtle.mime(),
1524 )),
1525 ldp::RdfFormat::NTriples => Some((
1526 graph.to_ntriples().into_bytes(),
1527 ldp::RdfFormat::NTriples.mime(),
1528 )),
1529 ldp::RdfFormat::JsonLd => {
1530 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
1531 Some((json, ldp::RdfFormat::JsonLd.mime()))
1532 }
1533 ldp::RdfFormat::RdfXml => None,
1535 }
1536}
1537
1538fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
1547 let text = std::str::from_utf8(current_body).map_err(|_| {
1548 actix_web::error::ErrorConflict(
1549 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
1550 )
1551 })?;
1552 if text.trim().is_empty() {
1553 return Ok(ldp::Graph::new());
1554 }
1555 ldp::Graph::parse_ntriples(text).map_err(|_| {
1556 actix_web::error::ErrorConflict(
1557 "existing resource is not N-Triples RDF and cannot be non-destructively \
1558 patched; PUT an N-Triples representation or use a JSON Patch",
1559 )
1560 })
1561}
1562
1563pub(crate) async fn find_effective_acl_dyn(
1569 storage: &dyn Storage,
1570 resource_path: &str,
1571) -> Result<Option<wac::AclDocument>, PodError> {
1572 let mut path = resource_path.to_string();
1573 let mut inherited = false;
1578 loop {
1579 let acl_key = if path == "/" {
1580 "/.acl".to_string()
1581 } else {
1582 format!("{}.acl", path.trim_end_matches('/'))
1583 };
1584 if let Ok((body, meta)) = storage.get(&acl_key).await {
1585 match parse_jsonld_acl(&body) {
1586 Ok(mut doc) => {
1587 doc.inherited = inherited;
1588 return Ok(Some(doc));
1589 }
1590 Err(PodError::BadRequest(_)) => {
1591 return Err(PodError::BadRequest("ACL document exceeds bounds".into()))
1592 }
1593 Err(_) => {}
1594 }
1595 let ct = meta.content_type.to_ascii_lowercase();
1596 let looks_turtle = ct.starts_with("text/turtle")
1597 || ct.starts_with("application/turtle")
1598 || ct.starts_with("application/x-turtle");
1599 let text = std::str::from_utf8(&body).unwrap_or("");
1600 if looks_turtle || text.contains("@prefix") || text.contains("acl:Authorization") {
1601 if let Ok(mut doc) = parse_turtle_acl(text) {
1602 doc.inherited = inherited;
1603 return Ok(Some(doc));
1604 }
1605 }
1606 }
1607 if path == "/" || path.is_empty() {
1608 break;
1609 }
1610 inherited = true;
1612 let trimmed = path.trim_end_matches('/');
1613 path = match trimmed.rfind('/') {
1614 Some(0) => "/".to_string(),
1615 Some(pos) => trimmed[..pos].to_string(),
1616 None => "/".to_string(),
1617 };
1618 }
1619 Ok(None)
1620}
1621
1622async fn handle_delete(
1623 req: HttpRequest,
1624 state: web::Data<AppState>,
1625) -> Result<HttpResponse, ActixError> {
1626 let path = req.uri().path().to_string();
1627 let auth_pk = extract_pubkey(&req).await;
1628 let agent = agent_uri(auth_pk.as_ref());
1629 enforce_write_ctx(
1630 &state,
1631 &path,
1632 AccessMode::Write,
1633 agent.as_deref(),
1634 req_origin(&req),
1635 )
1636 .await?;
1637
1638 match state.storage.delete(&path).await {
1639 Ok(()) => Ok(HttpResponse::NoContent().finish()),
1640 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1641 Err(e) => Err(to_actix(e)),
1642 }
1643}
1644
1645async fn handle_options(
1646 req: HttpRequest,
1647 state: web::Data<AppState>,
1648) -> Result<HttpResponse, ActixError> {
1649 let path = req.uri().path().to_string();
1650 let o = ldp::options_for(&path);
1651 let mut rsp = HttpResponse::NoContent().finish();
1652 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
1653 rsp.headers_mut()
1654 .insert(header::HeaderName::from_static("allow"), v);
1655 }
1656 if let Some(ap) = o.accept_post {
1657 if let Ok(v) = header::HeaderValue::from_str(ap) {
1658 rsp.headers_mut()
1659 .insert(header::HeaderName::from_static("accept-post"), v);
1660 }
1661 }
1662 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
1663 rsp.headers_mut()
1664 .insert(header::HeaderName::from_static("accept-patch"), v);
1665 }
1666 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
1667 rsp.headers_mut()
1668 .insert(header::HeaderName::from_static("accept-ranges"), v);
1669 }
1670 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1671 Ok(rsp)
1672}
1673
1674async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
1679 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
1680 HttpResponse::Ok()
1681 .content_type("application/ld+json")
1682 .json(doc)
1683}
1684
1685#[derive(Debug, Deserialize)]
1686struct WebFingerQuery {
1687 resource: Option<String>,
1688}
1689
1690async fn handle_well_known_webfinger(
1691 state: web::Data<AppState>,
1692 q: web::Query<WebFingerQuery>,
1693) -> HttpResponse {
1694 let resource = q.resource.clone().unwrap_or_else(|| {
1695 format!(
1696 "acct:anonymous@{}",
1697 state
1698 .nodeinfo
1699 .base_url
1700 .trim_start_matches("http://")
1701 .trim_start_matches("https://")
1702 )
1703 });
1704 let webid = format!(
1705 "{}/profile/card#me",
1706 state.nodeinfo.base_url.trim_end_matches('/')
1707 );
1708 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
1709 Some(jrd) => HttpResponse::Ok()
1710 .content_type("application/jrd+json")
1711 .json(jrd),
1712 None => HttpResponse::NotFound().finish(),
1713 }
1714}
1715
1716async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
1717 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
1718 HttpResponse::Ok()
1719 .content_type("application/json")
1720 .json(doc)
1721}
1722
1723async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
1724 let doc = interop::nodeinfo_2_1(
1725 &state.nodeinfo.software_name,
1726 &state.nodeinfo.software_version,
1727 state.nodeinfo.open_registrations,
1728 state.nodeinfo.total_users,
1729 );
1730 HttpResponse::Ok()
1731 .content_type("application/json")
1732 .json(doc)
1733}
1734
1735#[cfg(feature = "did-nostr")]
1736async fn handle_well_known_did_nostr(
1737 state: web::Data<AppState>,
1738 path: web::Path<String>,
1739) -> HttpResponse {
1740 let pubkey = path.into_inner();
1741 let pubkey_is_valid = pubkey.len() == 64
1746 && pubkey
1747 .bytes()
1748 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
1749 if !pubkey_is_valid {
1750 return HttpResponse::BadRequest()
1751 .insert_header(("Cache-Control", "no-store"))
1752 .json(serde_json::json!({
1753 "error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
1754 }));
1755 }
1756 let owner_pubkey = match state.storage.get("/profile/card").await {
1764 Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
1765 .ok()
1766 .flatten(),
1767 Err(_) => None,
1768 };
1769 let owner_claims_key = owner_pubkey
1770 .as_deref()
1771 .is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
1772 if !owner_claims_key {
1773 return HttpResponse::NotFound()
1774 .insert_header(("Cache-Control", "no-store"))
1775 .json(serde_json::json!({
1776 "error": "no account on this pod claims this did:nostr pubkey"
1777 }));
1778 }
1779 let also = vec![format!(
1780 "{}/profile/card#me",
1781 state.nodeinfo.base_url.trim_end_matches('/')
1782 )];
1783 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
1784 let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
1785 use std::hash::{Hash, Hasher};
1791 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1792 body.hash(&mut hasher);
1793 let etag = format!("\"{:016x}\"", hasher.finish());
1794 HttpResponse::Ok()
1795 .content_type("application/did+json")
1796 .insert_header(("Cache-Control", "max-age=3600"))
1797 .insert_header(("ETag", etag))
1798 .body(body)
1799}
1800
1801#[cfg(feature = "nip05-endpoint")]
1809#[derive(Debug, Deserialize)]
1810struct Nip05Query {
1811 name: Option<String>,
1814}
1815
1816#[cfg(feature = "nip05-endpoint")]
1817fn nip05_name_is_valid(name: &str) -> bool {
1818 if name.is_empty() {
1821 return false;
1822 }
1823 name.bytes()
1824 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
1825}
1826
1827#[cfg(feature = "nip05-endpoint")]
1828async fn handle_well_known_nip05(
1829 state: web::Data<AppState>,
1830 query: web::Query<Nip05Query>,
1831) -> HttpResponse {
1832 use solid_pod_rs::webid::extract_nostr_pubkey;
1833
1834 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
1836 if !nip05_name_is_valid(&name) {
1837 return HttpResponse::BadRequest().json(serde_json::json!({
1838 "error": "invalid NIP-05 local part",
1839 }));
1840 }
1841
1842 let profile_path = if name == "_" {
1848 "/profile/card".to_string()
1849 } else {
1850 format!("/{name}/profile/card")
1851 };
1852
1853 let (body, _meta) = match state.storage.get(&profile_path).await {
1854 Ok(v) => v,
1855 Err(_) => {
1856 return nip05_empty_response();
1860 }
1861 };
1862
1863 let pubkey_hex = match extract_nostr_pubkey(&body) {
1864 Ok(Some(p)) => p,
1865 _ => return nip05_empty_response(),
1866 };
1867
1868 let doc = interop::nip05_document([(name, pubkey_hex)]);
1869 HttpResponse::Ok()
1870 .insert_header(("Access-Control-Allow-Origin", "*"))
1871 .content_type("application/json")
1872 .json(doc)
1873}
1874
1875#[cfg(feature = "nip05-endpoint")]
1876fn nip05_empty_response() -> HttpResponse {
1877 HttpResponse::Ok()
1878 .insert_header(("Access-Control-Allow-Origin", "*"))
1879 .content_type("application/json")
1880 .json(serde_json::json!({ "names": {} }))
1881}
1882
1883#[cfg(feature = "export-jsonld")]
1897async fn handle_export_all(
1898 req: HttpRequest,
1899 state: web::Data<AppState>,
1900) -> Result<HttpResponse, ActixError> {
1901 let auth_pk = extract_pubkey(&req).await;
1902 let agent = agent_uri(auth_pk.as_ref());
1903
1904 enforce_write_ctx(
1909 &state,
1910 "/",
1911 AccessMode::Control,
1912 agent.as_deref(),
1913 req_origin(&req),
1914 )
1915 .await?;
1916
1917 let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
1921 .ok()
1922 .and_then(|q| q.get("include_private").map(|v| v == "true"))
1923 .unwrap_or(false);
1924
1925 let pod_base = {
1929 let conn = req.connection_info();
1930 format!("{}://{}/", conn.scheme(), conn.host())
1931 };
1932
1933 let options = solid_pod_rs::ExportOptions { include_private };
1934 let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
1935 .await
1936 .map_err(to_actix)?;
1937
1938 let body = serde_json::to_vec(&bundle).map_err(|e| {
1939 actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
1940 })?;
1941 Ok(HttpResponse::Ok()
1942 .content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
1943 .body(body))
1944}
1945
1946#[derive(Debug, Deserialize)]
1951struct CreateAccountRequest {
1952 username: String,
1953 #[serde(default)]
1954 name: Option<String>,
1955}
1956
1957#[derive(Debug, Deserialize)]
1958struct CreatePodRequest {
1959 name: String,
1960}
1961
1962async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
1963 let pod_name = path.into_inner();
1964 let pod_root = format!("/{pod_name}/");
1965 match state.storage.exists(&pod_root).await {
1966 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
1967 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
1968 }
1969}
1970
1971fn valid_pod_name(name: &str) -> bool {
1972 !name.is_empty()
1973 && name
1974 .chars()
1975 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
1976}
1977
1978fn request_ip(req: &HttpRequest) -> IpAddr {
1979 req.peer_addr()
1980 .map(|addr| addr.ip())
1981 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
1982}
1983
1984async fn handle_create_account(
1985 state: web::Data<AppState>,
1986 body: web::Json<CreateAccountRequest>,
1987) -> Result<HttpResponse, ActixError> {
1988 let pod_root = format!("/{}/", body.username);
1989 if state.storage.exists(&pod_root).await.unwrap_or(false) {
1990 return Ok(
1991 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
1992 );
1993 }
1994
1995 let mut plan = provision::ProvisionPlan::new(
1996 body.username.clone(),
1997 format!(
1998 "{}/{}",
1999 state.nodeinfo.base_url.trim_end_matches('/'),
2000 body.username,
2001 ),
2002 );
2003 plan.display_name = body.name.clone();
2004 plan.containers = vec![
2005 format!("/{}/", body.username),
2006 format!("/{}/profile/", body.username),
2007 format!("/{}/inbox/", body.username),
2008 format!("/{}/public/", body.username),
2009 format!("/{}/private/", body.username),
2010 format!("/{}/settings/", body.username),
2011 ];
2012
2013 #[cfg(feature = "git")]
2017 let outcome = {
2018 use solid_pod_rs_git::init::GitAutoInit;
2019 let git_hook = state.data_root.as_ref().map(|root| {
2020 let fs_path = root.join(&body.username);
2021 (GitAutoInit::new(), fs_path)
2022 });
2023 match git_hook {
2024 Some((hook, ref fs_path)) => {
2025 provision::provision_pod_ext(state.storage.as_ref(), &plan, Some((&hook, fs_path)))
2026 .await
2027 }
2028 None => provision::provision_pod(state.storage.as_ref(), &plan).await,
2029 }
2030 };
2031 #[cfg(not(feature = "git"))]
2032 let outcome = provision::provision_pod(state.storage.as_ref(), &plan).await;
2033
2034 match outcome {
2035 Ok(outcome) => Ok(HttpResponse::Created().json(serde_json::json!({
2036 "webid": outcome.webid,
2037 "pod_root": outcome.pod_root,
2038 "username": body.username,
2039 }))),
2040 Err(e) => Err(to_actix(e)),
2041 }
2042}
2043
2044async fn handle_create_pod(
2045 req: HttpRequest,
2046 state: web::Data<AppState>,
2047 body: web::Json<CreatePodRequest>,
2048) -> Result<HttpResponse, ActixError> {
2049 let ip = request_ip(&req);
2050 if let Err(retry_after) = state.pod_create_limiter.check(ip) {
2051 return Ok(HttpResponse::TooManyRequests()
2052 .insert_header(("Retry-After", retry_after.to_string()))
2053 .json(serde_json::json!({
2054 "error": "Too Many Requests",
2055 "message": "Pod creation rate limit exceeded",
2056 "retryAfter": retry_after
2057 })));
2058 }
2059
2060 if !valid_pod_name(&body.name) {
2061 return Ok(HttpResponse::BadRequest().json(serde_json::json!({
2062 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
2063 })));
2064 }
2065
2066 let pod_root = format!("/{}/", body.name);
2067 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2068 return Ok(
2069 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
2070 );
2071 }
2072
2073 let base_uri = {
2074 let conn = req.connection_info();
2075 format!("{}://{}", conn.scheme(), conn.host())
2076 };
2077 let pod_uri = format!("{}/{}/", base_uri.trim_end_matches('/'), body.name);
2078
2079 for container in [
2080 format!("/{}/", body.name),
2081 format!("/{}/profile/", body.name),
2082 format!("/{}/inbox/", body.name),
2083 format!("/{}/public/", body.name),
2084 format!("/{}/private/", body.name),
2085 format!("/{}/settings/", body.name),
2086 ] {
2087 let meta_key = format!("{}.meta", container.trim_end_matches('/'));
2088 state
2089 .storage
2090 .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
2091 .await
2092 .map_err(to_actix)?;
2093 }
2094
2095 let canonical_pods_prefix = format!("{}/pods/{}/", base_uri.trim_end_matches('/'), body.name);
2096 let webid = format!("{pod_uri}profile/card#me");
2097 let profile = solid_pod_rs::webid::generate_webid_html(&body.name, None, &base_uri)
2098 .replace(&canonical_pods_prefix, &pod_uri);
2099 state
2100 .storage
2101 .put(
2102 &format!("/{}/profile/card", body.name),
2103 Bytes::from(profile.into_bytes()),
2104 "text/html",
2105 )
2106 .await
2107 .map_err(to_actix)?;
2108
2109 Ok(HttpResponse::Created()
2110 .insert_header(("Location", pod_uri.clone()))
2111 .json(serde_json::json!({
2112 "name": body.name,
2113 "webId": webid,
2114 "podUri": pod_uri,
2115 })))
2116}
2117
2118async fn handle_copy(
2123 req: HttpRequest,
2124 state: web::Data<AppState>,
2125) -> Result<HttpResponse, ActixError> {
2126 let dest = req.uri().path().to_string();
2127 let auth_pk = extract_pubkey(&req).await;
2128 let agent = agent_uri(auth_pk.as_ref());
2129 enforce_write_ctx(
2130 &state,
2131 &dest,
2132 AccessMode::Write,
2133 agent.as_deref(),
2134 req_origin(&req),
2135 )
2136 .await?;
2137
2138 let source = req
2139 .headers()
2140 .get("source")
2141 .and_then(|v| v.to_str().ok())
2142 .map(|s| s.to_string());
2143 let source = match source {
2144 Some(s) => s,
2145 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
2146 };
2147
2148 let (body, meta) = match state.storage.get(&source).await {
2149 Ok(v) => v,
2150 Err(PodError::NotFound(_)) => {
2151 return Ok(HttpResponse::NotFound().body("source resource not found"))
2152 }
2153 Err(e) => return Err(to_actix(e)),
2154 };
2155
2156 state
2157 .storage
2158 .put(&dest, body, &meta.content_type)
2159 .await
2160 .map_err(to_actix)?;
2161
2162 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
2164 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
2165 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
2166 let _ = state
2167 .storage
2168 .put(&dst_acl, acl_body, &acl_meta.content_type)
2169 .await;
2170 }
2171
2172 let mut rsp = HttpResponse::Created().finish();
2173 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
2174 rsp.headers_mut().insert(header::LOCATION, loc);
2175 }
2176 Ok(rsp)
2177}
2178
2179async fn handle_glob_get(
2184 req: HttpRequest,
2185 state: web::Data<AppState>,
2186) -> Result<HttpResponse, ActixError> {
2187 let raw_path = req.uri().path().to_string();
2188 if !raw_path.ends_with("/*") {
2190 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
2191 }
2192 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
2194 folder.to_string()
2195 } else {
2196 format!("{folder}/")
2197 };
2198
2199 let auth_pk = extract_pubkey(&req).await;
2203 let agent = agent_uri(auth_pk.as_ref());
2204 enforce_read_ctx(&state, &folder, agent.as_deref(), req_origin(&req)).await?;
2205
2206 let children = state.storage.list(&folder).await.map_err(to_actix)?;
2207 let mut merged = String::new();
2208
2209 for child in &children {
2210 if child.ends_with('/') {
2211 continue;
2212 }
2213 let child_path = format!("{folder}{child}");
2214 if let Ok((body, meta)) = state.storage.get(&child_path).await {
2215 if meta.content_type.contains("turtle")
2216 || meta.content_type.contains("n-triples")
2217 || meta.content_type.contains("n3")
2218 {
2219 if let Ok(text) = std::str::from_utf8(&body) {
2220 merged.push_str(text);
2221 merged.push('\n');
2222 }
2223 }
2224 }
2225 }
2226
2227 if merged.is_empty() {
2228 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
2229 }
2230
2231 Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
2232}
2233
2234#[derive(Debug, Deserialize)]
2239struct LoginPasswordRequest {
2240 username: String,
2241 password: String,
2242}
2243
2244async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
2251 let _ = (&body.username, &body.password);
2252 HttpResponse::NotImplemented().json(serde_json::json!({
2253 "error": "password login is not implemented on this pod"
2254 }))
2255}
2256
2257#[derive(Debug, Deserialize)]
2258struct PasswordResetRequest {
2259 username: String,
2260}
2261
2262async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
2266 let _ = &body.username;
2267 HttpResponse::NotImplemented().json(serde_json::json!({
2268 "error": "password reset is not implemented on this pod"
2269 }))
2270}
2271
2272#[derive(Debug, Deserialize)]
2273struct PasswordChangeRequest {
2274 token: String,
2275 new_password: String,
2276}
2277
2278async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
2283 let _ = (&body.token, &body.new_password);
2284 HttpResponse::NotImplemented().json(serde_json::json!({
2285 "error": "password change is not implemented on this pod"
2286 }))
2287}
2288
2289async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
2294 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
2295 HttpResponse::Ok()
2296 .content_type("application/json")
2297 .json(body)
2298}
2299
2300pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
2315
2316#[derive(Debug, Deserialize)]
2318struct ProxyQuery {
2319 url: String,
2320}
2321
2322const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
2324 "set-cookie",
2325 "set-cookie2",
2326 "authorization",
2327 "www-authenticate",
2328 "proxy-authenticate",
2329 "proxy-authorization",
2330];
2331
2332async fn validate_proxy_target(target: &str) -> Result<(url::Url, IpAddr), HttpResponse> {
2348 let parsed = match url::Url::parse(target) {
2349 Ok(u) => u,
2350 Err(_) => {
2351 return Err(
2352 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
2353 );
2354 }
2355 };
2356
2357 match parsed.scheme() {
2359 "http" | "https" => {}
2360 scheme => {
2361 return Err(HttpResponse::BadRequest()
2362 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
2363 }
2364 }
2365
2366 if solid_pod_rs::security::is_safe_url(target).is_err() {
2369 return Err(HttpResponse::Forbidden()
2370 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
2371 }
2372
2373 let host = match parsed.host_str() {
2375 Some(h) => h.to_string(),
2376 None => {
2377 return Err(HttpResponse::BadRequest()
2378 .json(serde_json::json!({"error": "target URL has no host"})))
2379 }
2380 };
2381 let host_lower = host.to_ascii_lowercase();
2382 if host_lower == "localhost"
2383 || host_lower.ends_with(".localhost")
2384 || host_lower == "0.0.0.0"
2385 || host_lower == "[::1]"
2386 || host_lower == "[::0]"
2387 {
2388 return Err(HttpResponse::Forbidden()
2389 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
2390 }
2391
2392 match solid_pod_rs::security::resolve_and_check(&host).await {
2395 Ok(ip) => Ok((parsed, ip)),
2396 Err(_) => Err(HttpResponse::Forbidden()
2397 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"}))),
2398 }
2399}
2400
2401fn build_pinned_proxy_client(url: &url::Url, ip: IpAddr) -> Result<reqwest::Client, ActixError> {
2404 let mut builder = reqwest::Client::builder()
2405 .redirect(reqwest::redirect::Policy::none());
2408 if let Some(host) = url.host_str() {
2409 let port = url.port_or_known_default().unwrap_or(0);
2411 builder = builder.resolve(host, std::net::SocketAddr::new(ip, port));
2412 }
2413 builder
2414 .build()
2415 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))
2416}
2417
2418async fn handle_proxy(
2419 req: HttpRequest,
2420 _state: web::Data<AppState>,
2421 query: web::Query<ProxyQuery>,
2422) -> Result<HttpResponse, ActixError> {
2423 let auth_pk = extract_pubkey(&req).await;
2425 let agent = agent_uri(auth_pk.as_ref());
2426 if agent.is_none() {
2427 return Ok(HttpResponse::Unauthorized()
2428 .json(serde_json::json!({"error": "authentication required"})));
2429 }
2430
2431 let mut current_url = query.url.clone();
2432 let mut redirect_count = 0u8;
2433 const MAX_REDIRECTS: u8 = 5;
2434
2435 let byte_cap = std::env::var("PROXY_BYTE_CAP")
2436 .ok()
2437 .and_then(|v| {
2438 solid_pod_rs::config::sources::parse_size(&v)
2439 .map(|u| u as usize)
2440 .ok()
2441 })
2442 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
2443
2444 loop {
2445 let (target_url, pinned_ip) = match validate_proxy_target(¤t_url).await {
2449 Ok(pair) => pair,
2450 Err(rsp) => return Ok(rsp),
2451 };
2452 let client = build_pinned_proxy_client(&target_url, pinned_ip)?;
2453
2454 let mut upstream_req = client.get(¤t_url);
2455
2456 if let Some(auth_val) = req
2458 .headers()
2459 .get("x-upstream-authorization")
2460 .and_then(|v| v.to_str().ok())
2461 {
2462 upstream_req = upstream_req.header("Authorization", auth_val);
2463 }
2464
2465 let response = upstream_req
2466 .send()
2467 .await
2468 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
2469
2470 if response.status().is_redirection() {
2472 if redirect_count >= MAX_REDIRECTS {
2473 return Ok(HttpResponse::BadGateway()
2474 .json(serde_json::json!({"error": "too many redirects"})));
2475 }
2476 if let Some(location) = response.headers().get("location") {
2477 let loc_str = location
2478 .to_str()
2479 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
2480 let base = url::Url::parse(¤t_url)
2482 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
2483 let resolved = base
2484 .join(loc_str)
2485 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
2486 current_url = resolved.to_string();
2487 redirect_count += 1;
2488 continue;
2489 }
2490 return Ok(HttpResponse::BadGateway()
2491 .json(serde_json::json!({"error": "redirect without location"})));
2492 }
2493
2494 let upstream_status = response.status().as_u16();
2496 let upstream_content_type = response
2497 .headers()
2498 .get("content-type")
2499 .and_then(|v| v.to_str().ok())
2500 .unwrap_or("application/octet-stream")
2501 .to_string();
2502
2503 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
2505 for (name, value) in response.headers() {
2506 let name_lower = name.as_str().to_ascii_lowercase();
2507 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
2508 continue;
2509 }
2510 if matches!(
2512 name_lower.as_str(),
2513 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
2514 ) {
2515 continue;
2516 }
2517 if let Ok(val_str) = value.to_str() {
2518 forwarded_headers.push((name_lower, val_str.to_string()));
2519 }
2520 }
2521
2522 let body_bytes = response
2523 .bytes()
2524 .await
2525 .map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
2526
2527 if body_bytes.len() > byte_cap {
2528 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
2529 "error": "proxied response exceeds byte cap",
2530 "limit": byte_cap
2531 })));
2532 }
2533
2534 let mut rsp = HttpResponse::build(
2536 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
2537 );
2538 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
2539 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
2540
2541 for (name, value) in &forwarded_headers {
2543 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
2544 if let Ok(hval) = header::HeaderValue::from_str(value) {
2545 rsp.insert_header((hname, hval));
2546 }
2547 }
2548 }
2549
2550 return Ok(rsp.body(body_bytes.to_vec()));
2551 }
2552}
2553
2554pub struct PathTraversalGuard;
2560
2561impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
2562where
2563 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2564 B: 'static,
2565{
2566 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2567 type Error = ActixError;
2568 type InitError = ();
2569 type Transform = PathTraversalGuardMiddleware<S>;
2570 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2571
2572 fn new_transform(&self, service: S) -> Self::Future {
2573 ready(Ok(PathTraversalGuardMiddleware { service }))
2574 }
2575}
2576
2577pub struct PathTraversalGuardMiddleware<S> {
2579 service: S,
2580}
2581
2582impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
2583where
2584 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2585 B: 'static,
2586{
2587 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2588 type Error = ActixError;
2589 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2590
2591 actix_web::dev::forward_ready!(service);
2592
2593 fn call(&self, req: ServiceRequest) -> Self::Future {
2594 let raw = req.path().to_string();
2597 if path_is_traversal(&raw) {
2598 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
2599 let sr = req.into_response(rsp.map_into_boxed_body());
2600 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2601 }
2602 let fut = self.service.call(req);
2603 Box::pin(async move {
2604 let resp = fut.await?;
2605 Ok(resp.map_into_left_body())
2606 })
2607 }
2608}
2609
2610fn path_is_traversal(path: &str) -> bool {
2611 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
2613 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
2614 for seg in once.split('/').chain(twice.split('/')) {
2615 if seg == ".." || seg == "." {
2616 return true;
2617 }
2618 }
2619 if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
2622 return true;
2623 }
2624 false
2625}
2626
2627pub struct CorsHeaders {
2638 pub allowed_origins: Arc<Vec<String>>,
2639}
2640
2641impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
2642where
2643 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2644 B: 'static,
2645{
2646 type Response = ServiceResponse<B>;
2647 type Error = ActixError;
2648 type InitError = ();
2649 type Transform = CorsHeadersMiddleware<S>;
2650 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2651
2652 fn new_transform(&self, service: S) -> Self::Future {
2653 ready(Ok(CorsHeadersMiddleware {
2654 service,
2655 allowed_origins: self.allowed_origins.clone(),
2656 }))
2657 }
2658}
2659
2660pub struct CorsHeadersMiddleware<S> {
2662 service: S,
2663 allowed_origins: Arc<Vec<String>>,
2664}
2665
2666impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
2667where
2668 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2669 B: 'static,
2670{
2671 type Response = ServiceResponse<B>;
2672 type Error = ActixError;
2673 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2674
2675 actix_web::dev::forward_ready!(service);
2676
2677 fn call(&self, req: ServiceRequest) -> Self::Future {
2678 let origin = req
2679 .headers()
2680 .get(header::ORIGIN)
2681 .and_then(|v| v.to_str().ok())
2682 .map(str::to_string);
2683 let allowed = self.allowed_origins.clone();
2684 let fut = self.service.call(req);
2685 Box::pin(async move {
2686 let mut resp = fut.await?;
2687 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
2688 Ok(resp)
2689 })
2690 }
2691}
2692
2693fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
2694 if headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
2704 return;
2705 }
2706 let (origin_value, allow_credentials): (String, bool) = if allowed.is_empty() {
2717 ("*".to_string(), false)
2718 } else {
2719 match origin.filter(|o| allowed.iter().any(|a| a == *o)) {
2720 Some(o) => (o.to_string(), true),
2721 None => return,
2724 }
2725 };
2726
2727 let mut pairs = vec![
2728 ("access-control-allow-origin", origin_value.as_str()),
2729 (
2730 "access-control-allow-methods",
2731 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
2732 ),
2733 (
2734 "access-control-allow-headers",
2735 "Accept, Authorization, Content-Type, DPoP, Git-Protocol, If-Match, If-None-Match, Link, Range, Slug, Origin",
2736 ),
2737 (
2738 "access-control-expose-headers",
2739 "Accept-Patch, Accept-Post, Accept-Ranges, Allow, Content-Length, Content-Range, Content-Type, ETag, Link, Location, Updates-Via, WAC-Allow, X-Cost, X-Balance, X-Pay-Currency",
2740 ),
2741 ("access-control-max-age", "86400"),
2742 ];
2743 if allow_credentials {
2746 pairs.push(("access-control-allow-credentials", "true"));
2747 }
2748
2749 for (name, value) in pairs {
2750 if let (Ok(name), Ok(value)) = (
2751 header::HeaderName::from_lowercase(name.as_bytes()),
2752 header::HeaderValue::from_str(value),
2753 ) {
2754 headers.insert(name, value);
2755 }
2756 }
2757}
2758
2759pub struct ErrorLoggingMiddleware;
2775
2776impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
2777where
2778 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2779 B: 'static,
2780{
2781 type Response = ServiceResponse<B>;
2782 type Error = ActixError;
2783 type InitError = ();
2784 type Transform = ErrorLoggingMiddlewareService<S>;
2785 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2786
2787 fn new_transform(&self, service: S) -> Self::Future {
2788 ready(Ok(ErrorLoggingMiddlewareService { service }))
2789 }
2790}
2791
2792pub struct ErrorLoggingMiddlewareService<S> {
2794 service: S,
2795}
2796
2797impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
2798where
2799 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2800 B: 'static,
2801{
2802 type Response = ServiceResponse<B>;
2803 type Error = ActixError;
2804 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2805
2806 actix_web::dev::forward_ready!(service);
2807
2808 fn call(&self, req: ServiceRequest) -> Self::Future {
2809 let method = req.method().as_str().to_string();
2812 let path = req.path().to_string();
2813
2814 let fut = self.service.call(req);
2815 Box::pin(async move {
2816 let response = fut.await?;
2817 let status = response.status();
2818 if status.is_server_error() {
2819 log_5xx(&method, &path, status, response.response().error());
2820 }
2821 Ok(response)
2822 })
2823 }
2824}
2825
2826fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
2830 let chain = match error {
2834 Some(e) => format_error_chain(e),
2835 None => "<no error attached to response>".to_string(),
2836 };
2837
2838 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
2839 Some(std::backtrace::Backtrace::force_capture().to_string())
2840 } else {
2841 None
2842 };
2843
2844 tracing::error!(
2845 target: "solid_pod_rs_server::http",
2846 method = %method,
2847 path = %path,
2848 status = %status.as_u16(),
2849 error.chain = %chain,
2850 backtrace = backtrace.as_deref().unwrap_or(""),
2851 "5xx response"
2852 );
2853}
2854
2855fn format_error_chain(e: &actix_web::Error) -> String {
2866 let summary = format!("{}", e.as_response_error());
2867 let debug = format!("{e:?}");
2868 if debug == summary || debug.is_empty() {
2869 summary
2870 } else {
2871 format!("{summary} -> {debug}")
2872 }
2873}
2874
2875pub struct DotfileGuard {
2881 allow: Arc<DotfileAllowlist>,
2882}
2883
2884impl DotfileGuard {
2885 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
2886 Self { allow }
2887 }
2888}
2889
2890impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
2891where
2892 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2893 B: 'static,
2894{
2895 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2896 type Error = ActixError;
2897 type InitError = ();
2898 type Transform = DotfileGuardMiddleware<S>;
2899 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2900
2901 fn new_transform(&self, service: S) -> Self::Future {
2902 ready(Ok(DotfileGuardMiddleware {
2903 service,
2904 allow: self.allow.clone(),
2905 }))
2906 }
2907}
2908
2909pub struct DotfileGuardMiddleware<S> {
2911 service: S,
2912 allow: Arc<DotfileAllowlist>,
2913}
2914
2915impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
2916where
2917 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2918 B: 'static,
2919{
2920 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2921 type Error = ActixError;
2922 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2923
2924 actix_web::dev::forward_ready!(service);
2925
2926 fn call(&self, req: ServiceRequest) -> Self::Future {
2927 let path = req.path().to_string();
2928 let allow_system_route =
2935 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
2936 if !allow_system_route {
2937 let pb = PathBuf::from(&path);
2938 if !self.allow.is_allowed(Path::new(&pb)) {
2939 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
2940 let sr = req.into_response(rsp.map_into_boxed_body());
2941 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2942 }
2943 }
2944 let fut = self.service.call(req);
2945 Box::pin(async move {
2946 let resp = fut.await?;
2947 Ok(resp.map_into_left_body())
2948 })
2949 }
2950}
2951
2952#[cfg(feature = "git")]
2957pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
2958 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
2959 return None;
2960 }
2961 state.data_root.as_ref().map(|root| root.join(pubkey))
2962}
2963
2964#[cfg(feature = "git")]
2994async fn git_mark_write(state: &AppState, resource_path: &str, agent: Option<&str>, message: &str) {
2995 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
2996 use solid_pod_rs_git::mark::ShellGitMarker;
2997
2998 if resource_path.ends_with(".acl")
3001 || resource_path.ends_with(".meta")
3002 || resource_path.ends_with(".prov.ttl")
3003 {
3004 return;
3005 }
3006 if resource_path.ends_with('/') {
3008 return;
3009 }
3010
3011 let Some(data_root) = state.data_root.as_ref() else {
3013 return;
3014 };
3015
3016 let trimmed = resource_path.trim_start_matches('/');
3018 let mut segments = trimmed.splitn(2, '/');
3019 let pod = segments.next().unwrap_or("");
3020 let rel = segments.next().unwrap_or("");
3021 if pod.is_empty() || rel.is_empty() {
3022 return;
3023 }
3024 let repo = data_root.join(pod);
3025
3026 if !repo.join(".git").is_dir() {
3030 return;
3031 }
3032
3033 let agent_did = agent.unwrap_or("urn:solid:anonymous");
3034 let created = std::time::SystemTime::now()
3035 .duration_since(std::time::UNIX_EPOCH)
3036 .map(|d| d.as_secs())
3037 .unwrap_or(0);
3038
3039 let (policy, ticker_override) =
3042 handlers::prov::resolve_anchor_policy(state, resource_path).await;
3043
3044 let marker = std::sync::Arc::new(ShellGitMarker::new());
3049 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
3050 None
3051 } else {
3052 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
3053 };
3054 let (log, ticker, network) = match &anchorer_bundle {
3055 Some((anchorer, ticker, network)) => (
3056 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
3057 ticker.clone(),
3058 network.clone(),
3059 ),
3060 None => (
3062 ProvenanceLog::new(marker.clone()),
3063 String::new(),
3064 String::new(),
3065 ),
3066 };
3067
3068 let record_policy = match policy {
3072 AnchorPolicy::Epoch => AnchorPolicy::Never,
3073 other => other,
3074 };
3075 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
3076
3077 let write_record = solid_pod_rs::provenance::WriteRecord {
3081 repo: &repo,
3082 path: rel,
3083 agent_did,
3084 message,
3085 policy: record_policy,
3086 high_value,
3087 ticker: &ticker,
3088 network: &network,
3089 created,
3090 };
3091 let mut mark = match log.record(write_record).await {
3092 Ok(m) => m,
3093 Err(e) => {
3094 tracing::warn!(
3095 target: "solid_pod_rs_server::git_mark",
3096 resource = %resource_path,
3097 "provenance record failed (swallowed, write already succeeded): {e}"
3098 );
3099 return;
3100 }
3101 };
3102 mark.resource = resource_path.to_string();
3105
3106 if matches!(policy, AnchorPolicy::Epoch) {
3110 if let Some((anchorer, _, _)) = &anchorer_bundle {
3111 match handlers::prov::epoch_push_and_maybe_anchor(
3112 state,
3113 anchorer,
3114 &ticker,
3115 &network,
3116 &mark.git.commit_sha,
3117 )
3118 .await
3119 {
3120 Ok(Some(closed)) => tracing::debug!(
3121 target: "solid_pod_rs_server::git_mark",
3122 root = %closed.root,
3123 n = closed.commits.len(),
3124 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
3125 ),
3126 Ok(None) => {}
3127 Err(e) => tracing::warn!(
3128 target: "solid_pod_rs_server::git_mark",
3129 "epoch batch/anchor failed (swallowed): {e}"
3130 ),
3131 }
3132 }
3133 }
3134
3135 let ttl = prov_ttl(&mark);
3140 let sidecar = format!("{resource_path}.prov.ttl");
3141 if let Err(e) = state
3142 .storage
3143 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
3144 .await
3145 {
3146 tracing::warn!(
3147 target: "solid_pod_rs_server::git_mark",
3148 sidecar = %sidecar,
3149 "provenance sidecar write failed (swallowed): {e}"
3150 );
3151 return;
3152 }
3153
3154 tracing::debug!(
3155 target: "solid_pod_rs_server::git_mark",
3156 resource = %resource_path,
3157 commit = %mark.git.commit_sha,
3158 anchored = mark.anchor.is_some(),
3159 "provenance recorded"
3160 );
3161}
3162
3163#[cfg(not(feature = "git"))]
3166#[inline]
3167async fn git_mark_write(
3168 _state: &AppState,
3169 _resource_path: &str,
3170 _agent: Option<&str>,
3171 _message: &str,
3172) {
3173}
3174
3175#[cfg(feature = "git")]
3176pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
3177 let caller = extract_pubkey(req).await?;
3178 if caller != pod_pubkey {
3179 return None;
3180 }
3181 Some(caller)
3182}
3183
3184#[cfg(feature = "git")]
3185fn git_json_err(msg: &str, status: u16) -> HttpResponse {
3186 HttpResponse::build(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
3187 .content_type("application/json")
3188 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
3189}
3190
3191#[cfg(feature = "git")]
3193#[derive(serde::Deserialize)]
3194struct GitStageBody {
3195 paths: Option<Vec<String>>,
3196 all: Option<bool>,
3197}
3198
3199#[cfg(feature = "git")]
3200#[derive(serde::Deserialize)]
3201struct GitCommitBody {
3202 message: String,
3203 author_name: Option<String>,
3204 author_email: Option<String>,
3205}
3206
3207#[cfg(feature = "git")]
3208#[derive(serde::Deserialize)]
3209struct GitBranchBody {
3210 name: String,
3211}
3212
3213#[cfg(feature = "git")]
3216async fn handle_git_status(
3217 path: web::Path<String>,
3218 req: HttpRequest,
3219 state: web::Data<AppState>,
3220) -> HttpResponse {
3221 let pubkey = path.into_inner();
3222 if require_pod_owner(&req, &pubkey).await.is_none() {
3223 return git_json_err("Authentication required", 401);
3224 }
3225 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3226 return git_json_err("Git not available (no FS backend)", 501);
3227 };
3228 match solid_pod_rs_git::api::git_status(&repo).await {
3229 Ok(s) => HttpResponse::Ok()
3230 .content_type("application/json")
3231 .body(serde_json::to_string(&s).unwrap_or_default()),
3232 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3233 }
3234}
3235
3236#[cfg(feature = "git")]
3237async fn handle_git_log(
3238 path: web::Path<String>,
3239 req: HttpRequest,
3240 state: web::Data<AppState>,
3241 query: web::Query<std::collections::HashMap<String, String>>,
3242) -> HttpResponse {
3243 let pubkey = path.into_inner();
3244 if require_pod_owner(&req, &pubkey).await.is_none() {
3245 return git_json_err("Authentication required", 401);
3246 }
3247 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3248 return git_json_err("Git not available (no FS backend)", 501);
3249 };
3250 let limit: u32 = query
3251 .get("limit")
3252 .and_then(|v| v.parse().ok())
3253 .unwrap_or(20);
3254 match solid_pod_rs_git::api::git_log(&repo, limit).await {
3255 Ok(entries) => HttpResponse::Ok()
3256 .content_type("application/json")
3257 .body(serde_json::to_string(&entries).unwrap_or_default()),
3258 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3259 }
3260}
3261
3262#[cfg(feature = "git")]
3263async fn handle_git_diff(
3264 path: web::Path<String>,
3265 req: HttpRequest,
3266 state: web::Data<AppState>,
3267 query: web::Query<std::collections::HashMap<String, String>>,
3268) -> HttpResponse {
3269 let pubkey = path.into_inner();
3270 if require_pod_owner(&req, &pubkey).await.is_none() {
3271 return git_json_err("Authentication required", 401);
3272 }
3273 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3274 return git_json_err("Git not available (no FS backend)", 501);
3275 };
3276 let file_path = query.get("path").map(String::as_str);
3277 let staged = query
3278 .get("staged")
3279 .map(|v| v == "true" || v == "1")
3280 .unwrap_or(false);
3281 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
3282 Ok(diff) => HttpResponse::Ok().content_type("text/plain").body(diff),
3283 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3284 }
3285}
3286
3287#[cfg(feature = "git")]
3288async fn handle_git_stage(
3289 path: web::Path<String>,
3290 req: HttpRequest,
3291 state: web::Data<AppState>,
3292 body: web::Bytes,
3293) -> HttpResponse {
3294 let pubkey = path.into_inner();
3295 if require_pod_owner(&req, &pubkey).await.is_none() {
3296 return git_json_err("Authentication required", 401);
3297 }
3298 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3299 return git_json_err("Git not available (no FS backend)", 501);
3300 };
3301 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3302 Ok(v) => v,
3303 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3304 };
3305 let paths = parsed.paths.unwrap_or_default();
3306 let all = parsed.all.unwrap_or(false);
3307 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
3308 Ok(()) => HttpResponse::Ok()
3309 .content_type("application/json")
3310 .body(r#"{"ok":true}"#),
3311 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3312 }
3313}
3314
3315#[cfg(feature = "git")]
3316async fn handle_git_unstage(
3317 path: web::Path<String>,
3318 req: HttpRequest,
3319 state: web::Data<AppState>,
3320 body: web::Bytes,
3321) -> HttpResponse {
3322 let pubkey = path.into_inner();
3323 if require_pod_owner(&req, &pubkey).await.is_none() {
3324 return git_json_err("Authentication required", 401);
3325 }
3326 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3327 return git_json_err("Git not available (no FS backend)", 501);
3328 };
3329 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3330 Ok(v) => v,
3331 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3332 };
3333 let paths = parsed.paths.unwrap_or_default();
3334 let all = parsed.all.unwrap_or(false);
3335 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
3336 Ok(()) => HttpResponse::Ok()
3337 .content_type("application/json")
3338 .body(r#"{"ok":true}"#),
3339 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3340 }
3341}
3342
3343#[cfg(feature = "git")]
3344async fn handle_git_commit(
3345 path: web::Path<String>,
3346 req: HttpRequest,
3347 state: web::Data<AppState>,
3348 body: web::Bytes,
3349) -> HttpResponse {
3350 let pubkey = path.into_inner();
3351 if require_pod_owner(&req, &pubkey).await.is_none() {
3352 return git_json_err("Authentication required", 401);
3353 }
3354 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3355 return git_json_err("Git not available (no FS backend)", 501);
3356 };
3357 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
3358 Ok(v) => v,
3359 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3360 };
3361 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
3362 let author_email = parsed
3363 .author_email
3364 .as_deref()
3365 .unwrap_or("pod@dreamlab-ai.com");
3366 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email).await
3367 {
3368 Ok(result) => HttpResponse::Ok()
3369 .content_type("application/json")
3370 .body(serde_json::to_string(&result).unwrap_or_default()),
3371 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3372 }
3373}
3374
3375#[cfg(feature = "git")]
3376async fn handle_git_branches(
3377 path: web::Path<String>,
3378 req: HttpRequest,
3379 state: web::Data<AppState>,
3380) -> HttpResponse {
3381 let pubkey = path.into_inner();
3382 if require_pod_owner(&req, &pubkey).await.is_none() {
3383 return git_json_err("Authentication required", 401);
3384 }
3385 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3386 return git_json_err("Git not available (no FS backend)", 501);
3387 };
3388 match solid_pod_rs_git::api::git_branches(&repo).await {
3389 Ok(info) => HttpResponse::Ok()
3390 .content_type("application/json")
3391 .body(serde_json::to_string(&info).unwrap_or_default()),
3392 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3393 }
3394}
3395
3396#[cfg(feature = "git")]
3397async fn handle_git_create_branch(
3398 path: web::Path<String>,
3399 req: HttpRequest,
3400 state: web::Data<AppState>,
3401 body: web::Bytes,
3402) -> HttpResponse {
3403 let pubkey = path.into_inner();
3404 if require_pod_owner(&req, &pubkey).await.is_none() {
3405 return git_json_err("Authentication required", 401);
3406 }
3407 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3408 return git_json_err("Git not available (no FS backend)", 501);
3409 };
3410 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
3411 Ok(v) => v,
3412 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3413 };
3414 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
3415 Ok(()) => HttpResponse::Ok()
3416 .content_type("application/json")
3417 .body(r#"{"ok":true}"#),
3418 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3419 }
3420}
3421
3422#[cfg(feature = "git")]
3423async fn handle_git_discard(
3424 path: web::Path<String>,
3425 req: HttpRequest,
3426 state: web::Data<AppState>,
3427 body: web::Bytes,
3428) -> HttpResponse {
3429 let pubkey = path.into_inner();
3430 if require_pod_owner(&req, &pubkey).await.is_none() {
3431 return git_json_err("Authentication required", 401);
3432 }
3433 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3434 return git_json_err("Git not available (no FS backend)", 501);
3435 };
3436 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3437 Ok(v) => v,
3438 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3439 };
3440 let paths = parsed.paths.unwrap_or_default();
3441 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
3442 Ok(()) => HttpResponse::Ok()
3443 .content_type("application/json")
3444 .body(r#"{"ok":true}"#),
3445 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3446 }
3447}
3448
3449async fn handle_git_panel_options(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
3457 let origin = req
3458 .headers()
3459 .get(header::ORIGIN)
3460 .and_then(|v| v.to_str().ok())
3461 .map(str::to_string);
3462
3463 let mut rsp = HttpResponse::NoContent().finish();
3464 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
3465 rsp
3466}
3467
3468async fn handle_admin_provision(
3485 req: HttpRequest,
3486 state: web::Data<AppState>,
3487 path: web::Path<String>,
3488) -> HttpResponse {
3489 let expected = match &state.admin_key {
3491 Some(k) => k.clone(),
3492 None => {
3493 return HttpResponse::Forbidden().json(serde_json::json!({
3494 "error": "admin key not configured on this server"
3495 }));
3496 }
3497 };
3498 let provided = req
3499 .headers()
3500 .get("x-pod-admin-key")
3501 .and_then(|v| v.to_str().ok())
3502 .unwrap_or("");
3503 use subtle::ConstantTimeEq;
3508 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
3509 if !bool::from(key_match) {
3510 return HttpResponse::Forbidden().json(serde_json::json!({"error": "invalid admin key"}));
3511 }
3512
3513 let pubkey = path.into_inner();
3515 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
3516 return HttpResponse::BadRequest()
3517 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
3518 }
3519
3520 let data_root = match &state.data_root {
3522 Some(r) => r.clone(),
3523 None => {
3524 return HttpResponse::InternalServerError().json(serde_json::json!({
3525 "error": "server has no fs-backend storage configured"
3526 }));
3527 }
3528 };
3529
3530 let pods_root = data_root.join("pods");
3543 let pod_dir = pods_root.join(&pubkey);
3544
3545 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
3547 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
3548 return HttpResponse::InternalServerError()
3549 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
3550 }
3551
3552 let acl_content = format!(
3562 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
3563 <#owner> a acl:Authorization ;\n\
3564 acl:agent <did:nostr:{pubkey}> ;\n\
3565 acl:accessTo </pods/{pubkey}/> ;\n\
3566 acl:default </pods/{pubkey}/> ;\n\
3567 acl:mode acl:Read, acl:Write, acl:Control .\n"
3568 );
3569
3570 let sibling_acl_path = pods_root.join(format!("{pubkey}.acl"));
3580 if !sibling_acl_path.exists() {
3581 if let Err(e) = tokio::fs::write(&sibling_acl_path, acl_content.as_bytes()).await {
3582 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write sibling .acl failed");
3583 return HttpResponse::InternalServerError()
3584 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
3585 }
3586 }
3587
3588 let inner_acl_path = pod_dir.join(".acl");
3595 if !inner_acl_path.exists() {
3596 if let Err(e) = tokio::fs::write(&inner_acl_path, acl_content.as_bytes()).await {
3597 tracing::warn!(pubkey = %pubkey, error = %e, "/_admin/provision: write inner .acl failed (non-fatal; sibling ACL governs)");
3598 }
3599 }
3600
3601 #[cfg(feature = "git")]
3603 {
3604 use tokio::process::Command;
3605
3606 if !pod_dir.join(".git").exists() {
3608 let init_out = Command::new("git")
3609 .args(["init", "-b", "main", pod_dir.to_str().unwrap_or(".")])
3610 .output()
3611 .await;
3612
3613 match init_out {
3614 Ok(out) if out.status.success() => {}
3615 Ok(out) => {
3616 let stderr = String::from_utf8_lossy(&out.stderr);
3617 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
3618 }
3619 Err(e) => {
3620 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
3621 }
3622 }
3623
3624 let cfg_out = Command::new("git")
3627 .args([
3628 "-C",
3629 pod_dir.to_str().unwrap_or("."),
3630 "config",
3631 "receive.denyCurrentBranch",
3632 "updateInstead",
3633 ])
3634 .output()
3635 .await;
3636
3637 if let Err(e) = cfg_out {
3638 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
3639 }
3640 }
3641 }
3642
3643 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
3645 HttpResponse::Ok().json(serde_json::json!({
3646 "podUrl": format!("{base_url}/pods/{pubkey}/"),
3647 "ok": true,
3648 }))
3649}
3650
3651async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
3656 let Some(ref data_root) = state.data_root else {
3657 return HttpResponse::Ok()
3658 .content_type("application/json")
3659 .json(serde_json::json!({"apps": [], "count": 0}));
3660 };
3661
3662 let server_url = state.nodeinfo.base_url.clone();
3663
3664 let mut read_dir = match tokio::fs::read_dir(data_root).await {
3666 Ok(rd) => rd,
3667 Err(_) => {
3668 return HttpResponse::Ok()
3669 .content_type("application/json")
3670 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
3671 }
3672 };
3673
3674 let mut apps: Vec<serde_json::Value> = Vec::new();
3675 let mut scanned = 0usize;
3676
3677 while scanned < 1000 {
3678 let entry = match read_dir.next_entry().await {
3679 Ok(Some(e)) => e,
3680 Ok(None) => break,
3681 Err(_) => break,
3682 };
3683
3684 let file_type = match entry.file_type().await {
3685 Ok(ft) => ft,
3686 Err(_) => continue,
3687 };
3688 if !file_type.is_dir() {
3689 continue;
3690 }
3691
3692 scanned += 1;
3693
3694 let manifest_path = entry.path().join("apps").join("manifest.json");
3695 let contents = match tokio::fs::read(&manifest_path).await {
3696 Ok(c) => c,
3697 Err(_) => continue,
3698 };
3699
3700 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
3701 Ok(v) => v,
3702 Err(_) => continue,
3703 };
3704
3705 if let Some(pod_name) = entry.file_name().to_str() {
3707 if manifest.get("podOwner").is_none() {
3708 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
3709 }
3710 }
3711
3712 apps.push(manifest);
3713 }
3714
3715 let count = apps.len();
3716 HttpResponse::Ok()
3717 .content_type("application/json")
3718 .json(serde_json::json!({
3719 "apps": apps,
3720 "serverUrl": server_url,
3721 "count": count,
3722 }))
3723}
3724
3725#[allow(dead_code)]
3738fn is_git_request(path: &str) -> bool {
3739 path.contains("/info/refs")
3740 || path.contains("/git-upload-pack")
3741 || path.contains("/git-receive-pack")
3742}
3743
3744#[allow(dead_code)]
3747fn is_dot_git_path(path: &str) -> bool {
3748 path.contains("/.git/") || path.ends_with("/.git")
3749}
3750
3751#[cfg(feature = "git")]
3752async fn handle_git(
3753 req: HttpRequest,
3754 body: web::Bytes,
3755 state: web::Data<AppState>,
3756) -> HttpResponse {
3757 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
3758 use solid_pod_rs_git::service::{GitHttpService, GitRequest};
3759
3760 let path = req.uri().path().to_string();
3761
3762 let pod_name = path
3765 .trim_start_matches('/')
3766 .split('/')
3767 .next()
3768 .unwrap_or("")
3769 .to_string();
3770 let Some(ref data_root) = state.data_root else {
3771 return HttpResponse::NotImplemented().json(serde_json::json!({
3772 "error": "git requires fs-backend storage",
3773 "reason": "data_root_not_configured"
3774 }));
3775 };
3776 let repo_root = data_root.join(&pod_name);
3777 if !repo_root.exists() {
3778 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
3779 }
3780
3781 let query = req.uri().query().unwrap_or("").to_string();
3782 let host_url = {
3783 let conn = req.connection_info();
3784 Some(format!("{}://{}", conn.scheme(), conn.host()))
3785 };
3786 let headers: Vec<(String, String)> = req
3787 .headers()
3788 .iter()
3789 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
3790 .collect();
3791
3792 let git_req = GitRequest {
3793 method: req.method().as_str().to_string(),
3794 path,
3795 query,
3796 headers,
3797 body,
3798 host_url,
3799 };
3800
3801 let is_write = git_req.is_write();
3813 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
3814 Ok(pk) => Some(format!("did:nostr:{pk}")),
3815 Err(_) => None,
3816 };
3817 let wac_path = format!("/{pod_name}/");
3818 let origin = req_origin(&req);
3819 let wac = if is_write {
3820 enforce_write_ctx(
3821 &state,
3822 &wac_path,
3823 AccessMode::Write,
3824 agent.as_deref(),
3825 origin,
3826 )
3827 .await
3828 } else {
3829 enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin).await
3830 };
3831 if let Err(e) = wac {
3832 let mut resp = e.error_response();
3836 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
3837 if let (Ok(name), Ok(value)) = (
3838 actix_web::http::header::HeaderName::from_bytes(k.as_bytes()),
3839 actix_web::http::header::HeaderValue::from_str(v),
3840 ) {
3841 resp.headers_mut().insert(name, value);
3842 }
3843 }
3844 return resp;
3845 }
3846
3847 let service = GitHttpService::new(repo_root);
3848 match service.handle(git_req).await {
3849 Ok(git_resp) => {
3850 let mut builder = HttpResponse::build(
3851 actix_web::http::StatusCode::from_u16(git_resp.status)
3852 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3853 );
3854 for (k, v) in &git_resp.headers {
3855 builder.insert_header((k.as_str(), v.as_str()));
3856 }
3857 builder.body(git_resp.body)
3858 }
3859 Err(e) => {
3860 let status = e.status_code();
3861 let mut builder = HttpResponse::build(
3862 actix_web::http::StatusCode::from_u16(status)
3863 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3864 );
3865 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
3866 builder.insert_header((k, v));
3867 }
3868 builder.json(serde_json::json!({"error": e.to_string()}))
3869 }
3870 }
3871}
3872
3873#[cfg(feature = "forge")]
3882fn forge_plugin_dir(state: &AppState) -> Option<PathBuf> {
3883 state.data_root.as_ref().map(|r| r.join(".forge"))
3884}
3885
3886#[cfg(feature = "forge")]
3892struct ServerLoopback {
3893 client: reqwest::Client,
3894}
3895
3896#[cfg(feature = "forge")]
3897#[async_trait::async_trait]
3898impl solid_pod_rs_forge::LoopbackFetch for ServerLoopback {
3899 async fn get(
3900 &self,
3901 url: &str,
3902 max_bytes: usize,
3903 timeout_secs: u64,
3904 ) -> solid_pod_rs_forge::bodies::FetchResult {
3905 use solid_pod_rs_forge::bodies::FetchResult;
3906 let resp = match self
3907 .client
3908 .get(url)
3909 .timeout(Duration::from_secs(timeout_secs.max(1)))
3910 .send()
3911 .await
3912 {
3913 Ok(r) => r,
3914 Err(e) => return FetchResult::Error(e.to_string()),
3915 };
3916 let code = resp.status().as_u16();
3917 if code == 404 || code == 410 {
3918 return FetchResult::Removed;
3919 }
3920 if !resp.status().is_success() {
3921 return FetchResult::Error(format!("status {code}"));
3922 }
3923 match resp.bytes().await {
3924 Ok(b) if b.len() > max_bytes => FetchResult::TooLarge,
3925 Ok(b) => FetchResult::Body(b.to_vec()),
3926 Err(e) => FetchResult::Error(e.to_string()),
3927 }
3928 }
3929}
3930
3931#[cfg(feature = "forge")]
3937async fn handle_forge(
3938 req: HttpRequest,
3939 body: web::Bytes,
3940 state: web::Data<AppState>,
3941) -> HttpResponse {
3942 use solid_pod_rs_forge::{ForgeConfig, ForgeRequest, ForgeService};
3943
3944 let Some(plugin_dir) = forge_plugin_dir(&state) else {
3945 return HttpResponse::NotImplemented().json(serde_json::json!({
3946 "error": "forge requires fs-backend storage",
3947 "reason": "data_root_not_configured"
3948 }));
3949 };
3950
3951 let loopback: Arc<dyn solid_pod_rs_forge::LoopbackFetch> = Arc::new(ServerLoopback {
3956 client: reqwest::Client::new(),
3957 });
3958 let service = match ForgeService::new(ForgeConfig::default(), plugin_dir) {
3959 Ok(s) => s.with_loopback(loopback),
3960 Err(e) => {
3961 return HttpResponse::InternalServerError()
3962 .json(serde_json::json!({"error": e.to_string()}));
3963 }
3964 };
3965
3966 let path = req.uri().path().to_string();
3967 let query = req.uri().query().unwrap_or("").to_string();
3968 let host_url = {
3969 let conn = req.connection_info();
3970 Some(format!("{}://{}", conn.scheme(), conn.host()))
3971 };
3972 let headers: Vec<(String, String)> = req
3973 .headers()
3974 .iter()
3975 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
3976 .collect();
3977
3978 let forge_req = ForgeRequest {
3979 method: req.method().as_str().to_string(),
3980 path,
3981 query,
3982 headers,
3983 raw_body: body,
3984 host_url,
3985 };
3986
3987 let agent = service.resolve_agent(&forge_req);
3992
3993 match service.handle(forge_req, agent).await {
3994 Ok(resp) => {
3995 let mut builder = HttpResponse::build(
3996 StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3997 );
3998 for (k, v) in &resp.headers {
3999 builder.insert_header((k.as_str(), v.as_str()));
4000 }
4001 builder.body(resp.body)
4002 }
4003 Err(e) => {
4004 let status = e.status_code();
4005 HttpResponse::build(
4006 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
4007 )
4008 .json(serde_json::json!({"error": e.to_string()}))
4009 }
4010 }
4011}
4012
4013pub fn build_app(
4025 state: AppState,
4026) -> App<
4027 impl actix_web::dev::ServiceFactory<
4028 ServiceRequest,
4029 Config = (),
4030 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
4031 Error = ActixError,
4032 InitError = (),
4033 >,
4034> {
4035 let body_cap = state.body_cap;
4036 let dotfiles = state.dotfiles.clone();
4037 let allowed_origins = Arc::new(state.allowed_origins.clone());
4038
4039 let mut app = App::new()
4040 .app_data(web::Data::new(state.clone()))
4041 .app_data(web::PayloadConfig::new(body_cap))
4042 .wrap(ErrorLoggingMiddleware)
4047 .wrap(CorsHeaders { allowed_origins })
4048 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
4052 .wrap(PathTraversalGuard)
4053 .wrap(DotfileGuard::new(dotfiles));
4054
4055 app = app
4061 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
4062 .route(
4063 "/.well-known/webfinger",
4064 web::get().to(handle_well_known_webfinger),
4065 )
4066 .route(
4067 "/.well-known/nodeinfo",
4068 web::get().to(handle_well_known_nodeinfo),
4069 )
4070 .route(
4071 "/.well-known/nodeinfo/2.1",
4072 web::get().to(handle_well_known_nodeinfo_2_1),
4073 );
4074
4075 #[cfg(feature = "did-nostr")]
4076 {
4077 app = app.route(
4078 "/.well-known/did/nostr/{pubkey}.json",
4079 web::get().to(handle_well_known_did_nostr),
4080 );
4081 }
4082
4083 #[cfg(feature = "nip05-endpoint")]
4088 {
4089 app = app.route(
4090 "/.well-known/nostr.json",
4091 web::get().to(handle_well_known_nip05),
4092 );
4093 }
4094
4095 #[cfg(feature = "export-jsonld")]
4100 {
4101 app = app.route("/api/exports/all", web::get().to(handle_export_all));
4102 }
4103
4104 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
4106
4107 app = app.route("/pay/.info", web::get().to(handle_pay_info));
4109
4110 app = app.configure(handlers::pay::register);
4115
4116 app = app.route("/proxy", web::get().to(handle_proxy));
4118
4119 if state.mcp_enabled {
4123 app = app.route("/mcp", web::post().to(mcp::handle_mcp)).route(
4124 "/mcp",
4125 web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options),
4126 );
4127 }
4128
4129 app = app.route(
4132 "/_admin/provision/{pubkey}",
4133 web::post().to(handle_admin_provision),
4134 );
4135
4136 app = app
4138 .route("/.pods", web::post().to(handle_create_pod))
4139 .route("/api/accounts/new", web::post().to(handle_create_account))
4140 .route("/pods/check/{name}", web::get().to(handle_pod_check))
4141 .route("/login/password", web::post().to(handle_login_password))
4142 .route(
4143 "/account/password/reset",
4144 web::post().to(handle_password_reset_request),
4145 )
4146 .route(
4147 "/account/password/change",
4148 web::post().to(handle_password_change),
4149 );
4150
4151 #[cfg(feature = "forge")]
4156 {
4157 app = app
4158 .route("/forge", web::route().to(handle_forge))
4159 .route("/forge/{tail:.*}", web::route().to(handle_forge));
4160 }
4161
4162 app = app
4167 .route(
4168 "/{tail:.*}/.git",
4170 web::route().to(|| async {
4171 HttpResponse::Forbidden()
4172 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4173 }),
4174 )
4175 .route(
4176 "/{tail:.*}/.git/{rest:.*}",
4177 web::route().to(|| async {
4178 HttpResponse::Forbidden()
4179 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4180 }),
4181 );
4182
4183 app = app.route(
4187 "/pods/{pk}/_git/{tail:.*}",
4188 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
4189 );
4190
4191 #[cfg(feature = "git")]
4192 {
4193 app = app
4195 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
4196 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
4197 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
4198
4199 app = app
4202 .route(
4203 "/pods/{pubkey}/_git/status",
4204 web::get().to(handle_git_status),
4205 )
4206 .route("/pods/{pubkey}/_git/log", web::get().to(handle_git_log))
4207 .route("/pods/{pubkey}/_git/diff", web::get().to(handle_git_diff))
4208 .route(
4209 "/pods/{pubkey}/_git/stage",
4210 web::post().to(handle_git_stage),
4211 )
4212 .route(
4213 "/pods/{pubkey}/_git/unstage",
4214 web::post().to(handle_git_unstage),
4215 )
4216 .route(
4217 "/pods/{pubkey}/_git/commit",
4218 web::post().to(handle_git_commit),
4219 )
4220 .route(
4221 "/pods/{pubkey}/_git/branches",
4222 web::get().to(handle_git_branches),
4223 )
4224 .route(
4225 "/pods/{pubkey}/_git/branch",
4226 web::post().to(handle_git_create_branch),
4227 )
4228 .route(
4229 "/pods/{pubkey}/_git/discard",
4230 web::post().to(handle_git_discard),
4231 );
4232
4233 app = app.configure(handlers::prov::register);
4240 }
4241 #[cfg(not(feature = "git"))]
4242 {
4243 let git_501 = || async {
4247 HttpResponse::NotImplemented()
4248 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
4249 };
4250 app = app
4251 .route("/{tail:.*}/info/refs", web::get().to(git_501))
4252 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
4253 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
4254 }
4255
4256 app.route("/{tail:.*}/", web::post().to(handle_post))
4259 .route("/{tail:.*}/", web::put().to(handle_put))
4260 .route("/{tail:.*}", web::get().to(handle_get))
4261 .route("/{tail:.*}", web::head().to(handle_get))
4262 .route("/{tail:.*}", web::put().to(handle_put))
4263 .route("/{tail:.*}", web::patch().to(handle_patch))
4264 .route("/{tail:.*}", web::delete().to(handle_delete))
4265 .route(
4266 "/{tail:.*}",
4267 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
4268 )
4269 .route(
4270 "/{tail:.*}",
4271 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
4272 )
4273}
4274
4275#[cfg(test)]
4280mod payment_gating_tests {
4281 use super::*;
4282 use solid_pod_rs::payments::WebLedger;
4283 use solid_pod_rs::storage::memory::MemoryBackend;
4284
4285 const PRINCIPAL: &str = "did:nostr:alice";
4286
4287 const PAID_WRITE_ACL: &str = r#"
4290@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4291
4292<#paid-write> a acl:Authorization ;
4293 acl:agent <did:nostr:alice> ;
4294 acl:accessTo </premium/inbox> ;
4295 acl:mode acl:Write ;
4296 acl:condition [
4297 a acl:PaymentCondition ;
4298 acl:costSats 100
4299 ] .
4300"#;
4301
4302 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
4303 let mut ledger = WebLedger::new("Test Pod Credits");
4304 if sats > 0 {
4305 ledger.credit(did, sats);
4306 }
4307 let body = serde_json::to_vec(&ledger).unwrap();
4308 storage
4309 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
4310 .await
4311 .unwrap();
4312 }
4313
4314 async fn seed_acl(storage: &dyn Storage) {
4315 storage
4316 .put(
4317 "/premium/inbox.acl",
4318 Bytes::from(PAID_WRITE_ACL),
4319 "text/turtle",
4320 )
4321 .await
4322 .unwrap();
4323 }
4324
4325 #[actix_web::test]
4327 async fn resolve_balance_reads_ledger_entry() {
4328 let storage = MemoryBackend::new();
4329 seed_ledger(&storage, PRINCIPAL, 250).await;
4330 assert_eq!(
4331 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
4332 Some(250)
4333 );
4334 }
4335
4336 #[actix_web::test]
4338 async fn resolve_balance_zero_when_no_entry() {
4339 let storage = MemoryBackend::new();
4340 seed_ledger(&storage, "did:nostr:bob", 500).await;
4341 assert_eq!(
4342 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
4343 Some(0)
4344 );
4345 }
4346
4347 #[actix_web::test]
4349 async fn resolve_balance_none_when_anonymous() {
4350 let storage = MemoryBackend::new();
4351 seed_ledger(&storage, PRINCIPAL, 1_000).await;
4352 assert_eq!(resolve_balance_sats(&storage, None).await, None);
4353 }
4354
4355 #[actix_web::test]
4357 async fn paid_write_denied_below_balance() {
4358 let storage = Arc::new(MemoryBackend::new());
4359 seed_acl(storage.as_ref()).await;
4360 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
4362
4363 let result =
4364 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4365 assert!(
4366 result.is_err(),
4367 "balance 50 < cost 100 must be denied — sat-gating loop closed"
4368 );
4369 }
4370
4371 #[actix_web::test]
4373 async fn paid_write_allowed_at_balance() {
4374 let storage = Arc::new(MemoryBackend::new());
4375 seed_acl(storage.as_ref()).await;
4376 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
4378
4379 let result =
4380 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4381 assert!(
4382 result.is_ok(),
4383 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
4384 );
4385 }
4386
4387 #[actix_web::test]
4389 async fn paid_write_allowed_above_balance() {
4390 let storage = Arc::new(MemoryBackend::new());
4391 seed_acl(storage.as_ref()).await;
4392 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
4393 let state = AppState::new(storage);
4394
4395 let result =
4396 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4397 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
4398 }
4399
4400 #[actix_web::test]
4404 async fn paid_write_anonymous_denied() {
4405 let storage = Arc::new(MemoryBackend::new());
4406 seed_acl(storage.as_ref()).await;
4407 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
4408 let state = AppState::new(storage);
4409
4410 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
4411 assert!(
4412 result.is_err(),
4413 "anonymous caller has no ledger principal — PaymentCondition fails closed"
4414 );
4415 }
4416
4417 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
4424 let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
4425 let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
4426 ledger.get_balance(did)
4427 }
4428
4429 #[actix_web::test]
4431 async fn paid_write_debits_ledger() {
4432 let storage = Arc::new(MemoryBackend::new());
4433 seed_acl(storage.as_ref()).await;
4434 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
4436
4437 let result =
4438 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4439 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
4440 assert_eq!(
4441 read_balance(storage.as_ref(), PRINCIPAL).await,
4442 150,
4443 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
4444 );
4445 }
4446
4447 #[actix_web::test]
4450 async fn paid_write_debits_each_grant() {
4451 let storage = Arc::new(MemoryBackend::new());
4452 seed_acl(storage.as_ref()).await;
4453 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
4455
4456 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
4457 .await
4458 .unwrap();
4459 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
4460 .await
4461 .unwrap();
4462 assert_eq!(
4463 read_balance(storage.as_ref(), PRINCIPAL).await,
4464 50,
4465 "250 - 2*100: each granted request debits, no unmetered re-use"
4466 );
4467
4468 let third =
4470 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4471 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
4472 assert_eq!(
4473 read_balance(storage.as_ref(), PRINCIPAL).await,
4474 50,
4475 "a denied request must not debit"
4476 );
4477 }
4478
4479 #[actix_web::test]
4481 async fn paid_read_debits_ledger() {
4482 const PAID_READ_ACL: &str = r#"
4483@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4484
4485<#paid-read> a acl:Authorization ;
4486 acl:agent <did:nostr:alice> ;
4487 acl:accessTo </premium/feed> ;
4488 acl:mode acl:Read ;
4489 acl:condition [
4490 a acl:PaymentCondition ;
4491 acl:costSats 30
4492 ] .
4493"#;
4494 let storage = Arc::new(MemoryBackend::new());
4495 storage
4496 .put(
4497 "/premium/feed.acl",
4498 Bytes::from(PAID_READ_ACL),
4499 "text/turtle",
4500 )
4501 .await
4502 .unwrap();
4503 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
4504 let state = AppState::new(storage.clone());
4505
4506 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
4507 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
4508 assert_eq!(
4509 read_balance(storage.as_ref(), PRINCIPAL).await,
4510 70,
4511 "100 - 30 cost: a granted paid read must debit"
4512 );
4513 }
4514
4515 #[actix_web::test]
4518 async fn free_read_does_not_debit() {
4519 let storage = Arc::new(MemoryBackend::new());
4520 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
4522 let state = AppState::new(storage.clone());
4523
4524 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
4525 .await
4526 .unwrap();
4527 assert_eq!(
4528 read_balance(storage.as_ref(), PRINCIPAL).await,
4529 100,
4530 "a grant with no PaymentCondition must not debit"
4531 );
4532 }
4533
4534 const ALICE_ONLY_READ_ACL: &str = r#"
4540@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4541
4542<#alice> a acl:Authorization ;
4543 acl:agent <did:nostr:alice> ;
4544 acl:accessTo </private/secret> ;
4545 acl:default </private/> ;
4546 acl:mode acl:Read, acl:Write, acl:Control .
4547"#;
4548
4549 async fn seed_private_read_acl(storage: &dyn Storage) {
4550 storage
4555 .put(
4556 "/private.acl",
4557 Bytes::from(ALICE_ONLY_READ_ACL),
4558 "text/turtle",
4559 )
4560 .await
4561 .unwrap();
4562 }
4563
4564 #[actix_web::test]
4568 async fn enforce_read_grants_owner() {
4569 let storage = Arc::new(MemoryBackend::new());
4570 seed_private_read_acl(storage.as_ref()).await;
4571 let state = AppState::new(storage);
4572 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
4573 assert!(result.is_ok(), "owner alice must be granted Read");
4574 }
4575
4576 #[actix_web::test]
4579 async fn enforce_read_denies_other_principal() {
4580 let storage = Arc::new(MemoryBackend::new());
4581 seed_private_read_acl(storage.as_ref()).await;
4582 let state = AppState::new(storage);
4583 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
4584 assert!(
4585 result.is_err(),
4586 "bob has no Read grant — private resource must not be world-readable"
4587 );
4588 }
4589
4590 #[actix_web::test]
4593 async fn enforce_read_denies_anonymous() {
4594 let storage = Arc::new(MemoryBackend::new());
4595 seed_private_read_acl(storage.as_ref()).await;
4596 let state = AppState::new(storage);
4597 let result = enforce_read(&state, "/private/secret", None).await;
4598 assert!(result.is_err(), "anonymous Read must be denied");
4599 }
4600
4601 const WRITE_NOT_CONTROL_ACL: &str = r#"
4609@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4610
4611<#owner> a acl:Authorization ;
4612 acl:agent <did:nostr:alice> ;
4613 acl:accessTo </shared/doc> ;
4614 acl:default </shared/> ;
4615 acl:mode acl:Read, acl:Write, acl:Control .
4616
4617<#writer> a acl:Authorization ;
4618 acl:agent <did:nostr:writer> ;
4619 acl:accessTo </shared/doc> ;
4620 acl:default </shared/> ;
4621 acl:mode acl:Read, acl:Write .
4622"#;
4623
4624 async fn seed_shared_acl(storage: &dyn Storage) {
4625 storage
4630 .put(
4631 "/shared.acl",
4632 Bytes::from(WRITE_NOT_CONTROL_ACL),
4633 "text/turtle",
4634 )
4635 .await
4636 .unwrap();
4637 }
4638
4639 #[actix_web::test]
4643 async fn acl_put_denied_for_writer_without_control() {
4644 let storage = Arc::new(MemoryBackend::new());
4645 seed_shared_acl(storage.as_ref()).await;
4646 let state = AppState::new(storage);
4647 let result = enforce_write(
4651 &state,
4652 "/shared/.acl",
4653 AccessMode::Write,
4654 Some("did:nostr:writer"),
4655 )
4656 .await;
4657 assert!(
4658 result.is_err(),
4659 "writer lacks Control — must not be able to PUT /shared/.acl"
4660 );
4661 }
4662
4663 #[actix_web::test]
4665 async fn acl_put_allowed_for_control_holder() {
4666 let storage = Arc::new(MemoryBackend::new());
4667 seed_shared_acl(storage.as_ref()).await;
4668 let state = AppState::new(storage);
4669 let result =
4670 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
4671 assert!(
4672 result.is_ok(),
4673 "alice holds Control — must be allowed to PUT /shared/.acl"
4674 );
4675 }
4676
4677 #[actix_web::test]
4679 async fn meta_put_denied_for_writer_without_control() {
4680 let storage = Arc::new(MemoryBackend::new());
4681 seed_shared_acl(storage.as_ref()).await;
4682 let state = AppState::new(storage);
4683 let result = enforce_write(
4684 &state,
4685 "/shared/doc.meta",
4686 AccessMode::Write,
4687 Some("did:nostr:writer"),
4688 )
4689 .await;
4690 assert!(
4691 result.is_err(),
4692 "writer lacks Control — must not be able to PUT a .meta sidecar"
4693 );
4694 }
4695
4696 #[test]
4698 fn protected_resource_for_acl_strips_suffixes() {
4699 assert_eq!(
4700 protected_resource_for_acl("/victim/.acl").as_deref(),
4701 Some("/victim/")
4702 );
4703 assert_eq!(
4704 protected_resource_for_acl("/a/b.acl").as_deref(),
4705 Some("/a/b")
4706 );
4707 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
4708 assert_eq!(
4709 protected_resource_for_acl("/a/b.meta").as_deref(),
4710 Some("/a/b")
4711 );
4712 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
4713 }
4714}