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