1#![doc = include_str!("../README.md")]
107#![deny(unsafe_code)]
108#![warn(rust_2018_idioms)]
109
110pub mod cli;
112
113pub(crate) mod hardened_git;
114
115mod handlers;
120
121#[cfg(feature = "git")]
123mod git_push;
124
125mod mcp;
128
129pub mod mempool;
134
135pub mod trail_store;
141
142use std::collections::HashMap;
143use std::net::{IpAddr, Ipv4Addr};
144use std::path::{Path, PathBuf};
145use std::sync::{Arc, Mutex};
146use std::time::{Duration, Instant};
147
148use actix_web::body::{BoxBody, EitherBody};
149use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
150use actix_web::http::{header, StatusCode};
151use actix_web::middleware::{NormalizePath, TrailingSlash};
152use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
153use bytes::Bytes;
154use futures_util::future::{ready, LocalBoxFuture, Ready};
155use futures_util::StreamExt;
156use percent_encoding::percent_decode_str;
157use serde::Deserialize;
158use solid_pod_rs::{
159 auth::{nip98, replay::ReplayStore},
163 config::sources::parse_size,
164 interop,
165 ldp::{
166 self, cache_control_for_response, LdpContainerOps, PatchCreateOutcome, ResponseAudience,
167 },
168 mashlib::{self, MashlibConfig},
169 provenance::{ProvenanceReceipt, ProvenanceSkip},
170 security::DotfileAllowlist,
171 storage::Storage,
172 wac::{
173 self, conditions::RequestContext, effective_acl_target, parse_jsonld_acl,
174 parser::parse_turtle_acl, protected_resource_for_acl, AccessMode,
175 },
176 PodError,
177};
178
179const _: () = solid_pod_rs::auth::nip98::assert_schnorr_verification_enabled();
191
192static NIP98_REPLAY: std::sync::LazyLock<solid_pod_rs::auth::replay::Nip98ReplayCache> =
199 std::sync::LazyLock::new(solid_pod_rs::auth::replay::Nip98ReplayCache::from_env);
200
201pub(crate) static PAYMENT_STATE_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
207 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
208
209#[derive(Clone)]
215pub struct AppState {
216 pub storage: Arc<dyn Storage>,
217 pub dotfiles: Arc<DotfileAllowlist>,
218 pub body_cap: usize,
219 pub nodeinfo: NodeInfoMeta,
220 pub mashlib: MashlibConfig,
221 pub mashlib_cdn: Option<String>,
224 pub live_reload: bool,
226 pub pay_config: solid_pod_rs::payments::PayConfig,
229 pub data_root: Option<PathBuf>,
234 pub quota: Option<Arc<dyn solid_pod_rs::quota::QuotaPolicy>>,
236 pub pod_create_limiter: Arc<PodCreateLimiter>,
238 pub allowed_origins: Vec<String>,
245 pub admin_key: Option<String>,
250 pub mcp_enabled: bool,
255 pub mempool_url: Option<String>,
261}
262
263#[cfg(test)]
264mod nip98_request_binding_tests {
265 use super::*;
266 use actix_web::test::TestRequest;
267
268 const SECRET: &str = "3333333333333333333333333333333333333333333333333333333333333333";
269
270 fn now() -> u64 {
271 std::time::SystemTime::now()
272 .duration_since(std::time::UNIX_EPOCH)
273 .unwrap()
274 .as_secs()
275 }
276
277 #[actix_web::test]
278 async fn authentication_binds_the_complete_query_string() {
279 let signed_url = "http://localhost:8080/private?include=true";
280 let token = nip98::mint(signed_url, "GET", SECRET, now()).unwrap();
281 let request = TestRequest::get()
282 .uri("/private?include=true")
283 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
284 .to_http_request();
285 assert!(extract_pubkey(&request).await.is_some());
286
287 let path_only = nip98::mint("http://localhost:8080/private", "GET", SECRET, now()).unwrap();
288 let request = TestRequest::get()
289 .uri("/private?include=true")
290 .insert_header((header::AUTHORIZATION, format!("Nostr {path_only}")))
291 .to_http_request();
292 assert!(extract_pubkey(&request).await.is_none());
293 }
294
295 #[actix_web::test]
296 async fn authentication_binds_the_exact_raw_body() {
297 let expected = br#"{"value":"authorised"}"#;
298 let token = nip98::mint_with_payload(
299 "http://localhost:8080/resource",
300 "PUT",
301 Some(expected),
302 SECRET,
303 now(),
304 )
305 .unwrap();
306 let request = TestRequest::put()
307 .uri("/resource")
308 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
309 .to_http_request();
310
311 assert!(
312 extract_pubkey_with_body(&request, Some(br#"{"value":"tampered"}"#))
313 .await
314 .is_none()
315 );
316 assert!(extract_pubkey_with_body(&request, Some(expected))
317 .await
318 .is_some());
319 }
320}
321
322#[derive(Clone, Debug)]
324pub struct NodeInfoMeta {
325 pub software_name: String,
326 pub software_version: String,
327 pub open_registrations: bool,
328 pub total_users: u64,
329 pub base_url: String,
330}
331
332impl Default for NodeInfoMeta {
333 fn default() -> Self {
334 Self {
335 software_name: "solid-pod-rs-server".to_string(),
336 software_version: env!("CARGO_PKG_VERSION").to_string(),
337 open_registrations: false,
338 total_users: 0,
339 base_url: "http://localhost".to_string(),
340 }
341 }
342}
343
344pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
347
348pub fn body_cap_from_env() -> usize {
353 match std::env::var("JSS_MAX_REQUEST_BODY").or_else(|_| std::env::var("JSS_BODY_LIMIT")) {
354 Ok(v) => parse_size(&v)
355 .map(|u| u as usize)
356 .unwrap_or(DEFAULT_BODY_CAP),
357 Err(_) => DEFAULT_BODY_CAP,
358 }
359}
360
361impl AppState {
362 pub fn new(storage: Arc<dyn Storage>) -> Self {
365 Self {
366 storage,
367 dotfiles: Arc::new(DotfileAllowlist::from_env()),
368 body_cap: body_cap_from_env(),
369 nodeinfo: NodeInfoMeta::default(),
370 mashlib: MashlibConfig::default(),
371 mashlib_cdn: None,
372 live_reload: false,
373 pay_config: solid_pod_rs::payments::PayConfig::default(),
374 data_root: None,
375 quota: None,
376 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
377 allowed_origins: Vec::new(),
378 admin_key: None,
379 mcp_enabled: false,
380 mempool_url: None,
381 }
382 }
383}
384
385const 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>";
386
387fn inject_live_reload(html: impl AsRef<[u8]>, enabled: bool) -> Vec<u8> {
388 let bytes = html.as_ref();
389 if !enabled {
390 return bytes.to_vec();
391 }
392 let html = String::from_utf8_lossy(bytes);
393 if let Some(index) = html.find("</body>") {
394 let mut out = String::with_capacity(html.len() + LIVE_RELOAD_SCRIPT.len());
395 out.push_str(&html[..index]);
396 out.push_str(LIVE_RELOAD_SCRIPT);
397 out.push_str(&html[index..]);
398 out.into_bytes()
399 } else {
400 format!("{html}{LIVE_RELOAD_SCRIPT}").into_bytes()
401 }
402}
403
404#[cfg(test)]
405mod live_reload_tests {
406 use super::*;
407
408 #[test]
409 fn injects_before_body_close_and_is_disabled_by_default() {
410 let html = b"<html><body>ok</body></html>";
411 assert_eq!(inject_live_reload(html, false), html);
412 let injected = String::from_utf8(inject_live_reload(html, true)).unwrap();
413 assert!(injected.contains("/.notifications"));
414 assert!(injected.find("<script>").unwrap() < injected.find("</body>").unwrap());
415 }
416}
417
418#[cfg(all(test, feature = "dev-bearer"))]
419mod dev_bearer_tests {
420 use super::*;
421 use base64::Engine as _;
422 use hmac::{Hmac, Mac};
423 use sha2::Sha256;
424 use std::sync::Mutex;
425
426 static ENV: Mutex<()> = Mutex::new(());
427
428 fn now() -> u64 {
429 std::time::SystemTime::now()
430 .duration_since(std::time::UNIX_EPOCH)
431 .unwrap()
432 .as_secs()
433 }
434
435 fn token_with(iat: u64, exp: u64, aud: Option<&str>) -> String {
436 let mut claims = serde_json::json!({"webId":"https://alice.example/profile/card#me","iat":iat,"exp":exp});
437 if let Some(a) = aud {
438 claims["aud"] = serde_json::json!(a);
439 }
440 let data = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string());
441 let mut mac = Hmac::<Sha256>::new_from_slice(b"01234567890123456789012345678901").unwrap();
442 mac.update(data.as_bytes());
443 let sig =
444 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
445 format!("{data}.{sig}")
446 }
447
448 fn token(exp: u64) -> String {
449 token_with(now().saturating_sub(10), exp, Some(DEV_BEARER_AUDIENCE))
450 }
451
452 #[test]
453 fn simple_bearer_verifies_signature_and_expiry() {
454 let _guard = ENV.lock().unwrap();
455 std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
456 assert_eq!(
457 verify_dev_bearer(&token(now() + 3600)).as_deref(),
458 Some("https://alice.example/profile/card#me")
459 );
460 assert!(verify_dev_bearer(&(token(now() + 3600) + "x")).is_none());
461 assert!(verify_dev_bearer(&token(2)).is_none());
462 std::env::remove_var("TOKEN_SECRET");
463 }
464
465 #[test]
466 fn dev_bearer_enforces_audience_and_max_lifetime() {
467 let _guard = ENV.lock().unwrap();
468 std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
469 let n = now();
470 assert!(verify_dev_bearer(&token_with(n, n + 60, None)).is_none());
472 assert!(verify_dev_bearer(&token_with(n, n + 60, Some("solid"))).is_none());
473 assert!(verify_dev_bearer(&token_with(
475 n,
476 n + DEV_BEARER_MAX_LIFETIME_SECS,
477 Some(DEV_BEARER_AUDIENCE)
478 ))
479 .is_some());
480 assert!(verify_dev_bearer(&token_with(
481 n,
482 n + DEV_BEARER_MAX_LIFETIME_SECS + 1,
483 Some(DEV_BEARER_AUDIENCE)
484 ))
485 .is_none());
486 assert!(verify_dev_bearer(&token_with(1, n + 3600, Some(DEV_BEARER_AUDIENCE))).is_none());
488 assert!(
489 verify_dev_bearer(&token_with(1, u64::MAX / 2, Some(DEV_BEARER_AUDIENCE))).is_none()
490 );
491 std::env::remove_var("TOKEN_SECRET");
492 }
493}
494
495#[derive(Debug)]
497pub struct PodCreateLimiter {
498 hits: Mutex<HashMap<IpAddr, Instant>>,
499 window: Duration,
500}
501
502impl Default for PodCreateLimiter {
503 fn default() -> Self {
504 Self {
505 hits: Mutex::new(HashMap::new()),
506 window: Duration::from_secs(24 * 60 * 60),
507 }
508 }
509}
510
511impl PodCreateLimiter {
512 fn check(&self, ip: IpAddr) -> Result<(), u64> {
513 let now = Instant::now();
514 let mut hits = self.hits.lock().unwrap();
515 if let Some(last) = hits.get(&ip).copied() {
516 let elapsed = now.saturating_duration_since(last);
517 if elapsed < self.window {
518 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
519 }
520 }
521 hits.insert(ip, now);
522 Ok(())
523 }
524}
525
526pub(crate) fn to_actix(e: PodError) -> ActixError {
531 match e {
532 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
533 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
534 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
535 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
536 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
537 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
538 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
539 }
540}
541
542pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
554 extract_pubkey_with_body(req, None).await
555}
556
557pub(crate) async fn extract_pubkey_with_body(
561 req: &HttpRequest,
562 body: Option<&[u8]>,
563) -> Option<String> {
564 let header_val = req
565 .headers()
566 .get(header::AUTHORIZATION)
567 .and_then(|v| v.to_str().ok())?;
568 if let Some(_token) = header_val.strip_prefix("Bearer ") {
569 #[cfg(feature = "dev-bearer")]
572 return verify_dev_bearer(_token);
573 #[cfg(not(feature = "dev-bearer"))]
574 return None;
575 }
576 let url = {
587 let conn = req.connection_info();
588 format!("{}://{}{}", conn.scheme(), conn.host(), req.uri())
589 };
590 let now = std::time::SystemTime::now()
591 .duration_since(std::time::UNIX_EPOCH)
592 .map(|d| d.as_secs())
593 .unwrap_or(0);
594 let verified = nip98::verify_at(header_val, &url, req.method().as_str(), body, now).ok()?;
595
596 if NIP98_REPLAY
600 .check_and_record(&verified.event_id)
601 .await
602 .is_err()
603 {
604 tracing::warn!(
605 pubkey = %verified.pubkey,
606 method = %req.method(),
607 "NIP-98 replay rejected: token id already used within window"
608 );
609 return None;
610 }
611
612 Some(verified.pubkey)
613}
614
615pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
616 pubkey.map(|pk| {
617 if pk.starts_with("https://") || pk.starts_with("http://") {
618 pk.clone()
619 } else {
620 format!("did:nostr:{pk}")
621 }
622 })
623}
624
625#[cfg(feature = "dev-bearer")]
627pub const DEV_BEARER_AUDIENCE: &str = "solid-pod-rs-dev-bearer";
628
629#[cfg(feature = "dev-bearer")]
632pub const DEV_BEARER_MAX_LIFETIME_SECS: u64 = 24 * 60 * 60;
633
634#[cfg(feature = "dev-bearer")]
642fn verify_dev_bearer(token: &str) -> Option<String> {
643 use base64::Engine as _;
644 use hmac::{Hmac, Mac};
645 use sha2::Sha256;
646
647 let secret = std::env::var("TOKEN_SECRET").ok()?;
648 if secret.len() < 32 {
649 tracing::warn!("TOKEN_SECRET shorter than 32 bytes; refusing development bearer token");
650 return None;
651 }
652 let (data, signature) = token.split_once('.')?;
653 if signature.contains('.') {
654 return None;
655 }
656 let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
657 .decode(signature)
658 .ok()?;
659 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
660 mac.update(data.as_bytes());
661 mac.verify_slice(&signature).ok()?;
662
663 #[derive(serde::Deserialize)]
664 #[serde(rename_all = "camelCase")]
665 struct Claims {
666 web_id: String,
667 iat: u64,
668 exp: u64,
669 #[serde(default)]
670 aud: Option<String>,
671 }
672 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
673 .decode(data)
674 .ok()?;
675 let claims: Claims = serde_json::from_slice(&payload).ok()?;
676 let now = std::time::SystemTime::now()
677 .duration_since(std::time::UNIX_EPOCH)
678 .ok()?
679 .as_secs();
680 if claims.aud.as_deref() != Some(DEV_BEARER_AUDIENCE) {
681 return None;
682 }
683 if claims.exp < now || claims.iat > now.saturating_add(60) || claims.exp <= claims.iat {
684 return None;
685 }
686 if claims.exp - claims.iat > DEV_BEARER_MAX_LIFETIME_SECS
687 || claims.exp - now > DEV_BEARER_MAX_LIFETIME_SECS
688 {
689 return None;
690 }
691 let url = url::Url::parse(&claims.web_id).ok()?;
692 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
693 return None;
694 }
695 Some(claims.web_id)
696}
697
698fn req_origin(req: &HttpRequest) -> Option<&str> {
707 req.headers()
708 .get(header::ORIGIN)
709 .and_then(|v| v.to_str().ok())
710}
711
712pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
716
717async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
734 let did = agent_uri?;
735 let balance = match storage.get(WEBLEDGER_PATH).await {
736 Ok((bytes, _meta)) => {
737 match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
738 Ok(ledger) => ledger.get_balance(did),
739 Err(_) => 0,
743 }
744 }
745 Err(_) => 0,
748 };
749 Some(balance)
750}
751
752fn accept_includes_html(accept: &str) -> bool {
760 accept.split(',').any(|entry| {
761 let mime = entry.split(';').next().unwrap_or("").trim();
762 mime.eq_ignore_ascii_case("text/html")
763 })
764}
765
766pub(crate) fn guarded_acl_target(path: &str, mode: AccessMode) -> (String, AccessMode) {
785 let (resource, mode) = effective_acl_target(path, mode);
786 match canonical_pod_path(&resource) {
790 Some(canon)
791 if !trail_store::is_secret_trail_path(&canon)
792 && !trail_store::is_prov_state_path(&canon)
793 && !trail_store::is_server_state_path(&canon) =>
794 {
795 (resource, mode)
796 }
797 _ => (resource, AccessMode::Control),
798 }
799}
800
801pub(crate) fn canonical_pod_path(raw: &str) -> Option<String> {
809 let decoded = percent_decode_str(raw).decode_utf8().ok()?;
810 if decoded.contains(['\\', '\0']) {
811 return None;
812 }
813 let mut out = String::with_capacity(decoded.len() + 1);
814 for seg in decoded.split('/') {
815 match seg {
816 "" => {}
817 "." | ".." => return None,
818 _ => {
819 out.push('/');
820 out.push_str(seg);
821 }
822 }
823 }
824 if out.is_empty() || decoded.ends_with('/') {
825 out.push('/');
826 }
827 Some(out)
828}
829
830fn proposed_acl_keeps_caller_control(
838 body: &[u8],
839 content_type: &str,
840 caller: Option<&str>,
841) -> bool {
842 let doc = match parse_jsonld_acl(body) {
843 Ok(d) => Some(d),
844 Err(_) => {
845 let ct = content_type.to_ascii_lowercase();
846 let text = std::str::from_utf8(body).unwrap_or("");
847 let looks_turtle = ct.starts_with("text/turtle")
848 || ct.starts_with("application/turtle")
849 || ct.starts_with("application/x-turtle")
850 || ct.starts_with("application/n-triples")
851 || text.contains("@prefix")
852 || text.contains("acl:Authorization")
853 || text.contains("auth/acl#Authorization");
857 if looks_turtle {
858 parse_turtle_acl(text).ok()
859 } else {
860 None
861 }
862 }
863 };
864 let Some(doc) = doc else {
865 return true;
867 };
868 let Some(graph) = doc.graph.as_ref() else {
869 return false;
870 };
871 graph.iter().any(|auth| {
872 let grants_control = ids_of_acl_field(&auth.mode)
873 .iter()
874 .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
875 if !grants_control {
876 return false;
877 }
878 let agents = ids_of_acl_field(&auth.agent);
879 if let Some(web_id) = caller {
880 if agents.contains(&web_id) {
881 return true;
882 }
883 }
884 let classes = ids_of_acl_field(&auth.agent_class);
885 if classes
886 .iter()
887 .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
888 {
889 return true;
890 }
891 if caller.is_some()
892 && classes.iter().any(|c| {
893 *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
894 || *c == "acl:AuthenticatedAgent"
895 })
896 {
897 return true;
898 }
899 false
900 })
901}
902
903fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
905 match field {
906 None => Vec::new(),
907 Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
908 Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
909 }
910}
911
912#[cfg_attr(not(test), allow(dead_code))]
919async fn enforce_write(
920 state: &AppState,
921 path: &str,
922 mode: AccessMode,
923 agent_uri: Option<&str>,
924) -> Result<(), ActixError> {
925 enforce_write_ctx(state, path, mode, agent_uri, None).await
926}
927
928async fn enforce_write_ctx(
936 state: &AppState,
937 path: &str,
938 mode: AccessMode,
939 agent_uri: Option<&str>,
940 request_origin: Option<&str>,
941) -> Result<(), ActixError> {
942 let origin = request_origin.and_then(wac::Origin::parse);
943 let (resource, eff_mode) = guarded_acl_target(path, mode);
955
956 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
964 if outcome.is_failure() {
965 return Err(policy_failure_to_actix(&outcome, &resource));
966 }
967 let acl_doc = outcome.document().cloned();
968
969 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
974
975 let ctx = RequestContext {
976 web_id: agent_uri,
977 client_id: None,
978 issuer: None,
979 payment_balance_sats,
980 };
981 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
982 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
983 let granted = wac::evaluate_access_ctx_with_registry(
984 acl_doc.as_ref(),
985 &ctx,
986 &resource,
987 eff_mode,
988 origin.as_ref(),
989 &groups,
990 ®istry,
991 );
992 if !granted {
993 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
994 }
995 if resource.as_str() == path {
1002 charge_granted_payment(
1008 state,
1009 acl_doc.as_ref(),
1010 &ctx,
1011 &resource,
1012 eff_mode,
1013 &groups,
1014 ®istry,
1015 )
1016 .await?;
1017 }
1018 Ok(())
1019}
1020
1021async fn charge_granted_payment(
1030 state: &AppState,
1031 acl_doc: Option<&wac::AclDocument>,
1032 ctx: &RequestContext<'_>,
1033 path: &str,
1034 mode: AccessMode,
1035 groups: &wac::StaticGroupMembership,
1036 registry: &wac::conditions::ConditionRegistry,
1037) -> Result<(), ActixError> {
1038 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
1039 if cost == 0 {
1040 return Ok(());
1041 }
1042 if let Some(did) = ctx.web_id {
1043 if debit_ledger(&*state.storage, did, cost).await.is_err() {
1044 return Err(acl_denial(acl_doc, ctx.web_id, path));
1045 }
1046 }
1047 Ok(())
1048}
1049
1050fn acl_denial(
1056 acl_doc: Option<&wac::AclDocument>,
1057 agent_uri: Option<&str>,
1058 path: &str,
1059) -> ActixError {
1060 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
1061 let (status, body, unauthenticated) = if agent_uri.is_none() {
1062 (StatusCode::UNAUTHORIZED, "authentication required", true)
1063 } else {
1064 (StatusCode::FORBIDDEN, "access forbidden", false)
1065 };
1066 let mut rsp = HttpResponse::new(status);
1067 rsp.headers_mut().insert(
1068 header::HeaderName::from_static("wac-allow"),
1069 header::HeaderValue::from_str(&allow_header)
1070 .unwrap_or(header::HeaderValue::from_static("")),
1071 );
1072 if unauthenticated {
1073 rsp.headers_mut().insert(
1080 header::WWW_AUTHENTICATE,
1081 header::HeaderValue::from_static(
1082 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
1083 ),
1084 );
1085 }
1086 actix_web::error::InternalError::from_response(body, rsp).into()
1087}
1088
1089#[cfg_attr(not(test), allow(dead_code))]
1100async fn enforce_read(
1101 state: &AppState,
1102 path: &str,
1103 agent_uri: Option<&str>,
1104) -> Result<ResponseAudience, ActixError> {
1105 enforce_read_ctx(state, path, agent_uri, None).await
1106}
1107
1108async fn enforce_read_ctx(
1111 state: &AppState,
1112 path: &str,
1113 agent_uri: Option<&str>,
1114 request_origin: Option<&str>,
1115) -> Result<ResponseAudience, ActixError> {
1116 enforce_read_inner(state, path, agent_uri, request_origin, true).await
1117}
1118
1119async fn enforce_read_inner(
1124 state: &AppState,
1125 path: &str,
1126 agent_uri: Option<&str>,
1127 request_origin: Option<&str>,
1128 charge: bool,
1129) -> Result<ResponseAudience, ActixError> {
1130 let origin = request_origin.and_then(wac::Origin::parse);
1131 let (resource, eff_mode) = guarded_acl_target(path, AccessMode::Read);
1143 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
1146 if outcome.is_failure() {
1147 return Err(policy_failure_to_actix(&outcome, &resource));
1148 }
1149 let acl_doc = outcome.document().cloned();
1150 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
1151 let ctx = RequestContext {
1152 web_id: agent_uri,
1153 client_id: None,
1154 issuer: None,
1155 payment_balance_sats,
1156 };
1157 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
1158 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
1159 let granted = wac::evaluate_access_ctx_with_registry(
1160 acl_doc.as_ref(),
1161 &ctx,
1162 &resource,
1163 eff_mode,
1164 origin.as_ref(),
1165 &groups,
1166 ®istry,
1167 );
1168 if !granted {
1169 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1170 }
1171 if resource.as_str() == path && !charge {
1175 if wac::granted_payment_cost(
1176 acl_doc.as_ref(),
1177 &ctx,
1178 &resource,
1179 eff_mode,
1180 &groups,
1181 ®istry,
1182 ) > 0
1183 {
1184 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1185 }
1186 } else if resource.as_str() == path {
1187 charge_granted_payment(
1192 state,
1193 acl_doc.as_ref(),
1194 &ctx,
1195 &resource,
1196 eff_mode,
1197 &groups,
1198 ®istry,
1199 )
1200 .await?;
1201 }
1202 let anon_ctx = RequestContext {
1211 web_id: None,
1212 client_id: None,
1213 issuer: None,
1214 payment_balance_sats: None,
1215 };
1216 let anonymous_would_be_granted = wac::evaluate_access_ctx_with_registry(
1217 acl_doc.as_ref(),
1218 &anon_ctx,
1219 &resource,
1220 eff_mode,
1221 origin.as_ref(),
1222 &groups,
1223 ®istry,
1224 );
1225 Ok(if anonymous_would_be_granted && resource.as_str() == path {
1226 ResponseAudience::Public
1227 } else {
1228 ResponseAudience::Private
1229 })
1230}
1231
1232async fn debit_ledger(
1241 storage: &dyn Storage,
1242 did: &str,
1243 cost: u64,
1244) -> Result<(), solid_pod_rs::payments::PaymentError> {
1245 use solid_pod_rs::payments::{PaymentError, WebLedger};
1246 let _transaction = PAYMENT_STATE_LOCK.lock().await;
1247
1248 let (bytes, _meta) = storage
1249 .get(WEBLEDGER_PATH)
1250 .await
1251 .map_err(|e| PaymentError::Store(e.to_string()))?;
1252 let mut ledger: WebLedger = serde_json::from_slice(&bytes)
1253 .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
1254 let _charge = ledger.charge(did, cost)?;
1256 let body = serde_json::to_vec(&ledger)
1257 .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
1258 storage
1259 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
1260 .await
1261 .map_err(|e| PaymentError::Store(e.to_string()))?;
1262 Ok(())
1263}
1264
1265fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
1270 let links = ldp::link_headers(path).join(", ");
1271 if let Ok(value) = header::HeaderValue::from_str(&links) {
1272 rsp.headers_mut()
1273 .insert(header::HeaderName::from_static("link"), value);
1274 }
1275}
1276
1277fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
1278 if let Ok(v) = header::HeaderValue::from_str(header_value) {
1279 rsp.headers_mut()
1280 .insert(header::HeaderName::from_static("wac-allow"), v);
1281 }
1282}
1283
1284fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
1285 let ws_base = base_url
1286 .replacen("https://", "wss://", 1)
1287 .replacen("http://", "ws://", 1);
1288 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
1289 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
1290 rsp.headers_mut()
1291 .insert(header::HeaderName::from_static("updates-via"), v);
1292 }
1293}
1294
1295fn set_provenance_headers(rsp: &mut HttpResponse, receipt: &ProvenanceReceipt) {
1306 if let Ok(v) = header::HeaderValue::from_str(&receipt.summary()) {
1307 rsp.headers_mut()
1308 .insert(header::HeaderName::from_static("x-provenance"), v);
1309 }
1310 if let Some(sha) = receipt.commit_sha() {
1311 if let Ok(v) = header::HeaderValue::from_str(sha) {
1312 rsp.headers_mut()
1313 .insert(header::HeaderName::from_static("x-provenance-commit"), v);
1314 }
1315 }
1316}
1317
1318fn set_cache_policy(rsp: &mut HttpResponse, content_type: &str, audience: ResponseAudience) {
1330 if audience == ResponseAudience::Private {
1331 append_vary(rsp, "Authorization");
1335 }
1336 if rsp.headers().contains_key(header::CACHE_CONTROL) {
1337 return;
1338 }
1339 if let Some(value) = cache_control_for_response(content_type, audience) {
1340 rsp.headers_mut().insert(
1341 header::CACHE_CONTROL,
1342 header::HeaderValue::from_static(value),
1343 );
1344 }
1345}
1346
1347fn append_vary(rsp: &mut HttpResponse, field: &str) {
1349 let existing = rsp
1350 .headers()
1351 .get(header::VARY)
1352 .and_then(|v| v.to_str().ok())
1353 .unwrap_or("")
1354 .to_string();
1355 if existing
1356 .split(',')
1357 .any(|f| f.trim().eq_ignore_ascii_case(field))
1358 {
1359 return;
1360 }
1361 let merged = if existing.trim().is_empty() {
1362 field.to_string()
1363 } else {
1364 format!("{existing}, {field}")
1365 };
1366 if let Ok(v) = header::HeaderValue::from_str(&merged) {
1367 rsp.headers_mut().insert(header::VARY, v);
1368 }
1369}
1370
1371async fn handle_get(
1372 req: HttpRequest,
1373 state: web::Data<AppState>,
1374) -> Result<HttpResponse, ActixError> {
1375 let path = req.uri().path().to_string();
1376
1377 if path.contains('*') {
1378 return handle_glob_get(req, state).await;
1379 }
1380
1381 let auth_pk = extract_pubkey(&req).await;
1382 let agent = agent_uri(auth_pk.as_ref());
1383
1384 let audience = enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
1389
1390 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
1391
1392 if ldp::is_container(&path) {
1393 let accept = req
1394 .headers()
1395 .get(header::ACCEPT)
1396 .and_then(|v| v.to_str().ok())
1397 .unwrap_or("");
1398
1399 if accept_includes_html(accept) {
1405 let index_path = format!("{path}index.html");
1406 let index_audience =
1409 enforce_read_ctx(&state, &index_path, agent.as_deref(), req_origin(&req))
1410 .await
1411 .ok();
1412 if let (Some(index_audience), Ok((body, _meta))) =
1413 (index_audience, state.storage.get(&index_path).await)
1414 {
1415 let mut rsp = HttpResponse::Ok()
1416 .content_type("text/html; charset=utf-8")
1417 .body(inject_live_reload(&body, state.live_reload));
1418 set_cache_policy(&mut rsp, "text/html", index_audience);
1419 set_wac_allow(&mut rsp, &wac_allow);
1420 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1421 set_link_headers(&mut rsp, &path);
1422 return Ok(rsp);
1423 }
1424 }
1425
1426 let v = state
1427 .storage
1428 .container_representation(&path)
1429 .await
1430 .map_err(to_actix)?;
1431
1432 let sec_fetch_dest = req
1434 .headers()
1435 .get("sec-fetch-dest")
1436 .and_then(|v| v.to_str().ok());
1437 if mashlib::should_serve(
1438 accept,
1439 sec_fetch_dest,
1440 "application/ld+json",
1441 state.mashlib.enabled,
1442 ) {
1443 let json_ld = serde_json::to_string(&v).ok();
1444 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
1445 let mut rsp = HttpResponse::Ok()
1446 .content_type("text/html; charset=utf-8")
1447 .insert_header(("X-Frame-Options", "DENY"))
1448 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1449 .insert_header(("Cache-Control", "no-store"))
1450 .body(inject_live_reload(html, state.live_reload));
1451 set_cache_policy(&mut rsp, "text/html", audience);
1452 set_wac_allow(&mut rsp, &wac_allow);
1453 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1454 set_link_headers(&mut rsp, &path);
1455 return Ok(rsp);
1456 }
1457
1458 let mut rsp = HttpResponse::Ok().json(v);
1459 rsp.headers_mut().insert(
1460 header::CONTENT_TYPE,
1461 header::HeaderValue::from_static("application/ld+json"),
1462 );
1463 set_cache_policy(&mut rsp, "application/ld+json", audience);
1464 set_wac_allow(&mut rsp, &wac_allow);
1465 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1466 set_link_headers(&mut rsp, &path);
1467 return Ok(rsp);
1468 }
1469
1470 match state.storage.get(&path).await {
1471 Ok((body, meta)) => {
1472 let accept = req
1474 .headers()
1475 .get(header::ACCEPT)
1476 .and_then(|v| v.to_str().ok())
1477 .unwrap_or("");
1478 let sec_fetch_dest = req
1479 .headers()
1480 .get("sec-fetch-dest")
1481 .and_then(|v| v.to_str().ok());
1482 if mashlib::should_serve(
1483 accept,
1484 sec_fetch_dest,
1485 &meta.content_type,
1486 state.mashlib.enabled,
1487 ) {
1488 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
1489 std::str::from_utf8(&body).ok().map(|s| s.to_string())
1490 } else {
1491 None
1492 };
1493 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
1494 let mut rsp = HttpResponse::Ok()
1495 .content_type("text/html; charset=utf-8")
1496 .insert_header(("X-Frame-Options", "DENY"))
1497 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1498 .insert_header(("Cache-Control", "no-store"))
1499 .body(inject_live_reload(html, state.live_reload));
1500 set_cache_policy(&mut rsp, "text/html", audience);
1501 set_wac_allow(&mut rsp, &wac_allow);
1502 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1503 set_link_headers(&mut rsp, &path);
1504 return Ok(rsp);
1505 }
1506
1507 if let Some((negotiated_body, negotiated_ct)) =
1515 rdf_content_negotiate(&body, &meta.content_type, accept)
1516 {
1517 let mut rsp = HttpResponse::Ok().body(negotiated_body);
1518 rsp.headers_mut().insert(
1519 header::CONTENT_TYPE,
1520 header::HeaderValue::from_str(negotiated_ct)
1521 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
1522 );
1523 rsp.headers_mut()
1524 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
1525 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1526 rsp.headers_mut().insert(header::ETAG, etag);
1527 }
1528 set_cache_policy(&mut rsp, negotiated_ct, audience);
1529 set_wac_allow(&mut rsp, &wac_allow);
1530 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1531 set_link_headers(&mut rsp, &path);
1532 return Ok(rsp);
1533 }
1534
1535 let response_body = if meta.content_type.starts_with("text/html") {
1536 inject_live_reload(&body, state.live_reload)
1537 } else {
1538 body.to_vec()
1539 };
1540 let mut rsp = HttpResponse::Ok().body(response_body);
1541 rsp.headers_mut().insert(
1542 header::CONTENT_TYPE,
1543 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
1544 header::HeaderValue::from_static("application/octet-stream")
1545 }),
1546 );
1547 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1548 rsp.headers_mut().insert(header::ETAG, etag);
1549 }
1550 set_cache_policy(&mut rsp, &meta.content_type, audience);
1551 set_wac_allow(&mut rsp, &wac_allow);
1552 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1553 set_link_headers(&mut rsp, &path);
1554 Ok(rsp)
1555 }
1556 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1557 Err(e) => Err(to_actix(e)),
1558 }
1559}
1560
1561fn has_basic_container_link(req: &HttpRequest) -> bool {
1562 req.headers()
1563 .get_all(header::LINK)
1564 .filter_map(|v| v.to_str().ok())
1565 .any(|v| {
1566 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
1567 })
1568}
1569
1570async fn handle_put(
1571 req: HttpRequest,
1572 body: web::Bytes,
1573 state: web::Data<AppState>,
1574) -> Result<HttpResponse, ActixError> {
1575 let path = req.uri().path().to_string();
1576
1577 if ldp::is_container(&path) {
1578 if has_basic_container_link(&req) {
1579 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1580 let agent = agent_uri(auth_pk.as_ref());
1581 enforce_write_ctx(
1582 &state,
1583 &path,
1584 AccessMode::Write,
1585 agent.as_deref(),
1586 req_origin(&req),
1587 )
1588 .await?;
1589 let meta = state
1590 .storage
1591 .create_container(&path)
1592 .await
1593 .map_err(to_actix)?;
1594 let mut rsp = HttpResponse::Created().finish();
1595 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1596 rsp.headers_mut().insert(header::ETAG, etag);
1597 }
1598 set_link_headers(&mut rsp, &path);
1599 return Ok(rsp);
1600 }
1601 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
1602 }
1603
1604 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1605 let agent = agent_uri(auth_pk.as_ref());
1606 enforce_write_ctx(
1607 &state,
1608 &path,
1609 AccessMode::Write,
1610 agent.as_deref(),
1611 req_origin(&req),
1612 )
1613 .await?;
1614
1615 let ct = req
1616 .headers()
1617 .get(header::CONTENT_TYPE)
1618 .and_then(|v| v.to_str().ok())
1619 .unwrap_or("application/octet-stream");
1620
1621 if protected_resource_for_acl(&path).is_some()
1626 && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
1627 {
1628 return Ok(HttpResponse::Conflict().body(
1629 "refused: the proposed ACL would not grant Control to the caller \
1630 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1631 ));
1632 }
1633
1634 let quota = reserve_quota_for_size(&state, &path, body.len() as u64).await?;
1635 let write = state
1636 .storage
1637 .put(&path, Bytes::from(body.to_vec()), ct)
1638 .await;
1639 finish_quota_reservation(&state, quota, write.is_ok()).await;
1640 let meta = write.map_err(to_actix)?;
1641 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
1645 let mut rsp = HttpResponse::Created().finish();
1646 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1647 rsp.headers_mut().insert(header::ETAG, etag);
1648 }
1649 set_provenance_headers(&mut rsp, &provenance);
1650 set_link_headers(&mut rsp, &path);
1651 Ok(rsp)
1652}
1653
1654async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
1661 if !storage.exists(target).await.unwrap_or(false) {
1662 return target.to_string();
1663 }
1664 let seg_start = target.rfind('/').map(|s| s + 1).unwrap_or(0);
1667 let (stem, ext) = match target.rfind('.') {
1668 Some(dot) if dot > seg_start => (&target[..dot], &target[dot..]),
1669 _ => (target, ""),
1670 };
1671 for n in 1..10_000u32 {
1672 let candidate = format!("{stem}-{n}{ext}");
1673 if !storage.exists(&candidate).await.unwrap_or(false) {
1674 return candidate;
1675 }
1676 }
1677 use std::hash::{Hash, Hasher};
1678 let mut h = std::collections::hash_map::DefaultHasher::new();
1679 target.hash(&mut h);
1680 format!("{stem}-{:x}{ext}", h.finish())
1681}
1682
1683async fn handle_post(
1684 req: HttpRequest,
1685 body: web::Bytes,
1686 state: web::Data<AppState>,
1687) -> Result<HttpResponse, ActixError> {
1688 let path = req.uri().path().to_string();
1689 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1692 let agent = agent_uri(auth_pk.as_ref());
1693 enforce_write_ctx(
1694 &state,
1695 &path,
1696 AccessMode::Append,
1697 agent.as_deref(),
1698 req_origin(&req),
1699 )
1700 .await?;
1701
1702 let slug = req
1703 .headers()
1704 .get(header::HeaderName::from_static("slug"))
1705 .and_then(|v| v.to_str().ok());
1706 let mut target = match ldp::resolve_slug(&path, slug) {
1707 Ok(p) => p,
1708 Err(e) => return Err(to_actix(e)),
1709 };
1710 let ct = req
1711 .headers()
1712 .get(header::CONTENT_TYPE)
1713 .and_then(|v| v.to_str().ok())
1714 .unwrap_or("application/octet-stream");
1715
1716 if protected_resource_for_acl(&target).is_some() {
1725 enforce_write_ctx(
1726 &state,
1727 &target,
1728 AccessMode::Write,
1729 agent.as_deref(),
1730 req_origin(&req),
1731 )
1732 .await?;
1733 if !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref()) {
1734 return Ok(HttpResponse::Conflict().body(
1735 "refused: the proposed ACL would not grant Control to the caller \
1736 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1737 ));
1738 }
1739 } else {
1740 target = mint_unique_target(&*state.storage, &target).await;
1746 }
1747
1748 let quota = reserve_quota_for_size(&state, &target, body.len() as u64).await?;
1749 let write = state
1750 .storage
1751 .put(&target, Bytes::from(body.to_vec()), ct)
1752 .await;
1753 finish_quota_reservation(&state, quota, write.is_ok()).await;
1754 let meta = write.map_err(to_actix)?;
1755 let provenance = git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1758 let mut rsp = HttpResponse::Created().finish();
1759 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1760 rsp.headers_mut().insert(header::LOCATION, loc);
1761 }
1762 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1763 rsp.headers_mut().insert(header::ETAG, etag);
1764 }
1765 set_provenance_headers(&mut rsp, &provenance);
1766 set_link_headers(&mut rsp, &target);
1767 Ok(rsp)
1768}
1769
1770async fn handle_patch(
1771 req: HttpRequest,
1772 body: web::Bytes,
1773 state: web::Data<AppState>,
1774) -> Result<HttpResponse, ActixError> {
1775 let path = req.uri().path().to_string();
1776 if ldp::is_container(&path) {
1777 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1778 }
1779 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1780 let agent = agent_uri(auth_pk.as_ref());
1781 enforce_write_ctx(
1787 &state,
1788 &path,
1789 AccessMode::Write,
1790 agent.as_deref(),
1791 req_origin(&req),
1792 )
1793 .await?;
1794
1795 let ct = req
1796 .headers()
1797 .get(header::CONTENT_TYPE)
1798 .and_then(|v| v.to_str().ok())
1799 .unwrap_or("");
1800 let dialect = match ldp::patch_dialect_from_mime(ct) {
1801 Some(d) => d,
1802 None => {
1803 return Ok(HttpResponse::UnsupportedMediaType()
1804 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1805 }
1806 };
1807 let body_str = match std::str::from_utf8(&body) {
1808 Ok(s) => s.to_string(),
1809 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1810 };
1811
1812 let existing = state.storage.get(&path).await;
1814 match existing {
1815 Ok((current_body, meta)) => {
1816 let out = match dialect {
1826 ldp::PatchDialect::N3 => {
1827 let seed = seed_graph_from_patch_target(¤t_body)?;
1828 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1829 }
1830 ldp::PatchDialect::SparqlUpdate => {
1831 let seed = seed_graph_from_patch_target(¤t_body)?;
1832 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1833 }
1834 ldp::PatchDialect::JsonPatch => {
1835 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1836 Ok(v) => v,
1837 Err(_) => serde_json::json!({}),
1838 };
1839 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1840 Ok(v) => v,
1841 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1842 };
1843 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1844 let bytes = serde_json::to_vec(&json)
1845 .map_err(PodError::from)
1846 .map_err(to_actix)?;
1847 let quota = reserve_quota_for_size(&state, &path, bytes.len() as u64).await?;
1848 let write = state
1849 .storage
1850 .put(&path, Bytes::from(bytes), &meta.content_type)
1851 .await;
1852 finish_quota_reservation(&state, quota, write.is_ok()).await;
1853 let _ = write.map_err(to_actix)?;
1854 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1855 let mut rsp = HttpResponse::NoContent().finish();
1856 set_provenance_headers(&mut rsp, &provenance);
1857 return Ok(rsp);
1858 }
1859 };
1860 let outcome = out?;
1861 let serialised = graph_to_turtle(&outcome.graph);
1864 if protected_resource_for_acl(&path).is_some()
1870 && !proposed_acl_keeps_caller_control(
1871 serialised.as_bytes(),
1872 "application/n-triples",
1873 agent.as_deref(),
1874 )
1875 {
1876 return Ok(HttpResponse::Conflict().body(
1877 "refused: the patched ACL would not grant Control to the caller \
1878 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1879 ));
1880 }
1881 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
1882 let write = state
1883 .storage
1884 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1885 .await;
1886 finish_quota_reservation(&state, quota, write.is_ok()).await;
1887 let _ = write.map_err(to_actix)?;
1888 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1889 let mut rsp = HttpResponse::NoContent().finish();
1890 set_provenance_headers(&mut rsp, &provenance);
1891 Ok(rsp)
1892 }
1893 Err(PodError::NotFound(_)) => {
1894 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
1896 let PatchCreateOutcome::Created { graph, .. } = create else {
1897 return Err(to_actix(PodError::Unsupported(
1898 "unexpected patch outcome on absent resource".into(),
1899 )));
1900 };
1901 let serialised = graph_to_turtle(&graph);
1902 if protected_resource_for_acl(&path).is_some()
1904 && !proposed_acl_keeps_caller_control(
1905 serialised.as_bytes(),
1906 "application/n-triples",
1907 agent.as_deref(),
1908 )
1909 {
1910 return Ok(HttpResponse::Conflict().body(
1911 "refused: the patched ACL would not grant Control to the caller \
1912 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1913 ));
1914 }
1915 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
1916 let write = state
1917 .storage
1918 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1919 .await;
1920 finish_quota_reservation(&state, quota, write.is_ok()).await;
1921 let _ = write.map_err(to_actix)?;
1922 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1923 let mut rsp = HttpResponse::Created().finish();
1924 set_provenance_headers(&mut rsp, &provenance);
1925 Ok(rsp)
1926 }
1927 Err(e) => Err(to_actix(e)),
1928 }
1929}
1930
1931fn patch_parse_err(e: PodError) -> ActixError {
1935 match e {
1936 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
1937 actix_web::error::ErrorBadRequest(msg)
1938 }
1939 other => to_actix(other),
1940 }
1941}
1942
1943fn graph_to_turtle(g: &ldp::Graph) -> String {
1947 g.to_ntriples()
1948}
1949
1950fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
1957 let mut best: Option<(f32, ldp::RdfFormat)> = None;
1958 for entry in accept.split(',') {
1959 let entry = entry.trim();
1960 if entry.is_empty() {
1961 continue;
1962 }
1963 let mut parts = entry.split(';').map(|s| s.trim());
1964 let mime = match parts.next() {
1965 Some(m) => m,
1966 None => continue,
1967 };
1968 let mut q: f32 = 1.0;
1969 for token in parts {
1970 if let Some(v) = token.strip_prefix("q=") {
1971 if let Ok(parsed) = v.parse::<f32>() {
1972 q = parsed;
1973 }
1974 }
1975 }
1976 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
1979 match best {
1980 None => best = Some((q, format)),
1981 Some((bq, _)) if q > bq => best = Some((q, format)),
1982 _ => {}
1983 }
1984 }
1985 }
1986 best.map(|(_, f)| f)
1987}
1988
1989fn rdf_content_negotiate(
2005 body: &[u8],
2006 stored_ct: &str,
2007 accept: &str,
2008) -> Option<(Vec<u8>, &'static str)> {
2009 if accept.trim().is_empty() {
2010 return None;
2011 }
2012 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
2013 let target = best_explicit_rdf_format(accept)?;
2014 if target == stored_format {
2015 return None;
2016 }
2017 let text = std::str::from_utf8(body).ok()?;
2018 let graph = ldp::Graph::parse_ntriples(text).ok()?;
2019 match target {
2020 ldp::RdfFormat::Turtle => Some((
2023 graph.to_ntriples().into_bytes(),
2024 ldp::RdfFormat::Turtle.mime(),
2025 )),
2026 ldp::RdfFormat::NTriples => Some((
2027 graph.to_ntriples().into_bytes(),
2028 ldp::RdfFormat::NTriples.mime(),
2029 )),
2030 ldp::RdfFormat::JsonLd => {
2031 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
2032 Some((json, ldp::RdfFormat::JsonLd.mime()))
2033 }
2034 ldp::RdfFormat::RdfXml => None,
2036 }
2037}
2038
2039fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
2048 let text = std::str::from_utf8(current_body).map_err(|_| {
2049 actix_web::error::ErrorConflict(
2050 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
2051 )
2052 })?;
2053 if text.trim().is_empty() {
2054 return Ok(ldp::Graph::new());
2055 }
2056 ldp::Graph::parse_ntriples(text).map_err(|_| {
2057 actix_web::error::ErrorConflict(
2058 "existing resource is not N-Triples RDF and cannot be non-destructively \
2059 patched; PUT an N-Triples representation or use a JSON Patch",
2060 )
2061 })
2062}
2063
2064pub(crate) async fn find_effective_acl_dyn(
2080 storage: &dyn Storage,
2081 resource_path: &str,
2082) -> Result<Option<wac::AclDocument>, PodError> {
2083 resolve_policy_dyn(storage, resource_path)
2084 .await
2085 .into_result()
2086}
2087
2088pub(crate) async fn resolve_policy_dyn(
2093 storage: &dyn Storage,
2094 resource_path: &str,
2095) -> wac::PolicyOutcome {
2096 wac::resolve_policy_from_storage(storage, resource_path).await
2097}
2098
2099fn policy_failure_to_actix(outcome: &wac::PolicyOutcome, resource: &str) -> ActixError {
2109 match outcome {
2110 wac::PolicyOutcome::Invalid {
2111 policy_path,
2112 reason,
2113 } => {
2114 tracing::warn!(
2115 target: "solid_pod_rs_server::wac",
2116 resource = %resource,
2117 policy = %policy_path,
2118 "denying: effective ACL is invalid and MUST NOT inherit: {reason}"
2119 );
2120 actix_web::error::ErrorForbidden("access forbidden: governing ACL is invalid")
2121 }
2122 wac::PolicyOutcome::Unavailable {
2123 policy_path,
2124 detail,
2125 } => {
2126 tracing::error!(
2127 target: "solid_pod_rs_server::wac",
2128 resource = %resource,
2129 policy = %policy_path,
2130 "denying: effective ACL could not be read: {detail}"
2131 );
2132 actix_web::error::ErrorServiceUnavailable(
2133 "access control unavailable: governing ACL could not be read",
2134 )
2135 }
2136 _ => actix_web::error::ErrorInternalServerError("policy resolution error"),
2138 }
2139}
2140
2141async fn handle_delete(
2142 req: HttpRequest,
2143 state: web::Data<AppState>,
2144) -> Result<HttpResponse, ActixError> {
2145 let path = req.uri().path().to_string();
2146 let auth_pk = extract_pubkey(&req).await;
2147 let agent = agent_uri(auth_pk.as_ref());
2148 enforce_write_ctx(
2149 &state,
2150 &path,
2151 AccessMode::Write,
2152 agent.as_deref(),
2153 req_origin(&req),
2154 )
2155 .await?;
2156
2157 let quota = reserve_quota_for_size(&state, &path, 0).await?;
2158 match state.storage.delete(&path).await {
2159 Ok(()) => {
2160 finish_quota_reservation(&state, quota, true).await;
2161 Ok(HttpResponse::NoContent().finish())
2162 }
2163 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
2164 Err(e) => Err(to_actix(e)),
2165 }
2166}
2167
2168async fn handle_options(
2169 req: HttpRequest,
2170 state: web::Data<AppState>,
2171) -> Result<HttpResponse, ActixError> {
2172 let path = req.uri().path().to_string();
2173 let o = ldp::options_for(&path);
2174 let mut rsp = HttpResponse::NoContent().finish();
2175 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
2176 rsp.headers_mut()
2177 .insert(header::HeaderName::from_static("allow"), v);
2178 }
2179 if let Some(ap) = o.accept_post {
2180 if let Ok(v) = header::HeaderValue::from_str(ap) {
2181 rsp.headers_mut()
2182 .insert(header::HeaderName::from_static("accept-post"), v);
2183 }
2184 }
2185 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
2186 rsp.headers_mut()
2187 .insert(header::HeaderName::from_static("accept-patch"), v);
2188 }
2189 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
2190 rsp.headers_mut()
2191 .insert(header::HeaderName::from_static("accept-ranges"), v);
2192 }
2193 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
2194 Ok(rsp)
2195}
2196
2197async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
2202 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
2203 HttpResponse::Ok()
2204 .content_type("application/ld+json")
2205 .json(doc)
2206}
2207
2208#[derive(Debug, Deserialize)]
2209struct WebFingerQuery {
2210 resource: Option<String>,
2211}
2212
2213async fn handle_well_known_webfinger(
2214 state: web::Data<AppState>,
2215 q: web::Query<WebFingerQuery>,
2216) -> HttpResponse {
2217 let resource = q.resource.clone().unwrap_or_else(|| {
2218 format!(
2219 "acct:anonymous@{}",
2220 state
2221 .nodeinfo
2222 .base_url
2223 .trim_start_matches("http://")
2224 .trim_start_matches("https://")
2225 )
2226 });
2227 let webid = format!(
2228 "{}/profile/card#me",
2229 state.nodeinfo.base_url.trim_end_matches('/')
2230 );
2231 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
2232 Some(jrd) => HttpResponse::Ok()
2233 .content_type("application/jrd+json")
2234 .json(jrd),
2235 None => HttpResponse::NotFound().finish(),
2236 }
2237}
2238
2239async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
2240 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
2241 HttpResponse::Ok()
2242 .content_type("application/json")
2243 .json(doc)
2244}
2245
2246async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
2247 let doc = interop::nodeinfo_2_1(
2248 &state.nodeinfo.software_name,
2249 &state.nodeinfo.software_version,
2250 state.nodeinfo.open_registrations,
2251 state.nodeinfo.total_users,
2252 );
2253 HttpResponse::Ok()
2254 .content_type("application/json")
2255 .json(doc)
2256}
2257
2258#[cfg(feature = "did-nostr")]
2259async fn handle_well_known_did_nostr(
2260 state: web::Data<AppState>,
2261 path: web::Path<String>,
2262) -> HttpResponse {
2263 let pubkey = path.into_inner();
2264 let pubkey_is_valid = pubkey.len() == 64
2269 && pubkey
2270 .bytes()
2271 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
2272 if !pubkey_is_valid {
2273 return HttpResponse::BadRequest()
2274 .insert_header(("Cache-Control", "no-store"))
2275 .json(serde_json::json!({
2276 "error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
2277 }));
2278 }
2279 let owner_pubkey = match state.storage.get("/profile/card").await {
2287 Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
2288 .ok()
2289 .flatten(),
2290 Err(_) => None,
2291 };
2292 let owner_claims_key = owner_pubkey
2293 .as_deref()
2294 .is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
2295 if !owner_claims_key {
2296 return HttpResponse::NotFound()
2297 .insert_header(("Cache-Control", "no-store"))
2298 .json(serde_json::json!({
2299 "error": "no account on this pod claims this did:nostr pubkey"
2300 }));
2301 }
2302 let also = vec![format!(
2303 "{}/profile/card#me",
2304 state.nodeinfo.base_url.trim_end_matches('/')
2305 )];
2306 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
2307 let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
2308 use std::hash::{Hash, Hasher};
2314 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2315 body.hash(&mut hasher);
2316 let etag = format!("\"{:016x}\"", hasher.finish());
2317 HttpResponse::Ok()
2318 .content_type("application/did+json")
2319 .insert_header(("Cache-Control", "max-age=3600"))
2320 .insert_header(("ETag", etag))
2321 .body(body)
2322}
2323
2324#[cfg(feature = "nip05-endpoint")]
2332#[derive(Debug, Deserialize)]
2333struct Nip05Query {
2334 name: Option<String>,
2337}
2338
2339#[cfg(feature = "nip05-endpoint")]
2340fn nip05_name_is_valid(name: &str) -> bool {
2341 if name.is_empty() {
2344 return false;
2345 }
2346 name.bytes()
2347 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
2348}
2349
2350#[cfg(feature = "nip05-endpoint")]
2351async fn handle_well_known_nip05(
2352 state: web::Data<AppState>,
2353 query: web::Query<Nip05Query>,
2354) -> HttpResponse {
2355 use solid_pod_rs::webid::extract_nostr_pubkey;
2356
2357 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
2359 if !nip05_name_is_valid(&name) {
2360 return HttpResponse::BadRequest().json(serde_json::json!({
2361 "error": "invalid NIP-05 local part",
2362 }));
2363 }
2364
2365 let profile_path = if name == "_" {
2371 "/profile/card".to_string()
2372 } else {
2373 format!("/{name}/profile/card")
2374 };
2375
2376 let (body, _meta) = match state.storage.get(&profile_path).await {
2377 Ok(v) => v,
2378 Err(_) => {
2379 return nip05_empty_response();
2383 }
2384 };
2385
2386 let pubkey_hex = match extract_nostr_pubkey(&body) {
2387 Ok(Some(p)) => p,
2388 _ => return nip05_empty_response(),
2389 };
2390
2391 let doc = interop::nip05_document([(name, pubkey_hex)]);
2392 HttpResponse::Ok()
2393 .insert_header(("Access-Control-Allow-Origin", "*"))
2394 .content_type("application/json")
2395 .json(doc)
2396}
2397
2398#[cfg(feature = "nip05-endpoint")]
2399fn nip05_empty_response() -> HttpResponse {
2400 HttpResponse::Ok()
2401 .insert_header(("Access-Control-Allow-Origin", "*"))
2402 .content_type("application/json")
2403 .json(serde_json::json!({ "names": {} }))
2404}
2405
2406#[cfg(feature = "export-jsonld")]
2420async fn handle_export_all(
2421 req: HttpRequest,
2422 state: web::Data<AppState>,
2423) -> Result<HttpResponse, ActixError> {
2424 let auth_pk = extract_pubkey(&req).await;
2425 let agent = agent_uri(auth_pk.as_ref());
2426
2427 enforce_write_ctx(
2432 &state,
2433 "/",
2434 AccessMode::Control,
2435 agent.as_deref(),
2436 req_origin(&req),
2437 )
2438 .await?;
2439
2440 let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
2444 .ok()
2445 .and_then(|q| q.get("include_private").map(|v| v == "true"))
2446 .unwrap_or(false);
2447
2448 let pod_base = {
2452 let conn = req.connection_info();
2453 format!("{}://{}/", conn.scheme(), conn.host())
2454 };
2455
2456 let options = solid_pod_rs::ExportOptions { include_private };
2457 let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
2458 .await
2459 .map_err(to_actix)?;
2460
2461 let body = serde_json::to_vec(&bundle).map_err(|e| {
2462 actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
2463 })?;
2464 Ok(HttpResponse::Ok()
2465 .content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
2466 .body(body))
2467}
2468
2469#[derive(Debug, Deserialize)]
2474struct CreateAccountRequest {
2475 username: String,
2476 #[serde(default)]
2477 name: Option<String>,
2478}
2479
2480#[derive(Debug, Deserialize)]
2481struct CreatePodRequest {
2482 name: String,
2483}
2484
2485async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
2486 let pod_name = path.into_inner();
2487 let pod_root = format!("/{pod_name}/");
2488 match state.storage.exists(&pod_root).await {
2489 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
2490 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
2491 }
2492}
2493
2494fn valid_pod_name(name: &str) -> bool {
2495 !name.is_empty()
2496 && name
2497 .chars()
2498 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
2499}
2500
2501fn request_ip(req: &HttpRequest) -> IpAddr {
2502 req.peer_addr()
2503 .map(|addr| addr.ip())
2504 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
2505}
2506
2507#[derive(Debug)]
2508struct QuotaReservation {
2509 pod: String,
2510 delta: i64,
2511}
2512
2513fn pod_name_from_path(path: &str) -> Option<&str> {
2514 path.trim_start_matches('/')
2515 .split('/')
2516 .next()
2517 .filter(|p| !p.is_empty())
2518}
2519
2520async fn reserve_quota_for_size(
2521 state: &AppState,
2522 path: &str,
2523 new_size: u64,
2524) -> Result<Option<QuotaReservation>, ActixError> {
2525 let (Some(policy), Some(pod)) = (&state.quota, pod_name_from_path(path)) else {
2526 return Ok(None);
2527 };
2528 let old_size = state
2529 .storage
2530 .head(path)
2531 .await
2532 .map(|meta| meta.size)
2533 .unwrap_or(0);
2534 let delta = i128::from(new_size) - i128::from(old_size);
2535 let delta = delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64;
2536 if delta > 0 {
2537 policy
2538 .reserve(pod, delta as u64)
2539 .await
2540 .map_err(|error| actix_web::error::ErrorInsufficientStorage(error.to_string()))?;
2541 }
2542 Ok(Some(QuotaReservation {
2543 pod: pod.to_string(),
2544 delta,
2545 }))
2546}
2547
2548async fn finish_quota_reservation(
2549 state: &AppState,
2550 reservation: Option<QuotaReservation>,
2551 write_succeeded: bool,
2552) {
2553 let (Some(policy), Some(reservation)) = (&state.quota, reservation) else {
2554 return;
2555 };
2556 if write_succeeded {
2557 if reservation.delta < 0 {
2558 policy.record(&reservation.pod, reservation.delta).await;
2559 }
2560 } else if reservation.delta > 0 {
2561 policy.record(&reservation.pod, -reservation.delta).await;
2562 }
2563}
2564
2565async fn handle_create_account(
2566 req: HttpRequest,
2567 state: web::Data<AppState>,
2568 body: web::Json<CreateAccountRequest>,
2569) -> Result<HttpResponse, ActixError> {
2570 if let Some(response) = provisioning_gate(&req, &state, &body.username) {
2571 return Ok(response);
2572 }
2573 let pod_root = format!("/{}/", body.username);
2574 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2575 return Ok(
2576 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
2577 );
2578 }
2579
2580 let base_uri = state.nodeinfo.base_url.trim_end_matches('/');
2581 let outcome =
2582 provision_named_pod(&state, &body.username, body.name.as_deref(), base_uri).await?;
2583 Ok(HttpResponse::Created().json(serde_json::json!({
2584 "webid": outcome.webid,
2585 "pod_root": outcome.pod_uri,
2586 "username": body.username,
2587 })))
2588}
2589
2590struct NamedPodOutcome {
2591 webid: String,
2592 pod_uri: String,
2593}
2594
2595fn provisioning_gate(req: &HttpRequest, state: &AppState, name: &str) -> Option<HttpResponse> {
2598 if !valid_pod_name(name) {
2599 return Some(HttpResponse::BadRequest().json(serde_json::json!({
2600 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
2601 })));
2602 }
2603
2604 let supplied_key = req
2605 .headers()
2606 .get("x-pod-admin-key")
2607 .and_then(|value| value.to_str().ok())
2608 .unwrap_or("");
2609 use subtle::ConstantTimeEq;
2610 let admin_override = state
2611 .admin_key
2612 .as_deref()
2613 .is_some_and(|expected| bool::from(supplied_key.as_bytes().ct_eq(expected.as_bytes())));
2614 if !state.nodeinfo.open_registrations && !admin_override {
2615 return Some(HttpResponse::Forbidden().json(serde_json::json!({
2616 "error": "pod registration is closed"
2617 })));
2618 }
2619
2620 if let Err(retry_after) = state.pod_create_limiter.check(request_ip(req)) {
2621 return Some(
2622 HttpResponse::TooManyRequests()
2623 .insert_header(("Retry-After", retry_after.to_string()))
2624 .json(serde_json::json!({
2625 "error": "Too Many Requests",
2626 "message": "Pod creation rate limit exceeded",
2627 "retryAfter": retry_after
2628 })),
2629 );
2630 }
2631 None
2632}
2633
2634async fn provision_named_pod(
2638 state: &AppState,
2639 name: &str,
2640 display_name: Option<&str>,
2641 base_uri: &str,
2642) -> Result<NamedPodOutcome, ActixError> {
2643 for container in ["", "profile", "inbox", "public", "private", "settings"] {
2644 let path = if container.is_empty() {
2645 format!("/{name}")
2646 } else {
2647 format!("/{name}/{container}")
2648 };
2649 state
2650 .storage
2651 .put(
2652 &format!("{path}.meta"),
2653 Bytes::from_static(b"{}"),
2654 "application/ld+json",
2655 )
2656 .await
2657 .map_err(to_actix)?;
2658 }
2659
2660 let base_uri = base_uri.trim_end_matches('/');
2661 let pod_uri = format!("{base_uri}/{name}/");
2662 let canonical_prefix = format!("{base_uri}/pods/{name}/");
2663 let webid = format!("{pod_uri}profile/card#me");
2664 let profile = solid_pod_rs::webid::generate_webid_html(name, display_name, base_uri)
2665 .replace(&canonical_prefix, &pod_uri);
2666 state
2667 .storage
2668 .put(
2669 &format!("/{name}/profile/card"),
2670 Bytes::from(profile.into_bytes()),
2671 "text/html",
2672 )
2673 .await
2674 .map_err(to_actix)?;
2675
2676 #[cfg(feature = "git")]
2677 if let Some(root) = &state.data_root {
2678 let hook = solid_pod_rs_git::init::GitAutoInit::new();
2679 if let Err(error) = hook.init_repo_at(&root.join(name)).await {
2680 tracing::warn!(pod = name, %error, "git auto-init failed after pod creation");
2681 }
2682 }
2683
2684 Ok(NamedPodOutcome { webid, pod_uri })
2685}
2686
2687async fn handle_create_pod(
2688 req: HttpRequest,
2689 state: web::Data<AppState>,
2690 body: web::Json<CreatePodRequest>,
2691) -> Result<HttpResponse, ActixError> {
2692 if let Some(response) = provisioning_gate(&req, &state, &body.name) {
2693 return Ok(response);
2694 }
2695
2696 let pod_root = format!("/{}/", body.name);
2697 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2698 return Ok(
2699 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
2700 );
2701 }
2702
2703 let base_uri = {
2704 let conn = req.connection_info();
2705 format!("{}://{}", conn.scheme(), conn.host())
2706 };
2707 let outcome = provision_named_pod(&state, &body.name, None, &base_uri).await?;
2708
2709 Ok(HttpResponse::Created()
2710 .insert_header(("Location", outcome.pod_uri.clone()))
2711 .json(serde_json::json!({
2712 "name": body.name,
2713 "webId": outcome.webid,
2714 "podUri": outcome.pod_uri,
2715 })))
2716}
2717
2718fn normalise_copy_source(raw: &str, host: &str) -> Option<String> {
2728 let path = if raw.starts_with("http://") || raw.starts_with("https://") {
2729 let url = url::Url::parse(raw).ok()?;
2730 let authority = match url.port() {
2731 Some(port) => format!("{}:{port}", url.host_str()?),
2732 None => url.host_str()?.to_string(),
2733 };
2734 if !authority.eq_ignore_ascii_case(host)
2735 || url.query().is_some()
2736 || url.fragment().is_some()
2737 {
2738 return None;
2739 }
2740 url.path().to_string()
2741 } else if raw.starts_with('/') && !raw.contains(['?', '#']) {
2742 raw.to_string()
2743 } else {
2744 return None;
2745 };
2746 canonical_pod_path(&path)
2747}
2748
2749async fn handle_copy(
2750 req: HttpRequest,
2751 state: web::Data<AppState>,
2752) -> Result<HttpResponse, ActixError> {
2753 let dest = req.uri().path().to_string();
2754 let auth_pk = extract_pubkey(&req).await;
2755 let agent = agent_uri(auth_pk.as_ref());
2756 enforce_write_ctx(
2757 &state,
2758 &dest,
2759 AccessMode::Write,
2760 agent.as_deref(),
2761 req_origin(&req),
2762 )
2763 .await?;
2764
2765 let source = req
2766 .headers()
2767 .get("source")
2768 .and_then(|v| v.to_str().ok())
2769 .map(|s| s.to_string());
2770 let source = match source {
2771 Some(s) => s,
2772 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
2773 };
2774 let host = req.connection_info().host().to_string();
2775 let source = match normalise_copy_source(&source, &host) {
2776 Some(s) => s,
2777 None => return Ok(HttpResponse::BadRequest().body("invalid Source")),
2778 };
2779 if !state.dotfiles.is_allowed(Path::new(&source)) {
2780 return Ok(HttpResponse::Forbidden().body("dotfile path denied by allowlist"));
2781 }
2782 enforce_read_ctx(&state, &source, agent.as_deref(), req_origin(&req)).await?;
2785
2786 let (body, meta) = match state.storage.get(&source).await {
2787 Ok(v) => v,
2788 Err(PodError::NotFound(_)) => {
2789 return Ok(HttpResponse::NotFound().body("source resource not found"))
2790 }
2791 Err(e) => return Err(to_actix(e)),
2792 };
2793
2794 if protected_resource_for_acl(&dest).is_some()
2796 && !proposed_acl_keeps_caller_control(&body, &meta.content_type, agent.as_deref())
2797 {
2798 return Ok(HttpResponse::Conflict().body(
2799 "refused: the proposed ACL would not grant Control to the caller \
2800 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
2801 ));
2802 }
2803
2804 let quota = reserve_quota_for_size(&state, &dest, body.len() as u64).await?;
2805 let write = state.storage.put(&dest, body, &meta.content_type).await;
2806 finish_quota_reservation(&state, quota, write.is_ok()).await;
2807 write.map_err(to_actix)?;
2808
2809 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
2814 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
2815 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
2816 let origin = req_origin(&req);
2817 let permitted = enforce_read_ctx(&state, &src_acl, agent.as_deref(), origin)
2818 .await
2819 .is_ok()
2820 && enforce_write_ctx(
2821 &state,
2822 &dst_acl,
2823 AccessMode::Write,
2824 agent.as_deref(),
2825 origin,
2826 )
2827 .await
2828 .is_ok()
2829 && proposed_acl_keeps_caller_control(
2830 &acl_body,
2831 &acl_meta.content_type,
2832 agent.as_deref(),
2833 );
2834 if permitted {
2835 let quota = reserve_quota_for_size(&state, &dst_acl, acl_body.len() as u64).await?;
2836 let write = state
2837 .storage
2838 .put(&dst_acl, acl_body, &acl_meta.content_type)
2839 .await;
2840 finish_quota_reservation(&state, quota, write.is_ok()).await;
2841 write.map_err(to_actix)?;
2842 }
2843 }
2844
2845 let mut rsp = HttpResponse::Created().finish();
2846 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
2847 rsp.headers_mut().insert(header::LOCATION, loc);
2848 }
2849 Ok(rsp)
2850}
2851
2852async fn handle_glob_get(
2857 req: HttpRequest,
2858 state: web::Data<AppState>,
2859) -> Result<HttpResponse, ActixError> {
2860 let raw_path = req.uri().path().to_string();
2861 if !raw_path.ends_with("/*") {
2863 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
2864 }
2865 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
2867 folder.to_string()
2868 } else {
2869 format!("{folder}/")
2870 };
2871
2872 let auth_pk = extract_pubkey(&req).await;
2876 let agent = agent_uri(auth_pk.as_ref());
2877 let origin = req_origin(&req);
2878 let audience = enforce_read_ctx(&state, &folder, agent.as_deref(), origin).await?;
2879
2880 let children = state.storage.list(&folder).await.map_err(to_actix)?;
2881 let mut merged = String::new();
2882 let mut audience = audience;
2883
2884 for child in &children {
2885 if child.ends_with('/')
2886 || child.ends_with(".acl")
2887 || child.ends_with(".meta")
2888 || child.ends_with(".prov.ttl")
2889 {
2890 continue;
2891 }
2892 let child_path = format!("{folder}{child}");
2893 match enforce_read_inner(&state, &child_path, agent.as_deref(), origin, false).await {
2896 Ok(ResponseAudience::Private) => audience = ResponseAudience::Private,
2897 Ok(ResponseAudience::Public) => {}
2898 Err(_) => continue,
2899 }
2900 if let Ok((body, meta)) = state.storage.get(&child_path).await {
2901 if meta.content_type.contains("turtle")
2902 || meta.content_type.contains("n-triples")
2903 || meta.content_type.contains("n3")
2904 {
2905 if let Ok(text) = std::str::from_utf8(&body) {
2906 merged.push_str(text);
2907 merged.push('\n');
2908 }
2909 }
2910 }
2911 }
2912
2913 if merged.is_empty() {
2914 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
2915 }
2916
2917 let mut rsp = HttpResponse::Ok().content_type("text/turtle").body(merged);
2918 set_cache_policy(&mut rsp, "text/turtle", audience);
2919 Ok(rsp)
2920}
2921
2922#[derive(Debug, Deserialize)]
2927struct LoginPasswordRequest {
2928 username: String,
2929 password: String,
2930}
2931
2932async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
2939 let _ = (&body.username, &body.password);
2940 HttpResponse::NotImplemented().json(serde_json::json!({
2941 "error": "password login is not implemented on this pod"
2942 }))
2943}
2944
2945#[derive(Debug, Deserialize)]
2946struct PasswordResetRequest {
2947 username: String,
2948}
2949
2950async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
2954 let _ = &body.username;
2955 HttpResponse::NotImplemented().json(serde_json::json!({
2956 "error": "password reset is not implemented on this pod"
2957 }))
2958}
2959
2960#[derive(Debug, Deserialize)]
2961struct PasswordChangeRequest {
2962 token: String,
2963 new_password: String,
2964}
2965
2966async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
2971 let _ = (&body.token, &body.new_password);
2972 HttpResponse::NotImplemented().json(serde_json::json!({
2973 "error": "password change is not implemented on this pod"
2974 }))
2975}
2976
2977async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
2982 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
2983 HttpResponse::Ok()
2984 .content_type("application/json")
2985 .json(body)
2986}
2987
2988pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
3003
3004#[derive(Debug, Deserialize)]
3006struct ProxyQuery {
3007 url: String,
3008}
3009
3010const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
3012 "set-cookie",
3013 "set-cookie2",
3014 "authorization",
3015 "www-authenticate",
3016 "proxy-authenticate",
3017 "proxy-authorization",
3018];
3019
3020async fn validate_proxy_target(target: &str) -> Result<(url::Url, IpAddr), HttpResponse> {
3036 let parsed = match url::Url::parse(target) {
3037 Ok(u) => u,
3038 Err(_) => {
3039 return Err(
3040 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
3041 );
3042 }
3043 };
3044
3045 match parsed.scheme() {
3047 "http" | "https" => {}
3048 scheme => {
3049 return Err(HttpResponse::BadRequest()
3050 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
3051 }
3052 }
3053
3054 if solid_pod_rs::security::is_safe_url(target).is_err() {
3057 return Err(HttpResponse::Forbidden()
3058 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
3059 }
3060
3061 let host = match parsed.host_str() {
3063 Some(h) => h.to_string(),
3064 None => {
3065 return Err(HttpResponse::BadRequest()
3066 .json(serde_json::json!({"error": "target URL has no host"})))
3067 }
3068 };
3069 let host_lower = host.to_ascii_lowercase();
3070 if host_lower == "localhost"
3071 || host_lower.ends_with(".localhost")
3072 || host_lower == "0.0.0.0"
3073 || host_lower == "[::1]"
3074 || host_lower == "[::0]"
3075 {
3076 return Err(HttpResponse::Forbidden()
3077 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
3078 }
3079
3080 match solid_pod_rs::security::resolve_and_check(&host).await {
3083 Ok(ip) => Ok((parsed, ip)),
3084 Err(_) => Err(HttpResponse::Forbidden()
3085 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"}))),
3086 }
3087}
3088
3089fn build_pinned_proxy_client(url: &url::Url, ip: IpAddr) -> Result<reqwest::Client, ActixError> {
3092 let mut builder = reqwest::Client::builder()
3093 .redirect(reqwest::redirect::Policy::none());
3096 if let Some(host) = url.host_str() {
3097 let port = url.port_or_known_default().unwrap_or(0);
3099 builder = builder.resolve(host, std::net::SocketAddr::new(ip, port));
3100 }
3101 builder
3102 .build()
3103 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))
3104}
3105
3106async fn handle_proxy(
3107 req: HttpRequest,
3108 _state: web::Data<AppState>,
3109 query: web::Query<ProxyQuery>,
3110) -> Result<HttpResponse, ActixError> {
3111 let auth_pk = extract_pubkey(&req).await;
3113 let agent = agent_uri(auth_pk.as_ref());
3114 if agent.is_none() {
3115 return Ok(HttpResponse::Unauthorized()
3116 .json(serde_json::json!({"error": "authentication required"})));
3117 }
3118
3119 let mut current_url = query.url.clone();
3120 let mut redirect_count = 0u8;
3121 const MAX_REDIRECTS: u8 = 5;
3122 const TOTAL_TIMEOUT: Duration = Duration::from_secs(30);
3123 let started = Instant::now();
3124
3125 let byte_cap = std::env::var("PROXY_BYTE_CAP")
3126 .ok()
3127 .and_then(|v| {
3128 solid_pod_rs::config::sources::parse_size(&v)
3129 .map(|u| u as usize)
3130 .ok()
3131 })
3132 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
3133
3134 loop {
3135 let Some(remaining) = TOTAL_TIMEOUT.checked_sub(started.elapsed()) else {
3136 return Ok(HttpResponse::GatewayTimeout()
3137 .json(serde_json::json!({"error": "proxy operation timed out"})));
3138 };
3139 let (target_url, pinned_ip) = match validate_proxy_target(¤t_url).await {
3143 Ok(pair) => pair,
3144 Err(rsp) => return Ok(rsp),
3145 };
3146 let client = build_pinned_proxy_client(&target_url, pinned_ip)?;
3147
3148 let mut upstream_req = client.get(¤t_url).timeout(remaining);
3149
3150 if let Some(auth_val) = req
3152 .headers()
3153 .get("x-upstream-authorization")
3154 .and_then(|v| v.to_str().ok())
3155 {
3156 upstream_req = upstream_req.header("Authorization", auth_val);
3157 }
3158
3159 let response = upstream_req
3160 .send()
3161 .await
3162 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
3163
3164 if response.status().is_redirection() {
3166 if redirect_count >= MAX_REDIRECTS {
3167 return Ok(HttpResponse::BadGateway()
3168 .json(serde_json::json!({"error": "too many redirects"})));
3169 }
3170 if let Some(location) = response.headers().get("location") {
3171 let loc_str = location
3172 .to_str()
3173 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
3174 let base = url::Url::parse(¤t_url)
3176 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
3177 let resolved = base
3178 .join(loc_str)
3179 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
3180 current_url = resolved.to_string();
3181 redirect_count += 1;
3182 continue;
3183 }
3184 return Ok(HttpResponse::BadGateway()
3185 .json(serde_json::json!({"error": "redirect without location"})));
3186 }
3187
3188 let upstream_status = response.status().as_u16();
3190 let upstream_content_type = response
3191 .headers()
3192 .get("content-type")
3193 .and_then(|v| v.to_str().ok())
3194 .unwrap_or("application/octet-stream")
3195 .to_string();
3196 if response
3197 .content_length()
3198 .is_some_and(|length| length > byte_cap as u64)
3199 {
3200 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3201 "error": "proxied response exceeds byte cap",
3202 "limit": byte_cap
3203 })));
3204 }
3205
3206 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
3208 for (name, value) in response.headers() {
3209 let name_lower = name.as_str().to_ascii_lowercase();
3210 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
3211 continue;
3212 }
3213 if matches!(
3215 name_lower.as_str(),
3216 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
3217 ) {
3218 continue;
3219 }
3220 if let Ok(val_str) = value.to_str() {
3221 forwarded_headers.push((name_lower, val_str.to_string()));
3222 }
3223 }
3224
3225 let mut stream = response.bytes_stream();
3226 let mut body_bytes = Vec::with_capacity(byte_cap.min(64 * 1024));
3227 while let Some(chunk) = stream.next().await {
3228 let chunk =
3229 chunk.map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
3230 if body_bytes.len().saturating_add(chunk.len()) > byte_cap {
3231 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3232 "error": "proxied response exceeds byte cap",
3233 "limit": byte_cap
3234 })));
3235 }
3236 body_bytes.extend_from_slice(&chunk);
3237 }
3238
3239 let mut rsp = HttpResponse::build(
3241 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3242 );
3243 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
3244 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
3245
3246 for (name, value) in &forwarded_headers {
3248 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
3249 if let Ok(hval) = header::HeaderValue::from_str(value) {
3250 rsp.insert_header((hname, hval));
3251 }
3252 }
3253 }
3254
3255 return Ok(rsp.body(body_bytes));
3256 }
3257}
3258
3259pub struct PathTraversalGuard;
3265
3266impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
3267where
3268 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3269 B: 'static,
3270{
3271 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3272 type Error = ActixError;
3273 type InitError = ();
3274 type Transform = PathTraversalGuardMiddleware<S>;
3275 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3276
3277 fn new_transform(&self, service: S) -> Self::Future {
3278 ready(Ok(PathTraversalGuardMiddleware { service }))
3279 }
3280}
3281
3282pub struct PathTraversalGuardMiddleware<S> {
3284 service: S,
3285}
3286
3287impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
3288where
3289 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3290 B: 'static,
3291{
3292 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3293 type Error = ActixError;
3294 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3295
3296 actix_web::dev::forward_ready!(service);
3297
3298 fn call(&self, req: ServiceRequest) -> Self::Future {
3299 let raw = req.path().to_string();
3302 if path_is_traversal(&raw) {
3303 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
3304 let sr = req.into_response(rsp.map_into_boxed_body());
3305 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3306 }
3307 let fut = self.service.call(req);
3308 Box::pin(async move {
3309 let resp = fut.await?;
3310 Ok(resp.map_into_left_body())
3311 })
3312 }
3313}
3314
3315fn path_is_traversal(path: &str) -> bool {
3316 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
3318 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
3319 for seg in once.split('/').chain(twice.split('/')) {
3320 if seg == ".." || seg == "." {
3321 return true;
3322 }
3323 }
3324 twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..")
3327}
3328
3329pub struct CorsHeaders {
3340 pub allowed_origins: Arc<Vec<String>>,
3341}
3342
3343impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
3344where
3345 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3346 B: 'static,
3347{
3348 type Response = ServiceResponse<B>;
3349 type Error = ActixError;
3350 type InitError = ();
3351 type Transform = CorsHeadersMiddleware<S>;
3352 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3353
3354 fn new_transform(&self, service: S) -> Self::Future {
3355 ready(Ok(CorsHeadersMiddleware {
3356 service,
3357 allowed_origins: self.allowed_origins.clone(),
3358 }))
3359 }
3360}
3361
3362pub struct CorsHeadersMiddleware<S> {
3364 service: S,
3365 allowed_origins: Arc<Vec<String>>,
3366}
3367
3368impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
3369where
3370 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3371 B: 'static,
3372{
3373 type Response = ServiceResponse<B>;
3374 type Error = ActixError;
3375 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3376
3377 actix_web::dev::forward_ready!(service);
3378
3379 fn call(&self, req: ServiceRequest) -> Self::Future {
3380 let origin = req
3381 .headers()
3382 .get(header::ORIGIN)
3383 .and_then(|v| v.to_str().ok())
3384 .map(str::to_string);
3385 let allowed = self.allowed_origins.clone();
3386 let fut = self.service.call(req);
3387 Box::pin(async move {
3388 let mut resp = fut.await?;
3389 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
3390 Ok(resp)
3391 })
3392 }
3393}
3394
3395fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
3396 if headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
3406 return;
3407 }
3408 let (origin_value, allow_credentials): (String, bool) = if allowed.is_empty() {
3419 ("*".to_string(), false)
3420 } else {
3421 match origin.filter(|o| allowed.iter().any(|a| a == *o)) {
3422 Some(o) => (o.to_string(), true),
3423 None => return,
3426 }
3427 };
3428
3429 let mut pairs = vec![
3430 ("access-control-allow-origin", origin_value.as_str()),
3431 (
3432 "access-control-allow-methods",
3433 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
3434 ),
3435 (
3436 "access-control-allow-headers",
3437 "Accept, Authorization, Content-Type, DPoP, Git-Protocol, If-Match, If-None-Match, Link, Range, Slug, Origin",
3438 ),
3439 (
3440 "access-control-expose-headers",
3441 "Accept-Patch, Accept-Post, Accept-Ranges, Allow, Content-Length, Content-Range, Content-Type, ETag, Link, Location, Updates-Via, WAC-Allow, X-Cost, X-Balance, X-Pay-Currency",
3442 ),
3443 ("access-control-max-age", "86400"),
3444 ];
3445 if allow_credentials {
3448 pairs.push(("access-control-allow-credentials", "true"));
3449 }
3450
3451 for (name, value) in pairs {
3452 if let (Ok(name), Ok(value)) = (
3453 header::HeaderName::from_lowercase(name.as_bytes()),
3454 header::HeaderValue::from_str(value),
3455 ) {
3456 headers.insert(name, value);
3457 }
3458 }
3459}
3460
3461pub struct ErrorLoggingMiddleware;
3477
3478impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
3479where
3480 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3481 B: 'static,
3482{
3483 type Response = ServiceResponse<B>;
3484 type Error = ActixError;
3485 type InitError = ();
3486 type Transform = ErrorLoggingMiddlewareService<S>;
3487 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3488
3489 fn new_transform(&self, service: S) -> Self::Future {
3490 ready(Ok(ErrorLoggingMiddlewareService { service }))
3491 }
3492}
3493
3494pub struct ErrorLoggingMiddlewareService<S> {
3496 service: S,
3497}
3498
3499impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
3500where
3501 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3502 B: 'static,
3503{
3504 type Response = ServiceResponse<B>;
3505 type Error = ActixError;
3506 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3507
3508 actix_web::dev::forward_ready!(service);
3509
3510 fn call(&self, req: ServiceRequest) -> Self::Future {
3511 let method = req.method().as_str().to_string();
3514 let path = req.path().to_string();
3515
3516 let fut = self.service.call(req);
3517 Box::pin(async move {
3518 let response = fut.await?;
3519 let status = response.status();
3520 if status.is_server_error() {
3521 log_5xx(&method, &path, status, response.response().error());
3522 }
3523 Ok(response)
3524 })
3525 }
3526}
3527
3528fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
3532 let chain = match error {
3536 Some(e) => format_error_chain(e),
3537 None => "<no error attached to response>".to_string(),
3538 };
3539
3540 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
3541 Some(std::backtrace::Backtrace::force_capture().to_string())
3542 } else {
3543 None
3544 };
3545
3546 tracing::error!(
3547 target: "solid_pod_rs_server::http",
3548 method = %method,
3549 path = %path,
3550 status = %status.as_u16(),
3551 error.chain = %chain,
3552 backtrace = backtrace.as_deref().unwrap_or(""),
3553 "5xx response"
3554 );
3555}
3556
3557fn format_error_chain(e: &actix_web::Error) -> String {
3568 let summary = format!("{}", e.as_response_error());
3569 let debug = format!("{e:?}");
3570 if debug == summary || debug.is_empty() {
3571 summary
3572 } else {
3573 format!("{summary} -> {debug}")
3574 }
3575}
3576
3577pub struct DotfileGuard {
3583 allow: Arc<DotfileAllowlist>,
3584}
3585
3586impl DotfileGuard {
3587 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
3588 Self { allow }
3589 }
3590}
3591
3592impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
3593where
3594 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3595 B: 'static,
3596{
3597 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3598 type Error = ActixError;
3599 type InitError = ();
3600 type Transform = DotfileGuardMiddleware<S>;
3601 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3602
3603 fn new_transform(&self, service: S) -> Self::Future {
3604 ready(Ok(DotfileGuardMiddleware {
3605 service,
3606 allow: self.allow.clone(),
3607 }))
3608 }
3609}
3610
3611pub struct DotfileGuardMiddleware<S> {
3613 service: S,
3614 allow: Arc<DotfileAllowlist>,
3615}
3616
3617impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
3618where
3619 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3620 B: 'static,
3621{
3622 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3623 type Error = ActixError;
3624 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3625
3626 actix_web::dev::forward_ready!(service);
3627
3628 fn call(&self, req: ServiceRequest) -> Self::Future {
3629 let path = req.path().to_string();
3630 let allow_system_route =
3637 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
3638 if !allow_system_route {
3639 let pb = PathBuf::from(&path);
3640 if !self.allow.is_allowed(Path::new(&pb)) {
3641 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
3642 let sr = req.into_response(rsp.map_into_boxed_body());
3643 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3644 }
3645 }
3646 let fut = self.service.call(req);
3647 Box::pin(async move {
3648 let resp = fut.await?;
3649 Ok(resp.map_into_left_body())
3650 })
3651 }
3652}
3653
3654#[cfg(feature = "git")]
3659pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
3660 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
3661 return None;
3662 }
3663 state.data_root.as_ref().map(|root| root.join(pubkey))
3664}
3665
3666#[cfg(feature = "git")]
3696async fn git_mark_write(
3697 state: &AppState,
3698 resource_path: &str,
3699 agent: Option<&str>,
3700 message: &str,
3701) -> ProvenanceReceipt {
3702 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
3703 use solid_pod_rs_git::mark::ShellGitMarker;
3704
3705 if resource_path.ends_with(".acl")
3708 || resource_path.ends_with(".meta")
3709 || resource_path.ends_with(".prov.ttl")
3710 {
3711 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::ExcludedPath);
3712 }
3713 if resource_path.ends_with('/') {
3715 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::Container);
3716 }
3717
3718 let Some(data_root) = state.data_root.as_ref() else {
3720 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured);
3721 };
3722
3723 let trimmed = resource_path.trim_start_matches('/');
3725 let mut segments = trimmed.splitn(2, '/');
3726 let pod = segments.next().unwrap_or("");
3727 let rel = segments.next().unwrap_or("");
3728 if pod.is_empty() || rel.is_empty() {
3729 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::UnresolvablePath);
3730 }
3731 let repo = data_root.join(pod);
3732
3733 if !repo.join(".git").is_dir() {
3737 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotGitBacked);
3738 }
3739
3740 let agent_did = agent.unwrap_or("urn:solid:anonymous");
3741 let created = std::time::SystemTime::now()
3742 .duration_since(std::time::UNIX_EPOCH)
3743 .map(|d| d.as_secs())
3744 .unwrap_or(0);
3745
3746 let (policy, ticker_override) =
3749 handlers::prov::resolve_anchor_policy(state, resource_path).await;
3750
3751 let marker = std::sync::Arc::new(ShellGitMarker::new());
3756 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
3757 None
3758 } else {
3759 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
3760 };
3761 let (log, ticker, network) = match &anchorer_bundle {
3762 Some((anchorer, ticker, network)) => (
3763 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
3764 ticker.clone(),
3765 network.clone(),
3766 ),
3767 None => (
3769 ProvenanceLog::new(marker.clone()),
3770 String::new(),
3771 String::new(),
3772 ),
3773 };
3774
3775 let record_policy = match policy {
3779 AnchorPolicy::Epoch => AnchorPolicy::Never,
3780 other => other,
3781 };
3782 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
3783
3784 let write_record = solid_pod_rs::provenance::WriteRecord {
3788 repo: &repo,
3789 path: rel,
3790 agent_did,
3791 message,
3792 policy: record_policy,
3793 high_value,
3794 ticker: &ticker,
3795 network: &network,
3796 created,
3797 };
3798 let mut receipt = log.record_receipt(write_record).await;
3803 receipt.resource = resource_path.to_string();
3806
3807 let Some(git) = receipt.mark.clone() else {
3808 tracing::warn!(
3812 target: "solid_pod_rs_server::git_mark",
3813 resource = %resource_path,
3814 "provenance record failed (write already succeeded): {}",
3815 receipt.mark_error.as_deref().unwrap_or("unspecified")
3816 );
3817 return receipt;
3818 };
3819 if let Some(err) = &receipt.anchor_error {
3820 tracing::warn!(
3823 target: "solid_pod_rs_server::git_mark",
3824 resource = %resource_path,
3825 commit = %git.commit_sha,
3826 "block-trail anchor failed (git-mark stands): {err}"
3827 );
3828 }
3829 let mark = solid_pod_rs::provenance::ProvenanceMark {
3830 resource: resource_path.to_string(),
3831 git: git.clone(),
3832 anchor: receipt.anchor.clone(),
3833 agent_did: agent_did.to_string(),
3834 created,
3835 };
3836
3837 if matches!(policy, AnchorPolicy::Epoch) {
3841 if let Some((anchorer, _, _)) = &anchorer_bundle {
3842 match handlers::prov::epoch_push_and_maybe_anchor(
3843 state,
3844 anchorer,
3845 &ticker,
3846 &network,
3847 &mark.git.commit_sha,
3848 )
3849 .await
3850 {
3851 Ok(Some(closed)) => tracing::debug!(
3852 target: "solid_pod_rs_server::git_mark",
3853 root = %closed.root,
3854 n = closed.commits.len(),
3855 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
3856 ),
3857 Ok(None) => {}
3858 Err(e) => tracing::warn!(
3859 target: "solid_pod_rs_server::git_mark",
3860 "epoch batch/anchor failed (swallowed): {e}"
3861 ),
3862 }
3863 }
3864 }
3865
3866 let ttl = prov_ttl(&mark);
3871 let sidecar = format!("{resource_path}.prov.ttl");
3872 if let Err(e) = state
3873 .storage
3874 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
3875 .await
3876 {
3877 tracing::warn!(
3878 target: "solid_pod_rs_server::git_mark",
3879 sidecar = %sidecar,
3880 "provenance sidecar write failed: {e}"
3881 );
3882 receipt.mark_error = Some(format!("PROV-O sidecar write failed: {e}"));
3885 return receipt;
3886 }
3887
3888 tracing::debug!(
3889 target: "solid_pod_rs_server::git_mark",
3890 resource = %resource_path,
3891 commit = %mark.git.commit_sha,
3892 stage = %receipt.stage(),
3893 "provenance recorded"
3894 );
3895 receipt
3896}
3897
3898#[cfg(not(feature = "git"))]
3901#[inline]
3902async fn git_mark_write(
3903 _state: &AppState,
3904 resource_path: &str,
3905 _agent: Option<&str>,
3906 _message: &str,
3907) -> ProvenanceReceipt {
3908 ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured)
3909}
3910
3911#[cfg(feature = "git")]
3912pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
3913 require_pod_owner_with_body(req, pod_pubkey, None).await
3914}
3915
3916#[cfg(feature = "git")]
3917pub(crate) async fn require_pod_owner_with_body(
3918 req: &HttpRequest,
3919 pod_pubkey: &str,
3920 body: Option<&[u8]>,
3921) -> Option<String> {
3922 let caller = extract_pubkey_with_body(req, body).await?;
3923 if caller != pod_pubkey {
3924 return None;
3925 }
3926 Some(caller)
3927}
3928
3929#[cfg(feature = "git")]
3930fn git_json_err(msg: &str, status: u16) -> HttpResponse {
3931 HttpResponse::build(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
3932 .content_type("application/json")
3933 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
3934}
3935
3936#[cfg(feature = "git")]
3938#[derive(serde::Deserialize)]
3939struct GitStageBody {
3940 paths: Option<Vec<String>>,
3941 all: Option<bool>,
3942}
3943
3944#[cfg(feature = "git")]
3945#[derive(serde::Deserialize)]
3946struct GitCommitBody {
3947 message: String,
3948 author_name: Option<String>,
3949 author_email: Option<String>,
3950}
3951
3952#[cfg(feature = "git")]
3953#[derive(serde::Deserialize)]
3954struct GitBranchBody {
3955 name: String,
3956}
3957
3958#[cfg(feature = "git")]
3961async fn handle_git_status(
3962 path: web::Path<String>,
3963 req: HttpRequest,
3964 state: web::Data<AppState>,
3965) -> HttpResponse {
3966 let pubkey = path.into_inner();
3967 if require_pod_owner(&req, &pubkey).await.is_none() {
3968 return git_json_err("Authentication required", 401);
3969 }
3970 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3971 return git_json_err("Git not available (no FS backend)", 501);
3972 };
3973 match solid_pod_rs_git::api::git_status(&repo).await {
3974 Ok(s) => HttpResponse::Ok()
3975 .content_type("application/json")
3976 .body(serde_json::to_string(&s).unwrap_or_default()),
3977 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3978 }
3979}
3980
3981#[cfg(feature = "git")]
3982async fn handle_git_log(
3983 path: web::Path<String>,
3984 req: HttpRequest,
3985 state: web::Data<AppState>,
3986 query: web::Query<std::collections::HashMap<String, String>>,
3987) -> HttpResponse {
3988 let pubkey = path.into_inner();
3989 if require_pod_owner(&req, &pubkey).await.is_none() {
3990 return git_json_err("Authentication required", 401);
3991 }
3992 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3993 return git_json_err("Git not available (no FS backend)", 501);
3994 };
3995 let limit: u32 = query
3996 .get("limit")
3997 .and_then(|v| v.parse().ok())
3998 .unwrap_or(20);
3999 match solid_pod_rs_git::api::git_log(&repo, limit).await {
4000 Ok(entries) => HttpResponse::Ok()
4001 .content_type("application/json")
4002 .body(serde_json::to_string(&entries).unwrap_or_default()),
4003 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4004 }
4005}
4006
4007#[cfg(feature = "git")]
4008async fn handle_git_diff(
4009 path: web::Path<String>,
4010 req: HttpRequest,
4011 state: web::Data<AppState>,
4012 query: web::Query<std::collections::HashMap<String, String>>,
4013) -> HttpResponse {
4014 let pubkey = path.into_inner();
4015 if require_pod_owner(&req, &pubkey).await.is_none() {
4016 return git_json_err("Authentication required", 401);
4017 }
4018 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4019 return git_json_err("Git not available (no FS backend)", 501);
4020 };
4021 let file_path = query.get("path").map(String::as_str);
4022 let staged = query
4023 .get("staged")
4024 .map(|v| v == "true" || v == "1")
4025 .unwrap_or(false);
4026 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
4027 Ok(diff) => HttpResponse::Ok().content_type("text/plain").body(diff),
4028 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4029 }
4030}
4031
4032#[cfg(feature = "git")]
4033async fn handle_git_stage(
4034 path: web::Path<String>,
4035 req: HttpRequest,
4036 state: web::Data<AppState>,
4037 body: web::Bytes,
4038) -> HttpResponse {
4039 let pubkey = path.into_inner();
4040 if require_pod_owner(&req, &pubkey).await.is_none() {
4041 return git_json_err("Authentication required", 401);
4042 }
4043 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4044 return git_json_err("Git not available (no FS backend)", 501);
4045 };
4046 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4047 Ok(v) => v,
4048 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4049 };
4050 let paths = parsed.paths.unwrap_or_default();
4051 let all = parsed.all.unwrap_or(false);
4052 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
4053 Ok(()) => HttpResponse::Ok()
4054 .content_type("application/json")
4055 .body(r#"{"ok":true}"#),
4056 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4057 }
4058}
4059
4060#[cfg(feature = "git")]
4061async fn handle_git_unstage(
4062 path: web::Path<String>,
4063 req: HttpRequest,
4064 state: web::Data<AppState>,
4065 body: web::Bytes,
4066) -> HttpResponse {
4067 let pubkey = path.into_inner();
4068 if require_pod_owner(&req, &pubkey).await.is_none() {
4069 return git_json_err("Authentication required", 401);
4070 }
4071 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4072 return git_json_err("Git not available (no FS backend)", 501);
4073 };
4074 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4075 Ok(v) => v,
4076 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4077 };
4078 let paths = parsed.paths.unwrap_or_default();
4079 let all = parsed.all.unwrap_or(false);
4080 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
4081 Ok(()) => HttpResponse::Ok()
4082 .content_type("application/json")
4083 .body(r#"{"ok":true}"#),
4084 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4085 }
4086}
4087
4088#[cfg(feature = "git")]
4089async fn handle_git_commit(
4090 path: web::Path<String>,
4091 req: HttpRequest,
4092 state: web::Data<AppState>,
4093 body: web::Bytes,
4094) -> HttpResponse {
4095 let pubkey = path.into_inner();
4096 if require_pod_owner(&req, &pubkey).await.is_none() {
4097 return git_json_err("Authentication required", 401);
4098 }
4099 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4100 return git_json_err("Git not available (no FS backend)", 501);
4101 };
4102 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
4103 Ok(v) => v,
4104 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4105 };
4106 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
4107 let author_email = parsed
4108 .author_email
4109 .as_deref()
4110 .unwrap_or("pod@dreamlab-ai.com");
4111 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email).await
4112 {
4113 Ok(result) => HttpResponse::Ok()
4114 .content_type("application/json")
4115 .body(serde_json::to_string(&result).unwrap_or_default()),
4116 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4117 }
4118}
4119
4120#[cfg(feature = "git")]
4121async fn handle_git_branches(
4122 path: web::Path<String>,
4123 req: HttpRequest,
4124 state: web::Data<AppState>,
4125) -> HttpResponse {
4126 let pubkey = path.into_inner();
4127 if require_pod_owner(&req, &pubkey).await.is_none() {
4128 return git_json_err("Authentication required", 401);
4129 }
4130 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4131 return git_json_err("Git not available (no FS backend)", 501);
4132 };
4133 match solid_pod_rs_git::api::git_branches(&repo).await {
4134 Ok(info) => HttpResponse::Ok()
4135 .content_type("application/json")
4136 .body(serde_json::to_string(&info).unwrap_or_default()),
4137 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4138 }
4139}
4140
4141#[cfg(feature = "git")]
4142async fn handle_git_create_branch(
4143 path: web::Path<String>,
4144 req: HttpRequest,
4145 state: web::Data<AppState>,
4146 body: web::Bytes,
4147) -> HttpResponse {
4148 let pubkey = path.into_inner();
4149 if require_pod_owner(&req, &pubkey).await.is_none() {
4150 return git_json_err("Authentication required", 401);
4151 }
4152 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4153 return git_json_err("Git not available (no FS backend)", 501);
4154 };
4155 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
4156 Ok(v) => v,
4157 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4158 };
4159 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
4160 Ok(()) => HttpResponse::Ok()
4161 .content_type("application/json")
4162 .body(r#"{"ok":true}"#),
4163 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4164 }
4165}
4166
4167#[cfg(feature = "git")]
4168async fn handle_git_discard(
4169 path: web::Path<String>,
4170 req: HttpRequest,
4171 state: web::Data<AppState>,
4172 body: web::Bytes,
4173) -> HttpResponse {
4174 let pubkey = path.into_inner();
4175 if require_pod_owner(&req, &pubkey).await.is_none() {
4176 return git_json_err("Authentication required", 401);
4177 }
4178 let Some(repo) = pod_repo_path(&state, &pubkey) else {
4179 return git_json_err("Git not available (no FS backend)", 501);
4180 };
4181 let parsed: GitStageBody = match serde_json::from_slice(&body) {
4182 Ok(v) => v,
4183 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4184 };
4185 let paths = parsed.paths.unwrap_or_default();
4186 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
4187 Ok(()) => HttpResponse::Ok()
4188 .content_type("application/json")
4189 .body(r#"{"ok":true}"#),
4190 Err(e) => git_json_err(&e.to_string(), e.status_code()),
4191 }
4192}
4193
4194async fn handle_git_panel_options(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
4202 let origin = req
4203 .headers()
4204 .get(header::ORIGIN)
4205 .and_then(|v| v.to_str().ok())
4206 .map(str::to_string);
4207
4208 let mut rsp = HttpResponse::NoContent().finish();
4209 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
4210 rsp
4211}
4212
4213async fn handle_admin_provision(
4230 req: HttpRequest,
4231 state: web::Data<AppState>,
4232 path: web::Path<String>,
4233) -> HttpResponse {
4234 let expected = match &state.admin_key {
4236 Some(k) => k.clone(),
4237 None => {
4238 return HttpResponse::Forbidden().json(serde_json::json!({
4239 "error": "admin key not configured on this server"
4240 }));
4241 }
4242 };
4243 let provided = req
4244 .headers()
4245 .get("x-pod-admin-key")
4246 .and_then(|v| v.to_str().ok())
4247 .unwrap_or("");
4248 use subtle::ConstantTimeEq;
4253 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
4254 if !bool::from(key_match) {
4255 return HttpResponse::Forbidden().json(serde_json::json!({"error": "invalid admin key"}));
4256 }
4257
4258 let pubkey = path.into_inner();
4260 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
4261 return HttpResponse::BadRequest()
4262 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
4263 }
4264
4265 let data_root = match &state.data_root {
4267 Some(r) => r.clone(),
4268 None => {
4269 return HttpResponse::InternalServerError().json(serde_json::json!({
4270 "error": "server has no fs-backend storage configured"
4271 }));
4272 }
4273 };
4274
4275 let pods_root = data_root.join("pods");
4288 let pod_dir = pods_root.join(&pubkey);
4289
4290 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
4292 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
4293 return HttpResponse::InternalServerError()
4294 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
4295 }
4296
4297 let acl_content = format!(
4307 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
4308 <#owner> a acl:Authorization ;\n\
4309 acl:agent <did:nostr:{pubkey}> ;\n\
4310 acl:accessTo </pods/{pubkey}/> ;\n\
4311 acl:default </pods/{pubkey}/> ;\n\
4312 acl:mode acl:Read, acl:Write, acl:Control .\n"
4313 );
4314
4315 let sibling_acl_path = pods_root.join(format!("{pubkey}.acl"));
4325 if !sibling_acl_path.exists() {
4326 if let Err(e) = tokio::fs::write(&sibling_acl_path, acl_content.as_bytes()).await {
4327 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write sibling .acl failed");
4328 return HttpResponse::InternalServerError()
4329 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
4330 }
4331 }
4332
4333 let inner_acl_path = pod_dir.join(".acl");
4340 if !inner_acl_path.exists() {
4341 if let Err(e) = tokio::fs::write(&inner_acl_path, acl_content.as_bytes()).await {
4342 tracing::warn!(pubkey = %pubkey, error = %e, "/_admin/provision: write inner .acl failed (non-fatal; sibling ACL governs)");
4343 }
4344 }
4345
4346 #[cfg(feature = "git")]
4348 {
4349 if !pod_dir.join(".git").exists() {
4351 let init_out = crate::hardened_git::hardened_git_async()
4352 .args(["init", "-b", "main", pod_dir.to_str().unwrap_or(".")])
4353 .output()
4354 .await;
4355
4356 match init_out {
4357 Ok(out) if out.status.success() => {}
4358 Ok(out) => {
4359 let stderr = String::from_utf8_lossy(&out.stderr);
4360 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
4361 }
4362 Err(e) => {
4363 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
4364 }
4365 }
4366
4367 let cfg_out = crate::hardened_git::hardened_git_async()
4370 .args([
4371 "-C",
4372 pod_dir.to_str().unwrap_or("."),
4373 "config",
4374 "receive.denyCurrentBranch",
4375 "updateInstead",
4376 ])
4377 .output()
4378 .await;
4379
4380 if let Err(e) = cfg_out {
4381 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
4382 }
4383 }
4384 }
4385
4386 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
4388 HttpResponse::Ok().json(serde_json::json!({
4389 "podUrl": format!("{base_url}/pods/{pubkey}/"),
4390 "ok": true,
4391 }))
4392}
4393
4394async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
4399 let Some(ref data_root) = state.data_root else {
4400 return HttpResponse::Ok()
4401 .content_type("application/json")
4402 .json(serde_json::json!({"apps": [], "count": 0}));
4403 };
4404
4405 let server_url = state.nodeinfo.base_url.clone();
4406
4407 let mut read_dir = match tokio::fs::read_dir(data_root).await {
4409 Ok(rd) => rd,
4410 Err(_) => {
4411 return HttpResponse::Ok()
4412 .content_type("application/json")
4413 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
4414 }
4415 };
4416
4417 let mut apps: Vec<serde_json::Value> = Vec::new();
4418 let mut scanned = 0usize;
4419
4420 while scanned < 1000 {
4421 let entry = match read_dir.next_entry().await {
4422 Ok(Some(e)) => e,
4423 Ok(None) => break,
4424 Err(_) => break,
4425 };
4426
4427 let file_type = match entry.file_type().await {
4428 Ok(ft) => ft,
4429 Err(_) => continue,
4430 };
4431 if !file_type.is_dir() {
4432 continue;
4433 }
4434
4435 scanned += 1;
4436
4437 let manifest_path = entry.path().join("apps").join("manifest.json");
4438 let contents = match tokio::fs::read(&manifest_path).await {
4439 Ok(c) => c,
4440 Err(_) => continue,
4441 };
4442
4443 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
4444 Ok(v) => v,
4445 Err(_) => continue,
4446 };
4447
4448 if let Some(pod_name) = entry.file_name().to_str() {
4450 if manifest.get("podOwner").is_none() {
4451 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
4452 }
4453 }
4454
4455 apps.push(manifest);
4456 }
4457
4458 let count = apps.len();
4459 HttpResponse::Ok()
4460 .content_type("application/json")
4461 .json(serde_json::json!({
4462 "apps": apps,
4463 "serverUrl": server_url,
4464 "count": count,
4465 }))
4466}
4467
4468#[allow(dead_code)]
4481fn is_git_request(path: &str) -> bool {
4482 path.contains("/info/refs")
4483 || path.contains("/git-upload-pack")
4484 || path.contains("/git-receive-pack")
4485}
4486
4487#[allow(dead_code)]
4490fn is_dot_git_path(path: &str) -> bool {
4491 path.contains("/.git/") || path.ends_with("/.git")
4492}
4493
4494#[cfg(feature = "git")]
4495async fn handle_git(
4496 req: HttpRequest,
4497 body: web::Bytes,
4498 state: web::Data<AppState>,
4499) -> HttpResponse {
4500 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
4501 use solid_pod_rs_git::service::GitRequest;
4502
4503 let path = req.uri().path().to_string();
4504
4505 let pod_name = path
4508 .trim_start_matches('/')
4509 .split('/')
4510 .next()
4511 .unwrap_or("")
4512 .to_string();
4513 let Some(ref data_root) = state.data_root else {
4514 return HttpResponse::NotImplemented().json(serde_json::json!({
4515 "error": "git requires fs-backend storage",
4516 "reason": "data_root_not_configured"
4517 }));
4518 };
4519 let repo_root = data_root.join(&pod_name);
4520 if !repo_root.exists() {
4521 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
4522 }
4523
4524 let query = req.uri().query().unwrap_or("").to_string();
4525 let host_url = {
4526 let conn = req.connection_info();
4527 Some(format!("{}://{}", conn.scheme(), conn.host()))
4528 };
4529 let headers: Vec<(String, String)> = req
4530 .headers()
4531 .iter()
4532 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
4533 .collect();
4534
4535 let git_req = GitRequest {
4536 method: req.method().as_str().to_string(),
4537 path,
4538 query,
4539 headers,
4540 body,
4541 host_url,
4542 };
4543
4544 let is_write = git_req.is_write();
4556 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
4557 Ok(pk) => Some(format!("did:nostr:{pk}")),
4558 Err(_) => None,
4559 };
4560 let wac_path = format!("/{pod_name}/");
4561 let origin = req_origin(&req);
4562 let wac = if is_write {
4563 enforce_write_ctx(
4564 &state,
4565 &wac_path,
4566 AccessMode::Write,
4567 agent.as_deref(),
4568 origin,
4569 )
4570 .await
4571 } else {
4572 enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin)
4576 .await
4577 .map(|_audience| ())
4578 };
4579 if let Err(e) = wac {
4580 let mut resp = e.error_response();
4584 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4585 if let (Ok(name), Ok(value)) = (
4586 actix_web::http::header::HeaderName::from_bytes(k.as_bytes()),
4587 actix_web::http::header::HeaderValue::from_str(v),
4588 ) {
4589 resp.headers_mut().insert(name, value);
4590 }
4591 }
4592 return resp;
4593 }
4594
4595 let service = git_push::pod_git_service(repo_root, state.dotfiles.clone());
4596 match service.handle(git_req).await {
4597 Ok(git_resp) => {
4598 let mut builder = HttpResponse::build(
4599 actix_web::http::StatusCode::from_u16(git_resp.status)
4600 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4601 );
4602 for (k, v) in &git_resp.headers {
4603 builder.insert_header((k.as_str(), v.as_str()));
4604 }
4605 builder.body(git_resp.body)
4606 }
4607 Err(e) => {
4608 let status = e.status_code();
4609 let mut builder = HttpResponse::build(
4610 actix_web::http::StatusCode::from_u16(status)
4611 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4612 );
4613 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4614 builder.insert_header((k, v));
4615 }
4616 builder.json(serde_json::json!({"error": e.to_string()}))
4617 }
4618 }
4619}
4620
4621#[cfg(feature = "forge")]
4630fn forge_plugin_dir(state: &AppState) -> Option<PathBuf> {
4631 state.data_root.as_ref().map(|r| r.join(".forge"))
4632}
4633
4634#[cfg(feature = "forge")]
4640struct ServerLoopback {
4641 client: reqwest::Client,
4642}
4643
4644#[cfg(feature = "forge")]
4645#[async_trait::async_trait]
4646impl solid_pod_rs_forge::LoopbackFetch for ServerLoopback {
4647 async fn get(
4648 &self,
4649 url: &str,
4650 max_bytes: usize,
4651 timeout_secs: u64,
4652 ) -> solid_pod_rs_forge::bodies::FetchResult {
4653 use solid_pod_rs_forge::bodies::FetchResult;
4654 let resp = match self
4655 .client
4656 .get(url)
4657 .timeout(Duration::from_secs(timeout_secs.max(1)))
4658 .send()
4659 .await
4660 {
4661 Ok(r) => r,
4662 Err(e) => return FetchResult::Error(e.to_string()),
4663 };
4664 let code = resp.status().as_u16();
4665 if code == 404 || code == 410 {
4666 return FetchResult::Removed;
4667 }
4668 if !resp.status().is_success() {
4669 return FetchResult::Error(format!("status {code}"));
4670 }
4671 if resp
4672 .content_length()
4673 .is_some_and(|length| length > max_bytes as u64)
4674 {
4675 return FetchResult::TooLarge;
4676 }
4677 let mut stream = resp.bytes_stream();
4678 let mut body = Vec::with_capacity(max_bytes.min(16 * 1024));
4679 while let Some(chunk) = stream.next().await {
4680 match chunk {
4681 Ok(chunk) if body.len().saturating_add(chunk.len()) <= max_bytes => {
4682 body.extend_from_slice(&chunk);
4683 }
4684 Ok(_) => return FetchResult::TooLarge,
4685 Err(error) => return FetchResult::Error(error.to_string()),
4686 }
4687 }
4688 FetchResult::Body(body)
4689 }
4690}
4691
4692#[cfg(feature = "forge")]
4698async fn handle_forge(
4699 req: HttpRequest,
4700 body: web::Bytes,
4701 state: web::Data<AppState>,
4702) -> HttpResponse {
4703 use solid_pod_rs_forge::{ForgeConfig, ForgeRequest, ForgeService};
4704
4705 let Some(plugin_dir) = forge_plugin_dir(&state) else {
4706 return HttpResponse::NotImplemented().json(serde_json::json!({
4707 "error": "forge requires fs-backend storage",
4708 "reason": "data_root_not_configured"
4709 }));
4710 };
4711
4712 let loopback: Arc<dyn solid_pod_rs_forge::LoopbackFetch> = Arc::new(ServerLoopback {
4717 client: reqwest::Client::new(),
4718 });
4719 let service = match ForgeService::new(ForgeConfig::default(), plugin_dir) {
4720 Ok(s) => s.with_loopback(loopback),
4721 Err(e) => {
4722 return HttpResponse::InternalServerError()
4723 .json(serde_json::json!({"error": e.to_string()}));
4724 }
4725 };
4726
4727 let path = req.uri().path().to_string();
4728 let query = req.uri().query().unwrap_or("").to_string();
4729 let host_url = {
4730 let conn = req.connection_info();
4731 Some(format!("{}://{}", conn.scheme(), conn.host()))
4732 };
4733 let headers: Vec<(String, String)> = req
4734 .headers()
4735 .iter()
4736 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
4737 .collect();
4738
4739 let forge_req = ForgeRequest {
4740 method: req.method().as_str().to_string(),
4741 path,
4742 query,
4743 headers,
4744 raw_body: body,
4745 host_url,
4746 };
4747
4748 let agent = service.resolve_agent(&forge_req).await;
4753
4754 match service.handle(forge_req, agent).await {
4755 Ok(resp) => {
4756 let mut builder = HttpResponse::build(
4757 StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
4758 );
4759 for (k, v) in &resp.headers {
4760 builder.insert_header((k.as_str(), v.as_str()));
4761 }
4762 builder.body(resp.body)
4763 }
4764 Err(e) => {
4765 let status = e.status_code();
4766 HttpResponse::build(
4767 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
4768 )
4769 .json(serde_json::json!({"error": e.to_string()}))
4770 }
4771 }
4772}
4773
4774pub fn build_app(
4786 state: AppState,
4787) -> App<
4788 impl actix_web::dev::ServiceFactory<
4789 ServiceRequest,
4790 Config = (),
4791 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
4792 Error = ActixError,
4793 InitError = (),
4794 >,
4795> {
4796 let body_cap = state.body_cap;
4797 let dotfiles = state.dotfiles.clone();
4798 let allowed_origins = Arc::new(state.allowed_origins.clone());
4799
4800 let mut app = App::new()
4801 .app_data(web::Data::new(state.clone()))
4802 .app_data(web::PayloadConfig::new(body_cap))
4803 .wrap(ErrorLoggingMiddleware)
4808 .wrap(CorsHeaders { allowed_origins })
4809 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
4813 .wrap(PathTraversalGuard)
4814 .wrap(DotfileGuard::new(dotfiles));
4815
4816 app = app
4822 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
4823 .route(
4824 "/.well-known/webfinger",
4825 web::get().to(handle_well_known_webfinger),
4826 )
4827 .route(
4828 "/.well-known/nodeinfo",
4829 web::get().to(handle_well_known_nodeinfo),
4830 )
4831 .route(
4832 "/.well-known/nodeinfo/2.1",
4833 web::get().to(handle_well_known_nodeinfo_2_1),
4834 );
4835
4836 #[cfg(feature = "did-nostr")]
4837 {
4838 app = app.route(
4839 "/.well-known/did/nostr/{pubkey}.json",
4840 web::get().to(handle_well_known_did_nostr),
4841 );
4842 }
4843
4844 #[cfg(feature = "nip05-endpoint")]
4849 {
4850 app = app.route(
4851 "/.well-known/nostr.json",
4852 web::get().to(handle_well_known_nip05),
4853 );
4854 }
4855
4856 #[cfg(feature = "export-jsonld")]
4861 {
4862 app = app.route("/api/exports/all", web::get().to(handle_export_all));
4863 }
4864
4865 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
4867
4868 app = app.route("/pay/.info", web::get().to(handle_pay_info));
4870
4871 app = app.configure(handlers::pay::register);
4876
4877 app = app.route("/proxy", web::get().to(handle_proxy));
4879
4880 if state.mcp_enabled {
4884 app = app.route("/mcp", web::post().to(mcp::handle_mcp)).route(
4885 "/mcp",
4886 web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options),
4887 );
4888 }
4889
4890 app = app.route(
4893 "/_admin/provision/{pubkey}",
4894 web::post().to(handle_admin_provision),
4895 );
4896
4897 app = app
4899 .route("/.pods", web::post().to(handle_create_pod))
4900 .route("/api/accounts/new", web::post().to(handle_create_account))
4901 .route("/pods/check/{name}", web::get().to(handle_pod_check))
4902 .route("/login/password", web::post().to(handle_login_password))
4903 .route(
4904 "/account/password/reset",
4905 web::post().to(handle_password_reset_request),
4906 )
4907 .route(
4908 "/account/password/change",
4909 web::post().to(handle_password_change),
4910 );
4911
4912 #[cfg(feature = "forge")]
4917 {
4918 app = app
4919 .route("/forge", web::route().to(handle_forge))
4920 .route("/forge/{tail:.*}", web::route().to(handle_forge));
4921 }
4922
4923 app = app
4928 .route(
4929 "/{tail:.*}/.git",
4931 web::route().to(|| async {
4932 HttpResponse::Forbidden()
4933 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4934 }),
4935 )
4936 .route(
4937 "/{tail:.*}/.git/{rest:.*}",
4938 web::route().to(|| async {
4939 HttpResponse::Forbidden()
4940 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4941 }),
4942 );
4943
4944 app = app.route(
4948 "/pods/{pk}/_git/{tail:.*}",
4949 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
4950 );
4951
4952 #[cfg(feature = "git")]
4953 {
4954 app = app
4956 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
4957 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
4958 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
4959
4960 app = app
4963 .route(
4964 "/pods/{pubkey}/_git/status",
4965 web::get().to(handle_git_status),
4966 )
4967 .route("/pods/{pubkey}/_git/log", web::get().to(handle_git_log))
4968 .route("/pods/{pubkey}/_git/diff", web::get().to(handle_git_diff))
4969 .route(
4970 "/pods/{pubkey}/_git/stage",
4971 web::post().to(handle_git_stage),
4972 )
4973 .route(
4974 "/pods/{pubkey}/_git/unstage",
4975 web::post().to(handle_git_unstage),
4976 )
4977 .route(
4978 "/pods/{pubkey}/_git/commit",
4979 web::post().to(handle_git_commit),
4980 )
4981 .route(
4982 "/pods/{pubkey}/_git/branches",
4983 web::get().to(handle_git_branches),
4984 )
4985 .route(
4986 "/pods/{pubkey}/_git/branch",
4987 web::post().to(handle_git_create_branch),
4988 )
4989 .route(
4990 "/pods/{pubkey}/_git/discard",
4991 web::post().to(handle_git_discard),
4992 );
4993
4994 app = app.configure(handlers::prov::register);
5001 }
5002 #[cfg(not(feature = "git"))]
5003 {
5004 let git_501 = || async {
5008 HttpResponse::NotImplemented()
5009 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
5010 };
5011 app = app
5012 .route("/{tail:.*}/info/refs", web::get().to(git_501))
5013 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
5014 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
5015 }
5016
5017 app.route("/{tail:.*}/", web::post().to(handle_post))
5020 .route("/{tail:.*}/", web::put().to(handle_put))
5021 .route("/{tail:.*}", web::get().to(handle_get))
5022 .route("/{tail:.*}", web::head().to(handle_get))
5023 .route("/{tail:.*}", web::put().to(handle_put))
5024 .route("/{tail:.*}", web::patch().to(handle_patch))
5025 .route("/{tail:.*}", web::delete().to(handle_delete))
5026 .route(
5027 "/{tail:.*}",
5028 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
5029 )
5030 .route(
5031 "/{tail:.*}",
5032 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
5033 )
5034}
5035
5036#[cfg(test)]
5041mod payment_gating_tests {
5042 use super::*;
5043 use solid_pod_rs::payments::WebLedger;
5044 use solid_pod_rs::storage::memory::MemoryBackend;
5045
5046 macro_rules! principal {
5050 () => {
5051 "did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
5052 };
5053 }
5054
5055 const PRINCIPAL: &str = principal!();
5056
5057 const OTHER_PRINCIPAL: &str =
5059 "did:nostr:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
5060
5061 const PAID_WRITE_ACL: &str = concat!(
5064 r#"
5065@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5066
5067<#paid-write> a acl:Authorization ;
5068 acl:agent <"#,
5069 principal!(),
5070 r#"> ;
5071 acl:accessTo </premium/inbox> ;
5072 acl:mode acl:Write ;
5073 acl:condition [
5074 a acl:PaymentCondition ;
5075 acl:costSats 100
5076 ] .
5077"#
5078 );
5079
5080 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
5081 let mut ledger = WebLedger::new("Test Pod Credits");
5082 if sats > 0 {
5083 ledger
5084 .credit_by_outpoint(did, "satoshi", &"ab".repeat(32), 0, sats)
5085 .unwrap();
5086 }
5087 let body = serde_json::to_vec(&ledger).unwrap();
5088 storage
5089 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
5090 .await
5091 .unwrap();
5092 }
5093
5094 async fn seed_acl(storage: &dyn Storage) {
5095 storage
5096 .put(
5097 "/premium/inbox.acl",
5098 Bytes::from(PAID_WRITE_ACL),
5099 "text/turtle",
5100 )
5101 .await
5102 .unwrap();
5103 }
5104
5105 #[actix_web::test]
5107 async fn resolve_balance_reads_ledger_entry() {
5108 let storage = MemoryBackend::new();
5109 seed_ledger(&storage, PRINCIPAL, 250).await;
5110 assert_eq!(
5111 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5112 Some(250)
5113 );
5114 }
5115
5116 #[actix_web::test]
5118 async fn resolve_balance_zero_when_no_entry() {
5119 let storage = MemoryBackend::new();
5120 seed_ledger(&storage, OTHER_PRINCIPAL, 500).await;
5121 assert_eq!(
5122 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5123 Some(0)
5124 );
5125 }
5126
5127 #[test]
5133 fn fixtures_use_teller_accounts_and_grant_the_principal() {
5134 use solid_pod_rs::payments::account_of;
5135 for did in [PRINCIPAL, OTHER_PRINCIPAL] {
5136 assert_eq!(account_of(did).unwrap(), did, "{did} must be canonical");
5137 }
5138 assert_ne!(PRINCIPAL, OTHER_PRINCIPAL);
5139 assert!(account_of("did:nostr:bob").is_err());
5140 let agent = format!("acl:agent <{PRINCIPAL}>");
5141 for (name, acl) in [
5142 ("PAID_WRITE_ACL", PAID_WRITE_ACL),
5143 ("WRITE_NOT_CONTROL_ACL", WRITE_NOT_CONTROL_ACL),
5144 ] {
5145 assert!(acl.contains(&agent), "{name} must grant PRINCIPAL");
5146 }
5147 assert!(!WRITE_NOT_CONTROL_ACL.contains("did:nostr:alice"));
5148 }
5149
5150 #[actix_web::test]
5153 async fn uncharged_read_skips_pay_per_read_resource() {
5154 let storage = Arc::new(MemoryBackend::new());
5155 let acl = format!(
5156 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
5157 <#paid> a acl:Authorization ; acl:agent <{PRINCIPAL}> ;\n\
5158 acl:accessTo </premium/doc> ; acl:mode acl:Read ;\n\
5159 acl:condition [ a acl:PaymentCondition ; acl:costSats 100 ] .\n"
5160 );
5161 storage
5162 .put("/premium/doc.acl", Bytes::from(acl), "text/turtle")
5163 .await
5164 .unwrap();
5165 seed_ledger(storage.as_ref(), PRINCIPAL, 500).await;
5166 let state = AppState::new(storage.clone());
5167
5168 let skipped =
5169 enforce_read_inner(&state, "/premium/doc", Some(PRINCIPAL), None, false).await;
5170 assert!(skipped.is_err(), "glob must not serve a pay-per-read child");
5171 assert_eq!(
5172 resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5173 Some(500)
5174 );
5175
5176 enforce_read_ctx(&state, "/premium/doc", Some(PRINCIPAL), None)
5177 .await
5178 .expect("a direct read pays and is served");
5179 assert_eq!(
5180 resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5181 Some(400)
5182 );
5183 }
5184
5185 #[actix_web::test]
5187 async fn resolve_balance_none_when_anonymous() {
5188 let storage = MemoryBackend::new();
5189 seed_ledger(&storage, PRINCIPAL, 1_000).await;
5190 assert_eq!(resolve_balance_sats(&storage, None).await, None);
5191 }
5192
5193 #[actix_web::test]
5195 async fn paid_write_denied_below_balance() {
5196 let storage = Arc::new(MemoryBackend::new());
5197 seed_acl(storage.as_ref()).await;
5198 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
5200
5201 let result =
5202 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5203 assert!(
5204 result.is_err(),
5205 "balance 50 < cost 100 must be denied — sat-gating loop closed"
5206 );
5207 }
5208
5209 #[actix_web::test]
5211 async fn paid_write_allowed_at_balance() {
5212 let storage = Arc::new(MemoryBackend::new());
5213 seed_acl(storage.as_ref()).await;
5214 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
5216
5217 let result =
5218 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5219 assert!(
5220 result.is_ok(),
5221 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
5222 );
5223 }
5224
5225 #[actix_web::test]
5227 async fn paid_write_allowed_above_balance() {
5228 let storage = Arc::new(MemoryBackend::new());
5229 seed_acl(storage.as_ref()).await;
5230 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
5231 let state = AppState::new(storage);
5232
5233 let result =
5234 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5235 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
5236 }
5237
5238 #[actix_web::test]
5242 async fn paid_write_anonymous_denied() {
5243 let storage = Arc::new(MemoryBackend::new());
5244 seed_acl(storage.as_ref()).await;
5245 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
5246 let state = AppState::new(storage);
5247
5248 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
5249 assert!(
5250 result.is_err(),
5251 "anonymous caller has no ledger principal — PaymentCondition fails closed"
5252 );
5253 }
5254
5255 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
5262 let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
5263 let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
5264 ledger.get_balance(did)
5265 }
5266
5267 #[actix_web::test]
5269 async fn paid_write_debits_ledger() {
5270 let storage = Arc::new(MemoryBackend::new());
5271 seed_acl(storage.as_ref()).await;
5272 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
5274
5275 let result =
5276 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5277 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
5278 assert_eq!(
5279 read_balance(storage.as_ref(), PRINCIPAL).await,
5280 150,
5281 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
5282 );
5283 }
5284
5285 #[actix_web::test]
5288 async fn paid_write_debits_each_grant() {
5289 let storage = Arc::new(MemoryBackend::new());
5290 seed_acl(storage.as_ref()).await;
5291 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
5293
5294 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5295 .await
5296 .unwrap();
5297 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5298 .await
5299 .unwrap();
5300 assert_eq!(
5301 read_balance(storage.as_ref(), PRINCIPAL).await,
5302 50,
5303 "250 - 2*100: each granted request debits, no unmetered re-use"
5304 );
5305
5306 let third =
5308 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5309 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
5310 assert_eq!(
5311 read_balance(storage.as_ref(), PRINCIPAL).await,
5312 50,
5313 "a denied request must not debit"
5314 );
5315 }
5316
5317 #[actix_web::test]
5319 async fn paid_read_debits_ledger() {
5320 const PAID_READ_ACL: &str = r#"
5321@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5322
5323<#paid-read> a acl:Authorization ;
5324 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5325 acl:accessTo </premium/feed> ;
5326 acl:mode acl:Read ;
5327 acl:condition [
5328 a acl:PaymentCondition ;
5329 acl:costSats 30
5330 ] .
5331"#;
5332 let storage = Arc::new(MemoryBackend::new());
5333 storage
5334 .put(
5335 "/premium/feed.acl",
5336 Bytes::from(PAID_READ_ACL),
5337 "text/turtle",
5338 )
5339 .await
5340 .unwrap();
5341 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5342 let state = AppState::new(storage.clone());
5343
5344 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
5345 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
5346 assert_eq!(
5347 read_balance(storage.as_ref(), PRINCIPAL).await,
5348 70,
5349 "100 - 30 cost: a granted paid read must debit"
5350 );
5351 }
5352
5353 #[actix_web::test]
5356 async fn free_read_does_not_debit() {
5357 let storage = Arc::new(MemoryBackend::new());
5358 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5360 let state = AppState::new(storage.clone());
5361
5362 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
5363 .await
5364 .unwrap();
5365 assert_eq!(
5366 read_balance(storage.as_ref(), PRINCIPAL).await,
5367 100,
5368 "a grant with no PaymentCondition must not debit"
5369 );
5370 }
5371
5372 const ALICE_ONLY_READ_ACL: &str = r#"
5378@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5379
5380<#alice> a acl:Authorization ;
5381 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5382 acl:accessTo </private/secret> ;
5383 acl:default </private/> ;
5384 acl:mode acl:Read, acl:Write, acl:Control .
5385"#;
5386
5387 async fn seed_private_read_acl(storage: &dyn Storage) {
5388 storage
5393 .put(
5394 "/private.acl",
5395 Bytes::from(ALICE_ONLY_READ_ACL),
5396 "text/turtle",
5397 )
5398 .await
5399 .unwrap();
5400 }
5401
5402 #[actix_web::test]
5406 async fn enforce_read_grants_owner() {
5407 let storage = Arc::new(MemoryBackend::new());
5408 seed_private_read_acl(storage.as_ref()).await;
5409 let state = AppState::new(storage);
5410 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
5411 assert!(result.is_ok(), "owner alice must be granted Read");
5412 }
5413
5414 #[actix_web::test]
5417 async fn enforce_read_denies_other_principal() {
5418 let storage = Arc::new(MemoryBackend::new());
5419 seed_private_read_acl(storage.as_ref()).await;
5420 let state = AppState::new(storage);
5421 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
5422 assert!(
5423 result.is_err(),
5424 "bob has no Read grant — private resource must not be world-readable"
5425 );
5426 }
5427
5428 #[actix_web::test]
5431 async fn enforce_read_denies_anonymous() {
5432 let storage = Arc::new(MemoryBackend::new());
5433 seed_private_read_acl(storage.as_ref()).await;
5434 let state = AppState::new(storage);
5435 let result = enforce_read(&state, "/private/secret", None).await;
5436 assert!(result.is_err(), "anonymous Read must be denied");
5437 }
5438
5439 const WRITE_NOT_CONTROL_ACL: &str = concat!(
5447 r#"
5448@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5449
5450<#owner> a acl:Authorization ;
5451 acl:agent <"#,
5452 principal!(),
5453 r#"> ;
5454 acl:accessTo </shared/doc> ;
5455 acl:default </shared/> ;
5456 acl:mode acl:Read, acl:Write, acl:Control .
5457
5458<#writer> a acl:Authorization ;
5459 acl:agent <did:nostr:writer> ;
5460 acl:accessTo </shared/doc> ;
5461 acl:default </shared/> ;
5462 acl:mode acl:Read, acl:Write .
5463"#
5464 );
5465
5466 async fn seed_shared_acl(storage: &dyn Storage) {
5467 storage
5472 .put(
5473 "/shared.acl",
5474 Bytes::from(WRITE_NOT_CONTROL_ACL),
5475 "text/turtle",
5476 )
5477 .await
5478 .unwrap();
5479 }
5480
5481 #[actix_web::test]
5485 async fn acl_put_denied_for_writer_without_control() {
5486 let storage = Arc::new(MemoryBackend::new());
5487 seed_shared_acl(storage.as_ref()).await;
5488 let state = AppState::new(storage);
5489 let result = enforce_write(
5493 &state,
5494 "/shared/.acl",
5495 AccessMode::Write,
5496 Some("did:nostr:writer"),
5497 )
5498 .await;
5499 assert!(
5500 result.is_err(),
5501 "writer lacks Control — must not be able to PUT /shared/.acl"
5502 );
5503 }
5504
5505 #[actix_web::test]
5507 async fn acl_put_allowed_for_control_holder() {
5508 let storage = Arc::new(MemoryBackend::new());
5509 seed_shared_acl(storage.as_ref()).await;
5510 let state = AppState::new(storage);
5511 let result =
5512 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
5513 assert!(
5514 result.is_ok(),
5515 "the owner holds Control — must be allowed to PUT /shared/.acl"
5516 );
5517 }
5518
5519 #[actix_web::test]
5521 async fn meta_put_denied_for_writer_without_control() {
5522 let storage = Arc::new(MemoryBackend::new());
5523 seed_shared_acl(storage.as_ref()).await;
5524 let state = AppState::new(storage);
5525 let result = enforce_write(
5526 &state,
5527 "/shared/doc.meta",
5528 AccessMode::Write,
5529 Some("did:nostr:writer"),
5530 )
5531 .await;
5532 assert!(
5533 result.is_err(),
5534 "writer lacks Control — must not be able to PUT a .meta sidecar"
5535 );
5536 }
5537
5538 #[test]
5541 fn canonical_pod_path_vectors() {
5542 let ok = [
5543 ("a/b", "/a/b"),
5544 ("", "/"),
5545 ("/", "/"),
5546 ("//a//b/", "/a/b/"),
5547 ("/%2ewell-known/x", "/.well-known/x"),
5548 ("/a%2fb", "/a/b"),
5549 ("/a%2520b", "/a%20b"),
5550 ];
5551 for (raw, want) in ok {
5552 assert_eq!(canonical_pod_path(raw).as_deref(), Some(want), "{raw}");
5553 }
5554 for bad in [
5555 "/a/./b",
5556 "/a/..",
5557 "../a",
5558 "/a\\b",
5559 "/a%00b",
5560 "/a%ffb",
5561 "/%2e%2e/x",
5562 ] {
5563 assert_eq!(canonical_pod_path(bad), None, "{bad}");
5564 }
5565 }
5566
5567 #[test]
5568 fn guarded_acl_target_vectors() {
5569 use AccessMode::{Control, Read, Write};
5570 let c = |p: &str, m| guarded_acl_target(p, m);
5571 assert_eq!(
5572 c("/.well-known/token/x.json", Read),
5573 ("/.well-known/token/x.json".into(), Control)
5574 );
5575 assert_eq!(
5576 c("/.well-known/gitmark/p.json", Write),
5577 ("/.well-known/gitmark/p.json".into(), Control)
5578 );
5579 assert_eq!(c("/.well-known/%74oken/x.json", Read).1, Control);
5580 assert_eq!(
5581 c("/.well-known/token/x.json.acl", Read),
5582 ("/.well-known/token/x.json".into(), Control)
5583 );
5584 assert_eq!(
5585 c("/.well-known/gitmark/p/blocktrails.json", Read),
5586 ("/.well-known/gitmark/p/blocktrails.json".into(), Read)
5587 );
5588 assert_eq!(
5589 c("/.well-known/webledgers/webledgers.json", Write),
5590 ("/.well-known/webledgers/webledgers.json".into(), Control)
5591 );
5592 assert_eq!(c("/.well-known/%57ebledgers/state.json", Read).1, Control);
5593 assert_eq!(c("/.well-known/webledgersx/y", Read).1, Read);
5594 assert_eq!(c("/pub/x", Read), ("/pub/x".into(), Read));
5595 }
5596
5597 #[test]
5599 fn protected_resource_for_acl_strips_suffixes() {
5600 assert_eq!(
5601 protected_resource_for_acl("/victim/.acl").as_deref(),
5602 Some("/victim/")
5603 );
5604 assert_eq!(
5605 protected_resource_for_acl("/a/b.acl").as_deref(),
5606 Some("/a/b")
5607 );
5608 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
5609 assert_eq!(
5610 protected_resource_for_acl("/a/b.meta").as_deref(),
5611 Some("/a/b")
5612 );
5613 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
5614 }
5615}