1#![doc = include_str!("../README.md")]
91#![deny(unsafe_code)]
92#![warn(rust_2018_idioms)]
93
94pub mod cli;
96
97mod handlers;
102
103mod mcp;
106
107pub mod mempool;
112
113pub mod trail_store;
119
120use std::collections::HashMap;
121use std::net::{IpAddr, Ipv4Addr};
122use std::path::{Path, PathBuf};
123use std::sync::{Arc, Mutex};
124use std::time::{Duration, Instant};
125
126use actix_web::body::{BoxBody, EitherBody};
127use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
128use actix_web::http::{header, StatusCode};
129use actix_web::middleware::{NormalizePath, TrailingSlash};
130use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
131use bytes::Bytes;
132use futures_util::future::{ready, LocalBoxFuture, Ready};
133use percent_encoding::percent_decode_str;
134use serde::Deserialize;
135use solid_pod_rs::{
136 auth::{nip98, replay::ReplayStore},
140 config::sources::parse_size,
141 interop,
142 ldp::{self, LdpContainerOps, PatchCreateOutcome},
143 mashlib::{self, MashlibConfig},
144 provision,
145 security::DotfileAllowlist,
146 storage::Storage,
147 wac::{
148 self, conditions::RequestContext, effective_acl_target, parse_jsonld_acl,
149 parser::parse_turtle_acl, protected_resource_for_acl, AccessMode,
150 },
151 PodError,
152};
153
154const _: () = solid_pod_rs::auth::nip98::assert_schnorr_verification_enabled();
166
167static NIP98_REPLAY: std::sync::LazyLock<solid_pod_rs::auth::replay::Nip98ReplayCache> =
174 std::sync::LazyLock::new(solid_pod_rs::auth::replay::Nip98ReplayCache::from_env);
175
176#[derive(Clone)]
182pub struct AppState {
183 pub storage: Arc<dyn Storage>,
184 pub dotfiles: Arc<DotfileAllowlist>,
185 pub body_cap: usize,
186 pub nodeinfo: NodeInfoMeta,
187 pub mashlib: MashlibConfig,
188 pub mashlib_cdn: Option<String>,
191 pub pay_config: solid_pod_rs::payments::PayConfig,
194 pub data_root: Option<PathBuf>,
199 pub pod_create_limiter: Arc<PodCreateLimiter>,
201 pub allowed_origins: Vec<String>,
208 pub admin_key: Option<String>,
213 pub mcp_enabled: bool,
218 pub mempool_url: Option<String>,
224}
225
226#[derive(Clone, Debug)]
228pub struct NodeInfoMeta {
229 pub software_name: String,
230 pub software_version: String,
231 pub open_registrations: bool,
232 pub total_users: u64,
233 pub base_url: String,
234}
235
236impl Default for NodeInfoMeta {
237 fn default() -> Self {
238 Self {
239 software_name: "solid-pod-rs-server".to_string(),
240 software_version: env!("CARGO_PKG_VERSION").to_string(),
241 open_registrations: false,
242 total_users: 0,
243 base_url: "http://localhost".to_string(),
244 }
245 }
246}
247
248pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
251
252pub fn body_cap_from_env() -> usize {
255 match std::env::var("JSS_MAX_REQUEST_BODY") {
256 Ok(v) => parse_size(&v)
257 .map(|u| u as usize)
258 .unwrap_or(DEFAULT_BODY_CAP),
259 Err(_) => DEFAULT_BODY_CAP,
260 }
261}
262
263impl AppState {
264 pub fn new(storage: Arc<dyn Storage>) -> Self {
267 Self {
268 storage,
269 dotfiles: Arc::new(DotfileAllowlist::from_env()),
270 body_cap: body_cap_from_env(),
271 nodeinfo: NodeInfoMeta::default(),
272 mashlib: MashlibConfig::default(),
273 mashlib_cdn: None,
274 pay_config: solid_pod_rs::payments::PayConfig::default(),
275 data_root: None,
276 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
277 allowed_origins: Vec::new(),
278 admin_key: None,
279 mcp_enabled: false,
280 mempool_url: None,
281 }
282 }
283}
284
285#[derive(Debug)]
287pub struct PodCreateLimiter {
288 hits: Mutex<HashMap<IpAddr, Instant>>,
289 window: Duration,
290}
291
292impl Default for PodCreateLimiter {
293 fn default() -> Self {
294 Self {
295 hits: Mutex::new(HashMap::new()),
296 window: Duration::from_secs(24 * 60 * 60),
297 }
298 }
299}
300
301impl PodCreateLimiter {
302 fn check(&self, ip: IpAddr) -> Result<(), u64> {
303 let now = Instant::now();
304 let mut hits = self.hits.lock().unwrap();
305 if let Some(last) = hits.get(&ip).copied() {
306 let elapsed = now.saturating_duration_since(last);
307 if elapsed < self.window {
308 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
309 }
310 }
311 hits.insert(ip, now);
312 Ok(())
313 }
314}
315
316pub(crate) fn to_actix(e: PodError) -> ActixError {
321 match e {
322 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
323 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
324 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
325 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
326 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
327 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
328 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
329 }
330}
331
332pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
344 let header_val = req
345 .headers()
346 .get(header::AUTHORIZATION)
347 .and_then(|v| v.to_str().ok())?;
348 let conn = req.connection_info();
356 let url = format!("{}://{}{}", conn.scheme(), conn.host(), req.uri().path());
357 let now = std::time::SystemTime::now()
358 .duration_since(std::time::UNIX_EPOCH)
359 .map(|d| d.as_secs())
360 .unwrap_or(0);
361 let verified = nip98::verify_at(header_val, &url, req.method().as_str(), None, now).ok()?;
362
363 if NIP98_REPLAY.check_and_record(&verified.event_id).await.is_err() {
367 tracing::warn!(
368 pubkey = %verified.pubkey,
369 method = %req.method(),
370 "NIP-98 replay rejected: token id already used within window"
371 );
372 return None;
373 }
374
375 Some(verified.pubkey)
376}
377
378pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
379 pubkey.map(|pk| format!("did:nostr:{pk}"))
380}
381
382fn req_origin(req: &HttpRequest) -> Option<&str> {
391 req.headers()
392 .get(header::ORIGIN)
393 .and_then(|v| v.to_str().ok())
394}
395
396pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
400
401async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
418 let did = agent_uri?;
419 let balance = match storage.get(WEBLEDGER_PATH).await {
420 Ok((bytes, _meta)) => {
421 match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
422 Ok(ledger) => ledger.get_balance(did),
423 Err(_) => 0,
427 }
428 }
429 Err(_) => 0,
432 };
433 Some(balance)
434}
435
436fn accept_includes_html(accept: &str) -> bool {
444 accept.split(',').any(|entry| {
445 let mime = entry.split(';').next().unwrap_or("").trim();
446 mime.eq_ignore_ascii_case("text/html")
447 })
448}
449
450fn proposed_acl_keeps_caller_control(body: &[u8], content_type: &str, caller: Option<&str>) -> bool {
469 let doc = match parse_jsonld_acl(body) {
470 Ok(d) => Some(d),
471 Err(_) => {
472 let ct = content_type.to_ascii_lowercase();
473 let text = std::str::from_utf8(body).unwrap_or("");
474 let looks_turtle = ct.starts_with("text/turtle")
475 || ct.starts_with("application/turtle")
476 || ct.starts_with("application/x-turtle")
477 || ct.starts_with("application/n-triples")
478 || text.contains("@prefix")
479 || text.contains("acl:Authorization")
480 || text.contains("auth/acl#Authorization");
484 if looks_turtle {
485 parse_turtle_acl(text).ok()
486 } else {
487 None
488 }
489 }
490 };
491 let Some(doc) = doc else {
492 return true;
494 };
495 let Some(graph) = doc.graph.as_ref() else {
496 return false;
497 };
498 graph.iter().any(|auth| {
499 let grants_control = ids_of_acl_field(&auth.mode)
500 .iter()
501 .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
502 if !grants_control {
503 return false;
504 }
505 let agents = ids_of_acl_field(&auth.agent);
506 if let Some(web_id) = caller {
507 if agents.iter().any(|a| *a == web_id) {
508 return true;
509 }
510 }
511 let classes = ids_of_acl_field(&auth.agent_class);
512 if classes
513 .iter()
514 .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
515 {
516 return true;
517 }
518 if caller.is_some()
519 && classes.iter().any(|c| {
520 *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
521 || *c == "acl:AuthenticatedAgent"
522 })
523 {
524 return true;
525 }
526 false
527 })
528}
529
530fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
532 match field {
533 None => Vec::new(),
534 Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
535 Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
536 }
537}
538
539#[cfg_attr(not(test), allow(dead_code))]
546async fn enforce_write(
547 state: &AppState,
548 path: &str,
549 mode: AccessMode,
550 agent_uri: Option<&str>,
551) -> Result<(), ActixError> {
552 enforce_write_ctx(state, path, mode, agent_uri, None).await
553}
554
555async fn enforce_write_ctx(
563 state: &AppState,
564 path: &str,
565 mode: AccessMode,
566 agent_uri: Option<&str>,
567 request_origin: Option<&str>,
568) -> Result<(), ActixError> {
569 let origin = request_origin.and_then(wac::Origin::parse);
570 let (resource, eff_mode) = effective_acl_target(path, mode);
582
583 let acl_doc = match find_effective_acl_dyn(&*state.storage, &resource).await {
588 Ok(doc) => doc,
589 Err(e) => return Err(to_actix(e)),
590 };
591
592 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
597
598 let ctx = RequestContext {
599 web_id: agent_uri,
600 client_id: None,
601 issuer: None,
602 payment_balance_sats,
603 };
604 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
605 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
606 let granted = wac::evaluate_access_ctx_with_registry(
607 acl_doc.as_ref(),
608 &ctx,
609 &resource,
610 eff_mode,
611 origin.as_ref(),
612 &groups,
613 ®istry,
614 );
615 if !granted {
616 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
617 }
618 if resource.as_str() == path {
625 charge_granted_payment(
631 state,
632 acl_doc.as_ref(),
633 &ctx,
634 &resource,
635 eff_mode,
636 &groups,
637 ®istry,
638 )
639 .await?;
640 }
641 Ok(())
642}
643
644async fn charge_granted_payment(
653 state: &AppState,
654 acl_doc: Option<&wac::AclDocument>,
655 ctx: &RequestContext<'_>,
656 path: &str,
657 mode: AccessMode,
658 groups: &wac::StaticGroupMembership,
659 registry: &wac::conditions::ConditionRegistry,
660) -> Result<(), ActixError> {
661 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
662 if cost == 0 {
663 return Ok(());
664 }
665 if let Some(did) = ctx.web_id {
666 if debit_ledger(&*state.storage, did, cost).await.is_err() {
667 return Err(acl_denial(acl_doc, ctx.web_id, path));
668 }
669 }
670 Ok(())
671}
672
673fn acl_denial(
679 acl_doc: Option<&wac::AclDocument>,
680 agent_uri: Option<&str>,
681 path: &str,
682) -> ActixError {
683 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
684 let (status, body, unauthenticated) = if agent_uri.is_none() {
685 (StatusCode::UNAUTHORIZED, "authentication required", true)
686 } else {
687 (StatusCode::FORBIDDEN, "access forbidden", false)
688 };
689 let mut rsp = HttpResponse::new(status);
690 rsp.headers_mut().insert(
691 header::HeaderName::from_static("wac-allow"),
692 header::HeaderValue::from_str(&allow_header)
693 .unwrap_or(header::HeaderValue::from_static("")),
694 );
695 if unauthenticated {
696 rsp.headers_mut().insert(
703 header::WWW_AUTHENTICATE,
704 header::HeaderValue::from_static(
705 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
706 ),
707 );
708 }
709 actix_web::error::InternalError::from_response(body, rsp).into()
710}
711
712#[cfg_attr(not(test), allow(dead_code))]
723async fn enforce_read(
724 state: &AppState,
725 path: &str,
726 agent_uri: Option<&str>,
727) -> Result<(), ActixError> {
728 enforce_read_ctx(state, path, agent_uri, None).await
729}
730
731async fn enforce_read_ctx(
734 state: &AppState,
735 path: &str,
736 agent_uri: Option<&str>,
737 request_origin: Option<&str>,
738) -> Result<(), ActixError> {
739 let origin = request_origin.and_then(wac::Origin::parse);
740 let (resource, eff_mode) = effective_acl_target(path, AccessMode::Read);
752 let acl_doc = match find_effective_acl_dyn(&*state.storage, &resource).await {
753 Ok(doc) => doc,
754 Err(e) => return Err(to_actix(e)),
755 };
756 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
757 let ctx = RequestContext {
758 web_id: agent_uri,
759 client_id: None,
760 issuer: None,
761 payment_balance_sats,
762 };
763 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
764 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
765 let granted = wac::evaluate_access_ctx_with_registry(
766 acl_doc.as_ref(),
767 &ctx,
768 &resource,
769 eff_mode,
770 origin.as_ref(),
771 &groups,
772 ®istry,
773 );
774 if !granted {
775 return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
776 }
777 if resource.as_str() == path {
781 charge_granted_payment(
786 state,
787 acl_doc.as_ref(),
788 &ctx,
789 &resource,
790 eff_mode,
791 &groups,
792 ®istry,
793 )
794 .await?;
795 }
796 Ok(())
797}
798
799async fn debit_ledger(
808 storage: &dyn Storage,
809 did: &str,
810 cost: u64,
811) -> Result<(), solid_pod_rs::payments::PaymentError> {
812 use solid_pod_rs::payments::{PaymentError, WebLedger};
813
814 let (bytes, _meta) = storage
815 .get(WEBLEDGER_PATH)
816 .await
817 .map_err(|e| PaymentError::Store(e.to_string()))?;
818 let mut ledger: WebLedger = serde_json::from_slice(&bytes)
819 .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
820 ledger.debit(did, cost)?;
821 let body = serde_json::to_vec(&ledger)
822 .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
823 storage
824 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
825 .await
826 .map_err(|e| PaymentError::Store(e.to_string()))?;
827 Ok(())
828}
829
830fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
835 let links = ldp::link_headers(path).join(", ");
836 if let Ok(value) = header::HeaderValue::from_str(&links) {
837 rsp.headers_mut()
838 .insert(header::HeaderName::from_static("link"), value);
839 }
840}
841
842fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
843 if let Ok(v) = header::HeaderValue::from_str(header_value) {
844 rsp.headers_mut()
845 .insert(header::HeaderName::from_static("wac-allow"), v);
846 }
847}
848
849fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
850 let ws_base = base_url
851 .replacen("https://", "wss://", 1)
852 .replacen("http://", "ws://", 1);
853 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
854 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
855 rsp.headers_mut()
856 .insert(header::HeaderName::from_static("updates-via"), v);
857 }
858}
859
860async fn handle_get(
861 req: HttpRequest,
862 state: web::Data<AppState>,
863) -> Result<HttpResponse, ActixError> {
864 let path = req.uri().path().to_string();
865
866 if path.contains('*') {
867 return handle_glob_get(req, state).await;
868 }
869
870 let auth_pk = extract_pubkey(&req).await;
871 let agent = agent_uri(auth_pk.as_ref());
872
873 enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
878
879 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
880
881 if ldp::is_container(&path) {
882 let accept = req
883 .headers()
884 .get(header::ACCEPT)
885 .and_then(|v| v.to_str().ok())
886 .unwrap_or("");
887
888 if accept_includes_html(accept) {
894 let index_path = format!("{}index.html", &path);
895 if let Ok((body, _meta)) = state.storage.get(&index_path).await {
896 let mut rsp = HttpResponse::Ok()
897 .content_type("text/html; charset=utf-8")
898 .body(body.to_vec());
899 set_wac_allow(&mut rsp, &wac_allow);
900 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
901 set_link_headers(&mut rsp, &path);
902 return Ok(rsp);
903 }
904 }
905
906 let v = state
907 .storage
908 .container_representation(&path)
909 .await
910 .map_err(to_actix)?;
911
912 let sec_fetch_dest = req
914 .headers()
915 .get("sec-fetch-dest")
916 .and_then(|v| v.to_str().ok());
917 if mashlib::should_serve(
918 accept,
919 sec_fetch_dest,
920 "application/ld+json",
921 state.mashlib.enabled,
922 ) {
923 let json_ld = serde_json::to_string(&v).ok();
924 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
925 let mut rsp = HttpResponse::Ok()
926 .content_type("text/html; charset=utf-8")
927 .insert_header(("X-Frame-Options", "DENY"))
928 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
929 .insert_header(("Cache-Control", "no-store"))
930 .body(html);
931 set_wac_allow(&mut rsp, &wac_allow);
932 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
933 set_link_headers(&mut rsp, &path);
934 return Ok(rsp);
935 }
936
937 let mut rsp = HttpResponse::Ok().json(v);
938 rsp.headers_mut().insert(
939 header::CONTENT_TYPE,
940 header::HeaderValue::from_static("application/ld+json"),
941 );
942 set_wac_allow(&mut rsp, &wac_allow);
943 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
944 set_link_headers(&mut rsp, &path);
945 return Ok(rsp);
946 }
947
948 match state.storage.get(&path).await {
949 Ok((body, meta)) => {
950 let accept = req
952 .headers()
953 .get(header::ACCEPT)
954 .and_then(|v| v.to_str().ok())
955 .unwrap_or("");
956 let sec_fetch_dest = req
957 .headers()
958 .get("sec-fetch-dest")
959 .and_then(|v| v.to_str().ok());
960 if mashlib::should_serve(
961 accept,
962 sec_fetch_dest,
963 &meta.content_type,
964 state.mashlib.enabled,
965 ) {
966 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
967 std::str::from_utf8(&body).ok().map(|s| s.to_string())
968 } else {
969 None
970 };
971 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
972 let mut rsp = HttpResponse::Ok()
973 .content_type("text/html; charset=utf-8")
974 .insert_header(("X-Frame-Options", "DENY"))
975 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
976 .insert_header(("Cache-Control", "no-store"))
977 .body(html);
978 set_wac_allow(&mut rsp, &wac_allow);
979 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
980 set_link_headers(&mut rsp, &path);
981 return Ok(rsp);
982 }
983
984 if let Some((negotiated_body, negotiated_ct)) =
992 rdf_content_negotiate(&body, &meta.content_type, accept)
993 {
994 let mut rsp = HttpResponse::Ok().body(negotiated_body);
995 rsp.headers_mut().insert(
996 header::CONTENT_TYPE,
997 header::HeaderValue::from_str(negotiated_ct)
998 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
999 );
1000 rsp.headers_mut()
1001 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
1002 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1003 rsp.headers_mut().insert(header::ETAG, etag);
1004 }
1005 set_wac_allow(&mut rsp, &wac_allow);
1006 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1007 set_link_headers(&mut rsp, &path);
1008 return Ok(rsp);
1009 }
1010
1011 let mut rsp = HttpResponse::Ok().body(body.to_vec());
1012 rsp.headers_mut().insert(
1013 header::CONTENT_TYPE,
1014 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
1015 header::HeaderValue::from_static("application/octet-stream")
1016 }),
1017 );
1018 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1019 rsp.headers_mut().insert(header::ETAG, etag);
1020 }
1021 set_wac_allow(&mut rsp, &wac_allow);
1022 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1023 set_link_headers(&mut rsp, &path);
1024 Ok(rsp)
1025 }
1026 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1027 Err(e) => Err(to_actix(e)),
1028 }
1029}
1030
1031fn has_basic_container_link(req: &HttpRequest) -> bool {
1032 req.headers()
1033 .get_all(header::LINK)
1034 .filter_map(|v| v.to_str().ok())
1035 .any(|v| {
1036 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
1037 })
1038}
1039
1040async fn handle_put(
1041 req: HttpRequest,
1042 body: web::Bytes,
1043 state: web::Data<AppState>,
1044) -> Result<HttpResponse, ActixError> {
1045 let path = req.uri().path().to_string();
1046
1047 if ldp::is_container(&path) {
1048 if has_basic_container_link(&req) {
1049 let auth_pk = extract_pubkey(&req).await;
1050 let agent = agent_uri(auth_pk.as_ref());
1051 enforce_write_ctx(&state, &path, AccessMode::Write, agent.as_deref(), req_origin(&req)).await?;
1052 let meta = state
1053 .storage
1054 .create_container(&path)
1055 .await
1056 .map_err(to_actix)?;
1057 let mut rsp = HttpResponse::Created().finish();
1058 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1059 rsp.headers_mut().insert(header::ETAG, etag);
1060 }
1061 set_link_headers(&mut rsp, &path);
1062 return Ok(rsp);
1063 }
1064 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
1065 }
1066
1067 let auth_pk = extract_pubkey(&req).await;
1068 let agent = agent_uri(auth_pk.as_ref());
1069 enforce_write_ctx(&state, &path, AccessMode::Write, agent.as_deref(), req_origin(&req)).await?;
1070
1071 let ct = req
1072 .headers()
1073 .get(header::CONTENT_TYPE)
1074 .and_then(|v| v.to_str().ok())
1075 .unwrap_or("application/octet-stream");
1076
1077 if protected_resource_for_acl(&path).is_some()
1082 && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
1083 {
1084 return Ok(HttpResponse::Conflict().body(
1085 "refused: the proposed ACL would not grant Control to the caller \
1086 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1087 ));
1088 }
1089
1090 let meta = state
1091 .storage
1092 .put(&path, Bytes::from(body.to_vec()), ct)
1093 .await
1094 .map_err(to_actix)?;
1095 git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
1099 let mut rsp = HttpResponse::Created().finish();
1100 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1101 rsp.headers_mut().insert(header::ETAG, etag);
1102 }
1103 set_link_headers(&mut rsp, &path);
1104 Ok(rsp)
1105}
1106
1107async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
1114 if !storage.exists(target).await.unwrap_or(false) {
1115 return target.to_string();
1116 }
1117 let seg_start = target.rfind('/').map(|s| s + 1).unwrap_or(0);
1120 let (stem, ext) = match target.rfind('.') {
1121 Some(dot) if dot > seg_start => (&target[..dot], &target[dot..]),
1122 _ => (target, ""),
1123 };
1124 for n in 1..10_000u32 {
1125 let candidate = format!("{stem}-{n}{ext}");
1126 if !storage.exists(&candidate).await.unwrap_or(false) {
1127 return candidate;
1128 }
1129 }
1130 use std::hash::{Hash, Hasher};
1131 let mut h = std::collections::hash_map::DefaultHasher::new();
1132 target.hash(&mut h);
1133 format!("{stem}-{:x}{ext}", h.finish())
1134}
1135
1136async fn handle_post(
1137 req: HttpRequest,
1138 body: web::Bytes,
1139 state: web::Data<AppState>,
1140) -> Result<HttpResponse, ActixError> {
1141 let path = req.uri().path().to_string();
1142 let auth_pk = extract_pubkey(&req).await;
1145 let agent = agent_uri(auth_pk.as_ref());
1146 enforce_write_ctx(&state, &path, AccessMode::Append, agent.as_deref(), req_origin(&req)).await?;
1147
1148 let slug = req
1149 .headers()
1150 .get(header::HeaderName::from_static("slug"))
1151 .and_then(|v| v.to_str().ok());
1152 let mut target = match ldp::resolve_slug(&path, slug) {
1153 Ok(p) => p,
1154 Err(e) => return Err(to_actix(e)),
1155 };
1156 let ct = req
1157 .headers()
1158 .get(header::CONTENT_TYPE)
1159 .and_then(|v| v.to_str().ok())
1160 .unwrap_or("application/octet-stream");
1161
1162 if protected_resource_for_acl(&target).is_some() {
1171 enforce_write_ctx(
1172 &state,
1173 &target,
1174 AccessMode::Write,
1175 agent.as_deref(),
1176 req_origin(&req),
1177 )
1178 .await?;
1179 if !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref()) {
1180 return Ok(HttpResponse::Conflict().body(
1181 "refused: the proposed ACL would not grant Control to the caller \
1182 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1183 ));
1184 }
1185 } else {
1186 target = mint_unique_target(&*state.storage, &target).await;
1192 }
1193
1194 let meta = state
1195 .storage
1196 .put(&target, Bytes::from(body.to_vec()), ct)
1197 .await
1198 .map_err(to_actix)?;
1199 git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1202 let mut rsp = HttpResponse::Created().finish();
1203 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1204 rsp.headers_mut().insert(header::LOCATION, loc);
1205 }
1206 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1207 rsp.headers_mut().insert(header::ETAG, etag);
1208 }
1209 set_link_headers(&mut rsp, &target);
1210 Ok(rsp)
1211}
1212
1213async fn handle_patch(
1214 req: HttpRequest,
1215 body: web::Bytes,
1216 state: web::Data<AppState>,
1217) -> Result<HttpResponse, ActixError> {
1218 let path = req.uri().path().to_string();
1219 if ldp::is_container(&path) {
1220 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1221 }
1222 let auth_pk = extract_pubkey(&req).await;
1223 let agent = agent_uri(auth_pk.as_ref());
1224 enforce_write_ctx(&state, &path, AccessMode::Write, agent.as_deref(), req_origin(&req)).await?;
1230
1231 let ct = req
1232 .headers()
1233 .get(header::CONTENT_TYPE)
1234 .and_then(|v| v.to_str().ok())
1235 .unwrap_or("");
1236 let dialect = match ldp::patch_dialect_from_mime(ct) {
1237 Some(d) => d,
1238 None => {
1239 return Ok(HttpResponse::UnsupportedMediaType()
1240 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1241 }
1242 };
1243 let body_str = match std::str::from_utf8(&body) {
1244 Ok(s) => s.to_string(),
1245 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1246 };
1247
1248 let existing = state.storage.get(&path).await;
1250 match existing {
1251 Ok((current_body, meta)) => {
1252 let out = match dialect {
1262 ldp::PatchDialect::N3 => {
1263 let seed = seed_graph_from_patch_target(¤t_body)?;
1264 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1265 }
1266 ldp::PatchDialect::SparqlUpdate => {
1267 let seed = seed_graph_from_patch_target(¤t_body)?;
1268 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1269 }
1270 ldp::PatchDialect::JsonPatch => {
1271 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1272 Ok(v) => v,
1273 Err(_) => serde_json::json!({}),
1274 };
1275 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1276 Ok(v) => v,
1277 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1278 };
1279 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1280 let bytes = serde_json::to_vec(&json)
1281 .map_err(PodError::from)
1282 .map_err(to_actix)?;
1283 let _ = state
1284 .storage
1285 .put(&path, Bytes::from(bytes), &meta.content_type)
1286 .await
1287 .map_err(to_actix)?;
1288 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1289 return Ok(HttpResponse::NoContent().finish());
1290 }
1291 };
1292 let outcome = out?;
1293 let serialised = graph_to_turtle(&outcome.graph);
1296 if protected_resource_for_acl(&path).is_some()
1302 && !proposed_acl_keeps_caller_control(
1303 serialised.as_bytes(),
1304 "application/n-triples",
1305 agent.as_deref(),
1306 )
1307 {
1308 return Ok(HttpResponse::Conflict().body(
1309 "refused: the patched ACL would not grant Control to the caller \
1310 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1311 ));
1312 }
1313 let _ = state
1314 .storage
1315 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1316 .await
1317 .map_err(to_actix)?;
1318 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1319 Ok(HttpResponse::NoContent().finish())
1320 }
1321 Err(PodError::NotFound(_)) => {
1322 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
1324 let PatchCreateOutcome::Created { graph, .. } = create else {
1325 return Err(to_actix(PodError::Unsupported(
1326 "unexpected patch outcome on absent resource".into(),
1327 )));
1328 };
1329 let serialised = graph_to_turtle(&graph);
1330 if protected_resource_for_acl(&path).is_some()
1332 && !proposed_acl_keeps_caller_control(
1333 serialised.as_bytes(),
1334 "application/n-triples",
1335 agent.as_deref(),
1336 )
1337 {
1338 return Ok(HttpResponse::Conflict().body(
1339 "refused: the patched ACL would not grant Control to the caller \
1340 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1341 ));
1342 }
1343 let _ = state
1344 .storage
1345 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1346 .await
1347 .map_err(to_actix)?;
1348 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1349 Ok(HttpResponse::Created().finish())
1350 }
1351 Err(e) => Err(to_actix(e)),
1352 }
1353}
1354
1355fn patch_parse_err(e: PodError) -> ActixError {
1359 match e {
1360 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
1361 actix_web::error::ErrorBadRequest(msg)
1362 }
1363 other => to_actix(other),
1364 }
1365}
1366
1367fn graph_to_turtle(g: &ldp::Graph) -> String {
1371 g.to_ntriples()
1372}
1373
1374fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
1381 let mut best: Option<(f32, ldp::RdfFormat)> = None;
1382 for entry in accept.split(',') {
1383 let entry = entry.trim();
1384 if entry.is_empty() {
1385 continue;
1386 }
1387 let mut parts = entry.split(';').map(|s| s.trim());
1388 let mime = match parts.next() {
1389 Some(m) => m,
1390 None => continue,
1391 };
1392 let mut q: f32 = 1.0;
1393 for token in parts {
1394 if let Some(v) = token.strip_prefix("q=") {
1395 if let Ok(parsed) = v.parse::<f32>() {
1396 q = parsed;
1397 }
1398 }
1399 }
1400 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
1403 match best {
1404 None => best = Some((q, format)),
1405 Some((bq, _)) if q > bq => best = Some((q, format)),
1406 _ => {}
1407 }
1408 }
1409 }
1410 best.map(|(_, f)| f)
1411}
1412
1413fn rdf_content_negotiate(
1429 body: &[u8],
1430 stored_ct: &str,
1431 accept: &str,
1432) -> Option<(Vec<u8>, &'static str)> {
1433 if accept.trim().is_empty() {
1434 return None;
1435 }
1436 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
1437 let target = best_explicit_rdf_format(accept)?;
1438 if target == stored_format {
1439 return None;
1440 }
1441 let text = std::str::from_utf8(body).ok()?;
1442 let graph = ldp::Graph::parse_ntriples(text).ok()?;
1443 match target {
1444 ldp::RdfFormat::Turtle => {
1447 Some((graph.to_ntriples().into_bytes(), ldp::RdfFormat::Turtle.mime()))
1448 }
1449 ldp::RdfFormat::NTriples => Some((
1450 graph.to_ntriples().into_bytes(),
1451 ldp::RdfFormat::NTriples.mime(),
1452 )),
1453 ldp::RdfFormat::JsonLd => {
1454 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
1455 Some((json, ldp::RdfFormat::JsonLd.mime()))
1456 }
1457 ldp::RdfFormat::RdfXml => None,
1459 }
1460}
1461
1462fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
1471 let text = std::str::from_utf8(current_body).map_err(|_| {
1472 actix_web::error::ErrorConflict(
1473 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
1474 )
1475 })?;
1476 if text.trim().is_empty() {
1477 return Ok(ldp::Graph::new());
1478 }
1479 ldp::Graph::parse_ntriples(text).map_err(|_| {
1480 actix_web::error::ErrorConflict(
1481 "existing resource is not N-Triples RDF and cannot be non-destructively \
1482 patched; PUT an N-Triples representation or use a JSON Patch",
1483 )
1484 })
1485}
1486
1487pub(crate) async fn find_effective_acl_dyn(
1493 storage: &dyn Storage,
1494 resource_path: &str,
1495) -> Result<Option<wac::AclDocument>, PodError> {
1496 let mut path = resource_path.to_string();
1497 let mut inherited = false;
1502 loop {
1503 let acl_key = if path == "/" {
1504 "/.acl".to_string()
1505 } else {
1506 format!("{}.acl", path.trim_end_matches('/'))
1507 };
1508 if let Ok((body, meta)) = storage.get(&acl_key).await {
1509 match parse_jsonld_acl(&body) {
1510 Ok(mut doc) => {
1511 doc.inherited = inherited;
1512 return Ok(Some(doc));
1513 }
1514 Err(PodError::BadRequest(_)) => {
1515 return Err(PodError::BadRequest("ACL document exceeds bounds".into()))
1516 }
1517 Err(_) => {}
1518 }
1519 let ct = meta.content_type.to_ascii_lowercase();
1520 let looks_turtle = ct.starts_with("text/turtle")
1521 || ct.starts_with("application/turtle")
1522 || ct.starts_with("application/x-turtle");
1523 let text = std::str::from_utf8(&body).unwrap_or("");
1524 if looks_turtle || text.contains("@prefix") || text.contains("acl:Authorization") {
1525 if let Ok(mut doc) = parse_turtle_acl(text) {
1526 doc.inherited = inherited;
1527 return Ok(Some(doc));
1528 }
1529 }
1530 }
1531 if path == "/" || path.is_empty() {
1532 break;
1533 }
1534 inherited = true;
1536 let trimmed = path.trim_end_matches('/');
1537 path = match trimmed.rfind('/') {
1538 Some(0) => "/".to_string(),
1539 Some(pos) => trimmed[..pos].to_string(),
1540 None => "/".to_string(),
1541 };
1542 }
1543 Ok(None)
1544}
1545
1546async fn handle_delete(
1547 req: HttpRequest,
1548 state: web::Data<AppState>,
1549) -> Result<HttpResponse, ActixError> {
1550 let path = req.uri().path().to_string();
1551 let auth_pk = extract_pubkey(&req).await;
1552 let agent = agent_uri(auth_pk.as_ref());
1553 enforce_write_ctx(&state, &path, AccessMode::Write, agent.as_deref(), req_origin(&req)).await?;
1554
1555 match state.storage.delete(&path).await {
1556 Ok(()) => Ok(HttpResponse::NoContent().finish()),
1557 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1558 Err(e) => Err(to_actix(e)),
1559 }
1560}
1561
1562async fn handle_options(
1563 req: HttpRequest,
1564 state: web::Data<AppState>,
1565) -> Result<HttpResponse, ActixError> {
1566 let path = req.uri().path().to_string();
1567 let o = ldp::options_for(&path);
1568 let mut rsp = HttpResponse::NoContent().finish();
1569 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
1570 rsp.headers_mut()
1571 .insert(header::HeaderName::from_static("allow"), v);
1572 }
1573 if let Some(ap) = o.accept_post {
1574 if let Ok(v) = header::HeaderValue::from_str(ap) {
1575 rsp.headers_mut()
1576 .insert(header::HeaderName::from_static("accept-post"), v);
1577 }
1578 }
1579 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
1580 rsp.headers_mut()
1581 .insert(header::HeaderName::from_static("accept-patch"), v);
1582 }
1583 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
1584 rsp.headers_mut()
1585 .insert(header::HeaderName::from_static("accept-ranges"), v);
1586 }
1587 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1588 Ok(rsp)
1589}
1590
1591async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
1596 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
1597 HttpResponse::Ok()
1598 .content_type("application/ld+json")
1599 .json(doc)
1600}
1601
1602#[derive(Debug, Deserialize)]
1603struct WebFingerQuery {
1604 resource: Option<String>,
1605}
1606
1607async fn handle_well_known_webfinger(
1608 state: web::Data<AppState>,
1609 q: web::Query<WebFingerQuery>,
1610) -> HttpResponse {
1611 let resource = q.resource.clone().unwrap_or_else(|| {
1612 format!(
1613 "acct:anonymous@{}",
1614 state
1615 .nodeinfo
1616 .base_url
1617 .trim_start_matches("http://")
1618 .trim_start_matches("https://")
1619 )
1620 });
1621 let webid = format!(
1622 "{}/profile/card#me",
1623 state.nodeinfo.base_url.trim_end_matches('/')
1624 );
1625 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
1626 Some(jrd) => HttpResponse::Ok()
1627 .content_type("application/jrd+json")
1628 .json(jrd),
1629 None => HttpResponse::NotFound().finish(),
1630 }
1631}
1632
1633async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
1634 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
1635 HttpResponse::Ok()
1636 .content_type("application/json")
1637 .json(doc)
1638}
1639
1640async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
1641 let doc = interop::nodeinfo_2_1(
1642 &state.nodeinfo.software_name,
1643 &state.nodeinfo.software_version,
1644 state.nodeinfo.open_registrations,
1645 state.nodeinfo.total_users,
1646 );
1647 HttpResponse::Ok()
1648 .content_type("application/json")
1649 .json(doc)
1650}
1651
1652#[cfg(feature = "did-nostr")]
1653async fn handle_well_known_did_nostr(
1654 state: web::Data<AppState>,
1655 path: web::Path<String>,
1656) -> HttpResponse {
1657 let pubkey = path.into_inner();
1658 let pubkey_is_valid = pubkey.len() == 64
1663 && pubkey
1664 .bytes()
1665 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
1666 if !pubkey_is_valid {
1667 return HttpResponse::BadRequest()
1668 .insert_header(("Cache-Control", "no-store"))
1669 .json(serde_json::json!({
1670 "error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
1671 }));
1672 }
1673 let owner_pubkey = match state.storage.get("/profile/card").await {
1681 Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
1682 .ok()
1683 .flatten(),
1684 Err(_) => None,
1685 };
1686 let owner_claims_key = owner_pubkey
1687 .as_deref()
1688 .is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
1689 if !owner_claims_key {
1690 return HttpResponse::NotFound()
1691 .insert_header(("Cache-Control", "no-store"))
1692 .json(serde_json::json!({
1693 "error": "no account on this pod claims this did:nostr pubkey"
1694 }));
1695 }
1696 let also = vec![format!(
1697 "{}/profile/card#me",
1698 state.nodeinfo.base_url.trim_end_matches('/')
1699 )];
1700 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
1701 let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
1702 use std::hash::{Hash, Hasher};
1708 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1709 body.hash(&mut hasher);
1710 let etag = format!("\"{:016x}\"", hasher.finish());
1711 HttpResponse::Ok()
1712 .content_type("application/did+json")
1713 .insert_header(("Cache-Control", "max-age=3600"))
1714 .insert_header(("ETag", etag))
1715 .body(body)
1716}
1717
1718#[cfg(feature = "nip05-endpoint")]
1726#[derive(Debug, Deserialize)]
1727struct Nip05Query {
1728 name: Option<String>,
1731}
1732
1733#[cfg(feature = "nip05-endpoint")]
1734fn nip05_name_is_valid(name: &str) -> bool {
1735 if name.is_empty() {
1738 return false;
1739 }
1740 name.bytes()
1741 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
1742}
1743
1744#[cfg(feature = "nip05-endpoint")]
1745async fn handle_well_known_nip05(
1746 state: web::Data<AppState>,
1747 query: web::Query<Nip05Query>,
1748) -> HttpResponse {
1749 use solid_pod_rs::webid::extract_nostr_pubkey;
1750
1751 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
1753 if !nip05_name_is_valid(&name) {
1754 return HttpResponse::BadRequest().json(serde_json::json!({
1755 "error": "invalid NIP-05 local part",
1756 }));
1757 }
1758
1759 let profile_path = if name == "_" {
1765 "/profile/card".to_string()
1766 } else {
1767 format!("/{name}/profile/card")
1768 };
1769
1770 let (body, _meta) = match state.storage.get(&profile_path).await {
1771 Ok(v) => v,
1772 Err(_) => {
1773 return nip05_empty_response();
1777 }
1778 };
1779
1780 let pubkey_hex = match extract_nostr_pubkey(&body) {
1781 Ok(Some(p)) => p,
1782 _ => return nip05_empty_response(),
1783 };
1784
1785 let doc = interop::nip05_document([(name, pubkey_hex)]);
1786 HttpResponse::Ok()
1787 .insert_header(("Access-Control-Allow-Origin", "*"))
1788 .content_type("application/json")
1789 .json(doc)
1790}
1791
1792#[cfg(feature = "nip05-endpoint")]
1793fn nip05_empty_response() -> HttpResponse {
1794 HttpResponse::Ok()
1795 .insert_header(("Access-Control-Allow-Origin", "*"))
1796 .content_type("application/json")
1797 .json(serde_json::json!({ "names": {} }))
1798}
1799
1800#[cfg(feature = "export-jsonld")]
1814async fn handle_export_all(
1815 req: HttpRequest,
1816 state: web::Data<AppState>,
1817) -> Result<HttpResponse, ActixError> {
1818 let auth_pk = extract_pubkey(&req).await;
1819 let agent = agent_uri(auth_pk.as_ref());
1820
1821 enforce_write_ctx(&state, "/", AccessMode::Control, agent.as_deref(), req_origin(&req)).await?;
1826
1827 let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
1831 .ok()
1832 .and_then(|q| q.get("include_private").map(|v| v == "true"))
1833 .unwrap_or(false);
1834
1835 let conn = req.connection_info();
1839 let pod_base = format!("{}://{}/", conn.scheme(), conn.host());
1840 drop(conn);
1841
1842 let options = solid_pod_rs::ExportOptions { include_private };
1843 let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
1844 .await
1845 .map_err(to_actix)?;
1846
1847 let body = serde_json::to_vec(&bundle).map_err(|e| {
1848 actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
1849 })?;
1850 Ok(HttpResponse::Ok()
1851 .content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
1852 .body(body))
1853}
1854
1855#[derive(Debug, Deserialize)]
1860struct CreateAccountRequest {
1861 username: String,
1862 #[serde(default)]
1863 name: Option<String>,
1864}
1865
1866#[derive(Debug, Deserialize)]
1867struct CreatePodRequest {
1868 name: String,
1869}
1870
1871async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
1872 let pod_name = path.into_inner();
1873 let pod_root = format!("/{pod_name}/");
1874 match state.storage.exists(&pod_root).await {
1875 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
1876 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
1877 }
1878}
1879
1880fn valid_pod_name(name: &str) -> bool {
1881 !name.is_empty()
1882 && name
1883 .chars()
1884 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
1885}
1886
1887fn request_ip(req: &HttpRequest) -> IpAddr {
1888 req.peer_addr()
1889 .map(|addr| addr.ip())
1890 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
1891}
1892
1893async fn handle_create_account(
1894 state: web::Data<AppState>,
1895 body: web::Json<CreateAccountRequest>,
1896) -> Result<HttpResponse, ActixError> {
1897 let pod_root = format!("/{}/", body.username);
1898 if state.storage.exists(&pod_root).await.unwrap_or(false) {
1899 return Ok(
1900 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
1901 );
1902 }
1903
1904 let mut plan = provision::ProvisionPlan::new(
1905 body.username.clone(),
1906 format!(
1907 "{}/{}",
1908 state.nodeinfo.base_url.trim_end_matches('/'),
1909 body.username,
1910 ),
1911 );
1912 plan.display_name = body.name.clone();
1913 plan.containers = vec![
1914 format!("/{}/", body.username),
1915 format!("/{}/profile/", body.username),
1916 format!("/{}/inbox/", body.username),
1917 format!("/{}/public/", body.username),
1918 format!("/{}/private/", body.username),
1919 format!("/{}/settings/", body.username),
1920 ];
1921
1922 #[cfg(feature = "git")]
1926 let outcome = {
1927 use solid_pod_rs_git::init::GitAutoInit;
1928 let git_hook = state.data_root.as_ref().map(|root| {
1929 let fs_path = root.join(&body.username);
1930 (GitAutoInit::new(), fs_path)
1931 });
1932 match git_hook {
1933 Some((hook, ref fs_path)) => {
1934 provision::provision_pod_ext(state.storage.as_ref(), &plan, Some((&hook, fs_path)))
1935 .await
1936 }
1937 None => provision::provision_pod(state.storage.as_ref(), &plan).await,
1938 }
1939 };
1940 #[cfg(not(feature = "git"))]
1941 let outcome = provision::provision_pod(state.storage.as_ref(), &plan).await;
1942
1943 match outcome {
1944 Ok(outcome) => Ok(HttpResponse::Created().json(serde_json::json!({
1945 "webid": outcome.webid,
1946 "pod_root": outcome.pod_root,
1947 "username": body.username,
1948 }))),
1949 Err(e) => Err(to_actix(e)),
1950 }
1951}
1952
1953async fn handle_create_pod(
1954 req: HttpRequest,
1955 state: web::Data<AppState>,
1956 body: web::Json<CreatePodRequest>,
1957) -> Result<HttpResponse, ActixError> {
1958 let ip = request_ip(&req);
1959 if let Err(retry_after) = state.pod_create_limiter.check(ip) {
1960 return Ok(HttpResponse::TooManyRequests()
1961 .insert_header(("Retry-After", retry_after.to_string()))
1962 .json(serde_json::json!({
1963 "error": "Too Many Requests",
1964 "message": "Pod creation rate limit exceeded",
1965 "retryAfter": retry_after
1966 })));
1967 }
1968
1969 if !valid_pod_name(&body.name) {
1970 return Ok(HttpResponse::BadRequest().json(serde_json::json!({
1971 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
1972 })));
1973 }
1974
1975 let pod_root = format!("/{}/", body.name);
1976 if state.storage.exists(&pod_root).await.unwrap_or(false) {
1977 return Ok(
1978 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
1979 );
1980 }
1981
1982 let conn = req.connection_info();
1983 let base_uri = format!("{}://{}", conn.scheme(), conn.host());
1984 let pod_uri = format!("{}/{}/", base_uri.trim_end_matches('/'), body.name);
1985
1986 for container in [
1987 format!("/{}/", body.name),
1988 format!("/{}/profile/", body.name),
1989 format!("/{}/inbox/", body.name),
1990 format!("/{}/public/", body.name),
1991 format!("/{}/private/", body.name),
1992 format!("/{}/settings/", body.name),
1993 ] {
1994 let meta_key = format!("{}.meta", container.trim_end_matches('/'));
1995 state
1996 .storage
1997 .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
1998 .await
1999 .map_err(to_actix)?;
2000 }
2001
2002 let canonical_pods_prefix = format!("{}/pods/{}/", base_uri.trim_end_matches('/'), body.name);
2003 let webid = format!("{pod_uri}profile/card#me");
2004 let profile = solid_pod_rs::webid::generate_webid_html(&body.name, None, &base_uri)
2005 .replace(&canonical_pods_prefix, &pod_uri);
2006 state
2007 .storage
2008 .put(
2009 &format!("/{}/profile/card", body.name),
2010 Bytes::from(profile.into_bytes()),
2011 "text/html",
2012 )
2013 .await
2014 .map_err(to_actix)?;
2015
2016 Ok(HttpResponse::Created()
2017 .insert_header(("Location", pod_uri.clone()))
2018 .json(serde_json::json!({
2019 "name": body.name,
2020 "webId": webid,
2021 "podUri": pod_uri,
2022 })))
2023}
2024
2025async fn handle_copy(
2030 req: HttpRequest,
2031 state: web::Data<AppState>,
2032) -> Result<HttpResponse, ActixError> {
2033 let dest = req.uri().path().to_string();
2034 let auth_pk = extract_pubkey(&req).await;
2035 let agent = agent_uri(auth_pk.as_ref());
2036 enforce_write_ctx(&state, &dest, AccessMode::Write, agent.as_deref(), req_origin(&req)).await?;
2037
2038 let source = req
2039 .headers()
2040 .get("source")
2041 .and_then(|v| v.to_str().ok())
2042 .map(|s| s.to_string());
2043 let source = match source {
2044 Some(s) => s,
2045 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
2046 };
2047
2048 let (body, meta) = match state.storage.get(&source).await {
2049 Ok(v) => v,
2050 Err(PodError::NotFound(_)) => {
2051 return Ok(HttpResponse::NotFound().body("source resource not found"))
2052 }
2053 Err(e) => return Err(to_actix(e)),
2054 };
2055
2056 state
2057 .storage
2058 .put(&dest, body, &meta.content_type)
2059 .await
2060 .map_err(to_actix)?;
2061
2062 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
2064 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
2065 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
2066 let _ = state
2067 .storage
2068 .put(&dst_acl, acl_body, &acl_meta.content_type)
2069 .await;
2070 }
2071
2072 let mut rsp = HttpResponse::Created().finish();
2073 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
2074 rsp.headers_mut().insert(header::LOCATION, loc);
2075 }
2076 Ok(rsp)
2077}
2078
2079async fn handle_glob_get(
2084 req: HttpRequest,
2085 state: web::Data<AppState>,
2086) -> Result<HttpResponse, ActixError> {
2087 let raw_path = req.uri().path().to_string();
2088 if !raw_path.ends_with("/*") {
2090 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
2091 }
2092 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
2094 folder.to_string()
2095 } else {
2096 format!("{folder}/")
2097 };
2098
2099 let auth_pk = extract_pubkey(&req).await;
2103 let agent = agent_uri(auth_pk.as_ref());
2104 enforce_read_ctx(&state, &folder, agent.as_deref(), req_origin(&req)).await?;
2105
2106 let children = state.storage.list(&folder).await.map_err(to_actix)?;
2107 let mut merged = String::new();
2108
2109 for child in &children {
2110 if child.ends_with('/') {
2111 continue;
2112 }
2113 let child_path = format!("{folder}{child}");
2114 if let Ok((body, meta)) = state.storage.get(&child_path).await {
2115 if meta.content_type.contains("turtle")
2116 || meta.content_type.contains("n-triples")
2117 || meta.content_type.contains("n3")
2118 {
2119 if let Ok(text) = std::str::from_utf8(&body) {
2120 merged.push_str(text);
2121 merged.push('\n');
2122 }
2123 }
2124 }
2125 }
2126
2127 if merged.is_empty() {
2128 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
2129 }
2130
2131 Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
2132}
2133
2134#[derive(Debug, Deserialize)]
2139struct LoginPasswordRequest {
2140 username: String,
2141 password: String,
2142}
2143
2144async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
2145 let _ = (&body.username, &body.password);
2146 HttpResponse::Ok().json(serde_json::json!({
2147 "message": "login endpoint active"
2148 }))
2149}
2150
2151#[derive(Debug, Deserialize)]
2152struct PasswordResetRequest {
2153 username: String,
2154}
2155
2156async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
2157 let _ = &body.username;
2158 HttpResponse::Ok().json(serde_json::json!({
2159 "message": "if an account with that username exists, a reset link has been sent"
2160 }))
2161}
2162
2163#[derive(Debug, Deserialize)]
2164struct PasswordChangeRequest {
2165 token: String,
2166 new_password: String,
2167}
2168
2169async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
2170 let _ = (&body.token, &body.new_password);
2171 HttpResponse::Ok().json(serde_json::json!({
2172 "message": "password changed"
2173 }))
2174}
2175
2176async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
2181 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
2182 HttpResponse::Ok()
2183 .content_type("application/json")
2184 .json(body)
2185}
2186
2187pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
2202
2203#[derive(Debug, Deserialize)]
2205struct ProxyQuery {
2206 url: String,
2207}
2208
2209const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
2211 "set-cookie",
2212 "set-cookie2",
2213 "authorization",
2214 "www-authenticate",
2215 "proxy-authenticate",
2216 "proxy-authorization",
2217];
2218
2219fn validate_proxy_target(target: &str) -> Result<url::Url, HttpResponse> {
2225 let parsed = match url::Url::parse(target) {
2226 Ok(u) => u,
2227 Err(_) => {
2228 return Err(
2229 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
2230 );
2231 }
2232 };
2233
2234 match parsed.scheme() {
2236 "http" | "https" => {}
2237 scheme => {
2238 return Err(HttpResponse::BadRequest()
2239 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
2240 }
2241 }
2242
2243 if let Err(_e) = solid_pod_rs::security::is_safe_url(target) {
2245 return Err(HttpResponse::Forbidden()
2246 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
2247 }
2248
2249 if let Some(host) = parsed.host_str() {
2251 let host_lower = host.to_ascii_lowercase();
2252 if host_lower == "localhost"
2254 || host_lower.ends_with(".localhost")
2255 || host_lower == "0.0.0.0"
2256 || host_lower == "[::1]"
2257 || host_lower == "[::0]"
2258 {
2259 return Err(HttpResponse::Forbidden()
2260 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
2261 }
2262 } else {
2263 return Err(
2264 HttpResponse::BadRequest().json(serde_json::json!({"error": "target URL has no host"}))
2265 );
2266 }
2267
2268 Ok(parsed)
2269}
2270
2271async fn handle_proxy(
2272 req: HttpRequest,
2273 _state: web::Data<AppState>,
2274 query: web::Query<ProxyQuery>,
2275) -> Result<HttpResponse, ActixError> {
2276 let auth_pk = extract_pubkey(&req).await;
2278 let agent = agent_uri(auth_pk.as_ref());
2279 if agent.is_none() {
2280 return Ok(HttpResponse::Unauthorized()
2281 .json(serde_json::json!({"error": "authentication required"})));
2282 }
2283
2284 let _target_url = match validate_proxy_target(&query.url) {
2286 Ok(u) => u,
2287 Err(rsp) => return Ok(rsp),
2288 };
2289
2290 let client = reqwest::Client::builder()
2292 .redirect(reqwest::redirect::Policy::none())
2295 .build()
2296 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))?;
2297
2298 let mut current_url = query.url.clone();
2299 let mut redirect_count = 0u8;
2300 const MAX_REDIRECTS: u8 = 5;
2301
2302 let byte_cap = std::env::var("PROXY_BYTE_CAP")
2303 .ok()
2304 .and_then(|v| {
2305 solid_pod_rs::config::sources::parse_size(&v)
2306 .map(|u| u as usize)
2307 .ok()
2308 })
2309 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
2310
2311 loop {
2312 if redirect_count > 0 {
2314 match validate_proxy_target(¤t_url) {
2315 Ok(_) => {}
2316 Err(rsp) => return Ok(rsp),
2317 }
2318 }
2319
2320 let mut upstream_req = client.get(¤t_url);
2321
2322 if let Some(auth_val) = req
2324 .headers()
2325 .get("x-upstream-authorization")
2326 .and_then(|v| v.to_str().ok())
2327 {
2328 upstream_req = upstream_req.header("Authorization", auth_val);
2329 }
2330
2331 let response = upstream_req
2332 .send()
2333 .await
2334 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
2335
2336 if response.status().is_redirection() {
2338 if redirect_count >= MAX_REDIRECTS {
2339 return Ok(HttpResponse::BadGateway()
2340 .json(serde_json::json!({"error": "too many redirects"})));
2341 }
2342 if let Some(location) = response.headers().get("location") {
2343 let loc_str = location
2344 .to_str()
2345 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
2346 let base = url::Url::parse(¤t_url)
2348 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
2349 let resolved = base
2350 .join(loc_str)
2351 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
2352 current_url = resolved.to_string();
2353 redirect_count += 1;
2354 continue;
2355 }
2356 return Ok(HttpResponse::BadGateway()
2357 .json(serde_json::json!({"error": "redirect without location"})));
2358 }
2359
2360 let upstream_status = response.status().as_u16();
2362 let upstream_content_type = response
2363 .headers()
2364 .get("content-type")
2365 .and_then(|v| v.to_str().ok())
2366 .unwrap_or("application/octet-stream")
2367 .to_string();
2368
2369 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
2371 for (name, value) in response.headers() {
2372 let name_lower = name.as_str().to_ascii_lowercase();
2373 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
2374 continue;
2375 }
2376 if matches!(
2378 name_lower.as_str(),
2379 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
2380 ) {
2381 continue;
2382 }
2383 if let Ok(val_str) = value.to_str() {
2384 forwarded_headers.push((name_lower, val_str.to_string()));
2385 }
2386 }
2387
2388 let body_bytes = response
2389 .bytes()
2390 .await
2391 .map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
2392
2393 if body_bytes.len() > byte_cap {
2394 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
2395 "error": "proxied response exceeds byte cap",
2396 "limit": byte_cap
2397 })));
2398 }
2399
2400 let mut rsp = HttpResponse::build(
2402 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
2403 );
2404 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
2405 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
2406
2407 for (name, value) in &forwarded_headers {
2409 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
2410 if let Ok(hval) = header::HeaderValue::from_str(value) {
2411 rsp.insert_header((hname, hval));
2412 }
2413 }
2414 }
2415
2416 return Ok(rsp.body(body_bytes.to_vec()));
2417 }
2418}
2419
2420pub struct PathTraversalGuard;
2426
2427impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
2428where
2429 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2430 B: 'static,
2431{
2432 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2433 type Error = ActixError;
2434 type InitError = ();
2435 type Transform = PathTraversalGuardMiddleware<S>;
2436 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2437
2438 fn new_transform(&self, service: S) -> Self::Future {
2439 ready(Ok(PathTraversalGuardMiddleware { service }))
2440 }
2441}
2442
2443pub struct PathTraversalGuardMiddleware<S> {
2445 service: S,
2446}
2447
2448impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
2449where
2450 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2451 B: 'static,
2452{
2453 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2454 type Error = ActixError;
2455 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2456
2457 actix_web::dev::forward_ready!(service);
2458
2459 fn call(&self, req: ServiceRequest) -> Self::Future {
2460 let raw = req.path().to_string();
2463 if path_is_traversal(&raw) {
2464 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
2465 let sr = req.into_response(rsp.map_into_boxed_body());
2466 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2467 }
2468 let fut = self.service.call(req);
2469 Box::pin(async move {
2470 let resp = fut.await?;
2471 Ok(resp.map_into_left_body())
2472 })
2473 }
2474}
2475
2476fn path_is_traversal(path: &str) -> bool {
2477 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
2479 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
2480 for seg in once.split('/').chain(twice.split('/')) {
2481 if seg == ".." || seg == "." {
2482 return true;
2483 }
2484 }
2485 if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
2488 return true;
2489 }
2490 false
2491}
2492
2493pub struct CorsHeaders {
2504 pub allowed_origins: Arc<Vec<String>>,
2505}
2506
2507impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
2508where
2509 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2510 B: 'static,
2511{
2512 type Response = ServiceResponse<B>;
2513 type Error = ActixError;
2514 type InitError = ();
2515 type Transform = CorsHeadersMiddleware<S>;
2516 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2517
2518 fn new_transform(&self, service: S) -> Self::Future {
2519 ready(Ok(CorsHeadersMiddleware {
2520 service,
2521 allowed_origins: self.allowed_origins.clone(),
2522 }))
2523 }
2524}
2525
2526pub struct CorsHeadersMiddleware<S> {
2528 service: S,
2529 allowed_origins: Arc<Vec<String>>,
2530}
2531
2532impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
2533where
2534 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2535 B: 'static,
2536{
2537 type Response = ServiceResponse<B>;
2538 type Error = ActixError;
2539 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2540
2541 actix_web::dev::forward_ready!(service);
2542
2543 fn call(&self, req: ServiceRequest) -> Self::Future {
2544 let origin = req
2545 .headers()
2546 .get(header::ORIGIN)
2547 .and_then(|v| v.to_str().ok())
2548 .map(str::to_string);
2549 let allowed = self.allowed_origins.clone();
2550 let fut = self.service.call(req);
2551 Box::pin(async move {
2552 let mut resp = fut.await?;
2553 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
2554 Ok(resp)
2555 })
2556 }
2557}
2558
2559fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
2560 let effective_origin: Option<String> = if allowed.is_empty() {
2562 Some(origin.unwrap_or("*").to_string())
2564 } else {
2565 origin
2567 .filter(|o| allowed.iter().any(|a| a == *o))
2568 .map(str::to_string)
2569 };
2570
2571 let origin_value = match effective_origin {
2574 Some(ref v) => v.as_str(),
2575 None => return,
2576 };
2577
2578 let pairs = [
2579 ("access-control-allow-origin", origin_value),
2580 (
2581 "access-control-allow-methods",
2582 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
2583 ),
2584 (
2585 "access-control-allow-headers",
2586 "Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Range, Slug, Origin",
2587 ),
2588 (
2589 "access-control-expose-headers",
2590 "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",
2591 ),
2592 ("access-control-allow-credentials", "true"),
2593 ("access-control-max-age", "86400"),
2594 ];
2595
2596 for (name, value) in pairs {
2597 if let (Ok(name), Ok(value)) = (
2598 header::HeaderName::from_lowercase(name.as_bytes()),
2599 header::HeaderValue::from_str(value),
2600 ) {
2601 headers.insert(name, value);
2602 }
2603 }
2604}
2605
2606pub struct ErrorLoggingMiddleware;
2622
2623impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
2624where
2625 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2626 B: 'static,
2627{
2628 type Response = ServiceResponse<B>;
2629 type Error = ActixError;
2630 type InitError = ();
2631 type Transform = ErrorLoggingMiddlewareService<S>;
2632 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2633
2634 fn new_transform(&self, service: S) -> Self::Future {
2635 ready(Ok(ErrorLoggingMiddlewareService { service }))
2636 }
2637}
2638
2639pub struct ErrorLoggingMiddlewareService<S> {
2641 service: S,
2642}
2643
2644impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
2645where
2646 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2647 B: 'static,
2648{
2649 type Response = ServiceResponse<B>;
2650 type Error = ActixError;
2651 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2652
2653 actix_web::dev::forward_ready!(service);
2654
2655 fn call(&self, req: ServiceRequest) -> Self::Future {
2656 let method = req.method().as_str().to_string();
2659 let path = req.path().to_string();
2660
2661 let fut = self.service.call(req);
2662 Box::pin(async move {
2663 let response = fut.await?;
2664 let status = response.status();
2665 if status.is_server_error() {
2666 log_5xx(&method, &path, status, response.response().error());
2667 }
2668 Ok(response)
2669 })
2670 }
2671}
2672
2673fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
2677 let chain = match error {
2681 Some(e) => format_error_chain(e),
2682 None => "<no error attached to response>".to_string(),
2683 };
2684
2685 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
2686 Some(std::backtrace::Backtrace::force_capture().to_string())
2687 } else {
2688 None
2689 };
2690
2691 tracing::error!(
2692 target: "solid_pod_rs_server::http",
2693 method = %method,
2694 path = %path,
2695 status = %status.as_u16(),
2696 error.chain = %chain,
2697 backtrace = backtrace.as_deref().unwrap_or(""),
2698 "5xx response"
2699 );
2700}
2701
2702fn format_error_chain(e: &actix_web::Error) -> String {
2713 let summary = format!("{}", e.as_response_error());
2714 let debug = format!("{e:?}");
2715 if debug == summary || debug.is_empty() {
2716 summary
2717 } else {
2718 format!("{summary} -> {debug}")
2719 }
2720}
2721
2722pub struct DotfileGuard {
2728 allow: Arc<DotfileAllowlist>,
2729}
2730
2731impl DotfileGuard {
2732 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
2733 Self { allow }
2734 }
2735}
2736
2737impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
2738where
2739 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2740 B: 'static,
2741{
2742 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2743 type Error = ActixError;
2744 type InitError = ();
2745 type Transform = DotfileGuardMiddleware<S>;
2746 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2747
2748 fn new_transform(&self, service: S) -> Self::Future {
2749 ready(Ok(DotfileGuardMiddleware {
2750 service,
2751 allow: self.allow.clone(),
2752 }))
2753 }
2754}
2755
2756pub struct DotfileGuardMiddleware<S> {
2758 service: S,
2759 allow: Arc<DotfileAllowlist>,
2760}
2761
2762impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
2763where
2764 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2765 B: 'static,
2766{
2767 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2768 type Error = ActixError;
2769 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2770
2771 actix_web::dev::forward_ready!(service);
2772
2773 fn call(&self, req: ServiceRequest) -> Self::Future {
2774 let path = req.path().to_string();
2775 let allow_system_route =
2782 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
2783 if !allow_system_route {
2784 let pb = PathBuf::from(&path);
2785 if !self.allow.is_allowed(Path::new(&pb)) {
2786 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
2787 let sr = req.into_response(rsp.map_into_boxed_body());
2788 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2789 }
2790 }
2791 let fut = self.service.call(req);
2792 Box::pin(async move {
2793 let resp = fut.await?;
2794 Ok(resp.map_into_left_body())
2795 })
2796 }
2797}
2798
2799#[cfg(feature = "git")]
2804pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
2805 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
2806 return None;
2807 }
2808 state.data_root.as_ref().map(|root| root.join(pubkey))
2809}
2810
2811#[cfg(feature = "git")]
2841async fn git_mark_write(state: &AppState, resource_path: &str, agent: Option<&str>, message: &str) {
2842 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
2843 use solid_pod_rs_git::mark::ShellGitMarker;
2844
2845 if resource_path.ends_with(".acl")
2848 || resource_path.ends_with(".meta")
2849 || resource_path.ends_with(".prov.ttl")
2850 {
2851 return;
2852 }
2853 if resource_path.ends_with('/') {
2855 return;
2856 }
2857
2858 let Some(data_root) = state.data_root.as_ref() else {
2860 return;
2861 };
2862
2863 let trimmed = resource_path.trim_start_matches('/');
2865 let mut segments = trimmed.splitn(2, '/');
2866 let pod = segments.next().unwrap_or("");
2867 let rel = segments.next().unwrap_or("");
2868 if pod.is_empty() || rel.is_empty() {
2869 return;
2870 }
2871 let repo = data_root.join(pod);
2872
2873 if !repo.join(".git").is_dir() {
2877 return;
2878 }
2879
2880 let agent_did = agent.unwrap_or("urn:solid:anonymous");
2881 let created = std::time::SystemTime::now()
2882 .duration_since(std::time::UNIX_EPOCH)
2883 .map(|d| d.as_secs())
2884 .unwrap_or(0);
2885
2886 let (policy, ticker_override) =
2889 handlers::prov::resolve_anchor_policy(state, resource_path).await;
2890
2891 let marker = std::sync::Arc::new(ShellGitMarker::new());
2896 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
2897 None
2898 } else {
2899 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
2900 };
2901 let (log, ticker, network) = match &anchorer_bundle {
2902 Some((anchorer, ticker, network)) => (
2903 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
2904 ticker.clone(),
2905 network.clone(),
2906 ),
2907 None => (ProvenanceLog::new(marker.clone()), String::new(), String::new()),
2909 };
2910
2911 let record_policy = match policy {
2915 AnchorPolicy::Epoch => AnchorPolicy::Never,
2916 other => other,
2917 };
2918 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
2919
2920 let write_record = solid_pod_rs::provenance::WriteRecord {
2924 repo: &repo,
2925 path: rel,
2926 agent_did,
2927 message,
2928 policy: record_policy,
2929 high_value,
2930 ticker: &ticker,
2931 network: &network,
2932 created,
2933 };
2934 let mut mark = match log.record(write_record).await {
2935 Ok(m) => m,
2936 Err(e) => {
2937 tracing::warn!(
2938 target: "solid_pod_rs_server::git_mark",
2939 resource = %resource_path,
2940 "provenance record failed (swallowed, write already succeeded): {e}"
2941 );
2942 return;
2943 }
2944 };
2945 mark.resource = resource_path.to_string();
2948
2949 if matches!(policy, AnchorPolicy::Epoch) {
2953 if let Some((anchorer, _, _)) = &anchorer_bundle {
2954 match handlers::prov::epoch_push_and_maybe_anchor(
2955 state,
2956 anchorer,
2957 &ticker,
2958 &network,
2959 &mark.git.commit_sha,
2960 )
2961 .await
2962 {
2963 Ok(Some(closed)) => tracing::debug!(
2964 target: "solid_pod_rs_server::git_mark",
2965 root = %closed.root,
2966 n = closed.commits.len(),
2967 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
2968 ),
2969 Ok(None) => {}
2970 Err(e) => tracing::warn!(
2971 target: "solid_pod_rs_server::git_mark",
2972 "epoch batch/anchor failed (swallowed): {e}"
2973 ),
2974 }
2975 }
2976 }
2977
2978 let ttl = prov_ttl(&mark);
2983 let sidecar = format!("{resource_path}.prov.ttl");
2984 if let Err(e) = state
2985 .storage
2986 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
2987 .await
2988 {
2989 tracing::warn!(
2990 target: "solid_pod_rs_server::git_mark",
2991 sidecar = %sidecar,
2992 "provenance sidecar write failed (swallowed): {e}"
2993 );
2994 return;
2995 }
2996
2997 tracing::debug!(
2998 target: "solid_pod_rs_server::git_mark",
2999 resource = %resource_path,
3000 commit = %mark.git.commit_sha,
3001 anchored = mark.anchor.is_some(),
3002 "provenance recorded"
3003 );
3004}
3005
3006#[cfg(not(feature = "git"))]
3009#[inline]
3010async fn git_mark_write(_state: &AppState, _resource_path: &str, _agent: Option<&str>, _message: &str) {}
3011
3012#[cfg(feature = "git")]
3013pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
3014 let caller = extract_pubkey(req).await?;
3015 if caller != pod_pubkey {
3016 return None;
3017 }
3018 Some(caller)
3019}
3020
3021#[cfg(feature = "git")]
3022fn git_json_err(msg: &str, status: u16) -> HttpResponse {
3023 HttpResponse::build(
3024 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3025 )
3026 .content_type("application/json")
3027 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
3028}
3029
3030#[cfg(feature = "git")]
3032#[derive(serde::Deserialize)]
3033struct GitStageBody {
3034 paths: Option<Vec<String>>,
3035 all: Option<bool>,
3036}
3037
3038#[cfg(feature = "git")]
3039#[derive(serde::Deserialize)]
3040struct GitCommitBody {
3041 message: String,
3042 author_name: Option<String>,
3043 author_email: Option<String>,
3044}
3045
3046#[cfg(feature = "git")]
3047#[derive(serde::Deserialize)]
3048struct GitBranchBody {
3049 name: String,
3050}
3051
3052#[cfg(feature = "git")]
3055async fn handle_git_status(
3056 path: web::Path<String>,
3057 req: HttpRequest,
3058 state: web::Data<AppState>,
3059) -> HttpResponse {
3060 let pubkey = path.into_inner();
3061 if require_pod_owner(&req, &pubkey).await.is_none() {
3062 return git_json_err("Authentication required", 401);
3063 }
3064 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3065 return git_json_err("Git not available (no FS backend)", 501);
3066 };
3067 match solid_pod_rs_git::api::git_status(&repo).await {
3068 Ok(s) => HttpResponse::Ok()
3069 .content_type("application/json")
3070 .body(serde_json::to_string(&s).unwrap_or_default()),
3071 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3072 }
3073}
3074
3075#[cfg(feature = "git")]
3076async fn handle_git_log(
3077 path: web::Path<String>,
3078 req: HttpRequest,
3079 state: web::Data<AppState>,
3080 query: web::Query<std::collections::HashMap<String, String>>,
3081) -> HttpResponse {
3082 let pubkey = path.into_inner();
3083 if require_pod_owner(&req, &pubkey).await.is_none() {
3084 return git_json_err("Authentication required", 401);
3085 }
3086 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3087 return git_json_err("Git not available (no FS backend)", 501);
3088 };
3089 let limit: u32 = query
3090 .get("limit")
3091 .and_then(|v| v.parse().ok())
3092 .unwrap_or(20);
3093 match solid_pod_rs_git::api::git_log(&repo, limit).await {
3094 Ok(entries) => HttpResponse::Ok()
3095 .content_type("application/json")
3096 .body(serde_json::to_string(&entries).unwrap_or_default()),
3097 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3098 }
3099}
3100
3101#[cfg(feature = "git")]
3102async fn handle_git_diff(
3103 path: web::Path<String>,
3104 req: HttpRequest,
3105 state: web::Data<AppState>,
3106 query: web::Query<std::collections::HashMap<String, String>>,
3107) -> HttpResponse {
3108 let pubkey = path.into_inner();
3109 if require_pod_owner(&req, &pubkey).await.is_none() {
3110 return git_json_err("Authentication required", 401);
3111 }
3112 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3113 return git_json_err("Git not available (no FS backend)", 501);
3114 };
3115 let file_path = query.get("path").map(String::as_str);
3116 let staged = query
3117 .get("staged")
3118 .map(|v| v == "true" || v == "1")
3119 .unwrap_or(false);
3120 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
3121 Ok(diff) => HttpResponse::Ok()
3122 .content_type("text/plain")
3123 .body(diff),
3124 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3125 }
3126}
3127
3128#[cfg(feature = "git")]
3129async fn handle_git_stage(
3130 path: web::Path<String>,
3131 req: HttpRequest,
3132 state: web::Data<AppState>,
3133 body: web::Bytes,
3134) -> HttpResponse {
3135 let pubkey = path.into_inner();
3136 if require_pod_owner(&req, &pubkey).await.is_none() {
3137 return git_json_err("Authentication required", 401);
3138 }
3139 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3140 return git_json_err("Git not available (no FS backend)", 501);
3141 };
3142 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3143 Ok(v) => v,
3144 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3145 };
3146 let paths = parsed.paths.unwrap_or_default();
3147 let all = parsed.all.unwrap_or(false);
3148 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
3149 Ok(()) => HttpResponse::Ok()
3150 .content_type("application/json")
3151 .body(r#"{"ok":true}"#),
3152 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3153 }
3154}
3155
3156#[cfg(feature = "git")]
3157async fn handle_git_unstage(
3158 path: web::Path<String>,
3159 req: HttpRequest,
3160 state: web::Data<AppState>,
3161 body: web::Bytes,
3162) -> HttpResponse {
3163 let pubkey = path.into_inner();
3164 if require_pod_owner(&req, &pubkey).await.is_none() {
3165 return git_json_err("Authentication required", 401);
3166 }
3167 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3168 return git_json_err("Git not available (no FS backend)", 501);
3169 };
3170 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3171 Ok(v) => v,
3172 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3173 };
3174 let paths = parsed.paths.unwrap_or_default();
3175 let all = parsed.all.unwrap_or(false);
3176 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
3177 Ok(()) => HttpResponse::Ok()
3178 .content_type("application/json")
3179 .body(r#"{"ok":true}"#),
3180 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3181 }
3182}
3183
3184#[cfg(feature = "git")]
3185async fn handle_git_commit(
3186 path: web::Path<String>,
3187 req: HttpRequest,
3188 state: web::Data<AppState>,
3189 body: web::Bytes,
3190) -> HttpResponse {
3191 let pubkey = path.into_inner();
3192 if require_pod_owner(&req, &pubkey).await.is_none() {
3193 return git_json_err("Authentication required", 401);
3194 }
3195 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3196 return git_json_err("Git not available (no FS backend)", 501);
3197 };
3198 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
3199 Ok(v) => v,
3200 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3201 };
3202 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
3203 let author_email = parsed
3204 .author_email
3205 .as_deref()
3206 .unwrap_or("pod@dreamlab-ai.com");
3207 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email)
3208 .await
3209 {
3210 Ok(result) => HttpResponse::Ok()
3211 .content_type("application/json")
3212 .body(serde_json::to_string(&result).unwrap_or_default()),
3213 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3214 }
3215}
3216
3217#[cfg(feature = "git")]
3218async fn handle_git_branches(
3219 path: web::Path<String>,
3220 req: HttpRequest,
3221 state: web::Data<AppState>,
3222) -> HttpResponse {
3223 let pubkey = path.into_inner();
3224 if require_pod_owner(&req, &pubkey).await.is_none() {
3225 return git_json_err("Authentication required", 401);
3226 }
3227 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3228 return git_json_err("Git not available (no FS backend)", 501);
3229 };
3230 match solid_pod_rs_git::api::git_branches(&repo).await {
3231 Ok(info) => HttpResponse::Ok()
3232 .content_type("application/json")
3233 .body(serde_json::to_string(&info).unwrap_or_default()),
3234 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3235 }
3236}
3237
3238#[cfg(feature = "git")]
3239async fn handle_git_create_branch(
3240 path: web::Path<String>,
3241 req: HttpRequest,
3242 state: web::Data<AppState>,
3243 body: web::Bytes,
3244) -> HttpResponse {
3245 let pubkey = path.into_inner();
3246 if require_pod_owner(&req, &pubkey).await.is_none() {
3247 return git_json_err("Authentication required", 401);
3248 }
3249 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3250 return git_json_err("Git not available (no FS backend)", 501);
3251 };
3252 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
3253 Ok(v) => v,
3254 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3255 };
3256 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
3257 Ok(()) => HttpResponse::Ok()
3258 .content_type("application/json")
3259 .body(r#"{"ok":true}"#),
3260 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3261 }
3262}
3263
3264#[cfg(feature = "git")]
3265async fn handle_git_discard(
3266 path: web::Path<String>,
3267 req: HttpRequest,
3268 state: web::Data<AppState>,
3269 body: web::Bytes,
3270) -> HttpResponse {
3271 let pubkey = path.into_inner();
3272 if require_pod_owner(&req, &pubkey).await.is_none() {
3273 return git_json_err("Authentication required", 401);
3274 }
3275 let Some(repo) = pod_repo_path(&state, &pubkey) else {
3276 return git_json_err("Git not available (no FS backend)", 501);
3277 };
3278 let parsed: GitStageBody = match serde_json::from_slice(&body) {
3279 Ok(v) => v,
3280 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
3281 };
3282 let paths = parsed.paths.unwrap_or_default();
3283 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
3284 Ok(()) => HttpResponse::Ok()
3285 .content_type("application/json")
3286 .body(r#"{"ok":true}"#),
3287 Err(e) => git_json_err(&e.to_string(), e.status_code()),
3288 }
3289}
3290
3291async fn handle_git_panel_options(
3299 req: HttpRequest,
3300 state: web::Data<AppState>,
3301) -> HttpResponse {
3302 let origin = req
3303 .headers()
3304 .get(header::ORIGIN)
3305 .and_then(|v| v.to_str().ok())
3306 .map(str::to_string);
3307
3308 let mut rsp = HttpResponse::NoContent().finish();
3309 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
3310 rsp
3311}
3312
3313async fn handle_admin_provision(
3324 req: HttpRequest,
3325 state: web::Data<AppState>,
3326 path: web::Path<String>,
3327) -> HttpResponse {
3328 let expected = match &state.admin_key {
3330 Some(k) => k.clone(),
3331 None => {
3332 return HttpResponse::Forbidden().json(serde_json::json!({
3333 "error": "admin key not configured on this server"
3334 }));
3335 }
3336 };
3337 let provided = req
3338 .headers()
3339 .get("x-pod-admin-key")
3340 .and_then(|v| v.to_str().ok())
3341 .unwrap_or("");
3342 use subtle::ConstantTimeEq;
3347 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
3348 if !bool::from(key_match) {
3349 return HttpResponse::Forbidden()
3350 .json(serde_json::json!({"error": "invalid admin key"}));
3351 }
3352
3353 let pubkey = path.into_inner();
3355 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
3356 return HttpResponse::BadRequest()
3357 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
3358 }
3359
3360 let data_root = match &state.data_root {
3362 Some(r) => r.clone(),
3363 None => {
3364 return HttpResponse::InternalServerError().json(serde_json::json!({
3365 "error": "server has no fs-backend storage configured"
3366 }));
3367 }
3368 };
3369
3370 let pod_dir = data_root.join(&pubkey);
3371
3372 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
3374 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
3375 return HttpResponse::InternalServerError()
3376 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
3377 }
3378
3379 let acl_content = format!(
3381 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
3382 <#owner> a acl:Authorization ;\n\
3383 acl:agent <did:nostr:{pubkey}> ;\n\
3384 acl:accessTo <./> ;\n\
3385 acl:default <./> ;\n\
3386 acl:mode acl:Read, acl:Write, acl:Control .\n"
3387 );
3388 let acl_path = pod_dir.join(".acl");
3389 if !acl_path.exists() {
3390 if let Err(e) = tokio::fs::write(&acl_path, acl_content.as_bytes()).await {
3391 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write .acl failed");
3392 return HttpResponse::InternalServerError()
3393 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
3394 }
3395 }
3396
3397 #[cfg(feature = "git")]
3399 {
3400 use tokio::process::Command;
3401
3402 if !pod_dir.join(".git").exists() {
3404 let init_out = Command::new("git")
3405 .args([
3406 "init",
3407 "-b",
3408 "main",
3409 pod_dir.to_str().unwrap_or("."),
3410 ])
3411 .output()
3412 .await;
3413
3414 match init_out {
3415 Ok(out) if out.status.success() => {}
3416 Ok(out) => {
3417 let stderr = String::from_utf8_lossy(&out.stderr);
3418 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
3419 }
3420 Err(e) => {
3421 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
3422 }
3423 }
3424
3425 let cfg_out = Command::new("git")
3428 .args([
3429 "-C",
3430 pod_dir.to_str().unwrap_or("."),
3431 "config",
3432 "receive.denyCurrentBranch",
3433 "updateInstead",
3434 ])
3435 .output()
3436 .await;
3437
3438 if let Err(e) = cfg_out {
3439 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
3440 }
3441 }
3442 }
3443
3444 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
3446 HttpResponse::Ok().json(serde_json::json!({
3447 "podUrl": format!("{base_url}/pods/{pubkey}/"),
3448 "ok": true,
3449 }))
3450}
3451
3452async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
3457 let Some(ref data_root) = state.data_root else {
3458 return HttpResponse::Ok()
3459 .content_type("application/json")
3460 .json(serde_json::json!({"apps": [], "count": 0}));
3461 };
3462
3463 let server_url = state.nodeinfo.base_url.clone();
3464
3465 let mut read_dir = match tokio::fs::read_dir(data_root).await {
3467 Ok(rd) => rd,
3468 Err(_) => {
3469 return HttpResponse::Ok()
3470 .content_type("application/json")
3471 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
3472 }
3473 };
3474
3475 let mut apps: Vec<serde_json::Value> = Vec::new();
3476 let mut scanned = 0usize;
3477
3478 while scanned < 1000 {
3479 let entry = match read_dir.next_entry().await {
3480 Ok(Some(e)) => e,
3481 Ok(None) => break,
3482 Err(_) => break,
3483 };
3484
3485 let file_type = match entry.file_type().await {
3486 Ok(ft) => ft,
3487 Err(_) => continue,
3488 };
3489 if !file_type.is_dir() {
3490 continue;
3491 }
3492
3493 scanned += 1;
3494
3495 let manifest_path = entry.path().join("apps").join("manifest.json");
3496 let contents = match tokio::fs::read(&manifest_path).await {
3497 Ok(c) => c,
3498 Err(_) => continue,
3499 };
3500
3501 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
3502 Ok(v) => v,
3503 Err(_) => continue,
3504 };
3505
3506 if let Some(pod_name) = entry.file_name().to_str() {
3508 if manifest.get("podOwner").is_none() {
3509 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
3510 }
3511 }
3512
3513 apps.push(manifest);
3514 }
3515
3516 let count = apps.len();
3517 HttpResponse::Ok()
3518 .content_type("application/json")
3519 .json(serde_json::json!({
3520 "apps": apps,
3521 "serverUrl": server_url,
3522 "count": count,
3523 }))
3524}
3525
3526#[allow(dead_code)]
3539fn is_git_request(path: &str) -> bool {
3540 path.contains("/info/refs")
3541 || path.contains("/git-upload-pack")
3542 || path.contains("/git-receive-pack")
3543}
3544
3545#[allow(dead_code)]
3548fn is_dot_git_path(path: &str) -> bool {
3549 path.contains("/.git/") || path.ends_with("/.git")
3550}
3551
3552#[cfg(feature = "git")]
3553async fn handle_git(
3554 req: HttpRequest,
3555 body: web::Bytes,
3556 state: web::Data<AppState>,
3557) -> HttpResponse {
3558 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
3559 use solid_pod_rs_git::service::{GitHttpService, GitRequest};
3560
3561 let path = req.uri().path().to_string();
3562
3563 let pod_name = path
3566 .trim_start_matches('/')
3567 .split('/')
3568 .next()
3569 .unwrap_or("")
3570 .to_string();
3571 let Some(ref data_root) = state.data_root else {
3572 return HttpResponse::NotImplemented().json(serde_json::json!({
3573 "error": "git requires fs-backend storage",
3574 "reason": "data_root_not_configured"
3575 }));
3576 };
3577 let repo_root = data_root.join(&pod_name);
3578 if !repo_root.exists() {
3579 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
3580 }
3581
3582 let query = req.uri().query().unwrap_or("").to_string();
3583 let host_url = {
3584 let conn = req.connection_info();
3585 Some(format!("{}://{}", conn.scheme(), conn.host()))
3586 };
3587 let headers: Vec<(String, String)> = req
3588 .headers()
3589 .iter()
3590 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
3591 .collect();
3592
3593 let git_req = GitRequest {
3594 method: req.method().as_str().to_string(),
3595 path,
3596 query,
3597 headers,
3598 body: body.into(),
3599 host_url,
3600 };
3601
3602 let is_write = git_req.is_write();
3614 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
3615 Ok(pk) => Some(format!("did:nostr:{pk}")),
3616 Err(_) => None,
3617 };
3618 let wac_path = format!("/{pod_name}/");
3619 let origin = req_origin(&req);
3620 let wac = if is_write {
3621 enforce_write_ctx(&state, &wac_path, AccessMode::Write, agent.as_deref(), origin).await
3622 } else {
3623 enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin).await
3624 };
3625 if let Err(e) = wac {
3626 return e.error_response();
3627 }
3628
3629 let service = GitHttpService::new(repo_root);
3630 match service.handle(git_req).await {
3631 Ok(git_resp) => {
3632 let mut builder = HttpResponse::build(
3633 actix_web::http::StatusCode::from_u16(git_resp.status)
3634 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3635 );
3636 for (k, v) in &git_resp.headers {
3637 builder.insert_header((k.as_str(), v.as_str()));
3638 }
3639 builder.body(git_resp.body)
3640 }
3641 Err(e) => {
3642 let status = e.status_code();
3643 HttpResponse::build(
3644 actix_web::http::StatusCode::from_u16(status)
3645 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3646 )
3647 .json(serde_json::json!({"error": e.to_string()}))
3648 }
3649 }
3650}
3651
3652pub fn build_app(
3664 state: AppState,
3665) -> App<
3666 impl actix_web::dev::ServiceFactory<
3667 ServiceRequest,
3668 Config = (),
3669 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
3670 Error = ActixError,
3671 InitError = (),
3672 >,
3673> {
3674 let body_cap = state.body_cap;
3675 let dotfiles = state.dotfiles.clone();
3676 let allowed_origins = Arc::new(state.allowed_origins.clone());
3677
3678 let mut app = App::new()
3679 .app_data(web::Data::new(state.clone()))
3680 .app_data(web::PayloadConfig::new(body_cap))
3681 .wrap(ErrorLoggingMiddleware)
3686 .wrap(CorsHeaders { allowed_origins })
3687 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
3691 .wrap(PathTraversalGuard)
3692 .wrap(DotfileGuard::new(dotfiles));
3693
3694 app = app
3700 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
3701 .route(
3702 "/.well-known/webfinger",
3703 web::get().to(handle_well_known_webfinger),
3704 )
3705 .route(
3706 "/.well-known/nodeinfo",
3707 web::get().to(handle_well_known_nodeinfo),
3708 )
3709 .route(
3710 "/.well-known/nodeinfo/2.1",
3711 web::get().to(handle_well_known_nodeinfo_2_1),
3712 );
3713
3714 #[cfg(feature = "did-nostr")]
3715 {
3716 app = app.route(
3717 "/.well-known/did/nostr/{pubkey}.json",
3718 web::get().to(handle_well_known_did_nostr),
3719 );
3720 }
3721
3722 #[cfg(feature = "nip05-endpoint")]
3727 {
3728 app = app.route(
3729 "/.well-known/nostr.json",
3730 web::get().to(handle_well_known_nip05),
3731 );
3732 }
3733
3734 #[cfg(feature = "export-jsonld")]
3739 {
3740 app = app.route("/api/exports/all", web::get().to(handle_export_all));
3741 }
3742
3743 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
3745
3746 app = app.route("/pay/.info", web::get().to(handle_pay_info));
3748
3749 app = app.configure(handlers::pay::register);
3754
3755 app = app.route("/proxy", web::get().to(handle_proxy));
3757
3758 if state.mcp_enabled {
3762 app = app
3763 .route("/mcp", web::post().to(mcp::handle_mcp))
3764 .route("/mcp", web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options));
3765 }
3766
3767 app = app.route(
3770 "/_admin/provision/{pubkey}",
3771 web::post().to(handle_admin_provision),
3772 );
3773
3774 app = app
3776 .route("/.pods", web::post().to(handle_create_pod))
3777 .route("/api/accounts/new", web::post().to(handle_create_account))
3778 .route("/pods/check/{name}", web::get().to(handle_pod_check))
3779 .route("/login/password", web::post().to(handle_login_password))
3780 .route(
3781 "/account/password/reset",
3782 web::post().to(handle_password_reset_request),
3783 )
3784 .route(
3785 "/account/password/change",
3786 web::post().to(handle_password_change),
3787 );
3788
3789 app = app
3794 .route(
3795 "/{tail:.*}/.git",
3797 web::route().to(|| async {
3798 HttpResponse::Forbidden()
3799 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
3800 }),
3801 )
3802 .route(
3803 "/{tail:.*}/.git/{rest:.*}",
3804 web::route().to(|| async {
3805 HttpResponse::Forbidden()
3806 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
3807 }),
3808 );
3809
3810 app = app.route(
3814 "/pods/{pk}/_git/{tail:.*}",
3815 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
3816 );
3817
3818 #[cfg(feature = "git")]
3819 {
3820 app = app
3822 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
3823 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
3824 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
3825
3826 app = app
3829 .route(
3830 "/pods/{pubkey}/_git/status",
3831 web::get().to(handle_git_status),
3832 )
3833 .route(
3834 "/pods/{pubkey}/_git/log",
3835 web::get().to(handle_git_log),
3836 )
3837 .route(
3838 "/pods/{pubkey}/_git/diff",
3839 web::get().to(handle_git_diff),
3840 )
3841 .route(
3842 "/pods/{pubkey}/_git/stage",
3843 web::post().to(handle_git_stage),
3844 )
3845 .route(
3846 "/pods/{pubkey}/_git/unstage",
3847 web::post().to(handle_git_unstage),
3848 )
3849 .route(
3850 "/pods/{pubkey}/_git/commit",
3851 web::post().to(handle_git_commit),
3852 )
3853 .route(
3854 "/pods/{pubkey}/_git/branches",
3855 web::get().to(handle_git_branches),
3856 )
3857 .route(
3858 "/pods/{pubkey}/_git/branch",
3859 web::post().to(handle_git_create_branch),
3860 )
3861 .route(
3862 "/pods/{pubkey}/_git/discard",
3863 web::post().to(handle_git_discard),
3864 );
3865
3866 app = app.configure(handlers::prov::register);
3873 }
3874 #[cfg(not(feature = "git"))]
3875 {
3876 let git_501 = || async {
3880 HttpResponse::NotImplemented()
3881 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
3882 };
3883 app = app
3884 .route("/{tail:.*}/info/refs", web::get().to(git_501))
3885 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
3886 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
3887 }
3888
3889 app.route("/{tail:.*}/", web::post().to(handle_post))
3892 .route("/{tail:.*}/", web::put().to(handle_put))
3893 .route("/{tail:.*}", web::get().to(handle_get))
3894 .route("/{tail:.*}", web::head().to(handle_get))
3895 .route("/{tail:.*}", web::put().to(handle_put))
3896 .route("/{tail:.*}", web::patch().to(handle_patch))
3897 .route("/{tail:.*}", web::delete().to(handle_delete))
3898 .route(
3899 "/{tail:.*}",
3900 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
3901 )
3902 .route(
3903 "/{tail:.*}",
3904 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
3905 )
3906}
3907
3908#[cfg(test)]
3913mod payment_gating_tests {
3914 use super::*;
3915 use solid_pod_rs::payments::WebLedger;
3916 use solid_pod_rs::storage::memory::MemoryBackend;
3917
3918 const PRINCIPAL: &str = "did:nostr:alice";
3919
3920 const PAID_WRITE_ACL: &str = r#"
3923@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3924
3925<#paid-write> a acl:Authorization ;
3926 acl:agent <did:nostr:alice> ;
3927 acl:accessTo </premium/inbox> ;
3928 acl:mode acl:Write ;
3929 acl:condition [
3930 a acl:PaymentCondition ;
3931 acl:costSats 100
3932 ] .
3933"#;
3934
3935 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
3936 let mut ledger = WebLedger::new("Test Pod Credits");
3937 if sats > 0 {
3938 ledger.credit(did, sats);
3939 }
3940 let body = serde_json::to_vec(&ledger).unwrap();
3941 storage
3942 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
3943 .await
3944 .unwrap();
3945 }
3946
3947 async fn seed_acl(storage: &dyn Storage) {
3948 storage
3949 .put(
3950 "/premium/inbox.acl",
3951 Bytes::from(PAID_WRITE_ACL),
3952 "text/turtle",
3953 )
3954 .await
3955 .unwrap();
3956 }
3957
3958 #[actix_web::test]
3960 async fn resolve_balance_reads_ledger_entry() {
3961 let storage = MemoryBackend::new();
3962 seed_ledger(&storage, PRINCIPAL, 250).await;
3963 assert_eq!(
3964 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
3965 Some(250)
3966 );
3967 }
3968
3969 #[actix_web::test]
3971 async fn resolve_balance_zero_when_no_entry() {
3972 let storage = MemoryBackend::new();
3973 seed_ledger(&storage, "did:nostr:bob", 500).await;
3974 assert_eq!(resolve_balance_sats(&storage, Some(PRINCIPAL)).await, Some(0));
3975 }
3976
3977 #[actix_web::test]
3979 async fn resolve_balance_none_when_anonymous() {
3980 let storage = MemoryBackend::new();
3981 seed_ledger(&storage, PRINCIPAL, 1_000).await;
3982 assert_eq!(resolve_balance_sats(&storage, None).await, None);
3983 }
3984
3985 #[actix_web::test]
3987 async fn paid_write_denied_below_balance() {
3988 let storage = Arc::new(MemoryBackend::new());
3989 seed_acl(storage.as_ref()).await;
3990 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
3992
3993 let result =
3994 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3995 assert!(
3996 result.is_err(),
3997 "balance 50 < cost 100 must be denied — sat-gating loop closed"
3998 );
3999 }
4000
4001 #[actix_web::test]
4003 async fn paid_write_allowed_at_balance() {
4004 let storage = Arc::new(MemoryBackend::new());
4005 seed_acl(storage.as_ref()).await;
4006 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
4008
4009 let result =
4010 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4011 assert!(
4012 result.is_ok(),
4013 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
4014 );
4015 }
4016
4017 #[actix_web::test]
4019 async fn paid_write_allowed_above_balance() {
4020 let storage = Arc::new(MemoryBackend::new());
4021 seed_acl(storage.as_ref()).await;
4022 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
4023 let state = AppState::new(storage);
4024
4025 let result =
4026 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4027 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
4028 }
4029
4030 #[actix_web::test]
4034 async fn paid_write_anonymous_denied() {
4035 let storage = Arc::new(MemoryBackend::new());
4036 seed_acl(storage.as_ref()).await;
4037 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
4038 let state = AppState::new(storage);
4039
4040 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
4041 assert!(
4042 result.is_err(),
4043 "anonymous caller has no ledger principal — PaymentCondition fails closed"
4044 );
4045 }
4046
4047 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
4054 let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
4055 let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
4056 ledger.get_balance(did)
4057 }
4058
4059 #[actix_web::test]
4061 async fn paid_write_debits_ledger() {
4062 let storage = Arc::new(MemoryBackend::new());
4063 seed_acl(storage.as_ref()).await;
4064 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
4066
4067 let result =
4068 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4069 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
4070 assert_eq!(
4071 read_balance(storage.as_ref(), PRINCIPAL).await,
4072 150,
4073 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
4074 );
4075 }
4076
4077 #[actix_web::test]
4080 async fn paid_write_debits_each_grant() {
4081 let storage = Arc::new(MemoryBackend::new());
4082 seed_acl(storage.as_ref()).await;
4083 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
4085
4086 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
4087 .await
4088 .unwrap();
4089 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
4090 .await
4091 .unwrap();
4092 assert_eq!(
4093 read_balance(storage.as_ref(), PRINCIPAL).await,
4094 50,
4095 "250 - 2*100: each granted request debits, no unmetered re-use"
4096 );
4097
4098 let third =
4100 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
4101 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
4102 assert_eq!(
4103 read_balance(storage.as_ref(), PRINCIPAL).await,
4104 50,
4105 "a denied request must not debit"
4106 );
4107 }
4108
4109 #[actix_web::test]
4111 async fn paid_read_debits_ledger() {
4112 const PAID_READ_ACL: &str = r#"
4113@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4114
4115<#paid-read> a acl:Authorization ;
4116 acl:agent <did:nostr:alice> ;
4117 acl:accessTo </premium/feed> ;
4118 acl:mode acl:Read ;
4119 acl:condition [
4120 a acl:PaymentCondition ;
4121 acl:costSats 30
4122 ] .
4123"#;
4124 let storage = Arc::new(MemoryBackend::new());
4125 storage
4126 .put("/premium/feed.acl", Bytes::from(PAID_READ_ACL), "text/turtle")
4127 .await
4128 .unwrap();
4129 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
4130 let state = AppState::new(storage.clone());
4131
4132 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
4133 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
4134 assert_eq!(
4135 read_balance(storage.as_ref(), PRINCIPAL).await,
4136 70,
4137 "100 - 30 cost: a granted paid read must debit"
4138 );
4139 }
4140
4141 #[actix_web::test]
4144 async fn free_read_does_not_debit() {
4145 let storage = Arc::new(MemoryBackend::new());
4146 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
4148 let state = AppState::new(storage.clone());
4149
4150 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
4151 .await
4152 .unwrap();
4153 assert_eq!(
4154 read_balance(storage.as_ref(), PRINCIPAL).await,
4155 100,
4156 "a grant with no PaymentCondition must not debit"
4157 );
4158 }
4159
4160 const ALICE_ONLY_READ_ACL: &str = r#"
4166@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4167
4168<#alice> a acl:Authorization ;
4169 acl:agent <did:nostr:alice> ;
4170 acl:accessTo </private/secret> ;
4171 acl:default </private/> ;
4172 acl:mode acl:Read, acl:Write, acl:Control .
4173"#;
4174
4175 async fn seed_private_read_acl(storage: &dyn Storage) {
4176 storage
4181 .put(
4182 "/private.acl",
4183 Bytes::from(ALICE_ONLY_READ_ACL),
4184 "text/turtle",
4185 )
4186 .await
4187 .unwrap();
4188 }
4189
4190 #[actix_web::test]
4194 async fn enforce_read_grants_owner() {
4195 let storage = Arc::new(MemoryBackend::new());
4196 seed_private_read_acl(storage.as_ref()).await;
4197 let state = AppState::new(storage);
4198 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
4199 assert!(result.is_ok(), "owner alice must be granted Read");
4200 }
4201
4202 #[actix_web::test]
4205 async fn enforce_read_denies_other_principal() {
4206 let storage = Arc::new(MemoryBackend::new());
4207 seed_private_read_acl(storage.as_ref()).await;
4208 let state = AppState::new(storage);
4209 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
4210 assert!(
4211 result.is_err(),
4212 "bob has no Read grant — private resource must not be world-readable"
4213 );
4214 }
4215
4216 #[actix_web::test]
4219 async fn enforce_read_denies_anonymous() {
4220 let storage = Arc::new(MemoryBackend::new());
4221 seed_private_read_acl(storage.as_ref()).await;
4222 let state = AppState::new(storage);
4223 let result = enforce_read(&state, "/private/secret", None).await;
4224 assert!(result.is_err(), "anonymous Read must be denied");
4225 }
4226
4227 const WRITE_NOT_CONTROL_ACL: &str = r#"
4235@prefix acl: <http://www.w3.org/ns/auth/acl#> .
4236
4237<#owner> a acl:Authorization ;
4238 acl:agent <did:nostr:alice> ;
4239 acl:accessTo </shared/doc> ;
4240 acl:default </shared/> ;
4241 acl:mode acl:Read, acl:Write, acl:Control .
4242
4243<#writer> a acl:Authorization ;
4244 acl:agent <did:nostr:writer> ;
4245 acl:accessTo </shared/doc> ;
4246 acl:default </shared/> ;
4247 acl:mode acl:Read, acl:Write .
4248"#;
4249
4250 async fn seed_shared_acl(storage: &dyn Storage) {
4251 storage
4256 .put(
4257 "/shared.acl",
4258 Bytes::from(WRITE_NOT_CONTROL_ACL),
4259 "text/turtle",
4260 )
4261 .await
4262 .unwrap();
4263 }
4264
4265 #[actix_web::test]
4269 async fn acl_put_denied_for_writer_without_control() {
4270 let storage = Arc::new(MemoryBackend::new());
4271 seed_shared_acl(storage.as_ref()).await;
4272 let state = AppState::new(storage);
4273 let result =
4277 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some("did:nostr:writer")).await;
4278 assert!(
4279 result.is_err(),
4280 "writer lacks Control — must not be able to PUT /shared/.acl"
4281 );
4282 }
4283
4284 #[actix_web::test]
4286 async fn acl_put_allowed_for_control_holder() {
4287 let storage = Arc::new(MemoryBackend::new());
4288 seed_shared_acl(storage.as_ref()).await;
4289 let state = AppState::new(storage);
4290 let result =
4291 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
4292 assert!(
4293 result.is_ok(),
4294 "alice holds Control — must be allowed to PUT /shared/.acl"
4295 );
4296 }
4297
4298 #[actix_web::test]
4300 async fn meta_put_denied_for_writer_without_control() {
4301 let storage = Arc::new(MemoryBackend::new());
4302 seed_shared_acl(storage.as_ref()).await;
4303 let state = AppState::new(storage);
4304 let result = enforce_write(
4305 &state,
4306 "/shared/doc.meta",
4307 AccessMode::Write,
4308 Some("did:nostr:writer"),
4309 )
4310 .await;
4311 assert!(
4312 result.is_err(),
4313 "writer lacks Control — must not be able to PUT a .meta sidecar"
4314 );
4315 }
4316
4317 #[test]
4319 fn protected_resource_for_acl_strips_suffixes() {
4320 assert_eq!(protected_resource_for_acl("/victim/.acl").as_deref(), Some("/victim/"));
4321 assert_eq!(protected_resource_for_acl("/a/b.acl").as_deref(), Some("/a/b"));
4322 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
4323 assert_eq!(protected_resource_for_acl("/a/b.meta").as_deref(), Some("/a/b"));
4324 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
4325 }
4326}