1#![doc = include_str!("../README.md")]
107#![deny(unsafe_code)]
108#![warn(rust_2018_idioms)]
109
110#[cfg(feature = "activitypub")]
111pub mod activitypub;
112pub mod cli;
114pub mod cross_identity;
115pub mod scheme;
116
117pub(crate) mod hardened_git;
118
119mod handlers;
124
125#[cfg(feature = "jev")]
126pub mod jev;
127mod mcp;
130
131pub mod mempool;
136
137pub mod trail_store;
143
144use std::collections::HashMap;
145use std::net::{IpAddr, Ipv4Addr};
146use std::path::{Path, PathBuf};
147use std::sync::{Arc, Mutex};
148use std::time::{Duration, Instant};
149
150use actix_web::body::{BoxBody, EitherBody};
151use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
152use actix_web::http::{header, StatusCode};
153use actix_web::middleware::{NormalizePath, TrailingSlash};
154use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
155use bytes::Bytes;
156use futures_util::future::{ready, LocalBoxFuture, Ready};
157use futures_util::StreamExt;
158use percent_encoding::percent_decode_str;
159use serde::Deserialize;
160use solid_pod_rs::{
161 auth::{nip98, replay::ReplayStore},
165 config::sources::parse_size,
166 interop,
167 ldp::{
168 self, cache_control_for_response, LdpContainerOps, PatchCreateOutcome, ResponseAudience,
169 },
170 mashlib::{self, MashlibConfig},
171 provenance::{ProvenanceReceipt, ProvenanceSkip},
172 security::DotfileAllowlist,
173 storage::Storage,
174 wac::{
175 self, conditions::RequestContext, effective_acl_target, parse_jsonld_acl,
176 protected_resource_for_acl, AccessMode,
177 },
178 PodError,
179};
180
181const _: () = solid_pod_rs::auth::nip98::assert_schnorr_verification_enabled();
193
194static NIP98_REPLAY: std::sync::LazyLock<solid_pod_rs::auth::replay::Nip98ReplayCache> =
201 std::sync::LazyLock::new(solid_pod_rs::auth::replay::Nip98ReplayCache::from_env);
202
203pub(crate) static PAYMENT_STATE_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
209 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
210
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 use solid_pod_rs::payments::PaymentStore;
778 let ledger = handlers::pay::StoragePaymentStore::new(storage)
779 .read_ledger()
780 .await
781 .ok()?;
782 Some(ledger.get_balance(did))
783}
784
785fn accept_includes_html(accept: &str) -> bool {
793 accept.split(',').any(|entry| {
794 let mime = entry.split(';').next().unwrap_or("").trim();
795 mime.eq_ignore_ascii_case("text/html")
796 })
797}
798
799pub(crate) fn guarded_acl_target(path: &str, mode: AccessMode) -> (String, AccessMode) {
818 let (resource, mode) = effective_acl_target(path, mode);
819 match canonical_pod_path(&resource) {
823 Some(canon)
824 if !trail_store::is_secret_trail_path(&canon)
825 && !trail_store::is_prov_state_path(&canon)
826 && !trail_store::is_server_state_path(&canon) =>
827 {
828 (resource, mode)
829 }
830 _ => (resource, AccessMode::Control),
831 }
832}
833
834pub(crate) fn canonical_pod_path(raw: &str) -> Option<String> {
842 let decoded = percent_decode_str(raw).decode_utf8().ok()?;
843 if decoded.contains(['\\', '\0']) {
844 return None;
845 }
846 let mut out = String::with_capacity(decoded.len() + 1);
847 for seg in decoded.split('/') {
848 match seg {
849 "" => {}
850 "." | ".." => return None,
851 _ => {
852 out.push('/');
853 out.push_str(seg);
854 }
855 }
856 }
857 if out.is_empty() || decoded.ends_with('/') {
858 out.push('/');
859 }
860 Some(out)
861}
862
863async fn proposed_acl_keeps_caller_control(
867 state: &AppState,
868 req: &HttpRequest,
869 path: &str,
870 body: &[u8],
871 content_type: &str,
872 caller: Option<&str>,
873) -> bool {
874 if !ldp::is_acl_path(path) {
875 return true;
876 }
877 let Some(resource) = protected_resource_for_acl(path) else {
878 return false;
879 };
880 let wac::PolicyStep::Settled(wac::PolicyOutcome::Found(doc)) =
881 wac::classify_policy_read(path, wac::PolicyRead::Present { body, content_type }, false)
882 else {
883 return false;
884 };
885 if wac::validate_acl_document(&doc).is_err() {
886 return false;
887 }
888 if parse_jsonld_acl(body).is_err() {
893 use solid_pod_rs::rdf_input::{parse_rdf_body, RdfInputSyntax};
894 let syntax =
895 RdfInputSyntax::from_content_type(content_type).unwrap_or(RdfInputSyntax::Turtle);
896 let base = format!("{}{}", scheme::request_origin(req), path);
897 if parse_rdf_body(syntax, body, &base).is_err() {
898 return false;
899 }
900 }
901 let balance = resolve_balance_sats(&*state.storage, caller).await;
902 let linked = cross_identity::linked_identities(state.cross_identity.as_ref(), caller).await;
903 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
904 let groups = wac::StaticGroupMembership::default();
905 grant_for_identities(
908 Some(&doc),
909 caller,
910 &linked,
911 balance,
912 &resource,
913 AccessMode::Control,
914 None,
915 &groups,
916 ®istry,
917 )
918 .is_some()
919}
920
921#[cfg_attr(not(test), allow(dead_code))]
928async fn enforce_write(
929 state: &AppState,
930 path: &str,
931 mode: AccessMode,
932 agent_uri: Option<&str>,
933) -> Result<(), ActixError> {
934 enforce_write_ctx(state, path, mode, agent_uri, None).await
935}
936
937async fn enforce_write_ctx(
945 state: &AppState,
946 path: &str,
947 mode: AccessMode,
948 agent_uri: Option<&str>,
949 request_origin: Option<&str>,
950) -> Result<(), ActixError> {
951 let origin = request_origin.and_then(wac::Origin::parse);
952 let (resource, eff_mode) = guarded_acl_target(path, mode);
964
965 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
973 if outcome.is_failure() {
974 return Err(policy_failure_to_actix(&outcome, &resource));
975 }
976 let acl_doc = outcome.document().cloned();
977
978 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
983
984 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
985 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
986 let linked = cross_identity::linked_identities(state.cross_identity.as_ref(), agent_uri).await;
989 let Some(ctx) = grant_for_identities(
990 acl_doc.as_ref(),
991 agent_uri,
992 &linked,
993 payment_balance_sats,
994 &resource,
995 eff_mode,
996 origin.as_ref(),
997 &groups,
998 ®istry,
999 ) else {
1000 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1001 };
1002 if resource.as_str() == path {
1009 charge_granted_payment(
1015 state,
1016 acl_doc.as_ref(),
1017 &ctx,
1018 &resource,
1019 eff_mode,
1020 &groups,
1021 ®istry,
1022 agent_uri,
1023 )
1024 .await?;
1025 }
1026 Ok(())
1027}
1028
1029#[allow(clippy::too_many_arguments)]
1038async fn charge_granted_payment(
1039 state: &AppState,
1040 acl_doc: Option<&wac::AclDocument>,
1041 ctx: &RequestContext<'_>,
1042 path: &str,
1043 mode: AccessMode,
1044 groups: &wac::StaticGroupMembership,
1045 registry: &wac::conditions::ConditionRegistry,
1046 payer: Option<&str>,
1047) -> Result<(), ActixError> {
1048 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
1049 if cost == 0 {
1050 return Ok(());
1051 }
1052 if let Some(did) = payer {
1055 if debit_ledger(&*state.storage, did, cost).await.is_err() {
1056 return Err(acl_denial(acl_doc, payer, path));
1057 }
1058 }
1059 Ok(())
1060}
1061
1062#[allow(clippy::too_many_arguments)]
1069pub(crate) fn grant_for_identities<'a>(
1070 acl_doc: Option<&wac::AclDocument>,
1071 primary: Option<&'a str>,
1072 linked: &'a [String],
1073 payment_balance_sats: Option<u64>,
1074 resource: &str,
1075 mode: AccessMode,
1076 origin: Option<&wac::Origin>,
1077 groups: &wac::StaticGroupMembership,
1078 registry: &wac::conditions::ConditionRegistry,
1079) -> Option<RequestContext<'a>> {
1080 std::iter::once(primary)
1081 .chain(linked.iter().map(|s| Some(s.as_str())))
1082 .map(|web_id| RequestContext {
1083 web_id,
1084 client_id: None,
1085 issuer: None,
1086 payment_balance_sats,
1087 })
1088 .find(|ctx| {
1089 wac::evaluate_access_ctx_with_registry(
1090 acl_doc, ctx, resource, mode, origin, groups, registry,
1091 )
1092 })
1093}
1094
1095fn acl_denial(
1101 acl_doc: Option<&wac::AclDocument>,
1102 agent_uri: Option<&str>,
1103 path: &str,
1104) -> ActixError {
1105 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
1106 let (status, body, unauthenticated) = if agent_uri.is_none() {
1107 (StatusCode::UNAUTHORIZED, "authentication required", true)
1108 } else {
1109 (StatusCode::FORBIDDEN, "access forbidden", false)
1110 };
1111 let mut rsp = HttpResponse::new(status);
1112 rsp.headers_mut().insert(
1113 header::HeaderName::from_static("wac-allow"),
1114 header::HeaderValue::from_str(&allow_header)
1115 .unwrap_or(header::HeaderValue::from_static("")),
1116 );
1117 if unauthenticated {
1118 rsp.headers_mut().insert(
1125 header::WWW_AUTHENTICATE,
1126 header::HeaderValue::from_static(
1127 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
1128 ),
1129 );
1130 }
1131 actix_web::error::InternalError::from_response(body, rsp).into()
1132}
1133
1134#[cfg_attr(not(test), allow(dead_code))]
1145async fn enforce_read(
1146 state: &AppState,
1147 path: &str,
1148 agent_uri: Option<&str>,
1149) -> Result<ResponseAudience, ActixError> {
1150 enforce_read_ctx(state, path, agent_uri, None).await
1151}
1152
1153async fn enforce_read_ctx(
1156 state: &AppState,
1157 path: &str,
1158 agent_uri: Option<&str>,
1159 request_origin: Option<&str>,
1160) -> Result<ResponseAudience, ActixError> {
1161 enforce_read_inner(state, path, agent_uri, request_origin, true).await
1162}
1163
1164async fn enforce_read_inner(
1169 state: &AppState,
1170 path: &str,
1171 agent_uri: Option<&str>,
1172 request_origin: Option<&str>,
1173 charge: bool,
1174) -> Result<ResponseAudience, ActixError> {
1175 let origin = request_origin.and_then(wac::Origin::parse);
1176 let (resource, eff_mode) = guarded_acl_target(path, AccessMode::Read);
1188 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
1191 if outcome.is_failure() {
1192 return Err(policy_failure_to_actix(&outcome, &resource));
1193 }
1194 let acl_doc = outcome.document().cloned();
1195 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
1196 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
1197 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
1198 let linked = cross_identity::linked_identities(state.cross_identity.as_ref(), agent_uri).await;
1199 let Some(ctx) = grant_for_identities(
1200 acl_doc.as_ref(),
1201 agent_uri,
1202 &linked,
1203 payment_balance_sats,
1204 &resource,
1205 eff_mode,
1206 origin.as_ref(),
1207 &groups,
1208 ®istry,
1209 ) else {
1210 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1211 };
1212 if resource.as_str() == path && !charge {
1216 if wac::granted_payment_cost(
1217 acl_doc.as_ref(),
1218 &ctx,
1219 &resource,
1220 eff_mode,
1221 &groups,
1222 ®istry,
1223 ) > 0
1224 {
1225 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1226 }
1227 } else if resource.as_str() == path {
1228 charge_granted_payment(
1233 state,
1234 acl_doc.as_ref(),
1235 &ctx,
1236 &resource,
1237 eff_mode,
1238 &groups,
1239 ®istry,
1240 agent_uri,
1241 )
1242 .await?;
1243 }
1244 let anon_ctx = RequestContext {
1253 web_id: None,
1254 client_id: None,
1255 issuer: None,
1256 payment_balance_sats: None,
1257 };
1258 let anonymous_would_be_granted = wac::evaluate_access_ctx_with_registry(
1259 acl_doc.as_ref(),
1260 &anon_ctx,
1261 &resource,
1262 eff_mode,
1263 origin.as_ref(),
1264 &groups,
1265 ®istry,
1266 );
1267 Ok(if anonymous_would_be_granted && resource.as_str() == path {
1268 ResponseAudience::Public
1269 } else {
1270 ResponseAudience::Private
1271 })
1272}
1273
1274async fn debit_ledger(
1281 storage: &dyn Storage,
1282 did: &str,
1283 cost: u64,
1284) -> Result<(), solid_pod_rs::payments::PaymentError> {
1285 handlers::pay::StoragePaymentStore::new(storage)
1286 .charge_access(did, cost)
1287 .await
1288}
1289
1290fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
1295 let links = ldp::link_headers(path).join(", ");
1296 if let Ok(value) = header::HeaderValue::from_str(&links) {
1297 rsp.headers_mut()
1298 .insert(header::HeaderName::from_static("link"), value);
1299 }
1300}
1301
1302fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
1303 if let Ok(v) = header::HeaderValue::from_str(header_value) {
1304 rsp.headers_mut()
1305 .insert(header::HeaderName::from_static("wac-allow"), v);
1306 }
1307}
1308
1309fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
1310 let ws_base = base_url
1311 .replacen("https://", "wss://", 1)
1312 .replacen("http://", "ws://", 1);
1313 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
1314 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
1315 rsp.headers_mut()
1316 .insert(header::HeaderName::from_static("updates-via"), v);
1317 }
1318}
1319
1320fn set_provenance_headers(rsp: &mut HttpResponse, receipt: &ProvenanceReceipt) {
1331 if let Ok(v) = header::HeaderValue::from_str(&receipt.summary()) {
1332 rsp.headers_mut()
1333 .insert(header::HeaderName::from_static("x-provenance"), v);
1334 }
1335 if let Some(sha) = receipt.commit_sha() {
1336 if let Ok(v) = header::HeaderValue::from_str(sha) {
1337 rsp.headers_mut()
1338 .insert(header::HeaderName::from_static("x-provenance-commit"), v);
1339 }
1340 }
1341}
1342
1343fn set_cache_policy(rsp: &mut HttpResponse, content_type: &str, audience: ResponseAudience) {
1355 if audience == ResponseAudience::Private {
1356 append_vary(rsp, "Authorization");
1360 }
1361 if rsp.headers().contains_key(header::CACHE_CONTROL) {
1362 return;
1363 }
1364 if let Some(value) = cache_control_for_response(content_type, audience) {
1365 rsp.headers_mut().insert(
1366 header::CACHE_CONTROL,
1367 header::HeaderValue::from_static(value),
1368 );
1369 }
1370}
1371
1372fn append_vary(rsp: &mut HttpResponse, field: &str) {
1374 let existing = rsp
1375 .headers()
1376 .get(header::VARY)
1377 .and_then(|v| v.to_str().ok())
1378 .unwrap_or("")
1379 .to_string();
1380 if existing
1381 .split(',')
1382 .any(|f| f.trim().eq_ignore_ascii_case(field))
1383 {
1384 return;
1385 }
1386 let merged = if existing.trim().is_empty() {
1387 field.to_string()
1388 } else {
1389 format!("{existing}, {field}")
1390 };
1391 if let Ok(v) = header::HeaderValue::from_str(&merged) {
1392 rsp.headers_mut().insert(header::VARY, v);
1393 }
1394}
1395
1396async fn handle_get(
1397 req: HttpRequest,
1398 state: web::Data<AppState>,
1399) -> Result<HttpResponse, ActixError> {
1400 let path = req.uri().path().to_string();
1401
1402 #[cfg(feature = "activitypub")]
1407 if let (Some(ap), "/profile/card.jsonld") = (state.activitypub.as_ref(), path.as_str()) {
1408 let accept = req
1409 .headers()
1410 .get(header::ACCEPT)
1411 .and_then(|v| v.to_str().ok())
1412 .unwrap_or("");
1413 if solid_pod_rs_activitypub::negotiate_actor_format(accept)
1414 == solid_pod_rs_activitypub::ActorFormat::ActivityJson
1415 {
1416 let doc = ap.actor_json(&scheme::request_origin(&req));
1417 return Ok(HttpResponse::Ok()
1418 .content_type("application/activity+json")
1419 .insert_header((header::VARY, "Accept"))
1420 .json(doc));
1421 }
1422 }
1423
1424 if path.contains('*') {
1425 return handle_glob_get(req, state).await;
1426 }
1427
1428 let auth_pk = extract_pubkey(&req).await;
1429 let agent = agent_uri(auth_pk.as_ref());
1430
1431 let audience = enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
1436
1437 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
1438
1439 if ldp::is_container(&path) {
1440 let accept = req
1441 .headers()
1442 .get(header::ACCEPT)
1443 .and_then(|v| v.to_str().ok())
1444 .unwrap_or("");
1445
1446 if accept_includes_html(accept) {
1452 let index_path = format!("{path}index.html");
1453 let index_audience =
1456 enforce_read_ctx(&state, &index_path, agent.as_deref(), req_origin(&req))
1457 .await
1458 .ok();
1459 if let (Some(index_audience), Ok((body, _meta))) =
1460 (index_audience, state.storage.get(&index_path).await)
1461 {
1462 let mut rsp = HttpResponse::Ok()
1463 .content_type("text/html; charset=utf-8")
1464 .body(inject_live_reload(&body, state.live_reload));
1465 set_cache_policy(&mut rsp, "text/html", index_audience);
1466 set_wac_allow(&mut rsp, &wac_allow);
1467 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1468 set_link_headers(&mut rsp, &path);
1469 return Ok(rsp);
1470 }
1471 }
1472
1473 let v = state
1474 .storage
1475 .container_representation(&path)
1476 .await
1477 .map_err(to_actix)?;
1478
1479 let sec_fetch_dest = req
1481 .headers()
1482 .get("sec-fetch-dest")
1483 .and_then(|v| v.to_str().ok());
1484 if mashlib::should_serve(
1485 accept,
1486 sec_fetch_dest,
1487 "application/ld+json",
1488 state.mashlib.enabled,
1489 ) {
1490 let json_ld = serde_json::to_string(&v).ok();
1491 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
1492 let mut rsp = HttpResponse::Ok()
1493 .content_type("text/html; charset=utf-8")
1494 .insert_header(("X-Frame-Options", "DENY"))
1495 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1496 .insert_header(("Cache-Control", "no-store"))
1497 .body(inject_live_reload(html, state.live_reload));
1498 set_cache_policy(&mut rsp, "text/html", audience);
1499 set_wac_allow(&mut rsp, &wac_allow);
1500 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1501 set_link_headers(&mut rsp, &path);
1502 return Ok(rsp);
1503 }
1504
1505 let mut rsp = HttpResponse::Ok().json(v);
1506 rsp.headers_mut().insert(
1507 header::CONTENT_TYPE,
1508 header::HeaderValue::from_static("application/ld+json"),
1509 );
1510 set_cache_policy(&mut rsp, "application/ld+json", audience);
1511 set_wac_allow(&mut rsp, &wac_allow);
1512 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1513 set_link_headers(&mut rsp, &path);
1514 return Ok(rsp);
1515 }
1516
1517 match state.storage.get(&path).await {
1518 Ok((body, meta)) => {
1519 let accept = req
1521 .headers()
1522 .get(header::ACCEPT)
1523 .and_then(|v| v.to_str().ok())
1524 .unwrap_or("");
1525 let sec_fetch_dest = req
1526 .headers()
1527 .get("sec-fetch-dest")
1528 .and_then(|v| v.to_str().ok());
1529 if mashlib::should_serve(
1530 accept,
1531 sec_fetch_dest,
1532 &meta.content_type,
1533 state.mashlib.enabled,
1534 ) {
1535 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
1536 std::str::from_utf8(&body).ok().map(|s| s.to_string())
1537 } else {
1538 None
1539 };
1540 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
1541 let mut rsp = HttpResponse::Ok()
1542 .content_type("text/html; charset=utf-8")
1543 .insert_header(("X-Frame-Options", "DENY"))
1544 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1545 .insert_header(("Cache-Control", "no-store"))
1546 .body(inject_live_reload(html, state.live_reload));
1547 set_cache_policy(&mut rsp, "text/html", audience);
1548 set_wac_allow(&mut rsp, &wac_allow);
1549 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1550 set_link_headers(&mut rsp, &path);
1551 return Ok(rsp);
1552 }
1553
1554 if let Some((negotiated_body, negotiated_ct)) =
1562 rdf_content_negotiate(&body, &meta.content_type, accept)
1563 {
1564 let mut rsp = HttpResponse::Ok().body(negotiated_body);
1565 rsp.headers_mut().insert(
1566 header::CONTENT_TYPE,
1567 header::HeaderValue::from_str(negotiated_ct)
1568 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
1569 );
1570 rsp.headers_mut()
1571 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
1572 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1573 rsp.headers_mut().insert(header::ETAG, etag);
1574 }
1575 set_cache_policy(&mut rsp, negotiated_ct, audience);
1576 set_wac_allow(&mut rsp, &wac_allow);
1577 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1578 set_link_headers(&mut rsp, &path);
1579 return Ok(rsp);
1580 }
1581
1582 let response_body = if meta.content_type.starts_with("text/html") {
1583 inject_live_reload(&body, state.live_reload)
1584 } else {
1585 body.to_vec()
1586 };
1587 let mut rsp = HttpResponse::Ok().body(response_body);
1588 rsp.headers_mut().insert(
1589 header::CONTENT_TYPE,
1590 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
1591 header::HeaderValue::from_static("application/octet-stream")
1592 }),
1593 );
1594 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1595 rsp.headers_mut().insert(header::ETAG, etag);
1596 }
1597 set_cache_policy(&mut rsp, &meta.content_type, 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 Ok(rsp)
1602 }
1603 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1604 Err(e) => Err(to_actix(e)),
1605 }
1606}
1607
1608fn has_basic_container_link(req: &HttpRequest) -> bool {
1609 req.headers()
1610 .get_all(header::LINK)
1611 .filter_map(|v| v.to_str().ok())
1612 .any(|v| {
1613 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
1614 })
1615}
1616
1617fn validate_rdf_body(
1626 req: &HttpRequest,
1627 state: &AppState,
1628 resource_path: &str,
1629 content_type: &str,
1630 body: &[u8],
1631) -> Result<(), ActixError> {
1632 use solid_pod_rs::rdf_input::{parse_rdf_body, RdfInputSyntax};
1633 let Some(syntax) = RdfInputSyntax::from_content_type(content_type) else {
1634 return Ok(());
1635 };
1636 if syntax == RdfInputSyntax::N3 && !state.conneg_enabled {
1637 return Err(actix_web::error::ErrorUnsupportedMediaType(
1638 "text/n3 request bodies require content negotiation (conneg) to be enabled",
1639 ));
1640 }
1641 let base = format!("{}{}", scheme::request_origin(req), resource_path);
1642 match parse_rdf_body(syntax, body, &base) {
1643 Ok(_) => Ok(()),
1644 Err(solid_pod_rs::PodError::PayloadTooLarge(m)) => {
1645 Err(actix_web::error::ErrorPayloadTooLarge(m))
1646 }
1647 Err(e) => Err(to_actix(e)),
1648 }
1649}
1650
1651async fn handle_put(
1652 req: HttpRequest,
1653 body: web::Bytes,
1654 state: web::Data<AppState>,
1655) -> Result<HttpResponse, ActixError> {
1656 let path = req.uri().path().to_string();
1657
1658 if ldp::is_container(&path) {
1659 if has_basic_container_link(&req) {
1660 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1661 let agent = agent_uri(auth_pk.as_ref());
1662 enforce_write_ctx(
1663 &state,
1664 &path,
1665 AccessMode::Write,
1666 agent.as_deref(),
1667 req_origin(&req),
1668 )
1669 .await?;
1670 let meta = state
1671 .storage
1672 .create_container(&path)
1673 .await
1674 .map_err(to_actix)?;
1675 let mut rsp = HttpResponse::Created().finish();
1676 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1677 rsp.headers_mut().insert(header::ETAG, etag);
1678 }
1679 set_link_headers(&mut rsp, &path);
1680 return Ok(rsp);
1681 }
1682 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
1683 }
1684
1685 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1686 let agent = agent_uri(auth_pk.as_ref());
1687 enforce_write_ctx(
1688 &state,
1689 &path,
1690 AccessMode::Write,
1691 agent.as_deref(),
1692 req_origin(&req),
1693 )
1694 .await?;
1695
1696 let ct = req
1697 .headers()
1698 .get(header::CONTENT_TYPE)
1699 .and_then(|v| v.to_str().ok())
1700 .unwrap_or("application/octet-stream");
1701
1702 if protected_resource_for_acl(&path).is_some()
1707 && !proposed_acl_keeps_caller_control(&state, &req, &path, &body, ct, agent.as_deref())
1708 .await
1709 {
1710 return Ok(HttpResponse::Conflict().body(
1711 "refused: the proposed ACL would not grant Control to the caller \
1712 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1713 ));
1714 }
1715
1716 validate_rdf_body(&req, &state, &path, ct, &body)?;
1717
1718 let quota = reserve_quota_for_size(&state, &path, body.len() as u64).await?;
1719 let write = state
1720 .storage
1721 .put(&path, Bytes::from(body.to_vec()), ct)
1722 .await;
1723 finish_quota_reservation(&state, quota, write.is_ok()).await;
1724 let meta = write.map_err(to_actix)?;
1725 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
1729 let mut rsp = HttpResponse::Created().finish();
1730 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1731 rsp.headers_mut().insert(header::ETAG, etag);
1732 }
1733 set_provenance_headers(&mut rsp, &provenance);
1734 set_link_headers(&mut rsp, &path);
1735 Ok(rsp)
1736}
1737
1738async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
1745 if !storage.exists(target).await.unwrap_or(false) {
1746 return target.to_string();
1747 }
1748 let seg_start = target.rfind('/').map(|s| s + 1).unwrap_or(0);
1751 let (stem, ext) = match target.rfind('.') {
1752 Some(dot) if dot > seg_start => (&target[..dot], &target[dot..]),
1753 _ => (target, ""),
1754 };
1755 for n in 1..10_000u32 {
1756 let candidate = format!("{stem}-{n}{ext}");
1757 if !storage.exists(&candidate).await.unwrap_or(false) {
1758 return candidate;
1759 }
1760 }
1761 use std::hash::{Hash, Hasher};
1762 let mut h = std::collections::hash_map::DefaultHasher::new();
1763 target.hash(&mut h);
1764 format!("{stem}-{:x}{ext}", h.finish())
1765}
1766
1767async fn handle_post(
1768 req: HttpRequest,
1769 body: web::Bytes,
1770 state: web::Data<AppState>,
1771) -> Result<HttpResponse, ActixError> {
1772 let path = req.uri().path().to_string();
1773 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1776 let agent = agent_uri(auth_pk.as_ref());
1777 enforce_write_ctx(
1778 &state,
1779 &path,
1780 AccessMode::Append,
1781 agent.as_deref(),
1782 req_origin(&req),
1783 )
1784 .await?;
1785
1786 let slug = req
1787 .headers()
1788 .get(header::HeaderName::from_static("slug"))
1789 .and_then(|v| v.to_str().ok());
1790 let mut target = match ldp::resolve_slug(&path, slug) {
1791 Ok(p) => p,
1792 Err(e) => return Err(to_actix(e)),
1793 };
1794 let ct = req
1795 .headers()
1796 .get(header::CONTENT_TYPE)
1797 .and_then(|v| v.to_str().ok())
1798 .unwrap_or("application/octet-stream");
1799
1800 if protected_resource_for_acl(&target).is_some() {
1809 enforce_write_ctx(
1810 &state,
1811 &target,
1812 AccessMode::Write,
1813 agent.as_deref(),
1814 req_origin(&req),
1815 )
1816 .await?;
1817 if !proposed_acl_keeps_caller_control(&state, &req, &target, &body, ct, agent.as_deref())
1818 .await
1819 {
1820 return Ok(HttpResponse::Conflict().body(
1821 "refused: the proposed ACL would not grant Control to the caller \
1822 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1823 ));
1824 }
1825 } else {
1826 target = mint_unique_target(&*state.storage, &target).await;
1832 }
1833
1834 validate_rdf_body(&req, &state, &target, ct, &body)?;
1835
1836 let quota = reserve_quota_for_size(&state, &target, body.len() as u64).await?;
1837 let write = state
1838 .storage
1839 .put(&target, Bytes::from(body.to_vec()), ct)
1840 .await;
1841 finish_quota_reservation(&state, quota, write.is_ok()).await;
1842 let meta = write.map_err(to_actix)?;
1843 let provenance = git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1846 let mut rsp = HttpResponse::Created().finish();
1847 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1848 rsp.headers_mut().insert(header::LOCATION, loc);
1849 }
1850 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1851 rsp.headers_mut().insert(header::ETAG, etag);
1852 }
1853 set_provenance_headers(&mut rsp, &provenance);
1854 set_link_headers(&mut rsp, &target);
1855 Ok(rsp)
1856}
1857
1858async fn handle_patch(
1859 req: HttpRequest,
1860 body: web::Bytes,
1861 state: web::Data<AppState>,
1862) -> Result<HttpResponse, ActixError> {
1863 let path = req.uri().path().to_string();
1864 if ldp::is_container(&path) {
1865 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1866 }
1867 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1868 let agent = agent_uri(auth_pk.as_ref());
1869 enforce_write_ctx(
1875 &state,
1876 &path,
1877 AccessMode::Write,
1878 agent.as_deref(),
1879 req_origin(&req),
1880 )
1881 .await?;
1882
1883 let ct = req
1884 .headers()
1885 .get(header::CONTENT_TYPE)
1886 .and_then(|v| v.to_str().ok())
1887 .unwrap_or("");
1888 let dialect = match ldp::patch_dialect_from_mime(ct) {
1889 Some(d) => d,
1890 None => {
1891 return Ok(HttpResponse::UnsupportedMediaType()
1892 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1893 }
1894 };
1895 let body_str = match std::str::from_utf8(&body) {
1896 Ok(s) => s.to_string(),
1897 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1898 };
1899
1900 let existing = state.storage.get(&path).await;
1902 match existing {
1903 Ok((current_body, meta)) => {
1904 let out = match dialect {
1914 ldp::PatchDialect::N3 => {
1915 let seed = seed_graph_from_patch_target(¤t_body)?;
1916 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1917 }
1918 ldp::PatchDialect::SparqlUpdate => {
1919 let seed = seed_graph_from_patch_target(¤t_body)?;
1920 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1921 }
1922 ldp::PatchDialect::JsonPatch => {
1923 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1924 Ok(v) => v,
1925 Err(_) => serde_json::json!({}),
1926 };
1927 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1928 Ok(v) => v,
1929 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1930 };
1931 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1932 let bytes = serde_json::to_vec(&json)
1933 .map_err(PodError::from)
1934 .map_err(to_actix)?;
1935 if !proposed_acl_keeps_caller_control(
1936 &state,
1937 &req,
1938 &path,
1939 &bytes,
1940 &meta.content_type,
1941 agent.as_deref(),
1942 )
1943 .await
1944 {
1945 return Ok(HttpResponse::Conflict().body(
1946 "refused: the patched ACL would not grant Control to the caller",
1947 ));
1948 }
1949 let quota = reserve_quota_for_size(&state, &path, bytes.len() as u64).await?;
1950 let write = state
1951 .storage
1952 .put(&path, Bytes::from(bytes), &meta.content_type)
1953 .await;
1954 finish_quota_reservation(&state, quota, write.is_ok()).await;
1955 let _ = write.map_err(to_actix)?;
1956 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1957 let mut rsp = HttpResponse::NoContent().finish();
1958 set_provenance_headers(&mut rsp, &provenance);
1959 return Ok(rsp);
1960 }
1961 };
1962 let outcome = out?;
1963 let serialised = graph_to_turtle(&outcome.graph);
1966 if protected_resource_for_acl(&path).is_some()
1972 && !proposed_acl_keeps_caller_control(
1973 &state,
1974 &req,
1975 &path,
1976 serialised.as_bytes(),
1977 "application/n-triples",
1978 agent.as_deref(),
1979 )
1980 .await
1981 {
1982 return Ok(HttpResponse::Conflict().body(
1983 "refused: the patched ACL would not grant Control to the caller \
1984 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1985 ));
1986 }
1987 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
1988 let write = state
1989 .storage
1990 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1991 .await;
1992 finish_quota_reservation(&state, quota, write.is_ok()).await;
1993 let _ = write.map_err(to_actix)?;
1994 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1995 let mut rsp = HttpResponse::NoContent().finish();
1996 set_provenance_headers(&mut rsp, &provenance);
1997 Ok(rsp)
1998 }
1999 Err(PodError::NotFound(_)) => {
2000 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
2002 let PatchCreateOutcome::Created { graph, .. } = create else {
2003 return Err(to_actix(PodError::Unsupported(
2004 "unexpected patch outcome on absent resource".into(),
2005 )));
2006 };
2007 let serialised = graph_to_turtle(&graph);
2008 if protected_resource_for_acl(&path).is_some()
2010 && !proposed_acl_keeps_caller_control(
2011 &state,
2012 &req,
2013 &path,
2014 serialised.as_bytes(),
2015 "application/n-triples",
2016 agent.as_deref(),
2017 )
2018 .await
2019 {
2020 return Ok(HttpResponse::Conflict().body(
2021 "refused: the patched ACL would not grant Control to the caller \
2022 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
2023 ));
2024 }
2025 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
2026 let write = state
2027 .storage
2028 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
2029 .await;
2030 finish_quota_reservation(&state, quota, write.is_ok()).await;
2031 let _ = write.map_err(to_actix)?;
2032 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
2033 let mut rsp = HttpResponse::Created().finish();
2034 set_provenance_headers(&mut rsp, &provenance);
2035 Ok(rsp)
2036 }
2037 Err(e) => Err(to_actix(e)),
2038 }
2039}
2040
2041fn patch_parse_err(e: PodError) -> ActixError {
2045 match e {
2046 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
2047 actix_web::error::ErrorBadRequest(msg)
2048 }
2049 other => to_actix(other),
2050 }
2051}
2052
2053fn graph_to_turtle(g: &ldp::Graph) -> String {
2057 g.to_ntriples()
2058}
2059
2060fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
2067 let mut best: Option<(f32, ldp::RdfFormat)> = None;
2068 for entry in accept.split(',') {
2069 let entry = entry.trim();
2070 if entry.is_empty() {
2071 continue;
2072 }
2073 let mut parts = entry.split(';').map(|s| s.trim());
2074 let mime = match parts.next() {
2075 Some(m) => m,
2076 None => continue,
2077 };
2078 let mut q: f32 = 1.0;
2079 for token in parts {
2080 if let Some(v) = token.strip_prefix("q=") {
2081 if let Ok(parsed) = v.parse::<f32>() {
2082 q = parsed;
2083 }
2084 }
2085 }
2086 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
2089 match best {
2090 None => best = Some((q, format)),
2091 Some((bq, _)) if q > bq => best = Some((q, format)),
2092 _ => {}
2093 }
2094 }
2095 }
2096 best.map(|(_, f)| f)
2097}
2098
2099fn rdf_content_negotiate(
2115 body: &[u8],
2116 stored_ct: &str,
2117 accept: &str,
2118) -> Option<(Vec<u8>, &'static str)> {
2119 if accept.trim().is_empty() {
2120 return None;
2121 }
2122 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
2123 let target = best_explicit_rdf_format(accept)?;
2124 if target == stored_format {
2125 return None;
2126 }
2127 let text = std::str::from_utf8(body).ok()?;
2128 let graph = ldp::Graph::parse_ntriples(text).ok()?;
2129 match target {
2130 ldp::RdfFormat::Turtle => Some((
2133 graph.to_ntriples().into_bytes(),
2134 ldp::RdfFormat::Turtle.mime(),
2135 )),
2136 ldp::RdfFormat::NTriples => Some((
2137 graph.to_ntriples().into_bytes(),
2138 ldp::RdfFormat::NTriples.mime(),
2139 )),
2140 ldp::RdfFormat::JsonLd => {
2141 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
2142 Some((json, ldp::RdfFormat::JsonLd.mime()))
2143 }
2144 ldp::RdfFormat::RdfXml => None,
2146 }
2147}
2148
2149fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
2158 let text = std::str::from_utf8(current_body).map_err(|_| {
2159 actix_web::error::ErrorConflict(
2160 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
2161 )
2162 })?;
2163 if text.trim().is_empty() {
2164 return Ok(ldp::Graph::new());
2165 }
2166 ldp::Graph::parse_ntriples(text).map_err(|_| {
2167 actix_web::error::ErrorConflict(
2168 "existing resource is not N-Triples RDF and cannot be non-destructively \
2169 patched; PUT an N-Triples representation or use a JSON Patch",
2170 )
2171 })
2172}
2173
2174pub(crate) async fn find_effective_acl_dyn(
2190 storage: &dyn Storage,
2191 resource_path: &str,
2192) -> Result<Option<wac::AclDocument>, PodError> {
2193 resolve_policy_dyn(storage, resource_path)
2194 .await
2195 .into_result()
2196}
2197
2198pub(crate) async fn resolve_policy_dyn(
2203 storage: &dyn Storage,
2204 resource_path: &str,
2205) -> wac::PolicyOutcome {
2206 wac::resolve_policy_from_storage(storage, resource_path).await
2207}
2208
2209fn policy_failure_to_actix(outcome: &wac::PolicyOutcome, resource: &str) -> ActixError {
2219 match outcome {
2220 wac::PolicyOutcome::Invalid {
2221 policy_path,
2222 reason,
2223 } => {
2224 tracing::warn!(
2225 target: "solid_pod_rs_server::wac",
2226 resource = %resource,
2227 policy = %policy_path,
2228 "denying: effective ACL is invalid and MUST NOT inherit: {reason}"
2229 );
2230 actix_web::error::ErrorForbidden("access forbidden: governing ACL is invalid")
2231 }
2232 wac::PolicyOutcome::Unavailable {
2233 policy_path,
2234 detail,
2235 } => {
2236 tracing::error!(
2237 target: "solid_pod_rs_server::wac",
2238 resource = %resource,
2239 policy = %policy_path,
2240 "denying: effective ACL could not be read: {detail}"
2241 );
2242 actix_web::error::ErrorServiceUnavailable(
2243 "access control unavailable: governing ACL could not be read",
2244 )
2245 }
2246 _ => actix_web::error::ErrorInternalServerError("policy resolution error"),
2248 }
2249}
2250
2251async fn handle_delete(
2252 req: HttpRequest,
2253 state: web::Data<AppState>,
2254) -> Result<HttpResponse, ActixError> {
2255 let path = req.uri().path().to_string();
2256 let auth_pk = extract_pubkey(&req).await;
2257 let agent = agent_uri(auth_pk.as_ref());
2258 enforce_write_ctx(
2259 &state,
2260 &path,
2261 AccessMode::Write,
2262 agent.as_deref(),
2263 req_origin(&req),
2264 )
2265 .await?;
2266
2267 let quota = reserve_quota_for_size(&state, &path, 0).await?;
2268 match state.storage.delete(&path).await {
2269 Ok(()) => {
2270 finish_quota_reservation(&state, quota, true).await;
2271 Ok(HttpResponse::NoContent().finish())
2272 }
2273 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
2274 Err(e) => Err(to_actix(e)),
2275 }
2276}
2277
2278async fn handle_options(
2279 req: HttpRequest,
2280 state: web::Data<AppState>,
2281) -> Result<HttpResponse, ActixError> {
2282 let path = req.uri().path().to_string();
2283 let o = ldp::options_for_conneg(&path, state.conneg_enabled);
2284 let mut rsp = HttpResponse::NoContent().finish();
2285 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
2286 rsp.headers_mut()
2287 .insert(header::HeaderName::from_static("allow"), v);
2288 }
2289 if let Some(ap) = o.accept_post {
2290 if let Ok(v) = header::HeaderValue::from_str(ap) {
2291 rsp.headers_mut()
2292 .insert(header::HeaderName::from_static("accept-post"), v);
2293 }
2294 }
2295 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
2296 rsp.headers_mut()
2297 .insert(header::HeaderName::from_static("accept-patch"), v);
2298 }
2299 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
2300 rsp.headers_mut()
2301 .insert(header::HeaderName::from_static("accept-ranges"), v);
2302 }
2303 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
2304 Ok(rsp)
2305}
2306
2307async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
2312 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
2313 HttpResponse::Ok()
2314 .content_type("application/ld+json")
2315 .json(doc)
2316}
2317
2318#[derive(Debug, Deserialize)]
2319struct WebFingerQuery {
2320 resource: Option<String>,
2321}
2322
2323async fn handle_well_known_webfinger(
2324 state: web::Data<AppState>,
2325 q: web::Query<WebFingerQuery>,
2326) -> HttpResponse {
2327 let resource = q.resource.clone().unwrap_or_else(|| {
2328 format!(
2329 "acct:anonymous@{}",
2330 state
2331 .nodeinfo
2332 .base_url
2333 .trim_start_matches("http://")
2334 .trim_start_matches("https://")
2335 )
2336 });
2337 let webid = format!(
2338 "{}/profile/card#me",
2339 state.nodeinfo.base_url.trim_end_matches('/')
2340 );
2341 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
2342 Some(jrd) => HttpResponse::Ok()
2343 .content_type("application/jrd+json")
2344 .json(jrd),
2345 None => HttpResponse::NotFound().finish(),
2346 }
2347}
2348
2349async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
2350 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
2351 HttpResponse::Ok()
2352 .content_type("application/json")
2353 .json(doc)
2354}
2355
2356async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
2357 let doc = interop::nodeinfo_2_1(
2358 &state.nodeinfo.software_name,
2359 &state.nodeinfo.software_version,
2360 state.nodeinfo.open_registrations,
2361 state.nodeinfo.total_users,
2362 );
2363 HttpResponse::Ok()
2364 .content_type("application/json")
2365 .json(doc)
2366}
2367
2368#[cfg(feature = "did-nostr")]
2369async fn handle_well_known_did_nostr(
2370 state: web::Data<AppState>,
2371 path: web::Path<String>,
2372) -> HttpResponse {
2373 let pubkey = path.into_inner();
2374 let pubkey_is_valid = pubkey.len() == 64
2379 && pubkey
2380 .bytes()
2381 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
2382 if !pubkey_is_valid {
2383 return HttpResponse::BadRequest()
2384 .insert_header(("Cache-Control", "no-store"))
2385 .json(serde_json::json!({
2386 "error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
2387 }));
2388 }
2389 let owner_pubkey = match state.storage.get("/profile/card").await {
2397 Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
2398 .ok()
2399 .flatten(),
2400 Err(_) => None,
2401 };
2402 let owner_claims_key = owner_pubkey
2403 .as_deref()
2404 .is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
2405 if !owner_claims_key {
2406 return HttpResponse::NotFound()
2407 .insert_header(("Cache-Control", "no-store"))
2408 .json(serde_json::json!({
2409 "error": "no account on this pod claims this did:nostr pubkey"
2410 }));
2411 }
2412 let also = vec![format!(
2413 "{}/profile/card#me",
2414 state.nodeinfo.base_url.trim_end_matches('/')
2415 )];
2416 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
2417 let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
2418 use std::hash::{Hash, Hasher};
2424 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2425 body.hash(&mut hasher);
2426 let etag = format!("\"{:016x}\"", hasher.finish());
2427 HttpResponse::Ok()
2428 .content_type("application/did+json")
2429 .insert_header(("Cache-Control", "max-age=3600"))
2430 .insert_header(("ETag", etag))
2431 .body(body)
2432}
2433
2434#[cfg(feature = "nip05-endpoint")]
2442#[derive(Debug, Deserialize)]
2443struct Nip05Query {
2444 name: Option<String>,
2447}
2448
2449#[cfg(feature = "nip05-endpoint")]
2450fn nip05_name_is_valid(name: &str) -> bool {
2451 if name.is_empty() {
2454 return false;
2455 }
2456 name.bytes()
2457 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
2458}
2459
2460#[cfg(feature = "nip05-endpoint")]
2461async fn handle_well_known_nip05(
2462 state: web::Data<AppState>,
2463 query: web::Query<Nip05Query>,
2464) -> HttpResponse {
2465 use solid_pod_rs::webid::extract_nostr_pubkey;
2466
2467 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
2469 if !nip05_name_is_valid(&name) {
2470 return HttpResponse::BadRequest().json(serde_json::json!({
2471 "error": "invalid NIP-05 local part",
2472 }));
2473 }
2474
2475 let profile_path = if name == "_" {
2481 "/profile/card".to_string()
2482 } else {
2483 format!("/{name}/profile/card")
2484 };
2485
2486 let (body, _meta) = match state.storage.get(&profile_path).await {
2487 Ok(v) => v,
2488 Err(_) => {
2489 return nip05_empty_response();
2493 }
2494 };
2495
2496 let pubkey_hex = match extract_nostr_pubkey(&body) {
2497 Ok(Some(p)) => p,
2498 _ => return nip05_empty_response(),
2499 };
2500
2501 let doc = interop::nip05_document([(name, pubkey_hex)]);
2502 HttpResponse::Ok()
2503 .insert_header(("Access-Control-Allow-Origin", "*"))
2504 .content_type("application/json")
2505 .json(doc)
2506}
2507
2508#[cfg(feature = "nip05-endpoint")]
2509fn nip05_empty_response() -> HttpResponse {
2510 HttpResponse::Ok()
2511 .insert_header(("Access-Control-Allow-Origin", "*"))
2512 .content_type("application/json")
2513 .json(serde_json::json!({ "names": {} }))
2514}
2515
2516#[cfg(feature = "export-jsonld")]
2530async fn handle_export_all(
2531 req: HttpRequest,
2532 state: web::Data<AppState>,
2533) -> Result<HttpResponse, ActixError> {
2534 let auth_pk = extract_pubkey(&req).await;
2535 let agent = agent_uri(auth_pk.as_ref());
2536
2537 enforce_write_ctx(
2542 &state,
2543 "/",
2544 AccessMode::Control,
2545 agent.as_deref(),
2546 req_origin(&req),
2547 )
2548 .await?;
2549
2550 let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
2554 .ok()
2555 .and_then(|q| q.get("include_private").map(|v| v == "true"))
2556 .unwrap_or(false);
2557
2558 let pod_base = format!("{}/", scheme::request_origin(&req));
2562
2563 let options = solid_pod_rs::ExportOptions { include_private };
2564 let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
2565 .await
2566 .map_err(to_actix)?;
2567
2568 let body = serde_json::to_vec(&bundle).map_err(|e| {
2569 actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
2570 })?;
2571 Ok(HttpResponse::Ok()
2572 .content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
2573 .body(body))
2574}
2575
2576#[derive(Debug, Deserialize)]
2581struct CreateAccountRequest {
2582 username: String,
2583 #[serde(default)]
2584 name: Option<String>,
2585}
2586
2587#[derive(Debug, Deserialize)]
2588struct CreatePodRequest {
2589 name: String,
2590}
2591
2592async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
2593 let pod_name = path.into_inner();
2594 let pod_root = format!("/{pod_name}/");
2595 match state.storage.exists(&pod_root).await {
2596 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
2597 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
2598 }
2599}
2600
2601fn valid_pod_name(name: &str) -> bool {
2602 !name.is_empty()
2603 && name
2604 .chars()
2605 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
2606}
2607
2608fn request_ip(req: &HttpRequest) -> IpAddr {
2609 req.peer_addr()
2610 .map(|addr| addr.ip())
2611 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
2612}
2613
2614#[derive(Debug)]
2615struct QuotaReservation {
2616 pod: String,
2617 delta: i64,
2618}
2619
2620fn pod_name_from_path(path: &str) -> Option<&str> {
2621 path.trim_start_matches('/')
2622 .split('/')
2623 .next()
2624 .filter(|p| !p.is_empty())
2625}
2626
2627async fn reserve_quota_for_size(
2628 state: &AppState,
2629 path: &str,
2630 new_size: u64,
2631) -> Result<Option<QuotaReservation>, ActixError> {
2632 let (Some(policy), Some(pod)) = (&state.quota, pod_name_from_path(path)) else {
2633 return Ok(None);
2634 };
2635 let old_size = state
2636 .storage
2637 .head(path)
2638 .await
2639 .map(|meta| meta.size)
2640 .unwrap_or(0);
2641 let delta = i128::from(new_size) - i128::from(old_size);
2642 let delta = delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64;
2643 if delta > 0 {
2644 policy
2645 .reserve(pod, delta as u64)
2646 .await
2647 .map_err(|error| actix_web::error::ErrorInsufficientStorage(error.to_string()))?;
2648 }
2649 Ok(Some(QuotaReservation {
2650 pod: pod.to_string(),
2651 delta,
2652 }))
2653}
2654
2655async fn finish_quota_reservation(
2656 state: &AppState,
2657 reservation: Option<QuotaReservation>,
2658 write_succeeded: bool,
2659) {
2660 let (Some(policy), Some(reservation)) = (&state.quota, reservation) else {
2661 return;
2662 };
2663 if write_succeeded {
2664 if reservation.delta < 0 {
2665 policy.record(&reservation.pod, reservation.delta).await;
2666 }
2667 } else if reservation.delta > 0 {
2668 policy.record(&reservation.pod, -reservation.delta).await;
2669 }
2670}
2671
2672async fn handle_create_account(
2673 req: HttpRequest,
2674 state: web::Data<AppState>,
2675 body: web::Json<CreateAccountRequest>,
2676) -> Result<HttpResponse, ActixError> {
2677 if let Some(response) = provisioning_gate(&req, &state, &body.username).await {
2678 return Ok(response);
2679 }
2680 let pod_root = format!("/{}/", body.username);
2681 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2682 return Ok(
2683 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
2684 );
2685 }
2686
2687 let base_uri = state.nodeinfo.base_url.trim_end_matches('/');
2688 let outcome =
2689 provision_named_pod(&state, &body.username, body.name.as_deref(), base_uri).await?;
2690 Ok(HttpResponse::Created().json(serde_json::json!({
2691 "webid": outcome.webid,
2692 "pod_root": outcome.pod_uri,
2693 "username": body.username,
2694 })))
2695}
2696
2697struct NamedPodOutcome {
2698 webid: String,
2699 pod_uri: String,
2700}
2701
2702async fn provisioning_gate(
2705 req: &HttpRequest,
2706 state: &AppState,
2707 name: &str,
2708) -> Option<HttpResponse> {
2709 if !valid_pod_name(name) {
2710 return Some(HttpResponse::BadRequest().json(serde_json::json!({
2711 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
2712 })));
2713 }
2714
2715 let supplied_key = req
2716 .headers()
2717 .get("x-pod-admin-key")
2718 .and_then(|value| value.to_str().ok())
2719 .unwrap_or("");
2720 use subtle::ConstantTimeEq;
2721 let admin_override = state
2722 .admin_key
2723 .as_deref()
2724 .is_some_and(|expected| bool::from(supplied_key.as_bytes().ct_eq(expected.as_bytes())));
2725 let invite = req
2726 .headers()
2727 .get("x-invite-token")
2728 .and_then(|v| v.to_str().ok())
2729 .map(str::trim)
2730 .filter(|t| !t.is_empty());
2731 let closed = !state.nodeinfo.open_registrations && !admin_override;
2732 if closed && invite.is_none() {
2733 return Some(HttpResponse::Forbidden().json(serde_json::json!({
2734 "error": "pod registration is closed"
2735 })));
2736 }
2737
2738 if let Err(retry_after) = state.pod_create_limiter.check(request_ip(req)) {
2739 return Some(
2740 HttpResponse::TooManyRequests()
2741 .insert_header(("Retry-After", retry_after.to_string()))
2742 .json(serde_json::json!({
2743 "error": "Too Many Requests",
2744 "message": "Pod creation rate limit exceeded",
2745 "retryAfter": retry_after
2746 })),
2747 );
2748 }
2749 if closed {
2754 let (Some(token), Some(root)) = (invite, state.data_root.as_deref()) else {
2755 return Some(HttpResponse::Forbidden().json(serde_json::json!({
2756 "error": "pod registration is closed"
2757 })));
2758 };
2759 let refusal =
2760 |why: &str| HttpResponse::Forbidden().json(serde_json::json!({ "error": why }));
2761 match cli::FileInviteStore::new(root).consume(token).await {
2762 Ok(cli::Consume::Granted) => {}
2763 Ok(cli::Consume::Expired) => return Some(refusal("invite has expired")),
2764 Ok(cli::Consume::Exhausted) => return Some(refusal("invite has been used")),
2765 Ok(cli::Consume::Unknown) => return Some(refusal("invalid invite code")),
2766 Err(e) => {
2767 tracing::error!("invite store: {e}");
2768 return Some(
2769 HttpResponse::InternalServerError()
2770 .json(serde_json::json!({ "error": "invite store unavailable" })),
2771 );
2772 }
2773 }
2774 }
2775 None
2776}
2777
2778async fn provision_named_pod(
2782 state: &AppState,
2783 name: &str,
2784 display_name: Option<&str>,
2785 base_uri: &str,
2786) -> Result<NamedPodOutcome, ActixError> {
2787 for container in ["", "profile", "inbox", "public", "private", "settings"] {
2788 let path = if container.is_empty() {
2789 format!("/{name}")
2790 } else {
2791 format!("/{name}/{container}")
2792 };
2793 state
2794 .storage
2795 .put(
2796 &format!("{path}.meta"),
2797 Bytes::from_static(b"{}"),
2798 "application/ld+json",
2799 )
2800 .await
2801 .map_err(to_actix)?;
2802 }
2803
2804 let base_uri = base_uri.trim_end_matches('/');
2805 let pod_uri = format!("{base_uri}/{name}/");
2806 let canonical_prefix = format!("{base_uri}/pods/{name}/");
2807 let webid = format!("{pod_uri}profile/card#me");
2808 let profile = solid_pod_rs::webid::generate_webid_html(name, display_name, base_uri)
2809 .replace(&canonical_prefix, &pod_uri);
2810 state
2811 .storage
2812 .put(
2813 &format!("/{name}/profile/card"),
2814 Bytes::from(profile.into_bytes()),
2815 "text/html",
2816 )
2817 .await
2818 .map_err(to_actix)?;
2819
2820 #[cfg(feature = "git")]
2821 if let Some(root) = &state.data_root {
2822 let hook = solid_pod_rs_git::init::GitAutoInit::new();
2823 if let Err(error) = hook.init_repo_at(&root.join(name)).await {
2824 tracing::warn!(pod = name, %error, "git auto-init failed after pod creation");
2825 }
2826 }
2827
2828 Ok(NamedPodOutcome { webid, pod_uri })
2829}
2830
2831async fn handle_create_pod(
2832 req: HttpRequest,
2833 state: web::Data<AppState>,
2834 body: web::Json<CreatePodRequest>,
2835) -> Result<HttpResponse, ActixError> {
2836 if let Some(response) = provisioning_gate(&req, &state, &body.name).await {
2837 return Ok(response);
2838 }
2839
2840 let pod_root = format!("/{}/", body.name);
2841 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2842 return Ok(
2843 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
2844 );
2845 }
2846
2847 let base_uri = scheme::request_origin(&req);
2848 let outcome = provision_named_pod(&state, &body.name, None, &base_uri).await?;
2849
2850 Ok(HttpResponse::Created()
2851 .insert_header(("Location", outcome.pod_uri.clone()))
2852 .json(serde_json::json!({
2853 "name": body.name,
2854 "webId": outcome.webid,
2855 "podUri": outcome.pod_uri,
2856 })))
2857}
2858
2859fn normalise_copy_source(raw: &str, host: &str) -> Option<String> {
2869 let path = if raw.starts_with("http://") || raw.starts_with("https://") {
2870 let url = url::Url::parse(raw).ok()?;
2871 let authority = match url.port() {
2872 Some(port) => format!("{}:{port}", url.host_str()?),
2873 None => url.host_str()?.to_string(),
2874 };
2875 if !authority.eq_ignore_ascii_case(host)
2876 || url.query().is_some()
2877 || url.fragment().is_some()
2878 {
2879 return None;
2880 }
2881 url.path().to_string()
2882 } else if raw.starts_with('/') && !raw.contains(['?', '#']) {
2883 raw.to_string()
2884 } else {
2885 return None;
2886 };
2887 canonical_pod_path(&path)
2888}
2889
2890async fn handle_copy(
2891 req: HttpRequest,
2892 state: web::Data<AppState>,
2893) -> Result<HttpResponse, ActixError> {
2894 let dest = req.uri().path().to_string();
2895 let auth_pk = extract_pubkey(&req).await;
2896 let agent = agent_uri(auth_pk.as_ref());
2897 enforce_write_ctx(
2898 &state,
2899 &dest,
2900 AccessMode::Write,
2901 agent.as_deref(),
2902 req_origin(&req),
2903 )
2904 .await?;
2905
2906 let source = req
2907 .headers()
2908 .get("source")
2909 .and_then(|v| v.to_str().ok())
2910 .map(|s| s.to_string());
2911 let source = match source {
2912 Some(s) => s,
2913 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
2914 };
2915 let host = scheme::request_host(&req);
2916 let source = match normalise_copy_source(&source, &host) {
2917 Some(s) => s,
2918 None => return Ok(HttpResponse::BadRequest().body("invalid Source")),
2919 };
2920 if !state.dotfiles.is_allowed(Path::new(&source)) {
2921 return Ok(HttpResponse::Forbidden().body("dotfile path denied by allowlist"));
2922 }
2923 enforce_read_ctx(&state, &source, agent.as_deref(), req_origin(&req)).await?;
2926
2927 let (body, meta) = match state.storage.get(&source).await {
2928 Ok(v) => v,
2929 Err(PodError::NotFound(_)) => {
2930 return Ok(HttpResponse::NotFound().body("source resource not found"))
2931 }
2932 Err(e) => return Err(to_actix(e)),
2933 };
2934
2935 if protected_resource_for_acl(&dest).is_some()
2937 && !proposed_acl_keeps_caller_control(
2938 &state,
2939 &req,
2940 &dest,
2941 &body,
2942 &meta.content_type,
2943 agent.as_deref(),
2944 )
2945 .await
2946 {
2947 return Ok(HttpResponse::Conflict().body(
2948 "refused: the proposed ACL would not grant Control to the caller \
2949 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
2950 ));
2951 }
2952
2953 let quota = reserve_quota_for_size(&state, &dest, body.len() as u64).await?;
2954 let write = state.storage.put(&dest, body, &meta.content_type).await;
2955 finish_quota_reservation(&state, quota, write.is_ok()).await;
2956 write.map_err(to_actix)?;
2957
2958 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
2963 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
2964 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
2965 let origin = req_origin(&req);
2966 let permitted = enforce_read_ctx(&state, &src_acl, agent.as_deref(), origin)
2967 .await
2968 .is_ok()
2969 && enforce_write_ctx(
2970 &state,
2971 &dst_acl,
2972 AccessMode::Write,
2973 agent.as_deref(),
2974 origin,
2975 )
2976 .await
2977 .is_ok()
2978 && proposed_acl_keeps_caller_control(
2979 &state,
2980 &req,
2981 &dst_acl,
2982 &acl_body,
2983 &acl_meta.content_type,
2984 agent.as_deref(),
2985 )
2986 .await;
2987 if permitted {
2988 let quota = reserve_quota_for_size(&state, &dst_acl, acl_body.len() as u64).await?;
2989 let write = state
2990 .storage
2991 .put(&dst_acl, acl_body, &acl_meta.content_type)
2992 .await;
2993 finish_quota_reservation(&state, quota, write.is_ok()).await;
2994 write.map_err(to_actix)?;
2995 }
2996 }
2997
2998 let mut rsp = HttpResponse::Created().finish();
2999 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
3000 rsp.headers_mut().insert(header::LOCATION, loc);
3001 }
3002 Ok(rsp)
3003}
3004
3005async fn handle_glob_get(
3010 req: HttpRequest,
3011 state: web::Data<AppState>,
3012) -> Result<HttpResponse, ActixError> {
3013 let raw_path = req.uri().path().to_string();
3014 if !raw_path.ends_with("/*") {
3016 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
3017 }
3018 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
3020 folder.to_string()
3021 } else {
3022 format!("{folder}/")
3023 };
3024
3025 let auth_pk = extract_pubkey(&req).await;
3029 let agent = agent_uri(auth_pk.as_ref());
3030 let origin = req_origin(&req);
3031 let audience = enforce_read_ctx(&state, &folder, agent.as_deref(), origin).await?;
3032
3033 let children = state.storage.list(&folder).await.map_err(to_actix)?;
3034 let mut merged = String::new();
3035 let mut audience = audience;
3036
3037 for child in &children {
3038 if child.ends_with('/')
3039 || child.ends_with(".acl")
3040 || child.ends_with(".meta")
3041 || child.ends_with(".prov.ttl")
3042 {
3043 continue;
3044 }
3045 let child_path = format!("{folder}{child}");
3046 match enforce_read_inner(&state, &child_path, agent.as_deref(), origin, false).await {
3049 Ok(ResponseAudience::Private) => audience = ResponseAudience::Private,
3050 Ok(ResponseAudience::Public) => {}
3051 Err(_) => continue,
3052 }
3053 if let Ok((body, meta)) = state.storage.get(&child_path).await {
3054 if meta.content_type.contains("turtle")
3055 || meta.content_type.contains("n-triples")
3056 || meta.content_type.contains("n3")
3057 {
3058 if let Ok(text) = std::str::from_utf8(&body) {
3059 merged.push_str(text);
3060 merged.push('\n');
3061 }
3062 }
3063 }
3064 }
3065
3066 if merged.is_empty() {
3067 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
3068 }
3069
3070 let mut rsp = HttpResponse::Ok().content_type("text/turtle").body(merged);
3071 set_cache_policy(&mut rsp, "text/turtle", audience);
3072 Ok(rsp)
3073}
3074
3075#[derive(Debug, Deserialize)]
3080struct LoginPasswordRequest {
3081 username: String,
3082 password: String,
3083}
3084
3085async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
3092 let _ = (&body.username, &body.password);
3093 HttpResponse::NotImplemented().json(serde_json::json!({
3094 "error": "password login is not implemented on this pod"
3095 }))
3096}
3097
3098#[derive(Debug, Deserialize)]
3099struct PasswordResetRequest {
3100 username: String,
3101}
3102
3103async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
3107 let _ = &body.username;
3108 HttpResponse::NotImplemented().json(serde_json::json!({
3109 "error": "password reset is not implemented on this pod"
3110 }))
3111}
3112
3113#[derive(Debug, Deserialize)]
3114struct PasswordChangeRequest {
3115 token: String,
3116 new_password: String,
3117}
3118
3119async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
3124 let _ = (&body.token, &body.new_password);
3125 HttpResponse::NotImplemented().json(serde_json::json!({
3126 "error": "password change is not implemented on this pod"
3127 }))
3128}
3129
3130async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
3135 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
3136 HttpResponse::Ok()
3137 .content_type("application/json")
3138 .json(body)
3139}
3140
3141pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
3156
3157#[derive(Debug, Deserialize)]
3159struct ProxyQuery {
3160 url: String,
3161}
3162
3163const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
3165 "set-cookie",
3166 "set-cookie2",
3167 "authorization",
3168 "www-authenticate",
3169 "proxy-authenticate",
3170 "proxy-authorization",
3171];
3172
3173async fn validate_proxy_target(target: &str) -> Result<(url::Url, IpAddr), HttpResponse> {
3189 let parsed = match url::Url::parse(target) {
3190 Ok(u) => u,
3191 Err(_) => {
3192 return Err(
3193 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
3194 );
3195 }
3196 };
3197
3198 match parsed.scheme() {
3200 "http" | "https" => {}
3201 scheme => {
3202 return Err(HttpResponse::BadRequest()
3203 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
3204 }
3205 }
3206
3207 if solid_pod_rs::security::is_safe_url(target).is_err() {
3210 return Err(HttpResponse::Forbidden()
3211 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
3212 }
3213
3214 let host = match parsed.host_str() {
3216 Some(h) => h.to_string(),
3217 None => {
3218 return Err(HttpResponse::BadRequest()
3219 .json(serde_json::json!({"error": "target URL has no host"})))
3220 }
3221 };
3222 let host_lower = host.to_ascii_lowercase();
3223 if host_lower == "localhost"
3224 || host_lower.ends_with(".localhost")
3225 || host_lower == "0.0.0.0"
3226 || host_lower == "[::1]"
3227 || host_lower == "[::0]"
3228 {
3229 return Err(HttpResponse::Forbidden()
3230 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
3231 }
3232
3233 match solid_pod_rs::security::resolve_and_check(&host).await {
3236 Ok(ip) => Ok((parsed, ip)),
3237 Err(_) => Err(HttpResponse::Forbidden()
3238 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"}))),
3239 }
3240}
3241
3242fn build_pinned_proxy_client(url: &url::Url, ip: IpAddr) -> Result<reqwest::Client, ActixError> {
3245 let mut builder = reqwest::Client::builder()
3246 .redirect(reqwest::redirect::Policy::none());
3249 if let Some(host) = url.host_str() {
3250 let port = url.port_or_known_default().unwrap_or(0);
3252 builder = builder.resolve(host, std::net::SocketAddr::new(ip, port));
3253 }
3254 builder
3255 .build()
3256 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))
3257}
3258
3259async fn handle_proxy(
3260 req: HttpRequest,
3261 _state: web::Data<AppState>,
3262 query: web::Query<ProxyQuery>,
3263) -> Result<HttpResponse, ActixError> {
3264 let auth_pk = extract_pubkey(&req).await;
3266 let agent = agent_uri(auth_pk.as_ref());
3267 if agent.is_none() {
3268 return Ok(HttpResponse::Unauthorized()
3269 .json(serde_json::json!({"error": "authentication required"})));
3270 }
3271
3272 let mut current_url = query.url.clone();
3273 let mut redirect_count = 0u8;
3274 const MAX_REDIRECTS: u8 = 5;
3275 const TOTAL_TIMEOUT: Duration = Duration::from_secs(30);
3276 let started = Instant::now();
3277
3278 let byte_cap = std::env::var("PROXY_BYTE_CAP")
3279 .ok()
3280 .and_then(|v| {
3281 solid_pod_rs::config::sources::parse_size(&v)
3282 .map(|u| u as usize)
3283 .ok()
3284 })
3285 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
3286
3287 loop {
3288 let Some(remaining) = TOTAL_TIMEOUT.checked_sub(started.elapsed()) else {
3289 return Ok(HttpResponse::GatewayTimeout()
3290 .json(serde_json::json!({"error": "proxy operation timed out"})));
3291 };
3292 let (target_url, pinned_ip) = match validate_proxy_target(¤t_url).await {
3296 Ok(pair) => pair,
3297 Err(rsp) => return Ok(rsp),
3298 };
3299 let client = build_pinned_proxy_client(&target_url, pinned_ip)?;
3300
3301 let mut upstream_req = client.get(¤t_url).timeout(remaining);
3302
3303 if let Some(auth_val) = req
3305 .headers()
3306 .get("x-upstream-authorization")
3307 .and_then(|v| v.to_str().ok())
3308 {
3309 upstream_req = upstream_req.header("Authorization", auth_val);
3310 }
3311
3312 let response = upstream_req
3313 .send()
3314 .await
3315 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
3316
3317 if response.status().is_redirection() {
3319 if redirect_count >= MAX_REDIRECTS {
3320 return Ok(HttpResponse::BadGateway()
3321 .json(serde_json::json!({"error": "too many redirects"})));
3322 }
3323 if let Some(location) = response.headers().get("location") {
3324 let loc_str = location
3325 .to_str()
3326 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
3327 let base = url::Url::parse(¤t_url)
3329 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
3330 let resolved = base
3331 .join(loc_str)
3332 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
3333 current_url = resolved.to_string();
3334 redirect_count += 1;
3335 continue;
3336 }
3337 return Ok(HttpResponse::BadGateway()
3338 .json(serde_json::json!({"error": "redirect without location"})));
3339 }
3340
3341 let upstream_status = response.status().as_u16();
3343 let upstream_content_type = response
3344 .headers()
3345 .get("content-type")
3346 .and_then(|v| v.to_str().ok())
3347 .unwrap_or("application/octet-stream")
3348 .to_string();
3349 if response
3350 .content_length()
3351 .is_some_and(|length| length > byte_cap as u64)
3352 {
3353 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3354 "error": "proxied response exceeds byte cap",
3355 "limit": byte_cap
3356 })));
3357 }
3358
3359 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
3361 for (name, value) in response.headers() {
3362 let name_lower = name.as_str().to_ascii_lowercase();
3363 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
3364 continue;
3365 }
3366 if matches!(
3368 name_lower.as_str(),
3369 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
3370 ) {
3371 continue;
3372 }
3373 if let Ok(val_str) = value.to_str() {
3374 forwarded_headers.push((name_lower, val_str.to_string()));
3375 }
3376 }
3377
3378 let mut stream = response.bytes_stream();
3379 let mut body_bytes = Vec::with_capacity(byte_cap.min(64 * 1024));
3380 while let Some(chunk) = stream.next().await {
3381 let chunk =
3382 chunk.map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
3383 if body_bytes.len().saturating_add(chunk.len()) > byte_cap {
3384 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3385 "error": "proxied response exceeds byte cap",
3386 "limit": byte_cap
3387 })));
3388 }
3389 body_bytes.extend_from_slice(&chunk);
3390 }
3391
3392 let mut rsp = HttpResponse::build(
3394 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3395 );
3396 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
3397 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
3398
3399 for (name, value) in &forwarded_headers {
3401 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
3402 if let Ok(hval) = header::HeaderValue::from_str(value) {
3403 rsp.insert_header((hname, hval));
3404 }
3405 }
3406 }
3407
3408 return Ok(rsp.body(body_bytes));
3409 }
3410}
3411
3412pub struct PathTraversalGuard;
3418
3419impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
3420where
3421 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3422 B: 'static,
3423{
3424 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3425 type Error = ActixError;
3426 type InitError = ();
3427 type Transform = PathTraversalGuardMiddleware<S>;
3428 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3429
3430 fn new_transform(&self, service: S) -> Self::Future {
3431 ready(Ok(PathTraversalGuardMiddleware { service }))
3432 }
3433}
3434
3435pub struct PathTraversalGuardMiddleware<S> {
3437 service: S,
3438}
3439
3440impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
3441where
3442 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3443 B: 'static,
3444{
3445 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3446 type Error = ActixError;
3447 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3448
3449 actix_web::dev::forward_ready!(service);
3450
3451 fn call(&self, req: ServiceRequest) -> Self::Future {
3452 let raw = req.path().to_string();
3455 if path_is_traversal(&raw) {
3456 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
3457 let sr = req.into_response(rsp.map_into_boxed_body());
3458 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3459 }
3460 let fut = self.service.call(req);
3461 Box::pin(async move {
3462 let resp = fut.await?;
3463 Ok(resp.map_into_left_body())
3464 })
3465 }
3466}
3467
3468fn path_is_traversal(path: &str) -> bool {
3469 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
3471 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
3472 for seg in once.split('/').chain(twice.split('/')) {
3473 if seg == ".." || seg == "." {
3474 return true;
3475 }
3476 }
3477 twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..")
3480}
3481
3482pub struct CorsHeaders {
3493 pub allowed_origins: Arc<Vec<String>>,
3494}
3495
3496impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
3497where
3498 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3499 B: 'static,
3500{
3501 type Response = ServiceResponse<B>;
3502 type Error = ActixError;
3503 type InitError = ();
3504 type Transform = CorsHeadersMiddleware<S>;
3505 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3506
3507 fn new_transform(&self, service: S) -> Self::Future {
3508 ready(Ok(CorsHeadersMiddleware {
3509 service,
3510 allowed_origins: self.allowed_origins.clone(),
3511 }))
3512 }
3513}
3514
3515pub struct CorsHeadersMiddleware<S> {
3517 service: S,
3518 allowed_origins: Arc<Vec<String>>,
3519}
3520
3521impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
3522where
3523 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3524 B: 'static,
3525{
3526 type Response = ServiceResponse<B>;
3527 type Error = ActixError;
3528 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3529
3530 actix_web::dev::forward_ready!(service);
3531
3532 fn call(&self, req: ServiceRequest) -> Self::Future {
3533 let origin = req
3534 .headers()
3535 .get(header::ORIGIN)
3536 .and_then(|v| v.to_str().ok())
3537 .map(str::to_string);
3538 let allowed = self.allowed_origins.clone();
3539 let fut = self.service.call(req);
3540 Box::pin(async move {
3541 let mut resp = fut.await?;
3542 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
3543 Ok(resp)
3544 })
3545 }
3546}
3547
3548pub(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";
3556
3557fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
3558 if headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
3568 return;
3569 }
3570 let (origin_value, allow_credentials): (String, bool) = if allowed.is_empty() {
3581 ("*".to_string(), false)
3582 } else {
3583 match origin.filter(|o| allowed.iter().any(|a| a == *o)) {
3584 Some(o) => (o.to_string(), true),
3585 None => return,
3588 }
3589 };
3590
3591 let mut pairs = vec![
3592 ("access-control-allow-origin", origin_value.as_str()),
3593 (
3594 "access-control-allow-methods",
3595 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
3596 ),
3597 (
3598 "access-control-allow-headers",
3599 "Accept, Authorization, Content-Type, DPoP, Git-Protocol, If-Match, If-None-Match, Link, Range, Slug, Origin",
3600 ),
3601 (
3602 "access-control-expose-headers",
3603 CORS_EXPOSE_HEADERS,
3604 ),
3605 ("access-control-max-age", "86400"),
3606 ];
3607 if allow_credentials {
3610 pairs.push(("access-control-allow-credentials", "true"));
3611 }
3612
3613 for (name, value) in pairs {
3614 if let (Ok(name), Ok(value)) = (
3615 header::HeaderName::from_lowercase(name.as_bytes()),
3616 header::HeaderValue::from_str(value),
3617 ) {
3618 headers.insert(name, value);
3619 }
3620 }
3621}
3622
3623pub struct ErrorLoggingMiddleware;
3639
3640impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
3641where
3642 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3643 B: 'static,
3644{
3645 type Response = ServiceResponse<B>;
3646 type Error = ActixError;
3647 type InitError = ();
3648 type Transform = ErrorLoggingMiddlewareService<S>;
3649 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3650
3651 fn new_transform(&self, service: S) -> Self::Future {
3652 ready(Ok(ErrorLoggingMiddlewareService { service }))
3653 }
3654}
3655
3656pub struct ErrorLoggingMiddlewareService<S> {
3658 service: S,
3659}
3660
3661impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
3662where
3663 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3664 B: 'static,
3665{
3666 type Response = ServiceResponse<B>;
3667 type Error = ActixError;
3668 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3669
3670 actix_web::dev::forward_ready!(service);
3671
3672 fn call(&self, req: ServiceRequest) -> Self::Future {
3673 let method = req.method().as_str().to_string();
3676 let path = req.path().to_string();
3677
3678 let fut = self.service.call(req);
3679 Box::pin(async move {
3680 let response = fut.await?;
3681 let status = response.status();
3682 if status.is_server_error() {
3683 log_5xx(&method, &path, status, response.response().error());
3684 }
3685 Ok(response)
3686 })
3687 }
3688}
3689
3690fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
3694 let chain = match error {
3698 Some(e) => format_error_chain(e),
3699 None => "<no error attached to response>".to_string(),
3700 };
3701
3702 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
3703 Some(std::backtrace::Backtrace::force_capture().to_string())
3704 } else {
3705 None
3706 };
3707
3708 tracing::error!(
3709 target: "solid_pod_rs_server::http",
3710 method = %method,
3711 path = %path,
3712 status = %status.as_u16(),
3713 error.chain = %chain,
3714 backtrace = backtrace.as_deref().unwrap_or(""),
3715 "5xx response"
3716 );
3717}
3718
3719fn format_error_chain(e: &actix_web::Error) -> String {
3730 let summary = format!("{}", e.as_response_error());
3731 let debug = format!("{e:?}");
3732 if debug == summary || debug.is_empty() {
3733 summary
3734 } else {
3735 format!("{summary} -> {debug}")
3736 }
3737}
3738
3739pub struct DotfileGuard {
3745 allow: Arc<DotfileAllowlist>,
3746 hosts: Option<Arc<HostPolicy>>,
3747}
3748
3749pub struct HostPolicy {
3756 pub allowed: Vec<String>,
3758 pub trust_proxy: bool,
3760}
3761
3762impl DotfileGuard {
3763 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
3764 Self { allow, hosts: None }
3765 }
3766
3767 pub fn with_host_policy(mut self, policy: HostPolicy) -> Self {
3769 if !policy.allowed.is_empty() {
3770 self.hosts = Some(Arc::new(policy));
3771 }
3772 self
3773 }
3774}
3775
3776impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
3777where
3778 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3779 B: 'static,
3780{
3781 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3782 type Error = ActixError;
3783 type InitError = ();
3784 type Transform = DotfileGuardMiddleware<S>;
3785 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3786
3787 fn new_transform(&self, service: S) -> Self::Future {
3788 ready(Ok(DotfileGuardMiddleware {
3789 service,
3790 allow: self.allow.clone(),
3791 hosts: self.hosts.clone(),
3792 }))
3793 }
3794}
3795
3796pub struct DotfileGuardMiddleware<S> {
3798 service: S,
3799 allow: Arc<DotfileAllowlist>,
3800 hosts: Option<Arc<HostPolicy>>,
3801}
3802
3803impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
3804where
3805 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3806 B: 'static,
3807{
3808 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3809 type Error = ActixError;
3810 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3811
3812 actix_web::dev::forward_ready!(service);
3813
3814 fn call(&self, req: ServiceRequest) -> Self::Future {
3815 if let Some(p) = &self.hosts {
3816 let host = scheme::host_with(req.request(), None, p.trust_proxy);
3817 if !scheme::host_allowed(&host, &p.allowed) {
3818 let rsp = HttpResponse::build(actix_web::http::StatusCode::MISDIRECTED_REQUEST)
3819 .body("host not served here");
3820 let sr = req.into_response(rsp.map_into_boxed_body());
3821 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3822 }
3823 }
3824 let path = req.path().to_string();
3825 let allow_system_route =
3832 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
3833 if !allow_system_route {
3834 let pb = PathBuf::from(&path);
3835 if !self.allow.is_allowed(Path::new(&pb)) {
3836 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
3837 let sr = req.into_response(rsp.map_into_boxed_body());
3838 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3839 }
3840 }
3841 let fut = self.service.call(req);
3842 Box::pin(async move {
3843 let resp = fut.await?;
3844 Ok(resp.map_into_left_body())
3845 })
3846 }
3847}
3848
3849#[cfg(feature = "git")]
3854pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
3855 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
3856 return None;
3857 }
3858 state.data_root.as_ref().map(|root| root.join(pubkey))
3859}
3860
3861#[cfg(feature = "git")]
3891async fn git_mark_write(
3892 state: &AppState,
3893 resource_path: &str,
3894 agent: Option<&str>,
3895 message: &str,
3896) -> ProvenanceReceipt {
3897 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
3898 use solid_pod_rs_git::mark::ShellGitMarker;
3899
3900 if resource_path.ends_with(".acl")
3903 || resource_path.ends_with(".meta")
3904 || resource_path.ends_with(".prov.ttl")
3905 {
3906 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::ExcludedPath);
3907 }
3908 if resource_path.ends_with('/') {
3910 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::Container);
3911 }
3912
3913 let Some(data_root) = state.data_root.as_ref() else {
3915 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured);
3916 };
3917
3918 let trimmed = resource_path.trim_start_matches('/');
3920 let mut segments = trimmed.splitn(2, '/');
3921 let pod = segments.next().unwrap_or("");
3922 let rel = segments.next().unwrap_or("");
3923 if pod.is_empty() || rel.is_empty() {
3924 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::UnresolvablePath);
3925 }
3926 let repo = data_root.join(pod);
3927
3928 if !repo.join(".git").is_dir() {
3932 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotGitBacked);
3933 }
3934
3935 let agent_did = agent.unwrap_or("urn:solid:anonymous");
3936 let created = std::time::SystemTime::now()
3937 .duration_since(std::time::UNIX_EPOCH)
3938 .map(|d| d.as_secs())
3939 .unwrap_or(0);
3940
3941 let (policy, ticker_override) =
3944 handlers::prov::resolve_anchor_policy(state, resource_path).await;
3945
3946 let marker = std::sync::Arc::new(ShellGitMarker::new());
3951 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
3952 None
3953 } else {
3954 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
3955 };
3956 let (log, ticker, network) = match &anchorer_bundle {
3957 Some((anchorer, ticker, network)) => (
3958 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
3959 ticker.clone(),
3960 network.clone(),
3961 ),
3962 None => (
3964 ProvenanceLog::new(marker.clone()),
3965 String::new(),
3966 String::new(),
3967 ),
3968 };
3969
3970 let record_policy = match policy {
3974 AnchorPolicy::Epoch => AnchorPolicy::Never,
3975 other => other,
3976 };
3977 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
3978
3979 let write_record = solid_pod_rs::provenance::WriteRecord {
3983 repo: &repo,
3984 path: rel,
3985 agent_did,
3986 message,
3987 policy: record_policy,
3988 high_value,
3989 ticker: &ticker,
3990 network: &network,
3991 created,
3992 };
3993 let mut receipt = log.record_receipt(write_record).await;
3998 receipt.resource = resource_path.to_string();
4001
4002 let Some(git) = receipt.mark.clone() else {
4003 tracing::warn!(
4007 target: "solid_pod_rs_server::git_mark",
4008 resource = %resource_path,
4009 "provenance record failed (write already succeeded): {}",
4010 receipt.mark_error.as_deref().unwrap_or("unspecified")
4011 );
4012 return receipt;
4013 };
4014 if let Some(err) = &receipt.anchor_error {
4015 tracing::warn!(
4018 target: "solid_pod_rs_server::git_mark",
4019 resource = %resource_path,
4020 commit = %git.commit_sha,
4021 "block-trail anchor failed (git-mark stands): {err}"
4022 );
4023 }
4024 let mark = solid_pod_rs::provenance::ProvenanceMark {
4025 resource: resource_path.to_string(),
4026 git: git.clone(),
4027 anchor: receipt.anchor.clone(),
4028 agent_did: agent_did.to_string(),
4029 created,
4030 };
4031
4032 if matches!(policy, AnchorPolicy::Epoch) {
4036 if let Some((anchorer, _, _)) = &anchorer_bundle {
4037 match handlers::prov::epoch_push_and_maybe_anchor(
4038 state,
4039 anchorer,
4040 &ticker,
4041 &network,
4042 &mark.git.commit_sha,
4043 )
4044 .await
4045 {
4046 Ok(Some(closed)) => tracing::debug!(
4047 target: "solid_pod_rs_server::git_mark",
4048 root = %closed.root,
4049 n = closed.commits.len(),
4050 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
4051 ),
4052 Ok(None) => {}
4053 Err(e) => tracing::warn!(
4054 target: "solid_pod_rs_server::git_mark",
4055 "epoch batch/anchor failed (swallowed): {e}"
4056 ),
4057 }
4058 }
4059 }
4060
4061 let ttl = prov_ttl(&mark);
4066 let sidecar = format!("{resource_path}.prov.ttl");
4067 if let Err(e) = state
4068 .storage
4069 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
4070 .await
4071 {
4072 tracing::warn!(
4073 target: "solid_pod_rs_server::git_mark",
4074 sidecar = %sidecar,
4075 "provenance sidecar write failed: {e}"
4076 );
4077 receipt.mark_error = Some(format!("PROV-O sidecar write failed: {e}"));
4080 return receipt;
4081 }
4082
4083 tracing::debug!(
4084 target: "solid_pod_rs_server::git_mark",
4085 resource = %resource_path,
4086 commit = %mark.git.commit_sha,
4087 stage = %receipt.stage(),
4088 "provenance recorded"
4089 );
4090 receipt
4091}
4092
4093#[cfg(not(feature = "git"))]
4096#[inline]
4097async fn git_mark_write(
4098 _state: &AppState,
4099 resource_path: &str,
4100 _agent: Option<&str>,
4101 _message: &str,
4102) -> ProvenanceReceipt {
4103 ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured)
4104}
4105
4106#[cfg(feature = "git")]
4107pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
4108 require_pod_owner_with_body(req, pod_pubkey, None).await
4109}
4110
4111#[cfg(feature = "git")]
4112pub(crate) async fn require_pod_owner_with_body(
4113 req: &HttpRequest,
4114 pod_pubkey: &str,
4115 body: Option<&[u8]>,
4116) -> Option<String> {
4117 let caller = extract_pubkey_with_body(req, body).await?;
4118 if caller != pod_pubkey {
4119 return None;
4120 }
4121 Some(caller)
4122}
4123
4124#[cfg(feature = "git")]
4125fn git_json_err(msg: &str, status: u16) -> HttpResponse {
4126 HttpResponse::build(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
4127 .content_type("application/json")
4128 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
4129}
4130
4131#[cfg(feature = "git")]
4133#[derive(serde::Deserialize)]
4134struct GitStageBody {
4135 paths: Option<Vec<String>>,
4136 all: Option<bool>,
4137}
4138
4139#[cfg(feature = "git")]
4140#[derive(serde::Deserialize)]
4141struct GitCommitBody {
4142 message: String,
4143 author_name: Option<String>,
4144 author_email: Option<String>,
4145}
4146
4147#[cfg(feature = "git")]
4148#[derive(serde::Deserialize)]
4149struct GitBranchBody {
4150 name: String,
4151}
4152
4153#[cfg(feature = "git")]
4156async fn handle_git_status(
4157 path: web::Path<String>,
4158 req: HttpRequest,
4159 state: web::Data<AppState>,
4160) -> HttpResponse {
4161 let pubkey = path.into_inner();
4162 if require_pod_owner(&req, &pubkey).await.is_none() {
4163 return git_json_err("Authentication required", 401);
4164 }
4165 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4166 return git_json_err("Git not available (no FS backend)", 501);
4167 };
4168 match solid_pod_rs_git::api::git_status(&repo).await {
4169 Ok(s) => HttpResponse::Ok()
4170 .content_type("application/json")
4171 .body(serde_json::to_string(&s).unwrap_or_default()),
4172 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4173 }
4174}
4175
4176#[cfg(feature = "git")]
4177async fn handle_git_log(
4178 path: web::Path<String>,
4179 req: HttpRequest,
4180 state: web::Data<AppState>,
4181 query: web::Query<std::collections::HashMap<String, String>>,
4182) -> HttpResponse {
4183 let pubkey = path.into_inner();
4184 if require_pod_owner(&req, &pubkey).await.is_none() {
4185 return git_json_err("Authentication required", 401);
4186 }
4187 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4188 return git_json_err("Git not available (no FS backend)", 501);
4189 };
4190 let limit: u32 = query
4191 .get("limit")
4192 .and_then(|v| v.parse().ok())
4193 .unwrap_or(20);
4194 match solid_pod_rs_git::api::git_log(&repo, limit).await {
4195 Ok(entries) => HttpResponse::Ok()
4196 .content_type("application/json")
4197 .body(serde_json::to_string(&entries).unwrap_or_default()),
4198 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4199 }
4200}
4201
4202#[cfg(feature = "git")]
4203async fn handle_git_diff(
4204 path: web::Path<String>,
4205 req: HttpRequest,
4206 state: web::Data<AppState>,
4207 query: web::Query<std::collections::HashMap<String, String>>,
4208) -> HttpResponse {
4209 let pubkey = path.into_inner();
4210 if require_pod_owner(&req, &pubkey).await.is_none() {
4211 return git_json_err("Authentication required", 401);
4212 }
4213 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4214 return git_json_err("Git not available (no FS backend)", 501);
4215 };
4216 let file_path = query.get("path").map(String::as_str);
4217 let staged = query
4218 .get("staged")
4219 .map(|v| v == "true" || v == "1")
4220 .unwrap_or(false);
4221 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
4222 Ok(diff) => HttpResponse::Ok().content_type("text/plain").body(diff),
4223 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4224 }
4225}
4226
4227#[cfg(feature = "git")]
4228async fn handle_git_stage(
4229 path: web::Path<String>,
4230 req: HttpRequest,
4231 state: web::Data<AppState>,
4232 body: web::Bytes,
4233) -> HttpResponse {
4234 let pubkey = path.into_inner();
4235 if require_pod_owner(&req, &pubkey).await.is_none() {
4236 return git_json_err("Authentication required", 401);
4237 }
4238 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4239 return git_json_err("Git not available (no FS backend)", 501);
4240 };
4241 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4242 Ok(v) => v,
4243 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4244 };
4245 let paths = parsed.paths.unwrap_or_default();
4246 let all = parsed.all.unwrap_or(false);
4247 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
4248 Ok(()) => HttpResponse::Ok()
4249 .content_type("application/json")
4250 .body(r#"{"ok":true}"#),
4251 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4252 }
4253}
4254
4255#[cfg(feature = "git")]
4256async fn handle_git_unstage(
4257 path: web::Path<String>,
4258 req: HttpRequest,
4259 state: web::Data<AppState>,
4260 body: web::Bytes,
4261) -> HttpResponse {
4262 let pubkey = path.into_inner();
4263 if require_pod_owner(&req, &pubkey).await.is_none() {
4264 return git_json_err("Authentication required", 401);
4265 }
4266 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4267 return git_json_err("Git not available (no FS backend)", 501);
4268 };
4269 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4270 Ok(v) => v,
4271 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4272 };
4273 let paths = parsed.paths.unwrap_or_default();
4274 let all = parsed.all.unwrap_or(false);
4275 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
4276 Ok(()) => HttpResponse::Ok()
4277 .content_type("application/json")
4278 .body(r#"{"ok":true}"#),
4279 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4280 }
4281}
4282
4283#[cfg(feature = "git")]
4284async fn handle_git_commit(
4285 path: web::Path<String>,
4286 req: HttpRequest,
4287 state: web::Data<AppState>,
4288 body: web::Bytes,
4289) -> HttpResponse {
4290 let pubkey = path.into_inner();
4291 if require_pod_owner(&req, &pubkey).await.is_none() {
4292 return git_json_err("Authentication required", 401);
4293 }
4294 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4295 return git_json_err("Git not available (no FS backend)", 501);
4296 };
4297 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
4298 Ok(v) => v,
4299 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4300 };
4301 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
4302 let author_email = parsed
4303 .author_email
4304 .as_deref()
4305 .unwrap_or("pod@dreamlab-ai.com");
4306 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email).await
4307 {
4308 Ok(result) => HttpResponse::Ok()
4309 .content_type("application/json")
4310 .body(serde_json::to_string(&result).unwrap_or_default()),
4311 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4312 }
4313}
4314
4315#[cfg(feature = "git")]
4316async fn handle_git_branches(
4317 path: web::Path<String>,
4318 req: HttpRequest,
4319 state: web::Data<AppState>,
4320) -> HttpResponse {
4321 let pubkey = path.into_inner();
4322 if require_pod_owner(&req, &pubkey).await.is_none() {
4323 return git_json_err("Authentication required", 401);
4324 }
4325 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4326 return git_json_err("Git not available (no FS backend)", 501);
4327 };
4328 match solid_pod_rs_git::api::git_branches(&repo).await {
4329 Ok(info) => HttpResponse::Ok()
4330 .content_type("application/json")
4331 .body(serde_json::to_string(&info).unwrap_or_default()),
4332 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4333 }
4334}
4335
4336#[cfg(feature = "git")]
4337async fn handle_git_create_branch(
4338 path: web::Path<String>,
4339 req: HttpRequest,
4340 state: web::Data<AppState>,
4341 body: web::Bytes,
4342) -> HttpResponse {
4343 let pubkey = path.into_inner();
4344 if require_pod_owner(&req, &pubkey).await.is_none() {
4345 return git_json_err("Authentication required", 401);
4346 }
4347 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4348 return git_json_err("Git not available (no FS backend)", 501);
4349 };
4350 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
4351 Ok(v) => v,
4352 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4353 };
4354 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
4355 Ok(()) => HttpResponse::Ok()
4356 .content_type("application/json")
4357 .body(r#"{"ok":true}"#),
4358 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4359 }
4360}
4361
4362#[cfg(feature = "git")]
4363async fn handle_git_discard(
4364 path: web::Path<String>,
4365 req: HttpRequest,
4366 state: web::Data<AppState>,
4367 body: web::Bytes,
4368) -> HttpResponse {
4369 let pubkey = path.into_inner();
4370 if require_pod_owner(&req, &pubkey).await.is_none() {
4371 return git_json_err("Authentication required", 401);
4372 }
4373 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4374 return git_json_err("Git not available (no FS backend)", 501);
4375 };
4376 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4377 Ok(v) => v,
4378 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4379 };
4380 let paths = parsed.paths.unwrap_or_default();
4381 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
4382 Ok(()) => HttpResponse::Ok()
4383 .content_type("application/json")
4384 .body(r#"{"ok":true}"#),
4385 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4386 }
4387}
4388
4389async fn handle_git_panel_options(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
4397 let origin = req
4398 .headers()
4399 .get(header::ORIGIN)
4400 .and_then(|v| v.to_str().ok())
4401 .map(str::to_string);
4402
4403 let mut rsp = HttpResponse::NoContent().finish();
4404 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
4405 rsp
4406}
4407
4408async fn handle_admin_provision(
4425 req: HttpRequest,
4426 state: web::Data<AppState>,
4427 path: web::Path<String>,
4428) -> HttpResponse {
4429 let expected = match &state.admin_key {
4431 Some(k) => k.clone(),
4432 None => {
4433 return HttpResponse::Forbidden().json(serde_json::json!({
4434 "error": "admin key not configured on this server"
4435 }));
4436 }
4437 };
4438 let provided = req
4439 .headers()
4440 .get("x-pod-admin-key")
4441 .and_then(|v| v.to_str().ok())
4442 .unwrap_or("");
4443 use subtle::ConstantTimeEq;
4448 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
4449 if !bool::from(key_match) {
4450 return HttpResponse::Forbidden().json(serde_json::json!({"error": "invalid admin key"}));
4451 }
4452
4453 let pubkey = path.into_inner();
4455 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
4456 return HttpResponse::BadRequest()
4457 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
4458 }
4459
4460 let data_root = match &state.data_root {
4462 Some(r) => r.clone(),
4463 None => {
4464 return HttpResponse::InternalServerError().json(serde_json::json!({
4465 "error": "server has no fs-backend storage configured"
4466 }));
4467 }
4468 };
4469
4470 let pods_root = data_root.join("pods");
4483 let pod_dir = pods_root.join(&pubkey);
4484
4485 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
4487 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
4488 return HttpResponse::InternalServerError()
4489 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
4490 }
4491
4492 let acl_content = format!(
4502 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
4503 <#owner> a acl:Authorization ;\n\
4504 acl:agent <did:nostr:{pubkey}> ;\n\
4505 acl:accessTo </pods/{pubkey}/> ;\n\
4506 acl:default </pods/{pubkey}/> ;\n\
4507 acl:mode acl:Read, acl:Write, acl:Control .\n"
4508 );
4509
4510 let sibling_acl_path = pods_root.join(format!("{pubkey}.acl"));
4520 if !sibling_acl_path.exists() {
4521 if let Err(e) = tokio::fs::write(&sibling_acl_path, acl_content.as_bytes()).await {
4522 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write sibling .acl failed");
4523 return HttpResponse::InternalServerError()
4524 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
4525 }
4526 }
4527
4528 let inner_acl_path = pod_dir.join(".acl");
4535 if !inner_acl_path.exists() {
4536 if let Err(e) = tokio::fs::write(&inner_acl_path, acl_content.as_bytes()).await {
4537 tracing::warn!(pubkey = %pubkey, error = %e, "/_admin/provision: write inner .acl failed (non-fatal; sibling ACL governs)");
4538 }
4539 }
4540
4541 #[cfg(feature = "git")]
4543 {
4544 if !pod_dir.join(".git").exists() {
4546 let init_out = crate::hardened_git::hardened_git_async()
4547 .args(["init", "-b", "main", pod_dir.to_str().unwrap_or(".")])
4548 .output()
4549 .await;
4550
4551 match init_out {
4552 Ok(out) if out.status.success() => {}
4553 Ok(out) => {
4554 let stderr = String::from_utf8_lossy(&out.stderr);
4555 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
4556 }
4557 Err(e) => {
4558 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
4559 }
4560 }
4561
4562 let cfg_out = crate::hardened_git::hardened_git_async()
4565 .args([
4566 "-C",
4567 pod_dir.to_str().unwrap_or("."),
4568 "config",
4569 "receive.denyCurrentBranch",
4570 "updateInstead",
4571 ])
4572 .output()
4573 .await;
4574
4575 if let Err(e) = cfg_out {
4576 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
4577 }
4578 }
4579 }
4580
4581 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
4583 HttpResponse::Ok().json(serde_json::json!({
4584 "podUrl": format!("{base_url}/pods/{pubkey}/"),
4585 "ok": true,
4586 }))
4587}
4588
4589async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
4594 let Some(ref data_root) = state.data_root else {
4595 return HttpResponse::Ok()
4596 .content_type("application/json")
4597 .json(serde_json::json!({"apps": [], "count": 0}));
4598 };
4599
4600 let server_url = state.nodeinfo.base_url.clone();
4601
4602 let mut read_dir = match tokio::fs::read_dir(data_root).await {
4604 Ok(rd) => rd,
4605 Err(_) => {
4606 return HttpResponse::Ok()
4607 .content_type("application/json")
4608 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
4609 }
4610 };
4611
4612 let mut apps: Vec<serde_json::Value> = Vec::new();
4613 let mut scanned = 0usize;
4614
4615 while scanned < 1000 {
4616 let entry = match read_dir.next_entry().await {
4617 Ok(Some(e)) => e,
4618 Ok(None) => break,
4619 Err(_) => break,
4620 };
4621
4622 let file_type = match entry.file_type().await {
4623 Ok(ft) => ft,
4624 Err(_) => continue,
4625 };
4626 if !file_type.is_dir() {
4627 continue;
4628 }
4629
4630 scanned += 1;
4631
4632 let manifest_path = entry.path().join("apps").join("manifest.json");
4633 let contents = match tokio::fs::read(&manifest_path).await {
4634 Ok(c) => c,
4635 Err(_) => continue,
4636 };
4637
4638 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
4639 Ok(v) => v,
4640 Err(_) => continue,
4641 };
4642
4643 if let Some(pod_name) = entry.file_name().to_str() {
4645 if manifest.get("podOwner").is_none() {
4646 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
4647 }
4648 }
4649
4650 apps.push(manifest);
4651 }
4652
4653 let count = apps.len();
4654 HttpResponse::Ok()
4655 .content_type("application/json")
4656 .json(serde_json::json!({
4657 "apps": apps,
4658 "serverUrl": server_url,
4659 "count": count,
4660 }))
4661}
4662
4663#[allow(dead_code)]
4676fn is_git_request(path: &str) -> bool {
4677 path.contains("/info/refs")
4678 || path.contains("/git-upload-pack")
4679 || path.contains("/git-receive-pack")
4680}
4681
4682#[allow(dead_code)]
4685fn is_dot_git_path(path: &str) -> bool {
4686 path.contains("/.git/") || path.ends_with("/.git")
4687}
4688
4689#[cfg(feature = "git")]
4690async fn handle_git(
4691 req: HttpRequest,
4692 body: web::Bytes,
4693 state: web::Data<AppState>,
4694) -> HttpResponse {
4695 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
4696 use solid_pod_rs_git::service::GitRequest;
4697
4698 let path = req.uri().path().to_string();
4699
4700 let pod_name = path
4703 .trim_start_matches('/')
4704 .split('/')
4705 .next()
4706 .unwrap_or("")
4707 .to_string();
4708 let Some(ref data_root) = state.data_root else {
4709 return HttpResponse::NotImplemented().json(serde_json::json!({
4710 "error": "git requires fs-backend storage",
4711 "reason": "data_root_not_configured"
4712 }));
4713 };
4714 let repo_root = data_root.join(&pod_name);
4715 if !repo_root.exists() {
4716 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
4717 }
4718
4719 let query = req.uri().query().unwrap_or("").to_string();
4720 let host_url = Some(scheme::request_origin(&req));
4721 let headers: Vec<(String, String)> = req
4722 .headers()
4723 .iter()
4724 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
4725 .collect();
4726
4727 let git_req = GitRequest {
4728 method: req.method().as_str().to_string(),
4729 path,
4730 query,
4731 headers,
4732 body,
4733 host_url,
4734 };
4735
4736 if git_req.is_write() {
4742 let mut builder = HttpResponse::Forbidden();
4743 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4744 builder.insert_header((k, v));
4745 }
4746 return builder.json(serde_json::json!({
4747 "error": "git push is not served by this server",
4748 "reason": "push_not_served"
4749 }));
4750 }
4751
4752 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
4763 Ok(pk) => Some(format!("did:nostr:{pk}")),
4764 Err(_) => None,
4765 };
4766 let wac_path = format!("/{pod_name}/");
4767 let origin = req_origin(&req);
4768 let wac = enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin)
4772 .await
4773 .map(|_audience| ());
4774 if let Err(e) = wac {
4775 let mut resp = e.error_response();
4779 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4780 if let (Ok(name), Ok(value)) = (
4781 actix_web::http::header::HeaderName::from_bytes(k.as_bytes()),
4782 actix_web::http::header::HeaderValue::from_str(v),
4783 ) {
4784 resp.headers_mut().insert(name, value);
4785 }
4786 }
4787 return resp;
4788 }
4789
4790 if agent.as_deref() != Some(format!("did:nostr:{pod_name}").as_str()) {
4795 let mut response = HttpResponse::Forbidden();
4796 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4797 response.insert_header((k, v));
4798 }
4799 return response.json(serde_json::json!({
4800 "error": "whole-history export requires the pod owner",
4801 "reason": "history_export_owner_required"
4802 }));
4803 }
4804 let service = solid_pod_rs_git::service::GitHttpService::new(data_root.clone());
4806 match service.handle(git_req).await {
4807 Ok(git_resp) => {
4808 let mut builder = HttpResponse::build(
4809 actix_web::http::StatusCode::from_u16(git_resp.status)
4810 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4811 );
4812 for (k, v) in &git_resp.headers {
4813 builder.insert_header((k.as_str(), v.as_str()));
4814 }
4815 builder.body(git_resp.body)
4816 }
4817 Err(e) => {
4818 let status = e.status_code();
4819 let mut builder = HttpResponse::build(
4820 actix_web::http::StatusCode::from_u16(status)
4821 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4822 );
4823 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4824 builder.insert_header((k, v));
4825 }
4826 builder.json(serde_json::json!({"error": e.to_string()}))
4827 }
4828 }
4829}
4830
4831#[cfg(feature = "forge")]
4840fn forge_plugin_dir(state: &AppState) -> Option<PathBuf> {
4841 state.data_root.as_ref().map(|r| r.join(".forge"))
4842}
4843
4844#[cfg(feature = "forge")]
4850struct ServerLoopback {
4851 client: reqwest::Client,
4852}
4853
4854#[cfg(feature = "forge")]
4855#[async_trait::async_trait]
4856impl solid_pod_rs_forge::LoopbackFetch for ServerLoopback {
4857 async fn get(
4858 &self,
4859 url: &str,
4860 max_bytes: usize,
4861 timeout_secs: u64,
4862 ) -> solid_pod_rs_forge::bodies::FetchResult {
4863 use solid_pod_rs_forge::bodies::FetchResult;
4864 let resp = match self
4865 .client
4866 .get(url)
4867 .timeout(Duration::from_secs(timeout_secs.max(1)))
4868 .send()
4869 .await
4870 {
4871 Ok(r) => r,
4872 Err(e) => return FetchResult::Error(e.to_string()),
4873 };
4874 let code = resp.status().as_u16();
4875 if code == 404 || code == 410 {
4876 return FetchResult::Removed;
4877 }
4878 if !resp.status().is_success() {
4879 return FetchResult::Error(format!("status {code}"));
4880 }
4881 if resp
4882 .content_length()
4883 .is_some_and(|length| length > max_bytes as u64)
4884 {
4885 return FetchResult::TooLarge;
4886 }
4887 let mut stream = resp.bytes_stream();
4888 let mut body = Vec::with_capacity(max_bytes.min(16 * 1024));
4889 while let Some(chunk) = stream.next().await {
4890 match chunk {
4891 Ok(chunk) if body.len().saturating_add(chunk.len()) <= max_bytes => {
4892 body.extend_from_slice(&chunk);
4893 }
4894 Ok(_) => return FetchResult::TooLarge,
4895 Err(error) => return FetchResult::Error(error.to_string()),
4896 }
4897 }
4898 FetchResult::Body(body)
4899 }
4900}
4901
4902#[cfg(feature = "forge")]
4904fn env_list(name: &str) -> Vec<String> {
4905 std::env::var(name)
4906 .ok()
4907 .map(|v| {
4908 v.split(',')
4909 .map(str::trim)
4910 .filter(|s| !s.is_empty())
4911 .map(str::to_string)
4912 .collect()
4913 })
4914 .unwrap_or_default()
4915}
4916
4917#[cfg(feature = "forge")]
4922fn forge_config() -> solid_pod_rs_forge::ForgeConfig {
4923 use serde_json::{json, Map, Value};
4924 let mut o = Map::new();
4925 let flag = |n: &str| {
4926 std::env::var(n)
4927 .ok()
4928 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
4929 };
4930 if let Some(b) = flag("FORGE_PRIVATE_REPOS") {
4931 o.insert("privateRepos".into(), json!(b));
4932 }
4933 if let Some(b) = flag("FORGE_ALLOW_MAINNET") {
4934 o.insert("allowMainnet".into(), json!(b));
4935 }
4936 if let Some(b) = flag("FORGE_OPEN_EDIT") {
4937 o.insert("openEdit".into(), json!(b));
4938 }
4939 if let Some(b) = flag("FORGE_ANCHORING_UI") {
4940 o.insert("anchoringUi".into(), json!(b));
4941 }
4942 if let Some(b) = flag("FORGE_SPARSE_MARKS") {
4943 o.insert("sparseMarks".into(), json!(b));
4944 }
4945 if let Ok(c) = std::env::var("FORGE_CHAIN") {
4946 if !c.trim().is_empty() {
4947 o.insert("chain".into(), Value::String(c.trim().to_string()));
4948 }
4949 }
4950 if let Ok(u) = std::env::var("FORGE_BASE_URL") {
4951 if !u.trim().is_empty() {
4952 o.insert("baseUrl".into(), Value::String(u.trim().to_string()));
4953 }
4954 }
4955 let relays = env_list("FORGE_ANNOUNCE_RELAYS");
4956 if !relays.is_empty() {
4957 o.insert("announceRelays".into(), json!(relays));
4958 }
4959 solid_pod_rs_forge::ForgeConfig::from_env(Value::Object(o))
4960}
4961
4962#[cfg(feature = "forge-mirror")]
4967fn mirror_config() -> Option<solid_pod_rs_forge::mirror::MirrorConfig> {
4968 use serde_json::json;
4969 let relays = env_list("FORGE_MIRROR_RELAYS");
4970 let follow: Vec<serde_json::Value> = env_list("FORGE_MIRROR_FOLLOW")
4971 .iter()
4972 .filter_map(|e| e.split_once('/'))
4973 .map(|(pk, name)| json!({"pubkey": pk.trim(), "name": name.trim()}))
4974 .collect();
4975 let mut cfg = json!({"relays": relays, "follow": follow});
4976 if let Some(ms) = std::env::var("FORGE_MIRROR_MIN_INTERVAL_MS")
4977 .ok()
4978 .and_then(|v| v.trim().parse::<u64>().ok())
4979 {
4980 cfg["minIntervalMs"] = json!(ms);
4981 }
4982 solid_pod_rs_forge::mirror::parse_mirror_config(&cfg)
4983}
4984
4985#[cfg(feature = "forge")]
4987struct ForgeRuntime {
4988 service: Arc<solid_pod_rs_forge::ForgeService>,
4989 #[cfg(feature = "forge-mirror")]
4990 _mirror: Option<solid_pod_rs_forge::mirror::MirrorHandle>,
4991}
4992
4993#[cfg(feature = "forge")]
4997fn forge_service(
4998 plugin_dir: PathBuf,
4999) -> Result<Arc<solid_pod_rs_forge::ForgeService>, solid_pod_rs_forge::ForgeError> {
5000 use std::collections::HashMap;
5001 use std::sync::{Mutex, OnceLock};
5002 static REGISTRY: OnceLock<Mutex<HashMap<PathBuf, Arc<ForgeRuntime>>>> = OnceLock::new();
5003 let reg = REGISTRY.get_or_init(|| Mutex::new(HashMap::new()));
5004 let mut reg = reg
5005 .lock()
5006 .unwrap_or_else(std::sync::PoisonError::into_inner);
5007 if let Some(rt) = reg.get(&plugin_dir) {
5008 return Ok(Arc::clone(&rt.service));
5009 }
5010 let cfg = forge_config();
5011 #[cfg(feature = "forge-announce")]
5012 let relay_policy: Arc<dyn solid_pod_rs_forge::publish::SsrfCheck> = Arc::new(
5013 solid_pod_rs_forge::publish::OperatorRelays::new(&cfg.announce_relays),
5014 );
5015 let loopback: Arc<dyn solid_pod_rs_forge::LoopbackFetch> = Arc::new(ServerLoopback {
5016 client: reqwest::Client::new(),
5017 });
5018 let svc =
5019 solid_pod_rs_forge::ForgeService::new(cfg, plugin_dir.clone())?.with_loopback(loopback);
5020 #[cfg(feature = "forge-announce")]
5021 let svc = svc.with_announce_ssrf(relay_policy);
5022 let service = Arc::new(svc);
5023 #[cfg(feature = "forge-mirror")]
5024 let mirror = mirror_config().map(|mcfg| {
5025 use solid_pod_rs_forge::mirror::{GitCli, Mirror, MirrorStore};
5026 let git = Arc::new(GitCli::new(plugin_dir.join("repos")));
5027 let engine = Arc::new(Mirror::new(mcfg, MirrorStore::new(&plugin_dir), git));
5028 tracing::info!("forge: mirror on kind-30618 started");
5029 engine.start()
5030 });
5031 reg.insert(
5032 plugin_dir,
5033 Arc::new(ForgeRuntime {
5034 service: Arc::clone(&service),
5035 #[cfg(feature = "forge-mirror")]
5036 _mirror: mirror,
5037 }),
5038 );
5039 Ok(service)
5040}
5041
5042#[cfg(feature = "forge")]
5046pub fn start_forge(state: &AppState) {
5047 if let Some(dir) = forge_plugin_dir(state) {
5048 match forge_service(dir) {
5049 Ok(_) => tracing::info!("forge: started"),
5050 Err(e) => tracing::error!("forge: could not start: {e}"),
5051 }
5052 }
5053}
5054
5055#[cfg(feature = "forge")]
5061async fn handle_forge(
5062 req: HttpRequest,
5063 body: web::Bytes,
5064 state: web::Data<AppState>,
5065) -> HttpResponse {
5066 use solid_pod_rs_forge::ForgeRequest;
5067
5068 let Some(plugin_dir) = forge_plugin_dir(&state) else {
5069 return HttpResponse::NotImplemented().json(serde_json::json!({
5070 "error": "forge requires fs-backend storage",
5071 "reason": "data_root_not_configured"
5072 }));
5073 };
5074
5075 let service = match web::block(move || forge_service(plugin_dir)).await {
5079 Ok(Ok(s)) => s,
5080 Ok(Err(e)) => {
5081 return HttpResponse::InternalServerError()
5082 .json(serde_json::json!({"error": e.to_string()}));
5083 }
5084 Err(e) => {
5085 return HttpResponse::InternalServerError()
5086 .json(serde_json::json!({"error": e.to_string()}));
5087 }
5088 };
5089
5090 let path = req.uri().path().to_string();
5091 let query = req.uri().query().unwrap_or("").to_string();
5092 let host_url = Some(scheme::request_origin(&req));
5093 let headers: Vec<(String, String)> = req
5094 .headers()
5095 .iter()
5096 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
5097 .collect();
5098
5099 let forge_req = ForgeRequest {
5100 method: req.method().as_str().to_string(),
5101 path,
5102 query,
5103 headers,
5104 raw_body: body,
5105 host_url,
5106 };
5107
5108 let agent = service.resolve_agent(&forge_req).await;
5113
5114 #[cfg(feature = "jev")]
5116 let jev_ctx = (
5117 forge_req.method.clone(),
5118 forge_req.path.clone(),
5119 forge_req.query.clone(),
5120 );
5121
5122 match service.handle(forge_req, agent).await {
5123 Ok(resp) => {
5124 let mut builder = HttpResponse::build(
5125 StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
5126 );
5127 for (k, v) in &resp.headers {
5128 #[cfg(feature = "jev")]
5130 if k.eq_ignore_ascii_case("content-length") {
5131 continue;
5132 }
5133 builder.insert_header((k.as_str(), v.as_str()));
5134 }
5135 #[cfg(feature = "jev")]
5139 if jev::enabled_in_process_env() {
5140 let rt = jev::JevRuntime::from_process_env();
5141 if let Some(body) = jev::rerank_forge_listing(
5142 &rt,
5143 &jev_ctx.0,
5144 &jev_ctx.1,
5145 &jev_ctx.2,
5146 resp.status,
5147 &resp.body,
5148 )
5149 .await
5150 {
5151 return builder.body(body);
5152 }
5153 }
5154 builder.body(resp.body)
5155 }
5156 Err(e) => {
5157 let status = e.status_code();
5158 HttpResponse::build(
5159 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
5160 )
5161 .json(serde_json::json!({"error": e.to_string()}))
5162 }
5163 }
5164}
5165
5166pub fn build_app(
5178 state: AppState,
5179) -> App<
5180 impl actix_web::dev::ServiceFactory<
5181 ServiceRequest,
5182 Config = (),
5183 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
5184 Error = ActixError,
5185 InitError = (),
5186 >,
5187> {
5188 let body_cap = state.body_cap;
5189 let dotfiles = state.dotfiles.clone();
5190 let allowed_origins = Arc::new(state.allowed_origins.clone());
5191 let allowed_hosts = state.allowed_hosts.clone();
5192
5193 let mut app = App::new()
5194 .app_data(web::Data::new(state.clone()))
5195 .app_data(web::PayloadConfig::new(body_cap))
5196 .wrap(ErrorLoggingMiddleware)
5201 .wrap(CorsHeaders { allowed_origins })
5202 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
5206 .wrap(PathTraversalGuard)
5207 .wrap(DotfileGuard::new(dotfiles).with_host_policy(HostPolicy {
5208 allowed: allowed_hosts,
5209 trust_proxy: state.trust_proxy,
5210 }));
5211
5212 app = app
5218 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
5219 .route(
5220 "/.well-known/webfinger",
5221 web::get().to(handle_well_known_webfinger),
5222 )
5223 .route(
5224 "/.well-known/nodeinfo",
5225 web::get().to(handle_well_known_nodeinfo),
5226 )
5227 .route(
5228 "/.well-known/nodeinfo/2.1",
5229 web::get().to(handle_well_known_nodeinfo_2_1),
5230 );
5231
5232 #[cfg(feature = "did-nostr")]
5233 {
5234 app = app.route(
5235 "/.well-known/did/nostr/{pubkey}.json",
5236 web::get().to(handle_well_known_did_nostr),
5237 );
5238 }
5239
5240 #[cfg(feature = "nip05-endpoint")]
5245 {
5246 app = app.route(
5247 "/.well-known/nostr.json",
5248 web::get().to(handle_well_known_nip05),
5249 );
5250 }
5251
5252 #[cfg(feature = "export-jsonld")]
5257 {
5258 app = app.route("/api/exports/all", web::get().to(handle_export_all));
5259 }
5260
5261 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
5263
5264 app = app.route("/pay/.info", web::get().to(handle_pay_info));
5266
5267 app = app.configure(handlers::pay::register);
5272
5273 app = app.route("/proxy", web::get().to(handle_proxy));
5275
5276 if state.mcp_enabled {
5280 app = app.route("/mcp", web::post().to(mcp::handle_mcp)).route(
5281 "/mcp",
5282 web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options),
5283 );
5284 }
5285
5286 app = app.route(
5289 "/_admin/provision/{pubkey}",
5290 web::post().to(handle_admin_provision),
5291 );
5292
5293 app = app
5295 .route("/.pods", web::post().to(handle_create_pod))
5296 .route("/api/accounts/new", web::post().to(handle_create_account))
5297 .route("/pods/check/{name}", web::get().to(handle_pod_check))
5298 .route("/login/password", web::post().to(handle_login_password))
5299 .route(
5300 "/account/password/reset",
5301 web::post().to(handle_password_reset_request),
5302 )
5303 .route(
5304 "/account/password/change",
5305 web::post().to(handle_password_change),
5306 );
5307
5308 #[cfg(feature = "forge")]
5313 {
5314 app = app
5315 .route("/forge", web::route().to(handle_forge))
5316 .route("/forge/{tail:.*}", web::route().to(handle_forge));
5317 }
5318
5319 app = app
5324 .route(
5325 "/{tail:.*}/.git",
5327 web::route().to(|| async {
5328 HttpResponse::Forbidden()
5329 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
5330 }),
5331 )
5332 .route(
5333 "/{tail:.*}/.git/{rest:.*}",
5334 web::route().to(|| async {
5335 HttpResponse::Forbidden()
5336 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
5337 }),
5338 );
5339
5340 app = app.route(
5344 "/pods/{pk}/_git/{tail:.*}",
5345 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
5346 );
5347
5348 #[cfg(feature = "git")]
5349 {
5350 app = app
5357 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
5358 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
5359 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
5360
5361 app = app
5364 .route(
5365 "/pods/{pubkey}/_git/status",
5366 web::get().to(handle_git_status),
5367 )
5368 .route("/pods/{pubkey}/_git/log", web::get().to(handle_git_log))
5369 .route("/pods/{pubkey}/_git/diff", web::get().to(handle_git_diff))
5370 .route(
5371 "/pods/{pubkey}/_git/stage",
5372 web::post().to(handle_git_stage),
5373 )
5374 .route(
5375 "/pods/{pubkey}/_git/unstage",
5376 web::post().to(handle_git_unstage),
5377 )
5378 .route(
5379 "/pods/{pubkey}/_git/commit",
5380 web::post().to(handle_git_commit),
5381 )
5382 .route(
5383 "/pods/{pubkey}/_git/branches",
5384 web::get().to(handle_git_branches),
5385 )
5386 .route(
5387 "/pods/{pubkey}/_git/branch",
5388 web::post().to(handle_git_create_branch),
5389 )
5390 .route(
5391 "/pods/{pubkey}/_git/discard",
5392 web::post().to(handle_git_discard),
5393 );
5394
5395 app = app.configure(handlers::prov::register);
5402 }
5403 #[cfg(not(feature = "git"))]
5404 {
5405 let git_501 = || async {
5409 HttpResponse::NotImplemented()
5410 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
5411 };
5412 app = app
5413 .route("/{tail:.*}/info/refs", web::get().to(git_501))
5414 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
5415 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
5416 }
5417
5418 app.route("/{tail:.*}/", web::post().to(handle_post))
5421 .route("/{tail:.*}/", web::put().to(handle_put))
5422 .route("/{tail:.*}", web::get().to(handle_get))
5423 .route("/{tail:.*}", web::head().to(handle_get))
5424 .route("/{tail:.*}", web::put().to(handle_put))
5425 .route("/{tail:.*}", web::patch().to(handle_patch))
5426 .route("/{tail:.*}", web::delete().to(handle_delete))
5427 .route(
5428 "/{tail:.*}",
5429 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
5430 )
5431 .route(
5432 "/{tail:.*}",
5433 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
5434 )
5435}
5436
5437#[cfg(test)]
5442mod payment_gating_tests {
5443 use super::*;
5444 use solid_pod_rs::payments::WebLedger;
5445 use solid_pod_rs::storage::memory::MemoryBackend;
5446
5447 macro_rules! principal {
5451 () => {
5452 "did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
5453 };
5454 }
5455
5456 const PRINCIPAL: &str = principal!();
5457
5458 const OTHER_PRINCIPAL: &str =
5460 "did:nostr:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
5461
5462 const PAID_WRITE_ACL: &str = concat!(
5465 r#"
5466@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5467
5468<#paid-write> a acl:Authorization ;
5469 acl:agent <"#,
5470 principal!(),
5471 r#"> ;
5472 acl:accessTo </premium/inbox> ;
5473 acl:mode acl:Write ;
5474 acl:condition [
5475 a acl:PaymentCondition ;
5476 acl:costSats 100
5477 ] .
5478"#
5479 );
5480
5481 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
5482 let mut ledger = WebLedger::new("Test Pod Credits");
5483 if sats > 0 {
5484 ledger
5485 .credit_by_outpoint(did, "satoshi", &"ab".repeat(32), 0, sats)
5486 .unwrap();
5487 }
5488 let body = serde_json::to_vec(&ledger).unwrap();
5489 storage
5490 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
5491 .await
5492 .unwrap();
5493 }
5494
5495 async fn seed_acl(storage: &dyn Storage) {
5496 storage
5497 .put(
5498 "/premium/inbox.acl",
5499 Bytes::from(PAID_WRITE_ACL),
5500 "text/turtle",
5501 )
5502 .await
5503 .unwrap();
5504 }
5505
5506 #[actix_web::test]
5508 async fn resolve_balance_reads_ledger_entry() {
5509 let storage = MemoryBackend::new();
5510 seed_ledger(&storage, PRINCIPAL, 250).await;
5511 assert_eq!(
5512 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5513 Some(250)
5514 );
5515 }
5516
5517 #[actix_web::test]
5518 async fn wac_balance_uses_authoritative_state_after_a_stale_mirror() {
5519 use solid_pod_rs::payments::PaymentStore;
5520 let storage = MemoryBackend::new();
5521 seed_ledger(&storage, PRINCIPAL, 250).await;
5522 let ledger = WebLedger::new("empty canonical balance");
5523 handlers::pay::StoragePaymentStore::new(&storage)
5524 .write_ledger(&ledger)
5525 .await
5526 .unwrap();
5527 seed_ledger(&storage, PRINCIPAL, 250).await;
5528 assert_eq!(
5529 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5530 Some(0)
5531 );
5532 }
5533
5534 #[actix_web::test]
5536 async fn resolve_balance_zero_when_no_entry() {
5537 let storage = MemoryBackend::new();
5538 seed_ledger(&storage, OTHER_PRINCIPAL, 500).await;
5539 assert_eq!(
5540 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5541 Some(0)
5542 );
5543 }
5544
5545 #[test]
5551 fn fixtures_use_teller_accounts_and_grant_the_principal() {
5552 use solid_pod_rs::payments::account_of;
5553 for did in [PRINCIPAL, OTHER_PRINCIPAL] {
5554 assert_eq!(account_of(did).unwrap(), did, "{did} must be canonical");
5555 }
5556 assert_ne!(PRINCIPAL, OTHER_PRINCIPAL);
5557 assert!(account_of("did:nostr:bob").is_err());
5558 let agent = format!("acl:agent <{PRINCIPAL}>");
5559 for (name, acl) in [
5560 ("PAID_WRITE_ACL", PAID_WRITE_ACL),
5561 ("WRITE_NOT_CONTROL_ACL", WRITE_NOT_CONTROL_ACL),
5562 ] {
5563 assert!(acl.contains(&agent), "{name} must grant PRINCIPAL");
5564 }
5565 assert!(!WRITE_NOT_CONTROL_ACL.contains("did:nostr:alice"));
5566 }
5567
5568 #[actix_web::test]
5571 async fn uncharged_read_skips_pay_per_read_resource() {
5572 let storage = Arc::new(MemoryBackend::new());
5573 let acl = format!(
5574 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
5575 <#paid> a acl:Authorization ; acl:agent <{PRINCIPAL}> ;\n\
5576 acl:accessTo </premium/doc> ; acl:mode acl:Read ;\n\
5577 acl:condition [ a acl:PaymentCondition ; acl:costSats 100 ] .\n"
5578 );
5579 storage
5580 .put("/premium/doc.acl", Bytes::from(acl), "text/turtle")
5581 .await
5582 .unwrap();
5583 seed_ledger(storage.as_ref(), PRINCIPAL, 500).await;
5584 let state = AppState::new(storage.clone());
5585
5586 let skipped =
5587 enforce_read_inner(&state, "/premium/doc", Some(PRINCIPAL), None, false).await;
5588 assert!(skipped.is_err(), "glob must not serve a pay-per-read child");
5589 assert_eq!(
5590 resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5591 Some(500)
5592 );
5593
5594 enforce_read_ctx(&state, "/premium/doc", Some(PRINCIPAL), None)
5595 .await
5596 .expect("a direct read pays and is served");
5597 assert_eq!(
5598 resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5599 Some(400)
5600 );
5601 }
5602
5603 #[actix_web::test]
5605 async fn resolve_balance_none_when_anonymous() {
5606 let storage = MemoryBackend::new();
5607 seed_ledger(&storage, PRINCIPAL, 1_000).await;
5608 assert_eq!(resolve_balance_sats(&storage, None).await, None);
5609 }
5610
5611 #[actix_web::test]
5613 async fn paid_write_denied_below_balance() {
5614 let storage = Arc::new(MemoryBackend::new());
5615 seed_acl(storage.as_ref()).await;
5616 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
5618
5619 let result =
5620 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5621 assert!(
5622 result.is_err(),
5623 "balance 50 < cost 100 must be denied — sat-gating loop closed"
5624 );
5625 }
5626
5627 #[actix_web::test]
5629 async fn paid_write_allowed_at_balance() {
5630 let storage = Arc::new(MemoryBackend::new());
5631 seed_acl(storage.as_ref()).await;
5632 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
5634
5635 let result =
5636 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5637 assert!(
5638 result.is_ok(),
5639 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
5640 );
5641 }
5642
5643 #[actix_web::test]
5645 async fn paid_write_allowed_above_balance() {
5646 let storage = Arc::new(MemoryBackend::new());
5647 seed_acl(storage.as_ref()).await;
5648 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
5649 let state = AppState::new(storage);
5650
5651 let result =
5652 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5653 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
5654 }
5655
5656 #[actix_web::test]
5660 async fn paid_write_anonymous_denied() {
5661 let storage = Arc::new(MemoryBackend::new());
5662 seed_acl(storage.as_ref()).await;
5663 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
5664 let state = AppState::new(storage);
5665
5666 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
5667 assert!(
5668 result.is_err(),
5669 "anonymous caller has no ledger principal — PaymentCondition fails closed"
5670 );
5671 }
5672
5673 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
5680 use solid_pod_rs::payments::PaymentStore;
5681 let ledger = handlers::pay::StoragePaymentStore::new(storage)
5682 .read_ledger()
5683 .await
5684 .unwrap();
5685 ledger.get_balance(did)
5686 }
5687
5688 #[actix_web::test]
5690 async fn paid_write_debits_ledger() {
5691 let storage = Arc::new(MemoryBackend::new());
5692 seed_acl(storage.as_ref()).await;
5693 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
5695
5696 let result =
5697 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5698 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
5699 assert_eq!(
5700 read_balance(storage.as_ref(), PRINCIPAL).await,
5701 150,
5702 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
5703 );
5704 }
5705
5706 #[actix_web::test]
5709 async fn paid_write_debits_each_grant() {
5710 let storage = Arc::new(MemoryBackend::new());
5711 seed_acl(storage.as_ref()).await;
5712 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await;
5713 use solid_pod_rs::payments::PaymentStore;
5716 let store = handlers::pay::StoragePaymentStore::new(storage.as_ref());
5717 let ledger = store.read_ledger().await.unwrap();
5718 store.write_ledger(&ledger).await.unwrap();
5719 let state = AppState::new(storage.clone());
5720
5721 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5722 .await
5723 .unwrap();
5724 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5725 .await
5726 .unwrap();
5727 assert_eq!(
5728 read_balance(storage.as_ref(), PRINCIPAL).await,
5729 50,
5730 "250 - 2*100: each granted request debits, no unmetered re-use"
5731 );
5732
5733 let third =
5735 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5736 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
5737 assert_eq!(
5738 read_balance(storage.as_ref(), PRINCIPAL).await,
5739 50,
5740 "a denied request must not debit"
5741 );
5742 }
5743
5744 #[actix_web::test]
5746 async fn paid_read_debits_ledger() {
5747 const PAID_READ_ACL: &str = r#"
5748@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5749
5750<#paid-read> a acl:Authorization ;
5751 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5752 acl:accessTo </premium/feed> ;
5753 acl:mode acl:Read ;
5754 acl:condition [
5755 a acl:PaymentCondition ;
5756 acl:costSats 30
5757 ] .
5758"#;
5759 let storage = Arc::new(MemoryBackend::new());
5760 storage
5761 .put(
5762 "/premium/feed.acl",
5763 Bytes::from(PAID_READ_ACL),
5764 "text/turtle",
5765 )
5766 .await
5767 .unwrap();
5768 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5769 let state = AppState::new(storage.clone());
5770
5771 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
5772 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
5773 assert_eq!(
5774 read_balance(storage.as_ref(), PRINCIPAL).await,
5775 70,
5776 "100 - 30 cost: a granted paid read must debit"
5777 );
5778 }
5779
5780 #[actix_web::test]
5783 async fn free_read_does_not_debit() {
5784 let storage = Arc::new(MemoryBackend::new());
5785 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5787 let state = AppState::new(storage.clone());
5788
5789 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
5790 .await
5791 .unwrap();
5792 assert_eq!(
5793 read_balance(storage.as_ref(), PRINCIPAL).await,
5794 100,
5795 "a grant with no PaymentCondition must not debit"
5796 );
5797 }
5798
5799 const ALICE_ONLY_READ_ACL: &str = r#"
5805@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5806
5807<#alice> a acl:Authorization ;
5808 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5809 acl:accessTo </private/secret> ;
5810 acl:default </private/> ;
5811 acl:mode acl:Read, acl:Write, acl:Control .
5812"#;
5813
5814 async fn seed_private_read_acl(storage: &dyn Storage) {
5815 storage
5820 .put(
5821 "/private.acl",
5822 Bytes::from(ALICE_ONLY_READ_ACL),
5823 "text/turtle",
5824 )
5825 .await
5826 .unwrap();
5827 }
5828
5829 #[actix_web::test]
5833 async fn enforce_read_grants_owner() {
5834 let storage = Arc::new(MemoryBackend::new());
5835 seed_private_read_acl(storage.as_ref()).await;
5836 let state = AppState::new(storage);
5837 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
5838 assert!(result.is_ok(), "owner alice must be granted Read");
5839 }
5840
5841 #[actix_web::test]
5844 async fn enforce_read_denies_other_principal() {
5845 let storage = Arc::new(MemoryBackend::new());
5846 seed_private_read_acl(storage.as_ref()).await;
5847 let state = AppState::new(storage);
5848 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
5849 assert!(
5850 result.is_err(),
5851 "bob has no Read grant — private resource must not be world-readable"
5852 );
5853 }
5854
5855 #[actix_web::test]
5858 async fn enforce_read_denies_anonymous() {
5859 let storage = Arc::new(MemoryBackend::new());
5860 seed_private_read_acl(storage.as_ref()).await;
5861 let state = AppState::new(storage);
5862 let result = enforce_read(&state, "/private/secret", None).await;
5863 assert!(result.is_err(), "anonymous Read must be denied");
5864 }
5865
5866 const WRITE_NOT_CONTROL_ACL: &str = concat!(
5874 r#"
5875@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5876
5877<#owner> a acl:Authorization ;
5878 acl:agent <"#,
5879 principal!(),
5880 r#"> ;
5881 acl:accessTo </shared/doc> ;
5882 acl:default </shared/> ;
5883 acl:mode acl:Read, acl:Write, acl:Control .
5884
5885<#writer> a acl:Authorization ;
5886 acl:agent <did:nostr:writer> ;
5887 acl:accessTo </shared/doc> ;
5888 acl:default </shared/> ;
5889 acl:mode acl:Read, acl:Write .
5890"#
5891 );
5892
5893 async fn seed_shared_acl(storage: &dyn Storage) {
5894 storage
5899 .put(
5900 "/shared.acl",
5901 Bytes::from(WRITE_NOT_CONTROL_ACL),
5902 "text/turtle",
5903 )
5904 .await
5905 .unwrap();
5906 }
5907
5908 #[actix_web::test]
5912 async fn acl_put_denied_for_writer_without_control() {
5913 let storage = Arc::new(MemoryBackend::new());
5914 seed_shared_acl(storage.as_ref()).await;
5915 let state = AppState::new(storage);
5916 let result = enforce_write(
5920 &state,
5921 "/shared/.acl",
5922 AccessMode::Write,
5923 Some("did:nostr:writer"),
5924 )
5925 .await;
5926 assert!(
5927 result.is_err(),
5928 "writer lacks Control — must not be able to PUT /shared/.acl"
5929 );
5930 }
5931
5932 #[actix_web::test]
5934 async fn acl_put_allowed_for_control_holder() {
5935 let storage = Arc::new(MemoryBackend::new());
5936 seed_shared_acl(storage.as_ref()).await;
5937 let state = AppState::new(storage);
5938 let result =
5939 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
5940 assert!(
5941 result.is_ok(),
5942 "the owner holds Control — must be allowed to PUT /shared/.acl"
5943 );
5944 }
5945
5946 #[actix_web::test]
5948 async fn meta_put_denied_for_writer_without_control() {
5949 let storage = Arc::new(MemoryBackend::new());
5950 seed_shared_acl(storage.as_ref()).await;
5951 let state = AppState::new(storage);
5952 let result = enforce_write(
5953 &state,
5954 "/shared/doc.meta",
5955 AccessMode::Write,
5956 Some("did:nostr:writer"),
5957 )
5958 .await;
5959 assert!(
5960 result.is_err(),
5961 "writer lacks Control — must not be able to PUT a .meta sidecar"
5962 );
5963 }
5964
5965 #[test]
5968 fn canonical_pod_path_vectors() {
5969 let ok = [
5970 ("a/b", "/a/b"),
5971 ("", "/"),
5972 ("/", "/"),
5973 ("//a//b/", "/a/b/"),
5974 ("/%2ewell-known/x", "/.well-known/x"),
5975 ("/a%2fb", "/a/b"),
5976 ("/a%2520b", "/a%20b"),
5977 ];
5978 for (raw, want) in ok {
5979 assert_eq!(canonical_pod_path(raw).as_deref(), Some(want), "{raw}");
5980 }
5981 for bad in [
5982 "/a/./b",
5983 "/a/..",
5984 "../a",
5985 "/a\\b",
5986 "/a%00b",
5987 "/a%ffb",
5988 "/%2e%2e/x",
5989 ] {
5990 assert_eq!(canonical_pod_path(bad), None, "{bad}");
5991 }
5992 }
5993
5994 #[test]
5995 fn guarded_acl_target_vectors() {
5996 use AccessMode::{Control, Read, Write};
5997 let c = |p: &str, m| guarded_acl_target(p, m);
5998 assert_eq!(
5999 c("/.well-known/token/x.json", Read),
6000 ("/.well-known/token/x.json".into(), Control)
6001 );
6002 assert_eq!(
6003 c("/.well-known/gitmark/p.json", Write),
6004 ("/.well-known/gitmark/p.json".into(), Control)
6005 );
6006 assert_eq!(c("/.well-known/%74oken/x.json", Read).1, Control);
6007 assert_eq!(
6008 c("/.well-known/token/x.json.acl", Read),
6009 ("/.well-known/token/x.json".into(), Control)
6010 );
6011 assert_eq!(
6012 c("/.well-known/gitmark/p/blocktrails.json", Read),
6013 ("/.well-known/gitmark/p/blocktrails.json".into(), Read)
6014 );
6015 assert_eq!(
6016 c("/.well-known/webledgers/webledgers.json", Write),
6017 ("/.well-known/webledgers/webledgers.json".into(), Control)
6018 );
6019 assert_eq!(c("/.well-known/%57ebledgers/state.json", Read).1, Control);
6020 assert_eq!(c("/.well-known/webledgersx/y", Read).1, Read);
6021 assert_eq!(c("/pub/x", Read), ("/pub/x".into(), Read));
6022 }
6023
6024 #[test]
6026 fn protected_resource_for_acl_strips_suffixes() {
6027 assert_eq!(
6028 protected_resource_for_acl("/victim/.acl").as_deref(),
6029 Some("/victim/")
6030 );
6031 assert_eq!(
6032 protected_resource_for_acl("/a/b.acl").as_deref(),
6033 Some("/a/b")
6034 );
6035 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
6036 assert_eq!(
6037 protected_resource_for_acl("/a/b.meta").as_deref(),
6038 Some("/a/b")
6039 );
6040 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
6041 }
6042}
6043
6044#[cfg(all(test, feature = "forge-mirror"))]
6045mod forge_env_tests {
6046 use super::*;
6047
6048 #[test]
6050 fn mirror_config_comes_from_the_environment() {
6051 let pk = "ab".repeat(32);
6052 std::env::remove_var("FORGE_MIRROR_RELAYS");
6053 std::env::remove_var("FORGE_MIRROR_FOLLOW");
6054 assert!(mirror_config().is_none(), "nothing configured: mirror off");
6055
6056 std::env::set_var(
6057 "FORGE_MIRROR_RELAYS",
6058 "ws://127.0.0.1:7777, wss://r.example , nope",
6059 );
6060 assert!(mirror_config().is_none(), "relays alone follow nothing");
6061
6062 std::env::set_var(
6063 "FORGE_MIRROR_FOLLOW",
6064 format!("{pk}/agent, bad/entry, {pk}/../x"),
6065 );
6066 std::env::set_var("FORGE_MIRROR_MIN_INTERVAL_MS", "1000");
6067 let cfg = mirror_config().expect("one relay and one valid entry");
6068 assert_eq!(cfg.relays, ["ws://127.0.0.1:7777", "wss://r.example"]);
6069 assert_eq!(cfg.follow.len(), 1);
6070 assert_eq!(cfg.follow[0].name, "agent");
6071 assert_eq!(cfg.min_interval_ms, 1000);
6072
6073 std::env::remove_var("FORGE_MIRROR_RELAYS");
6074 std::env::remove_var("FORGE_MIRROR_FOLLOW");
6075 std::env::remove_var("FORGE_MIRROR_MIN_INTERVAL_MS");
6076 }
6077
6078 #[test]
6079 fn the_forge_service_is_built_once_per_data_directory() {
6080 let td = tempfile::tempdir().unwrap();
6081 let a = forge_service(td.path().join(".forge")).unwrap();
6082 let b = forge_service(td.path().join(".forge")).unwrap();
6083 assert!(Arc::ptr_eq(&a, &b));
6084 let other = tempfile::tempdir().unwrap();
6085 let c = forge_service(other.path().join(".forge")).unwrap();
6086 assert!(!Arc::ptr_eq(&a, &c));
6087 }
6088}