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
125mod mcp;
128
129pub mod mempool;
134
135pub mod trail_store;
141
142use std::collections::HashMap;
143use std::net::{IpAddr, Ipv4Addr};
144use std::path::{Path, PathBuf};
145use std::sync::{Arc, Mutex};
146use std::time::{Duration, Instant};
147
148use actix_web::body::{BoxBody, EitherBody};
149use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
150use actix_web::http::{header, StatusCode};
151use actix_web::middleware::{NormalizePath, TrailingSlash};
152use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
153use bytes::Bytes;
154use futures_util::future::{ready, LocalBoxFuture, Ready};
155use futures_util::StreamExt;
156use percent_encoding::percent_decode_str;
157use serde::Deserialize;
158use solid_pod_rs::{
159 auth::{nip98, replay::ReplayStore},
163 config::sources::parse_size,
164 interop,
165 ldp::{
166 self, cache_control_for_response, LdpContainerOps, PatchCreateOutcome, ResponseAudience,
167 },
168 mashlib::{self, MashlibConfig},
169 provenance::{ProvenanceReceipt, ProvenanceSkip},
170 security::DotfileAllowlist,
171 storage::Storage,
172 wac::{
173 self, conditions::RequestContext, effective_acl_target, parse_jsonld_acl,
174 parser::parse_turtle_acl, protected_resource_for_acl, AccessMode,
175 },
176 PodError,
177};
178
179const _: () = solid_pod_rs::auth::nip98::assert_schnorr_verification_enabled();
191
192static NIP98_REPLAY: std::sync::LazyLock<solid_pod_rs::auth::replay::Nip98ReplayCache> =
199 std::sync::LazyLock::new(solid_pod_rs::auth::replay::Nip98ReplayCache::from_env);
200
201pub(crate) static PAYMENT_STATE_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
207 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
208
209#[derive(Clone)]
215pub struct AppState {
216 pub storage: Arc<dyn Storage>,
217 pub dotfiles: Arc<DotfileAllowlist>,
218 pub body_cap: usize,
219 pub nodeinfo: NodeInfoMeta,
220 pub mashlib: MashlibConfig,
221 pub mashlib_cdn: Option<String>,
224 pub live_reload: bool,
226 pub pay_config: solid_pod_rs::payments::PayConfig,
229 pub data_root: Option<PathBuf>,
234 pub quota: Option<Arc<dyn solid_pod_rs::quota::QuotaPolicy>>,
236 pub pod_create_limiter: Arc<PodCreateLimiter>,
238 pub allowed_origins: Vec<String>,
245 pub admin_key: Option<String>,
250 pub mcp_enabled: bool,
255 pub mempool_url: Option<String>,
261 pub conneg_enabled: bool,
265 pub trust_proxy: bool,
269 pub public_base_url: Option<String>,
273 pub allowed_hosts: Vec<String>,
276 pub cross_identity: Option<Arc<dyn cross_identity::CrossIdentityResolver>>,
279 pub activitypub: Option<Arc<dyn ActorDocument>>,
282}
283
284pub trait ActorDocument: Send + Sync {
287 fn actor_json(&self, base_url: &str) -> serde_json::Value;
289}
290
291#[cfg(test)]
292mod nip98_request_binding_tests {
293 use super::*;
294 use actix_web::test::TestRequest;
295
296 const SECRET: &str = "3333333333333333333333333333333333333333333333333333333333333333";
297
298 fn now() -> u64 {
299 std::time::SystemTime::now()
300 .duration_since(std::time::UNIX_EPOCH)
301 .unwrap()
302 .as_secs()
303 }
304
305 #[actix_web::test]
306 async fn authentication_binds_the_complete_query_string() {
307 let signed_url = "http://localhost:8080/private?include=true";
308 let token = nip98::mint(signed_url, "GET", SECRET, now()).unwrap();
309 let request = TestRequest::get()
310 .uri("/private?include=true")
311 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
312 .to_http_request();
313 assert!(extract_pubkey(&request).await.is_some());
314
315 let path_only = nip98::mint("http://localhost:8080/private", "GET", SECRET, now()).unwrap();
316 let request = TestRequest::get()
317 .uri("/private?include=true")
318 .insert_header((header::AUTHORIZATION, format!("Nostr {path_only}")))
319 .to_http_request();
320 assert!(extract_pubkey(&request).await.is_none());
321 }
322
323 #[actix_web::test]
324 async fn authentication_binds_the_exact_raw_body() {
325 let expected = br#"{"value":"authorised"}"#;
326 let token = nip98::mint_with_payload(
327 "http://localhost:8080/resource",
328 "PUT",
329 Some(expected),
330 SECRET,
331 now(),
332 )
333 .unwrap();
334 let request = TestRequest::put()
335 .uri("/resource")
336 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
337 .to_http_request();
338
339 assert!(
340 extract_pubkey_with_body(&request, Some(br#"{"value":"tampered"}"#))
341 .await
342 .is_none()
343 );
344 assert!(extract_pubkey_with_body(&request, Some(expected))
345 .await
346 .is_some());
347 }
348}
349
350#[derive(Clone, Debug)]
352pub struct NodeInfoMeta {
353 pub software_name: String,
354 pub software_version: String,
355 pub open_registrations: bool,
356 pub total_users: u64,
357 pub base_url: String,
358}
359
360impl Default for NodeInfoMeta {
361 fn default() -> Self {
362 Self {
363 software_name: "solid-pod-rs-server".to_string(),
364 software_version: env!("CARGO_PKG_VERSION").to_string(),
365 open_registrations: false,
366 total_users: 0,
367 base_url: "http://localhost".to_string(),
368 }
369 }
370}
371
372pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
375
376pub fn body_cap_from_env() -> usize {
381 match std::env::var("JSS_MAX_REQUEST_BODY").or_else(|_| std::env::var("JSS_BODY_LIMIT")) {
382 Ok(v) => parse_size(&v)
383 .map(|u| u as usize)
384 .unwrap_or(DEFAULT_BODY_CAP),
385 Err(_) => DEFAULT_BODY_CAP,
386 }
387}
388
389impl AppState {
390 pub fn public_origin(&self) -> Option<String> {
392 self.public_base_url
393 .as_deref()
394 .and_then(scheme::normalise_origin)
395 }
396
397 pub fn new(storage: Arc<dyn Storage>) -> Self {
400 Self {
401 storage,
402 dotfiles: Arc::new(DotfileAllowlist::from_env()),
403 body_cap: body_cap_from_env(),
404 nodeinfo: NodeInfoMeta::default(),
405 mashlib: MashlibConfig::default(),
406 mashlib_cdn: None,
407 live_reload: false,
408 pay_config: solid_pod_rs::payments::PayConfig::default(),
409 data_root: None,
410 quota: None,
411 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
412 allowed_origins: Vec::new(),
413 admin_key: None,
414 mcp_enabled: false,
415 mempool_url: None,
416 conneg_enabled: false,
417 trust_proxy: false,
418 public_base_url: None,
419 allowed_hosts: Vec::new(),
420 cross_identity: None,
421 activitypub: None,
422 }
423 }
424}
425
426const 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>";
427
428fn inject_live_reload(html: impl AsRef<[u8]>, enabled: bool) -> Vec<u8> {
429 let bytes = html.as_ref();
430 if !enabled {
431 return bytes.to_vec();
432 }
433 let html = String::from_utf8_lossy(bytes);
434 if let Some(index) = html.find("</body>") {
435 let mut out = String::with_capacity(html.len() + LIVE_RELOAD_SCRIPT.len());
436 out.push_str(&html[..index]);
437 out.push_str(LIVE_RELOAD_SCRIPT);
438 out.push_str(&html[index..]);
439 out.into_bytes()
440 } else {
441 format!("{html}{LIVE_RELOAD_SCRIPT}").into_bytes()
442 }
443}
444
445#[cfg(test)]
446mod live_reload_tests {
447 use super::*;
448
449 #[test]
450 fn injects_before_body_close_and_is_disabled_by_default() {
451 let html = b"<html><body>ok</body></html>";
452 assert_eq!(inject_live_reload(html, false), html);
453 let injected = String::from_utf8(inject_live_reload(html, true)).unwrap();
454 assert!(injected.contains("/.notifications"));
455 assert!(injected.find("<script>").unwrap() < injected.find("</body>").unwrap());
456 }
457}
458
459#[cfg(all(test, feature = "dev-bearer"))]
460mod dev_bearer_tests {
461 use super::*;
462 use base64::Engine as _;
463 use hmac::{Hmac, Mac};
464 use sha2::Sha256;
465 use std::sync::Mutex;
466
467 static ENV: Mutex<()> = Mutex::new(());
468
469 fn now() -> u64 {
470 std::time::SystemTime::now()
471 .duration_since(std::time::UNIX_EPOCH)
472 .unwrap()
473 .as_secs()
474 }
475
476 fn token_with(iat: u64, exp: u64, aud: Option<&str>) -> String {
477 let mut claims = serde_json::json!({"webId":"https://alice.example/profile/card#me","iat":iat,"exp":exp});
478 if let Some(a) = aud {
479 claims["aud"] = serde_json::json!(a);
480 }
481 let data = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string());
482 let mut mac = Hmac::<Sha256>::new_from_slice(b"01234567890123456789012345678901").unwrap();
483 mac.update(data.as_bytes());
484 let sig =
485 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
486 format!("{data}.{sig}")
487 }
488
489 fn token(exp: u64) -> String {
490 token_with(now().saturating_sub(10), exp, Some(DEV_BEARER_AUDIENCE))
491 }
492
493 #[test]
494 fn simple_bearer_verifies_signature_and_expiry() {
495 let _guard = ENV.lock().unwrap();
496 std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
497 assert_eq!(
498 verify_dev_bearer(&token(now() + 3600)).as_deref(),
499 Some("https://alice.example/profile/card#me")
500 );
501 assert!(verify_dev_bearer(&(token(now() + 3600) + "x")).is_none());
502 assert!(verify_dev_bearer(&token(2)).is_none());
503 std::env::remove_var("TOKEN_SECRET");
504 }
505
506 #[test]
507 fn dev_bearer_enforces_audience_and_max_lifetime() {
508 let _guard = ENV.lock().unwrap();
509 std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
510 let n = now();
511 assert!(verify_dev_bearer(&token_with(n, n + 60, None)).is_none());
513 assert!(verify_dev_bearer(&token_with(n, n + 60, Some("solid"))).is_none());
514 assert!(verify_dev_bearer(&token_with(
516 n,
517 n + DEV_BEARER_MAX_LIFETIME_SECS,
518 Some(DEV_BEARER_AUDIENCE)
519 ))
520 .is_some());
521 assert!(verify_dev_bearer(&token_with(
522 n,
523 n + DEV_BEARER_MAX_LIFETIME_SECS + 1,
524 Some(DEV_BEARER_AUDIENCE)
525 ))
526 .is_none());
527 assert!(verify_dev_bearer(&token_with(1, n + 3600, Some(DEV_BEARER_AUDIENCE))).is_none());
529 assert!(
530 verify_dev_bearer(&token_with(1, u64::MAX / 2, Some(DEV_BEARER_AUDIENCE))).is_none()
531 );
532 std::env::remove_var("TOKEN_SECRET");
533 }
534}
535
536#[derive(Debug)]
538pub struct PodCreateLimiter {
539 hits: Mutex<HashMap<IpAddr, Instant>>,
540 window: Duration,
541}
542
543impl Default for PodCreateLimiter {
544 fn default() -> Self {
545 Self {
546 hits: Mutex::new(HashMap::new()),
547 window: Duration::from_secs(24 * 60 * 60),
548 }
549 }
550}
551
552impl PodCreateLimiter {
553 fn check(&self, ip: IpAddr) -> Result<(), u64> {
554 let now = Instant::now();
555 let mut hits = self.hits.lock().unwrap();
556 if let Some(last) = hits.get(&ip).copied() {
557 let elapsed = now.saturating_duration_since(last);
558 if elapsed < self.window {
559 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
560 }
561 }
562 hits.insert(ip, now);
563 Ok(())
564 }
565}
566
567pub(crate) fn to_actix(e: PodError) -> ActixError {
572 match e {
573 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
574 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
575 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
576 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
577 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
578 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
579 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
580 }
581}
582
583pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
595 extract_pubkey_with_body(req, None).await
596}
597
598pub(crate) async fn extract_pubkey_with_body(
602 req: &HttpRequest,
603 body: Option<&[u8]>,
604) -> Option<String> {
605 let header_val = req
606 .headers()
607 .get(header::AUTHORIZATION)
608 .and_then(|v| v.to_str().ok())?;
609 if let Some(_token) = header_val.strip_prefix("Bearer ") {
610 #[cfg(feature = "dev-bearer")]
613 return verify_dev_bearer(_token);
614 #[cfg(not(feature = "dev-bearer"))]
615 return None;
616 }
617 let url = scheme::request_url(req);
630 let now = std::time::SystemTime::now()
631 .duration_since(std::time::UNIX_EPOCH)
632 .map(|d| d.as_secs())
633 .unwrap_or(0);
634 let verified = nip98::verify_at(header_val, &url, req.method().as_str(), body, now).ok()?;
635
636 if NIP98_REPLAY
640 .check_and_record(&verified.event_id)
641 .await
642 .is_err()
643 {
644 tracing::warn!(
645 pubkey = %verified.pubkey,
646 method = %req.method(),
647 "NIP-98 replay rejected: token id already used within window"
648 );
649 return None;
650 }
651
652 Some(verified.pubkey)
653}
654
655pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
656 pubkey.map(|pk| {
657 if pk.starts_with("https://") || pk.starts_with("http://") {
658 pk.clone()
659 } else {
660 format!("did:nostr:{pk}")
661 }
662 })
663}
664
665#[cfg(feature = "dev-bearer")]
667pub const DEV_BEARER_AUDIENCE: &str = "solid-pod-rs-dev-bearer";
668
669#[cfg(feature = "dev-bearer")]
672pub const DEV_BEARER_MAX_LIFETIME_SECS: u64 = 24 * 60 * 60;
673
674#[cfg(feature = "dev-bearer")]
682fn verify_dev_bearer(token: &str) -> Option<String> {
683 use base64::Engine as _;
684 use hmac::{Hmac, Mac};
685 use sha2::Sha256;
686
687 let secret = std::env::var("TOKEN_SECRET").ok()?;
688 if secret.len() < 32 {
689 tracing::warn!("TOKEN_SECRET shorter than 32 bytes; refusing development bearer token");
690 return None;
691 }
692 let (data, signature) = token.split_once('.')?;
693 if signature.contains('.') {
694 return None;
695 }
696 let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
697 .decode(signature)
698 .ok()?;
699 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
700 mac.update(data.as_bytes());
701 mac.verify_slice(&signature).ok()?;
702
703 #[derive(serde::Deserialize)]
704 #[serde(rename_all = "camelCase")]
705 struct Claims {
706 web_id: String,
707 iat: u64,
708 exp: u64,
709 #[serde(default)]
710 aud: Option<String>,
711 }
712 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
713 .decode(data)
714 .ok()?;
715 let claims: Claims = serde_json::from_slice(&payload).ok()?;
716 let now = std::time::SystemTime::now()
717 .duration_since(std::time::UNIX_EPOCH)
718 .ok()?
719 .as_secs();
720 if claims.aud.as_deref() != Some(DEV_BEARER_AUDIENCE) {
721 return None;
722 }
723 if claims.exp < now || claims.iat > now.saturating_add(60) || claims.exp <= claims.iat {
724 return None;
725 }
726 if claims.exp - claims.iat > DEV_BEARER_MAX_LIFETIME_SECS
727 || claims.exp - now > DEV_BEARER_MAX_LIFETIME_SECS
728 {
729 return None;
730 }
731 let url = url::Url::parse(&claims.web_id).ok()?;
732 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
733 return None;
734 }
735 Some(claims.web_id)
736}
737
738fn req_origin(req: &HttpRequest) -> Option<&str> {
747 req.headers()
748 .get(header::ORIGIN)
749 .and_then(|v| v.to_str().ok())
750}
751
752pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
756
757async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
774 let did = agent_uri?;
775 let balance = match storage.get(WEBLEDGER_PATH).await {
776 Ok((bytes, _meta)) => {
777 match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
778 Ok(ledger) => ledger.get_balance(did),
779 Err(_) => 0,
783 }
784 }
785 Err(_) => 0,
788 };
789 Some(balance)
790}
791
792fn accept_includes_html(accept: &str) -> bool {
800 accept.split(',').any(|entry| {
801 let mime = entry.split(';').next().unwrap_or("").trim();
802 mime.eq_ignore_ascii_case("text/html")
803 })
804}
805
806pub(crate) fn guarded_acl_target(path: &str, mode: AccessMode) -> (String, AccessMode) {
825 let (resource, mode) = effective_acl_target(path, mode);
826 match canonical_pod_path(&resource) {
830 Some(canon)
831 if !trail_store::is_secret_trail_path(&canon)
832 && !trail_store::is_prov_state_path(&canon)
833 && !trail_store::is_server_state_path(&canon) =>
834 {
835 (resource, mode)
836 }
837 _ => (resource, AccessMode::Control),
838 }
839}
840
841pub(crate) fn canonical_pod_path(raw: &str) -> Option<String> {
849 let decoded = percent_decode_str(raw).decode_utf8().ok()?;
850 if decoded.contains(['\\', '\0']) {
851 return None;
852 }
853 let mut out = String::with_capacity(decoded.len() + 1);
854 for seg in decoded.split('/') {
855 match seg {
856 "" => {}
857 "." | ".." => return None,
858 _ => {
859 out.push('/');
860 out.push_str(seg);
861 }
862 }
863 }
864 if out.is_empty() || decoded.ends_with('/') {
865 out.push('/');
866 }
867 Some(out)
868}
869
870fn proposed_acl_keeps_caller_control(
878 body: &[u8],
879 content_type: &str,
880 caller: Option<&str>,
881) -> bool {
882 let doc = match parse_jsonld_acl(body) {
883 Ok(d) => Some(d),
884 Err(_) => {
885 let ct = content_type.to_ascii_lowercase();
886 let text = std::str::from_utf8(body).unwrap_or("");
887 let looks_turtle = ct.starts_with("text/turtle")
888 || ct.starts_with("application/turtle")
889 || ct.starts_with("application/x-turtle")
890 || ct.starts_with("application/n-triples")
891 || text.contains("@prefix")
892 || text.contains("acl:Authorization")
893 || text.contains("auth/acl#Authorization");
897 if looks_turtle {
898 parse_turtle_acl(text).ok()
899 } else {
900 None
901 }
902 }
903 };
904 let Some(doc) = doc else {
905 return true;
907 };
908 let Some(graph) = doc.graph.as_ref() else {
909 return false;
910 };
911 graph.iter().any(|auth| {
912 let grants_control = ids_of_acl_field(&auth.mode)
913 .iter()
914 .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
915 if !grants_control {
916 return false;
917 }
918 let agents = ids_of_acl_field(&auth.agent);
919 if let Some(web_id) = caller {
920 if agents.contains(&web_id) {
921 return true;
922 }
923 }
924 let classes = ids_of_acl_field(&auth.agent_class);
925 if classes
926 .iter()
927 .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
928 {
929 return true;
930 }
931 if caller.is_some()
932 && classes.iter().any(|c| {
933 *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
934 || *c == "acl:AuthenticatedAgent"
935 })
936 {
937 return true;
938 }
939 false
940 })
941}
942
943fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
945 match field {
946 None => Vec::new(),
947 Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
948 Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
949 }
950}
951
952#[cfg_attr(not(test), allow(dead_code))]
959async fn enforce_write(
960 state: &AppState,
961 path: &str,
962 mode: AccessMode,
963 agent_uri: Option<&str>,
964) -> Result<(), ActixError> {
965 enforce_write_ctx(state, path, mode, agent_uri, None).await
966}
967
968async fn enforce_write_ctx(
976 state: &AppState,
977 path: &str,
978 mode: AccessMode,
979 agent_uri: Option<&str>,
980 request_origin: Option<&str>,
981) -> Result<(), ActixError> {
982 let origin = request_origin.and_then(wac::Origin::parse);
983 let (resource, eff_mode) = guarded_acl_target(path, mode);
995
996 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
1004 if outcome.is_failure() {
1005 return Err(policy_failure_to_actix(&outcome, &resource));
1006 }
1007 let acl_doc = outcome.document().cloned();
1008
1009 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
1014
1015 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
1016 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
1017 let linked = cross_identity::linked_identities(state.cross_identity.as_ref(), agent_uri).await;
1020 let Some(ctx) = grant_for_identities(
1021 acl_doc.as_ref(),
1022 agent_uri,
1023 &linked,
1024 payment_balance_sats,
1025 &resource,
1026 eff_mode,
1027 origin.as_ref(),
1028 &groups,
1029 ®istry,
1030 ) else {
1031 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1032 };
1033 if resource.as_str() == path {
1040 charge_granted_payment(
1046 state,
1047 acl_doc.as_ref(),
1048 &ctx,
1049 &resource,
1050 eff_mode,
1051 &groups,
1052 ®istry,
1053 agent_uri,
1054 )
1055 .await?;
1056 }
1057 Ok(())
1058}
1059
1060#[allow(clippy::too_many_arguments)]
1069async fn charge_granted_payment(
1070 state: &AppState,
1071 acl_doc: Option<&wac::AclDocument>,
1072 ctx: &RequestContext<'_>,
1073 path: &str,
1074 mode: AccessMode,
1075 groups: &wac::StaticGroupMembership,
1076 registry: &wac::conditions::ConditionRegistry,
1077 payer: Option<&str>,
1078) -> Result<(), ActixError> {
1079 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
1080 if cost == 0 {
1081 return Ok(());
1082 }
1083 if let Some(did) = payer {
1086 if debit_ledger(&*state.storage, did, cost).await.is_err() {
1087 return Err(acl_denial(acl_doc, payer, path));
1088 }
1089 }
1090 Ok(())
1091}
1092
1093#[allow(clippy::too_many_arguments)]
1100pub(crate) fn grant_for_identities<'a>(
1101 acl_doc: Option<&wac::AclDocument>,
1102 primary: Option<&'a str>,
1103 linked: &'a [String],
1104 payment_balance_sats: Option<u64>,
1105 resource: &str,
1106 mode: AccessMode,
1107 origin: Option<&wac::Origin>,
1108 groups: &wac::StaticGroupMembership,
1109 registry: &wac::conditions::ConditionRegistry,
1110) -> Option<RequestContext<'a>> {
1111 std::iter::once(primary)
1112 .chain(linked.iter().map(|s| Some(s.as_str())))
1113 .map(|web_id| RequestContext {
1114 web_id,
1115 client_id: None,
1116 issuer: None,
1117 payment_balance_sats,
1118 })
1119 .find(|ctx| {
1120 wac::evaluate_access_ctx_with_registry(
1121 acl_doc, ctx, resource, mode, origin, groups, registry,
1122 )
1123 })
1124}
1125
1126fn acl_denial(
1132 acl_doc: Option<&wac::AclDocument>,
1133 agent_uri: Option<&str>,
1134 path: &str,
1135) -> ActixError {
1136 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
1137 let (status, body, unauthenticated) = if agent_uri.is_none() {
1138 (StatusCode::UNAUTHORIZED, "authentication required", true)
1139 } else {
1140 (StatusCode::FORBIDDEN, "access forbidden", false)
1141 };
1142 let mut rsp = HttpResponse::new(status);
1143 rsp.headers_mut().insert(
1144 header::HeaderName::from_static("wac-allow"),
1145 header::HeaderValue::from_str(&allow_header)
1146 .unwrap_or(header::HeaderValue::from_static("")),
1147 );
1148 if unauthenticated {
1149 rsp.headers_mut().insert(
1156 header::WWW_AUTHENTICATE,
1157 header::HeaderValue::from_static(
1158 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
1159 ),
1160 );
1161 }
1162 actix_web::error::InternalError::from_response(body, rsp).into()
1163}
1164
1165#[cfg_attr(not(test), allow(dead_code))]
1176async fn enforce_read(
1177 state: &AppState,
1178 path: &str,
1179 agent_uri: Option<&str>,
1180) -> Result<ResponseAudience, ActixError> {
1181 enforce_read_ctx(state, path, agent_uri, None).await
1182}
1183
1184async fn enforce_read_ctx(
1187 state: &AppState,
1188 path: &str,
1189 agent_uri: Option<&str>,
1190 request_origin: Option<&str>,
1191) -> Result<ResponseAudience, ActixError> {
1192 enforce_read_inner(state, path, agent_uri, request_origin, true).await
1193}
1194
1195async fn enforce_read_inner(
1200 state: &AppState,
1201 path: &str,
1202 agent_uri: Option<&str>,
1203 request_origin: Option<&str>,
1204 charge: bool,
1205) -> Result<ResponseAudience, ActixError> {
1206 let origin = request_origin.and_then(wac::Origin::parse);
1207 let (resource, eff_mode) = guarded_acl_target(path, AccessMode::Read);
1219 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
1222 if outcome.is_failure() {
1223 return Err(policy_failure_to_actix(&outcome, &resource));
1224 }
1225 let acl_doc = outcome.document().cloned();
1226 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
1227 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
1228 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
1229 let linked = cross_identity::linked_identities(state.cross_identity.as_ref(), agent_uri).await;
1230 let Some(ctx) = grant_for_identities(
1231 acl_doc.as_ref(),
1232 agent_uri,
1233 &linked,
1234 payment_balance_sats,
1235 &resource,
1236 eff_mode,
1237 origin.as_ref(),
1238 &groups,
1239 ®istry,
1240 ) else {
1241 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1242 };
1243 if resource.as_str() == path && !charge {
1247 if wac::granted_payment_cost(
1248 acl_doc.as_ref(),
1249 &ctx,
1250 &resource,
1251 eff_mode,
1252 &groups,
1253 ®istry,
1254 ) > 0
1255 {
1256 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1257 }
1258 } else if resource.as_str() == path {
1259 charge_granted_payment(
1264 state,
1265 acl_doc.as_ref(),
1266 &ctx,
1267 &resource,
1268 eff_mode,
1269 &groups,
1270 ®istry,
1271 agent_uri,
1272 )
1273 .await?;
1274 }
1275 let anon_ctx = RequestContext {
1284 web_id: None,
1285 client_id: None,
1286 issuer: None,
1287 payment_balance_sats: None,
1288 };
1289 let anonymous_would_be_granted = wac::evaluate_access_ctx_with_registry(
1290 acl_doc.as_ref(),
1291 &anon_ctx,
1292 &resource,
1293 eff_mode,
1294 origin.as_ref(),
1295 &groups,
1296 ®istry,
1297 );
1298 Ok(if anonymous_would_be_granted && resource.as_str() == path {
1299 ResponseAudience::Public
1300 } else {
1301 ResponseAudience::Private
1302 })
1303}
1304
1305async fn debit_ledger(
1314 storage: &dyn Storage,
1315 did: &str,
1316 cost: u64,
1317) -> Result<(), solid_pod_rs::payments::PaymentError> {
1318 use solid_pod_rs::payments::{PaymentError, WebLedger};
1319 let _transaction = PAYMENT_STATE_LOCK.lock().await;
1320
1321 let (bytes, _meta) = storage
1322 .get(WEBLEDGER_PATH)
1323 .await
1324 .map_err(|e| PaymentError::Store(e.to_string()))?;
1325 let mut ledger: WebLedger = serde_json::from_slice(&bytes)
1326 .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
1327 let _charge = ledger.charge(did, cost)?;
1329 let body = serde_json::to_vec(&ledger)
1330 .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
1331 storage
1332 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
1333 .await
1334 .map_err(|e| PaymentError::Store(e.to_string()))?;
1335 Ok(())
1336}
1337
1338fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
1343 let links = ldp::link_headers(path).join(", ");
1344 if let Ok(value) = header::HeaderValue::from_str(&links) {
1345 rsp.headers_mut()
1346 .insert(header::HeaderName::from_static("link"), value);
1347 }
1348}
1349
1350fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
1351 if let Ok(v) = header::HeaderValue::from_str(header_value) {
1352 rsp.headers_mut()
1353 .insert(header::HeaderName::from_static("wac-allow"), v);
1354 }
1355}
1356
1357fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
1358 let ws_base = base_url
1359 .replacen("https://", "wss://", 1)
1360 .replacen("http://", "ws://", 1);
1361 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
1362 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
1363 rsp.headers_mut()
1364 .insert(header::HeaderName::from_static("updates-via"), v);
1365 }
1366}
1367
1368fn set_provenance_headers(rsp: &mut HttpResponse, receipt: &ProvenanceReceipt) {
1379 if let Ok(v) = header::HeaderValue::from_str(&receipt.summary()) {
1380 rsp.headers_mut()
1381 .insert(header::HeaderName::from_static("x-provenance"), v);
1382 }
1383 if let Some(sha) = receipt.commit_sha() {
1384 if let Ok(v) = header::HeaderValue::from_str(sha) {
1385 rsp.headers_mut()
1386 .insert(header::HeaderName::from_static("x-provenance-commit"), v);
1387 }
1388 }
1389}
1390
1391fn set_cache_policy(rsp: &mut HttpResponse, content_type: &str, audience: ResponseAudience) {
1403 if audience == ResponseAudience::Private {
1404 append_vary(rsp, "Authorization");
1408 }
1409 if rsp.headers().contains_key(header::CACHE_CONTROL) {
1410 return;
1411 }
1412 if let Some(value) = cache_control_for_response(content_type, audience) {
1413 rsp.headers_mut().insert(
1414 header::CACHE_CONTROL,
1415 header::HeaderValue::from_static(value),
1416 );
1417 }
1418}
1419
1420fn append_vary(rsp: &mut HttpResponse, field: &str) {
1422 let existing = rsp
1423 .headers()
1424 .get(header::VARY)
1425 .and_then(|v| v.to_str().ok())
1426 .unwrap_or("")
1427 .to_string();
1428 if existing
1429 .split(',')
1430 .any(|f| f.trim().eq_ignore_ascii_case(field))
1431 {
1432 return;
1433 }
1434 let merged = if existing.trim().is_empty() {
1435 field.to_string()
1436 } else {
1437 format!("{existing}, {field}")
1438 };
1439 if let Ok(v) = header::HeaderValue::from_str(&merged) {
1440 rsp.headers_mut().insert(header::VARY, v);
1441 }
1442}
1443
1444async fn handle_get(
1445 req: HttpRequest,
1446 state: web::Data<AppState>,
1447) -> Result<HttpResponse, ActixError> {
1448 let path = req.uri().path().to_string();
1449
1450 #[cfg(feature = "activitypub")]
1455 if let (Some(ap), "/profile/card.jsonld") = (state.activitypub.as_ref(), path.as_str()) {
1456 let accept = req
1457 .headers()
1458 .get(header::ACCEPT)
1459 .and_then(|v| v.to_str().ok())
1460 .unwrap_or("");
1461 if solid_pod_rs_activitypub::negotiate_actor_format(accept)
1462 == solid_pod_rs_activitypub::ActorFormat::ActivityJson
1463 {
1464 let doc = ap.actor_json(&scheme::request_origin(&req));
1465 return Ok(HttpResponse::Ok()
1466 .content_type("application/activity+json")
1467 .insert_header((header::VARY, "Accept"))
1468 .json(doc));
1469 }
1470 }
1471
1472 if path.contains('*') {
1473 return handle_glob_get(req, state).await;
1474 }
1475
1476 let auth_pk = extract_pubkey(&req).await;
1477 let agent = agent_uri(auth_pk.as_ref());
1478
1479 let audience = enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
1484
1485 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
1486
1487 if ldp::is_container(&path) {
1488 let accept = req
1489 .headers()
1490 .get(header::ACCEPT)
1491 .and_then(|v| v.to_str().ok())
1492 .unwrap_or("");
1493
1494 if accept_includes_html(accept) {
1500 let index_path = format!("{path}index.html");
1501 let index_audience =
1504 enforce_read_ctx(&state, &index_path, agent.as_deref(), req_origin(&req))
1505 .await
1506 .ok();
1507 if let (Some(index_audience), Ok((body, _meta))) =
1508 (index_audience, state.storage.get(&index_path).await)
1509 {
1510 let mut rsp = HttpResponse::Ok()
1511 .content_type("text/html; charset=utf-8")
1512 .body(inject_live_reload(&body, state.live_reload));
1513 set_cache_policy(&mut rsp, "text/html", index_audience);
1514 set_wac_allow(&mut rsp, &wac_allow);
1515 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1516 set_link_headers(&mut rsp, &path);
1517 return Ok(rsp);
1518 }
1519 }
1520
1521 let v = state
1522 .storage
1523 .container_representation(&path)
1524 .await
1525 .map_err(to_actix)?;
1526
1527 let sec_fetch_dest = req
1529 .headers()
1530 .get("sec-fetch-dest")
1531 .and_then(|v| v.to_str().ok());
1532 if mashlib::should_serve(
1533 accept,
1534 sec_fetch_dest,
1535 "application/ld+json",
1536 state.mashlib.enabled,
1537 ) {
1538 let json_ld = serde_json::to_string(&v).ok();
1539 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
1540 let mut rsp = HttpResponse::Ok()
1541 .content_type("text/html; charset=utf-8")
1542 .insert_header(("X-Frame-Options", "DENY"))
1543 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1544 .insert_header(("Cache-Control", "no-store"))
1545 .body(inject_live_reload(html, state.live_reload));
1546 set_cache_policy(&mut rsp, "text/html", audience);
1547 set_wac_allow(&mut rsp, &wac_allow);
1548 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1549 set_link_headers(&mut rsp, &path);
1550 return Ok(rsp);
1551 }
1552
1553 let mut rsp = HttpResponse::Ok().json(v);
1554 rsp.headers_mut().insert(
1555 header::CONTENT_TYPE,
1556 header::HeaderValue::from_static("application/ld+json"),
1557 );
1558 set_cache_policy(&mut rsp, "application/ld+json", audience);
1559 set_wac_allow(&mut rsp, &wac_allow);
1560 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1561 set_link_headers(&mut rsp, &path);
1562 return Ok(rsp);
1563 }
1564
1565 match state.storage.get(&path).await {
1566 Ok((body, meta)) => {
1567 let accept = req
1569 .headers()
1570 .get(header::ACCEPT)
1571 .and_then(|v| v.to_str().ok())
1572 .unwrap_or("");
1573 let sec_fetch_dest = req
1574 .headers()
1575 .get("sec-fetch-dest")
1576 .and_then(|v| v.to_str().ok());
1577 if mashlib::should_serve(
1578 accept,
1579 sec_fetch_dest,
1580 &meta.content_type,
1581 state.mashlib.enabled,
1582 ) {
1583 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
1584 std::str::from_utf8(&body).ok().map(|s| s.to_string())
1585 } else {
1586 None
1587 };
1588 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
1589 let mut rsp = HttpResponse::Ok()
1590 .content_type("text/html; charset=utf-8")
1591 .insert_header(("X-Frame-Options", "DENY"))
1592 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1593 .insert_header(("Cache-Control", "no-store"))
1594 .body(inject_live_reload(html, state.live_reload));
1595 set_cache_policy(&mut rsp, "text/html", audience);
1596 set_wac_allow(&mut rsp, &wac_allow);
1597 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1598 set_link_headers(&mut rsp, &path);
1599 return Ok(rsp);
1600 }
1601
1602 if let Some((negotiated_body, negotiated_ct)) =
1610 rdf_content_negotiate(&body, &meta.content_type, accept)
1611 {
1612 let mut rsp = HttpResponse::Ok().body(negotiated_body);
1613 rsp.headers_mut().insert(
1614 header::CONTENT_TYPE,
1615 header::HeaderValue::from_str(negotiated_ct)
1616 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
1617 );
1618 rsp.headers_mut()
1619 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
1620 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1621 rsp.headers_mut().insert(header::ETAG, etag);
1622 }
1623 set_cache_policy(&mut rsp, negotiated_ct, audience);
1624 set_wac_allow(&mut rsp, &wac_allow);
1625 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1626 set_link_headers(&mut rsp, &path);
1627 return Ok(rsp);
1628 }
1629
1630 let response_body = if meta.content_type.starts_with("text/html") {
1631 inject_live_reload(&body, state.live_reload)
1632 } else {
1633 body.to_vec()
1634 };
1635 let mut rsp = HttpResponse::Ok().body(response_body);
1636 rsp.headers_mut().insert(
1637 header::CONTENT_TYPE,
1638 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
1639 header::HeaderValue::from_static("application/octet-stream")
1640 }),
1641 );
1642 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1643 rsp.headers_mut().insert(header::ETAG, etag);
1644 }
1645 set_cache_policy(&mut rsp, &meta.content_type, audience);
1646 set_wac_allow(&mut rsp, &wac_allow);
1647 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1648 set_link_headers(&mut rsp, &path);
1649 Ok(rsp)
1650 }
1651 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1652 Err(e) => Err(to_actix(e)),
1653 }
1654}
1655
1656fn has_basic_container_link(req: &HttpRequest) -> bool {
1657 req.headers()
1658 .get_all(header::LINK)
1659 .filter_map(|v| v.to_str().ok())
1660 .any(|v| {
1661 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
1662 })
1663}
1664
1665fn validate_rdf_body(
1674 req: &HttpRequest,
1675 state: &AppState,
1676 resource_path: &str,
1677 content_type: &str,
1678 body: &[u8],
1679) -> Result<(), ActixError> {
1680 use solid_pod_rs::rdf_input::{parse_rdf_body, RdfInputSyntax};
1681 let Some(syntax) = RdfInputSyntax::from_content_type(content_type) else {
1682 return Ok(());
1683 };
1684 if syntax == RdfInputSyntax::N3 && !state.conneg_enabled {
1685 return Err(actix_web::error::ErrorUnsupportedMediaType(
1686 "text/n3 request bodies require content negotiation (conneg) to be enabled",
1687 ));
1688 }
1689 let base = format!("{}{}", scheme::request_origin(req), resource_path);
1690 match parse_rdf_body(syntax, body, &base) {
1691 Ok(_) => Ok(()),
1692 Err(solid_pod_rs::PodError::PayloadTooLarge(m)) => {
1693 Err(actix_web::error::ErrorPayloadTooLarge(m))
1694 }
1695 Err(e) => Err(to_actix(e)),
1696 }
1697}
1698
1699async fn handle_put(
1700 req: HttpRequest,
1701 body: web::Bytes,
1702 state: web::Data<AppState>,
1703) -> Result<HttpResponse, ActixError> {
1704 let path = req.uri().path().to_string();
1705
1706 if ldp::is_container(&path) {
1707 if has_basic_container_link(&req) {
1708 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1709 let agent = agent_uri(auth_pk.as_ref());
1710 enforce_write_ctx(
1711 &state,
1712 &path,
1713 AccessMode::Write,
1714 agent.as_deref(),
1715 req_origin(&req),
1716 )
1717 .await?;
1718 let meta = state
1719 .storage
1720 .create_container(&path)
1721 .await
1722 .map_err(to_actix)?;
1723 let mut rsp = HttpResponse::Created().finish();
1724 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1725 rsp.headers_mut().insert(header::ETAG, etag);
1726 }
1727 set_link_headers(&mut rsp, &path);
1728 return Ok(rsp);
1729 }
1730 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
1731 }
1732
1733 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1734 let agent = agent_uri(auth_pk.as_ref());
1735 enforce_write_ctx(
1736 &state,
1737 &path,
1738 AccessMode::Write,
1739 agent.as_deref(),
1740 req_origin(&req),
1741 )
1742 .await?;
1743
1744 let ct = req
1745 .headers()
1746 .get(header::CONTENT_TYPE)
1747 .and_then(|v| v.to_str().ok())
1748 .unwrap_or("application/octet-stream");
1749
1750 if protected_resource_for_acl(&path).is_some()
1755 && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
1756 {
1757 return Ok(HttpResponse::Conflict().body(
1758 "refused: the proposed ACL would not grant Control to the caller \
1759 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1760 ));
1761 }
1762
1763 validate_rdf_body(&req, &state, &path, ct, &body)?;
1764
1765 let quota = reserve_quota_for_size(&state, &path, body.len() as u64).await?;
1766 let write = state
1767 .storage
1768 .put(&path, Bytes::from(body.to_vec()), ct)
1769 .await;
1770 finish_quota_reservation(&state, quota, write.is_ok()).await;
1771 let meta = write.map_err(to_actix)?;
1772 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
1776 let mut rsp = HttpResponse::Created().finish();
1777 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1778 rsp.headers_mut().insert(header::ETAG, etag);
1779 }
1780 set_provenance_headers(&mut rsp, &provenance);
1781 set_link_headers(&mut rsp, &path);
1782 Ok(rsp)
1783}
1784
1785async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
1792 if !storage.exists(target).await.unwrap_or(false) {
1793 return target.to_string();
1794 }
1795 let seg_start = target.rfind('/').map(|s| s + 1).unwrap_or(0);
1798 let (stem, ext) = match target.rfind('.') {
1799 Some(dot) if dot > seg_start => (&target[..dot], &target[dot..]),
1800 _ => (target, ""),
1801 };
1802 for n in 1..10_000u32 {
1803 let candidate = format!("{stem}-{n}{ext}");
1804 if !storage.exists(&candidate).await.unwrap_or(false) {
1805 return candidate;
1806 }
1807 }
1808 use std::hash::{Hash, Hasher};
1809 let mut h = std::collections::hash_map::DefaultHasher::new();
1810 target.hash(&mut h);
1811 format!("{stem}-{:x}{ext}", h.finish())
1812}
1813
1814async fn handle_post(
1815 req: HttpRequest,
1816 body: web::Bytes,
1817 state: web::Data<AppState>,
1818) -> Result<HttpResponse, ActixError> {
1819 let path = req.uri().path().to_string();
1820 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1823 let agent = agent_uri(auth_pk.as_ref());
1824 enforce_write_ctx(
1825 &state,
1826 &path,
1827 AccessMode::Append,
1828 agent.as_deref(),
1829 req_origin(&req),
1830 )
1831 .await?;
1832
1833 let slug = req
1834 .headers()
1835 .get(header::HeaderName::from_static("slug"))
1836 .and_then(|v| v.to_str().ok());
1837 let mut target = match ldp::resolve_slug(&path, slug) {
1838 Ok(p) => p,
1839 Err(e) => return Err(to_actix(e)),
1840 };
1841 let ct = req
1842 .headers()
1843 .get(header::CONTENT_TYPE)
1844 .and_then(|v| v.to_str().ok())
1845 .unwrap_or("application/octet-stream");
1846
1847 if protected_resource_for_acl(&target).is_some() {
1856 enforce_write_ctx(
1857 &state,
1858 &target,
1859 AccessMode::Write,
1860 agent.as_deref(),
1861 req_origin(&req),
1862 )
1863 .await?;
1864 if !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref()) {
1865 return Ok(HttpResponse::Conflict().body(
1866 "refused: the proposed ACL would not grant Control to the caller \
1867 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1868 ));
1869 }
1870 } else {
1871 target = mint_unique_target(&*state.storage, &target).await;
1877 }
1878
1879 validate_rdf_body(&req, &state, &target, ct, &body)?;
1880
1881 let quota = reserve_quota_for_size(&state, &target, body.len() as u64).await?;
1882 let write = state
1883 .storage
1884 .put(&target, Bytes::from(body.to_vec()), ct)
1885 .await;
1886 finish_quota_reservation(&state, quota, write.is_ok()).await;
1887 let meta = write.map_err(to_actix)?;
1888 let provenance = git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1891 let mut rsp = HttpResponse::Created().finish();
1892 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1893 rsp.headers_mut().insert(header::LOCATION, loc);
1894 }
1895 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1896 rsp.headers_mut().insert(header::ETAG, etag);
1897 }
1898 set_provenance_headers(&mut rsp, &provenance);
1899 set_link_headers(&mut rsp, &target);
1900 Ok(rsp)
1901}
1902
1903async fn handle_patch(
1904 req: HttpRequest,
1905 body: web::Bytes,
1906 state: web::Data<AppState>,
1907) -> Result<HttpResponse, ActixError> {
1908 let path = req.uri().path().to_string();
1909 if ldp::is_container(&path) {
1910 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1911 }
1912 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1913 let agent = agent_uri(auth_pk.as_ref());
1914 enforce_write_ctx(
1920 &state,
1921 &path,
1922 AccessMode::Write,
1923 agent.as_deref(),
1924 req_origin(&req),
1925 )
1926 .await?;
1927
1928 let ct = req
1929 .headers()
1930 .get(header::CONTENT_TYPE)
1931 .and_then(|v| v.to_str().ok())
1932 .unwrap_or("");
1933 let dialect = match ldp::patch_dialect_from_mime(ct) {
1934 Some(d) => d,
1935 None => {
1936 return Ok(HttpResponse::UnsupportedMediaType()
1937 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1938 }
1939 };
1940 let body_str = match std::str::from_utf8(&body) {
1941 Ok(s) => s.to_string(),
1942 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1943 };
1944
1945 let existing = state.storage.get(&path).await;
1947 match existing {
1948 Ok((current_body, meta)) => {
1949 let out = match dialect {
1959 ldp::PatchDialect::N3 => {
1960 let seed = seed_graph_from_patch_target(¤t_body)?;
1961 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1962 }
1963 ldp::PatchDialect::SparqlUpdate => {
1964 let seed = seed_graph_from_patch_target(¤t_body)?;
1965 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1966 }
1967 ldp::PatchDialect::JsonPatch => {
1968 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1969 Ok(v) => v,
1970 Err(_) => serde_json::json!({}),
1971 };
1972 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1973 Ok(v) => v,
1974 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1975 };
1976 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1977 let bytes = serde_json::to_vec(&json)
1978 .map_err(PodError::from)
1979 .map_err(to_actix)?;
1980 let quota = reserve_quota_for_size(&state, &path, bytes.len() as u64).await?;
1981 let write = state
1982 .storage
1983 .put(&path, Bytes::from(bytes), &meta.content_type)
1984 .await;
1985 finish_quota_reservation(&state, quota, write.is_ok()).await;
1986 let _ = write.map_err(to_actix)?;
1987 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1988 let mut rsp = HttpResponse::NoContent().finish();
1989 set_provenance_headers(&mut rsp, &provenance);
1990 return Ok(rsp);
1991 }
1992 };
1993 let outcome = out?;
1994 let serialised = graph_to_turtle(&outcome.graph);
1997 if protected_resource_for_acl(&path).is_some()
2003 && !proposed_acl_keeps_caller_control(
2004 serialised.as_bytes(),
2005 "application/n-triples",
2006 agent.as_deref(),
2007 )
2008 {
2009 return Ok(HttpResponse::Conflict().body(
2010 "refused: the patched ACL would not grant Control to the caller \
2011 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
2012 ));
2013 }
2014 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
2015 let write = state
2016 .storage
2017 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
2018 .await;
2019 finish_quota_reservation(&state, quota, write.is_ok()).await;
2020 let _ = write.map_err(to_actix)?;
2021 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
2022 let mut rsp = HttpResponse::NoContent().finish();
2023 set_provenance_headers(&mut rsp, &provenance);
2024 Ok(rsp)
2025 }
2026 Err(PodError::NotFound(_)) => {
2027 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
2029 let PatchCreateOutcome::Created { graph, .. } = create else {
2030 return Err(to_actix(PodError::Unsupported(
2031 "unexpected patch outcome on absent resource".into(),
2032 )));
2033 };
2034 let serialised = graph_to_turtle(&graph);
2035 if protected_resource_for_acl(&path).is_some()
2037 && !proposed_acl_keeps_caller_control(
2038 serialised.as_bytes(),
2039 "application/n-triples",
2040 agent.as_deref(),
2041 )
2042 {
2043 return Ok(HttpResponse::Conflict().body(
2044 "refused: the patched ACL would not grant Control to the caller \
2045 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
2046 ));
2047 }
2048 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
2049 let write = state
2050 .storage
2051 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
2052 .await;
2053 finish_quota_reservation(&state, quota, write.is_ok()).await;
2054 let _ = write.map_err(to_actix)?;
2055 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
2056 let mut rsp = HttpResponse::Created().finish();
2057 set_provenance_headers(&mut rsp, &provenance);
2058 Ok(rsp)
2059 }
2060 Err(e) => Err(to_actix(e)),
2061 }
2062}
2063
2064fn patch_parse_err(e: PodError) -> ActixError {
2068 match e {
2069 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
2070 actix_web::error::ErrorBadRequest(msg)
2071 }
2072 other => to_actix(other),
2073 }
2074}
2075
2076fn graph_to_turtle(g: &ldp::Graph) -> String {
2080 g.to_ntriples()
2081}
2082
2083fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
2090 let mut best: Option<(f32, ldp::RdfFormat)> = None;
2091 for entry in accept.split(',') {
2092 let entry = entry.trim();
2093 if entry.is_empty() {
2094 continue;
2095 }
2096 let mut parts = entry.split(';').map(|s| s.trim());
2097 let mime = match parts.next() {
2098 Some(m) => m,
2099 None => continue,
2100 };
2101 let mut q: f32 = 1.0;
2102 for token in parts {
2103 if let Some(v) = token.strip_prefix("q=") {
2104 if let Ok(parsed) = v.parse::<f32>() {
2105 q = parsed;
2106 }
2107 }
2108 }
2109 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
2112 match best {
2113 None => best = Some((q, format)),
2114 Some((bq, _)) if q > bq => best = Some((q, format)),
2115 _ => {}
2116 }
2117 }
2118 }
2119 best.map(|(_, f)| f)
2120}
2121
2122fn rdf_content_negotiate(
2138 body: &[u8],
2139 stored_ct: &str,
2140 accept: &str,
2141) -> Option<(Vec<u8>, &'static str)> {
2142 if accept.trim().is_empty() {
2143 return None;
2144 }
2145 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
2146 let target = best_explicit_rdf_format(accept)?;
2147 if target == stored_format {
2148 return None;
2149 }
2150 let text = std::str::from_utf8(body).ok()?;
2151 let graph = ldp::Graph::parse_ntriples(text).ok()?;
2152 match target {
2153 ldp::RdfFormat::Turtle => Some((
2156 graph.to_ntriples().into_bytes(),
2157 ldp::RdfFormat::Turtle.mime(),
2158 )),
2159 ldp::RdfFormat::NTriples => Some((
2160 graph.to_ntriples().into_bytes(),
2161 ldp::RdfFormat::NTriples.mime(),
2162 )),
2163 ldp::RdfFormat::JsonLd => {
2164 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
2165 Some((json, ldp::RdfFormat::JsonLd.mime()))
2166 }
2167 ldp::RdfFormat::RdfXml => None,
2169 }
2170}
2171
2172fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
2181 let text = std::str::from_utf8(current_body).map_err(|_| {
2182 actix_web::error::ErrorConflict(
2183 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
2184 )
2185 })?;
2186 if text.trim().is_empty() {
2187 return Ok(ldp::Graph::new());
2188 }
2189 ldp::Graph::parse_ntriples(text).map_err(|_| {
2190 actix_web::error::ErrorConflict(
2191 "existing resource is not N-Triples RDF and cannot be non-destructively \
2192 patched; PUT an N-Triples representation or use a JSON Patch",
2193 )
2194 })
2195}
2196
2197pub(crate) async fn find_effective_acl_dyn(
2213 storage: &dyn Storage,
2214 resource_path: &str,
2215) -> Result<Option<wac::AclDocument>, PodError> {
2216 resolve_policy_dyn(storage, resource_path)
2217 .await
2218 .into_result()
2219}
2220
2221pub(crate) async fn resolve_policy_dyn(
2226 storage: &dyn Storage,
2227 resource_path: &str,
2228) -> wac::PolicyOutcome {
2229 wac::resolve_policy_from_storage(storage, resource_path).await
2230}
2231
2232fn policy_failure_to_actix(outcome: &wac::PolicyOutcome, resource: &str) -> ActixError {
2242 match outcome {
2243 wac::PolicyOutcome::Invalid {
2244 policy_path,
2245 reason,
2246 } => {
2247 tracing::warn!(
2248 target: "solid_pod_rs_server::wac",
2249 resource = %resource,
2250 policy = %policy_path,
2251 "denying: effective ACL is invalid and MUST NOT inherit: {reason}"
2252 );
2253 actix_web::error::ErrorForbidden("access forbidden: governing ACL is invalid")
2254 }
2255 wac::PolicyOutcome::Unavailable {
2256 policy_path,
2257 detail,
2258 } => {
2259 tracing::error!(
2260 target: "solid_pod_rs_server::wac",
2261 resource = %resource,
2262 policy = %policy_path,
2263 "denying: effective ACL could not be read: {detail}"
2264 );
2265 actix_web::error::ErrorServiceUnavailable(
2266 "access control unavailable: governing ACL could not be read",
2267 )
2268 }
2269 _ => actix_web::error::ErrorInternalServerError("policy resolution error"),
2271 }
2272}
2273
2274async fn handle_delete(
2275 req: HttpRequest,
2276 state: web::Data<AppState>,
2277) -> Result<HttpResponse, ActixError> {
2278 let path = req.uri().path().to_string();
2279 let auth_pk = extract_pubkey(&req).await;
2280 let agent = agent_uri(auth_pk.as_ref());
2281 enforce_write_ctx(
2282 &state,
2283 &path,
2284 AccessMode::Write,
2285 agent.as_deref(),
2286 req_origin(&req),
2287 )
2288 .await?;
2289
2290 let quota = reserve_quota_for_size(&state, &path, 0).await?;
2291 match state.storage.delete(&path).await {
2292 Ok(()) => {
2293 finish_quota_reservation(&state, quota, true).await;
2294 Ok(HttpResponse::NoContent().finish())
2295 }
2296 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
2297 Err(e) => Err(to_actix(e)),
2298 }
2299}
2300
2301async fn handle_options(
2302 req: HttpRequest,
2303 state: web::Data<AppState>,
2304) -> Result<HttpResponse, ActixError> {
2305 let path = req.uri().path().to_string();
2306 let o = ldp::options_for_conneg(&path, state.conneg_enabled);
2307 let mut rsp = HttpResponse::NoContent().finish();
2308 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
2309 rsp.headers_mut()
2310 .insert(header::HeaderName::from_static("allow"), v);
2311 }
2312 if let Some(ap) = o.accept_post {
2313 if let Ok(v) = header::HeaderValue::from_str(ap) {
2314 rsp.headers_mut()
2315 .insert(header::HeaderName::from_static("accept-post"), v);
2316 }
2317 }
2318 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
2319 rsp.headers_mut()
2320 .insert(header::HeaderName::from_static("accept-patch"), v);
2321 }
2322 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
2323 rsp.headers_mut()
2324 .insert(header::HeaderName::from_static("accept-ranges"), v);
2325 }
2326 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
2327 Ok(rsp)
2328}
2329
2330async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
2335 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
2336 HttpResponse::Ok()
2337 .content_type("application/ld+json")
2338 .json(doc)
2339}
2340
2341#[derive(Debug, Deserialize)]
2342struct WebFingerQuery {
2343 resource: Option<String>,
2344}
2345
2346async fn handle_well_known_webfinger(
2347 state: web::Data<AppState>,
2348 q: web::Query<WebFingerQuery>,
2349) -> HttpResponse {
2350 let resource = q.resource.clone().unwrap_or_else(|| {
2351 format!(
2352 "acct:anonymous@{}",
2353 state
2354 .nodeinfo
2355 .base_url
2356 .trim_start_matches("http://")
2357 .trim_start_matches("https://")
2358 )
2359 });
2360 let webid = format!(
2361 "{}/profile/card#me",
2362 state.nodeinfo.base_url.trim_end_matches('/')
2363 );
2364 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
2365 Some(jrd) => HttpResponse::Ok()
2366 .content_type("application/jrd+json")
2367 .json(jrd),
2368 None => HttpResponse::NotFound().finish(),
2369 }
2370}
2371
2372async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
2373 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
2374 HttpResponse::Ok()
2375 .content_type("application/json")
2376 .json(doc)
2377}
2378
2379async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
2380 let doc = interop::nodeinfo_2_1(
2381 &state.nodeinfo.software_name,
2382 &state.nodeinfo.software_version,
2383 state.nodeinfo.open_registrations,
2384 state.nodeinfo.total_users,
2385 );
2386 HttpResponse::Ok()
2387 .content_type("application/json")
2388 .json(doc)
2389}
2390
2391#[cfg(feature = "did-nostr")]
2392async fn handle_well_known_did_nostr(
2393 state: web::Data<AppState>,
2394 path: web::Path<String>,
2395) -> HttpResponse {
2396 let pubkey = path.into_inner();
2397 let pubkey_is_valid = pubkey.len() == 64
2402 && pubkey
2403 .bytes()
2404 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
2405 if !pubkey_is_valid {
2406 return HttpResponse::BadRequest()
2407 .insert_header(("Cache-Control", "no-store"))
2408 .json(serde_json::json!({
2409 "error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
2410 }));
2411 }
2412 let owner_pubkey = match state.storage.get("/profile/card").await {
2420 Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
2421 .ok()
2422 .flatten(),
2423 Err(_) => None,
2424 };
2425 let owner_claims_key = owner_pubkey
2426 .as_deref()
2427 .is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
2428 if !owner_claims_key {
2429 return HttpResponse::NotFound()
2430 .insert_header(("Cache-Control", "no-store"))
2431 .json(serde_json::json!({
2432 "error": "no account on this pod claims this did:nostr pubkey"
2433 }));
2434 }
2435 let also = vec![format!(
2436 "{}/profile/card#me",
2437 state.nodeinfo.base_url.trim_end_matches('/')
2438 )];
2439 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
2440 let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
2441 use std::hash::{Hash, Hasher};
2447 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2448 body.hash(&mut hasher);
2449 let etag = format!("\"{:016x}\"", hasher.finish());
2450 HttpResponse::Ok()
2451 .content_type("application/did+json")
2452 .insert_header(("Cache-Control", "max-age=3600"))
2453 .insert_header(("ETag", etag))
2454 .body(body)
2455}
2456
2457#[cfg(feature = "nip05-endpoint")]
2465#[derive(Debug, Deserialize)]
2466struct Nip05Query {
2467 name: Option<String>,
2470}
2471
2472#[cfg(feature = "nip05-endpoint")]
2473fn nip05_name_is_valid(name: &str) -> bool {
2474 if name.is_empty() {
2477 return false;
2478 }
2479 name.bytes()
2480 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
2481}
2482
2483#[cfg(feature = "nip05-endpoint")]
2484async fn handle_well_known_nip05(
2485 state: web::Data<AppState>,
2486 query: web::Query<Nip05Query>,
2487) -> HttpResponse {
2488 use solid_pod_rs::webid::extract_nostr_pubkey;
2489
2490 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
2492 if !nip05_name_is_valid(&name) {
2493 return HttpResponse::BadRequest().json(serde_json::json!({
2494 "error": "invalid NIP-05 local part",
2495 }));
2496 }
2497
2498 let profile_path = if name == "_" {
2504 "/profile/card".to_string()
2505 } else {
2506 format!("/{name}/profile/card")
2507 };
2508
2509 let (body, _meta) = match state.storage.get(&profile_path).await {
2510 Ok(v) => v,
2511 Err(_) => {
2512 return nip05_empty_response();
2516 }
2517 };
2518
2519 let pubkey_hex = match extract_nostr_pubkey(&body) {
2520 Ok(Some(p)) => p,
2521 _ => return nip05_empty_response(),
2522 };
2523
2524 let doc = interop::nip05_document([(name, pubkey_hex)]);
2525 HttpResponse::Ok()
2526 .insert_header(("Access-Control-Allow-Origin", "*"))
2527 .content_type("application/json")
2528 .json(doc)
2529}
2530
2531#[cfg(feature = "nip05-endpoint")]
2532fn nip05_empty_response() -> HttpResponse {
2533 HttpResponse::Ok()
2534 .insert_header(("Access-Control-Allow-Origin", "*"))
2535 .content_type("application/json")
2536 .json(serde_json::json!({ "names": {} }))
2537}
2538
2539#[cfg(feature = "export-jsonld")]
2553async fn handle_export_all(
2554 req: HttpRequest,
2555 state: web::Data<AppState>,
2556) -> Result<HttpResponse, ActixError> {
2557 let auth_pk = extract_pubkey(&req).await;
2558 let agent = agent_uri(auth_pk.as_ref());
2559
2560 enforce_write_ctx(
2565 &state,
2566 "/",
2567 AccessMode::Control,
2568 agent.as_deref(),
2569 req_origin(&req),
2570 )
2571 .await?;
2572
2573 let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
2577 .ok()
2578 .and_then(|q| q.get("include_private").map(|v| v == "true"))
2579 .unwrap_or(false);
2580
2581 let pod_base = format!("{}/", scheme::request_origin(&req));
2585
2586 let options = solid_pod_rs::ExportOptions { include_private };
2587 let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
2588 .await
2589 .map_err(to_actix)?;
2590
2591 let body = serde_json::to_vec(&bundle).map_err(|e| {
2592 actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
2593 })?;
2594 Ok(HttpResponse::Ok()
2595 .content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
2596 .body(body))
2597}
2598
2599#[derive(Debug, Deserialize)]
2604struct CreateAccountRequest {
2605 username: String,
2606 #[serde(default)]
2607 name: Option<String>,
2608}
2609
2610#[derive(Debug, Deserialize)]
2611struct CreatePodRequest {
2612 name: String,
2613}
2614
2615async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
2616 let pod_name = path.into_inner();
2617 let pod_root = format!("/{pod_name}/");
2618 match state.storage.exists(&pod_root).await {
2619 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
2620 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
2621 }
2622}
2623
2624fn valid_pod_name(name: &str) -> bool {
2625 !name.is_empty()
2626 && name
2627 .chars()
2628 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
2629}
2630
2631fn request_ip(req: &HttpRequest) -> IpAddr {
2632 req.peer_addr()
2633 .map(|addr| addr.ip())
2634 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
2635}
2636
2637#[derive(Debug)]
2638struct QuotaReservation {
2639 pod: String,
2640 delta: i64,
2641}
2642
2643fn pod_name_from_path(path: &str) -> Option<&str> {
2644 path.trim_start_matches('/')
2645 .split('/')
2646 .next()
2647 .filter(|p| !p.is_empty())
2648}
2649
2650async fn reserve_quota_for_size(
2651 state: &AppState,
2652 path: &str,
2653 new_size: u64,
2654) -> Result<Option<QuotaReservation>, ActixError> {
2655 let (Some(policy), Some(pod)) = (&state.quota, pod_name_from_path(path)) else {
2656 return Ok(None);
2657 };
2658 let old_size = state
2659 .storage
2660 .head(path)
2661 .await
2662 .map(|meta| meta.size)
2663 .unwrap_or(0);
2664 let delta = i128::from(new_size) - i128::from(old_size);
2665 let delta = delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64;
2666 if delta > 0 {
2667 policy
2668 .reserve(pod, delta as u64)
2669 .await
2670 .map_err(|error| actix_web::error::ErrorInsufficientStorage(error.to_string()))?;
2671 }
2672 Ok(Some(QuotaReservation {
2673 pod: pod.to_string(),
2674 delta,
2675 }))
2676}
2677
2678async fn finish_quota_reservation(
2679 state: &AppState,
2680 reservation: Option<QuotaReservation>,
2681 write_succeeded: bool,
2682) {
2683 let (Some(policy), Some(reservation)) = (&state.quota, reservation) else {
2684 return;
2685 };
2686 if write_succeeded {
2687 if reservation.delta < 0 {
2688 policy.record(&reservation.pod, reservation.delta).await;
2689 }
2690 } else if reservation.delta > 0 {
2691 policy.record(&reservation.pod, -reservation.delta).await;
2692 }
2693}
2694
2695async fn handle_create_account(
2696 req: HttpRequest,
2697 state: web::Data<AppState>,
2698 body: web::Json<CreateAccountRequest>,
2699) -> Result<HttpResponse, ActixError> {
2700 if let Some(response) = provisioning_gate(&req, &state, &body.username).await {
2701 return Ok(response);
2702 }
2703 let pod_root = format!("/{}/", body.username);
2704 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2705 return Ok(
2706 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
2707 );
2708 }
2709
2710 let base_uri = state.nodeinfo.base_url.trim_end_matches('/');
2711 let outcome =
2712 provision_named_pod(&state, &body.username, body.name.as_deref(), base_uri).await?;
2713 Ok(HttpResponse::Created().json(serde_json::json!({
2714 "webid": outcome.webid,
2715 "pod_root": outcome.pod_uri,
2716 "username": body.username,
2717 })))
2718}
2719
2720struct NamedPodOutcome {
2721 webid: String,
2722 pod_uri: String,
2723}
2724
2725async fn provisioning_gate(
2728 req: &HttpRequest,
2729 state: &AppState,
2730 name: &str,
2731) -> Option<HttpResponse> {
2732 if !valid_pod_name(name) {
2733 return Some(HttpResponse::BadRequest().json(serde_json::json!({
2734 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
2735 })));
2736 }
2737
2738 let supplied_key = req
2739 .headers()
2740 .get("x-pod-admin-key")
2741 .and_then(|value| value.to_str().ok())
2742 .unwrap_or("");
2743 use subtle::ConstantTimeEq;
2744 let admin_override = state
2745 .admin_key
2746 .as_deref()
2747 .is_some_and(|expected| bool::from(supplied_key.as_bytes().ct_eq(expected.as_bytes())));
2748 let invite = req
2749 .headers()
2750 .get("x-invite-token")
2751 .and_then(|v| v.to_str().ok())
2752 .map(str::trim)
2753 .filter(|t| !t.is_empty());
2754 let closed = !state.nodeinfo.open_registrations && !admin_override;
2755 if closed && invite.is_none() {
2756 return Some(HttpResponse::Forbidden().json(serde_json::json!({
2757 "error": "pod registration is closed"
2758 })));
2759 }
2760
2761 if let Err(retry_after) = state.pod_create_limiter.check(request_ip(req)) {
2762 return Some(
2763 HttpResponse::TooManyRequests()
2764 .insert_header(("Retry-After", retry_after.to_string()))
2765 .json(serde_json::json!({
2766 "error": "Too Many Requests",
2767 "message": "Pod creation rate limit exceeded",
2768 "retryAfter": retry_after
2769 })),
2770 );
2771 }
2772 if closed {
2777 let (Some(token), Some(root)) = (invite, state.data_root.as_deref()) else {
2778 return Some(HttpResponse::Forbidden().json(serde_json::json!({
2779 "error": "pod registration is closed"
2780 })));
2781 };
2782 let refusal =
2783 |why: &str| HttpResponse::Forbidden().json(serde_json::json!({ "error": why }));
2784 match cli::FileInviteStore::new(root).consume(token).await {
2785 Ok(cli::Consume::Granted) => {}
2786 Ok(cli::Consume::Expired) => return Some(refusal("invite has expired")),
2787 Ok(cli::Consume::Exhausted) => return Some(refusal("invite has been used")),
2788 Ok(cli::Consume::Unknown) => return Some(refusal("invalid invite code")),
2789 Err(e) => {
2790 tracing::error!("invite store: {e}");
2791 return Some(
2792 HttpResponse::InternalServerError()
2793 .json(serde_json::json!({ "error": "invite store unavailable" })),
2794 );
2795 }
2796 }
2797 }
2798 None
2799}
2800
2801async fn provision_named_pod(
2805 state: &AppState,
2806 name: &str,
2807 display_name: Option<&str>,
2808 base_uri: &str,
2809) -> Result<NamedPodOutcome, ActixError> {
2810 for container in ["", "profile", "inbox", "public", "private", "settings"] {
2811 let path = if container.is_empty() {
2812 format!("/{name}")
2813 } else {
2814 format!("/{name}/{container}")
2815 };
2816 state
2817 .storage
2818 .put(
2819 &format!("{path}.meta"),
2820 Bytes::from_static(b"{}"),
2821 "application/ld+json",
2822 )
2823 .await
2824 .map_err(to_actix)?;
2825 }
2826
2827 let base_uri = base_uri.trim_end_matches('/');
2828 let pod_uri = format!("{base_uri}/{name}/");
2829 let canonical_prefix = format!("{base_uri}/pods/{name}/");
2830 let webid = format!("{pod_uri}profile/card#me");
2831 let profile = solid_pod_rs::webid::generate_webid_html(name, display_name, base_uri)
2832 .replace(&canonical_prefix, &pod_uri);
2833 state
2834 .storage
2835 .put(
2836 &format!("/{name}/profile/card"),
2837 Bytes::from(profile.into_bytes()),
2838 "text/html",
2839 )
2840 .await
2841 .map_err(to_actix)?;
2842
2843 #[cfg(feature = "git")]
2844 if let Some(root) = &state.data_root {
2845 let hook = solid_pod_rs_git::init::GitAutoInit::new();
2846 if let Err(error) = hook.init_repo_at(&root.join(name)).await {
2847 tracing::warn!(pod = name, %error, "git auto-init failed after pod creation");
2848 }
2849 }
2850
2851 Ok(NamedPodOutcome { webid, pod_uri })
2852}
2853
2854async fn handle_create_pod(
2855 req: HttpRequest,
2856 state: web::Data<AppState>,
2857 body: web::Json<CreatePodRequest>,
2858) -> Result<HttpResponse, ActixError> {
2859 if let Some(response) = provisioning_gate(&req, &state, &body.name).await {
2860 return Ok(response);
2861 }
2862
2863 let pod_root = format!("/{}/", body.name);
2864 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2865 return Ok(
2866 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
2867 );
2868 }
2869
2870 let base_uri = scheme::request_origin(&req);
2871 let outcome = provision_named_pod(&state, &body.name, None, &base_uri).await?;
2872
2873 Ok(HttpResponse::Created()
2874 .insert_header(("Location", outcome.pod_uri.clone()))
2875 .json(serde_json::json!({
2876 "name": body.name,
2877 "webId": outcome.webid,
2878 "podUri": outcome.pod_uri,
2879 })))
2880}
2881
2882fn normalise_copy_source(raw: &str, host: &str) -> Option<String> {
2892 let path = if raw.starts_with("http://") || raw.starts_with("https://") {
2893 let url = url::Url::parse(raw).ok()?;
2894 let authority = match url.port() {
2895 Some(port) => format!("{}:{port}", url.host_str()?),
2896 None => url.host_str()?.to_string(),
2897 };
2898 if !authority.eq_ignore_ascii_case(host)
2899 || url.query().is_some()
2900 || url.fragment().is_some()
2901 {
2902 return None;
2903 }
2904 url.path().to_string()
2905 } else if raw.starts_with('/') && !raw.contains(['?', '#']) {
2906 raw.to_string()
2907 } else {
2908 return None;
2909 };
2910 canonical_pod_path(&path)
2911}
2912
2913async fn handle_copy(
2914 req: HttpRequest,
2915 state: web::Data<AppState>,
2916) -> Result<HttpResponse, ActixError> {
2917 let dest = req.uri().path().to_string();
2918 let auth_pk = extract_pubkey(&req).await;
2919 let agent = agent_uri(auth_pk.as_ref());
2920 enforce_write_ctx(
2921 &state,
2922 &dest,
2923 AccessMode::Write,
2924 agent.as_deref(),
2925 req_origin(&req),
2926 )
2927 .await?;
2928
2929 let source = req
2930 .headers()
2931 .get("source")
2932 .and_then(|v| v.to_str().ok())
2933 .map(|s| s.to_string());
2934 let source = match source {
2935 Some(s) => s,
2936 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
2937 };
2938 let host = scheme::request_host(&req);
2939 let source = match normalise_copy_source(&source, &host) {
2940 Some(s) => s,
2941 None => return Ok(HttpResponse::BadRequest().body("invalid Source")),
2942 };
2943 if !state.dotfiles.is_allowed(Path::new(&source)) {
2944 return Ok(HttpResponse::Forbidden().body("dotfile path denied by allowlist"));
2945 }
2946 enforce_read_ctx(&state, &source, agent.as_deref(), req_origin(&req)).await?;
2949
2950 let (body, meta) = match state.storage.get(&source).await {
2951 Ok(v) => v,
2952 Err(PodError::NotFound(_)) => {
2953 return Ok(HttpResponse::NotFound().body("source resource not found"))
2954 }
2955 Err(e) => return Err(to_actix(e)),
2956 };
2957
2958 if protected_resource_for_acl(&dest).is_some()
2960 && !proposed_acl_keeps_caller_control(&body, &meta.content_type, agent.as_deref())
2961 {
2962 return Ok(HttpResponse::Conflict().body(
2963 "refused: the proposed ACL would not grant Control to the caller \
2964 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
2965 ));
2966 }
2967
2968 let quota = reserve_quota_for_size(&state, &dest, body.len() as u64).await?;
2969 let write = state.storage.put(&dest, body, &meta.content_type).await;
2970 finish_quota_reservation(&state, quota, write.is_ok()).await;
2971 write.map_err(to_actix)?;
2972
2973 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
2978 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
2979 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
2980 let origin = req_origin(&req);
2981 let permitted = enforce_read_ctx(&state, &src_acl, agent.as_deref(), origin)
2982 .await
2983 .is_ok()
2984 && enforce_write_ctx(
2985 &state,
2986 &dst_acl,
2987 AccessMode::Write,
2988 agent.as_deref(),
2989 origin,
2990 )
2991 .await
2992 .is_ok()
2993 && proposed_acl_keeps_caller_control(
2994 &acl_body,
2995 &acl_meta.content_type,
2996 agent.as_deref(),
2997 );
2998 if permitted {
2999 let quota = reserve_quota_for_size(&state, &dst_acl, acl_body.len() as u64).await?;
3000 let write = state
3001 .storage
3002 .put(&dst_acl, acl_body, &acl_meta.content_type)
3003 .await;
3004 finish_quota_reservation(&state, quota, write.is_ok()).await;
3005 write.map_err(to_actix)?;
3006 }
3007 }
3008
3009 let mut rsp = HttpResponse::Created().finish();
3010 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
3011 rsp.headers_mut().insert(header::LOCATION, loc);
3012 }
3013 Ok(rsp)
3014}
3015
3016async fn handle_glob_get(
3021 req: HttpRequest,
3022 state: web::Data<AppState>,
3023) -> Result<HttpResponse, ActixError> {
3024 let raw_path = req.uri().path().to_string();
3025 if !raw_path.ends_with("/*") {
3027 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
3028 }
3029 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
3031 folder.to_string()
3032 } else {
3033 format!("{folder}/")
3034 };
3035
3036 let auth_pk = extract_pubkey(&req).await;
3040 let agent = agent_uri(auth_pk.as_ref());
3041 let origin = req_origin(&req);
3042 let audience = enforce_read_ctx(&state, &folder, agent.as_deref(), origin).await?;
3043
3044 let children = state.storage.list(&folder).await.map_err(to_actix)?;
3045 let mut merged = String::new();
3046 let mut audience = audience;
3047
3048 for child in &children {
3049 if child.ends_with('/')
3050 || child.ends_with(".acl")
3051 || child.ends_with(".meta")
3052 || child.ends_with(".prov.ttl")
3053 {
3054 continue;
3055 }
3056 let child_path = format!("{folder}{child}");
3057 match enforce_read_inner(&state, &child_path, agent.as_deref(), origin, false).await {
3060 Ok(ResponseAudience::Private) => audience = ResponseAudience::Private,
3061 Ok(ResponseAudience::Public) => {}
3062 Err(_) => continue,
3063 }
3064 if let Ok((body, meta)) = state.storage.get(&child_path).await {
3065 if meta.content_type.contains("turtle")
3066 || meta.content_type.contains("n-triples")
3067 || meta.content_type.contains("n3")
3068 {
3069 if let Ok(text) = std::str::from_utf8(&body) {
3070 merged.push_str(text);
3071 merged.push('\n');
3072 }
3073 }
3074 }
3075 }
3076
3077 if merged.is_empty() {
3078 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
3079 }
3080
3081 let mut rsp = HttpResponse::Ok().content_type("text/turtle").body(merged);
3082 set_cache_policy(&mut rsp, "text/turtle", audience);
3083 Ok(rsp)
3084}
3085
3086#[derive(Debug, Deserialize)]
3091struct LoginPasswordRequest {
3092 username: String,
3093 password: String,
3094}
3095
3096async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
3103 let _ = (&body.username, &body.password);
3104 HttpResponse::NotImplemented().json(serde_json::json!({
3105 "error": "password login is not implemented on this pod"
3106 }))
3107}
3108
3109#[derive(Debug, Deserialize)]
3110struct PasswordResetRequest {
3111 username: String,
3112}
3113
3114async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
3118 let _ = &body.username;
3119 HttpResponse::NotImplemented().json(serde_json::json!({
3120 "error": "password reset is not implemented on this pod"
3121 }))
3122}
3123
3124#[derive(Debug, Deserialize)]
3125struct PasswordChangeRequest {
3126 token: String,
3127 new_password: String,
3128}
3129
3130async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
3135 let _ = (&body.token, &body.new_password);
3136 HttpResponse::NotImplemented().json(serde_json::json!({
3137 "error": "password change is not implemented on this pod"
3138 }))
3139}
3140
3141async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
3146 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
3147 HttpResponse::Ok()
3148 .content_type("application/json")
3149 .json(body)
3150}
3151
3152pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
3167
3168#[derive(Debug, Deserialize)]
3170struct ProxyQuery {
3171 url: String,
3172}
3173
3174const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
3176 "set-cookie",
3177 "set-cookie2",
3178 "authorization",
3179 "www-authenticate",
3180 "proxy-authenticate",
3181 "proxy-authorization",
3182];
3183
3184async fn validate_proxy_target(target: &str) -> Result<(url::Url, IpAddr), HttpResponse> {
3200 let parsed = match url::Url::parse(target) {
3201 Ok(u) => u,
3202 Err(_) => {
3203 return Err(
3204 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
3205 );
3206 }
3207 };
3208
3209 match parsed.scheme() {
3211 "http" | "https" => {}
3212 scheme => {
3213 return Err(HttpResponse::BadRequest()
3214 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
3215 }
3216 }
3217
3218 if solid_pod_rs::security::is_safe_url(target).is_err() {
3221 return Err(HttpResponse::Forbidden()
3222 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
3223 }
3224
3225 let host = match parsed.host_str() {
3227 Some(h) => h.to_string(),
3228 None => {
3229 return Err(HttpResponse::BadRequest()
3230 .json(serde_json::json!({"error": "target URL has no host"})))
3231 }
3232 };
3233 let host_lower = host.to_ascii_lowercase();
3234 if host_lower == "localhost"
3235 || host_lower.ends_with(".localhost")
3236 || host_lower == "0.0.0.0"
3237 || host_lower == "[::1]"
3238 || host_lower == "[::0]"
3239 {
3240 return Err(HttpResponse::Forbidden()
3241 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
3242 }
3243
3244 match solid_pod_rs::security::resolve_and_check(&host).await {
3247 Ok(ip) => Ok((parsed, ip)),
3248 Err(_) => Err(HttpResponse::Forbidden()
3249 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"}))),
3250 }
3251}
3252
3253fn build_pinned_proxy_client(url: &url::Url, ip: IpAddr) -> Result<reqwest::Client, ActixError> {
3256 let mut builder = reqwest::Client::builder()
3257 .redirect(reqwest::redirect::Policy::none());
3260 if let Some(host) = url.host_str() {
3261 let port = url.port_or_known_default().unwrap_or(0);
3263 builder = builder.resolve(host, std::net::SocketAddr::new(ip, port));
3264 }
3265 builder
3266 .build()
3267 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))
3268}
3269
3270async fn handle_proxy(
3271 req: HttpRequest,
3272 _state: web::Data<AppState>,
3273 query: web::Query<ProxyQuery>,
3274) -> Result<HttpResponse, ActixError> {
3275 let auth_pk = extract_pubkey(&req).await;
3277 let agent = agent_uri(auth_pk.as_ref());
3278 if agent.is_none() {
3279 return Ok(HttpResponse::Unauthorized()
3280 .json(serde_json::json!({"error": "authentication required"})));
3281 }
3282
3283 let mut current_url = query.url.clone();
3284 let mut redirect_count = 0u8;
3285 const MAX_REDIRECTS: u8 = 5;
3286 const TOTAL_TIMEOUT: Duration = Duration::from_secs(30);
3287 let started = Instant::now();
3288
3289 let byte_cap = std::env::var("PROXY_BYTE_CAP")
3290 .ok()
3291 .and_then(|v| {
3292 solid_pod_rs::config::sources::parse_size(&v)
3293 .map(|u| u as usize)
3294 .ok()
3295 })
3296 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
3297
3298 loop {
3299 let Some(remaining) = TOTAL_TIMEOUT.checked_sub(started.elapsed()) else {
3300 return Ok(HttpResponse::GatewayTimeout()
3301 .json(serde_json::json!({"error": "proxy operation timed out"})));
3302 };
3303 let (target_url, pinned_ip) = match validate_proxy_target(¤t_url).await {
3307 Ok(pair) => pair,
3308 Err(rsp) => return Ok(rsp),
3309 };
3310 let client = build_pinned_proxy_client(&target_url, pinned_ip)?;
3311
3312 let mut upstream_req = client.get(¤t_url).timeout(remaining);
3313
3314 if let Some(auth_val) = req
3316 .headers()
3317 .get("x-upstream-authorization")
3318 .and_then(|v| v.to_str().ok())
3319 {
3320 upstream_req = upstream_req.header("Authorization", auth_val);
3321 }
3322
3323 let response = upstream_req
3324 .send()
3325 .await
3326 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
3327
3328 if response.status().is_redirection() {
3330 if redirect_count >= MAX_REDIRECTS {
3331 return Ok(HttpResponse::BadGateway()
3332 .json(serde_json::json!({"error": "too many redirects"})));
3333 }
3334 if let Some(location) = response.headers().get("location") {
3335 let loc_str = location
3336 .to_str()
3337 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
3338 let base = url::Url::parse(¤t_url)
3340 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
3341 let resolved = base
3342 .join(loc_str)
3343 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
3344 current_url = resolved.to_string();
3345 redirect_count += 1;
3346 continue;
3347 }
3348 return Ok(HttpResponse::BadGateway()
3349 .json(serde_json::json!({"error": "redirect without location"})));
3350 }
3351
3352 let upstream_status = response.status().as_u16();
3354 let upstream_content_type = response
3355 .headers()
3356 .get("content-type")
3357 .and_then(|v| v.to_str().ok())
3358 .unwrap_or("application/octet-stream")
3359 .to_string();
3360 if response
3361 .content_length()
3362 .is_some_and(|length| length > byte_cap as u64)
3363 {
3364 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3365 "error": "proxied response exceeds byte cap",
3366 "limit": byte_cap
3367 })));
3368 }
3369
3370 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
3372 for (name, value) in response.headers() {
3373 let name_lower = name.as_str().to_ascii_lowercase();
3374 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
3375 continue;
3376 }
3377 if matches!(
3379 name_lower.as_str(),
3380 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
3381 ) {
3382 continue;
3383 }
3384 if let Ok(val_str) = value.to_str() {
3385 forwarded_headers.push((name_lower, val_str.to_string()));
3386 }
3387 }
3388
3389 let mut stream = response.bytes_stream();
3390 let mut body_bytes = Vec::with_capacity(byte_cap.min(64 * 1024));
3391 while let Some(chunk) = stream.next().await {
3392 let chunk =
3393 chunk.map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
3394 if body_bytes.len().saturating_add(chunk.len()) > byte_cap {
3395 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3396 "error": "proxied response exceeds byte cap",
3397 "limit": byte_cap
3398 })));
3399 }
3400 body_bytes.extend_from_slice(&chunk);
3401 }
3402
3403 let mut rsp = HttpResponse::build(
3405 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3406 );
3407 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
3408 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
3409
3410 for (name, value) in &forwarded_headers {
3412 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
3413 if let Ok(hval) = header::HeaderValue::from_str(value) {
3414 rsp.insert_header((hname, hval));
3415 }
3416 }
3417 }
3418
3419 return Ok(rsp.body(body_bytes));
3420 }
3421}
3422
3423pub struct PathTraversalGuard;
3429
3430impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
3431where
3432 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3433 B: 'static,
3434{
3435 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3436 type Error = ActixError;
3437 type InitError = ();
3438 type Transform = PathTraversalGuardMiddleware<S>;
3439 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3440
3441 fn new_transform(&self, service: S) -> Self::Future {
3442 ready(Ok(PathTraversalGuardMiddleware { service }))
3443 }
3444}
3445
3446pub struct PathTraversalGuardMiddleware<S> {
3448 service: S,
3449}
3450
3451impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
3452where
3453 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3454 B: 'static,
3455{
3456 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3457 type Error = ActixError;
3458 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3459
3460 actix_web::dev::forward_ready!(service);
3461
3462 fn call(&self, req: ServiceRequest) -> Self::Future {
3463 let raw = req.path().to_string();
3466 if path_is_traversal(&raw) {
3467 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
3468 let sr = req.into_response(rsp.map_into_boxed_body());
3469 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3470 }
3471 let fut = self.service.call(req);
3472 Box::pin(async move {
3473 let resp = fut.await?;
3474 Ok(resp.map_into_left_body())
3475 })
3476 }
3477}
3478
3479fn path_is_traversal(path: &str) -> bool {
3480 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
3482 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
3483 for seg in once.split('/').chain(twice.split('/')) {
3484 if seg == ".." || seg == "." {
3485 return true;
3486 }
3487 }
3488 twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..")
3491}
3492
3493pub struct CorsHeaders {
3504 pub allowed_origins: Arc<Vec<String>>,
3505}
3506
3507impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
3508where
3509 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3510 B: 'static,
3511{
3512 type Response = ServiceResponse<B>;
3513 type Error = ActixError;
3514 type InitError = ();
3515 type Transform = CorsHeadersMiddleware<S>;
3516 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3517
3518 fn new_transform(&self, service: S) -> Self::Future {
3519 ready(Ok(CorsHeadersMiddleware {
3520 service,
3521 allowed_origins: self.allowed_origins.clone(),
3522 }))
3523 }
3524}
3525
3526pub struct CorsHeadersMiddleware<S> {
3528 service: S,
3529 allowed_origins: Arc<Vec<String>>,
3530}
3531
3532impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
3533where
3534 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3535 B: 'static,
3536{
3537 type Response = ServiceResponse<B>;
3538 type Error = ActixError;
3539 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3540
3541 actix_web::dev::forward_ready!(service);
3542
3543 fn call(&self, req: ServiceRequest) -> Self::Future {
3544 let origin = req
3545 .headers()
3546 .get(header::ORIGIN)
3547 .and_then(|v| v.to_str().ok())
3548 .map(str::to_string);
3549 let allowed = self.allowed_origins.clone();
3550 let fut = self.service.call(req);
3551 Box::pin(async move {
3552 let mut resp = fut.await?;
3553 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
3554 Ok(resp)
3555 })
3556 }
3557}
3558
3559pub(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";
3567
3568fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
3569 if headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
3579 return;
3580 }
3581 let (origin_value, allow_credentials): (String, bool) = if allowed.is_empty() {
3592 ("*".to_string(), false)
3593 } else {
3594 match origin.filter(|o| allowed.iter().any(|a| a == *o)) {
3595 Some(o) => (o.to_string(), true),
3596 None => return,
3599 }
3600 };
3601
3602 let mut pairs = vec![
3603 ("access-control-allow-origin", origin_value.as_str()),
3604 (
3605 "access-control-allow-methods",
3606 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
3607 ),
3608 (
3609 "access-control-allow-headers",
3610 "Accept, Authorization, Content-Type, DPoP, Git-Protocol, If-Match, If-None-Match, Link, Range, Slug, Origin",
3611 ),
3612 (
3613 "access-control-expose-headers",
3614 CORS_EXPOSE_HEADERS,
3615 ),
3616 ("access-control-max-age", "86400"),
3617 ];
3618 if allow_credentials {
3621 pairs.push(("access-control-allow-credentials", "true"));
3622 }
3623
3624 for (name, value) in pairs {
3625 if let (Ok(name), Ok(value)) = (
3626 header::HeaderName::from_lowercase(name.as_bytes()),
3627 header::HeaderValue::from_str(value),
3628 ) {
3629 headers.insert(name, value);
3630 }
3631 }
3632}
3633
3634pub struct ErrorLoggingMiddleware;
3650
3651impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
3652where
3653 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3654 B: 'static,
3655{
3656 type Response = ServiceResponse<B>;
3657 type Error = ActixError;
3658 type InitError = ();
3659 type Transform = ErrorLoggingMiddlewareService<S>;
3660 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3661
3662 fn new_transform(&self, service: S) -> Self::Future {
3663 ready(Ok(ErrorLoggingMiddlewareService { service }))
3664 }
3665}
3666
3667pub struct ErrorLoggingMiddlewareService<S> {
3669 service: S,
3670}
3671
3672impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
3673where
3674 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3675 B: 'static,
3676{
3677 type Response = ServiceResponse<B>;
3678 type Error = ActixError;
3679 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3680
3681 actix_web::dev::forward_ready!(service);
3682
3683 fn call(&self, req: ServiceRequest) -> Self::Future {
3684 let method = req.method().as_str().to_string();
3687 let path = req.path().to_string();
3688
3689 let fut = self.service.call(req);
3690 Box::pin(async move {
3691 let response = fut.await?;
3692 let status = response.status();
3693 if status.is_server_error() {
3694 log_5xx(&method, &path, status, response.response().error());
3695 }
3696 Ok(response)
3697 })
3698 }
3699}
3700
3701fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
3705 let chain = match error {
3709 Some(e) => format_error_chain(e),
3710 None => "<no error attached to response>".to_string(),
3711 };
3712
3713 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
3714 Some(std::backtrace::Backtrace::force_capture().to_string())
3715 } else {
3716 None
3717 };
3718
3719 tracing::error!(
3720 target: "solid_pod_rs_server::http",
3721 method = %method,
3722 path = %path,
3723 status = %status.as_u16(),
3724 error.chain = %chain,
3725 backtrace = backtrace.as_deref().unwrap_or(""),
3726 "5xx response"
3727 );
3728}
3729
3730fn format_error_chain(e: &actix_web::Error) -> String {
3741 let summary = format!("{}", e.as_response_error());
3742 let debug = format!("{e:?}");
3743 if debug == summary || debug.is_empty() {
3744 summary
3745 } else {
3746 format!("{summary} -> {debug}")
3747 }
3748}
3749
3750pub struct DotfileGuard {
3756 allow: Arc<DotfileAllowlist>,
3757 hosts: Option<Arc<HostPolicy>>,
3758}
3759
3760pub struct HostPolicy {
3767 pub allowed: Vec<String>,
3769 pub trust_proxy: bool,
3771}
3772
3773impl DotfileGuard {
3774 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
3775 Self { allow, hosts: None }
3776 }
3777
3778 pub fn with_host_policy(mut self, policy: HostPolicy) -> Self {
3780 if !policy.allowed.is_empty() {
3781 self.hosts = Some(Arc::new(policy));
3782 }
3783 self
3784 }
3785}
3786
3787impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
3788where
3789 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3790 B: 'static,
3791{
3792 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3793 type Error = ActixError;
3794 type InitError = ();
3795 type Transform = DotfileGuardMiddleware<S>;
3796 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3797
3798 fn new_transform(&self, service: S) -> Self::Future {
3799 ready(Ok(DotfileGuardMiddleware {
3800 service,
3801 allow: self.allow.clone(),
3802 hosts: self.hosts.clone(),
3803 }))
3804 }
3805}
3806
3807pub struct DotfileGuardMiddleware<S> {
3809 service: S,
3810 allow: Arc<DotfileAllowlist>,
3811 hosts: Option<Arc<HostPolicy>>,
3812}
3813
3814impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
3815where
3816 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3817 B: 'static,
3818{
3819 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3820 type Error = ActixError;
3821 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3822
3823 actix_web::dev::forward_ready!(service);
3824
3825 fn call(&self, req: ServiceRequest) -> Self::Future {
3826 if let Some(p) = &self.hosts {
3827 let host = scheme::host_with(req.request(), None, p.trust_proxy);
3828 if !scheme::host_allowed(&host, &p.allowed) {
3829 let rsp = HttpResponse::build(actix_web::http::StatusCode::MISDIRECTED_REQUEST)
3830 .body("host not served here");
3831 let sr = req.into_response(rsp.map_into_boxed_body());
3832 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3833 }
3834 }
3835 let path = req.path().to_string();
3836 let allow_system_route =
3843 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
3844 if !allow_system_route {
3845 let pb = PathBuf::from(&path);
3846 if !self.allow.is_allowed(Path::new(&pb)) {
3847 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
3848 let sr = req.into_response(rsp.map_into_boxed_body());
3849 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3850 }
3851 }
3852 let fut = self.service.call(req);
3853 Box::pin(async move {
3854 let resp = fut.await?;
3855 Ok(resp.map_into_left_body())
3856 })
3857 }
3858}
3859
3860#[cfg(feature = "git")]
3865pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
3866 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
3867 return None;
3868 }
3869 state.data_root.as_ref().map(|root| root.join(pubkey))
3870}
3871
3872#[cfg(feature = "git")]
3902async fn git_mark_write(
3903 state: &AppState,
3904 resource_path: &str,
3905 agent: Option<&str>,
3906 message: &str,
3907) -> ProvenanceReceipt {
3908 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
3909 use solid_pod_rs_git::mark::ShellGitMarker;
3910
3911 if resource_path.ends_with(".acl")
3914 || resource_path.ends_with(".meta")
3915 || resource_path.ends_with(".prov.ttl")
3916 {
3917 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::ExcludedPath);
3918 }
3919 if resource_path.ends_with('/') {
3921 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::Container);
3922 }
3923
3924 let Some(data_root) = state.data_root.as_ref() else {
3926 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured);
3927 };
3928
3929 let trimmed = resource_path.trim_start_matches('/');
3931 let mut segments = trimmed.splitn(2, '/');
3932 let pod = segments.next().unwrap_or("");
3933 let rel = segments.next().unwrap_or("");
3934 if pod.is_empty() || rel.is_empty() {
3935 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::UnresolvablePath);
3936 }
3937 let repo = data_root.join(pod);
3938
3939 if !repo.join(".git").is_dir() {
3943 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotGitBacked);
3944 }
3945
3946 let agent_did = agent.unwrap_or("urn:solid:anonymous");
3947 let created = std::time::SystemTime::now()
3948 .duration_since(std::time::UNIX_EPOCH)
3949 .map(|d| d.as_secs())
3950 .unwrap_or(0);
3951
3952 let (policy, ticker_override) =
3955 handlers::prov::resolve_anchor_policy(state, resource_path).await;
3956
3957 let marker = std::sync::Arc::new(ShellGitMarker::new());
3962 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
3963 None
3964 } else {
3965 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
3966 };
3967 let (log, ticker, network) = match &anchorer_bundle {
3968 Some((anchorer, ticker, network)) => (
3969 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
3970 ticker.clone(),
3971 network.clone(),
3972 ),
3973 None => (
3975 ProvenanceLog::new(marker.clone()),
3976 String::new(),
3977 String::new(),
3978 ),
3979 };
3980
3981 let record_policy = match policy {
3985 AnchorPolicy::Epoch => AnchorPolicy::Never,
3986 other => other,
3987 };
3988 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
3989
3990 let write_record = solid_pod_rs::provenance::WriteRecord {
3994 repo: &repo,
3995 path: rel,
3996 agent_did,
3997 message,
3998 policy: record_policy,
3999 high_value,
4000 ticker: &ticker,
4001 network: &network,
4002 created,
4003 };
4004 let mut receipt = log.record_receipt(write_record).await;
4009 receipt.resource = resource_path.to_string();
4012
4013 let Some(git) = receipt.mark.clone() else {
4014 tracing::warn!(
4018 target: "solid_pod_rs_server::git_mark",
4019 resource = %resource_path,
4020 "provenance record failed (write already succeeded): {}",
4021 receipt.mark_error.as_deref().unwrap_or("unspecified")
4022 );
4023 return receipt;
4024 };
4025 if let Some(err) = &receipt.anchor_error {
4026 tracing::warn!(
4029 target: "solid_pod_rs_server::git_mark",
4030 resource = %resource_path,
4031 commit = %git.commit_sha,
4032 "block-trail anchor failed (git-mark stands): {err}"
4033 );
4034 }
4035 let mark = solid_pod_rs::provenance::ProvenanceMark {
4036 resource: resource_path.to_string(),
4037 git: git.clone(),
4038 anchor: receipt.anchor.clone(),
4039 agent_did: agent_did.to_string(),
4040 created,
4041 };
4042
4043 if matches!(policy, AnchorPolicy::Epoch) {
4047 if let Some((anchorer, _, _)) = &anchorer_bundle {
4048 match handlers::prov::epoch_push_and_maybe_anchor(
4049 state,
4050 anchorer,
4051 &ticker,
4052 &network,
4053 &mark.git.commit_sha,
4054 )
4055 .await
4056 {
4057 Ok(Some(closed)) => tracing::debug!(
4058 target: "solid_pod_rs_server::git_mark",
4059 root = %closed.root,
4060 n = closed.commits.len(),
4061 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
4062 ),
4063 Ok(None) => {}
4064 Err(e) => tracing::warn!(
4065 target: "solid_pod_rs_server::git_mark",
4066 "epoch batch/anchor failed (swallowed): {e}"
4067 ),
4068 }
4069 }
4070 }
4071
4072 let ttl = prov_ttl(&mark);
4077 let sidecar = format!("{resource_path}.prov.ttl");
4078 if let Err(e) = state
4079 .storage
4080 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
4081 .await
4082 {
4083 tracing::warn!(
4084 target: "solid_pod_rs_server::git_mark",
4085 sidecar = %sidecar,
4086 "provenance sidecar write failed: {e}"
4087 );
4088 receipt.mark_error = Some(format!("PROV-O sidecar write failed: {e}"));
4091 return receipt;
4092 }
4093
4094 tracing::debug!(
4095 target: "solid_pod_rs_server::git_mark",
4096 resource = %resource_path,
4097 commit = %mark.git.commit_sha,
4098 stage = %receipt.stage(),
4099 "provenance recorded"
4100 );
4101 receipt
4102}
4103
4104#[cfg(not(feature = "git"))]
4107#[inline]
4108async fn git_mark_write(
4109 _state: &AppState,
4110 resource_path: &str,
4111 _agent: Option<&str>,
4112 _message: &str,
4113) -> ProvenanceReceipt {
4114 ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured)
4115}
4116
4117#[cfg(feature = "git")]
4118pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
4119 require_pod_owner_with_body(req, pod_pubkey, None).await
4120}
4121
4122#[cfg(feature = "git")]
4123pub(crate) async fn require_pod_owner_with_body(
4124 req: &HttpRequest,
4125 pod_pubkey: &str,
4126 body: Option<&[u8]>,
4127) -> Option<String> {
4128 let caller = extract_pubkey_with_body(req, body).await?;
4129 if caller != pod_pubkey {
4130 return None;
4131 }
4132 Some(caller)
4133}
4134
4135#[cfg(feature = "git")]
4136fn git_json_err(msg: &str, status: u16) -> HttpResponse {
4137 HttpResponse::build(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
4138 .content_type("application/json")
4139 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
4140}
4141
4142#[cfg(feature = "git")]
4144#[derive(serde::Deserialize)]
4145struct GitStageBody {
4146 paths: Option<Vec<String>>,
4147 all: Option<bool>,
4148}
4149
4150#[cfg(feature = "git")]
4151#[derive(serde::Deserialize)]
4152struct GitCommitBody {
4153 message: String,
4154 author_name: Option<String>,
4155 author_email: Option<String>,
4156}
4157
4158#[cfg(feature = "git")]
4159#[derive(serde::Deserialize)]
4160struct GitBranchBody {
4161 name: String,
4162}
4163
4164#[cfg(feature = "git")]
4167async fn handle_git_status(
4168 path: web::Path<String>,
4169 req: HttpRequest,
4170 state: web::Data<AppState>,
4171) -> HttpResponse {
4172 let pubkey = path.into_inner();
4173 if require_pod_owner(&req, &pubkey).await.is_none() {
4174 return git_json_err("Authentication required", 401);
4175 }
4176 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4177 return git_json_err("Git not available (no FS backend)", 501);
4178 };
4179 match solid_pod_rs_git::api::git_status(&repo).await {
4180 Ok(s) => HttpResponse::Ok()
4181 .content_type("application/json")
4182 .body(serde_json::to_string(&s).unwrap_or_default()),
4183 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4184 }
4185}
4186
4187#[cfg(feature = "git")]
4188async fn handle_git_log(
4189 path: web::Path<String>,
4190 req: HttpRequest,
4191 state: web::Data<AppState>,
4192 query: web::Query<std::collections::HashMap<String, String>>,
4193) -> HttpResponse {
4194 let pubkey = path.into_inner();
4195 if require_pod_owner(&req, &pubkey).await.is_none() {
4196 return git_json_err("Authentication required", 401);
4197 }
4198 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4199 return git_json_err("Git not available (no FS backend)", 501);
4200 };
4201 let limit: u32 = query
4202 .get("limit")
4203 .and_then(|v| v.parse().ok())
4204 .unwrap_or(20);
4205 match solid_pod_rs_git::api::git_log(&repo, limit).await {
4206 Ok(entries) => HttpResponse::Ok()
4207 .content_type("application/json")
4208 .body(serde_json::to_string(&entries).unwrap_or_default()),
4209 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4210 }
4211}
4212
4213#[cfg(feature = "git")]
4214async fn handle_git_diff(
4215 path: web::Path<String>,
4216 req: HttpRequest,
4217 state: web::Data<AppState>,
4218 query: web::Query<std::collections::HashMap<String, String>>,
4219) -> HttpResponse {
4220 let pubkey = path.into_inner();
4221 if require_pod_owner(&req, &pubkey).await.is_none() {
4222 return git_json_err("Authentication required", 401);
4223 }
4224 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4225 return git_json_err("Git not available (no FS backend)", 501);
4226 };
4227 let file_path = query.get("path").map(String::as_str);
4228 let staged = query
4229 .get("staged")
4230 .map(|v| v == "true" || v == "1")
4231 .unwrap_or(false);
4232 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
4233 Ok(diff) => HttpResponse::Ok().content_type("text/plain").body(diff),
4234 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4235 }
4236}
4237
4238#[cfg(feature = "git")]
4239async fn handle_git_stage(
4240 path: web::Path<String>,
4241 req: HttpRequest,
4242 state: web::Data<AppState>,
4243 body: web::Bytes,
4244) -> HttpResponse {
4245 let pubkey = path.into_inner();
4246 if require_pod_owner(&req, &pubkey).await.is_none() {
4247 return git_json_err("Authentication required", 401);
4248 }
4249 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4250 return git_json_err("Git not available (no FS backend)", 501);
4251 };
4252 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4253 Ok(v) => v,
4254 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4255 };
4256 let paths = parsed.paths.unwrap_or_default();
4257 let all = parsed.all.unwrap_or(false);
4258 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
4259 Ok(()) => HttpResponse::Ok()
4260 .content_type("application/json")
4261 .body(r#"{"ok":true}"#),
4262 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4263 }
4264}
4265
4266#[cfg(feature = "git")]
4267async fn handle_git_unstage(
4268 path: web::Path<String>,
4269 req: HttpRequest,
4270 state: web::Data<AppState>,
4271 body: web::Bytes,
4272) -> HttpResponse {
4273 let pubkey = path.into_inner();
4274 if require_pod_owner(&req, &pubkey).await.is_none() {
4275 return git_json_err("Authentication required", 401);
4276 }
4277 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4278 return git_json_err("Git not available (no FS backend)", 501);
4279 };
4280 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4281 Ok(v) => v,
4282 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4283 };
4284 let paths = parsed.paths.unwrap_or_default();
4285 let all = parsed.all.unwrap_or(false);
4286 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
4287 Ok(()) => HttpResponse::Ok()
4288 .content_type("application/json")
4289 .body(r#"{"ok":true}"#),
4290 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4291 }
4292}
4293
4294#[cfg(feature = "git")]
4295async fn handle_git_commit(
4296 path: web::Path<String>,
4297 req: HttpRequest,
4298 state: web::Data<AppState>,
4299 body: web::Bytes,
4300) -> HttpResponse {
4301 let pubkey = path.into_inner();
4302 if require_pod_owner(&req, &pubkey).await.is_none() {
4303 return git_json_err("Authentication required", 401);
4304 }
4305 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4306 return git_json_err("Git not available (no FS backend)", 501);
4307 };
4308 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
4309 Ok(v) => v,
4310 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4311 };
4312 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
4313 let author_email = parsed
4314 .author_email
4315 .as_deref()
4316 .unwrap_or("pod@dreamlab-ai.com");
4317 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email).await
4318 {
4319 Ok(result) => HttpResponse::Ok()
4320 .content_type("application/json")
4321 .body(serde_json::to_string(&result).unwrap_or_default()),
4322 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4323 }
4324}
4325
4326#[cfg(feature = "git")]
4327async fn handle_git_branches(
4328 path: web::Path<String>,
4329 req: HttpRequest,
4330 state: web::Data<AppState>,
4331) -> HttpResponse {
4332 let pubkey = path.into_inner();
4333 if require_pod_owner(&req, &pubkey).await.is_none() {
4334 return git_json_err("Authentication required", 401);
4335 }
4336 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4337 return git_json_err("Git not available (no FS backend)", 501);
4338 };
4339 match solid_pod_rs_git::api::git_branches(&repo).await {
4340 Ok(info) => HttpResponse::Ok()
4341 .content_type("application/json")
4342 .body(serde_json::to_string(&info).unwrap_or_default()),
4343 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4344 }
4345}
4346
4347#[cfg(feature = "git")]
4348async fn handle_git_create_branch(
4349 path: web::Path<String>,
4350 req: HttpRequest,
4351 state: web::Data<AppState>,
4352 body: web::Bytes,
4353) -> HttpResponse {
4354 let pubkey = path.into_inner();
4355 if require_pod_owner(&req, &pubkey).await.is_none() {
4356 return git_json_err("Authentication required", 401);
4357 }
4358 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4359 return git_json_err("Git not available (no FS backend)", 501);
4360 };
4361 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
4362 Ok(v) => v,
4363 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4364 };
4365 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
4366 Ok(()) => HttpResponse::Ok()
4367 .content_type("application/json")
4368 .body(r#"{"ok":true}"#),
4369 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4370 }
4371}
4372
4373#[cfg(feature = "git")]
4374async fn handle_git_discard(
4375 path: web::Path<String>,
4376 req: HttpRequest,
4377 state: web::Data<AppState>,
4378 body: web::Bytes,
4379) -> HttpResponse {
4380 let pubkey = path.into_inner();
4381 if require_pod_owner(&req, &pubkey).await.is_none() {
4382 return git_json_err("Authentication required", 401);
4383 }
4384 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4385 return git_json_err("Git not available (no FS backend)", 501);
4386 };
4387 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4388 Ok(v) => v,
4389 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4390 };
4391 let paths = parsed.paths.unwrap_or_default();
4392 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
4393 Ok(()) => HttpResponse::Ok()
4394 .content_type("application/json")
4395 .body(r#"{"ok":true}"#),
4396 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4397 }
4398}
4399
4400async fn handle_git_panel_options(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
4408 let origin = req
4409 .headers()
4410 .get(header::ORIGIN)
4411 .and_then(|v| v.to_str().ok())
4412 .map(str::to_string);
4413
4414 let mut rsp = HttpResponse::NoContent().finish();
4415 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
4416 rsp
4417}
4418
4419async fn handle_admin_provision(
4436 req: HttpRequest,
4437 state: web::Data<AppState>,
4438 path: web::Path<String>,
4439) -> HttpResponse {
4440 let expected = match &state.admin_key {
4442 Some(k) => k.clone(),
4443 None => {
4444 return HttpResponse::Forbidden().json(serde_json::json!({
4445 "error": "admin key not configured on this server"
4446 }));
4447 }
4448 };
4449 let provided = req
4450 .headers()
4451 .get("x-pod-admin-key")
4452 .and_then(|v| v.to_str().ok())
4453 .unwrap_or("");
4454 use subtle::ConstantTimeEq;
4459 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
4460 if !bool::from(key_match) {
4461 return HttpResponse::Forbidden().json(serde_json::json!({"error": "invalid admin key"}));
4462 }
4463
4464 let pubkey = path.into_inner();
4466 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
4467 return HttpResponse::BadRequest()
4468 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
4469 }
4470
4471 let data_root = match &state.data_root {
4473 Some(r) => r.clone(),
4474 None => {
4475 return HttpResponse::InternalServerError().json(serde_json::json!({
4476 "error": "server has no fs-backend storage configured"
4477 }));
4478 }
4479 };
4480
4481 let pods_root = data_root.join("pods");
4494 let pod_dir = pods_root.join(&pubkey);
4495
4496 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
4498 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
4499 return HttpResponse::InternalServerError()
4500 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
4501 }
4502
4503 let acl_content = format!(
4513 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
4514 <#owner> a acl:Authorization ;\n\
4515 acl:agent <did:nostr:{pubkey}> ;\n\
4516 acl:accessTo </pods/{pubkey}/> ;\n\
4517 acl:default </pods/{pubkey}/> ;\n\
4518 acl:mode acl:Read, acl:Write, acl:Control .\n"
4519 );
4520
4521 let sibling_acl_path = pods_root.join(format!("{pubkey}.acl"));
4531 if !sibling_acl_path.exists() {
4532 if let Err(e) = tokio::fs::write(&sibling_acl_path, acl_content.as_bytes()).await {
4533 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write sibling .acl failed");
4534 return HttpResponse::InternalServerError()
4535 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
4536 }
4537 }
4538
4539 let inner_acl_path = pod_dir.join(".acl");
4546 if !inner_acl_path.exists() {
4547 if let Err(e) = tokio::fs::write(&inner_acl_path, acl_content.as_bytes()).await {
4548 tracing::warn!(pubkey = %pubkey, error = %e, "/_admin/provision: write inner .acl failed (non-fatal; sibling ACL governs)");
4549 }
4550 }
4551
4552 #[cfg(feature = "git")]
4554 {
4555 if !pod_dir.join(".git").exists() {
4557 let init_out = crate::hardened_git::hardened_git_async()
4558 .args(["init", "-b", "main", pod_dir.to_str().unwrap_or(".")])
4559 .output()
4560 .await;
4561
4562 match init_out {
4563 Ok(out) if out.status.success() => {}
4564 Ok(out) => {
4565 let stderr = String::from_utf8_lossy(&out.stderr);
4566 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
4567 }
4568 Err(e) => {
4569 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
4570 }
4571 }
4572
4573 let cfg_out = crate::hardened_git::hardened_git_async()
4576 .args([
4577 "-C",
4578 pod_dir.to_str().unwrap_or("."),
4579 "config",
4580 "receive.denyCurrentBranch",
4581 "updateInstead",
4582 ])
4583 .output()
4584 .await;
4585
4586 if let Err(e) = cfg_out {
4587 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
4588 }
4589 }
4590 }
4591
4592 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
4594 HttpResponse::Ok().json(serde_json::json!({
4595 "podUrl": format!("{base_url}/pods/{pubkey}/"),
4596 "ok": true,
4597 }))
4598}
4599
4600async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
4605 let Some(ref data_root) = state.data_root else {
4606 return HttpResponse::Ok()
4607 .content_type("application/json")
4608 .json(serde_json::json!({"apps": [], "count": 0}));
4609 };
4610
4611 let server_url = state.nodeinfo.base_url.clone();
4612
4613 let mut read_dir = match tokio::fs::read_dir(data_root).await {
4615 Ok(rd) => rd,
4616 Err(_) => {
4617 return HttpResponse::Ok()
4618 .content_type("application/json")
4619 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
4620 }
4621 };
4622
4623 let mut apps: Vec<serde_json::Value> = Vec::new();
4624 let mut scanned = 0usize;
4625
4626 while scanned < 1000 {
4627 let entry = match read_dir.next_entry().await {
4628 Ok(Some(e)) => e,
4629 Ok(None) => break,
4630 Err(_) => break,
4631 };
4632
4633 let file_type = match entry.file_type().await {
4634 Ok(ft) => ft,
4635 Err(_) => continue,
4636 };
4637 if !file_type.is_dir() {
4638 continue;
4639 }
4640
4641 scanned += 1;
4642
4643 let manifest_path = entry.path().join("apps").join("manifest.json");
4644 let contents = match tokio::fs::read(&manifest_path).await {
4645 Ok(c) => c,
4646 Err(_) => continue,
4647 };
4648
4649 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
4650 Ok(v) => v,
4651 Err(_) => continue,
4652 };
4653
4654 if let Some(pod_name) = entry.file_name().to_str() {
4656 if manifest.get("podOwner").is_none() {
4657 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
4658 }
4659 }
4660
4661 apps.push(manifest);
4662 }
4663
4664 let count = apps.len();
4665 HttpResponse::Ok()
4666 .content_type("application/json")
4667 .json(serde_json::json!({
4668 "apps": apps,
4669 "serverUrl": server_url,
4670 "count": count,
4671 }))
4672}
4673
4674#[allow(dead_code)]
4687fn is_git_request(path: &str) -> bool {
4688 path.contains("/info/refs")
4689 || path.contains("/git-upload-pack")
4690 || path.contains("/git-receive-pack")
4691}
4692
4693#[allow(dead_code)]
4696fn is_dot_git_path(path: &str) -> bool {
4697 path.contains("/.git/") || path.ends_with("/.git")
4698}
4699
4700#[cfg(feature = "git")]
4701async fn handle_git(
4702 req: HttpRequest,
4703 body: web::Bytes,
4704 state: web::Data<AppState>,
4705) -> HttpResponse {
4706 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
4707 use solid_pod_rs_git::service::GitRequest;
4708
4709 let path = req.uri().path().to_string();
4710
4711 let pod_name = path
4714 .trim_start_matches('/')
4715 .split('/')
4716 .next()
4717 .unwrap_or("")
4718 .to_string();
4719 let Some(ref data_root) = state.data_root else {
4720 return HttpResponse::NotImplemented().json(serde_json::json!({
4721 "error": "git requires fs-backend storage",
4722 "reason": "data_root_not_configured"
4723 }));
4724 };
4725 let repo_root = data_root.join(&pod_name);
4726 if !repo_root.exists() {
4727 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
4728 }
4729
4730 let query = req.uri().query().unwrap_or("").to_string();
4731 let host_url = Some(scheme::request_origin(&req));
4732 let headers: Vec<(String, String)> = req
4733 .headers()
4734 .iter()
4735 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
4736 .collect();
4737
4738 let git_req = GitRequest {
4739 method: req.method().as_str().to_string(),
4740 path,
4741 query,
4742 headers,
4743 body,
4744 host_url,
4745 };
4746
4747 if git_req.is_write() {
4753 let mut builder = HttpResponse::Forbidden();
4754 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4755 builder.insert_header((k, v));
4756 }
4757 return builder.json(serde_json::json!({
4758 "error": "git push is not served by this server",
4759 "reason": "push_not_served"
4760 }));
4761 }
4762
4763 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
4774 Ok(pk) => Some(format!("did:nostr:{pk}")),
4775 Err(_) => None,
4776 };
4777 let wac_path = format!("/{pod_name}/");
4778 let origin = req_origin(&req);
4779 let wac = enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin)
4783 .await
4784 .map(|_audience| ());
4785 if let Err(e) = wac {
4786 let mut resp = e.error_response();
4790 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4791 if let (Ok(name), Ok(value)) = (
4792 actix_web::http::header::HeaderName::from_bytes(k.as_bytes()),
4793 actix_web::http::header::HeaderValue::from_str(v),
4794 ) {
4795 resp.headers_mut().insert(name, value);
4796 }
4797 }
4798 return resp;
4799 }
4800
4801 let service = solid_pod_rs_git::service::GitHttpService::new(repo_root);
4802 match service.handle(git_req).await {
4803 Ok(git_resp) => {
4804 let mut builder = HttpResponse::build(
4805 actix_web::http::StatusCode::from_u16(git_resp.status)
4806 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4807 );
4808 for (k, v) in &git_resp.headers {
4809 builder.insert_header((k.as_str(), v.as_str()));
4810 }
4811 builder.body(git_resp.body)
4812 }
4813 Err(e) => {
4814 let status = e.status_code();
4815 let mut builder = HttpResponse::build(
4816 actix_web::http::StatusCode::from_u16(status)
4817 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4818 );
4819 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4820 builder.insert_header((k, v));
4821 }
4822 builder.json(serde_json::json!({"error": e.to_string()}))
4823 }
4824 }
4825}
4826
4827#[cfg(feature = "forge")]
4836fn forge_plugin_dir(state: &AppState) -> Option<PathBuf> {
4837 state.data_root.as_ref().map(|r| r.join(".forge"))
4838}
4839
4840#[cfg(feature = "forge")]
4846struct ServerLoopback {
4847 client: reqwest::Client,
4848}
4849
4850#[cfg(feature = "forge")]
4851#[async_trait::async_trait]
4852impl solid_pod_rs_forge::LoopbackFetch for ServerLoopback {
4853 async fn get(
4854 &self,
4855 url: &str,
4856 max_bytes: usize,
4857 timeout_secs: u64,
4858 ) -> solid_pod_rs_forge::bodies::FetchResult {
4859 use solid_pod_rs_forge::bodies::FetchResult;
4860 let resp = match self
4861 .client
4862 .get(url)
4863 .timeout(Duration::from_secs(timeout_secs.max(1)))
4864 .send()
4865 .await
4866 {
4867 Ok(r) => r,
4868 Err(e) => return FetchResult::Error(e.to_string()),
4869 };
4870 let code = resp.status().as_u16();
4871 if code == 404 || code == 410 {
4872 return FetchResult::Removed;
4873 }
4874 if !resp.status().is_success() {
4875 return FetchResult::Error(format!("status {code}"));
4876 }
4877 if resp
4878 .content_length()
4879 .is_some_and(|length| length > max_bytes as u64)
4880 {
4881 return FetchResult::TooLarge;
4882 }
4883 let mut stream = resp.bytes_stream();
4884 let mut body = Vec::with_capacity(max_bytes.min(16 * 1024));
4885 while let Some(chunk) = stream.next().await {
4886 match chunk {
4887 Ok(chunk) if body.len().saturating_add(chunk.len()) <= max_bytes => {
4888 body.extend_from_slice(&chunk);
4889 }
4890 Ok(_) => return FetchResult::TooLarge,
4891 Err(error) => return FetchResult::Error(error.to_string()),
4892 }
4893 }
4894 FetchResult::Body(body)
4895 }
4896}
4897
4898#[cfg(feature = "forge")]
4900fn env_list(name: &str) -> Vec<String> {
4901 std::env::var(name)
4902 .ok()
4903 .map(|v| {
4904 v.split(',')
4905 .map(str::trim)
4906 .filter(|s| !s.is_empty())
4907 .map(str::to_string)
4908 .collect()
4909 })
4910 .unwrap_or_default()
4911}
4912
4913#[cfg(feature = "forge")]
4918fn forge_config() -> solid_pod_rs_forge::ForgeConfig {
4919 use serde_json::{json, Map, Value};
4920 let mut o = Map::new();
4921 let flag = |n: &str| {
4922 std::env::var(n)
4923 .ok()
4924 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
4925 };
4926 if let Some(b) = flag("FORGE_PRIVATE_REPOS") {
4927 o.insert("privateRepos".into(), json!(b));
4928 }
4929 if let Some(b) = flag("FORGE_ALLOW_MAINNET") {
4930 o.insert("allowMainnet".into(), json!(b));
4931 }
4932 if let Some(b) = flag("FORGE_OPEN_EDIT") {
4933 o.insert("openEdit".into(), json!(b));
4934 }
4935 if let Some(b) = flag("FORGE_ANCHORING_UI") {
4936 o.insert("anchoringUi".into(), json!(b));
4937 }
4938 if let Some(b) = flag("FORGE_SPARSE_MARKS") {
4939 o.insert("sparseMarks".into(), json!(b));
4940 }
4941 if let Ok(c) = std::env::var("FORGE_CHAIN") {
4942 if !c.trim().is_empty() {
4943 o.insert("chain".into(), Value::String(c.trim().to_string()));
4944 }
4945 }
4946 if let Ok(u) = std::env::var("FORGE_BASE_URL") {
4947 if !u.trim().is_empty() {
4948 o.insert("baseUrl".into(), Value::String(u.trim().to_string()));
4949 }
4950 }
4951 let relays = env_list("FORGE_ANNOUNCE_RELAYS");
4952 if !relays.is_empty() {
4953 o.insert("announceRelays".into(), json!(relays));
4954 }
4955 solid_pod_rs_forge::ForgeConfig::from_env(Value::Object(o))
4956}
4957
4958#[cfg(feature = "forge-mirror")]
4963fn mirror_config() -> Option<solid_pod_rs_forge::mirror::MirrorConfig> {
4964 use serde_json::json;
4965 let relays = env_list("FORGE_MIRROR_RELAYS");
4966 let follow: Vec<serde_json::Value> = env_list("FORGE_MIRROR_FOLLOW")
4967 .iter()
4968 .filter_map(|e| e.split_once('/'))
4969 .map(|(pk, name)| json!({"pubkey": pk.trim(), "name": name.trim()}))
4970 .collect();
4971 let mut cfg = json!({"relays": relays, "follow": follow});
4972 if let Some(ms) = std::env::var("FORGE_MIRROR_MIN_INTERVAL_MS")
4973 .ok()
4974 .and_then(|v| v.trim().parse::<u64>().ok())
4975 {
4976 cfg["minIntervalMs"] = json!(ms);
4977 }
4978 solid_pod_rs_forge::mirror::parse_mirror_config(&cfg)
4979}
4980
4981#[cfg(feature = "forge")]
4983struct ForgeRuntime {
4984 service: Arc<solid_pod_rs_forge::ForgeService>,
4985 #[cfg(feature = "forge-mirror")]
4986 _mirror: Option<solid_pod_rs_forge::mirror::MirrorHandle>,
4987}
4988
4989#[cfg(feature = "forge")]
4993fn forge_service(
4994 plugin_dir: PathBuf,
4995) -> Result<Arc<solid_pod_rs_forge::ForgeService>, solid_pod_rs_forge::ForgeError> {
4996 use std::collections::HashMap;
4997 use std::sync::{Mutex, OnceLock};
4998 static REGISTRY: OnceLock<Mutex<HashMap<PathBuf, Arc<ForgeRuntime>>>> = OnceLock::new();
4999 let reg = REGISTRY.get_or_init(|| Mutex::new(HashMap::new()));
5000 let mut reg = reg
5001 .lock()
5002 .unwrap_or_else(std::sync::PoisonError::into_inner);
5003 if let Some(rt) = reg.get(&plugin_dir) {
5004 return Ok(Arc::clone(&rt.service));
5005 }
5006 let cfg = forge_config();
5007 #[cfg(feature = "forge-announce")]
5008 let relay_policy: Arc<dyn solid_pod_rs_forge::publish::SsrfCheck> = Arc::new(
5009 solid_pod_rs_forge::publish::OperatorRelays::new(&cfg.announce_relays),
5010 );
5011 let loopback: Arc<dyn solid_pod_rs_forge::LoopbackFetch> = Arc::new(ServerLoopback {
5012 client: reqwest::Client::new(),
5013 });
5014 let svc =
5015 solid_pod_rs_forge::ForgeService::new(cfg, plugin_dir.clone())?.with_loopback(loopback);
5016 #[cfg(feature = "forge-announce")]
5017 let svc = svc.with_announce_ssrf(relay_policy);
5018 let service = Arc::new(svc);
5019 #[cfg(feature = "forge-mirror")]
5020 let mirror = mirror_config().map(|mcfg| {
5021 use solid_pod_rs_forge::mirror::{GitCli, Mirror, MirrorStore};
5022 let git = Arc::new(GitCli::new(plugin_dir.join("repos")));
5023 let engine = Arc::new(Mirror::new(mcfg, MirrorStore::new(&plugin_dir), git));
5024 tracing::info!("forge: mirror on kind-30618 started");
5025 engine.start()
5026 });
5027 reg.insert(
5028 plugin_dir,
5029 Arc::new(ForgeRuntime {
5030 service: Arc::clone(&service),
5031 #[cfg(feature = "forge-mirror")]
5032 _mirror: mirror,
5033 }),
5034 );
5035 Ok(service)
5036}
5037
5038#[cfg(feature = "forge")]
5042pub fn start_forge(state: &AppState) {
5043 if let Some(dir) = forge_plugin_dir(state) {
5044 match forge_service(dir) {
5045 Ok(_) => tracing::info!("forge: started"),
5046 Err(e) => tracing::error!("forge: could not start: {e}"),
5047 }
5048 }
5049}
5050
5051#[cfg(feature = "forge")]
5057async fn handle_forge(
5058 req: HttpRequest,
5059 body: web::Bytes,
5060 state: web::Data<AppState>,
5061) -> HttpResponse {
5062 use solid_pod_rs_forge::ForgeRequest;
5063
5064 let Some(plugin_dir) = forge_plugin_dir(&state) else {
5065 return HttpResponse::NotImplemented().json(serde_json::json!({
5066 "error": "forge requires fs-backend storage",
5067 "reason": "data_root_not_configured"
5068 }));
5069 };
5070
5071 let service = match forge_service(plugin_dir) {
5072 Ok(s) => s,
5073 Err(e) => {
5074 return HttpResponse::InternalServerError()
5075 .json(serde_json::json!({"error": e.to_string()}));
5076 }
5077 };
5078
5079 let path = req.uri().path().to_string();
5080 let query = req.uri().query().unwrap_or("").to_string();
5081 let host_url = Some(scheme::request_origin(&req));
5082 let headers: Vec<(String, String)> = req
5083 .headers()
5084 .iter()
5085 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
5086 .collect();
5087
5088 let forge_req = ForgeRequest {
5089 method: req.method().as_str().to_string(),
5090 path,
5091 query,
5092 headers,
5093 raw_body: body,
5094 host_url,
5095 };
5096
5097 let agent = service.resolve_agent(&forge_req).await;
5102
5103 match service.handle(forge_req, agent).await {
5104 Ok(resp) => {
5105 let mut builder = HttpResponse::build(
5106 StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
5107 );
5108 for (k, v) in &resp.headers {
5109 builder.insert_header((k.as_str(), v.as_str()));
5110 }
5111 builder.body(resp.body)
5112 }
5113 Err(e) => {
5114 let status = e.status_code();
5115 HttpResponse::build(
5116 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
5117 )
5118 .json(serde_json::json!({"error": e.to_string()}))
5119 }
5120 }
5121}
5122
5123pub fn build_app(
5135 state: AppState,
5136) -> App<
5137 impl actix_web::dev::ServiceFactory<
5138 ServiceRequest,
5139 Config = (),
5140 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
5141 Error = ActixError,
5142 InitError = (),
5143 >,
5144> {
5145 let body_cap = state.body_cap;
5146 let dotfiles = state.dotfiles.clone();
5147 let allowed_origins = Arc::new(state.allowed_origins.clone());
5148 let allowed_hosts = state.allowed_hosts.clone();
5149
5150 let mut app = App::new()
5151 .app_data(web::Data::new(state.clone()))
5152 .app_data(web::PayloadConfig::new(body_cap))
5153 .wrap(ErrorLoggingMiddleware)
5158 .wrap(CorsHeaders { allowed_origins })
5159 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
5163 .wrap(PathTraversalGuard)
5164 .wrap(DotfileGuard::new(dotfiles).with_host_policy(HostPolicy {
5165 allowed: allowed_hosts,
5166 trust_proxy: state.trust_proxy,
5167 }));
5168
5169 app = app
5175 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
5176 .route(
5177 "/.well-known/webfinger",
5178 web::get().to(handle_well_known_webfinger),
5179 )
5180 .route(
5181 "/.well-known/nodeinfo",
5182 web::get().to(handle_well_known_nodeinfo),
5183 )
5184 .route(
5185 "/.well-known/nodeinfo/2.1",
5186 web::get().to(handle_well_known_nodeinfo_2_1),
5187 );
5188
5189 #[cfg(feature = "did-nostr")]
5190 {
5191 app = app.route(
5192 "/.well-known/did/nostr/{pubkey}.json",
5193 web::get().to(handle_well_known_did_nostr),
5194 );
5195 }
5196
5197 #[cfg(feature = "nip05-endpoint")]
5202 {
5203 app = app.route(
5204 "/.well-known/nostr.json",
5205 web::get().to(handle_well_known_nip05),
5206 );
5207 }
5208
5209 #[cfg(feature = "export-jsonld")]
5214 {
5215 app = app.route("/api/exports/all", web::get().to(handle_export_all));
5216 }
5217
5218 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
5220
5221 app = app.route("/pay/.info", web::get().to(handle_pay_info));
5223
5224 app = app.configure(handlers::pay::register);
5229
5230 app = app.route("/proxy", web::get().to(handle_proxy));
5232
5233 if state.mcp_enabled {
5237 app = app.route("/mcp", web::post().to(mcp::handle_mcp)).route(
5238 "/mcp",
5239 web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options),
5240 );
5241 }
5242
5243 app = app.route(
5246 "/_admin/provision/{pubkey}",
5247 web::post().to(handle_admin_provision),
5248 );
5249
5250 app = app
5252 .route("/.pods", web::post().to(handle_create_pod))
5253 .route("/api/accounts/new", web::post().to(handle_create_account))
5254 .route("/pods/check/{name}", web::get().to(handle_pod_check))
5255 .route("/login/password", web::post().to(handle_login_password))
5256 .route(
5257 "/account/password/reset",
5258 web::post().to(handle_password_reset_request),
5259 )
5260 .route(
5261 "/account/password/change",
5262 web::post().to(handle_password_change),
5263 );
5264
5265 #[cfg(feature = "forge")]
5270 {
5271 app = app
5272 .route("/forge", web::route().to(handle_forge))
5273 .route("/forge/{tail:.*}", web::route().to(handle_forge));
5274 }
5275
5276 app = app
5281 .route(
5282 "/{tail:.*}/.git",
5284 web::route().to(|| async {
5285 HttpResponse::Forbidden()
5286 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
5287 }),
5288 )
5289 .route(
5290 "/{tail:.*}/.git/{rest:.*}",
5291 web::route().to(|| async {
5292 HttpResponse::Forbidden()
5293 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
5294 }),
5295 );
5296
5297 app = app.route(
5301 "/pods/{pk}/_git/{tail:.*}",
5302 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
5303 );
5304
5305 #[cfg(feature = "git")]
5306 {
5307 app = app
5314 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
5315 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
5316 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
5317
5318 app = app
5321 .route(
5322 "/pods/{pubkey}/_git/status",
5323 web::get().to(handle_git_status),
5324 )
5325 .route("/pods/{pubkey}/_git/log", web::get().to(handle_git_log))
5326 .route("/pods/{pubkey}/_git/diff", web::get().to(handle_git_diff))
5327 .route(
5328 "/pods/{pubkey}/_git/stage",
5329 web::post().to(handle_git_stage),
5330 )
5331 .route(
5332 "/pods/{pubkey}/_git/unstage",
5333 web::post().to(handle_git_unstage),
5334 )
5335 .route(
5336 "/pods/{pubkey}/_git/commit",
5337 web::post().to(handle_git_commit),
5338 )
5339 .route(
5340 "/pods/{pubkey}/_git/branches",
5341 web::get().to(handle_git_branches),
5342 )
5343 .route(
5344 "/pods/{pubkey}/_git/branch",
5345 web::post().to(handle_git_create_branch),
5346 )
5347 .route(
5348 "/pods/{pubkey}/_git/discard",
5349 web::post().to(handle_git_discard),
5350 );
5351
5352 app = app.configure(handlers::prov::register);
5359 }
5360 #[cfg(not(feature = "git"))]
5361 {
5362 let git_501 = || async {
5366 HttpResponse::NotImplemented()
5367 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
5368 };
5369 app = app
5370 .route("/{tail:.*}/info/refs", web::get().to(git_501))
5371 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
5372 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
5373 }
5374
5375 app.route("/{tail:.*}/", web::post().to(handle_post))
5378 .route("/{tail:.*}/", web::put().to(handle_put))
5379 .route("/{tail:.*}", web::get().to(handle_get))
5380 .route("/{tail:.*}", web::head().to(handle_get))
5381 .route("/{tail:.*}", web::put().to(handle_put))
5382 .route("/{tail:.*}", web::patch().to(handle_patch))
5383 .route("/{tail:.*}", web::delete().to(handle_delete))
5384 .route(
5385 "/{tail:.*}",
5386 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
5387 )
5388 .route(
5389 "/{tail:.*}",
5390 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
5391 )
5392}
5393
5394#[cfg(test)]
5399mod payment_gating_tests {
5400 use super::*;
5401 use solid_pod_rs::payments::WebLedger;
5402 use solid_pod_rs::storage::memory::MemoryBackend;
5403
5404 macro_rules! principal {
5408 () => {
5409 "did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
5410 };
5411 }
5412
5413 const PRINCIPAL: &str = principal!();
5414
5415 const OTHER_PRINCIPAL: &str =
5417 "did:nostr:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
5418
5419 const PAID_WRITE_ACL: &str = concat!(
5422 r#"
5423@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5424
5425<#paid-write> a acl:Authorization ;
5426 acl:agent <"#,
5427 principal!(),
5428 r#"> ;
5429 acl:accessTo </premium/inbox> ;
5430 acl:mode acl:Write ;
5431 acl:condition [
5432 a acl:PaymentCondition ;
5433 acl:costSats 100
5434 ] .
5435"#
5436 );
5437
5438 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
5439 let mut ledger = WebLedger::new("Test Pod Credits");
5440 if sats > 0 {
5441 ledger
5442 .credit_by_outpoint(did, "satoshi", &"ab".repeat(32), 0, sats)
5443 .unwrap();
5444 }
5445 let body = serde_json::to_vec(&ledger).unwrap();
5446 storage
5447 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
5448 .await
5449 .unwrap();
5450 }
5451
5452 async fn seed_acl(storage: &dyn Storage) {
5453 storage
5454 .put(
5455 "/premium/inbox.acl",
5456 Bytes::from(PAID_WRITE_ACL),
5457 "text/turtle",
5458 )
5459 .await
5460 .unwrap();
5461 }
5462
5463 #[actix_web::test]
5465 async fn resolve_balance_reads_ledger_entry() {
5466 let storage = MemoryBackend::new();
5467 seed_ledger(&storage, PRINCIPAL, 250).await;
5468 assert_eq!(
5469 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5470 Some(250)
5471 );
5472 }
5473
5474 #[actix_web::test]
5476 async fn resolve_balance_zero_when_no_entry() {
5477 let storage = MemoryBackend::new();
5478 seed_ledger(&storage, OTHER_PRINCIPAL, 500).await;
5479 assert_eq!(
5480 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5481 Some(0)
5482 );
5483 }
5484
5485 #[test]
5491 fn fixtures_use_teller_accounts_and_grant_the_principal() {
5492 use solid_pod_rs::payments::account_of;
5493 for did in [PRINCIPAL, OTHER_PRINCIPAL] {
5494 assert_eq!(account_of(did).unwrap(), did, "{did} must be canonical");
5495 }
5496 assert_ne!(PRINCIPAL, OTHER_PRINCIPAL);
5497 assert!(account_of("did:nostr:bob").is_err());
5498 let agent = format!("acl:agent <{PRINCIPAL}>");
5499 for (name, acl) in [
5500 ("PAID_WRITE_ACL", PAID_WRITE_ACL),
5501 ("WRITE_NOT_CONTROL_ACL", WRITE_NOT_CONTROL_ACL),
5502 ] {
5503 assert!(acl.contains(&agent), "{name} must grant PRINCIPAL");
5504 }
5505 assert!(!WRITE_NOT_CONTROL_ACL.contains("did:nostr:alice"));
5506 }
5507
5508 #[actix_web::test]
5511 async fn uncharged_read_skips_pay_per_read_resource() {
5512 let storage = Arc::new(MemoryBackend::new());
5513 let acl = format!(
5514 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
5515 <#paid> a acl:Authorization ; acl:agent <{PRINCIPAL}> ;\n\
5516 acl:accessTo </premium/doc> ; acl:mode acl:Read ;\n\
5517 acl:condition [ a acl:PaymentCondition ; acl:costSats 100 ] .\n"
5518 );
5519 storage
5520 .put("/premium/doc.acl", Bytes::from(acl), "text/turtle")
5521 .await
5522 .unwrap();
5523 seed_ledger(storage.as_ref(), PRINCIPAL, 500).await;
5524 let state = AppState::new(storage.clone());
5525
5526 let skipped =
5527 enforce_read_inner(&state, "/premium/doc", Some(PRINCIPAL), None, false).await;
5528 assert!(skipped.is_err(), "glob must not serve a pay-per-read child");
5529 assert_eq!(
5530 resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5531 Some(500)
5532 );
5533
5534 enforce_read_ctx(&state, "/premium/doc", Some(PRINCIPAL), None)
5535 .await
5536 .expect("a direct read pays and is served");
5537 assert_eq!(
5538 resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5539 Some(400)
5540 );
5541 }
5542
5543 #[actix_web::test]
5545 async fn resolve_balance_none_when_anonymous() {
5546 let storage = MemoryBackend::new();
5547 seed_ledger(&storage, PRINCIPAL, 1_000).await;
5548 assert_eq!(resolve_balance_sats(&storage, None).await, None);
5549 }
5550
5551 #[actix_web::test]
5553 async fn paid_write_denied_below_balance() {
5554 let storage = Arc::new(MemoryBackend::new());
5555 seed_acl(storage.as_ref()).await;
5556 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
5558
5559 let result =
5560 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5561 assert!(
5562 result.is_err(),
5563 "balance 50 < cost 100 must be denied — sat-gating loop closed"
5564 );
5565 }
5566
5567 #[actix_web::test]
5569 async fn paid_write_allowed_at_balance() {
5570 let storage = Arc::new(MemoryBackend::new());
5571 seed_acl(storage.as_ref()).await;
5572 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
5574
5575 let result =
5576 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5577 assert!(
5578 result.is_ok(),
5579 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
5580 );
5581 }
5582
5583 #[actix_web::test]
5585 async fn paid_write_allowed_above_balance() {
5586 let storage = Arc::new(MemoryBackend::new());
5587 seed_acl(storage.as_ref()).await;
5588 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
5589 let state = AppState::new(storage);
5590
5591 let result =
5592 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5593 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
5594 }
5595
5596 #[actix_web::test]
5600 async fn paid_write_anonymous_denied() {
5601 let storage = Arc::new(MemoryBackend::new());
5602 seed_acl(storage.as_ref()).await;
5603 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
5604 let state = AppState::new(storage);
5605
5606 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
5607 assert!(
5608 result.is_err(),
5609 "anonymous caller has no ledger principal — PaymentCondition fails closed"
5610 );
5611 }
5612
5613 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
5620 let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
5621 let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
5622 ledger.get_balance(did)
5623 }
5624
5625 #[actix_web::test]
5627 async fn paid_write_debits_ledger() {
5628 let storage = Arc::new(MemoryBackend::new());
5629 seed_acl(storage.as_ref()).await;
5630 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
5632
5633 let result =
5634 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5635 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
5636 assert_eq!(
5637 read_balance(storage.as_ref(), PRINCIPAL).await,
5638 150,
5639 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
5640 );
5641 }
5642
5643 #[actix_web::test]
5646 async fn paid_write_debits_each_grant() {
5647 let storage = Arc::new(MemoryBackend::new());
5648 seed_acl(storage.as_ref()).await;
5649 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
5651
5652 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5653 .await
5654 .unwrap();
5655 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5656 .await
5657 .unwrap();
5658 assert_eq!(
5659 read_balance(storage.as_ref(), PRINCIPAL).await,
5660 50,
5661 "250 - 2*100: each granted request debits, no unmetered re-use"
5662 );
5663
5664 let third =
5666 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5667 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
5668 assert_eq!(
5669 read_balance(storage.as_ref(), PRINCIPAL).await,
5670 50,
5671 "a denied request must not debit"
5672 );
5673 }
5674
5675 #[actix_web::test]
5677 async fn paid_read_debits_ledger() {
5678 const PAID_READ_ACL: &str = r#"
5679@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5680
5681<#paid-read> a acl:Authorization ;
5682 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5683 acl:accessTo </premium/feed> ;
5684 acl:mode acl:Read ;
5685 acl:condition [
5686 a acl:PaymentCondition ;
5687 acl:costSats 30
5688 ] .
5689"#;
5690 let storage = Arc::new(MemoryBackend::new());
5691 storage
5692 .put(
5693 "/premium/feed.acl",
5694 Bytes::from(PAID_READ_ACL),
5695 "text/turtle",
5696 )
5697 .await
5698 .unwrap();
5699 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5700 let state = AppState::new(storage.clone());
5701
5702 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
5703 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
5704 assert_eq!(
5705 read_balance(storage.as_ref(), PRINCIPAL).await,
5706 70,
5707 "100 - 30 cost: a granted paid read must debit"
5708 );
5709 }
5710
5711 #[actix_web::test]
5714 async fn free_read_does_not_debit() {
5715 let storage = Arc::new(MemoryBackend::new());
5716 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5718 let state = AppState::new(storage.clone());
5719
5720 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
5721 .await
5722 .unwrap();
5723 assert_eq!(
5724 read_balance(storage.as_ref(), PRINCIPAL).await,
5725 100,
5726 "a grant with no PaymentCondition must not debit"
5727 );
5728 }
5729
5730 const ALICE_ONLY_READ_ACL: &str = r#"
5736@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5737
5738<#alice> a acl:Authorization ;
5739 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5740 acl:accessTo </private/secret> ;
5741 acl:default </private/> ;
5742 acl:mode acl:Read, acl:Write, acl:Control .
5743"#;
5744
5745 async fn seed_private_read_acl(storage: &dyn Storage) {
5746 storage
5751 .put(
5752 "/private.acl",
5753 Bytes::from(ALICE_ONLY_READ_ACL),
5754 "text/turtle",
5755 )
5756 .await
5757 .unwrap();
5758 }
5759
5760 #[actix_web::test]
5764 async fn enforce_read_grants_owner() {
5765 let storage = Arc::new(MemoryBackend::new());
5766 seed_private_read_acl(storage.as_ref()).await;
5767 let state = AppState::new(storage);
5768 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
5769 assert!(result.is_ok(), "owner alice must be granted Read");
5770 }
5771
5772 #[actix_web::test]
5775 async fn enforce_read_denies_other_principal() {
5776 let storage = Arc::new(MemoryBackend::new());
5777 seed_private_read_acl(storage.as_ref()).await;
5778 let state = AppState::new(storage);
5779 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
5780 assert!(
5781 result.is_err(),
5782 "bob has no Read grant — private resource must not be world-readable"
5783 );
5784 }
5785
5786 #[actix_web::test]
5789 async fn enforce_read_denies_anonymous() {
5790 let storage = Arc::new(MemoryBackend::new());
5791 seed_private_read_acl(storage.as_ref()).await;
5792 let state = AppState::new(storage);
5793 let result = enforce_read(&state, "/private/secret", None).await;
5794 assert!(result.is_err(), "anonymous Read must be denied");
5795 }
5796
5797 const WRITE_NOT_CONTROL_ACL: &str = concat!(
5805 r#"
5806@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5807
5808<#owner> a acl:Authorization ;
5809 acl:agent <"#,
5810 principal!(),
5811 r#"> ;
5812 acl:accessTo </shared/doc> ;
5813 acl:default </shared/> ;
5814 acl:mode acl:Read, acl:Write, acl:Control .
5815
5816<#writer> a acl:Authorization ;
5817 acl:agent <did:nostr:writer> ;
5818 acl:accessTo </shared/doc> ;
5819 acl:default </shared/> ;
5820 acl:mode acl:Read, acl:Write .
5821"#
5822 );
5823
5824 async fn seed_shared_acl(storage: &dyn Storage) {
5825 storage
5830 .put(
5831 "/shared.acl",
5832 Bytes::from(WRITE_NOT_CONTROL_ACL),
5833 "text/turtle",
5834 )
5835 .await
5836 .unwrap();
5837 }
5838
5839 #[actix_web::test]
5843 async fn acl_put_denied_for_writer_without_control() {
5844 let storage = Arc::new(MemoryBackend::new());
5845 seed_shared_acl(storage.as_ref()).await;
5846 let state = AppState::new(storage);
5847 let result = enforce_write(
5851 &state,
5852 "/shared/.acl",
5853 AccessMode::Write,
5854 Some("did:nostr:writer"),
5855 )
5856 .await;
5857 assert!(
5858 result.is_err(),
5859 "writer lacks Control — must not be able to PUT /shared/.acl"
5860 );
5861 }
5862
5863 #[actix_web::test]
5865 async fn acl_put_allowed_for_control_holder() {
5866 let storage = Arc::new(MemoryBackend::new());
5867 seed_shared_acl(storage.as_ref()).await;
5868 let state = AppState::new(storage);
5869 let result =
5870 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
5871 assert!(
5872 result.is_ok(),
5873 "the owner holds Control — must be allowed to PUT /shared/.acl"
5874 );
5875 }
5876
5877 #[actix_web::test]
5879 async fn meta_put_denied_for_writer_without_control() {
5880 let storage = Arc::new(MemoryBackend::new());
5881 seed_shared_acl(storage.as_ref()).await;
5882 let state = AppState::new(storage);
5883 let result = enforce_write(
5884 &state,
5885 "/shared/doc.meta",
5886 AccessMode::Write,
5887 Some("did:nostr:writer"),
5888 )
5889 .await;
5890 assert!(
5891 result.is_err(),
5892 "writer lacks Control — must not be able to PUT a .meta sidecar"
5893 );
5894 }
5895
5896 #[test]
5899 fn canonical_pod_path_vectors() {
5900 let ok = [
5901 ("a/b", "/a/b"),
5902 ("", "/"),
5903 ("/", "/"),
5904 ("//a//b/", "/a/b/"),
5905 ("/%2ewell-known/x", "/.well-known/x"),
5906 ("/a%2fb", "/a/b"),
5907 ("/a%2520b", "/a%20b"),
5908 ];
5909 for (raw, want) in ok {
5910 assert_eq!(canonical_pod_path(raw).as_deref(), Some(want), "{raw}");
5911 }
5912 for bad in [
5913 "/a/./b",
5914 "/a/..",
5915 "../a",
5916 "/a\\b",
5917 "/a%00b",
5918 "/a%ffb",
5919 "/%2e%2e/x",
5920 ] {
5921 assert_eq!(canonical_pod_path(bad), None, "{bad}");
5922 }
5923 }
5924
5925 #[test]
5926 fn guarded_acl_target_vectors() {
5927 use AccessMode::{Control, Read, Write};
5928 let c = |p: &str, m| guarded_acl_target(p, m);
5929 assert_eq!(
5930 c("/.well-known/token/x.json", Read),
5931 ("/.well-known/token/x.json".into(), Control)
5932 );
5933 assert_eq!(
5934 c("/.well-known/gitmark/p.json", Write),
5935 ("/.well-known/gitmark/p.json".into(), Control)
5936 );
5937 assert_eq!(c("/.well-known/%74oken/x.json", Read).1, Control);
5938 assert_eq!(
5939 c("/.well-known/token/x.json.acl", Read),
5940 ("/.well-known/token/x.json".into(), Control)
5941 );
5942 assert_eq!(
5943 c("/.well-known/gitmark/p/blocktrails.json", Read),
5944 ("/.well-known/gitmark/p/blocktrails.json".into(), Read)
5945 );
5946 assert_eq!(
5947 c("/.well-known/webledgers/webledgers.json", Write),
5948 ("/.well-known/webledgers/webledgers.json".into(), Control)
5949 );
5950 assert_eq!(c("/.well-known/%57ebledgers/state.json", Read).1, Control);
5951 assert_eq!(c("/.well-known/webledgersx/y", Read).1, Read);
5952 assert_eq!(c("/pub/x", Read), ("/pub/x".into(), Read));
5953 }
5954
5955 #[test]
5957 fn protected_resource_for_acl_strips_suffixes() {
5958 assert_eq!(
5959 protected_resource_for_acl("/victim/.acl").as_deref(),
5960 Some("/victim/")
5961 );
5962 assert_eq!(
5963 protected_resource_for_acl("/a/b.acl").as_deref(),
5964 Some("/a/b")
5965 );
5966 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
5967 assert_eq!(
5968 protected_resource_for_acl("/a/b.meta").as_deref(),
5969 Some("/a/b")
5970 );
5971 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
5972 }
5973}
5974
5975#[cfg(all(test, feature = "forge-mirror"))]
5976mod forge_env_tests {
5977 use super::*;
5978
5979 #[test]
5981 fn mirror_config_comes_from_the_environment() {
5982 let pk = "ab".repeat(32);
5983 std::env::remove_var("FORGE_MIRROR_RELAYS");
5984 std::env::remove_var("FORGE_MIRROR_FOLLOW");
5985 assert!(mirror_config().is_none(), "nothing configured: mirror off");
5986
5987 std::env::set_var(
5988 "FORGE_MIRROR_RELAYS",
5989 "ws://127.0.0.1:7777, wss://r.example , nope",
5990 );
5991 assert!(mirror_config().is_none(), "relays alone follow nothing");
5992
5993 std::env::set_var(
5994 "FORGE_MIRROR_FOLLOW",
5995 format!("{pk}/agent, bad/entry, {pk}/../x"),
5996 );
5997 std::env::set_var("FORGE_MIRROR_MIN_INTERVAL_MS", "1000");
5998 let cfg = mirror_config().expect("one relay and one valid entry");
5999 assert_eq!(cfg.relays, ["ws://127.0.0.1:7777", "wss://r.example"]);
6000 assert_eq!(cfg.follow.len(), 1);
6001 assert_eq!(cfg.follow[0].name, "agent");
6002 assert_eq!(cfg.min_interval_ms, 1000);
6003
6004 std::env::remove_var("FORGE_MIRROR_RELAYS");
6005 std::env::remove_var("FORGE_MIRROR_FOLLOW");
6006 std::env::remove_var("FORGE_MIRROR_MIN_INTERVAL_MS");
6007 }
6008
6009 #[test]
6010 fn the_forge_service_is_built_once_per_data_directory() {
6011 let td = tempfile::tempdir().unwrap();
6012 let a = forge_service(td.path().join(".forge")).unwrap();
6013 let b = forge_service(td.path().join(".forge")).unwrap();
6014 assert!(Arc::ptr_eq(&a, &b));
6015 let other = tempfile::tempdir().unwrap();
6016 let c = forge_service(other.path().join(".forge")).unwrap();
6017 assert!(!Arc::ptr_eq(&a, &c));
6018 }
6019}