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}
241
242#[derive(Clone, Debug)]
244pub struct NodeInfoMeta {
245 pub software_name: String,
246 pub software_version: String,
247 pub open_registrations: bool,
248 pub total_users: u64,
249 pub base_url: String,
250}
251
252impl Default for NodeInfoMeta {
253 fn default() -> Self {
254 Self {
255 software_name: "solid-pod-rs-server".to_string(),
256 software_version: env!("CARGO_PKG_VERSION").to_string(),
257 open_registrations: false,
258 total_users: 0,
259 base_url: "http://localhost".to_string(),
260 }
261 }
262}
263
264pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
267
268pub fn body_cap_from_env() -> usize {
271 match std::env::var("JSS_MAX_REQUEST_BODY") {
272 Ok(v) => parse_size(&v)
273 .map(|u| u as usize)
274 .unwrap_or(DEFAULT_BODY_CAP),
275 Err(_) => DEFAULT_BODY_CAP,
276 }
277}
278
279impl AppState {
280 pub fn new(storage: Arc<dyn Storage>) -> Self {
283 Self {
284 storage,
285 dotfiles: Arc::new(DotfileAllowlist::from_env()),
286 body_cap: body_cap_from_env(),
287 nodeinfo: NodeInfoMeta::default(),
288 mashlib: MashlibConfig::default(),
289 mashlib_cdn: None,
290 pay_config: solid_pod_rs::payments::PayConfig::default(),
291 data_root: None,
292 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
293 allowed_origins: Vec::new(),
294 admin_key: None,
295 mcp_enabled: false,
296 mempool_url: None,
297 }
298 }
299}
300
301#[derive(Debug)]
303pub struct PodCreateLimiter {
304 hits: Mutex<HashMap<IpAddr, Instant>>,
305 window: Duration,
306}
307
308impl Default for PodCreateLimiter {
309 fn default() -> Self {
310 Self {
311 hits: Mutex::new(HashMap::new()),
312 window: Duration::from_secs(24 * 60 * 60),
313 }
314 }
315}
316
317impl PodCreateLimiter {
318 fn check(&self, ip: IpAddr) -> Result<(), u64> {
319 let now = Instant::now();
320 let mut hits = self.hits.lock().unwrap();
321 if let Some(last) = hits.get(&ip).copied() {
322 let elapsed = now.saturating_duration_since(last);
323 if elapsed < self.window {
324 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
325 }
326 }
327 hits.insert(ip, now);
328 Ok(())
329 }
330}
331
332pub(crate) fn to_actix(e: PodError) -> ActixError {
337 match e {
338 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
339 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
340 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
341 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
342 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
343 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
344 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
345 }
346}
347
348pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
360 let header_val = req
361 .headers()
362 .get(header::AUTHORIZATION)
363 .and_then(|v| v.to_str().ok())?;
364 let url = {
375 let conn = req.connection_info();
376 format!("{}://{}{}", conn.scheme(), conn.host(), req.uri().path())
377 };
378 let now = std::time::SystemTime::now()
379 .duration_since(std::time::UNIX_EPOCH)
380 .map(|d| d.as_secs())
381 .unwrap_or(0);
382 let verified = nip98::verify_at(header_val, &url, req.method().as_str(), None, now).ok()?;
383
384 if NIP98_REPLAY
388 .check_and_record(&verified.event_id)
389 .await
390 .is_err()
391 {
392 tracing::warn!(
393 pubkey = %verified.pubkey,
394 method = %req.method(),
395 "NIP-98 replay rejected: token id already used within window"
396 );
397 return None;
398 }
399
400 Some(verified.pubkey)
401}
402
403pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
404 pubkey.map(|pk| format!("did:nostr:{pk}"))
405}
406
407fn req_origin(req: &HttpRequest) -> Option<&str> {
416 req.headers()
417 .get(header::ORIGIN)
418 .and_then(|v| v.to_str().ok())
419}
420
421pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
425
426async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
443 let did = agent_uri?;
444 let balance = match storage.get(WEBLEDGER_PATH).await {
445 Ok((bytes, _meta)) => {
446 match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
447 Ok(ledger) => ledger.get_balance(did),
448 Err(_) => 0,
452 }
453 }
454 Err(_) => 0,
457 };
458 Some(balance)
459}
460
461fn accept_includes_html(accept: &str) -> bool {
469 accept.split(',').any(|entry| {
470 let mime = entry.split(';').next().unwrap_or("").trim();
471 mime.eq_ignore_ascii_case("text/html")
472 })
473}
474
475fn proposed_acl_keeps_caller_control(
494 body: &[u8],
495 content_type: &str,
496 caller: Option<&str>,
497) -> bool {
498 let doc = match parse_jsonld_acl(body) {
499 Ok(d) => Some(d),
500 Err(_) => {
501 let ct = content_type.to_ascii_lowercase();
502 let text = std::str::from_utf8(body).unwrap_or("");
503 let looks_turtle = ct.starts_with("text/turtle")
504 || ct.starts_with("application/turtle")
505 || ct.starts_with("application/x-turtle")
506 || ct.starts_with("application/n-triples")
507 || text.contains("@prefix")
508 || text.contains("acl:Authorization")
509 || text.contains("auth/acl#Authorization");
513 if looks_turtle {
514 parse_turtle_acl(text).ok()
515 } else {
516 None
517 }
518 }
519 };
520 let Some(doc) = doc else {
521 return true;
523 };
524 let Some(graph) = doc.graph.as_ref() else {
525 return false;
526 };
527 graph.iter().any(|auth| {
528 let grants_control = ids_of_acl_field(&auth.mode)
529 .iter()
530 .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
531 if !grants_control {
532 return false;
533 }
534 let agents = ids_of_acl_field(&auth.agent);
535 if let Some(web_id) = caller {
536 if agents.contains(&web_id) {
537 return true;
538 }
539 }
540 let classes = ids_of_acl_field(&auth.agent_class);
541 if classes
542 .iter()
543 .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
544 {
545 return true;
546 }
547 if caller.is_some()
548 && classes.iter().any(|c| {
549 *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
550 || *c == "acl:AuthenticatedAgent"
551 })
552 {
553 return true;
554 }
555 false
556 })
557}
558
559fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
561 match field {
562 None => Vec::new(),
563 Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
564 Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
565 }
566}
567
568#[cfg_attr(not(test), allow(dead_code))]
575async fn enforce_write(
576 state: &AppState,
577 path: &str,
578 mode: AccessMode,
579 agent_uri: Option<&str>,
580) -> Result<(), ActixError> {
581 enforce_write_ctx(state, path, mode, agent_uri, None).await
582}
583
584async fn enforce_write_ctx(
592 state: &AppState,
593 path: &str,
594 mode: AccessMode,
595 agent_uri: Option<&str>,
596 request_origin: Option<&str>,
597) -> Result<(), ActixError> {
598 let origin = request_origin.and_then(wac::Origin::parse);
599 let (resource, eff_mode) = effective_acl_target(path, mode);
611
612 let acl_doc = match find_effective_acl_dyn(&*state.storage, &resource).await {
617 Ok(doc) => doc,
618 Err(e) => return Err(to_actix(e)),
619 };
620
621 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
626
627 let ctx = RequestContext {
628 web_id: agent_uri,
629 client_id: None,
630 issuer: None,
631 payment_balance_sats,
632 };
633 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
634 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
635 let granted = wac::evaluate_access_ctx_with_registry(
636 acl_doc.as_ref(),
637 &ctx,
638 &resource,
639 eff_mode,
640 origin.as_ref(),
641 &groups,
642 ®istry,
643 );
644 if !granted {
645 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
646 }
647 if resource.as_str() == path {
654 charge_granted_payment(
660 state,
661 acl_doc.as_ref(),
662 &ctx,
663 &resource,
664 eff_mode,
665 &groups,
666 ®istry,
667 )
668 .await?;
669 }
670 Ok(())
671}
672
673async fn charge_granted_payment(
682 state: &AppState,
683 acl_doc: Option<&wac::AclDocument>,
684 ctx: &RequestContext<'_>,
685 path: &str,
686 mode: AccessMode,
687 groups: &wac::StaticGroupMembership,
688 registry: &wac::conditions::ConditionRegistry,
689) -> Result<(), ActixError> {
690 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
691 if cost == 0 {
692 return Ok(());
693 }
694 if let Some(did) = ctx.web_id {
695 if debit_ledger(&*state.storage, did, cost).await.is_err() {
696 return Err(acl_denial(acl_doc, ctx.web_id, path));
697 }
698 }
699 Ok(())
700}
701
702fn acl_denial(
708 acl_doc: Option<&wac::AclDocument>,
709 agent_uri: Option<&str>,
710 path: &str,
711) -> ActixError {
712 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
713 let (status, body, unauthenticated) = if agent_uri.is_none() {
714 (StatusCode::UNAUTHORIZED, "authentication required", true)
715 } else {
716 (StatusCode::FORBIDDEN, "access forbidden", false)
717 };
718 let mut rsp = HttpResponse::new(status);
719 rsp.headers_mut().insert(
720 header::HeaderName::from_static("wac-allow"),
721 header::HeaderValue::from_str(&allow_header)
722 .unwrap_or(header::HeaderValue::from_static("")),
723 );
724 if unauthenticated {
725 rsp.headers_mut().insert(
732 header::WWW_AUTHENTICATE,
733 header::HeaderValue::from_static(
734 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
735 ),
736 );
737 }
738 actix_web::error::InternalError::from_response(body, rsp).into()
739}
740
741#[cfg_attr(not(test), allow(dead_code))]
752async fn enforce_read(
753 state: &AppState,
754 path: &str,
755 agent_uri: Option<&str>,
756) -> Result<(), ActixError> {
757 enforce_read_ctx(state, path, agent_uri, None).await
758}
759
760async fn enforce_read_ctx(
763 state: &AppState,
764 path: &str,
765 agent_uri: Option<&str>,
766 request_origin: Option<&str>,
767) -> Result<(), ActixError> {
768 let origin = request_origin.and_then(wac::Origin::parse);
769 let (resource, eff_mode) = effective_acl_target(path, AccessMode::Read);
781 let acl_doc = match find_effective_acl_dyn(&*state.storage, &resource).await {
782 Ok(doc) => doc,
783 Err(e) => return Err(to_actix(e)),
784 };
785 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
786 let ctx = RequestContext {
787 web_id: agent_uri,
788 client_id: None,
789 issuer: None,
790 payment_balance_sats,
791 };
792 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
793 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
794 let granted = wac::evaluate_access_ctx_with_registry(
795 acl_doc.as_ref(),
796 &ctx,
797 &resource,
798 eff_mode,
799 origin.as_ref(),
800 &groups,
801 ®istry,
802 );
803 if !granted {
804 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
805 }
806 if resource.as_str() == path {
810 charge_granted_payment(
815 state,
816 acl_doc.as_ref(),
817 &ctx,
818 &resource,
819 eff_mode,
820 &groups,
821 ®istry,
822 )
823 .await?;
824 }
825 Ok(())
826}
827
828async fn debit_ledger(
837 storage: &dyn Storage,
838 did: &str,
839 cost: u64,
840) -> Result<(), solid_pod_rs::payments::PaymentError> {
841 use solid_pod_rs::payments::{PaymentError, WebLedger};
842
843 let (bytes, _meta) = storage
844 .get(WEBLEDGER_PATH)
845 .await
846 .map_err(|e| PaymentError::Store(e.to_string()))?;
847 let mut ledger: WebLedger = serde_json::from_slice(&bytes)
848 .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
849 ledger.debit(did, cost)?;
850 let body = serde_json::to_vec(&ledger)
851 .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
852 storage
853 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
854 .await
855 .map_err(|e| PaymentError::Store(e.to_string()))?;
856 Ok(())
857}
858
859fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
864 let links = ldp::link_headers(path).join(", ");
865 if let Ok(value) = header::HeaderValue::from_str(&links) {
866 rsp.headers_mut()
867 .insert(header::HeaderName::from_static("link"), value);
868 }
869}
870
871fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
872 if let Ok(v) = header::HeaderValue::from_str(header_value) {
873 rsp.headers_mut()
874 .insert(header::HeaderName::from_static("wac-allow"), v);
875 }
876}
877
878fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
879 let ws_base = base_url
880 .replacen("https://", "wss://", 1)
881 .replacen("http://", "ws://", 1);
882 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
883 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
884 rsp.headers_mut()
885 .insert(header::HeaderName::from_static("updates-via"), v);
886 }
887}
888
889async fn handle_get(
890 req: HttpRequest,
891 state: web::Data<AppState>,
892) -> Result<HttpResponse, ActixError> {
893 let path = req.uri().path().to_string();
894
895 if path.contains('*') {
896 return handle_glob_get(req, state).await;
897 }
898
899 let auth_pk = extract_pubkey(&req).await;
900 let agent = agent_uri(auth_pk.as_ref());
901
902 enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
907
908 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
909
910 if ldp::is_container(&path) {
911 let accept = req
912 .headers()
913 .get(header::ACCEPT)
914 .and_then(|v| v.to_str().ok())
915 .unwrap_or("");
916
917 if accept_includes_html(accept) {
923 let index_path = format!("{path}index.html");
924 if let Ok((body, _meta)) = state.storage.get(&index_path).await {
925 let mut rsp = HttpResponse::Ok()
926 .content_type("text/html; charset=utf-8")
927 .body(body.to_vec());
928 set_wac_allow(&mut rsp, &wac_allow);
929 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
930 set_link_headers(&mut rsp, &path);
931 return Ok(rsp);
932 }
933 }
934
935 let v = state
936 .storage
937 .container_representation(&path)
938 .await
939 .map_err(to_actix)?;
940
941 let sec_fetch_dest = req
943 .headers()
944 .get("sec-fetch-dest")
945 .and_then(|v| v.to_str().ok());
946 if mashlib::should_serve(
947 accept,
948 sec_fetch_dest,
949 "application/ld+json",
950 state.mashlib.enabled,
951 ) {
952 let json_ld = serde_json::to_string(&v).ok();
953 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
954 let mut rsp = HttpResponse::Ok()
955 .content_type("text/html; charset=utf-8")
956 .insert_header(("X-Frame-Options", "DENY"))
957 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
958 .insert_header(("Cache-Control", "no-store"))
959 .body(html);
960 set_wac_allow(&mut rsp, &wac_allow);
961 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
962 set_link_headers(&mut rsp, &path);
963 return Ok(rsp);
964 }
965
966 let mut rsp = HttpResponse::Ok().json(v);
967 rsp.headers_mut().insert(
968 header::CONTENT_TYPE,
969 header::HeaderValue::from_static("application/ld+json"),
970 );
971 set_wac_allow(&mut rsp, &wac_allow);
972 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
973 set_link_headers(&mut rsp, &path);
974 return Ok(rsp);
975 }
976
977 match state.storage.get(&path).await {
978 Ok((body, meta)) => {
979 let accept = req
981 .headers()
982 .get(header::ACCEPT)
983 .and_then(|v| v.to_str().ok())
984 .unwrap_or("");
985 let sec_fetch_dest = req
986 .headers()
987 .get("sec-fetch-dest")
988 .and_then(|v| v.to_str().ok());
989 if mashlib::should_serve(
990 accept,
991 sec_fetch_dest,
992 &meta.content_type,
993 state.mashlib.enabled,
994 ) {
995 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
996 std::str::from_utf8(&body).ok().map(|s| s.to_string())
997 } else {
998 None
999 };
1000 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
1001 let mut rsp = HttpResponse::Ok()
1002 .content_type("text/html; charset=utf-8")
1003 .insert_header(("X-Frame-Options", "DENY"))
1004 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1005 .insert_header(("Cache-Control", "no-store"))
1006 .body(html);
1007 set_wac_allow(&mut rsp, &wac_allow);
1008 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1009 set_link_headers(&mut rsp, &path);
1010 return Ok(rsp);
1011 }
1012
1013 if let Some((negotiated_body, negotiated_ct)) =
1021 rdf_content_negotiate(&body, &meta.content_type, accept)
1022 {
1023 let mut rsp = HttpResponse::Ok().body(negotiated_body);
1024 rsp.headers_mut().insert(
1025 header::CONTENT_TYPE,
1026 header::HeaderValue::from_str(negotiated_ct)
1027 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
1028 );
1029 rsp.headers_mut()
1030 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
1031 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1032 rsp.headers_mut().insert(header::ETAG, etag);
1033 }
1034 set_wac_allow(&mut rsp, &wac_allow);
1035 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1036 set_link_headers(&mut rsp, &path);
1037 return Ok(rsp);
1038 }
1039
1040 let mut rsp = HttpResponse::Ok().body(body.to_vec());
1041 rsp.headers_mut().insert(
1042 header::CONTENT_TYPE,
1043 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
1044 header::HeaderValue::from_static("application/octet-stream")
1045 }),
1046 );
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 Ok(rsp)
1054 }
1055 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1056 Err(e) => Err(to_actix(e)),
1057 }
1058}
1059
1060fn has_basic_container_link(req: &HttpRequest) -> bool {
1061 req.headers()
1062 .get_all(header::LINK)
1063 .filter_map(|v| v.to_str().ok())
1064 .any(|v| {
1065 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
1066 })
1067}
1068
1069async fn handle_put(
1070 req: HttpRequest,
1071 body: web::Bytes,
1072 state: web::Data<AppState>,
1073) -> Result<HttpResponse, ActixError> {
1074 let path = req.uri().path().to_string();
1075
1076 if ldp::is_container(&path) {
1077 if has_basic_container_link(&req) {
1078 let auth_pk = extract_pubkey(&req).await;
1079 let agent = agent_uri(auth_pk.as_ref());
1080 enforce_write_ctx(
1081 &state,
1082 &path,
1083 AccessMode::Write,
1084 agent.as_deref(),
1085 req_origin(&req),
1086 )
1087 .await?;
1088 let meta = state
1089 .storage
1090 .create_container(&path)
1091 .await
1092 .map_err(to_actix)?;
1093 let mut rsp = HttpResponse::Created().finish();
1094 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1095 rsp.headers_mut().insert(header::ETAG, etag);
1096 }
1097 set_link_headers(&mut rsp, &path);
1098 return Ok(rsp);
1099 }
1100 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
1101 }
1102
1103 let auth_pk = extract_pubkey(&req).await;
1104 let agent = agent_uri(auth_pk.as_ref());
1105 enforce_write_ctx(
1106 &state,
1107 &path,
1108 AccessMode::Write,
1109 agent.as_deref(),
1110 req_origin(&req),
1111 )
1112 .await?;
1113
1114 let ct = req
1115 .headers()
1116 .get(header::CONTENT_TYPE)
1117 .and_then(|v| v.to_str().ok())
1118 .unwrap_or("application/octet-stream");
1119
1120 if protected_resource_for_acl(&path).is_some()
1125 && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
1126 {
1127 return Ok(HttpResponse::Conflict().body(
1128 "refused: the proposed ACL would not grant Control to the caller \
1129 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1130 ));
1131 }
1132
1133 let meta = state
1134 .storage
1135 .put(&path, Bytes::from(body.to_vec()), ct)
1136 .await
1137 .map_err(to_actix)?;
1138 git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
1142 let mut rsp = HttpResponse::Created().finish();
1143 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1144 rsp.headers_mut().insert(header::ETAG, etag);
1145 }
1146 set_link_headers(&mut rsp, &path);
1147 Ok(rsp)
1148}
1149
1150async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
1157 if !storage.exists(target).await.unwrap_or(false) {
1158 return target.to_string();
1159 }
1160 let seg_start = target.rfind('/').map(|s| s + 1).unwrap_or(0);
1163 let (stem, ext) = match target.rfind('.') {
1164 Some(dot) if dot > seg_start => (&target[..dot], &target[dot..]),
1165 _ => (target, ""),
1166 };
1167 for n in 1..10_000u32 {
1168 let candidate = format!("{stem}-{n}{ext}");
1169 if !storage.exists(&candidate).await.unwrap_or(false) {
1170 return candidate;
1171 }
1172 }
1173 use std::hash::{Hash, Hasher};
1174 let mut h = std::collections::hash_map::DefaultHasher::new();
1175 target.hash(&mut h);
1176 format!("{stem}-{:x}{ext}", h.finish())
1177}
1178
1179async fn handle_post(
1180 req: HttpRequest,
1181 body: web::Bytes,
1182 state: web::Data<AppState>,
1183) -> Result<HttpResponse, ActixError> {
1184 let path = req.uri().path().to_string();
1185 let auth_pk = extract_pubkey(&req).await;
1188 let agent = agent_uri(auth_pk.as_ref());
1189 enforce_write_ctx(
1190 &state,
1191 &path,
1192 AccessMode::Append,
1193 agent.as_deref(),
1194 req_origin(&req),
1195 )
1196 .await?;
1197
1198 let slug = req
1199 .headers()
1200 .get(header::HeaderName::from_static("slug"))
1201 .and_then(|v| v.to_str().ok());
1202 let mut target = match ldp::resolve_slug(&path, slug) {
1203 Ok(p) => p,
1204 Err(e) => return Err(to_actix(e)),
1205 };
1206 let ct = req
1207 .headers()
1208 .get(header::CONTENT_TYPE)
1209 .and_then(|v| v.to_str().ok())
1210 .unwrap_or("application/octet-stream");
1211
1212 if protected_resource_for_acl(&target).is_some() {
1221 enforce_write_ctx(
1222 &state,
1223 &target,
1224 AccessMode::Write,
1225 agent.as_deref(),
1226 req_origin(&req),
1227 )
1228 .await?;
1229 if !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref()) {
1230 return Ok(HttpResponse::Conflict().body(
1231 "refused: the proposed ACL would not grant Control to the caller \
1232 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1233 ));
1234 }
1235 } else {
1236 target = mint_unique_target(&*state.storage, &target).await;
1242 }
1243
1244 let meta = state
1245 .storage
1246 .put(&target, Bytes::from(body.to_vec()), ct)
1247 .await
1248 .map_err(to_actix)?;
1249 git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1252 let mut rsp = HttpResponse::Created().finish();
1253 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1254 rsp.headers_mut().insert(header::LOCATION, loc);
1255 }
1256 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1257 rsp.headers_mut().insert(header::ETAG, etag);
1258 }
1259 set_link_headers(&mut rsp, &target);
1260 Ok(rsp)
1261}
1262
1263async fn handle_patch(
1264 req: HttpRequest,
1265 body: web::Bytes,
1266 state: web::Data<AppState>,
1267) -> Result<HttpResponse, ActixError> {
1268 let path = req.uri().path().to_string();
1269 if ldp::is_container(&path) {
1270 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1271 }
1272 let auth_pk = extract_pubkey(&req).await;
1273 let agent = agent_uri(auth_pk.as_ref());
1274 enforce_write_ctx(
1280 &state,
1281 &path,
1282 AccessMode::Write,
1283 agent.as_deref(),
1284 req_origin(&req),
1285 )
1286 .await?;
1287
1288 let ct = req
1289 .headers()
1290 .get(header::CONTENT_TYPE)
1291 .and_then(|v| v.to_str().ok())
1292 .unwrap_or("");
1293 let dialect = match ldp::patch_dialect_from_mime(ct) {
1294 Some(d) => d,
1295 None => {
1296 return Ok(HttpResponse::UnsupportedMediaType()
1297 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1298 }
1299 };
1300 let body_str = match std::str::from_utf8(&body) {
1301 Ok(s) => s.to_string(),
1302 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1303 };
1304
1305 let existing = state.storage.get(&path).await;
1307 match existing {
1308 Ok((current_body, meta)) => {
1309 let out = match dialect {
1319 ldp::PatchDialect::N3 => {
1320 let seed = seed_graph_from_patch_target(¤t_body)?;
1321 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1322 }
1323 ldp::PatchDialect::SparqlUpdate => {
1324 let seed = seed_graph_from_patch_target(¤t_body)?;
1325 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1326 }
1327 ldp::PatchDialect::JsonPatch => {
1328 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1329 Ok(v) => v,
1330 Err(_) => serde_json::json!({}),
1331 };
1332 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1333 Ok(v) => v,
1334 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1335 };
1336 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1337 let bytes = serde_json::to_vec(&json)
1338 .map_err(PodError::from)
1339 .map_err(to_actix)?;
1340 let _ = state
1341 .storage
1342 .put(&path, Bytes::from(bytes), &meta.content_type)
1343 .await
1344 .map_err(to_actix)?;
1345 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1346 return Ok(HttpResponse::NoContent().finish());
1347 }
1348 };
1349 let outcome = out?;
1350 let serialised = graph_to_turtle(&outcome.graph);
1353 if protected_resource_for_acl(&path).is_some()
1359 && !proposed_acl_keeps_caller_control(
1360 serialised.as_bytes(),
1361 "application/n-triples",
1362 agent.as_deref(),
1363 )
1364 {
1365 return Ok(HttpResponse::Conflict().body(
1366 "refused: the patched ACL would not grant Control to the caller \
1367 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1368 ));
1369 }
1370 let _ = state
1371 .storage
1372 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1373 .await
1374 .map_err(to_actix)?;
1375 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1376 Ok(HttpResponse::NoContent().finish())
1377 }
1378 Err(PodError::NotFound(_)) => {
1379 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
1381 let PatchCreateOutcome::Created { graph, .. } = create else {
1382 return Err(to_actix(PodError::Unsupported(
1383 "unexpected patch outcome on absent resource".into(),
1384 )));
1385 };
1386 let serialised = graph_to_turtle(&graph);
1387 if protected_resource_for_acl(&path).is_some()
1389 && !proposed_acl_keeps_caller_control(
1390 serialised.as_bytes(),
1391 "application/n-triples",
1392 agent.as_deref(),
1393 )
1394 {
1395 return Ok(HttpResponse::Conflict().body(
1396 "refused: the patched ACL would not grant Control to the caller \
1397 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1398 ));
1399 }
1400 let _ = state
1401 .storage
1402 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1403 .await
1404 .map_err(to_actix)?;
1405 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1406 Ok(HttpResponse::Created().finish())
1407 }
1408 Err(e) => Err(to_actix(e)),
1409 }
1410}
1411
1412fn patch_parse_err(e: PodError) -> ActixError {
1416 match e {
1417 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
1418 actix_web::error::ErrorBadRequest(msg)
1419 }
1420 other => to_actix(other),
1421 }
1422}
1423
1424fn graph_to_turtle(g: &ldp::Graph) -> String {
1428 g.to_ntriples()
1429}
1430
1431fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
1438 let mut best: Option<(f32, ldp::RdfFormat)> = None;
1439 for entry in accept.split(',') {
1440 let entry = entry.trim();
1441 if entry.is_empty() {
1442 continue;
1443 }
1444 let mut parts = entry.split(';').map(|s| s.trim());
1445 let mime = match parts.next() {
1446 Some(m) => m,
1447 None => continue,
1448 };
1449 let mut q: f32 = 1.0;
1450 for token in parts {
1451 if let Some(v) = token.strip_prefix("q=") {
1452 if let Ok(parsed) = v.parse::<f32>() {
1453 q = parsed;
1454 }
1455 }
1456 }
1457 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
1460 match best {
1461 None => best = Some((q, format)),
1462 Some((bq, _)) if q > bq => best = Some((q, format)),
1463 _ => {}
1464 }
1465 }
1466 }
1467 best.map(|(_, f)| f)
1468}
1469
1470fn rdf_content_negotiate(
1486 body: &[u8],
1487 stored_ct: &str,
1488 accept: &str,
1489) -> Option<(Vec<u8>, &'static str)> {
1490 if accept.trim().is_empty() {
1491 return None;
1492 }
1493 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
1494 let target = best_explicit_rdf_format(accept)?;
1495 if target == stored_format {
1496 return None;
1497 }
1498 let text = std::str::from_utf8(body).ok()?;
1499 let graph = ldp::Graph::parse_ntriples(text).ok()?;
1500 match target {
1501 ldp::RdfFormat::Turtle => Some((
1504 graph.to_ntriples().into_bytes(),
1505 ldp::RdfFormat::Turtle.mime(),
1506 )),
1507 ldp::RdfFormat::NTriples => Some((
1508 graph.to_ntriples().into_bytes(),
1509 ldp::RdfFormat::NTriples.mime(),
1510 )),
1511 ldp::RdfFormat::JsonLd => {
1512 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
1513 Some((json, ldp::RdfFormat::JsonLd.mime()))
1514 }
1515 ldp::RdfFormat::RdfXml => None,
1517 }
1518}
1519
1520fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
1529 let text = std::str::from_utf8(current_body).map_err(|_| {
1530 actix_web::error::ErrorConflict(
1531 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
1532 )
1533 })?;
1534 if text.trim().is_empty() {
1535 return Ok(ldp::Graph::new());
1536 }
1537 ldp::Graph::parse_ntriples(text).map_err(|_| {
1538 actix_web::error::ErrorConflict(
1539 "existing resource is not N-Triples RDF and cannot be non-destructively \
1540 patched; PUT an N-Triples representation or use a JSON Patch",
1541 )
1542 })
1543}
1544
1545pub(crate) async fn find_effective_acl_dyn(
1551 storage: &dyn Storage,
1552 resource_path: &str,
1553) -> Result<Option<wac::AclDocument>, PodError> {
1554 let mut path = resource_path.to_string();
1555 let mut inherited = false;
1560 loop {
1561 let acl_key = if path == "/" {
1562 "/.acl".to_string()
1563 } else {
1564 format!("{}.acl", path.trim_end_matches('/'))
1565 };
1566 if let Ok((body, meta)) = storage.get(&acl_key).await {
1567 match parse_jsonld_acl(&body) {
1568 Ok(mut doc) => {
1569 doc.inherited = inherited;
1570 return Ok(Some(doc));
1571 }
1572 Err(PodError::BadRequest(_)) => {
1573 return Err(PodError::BadRequest("ACL document exceeds bounds".into()))
1574 }
1575 Err(_) => {}
1576 }
1577 let ct = meta.content_type.to_ascii_lowercase();
1578 let looks_turtle = ct.starts_with("text/turtle")
1579 || ct.starts_with("application/turtle")
1580 || ct.starts_with("application/x-turtle");
1581 let text = std::str::from_utf8(&body).unwrap_or("");
1582 if looks_turtle || text.contains("@prefix") || text.contains("acl:Authorization") {
1583 if let Ok(mut doc) = parse_turtle_acl(text) {
1584 doc.inherited = inherited;
1585 return Ok(Some(doc));
1586 }
1587 }
1588 }
1589 if path == "/" || path.is_empty() {
1590 break;
1591 }
1592 inherited = true;
1594 let trimmed = path.trim_end_matches('/');
1595 path = match trimmed.rfind('/') {
1596 Some(0) => "/".to_string(),
1597 Some(pos) => trimmed[..pos].to_string(),
1598 None => "/".to_string(),
1599 };
1600 }
1601 Ok(None)
1602}
1603
1604async fn handle_delete(
1605 req: HttpRequest,
1606 state: web::Data<AppState>,
1607) -> Result<HttpResponse, ActixError> {
1608 let path = req.uri().path().to_string();
1609 let auth_pk = extract_pubkey(&req).await;
1610 let agent = agent_uri(auth_pk.as_ref());
1611 enforce_write_ctx(
1612 &state,
1613 &path,
1614 AccessMode::Write,
1615 agent.as_deref(),
1616 req_origin(&req),
1617 )
1618 .await?;
1619
1620 match state.storage.delete(&path).await {
1621 Ok(()) => Ok(HttpResponse::NoContent().finish()),
1622 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1623 Err(e) => Err(to_actix(e)),
1624 }
1625}
1626
1627async fn handle_options(
1628 req: HttpRequest,
1629 state: web::Data<AppState>,
1630) -> Result<HttpResponse, ActixError> {
1631 let path = req.uri().path().to_string();
1632 let o = ldp::options_for(&path);
1633 let mut rsp = HttpResponse::NoContent().finish();
1634 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
1635 rsp.headers_mut()
1636 .insert(header::HeaderName::from_static("allow"), v);
1637 }
1638 if let Some(ap) = o.accept_post {
1639 if let Ok(v) = header::HeaderValue::from_str(ap) {
1640 rsp.headers_mut()
1641 .insert(header::HeaderName::from_static("accept-post"), v);
1642 }
1643 }
1644 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
1645 rsp.headers_mut()
1646 .insert(header::HeaderName::from_static("accept-patch"), v);
1647 }
1648 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
1649 rsp.headers_mut()
1650 .insert(header::HeaderName::from_static("accept-ranges"), v);
1651 }
1652 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1653 Ok(rsp)
1654}
1655
1656async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
1661 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
1662 HttpResponse::Ok()
1663 .content_type("application/ld+json")
1664 .json(doc)
1665}
1666
1667#[derive(Debug, Deserialize)]
1668struct WebFingerQuery {
1669 resource: Option<String>,
1670}
1671
1672async fn handle_well_known_webfinger(
1673 state: web::Data<AppState>,
1674 q: web::Query<WebFingerQuery>,
1675) -> HttpResponse {
1676 let resource = q.resource.clone().unwrap_or_else(|| {
1677 format!(
1678 "acct:anonymous@{}",
1679 state
1680 .nodeinfo
1681 .base_url
1682 .trim_start_matches("http://")
1683 .trim_start_matches("https://")
1684 )
1685 });
1686 let webid = format!(
1687 "{}/profile/card#me",
1688 state.nodeinfo.base_url.trim_end_matches('/')
1689 );
1690 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
1691 Some(jrd) => HttpResponse::Ok()
1692 .content_type("application/jrd+json")
1693 .json(jrd),
1694 None => HttpResponse::NotFound().finish(),
1695 }
1696}
1697
1698async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
1699 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
1700 HttpResponse::Ok()
1701 .content_type("application/json")
1702 .json(doc)
1703}
1704
1705async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
1706 let doc = interop::nodeinfo_2_1(
1707 &state.nodeinfo.software_name,
1708 &state.nodeinfo.software_version,
1709 state.nodeinfo.open_registrations,
1710 state.nodeinfo.total_users,
1711 );
1712 HttpResponse::Ok()
1713 .content_type("application/json")
1714 .json(doc)
1715}
1716
1717#[cfg(feature = "did-nostr")]
1718async fn handle_well_known_did_nostr(
1719 state: web::Data<AppState>,
1720 path: web::Path<String>,
1721) -> HttpResponse {
1722 let pubkey = path.into_inner();
1723 let pubkey_is_valid = pubkey.len() == 64
1728 && pubkey
1729 .bytes()
1730 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
1731 if !pubkey_is_valid {
1732 return HttpResponse::BadRequest()
1733 .insert_header(("Cache-Control", "no-store"))
1734 .json(serde_json::json!({
1735 "error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
1736 }));
1737 }
1738 let owner_pubkey = match state.storage.get("/profile/card").await {
1746 Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
1747 .ok()
1748 .flatten(),
1749 Err(_) => None,
1750 };
1751 let owner_claims_key = owner_pubkey
1752 .as_deref()
1753 .is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
1754 if !owner_claims_key {
1755 return HttpResponse::NotFound()
1756 .insert_header(("Cache-Control", "no-store"))
1757 .json(serde_json::json!({
1758 "error": "no account on this pod claims this did:nostr pubkey"
1759 }));
1760 }
1761 let also = vec![format!(
1762 "{}/profile/card#me",
1763 state.nodeinfo.base_url.trim_end_matches('/')
1764 )];
1765 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
1766 let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
1767 use std::hash::{Hash, Hasher};
1773 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1774 body.hash(&mut hasher);
1775 let etag = format!("\"{:016x}\"", hasher.finish());
1776 HttpResponse::Ok()
1777 .content_type("application/did+json")
1778 .insert_header(("Cache-Control", "max-age=3600"))
1779 .insert_header(("ETag", etag))
1780 .body(body)
1781}
1782
1783#[cfg(feature = "nip05-endpoint")]
1791#[derive(Debug, Deserialize)]
1792struct Nip05Query {
1793 name: Option<String>,
1796}
1797
1798#[cfg(feature = "nip05-endpoint")]
1799fn nip05_name_is_valid(name: &str) -> bool {
1800 if name.is_empty() {
1803 return false;
1804 }
1805 name.bytes()
1806 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
1807}
1808
1809#[cfg(feature = "nip05-endpoint")]
1810async fn handle_well_known_nip05(
1811 state: web::Data<AppState>,
1812 query: web::Query<Nip05Query>,
1813) -> HttpResponse {
1814 use solid_pod_rs::webid::extract_nostr_pubkey;
1815
1816 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
1818 if !nip05_name_is_valid(&name) {
1819 return HttpResponse::BadRequest().json(serde_json::json!({
1820 "error": "invalid NIP-05 local part",
1821 }));
1822 }
1823
1824 let profile_path = if name == "_" {
1830 "/profile/card".to_string()
1831 } else {
1832 format!("/{name}/profile/card")
1833 };
1834
1835 let (body, _meta) = match state.storage.get(&profile_path).await {
1836 Ok(v) => v,
1837 Err(_) => {
1838 return nip05_empty_response();
1842 }
1843 };
1844
1845 let pubkey_hex = match extract_nostr_pubkey(&body) {
1846 Ok(Some(p)) => p,
1847 _ => return nip05_empty_response(),
1848 };
1849
1850 let doc = interop::nip05_document([(name, pubkey_hex)]);
1851 HttpResponse::Ok()
1852 .insert_header(("Access-Control-Allow-Origin", "*"))
1853 .content_type("application/json")
1854 .json(doc)
1855}
1856
1857#[cfg(feature = "nip05-endpoint")]
1858fn nip05_empty_response() -> HttpResponse {
1859 HttpResponse::Ok()
1860 .insert_header(("Access-Control-Allow-Origin", "*"))
1861 .content_type("application/json")
1862 .json(serde_json::json!({ "names": {} }))
1863}
1864
1865#[cfg(feature = "export-jsonld")]
1879async fn handle_export_all(
1880 req: HttpRequest,
1881 state: web::Data<AppState>,
1882) -> Result<HttpResponse, ActixError> {
1883 let auth_pk = extract_pubkey(&req).await;
1884 let agent = agent_uri(auth_pk.as_ref());
1885
1886 enforce_write_ctx(
1891 &state,
1892 "/",
1893 AccessMode::Control,
1894 agent.as_deref(),
1895 req_origin(&req),
1896 )
1897 .await?;
1898
1899 let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
1903 .ok()
1904 .and_then(|q| q.get("include_private").map(|v| v == "true"))
1905 .unwrap_or(false);
1906
1907 let pod_base = {
1911 let conn = req.connection_info();
1912 format!("{}://{}/", conn.scheme(), conn.host())
1913 };
1914
1915 let options = solid_pod_rs::ExportOptions { include_private };
1916 let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
1917 .await
1918 .map_err(to_actix)?;
1919
1920 let body = serde_json::to_vec(&bundle).map_err(|e| {
1921 actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
1922 })?;
1923 Ok(HttpResponse::Ok()
1924 .content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
1925 .body(body))
1926}
1927
1928#[derive(Debug, Deserialize)]
1933struct CreateAccountRequest {
1934 username: String,
1935 #[serde(default)]
1936 name: Option<String>,
1937}
1938
1939#[derive(Debug, Deserialize)]
1940struct CreatePodRequest {
1941 name: String,
1942}
1943
1944async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
1945 let pod_name = path.into_inner();
1946 let pod_root = format!("/{pod_name}/");
1947 match state.storage.exists(&pod_root).await {
1948 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
1949 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
1950 }
1951}
1952
1953fn valid_pod_name(name: &str) -> bool {
1954 !name.is_empty()
1955 && name
1956 .chars()
1957 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
1958}
1959
1960fn request_ip(req: &HttpRequest) -> IpAddr {
1961 req.peer_addr()
1962 .map(|addr| addr.ip())
1963 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
1964}
1965
1966async fn handle_create_account(
1967 state: web::Data<AppState>,
1968 body: web::Json<CreateAccountRequest>,
1969) -> Result<HttpResponse, ActixError> {
1970 let pod_root = format!("/{}/", body.username);
1971 if state.storage.exists(&pod_root).await.unwrap_or(false) {
1972 return Ok(
1973 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
1974 );
1975 }
1976
1977 let mut plan = provision::ProvisionPlan::new(
1978 body.username.clone(),
1979 format!(
1980 "{}/{}",
1981 state.nodeinfo.base_url.trim_end_matches('/'),
1982 body.username,
1983 ),
1984 );
1985 plan.display_name = body.name.clone();
1986 plan.containers = vec![
1987 format!("/{}/", body.username),
1988 format!("/{}/profile/", body.username),
1989 format!("/{}/inbox/", body.username),
1990 format!("/{}/public/", body.username),
1991 format!("/{}/private/", body.username),
1992 format!("/{}/settings/", body.username),
1993 ];
1994
1995 #[cfg(feature = "git")]
1999 let outcome = {
2000 use solid_pod_rs_git::init::GitAutoInit;
2001 let git_hook = state.data_root.as_ref().map(|root| {
2002 let fs_path = root.join(&body.username);
2003 (GitAutoInit::new(), fs_path)
2004 });
2005 match git_hook {
2006 Some((hook, ref fs_path)) => {
2007 provision::provision_pod_ext(state.storage.as_ref(), &plan, Some((&hook, fs_path)))
2008 .await
2009 }
2010 None => provision::provision_pod(state.storage.as_ref(), &plan).await,
2011 }
2012 };
2013 #[cfg(not(feature = "git"))]
2014 let outcome = provision::provision_pod(state.storage.as_ref(), &plan).await;
2015
2016 match outcome {
2017 Ok(outcome) => Ok(HttpResponse::Created().json(serde_json::json!({
2018 "webid": outcome.webid,
2019 "pod_root": outcome.pod_root,
2020 "username": body.username,
2021 }))),
2022 Err(e) => Err(to_actix(e)),
2023 }
2024}
2025
2026async fn handle_create_pod(
2027 req: HttpRequest,
2028 state: web::Data<AppState>,
2029 body: web::Json<CreatePodRequest>,
2030) -> Result<HttpResponse, ActixError> {
2031 let ip = request_ip(&req);
2032 if let Err(retry_after) = state.pod_create_limiter.check(ip) {
2033 return Ok(HttpResponse::TooManyRequests()
2034 .insert_header(("Retry-After", retry_after.to_string()))
2035 .json(serde_json::json!({
2036 "error": "Too Many Requests",
2037 "message": "Pod creation rate limit exceeded",
2038 "retryAfter": retry_after
2039 })));
2040 }
2041
2042 if !valid_pod_name(&body.name) {
2043 return Ok(HttpResponse::BadRequest().json(serde_json::json!({
2044 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
2045 })));
2046 }
2047
2048 let pod_root = format!("/{}/", body.name);
2049 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2050 return Ok(
2051 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
2052 );
2053 }
2054
2055 let base_uri = {
2056 let conn = req.connection_info();
2057 format!("{}://{}", conn.scheme(), conn.host())
2058 };
2059 let pod_uri = format!("{}/{}/", base_uri.trim_end_matches('/'), body.name);
2060
2061 for container in [
2062 format!("/{}/", body.name),
2063 format!("/{}/profile/", body.name),
2064 format!("/{}/inbox/", body.name),
2065 format!("/{}/public/", body.name),
2066 format!("/{}/private/", body.name),
2067 format!("/{}/settings/", body.name),
2068 ] {
2069 let meta_key = format!("{}.meta", container.trim_end_matches('/'));
2070 state
2071 .storage
2072 .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
2073 .await
2074 .map_err(to_actix)?;
2075 }
2076
2077 let canonical_pods_prefix = format!("{}/pods/{}/", base_uri.trim_end_matches('/'), body.name);
2078 let webid = format!("{pod_uri}profile/card#me");
2079 let profile = solid_pod_rs::webid::generate_webid_html(&body.name, None, &base_uri)
2080 .replace(&canonical_pods_prefix, &pod_uri);
2081 state
2082 .storage
2083 .put(
2084 &format!("/{}/profile/card", body.name),
2085 Bytes::from(profile.into_bytes()),
2086 "text/html",
2087 )
2088 .await
2089 .map_err(to_actix)?;
2090
2091 Ok(HttpResponse::Created()
2092 .insert_header(("Location", pod_uri.clone()))
2093 .json(serde_json::json!({
2094 "name": body.name,
2095 "webId": webid,
2096 "podUri": pod_uri,
2097 })))
2098}
2099
2100async fn handle_copy(
2105 req: HttpRequest,
2106 state: web::Data<AppState>,
2107) -> Result<HttpResponse, ActixError> {
2108 let dest = req.uri().path().to_string();
2109 let auth_pk = extract_pubkey(&req).await;
2110 let agent = agent_uri(auth_pk.as_ref());
2111 enforce_write_ctx(
2112 &state,
2113 &dest,
2114 AccessMode::Write,
2115 agent.as_deref(),
2116 req_origin(&req),
2117 )
2118 .await?;
2119
2120 let source = req
2121 .headers()
2122 .get("source")
2123 .and_then(|v| v.to_str().ok())
2124 .map(|s| s.to_string());
2125 let source = match source {
2126 Some(s) => s,
2127 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
2128 };
2129
2130 let (body, meta) = match state.storage.get(&source).await {
2131 Ok(v) => v,
2132 Err(PodError::NotFound(_)) => {
2133 return Ok(HttpResponse::NotFound().body("source resource not found"))
2134 }
2135 Err(e) => return Err(to_actix(e)),
2136 };
2137
2138 state
2139 .storage
2140 .put(&dest, body, &meta.content_type)
2141 .await
2142 .map_err(to_actix)?;
2143
2144 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
2146 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
2147 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
2148 let _ = state
2149 .storage
2150 .put(&dst_acl, acl_body, &acl_meta.content_type)
2151 .await;
2152 }
2153
2154 let mut rsp = HttpResponse::Created().finish();
2155 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
2156 rsp.headers_mut().insert(header::LOCATION, loc);
2157 }
2158 Ok(rsp)
2159}
2160
2161async fn handle_glob_get(
2166 req: HttpRequest,
2167 state: web::Data<AppState>,
2168) -> Result<HttpResponse, ActixError> {
2169 let raw_path = req.uri().path().to_string();
2170 if !raw_path.ends_with("/*") {
2172 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
2173 }
2174 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
2176 folder.to_string()
2177 } else {
2178 format!("{folder}/")
2179 };
2180
2181 let auth_pk = extract_pubkey(&req).await;
2185 let agent = agent_uri(auth_pk.as_ref());
2186 enforce_read_ctx(&state, &folder, agent.as_deref(), req_origin(&req)).await?;
2187
2188 let children = state.storage.list(&folder).await.map_err(to_actix)?;
2189 let mut merged = String::new();
2190
2191 for child in &children {
2192 if child.ends_with('/') {
2193 continue;
2194 }
2195 let child_path = format!("{folder}{child}");
2196 if let Ok((body, meta)) = state.storage.get(&child_path).await {
2197 if meta.content_type.contains("turtle")
2198 || meta.content_type.contains("n-triples")
2199 || meta.content_type.contains("n3")
2200 {
2201 if let Ok(text) = std::str::from_utf8(&body) {
2202 merged.push_str(text);
2203 merged.push('\n');
2204 }
2205 }
2206 }
2207 }
2208
2209 if merged.is_empty() {
2210 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
2211 }
2212
2213 Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
2214}
2215
2216#[derive(Debug, Deserialize)]
2221struct LoginPasswordRequest {
2222 username: String,
2223 password: String,
2224}
2225
2226async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
2227 let _ = (&body.username, &body.password);
2228 HttpResponse::Ok().json(serde_json::json!({
2229 "message": "login endpoint active"
2230 }))
2231}
2232
2233#[derive(Debug, Deserialize)]
2234struct PasswordResetRequest {
2235 username: String,
2236}
2237
2238async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
2239 let _ = &body.username;
2240 HttpResponse::Ok().json(serde_json::json!({
2241 "message": "if an account with that username exists, a reset link has been sent"
2242 }))
2243}
2244
2245#[derive(Debug, Deserialize)]
2246struct PasswordChangeRequest {
2247 token: String,
2248 new_password: String,
2249}
2250
2251async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
2252 let _ = (&body.token, &body.new_password);
2253 HttpResponse::Ok().json(serde_json::json!({
2254 "message": "password changed"
2255 }))
2256}
2257
2258async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
2263 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
2264 HttpResponse::Ok()
2265 .content_type("application/json")
2266 .json(body)
2267}
2268
2269pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
2284
2285#[derive(Debug, Deserialize)]
2287struct ProxyQuery {
2288 url: String,
2289}
2290
2291const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
2293 "set-cookie",
2294 "set-cookie2",
2295 "authorization",
2296 "www-authenticate",
2297 "proxy-authenticate",
2298 "proxy-authorization",
2299];
2300
2301fn validate_proxy_target(target: &str) -> Result<url::Url, HttpResponse> {
2307 let parsed = match url::Url::parse(target) {
2308 Ok(u) => u,
2309 Err(_) => {
2310 return Err(
2311 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
2312 );
2313 }
2314 };
2315
2316 match parsed.scheme() {
2318 "http" | "https" => {}
2319 scheme => {
2320 return Err(HttpResponse::BadRequest()
2321 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
2322 }
2323 }
2324
2325 if let Err(_e) = solid_pod_rs::security::is_safe_url(target) {
2327 return Err(HttpResponse::Forbidden()
2328 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
2329 }
2330
2331 if let Some(host) = parsed.host_str() {
2333 let host_lower = host.to_ascii_lowercase();
2334 if host_lower == "localhost"
2336 || host_lower.ends_with(".localhost")
2337 || host_lower == "0.0.0.0"
2338 || host_lower == "[::1]"
2339 || host_lower == "[::0]"
2340 {
2341 return Err(HttpResponse::Forbidden()
2342 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
2343 }
2344 } else {
2345 return Err(
2346 HttpResponse::BadRequest().json(serde_json::json!({"error": "target URL has no host"}))
2347 );
2348 }
2349
2350 Ok(parsed)
2351}
2352
2353async fn handle_proxy(
2354 req: HttpRequest,
2355 _state: web::Data<AppState>,
2356 query: web::Query<ProxyQuery>,
2357) -> Result<HttpResponse, ActixError> {
2358 let auth_pk = extract_pubkey(&req).await;
2360 let agent = agent_uri(auth_pk.as_ref());
2361 if agent.is_none() {
2362 return Ok(HttpResponse::Unauthorized()
2363 .json(serde_json::json!({"error": "authentication required"})));
2364 }
2365
2366 let _target_url = match validate_proxy_target(&query.url) {
2368 Ok(u) => u,
2369 Err(rsp) => return Ok(rsp),
2370 };
2371
2372 let client = reqwest::Client::builder()
2374 .redirect(reqwest::redirect::Policy::none())
2377 .build()
2378 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))?;
2379
2380 let mut current_url = query.url.clone();
2381 let mut redirect_count = 0u8;
2382 const MAX_REDIRECTS: u8 = 5;
2383
2384 let byte_cap = std::env::var("PROXY_BYTE_CAP")
2385 .ok()
2386 .and_then(|v| {
2387 solid_pod_rs::config::sources::parse_size(&v)
2388 .map(|u| u as usize)
2389 .ok()
2390 })
2391 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
2392
2393 loop {
2394 if redirect_count > 0 {
2396 match validate_proxy_target(¤t_url) {
2397 Ok(_) => {}
2398 Err(rsp) => return Ok(rsp),
2399 }
2400 }
2401
2402 let mut upstream_req = client.get(¤t_url);
2403
2404 if let Some(auth_val) = req
2406 .headers()
2407 .get("x-upstream-authorization")
2408 .and_then(|v| v.to_str().ok())
2409 {
2410 upstream_req = upstream_req.header("Authorization", auth_val);
2411 }
2412
2413 let response = upstream_req
2414 .send()
2415 .await
2416 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
2417
2418 if response.status().is_redirection() {
2420 if redirect_count >= MAX_REDIRECTS {
2421 return Ok(HttpResponse::BadGateway()
2422 .json(serde_json::json!({"error": "too many redirects"})));
2423 }
2424 if let Some(location) = response.headers().get("location") {
2425 let loc_str = location
2426 .to_str()
2427 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
2428 let base = url::Url::parse(¤t_url)
2430 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
2431 let resolved = base
2432 .join(loc_str)
2433 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
2434 current_url = resolved.to_string();
2435 redirect_count += 1;
2436 continue;
2437 }
2438 return Ok(HttpResponse::BadGateway()
2439 .json(serde_json::json!({"error": "redirect without location"})));
2440 }
2441
2442 let upstream_status = response.status().as_u16();
2444 let upstream_content_type = response
2445 .headers()
2446 .get("content-type")
2447 .and_then(|v| v.to_str().ok())
2448 .unwrap_or("application/octet-stream")
2449 .to_string();
2450
2451 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
2453 for (name, value) in response.headers() {
2454 let name_lower = name.as_str().to_ascii_lowercase();
2455 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
2456 continue;
2457 }
2458 if matches!(
2460 name_lower.as_str(),
2461 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
2462 ) {
2463 continue;
2464 }
2465 if let Ok(val_str) = value.to_str() {
2466 forwarded_headers.push((name_lower, val_str.to_string()));
2467 }
2468 }
2469
2470 let body_bytes = response
2471 .bytes()
2472 .await
2473 .map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
2474
2475 if body_bytes.len() > byte_cap {
2476 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
2477 "error": "proxied response exceeds byte cap",
2478 "limit": byte_cap
2479 })));
2480 }
2481
2482 let mut rsp = HttpResponse::build(
2484 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
2485 );
2486 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
2487 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
2488
2489 for (name, value) in &forwarded_headers {
2491 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
2492 if let Ok(hval) = header::HeaderValue::from_str(value) {
2493 rsp.insert_header((hname, hval));
2494 }
2495 }
2496 }
2497
2498 return Ok(rsp.body(body_bytes.to_vec()));
2499 }
2500}
2501
2502pub struct PathTraversalGuard;
2508
2509impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
2510where
2511 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2512 B: 'static,
2513{
2514 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2515 type Error = ActixError;
2516 type InitError = ();
2517 type Transform = PathTraversalGuardMiddleware<S>;
2518 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2519
2520 fn new_transform(&self, service: S) -> Self::Future {
2521 ready(Ok(PathTraversalGuardMiddleware { service }))
2522 }
2523}
2524
2525pub struct PathTraversalGuardMiddleware<S> {
2527 service: S,
2528}
2529
2530impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
2531where
2532 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2533 B: 'static,
2534{
2535 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2536 type Error = ActixError;
2537 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2538
2539 actix_web::dev::forward_ready!(service);
2540
2541 fn call(&self, req: ServiceRequest) -> Self::Future {
2542 let raw = req.path().to_string();
2545 if path_is_traversal(&raw) {
2546 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
2547 let sr = req.into_response(rsp.map_into_boxed_body());
2548 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2549 }
2550 let fut = self.service.call(req);
2551 Box::pin(async move {
2552 let resp = fut.await?;
2553 Ok(resp.map_into_left_body())
2554 })
2555 }
2556}
2557
2558fn path_is_traversal(path: &str) -> bool {
2559 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
2561 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
2562 for seg in once.split('/').chain(twice.split('/')) {
2563 if seg == ".." || seg == "." {
2564 return true;
2565 }
2566 }
2567 if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
2570 return true;
2571 }
2572 false
2573}
2574
2575pub struct CorsHeaders {
2586 pub allowed_origins: Arc<Vec<String>>,
2587}
2588
2589impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
2590where
2591 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2592 B: 'static,
2593{
2594 type Response = ServiceResponse<B>;
2595 type Error = ActixError;
2596 type InitError = ();
2597 type Transform = CorsHeadersMiddleware<S>;
2598 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2599
2600 fn new_transform(&self, service: S) -> Self::Future {
2601 ready(Ok(CorsHeadersMiddleware {
2602 service,
2603 allowed_origins: self.allowed_origins.clone(),
2604 }))
2605 }
2606}
2607
2608pub struct CorsHeadersMiddleware<S> {
2610 service: S,
2611 allowed_origins: Arc<Vec<String>>,
2612}
2613
2614impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
2615where
2616 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2617 B: 'static,
2618{
2619 type Response = ServiceResponse<B>;
2620 type Error = ActixError;
2621 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2622
2623 actix_web::dev::forward_ready!(service);
2624
2625 fn call(&self, req: ServiceRequest) -> Self::Future {
2626 let origin = req
2627 .headers()
2628 .get(header::ORIGIN)
2629 .and_then(|v| v.to_str().ok())
2630 .map(str::to_string);
2631 let allowed = self.allowed_origins.clone();
2632 let fut = self.service.call(req);
2633 Box::pin(async move {
2634 let mut resp = fut.await?;
2635 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
2636 Ok(resp)
2637 })
2638 }
2639}
2640
2641fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
2642 let effective_origin: Option<String> = if allowed.is_empty() {
2644 Some(origin.unwrap_or("*").to_string())
2646 } else {
2647 origin
2649 .filter(|o| allowed.iter().any(|a| a == *o))
2650 .map(str::to_string)
2651 };
2652
2653 let origin_value = match effective_origin {
2656 Some(ref v) => v.as_str(),
2657 None => return,
2658 };
2659
2660 let pairs = [
2661 ("access-control-allow-origin", origin_value),
2662 (
2663 "access-control-allow-methods",
2664 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
2665 ),
2666 (
2667 "access-control-allow-headers",
2668 "Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Range, Slug, Origin",
2669 ),
2670 (
2671 "access-control-expose-headers",
2672 "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",
2673 ),
2674 ("access-control-allow-credentials", "true"),
2675 ("access-control-max-age", "86400"),
2676 ];
2677
2678 for (name, value) in pairs {
2679 if let (Ok(name), Ok(value)) = (
2680 header::HeaderName::from_lowercase(name.as_bytes()),
2681 header::HeaderValue::from_str(value),
2682 ) {
2683 headers.insert(name, value);
2684 }
2685 }
2686}
2687
2688pub struct ErrorLoggingMiddleware;
2704
2705impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
2706where
2707 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2708 B: 'static,
2709{
2710 type Response = ServiceResponse<B>;
2711 type Error = ActixError;
2712 type InitError = ();
2713 type Transform = ErrorLoggingMiddlewareService<S>;
2714 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2715
2716 fn new_transform(&self, service: S) -> Self::Future {
2717 ready(Ok(ErrorLoggingMiddlewareService { service }))
2718 }
2719}
2720
2721pub struct ErrorLoggingMiddlewareService<S> {
2723 service: S,
2724}
2725
2726impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
2727where
2728 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2729 B: 'static,
2730{
2731 type Response = ServiceResponse<B>;
2732 type Error = ActixError;
2733 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2734
2735 actix_web::dev::forward_ready!(service);
2736
2737 fn call(&self, req: ServiceRequest) -> Self::Future {
2738 let method = req.method().as_str().to_string();
2741 let path = req.path().to_string();
2742
2743 let fut = self.service.call(req);
2744 Box::pin(async move {
2745 let response = fut.await?;
2746 let status = response.status();
2747 if status.is_server_error() {
2748 log_5xx(&method, &path, status, response.response().error());
2749 }
2750 Ok(response)
2751 })
2752 }
2753}
2754
2755fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
2759 let chain = match error {
2763 Some(e) => format_error_chain(e),
2764 None => "<no error attached to response>".to_string(),
2765 };
2766
2767 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
2768 Some(std::backtrace::Backtrace::force_capture().to_string())
2769 } else {
2770 None
2771 };
2772
2773 tracing::error!(
2774 target: "solid_pod_rs_server::http",
2775 method = %method,
2776 path = %path,
2777 status = %status.as_u16(),
2778 error.chain = %chain,
2779 backtrace = backtrace.as_deref().unwrap_or(""),
2780 "5xx response"
2781 );
2782}
2783
2784fn format_error_chain(e: &actix_web::Error) -> String {
2795 let summary = format!("{}", e.as_response_error());
2796 let debug = format!("{e:?}");
2797 if debug == summary || debug.is_empty() {
2798 summary
2799 } else {
2800 format!("{summary} -> {debug}")
2801 }
2802}
2803
2804pub struct DotfileGuard {
2810 allow: Arc<DotfileAllowlist>,
2811}
2812
2813impl DotfileGuard {
2814 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
2815 Self { allow }
2816 }
2817}
2818
2819impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
2820where
2821 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2822 B: 'static,
2823{
2824 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2825 type Error = ActixError;
2826 type InitError = ();
2827 type Transform = DotfileGuardMiddleware<S>;
2828 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2829
2830 fn new_transform(&self, service: S) -> Self::Future {
2831 ready(Ok(DotfileGuardMiddleware {
2832 service,
2833 allow: self.allow.clone(),
2834 }))
2835 }
2836}
2837
2838pub struct DotfileGuardMiddleware<S> {
2840 service: S,
2841 allow: Arc<DotfileAllowlist>,
2842}
2843
2844impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
2845where
2846 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2847 B: 'static,
2848{
2849 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2850 type Error = ActixError;
2851 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2852
2853 actix_web::dev::forward_ready!(service);
2854
2855 fn call(&self, req: ServiceRequest) -> Self::Future {
2856 let path = req.path().to_string();
2857 let allow_system_route =
2864 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
2865 if !allow_system_route {
2866 let pb = PathBuf::from(&path);
2867 if !self.allow.is_allowed(Path::new(&pb)) {
2868 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
2869 let sr = req.into_response(rsp.map_into_boxed_body());
2870 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2871 }
2872 }
2873 let fut = self.service.call(req);
2874 Box::pin(async move {
2875 let resp = fut.await?;
2876 Ok(resp.map_into_left_body())
2877 })
2878 }
2879}
2880
2881#[cfg(feature = "git")]
2886pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
2887 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
2888 return None;
2889 }
2890 state.data_root.as_ref().map(|root| root.join(pubkey))
2891}
2892
2893#[cfg(feature = "git")]
2923async fn git_mark_write(state: &AppState, resource_path: &str, agent: Option<&str>, message: &str) {
2924 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
2925 use solid_pod_rs_git::mark::ShellGitMarker;
2926
2927 if resource_path.ends_with(".acl")
2930 || resource_path.ends_with(".meta")
2931 || resource_path.ends_with(".prov.ttl")
2932 {
2933 return;
2934 }
2935 if resource_path.ends_with('/') {
2937 return;
2938 }
2939
2940 let Some(data_root) = state.data_root.as_ref() else {
2942 return;
2943 };
2944
2945 let trimmed = resource_path.trim_start_matches('/');
2947 let mut segments = trimmed.splitn(2, '/');
2948 let pod = segments.next().unwrap_or("");
2949 let rel = segments.next().unwrap_or("");
2950 if pod.is_empty() || rel.is_empty() {
2951 return;
2952 }
2953 let repo = data_root.join(pod);
2954
2955 if !repo.join(".git").is_dir() {
2959 return;
2960 }
2961
2962 let agent_did = agent.unwrap_or("urn:solid:anonymous");
2963 let created = std::time::SystemTime::now()
2964 .duration_since(std::time::UNIX_EPOCH)
2965 .map(|d| d.as_secs())
2966 .unwrap_or(0);
2967
2968 let (policy, ticker_override) =
2971 handlers::prov::resolve_anchor_policy(state, resource_path).await;
2972
2973 let marker = std::sync::Arc::new(ShellGitMarker::new());
2978 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
2979 None
2980 } else {
2981 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
2982 };
2983 let (log, ticker, network) = match &anchorer_bundle {
2984 Some((anchorer, ticker, network)) => (
2985 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
2986 ticker.clone(),
2987 network.clone(),
2988 ),
2989 None => (
2991 ProvenanceLog::new(marker.clone()),
2992 String::new(),
2993 String::new(),
2994 ),
2995 };
2996
2997 let record_policy = match policy {
3001 AnchorPolicy::Epoch => AnchorPolicy::Never,
3002 other => other,
3003 };
3004 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
3005
3006 let write_record = solid_pod_rs::provenance::WriteRecord {
3010 repo: &repo,
3011 path: rel,
3012 agent_did,
3013 message,
3014 policy: record_policy,
3015 high_value,
3016 ticker: &ticker,
3017 network: &network,
3018 created,
3019 };
3020 let mut mark = match log.record(write_record).await {
3021 Ok(m) => m,
3022 Err(e) => {
3023 tracing::warn!(
3024 target: "solid_pod_rs_server::git_mark",
3025 resource = %resource_path,
3026 "provenance record failed (swallowed, write already succeeded): {e}"
3027 );
3028 return;
3029 }
3030 };
3031 mark.resource = resource_path.to_string();
3034
3035 if matches!(policy, AnchorPolicy::Epoch) {
3039 if let Some((anchorer, _, _)) = &anchorer_bundle {
3040 match handlers::prov::epoch_push_and_maybe_anchor(
3041 state,
3042 anchorer,
3043 &ticker,
3044 &network,
3045 &mark.git.commit_sha,
3046 )
3047 .await
3048 {
3049 Ok(Some(closed)) => tracing::debug!(
3050 target: "solid_pod_rs_server::git_mark",
3051 root = %closed.root,
3052 n = closed.commits.len(),
3053 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
3054 ),
3055 Ok(None) => {}
3056 Err(e) => tracing::warn!(
3057 target: "solid_pod_rs_server::git_mark",
3058 "epoch batch/anchor failed (swallowed): {e}"
3059 ),
3060 }
3061 }
3062 }
3063
3064 let ttl = prov_ttl(&mark);
3069 let sidecar = format!("{resource_path}.prov.ttl");
3070 if let Err(e) = state
3071 .storage
3072 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
3073 .await
3074 {
3075 tracing::warn!(
3076 target: "solid_pod_rs_server::git_mark",
3077 sidecar = %sidecar,
3078 "provenance sidecar write failed (swallowed): {e}"
3079 );
3080 return;
3081 }
3082
3083 tracing::debug!(
3084 target: "solid_pod_rs_server::git_mark",
3085 resource = %resource_path,
3086 commit = %mark.git.commit_sha,
3087 anchored = mark.anchor.is_some(),
3088 "provenance recorded"
3089 );
3090}
3091
3092#[cfg(not(feature = "git"))]
3095#[inline]
3096async fn git_mark_write(
3097 _state: &AppState,
3098 _resource_path: &str,
3099 _agent: Option<&str>,
3100 _message: &str,
3101) {
3102}
3103
3104#[cfg(feature = "git")]
3105pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
3106 let caller = extract_pubkey(req).await?;
3107 if caller != pod_pubkey {
3108 return None;
3109 }
3110 Some(caller)
3111}
3112
3113#[cfg(feature = "git")]
3114fn git_json_err(msg: &str, status: u16) -> HttpResponse {
3115 HttpResponse::build(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
3116 .content_type("application/json")
3117 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
3118}
3119
3120#[cfg(feature = "git")]
3122#[derive(serde::Deserialize)]
3123struct GitStageBody {
3124 paths: Option<Vec<String>>,
3125 all: Option<bool>,
3126}
3127
3128#[cfg(feature = "git")]
3129#[derive(serde::Deserialize)]
3130struct GitCommitBody {
3131 message: String,
3132 author_name: Option<String>,
3133 author_email: Option<String>,
3134}
3135
3136#[cfg(feature = "git")]
3137#[derive(serde::Deserialize)]
3138struct GitBranchBody {
3139 name: String,
3140}
3141
3142#[cfg(feature = "git")]
3145async fn handle_git_status(
3146 path: web::Path<String>,
3147 req: HttpRequest,
3148 state: web::Data<AppState>,
3149) -> HttpResponse {
3150 let pubkey = path.into_inner();
3151 if require_pod_owner(&req, &pubkey).await.is_none() {
3152 return git_json_err("Authentication required", 401);
3153 }
3154 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3155 return git_json_err("Git not available (no FS backend)", 501);
3156 };
3157 match solid_pod_rs_git::api::git_status(&repo).await {
3158 Ok(s) => HttpResponse::Ok()
3159 .content_type("application/json")
3160 .body(serde_json::to_string(&s).unwrap_or_default()),
3161 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3162 }
3163}
3164
3165#[cfg(feature = "git")]
3166async fn handle_git_log(
3167 path: web::Path<String>,
3168 req: HttpRequest,
3169 state: web::Data<AppState>,
3170 query: web::Query<std::collections::HashMap<String, String>>,
3171) -> HttpResponse {
3172 let pubkey = path.into_inner();
3173 if require_pod_owner(&req, &pubkey).await.is_none() {
3174 return git_json_err("Authentication required", 401);
3175 }
3176 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3177 return git_json_err("Git not available (no FS backend)", 501);
3178 };
3179 let limit: u32 = query
3180 .get("limit")
3181 .and_then(|v| v.parse().ok())
3182 .unwrap_or(20);
3183 match solid_pod_rs_git::api::git_log(&repo, limit).await {
3184 Ok(entries) => HttpResponse::Ok()
3185 .content_type("application/json")
3186 .body(serde_json::to_string(&entries).unwrap_or_default()),
3187 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3188 }
3189}
3190
3191#[cfg(feature = "git")]
3192async fn handle_git_diff(
3193 path: web::Path<String>,
3194 req: HttpRequest,
3195 state: web::Data<AppState>,
3196 query: web::Query<std::collections::HashMap<String, String>>,
3197) -> HttpResponse {
3198 let pubkey = path.into_inner();
3199 if require_pod_owner(&req, &pubkey).await.is_none() {
3200 return git_json_err("Authentication required", 401);
3201 }
3202 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3203 return git_json_err("Git not available (no FS backend)", 501);
3204 };
3205 let file_path = query.get("path").map(String::as_str);
3206 let staged = query
3207 .get("staged")
3208 .map(|v| v == "true" || v == "1")
3209 .unwrap_or(false);
3210 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
3211 Ok(diff) => HttpResponse::Ok().content_type("text/plain").body(diff),
3212 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3213 }
3214}
3215
3216#[cfg(feature = "git")]
3217async fn handle_git_stage(
3218 path: web::Path<String>,
3219 req: HttpRequest,
3220 state: web::Data<AppState>,
3221 body: web::Bytes,
3222) -> HttpResponse {
3223 let pubkey = path.into_inner();
3224 if require_pod_owner(&req, &pubkey).await.is_none() {
3225 return git_json_err("Authentication required", 401);
3226 }
3227 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3228 return git_json_err("Git not available (no FS backend)", 501);
3229 };
3230 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3231 Ok(v) => v,
3232 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3233 };
3234 let paths = parsed.paths.unwrap_or_default();
3235 let all = parsed.all.unwrap_or(false);
3236 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
3237 Ok(()) => HttpResponse::Ok()
3238 .content_type("application/json")
3239 .body(r#"{"ok":true}"#),
3240 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3241 }
3242}
3243
3244#[cfg(feature = "git")]
3245async fn handle_git_unstage(
3246 path: web::Path<String>,
3247 req: HttpRequest,
3248 state: web::Data<AppState>,
3249 body: web::Bytes,
3250) -> HttpResponse {
3251 let pubkey = path.into_inner();
3252 if require_pod_owner(&req, &pubkey).await.is_none() {
3253 return git_json_err("Authentication required", 401);
3254 }
3255 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3256 return git_json_err("Git not available (no FS backend)", 501);
3257 };
3258 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3259 Ok(v) => v,
3260 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3261 };
3262 let paths = parsed.paths.unwrap_or_default();
3263 let all = parsed.all.unwrap_or(false);
3264 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
3265 Ok(()) => HttpResponse::Ok()
3266 .content_type("application/json")
3267 .body(r#"{"ok":true}"#),
3268 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3269 }
3270}
3271
3272#[cfg(feature = "git")]
3273async fn handle_git_commit(
3274 path: web::Path<String>,
3275 req: HttpRequest,
3276 state: web::Data<AppState>,
3277 body: web::Bytes,
3278) -> HttpResponse {
3279 let pubkey = path.into_inner();
3280 if require_pod_owner(&req, &pubkey).await.is_none() {
3281 return git_json_err("Authentication required", 401);
3282 }
3283 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3284 return git_json_err("Git not available (no FS backend)", 501);
3285 };
3286 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
3287 Ok(v) => v,
3288 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3289 };
3290 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
3291 let author_email = parsed
3292 .author_email
3293 .as_deref()
3294 .unwrap_or("pod@dreamlab-ai.com");
3295 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email).await
3296 {
3297 Ok(result) => HttpResponse::Ok()
3298 .content_type("application/json")
3299 .body(serde_json::to_string(&result).unwrap_or_default()),
3300 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3301 }
3302}
3303
3304#[cfg(feature = "git")]
3305async fn handle_git_branches(
3306 path: web::Path<String>,
3307 req: HttpRequest,
3308 state: web::Data<AppState>,
3309) -> HttpResponse {
3310 let pubkey = path.into_inner();
3311 if require_pod_owner(&req, &pubkey).await.is_none() {
3312 return git_json_err("Authentication required", 401);
3313 }
3314 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3315 return git_json_err("Git not available (no FS backend)", 501);
3316 };
3317 match solid_pod_rs_git::api::git_branches(&repo).await {
3318 Ok(info) => HttpResponse::Ok()
3319 .content_type("application/json")
3320 .body(serde_json::to_string(&info).unwrap_or_default()),
3321 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3322 }
3323}
3324
3325#[cfg(feature = "git")]
3326async fn handle_git_create_branch(
3327 path: web::Path<String>,
3328 req: HttpRequest,
3329 state: web::Data<AppState>,
3330 body: web::Bytes,
3331) -> HttpResponse {
3332 let pubkey = path.into_inner();
3333 if require_pod_owner(&req, &pubkey).await.is_none() {
3334 return git_json_err("Authentication required", 401);
3335 }
3336 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3337 return git_json_err("Git not available (no FS backend)", 501);
3338 };
3339 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
3340 Ok(v) => v,
3341 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3342 };
3343 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
3344 Ok(()) => HttpResponse::Ok()
3345 .content_type("application/json")
3346 .body(r#"{"ok":true}"#),
3347 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3348 }
3349}
3350
3351#[cfg(feature = "git")]
3352async fn handle_git_discard(
3353 path: web::Path<String>,
3354 req: HttpRequest,
3355 state: web::Data<AppState>,
3356 body: web::Bytes,
3357) -> HttpResponse {
3358 let pubkey = path.into_inner();
3359 if require_pod_owner(&req, &pubkey).await.is_none() {
3360 return git_json_err("Authentication required", 401);
3361 }
3362 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3363 return git_json_err("Git not available (no FS backend)", 501);
3364 };
3365 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3366 Ok(v) => v,
3367 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3368 };
3369 let paths = parsed.paths.unwrap_or_default();
3370 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
3371 Ok(()) => HttpResponse::Ok()
3372 .content_type("application/json")
3373 .body(r#"{"ok":true}"#),
3374 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3375 }
3376}
3377
3378async fn handle_git_panel_options(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
3386 let origin = req
3387 .headers()
3388 .get(header::ORIGIN)
3389 .and_then(|v| v.to_str().ok())
3390 .map(str::to_string);
3391
3392 let mut rsp = HttpResponse::NoContent().finish();
3393 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
3394 rsp
3395}
3396
3397async fn handle_admin_provision(
3408 req: HttpRequest,
3409 state: web::Data<AppState>,
3410 path: web::Path<String>,
3411) -> HttpResponse {
3412 let expected = match &state.admin_key {
3414 Some(k) => k.clone(),
3415 None => {
3416 return HttpResponse::Forbidden().json(serde_json::json!({
3417 "error": "admin key not configured on this server"
3418 }));
3419 }
3420 };
3421 let provided = req
3422 .headers()
3423 .get("x-pod-admin-key")
3424 .and_then(|v| v.to_str().ok())
3425 .unwrap_or("");
3426 use subtle::ConstantTimeEq;
3431 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
3432 if !bool::from(key_match) {
3433 return HttpResponse::Forbidden().json(serde_json::json!({"error": "invalid admin key"}));
3434 }
3435
3436 let pubkey = path.into_inner();
3438 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
3439 return HttpResponse::BadRequest()
3440 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
3441 }
3442
3443 let data_root = match &state.data_root {
3445 Some(r) => r.clone(),
3446 None => {
3447 return HttpResponse::InternalServerError().json(serde_json::json!({
3448 "error": "server has no fs-backend storage configured"
3449 }));
3450 }
3451 };
3452
3453 let pod_dir = data_root.join(&pubkey);
3454
3455 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
3457 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
3458 return HttpResponse::InternalServerError()
3459 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
3460 }
3461
3462 let acl_content = format!(
3464 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
3465 <#owner> a acl:Authorization ;\n\
3466 acl:agent <did:nostr:{pubkey}> ;\n\
3467 acl:accessTo <./> ;\n\
3468 acl:default <./> ;\n\
3469 acl:mode acl:Read, acl:Write, acl:Control .\n"
3470 );
3471 let acl_path = pod_dir.join(".acl");
3472 if !acl_path.exists() {
3473 if let Err(e) = tokio::fs::write(&acl_path, acl_content.as_bytes()).await {
3474 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write .acl failed");
3475 return HttpResponse::InternalServerError()
3476 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
3477 }
3478 }
3479
3480 #[cfg(feature = "git")]
3482 {
3483 use tokio::process::Command;
3484
3485 if !pod_dir.join(".git").exists() {
3487 let init_out = Command::new("git")
3488 .args(["init", "-b", "main", pod_dir.to_str().unwrap_or(".")])
3489 .output()
3490 .await;
3491
3492 match init_out {
3493 Ok(out) if out.status.success() => {}
3494 Ok(out) => {
3495 let stderr = String::from_utf8_lossy(&out.stderr);
3496 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
3497 }
3498 Err(e) => {
3499 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
3500 }
3501 }
3502
3503 let cfg_out = Command::new("git")
3506 .args([
3507 "-C",
3508 pod_dir.to_str().unwrap_or("."),
3509 "config",
3510 "receive.denyCurrentBranch",
3511 "updateInstead",
3512 ])
3513 .output()
3514 .await;
3515
3516 if let Err(e) = cfg_out {
3517 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
3518 }
3519 }
3520 }
3521
3522 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
3524 HttpResponse::Ok().json(serde_json::json!({
3525 "podUrl": format!("{base_url}/pods/{pubkey}/"),
3526 "ok": true,
3527 }))
3528}
3529
3530async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
3535 let Some(ref data_root) = state.data_root else {
3536 return HttpResponse::Ok()
3537 .content_type("application/json")
3538 .json(serde_json::json!({"apps": [], "count": 0}));
3539 };
3540
3541 let server_url = state.nodeinfo.base_url.clone();
3542
3543 let mut read_dir = match tokio::fs::read_dir(data_root).await {
3545 Ok(rd) => rd,
3546 Err(_) => {
3547 return HttpResponse::Ok()
3548 .content_type("application/json")
3549 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
3550 }
3551 };
3552
3553 let mut apps: Vec<serde_json::Value> = Vec::new();
3554 let mut scanned = 0usize;
3555
3556 while scanned < 1000 {
3557 let entry = match read_dir.next_entry().await {
3558 Ok(Some(e)) => e,
3559 Ok(None) => break,
3560 Err(_) => break,
3561 };
3562
3563 let file_type = match entry.file_type().await {
3564 Ok(ft) => ft,
3565 Err(_) => continue,
3566 };
3567 if !file_type.is_dir() {
3568 continue;
3569 }
3570
3571 scanned += 1;
3572
3573 let manifest_path = entry.path().join("apps").join("manifest.json");
3574 let contents = match tokio::fs::read(&manifest_path).await {
3575 Ok(c) => c,
3576 Err(_) => continue,
3577 };
3578
3579 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
3580 Ok(v) => v,
3581 Err(_) => continue,
3582 };
3583
3584 if let Some(pod_name) = entry.file_name().to_str() {
3586 if manifest.get("podOwner").is_none() {
3587 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
3588 }
3589 }
3590
3591 apps.push(manifest);
3592 }
3593
3594 let count = apps.len();
3595 HttpResponse::Ok()
3596 .content_type("application/json")
3597 .json(serde_json::json!({
3598 "apps": apps,
3599 "serverUrl": server_url,
3600 "count": count,
3601 }))
3602}
3603
3604#[allow(dead_code)]
3617fn is_git_request(path: &str) -> bool {
3618 path.contains("/info/refs")
3619 || path.contains("/git-upload-pack")
3620 || path.contains("/git-receive-pack")
3621}
3622
3623#[allow(dead_code)]
3626fn is_dot_git_path(path: &str) -> bool {
3627 path.contains("/.git/") || path.ends_with("/.git")
3628}
3629
3630#[cfg(feature = "git")]
3631async fn handle_git(
3632 req: HttpRequest,
3633 body: web::Bytes,
3634 state: web::Data<AppState>,
3635) -> HttpResponse {
3636 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
3637 use solid_pod_rs_git::service::{GitHttpService, GitRequest};
3638
3639 let path = req.uri().path().to_string();
3640
3641 let pod_name = path
3644 .trim_start_matches('/')
3645 .split('/')
3646 .next()
3647 .unwrap_or("")
3648 .to_string();
3649 let Some(ref data_root) = state.data_root else {
3650 return HttpResponse::NotImplemented().json(serde_json::json!({
3651 "error": "git requires fs-backend storage",
3652 "reason": "data_root_not_configured"
3653 }));
3654 };
3655 let repo_root = data_root.join(&pod_name);
3656 if !repo_root.exists() {
3657 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
3658 }
3659
3660 let query = req.uri().query().unwrap_or("").to_string();
3661 let host_url = {
3662 let conn = req.connection_info();
3663 Some(format!("{}://{}", conn.scheme(), conn.host()))
3664 };
3665 let headers: Vec<(String, String)> = req
3666 .headers()
3667 .iter()
3668 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
3669 .collect();
3670
3671 let git_req = GitRequest {
3672 method: req.method().as_str().to_string(),
3673 path,
3674 query,
3675 headers,
3676 body,
3677 host_url,
3678 };
3679
3680 let is_write = git_req.is_write();
3692 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
3693 Ok(pk) => Some(format!("did:nostr:{pk}")),
3694 Err(_) => None,
3695 };
3696 let wac_path = format!("/{pod_name}/");
3697 let origin = req_origin(&req);
3698 let wac = if is_write {
3699 enforce_write_ctx(
3700 &state,
3701 &wac_path,
3702 AccessMode::Write,
3703 agent.as_deref(),
3704 origin,
3705 )
3706 .await
3707 } else {
3708 enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin).await
3709 };
3710 if let Err(e) = wac {
3711 return e.error_response();
3712 }
3713
3714 let service = GitHttpService::new(repo_root);
3715 match service.handle(git_req).await {
3716 Ok(git_resp) => {
3717 let mut builder = HttpResponse::build(
3718 actix_web::http::StatusCode::from_u16(git_resp.status)
3719 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3720 );
3721 for (k, v) in &git_resp.headers {
3722 builder.insert_header((k.as_str(), v.as_str()));
3723 }
3724 builder.body(git_resp.body)
3725 }
3726 Err(e) => {
3727 let status = e.status_code();
3728 HttpResponse::build(
3729 actix_web::http::StatusCode::from_u16(status)
3730 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3731 )
3732 .json(serde_json::json!({"error": e.to_string()}))
3733 }
3734 }
3735}
3736
3737#[cfg(feature = "forge")]
3746fn forge_plugin_dir(state: &AppState) -> Option<PathBuf> {
3747 state.data_root.as_ref().map(|r| r.join(".forge"))
3748}
3749
3750#[cfg(feature = "forge")]
3756struct ServerLoopback {
3757 client: reqwest::Client,
3758}
3759
3760#[cfg(feature = "forge")]
3761#[async_trait::async_trait]
3762impl solid_pod_rs_forge::LoopbackFetch for ServerLoopback {
3763 async fn get(
3764 &self,
3765 url: &str,
3766 max_bytes: usize,
3767 timeout_secs: u64,
3768 ) -> solid_pod_rs_forge::bodies::FetchResult {
3769 use solid_pod_rs_forge::bodies::FetchResult;
3770 let resp = match self
3771 .client
3772 .get(url)
3773 .timeout(Duration::from_secs(timeout_secs.max(1)))
3774 .send()
3775 .await
3776 {
3777 Ok(r) => r,
3778 Err(e) => return FetchResult::Error(e.to_string()),
3779 };
3780 let code = resp.status().as_u16();
3781 if code == 404 || code == 410 {
3782 return FetchResult::Removed;
3783 }
3784 if !resp.status().is_success() {
3785 return FetchResult::Error(format!("status {code}"));
3786 }
3787 match resp.bytes().await {
3788 Ok(b) if b.len() > max_bytes => FetchResult::TooLarge,
3789 Ok(b) => FetchResult::Body(b.to_vec()),
3790 Err(e) => FetchResult::Error(e.to_string()),
3791 }
3792 }
3793}
3794
3795#[cfg(feature = "forge")]
3801async fn handle_forge(
3802 req: HttpRequest,
3803 body: web::Bytes,
3804 state: web::Data<AppState>,
3805) -> HttpResponse {
3806 use solid_pod_rs_forge::{ForgeConfig, ForgeRequest, ForgeService};
3807
3808 let Some(plugin_dir) = forge_plugin_dir(&state) else {
3809 return HttpResponse::NotImplemented().json(serde_json::json!({
3810 "error": "forge requires fs-backend storage",
3811 "reason": "data_root_not_configured"
3812 }));
3813 };
3814
3815 let loopback: Arc<dyn solid_pod_rs_forge::LoopbackFetch> = Arc::new(ServerLoopback {
3820 client: reqwest::Client::new(),
3821 });
3822 let service = match ForgeService::new(ForgeConfig::default(), plugin_dir) {
3823 Ok(s) => s.with_loopback(loopback),
3824 Err(e) => {
3825 return HttpResponse::InternalServerError()
3826 .json(serde_json::json!({"error": e.to_string()}));
3827 }
3828 };
3829
3830 let path = req.uri().path().to_string();
3831 let query = req.uri().query().unwrap_or("").to_string();
3832 let host_url = {
3833 let conn = req.connection_info();
3834 Some(format!("{}://{}", conn.scheme(), conn.host()))
3835 };
3836 let headers: Vec<(String, String)> = req
3837 .headers()
3838 .iter()
3839 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
3840 .collect();
3841
3842 let forge_req = ForgeRequest {
3843 method: req.method().as_str().to_string(),
3844 path,
3845 query,
3846 headers,
3847 raw_body: body,
3848 host_url,
3849 };
3850
3851 let agent = service.resolve_agent(&forge_req);
3856
3857 match service.handle(forge_req, agent).await {
3858 Ok(resp) => {
3859 let mut builder = HttpResponse::build(
3860 StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3861 );
3862 for (k, v) in &resp.headers {
3863 builder.insert_header((k.as_str(), v.as_str()));
3864 }
3865 builder.body(resp.body)
3866 }
3867 Err(e) => {
3868 let status = e.status_code();
3869 HttpResponse::build(
3870 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3871 )
3872 .json(serde_json::json!({"error": e.to_string()}))
3873 }
3874 }
3875}
3876
3877pub fn build_app(
3889 state: AppState,
3890) -> App<
3891 impl actix_web::dev::ServiceFactory<
3892 ServiceRequest,
3893 Config = (),
3894 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
3895 Error = ActixError,
3896 InitError = (),
3897 >,
3898> {
3899 let body_cap = state.body_cap;
3900 let dotfiles = state.dotfiles.clone();
3901 let allowed_origins = Arc::new(state.allowed_origins.clone());
3902
3903 let mut app = App::new()
3904 .app_data(web::Data::new(state.clone()))
3905 .app_data(web::PayloadConfig::new(body_cap))
3906 .wrap(ErrorLoggingMiddleware)
3911 .wrap(CorsHeaders { allowed_origins })
3912 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
3916 .wrap(PathTraversalGuard)
3917 .wrap(DotfileGuard::new(dotfiles));
3918
3919 app = app
3925 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
3926 .route(
3927 "/.well-known/webfinger",
3928 web::get().to(handle_well_known_webfinger),
3929 )
3930 .route(
3931 "/.well-known/nodeinfo",
3932 web::get().to(handle_well_known_nodeinfo),
3933 )
3934 .route(
3935 "/.well-known/nodeinfo/2.1",
3936 web::get().to(handle_well_known_nodeinfo_2_1),
3937 );
3938
3939 #[cfg(feature = "did-nostr")]
3940 {
3941 app = app.route(
3942 "/.well-known/did/nostr/{pubkey}.json",
3943 web::get().to(handle_well_known_did_nostr),
3944 );
3945 }
3946
3947 #[cfg(feature = "nip05-endpoint")]
3952 {
3953 app = app.route(
3954 "/.well-known/nostr.json",
3955 web::get().to(handle_well_known_nip05),
3956 );
3957 }
3958
3959 #[cfg(feature = "export-jsonld")]
3964 {
3965 app = app.route("/api/exports/all", web::get().to(handle_export_all));
3966 }
3967
3968 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
3970
3971 app = app.route("/pay/.info", web::get().to(handle_pay_info));
3973
3974 app = app.configure(handlers::pay::register);
3979
3980 app = app.route("/proxy", web::get().to(handle_proxy));
3982
3983 if state.mcp_enabled {
3987 app = app.route("/mcp", web::post().to(mcp::handle_mcp)).route(
3988 "/mcp",
3989 web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options),
3990 );
3991 }
3992
3993 app = app.route(
3996 "/_admin/provision/{pubkey}",
3997 web::post().to(handle_admin_provision),
3998 );
3999
4000 app = app
4002 .route("/.pods", web::post().to(handle_create_pod))
4003 .route("/api/accounts/new", web::post().to(handle_create_account))
4004 .route("/pods/check/{name}", web::get().to(handle_pod_check))
4005 .route("/login/password", web::post().to(handle_login_password))
4006 .route(
4007 "/account/password/reset",
4008 web::post().to(handle_password_reset_request),
4009 )
4010 .route(
4011 "/account/password/change",
4012 web::post().to(handle_password_change),
4013 );
4014
4015 #[cfg(feature = "forge")]
4020 {
4021 app = app
4022 .route("/forge", web::route().to(handle_forge))
4023 .route("/forge/{tail:.*}", web::route().to(handle_forge));
4024 }
4025
4026 app = app
4031 .route(
4032 "/{tail:.*}/.git",
4034 web::route().to(|| async {
4035 HttpResponse::Forbidden()
4036 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4037 }),
4038 )
4039 .route(
4040 "/{tail:.*}/.git/{rest:.*}",
4041 web::route().to(|| async {
4042 HttpResponse::Forbidden()
4043 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4044 }),
4045 );
4046
4047 app = app.route(
4051 "/pods/{pk}/_git/{tail:.*}",
4052 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
4053 );
4054
4055 #[cfg(feature = "git")]
4056 {
4057 app = app
4059 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
4060 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
4061 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
4062
4063 app = app
4066 .route(
4067 "/pods/{pubkey}/_git/status",
4068 web::get().to(handle_git_status),
4069 )
4070 .route("/pods/{pubkey}/_git/log", web::get().to(handle_git_log))
4071 .route("/pods/{pubkey}/_git/diff", web::get().to(handle_git_diff))
4072 .route(
4073 "/pods/{pubkey}/_git/stage",
4074 web::post().to(handle_git_stage),
4075 )
4076 .route(
4077 "/pods/{pubkey}/_git/unstage",
4078 web::post().to(handle_git_unstage),
4079 )
4080 .route(
4081 "/pods/{pubkey}/_git/commit",
4082 web::post().to(handle_git_commit),
4083 )
4084 .route(
4085 "/pods/{pubkey}/_git/branches",
4086 web::get().to(handle_git_branches),
4087 )
4088 .route(
4089 "/pods/{pubkey}/_git/branch",
4090 web::post().to(handle_git_create_branch),
4091 )
4092 .route(
4093 "/pods/{pubkey}/_git/discard",
4094 web::post().to(handle_git_discard),
4095 );
4096
4097 app = app.configure(handlers::prov::register);
4104 }
4105 #[cfg(not(feature = "git"))]
4106 {
4107 let git_501 = || async {
4111 HttpResponse::NotImplemented()
4112 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
4113 };
4114 app = app
4115 .route("/{tail:.*}/info/refs", web::get().to(git_501))
4116 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
4117 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
4118 }
4119
4120 app.route("/{tail:.*}/", web::post().to(handle_post))
4123 .route("/{tail:.*}/", web::put().to(handle_put))
4124 .route("/{tail:.*}", web::get().to(handle_get))
4125 .route("/{tail:.*}", web::head().to(handle_get))
4126 .route("/{tail:.*}", web::put().to(handle_put))
4127 .route("/{tail:.*}", web::patch().to(handle_patch))
4128 .route("/{tail:.*}", web::delete().to(handle_delete))
4129 .route(
4130 "/{tail:.*}",
4131 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
4132 )
4133 .route(
4134 "/{tail:.*}",
4135 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
4136 )
4137}
4138
4139#[cfg(test)]
4144mod payment_gating_tests {
4145 use super::*;
4146 use solid_pod_rs::payments::WebLedger;
4147 use solid_pod_rs::storage::memory::MemoryBackend;
4148
4149 const PRINCIPAL: &str = "did:nostr:alice";
4150
4151 const PAID_WRITE_ACL: &str = r#"
4154@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4155
4156<#paid-write> a acl:Authorization ;
4157 acl:agent <did:nostr:alice> ;
4158 acl:accessTo </premium/inbox> ;
4159 acl:mode acl:Write ;
4160 acl:condition [
4161 a acl:PaymentCondition ;
4162 acl:costSats 100
4163 ] .
4164"#;
4165
4166 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
4167 let mut ledger = WebLedger::new("Test Pod Credits");
4168 if sats > 0 {
4169 ledger.credit(did, sats);
4170 }
4171 let body = serde_json::to_vec(&ledger).unwrap();
4172 storage
4173 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
4174 .await
4175 .unwrap();
4176 }
4177
4178 async fn seed_acl(storage: &dyn Storage) {
4179 storage
4180 .put(
4181 "/premium/inbox.acl",
4182 Bytes::from(PAID_WRITE_ACL),
4183 "text/turtle",
4184 )
4185 .await
4186 .unwrap();
4187 }
4188
4189 #[actix_web::test]
4191 async fn resolve_balance_reads_ledger_entry() {
4192 let storage = MemoryBackend::new();
4193 seed_ledger(&storage, PRINCIPAL, 250).await;
4194 assert_eq!(
4195 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
4196 Some(250)
4197 );
4198 }
4199
4200 #[actix_web::test]
4202 async fn resolve_balance_zero_when_no_entry() {
4203 let storage = MemoryBackend::new();
4204 seed_ledger(&storage, "did:nostr:bob", 500).await;
4205 assert_eq!(
4206 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
4207 Some(0)
4208 );
4209 }
4210
4211 #[actix_web::test]
4213 async fn resolve_balance_none_when_anonymous() {
4214 let storage = MemoryBackend::new();
4215 seed_ledger(&storage, PRINCIPAL, 1_000).await;
4216 assert_eq!(resolve_balance_sats(&storage, None).await, None);
4217 }
4218
4219 #[actix_web::test]
4221 async fn paid_write_denied_below_balance() {
4222 let storage = Arc::new(MemoryBackend::new());
4223 seed_acl(storage.as_ref()).await;
4224 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
4226
4227 let result =
4228 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4229 assert!(
4230 result.is_err(),
4231 "balance 50 < cost 100 must be denied — sat-gating loop closed"
4232 );
4233 }
4234
4235 #[actix_web::test]
4237 async fn paid_write_allowed_at_balance() {
4238 let storage = Arc::new(MemoryBackend::new());
4239 seed_acl(storage.as_ref()).await;
4240 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
4242
4243 let result =
4244 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4245 assert!(
4246 result.is_ok(),
4247 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
4248 );
4249 }
4250
4251 #[actix_web::test]
4253 async fn paid_write_allowed_above_balance() {
4254 let storage = Arc::new(MemoryBackend::new());
4255 seed_acl(storage.as_ref()).await;
4256 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
4257 let state = AppState::new(storage);
4258
4259 let result =
4260 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4261 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
4262 }
4263
4264 #[actix_web::test]
4268 async fn paid_write_anonymous_denied() {
4269 let storage = Arc::new(MemoryBackend::new());
4270 seed_acl(storage.as_ref()).await;
4271 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
4272 let state = AppState::new(storage);
4273
4274 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
4275 assert!(
4276 result.is_err(),
4277 "anonymous caller has no ledger principal — PaymentCondition fails closed"
4278 );
4279 }
4280
4281 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
4288 let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
4289 let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
4290 ledger.get_balance(did)
4291 }
4292
4293 #[actix_web::test]
4295 async fn paid_write_debits_ledger() {
4296 let storage = Arc::new(MemoryBackend::new());
4297 seed_acl(storage.as_ref()).await;
4298 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
4300
4301 let result =
4302 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4303 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
4304 assert_eq!(
4305 read_balance(storage.as_ref(), PRINCIPAL).await,
4306 150,
4307 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
4308 );
4309 }
4310
4311 #[actix_web::test]
4314 async fn paid_write_debits_each_grant() {
4315 let storage = Arc::new(MemoryBackend::new());
4316 seed_acl(storage.as_ref()).await;
4317 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
4319
4320 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
4321 .await
4322 .unwrap();
4323 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
4324 .await
4325 .unwrap();
4326 assert_eq!(
4327 read_balance(storage.as_ref(), PRINCIPAL).await,
4328 50,
4329 "250 - 2*100: each granted request debits, no unmetered re-use"
4330 );
4331
4332 let third =
4334 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4335 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
4336 assert_eq!(
4337 read_balance(storage.as_ref(), PRINCIPAL).await,
4338 50,
4339 "a denied request must not debit"
4340 );
4341 }
4342
4343 #[actix_web::test]
4345 async fn paid_read_debits_ledger() {
4346 const PAID_READ_ACL: &str = r#"
4347@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4348
4349<#paid-read> a acl:Authorization ;
4350 acl:agent <did:nostr:alice> ;
4351 acl:accessTo </premium/feed> ;
4352 acl:mode acl:Read ;
4353 acl:condition [
4354 a acl:PaymentCondition ;
4355 acl:costSats 30
4356 ] .
4357"#;
4358 let storage = Arc::new(MemoryBackend::new());
4359 storage
4360 .put(
4361 "/premium/feed.acl",
4362 Bytes::from(PAID_READ_ACL),
4363 "text/turtle",
4364 )
4365 .await
4366 .unwrap();
4367 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
4368 let state = AppState::new(storage.clone());
4369
4370 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
4371 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
4372 assert_eq!(
4373 read_balance(storage.as_ref(), PRINCIPAL).await,
4374 70,
4375 "100 - 30 cost: a granted paid read must debit"
4376 );
4377 }
4378
4379 #[actix_web::test]
4382 async fn free_read_does_not_debit() {
4383 let storage = Arc::new(MemoryBackend::new());
4384 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
4386 let state = AppState::new(storage.clone());
4387
4388 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
4389 .await
4390 .unwrap();
4391 assert_eq!(
4392 read_balance(storage.as_ref(), PRINCIPAL).await,
4393 100,
4394 "a grant with no PaymentCondition must not debit"
4395 );
4396 }
4397
4398 const ALICE_ONLY_READ_ACL: &str = r#"
4404@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4405
4406<#alice> a acl:Authorization ;
4407 acl:agent <did:nostr:alice> ;
4408 acl:accessTo </private/secret> ;
4409 acl:default </private/> ;
4410 acl:mode acl:Read, acl:Write, acl:Control .
4411"#;
4412
4413 async fn seed_private_read_acl(storage: &dyn Storage) {
4414 storage
4419 .put(
4420 "/private.acl",
4421 Bytes::from(ALICE_ONLY_READ_ACL),
4422 "text/turtle",
4423 )
4424 .await
4425 .unwrap();
4426 }
4427
4428 #[actix_web::test]
4432 async fn enforce_read_grants_owner() {
4433 let storage = Arc::new(MemoryBackend::new());
4434 seed_private_read_acl(storage.as_ref()).await;
4435 let state = AppState::new(storage);
4436 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
4437 assert!(result.is_ok(), "owner alice must be granted Read");
4438 }
4439
4440 #[actix_web::test]
4443 async fn enforce_read_denies_other_principal() {
4444 let storage = Arc::new(MemoryBackend::new());
4445 seed_private_read_acl(storage.as_ref()).await;
4446 let state = AppState::new(storage);
4447 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
4448 assert!(
4449 result.is_err(),
4450 "bob has no Read grant — private resource must not be world-readable"
4451 );
4452 }
4453
4454 #[actix_web::test]
4457 async fn enforce_read_denies_anonymous() {
4458 let storage = Arc::new(MemoryBackend::new());
4459 seed_private_read_acl(storage.as_ref()).await;
4460 let state = AppState::new(storage);
4461 let result = enforce_read(&state, "/private/secret", None).await;
4462 assert!(result.is_err(), "anonymous Read must be denied");
4463 }
4464
4465 const WRITE_NOT_CONTROL_ACL: &str = r#"
4473@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4474
4475<#owner> a acl:Authorization ;
4476 acl:agent <did:nostr:alice> ;
4477 acl:accessTo </shared/doc> ;
4478 acl:default </shared/> ;
4479 acl:mode acl:Read, acl:Write, acl:Control .
4480
4481<#writer> a acl:Authorization ;
4482 acl:agent <did:nostr:writer> ;
4483 acl:accessTo </shared/doc> ;
4484 acl:default </shared/> ;
4485 acl:mode acl:Read, acl:Write .
4486"#;
4487
4488 async fn seed_shared_acl(storage: &dyn Storage) {
4489 storage
4494 .put(
4495 "/shared.acl",
4496 Bytes::from(WRITE_NOT_CONTROL_ACL),
4497 "text/turtle",
4498 )
4499 .await
4500 .unwrap();
4501 }
4502
4503 #[actix_web::test]
4507 async fn acl_put_denied_for_writer_without_control() {
4508 let storage = Arc::new(MemoryBackend::new());
4509 seed_shared_acl(storage.as_ref()).await;
4510 let state = AppState::new(storage);
4511 let result = enforce_write(
4515 &state,
4516 "/shared/.acl",
4517 AccessMode::Write,
4518 Some("did:nostr:writer"),
4519 )
4520 .await;
4521 assert!(
4522 result.is_err(),
4523 "writer lacks Control — must not be able to PUT /shared/.acl"
4524 );
4525 }
4526
4527 #[actix_web::test]
4529 async fn acl_put_allowed_for_control_holder() {
4530 let storage = Arc::new(MemoryBackend::new());
4531 seed_shared_acl(storage.as_ref()).await;
4532 let state = AppState::new(storage);
4533 let result =
4534 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
4535 assert!(
4536 result.is_ok(),
4537 "alice holds Control — must be allowed to PUT /shared/.acl"
4538 );
4539 }
4540
4541 #[actix_web::test]
4543 async fn meta_put_denied_for_writer_without_control() {
4544 let storage = Arc::new(MemoryBackend::new());
4545 seed_shared_acl(storage.as_ref()).await;
4546 let state = AppState::new(storage);
4547 let result = enforce_write(
4548 &state,
4549 "/shared/doc.meta",
4550 AccessMode::Write,
4551 Some("did:nostr:writer"),
4552 )
4553 .await;
4554 assert!(
4555 result.is_err(),
4556 "writer lacks Control — must not be able to PUT a .meta sidecar"
4557 );
4558 }
4559
4560 #[test]
4562 fn protected_resource_for_acl_strips_suffixes() {
4563 assert_eq!(
4564 protected_resource_for_acl("/victim/.acl").as_deref(),
4565 Some("/victim/")
4566 );
4567 assert_eq!(
4568 protected_resource_for_acl("/a/b.acl").as_deref(),
4569 Some("/a/b")
4570 );
4571 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
4572 assert_eq!(
4573 protected_resource_for_acl("/a/b.meta").as_deref(),
4574 Some("/a/b")
4575 );
4576 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
4577 }
4578}