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 {
284 match std::env::var("JSS_MAX_REQUEST_BODY") {
285 Ok(v) => parse_size(&v)
286 .map(|u| u as usize)
287 .unwrap_or(DEFAULT_BODY_CAP),
288 Err(_) => DEFAULT_BODY_CAP,
289 }
290}
291
292impl AppState {
293 pub fn new(storage: Arc<dyn Storage>) -> Self {
296 Self {
297 storage,
298 dotfiles: Arc::new(DotfileAllowlist::from_env()),
299 body_cap: body_cap_from_env(),
300 nodeinfo: NodeInfoMeta::default(),
301 mashlib: MashlibConfig::default(),
302 mashlib_cdn: None,
303 pay_config: solid_pod_rs::payments::PayConfig::default(),
304 data_root: None,
305 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
306 allowed_origins: Vec::new(),
307 admin_key: None,
308 mcp_enabled: false,
309 mempool_url: None,
310 deposit_txo_standin_enabled: false,
313 }
314 }
315}
316
317#[derive(Debug)]
319pub struct PodCreateLimiter {
320 hits: Mutex<HashMap<IpAddr, Instant>>,
321 window: Duration,
322}
323
324impl Default for PodCreateLimiter {
325 fn default() -> Self {
326 Self {
327 hits: Mutex::new(HashMap::new()),
328 window: Duration::from_secs(24 * 60 * 60),
329 }
330 }
331}
332
333impl PodCreateLimiter {
334 fn check(&self, ip: IpAddr) -> Result<(), u64> {
335 let now = Instant::now();
336 let mut hits = self.hits.lock().unwrap();
337 if let Some(last) = hits.get(&ip).copied() {
338 let elapsed = now.saturating_duration_since(last);
339 if elapsed < self.window {
340 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
341 }
342 }
343 hits.insert(ip, now);
344 Ok(())
345 }
346}
347
348pub(crate) fn to_actix(e: PodError) -> ActixError {
353 match e {
354 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
355 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
356 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
357 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
358 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
359 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
360 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
361 }
362}
363
364pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
376 let header_val = req
377 .headers()
378 .get(header::AUTHORIZATION)
379 .and_then(|v| v.to_str().ok())?;
380 let url = {
391 let conn = req.connection_info();
392 format!("{}://{}{}", conn.scheme(), conn.host(), req.uri().path())
393 };
394 let now = std::time::SystemTime::now()
395 .duration_since(std::time::UNIX_EPOCH)
396 .map(|d| d.as_secs())
397 .unwrap_or(0);
398 let verified = nip98::verify_at(header_val, &url, req.method().as_str(), None, now).ok()?;
399
400 if NIP98_REPLAY
404 .check_and_record(&verified.event_id)
405 .await
406 .is_err()
407 {
408 tracing::warn!(
409 pubkey = %verified.pubkey,
410 method = %req.method(),
411 "NIP-98 replay rejected: token id already used within window"
412 );
413 return None;
414 }
415
416 Some(verified.pubkey)
417}
418
419pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
420 pubkey.map(|pk| format!("did:nostr:{pk}"))
421}
422
423fn req_origin(req: &HttpRequest) -> Option<&str> {
432 req.headers()
433 .get(header::ORIGIN)
434 .and_then(|v| v.to_str().ok())
435}
436
437pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
441
442async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
459 let did = agent_uri?;
460 let balance = match storage.get(WEBLEDGER_PATH).await {
461 Ok((bytes, _meta)) => {
462 match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
463 Ok(ledger) => ledger.get_balance(did),
464 Err(_) => 0,
468 }
469 }
470 Err(_) => 0,
473 };
474 Some(balance)
475}
476
477fn accept_includes_html(accept: &str) -> bool {
485 accept.split(',').any(|entry| {
486 let mime = entry.split(';').next().unwrap_or("").trim();
487 mime.eq_ignore_ascii_case("text/html")
488 })
489}
490
491fn proposed_acl_keeps_caller_control(
510 body: &[u8],
511 content_type: &str,
512 caller: Option<&str>,
513) -> bool {
514 let doc = match parse_jsonld_acl(body) {
515 Ok(d) => Some(d),
516 Err(_) => {
517 let ct = content_type.to_ascii_lowercase();
518 let text = std::str::from_utf8(body).unwrap_or("");
519 let looks_turtle = ct.starts_with("text/turtle")
520 || ct.starts_with("application/turtle")
521 || ct.starts_with("application/x-turtle")
522 || ct.starts_with("application/n-triples")
523 || text.contains("@prefix")
524 || text.contains("acl:Authorization")
525 || text.contains("auth/acl#Authorization");
529 if looks_turtle {
530 parse_turtle_acl(text).ok()
531 } else {
532 None
533 }
534 }
535 };
536 let Some(doc) = doc else {
537 return true;
539 };
540 let Some(graph) = doc.graph.as_ref() else {
541 return false;
542 };
543 graph.iter().any(|auth| {
544 let grants_control = ids_of_acl_field(&auth.mode)
545 .iter()
546 .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
547 if !grants_control {
548 return false;
549 }
550 let agents = ids_of_acl_field(&auth.agent);
551 if let Some(web_id) = caller {
552 if agents.contains(&web_id) {
553 return true;
554 }
555 }
556 let classes = ids_of_acl_field(&auth.agent_class);
557 if classes
558 .iter()
559 .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
560 {
561 return true;
562 }
563 if caller.is_some()
564 && classes.iter().any(|c| {
565 *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
566 || *c == "acl:AuthenticatedAgent"
567 })
568 {
569 return true;
570 }
571 false
572 })
573}
574
575fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
577 match field {
578 None => Vec::new(),
579 Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
580 Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
581 }
582}
583
584#[cfg_attr(not(test), allow(dead_code))]
591async fn enforce_write(
592 state: &AppState,
593 path: &str,
594 mode: AccessMode,
595 agent_uri: Option<&str>,
596) -> Result<(), ActixError> {
597 enforce_write_ctx(state, path, mode, agent_uri, None).await
598}
599
600async fn enforce_write_ctx(
608 state: &AppState,
609 path: &str,
610 mode: AccessMode,
611 agent_uri: Option<&str>,
612 request_origin: Option<&str>,
613) -> Result<(), ActixError> {
614 let origin = request_origin.and_then(wac::Origin::parse);
615 let (resource, eff_mode) = effective_acl_target(path, mode);
627
628 let acl_doc = match find_effective_acl_dyn(&*state.storage, &resource).await {
633 Ok(doc) => doc,
634 Err(e) => return Err(to_actix(e)),
635 };
636
637 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
642
643 let ctx = RequestContext {
644 web_id: agent_uri,
645 client_id: None,
646 issuer: None,
647 payment_balance_sats,
648 };
649 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
650 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
651 let granted = wac::evaluate_access_ctx_with_registry(
652 acl_doc.as_ref(),
653 &ctx,
654 &resource,
655 eff_mode,
656 origin.as_ref(),
657 &groups,
658 ®istry,
659 );
660 if !granted {
661 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
662 }
663 if resource.as_str() == path {
670 charge_granted_payment(
676 state,
677 acl_doc.as_ref(),
678 &ctx,
679 &resource,
680 eff_mode,
681 &groups,
682 ®istry,
683 )
684 .await?;
685 }
686 Ok(())
687}
688
689async fn charge_granted_payment(
698 state: &AppState,
699 acl_doc: Option<&wac::AclDocument>,
700 ctx: &RequestContext<'_>,
701 path: &str,
702 mode: AccessMode,
703 groups: &wac::StaticGroupMembership,
704 registry: &wac::conditions::ConditionRegistry,
705) -> Result<(), ActixError> {
706 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
707 if cost == 0 {
708 return Ok(());
709 }
710 if let Some(did) = ctx.web_id {
711 if debit_ledger(&*state.storage, did, cost).await.is_err() {
712 return Err(acl_denial(acl_doc, ctx.web_id, path));
713 }
714 }
715 Ok(())
716}
717
718fn acl_denial(
724 acl_doc: Option<&wac::AclDocument>,
725 agent_uri: Option<&str>,
726 path: &str,
727) -> ActixError {
728 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
729 let (status, body, unauthenticated) = if agent_uri.is_none() {
730 (StatusCode::UNAUTHORIZED, "authentication required", true)
731 } else {
732 (StatusCode::FORBIDDEN, "access forbidden", false)
733 };
734 let mut rsp = HttpResponse::new(status);
735 rsp.headers_mut().insert(
736 header::HeaderName::from_static("wac-allow"),
737 header::HeaderValue::from_str(&allow_header)
738 .unwrap_or(header::HeaderValue::from_static("")),
739 );
740 if unauthenticated {
741 rsp.headers_mut().insert(
748 header::WWW_AUTHENTICATE,
749 header::HeaderValue::from_static(
750 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
751 ),
752 );
753 }
754 actix_web::error::InternalError::from_response(body, rsp).into()
755}
756
757#[cfg_attr(not(test), allow(dead_code))]
768async fn enforce_read(
769 state: &AppState,
770 path: &str,
771 agent_uri: Option<&str>,
772) -> Result<(), ActixError> {
773 enforce_read_ctx(state, path, agent_uri, None).await
774}
775
776async fn enforce_read_ctx(
779 state: &AppState,
780 path: &str,
781 agent_uri: Option<&str>,
782 request_origin: Option<&str>,
783) -> Result<(), ActixError> {
784 let origin = request_origin.and_then(wac::Origin::parse);
785 let (resource, eff_mode) = effective_acl_target(path, AccessMode::Read);
797 let acl_doc = match find_effective_acl_dyn(&*state.storage, &resource).await {
798 Ok(doc) => doc,
799 Err(e) => return Err(to_actix(e)),
800 };
801 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
802 let ctx = RequestContext {
803 web_id: agent_uri,
804 client_id: None,
805 issuer: None,
806 payment_balance_sats,
807 };
808 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
809 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
810 let granted = wac::evaluate_access_ctx_with_registry(
811 acl_doc.as_ref(),
812 &ctx,
813 &resource,
814 eff_mode,
815 origin.as_ref(),
816 &groups,
817 ®istry,
818 );
819 if !granted {
820 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
821 }
822 if resource.as_str() == path {
826 charge_granted_payment(
831 state,
832 acl_doc.as_ref(),
833 &ctx,
834 &resource,
835 eff_mode,
836 &groups,
837 ®istry,
838 )
839 .await?;
840 }
841 Ok(())
842}
843
844async fn debit_ledger(
853 storage: &dyn Storage,
854 did: &str,
855 cost: u64,
856) -> Result<(), solid_pod_rs::payments::PaymentError> {
857 use solid_pod_rs::payments::{PaymentError, WebLedger};
858
859 let (bytes, _meta) = storage
860 .get(WEBLEDGER_PATH)
861 .await
862 .map_err(|e| PaymentError::Store(e.to_string()))?;
863 let mut ledger: WebLedger = serde_json::from_slice(&bytes)
864 .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
865 ledger.debit(did, cost)?;
866 let body = serde_json::to_vec(&ledger)
867 .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
868 storage
869 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
870 .await
871 .map_err(|e| PaymentError::Store(e.to_string()))?;
872 Ok(())
873}
874
875fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
880 let links = ldp::link_headers(path).join(", ");
881 if let Ok(value) = header::HeaderValue::from_str(&links) {
882 rsp.headers_mut()
883 .insert(header::HeaderName::from_static("link"), value);
884 }
885}
886
887fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
888 if let Ok(v) = header::HeaderValue::from_str(header_value) {
889 rsp.headers_mut()
890 .insert(header::HeaderName::from_static("wac-allow"), v);
891 }
892}
893
894fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
895 let ws_base = base_url
896 .replacen("https://", "wss://", 1)
897 .replacen("http://", "ws://", 1);
898 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
899 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
900 rsp.headers_mut()
901 .insert(header::HeaderName::from_static("updates-via"), v);
902 }
903}
904
905async fn handle_get(
906 req: HttpRequest,
907 state: web::Data<AppState>,
908) -> Result<HttpResponse, ActixError> {
909 let path = req.uri().path().to_string();
910
911 if path.contains('*') {
912 return handle_glob_get(req, state).await;
913 }
914
915 let auth_pk = extract_pubkey(&req).await;
916 let agent = agent_uri(auth_pk.as_ref());
917
918 enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
923
924 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
925
926 if ldp::is_container(&path) {
927 let accept = req
928 .headers()
929 .get(header::ACCEPT)
930 .and_then(|v| v.to_str().ok())
931 .unwrap_or("");
932
933 if accept_includes_html(accept) {
939 let index_path = format!("{path}index.html");
940 if let Ok((body, _meta)) = state.storage.get(&index_path).await {
941 let mut rsp = HttpResponse::Ok()
942 .content_type("text/html; charset=utf-8")
943 .body(body.to_vec());
944 set_wac_allow(&mut rsp, &wac_allow);
945 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
946 set_link_headers(&mut rsp, &path);
947 return Ok(rsp);
948 }
949 }
950
951 let v = state
952 .storage
953 .container_representation(&path)
954 .await
955 .map_err(to_actix)?;
956
957 let sec_fetch_dest = req
959 .headers()
960 .get("sec-fetch-dest")
961 .and_then(|v| v.to_str().ok());
962 if mashlib::should_serve(
963 accept,
964 sec_fetch_dest,
965 "application/ld+json",
966 state.mashlib.enabled,
967 ) {
968 let json_ld = serde_json::to_string(&v).ok();
969 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
970 let mut rsp = HttpResponse::Ok()
971 .content_type("text/html; charset=utf-8")
972 .insert_header(("X-Frame-Options", "DENY"))
973 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
974 .insert_header(("Cache-Control", "no-store"))
975 .body(html);
976 set_wac_allow(&mut rsp, &wac_allow);
977 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
978 set_link_headers(&mut rsp, &path);
979 return Ok(rsp);
980 }
981
982 let mut rsp = HttpResponse::Ok().json(v);
983 rsp.headers_mut().insert(
984 header::CONTENT_TYPE,
985 header::HeaderValue::from_static("application/ld+json"),
986 );
987 set_wac_allow(&mut rsp, &wac_allow);
988 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
989 set_link_headers(&mut rsp, &path);
990 return Ok(rsp);
991 }
992
993 match state.storage.get(&path).await {
994 Ok((body, meta)) => {
995 let accept = req
997 .headers()
998 .get(header::ACCEPT)
999 .and_then(|v| v.to_str().ok())
1000 .unwrap_or("");
1001 let sec_fetch_dest = req
1002 .headers()
1003 .get("sec-fetch-dest")
1004 .and_then(|v| v.to_str().ok());
1005 if mashlib::should_serve(
1006 accept,
1007 sec_fetch_dest,
1008 &meta.content_type,
1009 state.mashlib.enabled,
1010 ) {
1011 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
1012 std::str::from_utf8(&body).ok().map(|s| s.to_string())
1013 } else {
1014 None
1015 };
1016 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
1017 let mut rsp = HttpResponse::Ok()
1018 .content_type("text/html; charset=utf-8")
1019 .insert_header(("X-Frame-Options", "DENY"))
1020 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1021 .insert_header(("Cache-Control", "no-store"))
1022 .body(html);
1023 set_wac_allow(&mut rsp, &wac_allow);
1024 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1025 set_link_headers(&mut rsp, &path);
1026 return Ok(rsp);
1027 }
1028
1029 if let Some((negotiated_body, negotiated_ct)) =
1037 rdf_content_negotiate(&body, &meta.content_type, accept)
1038 {
1039 let mut rsp = HttpResponse::Ok().body(negotiated_body);
1040 rsp.headers_mut().insert(
1041 header::CONTENT_TYPE,
1042 header::HeaderValue::from_str(negotiated_ct)
1043 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
1044 );
1045 rsp.headers_mut()
1046 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
1047 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1048 rsp.headers_mut().insert(header::ETAG, etag);
1049 }
1050 set_wac_allow(&mut rsp, &wac_allow);
1051 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1052 set_link_headers(&mut rsp, &path);
1053 return Ok(rsp);
1054 }
1055
1056 let mut rsp = HttpResponse::Ok().body(body.to_vec());
1057 rsp.headers_mut().insert(
1058 header::CONTENT_TYPE,
1059 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
1060 header::HeaderValue::from_static("application/octet-stream")
1061 }),
1062 );
1063 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1064 rsp.headers_mut().insert(header::ETAG, etag);
1065 }
1066 set_wac_allow(&mut rsp, &wac_allow);
1067 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1068 set_link_headers(&mut rsp, &path);
1069 Ok(rsp)
1070 }
1071 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1072 Err(e) => Err(to_actix(e)),
1073 }
1074}
1075
1076fn has_basic_container_link(req: &HttpRequest) -> bool {
1077 req.headers()
1078 .get_all(header::LINK)
1079 .filter_map(|v| v.to_str().ok())
1080 .any(|v| {
1081 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
1082 })
1083}
1084
1085async fn handle_put(
1086 req: HttpRequest,
1087 body: web::Bytes,
1088 state: web::Data<AppState>,
1089) -> Result<HttpResponse, ActixError> {
1090 let path = req.uri().path().to_string();
1091
1092 if ldp::is_container(&path) {
1093 if has_basic_container_link(&req) {
1094 let auth_pk = extract_pubkey(&req).await;
1095 let agent = agent_uri(auth_pk.as_ref());
1096 enforce_write_ctx(
1097 &state,
1098 &path,
1099 AccessMode::Write,
1100 agent.as_deref(),
1101 req_origin(&req),
1102 )
1103 .await?;
1104 let meta = state
1105 .storage
1106 .create_container(&path)
1107 .await
1108 .map_err(to_actix)?;
1109 let mut rsp = HttpResponse::Created().finish();
1110 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1111 rsp.headers_mut().insert(header::ETAG, etag);
1112 }
1113 set_link_headers(&mut rsp, &path);
1114 return Ok(rsp);
1115 }
1116 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
1117 }
1118
1119 let auth_pk = extract_pubkey(&req).await;
1120 let agent = agent_uri(auth_pk.as_ref());
1121 enforce_write_ctx(
1122 &state,
1123 &path,
1124 AccessMode::Write,
1125 agent.as_deref(),
1126 req_origin(&req),
1127 )
1128 .await?;
1129
1130 let ct = req
1131 .headers()
1132 .get(header::CONTENT_TYPE)
1133 .and_then(|v| v.to_str().ok())
1134 .unwrap_or("application/octet-stream");
1135
1136 if protected_resource_for_acl(&path).is_some()
1141 && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
1142 {
1143 return Ok(HttpResponse::Conflict().body(
1144 "refused: the proposed ACL would not grant Control to the caller \
1145 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1146 ));
1147 }
1148
1149 let meta = state
1150 .storage
1151 .put(&path, Bytes::from(body.to_vec()), ct)
1152 .await
1153 .map_err(to_actix)?;
1154 git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
1158 let mut rsp = HttpResponse::Created().finish();
1159 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1160 rsp.headers_mut().insert(header::ETAG, etag);
1161 }
1162 set_link_headers(&mut rsp, &path);
1163 Ok(rsp)
1164}
1165
1166async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
1173 if !storage.exists(target).await.unwrap_or(false) {
1174 return target.to_string();
1175 }
1176 let seg_start = target.rfind('/').map(|s| s + 1).unwrap_or(0);
1179 let (stem, ext) = match target.rfind('.') {
1180 Some(dot) if dot > seg_start => (&target[..dot], &target[dot..]),
1181 _ => (target, ""),
1182 };
1183 for n in 1..10_000u32 {
1184 let candidate = format!("{stem}-{n}{ext}");
1185 if !storage.exists(&candidate).await.unwrap_or(false) {
1186 return candidate;
1187 }
1188 }
1189 use std::hash::{Hash, Hasher};
1190 let mut h = std::collections::hash_map::DefaultHasher::new();
1191 target.hash(&mut h);
1192 format!("{stem}-{:x}{ext}", h.finish())
1193}
1194
1195async fn handle_post(
1196 req: HttpRequest,
1197 body: web::Bytes,
1198 state: web::Data<AppState>,
1199) -> Result<HttpResponse, ActixError> {
1200 let path = req.uri().path().to_string();
1201 let auth_pk = extract_pubkey(&req).await;
1204 let agent = agent_uri(auth_pk.as_ref());
1205 enforce_write_ctx(
1206 &state,
1207 &path,
1208 AccessMode::Append,
1209 agent.as_deref(),
1210 req_origin(&req),
1211 )
1212 .await?;
1213
1214 let slug = req
1215 .headers()
1216 .get(header::HeaderName::from_static("slug"))
1217 .and_then(|v| v.to_str().ok());
1218 let mut target = match ldp::resolve_slug(&path, slug) {
1219 Ok(p) => p,
1220 Err(e) => return Err(to_actix(e)),
1221 };
1222 let ct = req
1223 .headers()
1224 .get(header::CONTENT_TYPE)
1225 .and_then(|v| v.to_str().ok())
1226 .unwrap_or("application/octet-stream");
1227
1228 if protected_resource_for_acl(&target).is_some() {
1237 enforce_write_ctx(
1238 &state,
1239 &target,
1240 AccessMode::Write,
1241 agent.as_deref(),
1242 req_origin(&req),
1243 )
1244 .await?;
1245 if !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref()) {
1246 return Ok(HttpResponse::Conflict().body(
1247 "refused: the proposed ACL would not grant Control to the caller \
1248 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1249 ));
1250 }
1251 } else {
1252 target = mint_unique_target(&*state.storage, &target).await;
1258 }
1259
1260 let meta = state
1261 .storage
1262 .put(&target, Bytes::from(body.to_vec()), ct)
1263 .await
1264 .map_err(to_actix)?;
1265 git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1268 let mut rsp = HttpResponse::Created().finish();
1269 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1270 rsp.headers_mut().insert(header::LOCATION, loc);
1271 }
1272 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1273 rsp.headers_mut().insert(header::ETAG, etag);
1274 }
1275 set_link_headers(&mut rsp, &target);
1276 Ok(rsp)
1277}
1278
1279async fn handle_patch(
1280 req: HttpRequest,
1281 body: web::Bytes,
1282 state: web::Data<AppState>,
1283) -> Result<HttpResponse, ActixError> {
1284 let path = req.uri().path().to_string();
1285 if ldp::is_container(&path) {
1286 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1287 }
1288 let auth_pk = extract_pubkey(&req).await;
1289 let agent = agent_uri(auth_pk.as_ref());
1290 enforce_write_ctx(
1296 &state,
1297 &path,
1298 AccessMode::Write,
1299 agent.as_deref(),
1300 req_origin(&req),
1301 )
1302 .await?;
1303
1304 let ct = req
1305 .headers()
1306 .get(header::CONTENT_TYPE)
1307 .and_then(|v| v.to_str().ok())
1308 .unwrap_or("");
1309 let dialect = match ldp::patch_dialect_from_mime(ct) {
1310 Some(d) => d,
1311 None => {
1312 return Ok(HttpResponse::UnsupportedMediaType()
1313 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1314 }
1315 };
1316 let body_str = match std::str::from_utf8(&body) {
1317 Ok(s) => s.to_string(),
1318 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1319 };
1320
1321 let existing = state.storage.get(&path).await;
1323 match existing {
1324 Ok((current_body, meta)) => {
1325 let out = match dialect {
1335 ldp::PatchDialect::N3 => {
1336 let seed = seed_graph_from_patch_target(¤t_body)?;
1337 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1338 }
1339 ldp::PatchDialect::SparqlUpdate => {
1340 let seed = seed_graph_from_patch_target(¤t_body)?;
1341 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1342 }
1343 ldp::PatchDialect::JsonPatch => {
1344 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1345 Ok(v) => v,
1346 Err(_) => serde_json::json!({}),
1347 };
1348 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1349 Ok(v) => v,
1350 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1351 };
1352 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1353 let bytes = serde_json::to_vec(&json)
1354 .map_err(PodError::from)
1355 .map_err(to_actix)?;
1356 let _ = state
1357 .storage
1358 .put(&path, Bytes::from(bytes), &meta.content_type)
1359 .await
1360 .map_err(to_actix)?;
1361 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1362 return Ok(HttpResponse::NoContent().finish());
1363 }
1364 };
1365 let outcome = out?;
1366 let serialised = graph_to_turtle(&outcome.graph);
1369 if protected_resource_for_acl(&path).is_some()
1375 && !proposed_acl_keeps_caller_control(
1376 serialised.as_bytes(),
1377 "application/n-triples",
1378 agent.as_deref(),
1379 )
1380 {
1381 return Ok(HttpResponse::Conflict().body(
1382 "refused: the patched ACL would not grant Control to the caller \
1383 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1384 ));
1385 }
1386 let _ = state
1387 .storage
1388 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1389 .await
1390 .map_err(to_actix)?;
1391 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1392 Ok(HttpResponse::NoContent().finish())
1393 }
1394 Err(PodError::NotFound(_)) => {
1395 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
1397 let PatchCreateOutcome::Created { graph, .. } = create else {
1398 return Err(to_actix(PodError::Unsupported(
1399 "unexpected patch outcome on absent resource".into(),
1400 )));
1401 };
1402 let serialised = graph_to_turtle(&graph);
1403 if protected_resource_for_acl(&path).is_some()
1405 && !proposed_acl_keeps_caller_control(
1406 serialised.as_bytes(),
1407 "application/n-triples",
1408 agent.as_deref(),
1409 )
1410 {
1411 return Ok(HttpResponse::Conflict().body(
1412 "refused: the patched ACL would not grant Control to the caller \
1413 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1414 ));
1415 }
1416 let _ = state
1417 .storage
1418 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1419 .await
1420 .map_err(to_actix)?;
1421 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1422 Ok(HttpResponse::Created().finish())
1423 }
1424 Err(e) => Err(to_actix(e)),
1425 }
1426}
1427
1428fn patch_parse_err(e: PodError) -> ActixError {
1432 match e {
1433 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
1434 actix_web::error::ErrorBadRequest(msg)
1435 }
1436 other => to_actix(other),
1437 }
1438}
1439
1440fn graph_to_turtle(g: &ldp::Graph) -> String {
1444 g.to_ntriples()
1445}
1446
1447fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
1454 let mut best: Option<(f32, ldp::RdfFormat)> = None;
1455 for entry in accept.split(',') {
1456 let entry = entry.trim();
1457 if entry.is_empty() {
1458 continue;
1459 }
1460 let mut parts = entry.split(';').map(|s| s.trim());
1461 let mime = match parts.next() {
1462 Some(m) => m,
1463 None => continue,
1464 };
1465 let mut q: f32 = 1.0;
1466 for token in parts {
1467 if let Some(v) = token.strip_prefix("q=") {
1468 if let Ok(parsed) = v.parse::<f32>() {
1469 q = parsed;
1470 }
1471 }
1472 }
1473 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
1476 match best {
1477 None => best = Some((q, format)),
1478 Some((bq, _)) if q > bq => best = Some((q, format)),
1479 _ => {}
1480 }
1481 }
1482 }
1483 best.map(|(_, f)| f)
1484}
1485
1486fn rdf_content_negotiate(
1502 body: &[u8],
1503 stored_ct: &str,
1504 accept: &str,
1505) -> Option<(Vec<u8>, &'static str)> {
1506 if accept.trim().is_empty() {
1507 return None;
1508 }
1509 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
1510 let target = best_explicit_rdf_format(accept)?;
1511 if target == stored_format {
1512 return None;
1513 }
1514 let text = std::str::from_utf8(body).ok()?;
1515 let graph = ldp::Graph::parse_ntriples(text).ok()?;
1516 match target {
1517 ldp::RdfFormat::Turtle => Some((
1520 graph.to_ntriples().into_bytes(),
1521 ldp::RdfFormat::Turtle.mime(),
1522 )),
1523 ldp::RdfFormat::NTriples => Some((
1524 graph.to_ntriples().into_bytes(),
1525 ldp::RdfFormat::NTriples.mime(),
1526 )),
1527 ldp::RdfFormat::JsonLd => {
1528 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
1529 Some((json, ldp::RdfFormat::JsonLd.mime()))
1530 }
1531 ldp::RdfFormat::RdfXml => None,
1533 }
1534}
1535
1536fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
1545 let text = std::str::from_utf8(current_body).map_err(|_| {
1546 actix_web::error::ErrorConflict(
1547 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
1548 )
1549 })?;
1550 if text.trim().is_empty() {
1551 return Ok(ldp::Graph::new());
1552 }
1553 ldp::Graph::parse_ntriples(text).map_err(|_| {
1554 actix_web::error::ErrorConflict(
1555 "existing resource is not N-Triples RDF and cannot be non-destructively \
1556 patched; PUT an N-Triples representation or use a JSON Patch",
1557 )
1558 })
1559}
1560
1561pub(crate) async fn find_effective_acl_dyn(
1567 storage: &dyn Storage,
1568 resource_path: &str,
1569) -> Result<Option<wac::AclDocument>, PodError> {
1570 let mut path = resource_path.to_string();
1571 let mut inherited = false;
1576 loop {
1577 let acl_key = if path == "/" {
1578 "/.acl".to_string()
1579 } else {
1580 format!("{}.acl", path.trim_end_matches('/'))
1581 };
1582 if let Ok((body, meta)) = storage.get(&acl_key).await {
1583 match parse_jsonld_acl(&body) {
1584 Ok(mut doc) => {
1585 doc.inherited = inherited;
1586 return Ok(Some(doc));
1587 }
1588 Err(PodError::BadRequest(_)) => {
1589 return Err(PodError::BadRequest("ACL document exceeds bounds".into()))
1590 }
1591 Err(_) => {}
1592 }
1593 let ct = meta.content_type.to_ascii_lowercase();
1594 let looks_turtle = ct.starts_with("text/turtle")
1595 || ct.starts_with("application/turtle")
1596 || ct.starts_with("application/x-turtle");
1597 let text = std::str::from_utf8(&body).unwrap_or("");
1598 if looks_turtle || text.contains("@prefix") || text.contains("acl:Authorization") {
1599 if let Ok(mut doc) = parse_turtle_acl(text) {
1600 doc.inherited = inherited;
1601 return Ok(Some(doc));
1602 }
1603 }
1604 }
1605 if path == "/" || path.is_empty() {
1606 break;
1607 }
1608 inherited = true;
1610 let trimmed = path.trim_end_matches('/');
1611 path = match trimmed.rfind('/') {
1612 Some(0) => "/".to_string(),
1613 Some(pos) => trimmed[..pos].to_string(),
1614 None => "/".to_string(),
1615 };
1616 }
1617 Ok(None)
1618}
1619
1620async fn handle_delete(
1621 req: HttpRequest,
1622 state: web::Data<AppState>,
1623) -> Result<HttpResponse, ActixError> {
1624 let path = req.uri().path().to_string();
1625 let auth_pk = extract_pubkey(&req).await;
1626 let agent = agent_uri(auth_pk.as_ref());
1627 enforce_write_ctx(
1628 &state,
1629 &path,
1630 AccessMode::Write,
1631 agent.as_deref(),
1632 req_origin(&req),
1633 )
1634 .await?;
1635
1636 match state.storage.delete(&path).await {
1637 Ok(()) => Ok(HttpResponse::NoContent().finish()),
1638 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1639 Err(e) => Err(to_actix(e)),
1640 }
1641}
1642
1643async fn handle_options(
1644 req: HttpRequest,
1645 state: web::Data<AppState>,
1646) -> Result<HttpResponse, ActixError> {
1647 let path = req.uri().path().to_string();
1648 let o = ldp::options_for(&path);
1649 let mut rsp = HttpResponse::NoContent().finish();
1650 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
1651 rsp.headers_mut()
1652 .insert(header::HeaderName::from_static("allow"), v);
1653 }
1654 if let Some(ap) = o.accept_post {
1655 if let Ok(v) = header::HeaderValue::from_str(ap) {
1656 rsp.headers_mut()
1657 .insert(header::HeaderName::from_static("accept-post"), v);
1658 }
1659 }
1660 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
1661 rsp.headers_mut()
1662 .insert(header::HeaderName::from_static("accept-patch"), v);
1663 }
1664 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
1665 rsp.headers_mut()
1666 .insert(header::HeaderName::from_static("accept-ranges"), v);
1667 }
1668 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1669 Ok(rsp)
1670}
1671
1672async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
1677 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
1678 HttpResponse::Ok()
1679 .content_type("application/ld+json")
1680 .json(doc)
1681}
1682
1683#[derive(Debug, Deserialize)]
1684struct WebFingerQuery {
1685 resource: Option<String>,
1686}
1687
1688async fn handle_well_known_webfinger(
1689 state: web::Data<AppState>,
1690 q: web::Query<WebFingerQuery>,
1691) -> HttpResponse {
1692 let resource = q.resource.clone().unwrap_or_else(|| {
1693 format!(
1694 "acct:anonymous@{}",
1695 state
1696 .nodeinfo
1697 .base_url
1698 .trim_start_matches("http://")
1699 .trim_start_matches("https://")
1700 )
1701 });
1702 let webid = format!(
1703 "{}/profile/card#me",
1704 state.nodeinfo.base_url.trim_end_matches('/')
1705 );
1706 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
1707 Some(jrd) => HttpResponse::Ok()
1708 .content_type("application/jrd+json")
1709 .json(jrd),
1710 None => HttpResponse::NotFound().finish(),
1711 }
1712}
1713
1714async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
1715 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
1716 HttpResponse::Ok()
1717 .content_type("application/json")
1718 .json(doc)
1719}
1720
1721async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
1722 let doc = interop::nodeinfo_2_1(
1723 &state.nodeinfo.software_name,
1724 &state.nodeinfo.software_version,
1725 state.nodeinfo.open_registrations,
1726 state.nodeinfo.total_users,
1727 );
1728 HttpResponse::Ok()
1729 .content_type("application/json")
1730 .json(doc)
1731}
1732
1733#[cfg(feature = "did-nostr")]
1734async fn handle_well_known_did_nostr(
1735 state: web::Data<AppState>,
1736 path: web::Path<String>,
1737) -> HttpResponse {
1738 let pubkey = path.into_inner();
1739 let pubkey_is_valid = pubkey.len() == 64
1744 && pubkey
1745 .bytes()
1746 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
1747 if !pubkey_is_valid {
1748 return HttpResponse::BadRequest()
1749 .insert_header(("Cache-Control", "no-store"))
1750 .json(serde_json::json!({
1751 "error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
1752 }));
1753 }
1754 let owner_pubkey = match state.storage.get("/profile/card").await {
1762 Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
1763 .ok()
1764 .flatten(),
1765 Err(_) => None,
1766 };
1767 let owner_claims_key = owner_pubkey
1768 .as_deref()
1769 .is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
1770 if !owner_claims_key {
1771 return HttpResponse::NotFound()
1772 .insert_header(("Cache-Control", "no-store"))
1773 .json(serde_json::json!({
1774 "error": "no account on this pod claims this did:nostr pubkey"
1775 }));
1776 }
1777 let also = vec![format!(
1778 "{}/profile/card#me",
1779 state.nodeinfo.base_url.trim_end_matches('/')
1780 )];
1781 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
1782 let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
1783 use std::hash::{Hash, Hasher};
1789 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1790 body.hash(&mut hasher);
1791 let etag = format!("\"{:016x}\"", hasher.finish());
1792 HttpResponse::Ok()
1793 .content_type("application/did+json")
1794 .insert_header(("Cache-Control", "max-age=3600"))
1795 .insert_header(("ETag", etag))
1796 .body(body)
1797}
1798
1799#[cfg(feature = "nip05-endpoint")]
1807#[derive(Debug, Deserialize)]
1808struct Nip05Query {
1809 name: Option<String>,
1812}
1813
1814#[cfg(feature = "nip05-endpoint")]
1815fn nip05_name_is_valid(name: &str) -> bool {
1816 if name.is_empty() {
1819 return false;
1820 }
1821 name.bytes()
1822 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
1823}
1824
1825#[cfg(feature = "nip05-endpoint")]
1826async fn handle_well_known_nip05(
1827 state: web::Data<AppState>,
1828 query: web::Query<Nip05Query>,
1829) -> HttpResponse {
1830 use solid_pod_rs::webid::extract_nostr_pubkey;
1831
1832 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
1834 if !nip05_name_is_valid(&name) {
1835 return HttpResponse::BadRequest().json(serde_json::json!({
1836 "error": "invalid NIP-05 local part",
1837 }));
1838 }
1839
1840 let profile_path = if name == "_" {
1846 "/profile/card".to_string()
1847 } else {
1848 format!("/{name}/profile/card")
1849 };
1850
1851 let (body, _meta) = match state.storage.get(&profile_path).await {
1852 Ok(v) => v,
1853 Err(_) => {
1854 return nip05_empty_response();
1858 }
1859 };
1860
1861 let pubkey_hex = match extract_nostr_pubkey(&body) {
1862 Ok(Some(p)) => p,
1863 _ => return nip05_empty_response(),
1864 };
1865
1866 let doc = interop::nip05_document([(name, pubkey_hex)]);
1867 HttpResponse::Ok()
1868 .insert_header(("Access-Control-Allow-Origin", "*"))
1869 .content_type("application/json")
1870 .json(doc)
1871}
1872
1873#[cfg(feature = "nip05-endpoint")]
1874fn nip05_empty_response() -> HttpResponse {
1875 HttpResponse::Ok()
1876 .insert_header(("Access-Control-Allow-Origin", "*"))
1877 .content_type("application/json")
1878 .json(serde_json::json!({ "names": {} }))
1879}
1880
1881#[cfg(feature = "export-jsonld")]
1895async fn handle_export_all(
1896 req: HttpRequest,
1897 state: web::Data<AppState>,
1898) -> Result<HttpResponse, ActixError> {
1899 let auth_pk = extract_pubkey(&req).await;
1900 let agent = agent_uri(auth_pk.as_ref());
1901
1902 enforce_write_ctx(
1907 &state,
1908 "/",
1909 AccessMode::Control,
1910 agent.as_deref(),
1911 req_origin(&req),
1912 )
1913 .await?;
1914
1915 let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
1919 .ok()
1920 .and_then(|q| q.get("include_private").map(|v| v == "true"))
1921 .unwrap_or(false);
1922
1923 let pod_base = {
1927 let conn = req.connection_info();
1928 format!("{}://{}/", conn.scheme(), conn.host())
1929 };
1930
1931 let options = solid_pod_rs::ExportOptions { include_private };
1932 let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
1933 .await
1934 .map_err(to_actix)?;
1935
1936 let body = serde_json::to_vec(&bundle).map_err(|e| {
1937 actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
1938 })?;
1939 Ok(HttpResponse::Ok()
1940 .content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
1941 .body(body))
1942}
1943
1944#[derive(Debug, Deserialize)]
1949struct CreateAccountRequest {
1950 username: String,
1951 #[serde(default)]
1952 name: Option<String>,
1953}
1954
1955#[derive(Debug, Deserialize)]
1956struct CreatePodRequest {
1957 name: String,
1958}
1959
1960async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
1961 let pod_name = path.into_inner();
1962 let pod_root = format!("/{pod_name}/");
1963 match state.storage.exists(&pod_root).await {
1964 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
1965 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
1966 }
1967}
1968
1969fn valid_pod_name(name: &str) -> bool {
1970 !name.is_empty()
1971 && name
1972 .chars()
1973 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
1974}
1975
1976fn request_ip(req: &HttpRequest) -> IpAddr {
1977 req.peer_addr()
1978 .map(|addr| addr.ip())
1979 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
1980}
1981
1982async fn handle_create_account(
1983 state: web::Data<AppState>,
1984 body: web::Json<CreateAccountRequest>,
1985) -> Result<HttpResponse, ActixError> {
1986 let pod_root = format!("/{}/", body.username);
1987 if state.storage.exists(&pod_root).await.unwrap_or(false) {
1988 return Ok(
1989 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
1990 );
1991 }
1992
1993 let mut plan = provision::ProvisionPlan::new(
1994 body.username.clone(),
1995 format!(
1996 "{}/{}",
1997 state.nodeinfo.base_url.trim_end_matches('/'),
1998 body.username,
1999 ),
2000 );
2001 plan.display_name = body.name.clone();
2002 plan.containers = vec![
2003 format!("/{}/", body.username),
2004 format!("/{}/profile/", body.username),
2005 format!("/{}/inbox/", body.username),
2006 format!("/{}/public/", body.username),
2007 format!("/{}/private/", body.username),
2008 format!("/{}/settings/", body.username),
2009 ];
2010
2011 #[cfg(feature = "git")]
2015 let outcome = {
2016 use solid_pod_rs_git::init::GitAutoInit;
2017 let git_hook = state.data_root.as_ref().map(|root| {
2018 let fs_path = root.join(&body.username);
2019 (GitAutoInit::new(), fs_path)
2020 });
2021 match git_hook {
2022 Some((hook, ref fs_path)) => {
2023 provision::provision_pod_ext(state.storage.as_ref(), &plan, Some((&hook, fs_path)))
2024 .await
2025 }
2026 None => provision::provision_pod(state.storage.as_ref(), &plan).await,
2027 }
2028 };
2029 #[cfg(not(feature = "git"))]
2030 let outcome = provision::provision_pod(state.storage.as_ref(), &plan).await;
2031
2032 match outcome {
2033 Ok(outcome) => Ok(HttpResponse::Created().json(serde_json::json!({
2034 "webid": outcome.webid,
2035 "pod_root": outcome.pod_root,
2036 "username": body.username,
2037 }))),
2038 Err(e) => Err(to_actix(e)),
2039 }
2040}
2041
2042async fn handle_create_pod(
2043 req: HttpRequest,
2044 state: web::Data<AppState>,
2045 body: web::Json<CreatePodRequest>,
2046) -> Result<HttpResponse, ActixError> {
2047 let ip = request_ip(&req);
2048 if let Err(retry_after) = state.pod_create_limiter.check(ip) {
2049 return Ok(HttpResponse::TooManyRequests()
2050 .insert_header(("Retry-After", retry_after.to_string()))
2051 .json(serde_json::json!({
2052 "error": "Too Many Requests",
2053 "message": "Pod creation rate limit exceeded",
2054 "retryAfter": retry_after
2055 })));
2056 }
2057
2058 if !valid_pod_name(&body.name) {
2059 return Ok(HttpResponse::BadRequest().json(serde_json::json!({
2060 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
2061 })));
2062 }
2063
2064 let pod_root = format!("/{}/", body.name);
2065 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2066 return Ok(
2067 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
2068 );
2069 }
2070
2071 let base_uri = {
2072 let conn = req.connection_info();
2073 format!("{}://{}", conn.scheme(), conn.host())
2074 };
2075 let pod_uri = format!("{}/{}/", base_uri.trim_end_matches('/'), body.name);
2076
2077 for container in [
2078 format!("/{}/", body.name),
2079 format!("/{}/profile/", body.name),
2080 format!("/{}/inbox/", body.name),
2081 format!("/{}/public/", body.name),
2082 format!("/{}/private/", body.name),
2083 format!("/{}/settings/", body.name),
2084 ] {
2085 let meta_key = format!("{}.meta", container.trim_end_matches('/'));
2086 state
2087 .storage
2088 .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
2089 .await
2090 .map_err(to_actix)?;
2091 }
2092
2093 let canonical_pods_prefix = format!("{}/pods/{}/", base_uri.trim_end_matches('/'), body.name);
2094 let webid = format!("{pod_uri}profile/card#me");
2095 let profile = solid_pod_rs::webid::generate_webid_html(&body.name, None, &base_uri)
2096 .replace(&canonical_pods_prefix, &pod_uri);
2097 state
2098 .storage
2099 .put(
2100 &format!("/{}/profile/card", body.name),
2101 Bytes::from(profile.into_bytes()),
2102 "text/html",
2103 )
2104 .await
2105 .map_err(to_actix)?;
2106
2107 Ok(HttpResponse::Created()
2108 .insert_header(("Location", pod_uri.clone()))
2109 .json(serde_json::json!({
2110 "name": body.name,
2111 "webId": webid,
2112 "podUri": pod_uri,
2113 })))
2114}
2115
2116async fn handle_copy(
2121 req: HttpRequest,
2122 state: web::Data<AppState>,
2123) -> Result<HttpResponse, ActixError> {
2124 let dest = req.uri().path().to_string();
2125 let auth_pk = extract_pubkey(&req).await;
2126 let agent = agent_uri(auth_pk.as_ref());
2127 enforce_write_ctx(
2128 &state,
2129 &dest,
2130 AccessMode::Write,
2131 agent.as_deref(),
2132 req_origin(&req),
2133 )
2134 .await?;
2135
2136 let source = req
2137 .headers()
2138 .get("source")
2139 .and_then(|v| v.to_str().ok())
2140 .map(|s| s.to_string());
2141 let source = match source {
2142 Some(s) => s,
2143 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
2144 };
2145
2146 let (body, meta) = match state.storage.get(&source).await {
2147 Ok(v) => v,
2148 Err(PodError::NotFound(_)) => {
2149 return Ok(HttpResponse::NotFound().body("source resource not found"))
2150 }
2151 Err(e) => return Err(to_actix(e)),
2152 };
2153
2154 state
2155 .storage
2156 .put(&dest, body, &meta.content_type)
2157 .await
2158 .map_err(to_actix)?;
2159
2160 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
2162 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
2163 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
2164 let _ = state
2165 .storage
2166 .put(&dst_acl, acl_body, &acl_meta.content_type)
2167 .await;
2168 }
2169
2170 let mut rsp = HttpResponse::Created().finish();
2171 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
2172 rsp.headers_mut().insert(header::LOCATION, loc);
2173 }
2174 Ok(rsp)
2175}
2176
2177async fn handle_glob_get(
2182 req: HttpRequest,
2183 state: web::Data<AppState>,
2184) -> Result<HttpResponse, ActixError> {
2185 let raw_path = req.uri().path().to_string();
2186 if !raw_path.ends_with("/*") {
2188 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
2189 }
2190 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
2192 folder.to_string()
2193 } else {
2194 format!("{folder}/")
2195 };
2196
2197 let auth_pk = extract_pubkey(&req).await;
2201 let agent = agent_uri(auth_pk.as_ref());
2202 enforce_read_ctx(&state, &folder, agent.as_deref(), req_origin(&req)).await?;
2203
2204 let children = state.storage.list(&folder).await.map_err(to_actix)?;
2205 let mut merged = String::new();
2206
2207 for child in &children {
2208 if child.ends_with('/') {
2209 continue;
2210 }
2211 let child_path = format!("{folder}{child}");
2212 if let Ok((body, meta)) = state.storage.get(&child_path).await {
2213 if meta.content_type.contains("turtle")
2214 || meta.content_type.contains("n-triples")
2215 || meta.content_type.contains("n3")
2216 {
2217 if let Ok(text) = std::str::from_utf8(&body) {
2218 merged.push_str(text);
2219 merged.push('\n');
2220 }
2221 }
2222 }
2223 }
2224
2225 if merged.is_empty() {
2226 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
2227 }
2228
2229 Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
2230}
2231
2232#[derive(Debug, Deserialize)]
2237struct LoginPasswordRequest {
2238 username: String,
2239 password: String,
2240}
2241
2242async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
2249 let _ = (&body.username, &body.password);
2250 HttpResponse::NotImplemented().json(serde_json::json!({
2251 "error": "password login is not implemented on this pod"
2252 }))
2253}
2254
2255#[derive(Debug, Deserialize)]
2256struct PasswordResetRequest {
2257 username: String,
2258}
2259
2260async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
2264 let _ = &body.username;
2265 HttpResponse::NotImplemented().json(serde_json::json!({
2266 "error": "password reset is not implemented on this pod"
2267 }))
2268}
2269
2270#[derive(Debug, Deserialize)]
2271struct PasswordChangeRequest {
2272 token: String,
2273 new_password: String,
2274}
2275
2276async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
2281 let _ = (&body.token, &body.new_password);
2282 HttpResponse::NotImplemented().json(serde_json::json!({
2283 "error": "password change is not implemented on this pod"
2284 }))
2285}
2286
2287async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
2292 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
2293 HttpResponse::Ok()
2294 .content_type("application/json")
2295 .json(body)
2296}
2297
2298pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
2313
2314#[derive(Debug, Deserialize)]
2316struct ProxyQuery {
2317 url: String,
2318}
2319
2320const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
2322 "set-cookie",
2323 "set-cookie2",
2324 "authorization",
2325 "www-authenticate",
2326 "proxy-authenticate",
2327 "proxy-authorization",
2328];
2329
2330async fn validate_proxy_target(target: &str) -> Result<(url::Url, IpAddr), HttpResponse> {
2346 let parsed = match url::Url::parse(target) {
2347 Ok(u) => u,
2348 Err(_) => {
2349 return Err(
2350 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
2351 );
2352 }
2353 };
2354
2355 match parsed.scheme() {
2357 "http" | "https" => {}
2358 scheme => {
2359 return Err(HttpResponse::BadRequest()
2360 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
2361 }
2362 }
2363
2364 if solid_pod_rs::security::is_safe_url(target).is_err() {
2367 return Err(HttpResponse::Forbidden()
2368 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
2369 }
2370
2371 let host = match parsed.host_str() {
2373 Some(h) => h.to_string(),
2374 None => {
2375 return Err(HttpResponse::BadRequest()
2376 .json(serde_json::json!({"error": "target URL has no host"})))
2377 }
2378 };
2379 let host_lower = host.to_ascii_lowercase();
2380 if host_lower == "localhost"
2381 || host_lower.ends_with(".localhost")
2382 || host_lower == "0.0.0.0"
2383 || host_lower == "[::1]"
2384 || host_lower == "[::0]"
2385 {
2386 return Err(HttpResponse::Forbidden()
2387 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
2388 }
2389
2390 match solid_pod_rs::security::resolve_and_check(&host).await {
2393 Ok(ip) => Ok((parsed, ip)),
2394 Err(_) => Err(HttpResponse::Forbidden()
2395 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"}))),
2396 }
2397}
2398
2399fn build_pinned_proxy_client(url: &url::Url, ip: IpAddr) -> Result<reqwest::Client, ActixError> {
2402 let mut builder = reqwest::Client::builder()
2403 .redirect(reqwest::redirect::Policy::none());
2406 if let Some(host) = url.host_str() {
2407 let port = url.port_or_known_default().unwrap_or(0);
2409 builder = builder.resolve(host, std::net::SocketAddr::new(ip, port));
2410 }
2411 builder
2412 .build()
2413 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))
2414}
2415
2416async fn handle_proxy(
2417 req: HttpRequest,
2418 _state: web::Data<AppState>,
2419 query: web::Query<ProxyQuery>,
2420) -> Result<HttpResponse, ActixError> {
2421 let auth_pk = extract_pubkey(&req).await;
2423 let agent = agent_uri(auth_pk.as_ref());
2424 if agent.is_none() {
2425 return Ok(HttpResponse::Unauthorized()
2426 .json(serde_json::json!({"error": "authentication required"})));
2427 }
2428
2429 let mut current_url = query.url.clone();
2430 let mut redirect_count = 0u8;
2431 const MAX_REDIRECTS: u8 = 5;
2432
2433 let byte_cap = std::env::var("PROXY_BYTE_CAP")
2434 .ok()
2435 .and_then(|v| {
2436 solid_pod_rs::config::sources::parse_size(&v)
2437 .map(|u| u as usize)
2438 .ok()
2439 })
2440 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
2441
2442 loop {
2443 let (target_url, pinned_ip) = match validate_proxy_target(¤t_url).await {
2447 Ok(pair) => pair,
2448 Err(rsp) => return Ok(rsp),
2449 };
2450 let client = build_pinned_proxy_client(&target_url, pinned_ip)?;
2451
2452 let mut upstream_req = client.get(¤t_url);
2453
2454 if let Some(auth_val) = req
2456 .headers()
2457 .get("x-upstream-authorization")
2458 .and_then(|v| v.to_str().ok())
2459 {
2460 upstream_req = upstream_req.header("Authorization", auth_val);
2461 }
2462
2463 let response = upstream_req
2464 .send()
2465 .await
2466 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
2467
2468 if response.status().is_redirection() {
2470 if redirect_count >= MAX_REDIRECTS {
2471 return Ok(HttpResponse::BadGateway()
2472 .json(serde_json::json!({"error": "too many redirects"})));
2473 }
2474 if let Some(location) = response.headers().get("location") {
2475 let loc_str = location
2476 .to_str()
2477 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
2478 let base = url::Url::parse(¤t_url)
2480 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
2481 let resolved = base
2482 .join(loc_str)
2483 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
2484 current_url = resolved.to_string();
2485 redirect_count += 1;
2486 continue;
2487 }
2488 return Ok(HttpResponse::BadGateway()
2489 .json(serde_json::json!({"error": "redirect without location"})));
2490 }
2491
2492 let upstream_status = response.status().as_u16();
2494 let upstream_content_type = response
2495 .headers()
2496 .get("content-type")
2497 .and_then(|v| v.to_str().ok())
2498 .unwrap_or("application/octet-stream")
2499 .to_string();
2500
2501 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
2503 for (name, value) in response.headers() {
2504 let name_lower = name.as_str().to_ascii_lowercase();
2505 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
2506 continue;
2507 }
2508 if matches!(
2510 name_lower.as_str(),
2511 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
2512 ) {
2513 continue;
2514 }
2515 if let Ok(val_str) = value.to_str() {
2516 forwarded_headers.push((name_lower, val_str.to_string()));
2517 }
2518 }
2519
2520 let body_bytes = response
2521 .bytes()
2522 .await
2523 .map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
2524
2525 if body_bytes.len() > byte_cap {
2526 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
2527 "error": "proxied response exceeds byte cap",
2528 "limit": byte_cap
2529 })));
2530 }
2531
2532 let mut rsp = HttpResponse::build(
2534 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
2535 );
2536 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
2537 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
2538
2539 for (name, value) in &forwarded_headers {
2541 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
2542 if let Ok(hval) = header::HeaderValue::from_str(value) {
2543 rsp.insert_header((hname, hval));
2544 }
2545 }
2546 }
2547
2548 return Ok(rsp.body(body_bytes.to_vec()));
2549 }
2550}
2551
2552pub struct PathTraversalGuard;
2558
2559impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
2560where
2561 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2562 B: 'static,
2563{
2564 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2565 type Error = ActixError;
2566 type InitError = ();
2567 type Transform = PathTraversalGuardMiddleware<S>;
2568 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2569
2570 fn new_transform(&self, service: S) -> Self::Future {
2571 ready(Ok(PathTraversalGuardMiddleware { service }))
2572 }
2573}
2574
2575pub struct PathTraversalGuardMiddleware<S> {
2577 service: S,
2578}
2579
2580impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
2581where
2582 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2583 B: 'static,
2584{
2585 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2586 type Error = ActixError;
2587 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2588
2589 actix_web::dev::forward_ready!(service);
2590
2591 fn call(&self, req: ServiceRequest) -> Self::Future {
2592 let raw = req.path().to_string();
2595 if path_is_traversal(&raw) {
2596 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
2597 let sr = req.into_response(rsp.map_into_boxed_body());
2598 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2599 }
2600 let fut = self.service.call(req);
2601 Box::pin(async move {
2602 let resp = fut.await?;
2603 Ok(resp.map_into_left_body())
2604 })
2605 }
2606}
2607
2608fn path_is_traversal(path: &str) -> bool {
2609 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
2611 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
2612 for seg in once.split('/').chain(twice.split('/')) {
2613 if seg == ".." || seg == "." {
2614 return true;
2615 }
2616 }
2617 if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
2620 return true;
2621 }
2622 false
2623}
2624
2625pub struct CorsHeaders {
2636 pub allowed_origins: Arc<Vec<String>>,
2637}
2638
2639impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
2640where
2641 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2642 B: 'static,
2643{
2644 type Response = ServiceResponse<B>;
2645 type Error = ActixError;
2646 type InitError = ();
2647 type Transform = CorsHeadersMiddleware<S>;
2648 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2649
2650 fn new_transform(&self, service: S) -> Self::Future {
2651 ready(Ok(CorsHeadersMiddleware {
2652 service,
2653 allowed_origins: self.allowed_origins.clone(),
2654 }))
2655 }
2656}
2657
2658pub struct CorsHeadersMiddleware<S> {
2660 service: S,
2661 allowed_origins: Arc<Vec<String>>,
2662}
2663
2664impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
2665where
2666 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2667 B: 'static,
2668{
2669 type Response = ServiceResponse<B>;
2670 type Error = ActixError;
2671 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2672
2673 actix_web::dev::forward_ready!(service);
2674
2675 fn call(&self, req: ServiceRequest) -> Self::Future {
2676 let origin = req
2677 .headers()
2678 .get(header::ORIGIN)
2679 .and_then(|v| v.to_str().ok())
2680 .map(str::to_string);
2681 let allowed = self.allowed_origins.clone();
2682 let fut = self.service.call(req);
2683 Box::pin(async move {
2684 let mut resp = fut.await?;
2685 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
2686 Ok(resp)
2687 })
2688 }
2689}
2690
2691fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
2692 let (origin_value, allow_credentials): (String, bool) = if allowed.is_empty() {
2703 ("*".to_string(), false)
2704 } else {
2705 match origin.filter(|o| allowed.iter().any(|a| a == *o)) {
2706 Some(o) => (o.to_string(), true),
2707 None => return,
2710 }
2711 };
2712
2713 let mut pairs = vec![
2714 ("access-control-allow-origin", origin_value.as_str()),
2715 (
2716 "access-control-allow-methods",
2717 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
2718 ),
2719 (
2720 "access-control-allow-headers",
2721 "Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Range, Slug, Origin",
2722 ),
2723 (
2724 "access-control-expose-headers",
2725 "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",
2726 ),
2727 ("access-control-max-age", "86400"),
2728 ];
2729 if allow_credentials {
2732 pairs.push(("access-control-allow-credentials", "true"));
2733 }
2734
2735 for (name, value) in pairs {
2736 if let (Ok(name), Ok(value)) = (
2737 header::HeaderName::from_lowercase(name.as_bytes()),
2738 header::HeaderValue::from_str(value),
2739 ) {
2740 headers.insert(name, value);
2741 }
2742 }
2743}
2744
2745pub struct ErrorLoggingMiddleware;
2761
2762impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
2763where
2764 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2765 B: 'static,
2766{
2767 type Response = ServiceResponse<B>;
2768 type Error = ActixError;
2769 type InitError = ();
2770 type Transform = ErrorLoggingMiddlewareService<S>;
2771 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2772
2773 fn new_transform(&self, service: S) -> Self::Future {
2774 ready(Ok(ErrorLoggingMiddlewareService { service }))
2775 }
2776}
2777
2778pub struct ErrorLoggingMiddlewareService<S> {
2780 service: S,
2781}
2782
2783impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
2784where
2785 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2786 B: 'static,
2787{
2788 type Response = ServiceResponse<B>;
2789 type Error = ActixError;
2790 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2791
2792 actix_web::dev::forward_ready!(service);
2793
2794 fn call(&self, req: ServiceRequest) -> Self::Future {
2795 let method = req.method().as_str().to_string();
2798 let path = req.path().to_string();
2799
2800 let fut = self.service.call(req);
2801 Box::pin(async move {
2802 let response = fut.await?;
2803 let status = response.status();
2804 if status.is_server_error() {
2805 log_5xx(&method, &path, status, response.response().error());
2806 }
2807 Ok(response)
2808 })
2809 }
2810}
2811
2812fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
2816 let chain = match error {
2820 Some(e) => format_error_chain(e),
2821 None => "<no error attached to response>".to_string(),
2822 };
2823
2824 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
2825 Some(std::backtrace::Backtrace::force_capture().to_string())
2826 } else {
2827 None
2828 };
2829
2830 tracing::error!(
2831 target: "solid_pod_rs_server::http",
2832 method = %method,
2833 path = %path,
2834 status = %status.as_u16(),
2835 error.chain = %chain,
2836 backtrace = backtrace.as_deref().unwrap_or(""),
2837 "5xx response"
2838 );
2839}
2840
2841fn format_error_chain(e: &actix_web::Error) -> String {
2852 let summary = format!("{}", e.as_response_error());
2853 let debug = format!("{e:?}");
2854 if debug == summary || debug.is_empty() {
2855 summary
2856 } else {
2857 format!("{summary} -> {debug}")
2858 }
2859}
2860
2861pub struct DotfileGuard {
2867 allow: Arc<DotfileAllowlist>,
2868}
2869
2870impl DotfileGuard {
2871 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
2872 Self { allow }
2873 }
2874}
2875
2876impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
2877where
2878 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2879 B: 'static,
2880{
2881 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2882 type Error = ActixError;
2883 type InitError = ();
2884 type Transform = DotfileGuardMiddleware<S>;
2885 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2886
2887 fn new_transform(&self, service: S) -> Self::Future {
2888 ready(Ok(DotfileGuardMiddleware {
2889 service,
2890 allow: self.allow.clone(),
2891 }))
2892 }
2893}
2894
2895pub struct DotfileGuardMiddleware<S> {
2897 service: S,
2898 allow: Arc<DotfileAllowlist>,
2899}
2900
2901impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
2902where
2903 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2904 B: 'static,
2905{
2906 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2907 type Error = ActixError;
2908 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2909
2910 actix_web::dev::forward_ready!(service);
2911
2912 fn call(&self, req: ServiceRequest) -> Self::Future {
2913 let path = req.path().to_string();
2914 let allow_system_route =
2921 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
2922 if !allow_system_route {
2923 let pb = PathBuf::from(&path);
2924 if !self.allow.is_allowed(Path::new(&pb)) {
2925 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
2926 let sr = req.into_response(rsp.map_into_boxed_body());
2927 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2928 }
2929 }
2930 let fut = self.service.call(req);
2931 Box::pin(async move {
2932 let resp = fut.await?;
2933 Ok(resp.map_into_left_body())
2934 })
2935 }
2936}
2937
2938#[cfg(feature = "git")]
2943pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
2944 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
2945 return None;
2946 }
2947 state.data_root.as_ref().map(|root| root.join(pubkey))
2948}
2949
2950#[cfg(feature = "git")]
2980async fn git_mark_write(state: &AppState, resource_path: &str, agent: Option<&str>, message: &str) {
2981 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
2982 use solid_pod_rs_git::mark::ShellGitMarker;
2983
2984 if resource_path.ends_with(".acl")
2987 || resource_path.ends_with(".meta")
2988 || resource_path.ends_with(".prov.ttl")
2989 {
2990 return;
2991 }
2992 if resource_path.ends_with('/') {
2994 return;
2995 }
2996
2997 let Some(data_root) = state.data_root.as_ref() else {
2999 return;
3000 };
3001
3002 let trimmed = resource_path.trim_start_matches('/');
3004 let mut segments = trimmed.splitn(2, '/');
3005 let pod = segments.next().unwrap_or("");
3006 let rel = segments.next().unwrap_or("");
3007 if pod.is_empty() || rel.is_empty() {
3008 return;
3009 }
3010 let repo = data_root.join(pod);
3011
3012 if !repo.join(".git").is_dir() {
3016 return;
3017 }
3018
3019 let agent_did = agent.unwrap_or("urn:solid:anonymous");
3020 let created = std::time::SystemTime::now()
3021 .duration_since(std::time::UNIX_EPOCH)
3022 .map(|d| d.as_secs())
3023 .unwrap_or(0);
3024
3025 let (policy, ticker_override) =
3028 handlers::prov::resolve_anchor_policy(state, resource_path).await;
3029
3030 let marker = std::sync::Arc::new(ShellGitMarker::new());
3035 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
3036 None
3037 } else {
3038 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
3039 };
3040 let (log, ticker, network) = match &anchorer_bundle {
3041 Some((anchorer, ticker, network)) => (
3042 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
3043 ticker.clone(),
3044 network.clone(),
3045 ),
3046 None => (
3048 ProvenanceLog::new(marker.clone()),
3049 String::new(),
3050 String::new(),
3051 ),
3052 };
3053
3054 let record_policy = match policy {
3058 AnchorPolicy::Epoch => AnchorPolicy::Never,
3059 other => other,
3060 };
3061 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
3062
3063 let write_record = solid_pod_rs::provenance::WriteRecord {
3067 repo: &repo,
3068 path: rel,
3069 agent_did,
3070 message,
3071 policy: record_policy,
3072 high_value,
3073 ticker: &ticker,
3074 network: &network,
3075 created,
3076 };
3077 let mut mark = match log.record(write_record).await {
3078 Ok(m) => m,
3079 Err(e) => {
3080 tracing::warn!(
3081 target: "solid_pod_rs_server::git_mark",
3082 resource = %resource_path,
3083 "provenance record failed (swallowed, write already succeeded): {e}"
3084 );
3085 return;
3086 }
3087 };
3088 mark.resource = resource_path.to_string();
3091
3092 if matches!(policy, AnchorPolicy::Epoch) {
3096 if let Some((anchorer, _, _)) = &anchorer_bundle {
3097 match handlers::prov::epoch_push_and_maybe_anchor(
3098 state,
3099 anchorer,
3100 &ticker,
3101 &network,
3102 &mark.git.commit_sha,
3103 )
3104 .await
3105 {
3106 Ok(Some(closed)) => tracing::debug!(
3107 target: "solid_pod_rs_server::git_mark",
3108 root = %closed.root,
3109 n = closed.commits.len(),
3110 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
3111 ),
3112 Ok(None) => {}
3113 Err(e) => tracing::warn!(
3114 target: "solid_pod_rs_server::git_mark",
3115 "epoch batch/anchor failed (swallowed): {e}"
3116 ),
3117 }
3118 }
3119 }
3120
3121 let ttl = prov_ttl(&mark);
3126 let sidecar = format!("{resource_path}.prov.ttl");
3127 if let Err(e) = state
3128 .storage
3129 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
3130 .await
3131 {
3132 tracing::warn!(
3133 target: "solid_pod_rs_server::git_mark",
3134 sidecar = %sidecar,
3135 "provenance sidecar write failed (swallowed): {e}"
3136 );
3137 return;
3138 }
3139
3140 tracing::debug!(
3141 target: "solid_pod_rs_server::git_mark",
3142 resource = %resource_path,
3143 commit = %mark.git.commit_sha,
3144 anchored = mark.anchor.is_some(),
3145 "provenance recorded"
3146 );
3147}
3148
3149#[cfg(not(feature = "git"))]
3152#[inline]
3153async fn git_mark_write(
3154 _state: &AppState,
3155 _resource_path: &str,
3156 _agent: Option<&str>,
3157 _message: &str,
3158) {
3159}
3160
3161#[cfg(feature = "git")]
3162pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
3163 let caller = extract_pubkey(req).await?;
3164 if caller != pod_pubkey {
3165 return None;
3166 }
3167 Some(caller)
3168}
3169
3170#[cfg(feature = "git")]
3171fn git_json_err(msg: &str, status: u16) -> HttpResponse {
3172 HttpResponse::build(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
3173 .content_type("application/json")
3174 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
3175}
3176
3177#[cfg(feature = "git")]
3179#[derive(serde::Deserialize)]
3180struct GitStageBody {
3181 paths: Option<Vec<String>>,
3182 all: Option<bool>,
3183}
3184
3185#[cfg(feature = "git")]
3186#[derive(serde::Deserialize)]
3187struct GitCommitBody {
3188 message: String,
3189 author_name: Option<String>,
3190 author_email: Option<String>,
3191}
3192
3193#[cfg(feature = "git")]
3194#[derive(serde::Deserialize)]
3195struct GitBranchBody {
3196 name: String,
3197}
3198
3199#[cfg(feature = "git")]
3202async fn handle_git_status(
3203 path: web::Path<String>,
3204 req: HttpRequest,
3205 state: web::Data<AppState>,
3206) -> HttpResponse {
3207 let pubkey = path.into_inner();
3208 if require_pod_owner(&req, &pubkey).await.is_none() {
3209 return git_json_err("Authentication required", 401);
3210 }
3211 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3212 return git_json_err("Git not available (no FS backend)", 501);
3213 };
3214 match solid_pod_rs_git::api::git_status(&repo).await {
3215 Ok(s) => HttpResponse::Ok()
3216 .content_type("application/json")
3217 .body(serde_json::to_string(&s).unwrap_or_default()),
3218 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3219 }
3220}
3221
3222#[cfg(feature = "git")]
3223async fn handle_git_log(
3224 path: web::Path<String>,
3225 req: HttpRequest,
3226 state: web::Data<AppState>,
3227 query: web::Query<std::collections::HashMap<String, String>>,
3228) -> HttpResponse {
3229 let pubkey = path.into_inner();
3230 if require_pod_owner(&req, &pubkey).await.is_none() {
3231 return git_json_err("Authentication required", 401);
3232 }
3233 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3234 return git_json_err("Git not available (no FS backend)", 501);
3235 };
3236 let limit: u32 = query
3237 .get("limit")
3238 .and_then(|v| v.parse().ok())
3239 .unwrap_or(20);
3240 match solid_pod_rs_git::api::git_log(&repo, limit).await {
3241 Ok(entries) => HttpResponse::Ok()
3242 .content_type("application/json")
3243 .body(serde_json::to_string(&entries).unwrap_or_default()),
3244 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3245 }
3246}
3247
3248#[cfg(feature = "git")]
3249async fn handle_git_diff(
3250 path: web::Path<String>,
3251 req: HttpRequest,
3252 state: web::Data<AppState>,
3253 query: web::Query<std::collections::HashMap<String, String>>,
3254) -> HttpResponse {
3255 let pubkey = path.into_inner();
3256 if require_pod_owner(&req, &pubkey).await.is_none() {
3257 return git_json_err("Authentication required", 401);
3258 }
3259 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3260 return git_json_err("Git not available (no FS backend)", 501);
3261 };
3262 let file_path = query.get("path").map(String::as_str);
3263 let staged = query
3264 .get("staged")
3265 .map(|v| v == "true" || v == "1")
3266 .unwrap_or(false);
3267 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
3268 Ok(diff) => HttpResponse::Ok().content_type("text/plain").body(diff),
3269 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3270 }
3271}
3272
3273#[cfg(feature = "git")]
3274async fn handle_git_stage(
3275 path: web::Path<String>,
3276 req: HttpRequest,
3277 state: web::Data<AppState>,
3278 body: web::Bytes,
3279) -> HttpResponse {
3280 let pubkey = path.into_inner();
3281 if require_pod_owner(&req, &pubkey).await.is_none() {
3282 return git_json_err("Authentication required", 401);
3283 }
3284 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3285 return git_json_err("Git not available (no FS backend)", 501);
3286 };
3287 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3288 Ok(v) => v,
3289 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3290 };
3291 let paths = parsed.paths.unwrap_or_default();
3292 let all = parsed.all.unwrap_or(false);
3293 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
3294 Ok(()) => HttpResponse::Ok()
3295 .content_type("application/json")
3296 .body(r#"{"ok":true}"#),
3297 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3298 }
3299}
3300
3301#[cfg(feature = "git")]
3302async fn handle_git_unstage(
3303 path: web::Path<String>,
3304 req: HttpRequest,
3305 state: web::Data<AppState>,
3306 body: web::Bytes,
3307) -> HttpResponse {
3308 let pubkey = path.into_inner();
3309 if require_pod_owner(&req, &pubkey).await.is_none() {
3310 return git_json_err("Authentication required", 401);
3311 }
3312 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3313 return git_json_err("Git not available (no FS backend)", 501);
3314 };
3315 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3316 Ok(v) => v,
3317 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3318 };
3319 let paths = parsed.paths.unwrap_or_default();
3320 let all = parsed.all.unwrap_or(false);
3321 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
3322 Ok(()) => HttpResponse::Ok()
3323 .content_type("application/json")
3324 .body(r#"{"ok":true}"#),
3325 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3326 }
3327}
3328
3329#[cfg(feature = "git")]
3330async fn handle_git_commit(
3331 path: web::Path<String>,
3332 req: HttpRequest,
3333 state: web::Data<AppState>,
3334 body: web::Bytes,
3335) -> HttpResponse {
3336 let pubkey = path.into_inner();
3337 if require_pod_owner(&req, &pubkey).await.is_none() {
3338 return git_json_err("Authentication required", 401);
3339 }
3340 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3341 return git_json_err("Git not available (no FS backend)", 501);
3342 };
3343 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
3344 Ok(v) => v,
3345 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3346 };
3347 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
3348 let author_email = parsed
3349 .author_email
3350 .as_deref()
3351 .unwrap_or("pod@dreamlab-ai.com");
3352 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email).await
3353 {
3354 Ok(result) => HttpResponse::Ok()
3355 .content_type("application/json")
3356 .body(serde_json::to_string(&result).unwrap_or_default()),
3357 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3358 }
3359}
3360
3361#[cfg(feature = "git")]
3362async fn handle_git_branches(
3363 path: web::Path<String>,
3364 req: HttpRequest,
3365 state: web::Data<AppState>,
3366) -> HttpResponse {
3367 let pubkey = path.into_inner();
3368 if require_pod_owner(&req, &pubkey).await.is_none() {
3369 return git_json_err("Authentication required", 401);
3370 }
3371 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3372 return git_json_err("Git not available (no FS backend)", 501);
3373 };
3374 match solid_pod_rs_git::api::git_branches(&repo).await {
3375 Ok(info) => HttpResponse::Ok()
3376 .content_type("application/json")
3377 .body(serde_json::to_string(&info).unwrap_or_default()),
3378 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3379 }
3380}
3381
3382#[cfg(feature = "git")]
3383async fn handle_git_create_branch(
3384 path: web::Path<String>,
3385 req: HttpRequest,
3386 state: web::Data<AppState>,
3387 body: web::Bytes,
3388) -> HttpResponse {
3389 let pubkey = path.into_inner();
3390 if require_pod_owner(&req, &pubkey).await.is_none() {
3391 return git_json_err("Authentication required", 401);
3392 }
3393 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3394 return git_json_err("Git not available (no FS backend)", 501);
3395 };
3396 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
3397 Ok(v) => v,
3398 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3399 };
3400 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
3401 Ok(()) => HttpResponse::Ok()
3402 .content_type("application/json")
3403 .body(r#"{"ok":true}"#),
3404 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3405 }
3406}
3407
3408#[cfg(feature = "git")]
3409async fn handle_git_discard(
3410 path: web::Path<String>,
3411 req: HttpRequest,
3412 state: web::Data<AppState>,
3413 body: web::Bytes,
3414) -> HttpResponse {
3415 let pubkey = path.into_inner();
3416 if require_pod_owner(&req, &pubkey).await.is_none() {
3417 return git_json_err("Authentication required", 401);
3418 }
3419 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3420 return git_json_err("Git not available (no FS backend)", 501);
3421 };
3422 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3423 Ok(v) => v,
3424 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3425 };
3426 let paths = parsed.paths.unwrap_or_default();
3427 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
3428 Ok(()) => HttpResponse::Ok()
3429 .content_type("application/json")
3430 .body(r#"{"ok":true}"#),
3431 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3432 }
3433}
3434
3435async fn handle_git_panel_options(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
3443 let origin = req
3444 .headers()
3445 .get(header::ORIGIN)
3446 .and_then(|v| v.to_str().ok())
3447 .map(str::to_string);
3448
3449 let mut rsp = HttpResponse::NoContent().finish();
3450 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
3451 rsp
3452}
3453
3454async fn handle_admin_provision(
3471 req: HttpRequest,
3472 state: web::Data<AppState>,
3473 path: web::Path<String>,
3474) -> HttpResponse {
3475 let expected = match &state.admin_key {
3477 Some(k) => k.clone(),
3478 None => {
3479 return HttpResponse::Forbidden().json(serde_json::json!({
3480 "error": "admin key not configured on this server"
3481 }));
3482 }
3483 };
3484 let provided = req
3485 .headers()
3486 .get("x-pod-admin-key")
3487 .and_then(|v| v.to_str().ok())
3488 .unwrap_or("");
3489 use subtle::ConstantTimeEq;
3494 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
3495 if !bool::from(key_match) {
3496 return HttpResponse::Forbidden().json(serde_json::json!({"error": "invalid admin key"}));
3497 }
3498
3499 let pubkey = path.into_inner();
3501 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
3502 return HttpResponse::BadRequest()
3503 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
3504 }
3505
3506 let data_root = match &state.data_root {
3508 Some(r) => r.clone(),
3509 None => {
3510 return HttpResponse::InternalServerError().json(serde_json::json!({
3511 "error": "server has no fs-backend storage configured"
3512 }));
3513 }
3514 };
3515
3516 let pods_root = data_root.join("pods");
3529 let pod_dir = pods_root.join(&pubkey);
3530
3531 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
3533 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
3534 return HttpResponse::InternalServerError()
3535 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
3536 }
3537
3538 let acl_content = format!(
3548 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
3549 <#owner> a acl:Authorization ;\n\
3550 acl:agent <did:nostr:{pubkey}> ;\n\
3551 acl:accessTo </pods/{pubkey}/> ;\n\
3552 acl:default </pods/{pubkey}/> ;\n\
3553 acl:mode acl:Read, acl:Write, acl:Control .\n"
3554 );
3555
3556 let sibling_acl_path = pods_root.join(format!("{pubkey}.acl"));
3566 if !sibling_acl_path.exists() {
3567 if let Err(e) = tokio::fs::write(&sibling_acl_path, acl_content.as_bytes()).await {
3568 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write sibling .acl failed");
3569 return HttpResponse::InternalServerError()
3570 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
3571 }
3572 }
3573
3574 let inner_acl_path = pod_dir.join(".acl");
3581 if !inner_acl_path.exists() {
3582 if let Err(e) = tokio::fs::write(&inner_acl_path, acl_content.as_bytes()).await {
3583 tracing::warn!(pubkey = %pubkey, error = %e, "/_admin/provision: write inner .acl failed (non-fatal; sibling ACL governs)");
3584 }
3585 }
3586
3587 #[cfg(feature = "git")]
3589 {
3590 use tokio::process::Command;
3591
3592 if !pod_dir.join(".git").exists() {
3594 let init_out = Command::new("git")
3595 .args(["init", "-b", "main", pod_dir.to_str().unwrap_or(".")])
3596 .output()
3597 .await;
3598
3599 match init_out {
3600 Ok(out) if out.status.success() => {}
3601 Ok(out) => {
3602 let stderr = String::from_utf8_lossy(&out.stderr);
3603 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
3604 }
3605 Err(e) => {
3606 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
3607 }
3608 }
3609
3610 let cfg_out = Command::new("git")
3613 .args([
3614 "-C",
3615 pod_dir.to_str().unwrap_or("."),
3616 "config",
3617 "receive.denyCurrentBranch",
3618 "updateInstead",
3619 ])
3620 .output()
3621 .await;
3622
3623 if let Err(e) = cfg_out {
3624 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
3625 }
3626 }
3627 }
3628
3629 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
3631 HttpResponse::Ok().json(serde_json::json!({
3632 "podUrl": format!("{base_url}/pods/{pubkey}/"),
3633 "ok": true,
3634 }))
3635}
3636
3637async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
3642 let Some(ref data_root) = state.data_root else {
3643 return HttpResponse::Ok()
3644 .content_type("application/json")
3645 .json(serde_json::json!({"apps": [], "count": 0}));
3646 };
3647
3648 let server_url = state.nodeinfo.base_url.clone();
3649
3650 let mut read_dir = match tokio::fs::read_dir(data_root).await {
3652 Ok(rd) => rd,
3653 Err(_) => {
3654 return HttpResponse::Ok()
3655 .content_type("application/json")
3656 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
3657 }
3658 };
3659
3660 let mut apps: Vec<serde_json::Value> = Vec::new();
3661 let mut scanned = 0usize;
3662
3663 while scanned < 1000 {
3664 let entry = match read_dir.next_entry().await {
3665 Ok(Some(e)) => e,
3666 Ok(None) => break,
3667 Err(_) => break,
3668 };
3669
3670 let file_type = match entry.file_type().await {
3671 Ok(ft) => ft,
3672 Err(_) => continue,
3673 };
3674 if !file_type.is_dir() {
3675 continue;
3676 }
3677
3678 scanned += 1;
3679
3680 let manifest_path = entry.path().join("apps").join("manifest.json");
3681 let contents = match tokio::fs::read(&manifest_path).await {
3682 Ok(c) => c,
3683 Err(_) => continue,
3684 };
3685
3686 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
3687 Ok(v) => v,
3688 Err(_) => continue,
3689 };
3690
3691 if let Some(pod_name) = entry.file_name().to_str() {
3693 if manifest.get("podOwner").is_none() {
3694 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
3695 }
3696 }
3697
3698 apps.push(manifest);
3699 }
3700
3701 let count = apps.len();
3702 HttpResponse::Ok()
3703 .content_type("application/json")
3704 .json(serde_json::json!({
3705 "apps": apps,
3706 "serverUrl": server_url,
3707 "count": count,
3708 }))
3709}
3710
3711#[allow(dead_code)]
3724fn is_git_request(path: &str) -> bool {
3725 path.contains("/info/refs")
3726 || path.contains("/git-upload-pack")
3727 || path.contains("/git-receive-pack")
3728}
3729
3730#[allow(dead_code)]
3733fn is_dot_git_path(path: &str) -> bool {
3734 path.contains("/.git/") || path.ends_with("/.git")
3735}
3736
3737#[cfg(feature = "git")]
3738async fn handle_git(
3739 req: HttpRequest,
3740 body: web::Bytes,
3741 state: web::Data<AppState>,
3742) -> HttpResponse {
3743 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
3744 use solid_pod_rs_git::service::{GitHttpService, GitRequest};
3745
3746 let path = req.uri().path().to_string();
3747
3748 let pod_name = path
3751 .trim_start_matches('/')
3752 .split('/')
3753 .next()
3754 .unwrap_or("")
3755 .to_string();
3756 let Some(ref data_root) = state.data_root else {
3757 return HttpResponse::NotImplemented().json(serde_json::json!({
3758 "error": "git requires fs-backend storage",
3759 "reason": "data_root_not_configured"
3760 }));
3761 };
3762 let repo_root = data_root.join(&pod_name);
3763 if !repo_root.exists() {
3764 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
3765 }
3766
3767 let query = req.uri().query().unwrap_or("").to_string();
3768 let host_url = {
3769 let conn = req.connection_info();
3770 Some(format!("{}://{}", conn.scheme(), conn.host()))
3771 };
3772 let headers: Vec<(String, String)> = req
3773 .headers()
3774 .iter()
3775 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
3776 .collect();
3777
3778 let git_req = GitRequest {
3779 method: req.method().as_str().to_string(),
3780 path,
3781 query,
3782 headers,
3783 body,
3784 host_url,
3785 };
3786
3787 let is_write = git_req.is_write();
3799 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
3800 Ok(pk) => Some(format!("did:nostr:{pk}")),
3801 Err(_) => None,
3802 };
3803 let wac_path = format!("/{pod_name}/");
3804 let origin = req_origin(&req);
3805 let wac = if is_write {
3806 enforce_write_ctx(
3807 &state,
3808 &wac_path,
3809 AccessMode::Write,
3810 agent.as_deref(),
3811 origin,
3812 )
3813 .await
3814 } else {
3815 enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin).await
3816 };
3817 if let Err(e) = wac {
3818 return e.error_response();
3819 }
3820
3821 let service = GitHttpService::new(repo_root);
3822 match service.handle(git_req).await {
3823 Ok(git_resp) => {
3824 let mut builder = HttpResponse::build(
3825 actix_web::http::StatusCode::from_u16(git_resp.status)
3826 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3827 );
3828 for (k, v) in &git_resp.headers {
3829 builder.insert_header((k.as_str(), v.as_str()));
3830 }
3831 builder.body(git_resp.body)
3832 }
3833 Err(e) => {
3834 let status = e.status_code();
3835 HttpResponse::build(
3836 actix_web::http::StatusCode::from_u16(status)
3837 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3838 )
3839 .json(serde_json::json!({"error": e.to_string()}))
3840 }
3841 }
3842}
3843
3844#[cfg(feature = "forge")]
3853fn forge_plugin_dir(state: &AppState) -> Option<PathBuf> {
3854 state.data_root.as_ref().map(|r| r.join(".forge"))
3855}
3856
3857#[cfg(feature = "forge")]
3863struct ServerLoopback {
3864 client: reqwest::Client,
3865}
3866
3867#[cfg(feature = "forge")]
3868#[async_trait::async_trait]
3869impl solid_pod_rs_forge::LoopbackFetch for ServerLoopback {
3870 async fn get(
3871 &self,
3872 url: &str,
3873 max_bytes: usize,
3874 timeout_secs: u64,
3875 ) -> solid_pod_rs_forge::bodies::FetchResult {
3876 use solid_pod_rs_forge::bodies::FetchResult;
3877 let resp = match self
3878 .client
3879 .get(url)
3880 .timeout(Duration::from_secs(timeout_secs.max(1)))
3881 .send()
3882 .await
3883 {
3884 Ok(r) => r,
3885 Err(e) => return FetchResult::Error(e.to_string()),
3886 };
3887 let code = resp.status().as_u16();
3888 if code == 404 || code == 410 {
3889 return FetchResult::Removed;
3890 }
3891 if !resp.status().is_success() {
3892 return FetchResult::Error(format!("status {code}"));
3893 }
3894 match resp.bytes().await {
3895 Ok(b) if b.len() > max_bytes => FetchResult::TooLarge,
3896 Ok(b) => FetchResult::Body(b.to_vec()),
3897 Err(e) => FetchResult::Error(e.to_string()),
3898 }
3899 }
3900}
3901
3902#[cfg(feature = "forge")]
3908async fn handle_forge(
3909 req: HttpRequest,
3910 body: web::Bytes,
3911 state: web::Data<AppState>,
3912) -> HttpResponse {
3913 use solid_pod_rs_forge::{ForgeConfig, ForgeRequest, ForgeService};
3914
3915 let Some(plugin_dir) = forge_plugin_dir(&state) else {
3916 return HttpResponse::NotImplemented().json(serde_json::json!({
3917 "error": "forge requires fs-backend storage",
3918 "reason": "data_root_not_configured"
3919 }));
3920 };
3921
3922 let loopback: Arc<dyn solid_pod_rs_forge::LoopbackFetch> = Arc::new(ServerLoopback {
3927 client: reqwest::Client::new(),
3928 });
3929 let service = match ForgeService::new(ForgeConfig::default(), plugin_dir) {
3930 Ok(s) => s.with_loopback(loopback),
3931 Err(e) => {
3932 return HttpResponse::InternalServerError()
3933 .json(serde_json::json!({"error": e.to_string()}));
3934 }
3935 };
3936
3937 let path = req.uri().path().to_string();
3938 let query = req.uri().query().unwrap_or("").to_string();
3939 let host_url = {
3940 let conn = req.connection_info();
3941 Some(format!("{}://{}", conn.scheme(), conn.host()))
3942 };
3943 let headers: Vec<(String, String)> = req
3944 .headers()
3945 .iter()
3946 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
3947 .collect();
3948
3949 let forge_req = ForgeRequest {
3950 method: req.method().as_str().to_string(),
3951 path,
3952 query,
3953 headers,
3954 raw_body: body,
3955 host_url,
3956 };
3957
3958 let agent = service.resolve_agent(&forge_req);
3963
3964 match service.handle(forge_req, agent).await {
3965 Ok(resp) => {
3966 let mut builder = HttpResponse::build(
3967 StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3968 );
3969 for (k, v) in &resp.headers {
3970 builder.insert_header((k.as_str(), v.as_str()));
3971 }
3972 builder.body(resp.body)
3973 }
3974 Err(e) => {
3975 let status = e.status_code();
3976 HttpResponse::build(
3977 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3978 )
3979 .json(serde_json::json!({"error": e.to_string()}))
3980 }
3981 }
3982}
3983
3984pub fn build_app(
3996 state: AppState,
3997) -> App<
3998 impl actix_web::dev::ServiceFactory<
3999 ServiceRequest,
4000 Config = (),
4001 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
4002 Error = ActixError,
4003 InitError = (),
4004 >,
4005> {
4006 let body_cap = state.body_cap;
4007 let dotfiles = state.dotfiles.clone();
4008 let allowed_origins = Arc::new(state.allowed_origins.clone());
4009
4010 let mut app = App::new()
4011 .app_data(web::Data::new(state.clone()))
4012 .app_data(web::PayloadConfig::new(body_cap))
4013 .wrap(ErrorLoggingMiddleware)
4018 .wrap(CorsHeaders { allowed_origins })
4019 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
4023 .wrap(PathTraversalGuard)
4024 .wrap(DotfileGuard::new(dotfiles));
4025
4026 app = app
4032 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
4033 .route(
4034 "/.well-known/webfinger",
4035 web::get().to(handle_well_known_webfinger),
4036 )
4037 .route(
4038 "/.well-known/nodeinfo",
4039 web::get().to(handle_well_known_nodeinfo),
4040 )
4041 .route(
4042 "/.well-known/nodeinfo/2.1",
4043 web::get().to(handle_well_known_nodeinfo_2_1),
4044 );
4045
4046 #[cfg(feature = "did-nostr")]
4047 {
4048 app = app.route(
4049 "/.well-known/did/nostr/{pubkey}.json",
4050 web::get().to(handle_well_known_did_nostr),
4051 );
4052 }
4053
4054 #[cfg(feature = "nip05-endpoint")]
4059 {
4060 app = app.route(
4061 "/.well-known/nostr.json",
4062 web::get().to(handle_well_known_nip05),
4063 );
4064 }
4065
4066 #[cfg(feature = "export-jsonld")]
4071 {
4072 app = app.route("/api/exports/all", web::get().to(handle_export_all));
4073 }
4074
4075 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
4077
4078 app = app.route("/pay/.info", web::get().to(handle_pay_info));
4080
4081 app = app.configure(handlers::pay::register);
4086
4087 app = app.route("/proxy", web::get().to(handle_proxy));
4089
4090 if state.mcp_enabled {
4094 app = app.route("/mcp", web::post().to(mcp::handle_mcp)).route(
4095 "/mcp",
4096 web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options),
4097 );
4098 }
4099
4100 app = app.route(
4103 "/_admin/provision/{pubkey}",
4104 web::post().to(handle_admin_provision),
4105 );
4106
4107 app = app
4109 .route("/.pods", web::post().to(handle_create_pod))
4110 .route("/api/accounts/new", web::post().to(handle_create_account))
4111 .route("/pods/check/{name}", web::get().to(handle_pod_check))
4112 .route("/login/password", web::post().to(handle_login_password))
4113 .route(
4114 "/account/password/reset",
4115 web::post().to(handle_password_reset_request),
4116 )
4117 .route(
4118 "/account/password/change",
4119 web::post().to(handle_password_change),
4120 );
4121
4122 #[cfg(feature = "forge")]
4127 {
4128 app = app
4129 .route("/forge", web::route().to(handle_forge))
4130 .route("/forge/{tail:.*}", web::route().to(handle_forge));
4131 }
4132
4133 app = app
4138 .route(
4139 "/{tail:.*}/.git",
4141 web::route().to(|| async {
4142 HttpResponse::Forbidden()
4143 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4144 }),
4145 )
4146 .route(
4147 "/{tail:.*}/.git/{rest:.*}",
4148 web::route().to(|| async {
4149 HttpResponse::Forbidden()
4150 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4151 }),
4152 );
4153
4154 app = app.route(
4158 "/pods/{pk}/_git/{tail:.*}",
4159 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
4160 );
4161
4162 #[cfg(feature = "git")]
4163 {
4164 app = app
4166 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
4167 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
4168 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
4169
4170 app = app
4173 .route(
4174 "/pods/{pubkey}/_git/status",
4175 web::get().to(handle_git_status),
4176 )
4177 .route("/pods/{pubkey}/_git/log", web::get().to(handle_git_log))
4178 .route("/pods/{pubkey}/_git/diff", web::get().to(handle_git_diff))
4179 .route(
4180 "/pods/{pubkey}/_git/stage",
4181 web::post().to(handle_git_stage),
4182 )
4183 .route(
4184 "/pods/{pubkey}/_git/unstage",
4185 web::post().to(handle_git_unstage),
4186 )
4187 .route(
4188 "/pods/{pubkey}/_git/commit",
4189 web::post().to(handle_git_commit),
4190 )
4191 .route(
4192 "/pods/{pubkey}/_git/branches",
4193 web::get().to(handle_git_branches),
4194 )
4195 .route(
4196 "/pods/{pubkey}/_git/branch",
4197 web::post().to(handle_git_create_branch),
4198 )
4199 .route(
4200 "/pods/{pubkey}/_git/discard",
4201 web::post().to(handle_git_discard),
4202 );
4203
4204 app = app.configure(handlers::prov::register);
4211 }
4212 #[cfg(not(feature = "git"))]
4213 {
4214 let git_501 = || async {
4218 HttpResponse::NotImplemented()
4219 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
4220 };
4221 app = app
4222 .route("/{tail:.*}/info/refs", web::get().to(git_501))
4223 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
4224 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
4225 }
4226
4227 app.route("/{tail:.*}/", web::post().to(handle_post))
4230 .route("/{tail:.*}/", web::put().to(handle_put))
4231 .route("/{tail:.*}", web::get().to(handle_get))
4232 .route("/{tail:.*}", web::head().to(handle_get))
4233 .route("/{tail:.*}", web::put().to(handle_put))
4234 .route("/{tail:.*}", web::patch().to(handle_patch))
4235 .route("/{tail:.*}", web::delete().to(handle_delete))
4236 .route(
4237 "/{tail:.*}",
4238 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
4239 )
4240 .route(
4241 "/{tail:.*}",
4242 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
4243 )
4244}
4245
4246#[cfg(test)]
4251mod payment_gating_tests {
4252 use super::*;
4253 use solid_pod_rs::payments::WebLedger;
4254 use solid_pod_rs::storage::memory::MemoryBackend;
4255
4256 const PRINCIPAL: &str = "did:nostr:alice";
4257
4258 const PAID_WRITE_ACL: &str = r#"
4261@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4262
4263<#paid-write> a acl:Authorization ;
4264 acl:agent <did:nostr:alice> ;
4265 acl:accessTo </premium/inbox> ;
4266 acl:mode acl:Write ;
4267 acl:condition [
4268 a acl:PaymentCondition ;
4269 acl:costSats 100
4270 ] .
4271"#;
4272
4273 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
4274 let mut ledger = WebLedger::new("Test Pod Credits");
4275 if sats > 0 {
4276 ledger.credit(did, sats);
4277 }
4278 let body = serde_json::to_vec(&ledger).unwrap();
4279 storage
4280 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
4281 .await
4282 .unwrap();
4283 }
4284
4285 async fn seed_acl(storage: &dyn Storage) {
4286 storage
4287 .put(
4288 "/premium/inbox.acl",
4289 Bytes::from(PAID_WRITE_ACL),
4290 "text/turtle",
4291 )
4292 .await
4293 .unwrap();
4294 }
4295
4296 #[actix_web::test]
4298 async fn resolve_balance_reads_ledger_entry() {
4299 let storage = MemoryBackend::new();
4300 seed_ledger(&storage, PRINCIPAL, 250).await;
4301 assert_eq!(
4302 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
4303 Some(250)
4304 );
4305 }
4306
4307 #[actix_web::test]
4309 async fn resolve_balance_zero_when_no_entry() {
4310 let storage = MemoryBackend::new();
4311 seed_ledger(&storage, "did:nostr:bob", 500).await;
4312 assert_eq!(
4313 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
4314 Some(0)
4315 );
4316 }
4317
4318 #[actix_web::test]
4320 async fn resolve_balance_none_when_anonymous() {
4321 let storage = MemoryBackend::new();
4322 seed_ledger(&storage, PRINCIPAL, 1_000).await;
4323 assert_eq!(resolve_balance_sats(&storage, None).await, None);
4324 }
4325
4326 #[actix_web::test]
4328 async fn paid_write_denied_below_balance() {
4329 let storage = Arc::new(MemoryBackend::new());
4330 seed_acl(storage.as_ref()).await;
4331 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
4333
4334 let result =
4335 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4336 assert!(
4337 result.is_err(),
4338 "balance 50 < cost 100 must be denied — sat-gating loop closed"
4339 );
4340 }
4341
4342 #[actix_web::test]
4344 async fn paid_write_allowed_at_balance() {
4345 let storage = Arc::new(MemoryBackend::new());
4346 seed_acl(storage.as_ref()).await;
4347 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
4349
4350 let result =
4351 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4352 assert!(
4353 result.is_ok(),
4354 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
4355 );
4356 }
4357
4358 #[actix_web::test]
4360 async fn paid_write_allowed_above_balance() {
4361 let storage = Arc::new(MemoryBackend::new());
4362 seed_acl(storage.as_ref()).await;
4363 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
4364 let state = AppState::new(storage);
4365
4366 let result =
4367 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4368 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
4369 }
4370
4371 #[actix_web::test]
4375 async fn paid_write_anonymous_denied() {
4376 let storage = Arc::new(MemoryBackend::new());
4377 seed_acl(storage.as_ref()).await;
4378 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
4379 let state = AppState::new(storage);
4380
4381 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
4382 assert!(
4383 result.is_err(),
4384 "anonymous caller has no ledger principal — PaymentCondition fails closed"
4385 );
4386 }
4387
4388 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
4395 let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
4396 let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
4397 ledger.get_balance(did)
4398 }
4399
4400 #[actix_web::test]
4402 async fn paid_write_debits_ledger() {
4403 let storage = Arc::new(MemoryBackend::new());
4404 seed_acl(storage.as_ref()).await;
4405 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
4407
4408 let result =
4409 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4410 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
4411 assert_eq!(
4412 read_balance(storage.as_ref(), PRINCIPAL).await,
4413 150,
4414 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
4415 );
4416 }
4417
4418 #[actix_web::test]
4421 async fn paid_write_debits_each_grant() {
4422 let storage = Arc::new(MemoryBackend::new());
4423 seed_acl(storage.as_ref()).await;
4424 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
4426
4427 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
4428 .await
4429 .unwrap();
4430 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
4431 .await
4432 .unwrap();
4433 assert_eq!(
4434 read_balance(storage.as_ref(), PRINCIPAL).await,
4435 50,
4436 "250 - 2*100: each granted request debits, no unmetered re-use"
4437 );
4438
4439 let third =
4441 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4442 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
4443 assert_eq!(
4444 read_balance(storage.as_ref(), PRINCIPAL).await,
4445 50,
4446 "a denied request must not debit"
4447 );
4448 }
4449
4450 #[actix_web::test]
4452 async fn paid_read_debits_ledger() {
4453 const PAID_READ_ACL: &str = r#"
4454@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4455
4456<#paid-read> a acl:Authorization ;
4457 acl:agent <did:nostr:alice> ;
4458 acl:accessTo </premium/feed> ;
4459 acl:mode acl:Read ;
4460 acl:condition [
4461 a acl:PaymentCondition ;
4462 acl:costSats 30
4463 ] .
4464"#;
4465 let storage = Arc::new(MemoryBackend::new());
4466 storage
4467 .put(
4468 "/premium/feed.acl",
4469 Bytes::from(PAID_READ_ACL),
4470 "text/turtle",
4471 )
4472 .await
4473 .unwrap();
4474 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
4475 let state = AppState::new(storage.clone());
4476
4477 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
4478 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
4479 assert_eq!(
4480 read_balance(storage.as_ref(), PRINCIPAL).await,
4481 70,
4482 "100 - 30 cost: a granted paid read must debit"
4483 );
4484 }
4485
4486 #[actix_web::test]
4489 async fn free_read_does_not_debit() {
4490 let storage = Arc::new(MemoryBackend::new());
4491 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
4493 let state = AppState::new(storage.clone());
4494
4495 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
4496 .await
4497 .unwrap();
4498 assert_eq!(
4499 read_balance(storage.as_ref(), PRINCIPAL).await,
4500 100,
4501 "a grant with no PaymentCondition must not debit"
4502 );
4503 }
4504
4505 const ALICE_ONLY_READ_ACL: &str = r#"
4511@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4512
4513<#alice> a acl:Authorization ;
4514 acl:agent <did:nostr:alice> ;
4515 acl:accessTo </private/secret> ;
4516 acl:default </private/> ;
4517 acl:mode acl:Read, acl:Write, acl:Control .
4518"#;
4519
4520 async fn seed_private_read_acl(storage: &dyn Storage) {
4521 storage
4526 .put(
4527 "/private.acl",
4528 Bytes::from(ALICE_ONLY_READ_ACL),
4529 "text/turtle",
4530 )
4531 .await
4532 .unwrap();
4533 }
4534
4535 #[actix_web::test]
4539 async fn enforce_read_grants_owner() {
4540 let storage = Arc::new(MemoryBackend::new());
4541 seed_private_read_acl(storage.as_ref()).await;
4542 let state = AppState::new(storage);
4543 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
4544 assert!(result.is_ok(), "owner alice must be granted Read");
4545 }
4546
4547 #[actix_web::test]
4550 async fn enforce_read_denies_other_principal() {
4551 let storage = Arc::new(MemoryBackend::new());
4552 seed_private_read_acl(storage.as_ref()).await;
4553 let state = AppState::new(storage);
4554 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
4555 assert!(
4556 result.is_err(),
4557 "bob has no Read grant — private resource must not be world-readable"
4558 );
4559 }
4560
4561 #[actix_web::test]
4564 async fn enforce_read_denies_anonymous() {
4565 let storage = Arc::new(MemoryBackend::new());
4566 seed_private_read_acl(storage.as_ref()).await;
4567 let state = AppState::new(storage);
4568 let result = enforce_read(&state, "/private/secret", None).await;
4569 assert!(result.is_err(), "anonymous Read must be denied");
4570 }
4571
4572 const WRITE_NOT_CONTROL_ACL: &str = r#"
4580@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4581
4582<#owner> a acl:Authorization ;
4583 acl:agent <did:nostr:alice> ;
4584 acl:accessTo </shared/doc> ;
4585 acl:default </shared/> ;
4586 acl:mode acl:Read, acl:Write, acl:Control .
4587
4588<#writer> a acl:Authorization ;
4589 acl:agent <did:nostr:writer> ;
4590 acl:accessTo </shared/doc> ;
4591 acl:default </shared/> ;
4592 acl:mode acl:Read, acl:Write .
4593"#;
4594
4595 async fn seed_shared_acl(storage: &dyn Storage) {
4596 storage
4601 .put(
4602 "/shared.acl",
4603 Bytes::from(WRITE_NOT_CONTROL_ACL),
4604 "text/turtle",
4605 )
4606 .await
4607 .unwrap();
4608 }
4609
4610 #[actix_web::test]
4614 async fn acl_put_denied_for_writer_without_control() {
4615 let storage = Arc::new(MemoryBackend::new());
4616 seed_shared_acl(storage.as_ref()).await;
4617 let state = AppState::new(storage);
4618 let result = enforce_write(
4622 &state,
4623 "/shared/.acl",
4624 AccessMode::Write,
4625 Some("did:nostr:writer"),
4626 )
4627 .await;
4628 assert!(
4629 result.is_err(),
4630 "writer lacks Control — must not be able to PUT /shared/.acl"
4631 );
4632 }
4633
4634 #[actix_web::test]
4636 async fn acl_put_allowed_for_control_holder() {
4637 let storage = Arc::new(MemoryBackend::new());
4638 seed_shared_acl(storage.as_ref()).await;
4639 let state = AppState::new(storage);
4640 let result =
4641 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
4642 assert!(
4643 result.is_ok(),
4644 "alice holds Control — must be allowed to PUT /shared/.acl"
4645 );
4646 }
4647
4648 #[actix_web::test]
4650 async fn meta_put_denied_for_writer_without_control() {
4651 let storage = Arc::new(MemoryBackend::new());
4652 seed_shared_acl(storage.as_ref()).await;
4653 let state = AppState::new(storage);
4654 let result = enforce_write(
4655 &state,
4656 "/shared/doc.meta",
4657 AccessMode::Write,
4658 Some("did:nostr:writer"),
4659 )
4660 .await;
4661 assert!(
4662 result.is_err(),
4663 "writer lacks Control — must not be able to PUT a .meta sidecar"
4664 );
4665 }
4666
4667 #[test]
4669 fn protected_resource_for_acl_strips_suffixes() {
4670 assert_eq!(
4671 protected_resource_for_acl("/victim/.acl").as_deref(),
4672 Some("/victim/")
4673 );
4674 assert_eq!(
4675 protected_resource_for_acl("/a/b.acl").as_deref(),
4676 Some("/a/b")
4677 );
4678 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
4679 assert_eq!(
4680 protected_resource_for_acl("/a/b.meta").as_deref(),
4681 Some("/a/b")
4682 );
4683 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
4684 }
4685}