1#![doc = include_str!("../README.md")]
107#![deny(unsafe_code)]
108#![warn(rust_2018_idioms)]
109
110pub mod cli;
112
113pub(crate) mod hardened_git;
114
115mod handlers;
120
121mod mcp;
124
125pub mod mempool;
130
131pub mod trail_store;
137
138use std::collections::HashMap;
139use std::net::{IpAddr, Ipv4Addr};
140use std::path::{Path, PathBuf};
141use std::sync::{Arc, Mutex};
142use std::time::{Duration, Instant};
143
144use actix_web::body::{BoxBody, EitherBody};
145use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
146use actix_web::http::{header, StatusCode};
147use actix_web::middleware::{NormalizePath, TrailingSlash};
148use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
149use bytes::Bytes;
150use futures_util::future::{ready, LocalBoxFuture, Ready};
151use futures_util::StreamExt;
152use percent_encoding::percent_decode_str;
153use serde::Deserialize;
154use solid_pod_rs::{
155 auth::{nip98, replay::ReplayStore},
159 config::sources::parse_size,
160 interop,
161 ldp::{
162 self, cache_control_for_response, LdpContainerOps, PatchCreateOutcome, ResponseAudience,
163 },
164 mashlib::{self, MashlibConfig},
165 provenance::{ProvenanceReceipt, ProvenanceSkip},
166 security::DotfileAllowlist,
167 storage::Storage,
168 wac::{
169 self, conditions::RequestContext, effective_acl_target, parse_jsonld_acl,
170 parser::parse_turtle_acl, protected_resource_for_acl, AccessMode,
171 },
172 PodError,
173};
174
175const _: () = solid_pod_rs::auth::nip98::assert_schnorr_verification_enabled();
187
188static NIP98_REPLAY: std::sync::LazyLock<solid_pod_rs::auth::replay::Nip98ReplayCache> =
195 std::sync::LazyLock::new(solid_pod_rs::auth::replay::Nip98ReplayCache::from_env);
196
197pub(crate) static PAYMENT_STATE_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
203 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
204
205#[derive(Clone)]
211pub struct AppState {
212 pub storage: Arc<dyn Storage>,
213 pub dotfiles: Arc<DotfileAllowlist>,
214 pub body_cap: usize,
215 pub nodeinfo: NodeInfoMeta,
216 pub mashlib: MashlibConfig,
217 pub mashlib_cdn: Option<String>,
220 pub live_reload: bool,
222 pub pay_config: solid_pod_rs::payments::PayConfig,
225 pub data_root: Option<PathBuf>,
230 pub quota: Option<Arc<dyn solid_pod_rs::quota::QuotaPolicy>>,
232 pub pod_create_limiter: Arc<PodCreateLimiter>,
234 pub allowed_origins: Vec<String>,
241 pub admin_key: Option<String>,
246 pub mcp_enabled: bool,
251 pub mempool_url: Option<String>,
257}
258
259#[cfg(test)]
260mod nip98_request_binding_tests {
261 use super::*;
262 use actix_web::test::TestRequest;
263
264 const SECRET: &str = "3333333333333333333333333333333333333333333333333333333333333333";
265
266 fn now() -> u64 {
267 std::time::SystemTime::now()
268 .duration_since(std::time::UNIX_EPOCH)
269 .unwrap()
270 .as_secs()
271 }
272
273 #[actix_web::test]
274 async fn authentication_binds_the_complete_query_string() {
275 let signed_url = "http://localhost:8080/private?include=true";
276 let token = nip98::mint(signed_url, "GET", SECRET, now()).unwrap();
277 let request = TestRequest::get()
278 .uri("/private?include=true")
279 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
280 .to_http_request();
281 assert!(extract_pubkey(&request).await.is_some());
282
283 let path_only = nip98::mint("http://localhost:8080/private", "GET", SECRET, now()).unwrap();
284 let request = TestRequest::get()
285 .uri("/private?include=true")
286 .insert_header((header::AUTHORIZATION, format!("Nostr {path_only}")))
287 .to_http_request();
288 assert!(extract_pubkey(&request).await.is_none());
289 }
290
291 #[actix_web::test]
292 async fn authentication_binds_the_exact_raw_body() {
293 let expected = br#"{"value":"authorised"}"#;
294 let token = nip98::mint_with_payload(
295 "http://localhost:8080/resource",
296 "PUT",
297 Some(expected),
298 SECRET,
299 now(),
300 )
301 .unwrap();
302 let request = TestRequest::put()
303 .uri("/resource")
304 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
305 .to_http_request();
306
307 assert!(
308 extract_pubkey_with_body(&request, Some(br#"{"value":"tampered"}"#))
309 .await
310 .is_none()
311 );
312 assert!(extract_pubkey_with_body(&request, Some(expected))
313 .await
314 .is_some());
315 }
316}
317
318#[derive(Clone, Debug)]
320pub struct NodeInfoMeta {
321 pub software_name: String,
322 pub software_version: String,
323 pub open_registrations: bool,
324 pub total_users: u64,
325 pub base_url: String,
326}
327
328impl Default for NodeInfoMeta {
329 fn default() -> Self {
330 Self {
331 software_name: "solid-pod-rs-server".to_string(),
332 software_version: env!("CARGO_PKG_VERSION").to_string(),
333 open_registrations: false,
334 total_users: 0,
335 base_url: "http://localhost".to_string(),
336 }
337 }
338}
339
340pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
343
344pub fn body_cap_from_env() -> usize {
349 match std::env::var("JSS_MAX_REQUEST_BODY").or_else(|_| std::env::var("JSS_BODY_LIMIT")) {
350 Ok(v) => parse_size(&v)
351 .map(|u| u as usize)
352 .unwrap_or(DEFAULT_BODY_CAP),
353 Err(_) => DEFAULT_BODY_CAP,
354 }
355}
356
357impl AppState {
358 pub fn new(storage: Arc<dyn Storage>) -> Self {
361 Self {
362 storage,
363 dotfiles: Arc::new(DotfileAllowlist::from_env()),
364 body_cap: body_cap_from_env(),
365 nodeinfo: NodeInfoMeta::default(),
366 mashlib: MashlibConfig::default(),
367 mashlib_cdn: None,
368 live_reload: false,
369 pay_config: solid_pod_rs::payments::PayConfig::default(),
370 data_root: None,
371 quota: None,
372 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
373 allowed_origins: Vec::new(),
374 admin_key: None,
375 mcp_enabled: false,
376 mempool_url: None,
377 }
378 }
379}
380
381const 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>";
382
383fn inject_live_reload(html: impl AsRef<[u8]>, enabled: bool) -> Vec<u8> {
384 let bytes = html.as_ref();
385 if !enabled {
386 return bytes.to_vec();
387 }
388 let html = String::from_utf8_lossy(bytes);
389 if let Some(index) = html.find("</body>") {
390 let mut out = String::with_capacity(html.len() + LIVE_RELOAD_SCRIPT.len());
391 out.push_str(&html[..index]);
392 out.push_str(LIVE_RELOAD_SCRIPT);
393 out.push_str(&html[index..]);
394 out.into_bytes()
395 } else {
396 format!("{html}{LIVE_RELOAD_SCRIPT}").into_bytes()
397 }
398}
399
400#[cfg(test)]
401mod live_reload_tests {
402 use super::*;
403
404 #[test]
405 fn injects_before_body_close_and_is_disabled_by_default() {
406 let html = b"<html><body>ok</body></html>";
407 assert_eq!(inject_live_reload(html, false), html);
408 let injected = String::from_utf8(inject_live_reload(html, true)).unwrap();
409 assert!(injected.contains("/.notifications"));
410 assert!(injected.find("<script>").unwrap() < injected.find("</body>").unwrap());
411 }
412}
413
414#[cfg(all(test, feature = "dev-bearer"))]
415mod dev_bearer_tests {
416 use super::*;
417 use base64::Engine as _;
418 use hmac::{Hmac, Mac};
419 use sha2::Sha256;
420 use std::sync::Mutex;
421
422 static ENV: Mutex<()> = Mutex::new(());
423
424 fn now() -> u64 {
425 std::time::SystemTime::now()
426 .duration_since(std::time::UNIX_EPOCH)
427 .unwrap()
428 .as_secs()
429 }
430
431 fn token_with(iat: u64, exp: u64, aud: Option<&str>) -> String {
432 let mut claims = serde_json::json!({"webId":"https://alice.example/profile/card#me","iat":iat,"exp":exp});
433 if let Some(a) = aud {
434 claims["aud"] = serde_json::json!(a);
435 }
436 let data = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string());
437 let mut mac = Hmac::<Sha256>::new_from_slice(b"01234567890123456789012345678901").unwrap();
438 mac.update(data.as_bytes());
439 let sig =
440 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
441 format!("{data}.{sig}")
442 }
443
444 fn token(exp: u64) -> String {
445 token_with(now().saturating_sub(10), exp, Some(DEV_BEARER_AUDIENCE))
446 }
447
448 #[test]
449 fn simple_bearer_verifies_signature_and_expiry() {
450 let _guard = ENV.lock().unwrap();
451 std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
452 assert_eq!(
453 verify_dev_bearer(&token(now() + 3600)).as_deref(),
454 Some("https://alice.example/profile/card#me")
455 );
456 assert!(verify_dev_bearer(&(token(now() + 3600) + "x")).is_none());
457 assert!(verify_dev_bearer(&token(2)).is_none());
458 std::env::remove_var("TOKEN_SECRET");
459 }
460
461 #[test]
462 fn dev_bearer_enforces_audience_and_max_lifetime() {
463 let _guard = ENV.lock().unwrap();
464 std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
465 let n = now();
466 assert!(verify_dev_bearer(&token_with(n, n + 60, None)).is_none());
468 assert!(verify_dev_bearer(&token_with(n, n + 60, Some("solid"))).is_none());
469 assert!(verify_dev_bearer(&token_with(
471 n,
472 n + DEV_BEARER_MAX_LIFETIME_SECS,
473 Some(DEV_BEARER_AUDIENCE)
474 ))
475 .is_some());
476 assert!(verify_dev_bearer(&token_with(
477 n,
478 n + DEV_BEARER_MAX_LIFETIME_SECS + 1,
479 Some(DEV_BEARER_AUDIENCE)
480 ))
481 .is_none());
482 assert!(verify_dev_bearer(&token_with(1, n + 3600, Some(DEV_BEARER_AUDIENCE))).is_none());
484 assert!(
485 verify_dev_bearer(&token_with(1, u64::MAX / 2, Some(DEV_BEARER_AUDIENCE))).is_none()
486 );
487 std::env::remove_var("TOKEN_SECRET");
488 }
489}
490
491#[derive(Debug)]
493pub struct PodCreateLimiter {
494 hits: Mutex<HashMap<IpAddr, Instant>>,
495 window: Duration,
496}
497
498impl Default for PodCreateLimiter {
499 fn default() -> Self {
500 Self {
501 hits: Mutex::new(HashMap::new()),
502 window: Duration::from_secs(24 * 60 * 60),
503 }
504 }
505}
506
507impl PodCreateLimiter {
508 fn check(&self, ip: IpAddr) -> Result<(), u64> {
509 let now = Instant::now();
510 let mut hits = self.hits.lock().unwrap();
511 if let Some(last) = hits.get(&ip).copied() {
512 let elapsed = now.saturating_duration_since(last);
513 if elapsed < self.window {
514 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
515 }
516 }
517 hits.insert(ip, now);
518 Ok(())
519 }
520}
521
522pub(crate) fn to_actix(e: PodError) -> ActixError {
527 match e {
528 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
529 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
530 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
531 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
532 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
533 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
534 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
535 }
536}
537
538pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
550 extract_pubkey_with_body(req, None).await
551}
552
553pub(crate) async fn extract_pubkey_with_body(
557 req: &HttpRequest,
558 body: Option<&[u8]>,
559) -> Option<String> {
560 let header_val = req
561 .headers()
562 .get(header::AUTHORIZATION)
563 .and_then(|v| v.to_str().ok())?;
564 if let Some(_token) = header_val.strip_prefix("Bearer ") {
565 #[cfg(feature = "dev-bearer")]
568 return verify_dev_bearer(_token);
569 #[cfg(not(feature = "dev-bearer"))]
570 return None;
571 }
572 let url = {
583 let conn = req.connection_info();
584 format!("{}://{}{}", conn.scheme(), conn.host(), req.uri())
585 };
586 let now = std::time::SystemTime::now()
587 .duration_since(std::time::UNIX_EPOCH)
588 .map(|d| d.as_secs())
589 .unwrap_or(0);
590 let verified = nip98::verify_at(header_val, &url, req.method().as_str(), body, now).ok()?;
591
592 if NIP98_REPLAY
596 .check_and_record(&verified.event_id)
597 .await
598 .is_err()
599 {
600 tracing::warn!(
601 pubkey = %verified.pubkey,
602 method = %req.method(),
603 "NIP-98 replay rejected: token id already used within window"
604 );
605 return None;
606 }
607
608 Some(verified.pubkey)
609}
610
611pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
612 pubkey.map(|pk| {
613 if pk.starts_with("https://") || pk.starts_with("http://") {
614 pk.clone()
615 } else {
616 format!("did:nostr:{pk}")
617 }
618 })
619}
620
621#[cfg(feature = "dev-bearer")]
623pub const DEV_BEARER_AUDIENCE: &str = "solid-pod-rs-dev-bearer";
624
625#[cfg(feature = "dev-bearer")]
628pub const DEV_BEARER_MAX_LIFETIME_SECS: u64 = 24 * 60 * 60;
629
630#[cfg(feature = "dev-bearer")]
638fn verify_dev_bearer(token: &str) -> Option<String> {
639 use base64::Engine as _;
640 use hmac::{Hmac, Mac};
641 use sha2::Sha256;
642
643 let secret = std::env::var("TOKEN_SECRET").ok()?;
644 if secret.len() < 32 {
645 tracing::warn!("TOKEN_SECRET shorter than 32 bytes; refusing development bearer token");
646 return None;
647 }
648 let (data, signature) = token.split_once('.')?;
649 if signature.contains('.') {
650 return None;
651 }
652 let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
653 .decode(signature)
654 .ok()?;
655 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
656 mac.update(data.as_bytes());
657 mac.verify_slice(&signature).ok()?;
658
659 #[derive(serde::Deserialize)]
660 #[serde(rename_all = "camelCase")]
661 struct Claims {
662 web_id: String,
663 iat: u64,
664 exp: u64,
665 #[serde(default)]
666 aud: Option<String>,
667 }
668 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
669 .decode(data)
670 .ok()?;
671 let claims: Claims = serde_json::from_slice(&payload).ok()?;
672 let now = std::time::SystemTime::now()
673 .duration_since(std::time::UNIX_EPOCH)
674 .ok()?
675 .as_secs();
676 if claims.aud.as_deref() != Some(DEV_BEARER_AUDIENCE) {
677 return None;
678 }
679 if claims.exp < now || claims.iat > now.saturating_add(60) || claims.exp <= claims.iat {
680 return None;
681 }
682 if claims.exp - claims.iat > DEV_BEARER_MAX_LIFETIME_SECS
683 || claims.exp - now > DEV_BEARER_MAX_LIFETIME_SECS
684 {
685 return None;
686 }
687 let url = url::Url::parse(&claims.web_id).ok()?;
688 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
689 return None;
690 }
691 Some(claims.web_id)
692}
693
694fn req_origin(req: &HttpRequest) -> Option<&str> {
703 req.headers()
704 .get(header::ORIGIN)
705 .and_then(|v| v.to_str().ok())
706}
707
708pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
712
713async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
730 let did = agent_uri?;
731 let balance = match storage.get(WEBLEDGER_PATH).await {
732 Ok((bytes, _meta)) => {
733 match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
734 Ok(ledger) => ledger.get_balance(did),
735 Err(_) => 0,
739 }
740 }
741 Err(_) => 0,
744 };
745 Some(balance)
746}
747
748fn accept_includes_html(accept: &str) -> bool {
756 accept.split(',').any(|entry| {
757 let mime = entry.split(';').next().unwrap_or("").trim();
758 mime.eq_ignore_ascii_case("text/html")
759 })
760}
761
762pub(crate) fn guarded_acl_target(path: &str, mode: AccessMode) -> (String, AccessMode) {
781 let (resource, mode) = effective_acl_target(path, mode);
782 match canonical_pod_path(&resource) {
786 Some(canon)
787 if !trail_store::is_secret_trail_path(&canon)
788 && !trail_store::is_prov_state_path(&canon)
789 && !trail_store::is_server_state_path(&canon) =>
790 {
791 (resource, mode)
792 }
793 _ => (resource, AccessMode::Control),
794 }
795}
796
797pub(crate) fn canonical_pod_path(raw: &str) -> Option<String> {
805 let decoded = percent_decode_str(raw).decode_utf8().ok()?;
806 if decoded.contains(['\\', '\0']) {
807 return None;
808 }
809 let mut out = String::with_capacity(decoded.len() + 1);
810 for seg in decoded.split('/') {
811 match seg {
812 "" => {}
813 "." | ".." => return None,
814 _ => {
815 out.push('/');
816 out.push_str(seg);
817 }
818 }
819 }
820 if out.is_empty() || decoded.ends_with('/') {
821 out.push('/');
822 }
823 Some(out)
824}
825
826fn proposed_acl_keeps_caller_control(
834 body: &[u8],
835 content_type: &str,
836 caller: Option<&str>,
837) -> bool {
838 let doc = match parse_jsonld_acl(body) {
839 Ok(d) => Some(d),
840 Err(_) => {
841 let ct = content_type.to_ascii_lowercase();
842 let text = std::str::from_utf8(body).unwrap_or("");
843 let looks_turtle = ct.starts_with("text/turtle")
844 || ct.starts_with("application/turtle")
845 || ct.starts_with("application/x-turtle")
846 || ct.starts_with("application/n-triples")
847 || text.contains("@prefix")
848 || text.contains("acl:Authorization")
849 || text.contains("auth/acl#Authorization");
853 if looks_turtle {
854 parse_turtle_acl(text).ok()
855 } else {
856 None
857 }
858 }
859 };
860 let Some(doc) = doc else {
861 return true;
863 };
864 let Some(graph) = doc.graph.as_ref() else {
865 return false;
866 };
867 graph.iter().any(|auth| {
868 let grants_control = ids_of_acl_field(&auth.mode)
869 .iter()
870 .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
871 if !grants_control {
872 return false;
873 }
874 let agents = ids_of_acl_field(&auth.agent);
875 if let Some(web_id) = caller {
876 if agents.contains(&web_id) {
877 return true;
878 }
879 }
880 let classes = ids_of_acl_field(&auth.agent_class);
881 if classes
882 .iter()
883 .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
884 {
885 return true;
886 }
887 if caller.is_some()
888 && classes.iter().any(|c| {
889 *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
890 || *c == "acl:AuthenticatedAgent"
891 })
892 {
893 return true;
894 }
895 false
896 })
897}
898
899fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
901 match field {
902 None => Vec::new(),
903 Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
904 Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
905 }
906}
907
908#[cfg_attr(not(test), allow(dead_code))]
915async fn enforce_write(
916 state: &AppState,
917 path: &str,
918 mode: AccessMode,
919 agent_uri: Option<&str>,
920) -> Result<(), ActixError> {
921 enforce_write_ctx(state, path, mode, agent_uri, None).await
922}
923
924async fn enforce_write_ctx(
932 state: &AppState,
933 path: &str,
934 mode: AccessMode,
935 agent_uri: Option<&str>,
936 request_origin: Option<&str>,
937) -> Result<(), ActixError> {
938 let origin = request_origin.and_then(wac::Origin::parse);
939 let (resource, eff_mode) = guarded_acl_target(path, mode);
951
952 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
960 if outcome.is_failure() {
961 return Err(policy_failure_to_actix(&outcome, &resource));
962 }
963 let acl_doc = outcome.document().cloned();
964
965 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
970
971 let ctx = RequestContext {
972 web_id: agent_uri,
973 client_id: None,
974 issuer: None,
975 payment_balance_sats,
976 };
977 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
978 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
979 let granted = wac::evaluate_access_ctx_with_registry(
980 acl_doc.as_ref(),
981 &ctx,
982 &resource,
983 eff_mode,
984 origin.as_ref(),
985 &groups,
986 ®istry,
987 );
988 if !granted {
989 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
990 }
991 if resource.as_str() == path {
998 charge_granted_payment(
1004 state,
1005 acl_doc.as_ref(),
1006 &ctx,
1007 &resource,
1008 eff_mode,
1009 &groups,
1010 ®istry,
1011 )
1012 .await?;
1013 }
1014 Ok(())
1015}
1016
1017async fn charge_granted_payment(
1026 state: &AppState,
1027 acl_doc: Option<&wac::AclDocument>,
1028 ctx: &RequestContext<'_>,
1029 path: &str,
1030 mode: AccessMode,
1031 groups: &wac::StaticGroupMembership,
1032 registry: &wac::conditions::ConditionRegistry,
1033) -> Result<(), ActixError> {
1034 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
1035 if cost == 0 {
1036 return Ok(());
1037 }
1038 if let Some(did) = ctx.web_id {
1039 if debit_ledger(&*state.storage, did, cost).await.is_err() {
1040 return Err(acl_denial(acl_doc, ctx.web_id, path));
1041 }
1042 }
1043 Ok(())
1044}
1045
1046fn acl_denial(
1052 acl_doc: Option<&wac::AclDocument>,
1053 agent_uri: Option<&str>,
1054 path: &str,
1055) -> ActixError {
1056 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
1057 let (status, body, unauthenticated) = if agent_uri.is_none() {
1058 (StatusCode::UNAUTHORIZED, "authentication required", true)
1059 } else {
1060 (StatusCode::FORBIDDEN, "access forbidden", false)
1061 };
1062 let mut rsp = HttpResponse::new(status);
1063 rsp.headers_mut().insert(
1064 header::HeaderName::from_static("wac-allow"),
1065 header::HeaderValue::from_str(&allow_header)
1066 .unwrap_or(header::HeaderValue::from_static("")),
1067 );
1068 if unauthenticated {
1069 rsp.headers_mut().insert(
1076 header::WWW_AUTHENTICATE,
1077 header::HeaderValue::from_static(
1078 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
1079 ),
1080 );
1081 }
1082 actix_web::error::InternalError::from_response(body, rsp).into()
1083}
1084
1085#[cfg_attr(not(test), allow(dead_code))]
1096async fn enforce_read(
1097 state: &AppState,
1098 path: &str,
1099 agent_uri: Option<&str>,
1100) -> Result<ResponseAudience, ActixError> {
1101 enforce_read_ctx(state, path, agent_uri, None).await
1102}
1103
1104async fn enforce_read_ctx(
1107 state: &AppState,
1108 path: &str,
1109 agent_uri: Option<&str>,
1110 request_origin: Option<&str>,
1111) -> Result<ResponseAudience, ActixError> {
1112 enforce_read_inner(state, path, agent_uri, request_origin, true).await
1113}
1114
1115async fn enforce_read_inner(
1120 state: &AppState,
1121 path: &str,
1122 agent_uri: Option<&str>,
1123 request_origin: Option<&str>,
1124 charge: bool,
1125) -> Result<ResponseAudience, ActixError> {
1126 let origin = request_origin.and_then(wac::Origin::parse);
1127 let (resource, eff_mode) = guarded_acl_target(path, AccessMode::Read);
1139 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
1142 if outcome.is_failure() {
1143 return Err(policy_failure_to_actix(&outcome, &resource));
1144 }
1145 let acl_doc = outcome.document().cloned();
1146 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
1147 let ctx = RequestContext {
1148 web_id: agent_uri,
1149 client_id: None,
1150 issuer: None,
1151 payment_balance_sats,
1152 };
1153 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
1154 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
1155 let granted = wac::evaluate_access_ctx_with_registry(
1156 acl_doc.as_ref(),
1157 &ctx,
1158 &resource,
1159 eff_mode,
1160 origin.as_ref(),
1161 &groups,
1162 ®istry,
1163 );
1164 if !granted {
1165 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1166 }
1167 if resource.as_str() == path && !charge {
1171 if wac::granted_payment_cost(
1172 acl_doc.as_ref(),
1173 &ctx,
1174 &resource,
1175 eff_mode,
1176 &groups,
1177 ®istry,
1178 ) > 0
1179 {
1180 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1181 }
1182 } else if resource.as_str() == path {
1183 charge_granted_payment(
1188 state,
1189 acl_doc.as_ref(),
1190 &ctx,
1191 &resource,
1192 eff_mode,
1193 &groups,
1194 ®istry,
1195 )
1196 .await?;
1197 }
1198 let anon_ctx = RequestContext {
1207 web_id: None,
1208 client_id: None,
1209 issuer: None,
1210 payment_balance_sats: None,
1211 };
1212 let anonymous_would_be_granted = wac::evaluate_access_ctx_with_registry(
1213 acl_doc.as_ref(),
1214 &anon_ctx,
1215 &resource,
1216 eff_mode,
1217 origin.as_ref(),
1218 &groups,
1219 ®istry,
1220 );
1221 Ok(if anonymous_would_be_granted && resource.as_str() == path {
1222 ResponseAudience::Public
1223 } else {
1224 ResponseAudience::Private
1225 })
1226}
1227
1228async fn debit_ledger(
1237 storage: &dyn Storage,
1238 did: &str,
1239 cost: u64,
1240) -> Result<(), solid_pod_rs::payments::PaymentError> {
1241 use solid_pod_rs::payments::{PaymentError, WebLedger};
1242 let _transaction = PAYMENT_STATE_LOCK.lock().await;
1243
1244 let (bytes, _meta) = storage
1245 .get(WEBLEDGER_PATH)
1246 .await
1247 .map_err(|e| PaymentError::Store(e.to_string()))?;
1248 let mut ledger: WebLedger = serde_json::from_slice(&bytes)
1249 .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
1250 let _charge = ledger.charge(did, cost)?;
1252 let body = serde_json::to_vec(&ledger)
1253 .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
1254 storage
1255 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
1256 .await
1257 .map_err(|e| PaymentError::Store(e.to_string()))?;
1258 Ok(())
1259}
1260
1261fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
1266 let links = ldp::link_headers(path).join(", ");
1267 if let Ok(value) = header::HeaderValue::from_str(&links) {
1268 rsp.headers_mut()
1269 .insert(header::HeaderName::from_static("link"), value);
1270 }
1271}
1272
1273fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
1274 if let Ok(v) = header::HeaderValue::from_str(header_value) {
1275 rsp.headers_mut()
1276 .insert(header::HeaderName::from_static("wac-allow"), v);
1277 }
1278}
1279
1280fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
1281 let ws_base = base_url
1282 .replacen("https://", "wss://", 1)
1283 .replacen("http://", "ws://", 1);
1284 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
1285 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
1286 rsp.headers_mut()
1287 .insert(header::HeaderName::from_static("updates-via"), v);
1288 }
1289}
1290
1291fn set_provenance_headers(rsp: &mut HttpResponse, receipt: &ProvenanceReceipt) {
1302 if let Ok(v) = header::HeaderValue::from_str(&receipt.summary()) {
1303 rsp.headers_mut()
1304 .insert(header::HeaderName::from_static("x-provenance"), v);
1305 }
1306 if let Some(sha) = receipt.commit_sha() {
1307 if let Ok(v) = header::HeaderValue::from_str(sha) {
1308 rsp.headers_mut()
1309 .insert(header::HeaderName::from_static("x-provenance-commit"), v);
1310 }
1311 }
1312}
1313
1314fn set_cache_policy(rsp: &mut HttpResponse, content_type: &str, audience: ResponseAudience) {
1326 if audience == ResponseAudience::Private {
1327 append_vary(rsp, "Authorization");
1331 }
1332 if rsp.headers().contains_key(header::CACHE_CONTROL) {
1333 return;
1334 }
1335 if let Some(value) = cache_control_for_response(content_type, audience) {
1336 rsp.headers_mut().insert(
1337 header::CACHE_CONTROL,
1338 header::HeaderValue::from_static(value),
1339 );
1340 }
1341}
1342
1343fn append_vary(rsp: &mut HttpResponse, field: &str) {
1345 let existing = rsp
1346 .headers()
1347 .get(header::VARY)
1348 .and_then(|v| v.to_str().ok())
1349 .unwrap_or("")
1350 .to_string();
1351 if existing
1352 .split(',')
1353 .any(|f| f.trim().eq_ignore_ascii_case(field))
1354 {
1355 return;
1356 }
1357 let merged = if existing.trim().is_empty() {
1358 field.to_string()
1359 } else {
1360 format!("{existing}, {field}")
1361 };
1362 if let Ok(v) = header::HeaderValue::from_str(&merged) {
1363 rsp.headers_mut().insert(header::VARY, v);
1364 }
1365}
1366
1367async fn handle_get(
1368 req: HttpRequest,
1369 state: web::Data<AppState>,
1370) -> Result<HttpResponse, ActixError> {
1371 let path = req.uri().path().to_string();
1372
1373 if path.contains('*') {
1374 return handle_glob_get(req, state).await;
1375 }
1376
1377 let auth_pk = extract_pubkey(&req).await;
1378 let agent = agent_uri(auth_pk.as_ref());
1379
1380 let audience = enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
1385
1386 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
1387
1388 if ldp::is_container(&path) {
1389 let accept = req
1390 .headers()
1391 .get(header::ACCEPT)
1392 .and_then(|v| v.to_str().ok())
1393 .unwrap_or("");
1394
1395 if accept_includes_html(accept) {
1401 let index_path = format!("{path}index.html");
1402 let index_audience =
1405 enforce_read_ctx(&state, &index_path, agent.as_deref(), req_origin(&req))
1406 .await
1407 .ok();
1408 if let (Some(index_audience), Ok((body, _meta))) =
1409 (index_audience, state.storage.get(&index_path).await)
1410 {
1411 let mut rsp = HttpResponse::Ok()
1412 .content_type("text/html; charset=utf-8")
1413 .body(inject_live_reload(&body, state.live_reload));
1414 set_cache_policy(&mut rsp, "text/html", index_audience);
1415 set_wac_allow(&mut rsp, &wac_allow);
1416 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1417 set_link_headers(&mut rsp, &path);
1418 return Ok(rsp);
1419 }
1420 }
1421
1422 let v = state
1423 .storage
1424 .container_representation(&path)
1425 .await
1426 .map_err(to_actix)?;
1427
1428 let sec_fetch_dest = req
1430 .headers()
1431 .get("sec-fetch-dest")
1432 .and_then(|v| v.to_str().ok());
1433 if mashlib::should_serve(
1434 accept,
1435 sec_fetch_dest,
1436 "application/ld+json",
1437 state.mashlib.enabled,
1438 ) {
1439 let json_ld = serde_json::to_string(&v).ok();
1440 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
1441 let mut rsp = HttpResponse::Ok()
1442 .content_type("text/html; charset=utf-8")
1443 .insert_header(("X-Frame-Options", "DENY"))
1444 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1445 .insert_header(("Cache-Control", "no-store"))
1446 .body(inject_live_reload(html, state.live_reload));
1447 set_cache_policy(&mut rsp, "text/html", audience);
1448 set_wac_allow(&mut rsp, &wac_allow);
1449 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1450 set_link_headers(&mut rsp, &path);
1451 return Ok(rsp);
1452 }
1453
1454 let mut rsp = HttpResponse::Ok().json(v);
1455 rsp.headers_mut().insert(
1456 header::CONTENT_TYPE,
1457 header::HeaderValue::from_static("application/ld+json"),
1458 );
1459 set_cache_policy(&mut rsp, "application/ld+json", audience);
1460 set_wac_allow(&mut rsp, &wac_allow);
1461 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1462 set_link_headers(&mut rsp, &path);
1463 return Ok(rsp);
1464 }
1465
1466 match state.storage.get(&path).await {
1467 Ok((body, meta)) => {
1468 let accept = req
1470 .headers()
1471 .get(header::ACCEPT)
1472 .and_then(|v| v.to_str().ok())
1473 .unwrap_or("");
1474 let sec_fetch_dest = req
1475 .headers()
1476 .get("sec-fetch-dest")
1477 .and_then(|v| v.to_str().ok());
1478 if mashlib::should_serve(
1479 accept,
1480 sec_fetch_dest,
1481 &meta.content_type,
1482 state.mashlib.enabled,
1483 ) {
1484 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
1485 std::str::from_utf8(&body).ok().map(|s| s.to_string())
1486 } else {
1487 None
1488 };
1489 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
1490 let mut rsp = HttpResponse::Ok()
1491 .content_type("text/html; charset=utf-8")
1492 .insert_header(("X-Frame-Options", "DENY"))
1493 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1494 .insert_header(("Cache-Control", "no-store"))
1495 .body(inject_live_reload(html, state.live_reload));
1496 set_cache_policy(&mut rsp, "text/html", audience);
1497 set_wac_allow(&mut rsp, &wac_allow);
1498 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1499 set_link_headers(&mut rsp, &path);
1500 return Ok(rsp);
1501 }
1502
1503 if let Some((negotiated_body, negotiated_ct)) =
1511 rdf_content_negotiate(&body, &meta.content_type, accept)
1512 {
1513 let mut rsp = HttpResponse::Ok().body(negotiated_body);
1514 rsp.headers_mut().insert(
1515 header::CONTENT_TYPE,
1516 header::HeaderValue::from_str(negotiated_ct)
1517 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
1518 );
1519 rsp.headers_mut()
1520 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
1521 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1522 rsp.headers_mut().insert(header::ETAG, etag);
1523 }
1524 set_cache_policy(&mut rsp, negotiated_ct, audience);
1525 set_wac_allow(&mut rsp, &wac_allow);
1526 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1527 set_link_headers(&mut rsp, &path);
1528 return Ok(rsp);
1529 }
1530
1531 let response_body = if meta.content_type.starts_with("text/html") {
1532 inject_live_reload(&body, state.live_reload)
1533 } else {
1534 body.to_vec()
1535 };
1536 let mut rsp = HttpResponse::Ok().body(response_body);
1537 rsp.headers_mut().insert(
1538 header::CONTENT_TYPE,
1539 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
1540 header::HeaderValue::from_static("application/octet-stream")
1541 }),
1542 );
1543 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1544 rsp.headers_mut().insert(header::ETAG, etag);
1545 }
1546 set_cache_policy(&mut rsp, &meta.content_type, audience);
1547 set_wac_allow(&mut rsp, &wac_allow);
1548 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1549 set_link_headers(&mut rsp, &path);
1550 Ok(rsp)
1551 }
1552 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1553 Err(e) => Err(to_actix(e)),
1554 }
1555}
1556
1557fn has_basic_container_link(req: &HttpRequest) -> bool {
1558 req.headers()
1559 .get_all(header::LINK)
1560 .filter_map(|v| v.to_str().ok())
1561 .any(|v| {
1562 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
1563 })
1564}
1565
1566async fn handle_put(
1567 req: HttpRequest,
1568 body: web::Bytes,
1569 state: web::Data<AppState>,
1570) -> Result<HttpResponse, ActixError> {
1571 let path = req.uri().path().to_string();
1572
1573 if ldp::is_container(&path) {
1574 if has_basic_container_link(&req) {
1575 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1576 let agent = agent_uri(auth_pk.as_ref());
1577 enforce_write_ctx(
1578 &state,
1579 &path,
1580 AccessMode::Write,
1581 agent.as_deref(),
1582 req_origin(&req),
1583 )
1584 .await?;
1585 let meta = state
1586 .storage
1587 .create_container(&path)
1588 .await
1589 .map_err(to_actix)?;
1590 let mut rsp = HttpResponse::Created().finish();
1591 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1592 rsp.headers_mut().insert(header::ETAG, etag);
1593 }
1594 set_link_headers(&mut rsp, &path);
1595 return Ok(rsp);
1596 }
1597 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
1598 }
1599
1600 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1601 let agent = agent_uri(auth_pk.as_ref());
1602 enforce_write_ctx(
1603 &state,
1604 &path,
1605 AccessMode::Write,
1606 agent.as_deref(),
1607 req_origin(&req),
1608 )
1609 .await?;
1610
1611 let ct = req
1612 .headers()
1613 .get(header::CONTENT_TYPE)
1614 .and_then(|v| v.to_str().ok())
1615 .unwrap_or("application/octet-stream");
1616
1617 if protected_resource_for_acl(&path).is_some()
1622 && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
1623 {
1624 return Ok(HttpResponse::Conflict().body(
1625 "refused: the proposed ACL would not grant Control to the caller \
1626 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1627 ));
1628 }
1629
1630 let quota = reserve_quota_for_size(&state, &path, body.len() as u64).await?;
1631 let write = state
1632 .storage
1633 .put(&path, Bytes::from(body.to_vec()), ct)
1634 .await;
1635 finish_quota_reservation(&state, quota, write.is_ok()).await;
1636 let meta = write.map_err(to_actix)?;
1637 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
1641 let mut rsp = HttpResponse::Created().finish();
1642 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1643 rsp.headers_mut().insert(header::ETAG, etag);
1644 }
1645 set_provenance_headers(&mut rsp, &provenance);
1646 set_link_headers(&mut rsp, &path);
1647 Ok(rsp)
1648}
1649
1650async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
1657 if !storage.exists(target).await.unwrap_or(false) {
1658 return target.to_string();
1659 }
1660 let seg_start = target.rfind('/').map(|s| s + 1).unwrap_or(0);
1663 let (stem, ext) = match target.rfind('.') {
1664 Some(dot) if dot > seg_start => (&target[..dot], &target[dot..]),
1665 _ => (target, ""),
1666 };
1667 for n in 1..10_000u32 {
1668 let candidate = format!("{stem}-{n}{ext}");
1669 if !storage.exists(&candidate).await.unwrap_or(false) {
1670 return candidate;
1671 }
1672 }
1673 use std::hash::{Hash, Hasher};
1674 let mut h = std::collections::hash_map::DefaultHasher::new();
1675 target.hash(&mut h);
1676 format!("{stem}-{:x}{ext}", h.finish())
1677}
1678
1679async fn handle_post(
1680 req: HttpRequest,
1681 body: web::Bytes,
1682 state: web::Data<AppState>,
1683) -> Result<HttpResponse, ActixError> {
1684 let path = req.uri().path().to_string();
1685 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1688 let agent = agent_uri(auth_pk.as_ref());
1689 enforce_write_ctx(
1690 &state,
1691 &path,
1692 AccessMode::Append,
1693 agent.as_deref(),
1694 req_origin(&req),
1695 )
1696 .await?;
1697
1698 let slug = req
1699 .headers()
1700 .get(header::HeaderName::from_static("slug"))
1701 .and_then(|v| v.to_str().ok());
1702 let mut target = match ldp::resolve_slug(&path, slug) {
1703 Ok(p) => p,
1704 Err(e) => return Err(to_actix(e)),
1705 };
1706 let ct = req
1707 .headers()
1708 .get(header::CONTENT_TYPE)
1709 .and_then(|v| v.to_str().ok())
1710 .unwrap_or("application/octet-stream");
1711
1712 if protected_resource_for_acl(&target).is_some() {
1721 enforce_write_ctx(
1722 &state,
1723 &target,
1724 AccessMode::Write,
1725 agent.as_deref(),
1726 req_origin(&req),
1727 )
1728 .await?;
1729 if !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref()) {
1730 return Ok(HttpResponse::Conflict().body(
1731 "refused: the proposed ACL would not grant Control to the caller \
1732 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1733 ));
1734 }
1735 } else {
1736 target = mint_unique_target(&*state.storage, &target).await;
1742 }
1743
1744 let quota = reserve_quota_for_size(&state, &target, body.len() as u64).await?;
1745 let write = state
1746 .storage
1747 .put(&target, Bytes::from(body.to_vec()), ct)
1748 .await;
1749 finish_quota_reservation(&state, quota, write.is_ok()).await;
1750 let meta = write.map_err(to_actix)?;
1751 let provenance = git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1754 let mut rsp = HttpResponse::Created().finish();
1755 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1756 rsp.headers_mut().insert(header::LOCATION, loc);
1757 }
1758 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1759 rsp.headers_mut().insert(header::ETAG, etag);
1760 }
1761 set_provenance_headers(&mut rsp, &provenance);
1762 set_link_headers(&mut rsp, &target);
1763 Ok(rsp)
1764}
1765
1766async fn handle_patch(
1767 req: HttpRequest,
1768 body: web::Bytes,
1769 state: web::Data<AppState>,
1770) -> Result<HttpResponse, ActixError> {
1771 let path = req.uri().path().to_string();
1772 if ldp::is_container(&path) {
1773 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1774 }
1775 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1776 let agent = agent_uri(auth_pk.as_ref());
1777 enforce_write_ctx(
1783 &state,
1784 &path,
1785 AccessMode::Write,
1786 agent.as_deref(),
1787 req_origin(&req),
1788 )
1789 .await?;
1790
1791 let ct = req
1792 .headers()
1793 .get(header::CONTENT_TYPE)
1794 .and_then(|v| v.to_str().ok())
1795 .unwrap_or("");
1796 let dialect = match ldp::patch_dialect_from_mime(ct) {
1797 Some(d) => d,
1798 None => {
1799 return Ok(HttpResponse::UnsupportedMediaType()
1800 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1801 }
1802 };
1803 let body_str = match std::str::from_utf8(&body) {
1804 Ok(s) => s.to_string(),
1805 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1806 };
1807
1808 let existing = state.storage.get(&path).await;
1810 match existing {
1811 Ok((current_body, meta)) => {
1812 let out = match dialect {
1822 ldp::PatchDialect::N3 => {
1823 let seed = seed_graph_from_patch_target(¤t_body)?;
1824 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1825 }
1826 ldp::PatchDialect::SparqlUpdate => {
1827 let seed = seed_graph_from_patch_target(¤t_body)?;
1828 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1829 }
1830 ldp::PatchDialect::JsonPatch => {
1831 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1832 Ok(v) => v,
1833 Err(_) => serde_json::json!({}),
1834 };
1835 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1836 Ok(v) => v,
1837 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1838 };
1839 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1840 let bytes = serde_json::to_vec(&json)
1841 .map_err(PodError::from)
1842 .map_err(to_actix)?;
1843 let quota = reserve_quota_for_size(&state, &path, bytes.len() as u64).await?;
1844 let write = state
1845 .storage
1846 .put(&path, Bytes::from(bytes), &meta.content_type)
1847 .await;
1848 finish_quota_reservation(&state, quota, write.is_ok()).await;
1849 let _ = write.map_err(to_actix)?;
1850 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1851 let mut rsp = HttpResponse::NoContent().finish();
1852 set_provenance_headers(&mut rsp, &provenance);
1853 return Ok(rsp);
1854 }
1855 };
1856 let outcome = out?;
1857 let serialised = graph_to_turtle(&outcome.graph);
1860 if protected_resource_for_acl(&path).is_some()
1866 && !proposed_acl_keeps_caller_control(
1867 serialised.as_bytes(),
1868 "application/n-triples",
1869 agent.as_deref(),
1870 )
1871 {
1872 return Ok(HttpResponse::Conflict().body(
1873 "refused: the patched ACL would not grant Control to the caller \
1874 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1875 ));
1876 }
1877 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
1878 let write = state
1879 .storage
1880 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1881 .await;
1882 finish_quota_reservation(&state, quota, write.is_ok()).await;
1883 let _ = write.map_err(to_actix)?;
1884 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1885 let mut rsp = HttpResponse::NoContent().finish();
1886 set_provenance_headers(&mut rsp, &provenance);
1887 Ok(rsp)
1888 }
1889 Err(PodError::NotFound(_)) => {
1890 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
1892 let PatchCreateOutcome::Created { graph, .. } = create else {
1893 return Err(to_actix(PodError::Unsupported(
1894 "unexpected patch outcome on absent resource".into(),
1895 )));
1896 };
1897 let serialised = graph_to_turtle(&graph);
1898 if protected_resource_for_acl(&path).is_some()
1900 && !proposed_acl_keeps_caller_control(
1901 serialised.as_bytes(),
1902 "application/n-triples",
1903 agent.as_deref(),
1904 )
1905 {
1906 return Ok(HttpResponse::Conflict().body(
1907 "refused: the patched ACL would not grant Control to the caller \
1908 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1909 ));
1910 }
1911 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
1912 let write = state
1913 .storage
1914 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1915 .await;
1916 finish_quota_reservation(&state, quota, write.is_ok()).await;
1917 let _ = write.map_err(to_actix)?;
1918 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1919 let mut rsp = HttpResponse::Created().finish();
1920 set_provenance_headers(&mut rsp, &provenance);
1921 Ok(rsp)
1922 }
1923 Err(e) => Err(to_actix(e)),
1924 }
1925}
1926
1927fn patch_parse_err(e: PodError) -> ActixError {
1931 match e {
1932 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
1933 actix_web::error::ErrorBadRequest(msg)
1934 }
1935 other => to_actix(other),
1936 }
1937}
1938
1939fn graph_to_turtle(g: &ldp::Graph) -> String {
1943 g.to_ntriples()
1944}
1945
1946fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
1953 let mut best: Option<(f32, ldp::RdfFormat)> = None;
1954 for entry in accept.split(',') {
1955 let entry = entry.trim();
1956 if entry.is_empty() {
1957 continue;
1958 }
1959 let mut parts = entry.split(';').map(|s| s.trim());
1960 let mime = match parts.next() {
1961 Some(m) => m,
1962 None => continue,
1963 };
1964 let mut q: f32 = 1.0;
1965 for token in parts {
1966 if let Some(v) = token.strip_prefix("q=") {
1967 if let Ok(parsed) = v.parse::<f32>() {
1968 q = parsed;
1969 }
1970 }
1971 }
1972 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
1975 match best {
1976 None => best = Some((q, format)),
1977 Some((bq, _)) if q > bq => best = Some((q, format)),
1978 _ => {}
1979 }
1980 }
1981 }
1982 best.map(|(_, f)| f)
1983}
1984
1985fn rdf_content_negotiate(
2001 body: &[u8],
2002 stored_ct: &str,
2003 accept: &str,
2004) -> Option<(Vec<u8>, &'static str)> {
2005 if accept.trim().is_empty() {
2006 return None;
2007 }
2008 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
2009 let target = best_explicit_rdf_format(accept)?;
2010 if target == stored_format {
2011 return None;
2012 }
2013 let text = std::str::from_utf8(body).ok()?;
2014 let graph = ldp::Graph::parse_ntriples(text).ok()?;
2015 match target {
2016 ldp::RdfFormat::Turtle => Some((
2019 graph.to_ntriples().into_bytes(),
2020 ldp::RdfFormat::Turtle.mime(),
2021 )),
2022 ldp::RdfFormat::NTriples => Some((
2023 graph.to_ntriples().into_bytes(),
2024 ldp::RdfFormat::NTriples.mime(),
2025 )),
2026 ldp::RdfFormat::JsonLd => {
2027 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
2028 Some((json, ldp::RdfFormat::JsonLd.mime()))
2029 }
2030 ldp::RdfFormat::RdfXml => None,
2032 }
2033}
2034
2035fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
2044 let text = std::str::from_utf8(current_body).map_err(|_| {
2045 actix_web::error::ErrorConflict(
2046 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
2047 )
2048 })?;
2049 if text.trim().is_empty() {
2050 return Ok(ldp::Graph::new());
2051 }
2052 ldp::Graph::parse_ntriples(text).map_err(|_| {
2053 actix_web::error::ErrorConflict(
2054 "existing resource is not N-Triples RDF and cannot be non-destructively \
2055 patched; PUT an N-Triples representation or use a JSON Patch",
2056 )
2057 })
2058}
2059
2060pub(crate) async fn find_effective_acl_dyn(
2076 storage: &dyn Storage,
2077 resource_path: &str,
2078) -> Result<Option<wac::AclDocument>, PodError> {
2079 resolve_policy_dyn(storage, resource_path)
2080 .await
2081 .into_result()
2082}
2083
2084pub(crate) async fn resolve_policy_dyn(
2089 storage: &dyn Storage,
2090 resource_path: &str,
2091) -> wac::PolicyOutcome {
2092 wac::resolve_policy_from_storage(storage, resource_path).await
2093}
2094
2095fn policy_failure_to_actix(outcome: &wac::PolicyOutcome, resource: &str) -> ActixError {
2105 match outcome {
2106 wac::PolicyOutcome::Invalid {
2107 policy_path,
2108 reason,
2109 } => {
2110 tracing::warn!(
2111 target: "solid_pod_rs_server::wac",
2112 resource = %resource,
2113 policy = %policy_path,
2114 "denying: effective ACL is invalid and MUST NOT inherit: {reason}"
2115 );
2116 actix_web::error::ErrorForbidden("access forbidden: governing ACL is invalid")
2117 }
2118 wac::PolicyOutcome::Unavailable {
2119 policy_path,
2120 detail,
2121 } => {
2122 tracing::error!(
2123 target: "solid_pod_rs_server::wac",
2124 resource = %resource,
2125 policy = %policy_path,
2126 "denying: effective ACL could not be read: {detail}"
2127 );
2128 actix_web::error::ErrorServiceUnavailable(
2129 "access control unavailable: governing ACL could not be read",
2130 )
2131 }
2132 _ => actix_web::error::ErrorInternalServerError("policy resolution error"),
2134 }
2135}
2136
2137async fn handle_delete(
2138 req: HttpRequest,
2139 state: web::Data<AppState>,
2140) -> Result<HttpResponse, ActixError> {
2141 let path = req.uri().path().to_string();
2142 let auth_pk = extract_pubkey(&req).await;
2143 let agent = agent_uri(auth_pk.as_ref());
2144 enforce_write_ctx(
2145 &state,
2146 &path,
2147 AccessMode::Write,
2148 agent.as_deref(),
2149 req_origin(&req),
2150 )
2151 .await?;
2152
2153 let quota = reserve_quota_for_size(&state, &path, 0).await?;
2154 match state.storage.delete(&path).await {
2155 Ok(()) => {
2156 finish_quota_reservation(&state, quota, true).await;
2157 Ok(HttpResponse::NoContent().finish())
2158 }
2159 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
2160 Err(e) => Err(to_actix(e)),
2161 }
2162}
2163
2164async fn handle_options(
2165 req: HttpRequest,
2166 state: web::Data<AppState>,
2167) -> Result<HttpResponse, ActixError> {
2168 let path = req.uri().path().to_string();
2169 let o = ldp::options_for(&path);
2170 let mut rsp = HttpResponse::NoContent().finish();
2171 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
2172 rsp.headers_mut()
2173 .insert(header::HeaderName::from_static("allow"), v);
2174 }
2175 if let Some(ap) = o.accept_post {
2176 if let Ok(v) = header::HeaderValue::from_str(ap) {
2177 rsp.headers_mut()
2178 .insert(header::HeaderName::from_static("accept-post"), v);
2179 }
2180 }
2181 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
2182 rsp.headers_mut()
2183 .insert(header::HeaderName::from_static("accept-patch"), v);
2184 }
2185 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
2186 rsp.headers_mut()
2187 .insert(header::HeaderName::from_static("accept-ranges"), v);
2188 }
2189 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
2190 Ok(rsp)
2191}
2192
2193async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
2198 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
2199 HttpResponse::Ok()
2200 .content_type("application/ld+json")
2201 .json(doc)
2202}
2203
2204#[derive(Debug, Deserialize)]
2205struct WebFingerQuery {
2206 resource: Option<String>,
2207}
2208
2209async fn handle_well_known_webfinger(
2210 state: web::Data<AppState>,
2211 q: web::Query<WebFingerQuery>,
2212) -> HttpResponse {
2213 let resource = q.resource.clone().unwrap_or_else(|| {
2214 format!(
2215 "acct:anonymous@{}",
2216 state
2217 .nodeinfo
2218 .base_url
2219 .trim_start_matches("http://")
2220 .trim_start_matches("https://")
2221 )
2222 });
2223 let webid = format!(
2224 "{}/profile/card#me",
2225 state.nodeinfo.base_url.trim_end_matches('/')
2226 );
2227 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
2228 Some(jrd) => HttpResponse::Ok()
2229 .content_type("application/jrd+json")
2230 .json(jrd),
2231 None => HttpResponse::NotFound().finish(),
2232 }
2233}
2234
2235async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
2236 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
2237 HttpResponse::Ok()
2238 .content_type("application/json")
2239 .json(doc)
2240}
2241
2242async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
2243 let doc = interop::nodeinfo_2_1(
2244 &state.nodeinfo.software_name,
2245 &state.nodeinfo.software_version,
2246 state.nodeinfo.open_registrations,
2247 state.nodeinfo.total_users,
2248 );
2249 HttpResponse::Ok()
2250 .content_type("application/json")
2251 .json(doc)
2252}
2253
2254#[cfg(feature = "did-nostr")]
2255async fn handle_well_known_did_nostr(
2256 state: web::Data<AppState>,
2257 path: web::Path<String>,
2258) -> HttpResponse {
2259 let pubkey = path.into_inner();
2260 let pubkey_is_valid = pubkey.len() == 64
2265 && pubkey
2266 .bytes()
2267 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
2268 if !pubkey_is_valid {
2269 return HttpResponse::BadRequest()
2270 .insert_header(("Cache-Control", "no-store"))
2271 .json(serde_json::json!({
2272 "error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
2273 }));
2274 }
2275 let owner_pubkey = match state.storage.get("/profile/card").await {
2283 Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
2284 .ok()
2285 .flatten(),
2286 Err(_) => None,
2287 };
2288 let owner_claims_key = owner_pubkey
2289 .as_deref()
2290 .is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
2291 if !owner_claims_key {
2292 return HttpResponse::NotFound()
2293 .insert_header(("Cache-Control", "no-store"))
2294 .json(serde_json::json!({
2295 "error": "no account on this pod claims this did:nostr pubkey"
2296 }));
2297 }
2298 let also = vec![format!(
2299 "{}/profile/card#me",
2300 state.nodeinfo.base_url.trim_end_matches('/')
2301 )];
2302 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
2303 let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
2304 use std::hash::{Hash, Hasher};
2310 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2311 body.hash(&mut hasher);
2312 let etag = format!("\"{:016x}\"", hasher.finish());
2313 HttpResponse::Ok()
2314 .content_type("application/did+json")
2315 .insert_header(("Cache-Control", "max-age=3600"))
2316 .insert_header(("ETag", etag))
2317 .body(body)
2318}
2319
2320#[cfg(feature = "nip05-endpoint")]
2328#[derive(Debug, Deserialize)]
2329struct Nip05Query {
2330 name: Option<String>,
2333}
2334
2335#[cfg(feature = "nip05-endpoint")]
2336fn nip05_name_is_valid(name: &str) -> bool {
2337 if name.is_empty() {
2340 return false;
2341 }
2342 name.bytes()
2343 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
2344}
2345
2346#[cfg(feature = "nip05-endpoint")]
2347async fn handle_well_known_nip05(
2348 state: web::Data<AppState>,
2349 query: web::Query<Nip05Query>,
2350) -> HttpResponse {
2351 use solid_pod_rs::webid::extract_nostr_pubkey;
2352
2353 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
2355 if !nip05_name_is_valid(&name) {
2356 return HttpResponse::BadRequest().json(serde_json::json!({
2357 "error": "invalid NIP-05 local part",
2358 }));
2359 }
2360
2361 let profile_path = if name == "_" {
2367 "/profile/card".to_string()
2368 } else {
2369 format!("/{name}/profile/card")
2370 };
2371
2372 let (body, _meta) = match state.storage.get(&profile_path).await {
2373 Ok(v) => v,
2374 Err(_) => {
2375 return nip05_empty_response();
2379 }
2380 };
2381
2382 let pubkey_hex = match extract_nostr_pubkey(&body) {
2383 Ok(Some(p)) => p,
2384 _ => return nip05_empty_response(),
2385 };
2386
2387 let doc = interop::nip05_document([(name, pubkey_hex)]);
2388 HttpResponse::Ok()
2389 .insert_header(("Access-Control-Allow-Origin", "*"))
2390 .content_type("application/json")
2391 .json(doc)
2392}
2393
2394#[cfg(feature = "nip05-endpoint")]
2395fn nip05_empty_response() -> HttpResponse {
2396 HttpResponse::Ok()
2397 .insert_header(("Access-Control-Allow-Origin", "*"))
2398 .content_type("application/json")
2399 .json(serde_json::json!({ "names": {} }))
2400}
2401
2402#[cfg(feature = "export-jsonld")]
2416async fn handle_export_all(
2417 req: HttpRequest,
2418 state: web::Data<AppState>,
2419) -> Result<HttpResponse, ActixError> {
2420 let auth_pk = extract_pubkey(&req).await;
2421 let agent = agent_uri(auth_pk.as_ref());
2422
2423 enforce_write_ctx(
2428 &state,
2429 "/",
2430 AccessMode::Control,
2431 agent.as_deref(),
2432 req_origin(&req),
2433 )
2434 .await?;
2435
2436 let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
2440 .ok()
2441 .and_then(|q| q.get("include_private").map(|v| v == "true"))
2442 .unwrap_or(false);
2443
2444 let pod_base = {
2448 let conn = req.connection_info();
2449 format!("{}://{}/", conn.scheme(), conn.host())
2450 };
2451
2452 let options = solid_pod_rs::ExportOptions { include_private };
2453 let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
2454 .await
2455 .map_err(to_actix)?;
2456
2457 let body = serde_json::to_vec(&bundle).map_err(|e| {
2458 actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
2459 })?;
2460 Ok(HttpResponse::Ok()
2461 .content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
2462 .body(body))
2463}
2464
2465#[derive(Debug, Deserialize)]
2470struct CreateAccountRequest {
2471 username: String,
2472 #[serde(default)]
2473 name: Option<String>,
2474}
2475
2476#[derive(Debug, Deserialize)]
2477struct CreatePodRequest {
2478 name: String,
2479}
2480
2481async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
2482 let pod_name = path.into_inner();
2483 let pod_root = format!("/{pod_name}/");
2484 match state.storage.exists(&pod_root).await {
2485 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
2486 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
2487 }
2488}
2489
2490fn valid_pod_name(name: &str) -> bool {
2491 !name.is_empty()
2492 && name
2493 .chars()
2494 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
2495}
2496
2497fn request_ip(req: &HttpRequest) -> IpAddr {
2498 req.peer_addr()
2499 .map(|addr| addr.ip())
2500 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
2501}
2502
2503#[derive(Debug)]
2504struct QuotaReservation {
2505 pod: String,
2506 delta: i64,
2507}
2508
2509fn pod_name_from_path(path: &str) -> Option<&str> {
2510 path.trim_start_matches('/')
2511 .split('/')
2512 .next()
2513 .filter(|p| !p.is_empty())
2514}
2515
2516async fn reserve_quota_for_size(
2517 state: &AppState,
2518 path: &str,
2519 new_size: u64,
2520) -> Result<Option<QuotaReservation>, ActixError> {
2521 let (Some(policy), Some(pod)) = (&state.quota, pod_name_from_path(path)) else {
2522 return Ok(None);
2523 };
2524 let old_size = state
2525 .storage
2526 .head(path)
2527 .await
2528 .map(|meta| meta.size)
2529 .unwrap_or(0);
2530 let delta = i128::from(new_size) - i128::from(old_size);
2531 let delta = delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64;
2532 if delta > 0 {
2533 policy
2534 .reserve(pod, delta as u64)
2535 .await
2536 .map_err(|error| actix_web::error::ErrorInsufficientStorage(error.to_string()))?;
2537 }
2538 Ok(Some(QuotaReservation {
2539 pod: pod.to_string(),
2540 delta,
2541 }))
2542}
2543
2544async fn finish_quota_reservation(
2545 state: &AppState,
2546 reservation: Option<QuotaReservation>,
2547 write_succeeded: bool,
2548) {
2549 let (Some(policy), Some(reservation)) = (&state.quota, reservation) else {
2550 return;
2551 };
2552 if write_succeeded {
2553 if reservation.delta < 0 {
2554 policy.record(&reservation.pod, reservation.delta).await;
2555 }
2556 } else if reservation.delta > 0 {
2557 policy.record(&reservation.pod, -reservation.delta).await;
2558 }
2559}
2560
2561async fn handle_create_account(
2562 req: HttpRequest,
2563 state: web::Data<AppState>,
2564 body: web::Json<CreateAccountRequest>,
2565) -> Result<HttpResponse, ActixError> {
2566 if let Some(response) = provisioning_gate(&req, &state, &body.username) {
2567 return Ok(response);
2568 }
2569 let pod_root = format!("/{}/", body.username);
2570 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2571 return Ok(
2572 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
2573 );
2574 }
2575
2576 let base_uri = state.nodeinfo.base_url.trim_end_matches('/');
2577 let outcome =
2578 provision_named_pod(&state, &body.username, body.name.as_deref(), base_uri).await?;
2579 Ok(HttpResponse::Created().json(serde_json::json!({
2580 "webid": outcome.webid,
2581 "pod_root": outcome.pod_uri,
2582 "username": body.username,
2583 })))
2584}
2585
2586struct NamedPodOutcome {
2587 webid: String,
2588 pod_uri: String,
2589}
2590
2591fn provisioning_gate(req: &HttpRequest, state: &AppState, name: &str) -> Option<HttpResponse> {
2594 if !valid_pod_name(name) {
2595 return Some(HttpResponse::BadRequest().json(serde_json::json!({
2596 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
2597 })));
2598 }
2599
2600 let supplied_key = req
2601 .headers()
2602 .get("x-pod-admin-key")
2603 .and_then(|value| value.to_str().ok())
2604 .unwrap_or("");
2605 use subtle::ConstantTimeEq;
2606 let admin_override = state
2607 .admin_key
2608 .as_deref()
2609 .is_some_and(|expected| bool::from(supplied_key.as_bytes().ct_eq(expected.as_bytes())));
2610 if !state.nodeinfo.open_registrations && !admin_override {
2611 return Some(HttpResponse::Forbidden().json(serde_json::json!({
2612 "error": "pod registration is closed"
2613 })));
2614 }
2615
2616 if let Err(retry_after) = state.pod_create_limiter.check(request_ip(req)) {
2617 return Some(
2618 HttpResponse::TooManyRequests()
2619 .insert_header(("Retry-After", retry_after.to_string()))
2620 .json(serde_json::json!({
2621 "error": "Too Many Requests",
2622 "message": "Pod creation rate limit exceeded",
2623 "retryAfter": retry_after
2624 })),
2625 );
2626 }
2627 None
2628}
2629
2630async fn provision_named_pod(
2634 state: &AppState,
2635 name: &str,
2636 display_name: Option<&str>,
2637 base_uri: &str,
2638) -> Result<NamedPodOutcome, ActixError> {
2639 for container in ["", "profile", "inbox", "public", "private", "settings"] {
2640 let path = if container.is_empty() {
2641 format!("/{name}")
2642 } else {
2643 format!("/{name}/{container}")
2644 };
2645 state
2646 .storage
2647 .put(
2648 &format!("{path}.meta"),
2649 Bytes::from_static(b"{}"),
2650 "application/ld+json",
2651 )
2652 .await
2653 .map_err(to_actix)?;
2654 }
2655
2656 let base_uri = base_uri.trim_end_matches('/');
2657 let pod_uri = format!("{base_uri}/{name}/");
2658 let canonical_prefix = format!("{base_uri}/pods/{name}/");
2659 let webid = format!("{pod_uri}profile/card#me");
2660 let profile = solid_pod_rs::webid::generate_webid_html(name, display_name, base_uri)
2661 .replace(&canonical_prefix, &pod_uri);
2662 state
2663 .storage
2664 .put(
2665 &format!("/{name}/profile/card"),
2666 Bytes::from(profile.into_bytes()),
2667 "text/html",
2668 )
2669 .await
2670 .map_err(to_actix)?;
2671
2672 #[cfg(feature = "git")]
2673 if let Some(root) = &state.data_root {
2674 let hook = solid_pod_rs_git::init::GitAutoInit::new();
2675 if let Err(error) = hook.init_repo_at(&root.join(name)).await {
2676 tracing::warn!(pod = name, %error, "git auto-init failed after pod creation");
2677 }
2678 }
2679
2680 Ok(NamedPodOutcome { webid, pod_uri })
2681}
2682
2683async fn handle_create_pod(
2684 req: HttpRequest,
2685 state: web::Data<AppState>,
2686 body: web::Json<CreatePodRequest>,
2687) -> Result<HttpResponse, ActixError> {
2688 if let Some(response) = provisioning_gate(&req, &state, &body.name) {
2689 return Ok(response);
2690 }
2691
2692 let pod_root = format!("/{}/", body.name);
2693 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2694 return Ok(
2695 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
2696 );
2697 }
2698
2699 let base_uri = {
2700 let conn = req.connection_info();
2701 format!("{}://{}", conn.scheme(), conn.host())
2702 };
2703 let outcome = provision_named_pod(&state, &body.name, None, &base_uri).await?;
2704
2705 Ok(HttpResponse::Created()
2706 .insert_header(("Location", outcome.pod_uri.clone()))
2707 .json(serde_json::json!({
2708 "name": body.name,
2709 "webId": outcome.webid,
2710 "podUri": outcome.pod_uri,
2711 })))
2712}
2713
2714fn normalise_copy_source(raw: &str, host: &str) -> Option<String> {
2724 let path = if raw.starts_with("http://") || raw.starts_with("https://") {
2725 let url = url::Url::parse(raw).ok()?;
2726 let authority = match url.port() {
2727 Some(port) => format!("{}:{port}", url.host_str()?),
2728 None => url.host_str()?.to_string(),
2729 };
2730 if !authority.eq_ignore_ascii_case(host)
2731 || url.query().is_some()
2732 || url.fragment().is_some()
2733 {
2734 return None;
2735 }
2736 url.path().to_string()
2737 } else if raw.starts_with('/') && !raw.contains(['?', '#']) {
2738 raw.to_string()
2739 } else {
2740 return None;
2741 };
2742 canonical_pod_path(&path)
2743}
2744
2745async fn handle_copy(
2746 req: HttpRequest,
2747 state: web::Data<AppState>,
2748) -> Result<HttpResponse, ActixError> {
2749 let dest = req.uri().path().to_string();
2750 let auth_pk = extract_pubkey(&req).await;
2751 let agent = agent_uri(auth_pk.as_ref());
2752 enforce_write_ctx(
2753 &state,
2754 &dest,
2755 AccessMode::Write,
2756 agent.as_deref(),
2757 req_origin(&req),
2758 )
2759 .await?;
2760
2761 let source = req
2762 .headers()
2763 .get("source")
2764 .and_then(|v| v.to_str().ok())
2765 .map(|s| s.to_string());
2766 let source = match source {
2767 Some(s) => s,
2768 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
2769 };
2770 let host = req.connection_info().host().to_string();
2771 let source = match normalise_copy_source(&source, &host) {
2772 Some(s) => s,
2773 None => return Ok(HttpResponse::BadRequest().body("invalid Source")),
2774 };
2775 if !state.dotfiles.is_allowed(Path::new(&source)) {
2776 return Ok(HttpResponse::Forbidden().body("dotfile path denied by allowlist"));
2777 }
2778 enforce_read_ctx(&state, &source, agent.as_deref(), req_origin(&req)).await?;
2781
2782 let (body, meta) = match state.storage.get(&source).await {
2783 Ok(v) => v,
2784 Err(PodError::NotFound(_)) => {
2785 return Ok(HttpResponse::NotFound().body("source resource not found"))
2786 }
2787 Err(e) => return Err(to_actix(e)),
2788 };
2789
2790 if protected_resource_for_acl(&dest).is_some()
2792 && !proposed_acl_keeps_caller_control(&body, &meta.content_type, agent.as_deref())
2793 {
2794 return Ok(HttpResponse::Conflict().body(
2795 "refused: the proposed ACL would not grant Control to the caller \
2796 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
2797 ));
2798 }
2799
2800 let quota = reserve_quota_for_size(&state, &dest, body.len() as u64).await?;
2801 let write = state.storage.put(&dest, body, &meta.content_type).await;
2802 finish_quota_reservation(&state, quota, write.is_ok()).await;
2803 write.map_err(to_actix)?;
2804
2805 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
2810 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
2811 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
2812 let origin = req_origin(&req);
2813 let permitted = enforce_read_ctx(&state, &src_acl, agent.as_deref(), origin)
2814 .await
2815 .is_ok()
2816 && enforce_write_ctx(
2817 &state,
2818 &dst_acl,
2819 AccessMode::Write,
2820 agent.as_deref(),
2821 origin,
2822 )
2823 .await
2824 .is_ok()
2825 && proposed_acl_keeps_caller_control(
2826 &acl_body,
2827 &acl_meta.content_type,
2828 agent.as_deref(),
2829 );
2830 if permitted {
2831 let quota = reserve_quota_for_size(&state, &dst_acl, acl_body.len() as u64).await?;
2832 let write = state
2833 .storage
2834 .put(&dst_acl, acl_body, &acl_meta.content_type)
2835 .await;
2836 finish_quota_reservation(&state, quota, write.is_ok()).await;
2837 write.map_err(to_actix)?;
2838 }
2839 }
2840
2841 let mut rsp = HttpResponse::Created().finish();
2842 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
2843 rsp.headers_mut().insert(header::LOCATION, loc);
2844 }
2845 Ok(rsp)
2846}
2847
2848async fn handle_glob_get(
2853 req: HttpRequest,
2854 state: web::Data<AppState>,
2855) -> Result<HttpResponse, ActixError> {
2856 let raw_path = req.uri().path().to_string();
2857 if !raw_path.ends_with("/*") {
2859 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
2860 }
2861 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
2863 folder.to_string()
2864 } else {
2865 format!("{folder}/")
2866 };
2867
2868 let auth_pk = extract_pubkey(&req).await;
2872 let agent = agent_uri(auth_pk.as_ref());
2873 let origin = req_origin(&req);
2874 let audience = enforce_read_ctx(&state, &folder, agent.as_deref(), origin).await?;
2875
2876 let children = state.storage.list(&folder).await.map_err(to_actix)?;
2877 let mut merged = String::new();
2878 let mut audience = audience;
2879
2880 for child in &children {
2881 if child.ends_with('/')
2882 || child.ends_with(".acl")
2883 || child.ends_with(".meta")
2884 || child.ends_with(".prov.ttl")
2885 {
2886 continue;
2887 }
2888 let child_path = format!("{folder}{child}");
2889 match enforce_read_inner(&state, &child_path, agent.as_deref(), origin, false).await {
2892 Ok(ResponseAudience::Private) => audience = ResponseAudience::Private,
2893 Ok(ResponseAudience::Public) => {}
2894 Err(_) => continue,
2895 }
2896 if let Ok((body, meta)) = state.storage.get(&child_path).await {
2897 if meta.content_type.contains("turtle")
2898 || meta.content_type.contains("n-triples")
2899 || meta.content_type.contains("n3")
2900 {
2901 if let Ok(text) = std::str::from_utf8(&body) {
2902 merged.push_str(text);
2903 merged.push('\n');
2904 }
2905 }
2906 }
2907 }
2908
2909 if merged.is_empty() {
2910 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
2911 }
2912
2913 let mut rsp = HttpResponse::Ok().content_type("text/turtle").body(merged);
2914 set_cache_policy(&mut rsp, "text/turtle", audience);
2915 Ok(rsp)
2916}
2917
2918#[derive(Debug, Deserialize)]
2923struct LoginPasswordRequest {
2924 username: String,
2925 password: String,
2926}
2927
2928async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
2935 let _ = (&body.username, &body.password);
2936 HttpResponse::NotImplemented().json(serde_json::json!({
2937 "error": "password login is not implemented on this pod"
2938 }))
2939}
2940
2941#[derive(Debug, Deserialize)]
2942struct PasswordResetRequest {
2943 username: String,
2944}
2945
2946async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
2950 let _ = &body.username;
2951 HttpResponse::NotImplemented().json(serde_json::json!({
2952 "error": "password reset is not implemented on this pod"
2953 }))
2954}
2955
2956#[derive(Debug, Deserialize)]
2957struct PasswordChangeRequest {
2958 token: String,
2959 new_password: String,
2960}
2961
2962async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
2967 let _ = (&body.token, &body.new_password);
2968 HttpResponse::NotImplemented().json(serde_json::json!({
2969 "error": "password change is not implemented on this pod"
2970 }))
2971}
2972
2973async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
2978 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
2979 HttpResponse::Ok()
2980 .content_type("application/json")
2981 .json(body)
2982}
2983
2984pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
2999
3000#[derive(Debug, Deserialize)]
3002struct ProxyQuery {
3003 url: String,
3004}
3005
3006const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
3008 "set-cookie",
3009 "set-cookie2",
3010 "authorization",
3011 "www-authenticate",
3012 "proxy-authenticate",
3013 "proxy-authorization",
3014];
3015
3016async fn validate_proxy_target(target: &str) -> Result<(url::Url, IpAddr), HttpResponse> {
3032 let parsed = match url::Url::parse(target) {
3033 Ok(u) => u,
3034 Err(_) => {
3035 return Err(
3036 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
3037 );
3038 }
3039 };
3040
3041 match parsed.scheme() {
3043 "http" | "https" => {}
3044 scheme => {
3045 return Err(HttpResponse::BadRequest()
3046 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
3047 }
3048 }
3049
3050 if solid_pod_rs::security::is_safe_url(target).is_err() {
3053 return Err(HttpResponse::Forbidden()
3054 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
3055 }
3056
3057 let host = match parsed.host_str() {
3059 Some(h) => h.to_string(),
3060 None => {
3061 return Err(HttpResponse::BadRequest()
3062 .json(serde_json::json!({"error": "target URL has no host"})))
3063 }
3064 };
3065 let host_lower = host.to_ascii_lowercase();
3066 if host_lower == "localhost"
3067 || host_lower.ends_with(".localhost")
3068 || host_lower == "0.0.0.0"
3069 || host_lower == "[::1]"
3070 || host_lower == "[::0]"
3071 {
3072 return Err(HttpResponse::Forbidden()
3073 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
3074 }
3075
3076 match solid_pod_rs::security::resolve_and_check(&host).await {
3079 Ok(ip) => Ok((parsed, ip)),
3080 Err(_) => Err(HttpResponse::Forbidden()
3081 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"}))),
3082 }
3083}
3084
3085fn build_pinned_proxy_client(url: &url::Url, ip: IpAddr) -> Result<reqwest::Client, ActixError> {
3088 let mut builder = reqwest::Client::builder()
3089 .redirect(reqwest::redirect::Policy::none());
3092 if let Some(host) = url.host_str() {
3093 let port = url.port_or_known_default().unwrap_or(0);
3095 builder = builder.resolve(host, std::net::SocketAddr::new(ip, port));
3096 }
3097 builder
3098 .build()
3099 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))
3100}
3101
3102async fn handle_proxy(
3103 req: HttpRequest,
3104 _state: web::Data<AppState>,
3105 query: web::Query<ProxyQuery>,
3106) -> Result<HttpResponse, ActixError> {
3107 let auth_pk = extract_pubkey(&req).await;
3109 let agent = agent_uri(auth_pk.as_ref());
3110 if agent.is_none() {
3111 return Ok(HttpResponse::Unauthorized()
3112 .json(serde_json::json!({"error": "authentication required"})));
3113 }
3114
3115 let mut current_url = query.url.clone();
3116 let mut redirect_count = 0u8;
3117 const MAX_REDIRECTS: u8 = 5;
3118 const TOTAL_TIMEOUT: Duration = Duration::from_secs(30);
3119 let started = Instant::now();
3120
3121 let byte_cap = std::env::var("PROXY_BYTE_CAP")
3122 .ok()
3123 .and_then(|v| {
3124 solid_pod_rs::config::sources::parse_size(&v)
3125 .map(|u| u as usize)
3126 .ok()
3127 })
3128 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
3129
3130 loop {
3131 let Some(remaining) = TOTAL_TIMEOUT.checked_sub(started.elapsed()) else {
3132 return Ok(HttpResponse::GatewayTimeout()
3133 .json(serde_json::json!({"error": "proxy operation timed out"})));
3134 };
3135 let (target_url, pinned_ip) = match validate_proxy_target(¤t_url).await {
3139 Ok(pair) => pair,
3140 Err(rsp) => return Ok(rsp),
3141 };
3142 let client = build_pinned_proxy_client(&target_url, pinned_ip)?;
3143
3144 let mut upstream_req = client.get(¤t_url).timeout(remaining);
3145
3146 if let Some(auth_val) = req
3148 .headers()
3149 .get("x-upstream-authorization")
3150 .and_then(|v| v.to_str().ok())
3151 {
3152 upstream_req = upstream_req.header("Authorization", auth_val);
3153 }
3154
3155 let response = upstream_req
3156 .send()
3157 .await
3158 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
3159
3160 if response.status().is_redirection() {
3162 if redirect_count >= MAX_REDIRECTS {
3163 return Ok(HttpResponse::BadGateway()
3164 .json(serde_json::json!({"error": "too many redirects"})));
3165 }
3166 if let Some(location) = response.headers().get("location") {
3167 let loc_str = location
3168 .to_str()
3169 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
3170 let base = url::Url::parse(¤t_url)
3172 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
3173 let resolved = base
3174 .join(loc_str)
3175 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
3176 current_url = resolved.to_string();
3177 redirect_count += 1;
3178 continue;
3179 }
3180 return Ok(HttpResponse::BadGateway()
3181 .json(serde_json::json!({"error": "redirect without location"})));
3182 }
3183
3184 let upstream_status = response.status().as_u16();
3186 let upstream_content_type = response
3187 .headers()
3188 .get("content-type")
3189 .and_then(|v| v.to_str().ok())
3190 .unwrap_or("application/octet-stream")
3191 .to_string();
3192 if response
3193 .content_length()
3194 .is_some_and(|length| length > byte_cap as u64)
3195 {
3196 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3197 "error": "proxied response exceeds byte cap",
3198 "limit": byte_cap
3199 })));
3200 }
3201
3202 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
3204 for (name, value) in response.headers() {
3205 let name_lower = name.as_str().to_ascii_lowercase();
3206 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
3207 continue;
3208 }
3209 if matches!(
3211 name_lower.as_str(),
3212 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
3213 ) {
3214 continue;
3215 }
3216 if let Ok(val_str) = value.to_str() {
3217 forwarded_headers.push((name_lower, val_str.to_string()));
3218 }
3219 }
3220
3221 let mut stream = response.bytes_stream();
3222 let mut body_bytes = Vec::with_capacity(byte_cap.min(64 * 1024));
3223 while let Some(chunk) = stream.next().await {
3224 let chunk =
3225 chunk.map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
3226 if body_bytes.len().saturating_add(chunk.len()) > byte_cap {
3227 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3228 "error": "proxied response exceeds byte cap",
3229 "limit": byte_cap
3230 })));
3231 }
3232 body_bytes.extend_from_slice(&chunk);
3233 }
3234
3235 let mut rsp = HttpResponse::build(
3237 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3238 );
3239 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
3240 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
3241
3242 for (name, value) in &forwarded_headers {
3244 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
3245 if let Ok(hval) = header::HeaderValue::from_str(value) {
3246 rsp.insert_header((hname, hval));
3247 }
3248 }
3249 }
3250
3251 return Ok(rsp.body(body_bytes));
3252 }
3253}
3254
3255pub struct PathTraversalGuard;
3261
3262impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
3263where
3264 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3265 B: 'static,
3266{
3267 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3268 type Error = ActixError;
3269 type InitError = ();
3270 type Transform = PathTraversalGuardMiddleware<S>;
3271 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3272
3273 fn new_transform(&self, service: S) -> Self::Future {
3274 ready(Ok(PathTraversalGuardMiddleware { service }))
3275 }
3276}
3277
3278pub struct PathTraversalGuardMiddleware<S> {
3280 service: S,
3281}
3282
3283impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
3284where
3285 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3286 B: 'static,
3287{
3288 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3289 type Error = ActixError;
3290 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3291
3292 actix_web::dev::forward_ready!(service);
3293
3294 fn call(&self, req: ServiceRequest) -> Self::Future {
3295 let raw = req.path().to_string();
3298 if path_is_traversal(&raw) {
3299 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
3300 let sr = req.into_response(rsp.map_into_boxed_body());
3301 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3302 }
3303 let fut = self.service.call(req);
3304 Box::pin(async move {
3305 let resp = fut.await?;
3306 Ok(resp.map_into_left_body())
3307 })
3308 }
3309}
3310
3311fn path_is_traversal(path: &str) -> bool {
3312 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
3314 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
3315 for seg in once.split('/').chain(twice.split('/')) {
3316 if seg == ".." || seg == "." {
3317 return true;
3318 }
3319 }
3320 twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..")
3323}
3324
3325pub struct CorsHeaders {
3336 pub allowed_origins: Arc<Vec<String>>,
3337}
3338
3339impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
3340where
3341 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3342 B: 'static,
3343{
3344 type Response = ServiceResponse<B>;
3345 type Error = ActixError;
3346 type InitError = ();
3347 type Transform = CorsHeadersMiddleware<S>;
3348 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3349
3350 fn new_transform(&self, service: S) -> Self::Future {
3351 ready(Ok(CorsHeadersMiddleware {
3352 service,
3353 allowed_origins: self.allowed_origins.clone(),
3354 }))
3355 }
3356}
3357
3358pub struct CorsHeadersMiddleware<S> {
3360 service: S,
3361 allowed_origins: Arc<Vec<String>>,
3362}
3363
3364impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
3365where
3366 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3367 B: 'static,
3368{
3369 type Response = ServiceResponse<B>;
3370 type Error = ActixError;
3371 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3372
3373 actix_web::dev::forward_ready!(service);
3374
3375 fn call(&self, req: ServiceRequest) -> Self::Future {
3376 let origin = req
3377 .headers()
3378 .get(header::ORIGIN)
3379 .and_then(|v| v.to_str().ok())
3380 .map(str::to_string);
3381 let allowed = self.allowed_origins.clone();
3382 let fut = self.service.call(req);
3383 Box::pin(async move {
3384 let mut resp = fut.await?;
3385 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
3386 Ok(resp)
3387 })
3388 }
3389}
3390
3391fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
3392 if headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
3402 return;
3403 }
3404 let (origin_value, allow_credentials): (String, bool) = if allowed.is_empty() {
3415 ("*".to_string(), false)
3416 } else {
3417 match origin.filter(|o| allowed.iter().any(|a| a == *o)) {
3418 Some(o) => (o.to_string(), true),
3419 None => return,
3422 }
3423 };
3424
3425 let mut pairs = vec![
3426 ("access-control-allow-origin", origin_value.as_str()),
3427 (
3428 "access-control-allow-methods",
3429 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
3430 ),
3431 (
3432 "access-control-allow-headers",
3433 "Accept, Authorization, Content-Type, DPoP, Git-Protocol, If-Match, If-None-Match, Link, Range, Slug, Origin",
3434 ),
3435 (
3436 "access-control-expose-headers",
3437 "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",
3438 ),
3439 ("access-control-max-age", "86400"),
3440 ];
3441 if allow_credentials {
3444 pairs.push(("access-control-allow-credentials", "true"));
3445 }
3446
3447 for (name, value) in pairs {
3448 if let (Ok(name), Ok(value)) = (
3449 header::HeaderName::from_lowercase(name.as_bytes()),
3450 header::HeaderValue::from_str(value),
3451 ) {
3452 headers.insert(name, value);
3453 }
3454 }
3455}
3456
3457pub struct ErrorLoggingMiddleware;
3473
3474impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
3475where
3476 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3477 B: 'static,
3478{
3479 type Response = ServiceResponse<B>;
3480 type Error = ActixError;
3481 type InitError = ();
3482 type Transform = ErrorLoggingMiddlewareService<S>;
3483 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3484
3485 fn new_transform(&self, service: S) -> Self::Future {
3486 ready(Ok(ErrorLoggingMiddlewareService { service }))
3487 }
3488}
3489
3490pub struct ErrorLoggingMiddlewareService<S> {
3492 service: S,
3493}
3494
3495impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
3496where
3497 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3498 B: 'static,
3499{
3500 type Response = ServiceResponse<B>;
3501 type Error = ActixError;
3502 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3503
3504 actix_web::dev::forward_ready!(service);
3505
3506 fn call(&self, req: ServiceRequest) -> Self::Future {
3507 let method = req.method().as_str().to_string();
3510 let path = req.path().to_string();
3511
3512 let fut = self.service.call(req);
3513 Box::pin(async move {
3514 let response = fut.await?;
3515 let status = response.status();
3516 if status.is_server_error() {
3517 log_5xx(&method, &path, status, response.response().error());
3518 }
3519 Ok(response)
3520 })
3521 }
3522}
3523
3524fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
3528 let chain = match error {
3532 Some(e) => format_error_chain(e),
3533 None => "<no error attached to response>".to_string(),
3534 };
3535
3536 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
3537 Some(std::backtrace::Backtrace::force_capture().to_string())
3538 } else {
3539 None
3540 };
3541
3542 tracing::error!(
3543 target: "solid_pod_rs_server::http",
3544 method = %method,
3545 path = %path,
3546 status = %status.as_u16(),
3547 error.chain = %chain,
3548 backtrace = backtrace.as_deref().unwrap_or(""),
3549 "5xx response"
3550 );
3551}
3552
3553fn format_error_chain(e: &actix_web::Error) -> String {
3564 let summary = format!("{}", e.as_response_error());
3565 let debug = format!("{e:?}");
3566 if debug == summary || debug.is_empty() {
3567 summary
3568 } else {
3569 format!("{summary} -> {debug}")
3570 }
3571}
3572
3573pub struct DotfileGuard {
3579 allow: Arc<DotfileAllowlist>,
3580}
3581
3582impl DotfileGuard {
3583 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
3584 Self { allow }
3585 }
3586}
3587
3588impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
3589where
3590 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3591 B: 'static,
3592{
3593 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3594 type Error = ActixError;
3595 type InitError = ();
3596 type Transform = DotfileGuardMiddleware<S>;
3597 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3598
3599 fn new_transform(&self, service: S) -> Self::Future {
3600 ready(Ok(DotfileGuardMiddleware {
3601 service,
3602 allow: self.allow.clone(),
3603 }))
3604 }
3605}
3606
3607pub struct DotfileGuardMiddleware<S> {
3609 service: S,
3610 allow: Arc<DotfileAllowlist>,
3611}
3612
3613impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
3614where
3615 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3616 B: 'static,
3617{
3618 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3619 type Error = ActixError;
3620 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3621
3622 actix_web::dev::forward_ready!(service);
3623
3624 fn call(&self, req: ServiceRequest) -> Self::Future {
3625 let path = req.path().to_string();
3626 let allow_system_route =
3633 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
3634 if !allow_system_route {
3635 let pb = PathBuf::from(&path);
3636 if !self.allow.is_allowed(Path::new(&pb)) {
3637 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
3638 let sr = req.into_response(rsp.map_into_boxed_body());
3639 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3640 }
3641 }
3642 let fut = self.service.call(req);
3643 Box::pin(async move {
3644 let resp = fut.await?;
3645 Ok(resp.map_into_left_body())
3646 })
3647 }
3648}
3649
3650#[cfg(feature = "git")]
3655pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
3656 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
3657 return None;
3658 }
3659 state.data_root.as_ref().map(|root| root.join(pubkey))
3660}
3661
3662#[cfg(feature = "git")]
3692async fn git_mark_write(
3693 state: &AppState,
3694 resource_path: &str,
3695 agent: Option<&str>,
3696 message: &str,
3697) -> ProvenanceReceipt {
3698 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
3699 use solid_pod_rs_git::mark::ShellGitMarker;
3700
3701 if resource_path.ends_with(".acl")
3704 || resource_path.ends_with(".meta")
3705 || resource_path.ends_with(".prov.ttl")
3706 {
3707 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::ExcludedPath);
3708 }
3709 if resource_path.ends_with('/') {
3711 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::Container);
3712 }
3713
3714 let Some(data_root) = state.data_root.as_ref() else {
3716 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured);
3717 };
3718
3719 let trimmed = resource_path.trim_start_matches('/');
3721 let mut segments = trimmed.splitn(2, '/');
3722 let pod = segments.next().unwrap_or("");
3723 let rel = segments.next().unwrap_or("");
3724 if pod.is_empty() || rel.is_empty() {
3725 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::UnresolvablePath);
3726 }
3727 let repo = data_root.join(pod);
3728
3729 if !repo.join(".git").is_dir() {
3733 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotGitBacked);
3734 }
3735
3736 let agent_did = agent.unwrap_or("urn:solid:anonymous");
3737 let created = std::time::SystemTime::now()
3738 .duration_since(std::time::UNIX_EPOCH)
3739 .map(|d| d.as_secs())
3740 .unwrap_or(0);
3741
3742 let (policy, ticker_override) =
3745 handlers::prov::resolve_anchor_policy(state, resource_path).await;
3746
3747 let marker = std::sync::Arc::new(ShellGitMarker::new());
3752 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
3753 None
3754 } else {
3755 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
3756 };
3757 let (log, ticker, network) = match &anchorer_bundle {
3758 Some((anchorer, ticker, network)) => (
3759 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
3760 ticker.clone(),
3761 network.clone(),
3762 ),
3763 None => (
3765 ProvenanceLog::new(marker.clone()),
3766 String::new(),
3767 String::new(),
3768 ),
3769 };
3770
3771 let record_policy = match policy {
3775 AnchorPolicy::Epoch => AnchorPolicy::Never,
3776 other => other,
3777 };
3778 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
3779
3780 let write_record = solid_pod_rs::provenance::WriteRecord {
3784 repo: &repo,
3785 path: rel,
3786 agent_did,
3787 message,
3788 policy: record_policy,
3789 high_value,
3790 ticker: &ticker,
3791 network: &network,
3792 created,
3793 };
3794 let mut receipt = log.record_receipt(write_record).await;
3799 receipt.resource = resource_path.to_string();
3802
3803 let Some(git) = receipt.mark.clone() else {
3804 tracing::warn!(
3808 target: "solid_pod_rs_server::git_mark",
3809 resource = %resource_path,
3810 "provenance record failed (write already succeeded): {}",
3811 receipt.mark_error.as_deref().unwrap_or("unspecified")
3812 );
3813 return receipt;
3814 };
3815 if let Some(err) = &receipt.anchor_error {
3816 tracing::warn!(
3819 target: "solid_pod_rs_server::git_mark",
3820 resource = %resource_path,
3821 commit = %git.commit_sha,
3822 "block-trail anchor failed (git-mark stands): {err}"
3823 );
3824 }
3825 let mark = solid_pod_rs::provenance::ProvenanceMark {
3826 resource: resource_path.to_string(),
3827 git: git.clone(),
3828 anchor: receipt.anchor.clone(),
3829 agent_did: agent_did.to_string(),
3830 created,
3831 };
3832
3833 if matches!(policy, AnchorPolicy::Epoch) {
3837 if let Some((anchorer, _, _)) = &anchorer_bundle {
3838 match handlers::prov::epoch_push_and_maybe_anchor(
3839 state,
3840 anchorer,
3841 &ticker,
3842 &network,
3843 &mark.git.commit_sha,
3844 )
3845 .await
3846 {
3847 Ok(Some(closed)) => tracing::debug!(
3848 target: "solid_pod_rs_server::git_mark",
3849 root = %closed.root,
3850 n = closed.commits.len(),
3851 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
3852 ),
3853 Ok(None) => {}
3854 Err(e) => tracing::warn!(
3855 target: "solid_pod_rs_server::git_mark",
3856 "epoch batch/anchor failed (swallowed): {e}"
3857 ),
3858 }
3859 }
3860 }
3861
3862 let ttl = prov_ttl(&mark);
3867 let sidecar = format!("{resource_path}.prov.ttl");
3868 if let Err(e) = state
3869 .storage
3870 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
3871 .await
3872 {
3873 tracing::warn!(
3874 target: "solid_pod_rs_server::git_mark",
3875 sidecar = %sidecar,
3876 "provenance sidecar write failed: {e}"
3877 );
3878 receipt.mark_error = Some(format!("PROV-O sidecar write failed: {e}"));
3881 return receipt;
3882 }
3883
3884 tracing::debug!(
3885 target: "solid_pod_rs_server::git_mark",
3886 resource = %resource_path,
3887 commit = %mark.git.commit_sha,
3888 stage = %receipt.stage(),
3889 "provenance recorded"
3890 );
3891 receipt
3892}
3893
3894#[cfg(not(feature = "git"))]
3897#[inline]
3898async fn git_mark_write(
3899 _state: &AppState,
3900 resource_path: &str,
3901 _agent: Option<&str>,
3902 _message: &str,
3903) -> ProvenanceReceipt {
3904 ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured)
3905}
3906
3907#[cfg(feature = "git")]
3908pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
3909 require_pod_owner_with_body(req, pod_pubkey, None).await
3910}
3911
3912#[cfg(feature = "git")]
3913pub(crate) async fn require_pod_owner_with_body(
3914 req: &HttpRequest,
3915 pod_pubkey: &str,
3916 body: Option<&[u8]>,
3917) -> Option<String> {
3918 let caller = extract_pubkey_with_body(req, body).await?;
3919 if caller != pod_pubkey {
3920 return None;
3921 }
3922 Some(caller)
3923}
3924
3925#[cfg(feature = "git")]
3926fn git_json_err(msg: &str, status: u16) -> HttpResponse {
3927 HttpResponse::build(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
3928 .content_type("application/json")
3929 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
3930}
3931
3932#[cfg(feature = "git")]
3934#[derive(serde::Deserialize)]
3935struct GitStageBody {
3936 paths: Option<Vec<String>>,
3937 all: Option<bool>,
3938}
3939
3940#[cfg(feature = "git")]
3941#[derive(serde::Deserialize)]
3942struct GitCommitBody {
3943 message: String,
3944 author_name: Option<String>,
3945 author_email: Option<String>,
3946}
3947
3948#[cfg(feature = "git")]
3949#[derive(serde::Deserialize)]
3950struct GitBranchBody {
3951 name: String,
3952}
3953
3954#[cfg(feature = "git")]
3957async fn handle_git_status(
3958 path: web::Path<String>,
3959 req: HttpRequest,
3960 state: web::Data<AppState>,
3961) -> HttpResponse {
3962 let pubkey = path.into_inner();
3963 if require_pod_owner(&req, &pubkey).await.is_none() {
3964 return git_json_err("Authentication required", 401);
3965 }
3966 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3967 return git_json_err("Git not available (no FS backend)", 501);
3968 };
3969 match solid_pod_rs_git::api::git_status(&repo).await {
3970 Ok(s) => HttpResponse::Ok()
3971 .content_type("application/json")
3972 .body(serde_json::to_string(&s).unwrap_or_default()),
3973 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3974 }
3975}
3976
3977#[cfg(feature = "git")]
3978async fn handle_git_log(
3979 path: web::Path<String>,
3980 req: HttpRequest,
3981 state: web::Data<AppState>,
3982 query: web::Query<std::collections::HashMap<String, String>>,
3983) -> HttpResponse {
3984 let pubkey = path.into_inner();
3985 if require_pod_owner(&req, &pubkey).await.is_none() {
3986 return git_json_err("Authentication required", 401);
3987 }
3988 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3989 return git_json_err("Git not available (no FS backend)", 501);
3990 };
3991 let limit: u32 = query
3992 .get("limit")
3993 .and_then(|v| v.parse().ok())
3994 .unwrap_or(20);
3995 match solid_pod_rs_git::api::git_log(&repo, limit).await {
3996 Ok(entries) => HttpResponse::Ok()
3997 .content_type("application/json")
3998 .body(serde_json::to_string(&entries).unwrap_or_default()),
3999 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4000 }
4001}
4002
4003#[cfg(feature = "git")]
4004async fn handle_git_diff(
4005 path: web::Path<String>,
4006 req: HttpRequest,
4007 state: web::Data<AppState>,
4008 query: web::Query<std::collections::HashMap<String, String>>,
4009) -> HttpResponse {
4010 let pubkey = path.into_inner();
4011 if require_pod_owner(&req, &pubkey).await.is_none() {
4012 return git_json_err("Authentication required", 401);
4013 }
4014 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4015 return git_json_err("Git not available (no FS backend)", 501);
4016 };
4017 let file_path = query.get("path").map(String::as_str);
4018 let staged = query
4019 .get("staged")
4020 .map(|v| v == "true" || v == "1")
4021 .unwrap_or(false);
4022 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
4023 Ok(diff) => HttpResponse::Ok().content_type("text/plain").body(diff),
4024 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4025 }
4026}
4027
4028#[cfg(feature = "git")]
4029async fn handle_git_stage(
4030 path: web::Path<String>,
4031 req: HttpRequest,
4032 state: web::Data<AppState>,
4033 body: web::Bytes,
4034) -> HttpResponse {
4035 let pubkey = path.into_inner();
4036 if require_pod_owner(&req, &pubkey).await.is_none() {
4037 return git_json_err("Authentication required", 401);
4038 }
4039 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4040 return git_json_err("Git not available (no FS backend)", 501);
4041 };
4042 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4043 Ok(v) => v,
4044 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4045 };
4046 let paths = parsed.paths.unwrap_or_default();
4047 let all = parsed.all.unwrap_or(false);
4048 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
4049 Ok(()) => HttpResponse::Ok()
4050 .content_type("application/json")
4051 .body(r#"{"ok":true}"#),
4052 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4053 }
4054}
4055
4056#[cfg(feature = "git")]
4057async fn handle_git_unstage(
4058 path: web::Path<String>,
4059 req: HttpRequest,
4060 state: web::Data<AppState>,
4061 body: web::Bytes,
4062) -> HttpResponse {
4063 let pubkey = path.into_inner();
4064 if require_pod_owner(&req, &pubkey).await.is_none() {
4065 return git_json_err("Authentication required", 401);
4066 }
4067 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4068 return git_json_err("Git not available (no FS backend)", 501);
4069 };
4070 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4071 Ok(v) => v,
4072 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4073 };
4074 let paths = parsed.paths.unwrap_or_default();
4075 let all = parsed.all.unwrap_or(false);
4076 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
4077 Ok(()) => HttpResponse::Ok()
4078 .content_type("application/json")
4079 .body(r#"{"ok":true}"#),
4080 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4081 }
4082}
4083
4084#[cfg(feature = "git")]
4085async fn handle_git_commit(
4086 path: web::Path<String>,
4087 req: HttpRequest,
4088 state: web::Data<AppState>,
4089 body: web::Bytes,
4090) -> HttpResponse {
4091 let pubkey = path.into_inner();
4092 if require_pod_owner(&req, &pubkey).await.is_none() {
4093 return git_json_err("Authentication required", 401);
4094 }
4095 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4096 return git_json_err("Git not available (no FS backend)", 501);
4097 };
4098 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
4099 Ok(v) => v,
4100 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4101 };
4102 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
4103 let author_email = parsed
4104 .author_email
4105 .as_deref()
4106 .unwrap_or("pod@dreamlab-ai.com");
4107 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email).await
4108 {
4109 Ok(result) => HttpResponse::Ok()
4110 .content_type("application/json")
4111 .body(serde_json::to_string(&result).unwrap_or_default()),
4112 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4113 }
4114}
4115
4116#[cfg(feature = "git")]
4117async fn handle_git_branches(
4118 path: web::Path<String>,
4119 req: HttpRequest,
4120 state: web::Data<AppState>,
4121) -> HttpResponse {
4122 let pubkey = path.into_inner();
4123 if require_pod_owner(&req, &pubkey).await.is_none() {
4124 return git_json_err("Authentication required", 401);
4125 }
4126 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4127 return git_json_err("Git not available (no FS backend)", 501);
4128 };
4129 match solid_pod_rs_git::api::git_branches(&repo).await {
4130 Ok(info) => HttpResponse::Ok()
4131 .content_type("application/json")
4132 .body(serde_json::to_string(&info).unwrap_or_default()),
4133 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4134 }
4135}
4136
4137#[cfg(feature = "git")]
4138async fn handle_git_create_branch(
4139 path: web::Path<String>,
4140 req: HttpRequest,
4141 state: web::Data<AppState>,
4142 body: web::Bytes,
4143) -> HttpResponse {
4144 let pubkey = path.into_inner();
4145 if require_pod_owner(&req, &pubkey).await.is_none() {
4146 return git_json_err("Authentication required", 401);
4147 }
4148 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4149 return git_json_err("Git not available (no FS backend)", 501);
4150 };
4151 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
4152 Ok(v) => v,
4153 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4154 };
4155 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
4156 Ok(()) => HttpResponse::Ok()
4157 .content_type("application/json")
4158 .body(r#"{"ok":true}"#),
4159 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4160 }
4161}
4162
4163#[cfg(feature = "git")]
4164async fn handle_git_discard(
4165 path: web::Path<String>,
4166 req: HttpRequest,
4167 state: web::Data<AppState>,
4168 body: web::Bytes,
4169) -> HttpResponse {
4170 let pubkey = path.into_inner();
4171 if require_pod_owner(&req, &pubkey).await.is_none() {
4172 return git_json_err("Authentication required", 401);
4173 }
4174 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4175 return git_json_err("Git not available (no FS backend)", 501);
4176 };
4177 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4178 Ok(v) => v,
4179 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4180 };
4181 let paths = parsed.paths.unwrap_or_default();
4182 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
4183 Ok(()) => HttpResponse::Ok()
4184 .content_type("application/json")
4185 .body(r#"{"ok":true}"#),
4186 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4187 }
4188}
4189
4190async fn handle_git_panel_options(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
4198 let origin = req
4199 .headers()
4200 .get(header::ORIGIN)
4201 .and_then(|v| v.to_str().ok())
4202 .map(str::to_string);
4203
4204 let mut rsp = HttpResponse::NoContent().finish();
4205 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
4206 rsp
4207}
4208
4209async fn handle_admin_provision(
4226 req: HttpRequest,
4227 state: web::Data<AppState>,
4228 path: web::Path<String>,
4229) -> HttpResponse {
4230 let expected = match &state.admin_key {
4232 Some(k) => k.clone(),
4233 None => {
4234 return HttpResponse::Forbidden().json(serde_json::json!({
4235 "error": "admin key not configured on this server"
4236 }));
4237 }
4238 };
4239 let provided = req
4240 .headers()
4241 .get("x-pod-admin-key")
4242 .and_then(|v| v.to_str().ok())
4243 .unwrap_or("");
4244 use subtle::ConstantTimeEq;
4249 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
4250 if !bool::from(key_match) {
4251 return HttpResponse::Forbidden().json(serde_json::json!({"error": "invalid admin key"}));
4252 }
4253
4254 let pubkey = path.into_inner();
4256 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
4257 return HttpResponse::BadRequest()
4258 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
4259 }
4260
4261 let data_root = match &state.data_root {
4263 Some(r) => r.clone(),
4264 None => {
4265 return HttpResponse::InternalServerError().json(serde_json::json!({
4266 "error": "server has no fs-backend storage configured"
4267 }));
4268 }
4269 };
4270
4271 let pods_root = data_root.join("pods");
4284 let pod_dir = pods_root.join(&pubkey);
4285
4286 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
4288 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
4289 return HttpResponse::InternalServerError()
4290 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
4291 }
4292
4293 let acl_content = format!(
4303 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
4304 <#owner> a acl:Authorization ;\n\
4305 acl:agent <did:nostr:{pubkey}> ;\n\
4306 acl:accessTo </pods/{pubkey}/> ;\n\
4307 acl:default </pods/{pubkey}/> ;\n\
4308 acl:mode acl:Read, acl:Write, acl:Control .\n"
4309 );
4310
4311 let sibling_acl_path = pods_root.join(format!("{pubkey}.acl"));
4321 if !sibling_acl_path.exists() {
4322 if let Err(e) = tokio::fs::write(&sibling_acl_path, acl_content.as_bytes()).await {
4323 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write sibling .acl failed");
4324 return HttpResponse::InternalServerError()
4325 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
4326 }
4327 }
4328
4329 let inner_acl_path = pod_dir.join(".acl");
4336 if !inner_acl_path.exists() {
4337 if let Err(e) = tokio::fs::write(&inner_acl_path, acl_content.as_bytes()).await {
4338 tracing::warn!(pubkey = %pubkey, error = %e, "/_admin/provision: write inner .acl failed (non-fatal; sibling ACL governs)");
4339 }
4340 }
4341
4342 #[cfg(feature = "git")]
4344 {
4345 if !pod_dir.join(".git").exists() {
4347 let init_out = crate::hardened_git::hardened_git_async()
4348 .args(["init", "-b", "main", pod_dir.to_str().unwrap_or(".")])
4349 .output()
4350 .await;
4351
4352 match init_out {
4353 Ok(out) if out.status.success() => {}
4354 Ok(out) => {
4355 let stderr = String::from_utf8_lossy(&out.stderr);
4356 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
4357 }
4358 Err(e) => {
4359 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
4360 }
4361 }
4362
4363 let cfg_out = crate::hardened_git::hardened_git_async()
4366 .args([
4367 "-C",
4368 pod_dir.to_str().unwrap_or("."),
4369 "config",
4370 "receive.denyCurrentBranch",
4371 "updateInstead",
4372 ])
4373 .output()
4374 .await;
4375
4376 if let Err(e) = cfg_out {
4377 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
4378 }
4379 }
4380 }
4381
4382 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
4384 HttpResponse::Ok().json(serde_json::json!({
4385 "podUrl": format!("{base_url}/pods/{pubkey}/"),
4386 "ok": true,
4387 }))
4388}
4389
4390async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
4395 let Some(ref data_root) = state.data_root else {
4396 return HttpResponse::Ok()
4397 .content_type("application/json")
4398 .json(serde_json::json!({"apps": [], "count": 0}));
4399 };
4400
4401 let server_url = state.nodeinfo.base_url.clone();
4402
4403 let mut read_dir = match tokio::fs::read_dir(data_root).await {
4405 Ok(rd) => rd,
4406 Err(_) => {
4407 return HttpResponse::Ok()
4408 .content_type("application/json")
4409 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
4410 }
4411 };
4412
4413 let mut apps: Vec<serde_json::Value> = Vec::new();
4414 let mut scanned = 0usize;
4415
4416 while scanned < 1000 {
4417 let entry = match read_dir.next_entry().await {
4418 Ok(Some(e)) => e,
4419 Ok(None) => break,
4420 Err(_) => break,
4421 };
4422
4423 let file_type = match entry.file_type().await {
4424 Ok(ft) => ft,
4425 Err(_) => continue,
4426 };
4427 if !file_type.is_dir() {
4428 continue;
4429 }
4430
4431 scanned += 1;
4432
4433 let manifest_path = entry.path().join("apps").join("manifest.json");
4434 let contents = match tokio::fs::read(&manifest_path).await {
4435 Ok(c) => c,
4436 Err(_) => continue,
4437 };
4438
4439 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
4440 Ok(v) => v,
4441 Err(_) => continue,
4442 };
4443
4444 if let Some(pod_name) = entry.file_name().to_str() {
4446 if manifest.get("podOwner").is_none() {
4447 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
4448 }
4449 }
4450
4451 apps.push(manifest);
4452 }
4453
4454 let count = apps.len();
4455 HttpResponse::Ok()
4456 .content_type("application/json")
4457 .json(serde_json::json!({
4458 "apps": apps,
4459 "serverUrl": server_url,
4460 "count": count,
4461 }))
4462}
4463
4464#[allow(dead_code)]
4477fn is_git_request(path: &str) -> bool {
4478 path.contains("/info/refs")
4479 || path.contains("/git-upload-pack")
4480 || path.contains("/git-receive-pack")
4481}
4482
4483#[allow(dead_code)]
4486fn is_dot_git_path(path: &str) -> bool {
4487 path.contains("/.git/") || path.ends_with("/.git")
4488}
4489
4490#[cfg(feature = "git")]
4491async fn handle_git(
4492 req: HttpRequest,
4493 body: web::Bytes,
4494 state: web::Data<AppState>,
4495) -> HttpResponse {
4496 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
4497 use solid_pod_rs_git::service::GitRequest;
4498
4499 let path = req.uri().path().to_string();
4500
4501 let pod_name = path
4504 .trim_start_matches('/')
4505 .split('/')
4506 .next()
4507 .unwrap_or("")
4508 .to_string();
4509 let Some(ref data_root) = state.data_root else {
4510 return HttpResponse::NotImplemented().json(serde_json::json!({
4511 "error": "git requires fs-backend storage",
4512 "reason": "data_root_not_configured"
4513 }));
4514 };
4515 let repo_root = data_root.join(&pod_name);
4516 if !repo_root.exists() {
4517 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
4518 }
4519
4520 let query = req.uri().query().unwrap_or("").to_string();
4521 let host_url = {
4522 let conn = req.connection_info();
4523 Some(format!("{}://{}", conn.scheme(), conn.host()))
4524 };
4525 let headers: Vec<(String, String)> = req
4526 .headers()
4527 .iter()
4528 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
4529 .collect();
4530
4531 let git_req = GitRequest {
4532 method: req.method().as_str().to_string(),
4533 path,
4534 query,
4535 headers,
4536 body,
4537 host_url,
4538 };
4539
4540 if git_req.is_write() {
4546 let mut builder = HttpResponse::Forbidden();
4547 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4548 builder.insert_header((k, v));
4549 }
4550 return builder.json(serde_json::json!({
4551 "error": "git push is not served by this server",
4552 "reason": "push_not_served"
4553 }));
4554 }
4555
4556 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
4567 Ok(pk) => Some(format!("did:nostr:{pk}")),
4568 Err(_) => None,
4569 };
4570 let wac_path = format!("/{pod_name}/");
4571 let origin = req_origin(&req);
4572 let wac = enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin)
4576 .await
4577 .map(|_audience| ());
4578 if let Err(e) = wac {
4579 let mut resp = e.error_response();
4583 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4584 if let (Ok(name), Ok(value)) = (
4585 actix_web::http::header::HeaderName::from_bytes(k.as_bytes()),
4586 actix_web::http::header::HeaderValue::from_str(v),
4587 ) {
4588 resp.headers_mut().insert(name, value);
4589 }
4590 }
4591 return resp;
4592 }
4593
4594 let service = solid_pod_rs_git::service::GitHttpService::new(repo_root);
4595 match service.handle(git_req).await {
4596 Ok(git_resp) => {
4597 let mut builder = HttpResponse::build(
4598 actix_web::http::StatusCode::from_u16(git_resp.status)
4599 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4600 );
4601 for (k, v) in &git_resp.headers {
4602 builder.insert_header((k.as_str(), v.as_str()));
4603 }
4604 builder.body(git_resp.body)
4605 }
4606 Err(e) => {
4607 let status = e.status_code();
4608 let mut builder = HttpResponse::build(
4609 actix_web::http::StatusCode::from_u16(status)
4610 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4611 );
4612 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4613 builder.insert_header((k, v));
4614 }
4615 builder.json(serde_json::json!({"error": e.to_string()}))
4616 }
4617 }
4618}
4619
4620#[cfg(feature = "forge")]
4629fn forge_plugin_dir(state: &AppState) -> Option<PathBuf> {
4630 state.data_root.as_ref().map(|r| r.join(".forge"))
4631}
4632
4633#[cfg(feature = "forge")]
4639struct ServerLoopback {
4640 client: reqwest::Client,
4641}
4642
4643#[cfg(feature = "forge")]
4644#[async_trait::async_trait]
4645impl solid_pod_rs_forge::LoopbackFetch for ServerLoopback {
4646 async fn get(
4647 &self,
4648 url: &str,
4649 max_bytes: usize,
4650 timeout_secs: u64,
4651 ) -> solid_pod_rs_forge::bodies::FetchResult {
4652 use solid_pod_rs_forge::bodies::FetchResult;
4653 let resp = match self
4654 .client
4655 .get(url)
4656 .timeout(Duration::from_secs(timeout_secs.max(1)))
4657 .send()
4658 .await
4659 {
4660 Ok(r) => r,
4661 Err(e) => return FetchResult::Error(e.to_string()),
4662 };
4663 let code = resp.status().as_u16();
4664 if code == 404 || code == 410 {
4665 return FetchResult::Removed;
4666 }
4667 if !resp.status().is_success() {
4668 return FetchResult::Error(format!("status {code}"));
4669 }
4670 if resp
4671 .content_length()
4672 .is_some_and(|length| length > max_bytes as u64)
4673 {
4674 return FetchResult::TooLarge;
4675 }
4676 let mut stream = resp.bytes_stream();
4677 let mut body = Vec::with_capacity(max_bytes.min(16 * 1024));
4678 while let Some(chunk) = stream.next().await {
4679 match chunk {
4680 Ok(chunk) if body.len().saturating_add(chunk.len()) <= max_bytes => {
4681 body.extend_from_slice(&chunk);
4682 }
4683 Ok(_) => return FetchResult::TooLarge,
4684 Err(error) => return FetchResult::Error(error.to_string()),
4685 }
4686 }
4687 FetchResult::Body(body)
4688 }
4689}
4690
4691#[cfg(feature = "forge")]
4697async fn handle_forge(
4698 req: HttpRequest,
4699 body: web::Bytes,
4700 state: web::Data<AppState>,
4701) -> HttpResponse {
4702 use solid_pod_rs_forge::{ForgeConfig, ForgeRequest, ForgeService};
4703
4704 let Some(plugin_dir) = forge_plugin_dir(&state) else {
4705 return HttpResponse::NotImplemented().json(serde_json::json!({
4706 "error": "forge requires fs-backend storage",
4707 "reason": "data_root_not_configured"
4708 }));
4709 };
4710
4711 let loopback: Arc<dyn solid_pod_rs_forge::LoopbackFetch> = Arc::new(ServerLoopback {
4716 client: reqwest::Client::new(),
4717 });
4718 let service = match ForgeService::new(ForgeConfig::default(), plugin_dir) {
4719 Ok(s) => s.with_loopback(loopback),
4720 Err(e) => {
4721 return HttpResponse::InternalServerError()
4722 .json(serde_json::json!({"error": e.to_string()}));
4723 }
4724 };
4725
4726 let path = req.uri().path().to_string();
4727 let query = req.uri().query().unwrap_or("").to_string();
4728 let host_url = {
4729 let conn = req.connection_info();
4730 Some(format!("{}://{}", conn.scheme(), conn.host()))
4731 };
4732 let headers: Vec<(String, String)> = req
4733 .headers()
4734 .iter()
4735 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
4736 .collect();
4737
4738 let forge_req = ForgeRequest {
4739 method: req.method().as_str().to_string(),
4740 path,
4741 query,
4742 headers,
4743 raw_body: body,
4744 host_url,
4745 };
4746
4747 let agent = service.resolve_agent(&forge_req).await;
4752
4753 match service.handle(forge_req, agent).await {
4754 Ok(resp) => {
4755 let mut builder = HttpResponse::build(
4756 StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
4757 );
4758 for (k, v) in &resp.headers {
4759 builder.insert_header((k.as_str(), v.as_str()));
4760 }
4761 builder.body(resp.body)
4762 }
4763 Err(e) => {
4764 let status = e.status_code();
4765 HttpResponse::build(
4766 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
4767 )
4768 .json(serde_json::json!({"error": e.to_string()}))
4769 }
4770 }
4771}
4772
4773pub fn build_app(
4785 state: AppState,
4786) -> App<
4787 impl actix_web::dev::ServiceFactory<
4788 ServiceRequest,
4789 Config = (),
4790 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
4791 Error = ActixError,
4792 InitError = (),
4793 >,
4794> {
4795 let body_cap = state.body_cap;
4796 let dotfiles = state.dotfiles.clone();
4797 let allowed_origins = Arc::new(state.allowed_origins.clone());
4798
4799 let mut app = App::new()
4800 .app_data(web::Data::new(state.clone()))
4801 .app_data(web::PayloadConfig::new(body_cap))
4802 .wrap(ErrorLoggingMiddleware)
4807 .wrap(CorsHeaders { allowed_origins })
4808 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
4812 .wrap(PathTraversalGuard)
4813 .wrap(DotfileGuard::new(dotfiles));
4814
4815 app = app
4821 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
4822 .route(
4823 "/.well-known/webfinger",
4824 web::get().to(handle_well_known_webfinger),
4825 )
4826 .route(
4827 "/.well-known/nodeinfo",
4828 web::get().to(handle_well_known_nodeinfo),
4829 )
4830 .route(
4831 "/.well-known/nodeinfo/2.1",
4832 web::get().to(handle_well_known_nodeinfo_2_1),
4833 );
4834
4835 #[cfg(feature = "did-nostr")]
4836 {
4837 app = app.route(
4838 "/.well-known/did/nostr/{pubkey}.json",
4839 web::get().to(handle_well_known_did_nostr),
4840 );
4841 }
4842
4843 #[cfg(feature = "nip05-endpoint")]
4848 {
4849 app = app.route(
4850 "/.well-known/nostr.json",
4851 web::get().to(handle_well_known_nip05),
4852 );
4853 }
4854
4855 #[cfg(feature = "export-jsonld")]
4860 {
4861 app = app.route("/api/exports/all", web::get().to(handle_export_all));
4862 }
4863
4864 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
4866
4867 app = app.route("/pay/.info", web::get().to(handle_pay_info));
4869
4870 app = app.configure(handlers::pay::register);
4875
4876 app = app.route("/proxy", web::get().to(handle_proxy));
4878
4879 if state.mcp_enabled {
4883 app = app.route("/mcp", web::post().to(mcp::handle_mcp)).route(
4884 "/mcp",
4885 web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options),
4886 );
4887 }
4888
4889 app = app.route(
4892 "/_admin/provision/{pubkey}",
4893 web::post().to(handle_admin_provision),
4894 );
4895
4896 app = app
4898 .route("/.pods", web::post().to(handle_create_pod))
4899 .route("/api/accounts/new", web::post().to(handle_create_account))
4900 .route("/pods/check/{name}", web::get().to(handle_pod_check))
4901 .route("/login/password", web::post().to(handle_login_password))
4902 .route(
4903 "/account/password/reset",
4904 web::post().to(handle_password_reset_request),
4905 )
4906 .route(
4907 "/account/password/change",
4908 web::post().to(handle_password_change),
4909 );
4910
4911 #[cfg(feature = "forge")]
4916 {
4917 app = app
4918 .route("/forge", web::route().to(handle_forge))
4919 .route("/forge/{tail:.*}", web::route().to(handle_forge));
4920 }
4921
4922 app = app
4927 .route(
4928 "/{tail:.*}/.git",
4930 web::route().to(|| async {
4931 HttpResponse::Forbidden()
4932 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4933 }),
4934 )
4935 .route(
4936 "/{tail:.*}/.git/{rest:.*}",
4937 web::route().to(|| async {
4938 HttpResponse::Forbidden()
4939 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4940 }),
4941 );
4942
4943 app = app.route(
4947 "/pods/{pk}/_git/{tail:.*}",
4948 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
4949 );
4950
4951 #[cfg(feature = "git")]
4952 {
4953 app = app
4960 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
4961 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
4962 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
4963
4964 app = app
4967 .route(
4968 "/pods/{pubkey}/_git/status",
4969 web::get().to(handle_git_status),
4970 )
4971 .route("/pods/{pubkey}/_git/log", web::get().to(handle_git_log))
4972 .route("/pods/{pubkey}/_git/diff", web::get().to(handle_git_diff))
4973 .route(
4974 "/pods/{pubkey}/_git/stage",
4975 web::post().to(handle_git_stage),
4976 )
4977 .route(
4978 "/pods/{pubkey}/_git/unstage",
4979 web::post().to(handle_git_unstage),
4980 )
4981 .route(
4982 "/pods/{pubkey}/_git/commit",
4983 web::post().to(handle_git_commit),
4984 )
4985 .route(
4986 "/pods/{pubkey}/_git/branches",
4987 web::get().to(handle_git_branches),
4988 )
4989 .route(
4990 "/pods/{pubkey}/_git/branch",
4991 web::post().to(handle_git_create_branch),
4992 )
4993 .route(
4994 "/pods/{pubkey}/_git/discard",
4995 web::post().to(handle_git_discard),
4996 );
4997
4998 app = app.configure(handlers::prov::register);
5005 }
5006 #[cfg(not(feature = "git"))]
5007 {
5008 let git_501 = || async {
5012 HttpResponse::NotImplemented()
5013 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
5014 };
5015 app = app
5016 .route("/{tail:.*}/info/refs", web::get().to(git_501))
5017 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
5018 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
5019 }
5020
5021 app.route("/{tail:.*}/", web::post().to(handle_post))
5024 .route("/{tail:.*}/", web::put().to(handle_put))
5025 .route("/{tail:.*}", web::get().to(handle_get))
5026 .route("/{tail:.*}", web::head().to(handle_get))
5027 .route("/{tail:.*}", web::put().to(handle_put))
5028 .route("/{tail:.*}", web::patch().to(handle_patch))
5029 .route("/{tail:.*}", web::delete().to(handle_delete))
5030 .route(
5031 "/{tail:.*}",
5032 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
5033 )
5034 .route(
5035 "/{tail:.*}",
5036 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
5037 )
5038}
5039
5040#[cfg(test)]
5045mod payment_gating_tests {
5046 use super::*;
5047 use solid_pod_rs::payments::WebLedger;
5048 use solid_pod_rs::storage::memory::MemoryBackend;
5049
5050 macro_rules! principal {
5054 () => {
5055 "did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
5056 };
5057 }
5058
5059 const PRINCIPAL: &str = principal!();
5060
5061 const OTHER_PRINCIPAL: &str =
5063 "did:nostr:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
5064
5065 const PAID_WRITE_ACL: &str = concat!(
5068 r#"
5069@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5070
5071<#paid-write> a acl:Authorization ;
5072 acl:agent <"#,
5073 principal!(),
5074 r#"> ;
5075 acl:accessTo </premium/inbox> ;
5076 acl:mode acl:Write ;
5077 acl:condition [
5078 a acl:PaymentCondition ;
5079 acl:costSats 100
5080 ] .
5081"#
5082 );
5083
5084 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
5085 let mut ledger = WebLedger::new("Test Pod Credits");
5086 if sats > 0 {
5087 ledger
5088 .credit_by_outpoint(did, "satoshi", &"ab".repeat(32), 0, sats)
5089 .unwrap();
5090 }
5091 let body = serde_json::to_vec(&ledger).unwrap();
5092 storage
5093 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
5094 .await
5095 .unwrap();
5096 }
5097
5098 async fn seed_acl(storage: &dyn Storage) {
5099 storage
5100 .put(
5101 "/premium/inbox.acl",
5102 Bytes::from(PAID_WRITE_ACL),
5103 "text/turtle",
5104 )
5105 .await
5106 .unwrap();
5107 }
5108
5109 #[actix_web::test]
5111 async fn resolve_balance_reads_ledger_entry() {
5112 let storage = MemoryBackend::new();
5113 seed_ledger(&storage, PRINCIPAL, 250).await;
5114 assert_eq!(
5115 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5116 Some(250)
5117 );
5118 }
5119
5120 #[actix_web::test]
5122 async fn resolve_balance_zero_when_no_entry() {
5123 let storage = MemoryBackend::new();
5124 seed_ledger(&storage, OTHER_PRINCIPAL, 500).await;
5125 assert_eq!(
5126 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5127 Some(0)
5128 );
5129 }
5130
5131 #[test]
5137 fn fixtures_use_teller_accounts_and_grant_the_principal() {
5138 use solid_pod_rs::payments::account_of;
5139 for did in [PRINCIPAL, OTHER_PRINCIPAL] {
5140 assert_eq!(account_of(did).unwrap(), did, "{did} must be canonical");
5141 }
5142 assert_ne!(PRINCIPAL, OTHER_PRINCIPAL);
5143 assert!(account_of("did:nostr:bob").is_err());
5144 let agent = format!("acl:agent <{PRINCIPAL}>");
5145 for (name, acl) in [
5146 ("PAID_WRITE_ACL", PAID_WRITE_ACL),
5147 ("WRITE_NOT_CONTROL_ACL", WRITE_NOT_CONTROL_ACL),
5148 ] {
5149 assert!(acl.contains(&agent), "{name} must grant PRINCIPAL");
5150 }
5151 assert!(!WRITE_NOT_CONTROL_ACL.contains("did:nostr:alice"));
5152 }
5153
5154 #[actix_web::test]
5157 async fn uncharged_read_skips_pay_per_read_resource() {
5158 let storage = Arc::new(MemoryBackend::new());
5159 let acl = format!(
5160 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
5161 <#paid> a acl:Authorization ; acl:agent <{PRINCIPAL}> ;\n\
5162 acl:accessTo </premium/doc> ; acl:mode acl:Read ;\n\
5163 acl:condition [ a acl:PaymentCondition ; acl:costSats 100 ] .\n"
5164 );
5165 storage
5166 .put("/premium/doc.acl", Bytes::from(acl), "text/turtle")
5167 .await
5168 .unwrap();
5169 seed_ledger(storage.as_ref(), PRINCIPAL, 500).await;
5170 let state = AppState::new(storage.clone());
5171
5172 let skipped =
5173 enforce_read_inner(&state, "/premium/doc", Some(PRINCIPAL), None, false).await;
5174 assert!(skipped.is_err(), "glob must not serve a pay-per-read child");
5175 assert_eq!(
5176 resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5177 Some(500)
5178 );
5179
5180 enforce_read_ctx(&state, "/premium/doc", Some(PRINCIPAL), None)
5181 .await
5182 .expect("a direct read pays and is served");
5183 assert_eq!(
5184 resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5185 Some(400)
5186 );
5187 }
5188
5189 #[actix_web::test]
5191 async fn resolve_balance_none_when_anonymous() {
5192 let storage = MemoryBackend::new();
5193 seed_ledger(&storage, PRINCIPAL, 1_000).await;
5194 assert_eq!(resolve_balance_sats(&storage, None).await, None);
5195 }
5196
5197 #[actix_web::test]
5199 async fn paid_write_denied_below_balance() {
5200 let storage = Arc::new(MemoryBackend::new());
5201 seed_acl(storage.as_ref()).await;
5202 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
5204
5205 let result =
5206 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5207 assert!(
5208 result.is_err(),
5209 "balance 50 < cost 100 must be denied — sat-gating loop closed"
5210 );
5211 }
5212
5213 #[actix_web::test]
5215 async fn paid_write_allowed_at_balance() {
5216 let storage = Arc::new(MemoryBackend::new());
5217 seed_acl(storage.as_ref()).await;
5218 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
5220
5221 let result =
5222 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5223 assert!(
5224 result.is_ok(),
5225 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
5226 );
5227 }
5228
5229 #[actix_web::test]
5231 async fn paid_write_allowed_above_balance() {
5232 let storage = Arc::new(MemoryBackend::new());
5233 seed_acl(storage.as_ref()).await;
5234 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
5235 let state = AppState::new(storage);
5236
5237 let result =
5238 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5239 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
5240 }
5241
5242 #[actix_web::test]
5246 async fn paid_write_anonymous_denied() {
5247 let storage = Arc::new(MemoryBackend::new());
5248 seed_acl(storage.as_ref()).await;
5249 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
5250 let state = AppState::new(storage);
5251
5252 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
5253 assert!(
5254 result.is_err(),
5255 "anonymous caller has no ledger principal — PaymentCondition fails closed"
5256 );
5257 }
5258
5259 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
5266 let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
5267 let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
5268 ledger.get_balance(did)
5269 }
5270
5271 #[actix_web::test]
5273 async fn paid_write_debits_ledger() {
5274 let storage = Arc::new(MemoryBackend::new());
5275 seed_acl(storage.as_ref()).await;
5276 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
5278
5279 let result =
5280 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5281 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
5282 assert_eq!(
5283 read_balance(storage.as_ref(), PRINCIPAL).await,
5284 150,
5285 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
5286 );
5287 }
5288
5289 #[actix_web::test]
5292 async fn paid_write_debits_each_grant() {
5293 let storage = Arc::new(MemoryBackend::new());
5294 seed_acl(storage.as_ref()).await;
5295 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
5297
5298 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5299 .await
5300 .unwrap();
5301 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5302 .await
5303 .unwrap();
5304 assert_eq!(
5305 read_balance(storage.as_ref(), PRINCIPAL).await,
5306 50,
5307 "250 - 2*100: each granted request debits, no unmetered re-use"
5308 );
5309
5310 let third =
5312 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5313 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
5314 assert_eq!(
5315 read_balance(storage.as_ref(), PRINCIPAL).await,
5316 50,
5317 "a denied request must not debit"
5318 );
5319 }
5320
5321 #[actix_web::test]
5323 async fn paid_read_debits_ledger() {
5324 const PAID_READ_ACL: &str = r#"
5325@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5326
5327<#paid-read> a acl:Authorization ;
5328 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5329 acl:accessTo </premium/feed> ;
5330 acl:mode acl:Read ;
5331 acl:condition [
5332 a acl:PaymentCondition ;
5333 acl:costSats 30
5334 ] .
5335"#;
5336 let storage = Arc::new(MemoryBackend::new());
5337 storage
5338 .put(
5339 "/premium/feed.acl",
5340 Bytes::from(PAID_READ_ACL),
5341 "text/turtle",
5342 )
5343 .await
5344 .unwrap();
5345 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5346 let state = AppState::new(storage.clone());
5347
5348 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
5349 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
5350 assert_eq!(
5351 read_balance(storage.as_ref(), PRINCIPAL).await,
5352 70,
5353 "100 - 30 cost: a granted paid read must debit"
5354 );
5355 }
5356
5357 #[actix_web::test]
5360 async fn free_read_does_not_debit() {
5361 let storage = Arc::new(MemoryBackend::new());
5362 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5364 let state = AppState::new(storage.clone());
5365
5366 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
5367 .await
5368 .unwrap();
5369 assert_eq!(
5370 read_balance(storage.as_ref(), PRINCIPAL).await,
5371 100,
5372 "a grant with no PaymentCondition must not debit"
5373 );
5374 }
5375
5376 const ALICE_ONLY_READ_ACL: &str = r#"
5382@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5383
5384<#alice> a acl:Authorization ;
5385 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5386 acl:accessTo </private/secret> ;
5387 acl:default </private/> ;
5388 acl:mode acl:Read, acl:Write, acl:Control .
5389"#;
5390
5391 async fn seed_private_read_acl(storage: &dyn Storage) {
5392 storage
5397 .put(
5398 "/private.acl",
5399 Bytes::from(ALICE_ONLY_READ_ACL),
5400 "text/turtle",
5401 )
5402 .await
5403 .unwrap();
5404 }
5405
5406 #[actix_web::test]
5410 async fn enforce_read_grants_owner() {
5411 let storage = Arc::new(MemoryBackend::new());
5412 seed_private_read_acl(storage.as_ref()).await;
5413 let state = AppState::new(storage);
5414 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
5415 assert!(result.is_ok(), "owner alice must be granted Read");
5416 }
5417
5418 #[actix_web::test]
5421 async fn enforce_read_denies_other_principal() {
5422 let storage = Arc::new(MemoryBackend::new());
5423 seed_private_read_acl(storage.as_ref()).await;
5424 let state = AppState::new(storage);
5425 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
5426 assert!(
5427 result.is_err(),
5428 "bob has no Read grant — private resource must not be world-readable"
5429 );
5430 }
5431
5432 #[actix_web::test]
5435 async fn enforce_read_denies_anonymous() {
5436 let storage = Arc::new(MemoryBackend::new());
5437 seed_private_read_acl(storage.as_ref()).await;
5438 let state = AppState::new(storage);
5439 let result = enforce_read(&state, "/private/secret", None).await;
5440 assert!(result.is_err(), "anonymous Read must be denied");
5441 }
5442
5443 const WRITE_NOT_CONTROL_ACL: &str = concat!(
5451 r#"
5452@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5453
5454<#owner> a acl:Authorization ;
5455 acl:agent <"#,
5456 principal!(),
5457 r#"> ;
5458 acl:accessTo </shared/doc> ;
5459 acl:default </shared/> ;
5460 acl:mode acl:Read, acl:Write, acl:Control .
5461
5462<#writer> a acl:Authorization ;
5463 acl:agent <did:nostr:writer> ;
5464 acl:accessTo </shared/doc> ;
5465 acl:default </shared/> ;
5466 acl:mode acl:Read, acl:Write .
5467"#
5468 );
5469
5470 async fn seed_shared_acl(storage: &dyn Storage) {
5471 storage
5476 .put(
5477 "/shared.acl",
5478 Bytes::from(WRITE_NOT_CONTROL_ACL),
5479 "text/turtle",
5480 )
5481 .await
5482 .unwrap();
5483 }
5484
5485 #[actix_web::test]
5489 async fn acl_put_denied_for_writer_without_control() {
5490 let storage = Arc::new(MemoryBackend::new());
5491 seed_shared_acl(storage.as_ref()).await;
5492 let state = AppState::new(storage);
5493 let result = enforce_write(
5497 &state,
5498 "/shared/.acl",
5499 AccessMode::Write,
5500 Some("did:nostr:writer"),
5501 )
5502 .await;
5503 assert!(
5504 result.is_err(),
5505 "writer lacks Control — must not be able to PUT /shared/.acl"
5506 );
5507 }
5508
5509 #[actix_web::test]
5511 async fn acl_put_allowed_for_control_holder() {
5512 let storage = Arc::new(MemoryBackend::new());
5513 seed_shared_acl(storage.as_ref()).await;
5514 let state = AppState::new(storage);
5515 let result =
5516 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
5517 assert!(
5518 result.is_ok(),
5519 "the owner holds Control — must be allowed to PUT /shared/.acl"
5520 );
5521 }
5522
5523 #[actix_web::test]
5525 async fn meta_put_denied_for_writer_without_control() {
5526 let storage = Arc::new(MemoryBackend::new());
5527 seed_shared_acl(storage.as_ref()).await;
5528 let state = AppState::new(storage);
5529 let result = enforce_write(
5530 &state,
5531 "/shared/doc.meta",
5532 AccessMode::Write,
5533 Some("did:nostr:writer"),
5534 )
5535 .await;
5536 assert!(
5537 result.is_err(),
5538 "writer lacks Control — must not be able to PUT a .meta sidecar"
5539 );
5540 }
5541
5542 #[test]
5545 fn canonical_pod_path_vectors() {
5546 let ok = [
5547 ("a/b", "/a/b"),
5548 ("", "/"),
5549 ("/", "/"),
5550 ("//a//b/", "/a/b/"),
5551 ("/%2ewell-known/x", "/.well-known/x"),
5552 ("/a%2fb", "/a/b"),
5553 ("/a%2520b", "/a%20b"),
5554 ];
5555 for (raw, want) in ok {
5556 assert_eq!(canonical_pod_path(raw).as_deref(), Some(want), "{raw}");
5557 }
5558 for bad in [
5559 "/a/./b",
5560 "/a/..",
5561 "../a",
5562 "/a\\b",
5563 "/a%00b",
5564 "/a%ffb",
5565 "/%2e%2e/x",
5566 ] {
5567 assert_eq!(canonical_pod_path(bad), None, "{bad}");
5568 }
5569 }
5570
5571 #[test]
5572 fn guarded_acl_target_vectors() {
5573 use AccessMode::{Control, Read, Write};
5574 let c = |p: &str, m| guarded_acl_target(p, m);
5575 assert_eq!(
5576 c("/.well-known/token/x.json", Read),
5577 ("/.well-known/token/x.json".into(), Control)
5578 );
5579 assert_eq!(
5580 c("/.well-known/gitmark/p.json", Write),
5581 ("/.well-known/gitmark/p.json".into(), Control)
5582 );
5583 assert_eq!(c("/.well-known/%74oken/x.json", Read).1, Control);
5584 assert_eq!(
5585 c("/.well-known/token/x.json.acl", Read),
5586 ("/.well-known/token/x.json".into(), Control)
5587 );
5588 assert_eq!(
5589 c("/.well-known/gitmark/p/blocktrails.json", Read),
5590 ("/.well-known/gitmark/p/blocktrails.json".into(), Read)
5591 );
5592 assert_eq!(
5593 c("/.well-known/webledgers/webledgers.json", Write),
5594 ("/.well-known/webledgers/webledgers.json".into(), Control)
5595 );
5596 assert_eq!(c("/.well-known/%57ebledgers/state.json", Read).1, Control);
5597 assert_eq!(c("/.well-known/webledgersx/y", Read).1, Read);
5598 assert_eq!(c("/pub/x", Read), ("/pub/x".into(), Read));
5599 }
5600
5601 #[test]
5603 fn protected_resource_for_acl_strips_suffixes() {
5604 assert_eq!(
5605 protected_resource_for_acl("/victim/.acl").as_deref(),
5606 Some("/victim/")
5607 );
5608 assert_eq!(
5609 protected_resource_for_acl("/a/b.acl").as_deref(),
5610 Some("/a/b")
5611 );
5612 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
5613 assert_eq!(
5614 protected_resource_for_acl("/a/b.meta").as_deref(),
5615 Some("/a/b")
5616 );
5617 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
5618 }
5619}