1#![doc = include_str!("../README.md")]
107#![deny(unsafe_code)]
108#![warn(rust_2018_idioms)]
109
110pub mod cli;
112
113mod handlers;
118
119mod mcp;
122
123pub mod mempool;
128
129pub mod trail_store;
135
136use std::collections::HashMap;
137use std::net::{IpAddr, Ipv4Addr};
138use std::path::{Path, PathBuf};
139use std::sync::{Arc, Mutex};
140use std::time::{Duration, Instant};
141
142use actix_web::body::{BoxBody, EitherBody};
143use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
144use actix_web::http::{header, StatusCode};
145use actix_web::middleware::{NormalizePath, TrailingSlash};
146use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
147use bytes::Bytes;
148use futures_util::future::{ready, LocalBoxFuture, Ready};
149use futures_util::StreamExt;
150use percent_encoding::percent_decode_str;
151use serde::Deserialize;
152use solid_pod_rs::{
153 auth::{nip98, replay::ReplayStore},
157 config::sources::parse_size,
158 interop,
159 ldp::{
160 self, cache_control_for_response, LdpContainerOps, PatchCreateOutcome, ResponseAudience,
161 },
162 mashlib::{self, MashlibConfig},
163 provenance::{ProvenanceReceipt, ProvenanceSkip},
164 security::DotfileAllowlist,
165 storage::Storage,
166 wac::{
167 self, conditions::RequestContext, effective_acl_target, parse_jsonld_acl,
168 parser::parse_turtle_acl, protected_resource_for_acl, AccessMode,
169 },
170 PodError,
171};
172
173const _: () = solid_pod_rs::auth::nip98::assert_schnorr_verification_enabled();
185
186static NIP98_REPLAY: std::sync::LazyLock<solid_pod_rs::auth::replay::Nip98ReplayCache> =
193 std::sync::LazyLock::new(solid_pod_rs::auth::replay::Nip98ReplayCache::from_env);
194
195pub(crate) static PAYMENT_STATE_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
201 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
202
203#[derive(Clone)]
209pub struct AppState {
210 pub storage: Arc<dyn Storage>,
211 pub dotfiles: Arc<DotfileAllowlist>,
212 pub body_cap: usize,
213 pub nodeinfo: NodeInfoMeta,
214 pub mashlib: MashlibConfig,
215 pub mashlib_cdn: Option<String>,
218 pub live_reload: bool,
220 pub pay_config: solid_pod_rs::payments::PayConfig,
223 pub data_root: Option<PathBuf>,
228 pub quota: Option<Arc<dyn solid_pod_rs::quota::QuotaPolicy>>,
230 pub pod_create_limiter: Arc<PodCreateLimiter>,
232 pub allowed_origins: Vec<String>,
239 pub admin_key: Option<String>,
244 pub mcp_enabled: bool,
249 pub mempool_url: Option<String>,
255}
256
257#[cfg(test)]
258mod nip98_request_binding_tests {
259 use super::*;
260 use actix_web::test::TestRequest;
261
262 const SECRET: &str = "3333333333333333333333333333333333333333333333333333333333333333";
263
264 fn now() -> u64 {
265 std::time::SystemTime::now()
266 .duration_since(std::time::UNIX_EPOCH)
267 .unwrap()
268 .as_secs()
269 }
270
271 #[actix_web::test]
272 async fn authentication_binds_the_complete_query_string() {
273 let signed_url = "http://localhost:8080/private?include=true";
274 let token = nip98::mint(signed_url, "GET", SECRET, now()).unwrap();
275 let request = TestRequest::get()
276 .uri("/private?include=true")
277 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
278 .to_http_request();
279 assert!(extract_pubkey(&request).await.is_some());
280
281 let path_only = nip98::mint("http://localhost:8080/private", "GET", SECRET, now()).unwrap();
282 let request = TestRequest::get()
283 .uri("/private?include=true")
284 .insert_header((header::AUTHORIZATION, format!("Nostr {path_only}")))
285 .to_http_request();
286 assert!(extract_pubkey(&request).await.is_none());
287 }
288
289 #[actix_web::test]
290 async fn authentication_binds_the_exact_raw_body() {
291 let expected = br#"{"value":"authorised"}"#;
292 let token = nip98::mint_with_payload(
293 "http://localhost:8080/resource",
294 "PUT",
295 Some(expected),
296 SECRET,
297 now(),
298 )
299 .unwrap();
300 let request = TestRequest::put()
301 .uri("/resource")
302 .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
303 .to_http_request();
304
305 assert!(
306 extract_pubkey_with_body(&request, Some(br#"{"value":"tampered"}"#))
307 .await
308 .is_none()
309 );
310 assert!(extract_pubkey_with_body(&request, Some(expected))
311 .await
312 .is_some());
313 }
314}
315
316#[derive(Clone, Debug)]
318pub struct NodeInfoMeta {
319 pub software_name: String,
320 pub software_version: String,
321 pub open_registrations: bool,
322 pub total_users: u64,
323 pub base_url: String,
324}
325
326impl Default for NodeInfoMeta {
327 fn default() -> Self {
328 Self {
329 software_name: "solid-pod-rs-server".to_string(),
330 software_version: env!("CARGO_PKG_VERSION").to_string(),
331 open_registrations: false,
332 total_users: 0,
333 base_url: "http://localhost".to_string(),
334 }
335 }
336}
337
338pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
341
342pub fn body_cap_from_env() -> usize {
347 match std::env::var("JSS_MAX_REQUEST_BODY").or_else(|_| std::env::var("JSS_BODY_LIMIT")) {
348 Ok(v) => parse_size(&v)
349 .map(|u| u as usize)
350 .unwrap_or(DEFAULT_BODY_CAP),
351 Err(_) => DEFAULT_BODY_CAP,
352 }
353}
354
355impl AppState {
356 pub fn new(storage: Arc<dyn Storage>) -> Self {
359 Self {
360 storage,
361 dotfiles: Arc::new(DotfileAllowlist::from_env()),
362 body_cap: body_cap_from_env(),
363 nodeinfo: NodeInfoMeta::default(),
364 mashlib: MashlibConfig::default(),
365 mashlib_cdn: None,
366 live_reload: false,
367 pay_config: solid_pod_rs::payments::PayConfig::default(),
368 data_root: None,
369 quota: None,
370 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
371 allowed_origins: Vec::new(),
372 admin_key: None,
373 mcp_enabled: false,
374 mempool_url: None,
375 }
376 }
377}
378
379const 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>";
380
381fn inject_live_reload(html: impl AsRef<[u8]>, enabled: bool) -> Vec<u8> {
382 let bytes = html.as_ref();
383 if !enabled {
384 return bytes.to_vec();
385 }
386 let html = String::from_utf8_lossy(bytes);
387 if let Some(index) = html.find("</body>") {
388 let mut out = String::with_capacity(html.len() + LIVE_RELOAD_SCRIPT.len());
389 out.push_str(&html[..index]);
390 out.push_str(LIVE_RELOAD_SCRIPT);
391 out.push_str(&html[index..]);
392 out.into_bytes()
393 } else {
394 format!("{html}{LIVE_RELOAD_SCRIPT}").into_bytes()
395 }
396}
397
398#[cfg(test)]
399mod live_reload_tests {
400 use super::*;
401
402 #[test]
403 fn injects_before_body_close_and_is_disabled_by_default() {
404 let html = b"<html><body>ok</body></html>";
405 assert_eq!(inject_live_reload(html, false), html);
406 let injected = String::from_utf8(inject_live_reload(html, true)).unwrap();
407 assert!(injected.contains("/.notifications"));
408 assert!(injected.find("<script>").unwrap() < injected.find("</body>").unwrap());
409 }
410}
411
412#[cfg(test)]
413mod dev_bearer_tests {
414 use super::*;
415 use base64::Engine as _;
416 use hmac::{Hmac, Mac};
417 use sha2::Sha256;
418 use std::sync::Mutex;
419
420 static ENV: Mutex<()> = Mutex::new(());
421
422 fn token(exp: u64) -> String {
423 let data = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
424 serde_json::json!({"webId":"https://alice.example/profile/card#me","iat":1,"exp":exp})
425 .to_string(),
426 );
427 let mut mac = Hmac::<Sha256>::new_from_slice(b"01234567890123456789012345678901").unwrap();
428 mac.update(data.as_bytes());
429 let sig =
430 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
431 format!("{data}.{sig}")
432 }
433
434 #[test]
435 fn simple_bearer_verifies_signature_and_expiry() {
436 let _guard = ENV.lock().unwrap();
437 std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
438 assert_eq!(
439 verify_dev_bearer(&token(u64::MAX)).as_deref(),
440 Some("https://alice.example/profile/card#me")
441 );
442 assert!(verify_dev_bearer(&(token(u64::MAX) + "x")).is_none());
443 assert!(verify_dev_bearer(&token(2)).is_none());
444 std::env::remove_var("TOKEN_SECRET");
445 }
446}
447
448#[derive(Debug)]
450pub struct PodCreateLimiter {
451 hits: Mutex<HashMap<IpAddr, Instant>>,
452 window: Duration,
453}
454
455impl Default for PodCreateLimiter {
456 fn default() -> Self {
457 Self {
458 hits: Mutex::new(HashMap::new()),
459 window: Duration::from_secs(24 * 60 * 60),
460 }
461 }
462}
463
464impl PodCreateLimiter {
465 fn check(&self, ip: IpAddr) -> Result<(), u64> {
466 let now = Instant::now();
467 let mut hits = self.hits.lock().unwrap();
468 if let Some(last) = hits.get(&ip).copied() {
469 let elapsed = now.saturating_duration_since(last);
470 if elapsed < self.window {
471 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
472 }
473 }
474 hits.insert(ip, now);
475 Ok(())
476 }
477}
478
479pub(crate) fn to_actix(e: PodError) -> ActixError {
484 match e {
485 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
486 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
487 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
488 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
489 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
490 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
491 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
492 }
493}
494
495pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
507 extract_pubkey_with_body(req, None).await
508}
509
510pub(crate) async fn extract_pubkey_with_body(
514 req: &HttpRequest,
515 body: Option<&[u8]>,
516) -> Option<String> {
517 let header_val = req
518 .headers()
519 .get(header::AUTHORIZATION)
520 .and_then(|v| v.to_str().ok())?;
521 if let Some(token) = header_val.strip_prefix("Bearer ") {
522 return verify_dev_bearer(token);
523 }
524 let url = {
535 let conn = req.connection_info();
536 format!("{}://{}{}", conn.scheme(), conn.host(), req.uri())
537 };
538 let now = std::time::SystemTime::now()
539 .duration_since(std::time::UNIX_EPOCH)
540 .map(|d| d.as_secs())
541 .unwrap_or(0);
542 let verified = nip98::verify_at(header_val, &url, req.method().as_str(), body, now).ok()?;
543
544 if NIP98_REPLAY
548 .check_and_record(&verified.event_id)
549 .await
550 .is_err()
551 {
552 tracing::warn!(
553 pubkey = %verified.pubkey,
554 method = %req.method(),
555 "NIP-98 replay rejected: token id already used within window"
556 );
557 return None;
558 }
559
560 Some(verified.pubkey)
561}
562
563pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
564 pubkey.map(|pk| {
565 if pk.starts_with("https://") || pk.starts_with("http://") {
566 pk.clone()
567 } else {
568 format!("did:nostr:{pk}")
569 }
570 })
571}
572
573fn verify_dev_bearer(token: &str) -> Option<String> {
574 use base64::Engine as _;
575 use hmac::{Hmac, Mac};
576 use sha2::Sha256;
577
578 let secret = std::env::var("TOKEN_SECRET").ok()?;
579 if secret.len() < 32 {
580 tracing::warn!("TOKEN_SECRET shorter than 32 bytes; refusing development bearer token");
581 return None;
582 }
583 let (data, signature) = token.split_once('.')?;
584 if signature.contains('.') {
585 return None;
586 }
587 let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
588 .decode(signature)
589 .ok()?;
590 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
591 mac.update(data.as_bytes());
592 mac.verify_slice(&signature).ok()?;
593
594 #[derive(serde::Deserialize)]
595 #[serde(rename_all = "camelCase")]
596 struct Claims {
597 web_id: String,
598 iat: u64,
599 exp: u64,
600 }
601 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
602 .decode(data)
603 .ok()?;
604 let claims: Claims = serde_json::from_slice(&payload).ok()?;
605 let now = std::time::SystemTime::now()
606 .duration_since(std::time::UNIX_EPOCH)
607 .ok()?
608 .as_secs();
609 if claims.exp < now || claims.iat > now.saturating_add(60) || claims.exp <= claims.iat {
610 return None;
611 }
612 let url = url::Url::parse(&claims.web_id).ok()?;
613 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
614 return None;
615 }
616 Some(claims.web_id)
617}
618
619fn req_origin(req: &HttpRequest) -> Option<&str> {
628 req.headers()
629 .get(header::ORIGIN)
630 .and_then(|v| v.to_str().ok())
631}
632
633pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
637
638async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
655 let did = agent_uri?;
656 let balance = match storage.get(WEBLEDGER_PATH).await {
657 Ok((bytes, _meta)) => {
658 match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
659 Ok(ledger) => ledger.get_balance(did),
660 Err(_) => 0,
664 }
665 }
666 Err(_) => 0,
669 };
670 Some(balance)
671}
672
673fn accept_includes_html(accept: &str) -> bool {
681 accept.split(',').any(|entry| {
682 let mime = entry.split(';').next().unwrap_or("").trim();
683 mime.eq_ignore_ascii_case("text/html")
684 })
685}
686
687fn proposed_acl_keeps_caller_control(
706 body: &[u8],
707 content_type: &str,
708 caller: Option<&str>,
709) -> bool {
710 let doc = match parse_jsonld_acl(body) {
711 Ok(d) => Some(d),
712 Err(_) => {
713 let ct = content_type.to_ascii_lowercase();
714 let text = std::str::from_utf8(body).unwrap_or("");
715 let looks_turtle = ct.starts_with("text/turtle")
716 || ct.starts_with("application/turtle")
717 || ct.starts_with("application/x-turtle")
718 || ct.starts_with("application/n-triples")
719 || text.contains("@prefix")
720 || text.contains("acl:Authorization")
721 || text.contains("auth/acl#Authorization");
725 if looks_turtle {
726 parse_turtle_acl(text).ok()
727 } else {
728 None
729 }
730 }
731 };
732 let Some(doc) = doc else {
733 return true;
735 };
736 let Some(graph) = doc.graph.as_ref() else {
737 return false;
738 };
739 graph.iter().any(|auth| {
740 let grants_control = ids_of_acl_field(&auth.mode)
741 .iter()
742 .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
743 if !grants_control {
744 return false;
745 }
746 let agents = ids_of_acl_field(&auth.agent);
747 if let Some(web_id) = caller {
748 if agents.contains(&web_id) {
749 return true;
750 }
751 }
752 let classes = ids_of_acl_field(&auth.agent_class);
753 if classes
754 .iter()
755 .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
756 {
757 return true;
758 }
759 if caller.is_some()
760 && classes.iter().any(|c| {
761 *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
762 || *c == "acl:AuthenticatedAgent"
763 })
764 {
765 return true;
766 }
767 false
768 })
769}
770
771fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
773 match field {
774 None => Vec::new(),
775 Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
776 Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
777 }
778}
779
780#[cfg_attr(not(test), allow(dead_code))]
787async fn enforce_write(
788 state: &AppState,
789 path: &str,
790 mode: AccessMode,
791 agent_uri: Option<&str>,
792) -> Result<(), ActixError> {
793 enforce_write_ctx(state, path, mode, agent_uri, None).await
794}
795
796async fn enforce_write_ctx(
804 state: &AppState,
805 path: &str,
806 mode: AccessMode,
807 agent_uri: Option<&str>,
808 request_origin: Option<&str>,
809) -> Result<(), ActixError> {
810 let origin = request_origin.and_then(wac::Origin::parse);
811 let (resource, eff_mode) = effective_acl_target(path, mode);
823
824 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
832 if outcome.is_failure() {
833 return Err(policy_failure_to_actix(&outcome, &resource));
834 }
835 let acl_doc = outcome.document().cloned();
836
837 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
842
843 let ctx = RequestContext {
844 web_id: agent_uri,
845 client_id: None,
846 issuer: None,
847 payment_balance_sats,
848 };
849 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
850 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
851 let granted = wac::evaluate_access_ctx_with_registry(
852 acl_doc.as_ref(),
853 &ctx,
854 &resource,
855 eff_mode,
856 origin.as_ref(),
857 &groups,
858 ®istry,
859 );
860 if !granted {
861 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
862 }
863 if resource.as_str() == path {
870 charge_granted_payment(
876 state,
877 acl_doc.as_ref(),
878 &ctx,
879 &resource,
880 eff_mode,
881 &groups,
882 ®istry,
883 )
884 .await?;
885 }
886 Ok(())
887}
888
889async fn charge_granted_payment(
898 state: &AppState,
899 acl_doc: Option<&wac::AclDocument>,
900 ctx: &RequestContext<'_>,
901 path: &str,
902 mode: AccessMode,
903 groups: &wac::StaticGroupMembership,
904 registry: &wac::conditions::ConditionRegistry,
905) -> Result<(), ActixError> {
906 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
907 if cost == 0 {
908 return Ok(());
909 }
910 if let Some(did) = ctx.web_id {
911 if debit_ledger(&*state.storage, did, cost).await.is_err() {
912 return Err(acl_denial(acl_doc, ctx.web_id, path));
913 }
914 }
915 Ok(())
916}
917
918fn acl_denial(
924 acl_doc: Option<&wac::AclDocument>,
925 agent_uri: Option<&str>,
926 path: &str,
927) -> ActixError {
928 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
929 let (status, body, unauthenticated) = if agent_uri.is_none() {
930 (StatusCode::UNAUTHORIZED, "authentication required", true)
931 } else {
932 (StatusCode::FORBIDDEN, "access forbidden", false)
933 };
934 let mut rsp = HttpResponse::new(status);
935 rsp.headers_mut().insert(
936 header::HeaderName::from_static("wac-allow"),
937 header::HeaderValue::from_str(&allow_header)
938 .unwrap_or(header::HeaderValue::from_static("")),
939 );
940 if unauthenticated {
941 rsp.headers_mut().insert(
948 header::WWW_AUTHENTICATE,
949 header::HeaderValue::from_static(
950 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
951 ),
952 );
953 }
954 actix_web::error::InternalError::from_response(body, rsp).into()
955}
956
957#[cfg_attr(not(test), allow(dead_code))]
968async fn enforce_read(
969 state: &AppState,
970 path: &str,
971 agent_uri: Option<&str>,
972) -> Result<ResponseAudience, ActixError> {
973 enforce_read_ctx(state, path, agent_uri, None).await
974}
975
976async fn enforce_read_ctx(
979 state: &AppState,
980 path: &str,
981 agent_uri: Option<&str>,
982 request_origin: Option<&str>,
983) -> Result<ResponseAudience, ActixError> {
984 let origin = request_origin.and_then(wac::Origin::parse);
985 let (resource, eff_mode) = effective_acl_target(path, AccessMode::Read);
997 let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
1000 if outcome.is_failure() {
1001 return Err(policy_failure_to_actix(&outcome, &resource));
1002 }
1003 let acl_doc = outcome.document().cloned();
1004 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
1005 let ctx = RequestContext {
1006 web_id: agent_uri,
1007 client_id: None,
1008 issuer: None,
1009 payment_balance_sats,
1010 };
1011 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
1012 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
1013 let granted = wac::evaluate_access_ctx_with_registry(
1014 acl_doc.as_ref(),
1015 &ctx,
1016 &resource,
1017 eff_mode,
1018 origin.as_ref(),
1019 &groups,
1020 ®istry,
1021 );
1022 if !granted {
1023 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1024 }
1025 if resource.as_str() == path {
1029 charge_granted_payment(
1034 state,
1035 acl_doc.as_ref(),
1036 &ctx,
1037 &resource,
1038 eff_mode,
1039 &groups,
1040 ®istry,
1041 )
1042 .await?;
1043 }
1044 let anon_ctx = RequestContext {
1053 web_id: None,
1054 client_id: None,
1055 issuer: None,
1056 payment_balance_sats: None,
1057 };
1058 let anonymous_would_be_granted = wac::evaluate_access_ctx_with_registry(
1059 acl_doc.as_ref(),
1060 &anon_ctx,
1061 &resource,
1062 eff_mode,
1063 origin.as_ref(),
1064 &groups,
1065 ®istry,
1066 );
1067 Ok(if anonymous_would_be_granted && resource.as_str() == path {
1068 ResponseAudience::Public
1069 } else {
1070 ResponseAudience::Private
1071 })
1072}
1073
1074async fn debit_ledger(
1083 storage: &dyn Storage,
1084 did: &str,
1085 cost: u64,
1086) -> Result<(), solid_pod_rs::payments::PaymentError> {
1087 use solid_pod_rs::payments::{PaymentError, WebLedger};
1088 let _transaction = PAYMENT_STATE_LOCK.lock().await;
1089
1090 let (bytes, _meta) = storage
1091 .get(WEBLEDGER_PATH)
1092 .await
1093 .map_err(|e| PaymentError::Store(e.to_string()))?;
1094 let mut ledger: WebLedger = serde_json::from_slice(&bytes)
1095 .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
1096 let _charge = ledger.charge(did, cost)?;
1098 let body = serde_json::to_vec(&ledger)
1099 .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
1100 storage
1101 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
1102 .await
1103 .map_err(|e| PaymentError::Store(e.to_string()))?;
1104 Ok(())
1105}
1106
1107fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
1112 let links = ldp::link_headers(path).join(", ");
1113 if let Ok(value) = header::HeaderValue::from_str(&links) {
1114 rsp.headers_mut()
1115 .insert(header::HeaderName::from_static("link"), value);
1116 }
1117}
1118
1119fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
1120 if let Ok(v) = header::HeaderValue::from_str(header_value) {
1121 rsp.headers_mut()
1122 .insert(header::HeaderName::from_static("wac-allow"), v);
1123 }
1124}
1125
1126fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
1127 let ws_base = base_url
1128 .replacen("https://", "wss://", 1)
1129 .replacen("http://", "ws://", 1);
1130 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
1131 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
1132 rsp.headers_mut()
1133 .insert(header::HeaderName::from_static("updates-via"), v);
1134 }
1135}
1136
1137fn set_provenance_headers(rsp: &mut HttpResponse, receipt: &ProvenanceReceipt) {
1148 if let Ok(v) = header::HeaderValue::from_str(&receipt.summary()) {
1149 rsp.headers_mut()
1150 .insert(header::HeaderName::from_static("x-provenance"), v);
1151 }
1152 if let Some(sha) = receipt.commit_sha() {
1153 if let Ok(v) = header::HeaderValue::from_str(sha) {
1154 rsp.headers_mut()
1155 .insert(header::HeaderName::from_static("x-provenance-commit"), v);
1156 }
1157 }
1158}
1159
1160fn set_cache_policy(rsp: &mut HttpResponse, content_type: &str, audience: ResponseAudience) {
1172 if audience == ResponseAudience::Private {
1173 append_vary(rsp, "Authorization");
1177 }
1178 if rsp.headers().contains_key(header::CACHE_CONTROL) {
1179 return;
1180 }
1181 if let Some(value) = cache_control_for_response(content_type, audience) {
1182 rsp.headers_mut().insert(
1183 header::CACHE_CONTROL,
1184 header::HeaderValue::from_static(value),
1185 );
1186 }
1187}
1188
1189fn append_vary(rsp: &mut HttpResponse, field: &str) {
1191 let existing = rsp
1192 .headers()
1193 .get(header::VARY)
1194 .and_then(|v| v.to_str().ok())
1195 .unwrap_or("")
1196 .to_string();
1197 if existing
1198 .split(',')
1199 .any(|f| f.trim().eq_ignore_ascii_case(field))
1200 {
1201 return;
1202 }
1203 let merged = if existing.trim().is_empty() {
1204 field.to_string()
1205 } else {
1206 format!("{existing}, {field}")
1207 };
1208 if let Ok(v) = header::HeaderValue::from_str(&merged) {
1209 rsp.headers_mut().insert(header::VARY, v);
1210 }
1211}
1212
1213async fn handle_get(
1214 req: HttpRequest,
1215 state: web::Data<AppState>,
1216) -> Result<HttpResponse, ActixError> {
1217 let path = req.uri().path().to_string();
1218
1219 if path.contains('*') {
1220 return handle_glob_get(req, state).await;
1221 }
1222
1223 let auth_pk = extract_pubkey(&req).await;
1224 let agent = agent_uri(auth_pk.as_ref());
1225
1226 let audience = enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
1231
1232 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
1233
1234 if ldp::is_container(&path) {
1235 let accept = req
1236 .headers()
1237 .get(header::ACCEPT)
1238 .and_then(|v| v.to_str().ok())
1239 .unwrap_or("");
1240
1241 if accept_includes_html(accept) {
1247 let index_path = format!("{path}index.html");
1248 if let Ok((body, _meta)) = state.storage.get(&index_path).await {
1249 let mut rsp = HttpResponse::Ok()
1250 .content_type("text/html; charset=utf-8")
1251 .body(inject_live_reload(&body, state.live_reload));
1252 set_cache_policy(&mut rsp, "text/html", audience);
1253 set_wac_allow(&mut rsp, &wac_allow);
1254 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1255 set_link_headers(&mut rsp, &path);
1256 return Ok(rsp);
1257 }
1258 }
1259
1260 let v = state
1261 .storage
1262 .container_representation(&path)
1263 .await
1264 .map_err(to_actix)?;
1265
1266 let sec_fetch_dest = req
1268 .headers()
1269 .get("sec-fetch-dest")
1270 .and_then(|v| v.to_str().ok());
1271 if mashlib::should_serve(
1272 accept,
1273 sec_fetch_dest,
1274 "application/ld+json",
1275 state.mashlib.enabled,
1276 ) {
1277 let json_ld = serde_json::to_string(&v).ok();
1278 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
1279 let mut rsp = HttpResponse::Ok()
1280 .content_type("text/html; charset=utf-8")
1281 .insert_header(("X-Frame-Options", "DENY"))
1282 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1283 .insert_header(("Cache-Control", "no-store"))
1284 .body(inject_live_reload(html, state.live_reload));
1285 set_cache_policy(&mut rsp, "text/html", audience);
1286 set_wac_allow(&mut rsp, &wac_allow);
1287 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1288 set_link_headers(&mut rsp, &path);
1289 return Ok(rsp);
1290 }
1291
1292 let mut rsp = HttpResponse::Ok().json(v);
1293 rsp.headers_mut().insert(
1294 header::CONTENT_TYPE,
1295 header::HeaderValue::from_static("application/ld+json"),
1296 );
1297 set_cache_policy(&mut rsp, "application/ld+json", audience);
1298 set_wac_allow(&mut rsp, &wac_allow);
1299 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1300 set_link_headers(&mut rsp, &path);
1301 return Ok(rsp);
1302 }
1303
1304 match state.storage.get(&path).await {
1305 Ok((body, meta)) => {
1306 let accept = req
1308 .headers()
1309 .get(header::ACCEPT)
1310 .and_then(|v| v.to_str().ok())
1311 .unwrap_or("");
1312 let sec_fetch_dest = req
1313 .headers()
1314 .get("sec-fetch-dest")
1315 .and_then(|v| v.to_str().ok());
1316 if mashlib::should_serve(
1317 accept,
1318 sec_fetch_dest,
1319 &meta.content_type,
1320 state.mashlib.enabled,
1321 ) {
1322 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
1323 std::str::from_utf8(&body).ok().map(|s| s.to_string())
1324 } else {
1325 None
1326 };
1327 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
1328 let mut rsp = HttpResponse::Ok()
1329 .content_type("text/html; charset=utf-8")
1330 .insert_header(("X-Frame-Options", "DENY"))
1331 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1332 .insert_header(("Cache-Control", "no-store"))
1333 .body(inject_live_reload(html, state.live_reload));
1334 set_cache_policy(&mut rsp, "text/html", audience);
1335 set_wac_allow(&mut rsp, &wac_allow);
1336 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1337 set_link_headers(&mut rsp, &path);
1338 return Ok(rsp);
1339 }
1340
1341 if let Some((negotiated_body, negotiated_ct)) =
1349 rdf_content_negotiate(&body, &meta.content_type, accept)
1350 {
1351 let mut rsp = HttpResponse::Ok().body(negotiated_body);
1352 rsp.headers_mut().insert(
1353 header::CONTENT_TYPE,
1354 header::HeaderValue::from_str(negotiated_ct)
1355 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
1356 );
1357 rsp.headers_mut()
1358 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
1359 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1360 rsp.headers_mut().insert(header::ETAG, etag);
1361 }
1362 set_cache_policy(&mut rsp, negotiated_ct, audience);
1363 set_wac_allow(&mut rsp, &wac_allow);
1364 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1365 set_link_headers(&mut rsp, &path);
1366 return Ok(rsp);
1367 }
1368
1369 let response_body = if meta.content_type.starts_with("text/html") {
1370 inject_live_reload(&body, state.live_reload)
1371 } else {
1372 body.to_vec()
1373 };
1374 let mut rsp = HttpResponse::Ok().body(response_body);
1375 rsp.headers_mut().insert(
1376 header::CONTENT_TYPE,
1377 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
1378 header::HeaderValue::from_static("application/octet-stream")
1379 }),
1380 );
1381 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1382 rsp.headers_mut().insert(header::ETAG, etag);
1383 }
1384 set_cache_policy(&mut rsp, &meta.content_type, audience);
1385 set_wac_allow(&mut rsp, &wac_allow);
1386 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1387 set_link_headers(&mut rsp, &path);
1388 Ok(rsp)
1389 }
1390 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1391 Err(e) => Err(to_actix(e)),
1392 }
1393}
1394
1395fn has_basic_container_link(req: &HttpRequest) -> bool {
1396 req.headers()
1397 .get_all(header::LINK)
1398 .filter_map(|v| v.to_str().ok())
1399 .any(|v| {
1400 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
1401 })
1402}
1403
1404async fn handle_put(
1405 req: HttpRequest,
1406 body: web::Bytes,
1407 state: web::Data<AppState>,
1408) -> Result<HttpResponse, ActixError> {
1409 let path = req.uri().path().to_string();
1410
1411 if ldp::is_container(&path) {
1412 if has_basic_container_link(&req) {
1413 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1414 let agent = agent_uri(auth_pk.as_ref());
1415 enforce_write_ctx(
1416 &state,
1417 &path,
1418 AccessMode::Write,
1419 agent.as_deref(),
1420 req_origin(&req),
1421 )
1422 .await?;
1423 let meta = state
1424 .storage
1425 .create_container(&path)
1426 .await
1427 .map_err(to_actix)?;
1428 let mut rsp = HttpResponse::Created().finish();
1429 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1430 rsp.headers_mut().insert(header::ETAG, etag);
1431 }
1432 set_link_headers(&mut rsp, &path);
1433 return Ok(rsp);
1434 }
1435 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
1436 }
1437
1438 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1439 let agent = agent_uri(auth_pk.as_ref());
1440 enforce_write_ctx(
1441 &state,
1442 &path,
1443 AccessMode::Write,
1444 agent.as_deref(),
1445 req_origin(&req),
1446 )
1447 .await?;
1448
1449 let ct = req
1450 .headers()
1451 .get(header::CONTENT_TYPE)
1452 .and_then(|v| v.to_str().ok())
1453 .unwrap_or("application/octet-stream");
1454
1455 if protected_resource_for_acl(&path).is_some()
1460 && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
1461 {
1462 return Ok(HttpResponse::Conflict().body(
1463 "refused: the proposed ACL would not grant Control to the caller \
1464 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1465 ));
1466 }
1467
1468 let quota = reserve_quota_for_size(&state, &path, body.len() as u64).await?;
1469 let write = state
1470 .storage
1471 .put(&path, Bytes::from(body.to_vec()), ct)
1472 .await;
1473 finish_quota_reservation(&state, quota, write.is_ok()).await;
1474 let meta = write.map_err(to_actix)?;
1475 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
1479 let mut rsp = HttpResponse::Created().finish();
1480 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1481 rsp.headers_mut().insert(header::ETAG, etag);
1482 }
1483 set_provenance_headers(&mut rsp, &provenance);
1484 set_link_headers(&mut rsp, &path);
1485 Ok(rsp)
1486}
1487
1488async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
1495 if !storage.exists(target).await.unwrap_or(false) {
1496 return target.to_string();
1497 }
1498 let seg_start = target.rfind('/').map(|s| s + 1).unwrap_or(0);
1501 let (stem, ext) = match target.rfind('.') {
1502 Some(dot) if dot > seg_start => (&target[..dot], &target[dot..]),
1503 _ => (target, ""),
1504 };
1505 for n in 1..10_000u32 {
1506 let candidate = format!("{stem}-{n}{ext}");
1507 if !storage.exists(&candidate).await.unwrap_or(false) {
1508 return candidate;
1509 }
1510 }
1511 use std::hash::{Hash, Hasher};
1512 let mut h = std::collections::hash_map::DefaultHasher::new();
1513 target.hash(&mut h);
1514 format!("{stem}-{:x}{ext}", h.finish())
1515}
1516
1517async fn handle_post(
1518 req: HttpRequest,
1519 body: web::Bytes,
1520 state: web::Data<AppState>,
1521) -> Result<HttpResponse, ActixError> {
1522 let path = req.uri().path().to_string();
1523 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1526 let agent = agent_uri(auth_pk.as_ref());
1527 enforce_write_ctx(
1528 &state,
1529 &path,
1530 AccessMode::Append,
1531 agent.as_deref(),
1532 req_origin(&req),
1533 )
1534 .await?;
1535
1536 let slug = req
1537 .headers()
1538 .get(header::HeaderName::from_static("slug"))
1539 .and_then(|v| v.to_str().ok());
1540 let mut target = match ldp::resolve_slug(&path, slug) {
1541 Ok(p) => p,
1542 Err(e) => return Err(to_actix(e)),
1543 };
1544 let ct = req
1545 .headers()
1546 .get(header::CONTENT_TYPE)
1547 .and_then(|v| v.to_str().ok())
1548 .unwrap_or("application/octet-stream");
1549
1550 if protected_resource_for_acl(&target).is_some() {
1559 enforce_write_ctx(
1560 &state,
1561 &target,
1562 AccessMode::Write,
1563 agent.as_deref(),
1564 req_origin(&req),
1565 )
1566 .await?;
1567 if !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref()) {
1568 return Ok(HttpResponse::Conflict().body(
1569 "refused: the proposed ACL would not grant Control to the caller \
1570 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1571 ));
1572 }
1573 } else {
1574 target = mint_unique_target(&*state.storage, &target).await;
1580 }
1581
1582 let quota = reserve_quota_for_size(&state, &target, body.len() as u64).await?;
1583 let write = state
1584 .storage
1585 .put(&target, Bytes::from(body.to_vec()), ct)
1586 .await;
1587 finish_quota_reservation(&state, quota, write.is_ok()).await;
1588 let meta = write.map_err(to_actix)?;
1589 let provenance = git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1592 let mut rsp = HttpResponse::Created().finish();
1593 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1594 rsp.headers_mut().insert(header::LOCATION, loc);
1595 }
1596 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1597 rsp.headers_mut().insert(header::ETAG, etag);
1598 }
1599 set_provenance_headers(&mut rsp, &provenance);
1600 set_link_headers(&mut rsp, &target);
1601 Ok(rsp)
1602}
1603
1604async fn handle_patch(
1605 req: HttpRequest,
1606 body: web::Bytes,
1607 state: web::Data<AppState>,
1608) -> Result<HttpResponse, ActixError> {
1609 let path = req.uri().path().to_string();
1610 if ldp::is_container(&path) {
1611 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1612 }
1613 let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1614 let agent = agent_uri(auth_pk.as_ref());
1615 enforce_write_ctx(
1621 &state,
1622 &path,
1623 AccessMode::Write,
1624 agent.as_deref(),
1625 req_origin(&req),
1626 )
1627 .await?;
1628
1629 let ct = req
1630 .headers()
1631 .get(header::CONTENT_TYPE)
1632 .and_then(|v| v.to_str().ok())
1633 .unwrap_or("");
1634 let dialect = match ldp::patch_dialect_from_mime(ct) {
1635 Some(d) => d,
1636 None => {
1637 return Ok(HttpResponse::UnsupportedMediaType()
1638 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1639 }
1640 };
1641 let body_str = match std::str::from_utf8(&body) {
1642 Ok(s) => s.to_string(),
1643 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1644 };
1645
1646 let existing = state.storage.get(&path).await;
1648 match existing {
1649 Ok((current_body, meta)) => {
1650 let out = match dialect {
1660 ldp::PatchDialect::N3 => {
1661 let seed = seed_graph_from_patch_target(¤t_body)?;
1662 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1663 }
1664 ldp::PatchDialect::SparqlUpdate => {
1665 let seed = seed_graph_from_patch_target(¤t_body)?;
1666 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1667 }
1668 ldp::PatchDialect::JsonPatch => {
1669 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1670 Ok(v) => v,
1671 Err(_) => serde_json::json!({}),
1672 };
1673 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1674 Ok(v) => v,
1675 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1676 };
1677 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1678 let bytes = serde_json::to_vec(&json)
1679 .map_err(PodError::from)
1680 .map_err(to_actix)?;
1681 let quota = reserve_quota_for_size(&state, &path, bytes.len() as u64).await?;
1682 let write = state
1683 .storage
1684 .put(&path, Bytes::from(bytes), &meta.content_type)
1685 .await;
1686 finish_quota_reservation(&state, quota, write.is_ok()).await;
1687 let _ = write.map_err(to_actix)?;
1688 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1689 let mut rsp = HttpResponse::NoContent().finish();
1690 set_provenance_headers(&mut rsp, &provenance);
1691 return Ok(rsp);
1692 }
1693 };
1694 let outcome = out?;
1695 let serialised = graph_to_turtle(&outcome.graph);
1698 if protected_resource_for_acl(&path).is_some()
1704 && !proposed_acl_keeps_caller_control(
1705 serialised.as_bytes(),
1706 "application/n-triples",
1707 agent.as_deref(),
1708 )
1709 {
1710 return Ok(HttpResponse::Conflict().body(
1711 "refused: the patched ACL would not grant Control to the caller \
1712 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1713 ));
1714 }
1715 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
1716 let write = state
1717 .storage
1718 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1719 .await;
1720 finish_quota_reservation(&state, quota, write.is_ok()).await;
1721 let _ = write.map_err(to_actix)?;
1722 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1723 let mut rsp = HttpResponse::NoContent().finish();
1724 set_provenance_headers(&mut rsp, &provenance);
1725 Ok(rsp)
1726 }
1727 Err(PodError::NotFound(_)) => {
1728 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
1730 let PatchCreateOutcome::Created { graph, .. } = create else {
1731 return Err(to_actix(PodError::Unsupported(
1732 "unexpected patch outcome on absent resource".into(),
1733 )));
1734 };
1735 let serialised = graph_to_turtle(&graph);
1736 if protected_resource_for_acl(&path).is_some()
1738 && !proposed_acl_keeps_caller_control(
1739 serialised.as_bytes(),
1740 "application/n-triples",
1741 agent.as_deref(),
1742 )
1743 {
1744 return Ok(HttpResponse::Conflict().body(
1745 "refused: the patched ACL would not grant Control to the caller \
1746 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1747 ));
1748 }
1749 let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
1750 let write = state
1751 .storage
1752 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1753 .await;
1754 finish_quota_reservation(&state, quota, write.is_ok()).await;
1755 let _ = write.map_err(to_actix)?;
1756 let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1757 let mut rsp = HttpResponse::Created().finish();
1758 set_provenance_headers(&mut rsp, &provenance);
1759 Ok(rsp)
1760 }
1761 Err(e) => Err(to_actix(e)),
1762 }
1763}
1764
1765fn patch_parse_err(e: PodError) -> ActixError {
1769 match e {
1770 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
1771 actix_web::error::ErrorBadRequest(msg)
1772 }
1773 other => to_actix(other),
1774 }
1775}
1776
1777fn graph_to_turtle(g: &ldp::Graph) -> String {
1781 g.to_ntriples()
1782}
1783
1784fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
1791 let mut best: Option<(f32, ldp::RdfFormat)> = None;
1792 for entry in accept.split(',') {
1793 let entry = entry.trim();
1794 if entry.is_empty() {
1795 continue;
1796 }
1797 let mut parts = entry.split(';').map(|s| s.trim());
1798 let mime = match parts.next() {
1799 Some(m) => m,
1800 None => continue,
1801 };
1802 let mut q: f32 = 1.0;
1803 for token in parts {
1804 if let Some(v) = token.strip_prefix("q=") {
1805 if let Ok(parsed) = v.parse::<f32>() {
1806 q = parsed;
1807 }
1808 }
1809 }
1810 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
1813 match best {
1814 None => best = Some((q, format)),
1815 Some((bq, _)) if q > bq => best = Some((q, format)),
1816 _ => {}
1817 }
1818 }
1819 }
1820 best.map(|(_, f)| f)
1821}
1822
1823fn rdf_content_negotiate(
1839 body: &[u8],
1840 stored_ct: &str,
1841 accept: &str,
1842) -> Option<(Vec<u8>, &'static str)> {
1843 if accept.trim().is_empty() {
1844 return None;
1845 }
1846 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
1847 let target = best_explicit_rdf_format(accept)?;
1848 if target == stored_format {
1849 return None;
1850 }
1851 let text = std::str::from_utf8(body).ok()?;
1852 let graph = ldp::Graph::parse_ntriples(text).ok()?;
1853 match target {
1854 ldp::RdfFormat::Turtle => Some((
1857 graph.to_ntriples().into_bytes(),
1858 ldp::RdfFormat::Turtle.mime(),
1859 )),
1860 ldp::RdfFormat::NTriples => Some((
1861 graph.to_ntriples().into_bytes(),
1862 ldp::RdfFormat::NTriples.mime(),
1863 )),
1864 ldp::RdfFormat::JsonLd => {
1865 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
1866 Some((json, ldp::RdfFormat::JsonLd.mime()))
1867 }
1868 ldp::RdfFormat::RdfXml => None,
1870 }
1871}
1872
1873fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
1882 let text = std::str::from_utf8(current_body).map_err(|_| {
1883 actix_web::error::ErrorConflict(
1884 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
1885 )
1886 })?;
1887 if text.trim().is_empty() {
1888 return Ok(ldp::Graph::new());
1889 }
1890 ldp::Graph::parse_ntriples(text).map_err(|_| {
1891 actix_web::error::ErrorConflict(
1892 "existing resource is not N-Triples RDF and cannot be non-destructively \
1893 patched; PUT an N-Triples representation or use a JSON Patch",
1894 )
1895 })
1896}
1897
1898pub(crate) async fn find_effective_acl_dyn(
1914 storage: &dyn Storage,
1915 resource_path: &str,
1916) -> Result<Option<wac::AclDocument>, PodError> {
1917 resolve_policy_dyn(storage, resource_path)
1918 .await
1919 .into_result()
1920}
1921
1922pub(crate) async fn resolve_policy_dyn(
1927 storage: &dyn Storage,
1928 resource_path: &str,
1929) -> wac::PolicyOutcome {
1930 wac::resolve_policy_from_storage(storage, resource_path).await
1931}
1932
1933fn policy_failure_to_actix(outcome: &wac::PolicyOutcome, resource: &str) -> ActixError {
1943 match outcome {
1944 wac::PolicyOutcome::Invalid {
1945 policy_path,
1946 reason,
1947 } => {
1948 tracing::warn!(
1949 target: "solid_pod_rs_server::wac",
1950 resource = %resource,
1951 policy = %policy_path,
1952 "denying: effective ACL is invalid and MUST NOT inherit: {reason}"
1953 );
1954 actix_web::error::ErrorForbidden("access forbidden: governing ACL is invalid")
1955 }
1956 wac::PolicyOutcome::Unavailable {
1957 policy_path,
1958 detail,
1959 } => {
1960 tracing::error!(
1961 target: "solid_pod_rs_server::wac",
1962 resource = %resource,
1963 policy = %policy_path,
1964 "denying: effective ACL could not be read: {detail}"
1965 );
1966 actix_web::error::ErrorServiceUnavailable(
1967 "access control unavailable: governing ACL could not be read",
1968 )
1969 }
1970 _ => actix_web::error::ErrorInternalServerError("policy resolution error"),
1972 }
1973}
1974
1975async fn handle_delete(
1976 req: HttpRequest,
1977 state: web::Data<AppState>,
1978) -> Result<HttpResponse, ActixError> {
1979 let path = req.uri().path().to_string();
1980 let auth_pk = extract_pubkey(&req).await;
1981 let agent = agent_uri(auth_pk.as_ref());
1982 enforce_write_ctx(
1983 &state,
1984 &path,
1985 AccessMode::Write,
1986 agent.as_deref(),
1987 req_origin(&req),
1988 )
1989 .await?;
1990
1991 let quota = reserve_quota_for_size(&state, &path, 0).await?;
1992 match state.storage.delete(&path).await {
1993 Ok(()) => {
1994 finish_quota_reservation(&state, quota, true).await;
1995 Ok(HttpResponse::NoContent().finish())
1996 }
1997 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1998 Err(e) => Err(to_actix(e)),
1999 }
2000}
2001
2002async fn handle_options(
2003 req: HttpRequest,
2004 state: web::Data<AppState>,
2005) -> Result<HttpResponse, ActixError> {
2006 let path = req.uri().path().to_string();
2007 let o = ldp::options_for(&path);
2008 let mut rsp = HttpResponse::NoContent().finish();
2009 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
2010 rsp.headers_mut()
2011 .insert(header::HeaderName::from_static("allow"), v);
2012 }
2013 if let Some(ap) = o.accept_post {
2014 if let Ok(v) = header::HeaderValue::from_str(ap) {
2015 rsp.headers_mut()
2016 .insert(header::HeaderName::from_static("accept-post"), v);
2017 }
2018 }
2019 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
2020 rsp.headers_mut()
2021 .insert(header::HeaderName::from_static("accept-patch"), v);
2022 }
2023 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
2024 rsp.headers_mut()
2025 .insert(header::HeaderName::from_static("accept-ranges"), v);
2026 }
2027 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
2028 Ok(rsp)
2029}
2030
2031async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
2036 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
2037 HttpResponse::Ok()
2038 .content_type("application/ld+json")
2039 .json(doc)
2040}
2041
2042#[derive(Debug, Deserialize)]
2043struct WebFingerQuery {
2044 resource: Option<String>,
2045}
2046
2047async fn handle_well_known_webfinger(
2048 state: web::Data<AppState>,
2049 q: web::Query<WebFingerQuery>,
2050) -> HttpResponse {
2051 let resource = q.resource.clone().unwrap_or_else(|| {
2052 format!(
2053 "acct:anonymous@{}",
2054 state
2055 .nodeinfo
2056 .base_url
2057 .trim_start_matches("http://")
2058 .trim_start_matches("https://")
2059 )
2060 });
2061 let webid = format!(
2062 "{}/profile/card#me",
2063 state.nodeinfo.base_url.trim_end_matches('/')
2064 );
2065 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
2066 Some(jrd) => HttpResponse::Ok()
2067 .content_type("application/jrd+json")
2068 .json(jrd),
2069 None => HttpResponse::NotFound().finish(),
2070 }
2071}
2072
2073async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
2074 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
2075 HttpResponse::Ok()
2076 .content_type("application/json")
2077 .json(doc)
2078}
2079
2080async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
2081 let doc = interop::nodeinfo_2_1(
2082 &state.nodeinfo.software_name,
2083 &state.nodeinfo.software_version,
2084 state.nodeinfo.open_registrations,
2085 state.nodeinfo.total_users,
2086 );
2087 HttpResponse::Ok()
2088 .content_type("application/json")
2089 .json(doc)
2090}
2091
2092#[cfg(feature = "did-nostr")]
2093async fn handle_well_known_did_nostr(
2094 state: web::Data<AppState>,
2095 path: web::Path<String>,
2096) -> HttpResponse {
2097 let pubkey = path.into_inner();
2098 let pubkey_is_valid = pubkey.len() == 64
2103 && pubkey
2104 .bytes()
2105 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
2106 if !pubkey_is_valid {
2107 return HttpResponse::BadRequest()
2108 .insert_header(("Cache-Control", "no-store"))
2109 .json(serde_json::json!({
2110 "error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
2111 }));
2112 }
2113 let owner_pubkey = match state.storage.get("/profile/card").await {
2121 Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
2122 .ok()
2123 .flatten(),
2124 Err(_) => None,
2125 };
2126 let owner_claims_key = owner_pubkey
2127 .as_deref()
2128 .is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
2129 if !owner_claims_key {
2130 return HttpResponse::NotFound()
2131 .insert_header(("Cache-Control", "no-store"))
2132 .json(serde_json::json!({
2133 "error": "no account on this pod claims this did:nostr pubkey"
2134 }));
2135 }
2136 let also = vec![format!(
2137 "{}/profile/card#me",
2138 state.nodeinfo.base_url.trim_end_matches('/')
2139 )];
2140 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
2141 let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
2142 use std::hash::{Hash, Hasher};
2148 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2149 body.hash(&mut hasher);
2150 let etag = format!("\"{:016x}\"", hasher.finish());
2151 HttpResponse::Ok()
2152 .content_type("application/did+json")
2153 .insert_header(("Cache-Control", "max-age=3600"))
2154 .insert_header(("ETag", etag))
2155 .body(body)
2156}
2157
2158#[cfg(feature = "nip05-endpoint")]
2166#[derive(Debug, Deserialize)]
2167struct Nip05Query {
2168 name: Option<String>,
2171}
2172
2173#[cfg(feature = "nip05-endpoint")]
2174fn nip05_name_is_valid(name: &str) -> bool {
2175 if name.is_empty() {
2178 return false;
2179 }
2180 name.bytes()
2181 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
2182}
2183
2184#[cfg(feature = "nip05-endpoint")]
2185async fn handle_well_known_nip05(
2186 state: web::Data<AppState>,
2187 query: web::Query<Nip05Query>,
2188) -> HttpResponse {
2189 use solid_pod_rs::webid::extract_nostr_pubkey;
2190
2191 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
2193 if !nip05_name_is_valid(&name) {
2194 return HttpResponse::BadRequest().json(serde_json::json!({
2195 "error": "invalid NIP-05 local part",
2196 }));
2197 }
2198
2199 let profile_path = if name == "_" {
2205 "/profile/card".to_string()
2206 } else {
2207 format!("/{name}/profile/card")
2208 };
2209
2210 let (body, _meta) = match state.storage.get(&profile_path).await {
2211 Ok(v) => v,
2212 Err(_) => {
2213 return nip05_empty_response();
2217 }
2218 };
2219
2220 let pubkey_hex = match extract_nostr_pubkey(&body) {
2221 Ok(Some(p)) => p,
2222 _ => return nip05_empty_response(),
2223 };
2224
2225 let doc = interop::nip05_document([(name, pubkey_hex)]);
2226 HttpResponse::Ok()
2227 .insert_header(("Access-Control-Allow-Origin", "*"))
2228 .content_type("application/json")
2229 .json(doc)
2230}
2231
2232#[cfg(feature = "nip05-endpoint")]
2233fn nip05_empty_response() -> HttpResponse {
2234 HttpResponse::Ok()
2235 .insert_header(("Access-Control-Allow-Origin", "*"))
2236 .content_type("application/json")
2237 .json(serde_json::json!({ "names": {} }))
2238}
2239
2240#[cfg(feature = "export-jsonld")]
2254async fn handle_export_all(
2255 req: HttpRequest,
2256 state: web::Data<AppState>,
2257) -> Result<HttpResponse, ActixError> {
2258 let auth_pk = extract_pubkey(&req).await;
2259 let agent = agent_uri(auth_pk.as_ref());
2260
2261 enforce_write_ctx(
2266 &state,
2267 "/",
2268 AccessMode::Control,
2269 agent.as_deref(),
2270 req_origin(&req),
2271 )
2272 .await?;
2273
2274 let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
2278 .ok()
2279 .and_then(|q| q.get("include_private").map(|v| v == "true"))
2280 .unwrap_or(false);
2281
2282 let pod_base = {
2286 let conn = req.connection_info();
2287 format!("{}://{}/", conn.scheme(), conn.host())
2288 };
2289
2290 let options = solid_pod_rs::ExportOptions { include_private };
2291 let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
2292 .await
2293 .map_err(to_actix)?;
2294
2295 let body = serde_json::to_vec(&bundle).map_err(|e| {
2296 actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
2297 })?;
2298 Ok(HttpResponse::Ok()
2299 .content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
2300 .body(body))
2301}
2302
2303#[derive(Debug, Deserialize)]
2308struct CreateAccountRequest {
2309 username: String,
2310 #[serde(default)]
2311 name: Option<String>,
2312}
2313
2314#[derive(Debug, Deserialize)]
2315struct CreatePodRequest {
2316 name: String,
2317}
2318
2319async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
2320 let pod_name = path.into_inner();
2321 let pod_root = format!("/{pod_name}/");
2322 match state.storage.exists(&pod_root).await {
2323 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
2324 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
2325 }
2326}
2327
2328fn valid_pod_name(name: &str) -> bool {
2329 !name.is_empty()
2330 && name
2331 .chars()
2332 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
2333}
2334
2335fn request_ip(req: &HttpRequest) -> IpAddr {
2336 req.peer_addr()
2337 .map(|addr| addr.ip())
2338 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
2339}
2340
2341#[derive(Debug)]
2342struct QuotaReservation {
2343 pod: String,
2344 delta: i64,
2345}
2346
2347fn pod_name_from_path(path: &str) -> Option<&str> {
2348 path.trim_start_matches('/')
2349 .split('/')
2350 .next()
2351 .filter(|p| !p.is_empty())
2352}
2353
2354async fn reserve_quota_for_size(
2355 state: &AppState,
2356 path: &str,
2357 new_size: u64,
2358) -> Result<Option<QuotaReservation>, ActixError> {
2359 let (Some(policy), Some(pod)) = (&state.quota, pod_name_from_path(path)) else {
2360 return Ok(None);
2361 };
2362 let old_size = state
2363 .storage
2364 .head(path)
2365 .await
2366 .map(|meta| meta.size)
2367 .unwrap_or(0);
2368 let delta = i128::from(new_size) - i128::from(old_size);
2369 let delta = delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64;
2370 if delta > 0 {
2371 policy
2372 .reserve(pod, delta as u64)
2373 .await
2374 .map_err(|error| actix_web::error::ErrorInsufficientStorage(error.to_string()))?;
2375 }
2376 Ok(Some(QuotaReservation {
2377 pod: pod.to_string(),
2378 delta,
2379 }))
2380}
2381
2382async fn finish_quota_reservation(
2383 state: &AppState,
2384 reservation: Option<QuotaReservation>,
2385 write_succeeded: bool,
2386) {
2387 let (Some(policy), Some(reservation)) = (&state.quota, reservation) else {
2388 return;
2389 };
2390 if write_succeeded {
2391 if reservation.delta < 0 {
2392 policy.record(&reservation.pod, reservation.delta).await;
2393 }
2394 } else if reservation.delta > 0 {
2395 policy.record(&reservation.pod, -reservation.delta).await;
2396 }
2397}
2398
2399async fn handle_create_account(
2400 req: HttpRequest,
2401 state: web::Data<AppState>,
2402 body: web::Json<CreateAccountRequest>,
2403) -> Result<HttpResponse, ActixError> {
2404 if let Some(response) = provisioning_gate(&req, &state, &body.username) {
2405 return Ok(response);
2406 }
2407 let pod_root = format!("/{}/", body.username);
2408 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2409 return Ok(
2410 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
2411 );
2412 }
2413
2414 let base_uri = state.nodeinfo.base_url.trim_end_matches('/');
2415 let outcome =
2416 provision_named_pod(&state, &body.username, body.name.as_deref(), base_uri).await?;
2417 Ok(HttpResponse::Created().json(serde_json::json!({
2418 "webid": outcome.webid,
2419 "pod_root": outcome.pod_uri,
2420 "username": body.username,
2421 })))
2422}
2423
2424struct NamedPodOutcome {
2425 webid: String,
2426 pod_uri: String,
2427}
2428
2429fn provisioning_gate(req: &HttpRequest, state: &AppState, name: &str) -> Option<HttpResponse> {
2432 if !valid_pod_name(name) {
2433 return Some(HttpResponse::BadRequest().json(serde_json::json!({
2434 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
2435 })));
2436 }
2437
2438 let supplied_key = req
2439 .headers()
2440 .get("x-pod-admin-key")
2441 .and_then(|value| value.to_str().ok())
2442 .unwrap_or("");
2443 use subtle::ConstantTimeEq;
2444 let admin_override = state
2445 .admin_key
2446 .as_deref()
2447 .is_some_and(|expected| bool::from(supplied_key.as_bytes().ct_eq(expected.as_bytes())));
2448 if !state.nodeinfo.open_registrations && !admin_override {
2449 return Some(HttpResponse::Forbidden().json(serde_json::json!({
2450 "error": "pod registration is closed"
2451 })));
2452 }
2453
2454 if let Err(retry_after) = state.pod_create_limiter.check(request_ip(req)) {
2455 return Some(
2456 HttpResponse::TooManyRequests()
2457 .insert_header(("Retry-After", retry_after.to_string()))
2458 .json(serde_json::json!({
2459 "error": "Too Many Requests",
2460 "message": "Pod creation rate limit exceeded",
2461 "retryAfter": retry_after
2462 })),
2463 );
2464 }
2465 None
2466}
2467
2468async fn provision_named_pod(
2472 state: &AppState,
2473 name: &str,
2474 display_name: Option<&str>,
2475 base_uri: &str,
2476) -> Result<NamedPodOutcome, ActixError> {
2477 for container in ["", "profile", "inbox", "public", "private", "settings"] {
2478 let path = if container.is_empty() {
2479 format!("/{name}")
2480 } else {
2481 format!("/{name}/{container}")
2482 };
2483 state
2484 .storage
2485 .put(
2486 &format!("{path}.meta"),
2487 Bytes::from_static(b"{}"),
2488 "application/ld+json",
2489 )
2490 .await
2491 .map_err(to_actix)?;
2492 }
2493
2494 let base_uri = base_uri.trim_end_matches('/');
2495 let pod_uri = format!("{base_uri}/{name}/");
2496 let canonical_prefix = format!("{base_uri}/pods/{name}/");
2497 let webid = format!("{pod_uri}profile/card#me");
2498 let profile = solid_pod_rs::webid::generate_webid_html(name, display_name, base_uri)
2499 .replace(&canonical_prefix, &pod_uri);
2500 state
2501 .storage
2502 .put(
2503 &format!("/{name}/profile/card"),
2504 Bytes::from(profile.into_bytes()),
2505 "text/html",
2506 )
2507 .await
2508 .map_err(to_actix)?;
2509
2510 #[cfg(feature = "git")]
2511 if let Some(root) = &state.data_root {
2512 let hook = solid_pod_rs_git::init::GitAutoInit::new();
2513 if let Err(error) = hook.init_repo_at(&root.join(name)).await {
2514 tracing::warn!(pod = name, %error, "git auto-init failed after pod creation");
2515 }
2516 }
2517
2518 Ok(NamedPodOutcome { webid, pod_uri })
2519}
2520
2521async fn handle_create_pod(
2522 req: HttpRequest,
2523 state: web::Data<AppState>,
2524 body: web::Json<CreatePodRequest>,
2525) -> Result<HttpResponse, ActixError> {
2526 if let Some(response) = provisioning_gate(&req, &state, &body.name) {
2527 return Ok(response);
2528 }
2529
2530 let pod_root = format!("/{}/", body.name);
2531 if state.storage.exists(&pod_root).await.unwrap_or(false) {
2532 return Ok(
2533 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
2534 );
2535 }
2536
2537 let base_uri = {
2538 let conn = req.connection_info();
2539 format!("{}://{}", conn.scheme(), conn.host())
2540 };
2541 let outcome = provision_named_pod(&state, &body.name, None, &base_uri).await?;
2542
2543 Ok(HttpResponse::Created()
2544 .insert_header(("Location", outcome.pod_uri.clone()))
2545 .json(serde_json::json!({
2546 "name": body.name,
2547 "webId": outcome.webid,
2548 "podUri": outcome.pod_uri,
2549 })))
2550}
2551
2552async fn handle_copy(
2557 req: HttpRequest,
2558 state: web::Data<AppState>,
2559) -> Result<HttpResponse, ActixError> {
2560 let dest = req.uri().path().to_string();
2561 let auth_pk = extract_pubkey(&req).await;
2562 let agent = agent_uri(auth_pk.as_ref());
2563 enforce_write_ctx(
2564 &state,
2565 &dest,
2566 AccessMode::Write,
2567 agent.as_deref(),
2568 req_origin(&req),
2569 )
2570 .await?;
2571
2572 let source = req
2573 .headers()
2574 .get("source")
2575 .and_then(|v| v.to_str().ok())
2576 .map(|s| s.to_string());
2577 let source = match source {
2578 Some(s) => s,
2579 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
2580 };
2581
2582 let (body, meta) = match state.storage.get(&source).await {
2583 Ok(v) => v,
2584 Err(PodError::NotFound(_)) => {
2585 return Ok(HttpResponse::NotFound().body("source resource not found"))
2586 }
2587 Err(e) => return Err(to_actix(e)),
2588 };
2589
2590 let quota = reserve_quota_for_size(&state, &dest, body.len() as u64).await?;
2591 let write = state.storage.put(&dest, body, &meta.content_type).await;
2592 finish_quota_reservation(&state, quota, write.is_ok()).await;
2593 write.map_err(to_actix)?;
2594
2595 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
2597 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
2598 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
2599 let quota = reserve_quota_for_size(&state, &dst_acl, acl_body.len() as u64).await?;
2600 let write = state
2601 .storage
2602 .put(&dst_acl, acl_body, &acl_meta.content_type)
2603 .await;
2604 finish_quota_reservation(&state, quota, write.is_ok()).await;
2605 write.map_err(to_actix)?;
2606 }
2607
2608 let mut rsp = HttpResponse::Created().finish();
2609 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
2610 rsp.headers_mut().insert(header::LOCATION, loc);
2611 }
2612 Ok(rsp)
2613}
2614
2615async fn handle_glob_get(
2620 req: HttpRequest,
2621 state: web::Data<AppState>,
2622) -> Result<HttpResponse, ActixError> {
2623 let raw_path = req.uri().path().to_string();
2624 if !raw_path.ends_with("/*") {
2626 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
2627 }
2628 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
2630 folder.to_string()
2631 } else {
2632 format!("{folder}/")
2633 };
2634
2635 let auth_pk = extract_pubkey(&req).await;
2639 let agent = agent_uri(auth_pk.as_ref());
2640 enforce_read_ctx(&state, &folder, agent.as_deref(), req_origin(&req)).await?;
2641
2642 let children = state.storage.list(&folder).await.map_err(to_actix)?;
2643 let mut merged = String::new();
2644
2645 for child in &children {
2646 if child.ends_with('/') {
2647 continue;
2648 }
2649 let child_path = format!("{folder}{child}");
2650 if let Ok((body, meta)) = state.storage.get(&child_path).await {
2651 if meta.content_type.contains("turtle")
2652 || meta.content_type.contains("n-triples")
2653 || meta.content_type.contains("n3")
2654 {
2655 if let Ok(text) = std::str::from_utf8(&body) {
2656 merged.push_str(text);
2657 merged.push('\n');
2658 }
2659 }
2660 }
2661 }
2662
2663 if merged.is_empty() {
2664 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
2665 }
2666
2667 Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
2668}
2669
2670#[derive(Debug, Deserialize)]
2675struct LoginPasswordRequest {
2676 username: String,
2677 password: String,
2678}
2679
2680async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
2687 let _ = (&body.username, &body.password);
2688 HttpResponse::NotImplemented().json(serde_json::json!({
2689 "error": "password login is not implemented on this pod"
2690 }))
2691}
2692
2693#[derive(Debug, Deserialize)]
2694struct PasswordResetRequest {
2695 username: String,
2696}
2697
2698async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
2702 let _ = &body.username;
2703 HttpResponse::NotImplemented().json(serde_json::json!({
2704 "error": "password reset is not implemented on this pod"
2705 }))
2706}
2707
2708#[derive(Debug, Deserialize)]
2709struct PasswordChangeRequest {
2710 token: String,
2711 new_password: String,
2712}
2713
2714async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
2719 let _ = (&body.token, &body.new_password);
2720 HttpResponse::NotImplemented().json(serde_json::json!({
2721 "error": "password change is not implemented on this pod"
2722 }))
2723}
2724
2725async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
2730 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
2731 HttpResponse::Ok()
2732 .content_type("application/json")
2733 .json(body)
2734}
2735
2736pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
2751
2752#[derive(Debug, Deserialize)]
2754struct ProxyQuery {
2755 url: String,
2756}
2757
2758const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
2760 "set-cookie",
2761 "set-cookie2",
2762 "authorization",
2763 "www-authenticate",
2764 "proxy-authenticate",
2765 "proxy-authorization",
2766];
2767
2768async fn validate_proxy_target(target: &str) -> Result<(url::Url, IpAddr), HttpResponse> {
2784 let parsed = match url::Url::parse(target) {
2785 Ok(u) => u,
2786 Err(_) => {
2787 return Err(
2788 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
2789 );
2790 }
2791 };
2792
2793 match parsed.scheme() {
2795 "http" | "https" => {}
2796 scheme => {
2797 return Err(HttpResponse::BadRequest()
2798 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
2799 }
2800 }
2801
2802 if solid_pod_rs::security::is_safe_url(target).is_err() {
2805 return Err(HttpResponse::Forbidden()
2806 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
2807 }
2808
2809 let host = match parsed.host_str() {
2811 Some(h) => h.to_string(),
2812 None => {
2813 return Err(HttpResponse::BadRequest()
2814 .json(serde_json::json!({"error": "target URL has no host"})))
2815 }
2816 };
2817 let host_lower = host.to_ascii_lowercase();
2818 if host_lower == "localhost"
2819 || host_lower.ends_with(".localhost")
2820 || host_lower == "0.0.0.0"
2821 || host_lower == "[::1]"
2822 || host_lower == "[::0]"
2823 {
2824 return Err(HttpResponse::Forbidden()
2825 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
2826 }
2827
2828 match solid_pod_rs::security::resolve_and_check(&host).await {
2831 Ok(ip) => Ok((parsed, ip)),
2832 Err(_) => Err(HttpResponse::Forbidden()
2833 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"}))),
2834 }
2835}
2836
2837fn build_pinned_proxy_client(url: &url::Url, ip: IpAddr) -> Result<reqwest::Client, ActixError> {
2840 let mut builder = reqwest::Client::builder()
2841 .redirect(reqwest::redirect::Policy::none());
2844 if let Some(host) = url.host_str() {
2845 let port = url.port_or_known_default().unwrap_or(0);
2847 builder = builder.resolve(host, std::net::SocketAddr::new(ip, port));
2848 }
2849 builder
2850 .build()
2851 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))
2852}
2853
2854async fn handle_proxy(
2855 req: HttpRequest,
2856 _state: web::Data<AppState>,
2857 query: web::Query<ProxyQuery>,
2858) -> Result<HttpResponse, ActixError> {
2859 let auth_pk = extract_pubkey(&req).await;
2861 let agent = agent_uri(auth_pk.as_ref());
2862 if agent.is_none() {
2863 return Ok(HttpResponse::Unauthorized()
2864 .json(serde_json::json!({"error": "authentication required"})));
2865 }
2866
2867 let mut current_url = query.url.clone();
2868 let mut redirect_count = 0u8;
2869 const MAX_REDIRECTS: u8 = 5;
2870 const TOTAL_TIMEOUT: Duration = Duration::from_secs(30);
2871 let started = Instant::now();
2872
2873 let byte_cap = std::env::var("PROXY_BYTE_CAP")
2874 .ok()
2875 .and_then(|v| {
2876 solid_pod_rs::config::sources::parse_size(&v)
2877 .map(|u| u as usize)
2878 .ok()
2879 })
2880 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
2881
2882 loop {
2883 let Some(remaining) = TOTAL_TIMEOUT.checked_sub(started.elapsed()) else {
2884 return Ok(HttpResponse::GatewayTimeout()
2885 .json(serde_json::json!({"error": "proxy operation timed out"})));
2886 };
2887 let (target_url, pinned_ip) = match validate_proxy_target(¤t_url).await {
2891 Ok(pair) => pair,
2892 Err(rsp) => return Ok(rsp),
2893 };
2894 let client = build_pinned_proxy_client(&target_url, pinned_ip)?;
2895
2896 let mut upstream_req = client.get(¤t_url).timeout(remaining);
2897
2898 if let Some(auth_val) = req
2900 .headers()
2901 .get("x-upstream-authorization")
2902 .and_then(|v| v.to_str().ok())
2903 {
2904 upstream_req = upstream_req.header("Authorization", auth_val);
2905 }
2906
2907 let response = upstream_req
2908 .send()
2909 .await
2910 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
2911
2912 if response.status().is_redirection() {
2914 if redirect_count >= MAX_REDIRECTS {
2915 return Ok(HttpResponse::BadGateway()
2916 .json(serde_json::json!({"error": "too many redirects"})));
2917 }
2918 if let Some(location) = response.headers().get("location") {
2919 let loc_str = location
2920 .to_str()
2921 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
2922 let base = url::Url::parse(¤t_url)
2924 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
2925 let resolved = base
2926 .join(loc_str)
2927 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
2928 current_url = resolved.to_string();
2929 redirect_count += 1;
2930 continue;
2931 }
2932 return Ok(HttpResponse::BadGateway()
2933 .json(serde_json::json!({"error": "redirect without location"})));
2934 }
2935
2936 let upstream_status = response.status().as_u16();
2938 let upstream_content_type = response
2939 .headers()
2940 .get("content-type")
2941 .and_then(|v| v.to_str().ok())
2942 .unwrap_or("application/octet-stream")
2943 .to_string();
2944 if response
2945 .content_length()
2946 .is_some_and(|length| length > byte_cap as u64)
2947 {
2948 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
2949 "error": "proxied response exceeds byte cap",
2950 "limit": byte_cap
2951 })));
2952 }
2953
2954 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
2956 for (name, value) in response.headers() {
2957 let name_lower = name.as_str().to_ascii_lowercase();
2958 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
2959 continue;
2960 }
2961 if matches!(
2963 name_lower.as_str(),
2964 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
2965 ) {
2966 continue;
2967 }
2968 if let Ok(val_str) = value.to_str() {
2969 forwarded_headers.push((name_lower, val_str.to_string()));
2970 }
2971 }
2972
2973 let mut stream = response.bytes_stream();
2974 let mut body_bytes = Vec::with_capacity(byte_cap.min(64 * 1024));
2975 while let Some(chunk) = stream.next().await {
2976 let chunk =
2977 chunk.map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
2978 if body_bytes.len().saturating_add(chunk.len()) > byte_cap {
2979 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
2980 "error": "proxied response exceeds byte cap",
2981 "limit": byte_cap
2982 })));
2983 }
2984 body_bytes.extend_from_slice(&chunk);
2985 }
2986
2987 let mut rsp = HttpResponse::build(
2989 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
2990 );
2991 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
2992 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
2993
2994 for (name, value) in &forwarded_headers {
2996 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
2997 if let Ok(hval) = header::HeaderValue::from_str(value) {
2998 rsp.insert_header((hname, hval));
2999 }
3000 }
3001 }
3002
3003 return Ok(rsp.body(body_bytes));
3004 }
3005}
3006
3007pub struct PathTraversalGuard;
3013
3014impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
3015where
3016 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3017 B: 'static,
3018{
3019 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3020 type Error = ActixError;
3021 type InitError = ();
3022 type Transform = PathTraversalGuardMiddleware<S>;
3023 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3024
3025 fn new_transform(&self, service: S) -> Self::Future {
3026 ready(Ok(PathTraversalGuardMiddleware { service }))
3027 }
3028}
3029
3030pub struct PathTraversalGuardMiddleware<S> {
3032 service: S,
3033}
3034
3035impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
3036where
3037 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3038 B: 'static,
3039{
3040 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3041 type Error = ActixError;
3042 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3043
3044 actix_web::dev::forward_ready!(service);
3045
3046 fn call(&self, req: ServiceRequest) -> Self::Future {
3047 let raw = req.path().to_string();
3050 if path_is_traversal(&raw) {
3051 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
3052 let sr = req.into_response(rsp.map_into_boxed_body());
3053 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3054 }
3055 let fut = self.service.call(req);
3056 Box::pin(async move {
3057 let resp = fut.await?;
3058 Ok(resp.map_into_left_body())
3059 })
3060 }
3061}
3062
3063fn path_is_traversal(path: &str) -> bool {
3064 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
3066 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
3067 for seg in once.split('/').chain(twice.split('/')) {
3068 if seg == ".." || seg == "." {
3069 return true;
3070 }
3071 }
3072 twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..")
3075}
3076
3077pub struct CorsHeaders {
3088 pub allowed_origins: Arc<Vec<String>>,
3089}
3090
3091impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
3092where
3093 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3094 B: 'static,
3095{
3096 type Response = ServiceResponse<B>;
3097 type Error = ActixError;
3098 type InitError = ();
3099 type Transform = CorsHeadersMiddleware<S>;
3100 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3101
3102 fn new_transform(&self, service: S) -> Self::Future {
3103 ready(Ok(CorsHeadersMiddleware {
3104 service,
3105 allowed_origins: self.allowed_origins.clone(),
3106 }))
3107 }
3108}
3109
3110pub struct CorsHeadersMiddleware<S> {
3112 service: S,
3113 allowed_origins: Arc<Vec<String>>,
3114}
3115
3116impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
3117where
3118 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3119 B: 'static,
3120{
3121 type Response = ServiceResponse<B>;
3122 type Error = ActixError;
3123 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3124
3125 actix_web::dev::forward_ready!(service);
3126
3127 fn call(&self, req: ServiceRequest) -> Self::Future {
3128 let origin = req
3129 .headers()
3130 .get(header::ORIGIN)
3131 .and_then(|v| v.to_str().ok())
3132 .map(str::to_string);
3133 let allowed = self.allowed_origins.clone();
3134 let fut = self.service.call(req);
3135 Box::pin(async move {
3136 let mut resp = fut.await?;
3137 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
3138 Ok(resp)
3139 })
3140 }
3141}
3142
3143fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
3144 if headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
3154 return;
3155 }
3156 let (origin_value, allow_credentials): (String, bool) = if allowed.is_empty() {
3167 ("*".to_string(), false)
3168 } else {
3169 match origin.filter(|o| allowed.iter().any(|a| a == *o)) {
3170 Some(o) => (o.to_string(), true),
3171 None => return,
3174 }
3175 };
3176
3177 let mut pairs = vec![
3178 ("access-control-allow-origin", origin_value.as_str()),
3179 (
3180 "access-control-allow-methods",
3181 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
3182 ),
3183 (
3184 "access-control-allow-headers",
3185 "Accept, Authorization, Content-Type, DPoP, Git-Protocol, If-Match, If-None-Match, Link, Range, Slug, Origin",
3186 ),
3187 (
3188 "access-control-expose-headers",
3189 "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",
3190 ),
3191 ("access-control-max-age", "86400"),
3192 ];
3193 if allow_credentials {
3196 pairs.push(("access-control-allow-credentials", "true"));
3197 }
3198
3199 for (name, value) in pairs {
3200 if let (Ok(name), Ok(value)) = (
3201 header::HeaderName::from_lowercase(name.as_bytes()),
3202 header::HeaderValue::from_str(value),
3203 ) {
3204 headers.insert(name, value);
3205 }
3206 }
3207}
3208
3209pub struct ErrorLoggingMiddleware;
3225
3226impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
3227where
3228 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3229 B: 'static,
3230{
3231 type Response = ServiceResponse<B>;
3232 type Error = ActixError;
3233 type InitError = ();
3234 type Transform = ErrorLoggingMiddlewareService<S>;
3235 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3236
3237 fn new_transform(&self, service: S) -> Self::Future {
3238 ready(Ok(ErrorLoggingMiddlewareService { service }))
3239 }
3240}
3241
3242pub struct ErrorLoggingMiddlewareService<S> {
3244 service: S,
3245}
3246
3247impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
3248where
3249 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3250 B: 'static,
3251{
3252 type Response = ServiceResponse<B>;
3253 type Error = ActixError;
3254 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3255
3256 actix_web::dev::forward_ready!(service);
3257
3258 fn call(&self, req: ServiceRequest) -> Self::Future {
3259 let method = req.method().as_str().to_string();
3262 let path = req.path().to_string();
3263
3264 let fut = self.service.call(req);
3265 Box::pin(async move {
3266 let response = fut.await?;
3267 let status = response.status();
3268 if status.is_server_error() {
3269 log_5xx(&method, &path, status, response.response().error());
3270 }
3271 Ok(response)
3272 })
3273 }
3274}
3275
3276fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
3280 let chain = match error {
3284 Some(e) => format_error_chain(e),
3285 None => "<no error attached to response>".to_string(),
3286 };
3287
3288 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
3289 Some(std::backtrace::Backtrace::force_capture().to_string())
3290 } else {
3291 None
3292 };
3293
3294 tracing::error!(
3295 target: "solid_pod_rs_server::http",
3296 method = %method,
3297 path = %path,
3298 status = %status.as_u16(),
3299 error.chain = %chain,
3300 backtrace = backtrace.as_deref().unwrap_or(""),
3301 "5xx response"
3302 );
3303}
3304
3305fn format_error_chain(e: &actix_web::Error) -> String {
3316 let summary = format!("{}", e.as_response_error());
3317 let debug = format!("{e:?}");
3318 if debug == summary || debug.is_empty() {
3319 summary
3320 } else {
3321 format!("{summary} -> {debug}")
3322 }
3323}
3324
3325pub struct DotfileGuard {
3331 allow: Arc<DotfileAllowlist>,
3332}
3333
3334impl DotfileGuard {
3335 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
3336 Self { allow }
3337 }
3338}
3339
3340impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
3341where
3342 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3343 B: 'static,
3344{
3345 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3346 type Error = ActixError;
3347 type InitError = ();
3348 type Transform = DotfileGuardMiddleware<S>;
3349 type Future = Ready<Result<Self::Transform, Self::InitError>>;
3350
3351 fn new_transform(&self, service: S) -> Self::Future {
3352 ready(Ok(DotfileGuardMiddleware {
3353 service,
3354 allow: self.allow.clone(),
3355 }))
3356 }
3357}
3358
3359pub struct DotfileGuardMiddleware<S> {
3361 service: S,
3362 allow: Arc<DotfileAllowlist>,
3363}
3364
3365impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
3366where
3367 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3368 B: 'static,
3369{
3370 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3371 type Error = ActixError;
3372 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3373
3374 actix_web::dev::forward_ready!(service);
3375
3376 fn call(&self, req: ServiceRequest) -> Self::Future {
3377 let path = req.path().to_string();
3378 let allow_system_route =
3385 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
3386 if !allow_system_route {
3387 let pb = PathBuf::from(&path);
3388 if !self.allow.is_allowed(Path::new(&pb)) {
3389 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
3390 let sr = req.into_response(rsp.map_into_boxed_body());
3391 return Box::pin(async move { Ok(sr.map_into_right_body()) });
3392 }
3393 }
3394 let fut = self.service.call(req);
3395 Box::pin(async move {
3396 let resp = fut.await?;
3397 Ok(resp.map_into_left_body())
3398 })
3399 }
3400}
3401
3402#[cfg(feature = "git")]
3407pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
3408 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
3409 return None;
3410 }
3411 state.data_root.as_ref().map(|root| root.join(pubkey))
3412}
3413
3414#[cfg(feature = "git")]
3444async fn git_mark_write(
3445 state: &AppState,
3446 resource_path: &str,
3447 agent: Option<&str>,
3448 message: &str,
3449) -> ProvenanceReceipt {
3450 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
3451 use solid_pod_rs_git::mark::ShellGitMarker;
3452
3453 if resource_path.ends_with(".acl")
3456 || resource_path.ends_with(".meta")
3457 || resource_path.ends_with(".prov.ttl")
3458 {
3459 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::ExcludedPath);
3460 }
3461 if resource_path.ends_with('/') {
3463 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::Container);
3464 }
3465
3466 let Some(data_root) = state.data_root.as_ref() else {
3468 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured);
3469 };
3470
3471 let trimmed = resource_path.trim_start_matches('/');
3473 let mut segments = trimmed.splitn(2, '/');
3474 let pod = segments.next().unwrap_or("");
3475 let rel = segments.next().unwrap_or("");
3476 if pod.is_empty() || rel.is_empty() {
3477 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::UnresolvablePath);
3478 }
3479 let repo = data_root.join(pod);
3480
3481 if !repo.join(".git").is_dir() {
3485 return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotGitBacked);
3486 }
3487
3488 let agent_did = agent.unwrap_or("urn:solid:anonymous");
3489 let created = std::time::SystemTime::now()
3490 .duration_since(std::time::UNIX_EPOCH)
3491 .map(|d| d.as_secs())
3492 .unwrap_or(0);
3493
3494 let (policy, ticker_override) =
3497 handlers::prov::resolve_anchor_policy(state, resource_path).await;
3498
3499 let marker = std::sync::Arc::new(ShellGitMarker::new());
3504 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
3505 None
3506 } else {
3507 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
3508 };
3509 let (log, ticker, network) = match &anchorer_bundle {
3510 Some((anchorer, ticker, network)) => (
3511 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
3512 ticker.clone(),
3513 network.clone(),
3514 ),
3515 None => (
3517 ProvenanceLog::new(marker.clone()),
3518 String::new(),
3519 String::new(),
3520 ),
3521 };
3522
3523 let record_policy = match policy {
3527 AnchorPolicy::Epoch => AnchorPolicy::Never,
3528 other => other,
3529 };
3530 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
3531
3532 let write_record = solid_pod_rs::provenance::WriteRecord {
3536 repo: &repo,
3537 path: rel,
3538 agent_did,
3539 message,
3540 policy: record_policy,
3541 high_value,
3542 ticker: &ticker,
3543 network: &network,
3544 created,
3545 };
3546 let mut receipt = log.record_receipt(write_record).await;
3551 receipt.resource = resource_path.to_string();
3554
3555 let Some(git) = receipt.mark.clone() else {
3556 tracing::warn!(
3560 target: "solid_pod_rs_server::git_mark",
3561 resource = %resource_path,
3562 "provenance record failed (write already succeeded): {}",
3563 receipt.mark_error.as_deref().unwrap_or("unspecified")
3564 );
3565 return receipt;
3566 };
3567 if let Some(err) = &receipt.anchor_error {
3568 tracing::warn!(
3571 target: "solid_pod_rs_server::git_mark",
3572 resource = %resource_path,
3573 commit = %git.commit_sha,
3574 "block-trail anchor failed (git-mark stands): {err}"
3575 );
3576 }
3577 let mark = solid_pod_rs::provenance::ProvenanceMark {
3578 resource: resource_path.to_string(),
3579 git: git.clone(),
3580 anchor: receipt.anchor.clone(),
3581 agent_did: agent_did.to_string(),
3582 created,
3583 };
3584
3585 if matches!(policy, AnchorPolicy::Epoch) {
3589 if let Some((anchorer, _, _)) = &anchorer_bundle {
3590 match handlers::prov::epoch_push_and_maybe_anchor(
3591 state,
3592 anchorer,
3593 &ticker,
3594 &network,
3595 &mark.git.commit_sha,
3596 )
3597 .await
3598 {
3599 Ok(Some(closed)) => tracing::debug!(
3600 target: "solid_pod_rs_server::git_mark",
3601 root = %closed.root,
3602 n = closed.commits.len(),
3603 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
3604 ),
3605 Ok(None) => {}
3606 Err(e) => tracing::warn!(
3607 target: "solid_pod_rs_server::git_mark",
3608 "epoch batch/anchor failed (swallowed): {e}"
3609 ),
3610 }
3611 }
3612 }
3613
3614 let ttl = prov_ttl(&mark);
3619 let sidecar = format!("{resource_path}.prov.ttl");
3620 if let Err(e) = state
3621 .storage
3622 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
3623 .await
3624 {
3625 tracing::warn!(
3626 target: "solid_pod_rs_server::git_mark",
3627 sidecar = %sidecar,
3628 "provenance sidecar write failed: {e}"
3629 );
3630 receipt.mark_error = Some(format!("PROV-O sidecar write failed: {e}"));
3633 return receipt;
3634 }
3635
3636 tracing::debug!(
3637 target: "solid_pod_rs_server::git_mark",
3638 resource = %resource_path,
3639 commit = %mark.git.commit_sha,
3640 stage = %receipt.stage(),
3641 "provenance recorded"
3642 );
3643 receipt
3644}
3645
3646#[cfg(not(feature = "git"))]
3649#[inline]
3650async fn git_mark_write(
3651 _state: &AppState,
3652 resource_path: &str,
3653 _agent: Option<&str>,
3654 _message: &str,
3655) -> ProvenanceReceipt {
3656 ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured)
3657}
3658
3659#[cfg(feature = "git")]
3660pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
3661 require_pod_owner_with_body(req, pod_pubkey, None).await
3662}
3663
3664#[cfg(feature = "git")]
3665pub(crate) async fn require_pod_owner_with_body(
3666 req: &HttpRequest,
3667 pod_pubkey: &str,
3668 body: Option<&[u8]>,
3669) -> Option<String> {
3670 let caller = extract_pubkey_with_body(req, body).await?;
3671 if caller != pod_pubkey {
3672 return None;
3673 }
3674 Some(caller)
3675}
3676
3677#[cfg(feature = "git")]
3678fn git_json_err(msg: &str, status: u16) -> HttpResponse {
3679 HttpResponse::build(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
3680 .content_type("application/json")
3681 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
3682}
3683
3684#[cfg(feature = "git")]
3686#[derive(serde::Deserialize)]
3687struct GitStageBody {
3688 paths: Option<Vec<String>>,
3689 all: Option<bool>,
3690}
3691
3692#[cfg(feature = "git")]
3693#[derive(serde::Deserialize)]
3694struct GitCommitBody {
3695 message: String,
3696 author_name: Option<String>,
3697 author_email: Option<String>,
3698}
3699
3700#[cfg(feature = "git")]
3701#[derive(serde::Deserialize)]
3702struct GitBranchBody {
3703 name: String,
3704}
3705
3706#[cfg(feature = "git")]
3709async fn handle_git_status(
3710 path: web::Path<String>,
3711 req: HttpRequest,
3712 state: web::Data<AppState>,
3713) -> HttpResponse {
3714 let pubkey = path.into_inner();
3715 if require_pod_owner(&req, &pubkey).await.is_none() {
3716 return git_json_err("Authentication required", 401);
3717 }
3718 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3719 return git_json_err("Git not available (no FS backend)", 501);
3720 };
3721 match solid_pod_rs_git::api::git_status(&repo).await {
3722 Ok(s) => HttpResponse::Ok()
3723 .content_type("application/json")
3724 .body(serde_json::to_string(&s).unwrap_or_default()),
3725 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3726 }
3727}
3728
3729#[cfg(feature = "git")]
3730async fn handle_git_log(
3731 path: web::Path<String>,
3732 req: HttpRequest,
3733 state: web::Data<AppState>,
3734 query: web::Query<std::collections::HashMap<String, String>>,
3735) -> HttpResponse {
3736 let pubkey = path.into_inner();
3737 if require_pod_owner(&req, &pubkey).await.is_none() {
3738 return git_json_err("Authentication required", 401);
3739 }
3740 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3741 return git_json_err("Git not available (no FS backend)", 501);
3742 };
3743 let limit: u32 = query
3744 .get("limit")
3745 .and_then(|v| v.parse().ok())
3746 .unwrap_or(20);
3747 match solid_pod_rs_git::api::git_log(&repo, limit).await {
3748 Ok(entries) => HttpResponse::Ok()
3749 .content_type("application/json")
3750 .body(serde_json::to_string(&entries).unwrap_or_default()),
3751 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3752 }
3753}
3754
3755#[cfg(feature = "git")]
3756async fn handle_git_diff(
3757 path: web::Path<String>,
3758 req: HttpRequest,
3759 state: web::Data<AppState>,
3760 query: web::Query<std::collections::HashMap<String, String>>,
3761) -> HttpResponse {
3762 let pubkey = path.into_inner();
3763 if require_pod_owner(&req, &pubkey).await.is_none() {
3764 return git_json_err("Authentication required", 401);
3765 }
3766 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3767 return git_json_err("Git not available (no FS backend)", 501);
3768 };
3769 let file_path = query.get("path").map(String::as_str);
3770 let staged = query
3771 .get("staged")
3772 .map(|v| v == "true" || v == "1")
3773 .unwrap_or(false);
3774 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
3775 Ok(diff) => HttpResponse::Ok().content_type("text/plain").body(diff),
3776 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3777 }
3778}
3779
3780#[cfg(feature = "git")]
3781async fn handle_git_stage(
3782 path: web::Path<String>,
3783 req: HttpRequest,
3784 state: web::Data<AppState>,
3785 body: web::Bytes,
3786) -> HttpResponse {
3787 let pubkey = path.into_inner();
3788 if require_pod_owner(&req, &pubkey).await.is_none() {
3789 return git_json_err("Authentication required", 401);
3790 }
3791 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3792 return git_json_err("Git not available (no FS backend)", 501);
3793 };
3794 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3795 Ok(v) => v,
3796 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3797 };
3798 let paths = parsed.paths.unwrap_or_default();
3799 let all = parsed.all.unwrap_or(false);
3800 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
3801 Ok(()) => HttpResponse::Ok()
3802 .content_type("application/json")
3803 .body(r#"{"ok":true}"#),
3804 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3805 }
3806}
3807
3808#[cfg(feature = "git")]
3809async fn handle_git_unstage(
3810 path: web::Path<String>,
3811 req: HttpRequest,
3812 state: web::Data<AppState>,
3813 body: web::Bytes,
3814) -> HttpResponse {
3815 let pubkey = path.into_inner();
3816 if require_pod_owner(&req, &pubkey).await.is_none() {
3817 return git_json_err("Authentication required", 401);
3818 }
3819 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3820 return git_json_err("Git not available (no FS backend)", 501);
3821 };
3822 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3823 Ok(v) => v,
3824 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3825 };
3826 let paths = parsed.paths.unwrap_or_default();
3827 let all = parsed.all.unwrap_or(false);
3828 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
3829 Ok(()) => HttpResponse::Ok()
3830 .content_type("application/json")
3831 .body(r#"{"ok":true}"#),
3832 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3833 }
3834}
3835
3836#[cfg(feature = "git")]
3837async fn handle_git_commit(
3838 path: web::Path<String>,
3839 req: HttpRequest,
3840 state: web::Data<AppState>,
3841 body: web::Bytes,
3842) -> HttpResponse {
3843 let pubkey = path.into_inner();
3844 if require_pod_owner(&req, &pubkey).await.is_none() {
3845 return git_json_err("Authentication required", 401);
3846 }
3847 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3848 return git_json_err("Git not available (no FS backend)", 501);
3849 };
3850 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
3851 Ok(v) => v,
3852 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3853 };
3854 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
3855 let author_email = parsed
3856 .author_email
3857 .as_deref()
3858 .unwrap_or("pod@dreamlab-ai.com");
3859 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email).await
3860 {
3861 Ok(result) => HttpResponse::Ok()
3862 .content_type("application/json")
3863 .body(serde_json::to_string(&result).unwrap_or_default()),
3864 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3865 }
3866}
3867
3868#[cfg(feature = "git")]
3869async fn handle_git_branches(
3870 path: web::Path<String>,
3871 req: HttpRequest,
3872 state: web::Data<AppState>,
3873) -> HttpResponse {
3874 let pubkey = path.into_inner();
3875 if require_pod_owner(&req, &pubkey).await.is_none() {
3876 return git_json_err("Authentication required", 401);
3877 }
3878 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3879 return git_json_err("Git not available (no FS backend)", 501);
3880 };
3881 match solid_pod_rs_git::api::git_branches(&repo).await {
3882 Ok(info) => HttpResponse::Ok()
3883 .content_type("application/json")
3884 .body(serde_json::to_string(&info).unwrap_or_default()),
3885 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3886 }
3887}
3888
3889#[cfg(feature = "git")]
3890async fn handle_git_create_branch(
3891 path: web::Path<String>,
3892 req: HttpRequest,
3893 state: web::Data<AppState>,
3894 body: web::Bytes,
3895) -> HttpResponse {
3896 let pubkey = path.into_inner();
3897 if require_pod_owner(&req, &pubkey).await.is_none() {
3898 return git_json_err("Authentication required", 401);
3899 }
3900 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3901 return git_json_err("Git not available (no FS backend)", 501);
3902 };
3903 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
3904 Ok(v) => v,
3905 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3906 };
3907 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
3908 Ok(()) => HttpResponse::Ok()
3909 .content_type("application/json")
3910 .body(r#"{"ok":true}"#),
3911 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3912 }
3913}
3914
3915#[cfg(feature = "git")]
3916async fn handle_git_discard(
3917 path: web::Path<String>,
3918 req: HttpRequest,
3919 state: web::Data<AppState>,
3920 body: web::Bytes,
3921) -> HttpResponse {
3922 let pubkey = path.into_inner();
3923 if require_pod_owner(&req, &pubkey).await.is_none() {
3924 return git_json_err("Authentication required", 401);
3925 }
3926 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3927 return git_json_err("Git not available (no FS backend)", 501);
3928 };
3929 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3930 Ok(v) => v,
3931 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3932 };
3933 let paths = parsed.paths.unwrap_or_default();
3934 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
3935 Ok(()) => HttpResponse::Ok()
3936 .content_type("application/json")
3937 .body(r#"{"ok":true}"#),
3938 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3939 }
3940}
3941
3942async fn handle_git_panel_options(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
3950 let origin = req
3951 .headers()
3952 .get(header::ORIGIN)
3953 .and_then(|v| v.to_str().ok())
3954 .map(str::to_string);
3955
3956 let mut rsp = HttpResponse::NoContent().finish();
3957 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
3958 rsp
3959}
3960
3961async fn handle_admin_provision(
3978 req: HttpRequest,
3979 state: web::Data<AppState>,
3980 path: web::Path<String>,
3981) -> HttpResponse {
3982 let expected = match &state.admin_key {
3984 Some(k) => k.clone(),
3985 None => {
3986 return HttpResponse::Forbidden().json(serde_json::json!({
3987 "error": "admin key not configured on this server"
3988 }));
3989 }
3990 };
3991 let provided = req
3992 .headers()
3993 .get("x-pod-admin-key")
3994 .and_then(|v| v.to_str().ok())
3995 .unwrap_or("");
3996 use subtle::ConstantTimeEq;
4001 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
4002 if !bool::from(key_match) {
4003 return HttpResponse::Forbidden().json(serde_json::json!({"error": "invalid admin key"}));
4004 }
4005
4006 let pubkey = path.into_inner();
4008 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
4009 return HttpResponse::BadRequest()
4010 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
4011 }
4012
4013 let data_root = match &state.data_root {
4015 Some(r) => r.clone(),
4016 None => {
4017 return HttpResponse::InternalServerError().json(serde_json::json!({
4018 "error": "server has no fs-backend storage configured"
4019 }));
4020 }
4021 };
4022
4023 let pods_root = data_root.join("pods");
4036 let pod_dir = pods_root.join(&pubkey);
4037
4038 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
4040 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
4041 return HttpResponse::InternalServerError()
4042 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
4043 }
4044
4045 let acl_content = format!(
4055 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
4056 <#owner> a acl:Authorization ;\n\
4057 acl:agent <did:nostr:{pubkey}> ;\n\
4058 acl:accessTo </pods/{pubkey}/> ;\n\
4059 acl:default </pods/{pubkey}/> ;\n\
4060 acl:mode acl:Read, acl:Write, acl:Control .\n"
4061 );
4062
4063 let sibling_acl_path = pods_root.join(format!("{pubkey}.acl"));
4073 if !sibling_acl_path.exists() {
4074 if let Err(e) = tokio::fs::write(&sibling_acl_path, acl_content.as_bytes()).await {
4075 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write sibling .acl failed");
4076 return HttpResponse::InternalServerError()
4077 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
4078 }
4079 }
4080
4081 let inner_acl_path = pod_dir.join(".acl");
4088 if !inner_acl_path.exists() {
4089 if let Err(e) = tokio::fs::write(&inner_acl_path, acl_content.as_bytes()).await {
4090 tracing::warn!(pubkey = %pubkey, error = %e, "/_admin/provision: write inner .acl failed (non-fatal; sibling ACL governs)");
4091 }
4092 }
4093
4094 #[cfg(feature = "git")]
4096 {
4097 use tokio::process::Command;
4098
4099 if !pod_dir.join(".git").exists() {
4101 let init_out = Command::new("git")
4102 .args(["init", "-b", "main", pod_dir.to_str().unwrap_or(".")])
4103 .output()
4104 .await;
4105
4106 match init_out {
4107 Ok(out) if out.status.success() => {}
4108 Ok(out) => {
4109 let stderr = String::from_utf8_lossy(&out.stderr);
4110 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
4111 }
4112 Err(e) => {
4113 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
4114 }
4115 }
4116
4117 let cfg_out = Command::new("git")
4120 .args([
4121 "-C",
4122 pod_dir.to_str().unwrap_or("."),
4123 "config",
4124 "receive.denyCurrentBranch",
4125 "updateInstead",
4126 ])
4127 .output()
4128 .await;
4129
4130 if let Err(e) = cfg_out {
4131 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
4132 }
4133 }
4134 }
4135
4136 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
4138 HttpResponse::Ok().json(serde_json::json!({
4139 "podUrl": format!("{base_url}/pods/{pubkey}/"),
4140 "ok": true,
4141 }))
4142}
4143
4144async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
4149 let Some(ref data_root) = state.data_root else {
4150 return HttpResponse::Ok()
4151 .content_type("application/json")
4152 .json(serde_json::json!({"apps": [], "count": 0}));
4153 };
4154
4155 let server_url = state.nodeinfo.base_url.clone();
4156
4157 let mut read_dir = match tokio::fs::read_dir(data_root).await {
4159 Ok(rd) => rd,
4160 Err(_) => {
4161 return HttpResponse::Ok()
4162 .content_type("application/json")
4163 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
4164 }
4165 };
4166
4167 let mut apps: Vec<serde_json::Value> = Vec::new();
4168 let mut scanned = 0usize;
4169
4170 while scanned < 1000 {
4171 let entry = match read_dir.next_entry().await {
4172 Ok(Some(e)) => e,
4173 Ok(None) => break,
4174 Err(_) => break,
4175 };
4176
4177 let file_type = match entry.file_type().await {
4178 Ok(ft) => ft,
4179 Err(_) => continue,
4180 };
4181 if !file_type.is_dir() {
4182 continue;
4183 }
4184
4185 scanned += 1;
4186
4187 let manifest_path = entry.path().join("apps").join("manifest.json");
4188 let contents = match tokio::fs::read(&manifest_path).await {
4189 Ok(c) => c,
4190 Err(_) => continue,
4191 };
4192
4193 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
4194 Ok(v) => v,
4195 Err(_) => continue,
4196 };
4197
4198 if let Some(pod_name) = entry.file_name().to_str() {
4200 if manifest.get("podOwner").is_none() {
4201 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
4202 }
4203 }
4204
4205 apps.push(manifest);
4206 }
4207
4208 let count = apps.len();
4209 HttpResponse::Ok()
4210 .content_type("application/json")
4211 .json(serde_json::json!({
4212 "apps": apps,
4213 "serverUrl": server_url,
4214 "count": count,
4215 }))
4216}
4217
4218#[allow(dead_code)]
4231fn is_git_request(path: &str) -> bool {
4232 path.contains("/info/refs")
4233 || path.contains("/git-upload-pack")
4234 || path.contains("/git-receive-pack")
4235}
4236
4237#[allow(dead_code)]
4240fn is_dot_git_path(path: &str) -> bool {
4241 path.contains("/.git/") || path.ends_with("/.git")
4242}
4243
4244#[cfg(feature = "git")]
4245async fn handle_git(
4246 req: HttpRequest,
4247 body: web::Bytes,
4248 state: web::Data<AppState>,
4249) -> HttpResponse {
4250 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
4251 use solid_pod_rs_git::service::{GitHttpService, GitRequest};
4252
4253 let path = req.uri().path().to_string();
4254
4255 let pod_name = path
4258 .trim_start_matches('/')
4259 .split('/')
4260 .next()
4261 .unwrap_or("")
4262 .to_string();
4263 let Some(ref data_root) = state.data_root else {
4264 return HttpResponse::NotImplemented().json(serde_json::json!({
4265 "error": "git requires fs-backend storage",
4266 "reason": "data_root_not_configured"
4267 }));
4268 };
4269 let repo_root = data_root.join(&pod_name);
4270 if !repo_root.exists() {
4271 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
4272 }
4273
4274 let query = req.uri().query().unwrap_or("").to_string();
4275 let host_url = {
4276 let conn = req.connection_info();
4277 Some(format!("{}://{}", conn.scheme(), conn.host()))
4278 };
4279 let headers: Vec<(String, String)> = req
4280 .headers()
4281 .iter()
4282 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
4283 .collect();
4284
4285 let git_req = GitRequest {
4286 method: req.method().as_str().to_string(),
4287 path,
4288 query,
4289 headers,
4290 body,
4291 host_url,
4292 };
4293
4294 let is_write = git_req.is_write();
4306 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
4307 Ok(pk) => Some(format!("did:nostr:{pk}")),
4308 Err(_) => None,
4309 };
4310 let wac_path = format!("/{pod_name}/");
4311 let origin = req_origin(&req);
4312 let wac = if is_write {
4313 enforce_write_ctx(
4314 &state,
4315 &wac_path,
4316 AccessMode::Write,
4317 agent.as_deref(),
4318 origin,
4319 )
4320 .await
4321 } else {
4322 enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin)
4326 .await
4327 .map(|_audience| ())
4328 };
4329 if let Err(e) = wac {
4330 let mut resp = e.error_response();
4334 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4335 if let (Ok(name), Ok(value)) = (
4336 actix_web::http::header::HeaderName::from_bytes(k.as_bytes()),
4337 actix_web::http::header::HeaderValue::from_str(v),
4338 ) {
4339 resp.headers_mut().insert(name, value);
4340 }
4341 }
4342 return resp;
4343 }
4344
4345 let service = GitHttpService::new(repo_root);
4346 match service.handle(git_req).await {
4347 Ok(git_resp) => {
4348 let mut builder = HttpResponse::build(
4349 actix_web::http::StatusCode::from_u16(git_resp.status)
4350 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4351 );
4352 for (k, v) in &git_resp.headers {
4353 builder.insert_header((k.as_str(), v.as_str()));
4354 }
4355 builder.body(git_resp.body)
4356 }
4357 Err(e) => {
4358 let status = e.status_code();
4359 let mut builder = HttpResponse::build(
4360 actix_web::http::StatusCode::from_u16(status)
4361 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4362 );
4363 for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4364 builder.insert_header((k, v));
4365 }
4366 builder.json(serde_json::json!({"error": e.to_string()}))
4367 }
4368 }
4369}
4370
4371#[cfg(feature = "forge")]
4380fn forge_plugin_dir(state: &AppState) -> Option<PathBuf> {
4381 state.data_root.as_ref().map(|r| r.join(".forge"))
4382}
4383
4384#[cfg(feature = "forge")]
4390struct ServerLoopback {
4391 client: reqwest::Client,
4392}
4393
4394#[cfg(feature = "forge")]
4395#[async_trait::async_trait]
4396impl solid_pod_rs_forge::LoopbackFetch for ServerLoopback {
4397 async fn get(
4398 &self,
4399 url: &str,
4400 max_bytes: usize,
4401 timeout_secs: u64,
4402 ) -> solid_pod_rs_forge::bodies::FetchResult {
4403 use solid_pod_rs_forge::bodies::FetchResult;
4404 let resp = match self
4405 .client
4406 .get(url)
4407 .timeout(Duration::from_secs(timeout_secs.max(1)))
4408 .send()
4409 .await
4410 {
4411 Ok(r) => r,
4412 Err(e) => return FetchResult::Error(e.to_string()),
4413 };
4414 let code = resp.status().as_u16();
4415 if code == 404 || code == 410 {
4416 return FetchResult::Removed;
4417 }
4418 if !resp.status().is_success() {
4419 return FetchResult::Error(format!("status {code}"));
4420 }
4421 if resp
4422 .content_length()
4423 .is_some_and(|length| length > max_bytes as u64)
4424 {
4425 return FetchResult::TooLarge;
4426 }
4427 let mut stream = resp.bytes_stream();
4428 let mut body = Vec::with_capacity(max_bytes.min(16 * 1024));
4429 while let Some(chunk) = stream.next().await {
4430 match chunk {
4431 Ok(chunk) if body.len().saturating_add(chunk.len()) <= max_bytes => {
4432 body.extend_from_slice(&chunk);
4433 }
4434 Ok(_) => return FetchResult::TooLarge,
4435 Err(error) => return FetchResult::Error(error.to_string()),
4436 }
4437 }
4438 FetchResult::Body(body)
4439 }
4440}
4441
4442#[cfg(feature = "forge")]
4448async fn handle_forge(
4449 req: HttpRequest,
4450 body: web::Bytes,
4451 state: web::Data<AppState>,
4452) -> HttpResponse {
4453 use solid_pod_rs_forge::{ForgeConfig, ForgeRequest, ForgeService};
4454
4455 let Some(plugin_dir) = forge_plugin_dir(&state) else {
4456 return HttpResponse::NotImplemented().json(serde_json::json!({
4457 "error": "forge requires fs-backend storage",
4458 "reason": "data_root_not_configured"
4459 }));
4460 };
4461
4462 let loopback: Arc<dyn solid_pod_rs_forge::LoopbackFetch> = Arc::new(ServerLoopback {
4467 client: reqwest::Client::new(),
4468 });
4469 let service = match ForgeService::new(ForgeConfig::default(), plugin_dir) {
4470 Ok(s) => s.with_loopback(loopback),
4471 Err(e) => {
4472 return HttpResponse::InternalServerError()
4473 .json(serde_json::json!({"error": e.to_string()}));
4474 }
4475 };
4476
4477 let path = req.uri().path().to_string();
4478 let query = req.uri().query().unwrap_or("").to_string();
4479 let host_url = {
4480 let conn = req.connection_info();
4481 Some(format!("{}://{}", conn.scheme(), conn.host()))
4482 };
4483 let headers: Vec<(String, String)> = req
4484 .headers()
4485 .iter()
4486 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
4487 .collect();
4488
4489 let forge_req = ForgeRequest {
4490 method: req.method().as_str().to_string(),
4491 path,
4492 query,
4493 headers,
4494 raw_body: body,
4495 host_url,
4496 };
4497
4498 let agent = service.resolve_agent(&forge_req).await;
4503
4504 match service.handle(forge_req, agent).await {
4505 Ok(resp) => {
4506 let mut builder = HttpResponse::build(
4507 StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
4508 );
4509 for (k, v) in &resp.headers {
4510 builder.insert_header((k.as_str(), v.as_str()));
4511 }
4512 builder.body(resp.body)
4513 }
4514 Err(e) => {
4515 let status = e.status_code();
4516 HttpResponse::build(
4517 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
4518 )
4519 .json(serde_json::json!({"error": e.to_string()}))
4520 }
4521 }
4522}
4523
4524pub fn build_app(
4536 state: AppState,
4537) -> App<
4538 impl actix_web::dev::ServiceFactory<
4539 ServiceRequest,
4540 Config = (),
4541 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
4542 Error = ActixError,
4543 InitError = (),
4544 >,
4545> {
4546 let body_cap = state.body_cap;
4547 let dotfiles = state.dotfiles.clone();
4548 let allowed_origins = Arc::new(state.allowed_origins.clone());
4549
4550 let mut app = App::new()
4551 .app_data(web::Data::new(state.clone()))
4552 .app_data(web::PayloadConfig::new(body_cap))
4553 .wrap(ErrorLoggingMiddleware)
4558 .wrap(CorsHeaders { allowed_origins })
4559 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
4563 .wrap(PathTraversalGuard)
4564 .wrap(DotfileGuard::new(dotfiles));
4565
4566 app = app
4572 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
4573 .route(
4574 "/.well-known/webfinger",
4575 web::get().to(handle_well_known_webfinger),
4576 )
4577 .route(
4578 "/.well-known/nodeinfo",
4579 web::get().to(handle_well_known_nodeinfo),
4580 )
4581 .route(
4582 "/.well-known/nodeinfo/2.1",
4583 web::get().to(handle_well_known_nodeinfo_2_1),
4584 );
4585
4586 #[cfg(feature = "did-nostr")]
4587 {
4588 app = app.route(
4589 "/.well-known/did/nostr/{pubkey}.json",
4590 web::get().to(handle_well_known_did_nostr),
4591 );
4592 }
4593
4594 #[cfg(feature = "nip05-endpoint")]
4599 {
4600 app = app.route(
4601 "/.well-known/nostr.json",
4602 web::get().to(handle_well_known_nip05),
4603 );
4604 }
4605
4606 #[cfg(feature = "export-jsonld")]
4611 {
4612 app = app.route("/api/exports/all", web::get().to(handle_export_all));
4613 }
4614
4615 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
4617
4618 app = app.route("/pay/.info", web::get().to(handle_pay_info));
4620
4621 app = app.configure(handlers::pay::register);
4626
4627 app = app.route("/proxy", web::get().to(handle_proxy));
4629
4630 if state.mcp_enabled {
4634 app = app.route("/mcp", web::post().to(mcp::handle_mcp)).route(
4635 "/mcp",
4636 web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options),
4637 );
4638 }
4639
4640 app = app.route(
4643 "/_admin/provision/{pubkey}",
4644 web::post().to(handle_admin_provision),
4645 );
4646
4647 app = app
4649 .route("/.pods", web::post().to(handle_create_pod))
4650 .route("/api/accounts/new", web::post().to(handle_create_account))
4651 .route("/pods/check/{name}", web::get().to(handle_pod_check))
4652 .route("/login/password", web::post().to(handle_login_password))
4653 .route(
4654 "/account/password/reset",
4655 web::post().to(handle_password_reset_request),
4656 )
4657 .route(
4658 "/account/password/change",
4659 web::post().to(handle_password_change),
4660 );
4661
4662 #[cfg(feature = "forge")]
4667 {
4668 app = app
4669 .route("/forge", web::route().to(handle_forge))
4670 .route("/forge/{tail:.*}", web::route().to(handle_forge));
4671 }
4672
4673 app = app
4678 .route(
4679 "/{tail:.*}/.git",
4681 web::route().to(|| async {
4682 HttpResponse::Forbidden()
4683 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4684 }),
4685 )
4686 .route(
4687 "/{tail:.*}/.git/{rest:.*}",
4688 web::route().to(|| async {
4689 HttpResponse::Forbidden()
4690 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
4691 }),
4692 );
4693
4694 app = app.route(
4698 "/pods/{pk}/_git/{tail:.*}",
4699 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
4700 );
4701
4702 #[cfg(feature = "git")]
4703 {
4704 app = app
4706 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
4707 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
4708 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
4709
4710 app = app
4713 .route(
4714 "/pods/{pubkey}/_git/status",
4715 web::get().to(handle_git_status),
4716 )
4717 .route("/pods/{pubkey}/_git/log", web::get().to(handle_git_log))
4718 .route("/pods/{pubkey}/_git/diff", web::get().to(handle_git_diff))
4719 .route(
4720 "/pods/{pubkey}/_git/stage",
4721 web::post().to(handle_git_stage),
4722 )
4723 .route(
4724 "/pods/{pubkey}/_git/unstage",
4725 web::post().to(handle_git_unstage),
4726 )
4727 .route(
4728 "/pods/{pubkey}/_git/commit",
4729 web::post().to(handle_git_commit),
4730 )
4731 .route(
4732 "/pods/{pubkey}/_git/branches",
4733 web::get().to(handle_git_branches),
4734 )
4735 .route(
4736 "/pods/{pubkey}/_git/branch",
4737 web::post().to(handle_git_create_branch),
4738 )
4739 .route(
4740 "/pods/{pubkey}/_git/discard",
4741 web::post().to(handle_git_discard),
4742 );
4743
4744 app = app.configure(handlers::prov::register);
4751 }
4752 #[cfg(not(feature = "git"))]
4753 {
4754 let git_501 = || async {
4758 HttpResponse::NotImplemented()
4759 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
4760 };
4761 app = app
4762 .route("/{tail:.*}/info/refs", web::get().to(git_501))
4763 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
4764 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
4765 }
4766
4767 app.route("/{tail:.*}/", web::post().to(handle_post))
4770 .route("/{tail:.*}/", web::put().to(handle_put))
4771 .route("/{tail:.*}", web::get().to(handle_get))
4772 .route("/{tail:.*}", web::head().to(handle_get))
4773 .route("/{tail:.*}", web::put().to(handle_put))
4774 .route("/{tail:.*}", web::patch().to(handle_patch))
4775 .route("/{tail:.*}", web::delete().to(handle_delete))
4776 .route(
4777 "/{tail:.*}",
4778 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
4779 )
4780 .route(
4781 "/{tail:.*}",
4782 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
4783 )
4784}
4785
4786#[cfg(test)]
4791mod payment_gating_tests {
4792 use super::*;
4793 use solid_pod_rs::payments::WebLedger;
4794 use solid_pod_rs::storage::memory::MemoryBackend;
4795
4796 macro_rules! principal {
4800 () => {
4801 "did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
4802 };
4803 }
4804
4805 const PRINCIPAL: &str = principal!();
4806
4807 const OTHER_PRINCIPAL: &str =
4809 "did:nostr:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
4810
4811 const PAID_WRITE_ACL: &str = concat!(
4814 r#"
4815@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4816
4817<#paid-write> a acl:Authorization ;
4818 acl:agent <"#,
4819 principal!(),
4820 r#"> ;
4821 acl:accessTo </premium/inbox> ;
4822 acl:mode acl:Write ;
4823 acl:condition [
4824 a acl:PaymentCondition ;
4825 acl:costSats 100
4826 ] .
4827"#
4828 );
4829
4830 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
4831 let mut ledger = WebLedger::new("Test Pod Credits");
4832 if sats > 0 {
4833 ledger
4834 .credit_by_outpoint(did, "satoshi", &"ab".repeat(32), 0, sats)
4835 .unwrap();
4836 }
4837 let body = serde_json::to_vec(&ledger).unwrap();
4838 storage
4839 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
4840 .await
4841 .unwrap();
4842 }
4843
4844 async fn seed_acl(storage: &dyn Storage) {
4845 storage
4846 .put(
4847 "/premium/inbox.acl",
4848 Bytes::from(PAID_WRITE_ACL),
4849 "text/turtle",
4850 )
4851 .await
4852 .unwrap();
4853 }
4854
4855 #[actix_web::test]
4857 async fn resolve_balance_reads_ledger_entry() {
4858 let storage = MemoryBackend::new();
4859 seed_ledger(&storage, PRINCIPAL, 250).await;
4860 assert_eq!(
4861 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
4862 Some(250)
4863 );
4864 }
4865
4866 #[actix_web::test]
4868 async fn resolve_balance_zero_when_no_entry() {
4869 let storage = MemoryBackend::new();
4870 seed_ledger(&storage, OTHER_PRINCIPAL, 500).await;
4871 assert_eq!(
4872 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
4873 Some(0)
4874 );
4875 }
4876
4877 #[test]
4883 fn fixtures_use_teller_accounts_and_grant_the_principal() {
4884 use solid_pod_rs::payments::account_of;
4885 for did in [PRINCIPAL, OTHER_PRINCIPAL] {
4886 assert_eq!(account_of(did).unwrap(), did, "{did} must be canonical");
4887 }
4888 assert_ne!(PRINCIPAL, OTHER_PRINCIPAL);
4889 assert!(account_of("did:nostr:bob").is_err());
4890 let agent = format!("acl:agent <{PRINCIPAL}>");
4891 for (name, acl) in [
4892 ("PAID_WRITE_ACL", PAID_WRITE_ACL),
4893 ("WRITE_NOT_CONTROL_ACL", WRITE_NOT_CONTROL_ACL),
4894 ] {
4895 assert!(acl.contains(&agent), "{name} must grant PRINCIPAL");
4896 }
4897 assert!(!WRITE_NOT_CONTROL_ACL.contains("did:nostr:alice"));
4898 }
4899
4900 #[actix_web::test]
4902 async fn resolve_balance_none_when_anonymous() {
4903 let storage = MemoryBackend::new();
4904 seed_ledger(&storage, PRINCIPAL, 1_000).await;
4905 assert_eq!(resolve_balance_sats(&storage, None).await, None);
4906 }
4907
4908 #[actix_web::test]
4910 async fn paid_write_denied_below_balance() {
4911 let storage = Arc::new(MemoryBackend::new());
4912 seed_acl(storage.as_ref()).await;
4913 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
4915
4916 let result =
4917 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4918 assert!(
4919 result.is_err(),
4920 "balance 50 < cost 100 must be denied — sat-gating loop closed"
4921 );
4922 }
4923
4924 #[actix_web::test]
4926 async fn paid_write_allowed_at_balance() {
4927 let storage = Arc::new(MemoryBackend::new());
4928 seed_acl(storage.as_ref()).await;
4929 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
4931
4932 let result =
4933 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4934 assert!(
4935 result.is_ok(),
4936 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
4937 );
4938 }
4939
4940 #[actix_web::test]
4942 async fn paid_write_allowed_above_balance() {
4943 let storage = Arc::new(MemoryBackend::new());
4944 seed_acl(storage.as_ref()).await;
4945 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
4946 let state = AppState::new(storage);
4947
4948 let result =
4949 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4950 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
4951 }
4952
4953 #[actix_web::test]
4957 async fn paid_write_anonymous_denied() {
4958 let storage = Arc::new(MemoryBackend::new());
4959 seed_acl(storage.as_ref()).await;
4960 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
4961 let state = AppState::new(storage);
4962
4963 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
4964 assert!(
4965 result.is_err(),
4966 "anonymous caller has no ledger principal — PaymentCondition fails closed"
4967 );
4968 }
4969
4970 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
4977 let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
4978 let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
4979 ledger.get_balance(did)
4980 }
4981
4982 #[actix_web::test]
4984 async fn paid_write_debits_ledger() {
4985 let storage = Arc::new(MemoryBackend::new());
4986 seed_acl(storage.as_ref()).await;
4987 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
4989
4990 let result =
4991 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4992 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
4993 assert_eq!(
4994 read_balance(storage.as_ref(), PRINCIPAL).await,
4995 150,
4996 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
4997 );
4998 }
4999
5000 #[actix_web::test]
5003 async fn paid_write_debits_each_grant() {
5004 let storage = Arc::new(MemoryBackend::new());
5005 seed_acl(storage.as_ref()).await;
5006 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
5008
5009 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5010 .await
5011 .unwrap();
5012 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5013 .await
5014 .unwrap();
5015 assert_eq!(
5016 read_balance(storage.as_ref(), PRINCIPAL).await,
5017 50,
5018 "250 - 2*100: each granted request debits, no unmetered re-use"
5019 );
5020
5021 let third =
5023 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5024 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
5025 assert_eq!(
5026 read_balance(storage.as_ref(), PRINCIPAL).await,
5027 50,
5028 "a denied request must not debit"
5029 );
5030 }
5031
5032 #[actix_web::test]
5034 async fn paid_read_debits_ledger() {
5035 const PAID_READ_ACL: &str = r#"
5036@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5037
5038<#paid-read> a acl:Authorization ;
5039 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5040 acl:accessTo </premium/feed> ;
5041 acl:mode acl:Read ;
5042 acl:condition [
5043 a acl:PaymentCondition ;
5044 acl:costSats 30
5045 ] .
5046"#;
5047 let storage = Arc::new(MemoryBackend::new());
5048 storage
5049 .put(
5050 "/premium/feed.acl",
5051 Bytes::from(PAID_READ_ACL),
5052 "text/turtle",
5053 )
5054 .await
5055 .unwrap();
5056 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5057 let state = AppState::new(storage.clone());
5058
5059 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
5060 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
5061 assert_eq!(
5062 read_balance(storage.as_ref(), PRINCIPAL).await,
5063 70,
5064 "100 - 30 cost: a granted paid read must debit"
5065 );
5066 }
5067
5068 #[actix_web::test]
5071 async fn free_read_does_not_debit() {
5072 let storage = Arc::new(MemoryBackend::new());
5073 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5075 let state = AppState::new(storage.clone());
5076
5077 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
5078 .await
5079 .unwrap();
5080 assert_eq!(
5081 read_balance(storage.as_ref(), PRINCIPAL).await,
5082 100,
5083 "a grant with no PaymentCondition must not debit"
5084 );
5085 }
5086
5087 const ALICE_ONLY_READ_ACL: &str = r#"
5093@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5094
5095<#alice> a acl:Authorization ;
5096 acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5097 acl:accessTo </private/secret> ;
5098 acl:default </private/> ;
5099 acl:mode acl:Read, acl:Write, acl:Control .
5100"#;
5101
5102 async fn seed_private_read_acl(storage: &dyn Storage) {
5103 storage
5108 .put(
5109 "/private.acl",
5110 Bytes::from(ALICE_ONLY_READ_ACL),
5111 "text/turtle",
5112 )
5113 .await
5114 .unwrap();
5115 }
5116
5117 #[actix_web::test]
5121 async fn enforce_read_grants_owner() {
5122 let storage = Arc::new(MemoryBackend::new());
5123 seed_private_read_acl(storage.as_ref()).await;
5124 let state = AppState::new(storage);
5125 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
5126 assert!(result.is_ok(), "owner alice must be granted Read");
5127 }
5128
5129 #[actix_web::test]
5132 async fn enforce_read_denies_other_principal() {
5133 let storage = Arc::new(MemoryBackend::new());
5134 seed_private_read_acl(storage.as_ref()).await;
5135 let state = AppState::new(storage);
5136 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
5137 assert!(
5138 result.is_err(),
5139 "bob has no Read grant — private resource must not be world-readable"
5140 );
5141 }
5142
5143 #[actix_web::test]
5146 async fn enforce_read_denies_anonymous() {
5147 let storage = Arc::new(MemoryBackend::new());
5148 seed_private_read_acl(storage.as_ref()).await;
5149 let state = AppState::new(storage);
5150 let result = enforce_read(&state, "/private/secret", None).await;
5151 assert!(result.is_err(), "anonymous Read must be denied");
5152 }
5153
5154 const WRITE_NOT_CONTROL_ACL: &str = concat!(
5162 r#"
5163@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5164
5165<#owner> a acl:Authorization ;
5166 acl:agent <"#,
5167 principal!(),
5168 r#"> ;
5169 acl:accessTo </shared/doc> ;
5170 acl:default </shared/> ;
5171 acl:mode acl:Read, acl:Write, acl:Control .
5172
5173<#writer> a acl:Authorization ;
5174 acl:agent <did:nostr:writer> ;
5175 acl:accessTo </shared/doc> ;
5176 acl:default </shared/> ;
5177 acl:mode acl:Read, acl:Write .
5178"#
5179 );
5180
5181 async fn seed_shared_acl(storage: &dyn Storage) {
5182 storage
5187 .put(
5188 "/shared.acl",
5189 Bytes::from(WRITE_NOT_CONTROL_ACL),
5190 "text/turtle",
5191 )
5192 .await
5193 .unwrap();
5194 }
5195
5196 #[actix_web::test]
5200 async fn acl_put_denied_for_writer_without_control() {
5201 let storage = Arc::new(MemoryBackend::new());
5202 seed_shared_acl(storage.as_ref()).await;
5203 let state = AppState::new(storage);
5204 let result = enforce_write(
5208 &state,
5209 "/shared/.acl",
5210 AccessMode::Write,
5211 Some("did:nostr:writer"),
5212 )
5213 .await;
5214 assert!(
5215 result.is_err(),
5216 "writer lacks Control — must not be able to PUT /shared/.acl"
5217 );
5218 }
5219
5220 #[actix_web::test]
5222 async fn acl_put_allowed_for_control_holder() {
5223 let storage = Arc::new(MemoryBackend::new());
5224 seed_shared_acl(storage.as_ref()).await;
5225 let state = AppState::new(storage);
5226 let result =
5227 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
5228 assert!(
5229 result.is_ok(),
5230 "the owner holds Control — must be allowed to PUT /shared/.acl"
5231 );
5232 }
5233
5234 #[actix_web::test]
5236 async fn meta_put_denied_for_writer_without_control() {
5237 let storage = Arc::new(MemoryBackend::new());
5238 seed_shared_acl(storage.as_ref()).await;
5239 let state = AppState::new(storage);
5240 let result = enforce_write(
5241 &state,
5242 "/shared/doc.meta",
5243 AccessMode::Write,
5244 Some("did:nostr:writer"),
5245 )
5246 .await;
5247 assert!(
5248 result.is_err(),
5249 "writer lacks Control — must not be able to PUT a .meta sidecar"
5250 );
5251 }
5252
5253 #[test]
5255 fn protected_resource_for_acl_strips_suffixes() {
5256 assert_eq!(
5257 protected_resource_for_acl("/victim/.acl").as_deref(),
5258 Some("/victim/")
5259 );
5260 assert_eq!(
5261 protected_resource_for_acl("/a/b.acl").as_deref(),
5262 Some("/a/b")
5263 );
5264 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
5265 assert_eq!(
5266 protected_resource_for_acl("/a/b.meta").as_deref(),
5267 Some("/a/b")
5268 );
5269 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
5270 }
5271}