1#![doc = include_str!("../README.md")]
107#![deny(unsafe_code)]
108#![warn(rust_2018_idioms)]
109
110pub mod cli;
112
113mod handlers;
118
119#[cfg(feature = "git")]
121mod git_push;
122
123mod mcp;
126
127pub mod mempool;
132
133pub mod trail_store;
139
140use std::collections::HashMap;
141use std::net::{IpAddr, Ipv4Addr};
142use std::path::{Path, PathBuf};
143use std::sync::{Arc, Mutex};
144use std::time::{Duration, Instant};
145
146use actix_web::body::{BoxBody, EitherBody};
147use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
148use actix_web::http::{header, StatusCode};
149use actix_web::middleware::{NormalizePath, TrailingSlash};
150use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
151use bytes::Bytes;
152use futures_util::future::{ready, LocalBoxFuture, Ready};
153use futures_util::StreamExt;
154use percent_encoding::percent_decode_str;
155use serde::Deserialize;
156use solid_pod_rs::{
157 auth::{nip98, replay::ReplayStore},
161 config::sources::parse_size,
162 interop,
163 ldp::{
164 self, cache_control_for_response, LdpContainerOps, PatchCreateOutcome, ResponseAudience,
165 },
166 mashlib::{self, MashlibConfig},
167 provenance::{ProvenanceReceipt, ProvenanceSkip},
168 security::DotfileAllowlist,
169 storage::Storage,
170 wac::{
171 self, conditions::RequestContext, effective_acl_target, parse_jsonld_acl,
172 parser::parse_turtle_acl, protected_resource_for_acl, AccessMode,
173 },
174 PodError,
175};
176
177const _: () = solid_pod_rs::auth::nip98::assert_schnorr_verification_enabled();
189
190static NIP98_REPLAY: std::sync::LazyLock<solid_pod_rs::auth::replay::Nip98ReplayCache> =
197 std::sync::LazyLock::new(solid_pod_rs::auth::replay::Nip98ReplayCache::from_env);
198
199pub(crate) static PAYMENT_STATE_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
205 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
206
207#[derive(Clone)]
213pub struct AppState {
214 pub storage: Arc<dyn Storage>,
215 pub dotfiles: Arc<DotfileAllowlist>,
216 pub body_cap: usize,
217 pub nodeinfo: NodeInfoMeta,
218 pub mashlib: MashlibConfig,
219 pub mashlib_cdn: Option<String>,
222 pub live_reload: bool,
224 pub pay_config: solid_pod_rs::payments::PayConfig,
227 pub data_root: Option<PathBuf>,
232 pub quota: Option<Arc<dyn solid_pod_rs::quota::QuotaPolicy>>,
234 pub pod_create_limiter: Arc<PodCreateLimiter>,
236 pub allowed_origins: Vec<String>,
243 pub admin_key: Option<String>,
248 pub mcp_enabled: bool,
253 pub mempool_url: Option<String>,
259}
260
261#[cfg(test)]
262mod nip98_request_binding_tests {
263 use super::*;
264 use actix_web::test::TestRequest;
265
266 const SECRET: &str = "3333333333333333333333333333333333333333333333333333333333333333";
267
268 fn now() -> u64 {
269 std::time::SystemTime::now()
270 .duration_since(std::time::UNIX_EPOCH)
271 .unwrap()
272 .as_secs()
273 }
274
275 #[actix_web::test]
276 async fn authentication_binds_the_complete_query_string() {
277 let signed_url = "http://localhost:8080/private?include=true";
278 let token = nip98::mint(signed_url, "GET", SECRET, now()).unwrap();
279 let request = TestRequest::get()
280 .uri("/private?include=true")
281 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
282 .to_http_request();
283 assert!(extract_pubkey(&request).await.is_some());
284
285 let path_only = nip98::mint("http://localhost:8080/private", "GET", SECRET, now()).unwrap();
286 let request = TestRequest::get()
287 .uri("/private?include=true")
288 .insert_header((header::AUTHORIZATION, format!("Nostr {path_only}")))
289 .to_http_request();
290 assert!(extract_pubkey(&request).await.is_none());
291 }
292
293 #[actix_web::test]
294 async fn authentication_binds_the_exact_raw_body() {
295 let expected = br#"{"value":"authorised"}"#;
296 let token = nip98::mint_with_payload(
297 "http://localhost:8080/resource",
298 "PUT",
299 Some(expected),
300 SECRET,
301 now(),
302 )
303 .unwrap();
304 let request = TestRequest::put()
305 .uri("/resource")
306 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
307 .to_http_request();
308
309 assert!(
310 extract_pubkey_with_body(&request, Some(br#"{"value":"tampered"}"#))
311 .await
312 .is_none()
313 );
314 assert!(extract_pubkey_with_body(&request, Some(expected))
315 .await
316 .is_some());
317 }
318}
319
320#[derive(Clone, Debug)]
322pub struct NodeInfoMeta {
323 pub software_name: String,
324 pub software_version: String,
325 pub open_registrations: bool,
326 pub total_users: u64,
327 pub base_url: String,
328}
329
330impl Default for NodeInfoMeta {
331 fn default() -> Self {
332 Self {
333 software_name: "solid-pod-rs-server".to_string(),
334 software_version: env!("CARGO_PKG_VERSION").to_string(),
335 open_registrations: false,
336 total_users: 0,
337 base_url: "http://localhost".to_string(),
338 }
339 }
340}
341
342pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
345
346pub fn body_cap_from_env() -> usize {
351 match std::env::var("JSS_MAX_REQUEST_BODY").or_else(|_| std::env::var("JSS_BODY_LIMIT")) {
352 Ok(v) => parse_size(&v)
353 .map(|u| u as usize)
354 .unwrap_or(DEFAULT_BODY_CAP),
355 Err(_) => DEFAULT_BODY_CAP,
356 }
357}
358
359impl AppState {
360 pub fn new(storage: Arc<dyn Storage>) -> Self {
363 Self {
364 storage,
365 dotfiles: Arc::new(DotfileAllowlist::from_env()),
366 body_cap: body_cap_from_env(),
367 nodeinfo: NodeInfoMeta::default(),
368 mashlib: MashlibConfig::default(),
369 mashlib_cdn: None,
370 live_reload: false,
371 pay_config: solid_pod_rs::payments::PayConfig::default(),
372 data_root: None,
373 quota: None,
374 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
375 allowed_origins: Vec::new(),
376 admin_key: None,
377 mcp_enabled: false,
378 mempool_url: None,
379 }
380 }
381}
382
383const LIVE_RELOAD_SCRIPT: &str = "<script>(function(){var ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/.notifications');ws.onopen=function(){ws.send('sub '+location.href)};ws.onmessage=function(e){if(e.data.startsWith('pub '))location.reload()};ws.onclose=function(){setTimeout(function(){location.reload()},1000)}})();</script>";
384
385fn inject_live_reload(html: impl AsRef<[u8]>, enabled: bool) -> Vec<u8> {
386 let bytes = html.as_ref();
387 if !enabled {
388 return bytes.to_vec();
389 }
390 let html = String::from_utf8_lossy(bytes);
391 if let Some(index) = html.find("</body>") {
392 let mut out = String::with_capacity(html.len() + LIVE_RELOAD_SCRIPT.len());
393 out.push_str(&html[..index]);
394 out.push_str(LIVE_RELOAD_SCRIPT);
395 out.push_str(&html[index..]);
396 out.into_bytes()
397 } else {
398 format!("{html}{LIVE_RELOAD_SCRIPT}").into_bytes()
399 }
400}
401
402#[cfg(test)]
403mod live_reload_tests {
404 use super::*;
405
406 #[test]
407 fn injects_before_body_close_and_is_disabled_by_default() {
408 let html = b"<html><body>ok</body></html>";
409 assert_eq!(inject_live_reload(html, false), html);
410 let injected = String::from_utf8(inject_live_reload(html, true)).unwrap();
411 assert!(injected.contains("/.notifications"));
412 assert!(injected.find("<script>").unwrap() < injected.find("</body>").unwrap());
413 }
414}
415
416#[cfg(test)]
417mod dev_bearer_tests {
418 use super::*;
419 use base64::Engine as _;
420 use hmac::{Hmac, Mac};
421 use sha2::Sha256;
422 use std::sync::Mutex;
423
424 static ENV: Mutex<()> = Mutex::new(());
425
426 fn token(exp: u64) -> String {
427 let data = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
428 serde_json::json!({"webId":"https://alice.example/profile/card#me","iat":1,"exp":exp})
429 .to_string(),
430 );
431 let mut mac = Hmac::<Sha256>::new_from_slice(b"01234567890123456789012345678901").unwrap();
432 mac.update(data.as_bytes());
433 let sig =
434 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
435 format!("{data}.{sig}")
436 }
437
438 #[test]
439 fn simple_bearer_verifies_signature_and_expiry() {
440 let _guard = ENV.lock().unwrap();
441 std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
442 assert_eq!(
443 verify_dev_bearer(&token(u64::MAX)).as_deref(),
444 Some("https://alice.example/profile/card#me")
445 );
446 assert!(verify_dev_bearer(&(token(u64::MAX) + "x")).is_none());
447 assert!(verify_dev_bearer(&token(2)).is_none());
448 std::env::remove_var("TOKEN_SECRET");
449 }
450}
451
452#[derive(Debug)]
454pub struct PodCreateLimiter {
455 hits: Mutex<HashMap<IpAddr, Instant>>,
456 window: Duration,
457}
458
459impl Default for PodCreateLimiter {
460 fn default() -> Self {
461 Self {
462 hits: Mutex::new(HashMap::new()),
463 window: Duration::from_secs(24 * 60 * 60),
464 }
465 }
466}
467
468impl PodCreateLimiter {
469 fn check(&self, ip: IpAddr) -> Result<(), u64> {
470 let now = Instant::now();
471 let mut hits = self.hits.lock().unwrap();
472 if let Some(last) = hits.get(&ip).copied() {
473 let elapsed = now.saturating_duration_since(last);
474 if elapsed < self.window {
475 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
476 }
477 }
478 hits.insert(ip, now);
479 Ok(())
480 }
481}
482
483pub(crate) fn to_actix(e: PodError) -> ActixError {
488 match e {
489 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
490 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
491 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
492 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
493 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
494 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
495 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
496 }
497}
498
499pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
511 extract_pubkey_with_body(req, None).await
512}
513
514pub(crate) async fn extract_pubkey_with_body(
518 req: &HttpRequest,
519 body: Option<&[u8]>,
520) -> Option<String> {
521 let header_val = req
522 .headers()
523 .get(header::AUTHORIZATION)
524 .and_then(|v| v.to_str().ok())?;
525 if let Some(token) = header_val.strip_prefix("Bearer ") {
526 return verify_dev_bearer(token);
527 }
528 let url = {
539 let conn = req.connection_info();
540 format!("{}://{}{}", conn.scheme(), conn.host(), req.uri())
541 };
542 let now = std::time::SystemTime::now()
543 .duration_since(std::time::UNIX_EPOCH)
544 .map(|d| d.as_secs())
545 .unwrap_or(0);
546 let verified = nip98::verify_at(header_val, &url, req.method().as_str(), body, now).ok()?;
547
548 if NIP98_REPLAY
552 .check_and_record(&verified.event_id)
553 .await
554 .is_err()
555 {
556 tracing::warn!(
557 pubkey = %verified.pubkey,
558 method = %req.method(),
559 "NIP-98 replay rejected: token id already used within window"
560 );
561 return None;
562 }
563
564 Some(verified.pubkey)
565}
566
567pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
568 pubkey.map(|pk| {
569 if pk.starts_with("https://") || pk.starts_with("http://") {
570 pk.clone()
571 } else {
572 format!("did:nostr:{pk}")
573 }
574 })
575}
576
577fn verify_dev_bearer(token: &str) -> Option<String> {
578 use base64::Engine as _;
579 use hmac::{Hmac, Mac};
580 use sha2::Sha256;
581
582 let secret = std::env::var("TOKEN_SECRET").ok()?;
583 if secret.len() < 32 {
584 tracing::warn!("TOKEN_SECRET shorter than 32 bytes; refusing development bearer token");
585 return None;
586 }
587 let (data, signature) = token.split_once('.')?;
588 if signature.contains('.') {
589 return None;
590 }
591 let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
592 .decode(signature)
593 .ok()?;
594 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
595 mac.update(data.as_bytes());
596 mac.verify_slice(&signature).ok()?;
597
598 #[derive(serde::Deserialize)]
599 #[serde(rename_all = "camelCase")]
600 struct Claims {
601 web_id: String,
602 iat: u64,
603 exp: u64,
604 }
605 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
606 .decode(data)
607 .ok()?;
608 let claims: Claims = serde_json::from_slice(&payload).ok()?;
609 let now = std::time::SystemTime::now()
610 .duration_since(std::time::UNIX_EPOCH)
611 .ok()?
612 .as_secs();
613 if claims.exp < now || claims.iat > now.saturating_add(60) || claims.exp <= claims.iat {
614 return None;
615 }
616 let url = url::Url::parse(&claims.web_id).ok()?;
617 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
618 return None;
619 }
620 Some(claims.web_id)
621}
622
623fn req_origin(req: &HttpRequest) -> Option<&str> {
632 req.headers()
633 .get(header::ORIGIN)
634 .and_then(|v| v.to_str().ok())
635}
636
637pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
641
642async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
659 let did = agent_uri?;
660 let balance = match storage.get(WEBLEDGER_PATH).await {
661 Ok((bytes, _meta)) => {
662 match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
663 Ok(ledger) => ledger.get_balance(did),
664 Err(_) => 0,
668 }
669 }
670 Err(_) => 0,
673 };
674 Some(balance)
675}
676
677fn accept_includes_html(accept: &str) -> bool {
685 accept.split(',').any(|entry| {
686 let mime = entry.split(';').next().unwrap_or("").trim();
687 mime.eq_ignore_ascii_case("text/html")
688 })
689}
690
691pub(crate) fn guarded_acl_target(path: &str, mode: AccessMode) -> (String, AccessMode) {
710 let (resource, mode) = effective_acl_target(path, mode);
711 match canonical_pod_path(&resource) {
715 Some(canon)
716 if !trail_store::is_secret_trail_path(&canon)
717 && !trail_store::is_prov_state_path(&canon)
718 && !trail_store::is_server_state_path(&canon) =>
719 {
720 (resource, mode)
721 }
722 _ => (resource, AccessMode::Control),
723 }
724}
725
726pub(crate) fn canonical_pod_path(raw: &str) -> Option<String> {
734 let decoded = percent_decode_str(raw).decode_utf8().ok()?;
735 if decoded.contains(['\\', '\0']) {
736 return None;
737 }
738 let mut out = String::with_capacity(decoded.len() + 1);
739 for seg in decoded.split('/') {
740 match seg {
741 "" => {}
742 "." | ".." => return None,
743 _ => {
744 out.push('/');
745 out.push_str(seg);
746 }
747 }
748 }
749 if out.is_empty() || decoded.ends_with('/') {
750 out.push('/');
751 }
752 Some(out)
753}
754
755fn proposed_acl_keeps_caller_control(
763 body: &[u8],
764 content_type: &str,
765 caller: Option<&str>,
766) -> bool {
767 let doc = match parse_jsonld_acl(body) {
768 Ok(d) => Some(d),
769 Err(_) => {
770 let ct = content_type.to_ascii_lowercase();
771 let text = std::str::from_utf8(body).unwrap_or("");
772 let looks_turtle = ct.starts_with("text/turtle")
773 || ct.starts_with("application/turtle")
774 || ct.starts_with("application/x-turtle")
775 || ct.starts_with("application/n-triples")
776 || text.contains("@prefix")
777 || text.contains("acl:Authorization")
778 || text.contains("auth/acl#Authorization");
782 if looks_turtle {
783 parse_turtle_acl(text).ok()
784 } else {
785 None
786 }
787 }
788 };
789 let Some(doc) = doc else {
790 return true;
792 };
793 let Some(graph) = doc.graph.as_ref() else {
794 return false;
795 };
796 graph.iter().any(|auth| {
797 let grants_control = ids_of_acl_field(&auth.mode)
798 .iter()
799 .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
800 if !grants_control {
801 return false;
802 }
803 let agents = ids_of_acl_field(&auth.agent);
804 if let Some(web_id) = caller {
805 if agents.contains(&web_id) {
806 return true;
807 }
808 }
809 let classes = ids_of_acl_field(&auth.agent_class);
810 if classes
811 .iter()
812 .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
813 {
814 return true;
815 }
816 if caller.is_some()
817 && classes.iter().any(|c| {
818 *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
819 || *c == "acl:AuthenticatedAgent"
820 })
821 {
822 return true;
823 }
824 false
825 })
826}
827
828fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
830 match field {
831 None => Vec::new(),
832 Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
833 Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
834 }
835}
836
837#[cfg_attr(not(test), allow(dead_code))]
844async fn enforce_write(
845 state: &AppState,
846 path: &str,
847 mode: AccessMode,
848 agent_uri: Option<&str>,
849) -> Result<(), ActixError> {
850 enforce_write_ctx(state, path, mode, agent_uri, None).await
851}
852
853async fn enforce_write_ctx(
861 state: &AppState,
862 path: &str,
863 mode: AccessMode,
864 agent_uri: Option<&str>,
865 request_origin: Option<&str>,
866) -> Result<(), ActixError> {
867 let origin = request_origin.and_then(wac::Origin::parse);
868 let (resource, eff_mode) = guarded_acl_target(path, mode);
880
881 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
889 if outcome.is_failure() {
890 return Err(policy_failure_to_actix(&outcome, &resource));
891 }
892 let acl_doc = outcome.document().cloned();
893
894 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
899
900 let ctx = RequestContext {
901 web_id: agent_uri,
902 client_id: None,
903 issuer: None,
904 payment_balance_sats,
905 };
906 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
907 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
908 let granted = wac::evaluate_access_ctx_with_registry(
909 acl_doc.as_ref(),
910 &ctx,
911 &resource,
912 eff_mode,
913 origin.as_ref(),
914 &groups,
915 ®istry,
916 );
917 if !granted {
918 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
919 }
920 if resource.as_str() == path {
927 charge_granted_payment(
933 state,
934 acl_doc.as_ref(),
935 &ctx,
936 &resource,
937 eff_mode,
938 &groups,
939 ®istry,
940 )
941 .await?;
942 }
943 Ok(())
944}
945
946async fn charge_granted_payment(
955 state: &AppState,
956 acl_doc: Option<&wac::AclDocument>,
957 ctx: &RequestContext<'_>,
958 path: &str,
959 mode: AccessMode,
960 groups: &wac::StaticGroupMembership,
961 registry: &wac::conditions::ConditionRegistry,
962) -> Result<(), ActixError> {
963 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
964 if cost == 0 {
965 return Ok(());
966 }
967 if let Some(did) = ctx.web_id {
968 if debit_ledger(&*state.storage, did, cost).await.is_err() {
969 return Err(acl_denial(acl_doc, ctx.web_id, path));
970 }
971 }
972 Ok(())
973}
974
975fn acl_denial(
981 acl_doc: Option<&wac::AclDocument>,
982 agent_uri: Option<&str>,
983 path: &str,
984) -> ActixError {
985 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
986 let (status, body, unauthenticated) = if agent_uri.is_none() {
987 (StatusCode::UNAUTHORIZED, "authentication required", true)
988 } else {
989 (StatusCode::FORBIDDEN, "access forbidden", false)
990 };
991 let mut rsp = HttpResponse::new(status);
992 rsp.headers_mut().insert(
993 header::HeaderName::from_static("wac-allow"),
994 header::HeaderValue::from_str(&allow_header)
995 .unwrap_or(header::HeaderValue::from_static("")),
996 );
997 if unauthenticated {
998 rsp.headers_mut().insert(
1005 header::WWW_AUTHENTICATE,
1006 header::HeaderValue::from_static(
1007 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
1008 ),
1009 );
1010 }
1011 actix_web::error::InternalError::from_response(body, rsp).into()
1012}
1013
1014#[cfg_attr(not(test), allow(dead_code))]
1025async fn enforce_read(
1026 state: &AppState,
1027 path: &str,
1028 agent_uri: Option<&str>,
1029) -> Result<ResponseAudience, ActixError> {
1030 enforce_read_ctx(state, path, agent_uri, None).await
1031}
1032
1033async fn enforce_read_ctx(
1036 state: &AppState,
1037 path: &str,
1038 agent_uri: Option<&str>,
1039 request_origin: Option<&str>,
1040) -> Result<ResponseAudience, ActixError> {
1041 enforce_read_inner(state, path, agent_uri, request_origin, true).await
1042}
1043
1044async fn enforce_read_inner(
1049 state: &AppState,
1050 path: &str,
1051 agent_uri: Option<&str>,
1052 request_origin: Option<&str>,
1053 charge: bool,
1054) -> Result<ResponseAudience, ActixError> {
1055 let origin = request_origin.and_then(wac::Origin::parse);
1056 let (resource, eff_mode) = guarded_acl_target(path, AccessMode::Read);
1068 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
1071 if outcome.is_failure() {
1072 return Err(policy_failure_to_actix(&outcome, &resource));
1073 }
1074 let acl_doc = outcome.document().cloned();
1075 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
1076 let ctx = RequestContext {
1077 web_id: agent_uri,
1078 client_id: None,
1079 issuer: None,
1080 payment_balance_sats,
1081 };
1082 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
1083 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
1084 let granted = wac::evaluate_access_ctx_with_registry(
1085 acl_doc.as_ref(),
1086 &ctx,
1087 &resource,
1088 eff_mode,
1089 origin.as_ref(),
1090 &groups,
1091 ®istry,
1092 );
1093 if !granted {
1094 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1095 }
1096 if resource.as_str() == path && !charge {
1100 if wac::granted_payment_cost(
1101 acl_doc.as_ref(),
1102 &ctx,
1103 &resource,
1104 eff_mode,
1105 &groups,
1106 ®istry,
1107 ) > 0
1108 {
1109 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1110 }
1111 } else if resource.as_str() == path {
1112 charge_granted_payment(
1117 state,
1118 acl_doc.as_ref(),
1119 &ctx,
1120 &resource,
1121 eff_mode,
1122 &groups,
1123 ®istry,
1124 )
1125 .await?;
1126 }
1127 let anon_ctx = RequestContext {
1136 web_id: None,
1137 client_id: None,
1138 issuer: None,
1139 payment_balance_sats: None,
1140 };
1141 let anonymous_would_be_granted = wac::evaluate_access_ctx_with_registry(
1142 acl_doc.as_ref(),
1143 &anon_ctx,
1144 &resource,
1145 eff_mode,
1146 origin.as_ref(),
1147 &groups,
1148 ®istry,
1149 );
1150 Ok(if anonymous_would_be_granted && resource.as_str() == path {
1151 ResponseAudience::Public
1152 } else {
1153 ResponseAudience::Private
1154 })
1155}
1156
1157async fn debit_ledger(
1166 storage: &dyn Storage,
1167 did: &str,
1168 cost: u64,
1169) -> Result<(), solid_pod_rs::payments::PaymentError> {
1170 use solid_pod_rs::payments::{PaymentError, WebLedger};
1171 let _transaction = PAYMENT_STATE_LOCK.lock().await;
1172
1173 let (bytes, _meta) = storage
1174 .get(WEBLEDGER_PATH)
1175 .await
1176 .map_err(|e| PaymentError::Store(e.to_string()))?;
1177 let mut ledger: WebLedger = serde_json::from_slice(&bytes)
1178 .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
1179 let _charge = ledger.charge(did, cost)?;
1181 let body = serde_json::to_vec(&ledger)
1182 .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
1183 storage
1184 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
1185 .await
1186 .map_err(|e| PaymentError::Store(e.to_string()))?;
1187 Ok(())
1188}
1189
1190fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
1195 let links = ldp::link_headers(path).join(", ");
1196 if let Ok(value) = header::HeaderValue::from_str(&links) {
1197 rsp.headers_mut()
1198 .insert(header::HeaderName::from_static("link"), value);
1199 }
1200}
1201
1202fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
1203 if let Ok(v) = header::HeaderValue::from_str(header_value) {
1204 rsp.headers_mut()
1205 .insert(header::HeaderName::from_static("wac-allow"), v);
1206 }
1207}
1208
1209fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
1210 let ws_base = base_url
1211 .replacen("https://", "wss://", 1)
1212 .replacen("http://", "ws://", 1);
1213 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
1214 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
1215 rsp.headers_mut()
1216 .insert(header::HeaderName::from_static("updates-via"), v);
1217 }
1218}
1219
1220fn set_provenance_headers(rsp: &mut HttpResponse, receipt: &ProvenanceReceipt) {
1231 if let Ok(v) = header::HeaderValue::from_str(&receipt.summary()) {
1232 rsp.headers_mut()
1233 .insert(header::HeaderName::from_static("x-provenance"), v);
1234 }
1235 if let Some(sha) = receipt.commit_sha() {
1236 if let Ok(v) = header::HeaderValue::from_str(sha) {
1237 rsp.headers_mut()
1238 .insert(header::HeaderName::from_static("x-provenance-commit"), v);
1239 }
1240 }
1241}
1242
1243fn set_cache_policy(rsp: &mut HttpResponse, content_type: &str, audience: ResponseAudience) {
1255 if audience == ResponseAudience::Private {
1256 append_vary(rsp, "Authorization");
1260 }
1261 if rsp.headers().contains_key(header::CACHE_CONTROL) {
1262 return;
1263 }
1264 if let Some(value) = cache_control_for_response(content_type, audience) {
1265 rsp.headers_mut().insert(
1266 header::CACHE_CONTROL,
1267 header::HeaderValue::from_static(value),
1268 );
1269 }
1270}
1271
1272fn append_vary(rsp: &mut HttpResponse, field: &str) {
1274 let existing = rsp
1275 .headers()
1276 .get(header::VARY)
1277 .and_then(|v| v.to_str().ok())
1278 .unwrap_or("")
1279 .to_string();
1280 if existing
1281 .split(',')
1282 .any(|f| f.trim().eq_ignore_ascii_case(field))
1283 {
1284 return;
1285 }
1286 let merged = if existing.trim().is_empty() {
1287 field.to_string()
1288 } else {
1289 format!("{existing}, {field}")
1290 };
1291 if let Ok(v) = header::HeaderValue::from_str(&merged) {
1292 rsp.headers_mut().insert(header::VARY, v);
1293 }
1294}
1295
1296async fn handle_get(
1297 req: HttpRequest,
1298 state: web::Data<AppState>,
1299) -> Result<HttpResponse, ActixError> {
1300 let path = req.uri().path().to_string();
1301
1302 if path.contains('*') {
1303 return handle_glob_get(req, state).await;
1304 }
1305
1306 let auth_pk = extract_pubkey(&req).await;
1307 let agent = agent_uri(auth_pk.as_ref());
1308
1309 let audience = enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
1314
1315 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
1316
1317 if ldp::is_container(&path) {
1318 let accept = req
1319 .headers()
1320 .get(header::ACCEPT)
1321 .and_then(|v| v.to_str().ok())
1322 .unwrap_or("");
1323
1324 if accept_includes_html(accept) {
1330 let index_path = format!("{path}index.html");
1331 let index_audience =
1334 enforce_read_ctx(&state, &index_path, agent.as_deref(), req_origin(&req))
1335 .await
1336 .ok();
1337 if let (Some(index_audience), Ok((body, _meta))) =
1338 (index_audience, state.storage.get(&index_path).await)
1339 {
1340 let mut rsp = HttpResponse::Ok()
1341 .content_type("text/html; charset=utf-8")
1342 .body(inject_live_reload(&body, state.live_reload));
1343 set_cache_policy(&mut rsp, "text/html", index_audience);
1344 set_wac_allow(&mut rsp, &wac_allow);
1345 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1346 set_link_headers(&mut rsp, &path);
1347 return Ok(rsp);
1348 }
1349 }
1350
1351 let v = state
1352 .storage
1353 .container_representation(&path)
1354 .await
1355 .map_err(to_actix)?;
1356
1357 let sec_fetch_dest = req
1359 .headers()
1360 .get("sec-fetch-dest")
1361 .and_then(|v| v.to_str().ok());
1362 if mashlib::should_serve(
1363 accept,
1364 sec_fetch_dest,
1365 "application/ld+json",
1366 state.mashlib.enabled,
1367 ) {
1368 let json_ld = serde_json::to_string(&v).ok();
1369 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
1370 let mut rsp = HttpResponse::Ok()
1371 .content_type("text/html; charset=utf-8")
1372 .insert_header(("X-Frame-Options", "DENY"))
1373 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1374 .insert_header(("Cache-Control", "no-store"))
1375 .body(inject_live_reload(html, state.live_reload));
1376 set_cache_policy(&mut rsp, "text/html", audience);
1377 set_wac_allow(&mut rsp, &wac_allow);
1378 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1379 set_link_headers(&mut rsp, &path);
1380 return Ok(rsp);
1381 }
1382
1383 let mut rsp = HttpResponse::Ok().json(v);
1384 rsp.headers_mut().insert(
1385 header::CONTENT_TYPE,
1386 header::HeaderValue::from_static("application/ld+json"),
1387 );
1388 set_cache_policy(&mut rsp, "application/ld+json", audience);
1389 set_wac_allow(&mut rsp, &wac_allow);
1390 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1391 set_link_headers(&mut rsp, &path);
1392 return Ok(rsp);
1393 }
1394
1395 match state.storage.get(&path).await {
1396 Ok((body, meta)) => {
1397 let accept = req
1399 .headers()
1400 .get(header::ACCEPT)
1401 .and_then(|v| v.to_str().ok())
1402 .unwrap_or("");
1403 let sec_fetch_dest = req
1404 .headers()
1405 .get("sec-fetch-dest")
1406 .and_then(|v| v.to_str().ok());
1407 if mashlib::should_serve(
1408 accept,
1409 sec_fetch_dest,
1410 &meta.content_type,
1411 state.mashlib.enabled,
1412 ) {
1413 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
1414 std::str::from_utf8(&body).ok().map(|s| s.to_string())
1415 } else {
1416 None
1417 };
1418 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
1419 let mut rsp = HttpResponse::Ok()
1420 .content_type("text/html; charset=utf-8")
1421 .insert_header(("X-Frame-Options", "DENY"))
1422 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1423 .insert_header(("Cache-Control", "no-store"))
1424 .body(inject_live_reload(html, state.live_reload));
1425 set_cache_policy(&mut rsp, "text/html", audience);
1426 set_wac_allow(&mut rsp, &wac_allow);
1427 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1428 set_link_headers(&mut rsp, &path);
1429 return Ok(rsp);
1430 }
1431
1432 if let Some((negotiated_body, negotiated_ct)) =
1440 rdf_content_negotiate(&body, &meta.content_type, accept)
1441 {
1442 let mut rsp = HttpResponse::Ok().body(negotiated_body);
1443 rsp.headers_mut().insert(
1444 header::CONTENT_TYPE,
1445 header::HeaderValue::from_str(negotiated_ct)
1446 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
1447 );
1448 rsp.headers_mut()
1449 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
1450 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1451 rsp.headers_mut().insert(header::ETAG, etag);
1452 }
1453 set_cache_policy(&mut rsp, negotiated_ct, audience);
1454 set_wac_allow(&mut rsp, &wac_allow);
1455 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1456 set_link_headers(&mut rsp, &path);
1457 return Ok(rsp);
1458 }
1459
1460 let response_body = if meta.content_type.starts_with("text/html") {
1461 inject_live_reload(&body, state.live_reload)
1462 } else {
1463 body.to_vec()
1464 };
1465 let mut rsp = HttpResponse::Ok().body(response_body);
1466 rsp.headers_mut().insert(
1467 header::CONTENT_TYPE,
1468 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
1469 header::HeaderValue::from_static("application/octet-stream")
1470 }),
1471 );
1472 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1473 rsp.headers_mut().insert(header::ETAG, etag);
1474 }
1475 set_cache_policy(&mut rsp, &meta.content_type, audience);
1476 set_wac_allow(&mut rsp, &wac_allow);
1477 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1478 set_link_headers(&mut rsp, &path);
1479 Ok(rsp)
1480 }
1481 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1482 Err(e) => Err(to_actix(e)),
1483 }
1484}
1485
1486fn has_basic_container_link(req: &HttpRequest) -> bool {
1487 req.headers()
1488 .get_all(header::LINK)
1489 .filter_map(|v| v.to_str().ok())
1490 .any(|v| {
1491 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
1492 })
1493}
1494
1495async fn handle_put(
1496 req: HttpRequest,
1497 body: web::Bytes,
1498 state: web::Data<AppState>,
1499) -> Result<HttpResponse, ActixError> {
1500 let path = req.uri().path().to_string();
1501
1502 if ldp::is_container(&path) {
1503 if has_basic_container_link(&req) {
1504 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1505 let agent = agent_uri(auth_pk.as_ref());
1506 enforce_write_ctx(
1507 &state,
1508 &path,
1509 AccessMode::Write,
1510 agent.as_deref(),
1511 req_origin(&req),
1512 )
1513 .await?;
1514 let meta = state
1515 .storage
1516 .create_container(&path)
1517 .await
1518 .map_err(to_actix)?;
1519 let mut rsp = HttpResponse::Created().finish();
1520 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1521 rsp.headers_mut().insert(header::ETAG, etag);
1522 }
1523 set_link_headers(&mut rsp, &path);
1524 return Ok(rsp);
1525 }
1526 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
1527 }
1528
1529 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1530 let agent = agent_uri(auth_pk.as_ref());
1531 enforce_write_ctx(
1532 &state,
1533 &path,
1534 AccessMode::Write,
1535 agent.as_deref(),
1536 req_origin(&req),
1537 )
1538 .await?;
1539
1540 let ct = req
1541 .headers()
1542 .get(header::CONTENT_TYPE)
1543 .and_then(|v| v.to_str().ok())
1544 .unwrap_or("application/octet-stream");
1545
1546 if protected_resource_for_acl(&path).is_some()
1551 && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
1552 {
1553 return Ok(HttpResponse::Conflict().body(
1554 "refused: the proposed ACL would not grant Control to the caller \
1555 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1556 ));
1557 }
1558
1559 let quota = reserve_quota_for_size(&state, &path, body.len() as u64).await?;
1560 let write = state
1561 .storage
1562 .put(&path, Bytes::from(body.to_vec()), ct)
1563 .await;
1564 finish_quota_reservation(&state, quota, write.is_ok()).await;
1565 let meta = write.map_err(to_actix)?;
1566 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
1570 let mut rsp = HttpResponse::Created().finish();
1571 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1572 rsp.headers_mut().insert(header::ETAG, etag);
1573 }
1574 set_provenance_headers(&mut rsp, &provenance);
1575 set_link_headers(&mut rsp, &path);
1576 Ok(rsp)
1577}
1578
1579async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
1586 if !storage.exists(target).await.unwrap_or(false) {
1587 return target.to_string();
1588 }
1589 let seg_start = target.rfind('/').map(|s| s + 1).unwrap_or(0);
1592 let (stem, ext) = match target.rfind('.') {
1593 Some(dot) if dot > seg_start => (&target[..dot], &target[dot..]),
1594 _ => (target, ""),
1595 };
1596 for n in 1..10_000u32 {
1597 let candidate = format!("{stem}-{n}{ext}");
1598 if !storage.exists(&candidate).await.unwrap_or(false) {
1599 return candidate;
1600 }
1601 }
1602 use std::hash::{Hash, Hasher};
1603 let mut h = std::collections::hash_map::DefaultHasher::new();
1604 target.hash(&mut h);
1605 format!("{stem}-{:x}{ext}", h.finish())
1606}
1607
1608async fn handle_post(
1609 req: HttpRequest,
1610 body: web::Bytes,
1611 state: web::Data<AppState>,
1612) -> Result<HttpResponse, ActixError> {
1613 let path = req.uri().path().to_string();
1614 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1617 let agent = agent_uri(auth_pk.as_ref());
1618 enforce_write_ctx(
1619 &state,
1620 &path,
1621 AccessMode::Append,
1622 agent.as_deref(),
1623 req_origin(&req),
1624 )
1625 .await?;
1626
1627 let slug = req
1628 .headers()
1629 .get(header::HeaderName::from_static("slug"))
1630 .and_then(|v| v.to_str().ok());
1631 let mut target = match ldp::resolve_slug(&path, slug) {
1632 Ok(p) => p,
1633 Err(e) => return Err(to_actix(e)),
1634 };
1635 let ct = req
1636 .headers()
1637 .get(header::CONTENT_TYPE)
1638 .and_then(|v| v.to_str().ok())
1639 .unwrap_or("application/octet-stream");
1640
1641 if protected_resource_for_acl(&target).is_some() {
1650 enforce_write_ctx(
1651 &state,
1652 &target,
1653 AccessMode::Write,
1654 agent.as_deref(),
1655 req_origin(&req),
1656 )
1657 .await?;
1658 if !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref()) {
1659 return Ok(HttpResponse::Conflict().body(
1660 "refused: the proposed ACL would not grant Control to the caller \
1661 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1662 ));
1663 }
1664 } else {
1665 target = mint_unique_target(&*state.storage, &target).await;
1671 }
1672
1673 let quota = reserve_quota_for_size(&state, &target, body.len() as u64).await?;
1674 let write = state
1675 .storage
1676 .put(&target, Bytes::from(body.to_vec()), ct)
1677 .await;
1678 finish_quota_reservation(&state, quota, write.is_ok()).await;
1679 let meta = write.map_err(to_actix)?;
1680 let provenance = git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1683 let mut rsp = HttpResponse::Created().finish();
1684 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1685 rsp.headers_mut().insert(header::LOCATION, loc);
1686 }
1687 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1688 rsp.headers_mut().insert(header::ETAG, etag);
1689 }
1690 set_provenance_headers(&mut rsp, &provenance);
1691 set_link_headers(&mut rsp, &target);
1692 Ok(rsp)
1693}
1694
1695async fn handle_patch(
1696 req: HttpRequest,
1697 body: web::Bytes,
1698 state: web::Data<AppState>,
1699) -> Result<HttpResponse, ActixError> {
1700 let path = req.uri().path().to_string();
1701 if ldp::is_container(&path) {
1702 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1703 }
1704 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1705 let agent = agent_uri(auth_pk.as_ref());
1706 enforce_write_ctx(
1712 &state,
1713 &path,
1714 AccessMode::Write,
1715 agent.as_deref(),
1716 req_origin(&req),
1717 )
1718 .await?;
1719
1720 let ct = req
1721 .headers()
1722 .get(header::CONTENT_TYPE)
1723 .and_then(|v| v.to_str().ok())
1724 .unwrap_or("");
1725 let dialect = match ldp::patch_dialect_from_mime(ct) {
1726 Some(d) => d,
1727 None => {
1728 return Ok(HttpResponse::UnsupportedMediaType()
1729 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1730 }
1731 };
1732 let body_str = match std::str::from_utf8(&body) {
1733 Ok(s) => s.to_string(),
1734 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1735 };
1736
1737 let existing = state.storage.get(&path).await;
1739 match existing {
1740 Ok((current_body, meta)) => {
1741 let out = match dialect {
1751 ldp::PatchDialect::N3 => {
1752 let seed = seed_graph_from_patch_target(¤t_body)?;
1753 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1754 }
1755 ldp::PatchDialect::SparqlUpdate => {
1756 let seed = seed_graph_from_patch_target(¤t_body)?;
1757 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1758 }
1759 ldp::PatchDialect::JsonPatch => {
1760 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1761 Ok(v) => v,
1762 Err(_) => serde_json::json!({}),
1763 };
1764 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1765 Ok(v) => v,
1766 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1767 };
1768 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1769 let bytes = serde_json::to_vec(&json)
1770 .map_err(PodError::from)
1771 .map_err(to_actix)?;
1772 let quota = reserve_quota_for_size(&state, &path, bytes.len() as u64).await?;
1773 let write = state
1774 .storage
1775 .put(&path, Bytes::from(bytes), &meta.content_type)
1776 .await;
1777 finish_quota_reservation(&state, quota, write.is_ok()).await;
1778 let _ = write.map_err(to_actix)?;
1779 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1780 let mut rsp = HttpResponse::NoContent().finish();
1781 set_provenance_headers(&mut rsp, &provenance);
1782 return Ok(rsp);
1783 }
1784 };
1785 let outcome = out?;
1786 let serialised = graph_to_turtle(&outcome.graph);
1789 if protected_resource_for_acl(&path).is_some()
1795 && !proposed_acl_keeps_caller_control(
1796 serialised.as_bytes(),
1797 "application/n-triples",
1798 agent.as_deref(),
1799 )
1800 {
1801 return Ok(HttpResponse::Conflict().body(
1802 "refused: the patched ACL would not grant Control to the caller \
1803 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1804 ));
1805 }
1806 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
1807 let write = state
1808 .storage
1809 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1810 .await;
1811 finish_quota_reservation(&state, quota, write.is_ok()).await;
1812 let _ = write.map_err(to_actix)?;
1813 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1814 let mut rsp = HttpResponse::NoContent().finish();
1815 set_provenance_headers(&mut rsp, &provenance);
1816 Ok(rsp)
1817 }
1818 Err(PodError::NotFound(_)) => {
1819 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
1821 let PatchCreateOutcome::Created { graph, .. } = create else {
1822 return Err(to_actix(PodError::Unsupported(
1823 "unexpected patch outcome on absent resource".into(),
1824 )));
1825 };
1826 let serialised = graph_to_turtle(&graph);
1827 if protected_resource_for_acl(&path).is_some()
1829 && !proposed_acl_keeps_caller_control(
1830 serialised.as_bytes(),
1831 "application/n-triples",
1832 agent.as_deref(),
1833 )
1834 {
1835 return Ok(HttpResponse::Conflict().body(
1836 "refused: the patched ACL would not grant Control to the caller \
1837 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1838 ));
1839 }
1840 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
1841 let write = state
1842 .storage
1843 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1844 .await;
1845 finish_quota_reservation(&state, quota, write.is_ok()).await;
1846 let _ = write.map_err(to_actix)?;
1847 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1848 let mut rsp = HttpResponse::Created().finish();
1849 set_provenance_headers(&mut rsp, &provenance);
1850 Ok(rsp)
1851 }
1852 Err(e) => Err(to_actix(e)),
1853 }
1854}
1855
1856fn patch_parse_err(e: PodError) -> ActixError {
1860 match e {
1861 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
1862 actix_web::error::ErrorBadRequest(msg)
1863 }
1864 other => to_actix(other),
1865 }
1866}
1867
1868fn graph_to_turtle(g: &ldp::Graph) -> String {
1872 g.to_ntriples()
1873}
1874
1875fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
1882 let mut best: Option<(f32, ldp::RdfFormat)> = None;
1883 for entry in accept.split(',') {
1884 let entry = entry.trim();
1885 if entry.is_empty() {
1886 continue;
1887 }
1888 let mut parts = entry.split(';').map(|s| s.trim());
1889 let mime = match parts.next() {
1890 Some(m) => m,
1891 None => continue,
1892 };
1893 let mut q: f32 = 1.0;
1894 for token in parts {
1895 if let Some(v) = token.strip_prefix("q=") {
1896 if let Ok(parsed) = v.parse::<f32>() {
1897 q = parsed;
1898 }
1899 }
1900 }
1901 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
1904 match best {
1905 None => best = Some((q, format)),
1906 Some((bq, _)) if q > bq => best = Some((q, format)),
1907 _ => {}
1908 }
1909 }
1910 }
1911 best.map(|(_, f)| f)
1912}
1913
1914fn rdf_content_negotiate(
1930 body: &[u8],
1931 stored_ct: &str,
1932 accept: &str,
1933) -> Option<(Vec<u8>, &'static str)> {
1934 if accept.trim().is_empty() {
1935 return None;
1936 }
1937 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
1938 let target = best_explicit_rdf_format(accept)?;
1939 if target == stored_format {
1940 return None;
1941 }
1942 let text = std::str::from_utf8(body).ok()?;
1943 let graph = ldp::Graph::parse_ntriples(text).ok()?;
1944 match target {
1945 ldp::RdfFormat::Turtle => Some((
1948 graph.to_ntriples().into_bytes(),
1949 ldp::RdfFormat::Turtle.mime(),
1950 )),
1951 ldp::RdfFormat::NTriples => Some((
1952 graph.to_ntriples().into_bytes(),
1953 ldp::RdfFormat::NTriples.mime(),
1954 )),
1955 ldp::RdfFormat::JsonLd => {
1956 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
1957 Some((json, ldp::RdfFormat::JsonLd.mime()))
1958 }
1959 ldp::RdfFormat::RdfXml => None,
1961 }
1962}
1963
1964fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
1973 let text = std::str::from_utf8(current_body).map_err(|_| {
1974 actix_web::error::ErrorConflict(
1975 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
1976 )
1977 })?;
1978 if text.trim().is_empty() {
1979 return Ok(ldp::Graph::new());
1980 }
1981 ldp::Graph::parse_ntriples(text).map_err(|_| {
1982 actix_web::error::ErrorConflict(
1983 "existing resource is not N-Triples RDF and cannot be non-destructively \
1984 patched; PUT an N-Triples representation or use a JSON Patch",
1985 )
1986 })
1987}
1988
1989pub(crate) async fn find_effective_acl_dyn(
2005 storage: &dyn Storage,
2006 resource_path: &str,
2007) -> Result<Option<wac::AclDocument>, PodError> {
2008 resolve_policy_dyn(storage, resource_path)
2009 .await
2010 .into_result()
2011}
2012
2013pub(crate) async fn resolve_policy_dyn(
2018 storage: &dyn Storage,
2019 resource_path: &str,
2020) -> wac::PolicyOutcome {
2021 wac::resolve_policy_from_storage(storage, resource_path).await
2022}
2023
2024fn policy_failure_to_actix(outcome: &wac::PolicyOutcome, resource: &str) -> ActixError {
2034 match outcome {
2035 wac::PolicyOutcome::Invalid {
2036 policy_path,
2037 reason,
2038 } => {
2039 tracing::warn!(
2040 target: "solid_pod_rs_server::wac",
2041 resource = %resource,
2042 policy = %policy_path,
2043 "denying: effective ACL is invalid and MUST NOT inherit: {reason}"
2044 );
2045 actix_web::error::ErrorForbidden("access forbidden: governing ACL is invalid")
2046 }
2047 wac::PolicyOutcome::Unavailable {
2048 policy_path,
2049 detail,
2050 } => {
2051 tracing::error!(
2052 target: "solid_pod_rs_server::wac",
2053 resource = %resource,
2054 policy = %policy_path,
2055 "denying: effective ACL could not be read: {detail}"
2056 );
2057 actix_web::error::ErrorServiceUnavailable(
2058 "access control unavailable: governing ACL could not be read",
2059 )
2060 }
2061 _ => actix_web::error::ErrorInternalServerError("policy resolution error"),
2063 }
2064}
2065
2066async fn handle_delete(
2067 req: HttpRequest,
2068 state: web::Data<AppState>,
2069) -> Result<HttpResponse, ActixError> {
2070 let path = req.uri().path().to_string();
2071 let auth_pk = extract_pubkey(&req).await;
2072 let agent = agent_uri(auth_pk.as_ref());
2073 enforce_write_ctx(
2074 &state,
2075 &path,
2076 AccessMode::Write,
2077 agent.as_deref(),
2078 req_origin(&req),
2079 )
2080 .await?;
2081
2082 let quota = reserve_quota_for_size(&state, &path, 0).await?;
2083 match state.storage.delete(&path).await {
2084 Ok(()) => {
2085 finish_quota_reservation(&state, quota, true).await;
2086 Ok(HttpResponse::NoContent().finish())
2087 }
2088 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
2089 Err(e) => Err(to_actix(e)),
2090 }
2091}
2092
2093async fn handle_options(
2094 req: HttpRequest,
2095 state: web::Data<AppState>,
2096) -> Result<HttpResponse, ActixError> {
2097 let path = req.uri().path().to_string();
2098 let o = ldp::options_for(&path);
2099 let mut rsp = HttpResponse::NoContent().finish();
2100 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
2101 rsp.headers_mut()
2102 .insert(header::HeaderName::from_static("allow"), v);
2103 }
2104 if let Some(ap) = o.accept_post {
2105 if let Ok(v) = header::HeaderValue::from_str(ap) {
2106 rsp.headers_mut()
2107 .insert(header::HeaderName::from_static("accept-post"), v);
2108 }
2109 }
2110 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
2111 rsp.headers_mut()
2112 .insert(header::HeaderName::from_static("accept-patch"), v);
2113 }
2114 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
2115 rsp.headers_mut()
2116 .insert(header::HeaderName::from_static("accept-ranges"), v);
2117 }
2118 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
2119 Ok(rsp)
2120}
2121
2122async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
2127 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
2128 HttpResponse::Ok()
2129 .content_type("application/ld+json")
2130 .json(doc)
2131}
2132
2133#[derive(Debug, Deserialize)]
2134struct WebFingerQuery {
2135 resource: Option<String>,
2136}
2137
2138async fn handle_well_known_webfinger(
2139 state: web::Data<AppState>,
2140 q: web::Query<WebFingerQuery>,
2141) -> HttpResponse {
2142 let resource = q.resource.clone().unwrap_or_else(|| {
2143 format!(
2144 "acct:anonymous@{}",
2145 state
2146 .nodeinfo
2147 .base_url
2148 .trim_start_matches("http://")
2149 .trim_start_matches("https://")
2150 )
2151 });
2152 let webid = format!(
2153 "{}/profile/card#me",
2154 state.nodeinfo.base_url.trim_end_matches('/')
2155 );
2156 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
2157 Some(jrd) => HttpResponse::Ok()
2158 .content_type("application/jrd+json")
2159 .json(jrd),
2160 None => HttpResponse::NotFound().finish(),
2161 }
2162}
2163
2164async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
2165 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
2166 HttpResponse::Ok()
2167 .content_type("application/json")
2168 .json(doc)
2169}
2170
2171async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
2172 let doc = interop::nodeinfo_2_1(
2173 &state.nodeinfo.software_name,
2174 &state.nodeinfo.software_version,
2175 state.nodeinfo.open_registrations,
2176 state.nodeinfo.total_users,
2177 );
2178 HttpResponse::Ok()
2179 .content_type("application/json")
2180 .json(doc)
2181}
2182
2183#[cfg(feature = "did-nostr")]
2184async fn handle_well_known_did_nostr(
2185 state: web::Data<AppState>,
2186 path: web::Path<String>,
2187) -> HttpResponse {
2188 let pubkey = path.into_inner();
2189 let pubkey_is_valid = pubkey.len() == 64
2194 && pubkey
2195 .bytes()
2196 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
2197 if !pubkey_is_valid {
2198 return HttpResponse::BadRequest()
2199 .insert_header(("Cache-Control", "no-store"))
2200 .json(serde_json::json!({
2201 "error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
2202 }));
2203 }
2204 let owner_pubkey = match state.storage.get("/profile/card").await {
2212 Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
2213 .ok()
2214 .flatten(),
2215 Err(_) => None,
2216 };
2217 let owner_claims_key = owner_pubkey
2218 .as_deref()
2219 .is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
2220 if !owner_claims_key {
2221 return HttpResponse::NotFound()
2222 .insert_header(("Cache-Control", "no-store"))
2223 .json(serde_json::json!({
2224 "error": "no account on this pod claims this did:nostr pubkey"
2225 }));
2226 }
2227 let also = vec![format!(
2228 "{}/profile/card#me",
2229 state.nodeinfo.base_url.trim_end_matches('/')
2230 )];
2231 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
2232 let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
2233 use std::hash::{Hash, Hasher};
2239 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2240 body.hash(&mut hasher);
2241 let etag = format!("\"{:016x}\"", hasher.finish());
2242 HttpResponse::Ok()
2243 .content_type("application/did+json")
2244 .insert_header(("Cache-Control", "max-age=3600"))
2245 .insert_header(("ETag", etag))
2246 .body(body)
2247}
2248
2249#[cfg(feature = "nip05-endpoint")]
2257#[derive(Debug, Deserialize)]
2258struct Nip05Query {
2259 name: Option<String>,
2262}
2263
2264#[cfg(feature = "nip05-endpoint")]
2265fn nip05_name_is_valid(name: &str) -> bool {
2266 if name.is_empty() {
2269 return false;
2270 }
2271 name.bytes()
2272 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
2273}
2274
2275#[cfg(feature = "nip05-endpoint")]
2276async fn handle_well_known_nip05(
2277 state: web::Data<AppState>,
2278 query: web::Query<Nip05Query>,
2279) -> HttpResponse {
2280 use solid_pod_rs::webid::extract_nostr_pubkey;
2281
2282 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
2284 if !nip05_name_is_valid(&name) {
2285 return HttpResponse::BadRequest().json(serde_json::json!({
2286 "error": "invalid NIP-05 local part",
2287 }));
2288 }
2289
2290 let profile_path = if name == "_" {
2296 "/profile/card".to_string()
2297 } else {
2298 format!("/{name}/profile/card")
2299 };
2300
2301 let (body, _meta) = match state.storage.get(&profile_path).await {
2302 Ok(v) => v,
2303 Err(_) => {
2304 return nip05_empty_response();
2308 }
2309 };
2310
2311 let pubkey_hex = match extract_nostr_pubkey(&body) {
2312 Ok(Some(p)) => p,
2313 _ => return nip05_empty_response(),
2314 };
2315
2316 let doc = interop::nip05_document([(name, pubkey_hex)]);
2317 HttpResponse::Ok()
2318 .insert_header(("Access-Control-Allow-Origin", "*"))
2319 .content_type("application/json")
2320 .json(doc)
2321}
2322
2323#[cfg(feature = "nip05-endpoint")]
2324fn nip05_empty_response() -> HttpResponse {
2325 HttpResponse::Ok()
2326 .insert_header(("Access-Control-Allow-Origin", "*"))
2327 .content_type("application/json")
2328 .json(serde_json::json!({ "names": {} }))
2329}
2330
2331#[cfg(feature = "export-jsonld")]
2345async fn handle_export_all(
2346 req: HttpRequest,
2347 state: web::Data<AppState>,
2348) -> Result<HttpResponse, ActixError> {
2349 let auth_pk = extract_pubkey(&req).await;
2350 let agent = agent_uri(auth_pk.as_ref());
2351
2352 enforce_write_ctx(
2357 &state,
2358 "/",
2359 AccessMode::Control,
2360 agent.as_deref(),
2361 req_origin(&req),
2362 )
2363 .await?;
2364
2365 let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
2369 .ok()
2370 .and_then(|q| q.get("include_private").map(|v| v == "true"))
2371 .unwrap_or(false);
2372
2373 let pod_base = {
2377 let conn = req.connection_info();
2378 format!("{}://{}/", conn.scheme(), conn.host())
2379 };
2380
2381 let options = solid_pod_rs::ExportOptions { include_private };
2382 let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
2383 .await
2384 .map_err(to_actix)?;
2385
2386 let body = serde_json::to_vec(&bundle).map_err(|e| {
2387 actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
2388 })?;
2389 Ok(HttpResponse::Ok()
2390 .content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
2391 .body(body))
2392}
2393
2394#[derive(Debug, Deserialize)]
2399struct CreateAccountRequest {
2400 username: String,
2401 #[serde(default)]
2402 name: Option<String>,
2403}
2404
2405#[derive(Debug, Deserialize)]
2406struct CreatePodRequest {
2407 name: String,
2408}
2409
2410async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
2411 let pod_name = path.into_inner();
2412 let pod_root = format!("/{pod_name}/");
2413 match state.storage.exists(&pod_root).await {
2414 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
2415 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
2416 }
2417}
2418
2419fn valid_pod_name(name: &str) -> bool {
2420 !name.is_empty()
2421 && name
2422 .chars()
2423 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
2424}
2425
2426fn request_ip(req: &HttpRequest) -> IpAddr {
2427 req.peer_addr()
2428 .map(|addr| addr.ip())
2429 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
2430}
2431
2432#[derive(Debug)]
2433struct QuotaReservation {
2434 pod: String,
2435 delta: i64,
2436}
2437
2438fn pod_name_from_path(path: &str) -> Option<&str> {
2439 path.trim_start_matches('/')
2440 .split('/')
2441 .next()
2442 .filter(|p| !p.is_empty())
2443}
2444
2445async fn reserve_quota_for_size(
2446 state: &AppState,
2447 path: &str,
2448 new_size: u64,
2449) -> Result<Option<QuotaReservation>, ActixError> {
2450 let (Some(policy), Some(pod)) = (&state.quota, pod_name_from_path(path)) else {
2451 return Ok(None);
2452 };
2453 let old_size = state
2454 .storage
2455 .head(path)
2456 .await
2457 .map(|meta| meta.size)
2458 .unwrap_or(0);
2459 let delta = i128::from(new_size) - i128::from(old_size);
2460 let delta = delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64;
2461 if delta > 0 {
2462 policy
2463 .reserve(pod, delta as u64)
2464 .await
2465 .map_err(|error| actix_web::error::ErrorInsufficientStorage(error.to_string()))?;
2466 }
2467 Ok(Some(QuotaReservation {
2468 pod: pod.to_string(),
2469 delta,
2470 }))
2471}
2472
2473async fn finish_quota_reservation(
2474 state: &AppState,
2475 reservation: Option<QuotaReservation>,
2476 write_succeeded: bool,
2477) {
2478 let (Some(policy), Some(reservation)) = (&state.quota, reservation) else {
2479 return;
2480 };
2481 if write_succeeded {
2482 if reservation.delta < 0 {
2483 policy.record(&reservation.pod, reservation.delta).await;
2484 }
2485 } else if reservation.delta > 0 {
2486 policy.record(&reservation.pod, -reservation.delta).await;
2487 }
2488}
2489
2490async fn handle_create_account(
2491 req: HttpRequest,
2492 state: web::Data<AppState>,
2493 body: web::Json<CreateAccountRequest>,
2494) -> Result<HttpResponse, ActixError> {
2495 if let Some(response) = provisioning_gate(&req, &state, &body.username) {
2496 return Ok(response);
2497 }
2498 let pod_root = format!("/{}/", body.username);
2499 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2500 return Ok(
2501 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
2502 );
2503 }
2504
2505 let base_uri = state.nodeinfo.base_url.trim_end_matches('/');
2506 let outcome =
2507 provision_named_pod(&state, &body.username, body.name.as_deref(), base_uri).await?;
2508 Ok(HttpResponse::Created().json(serde_json::json!({
2509 "webid": outcome.webid,
2510 "pod_root": outcome.pod_uri,
2511 "username": body.username,
2512 })))
2513}
2514
2515struct NamedPodOutcome {
2516 webid: String,
2517 pod_uri: String,
2518}
2519
2520fn provisioning_gate(req: &HttpRequest, state: &AppState, name: &str) -> Option<HttpResponse> {
2523 if !valid_pod_name(name) {
2524 return Some(HttpResponse::BadRequest().json(serde_json::json!({
2525 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
2526 })));
2527 }
2528
2529 let supplied_key = req
2530 .headers()
2531 .get("x-pod-admin-key")
2532 .and_then(|value| value.to_str().ok())
2533 .unwrap_or("");
2534 use subtle::ConstantTimeEq;
2535 let admin_override = state
2536 .admin_key
2537 .as_deref()
2538 .is_some_and(|expected| bool::from(supplied_key.as_bytes().ct_eq(expected.as_bytes())));
2539 if !state.nodeinfo.open_registrations && !admin_override {
2540 return Some(HttpResponse::Forbidden().json(serde_json::json!({
2541 "error": "pod registration is closed"
2542 })));
2543 }
2544
2545 if let Err(retry_after) = state.pod_create_limiter.check(request_ip(req)) {
2546 return Some(
2547 HttpResponse::TooManyRequests()
2548 .insert_header(("Retry-After", retry_after.to_string()))
2549 .json(serde_json::json!({
2550 "error": "Too Many Requests",
2551 "message": "Pod creation rate limit exceeded",
2552 "retryAfter": retry_after
2553 })),
2554 );
2555 }
2556 None
2557}
2558
2559async fn provision_named_pod(
2563 state: &AppState,
2564 name: &str,
2565 display_name: Option<&str>,
2566 base_uri: &str,
2567) -> Result<NamedPodOutcome, ActixError> {
2568 for container in ["", "profile", "inbox", "public", "private", "settings"] {
2569 let path = if container.is_empty() {
2570 format!("/{name}")
2571 } else {
2572 format!("/{name}/{container}")
2573 };
2574 state
2575 .storage
2576 .put(
2577 &format!("{path}.meta"),
2578 Bytes::from_static(b"{}"),
2579 "application/ld+json",
2580 )
2581 .await
2582 .map_err(to_actix)?;
2583 }
2584
2585 let base_uri = base_uri.trim_end_matches('/');
2586 let pod_uri = format!("{base_uri}/{name}/");
2587 let canonical_prefix = format!("{base_uri}/pods/{name}/");
2588 let webid = format!("{pod_uri}profile/card#me");
2589 let profile = solid_pod_rs::webid::generate_webid_html(name, display_name, base_uri)
2590 .replace(&canonical_prefix, &pod_uri);
2591 state
2592 .storage
2593 .put(
2594 &format!("/{name}/profile/card"),
2595 Bytes::from(profile.into_bytes()),
2596 "text/html",
2597 )
2598 .await
2599 .map_err(to_actix)?;
2600
2601 #[cfg(feature = "git")]
2602 if let Some(root) = &state.data_root {
2603 let hook = solid_pod_rs_git::init::GitAutoInit::new();
2604 if let Err(error) = hook.init_repo_at(&root.join(name)).await {
2605 tracing::warn!(pod = name, %error, "git auto-init failed after pod creation");
2606 }
2607 }
2608
2609 Ok(NamedPodOutcome { webid, pod_uri })
2610}
2611
2612async fn handle_create_pod(
2613 req: HttpRequest,
2614 state: web::Data<AppState>,
2615 body: web::Json<CreatePodRequest>,
2616) -> Result<HttpResponse, ActixError> {
2617 if let Some(response) = provisioning_gate(&req, &state, &body.name) {
2618 return Ok(response);
2619 }
2620
2621 let pod_root = format!("/{}/", body.name);
2622 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2623 return Ok(
2624 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
2625 );
2626 }
2627
2628 let base_uri = {
2629 let conn = req.connection_info();
2630 format!("{}://{}", conn.scheme(), conn.host())
2631 };
2632 let outcome = provision_named_pod(&state, &body.name, None, &base_uri).await?;
2633
2634 Ok(HttpResponse::Created()
2635 .insert_header(("Location", outcome.pod_uri.clone()))
2636 .json(serde_json::json!({
2637 "name": body.name,
2638 "webId": outcome.webid,
2639 "podUri": outcome.pod_uri,
2640 })))
2641}
2642
2643fn normalise_copy_source(raw: &str, host: &str) -> Option<String> {
2653 let path = if raw.starts_with("http://") || raw.starts_with("https://") {
2654 let url = url::Url::parse(raw).ok()?;
2655 let authority = match url.port() {
2656 Some(port) => format!("{}:{port}", url.host_str()?),
2657 None => url.host_str()?.to_string(),
2658 };
2659 if !authority.eq_ignore_ascii_case(host)
2660 || url.query().is_some()
2661 || url.fragment().is_some()
2662 {
2663 return None;
2664 }
2665 url.path().to_string()
2666 } else if raw.starts_with('/') && !raw.contains(['?', '#']) {
2667 raw.to_string()
2668 } else {
2669 return None;
2670 };
2671 canonical_pod_path(&path)
2672}
2673
2674async fn handle_copy(
2675 req: HttpRequest,
2676 state: web::Data<AppState>,
2677) -> Result<HttpResponse, ActixError> {
2678 let dest = req.uri().path().to_string();
2679 let auth_pk = extract_pubkey(&req).await;
2680 let agent = agent_uri(auth_pk.as_ref());
2681 enforce_write_ctx(
2682 &state,
2683 &dest,
2684 AccessMode::Write,
2685 agent.as_deref(),
2686 req_origin(&req),
2687 )
2688 .await?;
2689
2690 let source = req
2691 .headers()
2692 .get("source")
2693 .and_then(|v| v.to_str().ok())
2694 .map(|s| s.to_string());
2695 let source = match source {
2696 Some(s) => s,
2697 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
2698 };
2699 let host = req.connection_info().host().to_string();
2700 let source = match normalise_copy_source(&source, &host) {
2701 Some(s) => s,
2702 None => return Ok(HttpResponse::BadRequest().body("invalid Source")),
2703 };
2704 if !state.dotfiles.is_allowed(Path::new(&source)) {
2705 return Ok(HttpResponse::Forbidden().body("dotfile path denied by allowlist"));
2706 }
2707 enforce_read_ctx(&state, &source, agent.as_deref(), req_origin(&req)).await?;
2710
2711 let (body, meta) = match state.storage.get(&source).await {
2712 Ok(v) => v,
2713 Err(PodError::NotFound(_)) => {
2714 return Ok(HttpResponse::NotFound().body("source resource not found"))
2715 }
2716 Err(e) => return Err(to_actix(e)),
2717 };
2718
2719 if protected_resource_for_acl(&dest).is_some()
2721 && !proposed_acl_keeps_caller_control(&body, &meta.content_type, agent.as_deref())
2722 {
2723 return Ok(HttpResponse::Conflict().body(
2724 "refused: the proposed ACL would not grant Control to the caller \
2725 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
2726 ));
2727 }
2728
2729 let quota = reserve_quota_for_size(&state, &dest, body.len() as u64).await?;
2730 let write = state.storage.put(&dest, body, &meta.content_type).await;
2731 finish_quota_reservation(&state, quota, write.is_ok()).await;
2732 write.map_err(to_actix)?;
2733
2734 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
2739 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
2740 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
2741 let origin = req_origin(&req);
2742 let permitted = enforce_read_ctx(&state, &src_acl, agent.as_deref(), origin)
2743 .await
2744 .is_ok()
2745 && enforce_write_ctx(
2746 &state,
2747 &dst_acl,
2748 AccessMode::Write,
2749 agent.as_deref(),
2750 origin,
2751 )
2752 .await
2753 .is_ok()
2754 && proposed_acl_keeps_caller_control(
2755 &acl_body,
2756 &acl_meta.content_type,
2757 agent.as_deref(),
2758 );
2759 if permitted {
2760 let quota = reserve_quota_for_size(&state, &dst_acl, acl_body.len() as u64).await?;
2761 let write = state
2762 .storage
2763 .put(&dst_acl, acl_body, &acl_meta.content_type)
2764 .await;
2765 finish_quota_reservation(&state, quota, write.is_ok()).await;
2766 write.map_err(to_actix)?;
2767 }
2768 }
2769
2770 let mut rsp = HttpResponse::Created().finish();
2771 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
2772 rsp.headers_mut().insert(header::LOCATION, loc);
2773 }
2774 Ok(rsp)
2775}
2776
2777async fn handle_glob_get(
2782 req: HttpRequest,
2783 state: web::Data<AppState>,
2784) -> Result<HttpResponse, ActixError> {
2785 let raw_path = req.uri().path().to_string();
2786 if !raw_path.ends_with("/*") {
2788 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
2789 }
2790 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
2792 folder.to_string()
2793 } else {
2794 format!("{folder}/")
2795 };
2796
2797 let auth_pk = extract_pubkey(&req).await;
2801 let agent = agent_uri(auth_pk.as_ref());
2802 let origin = req_origin(&req);
2803 let audience = enforce_read_ctx(&state, &folder, agent.as_deref(), origin).await?;
2804
2805 let children = state.storage.list(&folder).await.map_err(to_actix)?;
2806 let mut merged = String::new();
2807 let mut audience = audience;
2808
2809 for child in &children {
2810 if child.ends_with('/')
2811 || child.ends_with(".acl")
2812 || child.ends_with(".meta")
2813 || child.ends_with(".prov.ttl")
2814 {
2815 continue;
2816 }
2817 let child_path = format!("{folder}{child}");
2818 match enforce_read_inner(&state, &child_path, agent.as_deref(), origin, false).await {
2821 Ok(ResponseAudience::Private) => audience = ResponseAudience::Private,
2822 Ok(ResponseAudience::Public) => {}
2823 Err(_) => continue,
2824 }
2825 if let Ok((body, meta)) = state.storage.get(&child_path).await {
2826 if meta.content_type.contains("turtle")
2827 || meta.content_type.contains("n-triples")
2828 || meta.content_type.contains("n3")
2829 {
2830 if let Ok(text) = std::str::from_utf8(&body) {
2831 merged.push_str(text);
2832 merged.push('\n');
2833 }
2834 }
2835 }
2836 }
2837
2838 if merged.is_empty() {
2839 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
2840 }
2841
2842 let mut rsp = HttpResponse::Ok().content_type("text/turtle").body(merged);
2843 set_cache_policy(&mut rsp, "text/turtle", audience);
2844 Ok(rsp)
2845}
2846
2847#[derive(Debug, Deserialize)]
2852struct LoginPasswordRequest {
2853 username: String,
2854 password: String,
2855}
2856
2857async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
2864 let _ = (&body.username, &body.password);
2865 HttpResponse::NotImplemented().json(serde_json::json!({
2866 "error": "password login is not implemented on this pod"
2867 }))
2868}
2869
2870#[derive(Debug, Deserialize)]
2871struct PasswordResetRequest {
2872 username: String,
2873}
2874
2875async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
2879 let _ = &body.username;
2880 HttpResponse::NotImplemented().json(serde_json::json!({
2881 "error": "password reset is not implemented on this pod"
2882 }))
2883}
2884
2885#[derive(Debug, Deserialize)]
2886struct PasswordChangeRequest {
2887 token: String,
2888 new_password: String,
2889}
2890
2891async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
2896 let _ = (&body.token, &body.new_password);
2897 HttpResponse::NotImplemented().json(serde_json::json!({
2898 "error": "password change is not implemented on this pod"
2899 }))
2900}
2901
2902async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
2907 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
2908 HttpResponse::Ok()
2909 .content_type("application/json")
2910 .json(body)
2911}
2912
2913pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
2928
2929#[derive(Debug, Deserialize)]
2931struct ProxyQuery {
2932 url: String,
2933}
2934
2935const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
2937 "set-cookie",
2938 "set-cookie2",
2939 "authorization",
2940 "www-authenticate",
2941 "proxy-authenticate",
2942 "proxy-authorization",
2943];
2944
2945async fn validate_proxy_target(target: &str) -> Result<(url::Url, IpAddr), HttpResponse> {
2961 let parsed = match url::Url::parse(target) {
2962 Ok(u) => u,
2963 Err(_) => {
2964 return Err(
2965 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
2966 );
2967 }
2968 };
2969
2970 match parsed.scheme() {
2972 "http" | "https" => {}
2973 scheme => {
2974 return Err(HttpResponse::BadRequest()
2975 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
2976 }
2977 }
2978
2979 if solid_pod_rs::security::is_safe_url(target).is_err() {
2982 return Err(HttpResponse::Forbidden()
2983 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
2984 }
2985
2986 let host = match parsed.host_str() {
2988 Some(h) => h.to_string(),
2989 None => {
2990 return Err(HttpResponse::BadRequest()
2991 .json(serde_json::json!({"error": "target URL has no host"})))
2992 }
2993 };
2994 let host_lower = host.to_ascii_lowercase();
2995 if host_lower == "localhost"
2996 || host_lower.ends_with(".localhost")
2997 || host_lower == "0.0.0.0"
2998 || host_lower == "[::1]"
2999 || host_lower == "[::0]"
3000 {
3001 return Err(HttpResponse::Forbidden()
3002 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
3003 }
3004
3005 match solid_pod_rs::security::resolve_and_check(&host).await {
3008 Ok(ip) => Ok((parsed, ip)),
3009 Err(_) => Err(HttpResponse::Forbidden()
3010 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"}))),
3011 }
3012}
3013
3014fn build_pinned_proxy_client(url: &url::Url, ip: IpAddr) -> Result<reqwest::Client, ActixError> {
3017 let mut builder = reqwest::Client::builder()
3018 .redirect(reqwest::redirect::Policy::none());
3021 if let Some(host) = url.host_str() {
3022 let port = url.port_or_known_default().unwrap_or(0);
3024 builder = builder.resolve(host, std::net::SocketAddr::new(ip, port));
3025 }
3026 builder
3027 .build()
3028 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))
3029}
3030
3031async fn handle_proxy(
3032 req: HttpRequest,
3033 _state: web::Data<AppState>,
3034 query: web::Query<ProxyQuery>,
3035) -> Result<HttpResponse, ActixError> {
3036 let auth_pk = extract_pubkey(&req).await;
3038 let agent = agent_uri(auth_pk.as_ref());
3039 if agent.is_none() {
3040 return Ok(HttpResponse::Unauthorized()
3041 .json(serde_json::json!({"error": "authentication required"})));
3042 }
3043
3044 let mut current_url = query.url.clone();
3045 let mut redirect_count = 0u8;
3046 const MAX_REDIRECTS: u8 = 5;
3047 const TOTAL_TIMEOUT: Duration = Duration::from_secs(30);
3048 let started = Instant::now();
3049
3050 let byte_cap = std::env::var("PROXY_BYTE_CAP")
3051 .ok()
3052 .and_then(|v| {
3053 solid_pod_rs::config::sources::parse_size(&v)
3054 .map(|u| u as usize)
3055 .ok()
3056 })
3057 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
3058
3059 loop {
3060 let Some(remaining) = TOTAL_TIMEOUT.checked_sub(started.elapsed()) else {
3061 return Ok(HttpResponse::GatewayTimeout()
3062 .json(serde_json::json!({"error": "proxy operation timed out"})));
3063 };
3064 let (target_url, pinned_ip) = match validate_proxy_target(¤t_url).await {
3068 Ok(pair) => pair,
3069 Err(rsp) => return Ok(rsp),
3070 };
3071 let client = build_pinned_proxy_client(&target_url, pinned_ip)?;
3072
3073 let mut upstream_req = client.get(¤t_url).timeout(remaining);
3074
3075 if let Some(auth_val) = req
3077 .headers()
3078 .get("x-upstream-authorization")
3079 .and_then(|v| v.to_str().ok())
3080 {
3081 upstream_req = upstream_req.header("Authorization", auth_val);
3082 }
3083
3084 let response = upstream_req
3085 .send()
3086 .await
3087 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
3088
3089 if response.status().is_redirection() {
3091 if redirect_count >= MAX_REDIRECTS {
3092 return Ok(HttpResponse::BadGateway()
3093 .json(serde_json::json!({"error": "too many redirects"})));
3094 }
3095 if let Some(location) = response.headers().get("location") {
3096 let loc_str = location
3097 .to_str()
3098 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
3099 let base = url::Url::parse(¤t_url)
3101 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
3102 let resolved = base
3103 .join(loc_str)
3104 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
3105 current_url = resolved.to_string();
3106 redirect_count += 1;
3107 continue;
3108 }
3109 return Ok(HttpResponse::BadGateway()
3110 .json(serde_json::json!({"error": "redirect without location"})));
3111 }
3112
3113 let upstream_status = response.status().as_u16();
3115 let upstream_content_type = response
3116 .headers()
3117 .get("content-type")
3118 .and_then(|v| v.to_str().ok())
3119 .unwrap_or("application/octet-stream")
3120 .to_string();
3121 if response
3122 .content_length()
3123 .is_some_and(|length| length > byte_cap as u64)
3124 {
3125 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3126 "error": "proxied response exceeds byte cap",
3127 "limit": byte_cap
3128 })));
3129 }
3130
3131 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
3133 for (name, value) in response.headers() {
3134 let name_lower = name.as_str().to_ascii_lowercase();
3135 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
3136 continue;
3137 }
3138 if matches!(
3140 name_lower.as_str(),
3141 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
3142 ) {
3143 continue;
3144 }
3145 if let Ok(val_str) = value.to_str() {
3146 forwarded_headers.push((name_lower, val_str.to_string()));
3147 }
3148 }
3149
3150 let mut stream = response.bytes_stream();
3151 let mut body_bytes = Vec::with_capacity(byte_cap.min(64 * 1024));
3152 while let Some(chunk) = stream.next().await {
3153 let chunk =
3154 chunk.map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
3155 if body_bytes.len().saturating_add(chunk.len()) > byte_cap {
3156 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3157 "error": "proxied response exceeds byte cap",
3158 "limit": byte_cap
3159 })));
3160 }
3161 body_bytes.extend_from_slice(&chunk);
3162 }
3163
3164 let mut rsp = HttpResponse::build(
3166 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3167 );
3168 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
3169 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
3170
3171 for (name, value) in &forwarded_headers {
3173 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
3174 if let Ok(hval) = header::HeaderValue::from_str(value) {
3175 rsp.insert_header((hname, hval));
3176 }
3177 }
3178 }
3179
3180 return Ok(rsp.body(body_bytes));
3181 }
3182}
3183
3184pub struct PathTraversalGuard;
3190
3191impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
3192where
3193 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3194 B: 'static,
3195{
3196 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3197 type Error = ActixError;
3198 type InitError = ();
3199 type Transform = PathTraversalGuardMiddleware<S>;
3200 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3201
3202 fn new_transform(&self, service: S) -> Self::Future {
3203 ready(Ok(PathTraversalGuardMiddleware { service }))
3204 }
3205}
3206
3207pub struct PathTraversalGuardMiddleware<S> {
3209 service: S,
3210}
3211
3212impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
3213where
3214 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3215 B: 'static,
3216{
3217 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3218 type Error = ActixError;
3219 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3220
3221 actix_web::dev::forward_ready!(service);
3222
3223 fn call(&self, req: ServiceRequest) -> Self::Future {
3224 let raw = req.path().to_string();
3227 if path_is_traversal(&raw) {
3228 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
3229 let sr = req.into_response(rsp.map_into_boxed_body());
3230 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3231 }
3232 let fut = self.service.call(req);
3233 Box::pin(async move {
3234 let resp = fut.await?;
3235 Ok(resp.map_into_left_body())
3236 })
3237 }
3238}
3239
3240fn path_is_traversal(path: &str) -> bool {
3241 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
3243 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
3244 for seg in once.split('/').chain(twice.split('/')) {
3245 if seg == ".." || seg == "." {
3246 return true;
3247 }
3248 }
3249 twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..")
3252}
3253
3254pub struct CorsHeaders {
3265 pub allowed_origins: Arc<Vec<String>>,
3266}
3267
3268impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
3269where
3270 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3271 B: 'static,
3272{
3273 type Response = ServiceResponse<B>;
3274 type Error = ActixError;
3275 type InitError = ();
3276 type Transform = CorsHeadersMiddleware<S>;
3277 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3278
3279 fn new_transform(&self, service: S) -> Self::Future {
3280 ready(Ok(CorsHeadersMiddleware {
3281 service,
3282 allowed_origins: self.allowed_origins.clone(),
3283 }))
3284 }
3285}
3286
3287pub struct CorsHeadersMiddleware<S> {
3289 service: S,
3290 allowed_origins: Arc<Vec<String>>,
3291}
3292
3293impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
3294where
3295 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3296 B: 'static,
3297{
3298 type Response = ServiceResponse<B>;
3299 type Error = ActixError;
3300 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3301
3302 actix_web::dev::forward_ready!(service);
3303
3304 fn call(&self, req: ServiceRequest) -> Self::Future {
3305 let origin = req
3306 .headers()
3307 .get(header::ORIGIN)
3308 .and_then(|v| v.to_str().ok())
3309 .map(str::to_string);
3310 let allowed = self.allowed_origins.clone();
3311 let fut = self.service.call(req);
3312 Box::pin(async move {
3313 let mut resp = fut.await?;
3314 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
3315 Ok(resp)
3316 })
3317 }
3318}
3319
3320fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
3321 if headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
3331 return;
3332 }
3333 let (origin_value, allow_credentials): (String, bool) = if allowed.is_empty() {
3344 ("*".to_string(), false)
3345 } else {
3346 match origin.filter(|o| allowed.iter().any(|a| a == *o)) {
3347 Some(o) => (o.to_string(), true),
3348 None => return,
3351 }
3352 };
3353
3354 let mut pairs = vec![
3355 ("access-control-allow-origin", origin_value.as_str()),
3356 (
3357 "access-control-allow-methods",
3358 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
3359 ),
3360 (
3361 "access-control-allow-headers",
3362 "Accept, Authorization, Content-Type, DPoP, Git-Protocol, If-Match, If-None-Match, Link, Range, Slug, Origin",
3363 ),
3364 (
3365 "access-control-expose-headers",
3366 "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",
3367 ),
3368 ("access-control-max-age", "86400"),
3369 ];
3370 if allow_credentials {
3373 pairs.push(("access-control-allow-credentials", "true"));
3374 }
3375
3376 for (name, value) in pairs {
3377 if let (Ok(name), Ok(value)) = (
3378 header::HeaderName::from_lowercase(name.as_bytes()),
3379 header::HeaderValue::from_str(value),
3380 ) {
3381 headers.insert(name, value);
3382 }
3383 }
3384}
3385
3386pub struct ErrorLoggingMiddleware;
3402
3403impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
3404where
3405 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3406 B: 'static,
3407{
3408 type Response = ServiceResponse<B>;
3409 type Error = ActixError;
3410 type InitError = ();
3411 type Transform = ErrorLoggingMiddlewareService<S>;
3412 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3413
3414 fn new_transform(&self, service: S) -> Self::Future {
3415 ready(Ok(ErrorLoggingMiddlewareService { service }))
3416 }
3417}
3418
3419pub struct ErrorLoggingMiddlewareService<S> {
3421 service: S,
3422}
3423
3424impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
3425where
3426 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3427 B: 'static,
3428{
3429 type Response = ServiceResponse<B>;
3430 type Error = ActixError;
3431 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3432
3433 actix_web::dev::forward_ready!(service);
3434
3435 fn call(&self, req: ServiceRequest) -> Self::Future {
3436 let method = req.method().as_str().to_string();
3439 let path = req.path().to_string();
3440
3441 let fut = self.service.call(req);
3442 Box::pin(async move {
3443 let response = fut.await?;
3444 let status = response.status();
3445 if status.is_server_error() {
3446 log_5xx(&method, &path, status, response.response().error());
3447 }
3448 Ok(response)
3449 })
3450 }
3451}
3452
3453fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
3457 let chain = match error {
3461 Some(e) => format_error_chain(e),
3462 None => "<no error attached to response>".to_string(),
3463 };
3464
3465 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
3466 Some(std::backtrace::Backtrace::force_capture().to_string())
3467 } else {
3468 None
3469 };
3470
3471 tracing::error!(
3472 target: "solid_pod_rs_server::http",
3473 method = %method,
3474 path = %path,
3475 status = %status.as_u16(),
3476 error.chain = %chain,
3477 backtrace = backtrace.as_deref().unwrap_or(""),
3478 "5xx response"
3479 );
3480}
3481
3482fn format_error_chain(e: &actix_web::Error) -> String {
3493 let summary = format!("{}", e.as_response_error());
3494 let debug = format!("{e:?}");
3495 if debug == summary || debug.is_empty() {
3496 summary
3497 } else {
3498 format!("{summary} -> {debug}")
3499 }
3500}
3501
3502pub struct DotfileGuard {
3508 allow: Arc<DotfileAllowlist>,
3509}
3510
3511impl DotfileGuard {
3512 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
3513 Self { allow }
3514 }
3515}
3516
3517impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
3518where
3519 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3520 B: 'static,
3521{
3522 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3523 type Error = ActixError;
3524 type InitError = ();
3525 type Transform = DotfileGuardMiddleware<S>;
3526 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3527
3528 fn new_transform(&self, service: S) -> Self::Future {
3529 ready(Ok(DotfileGuardMiddleware {
3530 service,
3531 allow: self.allow.clone(),
3532 }))
3533 }
3534}
3535
3536pub struct DotfileGuardMiddleware<S> {
3538 service: S,
3539 allow: Arc<DotfileAllowlist>,
3540}
3541
3542impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
3543where
3544 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3545 B: 'static,
3546{
3547 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3548 type Error = ActixError;
3549 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3550
3551 actix_web::dev::forward_ready!(service);
3552
3553 fn call(&self, req: ServiceRequest) -> Self::Future {
3554 let path = req.path().to_string();
3555 let allow_system_route =
3562 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
3563 if !allow_system_route {
3564 let pb = PathBuf::from(&path);
3565 if !self.allow.is_allowed(Path::new(&pb)) {
3566 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
3567 let sr = req.into_response(rsp.map_into_boxed_body());
3568 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3569 }
3570 }
3571 let fut = self.service.call(req);
3572 Box::pin(async move {
3573 let resp = fut.await?;
3574 Ok(resp.map_into_left_body())
3575 })
3576 }
3577}
3578
3579#[cfg(feature = "git")]
3584pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
3585 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
3586 return None;
3587 }
3588 state.data_root.as_ref().map(|root| root.join(pubkey))
3589}
3590
3591#[cfg(feature = "git")]
3621async fn git_mark_write(
3622 state: &AppState,
3623 resource_path: &str,
3624 agent: Option<&str>,
3625 message: &str,
3626) -> ProvenanceReceipt {
3627 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
3628 use solid_pod_rs_git::mark::ShellGitMarker;
3629
3630 if resource_path.ends_with(".acl")
3633 || resource_path.ends_with(".meta")
3634 || resource_path.ends_with(".prov.ttl")
3635 {
3636 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::ExcludedPath);
3637 }
3638 if resource_path.ends_with('/') {
3640 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::Container);
3641 }
3642
3643 let Some(data_root) = state.data_root.as_ref() else {
3645 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured);
3646 };
3647
3648 let trimmed = resource_path.trim_start_matches('/');
3650 let mut segments = trimmed.splitn(2, '/');
3651 let pod = segments.next().unwrap_or("");
3652 let rel = segments.next().unwrap_or("");
3653 if pod.is_empty() || rel.is_empty() {
3654 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::UnresolvablePath);
3655 }
3656 let repo = data_root.join(pod);
3657
3658 if !repo.join(".git").is_dir() {
3662 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotGitBacked);
3663 }
3664
3665 let agent_did = agent.unwrap_or("urn:solid:anonymous");
3666 let created = std::time::SystemTime::now()
3667 .duration_since(std::time::UNIX_EPOCH)
3668 .map(|d| d.as_secs())
3669 .unwrap_or(0);
3670
3671 let (policy, ticker_override) =
3674 handlers::prov::resolve_anchor_policy(state, resource_path).await;
3675
3676 let marker = std::sync::Arc::new(ShellGitMarker::new());
3681 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
3682 None
3683 } else {
3684 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
3685 };
3686 let (log, ticker, network) = match &anchorer_bundle {
3687 Some((anchorer, ticker, network)) => (
3688 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
3689 ticker.clone(),
3690 network.clone(),
3691 ),
3692 None => (
3694 ProvenanceLog::new(marker.clone()),
3695 String::new(),
3696 String::new(),
3697 ),
3698 };
3699
3700 let record_policy = match policy {
3704 AnchorPolicy::Epoch => AnchorPolicy::Never,
3705 other => other,
3706 };
3707 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
3708
3709 let write_record = solid_pod_rs::provenance::WriteRecord {
3713 repo: &repo,
3714 path: rel,
3715 agent_did,
3716 message,
3717 policy: record_policy,
3718 high_value,
3719 ticker: &ticker,
3720 network: &network,
3721 created,
3722 };
3723 let mut receipt = log.record_receipt(write_record).await;
3728 receipt.resource = resource_path.to_string();
3731
3732 let Some(git) = receipt.mark.clone() else {
3733 tracing::warn!(
3737 target: "solid_pod_rs_server::git_mark",
3738 resource = %resource_path,
3739 "provenance record failed (write already succeeded): {}",
3740 receipt.mark_error.as_deref().unwrap_or("unspecified")
3741 );
3742 return receipt;
3743 };
3744 if let Some(err) = &receipt.anchor_error {
3745 tracing::warn!(
3748 target: "solid_pod_rs_server::git_mark",
3749 resource = %resource_path,
3750 commit = %git.commit_sha,
3751 "block-trail anchor failed (git-mark stands): {err}"
3752 );
3753 }
3754 let mark = solid_pod_rs::provenance::ProvenanceMark {
3755 resource: resource_path.to_string(),
3756 git: git.clone(),
3757 anchor: receipt.anchor.clone(),
3758 agent_did: agent_did.to_string(),
3759 created,
3760 };
3761
3762 if matches!(policy, AnchorPolicy::Epoch) {
3766 if let Some((anchorer, _, _)) = &anchorer_bundle {
3767 match handlers::prov::epoch_push_and_maybe_anchor(
3768 state,
3769 anchorer,
3770 &ticker,
3771 &network,
3772 &mark.git.commit_sha,
3773 )
3774 .await
3775 {
3776 Ok(Some(closed)) => tracing::debug!(
3777 target: "solid_pod_rs_server::git_mark",
3778 root = %closed.root,
3779 n = closed.commits.len(),
3780 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
3781 ),
3782 Ok(None) => {}
3783 Err(e) => tracing::warn!(
3784 target: "solid_pod_rs_server::git_mark",
3785 "epoch batch/anchor failed (swallowed): {e}"
3786 ),
3787 }
3788 }
3789 }
3790
3791 let ttl = prov_ttl(&mark);
3796 let sidecar = format!("{resource_path}.prov.ttl");
3797 if let Err(e) = state
3798 .storage
3799 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
3800 .await
3801 {
3802 tracing::warn!(
3803 target: "solid_pod_rs_server::git_mark",
3804 sidecar = %sidecar,
3805 "provenance sidecar write failed: {e}"
3806 );
3807 receipt.mark_error = Some(format!("PROV-O sidecar write failed: {e}"));
3810 return receipt;
3811 }
3812
3813 tracing::debug!(
3814 target: "solid_pod_rs_server::git_mark",
3815 resource = %resource_path,
3816 commit = %mark.git.commit_sha,
3817 stage = %receipt.stage(),
3818 "provenance recorded"
3819 );
3820 receipt
3821}
3822
3823#[cfg(not(feature = "git"))]
3826#[inline]
3827async fn git_mark_write(
3828 _state: &AppState,
3829 resource_path: &str,
3830 _agent: Option<&str>,
3831 _message: &str,
3832) -> ProvenanceReceipt {
3833 ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured)
3834}
3835
3836#[cfg(feature = "git")]
3837pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
3838 require_pod_owner_with_body(req, pod_pubkey, None).await
3839}
3840
3841#[cfg(feature = "git")]
3842pub(crate) async fn require_pod_owner_with_body(
3843 req: &HttpRequest,
3844 pod_pubkey: &str,
3845 body: Option<&[u8]>,
3846) -> Option<String> {
3847 let caller = extract_pubkey_with_body(req, body).await?;
3848 if caller != pod_pubkey {
3849 return None;
3850 }
3851 Some(caller)
3852}
3853
3854#[cfg(feature = "git")]
3855fn git_json_err(msg: &str, status: u16) -> HttpResponse {
3856 HttpResponse::build(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
3857 .content_type("application/json")
3858 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
3859}
3860
3861#[cfg(feature = "git")]
3863#[derive(serde::Deserialize)]
3864struct GitStageBody {
3865 paths: Option<Vec<String>>,
3866 all: Option<bool>,
3867}
3868
3869#[cfg(feature = "git")]
3870#[derive(serde::Deserialize)]
3871struct GitCommitBody {
3872 message: String,
3873 author_name: Option<String>,
3874 author_email: Option<String>,
3875}
3876
3877#[cfg(feature = "git")]
3878#[derive(serde::Deserialize)]
3879struct GitBranchBody {
3880 name: String,
3881}
3882
3883#[cfg(feature = "git")]
3886async fn handle_git_status(
3887 path: web::Path<String>,
3888 req: HttpRequest,
3889 state: web::Data<AppState>,
3890) -> HttpResponse {
3891 let pubkey = path.into_inner();
3892 if require_pod_owner(&req, &pubkey).await.is_none() {
3893 return git_json_err("Authentication required", 401);
3894 }
3895 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3896 return git_json_err("Git not available (no FS backend)", 501);
3897 };
3898 match solid_pod_rs_git::api::git_status(&repo).await {
3899 Ok(s) => HttpResponse::Ok()
3900 .content_type("application/json")
3901 .body(serde_json::to_string(&s).unwrap_or_default()),
3902 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3903 }
3904}
3905
3906#[cfg(feature = "git")]
3907async fn handle_git_log(
3908 path: web::Path<String>,
3909 req: HttpRequest,
3910 state: web::Data<AppState>,
3911 query: web::Query<std::collections::HashMap<String, String>>,
3912) -> HttpResponse {
3913 let pubkey = path.into_inner();
3914 if require_pod_owner(&req, &pubkey).await.is_none() {
3915 return git_json_err("Authentication required", 401);
3916 }
3917 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3918 return git_json_err("Git not available (no FS backend)", 501);
3919 };
3920 let limit: u32 = query
3921 .get("limit")
3922 .and_then(|v| v.parse().ok())
3923 .unwrap_or(20);
3924 match solid_pod_rs_git::api::git_log(&repo, limit).await {
3925 Ok(entries) => HttpResponse::Ok()
3926 .content_type("application/json")
3927 .body(serde_json::to_string(&entries).unwrap_or_default()),
3928 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3929 }
3930}
3931
3932#[cfg(feature = "git")]
3933async fn handle_git_diff(
3934 path: web::Path<String>,
3935 req: HttpRequest,
3936 state: web::Data<AppState>,
3937 query: web::Query<std::collections::HashMap<String, String>>,
3938) -> HttpResponse {
3939 let pubkey = path.into_inner();
3940 if require_pod_owner(&req, &pubkey).await.is_none() {
3941 return git_json_err("Authentication required", 401);
3942 }
3943 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3944 return git_json_err("Git not available (no FS backend)", 501);
3945 };
3946 let file_path = query.get("path").map(String::as_str);
3947 let staged = query
3948 .get("staged")
3949 .map(|v| v == "true" || v == "1")
3950 .unwrap_or(false);
3951 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
3952 Ok(diff) => HttpResponse::Ok().content_type("text/plain").body(diff),
3953 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3954 }
3955}
3956
3957#[cfg(feature = "git")]
3958async fn handle_git_stage(
3959 path: web::Path<String>,
3960 req: HttpRequest,
3961 state: web::Data<AppState>,
3962 body: web::Bytes,
3963) -> HttpResponse {
3964 let pubkey = path.into_inner();
3965 if require_pod_owner(&req, &pubkey).await.is_none() {
3966 return git_json_err("Authentication required", 401);
3967 }
3968 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3969 return git_json_err("Git not available (no FS backend)", 501);
3970 };
3971 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3972 Ok(v) => v,
3973 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3974 };
3975 let paths = parsed.paths.unwrap_or_default();
3976 let all = parsed.all.unwrap_or(false);
3977 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
3978 Ok(()) => HttpResponse::Ok()
3979 .content_type("application/json")
3980 .body(r#"{"ok":true}"#),
3981 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3982 }
3983}
3984
3985#[cfg(feature = "git")]
3986async fn handle_git_unstage(
3987 path: web::Path<String>,
3988 req: HttpRequest,
3989 state: web::Data<AppState>,
3990 body: web::Bytes,
3991) -> HttpResponse {
3992 let pubkey = path.into_inner();
3993 if require_pod_owner(&req, &pubkey).await.is_none() {
3994 return git_json_err("Authentication required", 401);
3995 }
3996 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3997 return git_json_err("Git not available (no FS backend)", 501);
3998 };
3999 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4000 Ok(v) => v,
4001 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4002 };
4003 let paths = parsed.paths.unwrap_or_default();
4004 let all = parsed.all.unwrap_or(false);
4005 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
4006 Ok(()) => HttpResponse::Ok()
4007 .content_type("application/json")
4008 .body(r#"{"ok":true}"#),
4009 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4010 }
4011}
4012
4013#[cfg(feature = "git")]
4014async fn handle_git_commit(
4015 path: web::Path<String>,
4016 req: HttpRequest,
4017 state: web::Data<AppState>,
4018 body: web::Bytes,
4019) -> HttpResponse {
4020 let pubkey = path.into_inner();
4021 if require_pod_owner(&req, &pubkey).await.is_none() {
4022 return git_json_err("Authentication required", 401);
4023 }
4024 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4025 return git_json_err("Git not available (no FS backend)", 501);
4026 };
4027 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
4028 Ok(v) => v,
4029 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4030 };
4031 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
4032 let author_email = parsed
4033 .author_email
4034 .as_deref()
4035 .unwrap_or("pod@dreamlab-ai.com");
4036 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email).await
4037 {
4038 Ok(result) => HttpResponse::Ok()
4039 .content_type("application/json")
4040 .body(serde_json::to_string(&result).unwrap_or_default()),
4041 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4042 }
4043}
4044
4045#[cfg(feature = "git")]
4046async fn handle_git_branches(
4047 path: web::Path<String>,
4048 req: HttpRequest,
4049 state: web::Data<AppState>,
4050) -> HttpResponse {
4051 let pubkey = path.into_inner();
4052 if require_pod_owner(&req, &pubkey).await.is_none() {
4053 return git_json_err("Authentication required", 401);
4054 }
4055 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4056 return git_json_err("Git not available (no FS backend)", 501);
4057 };
4058 match solid_pod_rs_git::api::git_branches(&repo).await {
4059 Ok(info) => HttpResponse::Ok()
4060 .content_type("application/json")
4061 .body(serde_json::to_string(&info).unwrap_or_default()),
4062 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4063 }
4064}
4065
4066#[cfg(feature = "git")]
4067async fn handle_git_create_branch(
4068 path: web::Path<String>,
4069 req: HttpRequest,
4070 state: web::Data<AppState>,
4071 body: web::Bytes,
4072) -> HttpResponse {
4073 let pubkey = path.into_inner();
4074 if require_pod_owner(&req, &pubkey).await.is_none() {
4075 return git_json_err("Authentication required", 401);
4076 }
4077 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4078 return git_json_err("Git not available (no FS backend)", 501);
4079 };
4080 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
4081 Ok(v) => v,
4082 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4083 };
4084 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
4085 Ok(()) => HttpResponse::Ok()
4086 .content_type("application/json")
4087 .body(r#"{"ok":true}"#),
4088 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4089 }
4090}
4091
4092#[cfg(feature = "git")]
4093async fn handle_git_discard(
4094 path: web::Path<String>,
4095 req: HttpRequest,
4096 state: web::Data<AppState>,
4097 body: web::Bytes,
4098) -> HttpResponse {
4099 let pubkey = path.into_inner();
4100 if require_pod_owner(&req, &pubkey).await.is_none() {
4101 return git_json_err("Authentication required", 401);
4102 }
4103 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4104 return git_json_err("Git not available (no FS backend)", 501);
4105 };
4106 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4107 Ok(v) => v,
4108 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4109 };
4110 let paths = parsed.paths.unwrap_or_default();
4111 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
4112 Ok(()) => HttpResponse::Ok()
4113 .content_type("application/json")
4114 .body(r#"{"ok":true}"#),
4115 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4116 }
4117}
4118
4119async fn handle_git_panel_options(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
4127 let origin = req
4128 .headers()
4129 .get(header::ORIGIN)
4130 .and_then(|v| v.to_str().ok())
4131 .map(str::to_string);
4132
4133 let mut rsp = HttpResponse::NoContent().finish();
4134 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
4135 rsp
4136}
4137
4138async fn handle_admin_provision(
4155 req: HttpRequest,
4156 state: web::Data<AppState>,
4157 path: web::Path<String>,
4158) -> HttpResponse {
4159 let expected = match &state.admin_key {
4161 Some(k) => k.clone(),
4162 None => {
4163 return HttpResponse::Forbidden().json(serde_json::json!({
4164 "error": "admin key not configured on this server"
4165 }));
4166 }
4167 };
4168 let provided = req
4169 .headers()
4170 .get("x-pod-admin-key")
4171 .and_then(|v| v.to_str().ok())
4172 .unwrap_or("");
4173 use subtle::ConstantTimeEq;
4178 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
4179 if !bool::from(key_match) {
4180 return HttpResponse::Forbidden().json(serde_json::json!({"error": "invalid admin key"}));
4181 }
4182
4183 let pubkey = path.into_inner();
4185 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
4186 return HttpResponse::BadRequest()
4187 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
4188 }
4189
4190 let data_root = match &state.data_root {
4192 Some(r) => r.clone(),
4193 None => {
4194 return HttpResponse::InternalServerError().json(serde_json::json!({
4195 "error": "server has no fs-backend storage configured"
4196 }));
4197 }
4198 };
4199
4200 let pods_root = data_root.join("pods");
4213 let pod_dir = pods_root.join(&pubkey);
4214
4215 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
4217 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
4218 return HttpResponse::InternalServerError()
4219 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
4220 }
4221
4222 let acl_content = format!(
4232 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
4233 <#owner> a acl:Authorization ;\n\
4234 acl:agent <did:nostr:{pubkey}> ;\n\
4235 acl:accessTo </pods/{pubkey}/> ;\n\
4236 acl:default </pods/{pubkey}/> ;\n\
4237 acl:mode acl:Read, acl:Write, acl:Control .\n"
4238 );
4239
4240 let sibling_acl_path = pods_root.join(format!("{pubkey}.acl"));
4250 if !sibling_acl_path.exists() {
4251 if let Err(e) = tokio::fs::write(&sibling_acl_path, acl_content.as_bytes()).await {
4252 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write sibling .acl failed");
4253 return HttpResponse::InternalServerError()
4254 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
4255 }
4256 }
4257
4258 let inner_acl_path = pod_dir.join(".acl");
4265 if !inner_acl_path.exists() {
4266 if let Err(e) = tokio::fs::write(&inner_acl_path, acl_content.as_bytes()).await {
4267 tracing::warn!(pubkey = %pubkey, error = %e, "/_admin/provision: write inner .acl failed (non-fatal; sibling ACL governs)");
4268 }
4269 }
4270
4271 #[cfg(feature = "git")]
4273 {
4274 use tokio::process::Command;
4275
4276 if !pod_dir.join(".git").exists() {
4278 let init_out = Command::new("git")
4279 .args(["init", "-b", "main", pod_dir.to_str().unwrap_or(".")])
4280 .output()
4281 .await;
4282
4283 match init_out {
4284 Ok(out) if out.status.success() => {}
4285 Ok(out) => {
4286 let stderr = String::from_utf8_lossy(&out.stderr);
4287 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
4288 }
4289 Err(e) => {
4290 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
4291 }
4292 }
4293
4294 let cfg_out = Command::new("git")
4297 .args([
4298 "-C",
4299 pod_dir.to_str().unwrap_or("."),
4300 "config",
4301 "receive.denyCurrentBranch",
4302 "updateInstead",
4303 ])
4304 .output()
4305 .await;
4306
4307 if let Err(e) = cfg_out {
4308 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
4309 }
4310 }
4311 }
4312
4313 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
4315 HttpResponse::Ok().json(serde_json::json!({
4316 "podUrl": format!("{base_url}/pods/{pubkey}/"),
4317 "ok": true,
4318 }))
4319}
4320
4321async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
4326 let Some(ref data_root) = state.data_root else {
4327 return HttpResponse::Ok()
4328 .content_type("application/json")
4329 .json(serde_json::json!({"apps": [], "count": 0}));
4330 };
4331
4332 let server_url = state.nodeinfo.base_url.clone();
4333
4334 let mut read_dir = match tokio::fs::read_dir(data_root).await {
4336 Ok(rd) => rd,
4337 Err(_) => {
4338 return HttpResponse::Ok()
4339 .content_type("application/json")
4340 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
4341 }
4342 };
4343
4344 let mut apps: Vec<serde_json::Value> = Vec::new();
4345 let mut scanned = 0usize;
4346
4347 while scanned < 1000 {
4348 let entry = match read_dir.next_entry().await {
4349 Ok(Some(e)) => e,
4350 Ok(None) => break,
4351 Err(_) => break,
4352 };
4353
4354 let file_type = match entry.file_type().await {
4355 Ok(ft) => ft,
4356 Err(_) => continue,
4357 };
4358 if !file_type.is_dir() {
4359 continue;
4360 }
4361
4362 scanned += 1;
4363
4364 let manifest_path = entry.path().join("apps").join("manifest.json");
4365 let contents = match tokio::fs::read(&manifest_path).await {
4366 Ok(c) => c,
4367 Err(_) => continue,
4368 };
4369
4370 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
4371 Ok(v) => v,
4372 Err(_) => continue,
4373 };
4374
4375 if let Some(pod_name) = entry.file_name().to_str() {
4377 if manifest.get("podOwner").is_none() {
4378 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
4379 }
4380 }
4381
4382 apps.push(manifest);
4383 }
4384
4385 let count = apps.len();
4386 HttpResponse::Ok()
4387 .content_type("application/json")
4388 .json(serde_json::json!({
4389 "apps": apps,
4390 "serverUrl": server_url,
4391 "count": count,
4392 }))
4393}
4394
4395#[allow(dead_code)]
4408fn is_git_request(path: &str) -> bool {
4409 path.contains("/info/refs")
4410 || path.contains("/git-upload-pack")
4411 || path.contains("/git-receive-pack")
4412}
4413
4414#[allow(dead_code)]
4417fn is_dot_git_path(path: &str) -> bool {
4418 path.contains("/.git/") || path.ends_with("/.git")
4419}
4420
4421#[cfg(feature = "git")]
4422async fn handle_git(
4423 req: HttpRequest,
4424 body: web::Bytes,
4425 state: web::Data<AppState>,
4426) -> HttpResponse {
4427 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
4428 use solid_pod_rs_git::service::GitRequest;
4429
4430 let path = req.uri().path().to_string();
4431
4432 let pod_name = path
4435 .trim_start_matches('/')
4436 .split('/')
4437 .next()
4438 .unwrap_or("")
4439 .to_string();
4440 let Some(ref data_root) = state.data_root else {
4441 return HttpResponse::NotImplemented().json(serde_json::json!({
4442 "error": "git requires fs-backend storage",
4443 "reason": "data_root_not_configured"
4444 }));
4445 };
4446 let repo_root = data_root.join(&pod_name);
4447 if !repo_root.exists() {
4448 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
4449 }
4450
4451 let query = req.uri().query().unwrap_or("").to_string();
4452 let host_url = {
4453 let conn = req.connection_info();
4454 Some(format!("{}://{}", conn.scheme(), conn.host()))
4455 };
4456 let headers: Vec<(String, String)> = req
4457 .headers()
4458 .iter()
4459 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
4460 .collect();
4461
4462 let git_req = GitRequest {
4463 method: req.method().as_str().to_string(),
4464 path,
4465 query,
4466 headers,
4467 body,
4468 host_url,
4469 };
4470
4471 let is_write = git_req.is_write();
4483 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
4484 Ok(pk) => Some(format!("did:nostr:{pk}")),
4485 Err(_) => None,
4486 };
4487 let wac_path = format!("/{pod_name}/");
4488 let origin = req_origin(&req);
4489 let wac = if is_write {
4490 enforce_write_ctx(
4491 &state,
4492 &wac_path,
4493 AccessMode::Write,
4494 agent.as_deref(),
4495 origin,
4496 )
4497 .await
4498 } else {
4499 enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin)
4503 .await
4504 .map(|_audience| ())
4505 };
4506 if let Err(e) = wac {
4507 let mut resp = e.error_response();
4511 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4512 if let (Ok(name), Ok(value)) = (
4513 actix_web::http::header::HeaderName::from_bytes(k.as_bytes()),
4514 actix_web::http::header::HeaderValue::from_str(v),
4515 ) {
4516 resp.headers_mut().insert(name, value);
4517 }
4518 }
4519 return resp;
4520 }
4521
4522 let service = git_push::pod_git_service(repo_root, state.dotfiles.clone());
4523 match service.handle(git_req).await {
4524 Ok(git_resp) => {
4525 let mut builder = HttpResponse::build(
4526 actix_web::http::StatusCode::from_u16(git_resp.status)
4527 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4528 );
4529 for (k, v) in &git_resp.headers {
4530 builder.insert_header((k.as_str(), v.as_str()));
4531 }
4532 builder.body(git_resp.body)
4533 }
4534 Err(e) => {
4535 let status = e.status_code();
4536 let mut builder = HttpResponse::build(
4537 actix_web::http::StatusCode::from_u16(status)
4538 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4539 );
4540 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4541 builder.insert_header((k, v));
4542 }
4543 builder.json(serde_json::json!({"error": e.to_string()}))
4544 }
4545 }
4546}
4547
4548#[cfg(feature = "forge")]
4557fn forge_plugin_dir(state: &AppState) -> Option<PathBuf> {
4558 state.data_root.as_ref().map(|r| r.join(".forge"))
4559}
4560
4561#[cfg(feature = "forge")]
4567struct ServerLoopback {
4568 client: reqwest::Client,
4569}
4570
4571#[cfg(feature = "forge")]
4572#[async_trait::async_trait]
4573impl solid_pod_rs_forge::LoopbackFetch for ServerLoopback {
4574 async fn get(
4575 &self,
4576 url: &str,
4577 max_bytes: usize,
4578 timeout_secs: u64,
4579 ) -> solid_pod_rs_forge::bodies::FetchResult {
4580 use solid_pod_rs_forge::bodies::FetchResult;
4581 let resp = match self
4582 .client
4583 .get(url)
4584 .timeout(Duration::from_secs(timeout_secs.max(1)))
4585 .send()
4586 .await
4587 {
4588 Ok(r) => r,
4589 Err(e) => return FetchResult::Error(e.to_string()),
4590 };
4591 let code = resp.status().as_u16();
4592 if code == 404 || code == 410 {
4593 return FetchResult::Removed;
4594 }
4595 if !resp.status().is_success() {
4596 return FetchResult::Error(format!("status {code}"));
4597 }
4598 if resp
4599 .content_length()
4600 .is_some_and(|length| length > max_bytes as u64)
4601 {
4602 return FetchResult::TooLarge;
4603 }
4604 let mut stream = resp.bytes_stream();
4605 let mut body = Vec::with_capacity(max_bytes.min(16 * 1024));
4606 while let Some(chunk) = stream.next().await {
4607 match chunk {
4608 Ok(chunk) if body.len().saturating_add(chunk.len()) <= max_bytes => {
4609 body.extend_from_slice(&chunk);
4610 }
4611 Ok(_) => return FetchResult::TooLarge,
4612 Err(error) => return FetchResult::Error(error.to_string()),
4613 }
4614 }
4615 FetchResult::Body(body)
4616 }
4617}
4618
4619#[cfg(feature = "forge")]
4625async fn handle_forge(
4626 req: HttpRequest,
4627 body: web::Bytes,
4628 state: web::Data<AppState>,
4629) -> HttpResponse {
4630 use solid_pod_rs_forge::{ForgeConfig, ForgeRequest, ForgeService};
4631
4632 let Some(plugin_dir) = forge_plugin_dir(&state) else {
4633 return HttpResponse::NotImplemented().json(serde_json::json!({
4634 "error": "forge requires fs-backend storage",
4635 "reason": "data_root_not_configured"
4636 }));
4637 };
4638
4639 let loopback: Arc<dyn solid_pod_rs_forge::LoopbackFetch> = Arc::new(ServerLoopback {
4644 client: reqwest::Client::new(),
4645 });
4646 let service = match ForgeService::new(ForgeConfig::default(), plugin_dir) {
4647 Ok(s) => s.with_loopback(loopback),
4648 Err(e) => {
4649 return HttpResponse::InternalServerError()
4650 .json(serde_json::json!({"error": e.to_string()}));
4651 }
4652 };
4653
4654 let path = req.uri().path().to_string();
4655 let query = req.uri().query().unwrap_or("").to_string();
4656 let host_url = {
4657 let conn = req.connection_info();
4658 Some(format!("{}://{}", conn.scheme(), conn.host()))
4659 };
4660 let headers: Vec<(String, String)> = req
4661 .headers()
4662 .iter()
4663 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
4664 .collect();
4665
4666 let forge_req = ForgeRequest {
4667 method: req.method().as_str().to_string(),
4668 path,
4669 query,
4670 headers,
4671 raw_body: body,
4672 host_url,
4673 };
4674
4675 let agent = service.resolve_agent(&forge_req).await;
4680
4681 match service.handle(forge_req, agent).await {
4682 Ok(resp) => {
4683 let mut builder = HttpResponse::build(
4684 StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
4685 );
4686 for (k, v) in &resp.headers {
4687 builder.insert_header((k.as_str(), v.as_str()));
4688 }
4689 builder.body(resp.body)
4690 }
4691 Err(e) => {
4692 let status = e.status_code();
4693 HttpResponse::build(
4694 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
4695 )
4696 .json(serde_json::json!({"error": e.to_string()}))
4697 }
4698 }
4699}
4700
4701pub fn build_app(
4713 state: AppState,
4714) -> App<
4715 impl actix_web::dev::ServiceFactory<
4716 ServiceRequest,
4717 Config = (),
4718 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
4719 Error = ActixError,
4720 InitError = (),
4721 >,
4722> {
4723 let body_cap = state.body_cap;
4724 let dotfiles = state.dotfiles.clone();
4725 let allowed_origins = Arc::new(state.allowed_origins.clone());
4726
4727 let mut app = App::new()
4728 .app_data(web::Data::new(state.clone()))
4729 .app_data(web::PayloadConfig::new(body_cap))
4730 .wrap(ErrorLoggingMiddleware)
4735 .wrap(CorsHeaders { allowed_origins })
4736 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
4740 .wrap(PathTraversalGuard)
4741 .wrap(DotfileGuard::new(dotfiles));
4742
4743 app = app
4749 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
4750 .route(
4751 "/.well-known/webfinger",
4752 web::get().to(handle_well_known_webfinger),
4753 )
4754 .route(
4755 "/.well-known/nodeinfo",
4756 web::get().to(handle_well_known_nodeinfo),
4757 )
4758 .route(
4759 "/.well-known/nodeinfo/2.1",
4760 web::get().to(handle_well_known_nodeinfo_2_1),
4761 );
4762
4763 #[cfg(feature = "did-nostr")]
4764 {
4765 app = app.route(
4766 "/.well-known/did/nostr/{pubkey}.json",
4767 web::get().to(handle_well_known_did_nostr),
4768 );
4769 }
4770
4771 #[cfg(feature = "nip05-endpoint")]
4776 {
4777 app = app.route(
4778 "/.well-known/nostr.json",
4779 web::get().to(handle_well_known_nip05),
4780 );
4781 }
4782
4783 #[cfg(feature = "export-jsonld")]
4788 {
4789 app = app.route("/api/exports/all", web::get().to(handle_export_all));
4790 }
4791
4792 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
4794
4795 app = app.route("/pay/.info", web::get().to(handle_pay_info));
4797
4798 app = app.configure(handlers::pay::register);
4803
4804 app = app.route("/proxy", web::get().to(handle_proxy));
4806
4807 if state.mcp_enabled {
4811 app = app.route("/mcp", web::post().to(mcp::handle_mcp)).route(
4812 "/mcp",
4813 web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options),
4814 );
4815 }
4816
4817 app = app.route(
4820 "/_admin/provision/{pubkey}",
4821 web::post().to(handle_admin_provision),
4822 );
4823
4824 app = app
4826 .route("/.pods", web::post().to(handle_create_pod))
4827 .route("/api/accounts/new", web::post().to(handle_create_account))
4828 .route("/pods/check/{name}", web::get().to(handle_pod_check))
4829 .route("/login/password", web::post().to(handle_login_password))
4830 .route(
4831 "/account/password/reset",
4832 web::post().to(handle_password_reset_request),
4833 )
4834 .route(
4835 "/account/password/change",
4836 web::post().to(handle_password_change),
4837 );
4838
4839 #[cfg(feature = "forge")]
4844 {
4845 app = app
4846 .route("/forge", web::route().to(handle_forge))
4847 .route("/forge/{tail:.*}", web::route().to(handle_forge));
4848 }
4849
4850 app = app
4855 .route(
4856 "/{tail:.*}/.git",
4858 web::route().to(|| async {
4859 HttpResponse::Forbidden()
4860 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4861 }),
4862 )
4863 .route(
4864 "/{tail:.*}/.git/{rest:.*}",
4865 web::route().to(|| async {
4866 HttpResponse::Forbidden()
4867 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4868 }),
4869 );
4870
4871 app = app.route(
4875 "/pods/{pk}/_git/{tail:.*}",
4876 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
4877 );
4878
4879 #[cfg(feature = "git")]
4880 {
4881 app = app
4883 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
4884 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
4885 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
4886
4887 app = app
4890 .route(
4891 "/pods/{pubkey}/_git/status",
4892 web::get().to(handle_git_status),
4893 )
4894 .route("/pods/{pubkey}/_git/log", web::get().to(handle_git_log))
4895 .route("/pods/{pubkey}/_git/diff", web::get().to(handle_git_diff))
4896 .route(
4897 "/pods/{pubkey}/_git/stage",
4898 web::post().to(handle_git_stage),
4899 )
4900 .route(
4901 "/pods/{pubkey}/_git/unstage",
4902 web::post().to(handle_git_unstage),
4903 )
4904 .route(
4905 "/pods/{pubkey}/_git/commit",
4906 web::post().to(handle_git_commit),
4907 )
4908 .route(
4909 "/pods/{pubkey}/_git/branches",
4910 web::get().to(handle_git_branches),
4911 )
4912 .route(
4913 "/pods/{pubkey}/_git/branch",
4914 web::post().to(handle_git_create_branch),
4915 )
4916 .route(
4917 "/pods/{pubkey}/_git/discard",
4918 web::post().to(handle_git_discard),
4919 );
4920
4921 app = app.configure(handlers::prov::register);
4928 }
4929 #[cfg(not(feature = "git"))]
4930 {
4931 let git_501 = || async {
4935 HttpResponse::NotImplemented()
4936 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
4937 };
4938 app = app
4939 .route("/{tail:.*}/info/refs", web::get().to(git_501))
4940 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
4941 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
4942 }
4943
4944 app.route("/{tail:.*}/", web::post().to(handle_post))
4947 .route("/{tail:.*}/", web::put().to(handle_put))
4948 .route("/{tail:.*}", web::get().to(handle_get))
4949 .route("/{tail:.*}", web::head().to(handle_get))
4950 .route("/{tail:.*}", web::put().to(handle_put))
4951 .route("/{tail:.*}", web::patch().to(handle_patch))
4952 .route("/{tail:.*}", web::delete().to(handle_delete))
4953 .route(
4954 "/{tail:.*}",
4955 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
4956 )
4957 .route(
4958 "/{tail:.*}",
4959 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
4960 )
4961}
4962
4963#[cfg(test)]
4968mod payment_gating_tests {
4969 use super::*;
4970 use solid_pod_rs::payments::WebLedger;
4971 use solid_pod_rs::storage::memory::MemoryBackend;
4972
4973 macro_rules! principal {
4977 () => {
4978 "did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
4979 };
4980 }
4981
4982 const PRINCIPAL: &str = principal!();
4983
4984 const OTHER_PRINCIPAL: &str =
4986 "did:nostr:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
4987
4988 const PAID_WRITE_ACL: &str = concat!(
4991 r#"
4992@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4993
4994<#paid-write> a acl:Authorization ;
4995 acl:agent <"#,
4996 principal!(),
4997 r#"> ;
4998 acl:accessTo </premium/inbox> ;
4999 acl:mode acl:Write ;
5000 acl:condition [
5001 a acl:PaymentCondition ;
5002 acl:costSats 100
5003 ] .
5004"#
5005 );
5006
5007 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
5008 let mut ledger = WebLedger::new("Test Pod Credits");
5009 if sats > 0 {
5010 ledger
5011 .credit_by_outpoint(did, "satoshi", &"ab".repeat(32), 0, sats)
5012 .unwrap();
5013 }
5014 let body = serde_json::to_vec(&ledger).unwrap();
5015 storage
5016 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
5017 .await
5018 .unwrap();
5019 }
5020
5021 async fn seed_acl(storage: &dyn Storage) {
5022 storage
5023 .put(
5024 "/premium/inbox.acl",
5025 Bytes::from(PAID_WRITE_ACL),
5026 "text/turtle",
5027 )
5028 .await
5029 .unwrap();
5030 }
5031
5032 #[actix_web::test]
5034 async fn resolve_balance_reads_ledger_entry() {
5035 let storage = MemoryBackend::new();
5036 seed_ledger(&storage, PRINCIPAL, 250).await;
5037 assert_eq!(
5038 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5039 Some(250)
5040 );
5041 }
5042
5043 #[actix_web::test]
5045 async fn resolve_balance_zero_when_no_entry() {
5046 let storage = MemoryBackend::new();
5047 seed_ledger(&storage, OTHER_PRINCIPAL, 500).await;
5048 assert_eq!(
5049 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5050 Some(0)
5051 );
5052 }
5053
5054 #[test]
5060 fn fixtures_use_teller_accounts_and_grant_the_principal() {
5061 use solid_pod_rs::payments::account_of;
5062 for did in [PRINCIPAL, OTHER_PRINCIPAL] {
5063 assert_eq!(account_of(did).unwrap(), did, "{did} must be canonical");
5064 }
5065 assert_ne!(PRINCIPAL, OTHER_PRINCIPAL);
5066 assert!(account_of("did:nostr:bob").is_err());
5067 let agent = format!("acl:agent <{PRINCIPAL}>");
5068 for (name, acl) in [
5069 ("PAID_WRITE_ACL", PAID_WRITE_ACL),
5070 ("WRITE_NOT_CONTROL_ACL", WRITE_NOT_CONTROL_ACL),
5071 ] {
5072 assert!(acl.contains(&agent), "{name} must grant PRINCIPAL");
5073 }
5074 assert!(!WRITE_NOT_CONTROL_ACL.contains("did:nostr:alice"));
5075 }
5076
5077 #[actix_web::test]
5080 async fn uncharged_read_skips_pay_per_read_resource() {
5081 let storage = Arc::new(MemoryBackend::new());
5082 let acl = format!(
5083 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
5084 <#paid> a acl:Authorization ; acl:agent <{PRINCIPAL}> ;\n\
5085 acl:accessTo </premium/doc> ; acl:mode acl:Read ;\n\
5086 acl:condition [ a acl:PaymentCondition ; acl:costSats 100 ] .\n"
5087 );
5088 storage
5089 .put("/premium/doc.acl", Bytes::from(acl), "text/turtle")
5090 .await
5091 .unwrap();
5092 seed_ledger(storage.as_ref(), PRINCIPAL, 500).await;
5093 let state = AppState::new(storage.clone());
5094
5095 let skipped =
5096 enforce_read_inner(&state, "/premium/doc", Some(PRINCIPAL), None, false).await;
5097 assert!(skipped.is_err(), "glob must not serve a pay-per-read child");
5098 assert_eq!(
5099 resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5100 Some(500)
5101 );
5102
5103 enforce_read_ctx(&state, "/premium/doc", Some(PRINCIPAL), None)
5104 .await
5105 .expect("a direct read pays and is served");
5106 assert_eq!(
5107 resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5108 Some(400)
5109 );
5110 }
5111
5112 #[actix_web::test]
5114 async fn resolve_balance_none_when_anonymous() {
5115 let storage = MemoryBackend::new();
5116 seed_ledger(&storage, PRINCIPAL, 1_000).await;
5117 assert_eq!(resolve_balance_sats(&storage, None).await, None);
5118 }
5119
5120 #[actix_web::test]
5122 async fn paid_write_denied_below_balance() {
5123 let storage = Arc::new(MemoryBackend::new());
5124 seed_acl(storage.as_ref()).await;
5125 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
5127
5128 let result =
5129 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5130 assert!(
5131 result.is_err(),
5132 "balance 50 < cost 100 must be denied — sat-gating loop closed"
5133 );
5134 }
5135
5136 #[actix_web::test]
5138 async fn paid_write_allowed_at_balance() {
5139 let storage = Arc::new(MemoryBackend::new());
5140 seed_acl(storage.as_ref()).await;
5141 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
5143
5144 let result =
5145 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5146 assert!(
5147 result.is_ok(),
5148 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
5149 );
5150 }
5151
5152 #[actix_web::test]
5154 async fn paid_write_allowed_above_balance() {
5155 let storage = Arc::new(MemoryBackend::new());
5156 seed_acl(storage.as_ref()).await;
5157 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
5158 let state = AppState::new(storage);
5159
5160 let result =
5161 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5162 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
5163 }
5164
5165 #[actix_web::test]
5169 async fn paid_write_anonymous_denied() {
5170 let storage = Arc::new(MemoryBackend::new());
5171 seed_acl(storage.as_ref()).await;
5172 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
5173 let state = AppState::new(storage);
5174
5175 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
5176 assert!(
5177 result.is_err(),
5178 "anonymous caller has no ledger principal — PaymentCondition fails closed"
5179 );
5180 }
5181
5182 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
5189 let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
5190 let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
5191 ledger.get_balance(did)
5192 }
5193
5194 #[actix_web::test]
5196 async fn paid_write_debits_ledger() {
5197 let storage = Arc::new(MemoryBackend::new());
5198 seed_acl(storage.as_ref()).await;
5199 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
5201
5202 let result =
5203 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5204 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
5205 assert_eq!(
5206 read_balance(storage.as_ref(), PRINCIPAL).await,
5207 150,
5208 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
5209 );
5210 }
5211
5212 #[actix_web::test]
5215 async fn paid_write_debits_each_grant() {
5216 let storage = Arc::new(MemoryBackend::new());
5217 seed_acl(storage.as_ref()).await;
5218 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
5220
5221 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5222 .await
5223 .unwrap();
5224 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5225 .await
5226 .unwrap();
5227 assert_eq!(
5228 read_balance(storage.as_ref(), PRINCIPAL).await,
5229 50,
5230 "250 - 2*100: each granted request debits, no unmetered re-use"
5231 );
5232
5233 let third =
5235 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5236 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
5237 assert_eq!(
5238 read_balance(storage.as_ref(), PRINCIPAL).await,
5239 50,
5240 "a denied request must not debit"
5241 );
5242 }
5243
5244 #[actix_web::test]
5246 async fn paid_read_debits_ledger() {
5247 const PAID_READ_ACL: &str = r#"
5248@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5249
5250<#paid-read> a acl:Authorization ;
5251 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5252 acl:accessTo </premium/feed> ;
5253 acl:mode acl:Read ;
5254 acl:condition [
5255 a acl:PaymentCondition ;
5256 acl:costSats 30
5257 ] .
5258"#;
5259 let storage = Arc::new(MemoryBackend::new());
5260 storage
5261 .put(
5262 "/premium/feed.acl",
5263 Bytes::from(PAID_READ_ACL),
5264 "text/turtle",
5265 )
5266 .await
5267 .unwrap();
5268 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5269 let state = AppState::new(storage.clone());
5270
5271 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
5272 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
5273 assert_eq!(
5274 read_balance(storage.as_ref(), PRINCIPAL).await,
5275 70,
5276 "100 - 30 cost: a granted paid read must debit"
5277 );
5278 }
5279
5280 #[actix_web::test]
5283 async fn free_read_does_not_debit() {
5284 let storage = Arc::new(MemoryBackend::new());
5285 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5287 let state = AppState::new(storage.clone());
5288
5289 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
5290 .await
5291 .unwrap();
5292 assert_eq!(
5293 read_balance(storage.as_ref(), PRINCIPAL).await,
5294 100,
5295 "a grant with no PaymentCondition must not debit"
5296 );
5297 }
5298
5299 const ALICE_ONLY_READ_ACL: &str = r#"
5305@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5306
5307<#alice> a acl:Authorization ;
5308 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5309 acl:accessTo </private/secret> ;
5310 acl:default </private/> ;
5311 acl:mode acl:Read, acl:Write, acl:Control .
5312"#;
5313
5314 async fn seed_private_read_acl(storage: &dyn Storage) {
5315 storage
5320 .put(
5321 "/private.acl",
5322 Bytes::from(ALICE_ONLY_READ_ACL),
5323 "text/turtle",
5324 )
5325 .await
5326 .unwrap();
5327 }
5328
5329 #[actix_web::test]
5333 async fn enforce_read_grants_owner() {
5334 let storage = Arc::new(MemoryBackend::new());
5335 seed_private_read_acl(storage.as_ref()).await;
5336 let state = AppState::new(storage);
5337 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
5338 assert!(result.is_ok(), "owner alice must be granted Read");
5339 }
5340
5341 #[actix_web::test]
5344 async fn enforce_read_denies_other_principal() {
5345 let storage = Arc::new(MemoryBackend::new());
5346 seed_private_read_acl(storage.as_ref()).await;
5347 let state = AppState::new(storage);
5348 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
5349 assert!(
5350 result.is_err(),
5351 "bob has no Read grant — private resource must not be world-readable"
5352 );
5353 }
5354
5355 #[actix_web::test]
5358 async fn enforce_read_denies_anonymous() {
5359 let storage = Arc::new(MemoryBackend::new());
5360 seed_private_read_acl(storage.as_ref()).await;
5361 let state = AppState::new(storage);
5362 let result = enforce_read(&state, "/private/secret", None).await;
5363 assert!(result.is_err(), "anonymous Read must be denied");
5364 }
5365
5366 const WRITE_NOT_CONTROL_ACL: &str = concat!(
5374 r#"
5375@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5376
5377<#owner> a acl:Authorization ;
5378 acl:agent <"#,
5379 principal!(),
5380 r#"> ;
5381 acl:accessTo </shared/doc> ;
5382 acl:default </shared/> ;
5383 acl:mode acl:Read, acl:Write, acl:Control .
5384
5385<#writer> a acl:Authorization ;
5386 acl:agent <did:nostr:writer> ;
5387 acl:accessTo </shared/doc> ;
5388 acl:default </shared/> ;
5389 acl:mode acl:Read, acl:Write .
5390"#
5391 );
5392
5393 async fn seed_shared_acl(storage: &dyn Storage) {
5394 storage
5399 .put(
5400 "/shared.acl",
5401 Bytes::from(WRITE_NOT_CONTROL_ACL),
5402 "text/turtle",
5403 )
5404 .await
5405 .unwrap();
5406 }
5407
5408 #[actix_web::test]
5412 async fn acl_put_denied_for_writer_without_control() {
5413 let storage = Arc::new(MemoryBackend::new());
5414 seed_shared_acl(storage.as_ref()).await;
5415 let state = AppState::new(storage);
5416 let result = enforce_write(
5420 &state,
5421 "/shared/.acl",
5422 AccessMode::Write,
5423 Some("did:nostr:writer"),
5424 )
5425 .await;
5426 assert!(
5427 result.is_err(),
5428 "writer lacks Control — must not be able to PUT /shared/.acl"
5429 );
5430 }
5431
5432 #[actix_web::test]
5434 async fn acl_put_allowed_for_control_holder() {
5435 let storage = Arc::new(MemoryBackend::new());
5436 seed_shared_acl(storage.as_ref()).await;
5437 let state = AppState::new(storage);
5438 let result =
5439 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
5440 assert!(
5441 result.is_ok(),
5442 "the owner holds Control — must be allowed to PUT /shared/.acl"
5443 );
5444 }
5445
5446 #[actix_web::test]
5448 async fn meta_put_denied_for_writer_without_control() {
5449 let storage = Arc::new(MemoryBackend::new());
5450 seed_shared_acl(storage.as_ref()).await;
5451 let state = AppState::new(storage);
5452 let result = enforce_write(
5453 &state,
5454 "/shared/doc.meta",
5455 AccessMode::Write,
5456 Some("did:nostr:writer"),
5457 )
5458 .await;
5459 assert!(
5460 result.is_err(),
5461 "writer lacks Control — must not be able to PUT a .meta sidecar"
5462 );
5463 }
5464
5465 #[test]
5468 fn canonical_pod_path_vectors() {
5469 let ok = [
5470 ("a/b", "/a/b"),
5471 ("", "/"),
5472 ("/", "/"),
5473 ("//a//b/", "/a/b/"),
5474 ("/%2ewell-known/x", "/.well-known/x"),
5475 ("/a%2fb", "/a/b"),
5476 ("/a%2520b", "/a%20b"),
5477 ];
5478 for (raw, want) in ok {
5479 assert_eq!(canonical_pod_path(raw).as_deref(), Some(want), "{raw}");
5480 }
5481 for bad in [
5482 "/a/./b",
5483 "/a/..",
5484 "../a",
5485 "/a\\b",
5486 "/a%00b",
5487 "/a%ffb",
5488 "/%2e%2e/x",
5489 ] {
5490 assert_eq!(canonical_pod_path(bad), None, "{bad}");
5491 }
5492 }
5493
5494 #[test]
5495 fn guarded_acl_target_vectors() {
5496 use AccessMode::{Control, Read, Write};
5497 let c = |p: &str, m| guarded_acl_target(p, m);
5498 assert_eq!(
5499 c("/.well-known/token/x.json", Read),
5500 ("/.well-known/token/x.json".into(), Control)
5501 );
5502 assert_eq!(
5503 c("/.well-known/gitmark/p.json", Write),
5504 ("/.well-known/gitmark/p.json".into(), Control)
5505 );
5506 assert_eq!(c("/.well-known/%74oken/x.json", Read).1, Control);
5507 assert_eq!(
5508 c("/.well-known/token/x.json.acl", Read),
5509 ("/.well-known/token/x.json".into(), Control)
5510 );
5511 assert_eq!(
5512 c("/.well-known/gitmark/p/blocktrails.json", Read),
5513 ("/.well-known/gitmark/p/blocktrails.json".into(), Read)
5514 );
5515 assert_eq!(
5516 c("/.well-known/webledgers/webledgers.json", Write),
5517 ("/.well-known/webledgers/webledgers.json".into(), Control)
5518 );
5519 assert_eq!(c("/.well-known/%57ebledgers/state.json", Read).1, Control);
5520 assert_eq!(c("/.well-known/webledgersx/y", Read).1, Read);
5521 assert_eq!(c("/pub/x", Read), ("/pub/x".into(), Read));
5522 }
5523
5524 #[test]
5526 fn protected_resource_for_acl_strips_suffixes() {
5527 assert_eq!(
5528 protected_resource_for_acl("/victim/.acl").as_deref(),
5529 Some("/victim/")
5530 );
5531 assert_eq!(
5532 protected_resource_for_acl("/a/b.acl").as_deref(),
5533 Some("/a/b")
5534 );
5535 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
5536 assert_eq!(
5537 protected_resource_for_acl("/a/b.meta").as_deref(),
5538 Some("/a/b")
5539 );
5540 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
5541 }
5542}