1#![doc = include_str!("../README.md")]
107#![deny(unsafe_code)]
108#![warn(rust_2018_idioms)]
109
110#[cfg(feature = "activitypub")]
111pub mod activitypub;
112pub mod cli;
114pub mod cross_identity;
115pub mod scheme;
116
117pub(crate) mod hardened_git;
118
119mod handlers;
124
125#[cfg(feature = "jev")]
126pub mod jev;
127mod mcp;
130
131pub mod mempool;
136
137pub mod trail_store;
143
144use std::collections::HashMap;
145use std::net::{IpAddr, Ipv4Addr};
146use std::path::{Path, PathBuf};
147use std::sync::{Arc, Mutex};
148use std::time::{Duration, Instant};
149
150use actix_web::body::{BoxBody, EitherBody};
151use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
152use actix_web::http::{header, StatusCode};
153use actix_web::middleware::{NormalizePath, TrailingSlash};
154use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
155use bytes::Bytes;
156use futures_util::future::{ready, LocalBoxFuture, Ready};
157use futures_util::StreamExt;
158use percent_encoding::percent_decode_str;
159use serde::Deserialize;
160use solid_pod_rs::{
161 auth::{nip98, replay::ReplayStore},
165 config::sources::parse_size,
166 interop,
167 ldp::{
168 self, cache_control_for_response, LdpContainerOps, PatchCreateOutcome, ResponseAudience,
169 },
170 mashlib::{self, MashlibConfig},
171 provenance::{ProvenanceReceipt, ProvenanceSkip},
172 security::DotfileAllowlist,
173 storage::Storage,
174 wac::{
175 self, conditions::RequestContext, effective_acl_target, parse_jsonld_acl,
176 protected_resource_for_acl, AccessMode,
177 },
178 PodError,
179};
180
181const _: () = solid_pod_rs::auth::nip98::assert_schnorr_verification_enabled();
193
194static NIP98_REPLAY: std::sync::LazyLock<solid_pod_rs::auth::replay::Nip98ReplayCache> =
201 std::sync::LazyLock::new(solid_pod_rs::auth::replay::Nip98ReplayCache::from_env);
202
203pub(crate) static PAYMENT_STATE_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
209 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
210
211const RESOURCE_WRITE_LOCK_STRIPES: usize = 256;
213
214static RESOURCE_WRITE_LOCKS: std::sync::LazyLock<Vec<tokio::sync::Mutex<()>>> =
222 std::sync::LazyLock::new(|| {
223 (0..RESOURCE_WRITE_LOCK_STRIPES)
224 .map(|_| tokio::sync::Mutex::new(()))
225 .collect()
226 });
227
228pub(crate) async fn lock_resource(path: &str) -> tokio::sync::MutexGuard<'static, ()> {
241 use std::hash::BuildHasher;
242 static STRIPE_KEY: std::sync::LazyLock<std::collections::hash_map::RandomState> =
245 std::sync::LazyLock::new(std::collections::hash_map::RandomState::new);
246 let stripe = (STRIPE_KEY.hash_one(path) as usize) % RESOURCE_WRITE_LOCK_STRIPES;
247 RESOURCE_WRITE_LOCKS[stripe].lock().await
248}
249
250const MINT_LOCK_ATTEMPTS: usize = 64;
252
253pub(crate) async fn mint_and_lock_target(
262 storage: &dyn Storage,
263 target: &str,
264) -> Result<(String, tokio::sync::MutexGuard<'static, ()>), PodError> {
265 let mut previous: Option<String> = None;
266 for _ in 0..MINT_LOCK_ATTEMPTS {
267 let candidate = mint_unique_target(storage, target).await;
268 if previous.as_deref() == Some(candidate.as_str()) {
271 break;
272 }
273 let guard = lock_resource(&candidate).await;
274 if !storage.exists(&candidate).await? {
277 return Ok((candidate, guard));
278 }
279 drop(guard);
280 previous = Some(candidate);
281 }
282 Err(PodError::Conflict(format!("no free name for {target}")))
283}
284
285async fn check_write_preconditions(
290 req: &HttpRequest,
291 state: &AppState,
292 path: &str,
293) -> Result<(), ActixError> {
294 let value = |name| req.headers().get(name).and_then(|v| v.to_str().ok());
295 let (if_match, if_none_match) = (value(header::IF_MATCH), value(header::IF_NONE_MATCH));
296 if if_match.is_none() && if_none_match.is_none() {
297 return Ok(());
298 }
299 let current = match state.storage.head(path).await {
300 Ok(meta) => Some(meta.etag),
301 Err(PodError::NotFound(_)) => None,
302 Err(e) => return Err(to_actix(e)),
303 };
304 match ldp::evaluate_preconditions(
305 req.method().as_str(),
306 current.as_deref(),
307 if_match,
308 if_none_match,
309 ) {
310 ldp::ConditionalOutcome::PreconditionFailed => Err(
311 actix_web::error::ErrorPreconditionFailed("precondition failed"),
312 ),
313 _ => Ok(()),
314 }
315}
316
317#[derive(Clone)]
323pub struct AppState {
324 pub storage: Arc<dyn Storage>,
325 pub dotfiles: Arc<DotfileAllowlist>,
326 pub body_cap: usize,
327 pub nodeinfo: NodeInfoMeta,
328 pub mashlib: MashlibConfig,
329 pub mashlib_cdn: Option<String>,
332 pub live_reload: bool,
334 pub pay_config: solid_pod_rs::payments::PayConfig,
337 pub data_root: Option<PathBuf>,
342 pub quota: Option<Arc<dyn solid_pod_rs::quota::QuotaPolicy>>,
344 pub pod_create_limiter: Arc<PodCreateLimiter>,
346 pub allowed_origins: Vec<String>,
353 pub admin_key: Option<String>,
358 pub mcp_enabled: bool,
363 pub mempool_url: Option<String>,
369 pub conneg_enabled: bool,
373 pub trust_proxy: bool,
377 pub public_base_url: Option<String>,
381 pub allowed_hosts: Vec<String>,
384 pub cross_identity: Option<Arc<dyn cross_identity::CrossIdentityResolver>>,
387 pub activitypub: Option<Arc<dyn ActorDocument>>,
390}
391
392pub trait ActorDocument: Send + Sync {
395 fn actor_json(&self, base_url: &str) -> serde_json::Value;
397}
398
399#[cfg(test)]
400mod nip98_request_binding_tests {
401 use super::*;
402 use actix_web::test::TestRequest;
403
404 const SECRET: &str = "3333333333333333333333333333333333333333333333333333333333333333";
405
406 fn now() -> u64 {
407 std::time::SystemTime::now()
408 .duration_since(std::time::UNIX_EPOCH)
409 .unwrap()
410 .as_secs()
411 }
412
413 #[actix_web::test]
414 async fn authentication_binds_the_complete_query_string() {
415 let signed_url = "http://localhost:8080/private?include=true";
416 let token = nip98::mint(signed_url, "GET", SECRET, now()).unwrap();
417 let request = TestRequest::get()
418 .uri("/private?include=true")
419 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
420 .to_http_request();
421 assert!(extract_pubkey(&request).await.is_some());
422
423 let path_only = nip98::mint("http://localhost:8080/private", "GET", SECRET, now()).unwrap();
424 let request = TestRequest::get()
425 .uri("/private?include=true")
426 .insert_header((header::AUTHORIZATION, format!("Nostr {path_only}")))
427 .to_http_request();
428 assert!(extract_pubkey(&request).await.is_none());
429 }
430
431 #[actix_web::test]
432 async fn authentication_binds_the_exact_raw_body() {
433 let expected = br#"{"value":"authorised"}"#;
434 let token = nip98::mint_with_payload(
435 "http://localhost:8080/resource",
436 "PUT",
437 Some(expected),
438 SECRET,
439 now(),
440 )
441 .unwrap();
442 let request = TestRequest::put()
443 .uri("/resource")
444 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
445 .to_http_request();
446
447 assert!(
448 extract_pubkey_with_body(&request, Some(br#"{"value":"tampered"}"#))
449 .await
450 .is_none()
451 );
452 assert!(extract_pubkey_with_body(&request, Some(expected))
453 .await
454 .is_some());
455 }
456}
457
458#[derive(Clone, Debug)]
460pub struct NodeInfoMeta {
461 pub software_name: String,
462 pub software_version: String,
463 pub open_registrations: bool,
464 pub total_users: u64,
465 pub base_url: String,
466}
467
468impl Default for NodeInfoMeta {
469 fn default() -> Self {
470 Self {
471 software_name: "solid-pod-rs-server".to_string(),
472 software_version: env!("CARGO_PKG_VERSION").to_string(),
473 open_registrations: false,
474 total_users: 0,
475 base_url: "http://localhost".to_string(),
476 }
477 }
478}
479
480pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
483
484pub fn body_cap_from_env() -> usize {
489 match std::env::var("JSS_MAX_REQUEST_BODY").or_else(|_| std::env::var("JSS_BODY_LIMIT")) {
490 Ok(v) => parse_size(&v)
491 .map(|u| u as usize)
492 .unwrap_or(DEFAULT_BODY_CAP),
493 Err(_) => DEFAULT_BODY_CAP,
494 }
495}
496
497impl AppState {
498 pub fn public_origin(&self) -> Option<String> {
500 self.public_base_url
501 .as_deref()
502 .and_then(scheme::normalise_origin)
503 }
504
505 pub fn new(storage: Arc<dyn Storage>) -> Self {
508 Self {
509 storage,
510 dotfiles: Arc::new(DotfileAllowlist::from_env()),
511 body_cap: body_cap_from_env(),
512 nodeinfo: NodeInfoMeta::default(),
513 mashlib: MashlibConfig::default(),
514 mashlib_cdn: None,
515 live_reload: false,
516 pay_config: solid_pod_rs::payments::PayConfig::default(),
517 data_root: None,
518 quota: None,
519 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
520 allowed_origins: Vec::new(),
521 admin_key: None,
522 mcp_enabled: false,
523 mempool_url: None,
524 conneg_enabled: false,
525 trust_proxy: false,
526 public_base_url: None,
527 allowed_hosts: Vec::new(),
528 cross_identity: None,
529 activitypub: None,
530 }
531 }
532}
533
534const 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>";
535
536fn inject_live_reload(html: impl AsRef<[u8]>, enabled: bool) -> Vec<u8> {
537 let bytes = html.as_ref();
538 if !enabled {
539 return bytes.to_vec();
540 }
541 let html = String::from_utf8_lossy(bytes);
542 if let Some(index) = html.find("</body>") {
543 let mut out = String::with_capacity(html.len() + LIVE_RELOAD_SCRIPT.len());
544 out.push_str(&html[..index]);
545 out.push_str(LIVE_RELOAD_SCRIPT);
546 out.push_str(&html[index..]);
547 out.into_bytes()
548 } else {
549 format!("{html}{LIVE_RELOAD_SCRIPT}").into_bytes()
550 }
551}
552
553#[cfg(test)]
554mod live_reload_tests {
555 use super::*;
556
557 #[test]
558 fn injects_before_body_close_and_is_disabled_by_default() {
559 let html = b"<html><body>ok</body></html>";
560 assert_eq!(inject_live_reload(html, false), html);
561 let injected = String::from_utf8(inject_live_reload(html, true)).unwrap();
562 assert!(injected.contains("/.notifications"));
563 assert!(injected.find("<script>").unwrap() < injected.find("</body>").unwrap());
564 }
565}
566
567#[cfg(all(test, feature = "dev-bearer"))]
568mod dev_bearer_tests {
569 use super::*;
570 use base64::Engine as _;
571 use hmac::{Hmac, Mac};
572 use sha2::Sha256;
573 use std::sync::Mutex;
574
575 static ENV: Mutex<()> = Mutex::new(());
576
577 fn now() -> u64 {
578 std::time::SystemTime::now()
579 .duration_since(std::time::UNIX_EPOCH)
580 .unwrap()
581 .as_secs()
582 }
583
584 fn token_with(iat: u64, exp: u64, aud: Option<&str>) -> String {
585 let mut claims = serde_json::json!({"webId":"https://alice.example/profile/card#me","iat":iat,"exp":exp});
586 if let Some(a) = aud {
587 claims["aud"] = serde_json::json!(a);
588 }
589 let data = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string());
590 let mut mac = Hmac::<Sha256>::new_from_slice(b"01234567890123456789012345678901").unwrap();
591 mac.update(data.as_bytes());
592 let sig =
593 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
594 format!("{data}.{sig}")
595 }
596
597 fn token(exp: u64) -> String {
598 token_with(now().saturating_sub(10), exp, Some(DEV_BEARER_AUDIENCE))
599 }
600
601 #[test]
602 fn simple_bearer_verifies_signature_and_expiry() {
603 let _guard = ENV.lock().unwrap();
604 std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
605 assert_eq!(
606 verify_dev_bearer(&token(now() + 3600)).as_deref(),
607 Some("https://alice.example/profile/card#me")
608 );
609 assert!(verify_dev_bearer(&(token(now() + 3600) + "x")).is_none());
610 assert!(verify_dev_bearer(&token(2)).is_none());
611 std::env::remove_var("TOKEN_SECRET");
612 }
613
614 #[test]
615 fn dev_bearer_enforces_audience_and_max_lifetime() {
616 let _guard = ENV.lock().unwrap();
617 std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
618 let n = now();
619 assert!(verify_dev_bearer(&token_with(n, n + 60, None)).is_none());
621 assert!(verify_dev_bearer(&token_with(n, n + 60, Some("solid"))).is_none());
622 assert!(verify_dev_bearer(&token_with(
624 n,
625 n + DEV_BEARER_MAX_LIFETIME_SECS,
626 Some(DEV_BEARER_AUDIENCE)
627 ))
628 .is_some());
629 assert!(verify_dev_bearer(&token_with(
630 n,
631 n + DEV_BEARER_MAX_LIFETIME_SECS + 1,
632 Some(DEV_BEARER_AUDIENCE)
633 ))
634 .is_none());
635 assert!(verify_dev_bearer(&token_with(1, n + 3600, Some(DEV_BEARER_AUDIENCE))).is_none());
637 assert!(
638 verify_dev_bearer(&token_with(1, u64::MAX / 2, Some(DEV_BEARER_AUDIENCE))).is_none()
639 );
640 std::env::remove_var("TOKEN_SECRET");
641 }
642}
643
644#[derive(Debug)]
646pub struct PodCreateLimiter {
647 hits: Mutex<HashMap<IpAddr, Instant>>,
648 window: Duration,
649}
650
651impl Default for PodCreateLimiter {
652 fn default() -> Self {
653 Self {
654 hits: Mutex::new(HashMap::new()),
655 window: Duration::from_secs(24 * 60 * 60),
656 }
657 }
658}
659
660impl PodCreateLimiter {
661 fn check(&self, ip: IpAddr) -> Result<(), u64> {
662 let now = Instant::now();
663 let mut hits = self.hits.lock().unwrap();
664 if let Some(last) = hits.get(&ip).copied() {
665 let elapsed = now.saturating_duration_since(last);
666 if elapsed < self.window {
667 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
668 }
669 }
670 hits.insert(ip, now);
671 Ok(())
672 }
673}
674
675pub(crate) fn to_actix(e: PodError) -> ActixError {
680 match e {
681 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
682 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
683 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
684 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
685 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
686 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
687 PodError::Conflict(_) => actix_web::error::ErrorConflict(e.to_string()),
688 PodError::PayloadTooLarge(_) => actix_web::error::ErrorPayloadTooLarge(e.to_string()),
689 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
690 }
691}
692
693pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
705 extract_pubkey_with_body(req, None).await
706}
707
708pub(crate) async fn extract_pubkey_with_body(
712 req: &HttpRequest,
713 body: Option<&[u8]>,
714) -> Option<String> {
715 let header_val = req
716 .headers()
717 .get(header::AUTHORIZATION)
718 .and_then(|v| v.to_str().ok())?;
719 if let Some(_token) = header_val.strip_prefix("Bearer ") {
720 #[cfg(feature = "dev-bearer")]
723 return verify_dev_bearer(_token);
724 #[cfg(not(feature = "dev-bearer"))]
725 return None;
726 }
727 let url = scheme::request_url(req);
740 let now = std::time::SystemTime::now()
741 .duration_since(std::time::UNIX_EPOCH)
742 .map(|d| d.as_secs())
743 .unwrap_or(0);
744 let verified = nip98::verify_at(header_val, &url, req.method().as_str(), body, now).ok()?;
745
746 if NIP98_REPLAY
750 .check_and_record(&verified.event_id)
751 .await
752 .is_err()
753 {
754 tracing::warn!(
755 pubkey = %verified.pubkey,
756 method = %req.method(),
757 "NIP-98 replay rejected: token id already used within window"
758 );
759 return None;
760 }
761
762 Some(verified.pubkey)
763}
764
765pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
766 pubkey.map(|pk| {
767 if pk.starts_with("https://") || pk.starts_with("http://") {
768 pk.clone()
769 } else {
770 format!("did:nostr:{pk}")
771 }
772 })
773}
774
775#[cfg(feature = "dev-bearer")]
777pub const DEV_BEARER_AUDIENCE: &str = "solid-pod-rs-dev-bearer";
778
779#[cfg(feature = "dev-bearer")]
782pub const DEV_BEARER_MAX_LIFETIME_SECS: u64 = 24 * 60 * 60;
783
784#[cfg(feature = "dev-bearer")]
792fn verify_dev_bearer(token: &str) -> Option<String> {
793 use base64::Engine as _;
794 use hmac::{Hmac, Mac};
795 use sha2::Sha256;
796
797 let secret = std::env::var("TOKEN_SECRET").ok()?;
798 if secret.len() < 32 {
799 tracing::warn!("TOKEN_SECRET shorter than 32 bytes; refusing development bearer token");
800 return None;
801 }
802 let (data, signature) = token.split_once('.')?;
803 if signature.contains('.') {
804 return None;
805 }
806 let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
807 .decode(signature)
808 .ok()?;
809 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
810 mac.update(data.as_bytes());
811 mac.verify_slice(&signature).ok()?;
812
813 #[derive(serde::Deserialize)]
814 #[serde(rename_all = "camelCase")]
815 struct Claims {
816 web_id: String,
817 iat: u64,
818 exp: u64,
819 #[serde(default)]
820 aud: Option<String>,
821 }
822 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
823 .decode(data)
824 .ok()?;
825 let claims: Claims = serde_json::from_slice(&payload).ok()?;
826 let now = std::time::SystemTime::now()
827 .duration_since(std::time::UNIX_EPOCH)
828 .ok()?
829 .as_secs();
830 if claims.aud.as_deref() != Some(DEV_BEARER_AUDIENCE) {
831 return None;
832 }
833 if claims.exp < now || claims.iat > now.saturating_add(60) || claims.exp <= claims.iat {
834 return None;
835 }
836 if claims.exp - claims.iat > DEV_BEARER_MAX_LIFETIME_SECS
837 || claims.exp - now > DEV_BEARER_MAX_LIFETIME_SECS
838 {
839 return None;
840 }
841 let url = url::Url::parse(&claims.web_id).ok()?;
842 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
843 return None;
844 }
845 Some(claims.web_id)
846}
847
848fn req_origin(req: &HttpRequest) -> Option<&str> {
857 req.headers()
858 .get(header::ORIGIN)
859 .and_then(|v| v.to_str().ok())
860}
861
862pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
866
867async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
884 let did = agent_uri?;
885 use solid_pod_rs::payments::PaymentStore;
886 let ledger = handlers::pay::StoragePaymentStore::new(storage)
887 .read_ledger()
888 .await
889 .ok()?;
890 Some(ledger.get_balance(did))
891}
892
893fn accept_includes_html(accept: &str) -> bool {
901 accept.split(',').any(|entry| {
902 let mime = entry.split(';').next().unwrap_or("").trim();
903 mime.eq_ignore_ascii_case("text/html")
904 })
905}
906
907pub(crate) fn guarded_acl_target(path: &str, mode: AccessMode) -> (String, AccessMode) {
926 let (resource, mode) = effective_acl_target(path, mode);
927 match canonical_pod_path(&resource) {
931 Some(canon)
932 if !trail_store::is_secret_trail_path(&canon)
933 && !trail_store::is_prov_state_path(&canon)
934 && !trail_store::is_server_state_path(&canon) =>
935 {
936 (resource, mode)
937 }
938 _ => (resource, AccessMode::Control),
939 }
940}
941
942pub(crate) fn canonical_pod_path(raw: &str) -> Option<String> {
950 let decoded = percent_decode_str(raw).decode_utf8().ok()?;
951 if decoded.contains(['\\', '\0']) {
952 return None;
953 }
954 let mut out = String::with_capacity(decoded.len() + 1);
955 for seg in decoded.split('/') {
956 match seg {
957 "" => {}
958 "." | ".." => return None,
959 _ => {
960 out.push('/');
961 out.push_str(seg);
962 }
963 }
964 }
965 if out.is_empty() || decoded.ends_with('/') {
966 out.push('/');
967 }
968 Some(out)
969}
970
971async fn proposed_acl_keeps_caller_control(
975 state: &AppState,
976 req: &HttpRequest,
977 path: &str,
978 body: &[u8],
979 content_type: &str,
980 caller: Option<&str>,
981) -> bool {
982 if !ldp::is_acl_path(path) {
983 return true;
984 }
985 let Some(resource) = protected_resource_for_acl(path) else {
986 return false;
987 };
988 let wac::PolicyStep::Settled(wac::PolicyOutcome::Found(doc)) =
989 wac::classify_policy_read(path, wac::PolicyRead::Present { body, content_type }, false)
990 else {
991 return false;
992 };
993 if wac::validate_acl_document(&doc).is_err() {
994 return false;
995 }
996 if parse_jsonld_acl(body).is_err() {
1001 use solid_pod_rs::rdf_input::{parse_rdf_body, RdfInputSyntax};
1002 let syntax =
1003 RdfInputSyntax::from_content_type(content_type).unwrap_or(RdfInputSyntax::Turtle);
1004 let base = format!("{}{}", scheme::request_origin(req), path);
1005 if parse_rdf_body(syntax, body, &base).is_err() {
1006 return false;
1007 }
1008 }
1009 let balance = resolve_balance_sats(&*state.storage, caller).await;
1010 let linked = cross_identity::linked_identities(state.cross_identity.as_ref(), caller).await;
1011 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
1012 let groups = wac::StaticGroupMembership::default();
1013 grant_for_identities(
1016 Some(&doc),
1017 caller,
1018 &linked,
1019 balance,
1020 &resource,
1021 AccessMode::Control,
1022 None,
1023 &groups,
1024 ®istry,
1025 )
1026 .is_some()
1027}
1028
1029#[cfg_attr(not(test), allow(dead_code))]
1036async fn enforce_write(
1037 state: &AppState,
1038 path: &str,
1039 mode: AccessMode,
1040 agent_uri: Option<&str>,
1041) -> Result<(), ActixError> {
1042 enforce_write_ctx(state, path, mode, agent_uri, None).await
1043}
1044
1045async fn enforce_write_ctx(
1053 state: &AppState,
1054 path: &str,
1055 mode: AccessMode,
1056 agent_uri: Option<&str>,
1057 request_origin: Option<&str>,
1058) -> Result<(), ActixError> {
1059 let origin = request_origin.and_then(wac::Origin::parse);
1060 let (resource, eff_mode) = guarded_acl_target(path, mode);
1072
1073 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
1081 if outcome.is_failure() {
1082 return Err(policy_failure_to_actix(&outcome, &resource));
1083 }
1084 let acl_doc = outcome.document().cloned();
1085
1086 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
1091
1092 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
1093 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
1094 let linked = cross_identity::linked_identities(state.cross_identity.as_ref(), agent_uri).await;
1097 let Some(ctx) = grant_for_identities(
1098 acl_doc.as_ref(),
1099 agent_uri,
1100 &linked,
1101 payment_balance_sats,
1102 &resource,
1103 eff_mode,
1104 origin.as_ref(),
1105 &groups,
1106 ®istry,
1107 ) else {
1108 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1109 };
1110 if resource.as_str() == path {
1117 charge_granted_payment(
1123 state,
1124 acl_doc.as_ref(),
1125 &ctx,
1126 &resource,
1127 eff_mode,
1128 &groups,
1129 ®istry,
1130 agent_uri,
1131 )
1132 .await?;
1133 }
1134 Ok(())
1135}
1136
1137#[allow(clippy::too_many_arguments)]
1146async fn charge_granted_payment(
1147 state: &AppState,
1148 acl_doc: Option<&wac::AclDocument>,
1149 ctx: &RequestContext<'_>,
1150 path: &str,
1151 mode: AccessMode,
1152 groups: &wac::StaticGroupMembership,
1153 registry: &wac::conditions::ConditionRegistry,
1154 payer: Option<&str>,
1155) -> Result<(), ActixError> {
1156 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
1157 if cost == 0 {
1158 return Ok(());
1159 }
1160 if let Some(did) = payer {
1163 if debit_ledger(&*state.storage, did, cost).await.is_err() {
1164 return Err(acl_denial(acl_doc, payer, path));
1165 }
1166 }
1167 Ok(())
1168}
1169
1170#[allow(clippy::too_many_arguments)]
1177pub(crate) fn grant_for_identities<'a>(
1178 acl_doc: Option<&wac::AclDocument>,
1179 primary: Option<&'a str>,
1180 linked: &'a [String],
1181 payment_balance_sats: Option<u64>,
1182 resource: &str,
1183 mode: AccessMode,
1184 origin: Option<&wac::Origin>,
1185 groups: &wac::StaticGroupMembership,
1186 registry: &wac::conditions::ConditionRegistry,
1187) -> Option<RequestContext<'a>> {
1188 std::iter::once(primary)
1189 .chain(linked.iter().map(|s| Some(s.as_str())))
1190 .map(|web_id| RequestContext {
1191 web_id,
1192 client_id: None,
1193 issuer: None,
1194 payment_balance_sats,
1195 })
1196 .find(|ctx| {
1197 wac::evaluate_access_ctx_with_registry(
1198 acl_doc, ctx, resource, mode, origin, groups, registry,
1199 )
1200 })
1201}
1202
1203fn acl_denial(
1209 acl_doc: Option<&wac::AclDocument>,
1210 agent_uri: Option<&str>,
1211 path: &str,
1212) -> ActixError {
1213 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
1214 let (status, body, unauthenticated) = if agent_uri.is_none() {
1215 (StatusCode::UNAUTHORIZED, "authentication required", true)
1216 } else {
1217 (StatusCode::FORBIDDEN, "access forbidden", false)
1218 };
1219 let mut rsp = HttpResponse::new(status);
1220 rsp.headers_mut().insert(
1221 header::HeaderName::from_static("wac-allow"),
1222 header::HeaderValue::from_str(&allow_header)
1223 .unwrap_or(header::HeaderValue::from_static("")),
1224 );
1225 if unauthenticated {
1226 rsp.headers_mut().insert(
1233 header::WWW_AUTHENTICATE,
1234 header::HeaderValue::from_static(
1235 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
1236 ),
1237 );
1238 }
1239 actix_web::error::InternalError::from_response(body, rsp).into()
1240}
1241
1242#[cfg_attr(not(test), allow(dead_code))]
1253async fn enforce_read(
1254 state: &AppState,
1255 path: &str,
1256 agent_uri: Option<&str>,
1257) -> Result<ResponseAudience, ActixError> {
1258 enforce_read_ctx(state, path, agent_uri, None).await
1259}
1260
1261async fn enforce_read_ctx(
1264 state: &AppState,
1265 path: &str,
1266 agent_uri: Option<&str>,
1267 request_origin: Option<&str>,
1268) -> Result<ResponseAudience, ActixError> {
1269 enforce_read_inner(state, path, agent_uri, request_origin, true).await
1270}
1271
1272async fn enforce_read_inner(
1277 state: &AppState,
1278 path: &str,
1279 agent_uri: Option<&str>,
1280 request_origin: Option<&str>,
1281 charge: bool,
1282) -> Result<ResponseAudience, ActixError> {
1283 let origin = request_origin.and_then(wac::Origin::parse);
1284 let (resource, eff_mode) = guarded_acl_target(path, AccessMode::Read);
1296 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
1299 if outcome.is_failure() {
1300 return Err(policy_failure_to_actix(&outcome, &resource));
1301 }
1302 let acl_doc = outcome.document().cloned();
1303 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
1304 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
1305 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
1306 let linked = cross_identity::linked_identities(state.cross_identity.as_ref(), agent_uri).await;
1307 let Some(ctx) = grant_for_identities(
1308 acl_doc.as_ref(),
1309 agent_uri,
1310 &linked,
1311 payment_balance_sats,
1312 &resource,
1313 eff_mode,
1314 origin.as_ref(),
1315 &groups,
1316 ®istry,
1317 ) else {
1318 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1319 };
1320 if resource.as_str() == path && !charge {
1324 if wac::granted_payment_cost(
1325 acl_doc.as_ref(),
1326 &ctx,
1327 &resource,
1328 eff_mode,
1329 &groups,
1330 ®istry,
1331 ) > 0
1332 {
1333 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1334 }
1335 } else if resource.as_str() == path {
1336 charge_granted_payment(
1341 state,
1342 acl_doc.as_ref(),
1343 &ctx,
1344 &resource,
1345 eff_mode,
1346 &groups,
1347 ®istry,
1348 agent_uri,
1349 )
1350 .await?;
1351 }
1352 let anon_ctx = RequestContext {
1361 web_id: None,
1362 client_id: None,
1363 issuer: None,
1364 payment_balance_sats: None,
1365 };
1366 let anonymous_would_be_granted = wac::evaluate_access_ctx_with_registry(
1367 acl_doc.as_ref(),
1368 &anon_ctx,
1369 &resource,
1370 eff_mode,
1371 origin.as_ref(),
1372 &groups,
1373 ®istry,
1374 );
1375 Ok(if anonymous_would_be_granted && resource.as_str() == path {
1376 ResponseAudience::Public
1377 } else {
1378 ResponseAudience::Private
1379 })
1380}
1381
1382async fn debit_ledger(
1389 storage: &dyn Storage,
1390 did: &str,
1391 cost: u64,
1392) -> Result<(), solid_pod_rs::payments::PaymentError> {
1393 handlers::pay::StoragePaymentStore::new(storage)
1394 .charge_access(did, cost)
1395 .await
1396}
1397
1398fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
1403 let links = ldp::link_headers(path).join(", ");
1404 if let Ok(value) = header::HeaderValue::from_str(&links) {
1405 rsp.headers_mut()
1406 .insert(header::HeaderName::from_static("link"), value);
1407 }
1408}
1409
1410fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
1411 if let Ok(v) = header::HeaderValue::from_str(header_value) {
1412 rsp.headers_mut()
1413 .insert(header::HeaderName::from_static("wac-allow"), v);
1414 }
1415}
1416
1417fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
1418 let ws_base = base_url
1419 .replacen("https://", "wss://", 1)
1420 .replacen("http://", "ws://", 1);
1421 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
1422 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
1423 rsp.headers_mut()
1424 .insert(header::HeaderName::from_static("updates-via"), v);
1425 }
1426}
1427
1428fn set_provenance_headers(rsp: &mut HttpResponse, receipt: &ProvenanceReceipt) {
1439 if let Ok(v) = header::HeaderValue::from_str(&receipt.summary()) {
1440 rsp.headers_mut()
1441 .insert(header::HeaderName::from_static("x-provenance"), v);
1442 }
1443 if let Some(sha) = receipt.commit_sha() {
1444 if let Ok(v) = header::HeaderValue::from_str(sha) {
1445 rsp.headers_mut()
1446 .insert(header::HeaderName::from_static("x-provenance-commit"), v);
1447 }
1448 }
1449}
1450
1451fn set_cache_policy(rsp: &mut HttpResponse, content_type: &str, audience: ResponseAudience) {
1463 if audience == ResponseAudience::Private {
1464 append_vary(rsp, "Authorization");
1468 }
1469 if rsp.headers().contains_key(header::CACHE_CONTROL) {
1470 return;
1471 }
1472 if let Some(value) = cache_control_for_response(content_type, audience) {
1473 rsp.headers_mut().insert(
1474 header::CACHE_CONTROL,
1475 header::HeaderValue::from_static(value),
1476 );
1477 }
1478}
1479
1480fn append_vary(rsp: &mut HttpResponse, field: &str) {
1482 let existing = rsp
1483 .headers()
1484 .get(header::VARY)
1485 .and_then(|v| v.to_str().ok())
1486 .unwrap_or("")
1487 .to_string();
1488 if existing
1489 .split(',')
1490 .any(|f| f.trim().eq_ignore_ascii_case(field))
1491 {
1492 return;
1493 }
1494 let merged = if existing.trim().is_empty() {
1495 field.to_string()
1496 } else {
1497 format!("{existing}, {field}")
1498 };
1499 if let Ok(v) = header::HeaderValue::from_str(&merged) {
1500 rsp.headers_mut().insert(header::VARY, v);
1501 }
1502}
1503
1504async fn handle_get(
1505 req: HttpRequest,
1506 state: web::Data<AppState>,
1507) -> Result<HttpResponse, ActixError> {
1508 let path = req.uri().path().to_string();
1509
1510 #[cfg(feature = "activitypub")]
1515 if let (Some(ap), "/profile/card.jsonld") = (state.activitypub.as_ref(), path.as_str()) {
1516 let accept = req
1517 .headers()
1518 .get(header::ACCEPT)
1519 .and_then(|v| v.to_str().ok())
1520 .unwrap_or("");
1521 if solid_pod_rs_activitypub::negotiate_actor_format(accept)
1522 == solid_pod_rs_activitypub::ActorFormat::ActivityJson
1523 {
1524 let doc = ap.actor_json(&scheme::request_origin(&req));
1525 return Ok(HttpResponse::Ok()
1526 .content_type("application/activity+json")
1527 .insert_header((header::VARY, "Accept"))
1528 .json(doc));
1529 }
1530 }
1531
1532 if path.contains('*') {
1533 return handle_glob_get(req, state).await;
1534 }
1535
1536 let auth_pk = extract_pubkey(&req).await;
1537 let agent = agent_uri(auth_pk.as_ref());
1538
1539 let audience = enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
1544
1545 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
1546
1547 if ldp::is_container(&path) {
1548 let accept = req
1549 .headers()
1550 .get(header::ACCEPT)
1551 .and_then(|v| v.to_str().ok())
1552 .unwrap_or("");
1553
1554 if accept_includes_html(accept) {
1560 let index_path = format!("{path}index.html");
1561 let index_audience =
1564 enforce_read_ctx(&state, &index_path, agent.as_deref(), req_origin(&req))
1565 .await
1566 .ok();
1567 if let (Some(index_audience), Ok((body, _meta))) =
1568 (index_audience, state.storage.get(&index_path).await)
1569 {
1570 let mut rsp = HttpResponse::Ok()
1571 .content_type("text/html; charset=utf-8")
1572 .body(inject_live_reload(&body, state.live_reload));
1573 set_cache_policy(&mut rsp, "text/html", index_audience);
1574 set_wac_allow(&mut rsp, &wac_allow);
1575 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1576 set_link_headers(&mut rsp, &path);
1577 return Ok(rsp);
1578 }
1579 }
1580
1581 let v = state
1582 .storage
1583 .container_representation(&path)
1584 .await
1585 .map_err(to_actix)?;
1586
1587 let sec_fetch_dest = req
1589 .headers()
1590 .get("sec-fetch-dest")
1591 .and_then(|v| v.to_str().ok());
1592 if mashlib::should_serve(
1593 accept,
1594 sec_fetch_dest,
1595 "application/ld+json",
1596 state.mashlib.enabled,
1597 ) {
1598 let json_ld = serde_json::to_string(&v).ok();
1599 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
1600 let mut rsp = HttpResponse::Ok()
1601 .content_type("text/html; charset=utf-8")
1602 .insert_header(("X-Frame-Options", "DENY"))
1603 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1604 .insert_header(("Cache-Control", "no-store"))
1605 .body(inject_live_reload(html, state.live_reload));
1606 set_cache_policy(&mut rsp, "text/html", audience);
1607 set_wac_allow(&mut rsp, &wac_allow);
1608 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1609 set_link_headers(&mut rsp, &path);
1610 return Ok(rsp);
1611 }
1612
1613 let mut rsp = HttpResponse::Ok().json(v);
1614 rsp.headers_mut().insert(
1615 header::CONTENT_TYPE,
1616 header::HeaderValue::from_static("application/ld+json"),
1617 );
1618 set_cache_policy(&mut rsp, "application/ld+json", audience);
1619 set_wac_allow(&mut rsp, &wac_allow);
1620 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1621 set_link_headers(&mut rsp, &path);
1622 return Ok(rsp);
1623 }
1624
1625 match state.storage.get(&path).await {
1626 Ok((body, meta)) => {
1627 let accept = req
1629 .headers()
1630 .get(header::ACCEPT)
1631 .and_then(|v| v.to_str().ok())
1632 .unwrap_or("");
1633 let sec_fetch_dest = req
1634 .headers()
1635 .get("sec-fetch-dest")
1636 .and_then(|v| v.to_str().ok());
1637 if mashlib::should_serve(
1638 accept,
1639 sec_fetch_dest,
1640 &meta.content_type,
1641 state.mashlib.enabled,
1642 ) {
1643 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
1644 std::str::from_utf8(&body).ok().map(|s| s.to_string())
1645 } else {
1646 None
1647 };
1648 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
1649 let mut rsp = HttpResponse::Ok()
1650 .content_type("text/html; charset=utf-8")
1651 .insert_header(("X-Frame-Options", "DENY"))
1652 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1653 .insert_header(("Cache-Control", "no-store"))
1654 .body(inject_live_reload(html, state.live_reload));
1655 set_cache_policy(&mut rsp, "text/html", audience);
1656 set_wac_allow(&mut rsp, &wac_allow);
1657 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1658 set_link_headers(&mut rsp, &path);
1659 return Ok(rsp);
1660 }
1661
1662 if let Some((negotiated_body, negotiated_ct)) =
1670 rdf_content_negotiate(&body, &meta.content_type, accept)
1671 {
1672 let mut rsp = HttpResponse::Ok().body(negotiated_body);
1673 rsp.headers_mut().insert(
1674 header::CONTENT_TYPE,
1675 header::HeaderValue::from_str(negotiated_ct)
1676 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
1677 );
1678 rsp.headers_mut()
1679 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
1680 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1681 rsp.headers_mut().insert(header::ETAG, etag);
1682 }
1683 set_cache_policy(&mut rsp, negotiated_ct, audience);
1684 set_wac_allow(&mut rsp, &wac_allow);
1685 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1686 set_link_headers(&mut rsp, &path);
1687 return Ok(rsp);
1688 }
1689
1690 let response_body = if meta.content_type.starts_with("text/html") {
1691 inject_live_reload(&body, state.live_reload)
1692 } else {
1693 body.to_vec()
1694 };
1695 let mut rsp = HttpResponse::Ok().body(response_body);
1696 rsp.headers_mut().insert(
1697 header::CONTENT_TYPE,
1698 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
1699 header::HeaderValue::from_static("application/octet-stream")
1700 }),
1701 );
1702 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1703 rsp.headers_mut().insert(header::ETAG, etag);
1704 }
1705 set_cache_policy(&mut rsp, &meta.content_type, audience);
1706 set_wac_allow(&mut rsp, &wac_allow);
1707 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1708 set_link_headers(&mut rsp, &path);
1709 Ok(rsp)
1710 }
1711 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1712 Err(e) => Err(to_actix(e)),
1713 }
1714}
1715
1716fn has_basic_container_link(req: &HttpRequest) -> bool {
1717 req.headers()
1718 .get_all(header::LINK)
1719 .filter_map(|v| v.to_str().ok())
1720 .any(|v| {
1721 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
1722 })
1723}
1724
1725fn validate_rdf_body(
1734 req: &HttpRequest,
1735 state: &AppState,
1736 resource_path: &str,
1737 content_type: &str,
1738 body: &[u8],
1739) -> Result<(), ActixError> {
1740 use solid_pod_rs::rdf_input::{parse_rdf_body, RdfInputSyntax};
1741 let Some(syntax) = RdfInputSyntax::from_content_type(content_type) else {
1742 return Ok(());
1743 };
1744 if syntax == RdfInputSyntax::N3 && !state.conneg_enabled {
1745 return Err(actix_web::error::ErrorUnsupportedMediaType(
1746 "text/n3 request bodies require content negotiation (conneg) to be enabled",
1747 ));
1748 }
1749 let base = format!("{}{}", scheme::request_origin(req), resource_path);
1750 match parse_rdf_body(syntax, body, &base) {
1751 Ok(_) => Ok(()),
1752 Err(solid_pod_rs::PodError::PayloadTooLarge(m)) => {
1753 Err(actix_web::error::ErrorPayloadTooLarge(m))
1754 }
1755 Err(e) => Err(to_actix(e)),
1756 }
1757}
1758
1759async fn handle_put(
1760 req: HttpRequest,
1761 body: web::Bytes,
1762 state: web::Data<AppState>,
1763) -> Result<HttpResponse, ActixError> {
1764 let path = req.uri().path().to_string();
1765
1766 if ldp::is_container(&path) {
1767 if has_basic_container_link(&req) {
1768 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1769 let agent = agent_uri(auth_pk.as_ref());
1770 enforce_write_ctx(
1771 &state,
1772 &path,
1773 AccessMode::Write,
1774 agent.as_deref(),
1775 req_origin(&req),
1776 )
1777 .await?;
1778 let _write_guard = lock_resource(&path).await;
1779 let meta = state
1780 .storage
1781 .create_container(&path)
1782 .await
1783 .map_err(to_actix)?;
1784 let mut rsp = HttpResponse::Created().finish();
1785 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1786 rsp.headers_mut().insert(header::ETAG, etag);
1787 }
1788 set_link_headers(&mut rsp, &path);
1789 return Ok(rsp);
1790 }
1791 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
1792 }
1793
1794 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1795 let agent = agent_uri(auth_pk.as_ref());
1796 enforce_write_ctx(
1797 &state,
1798 &path,
1799 AccessMode::Write,
1800 agent.as_deref(),
1801 req_origin(&req),
1802 )
1803 .await?;
1804
1805 let ct = req
1806 .headers()
1807 .get(header::CONTENT_TYPE)
1808 .and_then(|v| v.to_str().ok())
1809 .unwrap_or("application/octet-stream");
1810
1811 if protected_resource_for_acl(&path).is_some()
1816 && !proposed_acl_keeps_caller_control(&state, &req, &path, &body, ct, agent.as_deref())
1817 .await
1818 {
1819 return Ok(HttpResponse::Conflict().body(
1820 "refused: the proposed ACL would not grant Control to the caller \
1821 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1822 ));
1823 }
1824
1825 validate_rdf_body(&req, &state, &path, ct, &body)?;
1826
1827 let _write_guard = lock_resource(&path).await;
1828 check_write_preconditions(&req, &state, &path).await?;
1829 let quota = reserve_quota_for_size(&state, &path, body.len() as u64).await?;
1830 let write = state
1831 .storage
1832 .put(&path, Bytes::from(body.to_vec()), ct)
1833 .await;
1834 finish_quota_reservation(&state, quota, write.is_ok()).await;
1835 let meta = write.map_err(to_actix)?;
1836 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
1840 let mut rsp = HttpResponse::Created().finish();
1841 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1842 rsp.headers_mut().insert(header::ETAG, etag);
1843 }
1844 set_provenance_headers(&mut rsp, &provenance);
1845 set_link_headers(&mut rsp, &path);
1846 Ok(rsp)
1847}
1848
1849async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
1856 if !storage.exists(target).await.unwrap_or(false) {
1857 return target.to_string();
1858 }
1859 let (name, slash) = match target.strip_suffix('/') {
1861 Some(name) => (name, "/"),
1862 None => (target, ""),
1863 };
1864 let seg_start = name.rfind('/').map(|s| s + 1).unwrap_or(0);
1867 let (stem, ext) = match name.rfind('.') {
1868 Some(dot) if dot > seg_start => (&name[..dot], &name[dot..]),
1869 _ => (name, ""),
1870 };
1871 for n in 1..10_000u32 {
1872 let candidate = format!("{stem}-{n}{ext}{slash}");
1873 if !storage.exists(&candidate).await.unwrap_or(false) {
1874 return candidate;
1875 }
1876 }
1877 use std::hash::{Hash, Hasher};
1878 let mut h = std::collections::hash_map::DefaultHasher::new();
1879 target.hash(&mut h);
1880 format!("{stem}-{:x}{ext}{slash}", h.finish())
1881}
1882
1883async fn handle_post(
1884 req: HttpRequest,
1885 body: web::Bytes,
1886 state: web::Data<AppState>,
1887) -> Result<HttpResponse, ActixError> {
1888 let path = req.uri().path().to_string();
1889 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1892 let agent = agent_uri(auth_pk.as_ref());
1893 enforce_write_ctx(
1894 &state,
1895 &path,
1896 AccessMode::Append,
1897 agent.as_deref(),
1898 req_origin(&req),
1899 )
1900 .await?;
1901
1902 let slug = req
1903 .headers()
1904 .get(header::HeaderName::from_static("slug"))
1905 .and_then(|v| v.to_str().ok());
1906 let mut target = match ldp::resolve_slug(&path, slug) {
1907 Ok(p) => p,
1908 Err(e) => return Err(to_actix(e)),
1909 };
1910 let ct = req
1911 .headers()
1912 .get(header::CONTENT_TYPE)
1913 .and_then(|v| v.to_str().ok())
1914 .unwrap_or("application/octet-stream");
1915
1916 let _write_guard = if protected_resource_for_acl(&target).is_some() {
1925 enforce_write_ctx(
1926 &state,
1927 &target,
1928 AccessMode::Write,
1929 agent.as_deref(),
1930 req_origin(&req),
1931 )
1932 .await?;
1933 if !proposed_acl_keeps_caller_control(&state, &req, &target, &body, ct, agent.as_deref())
1934 .await
1935 {
1936 return Ok(HttpResponse::Conflict().body(
1937 "refused: the proposed ACL would not grant Control to the caller \
1938 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1939 ));
1940 }
1941 lock_resource(&target).await
1942 } else {
1943 let (minted, guard) = mint_and_lock_target(&*state.storage, &target)
1950 .await
1951 .map_err(to_actix)?;
1952 target = minted;
1953 guard
1954 };
1955
1956 validate_rdf_body(&req, &state, &target, ct, &body)?;
1957
1958 let quota = reserve_quota_for_size(&state, &target, body.len() as u64).await?;
1959 let write = state
1960 .storage
1961 .put(&target, Bytes::from(body.to_vec()), ct)
1962 .await;
1963 finish_quota_reservation(&state, quota, write.is_ok()).await;
1964 let meta = write.map_err(to_actix)?;
1965 let provenance = git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1968 let mut rsp = HttpResponse::Created().finish();
1969 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1970 rsp.headers_mut().insert(header::LOCATION, loc);
1971 }
1972 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1973 rsp.headers_mut().insert(header::ETAG, etag);
1974 }
1975 set_provenance_headers(&mut rsp, &provenance);
1976 set_link_headers(&mut rsp, &target);
1977 Ok(rsp)
1978}
1979
1980const CONDITIONAL_ACL_PATCH_REFUSED: &str =
1982 "refused: the patched ACL carries acl:condition, which an RDF PATCH cannot \
1983 preserve (the N-Triples write-back turns an inline condition into a blank \
1984 node the WAC reader does not resolve); PUT the whole ACL document instead";
1985
1986fn graph_has_acl_condition(graph: &ldp::Graph) -> bool {
1990 const CONDITION: &str = "http://www.w3.org/ns/auth/acl#condition";
1991 graph
1992 .triples()
1993 .any(|t| matches!(&t.predicate, ldp::Term::Iri(p) if p == CONDITION))
1994}
1995
1996const PATCHED_RDF_CONTENT_TYPE: &str = "text/turtle";
2001
2002async fn handle_patch(
2003 req: HttpRequest,
2004 body: web::Bytes,
2005 state: web::Data<AppState>,
2006) -> Result<HttpResponse, ActixError> {
2007 let path = req.uri().path().to_string();
2008 if ldp::is_container(&path) {
2009 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
2010 }
2011 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
2012 let agent = agent_uri(auth_pk.as_ref());
2013 let ct = req
2019 .headers()
2020 .get(header::CONTENT_TYPE)
2021 .and_then(|v| v.to_str().ok())
2022 .unwrap_or("")
2023 .to_string();
2024 let dialect = ldp::patch_dialect_from_mime(&ct);
2025 let body_str = std::str::from_utf8(&body).map(str::to_string);
2026 let base_iri = format!("{}{path}", scheme::request_origin(&req));
2029 let n3_parsed = match (dialect, &body_str) {
2030 (Some(ldp::PatchDialect::N3), Ok(text)) => Some(ldp::N3Patch::parse(text, Some(&base_iri))),
2031 _ => None,
2032 };
2033 let inspects = match dialect {
2041 Some(ldp::PatchDialect::N3) => {
2042 matches!(&n3_parsed, Some(Ok(p)) if !p.is_insert_only())
2043 }
2044 Some(ldp::PatchDialect::JsonPatch) => body_str.is_ok(),
2045 _ => false,
2046 };
2047 if inspects {
2048 enforce_read_inner(&state, &path, agent.as_deref(), req_origin(&req), false).await?;
2049 }
2050 enforce_write_ctx(
2056 &state,
2057 &path,
2058 AccessMode::Write,
2059 agent.as_deref(),
2060 req_origin(&req),
2061 )
2062 .await?;
2063
2064 let Some(dialect) = dialect else {
2065 return Ok(HttpResponse::UnsupportedMediaType()
2066 .body(format!("unsupported patch dialect for content-type {ct:?}")));
2067 };
2068 let Ok(body_str) = body_str else {
2069 return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8"));
2070 };
2071 let n3_patch = match n3_parsed {
2072 Some(parsed) => Some(parsed.map_err(patch_parse_err)?),
2073 None => None,
2074 };
2075 let acl_origin = protected_resource_for_acl(&path)
2076 .is_some()
2077 .then(|| scheme::request_origin(&req));
2078
2079 let _write_guard = lock_resource(&path).await;
2082 check_write_preconditions(&req, &state, &path).await?;
2083
2084 let existing = state.storage.get(&path).await;
2086 match existing {
2087 Ok((current_body, meta)) => {
2088 let out = match dialect {
2098 ldp::PatchDialect::N3 => {
2099 let seed = seed_graph_from_patch_target(¤t_body, &base_iri)?;
2100 n3_patch
2101 .as_ref()
2102 .expect("parsed above for the N3 dialect")
2103 .apply(seed)
2104 .map_err(patch_parse_err)
2105 }
2106 ldp::PatchDialect::SparqlUpdate => {
2107 let seed = seed_graph_from_patch_target(¤t_body, &base_iri)?;
2108 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
2109 }
2110 ldp::PatchDialect::JsonPatch => {
2111 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
2112 Ok(v) => v,
2113 Err(_) => serde_json::json!({}),
2114 };
2115 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
2116 Ok(v) => v,
2117 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
2118 };
2119 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
2120 let bytes = serde_json::to_vec(&json)
2121 .map_err(PodError::from)
2122 .map_err(to_actix)?;
2123 if !proposed_acl_keeps_caller_control(
2124 &state,
2125 &req,
2126 &path,
2127 &bytes,
2128 &meta.content_type,
2129 agent.as_deref(),
2130 )
2131 .await
2132 {
2133 return Ok(HttpResponse::Conflict().body(
2134 "refused: the patched ACL would not grant Control to the caller",
2135 ));
2136 }
2137 let quota = reserve_quota_for_size(&state, &path, bytes.len() as u64).await?;
2138 let write = state
2139 .storage
2140 .put(&path, Bytes::from(bytes), &meta.content_type)
2141 .await;
2142 finish_quota_reservation(&state, quota, write.is_ok()).await;
2143 let _ = write.map_err(to_actix)?;
2144 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
2145 let mut rsp = HttpResponse::NoContent().finish();
2146 set_provenance_headers(&mut rsp, &provenance);
2147 return Ok(rsp);
2148 }
2149 };
2150 let mut outcome = out?;
2151 if let Some(origin) = &acl_origin {
2152 outcome.graph = acl_targets_as_paths(outcome.graph, origin);
2153 }
2154 if acl_origin.is_some() && graph_has_acl_condition(&outcome.graph) {
2157 return Ok(HttpResponse::Conflict().body(CONDITIONAL_ACL_PATCH_REFUSED));
2158 }
2159 let serialised = graph_to_turtle(&outcome.graph);
2160 if protected_resource_for_acl(&path).is_some()
2166 && !proposed_acl_keeps_caller_control(
2167 &state,
2168 &req,
2169 &path,
2170 serialised.as_bytes(),
2171 PATCHED_RDF_CONTENT_TYPE,
2172 agent.as_deref(),
2173 )
2174 .await
2175 {
2176 return Ok(HttpResponse::Conflict().body(
2177 "refused: the patched ACL would not grant Control to the caller \
2178 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
2179 ));
2180 }
2181 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
2182 let write = state
2183 .storage
2184 .put(
2185 &path,
2186 Bytes::from(serialised.into_bytes()),
2187 PATCHED_RDF_CONTENT_TYPE,
2188 )
2189 .await;
2190 finish_quota_reservation(&state, quota, write.is_ok()).await;
2191 let _ = write.map_err(to_actix)?;
2192 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
2193 let mut rsp = HttpResponse::NoContent().finish();
2194 set_provenance_headers(&mut rsp, &provenance);
2195 Ok(rsp)
2196 }
2197 Err(PodError::NotFound(_)) => {
2198 let graph = match &n3_patch {
2200 Some(p) => p.apply(ldp::Graph::new()).map_err(patch_parse_err)?.graph,
2201 None => {
2202 let create =
2203 ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
2204 let PatchCreateOutcome::Created { graph, .. } = create else {
2205 return Err(to_actix(PodError::Unsupported(
2206 "unexpected patch outcome on absent resource".into(),
2207 )));
2208 };
2209 graph
2210 }
2211 };
2212 let graph = match &acl_origin {
2213 Some(origin) => acl_targets_as_paths(graph, origin),
2214 None => graph,
2215 };
2216 if acl_origin.is_some() && graph_has_acl_condition(&graph) {
2217 return Ok(HttpResponse::Conflict().body(CONDITIONAL_ACL_PATCH_REFUSED));
2218 }
2219 let serialised = graph_to_turtle(&graph);
2220 if protected_resource_for_acl(&path).is_some()
2222 && !proposed_acl_keeps_caller_control(
2223 &state,
2224 &req,
2225 &path,
2226 serialised.as_bytes(),
2227 PATCHED_RDF_CONTENT_TYPE,
2228 agent.as_deref(),
2229 )
2230 .await
2231 {
2232 return Ok(HttpResponse::Conflict().body(
2233 "refused: the patched ACL would not grant Control to the caller \
2234 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
2235 ));
2236 }
2237 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
2238 let write = state
2239 .storage
2240 .put(
2241 &path,
2242 Bytes::from(serialised.into_bytes()),
2243 PATCHED_RDF_CONTENT_TYPE,
2244 )
2245 .await;
2246 finish_quota_reservation(&state, quota, write.is_ok()).await;
2247 let _ = write.map_err(to_actix)?;
2248 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
2249 let mut rsp = HttpResponse::Created().finish();
2250 set_provenance_headers(&mut rsp, &provenance);
2251 Ok(rsp)
2252 }
2253 Err(e) => Err(to_actix(e)),
2254 }
2255}
2256
2257fn patch_parse_err(e: PodError) -> ActixError {
2261 match e {
2262 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
2263 actix_web::error::ErrorBadRequest(msg)
2264 }
2265 other => to_actix(other),
2266 }
2267}
2268
2269fn graph_to_turtle(g: &ldp::Graph) -> String {
2273 g.to_ntriples()
2274}
2275
2276fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
2283 let mut best: Option<(f32, ldp::RdfFormat)> = None;
2284 for entry in accept.split(',') {
2285 let entry = entry.trim();
2286 if entry.is_empty() {
2287 continue;
2288 }
2289 let mut parts = entry.split(';').map(|s| s.trim());
2290 let mime = match parts.next() {
2291 Some(m) => m,
2292 None => continue,
2293 };
2294 let mut q: f32 = 1.0;
2295 for token in parts {
2296 if let Some(v) = token.strip_prefix("q=") {
2297 if let Ok(parsed) = v.parse::<f32>() {
2298 q = parsed;
2299 }
2300 }
2301 }
2302 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
2305 match best {
2306 None => best = Some((q, format)),
2307 Some((bq, _)) if q > bq => best = Some((q, format)),
2308 _ => {}
2309 }
2310 }
2311 }
2312 best.map(|(_, f)| f)
2313}
2314
2315fn rdf_content_negotiate(
2331 body: &[u8],
2332 stored_ct: &str,
2333 accept: &str,
2334) -> Option<(Vec<u8>, &'static str)> {
2335 if accept.trim().is_empty() {
2336 return None;
2337 }
2338 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
2339 let target = best_explicit_rdf_format(accept)?;
2340 if target == stored_format {
2341 return None;
2342 }
2343 let text = std::str::from_utf8(body).ok()?;
2344 let graph = ldp::Graph::parse_ntriples(text).ok()?;
2345 match target {
2346 ldp::RdfFormat::Turtle => Some((
2349 graph.to_ntriples().into_bytes(),
2350 ldp::RdfFormat::Turtle.mime(),
2351 )),
2352 ldp::RdfFormat::NTriples => Some((
2353 graph.to_ntriples().into_bytes(),
2354 ldp::RdfFormat::NTriples.mime(),
2355 )),
2356 ldp::RdfFormat::JsonLd => {
2357 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
2358 Some((json, ldp::RdfFormat::JsonLd.mime()))
2359 }
2360 ldp::RdfFormat::RdfXml => None,
2362 }
2363}
2364
2365fn seed_graph_from_patch_target(
2375 current_body: &[u8],
2376 base_iri: &str,
2377) -> Result<ldp::Graph, ActixError> {
2378 let text = std::str::from_utf8(current_body).map_err(|_| {
2379 actix_web::error::ErrorConflict(
2380 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
2381 )
2382 })?;
2383 if text.trim().is_empty() {
2384 return Ok(ldp::Graph::new());
2385 }
2386 if let Ok(graph) = solid_pod_rs::rdf_input::parse_turtle(current_body, base_iri) {
2398 return Ok(graph);
2399 }
2400 ldp::Graph::parse_ntriples(text).map_err(|_| {
2401 actix_web::error::ErrorConflict(
2402 "existing resource is not Turtle or N-Triples RDF and cannot be \
2403 non-destructively patched; PUT an RDF representation or use a JSON Patch",
2404 )
2405 })
2406}
2407
2408fn acl_targets_as_paths(graph: ldp::Graph, origin: &str) -> ldp::Graph {
2418 const ACCESS_TO: &str = "http://www.w3.org/ns/auth/acl#accessTo";
2419 const DEFAULT: &str = "http://www.w3.org/ns/auth/acl#default";
2420 let origin = origin.trim_end_matches('/');
2421 let mut out = ldp::Graph::new();
2422 for t in graph.triples() {
2423 let object = match (&t.predicate, &t.object) {
2424 (ldp::Term::Iri(p), ldp::Term::Iri(o)) if p == ACCESS_TO || p == DEFAULT => o
2425 .strip_prefix(origin)
2426 .filter(|rest| rest.starts_with('/'))
2427 .map(|rest| ldp::Term::Iri(rest.to_string()))
2428 .unwrap_or_else(|| t.object.clone()),
2429 _ => t.object.clone(),
2430 };
2431 out.insert(ldp::Triple::new(
2432 t.subject.clone(),
2433 t.predicate.clone(),
2434 object,
2435 ));
2436 }
2437 out
2438}
2439
2440pub(crate) async fn find_effective_acl_dyn(
2456 storage: &dyn Storage,
2457 resource_path: &str,
2458) -> Result<Option<wac::AclDocument>, PodError> {
2459 resolve_policy_dyn(storage, resource_path)
2460 .await
2461 .into_result()
2462}
2463
2464pub(crate) async fn resolve_policy_dyn(
2469 storage: &dyn Storage,
2470 resource_path: &str,
2471) -> wac::PolicyOutcome {
2472 wac::resolve_policy_from_storage(storage, resource_path).await
2473}
2474
2475fn policy_failure_to_actix(outcome: &wac::PolicyOutcome, resource: &str) -> ActixError {
2485 match outcome {
2486 wac::PolicyOutcome::Invalid {
2487 policy_path,
2488 reason,
2489 } => {
2490 tracing::warn!(
2491 target: "solid_pod_rs_server::wac",
2492 resource = %resource,
2493 policy = %policy_path,
2494 "denying: effective ACL is invalid and MUST NOT inherit: {reason}"
2495 );
2496 actix_web::error::ErrorForbidden("access forbidden: governing ACL is invalid")
2497 }
2498 wac::PolicyOutcome::Unavailable {
2499 policy_path,
2500 detail,
2501 } => {
2502 tracing::error!(
2503 target: "solid_pod_rs_server::wac",
2504 resource = %resource,
2505 policy = %policy_path,
2506 "denying: effective ACL could not be read: {detail}"
2507 );
2508 actix_web::error::ErrorServiceUnavailable(
2509 "access control unavailable: governing ACL could not be read",
2510 )
2511 }
2512 _ => actix_web::error::ErrorInternalServerError("policy resolution error"),
2514 }
2515}
2516
2517async fn handle_delete(
2518 req: HttpRequest,
2519 state: web::Data<AppState>,
2520) -> Result<HttpResponse, ActixError> {
2521 let path = req.uri().path().to_string();
2522 let auth_pk = extract_pubkey(&req).await;
2523 let agent = agent_uri(auth_pk.as_ref());
2524 enforce_write_ctx(
2525 &state,
2526 &path,
2527 AccessMode::Write,
2528 agent.as_deref(),
2529 req_origin(&req),
2530 )
2531 .await?;
2532
2533 let _write_guard = lock_resource(&path).await;
2534 check_write_preconditions(&req, &state, &path).await?;
2535 let quota = reserve_quota_for_size(&state, &path, 0).await?;
2536 match state.storage.delete(&path).await {
2537 Ok(()) => {
2538 finish_quota_reservation(&state, quota, true).await;
2539 Ok(HttpResponse::NoContent().finish())
2540 }
2541 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
2542 Err(e) => Err(to_actix(e)),
2543 }
2544}
2545
2546async fn handle_options(
2547 req: HttpRequest,
2548 state: web::Data<AppState>,
2549) -> Result<HttpResponse, ActixError> {
2550 let path = req.uri().path().to_string();
2551 let o = ldp::options_for_conneg(&path, state.conneg_enabled);
2552 let mut rsp = HttpResponse::NoContent().finish();
2553 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
2554 rsp.headers_mut()
2555 .insert(header::HeaderName::from_static("allow"), v);
2556 }
2557 if let Some(ap) = o.accept_post {
2558 if let Ok(v) = header::HeaderValue::from_str(ap) {
2559 rsp.headers_mut()
2560 .insert(header::HeaderName::from_static("accept-post"), v);
2561 }
2562 }
2563 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
2564 rsp.headers_mut()
2565 .insert(header::HeaderName::from_static("accept-patch"), v);
2566 }
2567 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
2568 rsp.headers_mut()
2569 .insert(header::HeaderName::from_static("accept-ranges"), v);
2570 }
2571 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
2572 Ok(rsp)
2573}
2574
2575async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
2580 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
2581 HttpResponse::Ok()
2582 .content_type("application/ld+json")
2583 .json(doc)
2584}
2585
2586#[derive(Debug, Deserialize)]
2587struct WebFingerQuery {
2588 resource: Option<String>,
2589}
2590
2591async fn handle_well_known_webfinger(
2592 state: web::Data<AppState>,
2593 q: web::Query<WebFingerQuery>,
2594) -> HttpResponse {
2595 let resource = q.resource.clone().unwrap_or_else(|| {
2596 format!(
2597 "acct:anonymous@{}",
2598 state
2599 .nodeinfo
2600 .base_url
2601 .trim_start_matches("http://")
2602 .trim_start_matches("https://")
2603 )
2604 });
2605 let webid = format!(
2606 "{}/profile/card#me",
2607 state.nodeinfo.base_url.trim_end_matches('/')
2608 );
2609 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
2610 Some(jrd) => HttpResponse::Ok()
2611 .content_type("application/jrd+json")
2612 .json(jrd),
2613 None => HttpResponse::NotFound().finish(),
2614 }
2615}
2616
2617async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
2618 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
2619 HttpResponse::Ok()
2620 .content_type("application/json")
2621 .json(doc)
2622}
2623
2624async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
2625 let doc = interop::nodeinfo_2_1(
2626 &state.nodeinfo.software_name,
2627 &state.nodeinfo.software_version,
2628 state.nodeinfo.open_registrations,
2629 state.nodeinfo.total_users,
2630 );
2631 HttpResponse::Ok()
2632 .content_type("application/json")
2633 .json(doc)
2634}
2635
2636#[cfg(feature = "did-nostr")]
2637async fn handle_well_known_did_nostr(
2638 state: web::Data<AppState>,
2639 path: web::Path<String>,
2640) -> HttpResponse {
2641 let pubkey = path.into_inner();
2642 let pubkey_is_valid = pubkey.len() == 64
2647 && pubkey
2648 .bytes()
2649 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
2650 if !pubkey_is_valid {
2651 return HttpResponse::BadRequest()
2652 .insert_header(("Cache-Control", "no-store"))
2653 .json(serde_json::json!({
2654 "error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
2655 }));
2656 }
2657 let owner_pubkey = match state.storage.get("/profile/card").await {
2665 Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
2666 .ok()
2667 .flatten(),
2668 Err(_) => None,
2669 };
2670 let owner_claims_key = owner_pubkey
2671 .as_deref()
2672 .is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
2673 if !owner_claims_key {
2674 return HttpResponse::NotFound()
2675 .insert_header(("Cache-Control", "no-store"))
2676 .json(serde_json::json!({
2677 "error": "no account on this pod claims this did:nostr pubkey"
2678 }));
2679 }
2680 let also = vec![format!(
2681 "{}/profile/card#me",
2682 state.nodeinfo.base_url.trim_end_matches('/')
2683 )];
2684 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
2685 let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
2686 use std::hash::{Hash, Hasher};
2692 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2693 body.hash(&mut hasher);
2694 let etag = format!("\"{:016x}\"", hasher.finish());
2695 HttpResponse::Ok()
2696 .content_type("application/did+json")
2697 .insert_header(("Cache-Control", "max-age=3600"))
2698 .insert_header(("ETag", etag))
2699 .body(body)
2700}
2701
2702#[cfg(feature = "nip05-endpoint")]
2710#[derive(Debug, Deserialize)]
2711struct Nip05Query {
2712 name: Option<String>,
2715}
2716
2717#[cfg(feature = "nip05-endpoint")]
2718fn nip05_name_is_valid(name: &str) -> bool {
2719 if name.is_empty() {
2722 return false;
2723 }
2724 name.bytes()
2725 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
2726}
2727
2728#[cfg(feature = "nip05-endpoint")]
2729async fn handle_well_known_nip05(
2730 state: web::Data<AppState>,
2731 query: web::Query<Nip05Query>,
2732) -> HttpResponse {
2733 use solid_pod_rs::webid::extract_nostr_pubkey;
2734
2735 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
2737 if !nip05_name_is_valid(&name) {
2738 return HttpResponse::BadRequest().json(serde_json::json!({
2739 "error": "invalid NIP-05 local part",
2740 }));
2741 }
2742
2743 let profile_path = if name == "_" {
2749 "/profile/card".to_string()
2750 } else {
2751 format!("/{name}/profile/card")
2752 };
2753
2754 let (body, _meta) = match state.storage.get(&profile_path).await {
2755 Ok(v) => v,
2756 Err(_) => {
2757 return nip05_empty_response();
2761 }
2762 };
2763
2764 let pubkey_hex = match extract_nostr_pubkey(&body) {
2765 Ok(Some(p)) => p,
2766 _ => return nip05_empty_response(),
2767 };
2768
2769 let doc = interop::nip05_document([(name, pubkey_hex)]);
2770 HttpResponse::Ok()
2771 .insert_header(("Access-Control-Allow-Origin", "*"))
2772 .content_type("application/json")
2773 .json(doc)
2774}
2775
2776#[cfg(feature = "nip05-endpoint")]
2777fn nip05_empty_response() -> HttpResponse {
2778 HttpResponse::Ok()
2779 .insert_header(("Access-Control-Allow-Origin", "*"))
2780 .content_type("application/json")
2781 .json(serde_json::json!({ "names": {} }))
2782}
2783
2784#[cfg(feature = "export-jsonld")]
2798async fn handle_export_all(
2799 req: HttpRequest,
2800 state: web::Data<AppState>,
2801) -> Result<HttpResponse, ActixError> {
2802 let auth_pk = extract_pubkey(&req).await;
2803 let agent = agent_uri(auth_pk.as_ref());
2804
2805 enforce_write_ctx(
2810 &state,
2811 "/",
2812 AccessMode::Control,
2813 agent.as_deref(),
2814 req_origin(&req),
2815 )
2816 .await?;
2817
2818 let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
2822 .ok()
2823 .and_then(|q| q.get("include_private").map(|v| v == "true"))
2824 .unwrap_or(false);
2825
2826 let pod_base = format!("{}/", scheme::request_origin(&req));
2830
2831 let options = solid_pod_rs::ExportOptions { include_private };
2832 let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
2833 .await
2834 .map_err(to_actix)?;
2835
2836 let body = serde_json::to_vec(&bundle).map_err(|e| {
2837 actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
2838 })?;
2839 Ok(HttpResponse::Ok()
2840 .content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
2841 .body(body))
2842}
2843
2844#[derive(Debug, Deserialize)]
2849struct CreateAccountRequest {
2850 username: String,
2851 #[serde(default)]
2852 name: Option<String>,
2853}
2854
2855#[derive(Debug, Deserialize)]
2856struct CreatePodRequest {
2857 name: String,
2858}
2859
2860async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
2861 let pod_name = path.into_inner();
2862 let pod_root = format!("/{pod_name}/");
2863 match state.storage.exists(&pod_root).await {
2864 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
2865 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
2866 }
2867}
2868
2869fn valid_pod_name(name: &str) -> bool {
2870 !name.is_empty()
2871 && name
2872 .chars()
2873 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
2874}
2875
2876fn request_ip(req: &HttpRequest) -> IpAddr {
2877 req.peer_addr()
2878 .map(|addr| addr.ip())
2879 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
2880}
2881
2882#[derive(Debug)]
2883struct QuotaReservation {
2884 pod: String,
2885 delta: i64,
2886}
2887
2888fn pod_name_from_path(path: &str) -> Option<&str> {
2889 path.trim_start_matches('/')
2890 .split('/')
2891 .next()
2892 .filter(|p| !p.is_empty())
2893}
2894
2895async fn reserve_quota_for_size(
2896 state: &AppState,
2897 path: &str,
2898 new_size: u64,
2899) -> Result<Option<QuotaReservation>, ActixError> {
2900 let (Some(policy), Some(pod)) = (&state.quota, pod_name_from_path(path)) else {
2901 return Ok(None);
2902 };
2903 let old_size = state
2904 .storage
2905 .head(path)
2906 .await
2907 .map(|meta| meta.size)
2908 .unwrap_or(0);
2909 let delta = i128::from(new_size) - i128::from(old_size);
2910 let delta = delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64;
2911 if delta > 0 {
2912 policy
2913 .reserve(pod, delta as u64)
2914 .await
2915 .map_err(|error| actix_web::error::ErrorInsufficientStorage(error.to_string()))?;
2916 }
2917 Ok(Some(QuotaReservation {
2918 pod: pod.to_string(),
2919 delta,
2920 }))
2921}
2922
2923async fn finish_quota_reservation(
2924 state: &AppState,
2925 reservation: Option<QuotaReservation>,
2926 write_succeeded: bool,
2927) {
2928 let (Some(policy), Some(reservation)) = (&state.quota, reservation) else {
2929 return;
2930 };
2931 if write_succeeded {
2932 if reservation.delta < 0 {
2933 policy.record(&reservation.pod, reservation.delta).await;
2934 }
2935 } else if reservation.delta > 0 {
2936 policy.record(&reservation.pod, -reservation.delta).await;
2937 }
2938}
2939
2940async fn handle_create_account(
2941 req: HttpRequest,
2942 state: web::Data<AppState>,
2943 body: web::Json<CreateAccountRequest>,
2944) -> Result<HttpResponse, ActixError> {
2945 if let Some(response) = provisioning_gate(&req, &state, &body.username).await {
2946 return Ok(response);
2947 }
2948 let pod_root = format!("/{}/", body.username);
2949 let _write_guard = lock_resource(&pod_root).await;
2954 if state.storage.exists(&pod_root).await.map_err(to_actix)? {
2955 return Ok(
2956 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
2957 );
2958 }
2959
2960 let base_uri = state.nodeinfo.base_url.trim_end_matches('/');
2961 let outcome =
2962 provision_named_pod(&state, &body.username, body.name.as_deref(), base_uri).await?;
2963 Ok(HttpResponse::Created().json(serde_json::json!({
2964 "webid": outcome.webid,
2965 "pod_root": outcome.pod_uri,
2966 "username": body.username,
2967 })))
2968}
2969
2970struct NamedPodOutcome {
2971 webid: String,
2972 pod_uri: String,
2973}
2974
2975async fn provisioning_gate(
2978 req: &HttpRequest,
2979 state: &AppState,
2980 name: &str,
2981) -> Option<HttpResponse> {
2982 if !valid_pod_name(name) {
2983 return Some(HttpResponse::BadRequest().json(serde_json::json!({
2984 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
2985 })));
2986 }
2987
2988 let supplied_key = req
2989 .headers()
2990 .get("x-pod-admin-key")
2991 .and_then(|value| value.to_str().ok())
2992 .unwrap_or("");
2993 use subtle::ConstantTimeEq;
2994 let admin_override = state
2995 .admin_key
2996 .as_deref()
2997 .is_some_and(|expected| bool::from(supplied_key.as_bytes().ct_eq(expected.as_bytes())));
2998 let invite = req
2999 .headers()
3000 .get("x-invite-token")
3001 .and_then(|v| v.to_str().ok())
3002 .map(str::trim)
3003 .filter(|t| !t.is_empty());
3004 let closed = !state.nodeinfo.open_registrations && !admin_override;
3005 if closed && invite.is_none() {
3006 return Some(HttpResponse::Forbidden().json(serde_json::json!({
3007 "error": "pod registration is closed"
3008 })));
3009 }
3010
3011 if let Err(retry_after) = state.pod_create_limiter.check(request_ip(req)) {
3012 return Some(
3013 HttpResponse::TooManyRequests()
3014 .insert_header(("Retry-After", retry_after.to_string()))
3015 .json(serde_json::json!({
3016 "error": "Too Many Requests",
3017 "message": "Pod creation rate limit exceeded",
3018 "retryAfter": retry_after
3019 })),
3020 );
3021 }
3022 if closed {
3027 let (Some(token), Some(root)) = (invite, state.data_root.as_deref()) else {
3028 return Some(HttpResponse::Forbidden().json(serde_json::json!({
3029 "error": "pod registration is closed"
3030 })));
3031 };
3032 let refusal =
3033 |why: &str| HttpResponse::Forbidden().json(serde_json::json!({ "error": why }));
3034 match cli::FileInviteStore::new(root).consume(token).await {
3035 Ok(cli::Consume::Granted) => {}
3036 Ok(cli::Consume::Expired) => return Some(refusal("invite has expired")),
3037 Ok(cli::Consume::Exhausted) => return Some(refusal("invite has been used")),
3038 Ok(cli::Consume::Unknown) => return Some(refusal("invalid invite code")),
3039 Err(e) => {
3040 tracing::error!("invite store: {e}");
3041 return Some(
3042 HttpResponse::InternalServerError()
3043 .json(serde_json::json!({ "error": "invite store unavailable" })),
3044 );
3045 }
3046 }
3047 }
3048 None
3049}
3050
3051fn named_pod_acls(name: &str, webid: &str) -> Vec<(String, String)> {
3062 const PREFIXES: &str = "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
3063 @prefix foaf: <http://xmlns.com/foaf/0.1/> .\n";
3064 let owner = |target: &str| {
3065 format!(
3066 "<#owner> a acl:Authorization ;\n acl:agent <{webid}> ;\n \
3067 acl:accessTo <{target}> ;\n acl:default <{target}> ;\n \
3068 acl:mode acl:Read, acl:Write, acl:Control .\n"
3069 )
3070 };
3071 let public = |target: &str, mode: &str, inherited: bool| {
3072 let default = if inherited {
3073 format!(" acl:default <{target}> ;\n")
3074 } else {
3075 String::new()
3076 };
3077 format!(
3078 "<#public> a acl:Authorization ;\n acl:agentClass foaf:Agent ;\n \
3079 acl:accessTo <{target}> ;\n{default} acl:mode {mode} .\n"
3080 )
3081 };
3082
3083 let root = format!("/{name}/");
3084 let mut acls = vec![(
3085 solid_pod_rs::wac::resolver::acl_sidecar_key(&root),
3086 format!(
3087 "{PREFIXES}{}{}",
3088 owner(&root),
3089 public(&root, "acl:Read", false)
3090 ),
3091 )];
3092 for (container, mode) in [
3093 ("profile", "acl:Read"),
3094 ("public", "acl:Read"),
3095 ("inbox", "acl:Append"),
3096 ] {
3097 let target = format!("/{name}/{container}/");
3098 acls.push((
3099 solid_pod_rs::wac::resolver::acl_sidecar_key(&target),
3100 format!(
3101 "{PREFIXES}{}{}",
3102 owner(&target),
3103 public(&target, mode, true)
3104 ),
3105 ));
3106 }
3107 acls
3108}
3109
3110async fn provision_named_pod(
3114 state: &AppState,
3115 name: &str,
3116 display_name: Option<&str>,
3117 base_uri: &str,
3118) -> Result<NamedPodOutcome, ActixError> {
3119 let base_uri = base_uri.trim_end_matches('/');
3120 let pod_uri = format!("{base_uri}/{name}/");
3121 let canonical_prefix = format!("{base_uri}/pods/{name}/");
3122 let webid = format!("{pod_uri}profile/card#me");
3123
3124 for (key, body) in named_pod_acls(name, &webid) {
3129 state
3130 .storage
3131 .put(&key, Bytes::from(body), "text/turtle")
3132 .await
3133 .map_err(to_actix)?;
3134 }
3135
3136 for container in ["", "profile", "inbox", "public", "private", "settings"] {
3137 let path = if container.is_empty() {
3138 format!("/{name}")
3139 } else {
3140 format!("/{name}/{container}")
3141 };
3142 state
3143 .storage
3144 .put(
3145 &format!("{path}.meta"),
3146 Bytes::from_static(b"{}"),
3147 "application/ld+json",
3148 )
3149 .await
3150 .map_err(to_actix)?;
3151 }
3152 let profile = solid_pod_rs::webid::generate_webid_html(name, display_name, base_uri)
3153 .replace(&canonical_prefix, &pod_uri);
3154 state
3155 .storage
3156 .put(
3157 &format!("/{name}/profile/card"),
3158 Bytes::from(profile.into_bytes()),
3159 "text/html",
3160 )
3161 .await
3162 .map_err(to_actix)?;
3163
3164 #[cfg(feature = "git")]
3165 if let Some(root) = &state.data_root {
3166 let hook = solid_pod_rs_git::init::GitAutoInit::new();
3167 if let Err(error) = hook.init_repo_at(&root.join(name)).await {
3168 tracing::warn!(pod = name, %error, "git auto-init failed after pod creation");
3169 }
3170 }
3171
3172 Ok(NamedPodOutcome { webid, pod_uri })
3173}
3174
3175async fn handle_create_pod(
3176 req: HttpRequest,
3177 state: web::Data<AppState>,
3178 body: web::Json<CreatePodRequest>,
3179) -> Result<HttpResponse, ActixError> {
3180 if let Some(response) = provisioning_gate(&req, &state, &body.name).await {
3181 return Ok(response);
3182 }
3183
3184 let pod_root = format!("/{}/", body.name);
3185 let _write_guard = lock_resource(&pod_root).await;
3190 if state.storage.exists(&pod_root).await.map_err(to_actix)? {
3191 return Ok(
3192 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
3193 );
3194 }
3195
3196 let base_uri = scheme::request_origin(&req);
3197 let outcome = provision_named_pod(&state, &body.name, None, &base_uri).await?;
3198
3199 Ok(HttpResponse::Created()
3200 .insert_header(("Location", outcome.pod_uri.clone()))
3201 .json(serde_json::json!({
3202 "name": body.name,
3203 "webId": outcome.webid,
3204 "podUri": outcome.pod_uri,
3205 })))
3206}
3207
3208fn normalise_copy_source(raw: &str, host: &str) -> Option<String> {
3218 let path = if raw.starts_with("http://") || raw.starts_with("https://") {
3219 let url = url::Url::parse(raw).ok()?;
3220 let authority = match url.port() {
3221 Some(port) => format!("{}:{port}", url.host_str()?),
3222 None => url.host_str()?.to_string(),
3223 };
3224 if !authority.eq_ignore_ascii_case(host)
3225 || url.query().is_some()
3226 || url.fragment().is_some()
3227 {
3228 return None;
3229 }
3230 url.path().to_string()
3231 } else if raw.starts_with('/') && !raw.contains(['?', '#']) {
3232 raw.to_string()
3233 } else {
3234 return None;
3235 };
3236 canonical_pod_path(&path)
3237}
3238
3239async fn handle_copy(
3240 req: HttpRequest,
3241 state: web::Data<AppState>,
3242) -> Result<HttpResponse, ActixError> {
3243 let dest = req.uri().path().to_string();
3244 let auth_pk = extract_pubkey(&req).await;
3245 let agent = agent_uri(auth_pk.as_ref());
3246 enforce_write_ctx(
3247 &state,
3248 &dest,
3249 AccessMode::Write,
3250 agent.as_deref(),
3251 req_origin(&req),
3252 )
3253 .await?;
3254
3255 let source = req
3256 .headers()
3257 .get("source")
3258 .and_then(|v| v.to_str().ok())
3259 .map(|s| s.to_string());
3260 let source = match source {
3261 Some(s) => s,
3262 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
3263 };
3264 let host = scheme::request_host(&req);
3265 let source = match normalise_copy_source(&source, &host) {
3266 Some(s) => s,
3267 None => return Ok(HttpResponse::BadRequest().body("invalid Source")),
3268 };
3269 if !state.dotfiles.is_allowed(Path::new(&source)) {
3270 return Ok(HttpResponse::Forbidden().body("dotfile path denied by allowlist"));
3271 }
3272 enforce_read_ctx(&state, &source, agent.as_deref(), req_origin(&req)).await?;
3275
3276 let (body, meta) = match state.storage.get(&source).await {
3277 Ok(v) => v,
3278 Err(PodError::NotFound(_)) => {
3279 return Ok(HttpResponse::NotFound().body("source resource not found"))
3280 }
3281 Err(e) => return Err(to_actix(e)),
3282 };
3283
3284 if protected_resource_for_acl(&dest).is_some()
3286 && !proposed_acl_keeps_caller_control(
3287 &state,
3288 &req,
3289 &dest,
3290 &body,
3291 &meta.content_type,
3292 agent.as_deref(),
3293 )
3294 .await
3295 {
3296 return Ok(HttpResponse::Conflict().body(
3297 "refused: the proposed ACL would not grant Control to the caller \
3298 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
3299 ));
3300 }
3301
3302 {
3303 let _write_guard = lock_resource(&dest).await;
3306 let quota = reserve_quota_for_size(&state, &dest, body.len() as u64).await?;
3307 let write = state.storage.put(&dest, body, &meta.content_type).await;
3308 finish_quota_reservation(&state, quota, write.is_ok()).await;
3309 write.map_err(to_actix)?;
3310 }
3311
3312 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
3317 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
3318 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
3319 let origin = req_origin(&req);
3320 let permitted = enforce_read_ctx(&state, &src_acl, agent.as_deref(), origin)
3321 .await
3322 .is_ok()
3323 && enforce_write_ctx(
3324 &state,
3325 &dst_acl,
3326 AccessMode::Write,
3327 agent.as_deref(),
3328 origin,
3329 )
3330 .await
3331 .is_ok()
3332 && proposed_acl_keeps_caller_control(
3333 &state,
3334 &req,
3335 &dst_acl,
3336 &acl_body,
3337 &acl_meta.content_type,
3338 agent.as_deref(),
3339 )
3340 .await;
3341 if permitted {
3342 let _write_guard = lock_resource(&dst_acl).await;
3343 let quota = reserve_quota_for_size(&state, &dst_acl, acl_body.len() as u64).await?;
3344 let write = state
3345 .storage
3346 .put(&dst_acl, acl_body, &acl_meta.content_type)
3347 .await;
3348 finish_quota_reservation(&state, quota, write.is_ok()).await;
3349 write.map_err(to_actix)?;
3350 }
3351 }
3352
3353 let mut rsp = HttpResponse::Created().finish();
3354 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
3355 rsp.headers_mut().insert(header::LOCATION, loc);
3356 }
3357 Ok(rsp)
3358}
3359
3360async fn handle_glob_get(
3365 req: HttpRequest,
3366 state: web::Data<AppState>,
3367) -> Result<HttpResponse, ActixError> {
3368 let raw_path = req.uri().path().to_string();
3369 if !raw_path.ends_with("/*") {
3371 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
3372 }
3373 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
3375 folder.to_string()
3376 } else {
3377 format!("{folder}/")
3378 };
3379
3380 let auth_pk = extract_pubkey(&req).await;
3384 let agent = agent_uri(auth_pk.as_ref());
3385 let origin = req_origin(&req);
3386 let audience = enforce_read_ctx(&state, &folder, agent.as_deref(), origin).await?;
3387
3388 let children = state.storage.list(&folder).await.map_err(to_actix)?;
3389 let mut merged = String::new();
3390 let mut audience = audience;
3391
3392 for child in &children {
3393 if child.ends_with('/')
3394 || child.ends_with(".acl")
3395 || child.ends_with(".meta")
3396 || child.ends_with(".prov.ttl")
3397 {
3398 continue;
3399 }
3400 let child_path = format!("{folder}{child}");
3401 match enforce_read_inner(&state, &child_path, agent.as_deref(), origin, false).await {
3404 Ok(ResponseAudience::Private) => audience = ResponseAudience::Private,
3405 Ok(ResponseAudience::Public) => {}
3406 Err(_) => continue,
3407 }
3408 if let Ok((body, meta)) = state.storage.get(&child_path).await {
3409 if meta.content_type.contains("turtle")
3410 || meta.content_type.contains("n-triples")
3411 || meta.content_type.contains("n3")
3412 {
3413 if let Ok(text) = std::str::from_utf8(&body) {
3414 merged.push_str(text);
3415 merged.push('\n');
3416 }
3417 }
3418 }
3419 }
3420
3421 if merged.is_empty() {
3422 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
3423 }
3424
3425 let mut rsp = HttpResponse::Ok().content_type("text/turtle").body(merged);
3426 set_cache_policy(&mut rsp, "text/turtle", audience);
3427 Ok(rsp)
3428}
3429
3430#[derive(Debug, Deserialize)]
3435struct LoginPasswordRequest {
3436 username: String,
3437 password: String,
3438}
3439
3440async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
3447 let _ = (&body.username, &body.password);
3448 HttpResponse::NotImplemented().json(serde_json::json!({
3449 "error": "password login is not implemented on this pod"
3450 }))
3451}
3452
3453#[derive(Debug, Deserialize)]
3454struct PasswordResetRequest {
3455 username: String,
3456}
3457
3458async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
3462 let _ = &body.username;
3463 HttpResponse::NotImplemented().json(serde_json::json!({
3464 "error": "password reset is not implemented on this pod"
3465 }))
3466}
3467
3468#[derive(Debug, Deserialize)]
3469struct PasswordChangeRequest {
3470 token: String,
3471 new_password: String,
3472}
3473
3474async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
3479 let _ = (&body.token, &body.new_password);
3480 HttpResponse::NotImplemented().json(serde_json::json!({
3481 "error": "password change is not implemented on this pod"
3482 }))
3483}
3484
3485async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
3490 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
3491 HttpResponse::Ok()
3492 .content_type("application/json")
3493 .json(body)
3494}
3495
3496pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
3511
3512#[derive(Debug, Deserialize)]
3514struct ProxyQuery {
3515 url: String,
3516}
3517
3518const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
3520 "set-cookie",
3521 "set-cookie2",
3522 "authorization",
3523 "www-authenticate",
3524 "proxy-authenticate",
3525 "proxy-authorization",
3526];
3527
3528#[allow(clippy::result_large_err)]
3547async fn validate_proxy_target(target: &str) -> Result<(url::Url, IpAddr), HttpResponse> {
3548 let parsed = match url::Url::parse(target) {
3549 Ok(u) => u,
3550 Err(_) => {
3551 return Err(
3552 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
3553 );
3554 }
3555 };
3556
3557 match parsed.scheme() {
3559 "http" | "https" => {}
3560 scheme => {
3561 return Err(HttpResponse::BadRequest()
3562 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
3563 }
3564 }
3565
3566 if solid_pod_rs::security::is_safe_url(target).is_err() {
3569 return Err(HttpResponse::Forbidden()
3570 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
3571 }
3572
3573 let host = match parsed.host_str() {
3575 Some(h) => h.to_string(),
3576 None => {
3577 return Err(HttpResponse::BadRequest()
3578 .json(serde_json::json!({"error": "target URL has no host"})))
3579 }
3580 };
3581 let host_lower = host.to_ascii_lowercase();
3582 if host_lower == "localhost"
3583 || host_lower.ends_with(".localhost")
3584 || host_lower == "0.0.0.0"
3585 || host_lower == "[::1]"
3586 || host_lower == "[::0]"
3587 {
3588 return Err(HttpResponse::Forbidden()
3589 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
3590 }
3591
3592 match solid_pod_rs::security::resolve_and_check(&host).await {
3595 Ok(ip) => Ok((parsed, ip)),
3596 Err(_) => Err(HttpResponse::Forbidden()
3597 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"}))),
3598 }
3599}
3600
3601fn build_pinned_proxy_client(url: &url::Url, ip: IpAddr) -> Result<reqwest::Client, ActixError> {
3604 let mut builder = reqwest::Client::builder()
3605 .redirect(reqwest::redirect::Policy::none());
3608 if let Some(host) = url.host_str() {
3609 let port = url.port_or_known_default().unwrap_or(0);
3611 builder = builder.resolve(host, std::net::SocketAddr::new(ip, port));
3612 }
3613 builder
3614 .build()
3615 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))
3616}
3617
3618async fn handle_proxy(
3619 req: HttpRequest,
3620 _state: web::Data<AppState>,
3621 query: web::Query<ProxyQuery>,
3622) -> Result<HttpResponse, ActixError> {
3623 let auth_pk = extract_pubkey(&req).await;
3625 let agent = agent_uri(auth_pk.as_ref());
3626 if agent.is_none() {
3627 return Ok(HttpResponse::Unauthorized()
3628 .json(serde_json::json!({"error": "authentication required"})));
3629 }
3630
3631 let mut current_url = query.url.clone();
3632 let mut redirect_count = 0u8;
3633 const MAX_REDIRECTS: u8 = 5;
3634 const TOTAL_TIMEOUT: Duration = Duration::from_secs(30);
3635 let started = Instant::now();
3636
3637 let byte_cap = std::env::var("PROXY_BYTE_CAP")
3638 .ok()
3639 .and_then(|v| {
3640 solid_pod_rs::config::sources::parse_size(&v)
3641 .map(|u| u as usize)
3642 .ok()
3643 })
3644 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
3645
3646 loop {
3647 let Some(remaining) = TOTAL_TIMEOUT.checked_sub(started.elapsed()) else {
3648 return Ok(HttpResponse::GatewayTimeout()
3649 .json(serde_json::json!({"error": "proxy operation timed out"})));
3650 };
3651 let (target_url, pinned_ip) = match validate_proxy_target(¤t_url).await {
3655 Ok(pair) => pair,
3656 Err(rsp) => return Ok(rsp),
3657 };
3658 let client = build_pinned_proxy_client(&target_url, pinned_ip)?;
3659
3660 let mut upstream_req = client.get(¤t_url).timeout(remaining);
3661
3662 if let Some(auth_val) = req
3664 .headers()
3665 .get("x-upstream-authorization")
3666 .and_then(|v| v.to_str().ok())
3667 {
3668 upstream_req = upstream_req.header("Authorization", auth_val);
3669 }
3670
3671 let response = upstream_req
3672 .send()
3673 .await
3674 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
3675
3676 if response.status().is_redirection() {
3678 if redirect_count >= MAX_REDIRECTS {
3679 return Ok(HttpResponse::BadGateway()
3680 .json(serde_json::json!({"error": "too many redirects"})));
3681 }
3682 if let Some(location) = response.headers().get("location") {
3683 let loc_str = location
3684 .to_str()
3685 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
3686 let base = url::Url::parse(¤t_url)
3688 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
3689 let resolved = base
3690 .join(loc_str)
3691 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
3692 current_url = resolved.to_string();
3693 redirect_count += 1;
3694 continue;
3695 }
3696 return Ok(HttpResponse::BadGateway()
3697 .json(serde_json::json!({"error": "redirect without location"})));
3698 }
3699
3700 let upstream_status = response.status().as_u16();
3702 let upstream_content_type = response
3703 .headers()
3704 .get("content-type")
3705 .and_then(|v| v.to_str().ok())
3706 .unwrap_or("application/octet-stream")
3707 .to_string();
3708 if response
3709 .content_length()
3710 .is_some_and(|length| length > byte_cap as u64)
3711 {
3712 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3713 "error": "proxied response exceeds byte cap",
3714 "limit": byte_cap
3715 })));
3716 }
3717
3718 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
3720 for (name, value) in response.headers() {
3721 let name_lower = name.as_str().to_ascii_lowercase();
3722 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
3723 continue;
3724 }
3725 if matches!(
3727 name_lower.as_str(),
3728 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
3729 ) {
3730 continue;
3731 }
3732 if let Ok(val_str) = value.to_str() {
3733 forwarded_headers.push((name_lower, val_str.to_string()));
3734 }
3735 }
3736
3737 let mut stream = response.bytes_stream();
3738 let mut body_bytes = Vec::with_capacity(byte_cap.min(64 * 1024));
3739 while let Some(chunk) = stream.next().await {
3740 let chunk =
3741 chunk.map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
3742 if body_bytes.len().saturating_add(chunk.len()) > byte_cap {
3743 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3744 "error": "proxied response exceeds byte cap",
3745 "limit": byte_cap
3746 })));
3747 }
3748 body_bytes.extend_from_slice(&chunk);
3749 }
3750
3751 let mut rsp = HttpResponse::build(
3753 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3754 );
3755 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
3756 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
3757
3758 for (name, value) in &forwarded_headers {
3760 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
3761 if let Ok(hval) = header::HeaderValue::from_str(value) {
3762 rsp.insert_header((hname, hval));
3763 }
3764 }
3765 }
3766
3767 return Ok(rsp.body(body_bytes));
3768 }
3769}
3770
3771pub struct PathTraversalGuard;
3777
3778impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
3779where
3780 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3781 B: 'static,
3782{
3783 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3784 type Error = ActixError;
3785 type InitError = ();
3786 type Transform = PathTraversalGuardMiddleware<S>;
3787 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3788
3789 fn new_transform(&self, service: S) -> Self::Future {
3790 ready(Ok(PathTraversalGuardMiddleware { service }))
3791 }
3792}
3793
3794pub struct PathTraversalGuardMiddleware<S> {
3796 service: S,
3797}
3798
3799impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
3800where
3801 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3802 B: 'static,
3803{
3804 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3805 type Error = ActixError;
3806 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3807
3808 actix_web::dev::forward_ready!(service);
3809
3810 fn call(&self, req: ServiceRequest) -> Self::Future {
3811 let raw = req.path().to_string();
3814 if path_is_traversal(&raw) {
3815 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
3816 let sr = req.into_response(rsp.map_into_boxed_body());
3817 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3818 }
3819 let fut = self.service.call(req);
3820 Box::pin(async move {
3821 let resp = fut.await?;
3822 Ok(resp.map_into_left_body())
3823 })
3824 }
3825}
3826
3827fn path_is_traversal(path: &str) -> bool {
3828 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
3830 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
3831 for seg in once.split('/').chain(twice.split('/')) {
3832 if seg == ".." || seg == "." {
3833 return true;
3834 }
3835 }
3836 twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..")
3839}
3840
3841pub struct CorsHeaders {
3852 pub allowed_origins: Arc<Vec<String>>,
3853}
3854
3855impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
3856where
3857 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3858 B: 'static,
3859{
3860 type Response = ServiceResponse<B>;
3861 type Error = ActixError;
3862 type InitError = ();
3863 type Transform = CorsHeadersMiddleware<S>;
3864 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3865
3866 fn new_transform(&self, service: S) -> Self::Future {
3867 ready(Ok(CorsHeadersMiddleware {
3868 service,
3869 allowed_origins: self.allowed_origins.clone(),
3870 }))
3871 }
3872}
3873
3874pub struct CorsHeadersMiddleware<S> {
3876 service: S,
3877 allowed_origins: Arc<Vec<String>>,
3878}
3879
3880impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
3881where
3882 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3883 B: 'static,
3884{
3885 type Response = ServiceResponse<B>;
3886 type Error = ActixError;
3887 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3888
3889 actix_web::dev::forward_ready!(service);
3890
3891 fn call(&self, req: ServiceRequest) -> Self::Future {
3892 let origin = req
3893 .headers()
3894 .get(header::ORIGIN)
3895 .and_then(|v| v.to_str().ok())
3896 .map(str::to_string);
3897 let allowed = self.allowed_origins.clone();
3898 let fut = self.service.call(req);
3899 Box::pin(async move {
3900 let mut resp = fut.await?;
3901 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
3902 Ok(resp)
3903 })
3904 }
3905}
3906
3907pub(crate) const CORS_EXPOSE_HEADERS: &str = "Accept-Patch, Accept-Post, Accept-Query, Accept-Ranges, Allow, Content-Length, Content-Range, Content-Type, ETag, Last-Modified, Link, Location, Updates-Via, WAC-Allow, WWW-Authenticate, X-Cost, X-Balance, X-Pay-Currency";
3915
3916fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
3917 if headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
3927 return;
3928 }
3929 let (origin_value, allow_credentials): (String, bool) = if allowed.is_empty() {
3940 ("*".to_string(), false)
3941 } else {
3942 match origin.filter(|o| allowed.iter().any(|a| a == *o)) {
3943 Some(o) => (o.to_string(), true),
3944 None => return,
3947 }
3948 };
3949
3950 let mut pairs = vec![
3951 ("access-control-allow-origin", origin_value.as_str()),
3952 (
3953 "access-control-allow-methods",
3954 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
3955 ),
3956 (
3957 "access-control-allow-headers",
3958 "Accept, Authorization, Content-Type, DPoP, Git-Protocol, If-Match, If-None-Match, Link, Range, Slug, Origin",
3959 ),
3960 (
3961 "access-control-expose-headers",
3962 CORS_EXPOSE_HEADERS,
3963 ),
3964 ("access-control-max-age", "86400"),
3965 ];
3966 if allow_credentials {
3969 pairs.push(("access-control-allow-credentials", "true"));
3970 }
3971
3972 for (name, value) in pairs {
3973 if let (Ok(name), Ok(value)) = (
3974 header::HeaderName::from_lowercase(name.as_bytes()),
3975 header::HeaderValue::from_str(value),
3976 ) {
3977 headers.insert(name, value);
3978 }
3979 }
3980}
3981
3982pub struct ErrorLoggingMiddleware;
3998
3999impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
4000where
4001 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
4002 B: 'static,
4003{
4004 type Response = ServiceResponse<B>;
4005 type Error = ActixError;
4006 type InitError = ();
4007 type Transform = ErrorLoggingMiddlewareService<S>;
4008 type Future = Ready<Result<Self::Transform, Self::InitError>>;
4009
4010 fn new_transform(&self, service: S) -> Self::Future {
4011 ready(Ok(ErrorLoggingMiddlewareService { service }))
4012 }
4013}
4014
4015pub struct ErrorLoggingMiddlewareService<S> {
4017 service: S,
4018}
4019
4020impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
4021where
4022 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
4023 B: 'static,
4024{
4025 type Response = ServiceResponse<B>;
4026 type Error = ActixError;
4027 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
4028
4029 actix_web::dev::forward_ready!(service);
4030
4031 fn call(&self, req: ServiceRequest) -> Self::Future {
4032 let method = req.method().as_str().to_string();
4035 let path = req.path().to_string();
4036
4037 let fut = self.service.call(req);
4038 Box::pin(async move {
4039 let response = fut.await?;
4040 let status = response.status();
4041 if status.is_server_error() {
4042 log_5xx(&method, &path, status, response.response().error());
4043 }
4044 Ok(response)
4045 })
4046 }
4047}
4048
4049fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
4053 let chain = match error {
4057 Some(e) => format_error_chain(e),
4058 None => "<no error attached to response>".to_string(),
4059 };
4060
4061 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
4062 Some(std::backtrace::Backtrace::force_capture().to_string())
4063 } else {
4064 None
4065 };
4066
4067 tracing::error!(
4068 target: "solid_pod_rs_server::http",
4069 method = %method,
4070 path = %path,
4071 status = %status.as_u16(),
4072 error.chain = %chain,
4073 backtrace = backtrace.as_deref().unwrap_or(""),
4074 "5xx response"
4075 );
4076}
4077
4078fn format_error_chain(e: &actix_web::Error) -> String {
4089 let summary = format!("{}", e.as_response_error());
4090 let debug = format!("{e:?}");
4091 if debug == summary || debug.is_empty() {
4092 summary
4093 } else {
4094 format!("{summary} -> {debug}")
4095 }
4096}
4097
4098pub struct DotfileGuard {
4104 allow: Arc<DotfileAllowlist>,
4105 hosts: Option<Arc<HostPolicy>>,
4106}
4107
4108pub struct HostPolicy {
4115 pub allowed: Vec<String>,
4117 pub trust_proxy: bool,
4119}
4120
4121impl DotfileGuard {
4122 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
4123 Self { allow, hosts: None }
4124 }
4125
4126 pub fn with_host_policy(mut self, policy: HostPolicy) -> Self {
4128 if !policy.allowed.is_empty() {
4129 self.hosts = Some(Arc::new(policy));
4130 }
4131 self
4132 }
4133}
4134
4135impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
4136where
4137 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
4138 B: 'static,
4139{
4140 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
4141 type Error = ActixError;
4142 type InitError = ();
4143 type Transform = DotfileGuardMiddleware<S>;
4144 type Future = Ready<Result<Self::Transform, Self::InitError>>;
4145
4146 fn new_transform(&self, service: S) -> Self::Future {
4147 ready(Ok(DotfileGuardMiddleware {
4148 service,
4149 allow: self.allow.clone(),
4150 hosts: self.hosts.clone(),
4151 }))
4152 }
4153}
4154
4155pub struct DotfileGuardMiddleware<S> {
4157 service: S,
4158 allow: Arc<DotfileAllowlist>,
4159 hosts: Option<Arc<HostPolicy>>,
4160}
4161
4162impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
4163where
4164 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
4165 B: 'static,
4166{
4167 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
4168 type Error = ActixError;
4169 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
4170
4171 actix_web::dev::forward_ready!(service);
4172
4173 fn call(&self, req: ServiceRequest) -> Self::Future {
4174 if let Some(p) = &self.hosts {
4175 let host = scheme::host_with(req.request(), None, p.trust_proxy);
4176 if !scheme::host_allowed(&host, &p.allowed) {
4177 let rsp = HttpResponse::build(actix_web::http::StatusCode::MISDIRECTED_REQUEST)
4178 .body("host not served here");
4179 let sr = req.into_response(rsp.map_into_boxed_body());
4180 return Box::pin(async move { Ok(sr.map_into_right_body()) });
4181 }
4182 }
4183 let path = req.path().to_string();
4184 let allow_system_route =
4191 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
4192 if !allow_system_route {
4193 let pb = PathBuf::from(&path);
4194 if !self.allow.is_allowed(Path::new(&pb)) {
4195 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
4196 let sr = req.into_response(rsp.map_into_boxed_body());
4197 return Box::pin(async move { Ok(sr.map_into_right_body()) });
4198 }
4199 }
4200 let fut = self.service.call(req);
4201 Box::pin(async move {
4202 let resp = fut.await?;
4203 Ok(resp.map_into_left_body())
4204 })
4205 }
4206}
4207
4208#[cfg(feature = "git")]
4213pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
4214 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
4215 return None;
4216 }
4217 state.data_root.as_ref().map(|root| root.join(pubkey))
4218}
4219
4220#[cfg(feature = "git")]
4250async fn git_mark_write(
4251 state: &AppState,
4252 resource_path: &str,
4253 agent: Option<&str>,
4254 message: &str,
4255) -> ProvenanceReceipt {
4256 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
4257 use solid_pod_rs_git::mark::ShellGitMarker;
4258
4259 if resource_path.ends_with(".acl")
4262 || resource_path.ends_with(".meta")
4263 || resource_path.ends_with(".prov.ttl")
4264 {
4265 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::ExcludedPath);
4266 }
4267 if resource_path.ends_with('/') {
4269 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::Container);
4270 }
4271
4272 let Some(data_root) = state.data_root.as_ref() else {
4274 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured);
4275 };
4276
4277 let trimmed = resource_path.trim_start_matches('/');
4279 let mut segments = trimmed.splitn(2, '/');
4280 let pod = segments.next().unwrap_or("");
4281 let rel = segments.next().unwrap_or("");
4282 if pod.is_empty() || rel.is_empty() {
4283 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::UnresolvablePath);
4284 }
4285 let repo = data_root.join(pod);
4286
4287 if !repo.join(".git").is_dir() {
4291 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotGitBacked);
4292 }
4293
4294 let agent_did = agent.unwrap_or("urn:solid:anonymous");
4295 let created = std::time::SystemTime::now()
4296 .duration_since(std::time::UNIX_EPOCH)
4297 .map(|d| d.as_secs())
4298 .unwrap_or(0);
4299
4300 let (policy, ticker_override) =
4303 handlers::prov::resolve_anchor_policy(state, resource_path).await;
4304
4305 let marker = std::sync::Arc::new(ShellGitMarker::new());
4310 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
4311 None
4312 } else {
4313 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
4314 };
4315 let (log, ticker, network) = match &anchorer_bundle {
4316 Some((anchorer, ticker, network)) => (
4317 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
4318 ticker.clone(),
4319 network.clone(),
4320 ),
4321 None => (
4323 ProvenanceLog::new(marker.clone()),
4324 String::new(),
4325 String::new(),
4326 ),
4327 };
4328
4329 let record_policy = match policy {
4333 AnchorPolicy::Epoch => AnchorPolicy::Never,
4334 other => other,
4335 };
4336 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
4337
4338 let write_record = solid_pod_rs::provenance::WriteRecord {
4342 repo: &repo,
4343 path: rel,
4344 agent_did,
4345 message,
4346 policy: record_policy,
4347 high_value,
4348 ticker: &ticker,
4349 network: &network,
4350 created,
4351 };
4352 let mut receipt = log.record_receipt(write_record).await;
4357 receipt.resource = resource_path.to_string();
4360
4361 let Some(git) = receipt.mark.clone() else {
4362 tracing::warn!(
4366 target: "solid_pod_rs_server::git_mark",
4367 resource = %resource_path,
4368 "provenance record failed (write already succeeded): {}",
4369 receipt.mark_error.as_deref().unwrap_or("unspecified")
4370 );
4371 return receipt;
4372 };
4373 if let Some(err) = &receipt.anchor_error {
4374 tracing::warn!(
4377 target: "solid_pod_rs_server::git_mark",
4378 resource = %resource_path,
4379 commit = %git.commit_sha,
4380 "block-trail anchor failed (git-mark stands): {err}"
4381 );
4382 }
4383 let mark = solid_pod_rs::provenance::ProvenanceMark {
4384 resource: resource_path.to_string(),
4385 git: git.clone(),
4386 anchor: receipt.anchor.clone(),
4387 agent_did: agent_did.to_string(),
4388 created,
4389 };
4390
4391 if matches!(policy, AnchorPolicy::Epoch) {
4395 if let Some((anchorer, _, _)) = &anchorer_bundle {
4396 match handlers::prov::epoch_push_and_maybe_anchor(
4397 state,
4398 anchorer,
4399 &ticker,
4400 &network,
4401 &mark.git.commit_sha,
4402 )
4403 .await
4404 {
4405 Ok(Some(closed)) => tracing::debug!(
4406 target: "solid_pod_rs_server::git_mark",
4407 root = %closed.root,
4408 n = closed.commits.len(),
4409 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
4410 ),
4411 Ok(None) => {}
4412 Err(e) => tracing::warn!(
4413 target: "solid_pod_rs_server::git_mark",
4414 "epoch batch/anchor failed (swallowed): {e}"
4415 ),
4416 }
4417 }
4418 }
4419
4420 let ttl = prov_ttl(&mark);
4425 let sidecar = format!("{resource_path}.prov.ttl");
4426 if let Err(e) = state
4427 .storage
4428 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
4429 .await
4430 {
4431 tracing::warn!(
4432 target: "solid_pod_rs_server::git_mark",
4433 sidecar = %sidecar,
4434 "provenance sidecar write failed: {e}"
4435 );
4436 receipt.mark_error = Some(format!("PROV-O sidecar write failed: {e}"));
4439 return receipt;
4440 }
4441
4442 tracing::debug!(
4443 target: "solid_pod_rs_server::git_mark",
4444 resource = %resource_path,
4445 commit = %mark.git.commit_sha,
4446 stage = %receipt.stage(),
4447 "provenance recorded"
4448 );
4449 receipt
4450}
4451
4452#[cfg(not(feature = "git"))]
4455#[inline]
4456async fn git_mark_write(
4457 _state: &AppState,
4458 resource_path: &str,
4459 _agent: Option<&str>,
4460 _message: &str,
4461) -> ProvenanceReceipt {
4462 ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured)
4463}
4464
4465#[cfg(feature = "git")]
4466pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
4467 require_pod_owner_with_body(req, pod_pubkey, None).await
4468}
4469
4470#[cfg(feature = "git")]
4471pub(crate) async fn require_pod_owner_with_body(
4472 req: &HttpRequest,
4473 pod_pubkey: &str,
4474 body: Option<&[u8]>,
4475) -> Option<String> {
4476 let caller = extract_pubkey_with_body(req, body).await?;
4477 if caller != pod_pubkey {
4478 return None;
4479 }
4480 Some(caller)
4481}
4482
4483#[cfg(feature = "git")]
4484fn git_json_err(msg: &str, status: u16) -> HttpResponse {
4485 HttpResponse::build(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
4486 .content_type("application/json")
4487 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
4488}
4489
4490#[cfg(feature = "git")]
4492#[derive(serde::Deserialize)]
4493struct GitStageBody {
4494 paths: Option<Vec<String>>,
4495 all: Option<bool>,
4496}
4497
4498#[cfg(feature = "git")]
4499#[derive(serde::Deserialize)]
4500struct GitCommitBody {
4501 message: String,
4502 author_name: Option<String>,
4503 author_email: Option<String>,
4504}
4505
4506#[cfg(feature = "git")]
4507#[derive(serde::Deserialize)]
4508struct GitBranchBody {
4509 name: String,
4510}
4511
4512#[cfg(feature = "git")]
4515async fn handle_git_status(
4516 path: web::Path<String>,
4517 req: HttpRequest,
4518 state: web::Data<AppState>,
4519) -> HttpResponse {
4520 let pubkey = path.into_inner();
4521 if require_pod_owner(&req, &pubkey).await.is_none() {
4522 return git_json_err("Authentication required", 401);
4523 }
4524 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4525 return git_json_err("Git not available (no FS backend)", 501);
4526 };
4527 match solid_pod_rs_git::api::git_status(&repo).await {
4528 Ok(s) => HttpResponse::Ok()
4529 .content_type("application/json")
4530 .body(serde_json::to_string(&s).unwrap_or_default()),
4531 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4532 }
4533}
4534
4535#[cfg(feature = "git")]
4536async fn handle_git_log(
4537 path: web::Path<String>,
4538 req: HttpRequest,
4539 state: web::Data<AppState>,
4540 query: web::Query<std::collections::HashMap<String, String>>,
4541) -> HttpResponse {
4542 let pubkey = path.into_inner();
4543 if require_pod_owner(&req, &pubkey).await.is_none() {
4544 return git_json_err("Authentication required", 401);
4545 }
4546 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4547 return git_json_err("Git not available (no FS backend)", 501);
4548 };
4549 let limit: u32 = query
4550 .get("limit")
4551 .and_then(|v| v.parse().ok())
4552 .unwrap_or(20);
4553 match solid_pod_rs_git::api::git_log(&repo, limit).await {
4554 Ok(entries) => HttpResponse::Ok()
4555 .content_type("application/json")
4556 .body(serde_json::to_string(&entries).unwrap_or_default()),
4557 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4558 }
4559}
4560
4561#[cfg(feature = "git")]
4562async fn handle_git_diff(
4563 path: web::Path<String>,
4564 req: HttpRequest,
4565 state: web::Data<AppState>,
4566 query: web::Query<std::collections::HashMap<String, String>>,
4567) -> HttpResponse {
4568 let pubkey = path.into_inner();
4569 if require_pod_owner(&req, &pubkey).await.is_none() {
4570 return git_json_err("Authentication required", 401);
4571 }
4572 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4573 return git_json_err("Git not available (no FS backend)", 501);
4574 };
4575 let file_path = query.get("path").map(String::as_str);
4576 let staged = query
4577 .get("staged")
4578 .map(|v| v == "true" || v == "1")
4579 .unwrap_or(false);
4580 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
4581 Ok(diff) => HttpResponse::Ok().content_type("text/plain").body(diff),
4582 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4583 }
4584}
4585
4586#[cfg(feature = "git")]
4587async fn handle_git_stage(
4588 path: web::Path<String>,
4589 req: HttpRequest,
4590 state: web::Data<AppState>,
4591 body: web::Bytes,
4592) -> HttpResponse {
4593 let pubkey = path.into_inner();
4594 if require_pod_owner(&req, &pubkey).await.is_none() {
4595 return git_json_err("Authentication required", 401);
4596 }
4597 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4598 return git_json_err("Git not available (no FS backend)", 501);
4599 };
4600 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4601 Ok(v) => v,
4602 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4603 };
4604 let paths = parsed.paths.unwrap_or_default();
4605 let all = parsed.all.unwrap_or(false);
4606 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
4607 Ok(()) => HttpResponse::Ok()
4608 .content_type("application/json")
4609 .body(r#"{"ok":true}"#),
4610 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4611 }
4612}
4613
4614#[cfg(feature = "git")]
4615async fn handle_git_unstage(
4616 path: web::Path<String>,
4617 req: HttpRequest,
4618 state: web::Data<AppState>,
4619 body: web::Bytes,
4620) -> HttpResponse {
4621 let pubkey = path.into_inner();
4622 if require_pod_owner(&req, &pubkey).await.is_none() {
4623 return git_json_err("Authentication required", 401);
4624 }
4625 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4626 return git_json_err("Git not available (no FS backend)", 501);
4627 };
4628 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4629 Ok(v) => v,
4630 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4631 };
4632 let paths = parsed.paths.unwrap_or_default();
4633 let all = parsed.all.unwrap_or(false);
4634 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
4635 Ok(()) => HttpResponse::Ok()
4636 .content_type("application/json")
4637 .body(r#"{"ok":true}"#),
4638 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4639 }
4640}
4641
4642#[cfg(feature = "git")]
4643async fn handle_git_commit(
4644 path: web::Path<String>,
4645 req: HttpRequest,
4646 state: web::Data<AppState>,
4647 body: web::Bytes,
4648) -> HttpResponse {
4649 let pubkey = path.into_inner();
4650 if require_pod_owner(&req, &pubkey).await.is_none() {
4651 return git_json_err("Authentication required", 401);
4652 }
4653 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4654 return git_json_err("Git not available (no FS backend)", 501);
4655 };
4656 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
4657 Ok(v) => v,
4658 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4659 };
4660 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
4661 let author_email = parsed
4662 .author_email
4663 .as_deref()
4664 .unwrap_or("pod@dreamlab-ai.com");
4665 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email).await
4666 {
4667 Ok(result) => HttpResponse::Ok()
4668 .content_type("application/json")
4669 .body(serde_json::to_string(&result).unwrap_or_default()),
4670 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4671 }
4672}
4673
4674#[cfg(feature = "git")]
4675async fn handle_git_branches(
4676 path: web::Path<String>,
4677 req: HttpRequest,
4678 state: web::Data<AppState>,
4679) -> HttpResponse {
4680 let pubkey = path.into_inner();
4681 if require_pod_owner(&req, &pubkey).await.is_none() {
4682 return git_json_err("Authentication required", 401);
4683 }
4684 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4685 return git_json_err("Git not available (no FS backend)", 501);
4686 };
4687 match solid_pod_rs_git::api::git_branches(&repo).await {
4688 Ok(info) => HttpResponse::Ok()
4689 .content_type("application/json")
4690 .body(serde_json::to_string(&info).unwrap_or_default()),
4691 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4692 }
4693}
4694
4695#[cfg(feature = "git")]
4696async fn handle_git_create_branch(
4697 path: web::Path<String>,
4698 req: HttpRequest,
4699 state: web::Data<AppState>,
4700 body: web::Bytes,
4701) -> HttpResponse {
4702 let pubkey = path.into_inner();
4703 if require_pod_owner(&req, &pubkey).await.is_none() {
4704 return git_json_err("Authentication required", 401);
4705 }
4706 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4707 return git_json_err("Git not available (no FS backend)", 501);
4708 };
4709 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
4710 Ok(v) => v,
4711 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4712 };
4713 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
4714 Ok(()) => HttpResponse::Ok()
4715 .content_type("application/json")
4716 .body(r#"{"ok":true}"#),
4717 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4718 }
4719}
4720
4721#[cfg(feature = "git")]
4722async fn handle_git_discard(
4723 path: web::Path<String>,
4724 req: HttpRequest,
4725 state: web::Data<AppState>,
4726 body: web::Bytes,
4727) -> HttpResponse {
4728 let pubkey = path.into_inner();
4729 if require_pod_owner(&req, &pubkey).await.is_none() {
4730 return git_json_err("Authentication required", 401);
4731 }
4732 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4733 return git_json_err("Git not available (no FS backend)", 501);
4734 };
4735 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4736 Ok(v) => v,
4737 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4738 };
4739 let paths = parsed.paths.unwrap_or_default();
4740 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
4741 Ok(()) => HttpResponse::Ok()
4742 .content_type("application/json")
4743 .body(r#"{"ok":true}"#),
4744 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4745 }
4746}
4747
4748async fn handle_git_panel_options(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
4756 let origin = req
4757 .headers()
4758 .get(header::ORIGIN)
4759 .and_then(|v| v.to_str().ok())
4760 .map(str::to_string);
4761
4762 let mut rsp = HttpResponse::NoContent().finish();
4763 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
4764 rsp
4765}
4766
4767async fn handle_admin_provision(
4784 req: HttpRequest,
4785 state: web::Data<AppState>,
4786 path: web::Path<String>,
4787) -> HttpResponse {
4788 let expected = match &state.admin_key {
4790 Some(k) => k.clone(),
4791 None => {
4792 return HttpResponse::Forbidden().json(serde_json::json!({
4793 "error": "admin key not configured on this server"
4794 }));
4795 }
4796 };
4797 let provided = req
4798 .headers()
4799 .get("x-pod-admin-key")
4800 .and_then(|v| v.to_str().ok())
4801 .unwrap_or("");
4802 use subtle::ConstantTimeEq;
4807 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
4808 if !bool::from(key_match) {
4809 return HttpResponse::Forbidden().json(serde_json::json!({"error": "invalid admin key"}));
4810 }
4811
4812 let pubkey = path.into_inner();
4814 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
4815 return HttpResponse::BadRequest()
4816 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
4817 }
4818
4819 let data_root = match &state.data_root {
4821 Some(r) => r.clone(),
4822 None => {
4823 return HttpResponse::InternalServerError().json(serde_json::json!({
4824 "error": "server has no fs-backend storage configured"
4825 }));
4826 }
4827 };
4828
4829 let pods_root = data_root.join("pods");
4842 let pod_dir = pods_root.join(&pubkey);
4843
4844 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
4846 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
4847 return HttpResponse::InternalServerError()
4848 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
4849 }
4850
4851 let acl_content = format!(
4861 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
4862 <#owner> a acl:Authorization ;\n\
4863 acl:agent <did:nostr:{pubkey}> ;\n\
4864 acl:accessTo </pods/{pubkey}/> ;\n\
4865 acl:default </pods/{pubkey}/> ;\n\
4866 acl:mode acl:Read, acl:Write, acl:Control .\n"
4867 );
4868
4869 let sibling_acl_path = pods_root.join(format!("{pubkey}.acl"));
4879 if !sibling_acl_path.exists() {
4880 if let Err(e) = tokio::fs::write(&sibling_acl_path, acl_content.as_bytes()).await {
4881 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write sibling .acl failed");
4882 return HttpResponse::InternalServerError()
4883 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
4884 }
4885 }
4886
4887 let inner_acl_path = pod_dir.join(".acl");
4894 if !inner_acl_path.exists() {
4895 if let Err(e) = tokio::fs::write(&inner_acl_path, acl_content.as_bytes()).await {
4896 tracing::warn!(pubkey = %pubkey, error = %e, "/_admin/provision: write inner .acl failed (non-fatal; sibling ACL governs)");
4897 }
4898 }
4899
4900 #[cfg(feature = "git")]
4902 {
4903 if !pod_dir.join(".git").exists() {
4905 let init_out = crate::hardened_git::hardened_git_async()
4906 .args(["init", "-b", "main", pod_dir.to_str().unwrap_or(".")])
4907 .output()
4908 .await;
4909
4910 match init_out {
4911 Ok(out) if out.status.success() => {}
4912 Ok(out) => {
4913 let stderr = String::from_utf8_lossy(&out.stderr);
4914 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
4915 }
4916 Err(e) => {
4917 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
4918 }
4919 }
4920
4921 let cfg_out = crate::hardened_git::hardened_git_async()
4924 .args([
4925 "-C",
4926 pod_dir.to_str().unwrap_or("."),
4927 "config",
4928 "receive.denyCurrentBranch",
4929 "updateInstead",
4930 ])
4931 .output()
4932 .await;
4933
4934 if let Err(e) = cfg_out {
4935 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
4936 }
4937 }
4938 }
4939
4940 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
4942 HttpResponse::Ok().json(serde_json::json!({
4943 "podUrl": format!("{base_url}/pods/{pubkey}/"),
4944 "ok": true,
4945 }))
4946}
4947
4948async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
4953 let Some(ref data_root) = state.data_root else {
4954 return HttpResponse::Ok()
4955 .content_type("application/json")
4956 .json(serde_json::json!({"apps": [], "count": 0}));
4957 };
4958
4959 let server_url = state.nodeinfo.base_url.clone();
4960
4961 let mut read_dir = match tokio::fs::read_dir(data_root).await {
4963 Ok(rd) => rd,
4964 Err(_) => {
4965 return HttpResponse::Ok()
4966 .content_type("application/json")
4967 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
4968 }
4969 };
4970
4971 let mut apps: Vec<serde_json::Value> = Vec::new();
4972 let mut scanned = 0usize;
4973
4974 while scanned < 1000 {
4975 let entry = match read_dir.next_entry().await {
4976 Ok(Some(e)) => e,
4977 Ok(None) => break,
4978 Err(_) => break,
4979 };
4980
4981 let file_type = match entry.file_type().await {
4982 Ok(ft) => ft,
4983 Err(_) => continue,
4984 };
4985 if !file_type.is_dir() {
4986 continue;
4987 }
4988
4989 scanned += 1;
4990
4991 let manifest_path = entry.path().join("apps").join("manifest.json");
4992 let contents = match tokio::fs::read(&manifest_path).await {
4993 Ok(c) => c,
4994 Err(_) => continue,
4995 };
4996
4997 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
4998 Ok(v) => v,
4999 Err(_) => continue,
5000 };
5001
5002 if let Some(pod_name) = entry.file_name().to_str() {
5004 if manifest.get("podOwner").is_none() {
5005 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
5006 }
5007 }
5008
5009 apps.push(manifest);
5010 }
5011
5012 let count = apps.len();
5013 HttpResponse::Ok()
5014 .content_type("application/json")
5015 .json(serde_json::json!({
5016 "apps": apps,
5017 "serverUrl": server_url,
5018 "count": count,
5019 }))
5020}
5021
5022#[allow(dead_code)]
5035fn is_git_request(path: &str) -> bool {
5036 path.contains("/info/refs")
5037 || path.contains("/git-upload-pack")
5038 || path.contains("/git-receive-pack")
5039}
5040
5041#[allow(dead_code)]
5044fn is_dot_git_path(path: &str) -> bool {
5045 path.contains("/.git/") || path.ends_with("/.git")
5046}
5047
5048#[cfg(feature = "git")]
5049async fn handle_git(
5050 req: HttpRequest,
5051 body: web::Bytes,
5052 state: web::Data<AppState>,
5053) -> HttpResponse {
5054 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
5055 use solid_pod_rs_git::service::GitRequest;
5056
5057 let path = req.uri().path().to_string();
5058
5059 let pod_name = path
5062 .trim_start_matches('/')
5063 .split('/')
5064 .next()
5065 .unwrap_or("")
5066 .to_string();
5067 let Some(ref data_root) = state.data_root else {
5068 return HttpResponse::NotImplemented().json(serde_json::json!({
5069 "error": "git requires fs-backend storage",
5070 "reason": "data_root_not_configured"
5071 }));
5072 };
5073 let repo_root = data_root.join(&pod_name);
5074 if !repo_root.exists() {
5075 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
5076 }
5077
5078 let query = req.uri().query().unwrap_or("").to_string();
5079 let host_url = Some(scheme::request_origin(&req));
5080 let headers: Vec<(String, String)> = req
5081 .headers()
5082 .iter()
5083 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
5084 .collect();
5085
5086 let git_req = GitRequest {
5087 method: req.method().as_str().to_string(),
5088 path,
5089 query,
5090 headers,
5091 body,
5092 host_url,
5093 };
5094
5095 if git_req.is_write() {
5101 let mut builder = HttpResponse::Forbidden();
5102 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
5103 builder.insert_header((k, v));
5104 }
5105 return builder.json(serde_json::json!({
5106 "error": "git push is not served by this server",
5107 "reason": "push_not_served"
5108 }));
5109 }
5110
5111 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
5122 Ok(pk) => Some(format!("did:nostr:{pk}")),
5123 Err(_) => None,
5124 };
5125 let wac_path = format!("/{pod_name}/");
5126 let origin = req_origin(&req);
5127 let wac = enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin)
5131 .await
5132 .map(|_audience| ());
5133 if let Err(e) = wac {
5134 let mut resp = e.error_response();
5138 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
5139 if let (Ok(name), Ok(value)) = (
5140 actix_web::http::header::HeaderName::from_bytes(k.as_bytes()),
5141 actix_web::http::header::HeaderValue::from_str(v),
5142 ) {
5143 resp.headers_mut().insert(name, value);
5144 }
5145 }
5146 return resp;
5147 }
5148
5149 if agent.as_deref() != Some(format!("did:nostr:{pod_name}").as_str()) {
5154 let mut response = HttpResponse::Forbidden();
5155 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
5156 response.insert_header((k, v));
5157 }
5158 return response.json(serde_json::json!({
5159 "error": "whole-history export requires the pod owner",
5160 "reason": "history_export_owner_required"
5161 }));
5162 }
5163 let service = solid_pod_rs_git::service::GitHttpService::new(data_root.clone());
5165 match service.handle(git_req).await {
5166 Ok(git_resp) => {
5167 let mut builder = HttpResponse::build(
5168 actix_web::http::StatusCode::from_u16(git_resp.status)
5169 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
5170 );
5171 for (k, v) in &git_resp.headers {
5172 builder.insert_header((k.as_str(), v.as_str()));
5173 }
5174 builder.body(git_resp.body)
5175 }
5176 Err(e) => {
5177 let status = e.status_code();
5178 let mut builder = HttpResponse::build(
5179 actix_web::http::StatusCode::from_u16(status)
5180 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
5181 );
5182 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
5183 builder.insert_header((k, v));
5184 }
5185 builder.json(serde_json::json!({"error": e.to_string()}))
5186 }
5187 }
5188}
5189
5190#[cfg(feature = "forge")]
5199fn forge_plugin_dir(state: &AppState) -> Option<PathBuf> {
5200 state.data_root.as_ref().map(|r| r.join(".forge"))
5201}
5202
5203#[cfg(feature = "forge")]
5209struct ServerLoopback {
5210 client: reqwest::Client,
5211}
5212
5213#[cfg(feature = "forge")]
5214#[async_trait::async_trait]
5215impl solid_pod_rs_forge::LoopbackFetch for ServerLoopback {
5216 async fn get(
5217 &self,
5218 url: &str,
5219 max_bytes: usize,
5220 timeout_secs: u64,
5221 ) -> solid_pod_rs_forge::bodies::FetchResult {
5222 use solid_pod_rs_forge::bodies::FetchResult;
5223 let resp = match self
5224 .client
5225 .get(url)
5226 .timeout(Duration::from_secs(timeout_secs.max(1)))
5227 .send()
5228 .await
5229 {
5230 Ok(r) => r,
5231 Err(e) => return FetchResult::Error(e.to_string()),
5232 };
5233 let code = resp.status().as_u16();
5234 if code == 404 || code == 410 {
5235 return FetchResult::Removed;
5236 }
5237 if !resp.status().is_success() {
5238 return FetchResult::Error(format!("status {code}"));
5239 }
5240 if resp
5241 .content_length()
5242 .is_some_and(|length| length > max_bytes as u64)
5243 {
5244 return FetchResult::TooLarge;
5245 }
5246 let mut stream = resp.bytes_stream();
5247 let mut body = Vec::with_capacity(max_bytes.min(16 * 1024));
5248 while let Some(chunk) = stream.next().await {
5249 match chunk {
5250 Ok(chunk) if body.len().saturating_add(chunk.len()) <= max_bytes => {
5251 body.extend_from_slice(&chunk);
5252 }
5253 Ok(_) => return FetchResult::TooLarge,
5254 Err(error) => return FetchResult::Error(error.to_string()),
5255 }
5256 }
5257 FetchResult::Body(body)
5258 }
5259}
5260
5261#[cfg(feature = "forge")]
5263fn env_list(name: &str) -> Vec<String> {
5264 std::env::var(name)
5265 .ok()
5266 .map(|v| {
5267 v.split(',')
5268 .map(str::trim)
5269 .filter(|s| !s.is_empty())
5270 .map(str::to_string)
5271 .collect()
5272 })
5273 .unwrap_or_default()
5274}
5275
5276#[cfg(feature = "forge")]
5281fn forge_config() -> solid_pod_rs_forge::ForgeConfig {
5282 use serde_json::{json, Map, Value};
5283 let mut o = Map::new();
5284 let flag = |n: &str| {
5285 std::env::var(n)
5286 .ok()
5287 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
5288 };
5289 if let Some(b) = flag("FORGE_PRIVATE_REPOS") {
5290 o.insert("privateRepos".into(), json!(b));
5291 }
5292 if let Some(b) = flag("FORGE_ALLOW_MAINNET") {
5293 o.insert("allowMainnet".into(), json!(b));
5294 }
5295 if let Some(b) = flag("FORGE_OPEN_EDIT") {
5296 o.insert("openEdit".into(), json!(b));
5297 }
5298 if let Some(b) = flag("FORGE_ANCHORING_UI") {
5299 o.insert("anchoringUi".into(), json!(b));
5300 }
5301 if let Some(b) = flag("FORGE_SPARSE_MARKS") {
5302 o.insert("sparseMarks".into(), json!(b));
5303 }
5304 if let Ok(c) = std::env::var("FORGE_CHAIN") {
5305 if !c.trim().is_empty() {
5306 o.insert("chain".into(), Value::String(c.trim().to_string()));
5307 }
5308 }
5309 if let Ok(u) = std::env::var("FORGE_BASE_URL") {
5310 if !u.trim().is_empty() {
5311 o.insert("baseUrl".into(), Value::String(u.trim().to_string()));
5312 }
5313 }
5314 let relays = env_list("FORGE_ANNOUNCE_RELAYS");
5315 if !relays.is_empty() {
5316 o.insert("announceRelays".into(), json!(relays));
5317 }
5318 solid_pod_rs_forge::ForgeConfig::from_env(Value::Object(o))
5319}
5320
5321#[cfg(feature = "forge-mirror")]
5326fn mirror_config() -> Option<solid_pod_rs_forge::mirror::MirrorConfig> {
5327 use serde_json::json;
5328 let relays = env_list("FORGE_MIRROR_RELAYS");
5329 let follow: Vec<serde_json::Value> = env_list("FORGE_MIRROR_FOLLOW")
5330 .iter()
5331 .filter_map(|e| e.split_once('/'))
5332 .map(|(pk, name)| json!({"pubkey": pk.trim(), "name": name.trim()}))
5333 .collect();
5334 let mut cfg = json!({"relays": relays, "follow": follow});
5335 if let Some(ms) = std::env::var("FORGE_MIRROR_MIN_INTERVAL_MS")
5336 .ok()
5337 .and_then(|v| v.trim().parse::<u64>().ok())
5338 {
5339 cfg["minIntervalMs"] = json!(ms);
5340 }
5341 solid_pod_rs_forge::mirror::parse_mirror_config(&cfg)
5342}
5343
5344#[cfg(feature = "forge")]
5346struct ForgeRuntime {
5347 service: Arc<solid_pod_rs_forge::ForgeService>,
5348 #[cfg(feature = "forge-mirror")]
5349 _mirror: Option<solid_pod_rs_forge::mirror::MirrorHandle>,
5350}
5351
5352#[cfg(feature = "forge")]
5356fn forge_service(
5357 plugin_dir: PathBuf,
5358) -> Result<Arc<solid_pod_rs_forge::ForgeService>, solid_pod_rs_forge::ForgeError> {
5359 use std::collections::HashMap;
5360 use std::sync::{Mutex, OnceLock};
5361 static REGISTRY: OnceLock<Mutex<HashMap<PathBuf, Arc<ForgeRuntime>>>> = OnceLock::new();
5362 let reg = REGISTRY.get_or_init(|| Mutex::new(HashMap::new()));
5363 let mut reg = reg
5364 .lock()
5365 .unwrap_or_else(std::sync::PoisonError::into_inner);
5366 if let Some(rt) = reg.get(&plugin_dir) {
5367 return Ok(Arc::clone(&rt.service));
5368 }
5369 let cfg = forge_config();
5370 #[cfg(feature = "forge-announce")]
5371 let relay_policy: Arc<dyn solid_pod_rs_forge::publish::SsrfCheck> = Arc::new(
5372 solid_pod_rs_forge::publish::OperatorRelays::new(&cfg.announce_relays),
5373 );
5374 let loopback: Arc<dyn solid_pod_rs_forge::LoopbackFetch> = Arc::new(ServerLoopback {
5375 client: reqwest::Client::new(),
5376 });
5377 let svc =
5378 solid_pod_rs_forge::ForgeService::new(cfg, plugin_dir.clone())?.with_loopback(loopback);
5379 #[cfg(feature = "forge-announce")]
5380 let svc = svc.with_announce_ssrf(relay_policy);
5381 let service = Arc::new(svc);
5382 #[cfg(feature = "forge-mirror")]
5383 let mirror = mirror_config().map(|mcfg| {
5384 use solid_pod_rs_forge::mirror::{GitCli, Mirror, MirrorStore};
5385 let git = Arc::new(GitCli::new(plugin_dir.join("repos")));
5386 let engine = Arc::new(Mirror::new(mcfg, MirrorStore::new(&plugin_dir), git));
5387 tracing::info!("forge: mirror on kind-30618 started");
5388 engine.start()
5389 });
5390 reg.insert(
5391 plugin_dir,
5392 Arc::new(ForgeRuntime {
5393 service: Arc::clone(&service),
5394 #[cfg(feature = "forge-mirror")]
5395 _mirror: mirror,
5396 }),
5397 );
5398 Ok(service)
5399}
5400
5401#[cfg(feature = "forge")]
5405pub fn start_forge(state: &AppState) {
5406 if let Some(dir) = forge_plugin_dir(state) {
5407 match forge_service(dir) {
5408 Ok(_) => tracing::info!("forge: started"),
5409 Err(e) => tracing::error!("forge: could not start: {e}"),
5410 }
5411 }
5412}
5413
5414#[cfg(feature = "forge")]
5420async fn handle_forge(
5421 req: HttpRequest,
5422 body: web::Bytes,
5423 state: web::Data<AppState>,
5424) -> HttpResponse {
5425 use solid_pod_rs_forge::ForgeRequest;
5426
5427 let Some(plugin_dir) = forge_plugin_dir(&state) else {
5428 return HttpResponse::NotImplemented().json(serde_json::json!({
5429 "error": "forge requires fs-backend storage",
5430 "reason": "data_root_not_configured"
5431 }));
5432 };
5433
5434 let service = match web::block(move || forge_service(plugin_dir)).await {
5438 Ok(Ok(s)) => s,
5439 Ok(Err(e)) => {
5440 return HttpResponse::InternalServerError()
5441 .json(serde_json::json!({"error": e.to_string()}));
5442 }
5443 Err(e) => {
5444 return HttpResponse::InternalServerError()
5445 .json(serde_json::json!({"error": e.to_string()}));
5446 }
5447 };
5448
5449 let path = req.uri().path().to_string();
5450 let query = req.uri().query().unwrap_or("").to_string();
5451 let host_url = Some(scheme::request_origin(&req));
5452 let headers: Vec<(String, String)> = req
5453 .headers()
5454 .iter()
5455 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
5456 .collect();
5457
5458 let forge_req = ForgeRequest {
5459 method: req.method().as_str().to_string(),
5460 path,
5461 query,
5462 headers,
5463 raw_body: body,
5464 host_url,
5465 };
5466
5467 let agent = service.resolve_agent(&forge_req).await;
5472
5473 #[cfg(feature = "jev")]
5475 let jev_ctx = (
5476 forge_req.method.clone(),
5477 forge_req.path.clone(),
5478 forge_req.query.clone(),
5479 );
5480
5481 match service.handle(forge_req, agent).await {
5482 Ok(resp) => {
5483 let mut builder = HttpResponse::build(
5484 StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
5485 );
5486 for (k, v) in &resp.headers {
5487 #[cfg(feature = "jev")]
5489 if k.eq_ignore_ascii_case("content-length") {
5490 continue;
5491 }
5492 builder.insert_header((k.as_str(), v.as_str()));
5493 }
5494 #[cfg(feature = "jev")]
5498 if jev::enabled_in_process_env() {
5499 let rt = jev::JevRuntime::from_process_env();
5500 if let Some(body) = jev::rerank_forge_listing(
5501 &rt,
5502 &jev_ctx.0,
5503 &jev_ctx.1,
5504 &jev_ctx.2,
5505 resp.status,
5506 &resp.body,
5507 )
5508 .await
5509 {
5510 return builder.body(body);
5511 }
5512 }
5513 builder.body(resp.body)
5514 }
5515 Err(e) => {
5516 let status = e.status_code();
5517 HttpResponse::build(
5518 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
5519 )
5520 .json(serde_json::json!({"error": e.to_string()}))
5521 }
5522 }
5523}
5524
5525pub fn build_app(
5537 state: AppState,
5538) -> App<
5539 impl actix_web::dev::ServiceFactory<
5540 ServiceRequest,
5541 Config = (),
5542 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
5543 Error = ActixError,
5544 InitError = (),
5545 >,
5546> {
5547 let body_cap = state.body_cap;
5548 let dotfiles = state.dotfiles.clone();
5549 let allowed_origins = Arc::new(state.allowed_origins.clone());
5550 let allowed_hosts = state.allowed_hosts.clone();
5551
5552 let mut app = App::new()
5553 .app_data(web::Data::new(state.clone()))
5554 .app_data(web::PayloadConfig::new(body_cap))
5555 .wrap(ErrorLoggingMiddleware)
5560 .wrap(CorsHeaders { allowed_origins })
5561 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
5565 .wrap(PathTraversalGuard)
5566 .wrap(DotfileGuard::new(dotfiles).with_host_policy(HostPolicy {
5567 allowed: allowed_hosts,
5568 trust_proxy: state.trust_proxy,
5569 }));
5570
5571 app = app
5577 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
5578 .route(
5579 "/.well-known/webfinger",
5580 web::get().to(handle_well_known_webfinger),
5581 )
5582 .route(
5583 "/.well-known/nodeinfo",
5584 web::get().to(handle_well_known_nodeinfo),
5585 )
5586 .route(
5587 "/.well-known/nodeinfo/2.1",
5588 web::get().to(handle_well_known_nodeinfo_2_1),
5589 );
5590
5591 #[cfg(feature = "did-nostr")]
5592 {
5593 app = app.route(
5594 "/.well-known/did/nostr/{pubkey}.json",
5595 web::get().to(handle_well_known_did_nostr),
5596 );
5597 }
5598
5599 #[cfg(feature = "nip05-endpoint")]
5604 {
5605 app = app.route(
5606 "/.well-known/nostr.json",
5607 web::get().to(handle_well_known_nip05),
5608 );
5609 }
5610
5611 #[cfg(feature = "export-jsonld")]
5616 {
5617 app = app.route("/api/exports/all", web::get().to(handle_export_all));
5618 }
5619
5620 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
5622
5623 app = app.route("/pay/.info", web::get().to(handle_pay_info));
5625
5626 app = app.configure(handlers::pay::register);
5631
5632 app = app.route("/proxy", web::get().to(handle_proxy));
5634
5635 if state.mcp_enabled {
5639 app = app.route("/mcp", web::post().to(mcp::handle_mcp)).route(
5640 "/mcp",
5641 web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options),
5642 );
5643 }
5644
5645 app = app.route(
5648 "/_admin/provision/{pubkey}",
5649 web::post().to(handle_admin_provision),
5650 );
5651
5652 app = app
5654 .route("/.pods", web::post().to(handle_create_pod))
5655 .route("/api/accounts/new", web::post().to(handle_create_account))
5656 .route("/pods/check/{name}", web::get().to(handle_pod_check))
5657 .route("/login/password", web::post().to(handle_login_password))
5658 .route(
5659 "/account/password/reset",
5660 web::post().to(handle_password_reset_request),
5661 )
5662 .route(
5663 "/account/password/change",
5664 web::post().to(handle_password_change),
5665 );
5666
5667 #[cfg(feature = "forge")]
5672 {
5673 app = app
5674 .route("/forge", web::route().to(handle_forge))
5675 .route("/forge/{tail:.*}", web::route().to(handle_forge));
5676 }
5677
5678 app = app
5683 .route(
5684 "/{tail:.*}/.git",
5686 web::route().to(|| async {
5687 HttpResponse::Forbidden()
5688 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
5689 }),
5690 )
5691 .route(
5692 "/{tail:.*}/.git/{rest:.*}",
5693 web::route().to(|| async {
5694 HttpResponse::Forbidden()
5695 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
5696 }),
5697 );
5698
5699 app = app.route(
5703 "/pods/{pk}/_git/{tail:.*}",
5704 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
5705 );
5706
5707 #[cfg(feature = "git")]
5708 {
5709 app = app
5716 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
5717 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
5718 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
5719
5720 app = app
5723 .route(
5724 "/pods/{pubkey}/_git/status",
5725 web::get().to(handle_git_status),
5726 )
5727 .route("/pods/{pubkey}/_git/log", web::get().to(handle_git_log))
5728 .route("/pods/{pubkey}/_git/diff", web::get().to(handle_git_diff))
5729 .route(
5730 "/pods/{pubkey}/_git/stage",
5731 web::post().to(handle_git_stage),
5732 )
5733 .route(
5734 "/pods/{pubkey}/_git/unstage",
5735 web::post().to(handle_git_unstage),
5736 )
5737 .route(
5738 "/pods/{pubkey}/_git/commit",
5739 web::post().to(handle_git_commit),
5740 )
5741 .route(
5742 "/pods/{pubkey}/_git/branches",
5743 web::get().to(handle_git_branches),
5744 )
5745 .route(
5746 "/pods/{pubkey}/_git/branch",
5747 web::post().to(handle_git_create_branch),
5748 )
5749 .route(
5750 "/pods/{pubkey}/_git/discard",
5751 web::post().to(handle_git_discard),
5752 );
5753
5754 app = app.configure(handlers::prov::register);
5761 }
5762 #[cfg(not(feature = "git"))]
5763 {
5764 let git_501 = || async {
5768 HttpResponse::NotImplemented()
5769 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
5770 };
5771 app = app
5772 .route("/{tail:.*}/info/refs", web::get().to(git_501))
5773 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
5774 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
5775 }
5776
5777 app.route("/{tail:.*}/", web::post().to(handle_post))
5780 .route("/{tail:.*}/", web::put().to(handle_put))
5781 .route("/{tail:.*}", web::get().to(handle_get))
5782 .route("/{tail:.*}", web::head().to(handle_get))
5783 .route("/{tail:.*}", web::put().to(handle_put))
5784 .route("/{tail:.*}", web::patch().to(handle_patch))
5785 .route("/{tail:.*}", web::delete().to(handle_delete))
5786 .route(
5787 "/{tail:.*}",
5788 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
5789 )
5790 .route(
5791 "/{tail:.*}",
5792 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
5793 )
5794}
5795
5796#[cfg(test)]
5801mod payment_gating_tests {
5802 use super::*;
5803 use solid_pod_rs::payments::WebLedger;
5804 use solid_pod_rs::storage::memory::MemoryBackend;
5805
5806 macro_rules! principal {
5810 () => {
5811 "did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
5812 };
5813 }
5814
5815 const PRINCIPAL: &str = principal!();
5816
5817 const OTHER_PRINCIPAL: &str =
5819 "did:nostr:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
5820
5821 const PAID_WRITE_ACL: &str = concat!(
5824 r#"
5825@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5826
5827<#paid-write> a acl:Authorization ;
5828 acl:agent <"#,
5829 principal!(),
5830 r#"> ;
5831 acl:accessTo </premium/inbox> ;
5832 acl:mode acl:Write ;
5833 acl:condition [
5834 a acl:PaymentCondition ;
5835 acl:costSats 100
5836 ] .
5837"#
5838 );
5839
5840 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
5841 let mut ledger = WebLedger::new("Test Pod Credits");
5842 if sats > 0 {
5843 ledger
5844 .credit_by_outpoint(did, "satoshi", &"ab".repeat(32), 0, sats)
5845 .unwrap();
5846 }
5847 let body = serde_json::to_vec(&ledger).unwrap();
5848 storage
5849 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
5850 .await
5851 .unwrap();
5852 }
5853
5854 async fn seed_acl(storage: &dyn Storage) {
5855 storage
5856 .put(
5857 "/premium/inbox.acl",
5858 Bytes::from(PAID_WRITE_ACL),
5859 "text/turtle",
5860 )
5861 .await
5862 .unwrap();
5863 }
5864
5865 #[actix_web::test]
5867 async fn resolve_balance_reads_ledger_entry() {
5868 let storage = MemoryBackend::new();
5869 seed_ledger(&storage, PRINCIPAL, 250).await;
5870 assert_eq!(
5871 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5872 Some(250)
5873 );
5874 }
5875
5876 #[actix_web::test]
5877 async fn wac_balance_uses_authoritative_state_after_a_stale_mirror() {
5878 use solid_pod_rs::payments::PaymentStore;
5879 let storage = MemoryBackend::new();
5880 seed_ledger(&storage, PRINCIPAL, 250).await;
5881 let ledger = WebLedger::new("empty canonical balance");
5882 handlers::pay::StoragePaymentStore::new(&storage)
5883 .write_ledger(&ledger)
5884 .await
5885 .unwrap();
5886 seed_ledger(&storage, PRINCIPAL, 250).await;
5887 assert_eq!(
5888 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5889 Some(0)
5890 );
5891 }
5892
5893 #[actix_web::test]
5895 async fn resolve_balance_zero_when_no_entry() {
5896 let storage = MemoryBackend::new();
5897 seed_ledger(&storage, OTHER_PRINCIPAL, 500).await;
5898 assert_eq!(
5899 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5900 Some(0)
5901 );
5902 }
5903
5904 #[test]
5910 fn fixtures_use_teller_accounts_and_grant_the_principal() {
5911 use solid_pod_rs::payments::account_of;
5912 for did in [PRINCIPAL, OTHER_PRINCIPAL] {
5913 assert_eq!(account_of(did).unwrap(), did, "{did} must be canonical");
5914 }
5915 assert_ne!(PRINCIPAL, OTHER_PRINCIPAL);
5916 assert!(account_of("did:nostr:bob").is_err());
5917 let agent = format!("acl:agent <{PRINCIPAL}>");
5918 for (name, acl) in [
5919 ("PAID_WRITE_ACL", PAID_WRITE_ACL),
5920 ("WRITE_NOT_CONTROL_ACL", WRITE_NOT_CONTROL_ACL),
5921 ] {
5922 assert!(acl.contains(&agent), "{name} must grant PRINCIPAL");
5923 }
5924 assert!(!WRITE_NOT_CONTROL_ACL.contains("did:nostr:alice"));
5925 }
5926
5927 #[actix_web::test]
5930 async fn uncharged_read_skips_pay_per_read_resource() {
5931 let storage = Arc::new(MemoryBackend::new());
5932 let acl = format!(
5933 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
5934 <#paid> a acl:Authorization ; acl:agent <{PRINCIPAL}> ;\n\
5935 acl:accessTo </premium/doc> ; acl:mode acl:Read ;\n\
5936 acl:condition [ a acl:PaymentCondition ; acl:costSats 100 ] .\n"
5937 );
5938 storage
5939 .put("/premium/doc.acl", Bytes::from(acl), "text/turtle")
5940 .await
5941 .unwrap();
5942 seed_ledger(storage.as_ref(), PRINCIPAL, 500).await;
5943 let state = AppState::new(storage.clone());
5944
5945 let skipped =
5946 enforce_read_inner(&state, "/premium/doc", Some(PRINCIPAL), None, false).await;
5947 assert!(skipped.is_err(), "glob must not serve a pay-per-read child");
5948 assert_eq!(
5949 resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5950 Some(500)
5951 );
5952
5953 enforce_read_ctx(&state, "/premium/doc", Some(PRINCIPAL), None)
5954 .await
5955 .expect("a direct read pays and is served");
5956 assert_eq!(
5957 resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5958 Some(400)
5959 );
5960 }
5961
5962 #[actix_web::test]
5964 async fn resolve_balance_none_when_anonymous() {
5965 let storage = MemoryBackend::new();
5966 seed_ledger(&storage, PRINCIPAL, 1_000).await;
5967 assert_eq!(resolve_balance_sats(&storage, None).await, None);
5968 }
5969
5970 #[actix_web::test]
5972 async fn paid_write_denied_below_balance() {
5973 let storage = Arc::new(MemoryBackend::new());
5974 seed_acl(storage.as_ref()).await;
5975 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
5977
5978 let result =
5979 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5980 assert!(
5981 result.is_err(),
5982 "balance 50 < cost 100 must be denied — sat-gating loop closed"
5983 );
5984 }
5985
5986 #[actix_web::test]
5988 async fn paid_write_allowed_at_balance() {
5989 let storage = Arc::new(MemoryBackend::new());
5990 seed_acl(storage.as_ref()).await;
5991 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
5993
5994 let result =
5995 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5996 assert!(
5997 result.is_ok(),
5998 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
5999 );
6000 }
6001
6002 #[actix_web::test]
6004 async fn paid_write_allowed_above_balance() {
6005 let storage = Arc::new(MemoryBackend::new());
6006 seed_acl(storage.as_ref()).await;
6007 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
6008 let state = AppState::new(storage);
6009
6010 let result =
6011 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
6012 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
6013 }
6014
6015 #[actix_web::test]
6019 async fn paid_write_anonymous_denied() {
6020 let storage = Arc::new(MemoryBackend::new());
6021 seed_acl(storage.as_ref()).await;
6022 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
6023 let state = AppState::new(storage);
6024
6025 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
6026 assert!(
6027 result.is_err(),
6028 "anonymous caller has no ledger principal — PaymentCondition fails closed"
6029 );
6030 }
6031
6032 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
6039 use solid_pod_rs::payments::PaymentStore;
6040 let ledger = handlers::pay::StoragePaymentStore::new(storage)
6041 .read_ledger()
6042 .await
6043 .unwrap();
6044 ledger.get_balance(did)
6045 }
6046
6047 #[actix_web::test]
6049 async fn paid_write_debits_ledger() {
6050 let storage = Arc::new(MemoryBackend::new());
6051 seed_acl(storage.as_ref()).await;
6052 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
6054
6055 let result =
6056 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
6057 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
6058 assert_eq!(
6059 read_balance(storage.as_ref(), PRINCIPAL).await,
6060 150,
6061 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
6062 );
6063 }
6064
6065 #[actix_web::test]
6068 async fn paid_write_debits_each_grant() {
6069 let storage = Arc::new(MemoryBackend::new());
6070 seed_acl(storage.as_ref()).await;
6071 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await;
6072 use solid_pod_rs::payments::PaymentStore;
6075 let store = handlers::pay::StoragePaymentStore::new(storage.as_ref());
6076 let ledger = store.read_ledger().await.unwrap();
6077 store.write_ledger(&ledger).await.unwrap();
6078 let state = AppState::new(storage.clone());
6079
6080 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
6081 .await
6082 .unwrap();
6083 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
6084 .await
6085 .unwrap();
6086 assert_eq!(
6087 read_balance(storage.as_ref(), PRINCIPAL).await,
6088 50,
6089 "250 - 2*100: each granted request debits, no unmetered re-use"
6090 );
6091
6092 let third =
6094 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
6095 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
6096 assert_eq!(
6097 read_balance(storage.as_ref(), PRINCIPAL).await,
6098 50,
6099 "a denied request must not debit"
6100 );
6101 }
6102
6103 #[actix_web::test]
6105 async fn paid_read_debits_ledger() {
6106 const PAID_READ_ACL: &str = r#"
6107@prefix acl: <http://www.w3.org/ns/auth/acl#> .
6108
6109<#paid-read> a acl:Authorization ;
6110 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
6111 acl:accessTo </premium/feed> ;
6112 acl:mode acl:Read ;
6113 acl:condition [
6114 a acl:PaymentCondition ;
6115 acl:costSats 30
6116 ] .
6117"#;
6118 let storage = Arc::new(MemoryBackend::new());
6119 storage
6120 .put(
6121 "/premium/feed.acl",
6122 Bytes::from(PAID_READ_ACL),
6123 "text/turtle",
6124 )
6125 .await
6126 .unwrap();
6127 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
6128 let state = AppState::new(storage.clone());
6129
6130 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
6131 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
6132 assert_eq!(
6133 read_balance(storage.as_ref(), PRINCIPAL).await,
6134 70,
6135 "100 - 30 cost: a granted paid read must debit"
6136 );
6137 }
6138
6139 #[actix_web::test]
6142 async fn free_read_does_not_debit() {
6143 let storage = Arc::new(MemoryBackend::new());
6144 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
6146 let state = AppState::new(storage.clone());
6147
6148 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
6149 .await
6150 .unwrap();
6151 assert_eq!(
6152 read_balance(storage.as_ref(), PRINCIPAL).await,
6153 100,
6154 "a grant with no PaymentCondition must not debit"
6155 );
6156 }
6157
6158 const ALICE_ONLY_READ_ACL: &str = r#"
6164@prefix acl: <http://www.w3.org/ns/auth/acl#> .
6165
6166<#alice> a acl:Authorization ;
6167 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
6168 acl:accessTo </private/secret> ;
6169 acl:default </private/> ;
6170 acl:mode acl:Read, acl:Write, acl:Control .
6171"#;
6172
6173 async fn seed_private_read_acl(storage: &dyn Storage) {
6174 storage
6179 .put(
6180 "/private.acl",
6181 Bytes::from(ALICE_ONLY_READ_ACL),
6182 "text/turtle",
6183 )
6184 .await
6185 .unwrap();
6186 }
6187
6188 #[actix_web::test]
6192 async fn enforce_read_grants_owner() {
6193 let storage = Arc::new(MemoryBackend::new());
6194 seed_private_read_acl(storage.as_ref()).await;
6195 let state = AppState::new(storage);
6196 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
6197 assert!(result.is_ok(), "owner alice must be granted Read");
6198 }
6199
6200 #[actix_web::test]
6203 async fn enforce_read_denies_other_principal() {
6204 let storage = Arc::new(MemoryBackend::new());
6205 seed_private_read_acl(storage.as_ref()).await;
6206 let state = AppState::new(storage);
6207 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
6208 assert!(
6209 result.is_err(),
6210 "bob has no Read grant — private resource must not be world-readable"
6211 );
6212 }
6213
6214 #[actix_web::test]
6217 async fn enforce_read_denies_anonymous() {
6218 let storage = Arc::new(MemoryBackend::new());
6219 seed_private_read_acl(storage.as_ref()).await;
6220 let state = AppState::new(storage);
6221 let result = enforce_read(&state, "/private/secret", None).await;
6222 assert!(result.is_err(), "anonymous Read must be denied");
6223 }
6224
6225 const WRITE_NOT_CONTROL_ACL: &str = concat!(
6233 r#"
6234@prefix acl: <http://www.w3.org/ns/auth/acl#> .
6235
6236<#owner> a acl:Authorization ;
6237 acl:agent <"#,
6238 principal!(),
6239 r#"> ;
6240 acl:accessTo </shared/doc> ;
6241 acl:default </shared/> ;
6242 acl:mode acl:Read, acl:Write, acl:Control .
6243
6244<#writer> a acl:Authorization ;
6245 acl:agent <did:nostr:writer> ;
6246 acl:accessTo </shared/doc> ;
6247 acl:default </shared/> ;
6248 acl:mode acl:Read, acl:Write .
6249"#
6250 );
6251
6252 async fn seed_shared_acl(storage: &dyn Storage) {
6253 storage
6258 .put(
6259 "/shared.acl",
6260 Bytes::from(WRITE_NOT_CONTROL_ACL),
6261 "text/turtle",
6262 )
6263 .await
6264 .unwrap();
6265 }
6266
6267 #[actix_web::test]
6271 async fn acl_put_denied_for_writer_without_control() {
6272 let storage = Arc::new(MemoryBackend::new());
6273 seed_shared_acl(storage.as_ref()).await;
6274 let state = AppState::new(storage);
6275 let result = enforce_write(
6279 &state,
6280 "/shared/.acl",
6281 AccessMode::Write,
6282 Some("did:nostr:writer"),
6283 )
6284 .await;
6285 assert!(
6286 result.is_err(),
6287 "writer lacks Control — must not be able to PUT /shared/.acl"
6288 );
6289 }
6290
6291 #[actix_web::test]
6293 async fn acl_put_allowed_for_control_holder() {
6294 let storage = Arc::new(MemoryBackend::new());
6295 seed_shared_acl(storage.as_ref()).await;
6296 let state = AppState::new(storage);
6297 let result =
6298 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
6299 assert!(
6300 result.is_ok(),
6301 "the owner holds Control — must be allowed to PUT /shared/.acl"
6302 );
6303 }
6304
6305 #[actix_web::test]
6307 async fn meta_put_denied_for_writer_without_control() {
6308 let storage = Arc::new(MemoryBackend::new());
6309 seed_shared_acl(storage.as_ref()).await;
6310 let state = AppState::new(storage);
6311 let result = enforce_write(
6312 &state,
6313 "/shared/doc.meta",
6314 AccessMode::Write,
6315 Some("did:nostr:writer"),
6316 )
6317 .await;
6318 assert!(
6319 result.is_err(),
6320 "writer lacks Control — must not be able to PUT a .meta sidecar"
6321 );
6322 }
6323
6324 #[test]
6327 fn canonical_pod_path_vectors() {
6328 let ok = [
6329 ("a/b", "/a/b"),
6330 ("", "/"),
6331 ("/", "/"),
6332 ("//a//b/", "/a/b/"),
6333 ("/%2ewell-known/x", "/.well-known/x"),
6334 ("/a%2fb", "/a/b"),
6335 ("/a%2520b", "/a%20b"),
6336 ];
6337 for (raw, want) in ok {
6338 assert_eq!(canonical_pod_path(raw).as_deref(), Some(want), "{raw}");
6339 }
6340 for bad in [
6341 "/a/./b",
6342 "/a/..",
6343 "../a",
6344 "/a\\b",
6345 "/a%00b",
6346 "/a%ffb",
6347 "/%2e%2e/x",
6348 ] {
6349 assert_eq!(canonical_pod_path(bad), None, "{bad}");
6350 }
6351 }
6352
6353 #[test]
6354 fn guarded_acl_target_vectors() {
6355 use AccessMode::{Control, Read, Write};
6356 let c = |p: &str, m| guarded_acl_target(p, m);
6357 assert_eq!(
6358 c("/.well-known/token/x.json", Read),
6359 ("/.well-known/token/x.json".into(), Control)
6360 );
6361 assert_eq!(
6362 c("/.well-known/gitmark/p.json", Write),
6363 ("/.well-known/gitmark/p.json".into(), Control)
6364 );
6365 assert_eq!(c("/.well-known/%74oken/x.json", Read).1, Control);
6366 assert_eq!(
6367 c("/.well-known/token/x.json.acl", Read),
6368 ("/.well-known/token/x.json".into(), Control)
6369 );
6370 assert_eq!(
6371 c("/.well-known/gitmark/p/blocktrails.json", Read),
6372 ("/.well-known/gitmark/p/blocktrails.json".into(), Read)
6373 );
6374 assert_eq!(
6375 c("/.well-known/webledgers/webledgers.json", Write),
6376 ("/.well-known/webledgers/webledgers.json".into(), Control)
6377 );
6378 assert_eq!(c("/.well-known/%57ebledgers/state.json", Read).1, Control);
6379 assert_eq!(c("/.well-known/webledgersx/y", Read).1, Read);
6380 assert_eq!(c("/pub/x", Read), ("/pub/x".into(), Read));
6381 }
6382
6383 #[test]
6385 fn protected_resource_for_acl_strips_suffixes() {
6386 assert_eq!(
6387 protected_resource_for_acl("/victim/.acl").as_deref(),
6388 Some("/victim/")
6389 );
6390 assert_eq!(
6391 protected_resource_for_acl("/a/b.acl").as_deref(),
6392 Some("/a/b")
6393 );
6394 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
6395 assert_eq!(
6396 protected_resource_for_acl("/a/b.meta").as_deref(),
6397 Some("/a/b")
6398 );
6399 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
6400 }
6401}
6402
6403#[cfg(all(test, feature = "forge-mirror"))]
6404mod forge_env_tests {
6405 use super::*;
6406
6407 #[test]
6409 fn mirror_config_comes_from_the_environment() {
6410 let pk = "ab".repeat(32);
6411 std::env::remove_var("FORGE_MIRROR_RELAYS");
6412 std::env::remove_var("FORGE_MIRROR_FOLLOW");
6413 assert!(mirror_config().is_none(), "nothing configured: mirror off");
6414
6415 std::env::set_var(
6416 "FORGE_MIRROR_RELAYS",
6417 "ws://127.0.0.1:7777, wss://r.example , nope",
6418 );
6419 assert!(mirror_config().is_none(), "relays alone follow nothing");
6420
6421 std::env::set_var(
6422 "FORGE_MIRROR_FOLLOW",
6423 format!("{pk}/agent, bad/entry, {pk}/../x"),
6424 );
6425 std::env::set_var("FORGE_MIRROR_MIN_INTERVAL_MS", "1000");
6426 let cfg = mirror_config().expect("one relay and one valid entry");
6427 assert_eq!(cfg.relays, ["ws://127.0.0.1:7777", "wss://r.example"]);
6428 assert_eq!(cfg.follow.len(), 1);
6429 assert_eq!(cfg.follow[0].name, "agent");
6430 assert_eq!(cfg.min_interval_ms, 1000);
6431
6432 std::env::remove_var("FORGE_MIRROR_RELAYS");
6433 std::env::remove_var("FORGE_MIRROR_FOLLOW");
6434 std::env::remove_var("FORGE_MIRROR_MIN_INTERVAL_MS");
6435 }
6436
6437 #[test]
6438 fn the_forge_service_is_built_once_per_data_directory() {
6439 let td = tempfile::tempdir().unwrap();
6440 let a = forge_service(td.path().join(".forge")).unwrap();
6441 let b = forge_service(td.path().join(".forge")).unwrap();
6442 assert!(Arc::ptr_eq(&a, &b));
6443 let other = tempfile::tempdir().unwrap();
6444 let c = forge_service(other.path().join(".forge")).unwrap();
6445 assert!(!Arc::ptr_eq(&a, &c));
6446 }
6447}