1#![doc = include_str!("../README.md")]
107#![deny(unsafe_code)]
108#![warn(rust_2018_idioms)]
109
110pub mod cli;
112
113mod handlers;
118
119mod mcp;
122
123pub mod mempool;
128
129pub mod trail_store;
135
136use std::collections::HashMap;
137use std::net::{IpAddr, Ipv4Addr};
138use std::path::{Path, PathBuf};
139use std::sync::{Arc, Mutex};
140use std::time::{Duration, Instant};
141
142use actix_web::body::{BoxBody, EitherBody};
143use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
144use actix_web::http::{header, StatusCode};
145use actix_web::middleware::{NormalizePath, TrailingSlash};
146use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
147use bytes::Bytes;
148use futures_util::future::{ready, LocalBoxFuture, Ready};
149use futures_util::StreamExt;
150use percent_encoding::percent_decode_str;
151use serde::Deserialize;
152use solid_pod_rs::{
153 auth::{nip98, replay::ReplayStore},
157 config::sources::parse_size,
158 interop,
159 ldp::{
160 self, cache_control_for_response, LdpContainerOps, PatchCreateOutcome, ResponseAudience,
161 },
162 mashlib::{self, MashlibConfig},
163 provenance::{ProvenanceReceipt, ProvenanceSkip},
164 security::DotfileAllowlist,
165 storage::Storage,
166 wac::{
167 self, conditions::RequestContext, effective_acl_target, parse_jsonld_acl,
168 parser::parse_turtle_acl, protected_resource_for_acl, AccessMode,
169 },
170 PodError,
171};
172
173const _: () = solid_pod_rs::auth::nip98::assert_schnorr_verification_enabled();
185
186static NIP98_REPLAY: std::sync::LazyLock<solid_pod_rs::auth::replay::Nip98ReplayCache> =
193 std::sync::LazyLock::new(solid_pod_rs::auth::replay::Nip98ReplayCache::from_env);
194
195pub(crate) static PAYMENT_STATE_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
201 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
202
203#[derive(Clone)]
209pub struct AppState {
210 pub storage: Arc<dyn Storage>,
211 pub dotfiles: Arc<DotfileAllowlist>,
212 pub body_cap: usize,
213 pub nodeinfo: NodeInfoMeta,
214 pub mashlib: MashlibConfig,
215 pub mashlib_cdn: Option<String>,
218 pub live_reload: bool,
220 pub pay_config: solid_pod_rs::payments::PayConfig,
223 pub data_root: Option<PathBuf>,
228 pub quota: Option<Arc<dyn solid_pod_rs::quota::QuotaPolicy>>,
230 pub pod_create_limiter: Arc<PodCreateLimiter>,
232 pub allowed_origins: Vec<String>,
239 pub admin_key: Option<String>,
244 pub mcp_enabled: bool,
249 pub mempool_url: Option<String>,
255 pub deposit_txo_standin_enabled: bool,
268}
269
270#[cfg(test)]
271mod nip98_request_binding_tests {
272 use super::*;
273 use actix_web::test::TestRequest;
274
275 const SECRET: &str = "3333333333333333333333333333333333333333333333333333333333333333";
276
277 fn now() -> u64 {
278 std::time::SystemTime::now()
279 .duration_since(std::time::UNIX_EPOCH)
280 .unwrap()
281 .as_secs()
282 }
283
284 #[actix_web::test]
285 async fn authentication_binds_the_complete_query_string() {
286 let signed_url = "http://localhost:8080/private?include=true";
287 let token = nip98::mint(signed_url, "GET", SECRET, now()).unwrap();
288 let request = TestRequest::get()
289 .uri("/private?include=true")
290 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
291 .to_http_request();
292 assert!(extract_pubkey(&request).await.is_some());
293
294 let path_only = nip98::mint("http://localhost:8080/private", "GET", SECRET, now()).unwrap();
295 let request = TestRequest::get()
296 .uri("/private?include=true")
297 .insert_header((header::AUTHORIZATION, format!("Nostr {path_only}")))
298 .to_http_request();
299 assert!(extract_pubkey(&request).await.is_none());
300 }
301
302 #[actix_web::test]
303 async fn authentication_binds_the_exact_raw_body() {
304 let expected = br#"{"value":"authorised"}"#;
305 let token = nip98::mint_with_payload(
306 "http://localhost:8080/resource",
307 "PUT",
308 Some(expected),
309 SECRET,
310 now(),
311 )
312 .unwrap();
313 let request = TestRequest::put()
314 .uri("/resource")
315 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
316 .to_http_request();
317
318 assert!(
319 extract_pubkey_with_body(&request, Some(br#"{"value":"tampered"}"#))
320 .await
321 .is_none()
322 );
323 assert!(extract_pubkey_with_body(&request, Some(expected))
324 .await
325 .is_some());
326 }
327}
328
329#[derive(Clone, Debug)]
331pub struct NodeInfoMeta {
332 pub software_name: String,
333 pub software_version: String,
334 pub open_registrations: bool,
335 pub total_users: u64,
336 pub base_url: String,
337}
338
339impl Default for NodeInfoMeta {
340 fn default() -> Self {
341 Self {
342 software_name: "solid-pod-rs-server".to_string(),
343 software_version: env!("CARGO_PKG_VERSION").to_string(),
344 open_registrations: false,
345 total_users: 0,
346 base_url: "http://localhost".to_string(),
347 }
348 }
349}
350
351pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
354
355pub fn body_cap_from_env() -> usize {
360 match std::env::var("JSS_MAX_REQUEST_BODY").or_else(|_| std::env::var("JSS_BODY_LIMIT")) {
361 Ok(v) => parse_size(&v)
362 .map(|u| u as usize)
363 .unwrap_or(DEFAULT_BODY_CAP),
364 Err(_) => DEFAULT_BODY_CAP,
365 }
366}
367
368impl AppState {
369 pub fn new(storage: Arc<dyn Storage>) -> Self {
372 Self {
373 storage,
374 dotfiles: Arc::new(DotfileAllowlist::from_env()),
375 body_cap: body_cap_from_env(),
376 nodeinfo: NodeInfoMeta::default(),
377 mashlib: MashlibConfig::default(),
378 mashlib_cdn: None,
379 live_reload: false,
380 pay_config: solid_pod_rs::payments::PayConfig::default(),
381 data_root: None,
382 quota: None,
383 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
384 allowed_origins: Vec::new(),
385 admin_key: None,
386 mcp_enabled: false,
387 mempool_url: None,
388 deposit_txo_standin_enabled: false,
391 }
392 }
393}
394
395const 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>";
396
397fn inject_live_reload(html: impl AsRef<[u8]>, enabled: bool) -> Vec<u8> {
398 let bytes = html.as_ref();
399 if !enabled {
400 return bytes.to_vec();
401 }
402 let html = String::from_utf8_lossy(bytes);
403 if let Some(index) = html.find("</body>") {
404 let mut out = String::with_capacity(html.len() + LIVE_RELOAD_SCRIPT.len());
405 out.push_str(&html[..index]);
406 out.push_str(LIVE_RELOAD_SCRIPT);
407 out.push_str(&html[index..]);
408 out.into_bytes()
409 } else {
410 format!("{html}{LIVE_RELOAD_SCRIPT}").into_bytes()
411 }
412}
413
414#[cfg(test)]
415mod live_reload_tests {
416 use super::*;
417
418 #[test]
419 fn injects_before_body_close_and_is_disabled_by_default() {
420 let html = b"<html><body>ok</body></html>";
421 assert_eq!(inject_live_reload(html, false), html);
422 let injected = String::from_utf8(inject_live_reload(html, true)).unwrap();
423 assert!(injected.contains("/.notifications"));
424 assert!(injected.find("<script>").unwrap() < injected.find("</body>").unwrap());
425 }
426}
427
428#[cfg(test)]
429mod dev_bearer_tests {
430 use super::*;
431 use base64::Engine as _;
432 use hmac::{Hmac, Mac};
433 use sha2::Sha256;
434 use std::sync::Mutex;
435
436 static ENV: Mutex<()> = Mutex::new(());
437
438 fn token(exp: u64) -> String {
439 let data = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
440 serde_json::json!({"webId":"https://alice.example/profile/card#me","iat":1,"exp":exp})
441 .to_string(),
442 );
443 let mut mac = Hmac::<Sha256>::new_from_slice(b"01234567890123456789012345678901").unwrap();
444 mac.update(data.as_bytes());
445 let sig =
446 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
447 format!("{data}.{sig}")
448 }
449
450 #[test]
451 fn simple_bearer_verifies_signature_and_expiry() {
452 let _guard = ENV.lock().unwrap();
453 std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
454 assert_eq!(
455 verify_dev_bearer(&token(u64::MAX)).as_deref(),
456 Some("https://alice.example/profile/card#me")
457 );
458 assert!(verify_dev_bearer(&(token(u64::MAX) + "x")).is_none());
459 assert!(verify_dev_bearer(&token(2)).is_none());
460 std::env::remove_var("TOKEN_SECRET");
461 }
462}
463
464#[derive(Debug)]
466pub struct PodCreateLimiter {
467 hits: Mutex<HashMap<IpAddr, Instant>>,
468 window: Duration,
469}
470
471impl Default for PodCreateLimiter {
472 fn default() -> Self {
473 Self {
474 hits: Mutex::new(HashMap::new()),
475 window: Duration::from_secs(24 * 60 * 60),
476 }
477 }
478}
479
480impl PodCreateLimiter {
481 fn check(&self, ip: IpAddr) -> Result<(), u64> {
482 let now = Instant::now();
483 let mut hits = self.hits.lock().unwrap();
484 if let Some(last) = hits.get(&ip).copied() {
485 let elapsed = now.saturating_duration_since(last);
486 if elapsed < self.window {
487 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
488 }
489 }
490 hits.insert(ip, now);
491 Ok(())
492 }
493}
494
495pub(crate) fn to_actix(e: PodError) -> ActixError {
500 match e {
501 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
502 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
503 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
504 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
505 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
506 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
507 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
508 }
509}
510
511pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
523 extract_pubkey_with_body(req, None).await
524}
525
526pub(crate) async fn extract_pubkey_with_body(
530 req: &HttpRequest,
531 body: Option<&[u8]>,
532) -> Option<String> {
533 let header_val = req
534 .headers()
535 .get(header::AUTHORIZATION)
536 .and_then(|v| v.to_str().ok())?;
537 if let Some(token) = header_val.strip_prefix("Bearer ") {
538 return verify_dev_bearer(token);
539 }
540 let url = {
551 let conn = req.connection_info();
552 format!("{}://{}{}", conn.scheme(), conn.host(), req.uri())
553 };
554 let now = std::time::SystemTime::now()
555 .duration_since(std::time::UNIX_EPOCH)
556 .map(|d| d.as_secs())
557 .unwrap_or(0);
558 let verified = nip98::verify_at(header_val, &url, req.method().as_str(), body, now).ok()?;
559
560 if NIP98_REPLAY
564 .check_and_record(&verified.event_id)
565 .await
566 .is_err()
567 {
568 tracing::warn!(
569 pubkey = %verified.pubkey,
570 method = %req.method(),
571 "NIP-98 replay rejected: token id already used within window"
572 );
573 return None;
574 }
575
576 Some(verified.pubkey)
577}
578
579pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
580 pubkey.map(|pk| {
581 if pk.starts_with("https://") || pk.starts_with("http://") {
582 pk.clone()
583 } else {
584 format!("did:nostr:{pk}")
585 }
586 })
587}
588
589fn verify_dev_bearer(token: &str) -> Option<String> {
590 use base64::Engine as _;
591 use hmac::{Hmac, Mac};
592 use sha2::Sha256;
593
594 let secret = std::env::var("TOKEN_SECRET").ok()?;
595 if secret.len() < 32 {
596 tracing::warn!("TOKEN_SECRET shorter than 32 bytes; refusing development bearer token");
597 return None;
598 }
599 let (data, signature) = token.split_once('.')?;
600 if signature.contains('.') {
601 return None;
602 }
603 let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
604 .decode(signature)
605 .ok()?;
606 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
607 mac.update(data.as_bytes());
608 mac.verify_slice(&signature).ok()?;
609
610 #[derive(serde::Deserialize)]
611 #[serde(rename_all = "camelCase")]
612 struct Claims {
613 web_id: String,
614 iat: u64,
615 exp: u64,
616 }
617 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
618 .decode(data)
619 .ok()?;
620 let claims: Claims = serde_json::from_slice(&payload).ok()?;
621 let now = std::time::SystemTime::now()
622 .duration_since(std::time::UNIX_EPOCH)
623 .ok()?
624 .as_secs();
625 if claims.exp < now || claims.iat > now.saturating_add(60) || claims.exp <= claims.iat {
626 return None;
627 }
628 let url = url::Url::parse(&claims.web_id).ok()?;
629 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
630 return None;
631 }
632 Some(claims.web_id)
633}
634
635fn req_origin(req: &HttpRequest) -> Option<&str> {
644 req.headers()
645 .get(header::ORIGIN)
646 .and_then(|v| v.to_str().ok())
647}
648
649pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
653
654async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
671 let did = agent_uri?;
672 let balance = match storage.get(WEBLEDGER_PATH).await {
673 Ok((bytes, _meta)) => {
674 match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
675 Ok(ledger) => ledger.get_balance(did),
676 Err(_) => 0,
680 }
681 }
682 Err(_) => 0,
685 };
686 Some(balance)
687}
688
689fn accept_includes_html(accept: &str) -> bool {
697 accept.split(',').any(|entry| {
698 let mime = entry.split(';').next().unwrap_or("").trim();
699 mime.eq_ignore_ascii_case("text/html")
700 })
701}
702
703fn proposed_acl_keeps_caller_control(
722 body: &[u8],
723 content_type: &str,
724 caller: Option<&str>,
725) -> bool {
726 let doc = match parse_jsonld_acl(body) {
727 Ok(d) => Some(d),
728 Err(_) => {
729 let ct = content_type.to_ascii_lowercase();
730 let text = std::str::from_utf8(body).unwrap_or("");
731 let looks_turtle = ct.starts_with("text/turtle")
732 || ct.starts_with("application/turtle")
733 || ct.starts_with("application/x-turtle")
734 || ct.starts_with("application/n-triples")
735 || text.contains("@prefix")
736 || text.contains("acl:Authorization")
737 || text.contains("auth/acl#Authorization");
741 if looks_turtle {
742 parse_turtle_acl(text).ok()
743 } else {
744 None
745 }
746 }
747 };
748 let Some(doc) = doc else {
749 return true;
751 };
752 let Some(graph) = doc.graph.as_ref() else {
753 return false;
754 };
755 graph.iter().any(|auth| {
756 let grants_control = ids_of_acl_field(&auth.mode)
757 .iter()
758 .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
759 if !grants_control {
760 return false;
761 }
762 let agents = ids_of_acl_field(&auth.agent);
763 if let Some(web_id) = caller {
764 if agents.contains(&web_id) {
765 return true;
766 }
767 }
768 let classes = ids_of_acl_field(&auth.agent_class);
769 if classes
770 .iter()
771 .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
772 {
773 return true;
774 }
775 if caller.is_some()
776 && classes.iter().any(|c| {
777 *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
778 || *c == "acl:AuthenticatedAgent"
779 })
780 {
781 return true;
782 }
783 false
784 })
785}
786
787fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
789 match field {
790 None => Vec::new(),
791 Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
792 Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
793 }
794}
795
796#[cfg_attr(not(test), allow(dead_code))]
803async fn enforce_write(
804 state: &AppState,
805 path: &str,
806 mode: AccessMode,
807 agent_uri: Option<&str>,
808) -> Result<(), ActixError> {
809 enforce_write_ctx(state, path, mode, agent_uri, None).await
810}
811
812async fn enforce_write_ctx(
820 state: &AppState,
821 path: &str,
822 mode: AccessMode,
823 agent_uri: Option<&str>,
824 request_origin: Option<&str>,
825) -> Result<(), ActixError> {
826 let origin = request_origin.and_then(wac::Origin::parse);
827 let (resource, eff_mode) = effective_acl_target(path, mode);
839
840 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
848 if outcome.is_failure() {
849 return Err(policy_failure_to_actix(&outcome, &resource));
850 }
851 let acl_doc = outcome.document().cloned();
852
853 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
858
859 let ctx = RequestContext {
860 web_id: agent_uri,
861 client_id: None,
862 issuer: None,
863 payment_balance_sats,
864 };
865 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
866 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
867 let granted = wac::evaluate_access_ctx_with_registry(
868 acl_doc.as_ref(),
869 &ctx,
870 &resource,
871 eff_mode,
872 origin.as_ref(),
873 &groups,
874 ®istry,
875 );
876 if !granted {
877 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
878 }
879 if resource.as_str() == path {
886 charge_granted_payment(
892 state,
893 acl_doc.as_ref(),
894 &ctx,
895 &resource,
896 eff_mode,
897 &groups,
898 ®istry,
899 )
900 .await?;
901 }
902 Ok(())
903}
904
905async fn charge_granted_payment(
914 state: &AppState,
915 acl_doc: Option<&wac::AclDocument>,
916 ctx: &RequestContext<'_>,
917 path: &str,
918 mode: AccessMode,
919 groups: &wac::StaticGroupMembership,
920 registry: &wac::conditions::ConditionRegistry,
921) -> Result<(), ActixError> {
922 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
923 if cost == 0 {
924 return Ok(());
925 }
926 if let Some(did) = ctx.web_id {
927 if debit_ledger(&*state.storage, did, cost).await.is_err() {
928 return Err(acl_denial(acl_doc, ctx.web_id, path));
929 }
930 }
931 Ok(())
932}
933
934fn acl_denial(
940 acl_doc: Option<&wac::AclDocument>,
941 agent_uri: Option<&str>,
942 path: &str,
943) -> ActixError {
944 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
945 let (status, body, unauthenticated) = if agent_uri.is_none() {
946 (StatusCode::UNAUTHORIZED, "authentication required", true)
947 } else {
948 (StatusCode::FORBIDDEN, "access forbidden", false)
949 };
950 let mut rsp = HttpResponse::new(status);
951 rsp.headers_mut().insert(
952 header::HeaderName::from_static("wac-allow"),
953 header::HeaderValue::from_str(&allow_header)
954 .unwrap_or(header::HeaderValue::from_static("")),
955 );
956 if unauthenticated {
957 rsp.headers_mut().insert(
964 header::WWW_AUTHENTICATE,
965 header::HeaderValue::from_static(
966 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
967 ),
968 );
969 }
970 actix_web::error::InternalError::from_response(body, rsp).into()
971}
972
973#[cfg_attr(not(test), allow(dead_code))]
984async fn enforce_read(
985 state: &AppState,
986 path: &str,
987 agent_uri: Option<&str>,
988) -> Result<ResponseAudience, ActixError> {
989 enforce_read_ctx(state, path, agent_uri, None).await
990}
991
992async fn enforce_read_ctx(
995 state: &AppState,
996 path: &str,
997 agent_uri: Option<&str>,
998 request_origin: Option<&str>,
999) -> Result<ResponseAudience, ActixError> {
1000 let origin = request_origin.and_then(wac::Origin::parse);
1001 let (resource, eff_mode) = effective_acl_target(path, AccessMode::Read);
1013 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
1016 if outcome.is_failure() {
1017 return Err(policy_failure_to_actix(&outcome, &resource));
1018 }
1019 let acl_doc = outcome.document().cloned();
1020 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
1021 let ctx = RequestContext {
1022 web_id: agent_uri,
1023 client_id: None,
1024 issuer: None,
1025 payment_balance_sats,
1026 };
1027 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
1028 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
1029 let granted = wac::evaluate_access_ctx_with_registry(
1030 acl_doc.as_ref(),
1031 &ctx,
1032 &resource,
1033 eff_mode,
1034 origin.as_ref(),
1035 &groups,
1036 ®istry,
1037 );
1038 if !granted {
1039 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1040 }
1041 if resource.as_str() == path {
1045 charge_granted_payment(
1050 state,
1051 acl_doc.as_ref(),
1052 &ctx,
1053 &resource,
1054 eff_mode,
1055 &groups,
1056 ®istry,
1057 )
1058 .await?;
1059 }
1060 let anon_ctx = RequestContext {
1069 web_id: None,
1070 client_id: None,
1071 issuer: None,
1072 payment_balance_sats: None,
1073 };
1074 let anonymous_would_be_granted = wac::evaluate_access_ctx_with_registry(
1075 acl_doc.as_ref(),
1076 &anon_ctx,
1077 &resource,
1078 eff_mode,
1079 origin.as_ref(),
1080 &groups,
1081 ®istry,
1082 );
1083 Ok(if anonymous_would_be_granted && resource.as_str() == path {
1084 ResponseAudience::Public
1085 } else {
1086 ResponseAudience::Private
1087 })
1088}
1089
1090async fn debit_ledger(
1099 storage: &dyn Storage,
1100 did: &str,
1101 cost: u64,
1102) -> Result<(), solid_pod_rs::payments::PaymentError> {
1103 use solid_pod_rs::payments::{PaymentError, WebLedger};
1104 let _transaction = PAYMENT_STATE_LOCK.lock().await;
1105
1106 let (bytes, _meta) = storage
1107 .get(WEBLEDGER_PATH)
1108 .await
1109 .map_err(|e| PaymentError::Store(e.to_string()))?;
1110 let mut ledger: WebLedger = serde_json::from_slice(&bytes)
1111 .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
1112 ledger.debit(did, cost)?;
1113 let body = serde_json::to_vec(&ledger)
1114 .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
1115 storage
1116 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
1117 .await
1118 .map_err(|e| PaymentError::Store(e.to_string()))?;
1119 Ok(())
1120}
1121
1122fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
1127 let links = ldp::link_headers(path).join(", ");
1128 if let Ok(value) = header::HeaderValue::from_str(&links) {
1129 rsp.headers_mut()
1130 .insert(header::HeaderName::from_static("link"), value);
1131 }
1132}
1133
1134fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
1135 if let Ok(v) = header::HeaderValue::from_str(header_value) {
1136 rsp.headers_mut()
1137 .insert(header::HeaderName::from_static("wac-allow"), v);
1138 }
1139}
1140
1141fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
1142 let ws_base = base_url
1143 .replacen("https://", "wss://", 1)
1144 .replacen("http://", "ws://", 1);
1145 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
1146 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
1147 rsp.headers_mut()
1148 .insert(header::HeaderName::from_static("updates-via"), v);
1149 }
1150}
1151
1152fn set_provenance_headers(rsp: &mut HttpResponse, receipt: &ProvenanceReceipt) {
1163 if let Ok(v) = header::HeaderValue::from_str(&receipt.summary()) {
1164 rsp.headers_mut()
1165 .insert(header::HeaderName::from_static("x-provenance"), v);
1166 }
1167 if let Some(sha) = receipt.commit_sha() {
1168 if let Ok(v) = header::HeaderValue::from_str(sha) {
1169 rsp.headers_mut()
1170 .insert(header::HeaderName::from_static("x-provenance-commit"), v);
1171 }
1172 }
1173}
1174
1175fn set_cache_policy(rsp: &mut HttpResponse, content_type: &str, audience: ResponseAudience) {
1187 if audience == ResponseAudience::Private {
1188 append_vary(rsp, "Authorization");
1192 }
1193 if rsp.headers().contains_key(header::CACHE_CONTROL) {
1194 return;
1195 }
1196 if let Some(value) = cache_control_for_response(content_type, audience) {
1197 rsp.headers_mut().insert(
1198 header::CACHE_CONTROL,
1199 header::HeaderValue::from_static(value),
1200 );
1201 }
1202}
1203
1204fn append_vary(rsp: &mut HttpResponse, field: &str) {
1206 let existing = rsp
1207 .headers()
1208 .get(header::VARY)
1209 .and_then(|v| v.to_str().ok())
1210 .unwrap_or("")
1211 .to_string();
1212 if existing
1213 .split(',')
1214 .any(|f| f.trim().eq_ignore_ascii_case(field))
1215 {
1216 return;
1217 }
1218 let merged = if existing.trim().is_empty() {
1219 field.to_string()
1220 } else {
1221 format!("{existing}, {field}")
1222 };
1223 if let Ok(v) = header::HeaderValue::from_str(&merged) {
1224 rsp.headers_mut().insert(header::VARY, v);
1225 }
1226}
1227
1228async fn handle_get(
1229 req: HttpRequest,
1230 state: web::Data<AppState>,
1231) -> Result<HttpResponse, ActixError> {
1232 let path = req.uri().path().to_string();
1233
1234 if path.contains('*') {
1235 return handle_glob_get(req, state).await;
1236 }
1237
1238 let auth_pk = extract_pubkey(&req).await;
1239 let agent = agent_uri(auth_pk.as_ref());
1240
1241 let audience = enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
1246
1247 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
1248
1249 if ldp::is_container(&path) {
1250 let accept = req
1251 .headers()
1252 .get(header::ACCEPT)
1253 .and_then(|v| v.to_str().ok())
1254 .unwrap_or("");
1255
1256 if accept_includes_html(accept) {
1262 let index_path = format!("{path}index.html");
1263 if let Ok((body, _meta)) = state.storage.get(&index_path).await {
1264 let mut rsp = HttpResponse::Ok()
1265 .content_type("text/html; charset=utf-8")
1266 .body(inject_live_reload(&body, state.live_reload));
1267 set_cache_policy(&mut rsp, "text/html", audience);
1268 set_wac_allow(&mut rsp, &wac_allow);
1269 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1270 set_link_headers(&mut rsp, &path);
1271 return Ok(rsp);
1272 }
1273 }
1274
1275 let v = state
1276 .storage
1277 .container_representation(&path)
1278 .await
1279 .map_err(to_actix)?;
1280
1281 let sec_fetch_dest = req
1283 .headers()
1284 .get("sec-fetch-dest")
1285 .and_then(|v| v.to_str().ok());
1286 if mashlib::should_serve(
1287 accept,
1288 sec_fetch_dest,
1289 "application/ld+json",
1290 state.mashlib.enabled,
1291 ) {
1292 let json_ld = serde_json::to_string(&v).ok();
1293 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
1294 let mut rsp = HttpResponse::Ok()
1295 .content_type("text/html; charset=utf-8")
1296 .insert_header(("X-Frame-Options", "DENY"))
1297 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1298 .insert_header(("Cache-Control", "no-store"))
1299 .body(inject_live_reload(html, state.live_reload));
1300 set_cache_policy(&mut rsp, "text/html", audience);
1301 set_wac_allow(&mut rsp, &wac_allow);
1302 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1303 set_link_headers(&mut rsp, &path);
1304 return Ok(rsp);
1305 }
1306
1307 let mut rsp = HttpResponse::Ok().json(v);
1308 rsp.headers_mut().insert(
1309 header::CONTENT_TYPE,
1310 header::HeaderValue::from_static("application/ld+json"),
1311 );
1312 set_cache_policy(&mut rsp, "application/ld+json", audience);
1313 set_wac_allow(&mut rsp, &wac_allow);
1314 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1315 set_link_headers(&mut rsp, &path);
1316 return Ok(rsp);
1317 }
1318
1319 match state.storage.get(&path).await {
1320 Ok((body, meta)) => {
1321 let accept = req
1323 .headers()
1324 .get(header::ACCEPT)
1325 .and_then(|v| v.to_str().ok())
1326 .unwrap_or("");
1327 let sec_fetch_dest = req
1328 .headers()
1329 .get("sec-fetch-dest")
1330 .and_then(|v| v.to_str().ok());
1331 if mashlib::should_serve(
1332 accept,
1333 sec_fetch_dest,
1334 &meta.content_type,
1335 state.mashlib.enabled,
1336 ) {
1337 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
1338 std::str::from_utf8(&body).ok().map(|s| s.to_string())
1339 } else {
1340 None
1341 };
1342 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
1343 let mut rsp = HttpResponse::Ok()
1344 .content_type("text/html; charset=utf-8")
1345 .insert_header(("X-Frame-Options", "DENY"))
1346 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1347 .insert_header(("Cache-Control", "no-store"))
1348 .body(inject_live_reload(html, state.live_reload));
1349 set_cache_policy(&mut rsp, "text/html", audience);
1350 set_wac_allow(&mut rsp, &wac_allow);
1351 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1352 set_link_headers(&mut rsp, &path);
1353 return Ok(rsp);
1354 }
1355
1356 if let Some((negotiated_body, negotiated_ct)) =
1364 rdf_content_negotiate(&body, &meta.content_type, accept)
1365 {
1366 let mut rsp = HttpResponse::Ok().body(negotiated_body);
1367 rsp.headers_mut().insert(
1368 header::CONTENT_TYPE,
1369 header::HeaderValue::from_str(negotiated_ct)
1370 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
1371 );
1372 rsp.headers_mut()
1373 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
1374 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1375 rsp.headers_mut().insert(header::ETAG, etag);
1376 }
1377 set_cache_policy(&mut rsp, negotiated_ct, audience);
1378 set_wac_allow(&mut rsp, &wac_allow);
1379 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1380 set_link_headers(&mut rsp, &path);
1381 return Ok(rsp);
1382 }
1383
1384 let response_body = if meta.content_type.starts_with("text/html") {
1385 inject_live_reload(&body, state.live_reload)
1386 } else {
1387 body.to_vec()
1388 };
1389 let mut rsp = HttpResponse::Ok().body(response_body);
1390 rsp.headers_mut().insert(
1391 header::CONTENT_TYPE,
1392 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
1393 header::HeaderValue::from_static("application/octet-stream")
1394 }),
1395 );
1396 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1397 rsp.headers_mut().insert(header::ETAG, etag);
1398 }
1399 set_cache_policy(&mut rsp, &meta.content_type, audience);
1400 set_wac_allow(&mut rsp, &wac_allow);
1401 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1402 set_link_headers(&mut rsp, &path);
1403 Ok(rsp)
1404 }
1405 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1406 Err(e) => Err(to_actix(e)),
1407 }
1408}
1409
1410fn has_basic_container_link(req: &HttpRequest) -> bool {
1411 req.headers()
1412 .get_all(header::LINK)
1413 .filter_map(|v| v.to_str().ok())
1414 .any(|v| {
1415 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
1416 })
1417}
1418
1419async fn handle_put(
1420 req: HttpRequest,
1421 body: web::Bytes,
1422 state: web::Data<AppState>,
1423) -> Result<HttpResponse, ActixError> {
1424 let path = req.uri().path().to_string();
1425
1426 if ldp::is_container(&path) {
1427 if has_basic_container_link(&req) {
1428 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1429 let agent = agent_uri(auth_pk.as_ref());
1430 enforce_write_ctx(
1431 &state,
1432 &path,
1433 AccessMode::Write,
1434 agent.as_deref(),
1435 req_origin(&req),
1436 )
1437 .await?;
1438 let meta = state
1439 .storage
1440 .create_container(&path)
1441 .await
1442 .map_err(to_actix)?;
1443 let mut rsp = HttpResponse::Created().finish();
1444 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1445 rsp.headers_mut().insert(header::ETAG, etag);
1446 }
1447 set_link_headers(&mut rsp, &path);
1448 return Ok(rsp);
1449 }
1450 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
1451 }
1452
1453 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1454 let agent = agent_uri(auth_pk.as_ref());
1455 enforce_write_ctx(
1456 &state,
1457 &path,
1458 AccessMode::Write,
1459 agent.as_deref(),
1460 req_origin(&req),
1461 )
1462 .await?;
1463
1464 let ct = req
1465 .headers()
1466 .get(header::CONTENT_TYPE)
1467 .and_then(|v| v.to_str().ok())
1468 .unwrap_or("application/octet-stream");
1469
1470 if protected_resource_for_acl(&path).is_some()
1475 && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
1476 {
1477 return Ok(HttpResponse::Conflict().body(
1478 "refused: the proposed ACL would not grant Control to the caller \
1479 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1480 ));
1481 }
1482
1483 let quota = reserve_quota_for_size(&state, &path, body.len() as u64).await?;
1484 let write = state
1485 .storage
1486 .put(&path, Bytes::from(body.to_vec()), ct)
1487 .await;
1488 finish_quota_reservation(&state, quota, write.is_ok()).await;
1489 let meta = write.map_err(to_actix)?;
1490 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
1494 let mut rsp = HttpResponse::Created().finish();
1495 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1496 rsp.headers_mut().insert(header::ETAG, etag);
1497 }
1498 set_provenance_headers(&mut rsp, &provenance);
1499 set_link_headers(&mut rsp, &path);
1500 Ok(rsp)
1501}
1502
1503async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
1510 if !storage.exists(target).await.unwrap_or(false) {
1511 return target.to_string();
1512 }
1513 let seg_start = target.rfind('/').map(|s| s + 1).unwrap_or(0);
1516 let (stem, ext) = match target.rfind('.') {
1517 Some(dot) if dot > seg_start => (&target[..dot], &target[dot..]),
1518 _ => (target, ""),
1519 };
1520 for n in 1..10_000u32 {
1521 let candidate = format!("{stem}-{n}{ext}");
1522 if !storage.exists(&candidate).await.unwrap_or(false) {
1523 return candidate;
1524 }
1525 }
1526 use std::hash::{Hash, Hasher};
1527 let mut h = std::collections::hash_map::DefaultHasher::new();
1528 target.hash(&mut h);
1529 format!("{stem}-{:x}{ext}", h.finish())
1530}
1531
1532async fn handle_post(
1533 req: HttpRequest,
1534 body: web::Bytes,
1535 state: web::Data<AppState>,
1536) -> Result<HttpResponse, ActixError> {
1537 let path = req.uri().path().to_string();
1538 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1541 let agent = agent_uri(auth_pk.as_ref());
1542 enforce_write_ctx(
1543 &state,
1544 &path,
1545 AccessMode::Append,
1546 agent.as_deref(),
1547 req_origin(&req),
1548 )
1549 .await?;
1550
1551 let slug = req
1552 .headers()
1553 .get(header::HeaderName::from_static("slug"))
1554 .and_then(|v| v.to_str().ok());
1555 let mut target = match ldp::resolve_slug(&path, slug) {
1556 Ok(p) => p,
1557 Err(e) => return Err(to_actix(e)),
1558 };
1559 let ct = req
1560 .headers()
1561 .get(header::CONTENT_TYPE)
1562 .and_then(|v| v.to_str().ok())
1563 .unwrap_or("application/octet-stream");
1564
1565 if protected_resource_for_acl(&target).is_some() {
1574 enforce_write_ctx(
1575 &state,
1576 &target,
1577 AccessMode::Write,
1578 agent.as_deref(),
1579 req_origin(&req),
1580 )
1581 .await?;
1582 if !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref()) {
1583 return Ok(HttpResponse::Conflict().body(
1584 "refused: the proposed ACL would not grant Control to the caller \
1585 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1586 ));
1587 }
1588 } else {
1589 target = mint_unique_target(&*state.storage, &target).await;
1595 }
1596
1597 let quota = reserve_quota_for_size(&state, &target, body.len() as u64).await?;
1598 let write = state
1599 .storage
1600 .put(&target, Bytes::from(body.to_vec()), ct)
1601 .await;
1602 finish_quota_reservation(&state, quota, write.is_ok()).await;
1603 let meta = write.map_err(to_actix)?;
1604 let provenance = git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1607 let mut rsp = HttpResponse::Created().finish();
1608 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1609 rsp.headers_mut().insert(header::LOCATION, loc);
1610 }
1611 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1612 rsp.headers_mut().insert(header::ETAG, etag);
1613 }
1614 set_provenance_headers(&mut rsp, &provenance);
1615 set_link_headers(&mut rsp, &target);
1616 Ok(rsp)
1617}
1618
1619async fn handle_patch(
1620 req: HttpRequest,
1621 body: web::Bytes,
1622 state: web::Data<AppState>,
1623) -> Result<HttpResponse, ActixError> {
1624 let path = req.uri().path().to_string();
1625 if ldp::is_container(&path) {
1626 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1627 }
1628 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1629 let agent = agent_uri(auth_pk.as_ref());
1630 enforce_write_ctx(
1636 &state,
1637 &path,
1638 AccessMode::Write,
1639 agent.as_deref(),
1640 req_origin(&req),
1641 )
1642 .await?;
1643
1644 let ct = req
1645 .headers()
1646 .get(header::CONTENT_TYPE)
1647 .and_then(|v| v.to_str().ok())
1648 .unwrap_or("");
1649 let dialect = match ldp::patch_dialect_from_mime(ct) {
1650 Some(d) => d,
1651 None => {
1652 return Ok(HttpResponse::UnsupportedMediaType()
1653 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1654 }
1655 };
1656 let body_str = match std::str::from_utf8(&body) {
1657 Ok(s) => s.to_string(),
1658 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1659 };
1660
1661 let existing = state.storage.get(&path).await;
1663 match existing {
1664 Ok((current_body, meta)) => {
1665 let out = match dialect {
1675 ldp::PatchDialect::N3 => {
1676 let seed = seed_graph_from_patch_target(¤t_body)?;
1677 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1678 }
1679 ldp::PatchDialect::SparqlUpdate => {
1680 let seed = seed_graph_from_patch_target(¤t_body)?;
1681 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1682 }
1683 ldp::PatchDialect::JsonPatch => {
1684 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1685 Ok(v) => v,
1686 Err(_) => serde_json::json!({}),
1687 };
1688 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1689 Ok(v) => v,
1690 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1691 };
1692 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1693 let bytes = serde_json::to_vec(&json)
1694 .map_err(PodError::from)
1695 .map_err(to_actix)?;
1696 let quota = reserve_quota_for_size(&state, &path, bytes.len() as u64).await?;
1697 let write = state
1698 .storage
1699 .put(&path, Bytes::from(bytes), &meta.content_type)
1700 .await;
1701 finish_quota_reservation(&state, quota, write.is_ok()).await;
1702 let _ = write.map_err(to_actix)?;
1703 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1704 let mut rsp = HttpResponse::NoContent().finish();
1705 set_provenance_headers(&mut rsp, &provenance);
1706 return Ok(rsp);
1707 }
1708 };
1709 let outcome = out?;
1710 let serialised = graph_to_turtle(&outcome.graph);
1713 if protected_resource_for_acl(&path).is_some()
1719 && !proposed_acl_keeps_caller_control(
1720 serialised.as_bytes(),
1721 "application/n-triples",
1722 agent.as_deref(),
1723 )
1724 {
1725 return Ok(HttpResponse::Conflict().body(
1726 "refused: the patched ACL would not grant Control to the caller \
1727 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1728 ));
1729 }
1730 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
1731 let write = state
1732 .storage
1733 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1734 .await;
1735 finish_quota_reservation(&state, quota, write.is_ok()).await;
1736 let _ = write.map_err(to_actix)?;
1737 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1738 let mut rsp = HttpResponse::NoContent().finish();
1739 set_provenance_headers(&mut rsp, &provenance);
1740 Ok(rsp)
1741 }
1742 Err(PodError::NotFound(_)) => {
1743 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
1745 let PatchCreateOutcome::Created { graph, .. } = create else {
1746 return Err(to_actix(PodError::Unsupported(
1747 "unexpected patch outcome on absent resource".into(),
1748 )));
1749 };
1750 let serialised = graph_to_turtle(&graph);
1751 if protected_resource_for_acl(&path).is_some()
1753 && !proposed_acl_keeps_caller_control(
1754 serialised.as_bytes(),
1755 "application/n-triples",
1756 agent.as_deref(),
1757 )
1758 {
1759 return Ok(HttpResponse::Conflict().body(
1760 "refused: the patched ACL would not grant Control to the caller \
1761 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1762 ));
1763 }
1764 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
1765 let write = state
1766 .storage
1767 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1768 .await;
1769 finish_quota_reservation(&state, quota, write.is_ok()).await;
1770 let _ = write.map_err(to_actix)?;
1771 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1772 let mut rsp = HttpResponse::Created().finish();
1773 set_provenance_headers(&mut rsp, &provenance);
1774 Ok(rsp)
1775 }
1776 Err(e) => Err(to_actix(e)),
1777 }
1778}
1779
1780fn patch_parse_err(e: PodError) -> ActixError {
1784 match e {
1785 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
1786 actix_web::error::ErrorBadRequest(msg)
1787 }
1788 other => to_actix(other),
1789 }
1790}
1791
1792fn graph_to_turtle(g: &ldp::Graph) -> String {
1796 g.to_ntriples()
1797}
1798
1799fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
1806 let mut best: Option<(f32, ldp::RdfFormat)> = None;
1807 for entry in accept.split(',') {
1808 let entry = entry.trim();
1809 if entry.is_empty() {
1810 continue;
1811 }
1812 let mut parts = entry.split(';').map(|s| s.trim());
1813 let mime = match parts.next() {
1814 Some(m) => m,
1815 None => continue,
1816 };
1817 let mut q: f32 = 1.0;
1818 for token in parts {
1819 if let Some(v) = token.strip_prefix("q=") {
1820 if let Ok(parsed) = v.parse::<f32>() {
1821 q = parsed;
1822 }
1823 }
1824 }
1825 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
1828 match best {
1829 None => best = Some((q, format)),
1830 Some((bq, _)) if q > bq => best = Some((q, format)),
1831 _ => {}
1832 }
1833 }
1834 }
1835 best.map(|(_, f)| f)
1836}
1837
1838fn rdf_content_negotiate(
1854 body: &[u8],
1855 stored_ct: &str,
1856 accept: &str,
1857) -> Option<(Vec<u8>, &'static str)> {
1858 if accept.trim().is_empty() {
1859 return None;
1860 }
1861 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
1862 let target = best_explicit_rdf_format(accept)?;
1863 if target == stored_format {
1864 return None;
1865 }
1866 let text = std::str::from_utf8(body).ok()?;
1867 let graph = ldp::Graph::parse_ntriples(text).ok()?;
1868 match target {
1869 ldp::RdfFormat::Turtle => Some((
1872 graph.to_ntriples().into_bytes(),
1873 ldp::RdfFormat::Turtle.mime(),
1874 )),
1875 ldp::RdfFormat::NTriples => Some((
1876 graph.to_ntriples().into_bytes(),
1877 ldp::RdfFormat::NTriples.mime(),
1878 )),
1879 ldp::RdfFormat::JsonLd => {
1880 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
1881 Some((json, ldp::RdfFormat::JsonLd.mime()))
1882 }
1883 ldp::RdfFormat::RdfXml => None,
1885 }
1886}
1887
1888fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
1897 let text = std::str::from_utf8(current_body).map_err(|_| {
1898 actix_web::error::ErrorConflict(
1899 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
1900 )
1901 })?;
1902 if text.trim().is_empty() {
1903 return Ok(ldp::Graph::new());
1904 }
1905 ldp::Graph::parse_ntriples(text).map_err(|_| {
1906 actix_web::error::ErrorConflict(
1907 "existing resource is not N-Triples RDF and cannot be non-destructively \
1908 patched; PUT an N-Triples representation or use a JSON Patch",
1909 )
1910 })
1911}
1912
1913pub(crate) async fn find_effective_acl_dyn(
1929 storage: &dyn Storage,
1930 resource_path: &str,
1931) -> Result<Option<wac::AclDocument>, PodError> {
1932 resolve_policy_dyn(storage, resource_path)
1933 .await
1934 .into_result()
1935}
1936
1937pub(crate) async fn resolve_policy_dyn(
1942 storage: &dyn Storage,
1943 resource_path: &str,
1944) -> wac::PolicyOutcome {
1945 wac::resolve_policy_from_storage(storage, resource_path).await
1946}
1947
1948fn policy_failure_to_actix(outcome: &wac::PolicyOutcome, resource: &str) -> ActixError {
1958 match outcome {
1959 wac::PolicyOutcome::Invalid {
1960 policy_path,
1961 reason,
1962 } => {
1963 tracing::warn!(
1964 target: "solid_pod_rs_server::wac",
1965 resource = %resource,
1966 policy = %policy_path,
1967 "denying: effective ACL is invalid and MUST NOT inherit: {reason}"
1968 );
1969 actix_web::error::ErrorForbidden("access forbidden: governing ACL is invalid")
1970 }
1971 wac::PolicyOutcome::Unavailable {
1972 policy_path,
1973 detail,
1974 } => {
1975 tracing::error!(
1976 target: "solid_pod_rs_server::wac",
1977 resource = %resource,
1978 policy = %policy_path,
1979 "denying: effective ACL could not be read: {detail}"
1980 );
1981 actix_web::error::ErrorServiceUnavailable(
1982 "access control unavailable: governing ACL could not be read",
1983 )
1984 }
1985 _ => actix_web::error::ErrorInternalServerError("policy resolution error"),
1987 }
1988}
1989
1990async fn handle_delete(
1991 req: HttpRequest,
1992 state: web::Data<AppState>,
1993) -> Result<HttpResponse, ActixError> {
1994 let path = req.uri().path().to_string();
1995 let auth_pk = extract_pubkey(&req).await;
1996 let agent = agent_uri(auth_pk.as_ref());
1997 enforce_write_ctx(
1998 &state,
1999 &path,
2000 AccessMode::Write,
2001 agent.as_deref(),
2002 req_origin(&req),
2003 )
2004 .await?;
2005
2006 let quota = reserve_quota_for_size(&state, &path, 0).await?;
2007 match state.storage.delete(&path).await {
2008 Ok(()) => {
2009 finish_quota_reservation(&state, quota, true).await;
2010 Ok(HttpResponse::NoContent().finish())
2011 }
2012 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
2013 Err(e) => Err(to_actix(e)),
2014 }
2015}
2016
2017async fn handle_options(
2018 req: HttpRequest,
2019 state: web::Data<AppState>,
2020) -> Result<HttpResponse, ActixError> {
2021 let path = req.uri().path().to_string();
2022 let o = ldp::options_for(&path);
2023 let mut rsp = HttpResponse::NoContent().finish();
2024 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
2025 rsp.headers_mut()
2026 .insert(header::HeaderName::from_static("allow"), v);
2027 }
2028 if let Some(ap) = o.accept_post {
2029 if let Ok(v) = header::HeaderValue::from_str(ap) {
2030 rsp.headers_mut()
2031 .insert(header::HeaderName::from_static("accept-post"), v);
2032 }
2033 }
2034 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
2035 rsp.headers_mut()
2036 .insert(header::HeaderName::from_static("accept-patch"), v);
2037 }
2038 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
2039 rsp.headers_mut()
2040 .insert(header::HeaderName::from_static("accept-ranges"), v);
2041 }
2042 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
2043 Ok(rsp)
2044}
2045
2046async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
2051 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
2052 HttpResponse::Ok()
2053 .content_type("application/ld+json")
2054 .json(doc)
2055}
2056
2057#[derive(Debug, Deserialize)]
2058struct WebFingerQuery {
2059 resource: Option<String>,
2060}
2061
2062async fn handle_well_known_webfinger(
2063 state: web::Data<AppState>,
2064 q: web::Query<WebFingerQuery>,
2065) -> HttpResponse {
2066 let resource = q.resource.clone().unwrap_or_else(|| {
2067 format!(
2068 "acct:anonymous@{}",
2069 state
2070 .nodeinfo
2071 .base_url
2072 .trim_start_matches("http://")
2073 .trim_start_matches("https://")
2074 )
2075 });
2076 let webid = format!(
2077 "{}/profile/card#me",
2078 state.nodeinfo.base_url.trim_end_matches('/')
2079 );
2080 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
2081 Some(jrd) => HttpResponse::Ok()
2082 .content_type("application/jrd+json")
2083 .json(jrd),
2084 None => HttpResponse::NotFound().finish(),
2085 }
2086}
2087
2088async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
2089 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
2090 HttpResponse::Ok()
2091 .content_type("application/json")
2092 .json(doc)
2093}
2094
2095async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
2096 let doc = interop::nodeinfo_2_1(
2097 &state.nodeinfo.software_name,
2098 &state.nodeinfo.software_version,
2099 state.nodeinfo.open_registrations,
2100 state.nodeinfo.total_users,
2101 );
2102 HttpResponse::Ok()
2103 .content_type("application/json")
2104 .json(doc)
2105}
2106
2107#[cfg(feature = "did-nostr")]
2108async fn handle_well_known_did_nostr(
2109 state: web::Data<AppState>,
2110 path: web::Path<String>,
2111) -> HttpResponse {
2112 let pubkey = path.into_inner();
2113 let pubkey_is_valid = pubkey.len() == 64
2118 && pubkey
2119 .bytes()
2120 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
2121 if !pubkey_is_valid {
2122 return HttpResponse::BadRequest()
2123 .insert_header(("Cache-Control", "no-store"))
2124 .json(serde_json::json!({
2125 "error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
2126 }));
2127 }
2128 let owner_pubkey = match state.storage.get("/profile/card").await {
2136 Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
2137 .ok()
2138 .flatten(),
2139 Err(_) => None,
2140 };
2141 let owner_claims_key = owner_pubkey
2142 .as_deref()
2143 .is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
2144 if !owner_claims_key {
2145 return HttpResponse::NotFound()
2146 .insert_header(("Cache-Control", "no-store"))
2147 .json(serde_json::json!({
2148 "error": "no account on this pod claims this did:nostr pubkey"
2149 }));
2150 }
2151 let also = vec![format!(
2152 "{}/profile/card#me",
2153 state.nodeinfo.base_url.trim_end_matches('/')
2154 )];
2155 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
2156 let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
2157 use std::hash::{Hash, Hasher};
2163 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2164 body.hash(&mut hasher);
2165 let etag = format!("\"{:016x}\"", hasher.finish());
2166 HttpResponse::Ok()
2167 .content_type("application/did+json")
2168 .insert_header(("Cache-Control", "max-age=3600"))
2169 .insert_header(("ETag", etag))
2170 .body(body)
2171}
2172
2173#[cfg(feature = "nip05-endpoint")]
2181#[derive(Debug, Deserialize)]
2182struct Nip05Query {
2183 name: Option<String>,
2186}
2187
2188#[cfg(feature = "nip05-endpoint")]
2189fn nip05_name_is_valid(name: &str) -> bool {
2190 if name.is_empty() {
2193 return false;
2194 }
2195 name.bytes()
2196 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
2197}
2198
2199#[cfg(feature = "nip05-endpoint")]
2200async fn handle_well_known_nip05(
2201 state: web::Data<AppState>,
2202 query: web::Query<Nip05Query>,
2203) -> HttpResponse {
2204 use solid_pod_rs::webid::extract_nostr_pubkey;
2205
2206 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
2208 if !nip05_name_is_valid(&name) {
2209 return HttpResponse::BadRequest().json(serde_json::json!({
2210 "error": "invalid NIP-05 local part",
2211 }));
2212 }
2213
2214 let profile_path = if name == "_" {
2220 "/profile/card".to_string()
2221 } else {
2222 format!("/{name}/profile/card")
2223 };
2224
2225 let (body, _meta) = match state.storage.get(&profile_path).await {
2226 Ok(v) => v,
2227 Err(_) => {
2228 return nip05_empty_response();
2232 }
2233 };
2234
2235 let pubkey_hex = match extract_nostr_pubkey(&body) {
2236 Ok(Some(p)) => p,
2237 _ => return nip05_empty_response(),
2238 };
2239
2240 let doc = interop::nip05_document([(name, pubkey_hex)]);
2241 HttpResponse::Ok()
2242 .insert_header(("Access-Control-Allow-Origin", "*"))
2243 .content_type("application/json")
2244 .json(doc)
2245}
2246
2247#[cfg(feature = "nip05-endpoint")]
2248fn nip05_empty_response() -> HttpResponse {
2249 HttpResponse::Ok()
2250 .insert_header(("Access-Control-Allow-Origin", "*"))
2251 .content_type("application/json")
2252 .json(serde_json::json!({ "names": {} }))
2253}
2254
2255#[cfg(feature = "export-jsonld")]
2269async fn handle_export_all(
2270 req: HttpRequest,
2271 state: web::Data<AppState>,
2272) -> Result<HttpResponse, ActixError> {
2273 let auth_pk = extract_pubkey(&req).await;
2274 let agent = agent_uri(auth_pk.as_ref());
2275
2276 enforce_write_ctx(
2281 &state,
2282 "/",
2283 AccessMode::Control,
2284 agent.as_deref(),
2285 req_origin(&req),
2286 )
2287 .await?;
2288
2289 let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
2293 .ok()
2294 .and_then(|q| q.get("include_private").map(|v| v == "true"))
2295 .unwrap_or(false);
2296
2297 let pod_base = {
2301 let conn = req.connection_info();
2302 format!("{}://{}/", conn.scheme(), conn.host())
2303 };
2304
2305 let options = solid_pod_rs::ExportOptions { include_private };
2306 let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
2307 .await
2308 .map_err(to_actix)?;
2309
2310 let body = serde_json::to_vec(&bundle).map_err(|e| {
2311 actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
2312 })?;
2313 Ok(HttpResponse::Ok()
2314 .content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
2315 .body(body))
2316}
2317
2318#[derive(Debug, Deserialize)]
2323struct CreateAccountRequest {
2324 username: String,
2325 #[serde(default)]
2326 name: Option<String>,
2327}
2328
2329#[derive(Debug, Deserialize)]
2330struct CreatePodRequest {
2331 name: String,
2332}
2333
2334async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
2335 let pod_name = path.into_inner();
2336 let pod_root = format!("/{pod_name}/");
2337 match state.storage.exists(&pod_root).await {
2338 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
2339 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
2340 }
2341}
2342
2343fn valid_pod_name(name: &str) -> bool {
2344 !name.is_empty()
2345 && name
2346 .chars()
2347 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
2348}
2349
2350fn request_ip(req: &HttpRequest) -> IpAddr {
2351 req.peer_addr()
2352 .map(|addr| addr.ip())
2353 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
2354}
2355
2356#[derive(Debug)]
2357struct QuotaReservation {
2358 pod: String,
2359 delta: i64,
2360}
2361
2362fn pod_name_from_path(path: &str) -> Option<&str> {
2363 path.trim_start_matches('/')
2364 .split('/')
2365 .next()
2366 .filter(|p| !p.is_empty())
2367}
2368
2369async fn reserve_quota_for_size(
2370 state: &AppState,
2371 path: &str,
2372 new_size: u64,
2373) -> Result<Option<QuotaReservation>, ActixError> {
2374 let (Some(policy), Some(pod)) = (&state.quota, pod_name_from_path(path)) else {
2375 return Ok(None);
2376 };
2377 let old_size = state
2378 .storage
2379 .head(path)
2380 .await
2381 .map(|meta| meta.size)
2382 .unwrap_or(0);
2383 let delta = i128::from(new_size) - i128::from(old_size);
2384 let delta = delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64;
2385 if delta > 0 {
2386 policy
2387 .reserve(pod, delta as u64)
2388 .await
2389 .map_err(|error| actix_web::error::ErrorInsufficientStorage(error.to_string()))?;
2390 }
2391 Ok(Some(QuotaReservation {
2392 pod: pod.to_string(),
2393 delta,
2394 }))
2395}
2396
2397async fn finish_quota_reservation(
2398 state: &AppState,
2399 reservation: Option<QuotaReservation>,
2400 write_succeeded: bool,
2401) {
2402 let (Some(policy), Some(reservation)) = (&state.quota, reservation) else {
2403 return;
2404 };
2405 if write_succeeded {
2406 if reservation.delta < 0 {
2407 policy.record(&reservation.pod, reservation.delta).await;
2408 }
2409 } else if reservation.delta > 0 {
2410 policy.record(&reservation.pod, -reservation.delta).await;
2411 }
2412}
2413
2414async fn handle_create_account(
2415 req: HttpRequest,
2416 state: web::Data<AppState>,
2417 body: web::Json<CreateAccountRequest>,
2418) -> Result<HttpResponse, ActixError> {
2419 if let Some(response) = provisioning_gate(&req, &state, &body.username) {
2420 return Ok(response);
2421 }
2422 let pod_root = format!("/{}/", body.username);
2423 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2424 return Ok(
2425 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
2426 );
2427 }
2428
2429 let base_uri = state.nodeinfo.base_url.trim_end_matches('/');
2430 let outcome =
2431 provision_named_pod(&state, &body.username, body.name.as_deref(), base_uri).await?;
2432 Ok(HttpResponse::Created().json(serde_json::json!({
2433 "webid": outcome.webid,
2434 "pod_root": outcome.pod_uri,
2435 "username": body.username,
2436 })))
2437}
2438
2439struct NamedPodOutcome {
2440 webid: String,
2441 pod_uri: String,
2442}
2443
2444fn provisioning_gate(req: &HttpRequest, state: &AppState, name: &str) -> Option<HttpResponse> {
2447 if !valid_pod_name(name) {
2448 return Some(HttpResponse::BadRequest().json(serde_json::json!({
2449 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
2450 })));
2451 }
2452
2453 let supplied_key = req
2454 .headers()
2455 .get("x-pod-admin-key")
2456 .and_then(|value| value.to_str().ok())
2457 .unwrap_or("");
2458 use subtle::ConstantTimeEq;
2459 let admin_override = state
2460 .admin_key
2461 .as_deref()
2462 .is_some_and(|expected| bool::from(supplied_key.as_bytes().ct_eq(expected.as_bytes())));
2463 if !state.nodeinfo.open_registrations && !admin_override {
2464 return Some(HttpResponse::Forbidden().json(serde_json::json!({
2465 "error": "pod registration is closed"
2466 })));
2467 }
2468
2469 if let Err(retry_after) = state.pod_create_limiter.check(request_ip(req)) {
2470 return Some(
2471 HttpResponse::TooManyRequests()
2472 .insert_header(("Retry-After", retry_after.to_string()))
2473 .json(serde_json::json!({
2474 "error": "Too Many Requests",
2475 "message": "Pod creation rate limit exceeded",
2476 "retryAfter": retry_after
2477 })),
2478 );
2479 }
2480 None
2481}
2482
2483async fn provision_named_pod(
2487 state: &AppState,
2488 name: &str,
2489 display_name: Option<&str>,
2490 base_uri: &str,
2491) -> Result<NamedPodOutcome, ActixError> {
2492 for container in ["", "profile", "inbox", "public", "private", "settings"] {
2493 let path = if container.is_empty() {
2494 format!("/{name}")
2495 } else {
2496 format!("/{name}/{container}")
2497 };
2498 state
2499 .storage
2500 .put(
2501 &format!("{path}.meta"),
2502 Bytes::from_static(b"{}"),
2503 "application/ld+json",
2504 )
2505 .await
2506 .map_err(to_actix)?;
2507 }
2508
2509 let base_uri = base_uri.trim_end_matches('/');
2510 let pod_uri = format!("{base_uri}/{name}/");
2511 let canonical_prefix = format!("{base_uri}/pods/{name}/");
2512 let webid = format!("{pod_uri}profile/card#me");
2513 let profile = solid_pod_rs::webid::generate_webid_html(name, display_name, base_uri)
2514 .replace(&canonical_prefix, &pod_uri);
2515 state
2516 .storage
2517 .put(
2518 &format!("/{name}/profile/card"),
2519 Bytes::from(profile.into_bytes()),
2520 "text/html",
2521 )
2522 .await
2523 .map_err(to_actix)?;
2524
2525 #[cfg(feature = "git")]
2526 if let Some(root) = &state.data_root {
2527 let hook = solid_pod_rs_git::init::GitAutoInit::new();
2528 if let Err(error) = hook.init_repo_at(&root.join(name)).await {
2529 tracing::warn!(pod = name, %error, "git auto-init failed after pod creation");
2530 }
2531 }
2532
2533 Ok(NamedPodOutcome { webid, pod_uri })
2534}
2535
2536async fn handle_create_pod(
2537 req: HttpRequest,
2538 state: web::Data<AppState>,
2539 body: web::Json<CreatePodRequest>,
2540) -> Result<HttpResponse, ActixError> {
2541 if let Some(response) = provisioning_gate(&req, &state, &body.name) {
2542 return Ok(response);
2543 }
2544
2545 let pod_root = format!("/{}/", body.name);
2546 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2547 return Ok(
2548 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
2549 );
2550 }
2551
2552 let base_uri = {
2553 let conn = req.connection_info();
2554 format!("{}://{}", conn.scheme(), conn.host())
2555 };
2556 let outcome = provision_named_pod(&state, &body.name, None, &base_uri).await?;
2557
2558 Ok(HttpResponse::Created()
2559 .insert_header(("Location", outcome.pod_uri.clone()))
2560 .json(serde_json::json!({
2561 "name": body.name,
2562 "webId": outcome.webid,
2563 "podUri": outcome.pod_uri,
2564 })))
2565}
2566
2567async fn handle_copy(
2572 req: HttpRequest,
2573 state: web::Data<AppState>,
2574) -> Result<HttpResponse, ActixError> {
2575 let dest = req.uri().path().to_string();
2576 let auth_pk = extract_pubkey(&req).await;
2577 let agent = agent_uri(auth_pk.as_ref());
2578 enforce_write_ctx(
2579 &state,
2580 &dest,
2581 AccessMode::Write,
2582 agent.as_deref(),
2583 req_origin(&req),
2584 )
2585 .await?;
2586
2587 let source = req
2588 .headers()
2589 .get("source")
2590 .and_then(|v| v.to_str().ok())
2591 .map(|s| s.to_string());
2592 let source = match source {
2593 Some(s) => s,
2594 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
2595 };
2596
2597 let (body, meta) = match state.storage.get(&source).await {
2598 Ok(v) => v,
2599 Err(PodError::NotFound(_)) => {
2600 return Ok(HttpResponse::NotFound().body("source resource not found"))
2601 }
2602 Err(e) => return Err(to_actix(e)),
2603 };
2604
2605 let quota = reserve_quota_for_size(&state, &dest, body.len() as u64).await?;
2606 let write = state.storage.put(&dest, body, &meta.content_type).await;
2607 finish_quota_reservation(&state, quota, write.is_ok()).await;
2608 write.map_err(to_actix)?;
2609
2610 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
2612 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
2613 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
2614 let quota = reserve_quota_for_size(&state, &dst_acl, acl_body.len() as u64).await?;
2615 let write = state
2616 .storage
2617 .put(&dst_acl, acl_body, &acl_meta.content_type)
2618 .await;
2619 finish_quota_reservation(&state, quota, write.is_ok()).await;
2620 write.map_err(to_actix)?;
2621 }
2622
2623 let mut rsp = HttpResponse::Created().finish();
2624 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
2625 rsp.headers_mut().insert(header::LOCATION, loc);
2626 }
2627 Ok(rsp)
2628}
2629
2630async fn handle_glob_get(
2635 req: HttpRequest,
2636 state: web::Data<AppState>,
2637) -> Result<HttpResponse, ActixError> {
2638 let raw_path = req.uri().path().to_string();
2639 if !raw_path.ends_with("/*") {
2641 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
2642 }
2643 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
2645 folder.to_string()
2646 } else {
2647 format!("{folder}/")
2648 };
2649
2650 let auth_pk = extract_pubkey(&req).await;
2654 let agent = agent_uri(auth_pk.as_ref());
2655 enforce_read_ctx(&state, &folder, agent.as_deref(), req_origin(&req)).await?;
2656
2657 let children = state.storage.list(&folder).await.map_err(to_actix)?;
2658 let mut merged = String::new();
2659
2660 for child in &children {
2661 if child.ends_with('/') {
2662 continue;
2663 }
2664 let child_path = format!("{folder}{child}");
2665 if let Ok((body, meta)) = state.storage.get(&child_path).await {
2666 if meta.content_type.contains("turtle")
2667 || meta.content_type.contains("n-triples")
2668 || meta.content_type.contains("n3")
2669 {
2670 if let Ok(text) = std::str::from_utf8(&body) {
2671 merged.push_str(text);
2672 merged.push('\n');
2673 }
2674 }
2675 }
2676 }
2677
2678 if merged.is_empty() {
2679 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
2680 }
2681
2682 Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
2683}
2684
2685#[derive(Debug, Deserialize)]
2690struct LoginPasswordRequest {
2691 username: String,
2692 password: String,
2693}
2694
2695async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
2702 let _ = (&body.username, &body.password);
2703 HttpResponse::NotImplemented().json(serde_json::json!({
2704 "error": "password login is not implemented on this pod"
2705 }))
2706}
2707
2708#[derive(Debug, Deserialize)]
2709struct PasswordResetRequest {
2710 username: String,
2711}
2712
2713async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
2717 let _ = &body.username;
2718 HttpResponse::NotImplemented().json(serde_json::json!({
2719 "error": "password reset is not implemented on this pod"
2720 }))
2721}
2722
2723#[derive(Debug, Deserialize)]
2724struct PasswordChangeRequest {
2725 token: String,
2726 new_password: String,
2727}
2728
2729async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
2734 let _ = (&body.token, &body.new_password);
2735 HttpResponse::NotImplemented().json(serde_json::json!({
2736 "error": "password change is not implemented on this pod"
2737 }))
2738}
2739
2740async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
2745 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
2746 HttpResponse::Ok()
2747 .content_type("application/json")
2748 .json(body)
2749}
2750
2751pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
2766
2767#[derive(Debug, Deserialize)]
2769struct ProxyQuery {
2770 url: String,
2771}
2772
2773const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
2775 "set-cookie",
2776 "set-cookie2",
2777 "authorization",
2778 "www-authenticate",
2779 "proxy-authenticate",
2780 "proxy-authorization",
2781];
2782
2783async fn validate_proxy_target(target: &str) -> Result<(url::Url, IpAddr), HttpResponse> {
2799 let parsed = match url::Url::parse(target) {
2800 Ok(u) => u,
2801 Err(_) => {
2802 return Err(
2803 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
2804 );
2805 }
2806 };
2807
2808 match parsed.scheme() {
2810 "http" | "https" => {}
2811 scheme => {
2812 return Err(HttpResponse::BadRequest()
2813 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
2814 }
2815 }
2816
2817 if solid_pod_rs::security::is_safe_url(target).is_err() {
2820 return Err(HttpResponse::Forbidden()
2821 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
2822 }
2823
2824 let host = match parsed.host_str() {
2826 Some(h) => h.to_string(),
2827 None => {
2828 return Err(HttpResponse::BadRequest()
2829 .json(serde_json::json!({"error": "target URL has no host"})))
2830 }
2831 };
2832 let host_lower = host.to_ascii_lowercase();
2833 if host_lower == "localhost"
2834 || host_lower.ends_with(".localhost")
2835 || host_lower == "0.0.0.0"
2836 || host_lower == "[::1]"
2837 || host_lower == "[::0]"
2838 {
2839 return Err(HttpResponse::Forbidden()
2840 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
2841 }
2842
2843 match solid_pod_rs::security::resolve_and_check(&host).await {
2846 Ok(ip) => Ok((parsed, ip)),
2847 Err(_) => Err(HttpResponse::Forbidden()
2848 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"}))),
2849 }
2850}
2851
2852fn build_pinned_proxy_client(url: &url::Url, ip: IpAddr) -> Result<reqwest::Client, ActixError> {
2855 let mut builder = reqwest::Client::builder()
2856 .redirect(reqwest::redirect::Policy::none());
2859 if let Some(host) = url.host_str() {
2860 let port = url.port_or_known_default().unwrap_or(0);
2862 builder = builder.resolve(host, std::net::SocketAddr::new(ip, port));
2863 }
2864 builder
2865 .build()
2866 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))
2867}
2868
2869async fn handle_proxy(
2870 req: HttpRequest,
2871 _state: web::Data<AppState>,
2872 query: web::Query<ProxyQuery>,
2873) -> Result<HttpResponse, ActixError> {
2874 let auth_pk = extract_pubkey(&req).await;
2876 let agent = agent_uri(auth_pk.as_ref());
2877 if agent.is_none() {
2878 return Ok(HttpResponse::Unauthorized()
2879 .json(serde_json::json!({"error": "authentication required"})));
2880 }
2881
2882 let mut current_url = query.url.clone();
2883 let mut redirect_count = 0u8;
2884 const MAX_REDIRECTS: u8 = 5;
2885 const TOTAL_TIMEOUT: Duration = Duration::from_secs(30);
2886 let started = Instant::now();
2887
2888 let byte_cap = std::env::var("PROXY_BYTE_CAP")
2889 .ok()
2890 .and_then(|v| {
2891 solid_pod_rs::config::sources::parse_size(&v)
2892 .map(|u| u as usize)
2893 .ok()
2894 })
2895 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
2896
2897 loop {
2898 let Some(remaining) = TOTAL_TIMEOUT.checked_sub(started.elapsed()) else {
2899 return Ok(HttpResponse::GatewayTimeout()
2900 .json(serde_json::json!({"error": "proxy operation timed out"})));
2901 };
2902 let (target_url, pinned_ip) = match validate_proxy_target(¤t_url).await {
2906 Ok(pair) => pair,
2907 Err(rsp) => return Ok(rsp),
2908 };
2909 let client = build_pinned_proxy_client(&target_url, pinned_ip)?;
2910
2911 let mut upstream_req = client.get(¤t_url).timeout(remaining);
2912
2913 if let Some(auth_val) = req
2915 .headers()
2916 .get("x-upstream-authorization")
2917 .and_then(|v| v.to_str().ok())
2918 {
2919 upstream_req = upstream_req.header("Authorization", auth_val);
2920 }
2921
2922 let response = upstream_req
2923 .send()
2924 .await
2925 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
2926
2927 if response.status().is_redirection() {
2929 if redirect_count >= MAX_REDIRECTS {
2930 return Ok(HttpResponse::BadGateway()
2931 .json(serde_json::json!({"error": "too many redirects"})));
2932 }
2933 if let Some(location) = response.headers().get("location") {
2934 let loc_str = location
2935 .to_str()
2936 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
2937 let base = url::Url::parse(¤t_url)
2939 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
2940 let resolved = base
2941 .join(loc_str)
2942 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
2943 current_url = resolved.to_string();
2944 redirect_count += 1;
2945 continue;
2946 }
2947 return Ok(HttpResponse::BadGateway()
2948 .json(serde_json::json!({"error": "redirect without location"})));
2949 }
2950
2951 let upstream_status = response.status().as_u16();
2953 let upstream_content_type = response
2954 .headers()
2955 .get("content-type")
2956 .and_then(|v| v.to_str().ok())
2957 .unwrap_or("application/octet-stream")
2958 .to_string();
2959 if response
2960 .content_length()
2961 .is_some_and(|length| length > byte_cap as u64)
2962 {
2963 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
2964 "error": "proxied response exceeds byte cap",
2965 "limit": byte_cap
2966 })));
2967 }
2968
2969 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
2971 for (name, value) in response.headers() {
2972 let name_lower = name.as_str().to_ascii_lowercase();
2973 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
2974 continue;
2975 }
2976 if matches!(
2978 name_lower.as_str(),
2979 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
2980 ) {
2981 continue;
2982 }
2983 if let Ok(val_str) = value.to_str() {
2984 forwarded_headers.push((name_lower, val_str.to_string()));
2985 }
2986 }
2987
2988 let mut stream = response.bytes_stream();
2989 let mut body_bytes = Vec::with_capacity(byte_cap.min(64 * 1024));
2990 while let Some(chunk) = stream.next().await {
2991 let chunk =
2992 chunk.map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
2993 if body_bytes.len().saturating_add(chunk.len()) > byte_cap {
2994 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
2995 "error": "proxied response exceeds byte cap",
2996 "limit": byte_cap
2997 })));
2998 }
2999 body_bytes.extend_from_slice(&chunk);
3000 }
3001
3002 let mut rsp = HttpResponse::build(
3004 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3005 );
3006 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
3007 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
3008
3009 for (name, value) in &forwarded_headers {
3011 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
3012 if let Ok(hval) = header::HeaderValue::from_str(value) {
3013 rsp.insert_header((hname, hval));
3014 }
3015 }
3016 }
3017
3018 return Ok(rsp.body(body_bytes));
3019 }
3020}
3021
3022pub struct PathTraversalGuard;
3028
3029impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
3030where
3031 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3032 B: 'static,
3033{
3034 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3035 type Error = ActixError;
3036 type InitError = ();
3037 type Transform = PathTraversalGuardMiddleware<S>;
3038 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3039
3040 fn new_transform(&self, service: S) -> Self::Future {
3041 ready(Ok(PathTraversalGuardMiddleware { service }))
3042 }
3043}
3044
3045pub struct PathTraversalGuardMiddleware<S> {
3047 service: S,
3048}
3049
3050impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
3051where
3052 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3053 B: 'static,
3054{
3055 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3056 type Error = ActixError;
3057 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3058
3059 actix_web::dev::forward_ready!(service);
3060
3061 fn call(&self, req: ServiceRequest) -> Self::Future {
3062 let raw = req.path().to_string();
3065 if path_is_traversal(&raw) {
3066 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
3067 let sr = req.into_response(rsp.map_into_boxed_body());
3068 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3069 }
3070 let fut = self.service.call(req);
3071 Box::pin(async move {
3072 let resp = fut.await?;
3073 Ok(resp.map_into_left_body())
3074 })
3075 }
3076}
3077
3078fn path_is_traversal(path: &str) -> bool {
3079 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
3081 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
3082 for seg in once.split('/').chain(twice.split('/')) {
3083 if seg == ".." || seg == "." {
3084 return true;
3085 }
3086 }
3087 if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
3090 return true;
3091 }
3092 false
3093}
3094
3095pub struct CorsHeaders {
3106 pub allowed_origins: Arc<Vec<String>>,
3107}
3108
3109impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
3110where
3111 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3112 B: 'static,
3113{
3114 type Response = ServiceResponse<B>;
3115 type Error = ActixError;
3116 type InitError = ();
3117 type Transform = CorsHeadersMiddleware<S>;
3118 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3119
3120 fn new_transform(&self, service: S) -> Self::Future {
3121 ready(Ok(CorsHeadersMiddleware {
3122 service,
3123 allowed_origins: self.allowed_origins.clone(),
3124 }))
3125 }
3126}
3127
3128pub struct CorsHeadersMiddleware<S> {
3130 service: S,
3131 allowed_origins: Arc<Vec<String>>,
3132}
3133
3134impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
3135where
3136 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3137 B: 'static,
3138{
3139 type Response = ServiceResponse<B>;
3140 type Error = ActixError;
3141 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3142
3143 actix_web::dev::forward_ready!(service);
3144
3145 fn call(&self, req: ServiceRequest) -> Self::Future {
3146 let origin = req
3147 .headers()
3148 .get(header::ORIGIN)
3149 .and_then(|v| v.to_str().ok())
3150 .map(str::to_string);
3151 let allowed = self.allowed_origins.clone();
3152 let fut = self.service.call(req);
3153 Box::pin(async move {
3154 let mut resp = fut.await?;
3155 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
3156 Ok(resp)
3157 })
3158 }
3159}
3160
3161fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
3162 if headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
3172 return;
3173 }
3174 let (origin_value, allow_credentials): (String, bool) = if allowed.is_empty() {
3185 ("*".to_string(), false)
3186 } else {
3187 match origin.filter(|o| allowed.iter().any(|a| a == *o)) {
3188 Some(o) => (o.to_string(), true),
3189 None => return,
3192 }
3193 };
3194
3195 let mut pairs = vec![
3196 ("access-control-allow-origin", origin_value.as_str()),
3197 (
3198 "access-control-allow-methods",
3199 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
3200 ),
3201 (
3202 "access-control-allow-headers",
3203 "Accept, Authorization, Content-Type, DPoP, Git-Protocol, If-Match, If-None-Match, Link, Range, Slug, Origin",
3204 ),
3205 (
3206 "access-control-expose-headers",
3207 "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",
3208 ),
3209 ("access-control-max-age", "86400"),
3210 ];
3211 if allow_credentials {
3214 pairs.push(("access-control-allow-credentials", "true"));
3215 }
3216
3217 for (name, value) in pairs {
3218 if let (Ok(name), Ok(value)) = (
3219 header::HeaderName::from_lowercase(name.as_bytes()),
3220 header::HeaderValue::from_str(value),
3221 ) {
3222 headers.insert(name, value);
3223 }
3224 }
3225}
3226
3227pub struct ErrorLoggingMiddleware;
3243
3244impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
3245where
3246 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3247 B: 'static,
3248{
3249 type Response = ServiceResponse<B>;
3250 type Error = ActixError;
3251 type InitError = ();
3252 type Transform = ErrorLoggingMiddlewareService<S>;
3253 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3254
3255 fn new_transform(&self, service: S) -> Self::Future {
3256 ready(Ok(ErrorLoggingMiddlewareService { service }))
3257 }
3258}
3259
3260pub struct ErrorLoggingMiddlewareService<S> {
3262 service: S,
3263}
3264
3265impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
3266where
3267 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3268 B: 'static,
3269{
3270 type Response = ServiceResponse<B>;
3271 type Error = ActixError;
3272 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3273
3274 actix_web::dev::forward_ready!(service);
3275
3276 fn call(&self, req: ServiceRequest) -> Self::Future {
3277 let method = req.method().as_str().to_string();
3280 let path = req.path().to_string();
3281
3282 let fut = self.service.call(req);
3283 Box::pin(async move {
3284 let response = fut.await?;
3285 let status = response.status();
3286 if status.is_server_error() {
3287 log_5xx(&method, &path, status, response.response().error());
3288 }
3289 Ok(response)
3290 })
3291 }
3292}
3293
3294fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
3298 let chain = match error {
3302 Some(e) => format_error_chain(e),
3303 None => "<no error attached to response>".to_string(),
3304 };
3305
3306 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
3307 Some(std::backtrace::Backtrace::force_capture().to_string())
3308 } else {
3309 None
3310 };
3311
3312 tracing::error!(
3313 target: "solid_pod_rs_server::http",
3314 method = %method,
3315 path = %path,
3316 status = %status.as_u16(),
3317 error.chain = %chain,
3318 backtrace = backtrace.as_deref().unwrap_or(""),
3319 "5xx response"
3320 );
3321}
3322
3323fn format_error_chain(e: &actix_web::Error) -> String {
3334 let summary = format!("{}", e.as_response_error());
3335 let debug = format!("{e:?}");
3336 if debug == summary || debug.is_empty() {
3337 summary
3338 } else {
3339 format!("{summary} -> {debug}")
3340 }
3341}
3342
3343pub struct DotfileGuard {
3349 allow: Arc<DotfileAllowlist>,
3350}
3351
3352impl DotfileGuard {
3353 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
3354 Self { allow }
3355 }
3356}
3357
3358impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
3359where
3360 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3361 B: 'static,
3362{
3363 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3364 type Error = ActixError;
3365 type InitError = ();
3366 type Transform = DotfileGuardMiddleware<S>;
3367 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3368
3369 fn new_transform(&self, service: S) -> Self::Future {
3370 ready(Ok(DotfileGuardMiddleware {
3371 service,
3372 allow: self.allow.clone(),
3373 }))
3374 }
3375}
3376
3377pub struct DotfileGuardMiddleware<S> {
3379 service: S,
3380 allow: Arc<DotfileAllowlist>,
3381}
3382
3383impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
3384where
3385 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3386 B: 'static,
3387{
3388 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3389 type Error = ActixError;
3390 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3391
3392 actix_web::dev::forward_ready!(service);
3393
3394 fn call(&self, req: ServiceRequest) -> Self::Future {
3395 let path = req.path().to_string();
3396 let allow_system_route =
3403 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
3404 if !allow_system_route {
3405 let pb = PathBuf::from(&path);
3406 if !self.allow.is_allowed(Path::new(&pb)) {
3407 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
3408 let sr = req.into_response(rsp.map_into_boxed_body());
3409 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3410 }
3411 }
3412 let fut = self.service.call(req);
3413 Box::pin(async move {
3414 let resp = fut.await?;
3415 Ok(resp.map_into_left_body())
3416 })
3417 }
3418}
3419
3420#[cfg(feature = "git")]
3425pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
3426 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
3427 return None;
3428 }
3429 state.data_root.as_ref().map(|root| root.join(pubkey))
3430}
3431
3432#[cfg(feature = "git")]
3462async fn git_mark_write(
3463 state: &AppState,
3464 resource_path: &str,
3465 agent: Option<&str>,
3466 message: &str,
3467) -> ProvenanceReceipt {
3468 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
3469 use solid_pod_rs_git::mark::ShellGitMarker;
3470
3471 if resource_path.ends_with(".acl")
3474 || resource_path.ends_with(".meta")
3475 || resource_path.ends_with(".prov.ttl")
3476 {
3477 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::ExcludedPath);
3478 }
3479 if resource_path.ends_with('/') {
3481 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::Container);
3482 }
3483
3484 let Some(data_root) = state.data_root.as_ref() else {
3486 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured);
3487 };
3488
3489 let trimmed = resource_path.trim_start_matches('/');
3491 let mut segments = trimmed.splitn(2, '/');
3492 let pod = segments.next().unwrap_or("");
3493 let rel = segments.next().unwrap_or("");
3494 if pod.is_empty() || rel.is_empty() {
3495 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::UnresolvablePath);
3496 }
3497 let repo = data_root.join(pod);
3498
3499 if !repo.join(".git").is_dir() {
3503 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotGitBacked);
3504 }
3505
3506 let agent_did = agent.unwrap_or("urn:solid:anonymous");
3507 let created = std::time::SystemTime::now()
3508 .duration_since(std::time::UNIX_EPOCH)
3509 .map(|d| d.as_secs())
3510 .unwrap_or(0);
3511
3512 let (policy, ticker_override) =
3515 handlers::prov::resolve_anchor_policy(state, resource_path).await;
3516
3517 let marker = std::sync::Arc::new(ShellGitMarker::new());
3522 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
3523 None
3524 } else {
3525 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
3526 };
3527 let (log, ticker, network) = match &anchorer_bundle {
3528 Some((anchorer, ticker, network)) => (
3529 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
3530 ticker.clone(),
3531 network.clone(),
3532 ),
3533 None => (
3535 ProvenanceLog::new(marker.clone()),
3536 String::new(),
3537 String::new(),
3538 ),
3539 };
3540
3541 let record_policy = match policy {
3545 AnchorPolicy::Epoch => AnchorPolicy::Never,
3546 other => other,
3547 };
3548 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
3549
3550 let write_record = solid_pod_rs::provenance::WriteRecord {
3554 repo: &repo,
3555 path: rel,
3556 agent_did,
3557 message,
3558 policy: record_policy,
3559 high_value,
3560 ticker: &ticker,
3561 network: &network,
3562 created,
3563 };
3564 let mut receipt = log.record_receipt(write_record).await;
3569 receipt.resource = resource_path.to_string();
3572
3573 let Some(git) = receipt.mark.clone() else {
3574 tracing::warn!(
3578 target: "solid_pod_rs_server::git_mark",
3579 resource = %resource_path,
3580 "provenance record failed (write already succeeded): {}",
3581 receipt.mark_error.as_deref().unwrap_or("unspecified")
3582 );
3583 return receipt;
3584 };
3585 if let Some(err) = &receipt.anchor_error {
3586 tracing::warn!(
3589 target: "solid_pod_rs_server::git_mark",
3590 resource = %resource_path,
3591 commit = %git.commit_sha,
3592 "block-trail anchor failed (git-mark stands): {err}"
3593 );
3594 }
3595 let mark = solid_pod_rs::provenance::ProvenanceMark {
3596 resource: resource_path.to_string(),
3597 git: git.clone(),
3598 anchor: receipt.anchor.clone(),
3599 agent_did: agent_did.to_string(),
3600 created,
3601 };
3602
3603 if matches!(policy, AnchorPolicy::Epoch) {
3607 if let Some((anchorer, _, _)) = &anchorer_bundle {
3608 match handlers::prov::epoch_push_and_maybe_anchor(
3609 state,
3610 anchorer,
3611 &ticker,
3612 &network,
3613 &mark.git.commit_sha,
3614 )
3615 .await
3616 {
3617 Ok(Some(closed)) => tracing::debug!(
3618 target: "solid_pod_rs_server::git_mark",
3619 root = %closed.root,
3620 n = closed.commits.len(),
3621 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
3622 ),
3623 Ok(None) => {}
3624 Err(e) => tracing::warn!(
3625 target: "solid_pod_rs_server::git_mark",
3626 "epoch batch/anchor failed (swallowed): {e}"
3627 ),
3628 }
3629 }
3630 }
3631
3632 let ttl = prov_ttl(&mark);
3637 let sidecar = format!("{resource_path}.prov.ttl");
3638 if let Err(e) = state
3639 .storage
3640 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
3641 .await
3642 {
3643 tracing::warn!(
3644 target: "solid_pod_rs_server::git_mark",
3645 sidecar = %sidecar,
3646 "provenance sidecar write failed: {e}"
3647 );
3648 receipt.mark_error = Some(format!("PROV-O sidecar write failed: {e}"));
3651 return receipt;
3652 }
3653
3654 tracing::debug!(
3655 target: "solid_pod_rs_server::git_mark",
3656 resource = %resource_path,
3657 commit = %mark.git.commit_sha,
3658 stage = %receipt.stage(),
3659 "provenance recorded"
3660 );
3661 receipt
3662}
3663
3664#[cfg(not(feature = "git"))]
3667#[inline]
3668async fn git_mark_write(
3669 _state: &AppState,
3670 resource_path: &str,
3671 _agent: Option<&str>,
3672 _message: &str,
3673) -> ProvenanceReceipt {
3674 ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured)
3675}
3676
3677#[cfg(feature = "git")]
3678pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
3679 require_pod_owner_with_body(req, pod_pubkey, None).await
3680}
3681
3682#[cfg(feature = "git")]
3683pub(crate) async fn require_pod_owner_with_body(
3684 req: &HttpRequest,
3685 pod_pubkey: &str,
3686 body: Option<&[u8]>,
3687) -> Option<String> {
3688 let caller = extract_pubkey_with_body(req, body).await?;
3689 if caller != pod_pubkey {
3690 return None;
3691 }
3692 Some(caller)
3693}
3694
3695#[cfg(feature = "git")]
3696fn git_json_err(msg: &str, status: u16) -> HttpResponse {
3697 HttpResponse::build(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
3698 .content_type("application/json")
3699 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
3700}
3701
3702#[cfg(feature = "git")]
3704#[derive(serde::Deserialize)]
3705struct GitStageBody {
3706 paths: Option<Vec<String>>,
3707 all: Option<bool>,
3708}
3709
3710#[cfg(feature = "git")]
3711#[derive(serde::Deserialize)]
3712struct GitCommitBody {
3713 message: String,
3714 author_name: Option<String>,
3715 author_email: Option<String>,
3716}
3717
3718#[cfg(feature = "git")]
3719#[derive(serde::Deserialize)]
3720struct GitBranchBody {
3721 name: String,
3722}
3723
3724#[cfg(feature = "git")]
3727async fn handle_git_status(
3728 path: web::Path<String>,
3729 req: HttpRequest,
3730 state: web::Data<AppState>,
3731) -> HttpResponse {
3732 let pubkey = path.into_inner();
3733 if require_pod_owner(&req, &pubkey).await.is_none() {
3734 return git_json_err("Authentication required", 401);
3735 }
3736 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3737 return git_json_err("Git not available (no FS backend)", 501);
3738 };
3739 match solid_pod_rs_git::api::git_status(&repo).await {
3740 Ok(s) => HttpResponse::Ok()
3741 .content_type("application/json")
3742 .body(serde_json::to_string(&s).unwrap_or_default()),
3743 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3744 }
3745}
3746
3747#[cfg(feature = "git")]
3748async fn handle_git_log(
3749 path: web::Path<String>,
3750 req: HttpRequest,
3751 state: web::Data<AppState>,
3752 query: web::Query<std::collections::HashMap<String, String>>,
3753) -> HttpResponse {
3754 let pubkey = path.into_inner();
3755 if require_pod_owner(&req, &pubkey).await.is_none() {
3756 return git_json_err("Authentication required", 401);
3757 }
3758 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3759 return git_json_err("Git not available (no FS backend)", 501);
3760 };
3761 let limit: u32 = query
3762 .get("limit")
3763 .and_then(|v| v.parse().ok())
3764 .unwrap_or(20);
3765 match solid_pod_rs_git::api::git_log(&repo, limit).await {
3766 Ok(entries) => HttpResponse::Ok()
3767 .content_type("application/json")
3768 .body(serde_json::to_string(&entries).unwrap_or_default()),
3769 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3770 }
3771}
3772
3773#[cfg(feature = "git")]
3774async fn handle_git_diff(
3775 path: web::Path<String>,
3776 req: HttpRequest,
3777 state: web::Data<AppState>,
3778 query: web::Query<std::collections::HashMap<String, String>>,
3779) -> HttpResponse {
3780 let pubkey = path.into_inner();
3781 if require_pod_owner(&req, &pubkey).await.is_none() {
3782 return git_json_err("Authentication required", 401);
3783 }
3784 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3785 return git_json_err("Git not available (no FS backend)", 501);
3786 };
3787 let file_path = query.get("path").map(String::as_str);
3788 let staged = query
3789 .get("staged")
3790 .map(|v| v == "true" || v == "1")
3791 .unwrap_or(false);
3792 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
3793 Ok(diff) => HttpResponse::Ok().content_type("text/plain").body(diff),
3794 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3795 }
3796}
3797
3798#[cfg(feature = "git")]
3799async fn handle_git_stage(
3800 path: web::Path<String>,
3801 req: HttpRequest,
3802 state: web::Data<AppState>,
3803 body: web::Bytes,
3804) -> HttpResponse {
3805 let pubkey = path.into_inner();
3806 if require_pod_owner(&req, &pubkey).await.is_none() {
3807 return git_json_err("Authentication required", 401);
3808 }
3809 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3810 return git_json_err("Git not available (no FS backend)", 501);
3811 };
3812 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3813 Ok(v) => v,
3814 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3815 };
3816 let paths = parsed.paths.unwrap_or_default();
3817 let all = parsed.all.unwrap_or(false);
3818 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
3819 Ok(()) => HttpResponse::Ok()
3820 .content_type("application/json")
3821 .body(r#"{"ok":true}"#),
3822 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3823 }
3824}
3825
3826#[cfg(feature = "git")]
3827async fn handle_git_unstage(
3828 path: web::Path<String>,
3829 req: HttpRequest,
3830 state: web::Data<AppState>,
3831 body: web::Bytes,
3832) -> HttpResponse {
3833 let pubkey = path.into_inner();
3834 if require_pod_owner(&req, &pubkey).await.is_none() {
3835 return git_json_err("Authentication required", 401);
3836 }
3837 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3838 return git_json_err("Git not available (no FS backend)", 501);
3839 };
3840 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3841 Ok(v) => v,
3842 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3843 };
3844 let paths = parsed.paths.unwrap_or_default();
3845 let all = parsed.all.unwrap_or(false);
3846 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
3847 Ok(()) => HttpResponse::Ok()
3848 .content_type("application/json")
3849 .body(r#"{"ok":true}"#),
3850 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3851 }
3852}
3853
3854#[cfg(feature = "git")]
3855async fn handle_git_commit(
3856 path: web::Path<String>,
3857 req: HttpRequest,
3858 state: web::Data<AppState>,
3859 body: web::Bytes,
3860) -> HttpResponse {
3861 let pubkey = path.into_inner();
3862 if require_pod_owner(&req, &pubkey).await.is_none() {
3863 return git_json_err("Authentication required", 401);
3864 }
3865 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3866 return git_json_err("Git not available (no FS backend)", 501);
3867 };
3868 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
3869 Ok(v) => v,
3870 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3871 };
3872 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
3873 let author_email = parsed
3874 .author_email
3875 .as_deref()
3876 .unwrap_or("pod@dreamlab-ai.com");
3877 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email).await
3878 {
3879 Ok(result) => HttpResponse::Ok()
3880 .content_type("application/json")
3881 .body(serde_json::to_string(&result).unwrap_or_default()),
3882 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3883 }
3884}
3885
3886#[cfg(feature = "git")]
3887async fn handle_git_branches(
3888 path: web::Path<String>,
3889 req: HttpRequest,
3890 state: web::Data<AppState>,
3891) -> HttpResponse {
3892 let pubkey = path.into_inner();
3893 if require_pod_owner(&req, &pubkey).await.is_none() {
3894 return git_json_err("Authentication required", 401);
3895 }
3896 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3897 return git_json_err("Git not available (no FS backend)", 501);
3898 };
3899 match solid_pod_rs_git::api::git_branches(&repo).await {
3900 Ok(info) => HttpResponse::Ok()
3901 .content_type("application/json")
3902 .body(serde_json::to_string(&info).unwrap_or_default()),
3903 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3904 }
3905}
3906
3907#[cfg(feature = "git")]
3908async fn handle_git_create_branch(
3909 path: web::Path<String>,
3910 req: HttpRequest,
3911 state: web::Data<AppState>,
3912 body: web::Bytes,
3913) -> HttpResponse {
3914 let pubkey = path.into_inner();
3915 if require_pod_owner(&req, &pubkey).await.is_none() {
3916 return git_json_err("Authentication required", 401);
3917 }
3918 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3919 return git_json_err("Git not available (no FS backend)", 501);
3920 };
3921 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
3922 Ok(v) => v,
3923 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3924 };
3925 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
3926 Ok(()) => HttpResponse::Ok()
3927 .content_type("application/json")
3928 .body(r#"{"ok":true}"#),
3929 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3930 }
3931}
3932
3933#[cfg(feature = "git")]
3934async fn handle_git_discard(
3935 path: web::Path<String>,
3936 req: HttpRequest,
3937 state: web::Data<AppState>,
3938 body: web::Bytes,
3939) -> HttpResponse {
3940 let pubkey = path.into_inner();
3941 if require_pod_owner(&req, &pubkey).await.is_none() {
3942 return git_json_err("Authentication required", 401);
3943 }
3944 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3945 return git_json_err("Git not available (no FS backend)", 501);
3946 };
3947 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3948 Ok(v) => v,
3949 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3950 };
3951 let paths = parsed.paths.unwrap_or_default();
3952 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
3953 Ok(()) => HttpResponse::Ok()
3954 .content_type("application/json")
3955 .body(r#"{"ok":true}"#),
3956 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3957 }
3958}
3959
3960async fn handle_git_panel_options(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
3968 let origin = req
3969 .headers()
3970 .get(header::ORIGIN)
3971 .and_then(|v| v.to_str().ok())
3972 .map(str::to_string);
3973
3974 let mut rsp = HttpResponse::NoContent().finish();
3975 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
3976 rsp
3977}
3978
3979async fn handle_admin_provision(
3996 req: HttpRequest,
3997 state: web::Data<AppState>,
3998 path: web::Path<String>,
3999) -> HttpResponse {
4000 let expected = match &state.admin_key {
4002 Some(k) => k.clone(),
4003 None => {
4004 return HttpResponse::Forbidden().json(serde_json::json!({
4005 "error": "admin key not configured on this server"
4006 }));
4007 }
4008 };
4009 let provided = req
4010 .headers()
4011 .get("x-pod-admin-key")
4012 .and_then(|v| v.to_str().ok())
4013 .unwrap_or("");
4014 use subtle::ConstantTimeEq;
4019 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
4020 if !bool::from(key_match) {
4021 return HttpResponse::Forbidden().json(serde_json::json!({"error": "invalid admin key"}));
4022 }
4023
4024 let pubkey = path.into_inner();
4026 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
4027 return HttpResponse::BadRequest()
4028 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
4029 }
4030
4031 let data_root = match &state.data_root {
4033 Some(r) => r.clone(),
4034 None => {
4035 return HttpResponse::InternalServerError().json(serde_json::json!({
4036 "error": "server has no fs-backend storage configured"
4037 }));
4038 }
4039 };
4040
4041 let pods_root = data_root.join("pods");
4054 let pod_dir = pods_root.join(&pubkey);
4055
4056 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
4058 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
4059 return HttpResponse::InternalServerError()
4060 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
4061 }
4062
4063 let acl_content = format!(
4073 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
4074 <#owner> a acl:Authorization ;\n\
4075 acl:agent <did:nostr:{pubkey}> ;\n\
4076 acl:accessTo </pods/{pubkey}/> ;\n\
4077 acl:default </pods/{pubkey}/> ;\n\
4078 acl:mode acl:Read, acl:Write, acl:Control .\n"
4079 );
4080
4081 let sibling_acl_path = pods_root.join(format!("{pubkey}.acl"));
4091 if !sibling_acl_path.exists() {
4092 if let Err(e) = tokio::fs::write(&sibling_acl_path, acl_content.as_bytes()).await {
4093 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write sibling .acl failed");
4094 return HttpResponse::InternalServerError()
4095 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
4096 }
4097 }
4098
4099 let inner_acl_path = pod_dir.join(".acl");
4106 if !inner_acl_path.exists() {
4107 if let Err(e) = tokio::fs::write(&inner_acl_path, acl_content.as_bytes()).await {
4108 tracing::warn!(pubkey = %pubkey, error = %e, "/_admin/provision: write inner .acl failed (non-fatal; sibling ACL governs)");
4109 }
4110 }
4111
4112 #[cfg(feature = "git")]
4114 {
4115 use tokio::process::Command;
4116
4117 if !pod_dir.join(".git").exists() {
4119 let init_out = Command::new("git")
4120 .args(["init", "-b", "main", pod_dir.to_str().unwrap_or(".")])
4121 .output()
4122 .await;
4123
4124 match init_out {
4125 Ok(out) if out.status.success() => {}
4126 Ok(out) => {
4127 let stderr = String::from_utf8_lossy(&out.stderr);
4128 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
4129 }
4130 Err(e) => {
4131 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
4132 }
4133 }
4134
4135 let cfg_out = Command::new("git")
4138 .args([
4139 "-C",
4140 pod_dir.to_str().unwrap_or("."),
4141 "config",
4142 "receive.denyCurrentBranch",
4143 "updateInstead",
4144 ])
4145 .output()
4146 .await;
4147
4148 if let Err(e) = cfg_out {
4149 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
4150 }
4151 }
4152 }
4153
4154 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
4156 HttpResponse::Ok().json(serde_json::json!({
4157 "podUrl": format!("{base_url}/pods/{pubkey}/"),
4158 "ok": true,
4159 }))
4160}
4161
4162async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
4167 let Some(ref data_root) = state.data_root else {
4168 return HttpResponse::Ok()
4169 .content_type("application/json")
4170 .json(serde_json::json!({"apps": [], "count": 0}));
4171 };
4172
4173 let server_url = state.nodeinfo.base_url.clone();
4174
4175 let mut read_dir = match tokio::fs::read_dir(data_root).await {
4177 Ok(rd) => rd,
4178 Err(_) => {
4179 return HttpResponse::Ok()
4180 .content_type("application/json")
4181 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
4182 }
4183 };
4184
4185 let mut apps: Vec<serde_json::Value> = Vec::new();
4186 let mut scanned = 0usize;
4187
4188 while scanned < 1000 {
4189 let entry = match read_dir.next_entry().await {
4190 Ok(Some(e)) => e,
4191 Ok(None) => break,
4192 Err(_) => break,
4193 };
4194
4195 let file_type = match entry.file_type().await {
4196 Ok(ft) => ft,
4197 Err(_) => continue,
4198 };
4199 if !file_type.is_dir() {
4200 continue;
4201 }
4202
4203 scanned += 1;
4204
4205 let manifest_path = entry.path().join("apps").join("manifest.json");
4206 let contents = match tokio::fs::read(&manifest_path).await {
4207 Ok(c) => c,
4208 Err(_) => continue,
4209 };
4210
4211 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
4212 Ok(v) => v,
4213 Err(_) => continue,
4214 };
4215
4216 if let Some(pod_name) = entry.file_name().to_str() {
4218 if manifest.get("podOwner").is_none() {
4219 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
4220 }
4221 }
4222
4223 apps.push(manifest);
4224 }
4225
4226 let count = apps.len();
4227 HttpResponse::Ok()
4228 .content_type("application/json")
4229 .json(serde_json::json!({
4230 "apps": apps,
4231 "serverUrl": server_url,
4232 "count": count,
4233 }))
4234}
4235
4236#[allow(dead_code)]
4249fn is_git_request(path: &str) -> bool {
4250 path.contains("/info/refs")
4251 || path.contains("/git-upload-pack")
4252 || path.contains("/git-receive-pack")
4253}
4254
4255#[allow(dead_code)]
4258fn is_dot_git_path(path: &str) -> bool {
4259 path.contains("/.git/") || path.ends_with("/.git")
4260}
4261
4262#[cfg(feature = "git")]
4263async fn handle_git(
4264 req: HttpRequest,
4265 body: web::Bytes,
4266 state: web::Data<AppState>,
4267) -> HttpResponse {
4268 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
4269 use solid_pod_rs_git::service::{GitHttpService, GitRequest};
4270
4271 let path = req.uri().path().to_string();
4272
4273 let pod_name = path
4276 .trim_start_matches('/')
4277 .split('/')
4278 .next()
4279 .unwrap_or("")
4280 .to_string();
4281 let Some(ref data_root) = state.data_root else {
4282 return HttpResponse::NotImplemented().json(serde_json::json!({
4283 "error": "git requires fs-backend storage",
4284 "reason": "data_root_not_configured"
4285 }));
4286 };
4287 let repo_root = data_root.join(&pod_name);
4288 if !repo_root.exists() {
4289 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
4290 }
4291
4292 let query = req.uri().query().unwrap_or("").to_string();
4293 let host_url = {
4294 let conn = req.connection_info();
4295 Some(format!("{}://{}", conn.scheme(), conn.host()))
4296 };
4297 let headers: Vec<(String, String)> = req
4298 .headers()
4299 .iter()
4300 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
4301 .collect();
4302
4303 let git_req = GitRequest {
4304 method: req.method().as_str().to_string(),
4305 path,
4306 query,
4307 headers,
4308 body,
4309 host_url,
4310 };
4311
4312 let is_write = git_req.is_write();
4324 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
4325 Ok(pk) => Some(format!("did:nostr:{pk}")),
4326 Err(_) => None,
4327 };
4328 let wac_path = format!("/{pod_name}/");
4329 let origin = req_origin(&req);
4330 let wac = if is_write {
4331 enforce_write_ctx(
4332 &state,
4333 &wac_path,
4334 AccessMode::Write,
4335 agent.as_deref(),
4336 origin,
4337 )
4338 .await
4339 } else {
4340 enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin)
4344 .await
4345 .map(|_audience| ())
4346 };
4347 if let Err(e) = wac {
4348 let mut resp = e.error_response();
4352 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4353 if let (Ok(name), Ok(value)) = (
4354 actix_web::http::header::HeaderName::from_bytes(k.as_bytes()),
4355 actix_web::http::header::HeaderValue::from_str(v),
4356 ) {
4357 resp.headers_mut().insert(name, value);
4358 }
4359 }
4360 return resp;
4361 }
4362
4363 let service = GitHttpService::new(repo_root);
4364 match service.handle(git_req).await {
4365 Ok(git_resp) => {
4366 let mut builder = HttpResponse::build(
4367 actix_web::http::StatusCode::from_u16(git_resp.status)
4368 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4369 );
4370 for (k, v) in &git_resp.headers {
4371 builder.insert_header((k.as_str(), v.as_str()));
4372 }
4373 builder.body(git_resp.body)
4374 }
4375 Err(e) => {
4376 let status = e.status_code();
4377 let mut builder = HttpResponse::build(
4378 actix_web::http::StatusCode::from_u16(status)
4379 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4380 );
4381 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4382 builder.insert_header((k, v));
4383 }
4384 builder.json(serde_json::json!({"error": e.to_string()}))
4385 }
4386 }
4387}
4388
4389#[cfg(feature = "forge")]
4398fn forge_plugin_dir(state: &AppState) -> Option<PathBuf> {
4399 state.data_root.as_ref().map(|r| r.join(".forge"))
4400}
4401
4402#[cfg(feature = "forge")]
4408struct ServerLoopback {
4409 client: reqwest::Client,
4410}
4411
4412#[cfg(feature = "forge")]
4413#[async_trait::async_trait]
4414impl solid_pod_rs_forge::LoopbackFetch for ServerLoopback {
4415 async fn get(
4416 &self,
4417 url: &str,
4418 max_bytes: usize,
4419 timeout_secs: u64,
4420 ) -> solid_pod_rs_forge::bodies::FetchResult {
4421 use solid_pod_rs_forge::bodies::FetchResult;
4422 let resp = match self
4423 .client
4424 .get(url)
4425 .timeout(Duration::from_secs(timeout_secs.max(1)))
4426 .send()
4427 .await
4428 {
4429 Ok(r) => r,
4430 Err(e) => return FetchResult::Error(e.to_string()),
4431 };
4432 let code = resp.status().as_u16();
4433 if code == 404 || code == 410 {
4434 return FetchResult::Removed;
4435 }
4436 if !resp.status().is_success() {
4437 return FetchResult::Error(format!("status {code}"));
4438 }
4439 if resp
4440 .content_length()
4441 .is_some_and(|length| length > max_bytes as u64)
4442 {
4443 return FetchResult::TooLarge;
4444 }
4445 let mut stream = resp.bytes_stream();
4446 let mut body = Vec::with_capacity(max_bytes.min(16 * 1024));
4447 while let Some(chunk) = stream.next().await {
4448 match chunk {
4449 Ok(chunk) if body.len().saturating_add(chunk.len()) <= max_bytes => {
4450 body.extend_from_slice(&chunk);
4451 }
4452 Ok(_) => return FetchResult::TooLarge,
4453 Err(error) => return FetchResult::Error(error.to_string()),
4454 }
4455 }
4456 FetchResult::Body(body)
4457 }
4458}
4459
4460#[cfg(feature = "forge")]
4466async fn handle_forge(
4467 req: HttpRequest,
4468 body: web::Bytes,
4469 state: web::Data<AppState>,
4470) -> HttpResponse {
4471 use solid_pod_rs_forge::{ForgeConfig, ForgeRequest, ForgeService};
4472
4473 let Some(plugin_dir) = forge_plugin_dir(&state) else {
4474 return HttpResponse::NotImplemented().json(serde_json::json!({
4475 "error": "forge requires fs-backend storage",
4476 "reason": "data_root_not_configured"
4477 }));
4478 };
4479
4480 let loopback: Arc<dyn solid_pod_rs_forge::LoopbackFetch> = Arc::new(ServerLoopback {
4485 client: reqwest::Client::new(),
4486 });
4487 let service = match ForgeService::new(ForgeConfig::default(), plugin_dir) {
4488 Ok(s) => s.with_loopback(loopback),
4489 Err(e) => {
4490 return HttpResponse::InternalServerError()
4491 .json(serde_json::json!({"error": e.to_string()}));
4492 }
4493 };
4494
4495 let path = req.uri().path().to_string();
4496 let query = req.uri().query().unwrap_or("").to_string();
4497 let host_url = {
4498 let conn = req.connection_info();
4499 Some(format!("{}://{}", conn.scheme(), conn.host()))
4500 };
4501 let headers: Vec<(String, String)> = req
4502 .headers()
4503 .iter()
4504 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
4505 .collect();
4506
4507 let forge_req = ForgeRequest {
4508 method: req.method().as_str().to_string(),
4509 path,
4510 query,
4511 headers,
4512 raw_body: body,
4513 host_url,
4514 };
4515
4516 let agent = service.resolve_agent(&forge_req).await;
4521
4522 match service.handle(forge_req, agent).await {
4523 Ok(resp) => {
4524 let mut builder = HttpResponse::build(
4525 StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
4526 );
4527 for (k, v) in &resp.headers {
4528 builder.insert_header((k.as_str(), v.as_str()));
4529 }
4530 builder.body(resp.body)
4531 }
4532 Err(e) => {
4533 let status = e.status_code();
4534 HttpResponse::build(
4535 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
4536 )
4537 .json(serde_json::json!({"error": e.to_string()}))
4538 }
4539 }
4540}
4541
4542pub fn build_app(
4554 state: AppState,
4555) -> App<
4556 impl actix_web::dev::ServiceFactory<
4557 ServiceRequest,
4558 Config = (),
4559 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
4560 Error = ActixError,
4561 InitError = (),
4562 >,
4563> {
4564 let body_cap = state.body_cap;
4565 let dotfiles = state.dotfiles.clone();
4566 let allowed_origins = Arc::new(state.allowed_origins.clone());
4567
4568 let mut app = App::new()
4569 .app_data(web::Data::new(state.clone()))
4570 .app_data(web::PayloadConfig::new(body_cap))
4571 .wrap(ErrorLoggingMiddleware)
4576 .wrap(CorsHeaders { allowed_origins })
4577 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
4581 .wrap(PathTraversalGuard)
4582 .wrap(DotfileGuard::new(dotfiles));
4583
4584 app = app
4590 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
4591 .route(
4592 "/.well-known/webfinger",
4593 web::get().to(handle_well_known_webfinger),
4594 )
4595 .route(
4596 "/.well-known/nodeinfo",
4597 web::get().to(handle_well_known_nodeinfo),
4598 )
4599 .route(
4600 "/.well-known/nodeinfo/2.1",
4601 web::get().to(handle_well_known_nodeinfo_2_1),
4602 );
4603
4604 #[cfg(feature = "did-nostr")]
4605 {
4606 app = app.route(
4607 "/.well-known/did/nostr/{pubkey}.json",
4608 web::get().to(handle_well_known_did_nostr),
4609 );
4610 }
4611
4612 #[cfg(feature = "nip05-endpoint")]
4617 {
4618 app = app.route(
4619 "/.well-known/nostr.json",
4620 web::get().to(handle_well_known_nip05),
4621 );
4622 }
4623
4624 #[cfg(feature = "export-jsonld")]
4629 {
4630 app = app.route("/api/exports/all", web::get().to(handle_export_all));
4631 }
4632
4633 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
4635
4636 app = app.route("/pay/.info", web::get().to(handle_pay_info));
4638
4639 app = app.configure(handlers::pay::register);
4644
4645 app = app.route("/proxy", web::get().to(handle_proxy));
4647
4648 if state.mcp_enabled {
4652 app = app.route("/mcp", web::post().to(mcp::handle_mcp)).route(
4653 "/mcp",
4654 web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options),
4655 );
4656 }
4657
4658 app = app.route(
4661 "/_admin/provision/{pubkey}",
4662 web::post().to(handle_admin_provision),
4663 );
4664
4665 app = app
4667 .route("/.pods", web::post().to(handle_create_pod))
4668 .route("/api/accounts/new", web::post().to(handle_create_account))
4669 .route("/pods/check/{name}", web::get().to(handle_pod_check))
4670 .route("/login/password", web::post().to(handle_login_password))
4671 .route(
4672 "/account/password/reset",
4673 web::post().to(handle_password_reset_request),
4674 )
4675 .route(
4676 "/account/password/change",
4677 web::post().to(handle_password_change),
4678 );
4679
4680 #[cfg(feature = "forge")]
4685 {
4686 app = app
4687 .route("/forge", web::route().to(handle_forge))
4688 .route("/forge/{tail:.*}", web::route().to(handle_forge));
4689 }
4690
4691 app = app
4696 .route(
4697 "/{tail:.*}/.git",
4699 web::route().to(|| async {
4700 HttpResponse::Forbidden()
4701 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4702 }),
4703 )
4704 .route(
4705 "/{tail:.*}/.git/{rest:.*}",
4706 web::route().to(|| async {
4707 HttpResponse::Forbidden()
4708 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4709 }),
4710 );
4711
4712 app = app.route(
4716 "/pods/{pk}/_git/{tail:.*}",
4717 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
4718 );
4719
4720 #[cfg(feature = "git")]
4721 {
4722 app = app
4724 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
4725 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
4726 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
4727
4728 app = app
4731 .route(
4732 "/pods/{pubkey}/_git/status",
4733 web::get().to(handle_git_status),
4734 )
4735 .route("/pods/{pubkey}/_git/log", web::get().to(handle_git_log))
4736 .route("/pods/{pubkey}/_git/diff", web::get().to(handle_git_diff))
4737 .route(
4738 "/pods/{pubkey}/_git/stage",
4739 web::post().to(handle_git_stage),
4740 )
4741 .route(
4742 "/pods/{pubkey}/_git/unstage",
4743 web::post().to(handle_git_unstage),
4744 )
4745 .route(
4746 "/pods/{pubkey}/_git/commit",
4747 web::post().to(handle_git_commit),
4748 )
4749 .route(
4750 "/pods/{pubkey}/_git/branches",
4751 web::get().to(handle_git_branches),
4752 )
4753 .route(
4754 "/pods/{pubkey}/_git/branch",
4755 web::post().to(handle_git_create_branch),
4756 )
4757 .route(
4758 "/pods/{pubkey}/_git/discard",
4759 web::post().to(handle_git_discard),
4760 );
4761
4762 app = app.configure(handlers::prov::register);
4769 }
4770 #[cfg(not(feature = "git"))]
4771 {
4772 let git_501 = || async {
4776 HttpResponse::NotImplemented()
4777 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
4778 };
4779 app = app
4780 .route("/{tail:.*}/info/refs", web::get().to(git_501))
4781 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
4782 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
4783 }
4784
4785 app.route("/{tail:.*}/", web::post().to(handle_post))
4788 .route("/{tail:.*}/", web::put().to(handle_put))
4789 .route("/{tail:.*}", web::get().to(handle_get))
4790 .route("/{tail:.*}", web::head().to(handle_get))
4791 .route("/{tail:.*}", web::put().to(handle_put))
4792 .route("/{tail:.*}", web::patch().to(handle_patch))
4793 .route("/{tail:.*}", web::delete().to(handle_delete))
4794 .route(
4795 "/{tail:.*}",
4796 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
4797 )
4798 .route(
4799 "/{tail:.*}",
4800 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
4801 )
4802}
4803
4804#[cfg(test)]
4809mod payment_gating_tests {
4810 use super::*;
4811 use solid_pod_rs::payments::WebLedger;
4812 use solid_pod_rs::storage::memory::MemoryBackend;
4813
4814 const PRINCIPAL: &str = "did:nostr:alice";
4815
4816 const PAID_WRITE_ACL: &str = r#"
4819@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4820
4821<#paid-write> a acl:Authorization ;
4822 acl:agent <did:nostr:alice> ;
4823 acl:accessTo </premium/inbox> ;
4824 acl:mode acl:Write ;
4825 acl:condition [
4826 a acl:PaymentCondition ;
4827 acl:costSats 100
4828 ] .
4829"#;
4830
4831 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
4832 let mut ledger = WebLedger::new("Test Pod Credits");
4833 if sats > 0 {
4834 ledger.credit(did, sats);
4835 }
4836 let body = serde_json::to_vec(&ledger).unwrap();
4837 storage
4838 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
4839 .await
4840 .unwrap();
4841 }
4842
4843 async fn seed_acl(storage: &dyn Storage) {
4844 storage
4845 .put(
4846 "/premium/inbox.acl",
4847 Bytes::from(PAID_WRITE_ACL),
4848 "text/turtle",
4849 )
4850 .await
4851 .unwrap();
4852 }
4853
4854 #[actix_web::test]
4856 async fn resolve_balance_reads_ledger_entry() {
4857 let storage = MemoryBackend::new();
4858 seed_ledger(&storage, PRINCIPAL, 250).await;
4859 assert_eq!(
4860 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
4861 Some(250)
4862 );
4863 }
4864
4865 #[actix_web::test]
4867 async fn resolve_balance_zero_when_no_entry() {
4868 let storage = MemoryBackend::new();
4869 seed_ledger(&storage, "did:nostr:bob", 500).await;
4870 assert_eq!(
4871 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
4872 Some(0)
4873 );
4874 }
4875
4876 #[actix_web::test]
4878 async fn resolve_balance_none_when_anonymous() {
4879 let storage = MemoryBackend::new();
4880 seed_ledger(&storage, PRINCIPAL, 1_000).await;
4881 assert_eq!(resolve_balance_sats(&storage, None).await, None);
4882 }
4883
4884 #[actix_web::test]
4886 async fn paid_write_denied_below_balance() {
4887 let storage = Arc::new(MemoryBackend::new());
4888 seed_acl(storage.as_ref()).await;
4889 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
4891
4892 let result =
4893 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4894 assert!(
4895 result.is_err(),
4896 "balance 50 < cost 100 must be denied — sat-gating loop closed"
4897 );
4898 }
4899
4900 #[actix_web::test]
4902 async fn paid_write_allowed_at_balance() {
4903 let storage = Arc::new(MemoryBackend::new());
4904 seed_acl(storage.as_ref()).await;
4905 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
4907
4908 let result =
4909 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4910 assert!(
4911 result.is_ok(),
4912 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
4913 );
4914 }
4915
4916 #[actix_web::test]
4918 async fn paid_write_allowed_above_balance() {
4919 let storage = Arc::new(MemoryBackend::new());
4920 seed_acl(storage.as_ref()).await;
4921 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
4922 let state = AppState::new(storage);
4923
4924 let result =
4925 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4926 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
4927 }
4928
4929 #[actix_web::test]
4933 async fn paid_write_anonymous_denied() {
4934 let storage = Arc::new(MemoryBackend::new());
4935 seed_acl(storage.as_ref()).await;
4936 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
4937 let state = AppState::new(storage);
4938
4939 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
4940 assert!(
4941 result.is_err(),
4942 "anonymous caller has no ledger principal — PaymentCondition fails closed"
4943 );
4944 }
4945
4946 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
4953 let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
4954 let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
4955 ledger.get_balance(did)
4956 }
4957
4958 #[actix_web::test]
4960 async fn paid_write_debits_ledger() {
4961 let storage = Arc::new(MemoryBackend::new());
4962 seed_acl(storage.as_ref()).await;
4963 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
4965
4966 let result =
4967 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4968 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
4969 assert_eq!(
4970 read_balance(storage.as_ref(), PRINCIPAL).await,
4971 150,
4972 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
4973 );
4974 }
4975
4976 #[actix_web::test]
4979 async fn paid_write_debits_each_grant() {
4980 let storage = Arc::new(MemoryBackend::new());
4981 seed_acl(storage.as_ref()).await;
4982 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
4984
4985 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
4986 .await
4987 .unwrap();
4988 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
4989 .await
4990 .unwrap();
4991 assert_eq!(
4992 read_balance(storage.as_ref(), PRINCIPAL).await,
4993 50,
4994 "250 - 2*100: each granted request debits, no unmetered re-use"
4995 );
4996
4997 let third =
4999 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5000 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
5001 assert_eq!(
5002 read_balance(storage.as_ref(), PRINCIPAL).await,
5003 50,
5004 "a denied request must not debit"
5005 );
5006 }
5007
5008 #[actix_web::test]
5010 async fn paid_read_debits_ledger() {
5011 const PAID_READ_ACL: &str = r#"
5012@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5013
5014<#paid-read> a acl:Authorization ;
5015 acl:agent <did:nostr:alice> ;
5016 acl:accessTo </premium/feed> ;
5017 acl:mode acl:Read ;
5018 acl:condition [
5019 a acl:PaymentCondition ;
5020 acl:costSats 30
5021 ] .
5022"#;
5023 let storage = Arc::new(MemoryBackend::new());
5024 storage
5025 .put(
5026 "/premium/feed.acl",
5027 Bytes::from(PAID_READ_ACL),
5028 "text/turtle",
5029 )
5030 .await
5031 .unwrap();
5032 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5033 let state = AppState::new(storage.clone());
5034
5035 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
5036 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
5037 assert_eq!(
5038 read_balance(storage.as_ref(), PRINCIPAL).await,
5039 70,
5040 "100 - 30 cost: a granted paid read must debit"
5041 );
5042 }
5043
5044 #[actix_web::test]
5047 async fn free_read_does_not_debit() {
5048 let storage = Arc::new(MemoryBackend::new());
5049 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5051 let state = AppState::new(storage.clone());
5052
5053 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
5054 .await
5055 .unwrap();
5056 assert_eq!(
5057 read_balance(storage.as_ref(), PRINCIPAL).await,
5058 100,
5059 "a grant with no PaymentCondition must not debit"
5060 );
5061 }
5062
5063 const ALICE_ONLY_READ_ACL: &str = r#"
5069@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5070
5071<#alice> a acl:Authorization ;
5072 acl:agent <did:nostr:alice> ;
5073 acl:accessTo </private/secret> ;
5074 acl:default </private/> ;
5075 acl:mode acl:Read, acl:Write, acl:Control .
5076"#;
5077
5078 async fn seed_private_read_acl(storage: &dyn Storage) {
5079 storage
5084 .put(
5085 "/private.acl",
5086 Bytes::from(ALICE_ONLY_READ_ACL),
5087 "text/turtle",
5088 )
5089 .await
5090 .unwrap();
5091 }
5092
5093 #[actix_web::test]
5097 async fn enforce_read_grants_owner() {
5098 let storage = Arc::new(MemoryBackend::new());
5099 seed_private_read_acl(storage.as_ref()).await;
5100 let state = AppState::new(storage);
5101 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
5102 assert!(result.is_ok(), "owner alice must be granted Read");
5103 }
5104
5105 #[actix_web::test]
5108 async fn enforce_read_denies_other_principal() {
5109 let storage = Arc::new(MemoryBackend::new());
5110 seed_private_read_acl(storage.as_ref()).await;
5111 let state = AppState::new(storage);
5112 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
5113 assert!(
5114 result.is_err(),
5115 "bob has no Read grant — private resource must not be world-readable"
5116 );
5117 }
5118
5119 #[actix_web::test]
5122 async fn enforce_read_denies_anonymous() {
5123 let storage = Arc::new(MemoryBackend::new());
5124 seed_private_read_acl(storage.as_ref()).await;
5125 let state = AppState::new(storage);
5126 let result = enforce_read(&state, "/private/secret", None).await;
5127 assert!(result.is_err(), "anonymous Read must be denied");
5128 }
5129
5130 const WRITE_NOT_CONTROL_ACL: &str = r#"
5138@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5139
5140<#owner> a acl:Authorization ;
5141 acl:agent <did:nostr:alice> ;
5142 acl:accessTo </shared/doc> ;
5143 acl:default </shared/> ;
5144 acl:mode acl:Read, acl:Write, acl:Control .
5145
5146<#writer> a acl:Authorization ;
5147 acl:agent <did:nostr:writer> ;
5148 acl:accessTo </shared/doc> ;
5149 acl:default </shared/> ;
5150 acl:mode acl:Read, acl:Write .
5151"#;
5152
5153 async fn seed_shared_acl(storage: &dyn Storage) {
5154 storage
5159 .put(
5160 "/shared.acl",
5161 Bytes::from(WRITE_NOT_CONTROL_ACL),
5162 "text/turtle",
5163 )
5164 .await
5165 .unwrap();
5166 }
5167
5168 #[actix_web::test]
5172 async fn acl_put_denied_for_writer_without_control() {
5173 let storage = Arc::new(MemoryBackend::new());
5174 seed_shared_acl(storage.as_ref()).await;
5175 let state = AppState::new(storage);
5176 let result = enforce_write(
5180 &state,
5181 "/shared/.acl",
5182 AccessMode::Write,
5183 Some("did:nostr:writer"),
5184 )
5185 .await;
5186 assert!(
5187 result.is_err(),
5188 "writer lacks Control — must not be able to PUT /shared/.acl"
5189 );
5190 }
5191
5192 #[actix_web::test]
5194 async fn acl_put_allowed_for_control_holder() {
5195 let storage = Arc::new(MemoryBackend::new());
5196 seed_shared_acl(storage.as_ref()).await;
5197 let state = AppState::new(storage);
5198 let result =
5199 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
5200 assert!(
5201 result.is_ok(),
5202 "alice holds Control — must be allowed to PUT /shared/.acl"
5203 );
5204 }
5205
5206 #[actix_web::test]
5208 async fn meta_put_denied_for_writer_without_control() {
5209 let storage = Arc::new(MemoryBackend::new());
5210 seed_shared_acl(storage.as_ref()).await;
5211 let state = AppState::new(storage);
5212 let result = enforce_write(
5213 &state,
5214 "/shared/doc.meta",
5215 AccessMode::Write,
5216 Some("did:nostr:writer"),
5217 )
5218 .await;
5219 assert!(
5220 result.is_err(),
5221 "writer lacks Control — must not be able to PUT a .meta sidecar"
5222 );
5223 }
5224
5225 #[test]
5227 fn protected_resource_for_acl_strips_suffixes() {
5228 assert_eq!(
5229 protected_resource_for_acl("/victim/.acl").as_deref(),
5230 Some("/victim/")
5231 );
5232 assert_eq!(
5233 protected_resource_for_acl("/a/b.acl").as_deref(),
5234 Some("/a/b")
5235 );
5236 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
5237 assert_eq!(
5238 protected_resource_for_acl("/a/b.meta").as_deref(),
5239 Some("/a/b")
5240 );
5241 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
5242 }
5243}