1#![doc = include_str!("../README.md")]
53#![deny(unsafe_code)]
54#![warn(rust_2018_idioms)]
55
56pub mod cli;
58
59mod handlers;
64
65mod mcp;
68
69pub mod mempool;
74
75pub mod trail_store;
81
82use std::collections::HashMap;
83use std::net::{IpAddr, Ipv4Addr};
84use std::path::{Path, PathBuf};
85use std::sync::{Arc, Mutex};
86use std::time::{Duration, Instant};
87
88use actix_web::body::{BoxBody, EitherBody};
89use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
90use actix_web::http::{header, StatusCode};
91use actix_web::middleware::{NormalizePath, TrailingSlash};
92use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
93use bytes::Bytes;
94use futures_util::future::{ready, LocalBoxFuture, Ready};
95use percent_encoding::percent_decode_str;
96use serde::Deserialize;
97use solid_pod_rs::{
98 auth::nip98,
99 config::sources::parse_size,
100 interop,
101 ldp::{self, LdpContainerOps, PatchCreateOutcome},
102 mashlib::{self, MashlibConfig},
103 provision,
104 security::DotfileAllowlist,
105 storage::Storage,
106 wac::{
107 self, conditions::RequestContext, parse_jsonld_acl, parser::parse_turtle_acl, AccessMode,
108 },
109 PodError,
110};
111
112#[derive(Clone)]
118pub struct AppState {
119 pub storage: Arc<dyn Storage>,
120 pub dotfiles: Arc<DotfileAllowlist>,
121 pub body_cap: usize,
122 pub nodeinfo: NodeInfoMeta,
123 pub mashlib: MashlibConfig,
124 pub mashlib_cdn: Option<String>,
127 pub pay_config: solid_pod_rs::payments::PayConfig,
130 pub data_root: Option<PathBuf>,
135 pub pod_create_limiter: Arc<PodCreateLimiter>,
137 pub allowed_origins: Vec<String>,
144 pub admin_key: Option<String>,
149 pub mcp_enabled: bool,
154 pub mempool_url: Option<String>,
160}
161
162#[derive(Clone, Debug)]
164pub struct NodeInfoMeta {
165 pub software_name: String,
166 pub software_version: String,
167 pub open_registrations: bool,
168 pub total_users: u64,
169 pub base_url: String,
170}
171
172impl Default for NodeInfoMeta {
173 fn default() -> Self {
174 Self {
175 software_name: "solid-pod-rs-server".to_string(),
176 software_version: env!("CARGO_PKG_VERSION").to_string(),
177 open_registrations: false,
178 total_users: 0,
179 base_url: "http://localhost".to_string(),
180 }
181 }
182}
183
184pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
187
188pub fn body_cap_from_env() -> usize {
191 match std::env::var("JSS_MAX_REQUEST_BODY") {
192 Ok(v) => parse_size(&v)
193 .map(|u| u as usize)
194 .unwrap_or(DEFAULT_BODY_CAP),
195 Err(_) => DEFAULT_BODY_CAP,
196 }
197}
198
199impl AppState {
200 pub fn new(storage: Arc<dyn Storage>) -> Self {
203 Self {
204 storage,
205 dotfiles: Arc::new(DotfileAllowlist::from_env()),
206 body_cap: body_cap_from_env(),
207 nodeinfo: NodeInfoMeta::default(),
208 mashlib: MashlibConfig::default(),
209 mashlib_cdn: None,
210 pay_config: solid_pod_rs::payments::PayConfig::default(),
211 data_root: None,
212 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
213 allowed_origins: Vec::new(),
214 admin_key: None,
215 mcp_enabled: false,
216 mempool_url: None,
217 }
218 }
219}
220
221#[derive(Debug)]
223pub struct PodCreateLimiter {
224 hits: Mutex<HashMap<IpAddr, Instant>>,
225 window: Duration,
226}
227
228impl Default for PodCreateLimiter {
229 fn default() -> Self {
230 Self {
231 hits: Mutex::new(HashMap::new()),
232 window: Duration::from_secs(24 * 60 * 60),
233 }
234 }
235}
236
237impl PodCreateLimiter {
238 fn check(&self, ip: IpAddr) -> Result<(), u64> {
239 let now = Instant::now();
240 let mut hits = self.hits.lock().unwrap();
241 if let Some(last) = hits.get(&ip).copied() {
242 let elapsed = now.saturating_duration_since(last);
243 if elapsed < self.window {
244 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
245 }
246 }
247 hits.insert(ip, now);
248 Ok(())
249 }
250}
251
252pub(crate) fn to_actix(e: PodError) -> ActixError {
257 match e {
258 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
259 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
260 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
261 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
262 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
263 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
264 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
265 }
266}
267
268pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
274 let header_val = req
275 .headers()
276 .get(header::AUTHORIZATION)
277 .and_then(|v| v.to_str().ok())?;
278 let conn = req.connection_info();
286 let url = format!("{}://{}{}", conn.scheme(), conn.host(), req.uri().path());
287 nip98::verify(header_val, &url, req.method().as_str(), None)
288 .await
289 .ok()
290}
291
292pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
293 pubkey.map(|pk| format!("did:nostr:{pk}"))
294}
295
296pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
300
301async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
318 let did = agent_uri?;
319 let balance = match storage.get(WEBLEDGER_PATH).await {
320 Ok((bytes, _meta)) => {
321 match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
322 Ok(ledger) => ledger.get_balance(did),
323 Err(_) => 0,
327 }
328 }
329 Err(_) => 0,
332 };
333 Some(balance)
334}
335
336fn accept_includes_html(accept: &str) -> bool {
344 accept.split(',').any(|entry| {
345 let mime = entry.split(';').next().unwrap_or("").trim();
346 mime.eq_ignore_ascii_case("text/html")
347 })
348}
349
350fn protected_resource_for_acl(path: &str) -> Option<String> {
366 for suffix in [".acl", ".meta"] {
367 if let Some(stripped) = path.strip_suffix(suffix) {
368 if stripped.is_empty() {
372 return Some("/".to_string());
373 }
374 return Some(stripped.to_string());
375 }
376 }
377 None
378}
379
380fn proposed_acl_keeps_caller_control(body: &[u8], content_type: &str, caller: Option<&str>) -> bool {
388 let doc = match parse_jsonld_acl(body) {
389 Ok(d) => Some(d),
390 Err(_) => {
391 let ct = content_type.to_ascii_lowercase();
392 let text = std::str::from_utf8(body).unwrap_or("");
393 let looks_turtle = ct.starts_with("text/turtle")
394 || ct.starts_with("application/turtle")
395 || ct.starts_with("application/x-turtle")
396 || text.contains("@prefix")
397 || text.contains("acl:Authorization");
398 if looks_turtle {
399 parse_turtle_acl(text).ok()
400 } else {
401 None
402 }
403 }
404 };
405 let Some(doc) = doc else {
406 return true;
408 };
409 let Some(graph) = doc.graph.as_ref() else {
410 return false;
411 };
412 graph.iter().any(|auth| {
413 let grants_control = ids_of_acl_field(&auth.mode)
414 .iter()
415 .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
416 if !grants_control {
417 return false;
418 }
419 let agents = ids_of_acl_field(&auth.agent);
420 if let Some(web_id) = caller {
421 if agents.iter().any(|a| *a == web_id) {
422 return true;
423 }
424 }
425 let classes = ids_of_acl_field(&auth.agent_class);
426 if classes
427 .iter()
428 .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
429 {
430 return true;
431 }
432 if caller.is_some()
433 && classes.iter().any(|c| {
434 *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
435 || *c == "acl:AuthenticatedAgent"
436 })
437 {
438 return true;
439 }
440 false
441 })
442}
443
444fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
446 match field {
447 None => Vec::new(),
448 Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
449 Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
450 }
451}
452
453async fn enforce_write(
454 state: &AppState,
455 path: &str,
456 mode: AccessMode,
457 agent_uri: Option<&str>,
458) -> Result<(), ActixError> {
459 if let Some(protected) = protected_resource_for_acl(path) {
468 let control_acl = match find_effective_acl_dyn(&*state.storage, &protected).await {
469 Ok(doc) => doc,
470 Err(e) => return Err(to_actix(e)),
471 };
472 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
473 let ctx = RequestContext {
474 web_id: agent_uri,
475 client_id: None,
476 issuer: None,
477 payment_balance_sats,
478 };
479 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
480 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
481 let has_control = wac::evaluate_access_ctx_with_registry(
482 control_acl.as_ref(),
483 &ctx,
484 &protected,
485 AccessMode::Control,
486 None,
487 &groups,
488 ®istry,
489 );
490 if !has_control {
491 return Err(acl_denial(control_acl.as_ref(), agent_uri, &protected));
492 }
493 return Ok(());
494 }
495
496 let acl_doc = match find_effective_acl_dyn(&*state.storage, path).await {
501 Ok(doc) => doc,
502 Err(e) => return Err(to_actix(e)),
503 };
504
505 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
510
511 let ctx = RequestContext {
512 web_id: agent_uri,
513 client_id: None,
514 issuer: None,
515 payment_balance_sats,
516 };
517 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
518 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
519 let granted = wac::evaluate_access_ctx_with_registry(
520 acl_doc.as_ref(),
521 &ctx,
522 path,
523 mode,
524 None,
525 &groups,
526 ®istry,
527 );
528 if granted {
529 if let Err(e) =
537 charge_granted_payment(state, acl_doc.as_ref(), &ctx, path, mode, &groups, ®istry)
538 .await
539 {
540 return Err(e);
541 }
542 return Ok(());
543 }
544
545 Err(acl_denial(acl_doc.as_ref(), agent_uri, path))
546}
547
548async fn charge_granted_payment(
557 state: &AppState,
558 acl_doc: Option<&wac::AclDocument>,
559 ctx: &RequestContext<'_>,
560 path: &str,
561 mode: AccessMode,
562 groups: &wac::StaticGroupMembership,
563 registry: &wac::conditions::ConditionRegistry,
564) -> Result<(), ActixError> {
565 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
566 if cost == 0 {
567 return Ok(());
568 }
569 if let Some(did) = ctx.web_id {
570 if debit_ledger(&*state.storage, did, cost).await.is_err() {
571 return Err(acl_denial(acl_doc, ctx.web_id, path));
572 }
573 }
574 Ok(())
575}
576
577fn acl_denial(
583 acl_doc: Option<&wac::AclDocument>,
584 agent_uri: Option<&str>,
585 path: &str,
586) -> ActixError {
587 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
588 let (status, body, unauthenticated) = if agent_uri.is_none() {
589 (StatusCode::UNAUTHORIZED, "authentication required", true)
590 } else {
591 (StatusCode::FORBIDDEN, "access forbidden", false)
592 };
593 let mut rsp = HttpResponse::new(status);
594 rsp.headers_mut().insert(
595 header::HeaderName::from_static("wac-allow"),
596 header::HeaderValue::from_str(&allow_header)
597 .unwrap_or(header::HeaderValue::from_static("")),
598 );
599 if unauthenticated {
600 rsp.headers_mut().insert(
607 header::WWW_AUTHENTICATE,
608 header::HeaderValue::from_static(
609 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
610 ),
611 );
612 }
613 actix_web::error::InternalError::from_response(body, rsp).into()
614}
615
616async fn enforce_read(
624 state: &AppState,
625 path: &str,
626 agent_uri: Option<&str>,
627) -> Result<(), ActixError> {
628 let acl_doc = match find_effective_acl_dyn(&*state.storage, path).await {
629 Ok(doc) => doc,
630 Err(e) => return Err(to_actix(e)),
631 };
632 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
633 let ctx = RequestContext {
634 web_id: agent_uri,
635 client_id: None,
636 issuer: None,
637 payment_balance_sats,
638 };
639 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
640 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
641 let granted = wac::evaluate_access_ctx_with_registry(
642 acl_doc.as_ref(),
643 &ctx,
644 path,
645 AccessMode::Read,
646 None,
647 &groups,
648 ®istry,
649 );
650 if granted {
651 charge_granted_payment(
656 state,
657 acl_doc.as_ref(),
658 &ctx,
659 path,
660 AccessMode::Read,
661 &groups,
662 ®istry,
663 )
664 .await?;
665 return Ok(());
666 }
667 Err(acl_denial(acl_doc.as_ref(), agent_uri, path))
668}
669
670async fn debit_ledger(
679 storage: &dyn Storage,
680 did: &str,
681 cost: u64,
682) -> Result<(), solid_pod_rs::payments::PaymentError> {
683 use solid_pod_rs::payments::{PaymentError, WebLedger};
684
685 let (bytes, _meta) = storage
686 .get(WEBLEDGER_PATH)
687 .await
688 .map_err(|e| PaymentError::Store(e.to_string()))?;
689 let mut ledger: WebLedger = serde_json::from_slice(&bytes)
690 .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
691 ledger.debit(did, cost)?;
692 let body = serde_json::to_vec(&ledger)
693 .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
694 storage
695 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
696 .await
697 .map_err(|e| PaymentError::Store(e.to_string()))?;
698 Ok(())
699}
700
701fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
706 let links = ldp::link_headers(path).join(", ");
707 if let Ok(value) = header::HeaderValue::from_str(&links) {
708 rsp.headers_mut()
709 .insert(header::HeaderName::from_static("link"), value);
710 }
711}
712
713fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
714 if let Ok(v) = header::HeaderValue::from_str(header_value) {
715 rsp.headers_mut()
716 .insert(header::HeaderName::from_static("wac-allow"), v);
717 }
718}
719
720fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
721 let ws_base = base_url
722 .replacen("https://", "wss://", 1)
723 .replacen("http://", "ws://", 1);
724 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
725 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
726 rsp.headers_mut()
727 .insert(header::HeaderName::from_static("updates-via"), v);
728 }
729}
730
731async fn handle_get(
732 req: HttpRequest,
733 state: web::Data<AppState>,
734) -> Result<HttpResponse, ActixError> {
735 let path = req.uri().path().to_string();
736
737 if path.contains('*') {
738 return handle_glob_get(req, state).await;
739 }
740
741 let auth_pk = extract_pubkey(&req).await;
742 let agent = agent_uri(auth_pk.as_ref());
743
744 enforce_read(&state, &path, agent.as_deref()).await?;
749
750 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
751
752 if ldp::is_container(&path) {
753 let accept = req
754 .headers()
755 .get(header::ACCEPT)
756 .and_then(|v| v.to_str().ok())
757 .unwrap_or("");
758
759 if accept_includes_html(accept) {
765 let index_path = format!("{}index.html", &path);
766 if let Ok((body, _meta)) = state.storage.get(&index_path).await {
767 let mut rsp = HttpResponse::Ok()
768 .content_type("text/html; charset=utf-8")
769 .body(body.to_vec());
770 set_wac_allow(&mut rsp, &wac_allow);
771 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
772 set_link_headers(&mut rsp, &path);
773 return Ok(rsp);
774 }
775 }
776
777 let v = state
778 .storage
779 .container_representation(&path)
780 .await
781 .map_err(to_actix)?;
782
783 let sec_fetch_dest = req
785 .headers()
786 .get("sec-fetch-dest")
787 .and_then(|v| v.to_str().ok());
788 if mashlib::should_serve(
789 accept,
790 sec_fetch_dest,
791 "application/ld+json",
792 state.mashlib.enabled,
793 ) {
794 let json_ld = serde_json::to_string(&v).ok();
795 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
796 let mut rsp = HttpResponse::Ok()
797 .content_type("text/html; charset=utf-8")
798 .insert_header(("X-Frame-Options", "DENY"))
799 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
800 .insert_header(("Cache-Control", "no-store"))
801 .body(html);
802 set_wac_allow(&mut rsp, &wac_allow);
803 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
804 set_link_headers(&mut rsp, &path);
805 return Ok(rsp);
806 }
807
808 let mut rsp = HttpResponse::Ok().json(v);
809 rsp.headers_mut().insert(
810 header::CONTENT_TYPE,
811 header::HeaderValue::from_static("application/ld+json"),
812 );
813 set_wac_allow(&mut rsp, &wac_allow);
814 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
815 set_link_headers(&mut rsp, &path);
816 return Ok(rsp);
817 }
818
819 match state.storage.get(&path).await {
820 Ok((body, meta)) => {
821 let accept = req
823 .headers()
824 .get(header::ACCEPT)
825 .and_then(|v| v.to_str().ok())
826 .unwrap_or("");
827 let sec_fetch_dest = req
828 .headers()
829 .get("sec-fetch-dest")
830 .and_then(|v| v.to_str().ok());
831 if mashlib::should_serve(
832 accept,
833 sec_fetch_dest,
834 &meta.content_type,
835 state.mashlib.enabled,
836 ) {
837 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
838 std::str::from_utf8(&body).ok().map(|s| s.to_string())
839 } else {
840 None
841 };
842 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
843 let mut rsp = HttpResponse::Ok()
844 .content_type("text/html; charset=utf-8")
845 .insert_header(("X-Frame-Options", "DENY"))
846 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
847 .insert_header(("Cache-Control", "no-store"))
848 .body(html);
849 set_wac_allow(&mut rsp, &wac_allow);
850 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
851 set_link_headers(&mut rsp, &path);
852 return Ok(rsp);
853 }
854
855 if let Some((negotiated_body, negotiated_ct)) =
863 rdf_content_negotiate(&body, &meta.content_type, accept)
864 {
865 let mut rsp = HttpResponse::Ok().body(negotiated_body);
866 rsp.headers_mut().insert(
867 header::CONTENT_TYPE,
868 header::HeaderValue::from_str(negotiated_ct)
869 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
870 );
871 rsp.headers_mut()
872 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
873 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
874 rsp.headers_mut().insert(header::ETAG, etag);
875 }
876 set_wac_allow(&mut rsp, &wac_allow);
877 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
878 set_link_headers(&mut rsp, &path);
879 return Ok(rsp);
880 }
881
882 let mut rsp = HttpResponse::Ok().body(body.to_vec());
883 rsp.headers_mut().insert(
884 header::CONTENT_TYPE,
885 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
886 header::HeaderValue::from_static("application/octet-stream")
887 }),
888 );
889 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
890 rsp.headers_mut().insert(header::ETAG, etag);
891 }
892 set_wac_allow(&mut rsp, &wac_allow);
893 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
894 set_link_headers(&mut rsp, &path);
895 Ok(rsp)
896 }
897 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
898 Err(e) => Err(to_actix(e)),
899 }
900}
901
902fn has_basic_container_link(req: &HttpRequest) -> bool {
903 req.headers()
904 .get_all(header::LINK)
905 .filter_map(|v| v.to_str().ok())
906 .any(|v| {
907 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
908 })
909}
910
911async fn handle_put(
912 req: HttpRequest,
913 body: web::Bytes,
914 state: web::Data<AppState>,
915) -> Result<HttpResponse, ActixError> {
916 let path = req.uri().path().to_string();
917
918 if ldp::is_container(&path) {
919 if has_basic_container_link(&req) {
920 let auth_pk = extract_pubkey(&req).await;
921 let agent = agent_uri(auth_pk.as_ref());
922 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
923 let meta = state
924 .storage
925 .create_container(&path)
926 .await
927 .map_err(to_actix)?;
928 let mut rsp = HttpResponse::Created().finish();
929 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
930 rsp.headers_mut().insert(header::ETAG, etag);
931 }
932 set_link_headers(&mut rsp, &path);
933 return Ok(rsp);
934 }
935 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
936 }
937
938 let auth_pk = extract_pubkey(&req).await;
939 let agent = agent_uri(auth_pk.as_ref());
940 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
941
942 let ct = req
943 .headers()
944 .get(header::CONTENT_TYPE)
945 .and_then(|v| v.to_str().ok())
946 .unwrap_or("application/octet-stream");
947
948 if protected_resource_for_acl(&path).is_some()
953 && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
954 {
955 return Ok(HttpResponse::Conflict().body(
956 "refused: the proposed ACL would not grant Control to the caller \
957 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
958 ));
959 }
960
961 let meta = state
962 .storage
963 .put(&path, Bytes::from(body.to_vec()), ct)
964 .await
965 .map_err(to_actix)?;
966 git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
970 let mut rsp = HttpResponse::Created().finish();
971 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
972 rsp.headers_mut().insert(header::ETAG, etag);
973 }
974 set_link_headers(&mut rsp, &path);
975 Ok(rsp)
976}
977
978async fn handle_post(
979 req: HttpRequest,
980 body: web::Bytes,
981 state: web::Data<AppState>,
982) -> Result<HttpResponse, ActixError> {
983 let path = req.uri().path().to_string();
984 let auth_pk = extract_pubkey(&req).await;
987 let agent = agent_uri(auth_pk.as_ref());
988 enforce_write(&state, &path, AccessMode::Append, agent.as_deref()).await?;
989
990 let slug = req
991 .headers()
992 .get(header::HeaderName::from_static("slug"))
993 .and_then(|v| v.to_str().ok());
994 let target = match ldp::resolve_slug(&path, slug) {
995 Ok(p) => p,
996 Err(e) => return Err(to_actix(e)),
997 };
998 let ct = req
999 .headers()
1000 .get(header::CONTENT_TYPE)
1001 .and_then(|v| v.to_str().ok())
1002 .unwrap_or("application/octet-stream");
1003 let meta = state
1004 .storage
1005 .put(&target, Bytes::from(body.to_vec()), ct)
1006 .await
1007 .map_err(to_actix)?;
1008 git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1011 let mut rsp = HttpResponse::Created().finish();
1012 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1013 rsp.headers_mut().insert(header::LOCATION, loc);
1014 }
1015 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1016 rsp.headers_mut().insert(header::ETAG, etag);
1017 }
1018 set_link_headers(&mut rsp, &target);
1019 Ok(rsp)
1020}
1021
1022async fn handle_patch(
1023 req: HttpRequest,
1024 body: web::Bytes,
1025 state: web::Data<AppState>,
1026) -> Result<HttpResponse, ActixError> {
1027 let path = req.uri().path().to_string();
1028 if ldp::is_container(&path) {
1029 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1030 }
1031 let auth_pk = extract_pubkey(&req).await;
1032 let agent = agent_uri(auth_pk.as_ref());
1033 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
1039
1040 let ct = req
1041 .headers()
1042 .get(header::CONTENT_TYPE)
1043 .and_then(|v| v.to_str().ok())
1044 .unwrap_or("");
1045 let dialect = match ldp::patch_dialect_from_mime(ct) {
1046 Some(d) => d,
1047 None => {
1048 return Ok(HttpResponse::UnsupportedMediaType()
1049 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1050 }
1051 };
1052 let body_str = match std::str::from_utf8(&body) {
1053 Ok(s) => s.to_string(),
1054 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1055 };
1056
1057 let existing = state.storage.get(&path).await;
1059 match existing {
1060 Ok((current_body, meta)) => {
1061 let out = match dialect {
1071 ldp::PatchDialect::N3 => {
1072 let seed = seed_graph_from_patch_target(¤t_body)?;
1073 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1074 }
1075 ldp::PatchDialect::SparqlUpdate => {
1076 let seed = seed_graph_from_patch_target(¤t_body)?;
1077 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1078 }
1079 ldp::PatchDialect::JsonPatch => {
1080 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1081 Ok(v) => v,
1082 Err(_) => serde_json::json!({}),
1083 };
1084 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1085 Ok(v) => v,
1086 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1087 };
1088 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1089 let bytes = serde_json::to_vec(&json)
1090 .map_err(PodError::from)
1091 .map_err(to_actix)?;
1092 let _ = state
1093 .storage
1094 .put(&path, Bytes::from(bytes), &meta.content_type)
1095 .await
1096 .map_err(to_actix)?;
1097 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1098 return Ok(HttpResponse::NoContent().finish());
1099 }
1100 };
1101 let outcome = out?;
1102 let serialised = graph_to_turtle(&outcome.graph);
1105 let _ = state
1106 .storage
1107 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1108 .await
1109 .map_err(to_actix)?;
1110 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1111 Ok(HttpResponse::NoContent().finish())
1112 }
1113 Err(PodError::NotFound(_)) => {
1114 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
1116 let PatchCreateOutcome::Created { graph, .. } = create else {
1117 return Err(to_actix(PodError::Unsupported(
1118 "unexpected patch outcome on absent resource".into(),
1119 )));
1120 };
1121 let serialised = graph_to_turtle(&graph);
1122 let _ = state
1123 .storage
1124 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1125 .await
1126 .map_err(to_actix)?;
1127 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1128 Ok(HttpResponse::Created().finish())
1129 }
1130 Err(e) => Err(to_actix(e)),
1131 }
1132}
1133
1134fn patch_parse_err(e: PodError) -> ActixError {
1138 match e {
1139 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
1140 actix_web::error::ErrorBadRequest(msg)
1141 }
1142 other => to_actix(other),
1143 }
1144}
1145
1146fn graph_to_turtle(g: &ldp::Graph) -> String {
1150 g.to_ntriples()
1151}
1152
1153fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
1160 let mut best: Option<(f32, ldp::RdfFormat)> = None;
1161 for entry in accept.split(',') {
1162 let entry = entry.trim();
1163 if entry.is_empty() {
1164 continue;
1165 }
1166 let mut parts = entry.split(';').map(|s| s.trim());
1167 let mime = match parts.next() {
1168 Some(m) => m,
1169 None => continue,
1170 };
1171 let mut q: f32 = 1.0;
1172 for token in parts {
1173 if let Some(v) = token.strip_prefix("q=") {
1174 if let Ok(parsed) = v.parse::<f32>() {
1175 q = parsed;
1176 }
1177 }
1178 }
1179 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
1182 match best {
1183 None => best = Some((q, format)),
1184 Some((bq, _)) if q > bq => best = Some((q, format)),
1185 _ => {}
1186 }
1187 }
1188 }
1189 best.map(|(_, f)| f)
1190}
1191
1192fn rdf_content_negotiate(
1208 body: &[u8],
1209 stored_ct: &str,
1210 accept: &str,
1211) -> Option<(Vec<u8>, &'static str)> {
1212 if accept.trim().is_empty() {
1213 return None;
1214 }
1215 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
1216 let target = best_explicit_rdf_format(accept)?;
1217 if target == stored_format {
1218 return None;
1219 }
1220 let text = std::str::from_utf8(body).ok()?;
1221 let graph = ldp::Graph::parse_ntriples(text).ok()?;
1222 match target {
1223 ldp::RdfFormat::Turtle => {
1226 Some((graph.to_ntriples().into_bytes(), ldp::RdfFormat::Turtle.mime()))
1227 }
1228 ldp::RdfFormat::NTriples => Some((
1229 graph.to_ntriples().into_bytes(),
1230 ldp::RdfFormat::NTriples.mime(),
1231 )),
1232 ldp::RdfFormat::JsonLd => {
1233 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
1234 Some((json, ldp::RdfFormat::JsonLd.mime()))
1235 }
1236 ldp::RdfFormat::RdfXml => None,
1238 }
1239}
1240
1241fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
1250 let text = std::str::from_utf8(current_body).map_err(|_| {
1251 actix_web::error::ErrorConflict(
1252 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
1253 )
1254 })?;
1255 if text.trim().is_empty() {
1256 return Ok(ldp::Graph::new());
1257 }
1258 ldp::Graph::parse_ntriples(text).map_err(|_| {
1259 actix_web::error::ErrorConflict(
1260 "existing resource is not N-Triples RDF and cannot be non-destructively \
1261 patched; PUT an N-Triples representation or use a JSON Patch",
1262 )
1263 })
1264}
1265
1266pub(crate) async fn find_effective_acl_dyn(
1272 storage: &dyn Storage,
1273 resource_path: &str,
1274) -> Result<Option<wac::AclDocument>, PodError> {
1275 let mut path = resource_path.to_string();
1276 let mut inherited = false;
1281 loop {
1282 let acl_key = if path == "/" {
1283 "/.acl".to_string()
1284 } else {
1285 format!("{}.acl", path.trim_end_matches('/'))
1286 };
1287 if let Ok((body, meta)) = storage.get(&acl_key).await {
1288 match parse_jsonld_acl(&body) {
1289 Ok(mut doc) => {
1290 doc.inherited = inherited;
1291 return Ok(Some(doc));
1292 }
1293 Err(PodError::BadRequest(_)) => {
1294 return Err(PodError::BadRequest("ACL document exceeds bounds".into()))
1295 }
1296 Err(_) => {}
1297 }
1298 let ct = meta.content_type.to_ascii_lowercase();
1299 let looks_turtle = ct.starts_with("text/turtle")
1300 || ct.starts_with("application/turtle")
1301 || ct.starts_with("application/x-turtle");
1302 let text = std::str::from_utf8(&body).unwrap_or("");
1303 if looks_turtle || text.contains("@prefix") || text.contains("acl:Authorization") {
1304 if let Ok(mut doc) = parse_turtle_acl(text) {
1305 doc.inherited = inherited;
1306 return Ok(Some(doc));
1307 }
1308 }
1309 }
1310 if path == "/" || path.is_empty() {
1311 break;
1312 }
1313 inherited = true;
1315 let trimmed = path.trim_end_matches('/');
1316 path = match trimmed.rfind('/') {
1317 Some(0) => "/".to_string(),
1318 Some(pos) => trimmed[..pos].to_string(),
1319 None => "/".to_string(),
1320 };
1321 }
1322 Ok(None)
1323}
1324
1325async fn handle_delete(
1326 req: HttpRequest,
1327 state: web::Data<AppState>,
1328) -> Result<HttpResponse, ActixError> {
1329 let path = req.uri().path().to_string();
1330 let auth_pk = extract_pubkey(&req).await;
1331 let agent = agent_uri(auth_pk.as_ref());
1332 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
1333
1334 match state.storage.delete(&path).await {
1335 Ok(()) => Ok(HttpResponse::NoContent().finish()),
1336 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1337 Err(e) => Err(to_actix(e)),
1338 }
1339}
1340
1341async fn handle_options(
1342 req: HttpRequest,
1343 state: web::Data<AppState>,
1344) -> Result<HttpResponse, ActixError> {
1345 let path = req.uri().path().to_string();
1346 let o = ldp::options_for(&path);
1347 let mut rsp = HttpResponse::NoContent().finish();
1348 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
1349 rsp.headers_mut()
1350 .insert(header::HeaderName::from_static("allow"), v);
1351 }
1352 if let Some(ap) = o.accept_post {
1353 if let Ok(v) = header::HeaderValue::from_str(ap) {
1354 rsp.headers_mut()
1355 .insert(header::HeaderName::from_static("accept-post"), v);
1356 }
1357 }
1358 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
1359 rsp.headers_mut()
1360 .insert(header::HeaderName::from_static("accept-patch"), v);
1361 }
1362 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
1363 rsp.headers_mut()
1364 .insert(header::HeaderName::from_static("accept-ranges"), v);
1365 }
1366 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1367 Ok(rsp)
1368}
1369
1370async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
1375 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
1376 HttpResponse::Ok()
1377 .content_type("application/ld+json")
1378 .json(doc)
1379}
1380
1381#[derive(Debug, Deserialize)]
1382struct WebFingerQuery {
1383 resource: Option<String>,
1384}
1385
1386async fn handle_well_known_webfinger(
1387 state: web::Data<AppState>,
1388 q: web::Query<WebFingerQuery>,
1389) -> HttpResponse {
1390 let resource = q.resource.clone().unwrap_or_else(|| {
1391 format!(
1392 "acct:anonymous@{}",
1393 state
1394 .nodeinfo
1395 .base_url
1396 .trim_start_matches("http://")
1397 .trim_start_matches("https://")
1398 )
1399 });
1400 let webid = format!(
1401 "{}/profile/card#me",
1402 state.nodeinfo.base_url.trim_end_matches('/')
1403 );
1404 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
1405 Some(jrd) => HttpResponse::Ok()
1406 .content_type("application/jrd+json")
1407 .json(jrd),
1408 None => HttpResponse::NotFound().finish(),
1409 }
1410}
1411
1412async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
1413 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
1414 HttpResponse::Ok()
1415 .content_type("application/json")
1416 .json(doc)
1417}
1418
1419async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
1420 let doc = interop::nodeinfo_2_1(
1421 &state.nodeinfo.software_name,
1422 &state.nodeinfo.software_version,
1423 state.nodeinfo.open_registrations,
1424 state.nodeinfo.total_users,
1425 );
1426 HttpResponse::Ok()
1427 .content_type("application/json")
1428 .json(doc)
1429}
1430
1431#[cfg(feature = "did-nostr")]
1432async fn handle_well_known_did_nostr(
1433 state: web::Data<AppState>,
1434 path: web::Path<String>,
1435) -> HttpResponse {
1436 let pubkey = path.into_inner();
1437 let also = vec![format!(
1438 "{}/profile/card#me",
1439 state.nodeinfo.base_url.trim_end_matches('/')
1440 )];
1441 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
1442 HttpResponse::Ok()
1443 .content_type("application/did+json")
1444 .json(doc)
1445}
1446
1447#[cfg(feature = "nip05-endpoint")]
1455#[derive(Debug, Deserialize)]
1456struct Nip05Query {
1457 name: Option<String>,
1460}
1461
1462#[cfg(feature = "nip05-endpoint")]
1463fn nip05_name_is_valid(name: &str) -> bool {
1464 if name.is_empty() {
1467 return false;
1468 }
1469 name.bytes()
1470 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
1471}
1472
1473#[cfg(feature = "nip05-endpoint")]
1474async fn handle_well_known_nip05(
1475 state: web::Data<AppState>,
1476 query: web::Query<Nip05Query>,
1477) -> HttpResponse {
1478 use solid_pod_rs::webid::extract_nostr_pubkey;
1479
1480 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
1482 if !nip05_name_is_valid(&name) {
1483 return HttpResponse::BadRequest().json(serde_json::json!({
1484 "error": "invalid NIP-05 local part",
1485 }));
1486 }
1487
1488 let profile_path = if name == "_" {
1494 "/profile/card".to_string()
1495 } else {
1496 format!("/{name}/profile/card")
1497 };
1498
1499 let (body, _meta) = match state.storage.get(&profile_path).await {
1500 Ok(v) => v,
1501 Err(_) => {
1502 return nip05_empty_response();
1506 }
1507 };
1508
1509 let pubkey_hex = match extract_nostr_pubkey(&body) {
1510 Ok(Some(p)) => p,
1511 _ => return nip05_empty_response(),
1512 };
1513
1514 let doc = interop::nip05_document([(name, pubkey_hex)]);
1515 HttpResponse::Ok()
1516 .insert_header(("Access-Control-Allow-Origin", "*"))
1517 .content_type("application/json")
1518 .json(doc)
1519}
1520
1521#[cfg(feature = "nip05-endpoint")]
1522fn nip05_empty_response() -> HttpResponse {
1523 HttpResponse::Ok()
1524 .insert_header(("Access-Control-Allow-Origin", "*"))
1525 .content_type("application/json")
1526 .json(serde_json::json!({ "names": {} }))
1527}
1528
1529#[derive(Debug, Deserialize)]
1534struct CreateAccountRequest {
1535 username: String,
1536 #[serde(default)]
1537 name: Option<String>,
1538}
1539
1540#[derive(Debug, Deserialize)]
1541struct CreatePodRequest {
1542 name: String,
1543}
1544
1545async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
1546 let pod_name = path.into_inner();
1547 let pod_root = format!("/{pod_name}/");
1548 match state.storage.exists(&pod_root).await {
1549 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
1550 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
1551 }
1552}
1553
1554fn valid_pod_name(name: &str) -> bool {
1555 !name.is_empty()
1556 && name
1557 .chars()
1558 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
1559}
1560
1561fn request_ip(req: &HttpRequest) -> IpAddr {
1562 req.peer_addr()
1563 .map(|addr| addr.ip())
1564 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
1565}
1566
1567async fn handle_create_account(
1568 state: web::Data<AppState>,
1569 body: web::Json<CreateAccountRequest>,
1570) -> Result<HttpResponse, ActixError> {
1571 let pod_root = format!("/{}/", body.username);
1572 if state.storage.exists(&pod_root).await.unwrap_or(false) {
1573 return Ok(
1574 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
1575 );
1576 }
1577
1578 let mut plan = provision::ProvisionPlan::new(
1579 body.username.clone(),
1580 format!(
1581 "{}/{}",
1582 state.nodeinfo.base_url.trim_end_matches('/'),
1583 body.username,
1584 ),
1585 );
1586 plan.display_name = body.name.clone();
1587 plan.containers = vec![
1588 format!("/{}/", body.username),
1589 format!("/{}/profile/", body.username),
1590 format!("/{}/inbox/", body.username),
1591 format!("/{}/public/", body.username),
1592 format!("/{}/private/", body.username),
1593 format!("/{}/settings/", body.username),
1594 ];
1595
1596 #[cfg(feature = "git")]
1600 let outcome = {
1601 use solid_pod_rs_git::init::GitAutoInit;
1602 let git_hook = state.data_root.as_ref().map(|root| {
1603 let fs_path = root.join(&body.username);
1604 (GitAutoInit::new(), fs_path)
1605 });
1606 match git_hook {
1607 Some((hook, ref fs_path)) => {
1608 provision::provision_pod_ext(state.storage.as_ref(), &plan, Some((&hook, fs_path)))
1609 .await
1610 }
1611 None => provision::provision_pod(state.storage.as_ref(), &plan).await,
1612 }
1613 };
1614 #[cfg(not(feature = "git"))]
1615 let outcome = provision::provision_pod(state.storage.as_ref(), &plan).await;
1616
1617 match outcome {
1618 Ok(outcome) => Ok(HttpResponse::Created().json(serde_json::json!({
1619 "webid": outcome.webid,
1620 "pod_root": outcome.pod_root,
1621 "username": body.username,
1622 }))),
1623 Err(e) => Err(to_actix(e)),
1624 }
1625}
1626
1627async fn handle_create_pod(
1628 req: HttpRequest,
1629 state: web::Data<AppState>,
1630 body: web::Json<CreatePodRequest>,
1631) -> Result<HttpResponse, ActixError> {
1632 let ip = request_ip(&req);
1633 if let Err(retry_after) = state.pod_create_limiter.check(ip) {
1634 return Ok(HttpResponse::TooManyRequests()
1635 .insert_header(("Retry-After", retry_after.to_string()))
1636 .json(serde_json::json!({
1637 "error": "Too Many Requests",
1638 "message": "Pod creation rate limit exceeded",
1639 "retryAfter": retry_after
1640 })));
1641 }
1642
1643 if !valid_pod_name(&body.name) {
1644 return Ok(HttpResponse::BadRequest().json(serde_json::json!({
1645 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
1646 })));
1647 }
1648
1649 let pod_root = format!("/{}/", body.name);
1650 if state.storage.exists(&pod_root).await.unwrap_or(false) {
1651 return Ok(
1652 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
1653 );
1654 }
1655
1656 let conn = req.connection_info();
1657 let base_uri = format!("{}://{}", conn.scheme(), conn.host());
1658 let pod_uri = format!("{}/{}/", base_uri.trim_end_matches('/'), body.name);
1659
1660 for container in [
1661 format!("/{}/", body.name),
1662 format!("/{}/profile/", body.name),
1663 format!("/{}/inbox/", body.name),
1664 format!("/{}/public/", body.name),
1665 format!("/{}/private/", body.name),
1666 format!("/{}/settings/", body.name),
1667 ] {
1668 let meta_key = format!("{}.meta", container.trim_end_matches('/'));
1669 state
1670 .storage
1671 .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
1672 .await
1673 .map_err(to_actix)?;
1674 }
1675
1676 let canonical_pods_prefix = format!("{}/pods/{}/", base_uri.trim_end_matches('/'), body.name);
1677 let webid = format!("{pod_uri}profile/card#me");
1678 let profile = solid_pod_rs::webid::generate_webid_html(&body.name, None, &base_uri)
1679 .replace(&canonical_pods_prefix, &pod_uri);
1680 state
1681 .storage
1682 .put(
1683 &format!("/{}/profile/card", body.name),
1684 Bytes::from(profile.into_bytes()),
1685 "text/html",
1686 )
1687 .await
1688 .map_err(to_actix)?;
1689
1690 Ok(HttpResponse::Created()
1691 .insert_header(("Location", pod_uri.clone()))
1692 .json(serde_json::json!({
1693 "name": body.name,
1694 "webId": webid,
1695 "podUri": pod_uri,
1696 })))
1697}
1698
1699async fn handle_copy(
1704 req: HttpRequest,
1705 state: web::Data<AppState>,
1706) -> Result<HttpResponse, ActixError> {
1707 let dest = req.uri().path().to_string();
1708 let auth_pk = extract_pubkey(&req).await;
1709 let agent = agent_uri(auth_pk.as_ref());
1710 enforce_write(&state, &dest, AccessMode::Write, agent.as_deref()).await?;
1711
1712 let source = req
1713 .headers()
1714 .get("source")
1715 .and_then(|v| v.to_str().ok())
1716 .map(|s| s.to_string());
1717 let source = match source {
1718 Some(s) => s,
1719 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
1720 };
1721
1722 let (body, meta) = match state.storage.get(&source).await {
1723 Ok(v) => v,
1724 Err(PodError::NotFound(_)) => {
1725 return Ok(HttpResponse::NotFound().body("source resource not found"))
1726 }
1727 Err(e) => return Err(to_actix(e)),
1728 };
1729
1730 state
1731 .storage
1732 .put(&dest, body, &meta.content_type)
1733 .await
1734 .map_err(to_actix)?;
1735
1736 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
1738 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
1739 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
1740 let _ = state
1741 .storage
1742 .put(&dst_acl, acl_body, &acl_meta.content_type)
1743 .await;
1744 }
1745
1746 let mut rsp = HttpResponse::Created().finish();
1747 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
1748 rsp.headers_mut().insert(header::LOCATION, loc);
1749 }
1750 Ok(rsp)
1751}
1752
1753async fn handle_glob_get(
1758 req: HttpRequest,
1759 state: web::Data<AppState>,
1760) -> Result<HttpResponse, ActixError> {
1761 let raw_path = req.uri().path().to_string();
1762 if !raw_path.ends_with("/*") {
1764 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
1765 }
1766 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
1768 folder.to_string()
1769 } else {
1770 format!("{folder}/")
1771 };
1772
1773 let auth_pk = extract_pubkey(&req).await;
1777 let agent = agent_uri(auth_pk.as_ref());
1778 enforce_read(&state, &folder, agent.as_deref()).await?;
1779
1780 let children = state.storage.list(&folder).await.map_err(to_actix)?;
1781 let mut merged = String::new();
1782
1783 for child in &children {
1784 if child.ends_with('/') {
1785 continue;
1786 }
1787 let child_path = format!("{folder}{child}");
1788 if let Ok((body, meta)) = state.storage.get(&child_path).await {
1789 if meta.content_type.contains("turtle")
1790 || meta.content_type.contains("n-triples")
1791 || meta.content_type.contains("n3")
1792 {
1793 if let Ok(text) = std::str::from_utf8(&body) {
1794 merged.push_str(text);
1795 merged.push('\n');
1796 }
1797 }
1798 }
1799 }
1800
1801 if merged.is_empty() {
1802 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
1803 }
1804
1805 Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
1806}
1807
1808#[derive(Debug, Deserialize)]
1813struct LoginPasswordRequest {
1814 username: String,
1815 password: String,
1816}
1817
1818async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
1819 let _ = (&body.username, &body.password);
1820 HttpResponse::Ok().json(serde_json::json!({
1821 "message": "login endpoint active"
1822 }))
1823}
1824
1825#[derive(Debug, Deserialize)]
1826struct PasswordResetRequest {
1827 username: String,
1828}
1829
1830async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
1831 let _ = &body.username;
1832 HttpResponse::Ok().json(serde_json::json!({
1833 "message": "if an account with that username exists, a reset link has been sent"
1834 }))
1835}
1836
1837#[derive(Debug, Deserialize)]
1838struct PasswordChangeRequest {
1839 token: String,
1840 new_password: String,
1841}
1842
1843async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
1844 let _ = (&body.token, &body.new_password);
1845 HttpResponse::Ok().json(serde_json::json!({
1846 "message": "password changed"
1847 }))
1848}
1849
1850async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
1855 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
1856 HttpResponse::Ok()
1857 .content_type("application/json")
1858 .json(body)
1859}
1860
1861pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
1876
1877#[derive(Debug, Deserialize)]
1879struct ProxyQuery {
1880 url: String,
1881}
1882
1883const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
1885 "set-cookie",
1886 "set-cookie2",
1887 "authorization",
1888 "www-authenticate",
1889 "proxy-authenticate",
1890 "proxy-authorization",
1891];
1892
1893fn validate_proxy_target(target: &str) -> Result<url::Url, HttpResponse> {
1899 let parsed = match url::Url::parse(target) {
1900 Ok(u) => u,
1901 Err(_) => {
1902 return Err(
1903 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
1904 );
1905 }
1906 };
1907
1908 match parsed.scheme() {
1910 "http" | "https" => {}
1911 scheme => {
1912 return Err(HttpResponse::BadRequest()
1913 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
1914 }
1915 }
1916
1917 if let Err(_e) = solid_pod_rs::security::is_safe_url(target) {
1919 return Err(HttpResponse::Forbidden()
1920 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1921 }
1922
1923 if let Some(host) = parsed.host_str() {
1925 let host_lower = host.to_ascii_lowercase();
1926 if host_lower == "localhost"
1928 || host_lower.ends_with(".localhost")
1929 || host_lower == "0.0.0.0"
1930 || host_lower == "[::1]"
1931 || host_lower == "[::0]"
1932 {
1933 return Err(HttpResponse::Forbidden()
1934 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1935 }
1936 } else {
1937 return Err(
1938 HttpResponse::BadRequest().json(serde_json::json!({"error": "target URL has no host"}))
1939 );
1940 }
1941
1942 Ok(parsed)
1943}
1944
1945async fn handle_proxy(
1946 req: HttpRequest,
1947 _state: web::Data<AppState>,
1948 query: web::Query<ProxyQuery>,
1949) -> Result<HttpResponse, ActixError> {
1950 let auth_pk = extract_pubkey(&req).await;
1952 let agent = agent_uri(auth_pk.as_ref());
1953 if agent.is_none() {
1954 return Ok(HttpResponse::Unauthorized()
1955 .json(serde_json::json!({"error": "authentication required"})));
1956 }
1957
1958 let _target_url = match validate_proxy_target(&query.url) {
1960 Ok(u) => u,
1961 Err(rsp) => return Ok(rsp),
1962 };
1963
1964 let client = reqwest::Client::builder()
1966 .redirect(reqwest::redirect::Policy::none())
1969 .build()
1970 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))?;
1971
1972 let mut current_url = query.url.clone();
1973 let mut redirect_count = 0u8;
1974 const MAX_REDIRECTS: u8 = 5;
1975
1976 let byte_cap = std::env::var("PROXY_BYTE_CAP")
1977 .ok()
1978 .and_then(|v| {
1979 solid_pod_rs::config::sources::parse_size(&v)
1980 .map(|u| u as usize)
1981 .ok()
1982 })
1983 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
1984
1985 loop {
1986 if redirect_count > 0 {
1988 match validate_proxy_target(¤t_url) {
1989 Ok(_) => {}
1990 Err(rsp) => return Ok(rsp),
1991 }
1992 }
1993
1994 let mut upstream_req = client.get(¤t_url);
1995
1996 if let Some(auth_val) = req
1998 .headers()
1999 .get("x-upstream-authorization")
2000 .and_then(|v| v.to_str().ok())
2001 {
2002 upstream_req = upstream_req.header("Authorization", auth_val);
2003 }
2004
2005 let response = upstream_req
2006 .send()
2007 .await
2008 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
2009
2010 if response.status().is_redirection() {
2012 if redirect_count >= MAX_REDIRECTS {
2013 return Ok(HttpResponse::BadGateway()
2014 .json(serde_json::json!({"error": "too many redirects"})));
2015 }
2016 if let Some(location) = response.headers().get("location") {
2017 let loc_str = location
2018 .to_str()
2019 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
2020 let base = url::Url::parse(¤t_url)
2022 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
2023 let resolved = base
2024 .join(loc_str)
2025 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
2026 current_url = resolved.to_string();
2027 redirect_count += 1;
2028 continue;
2029 }
2030 return Ok(HttpResponse::BadGateway()
2031 .json(serde_json::json!({"error": "redirect without location"})));
2032 }
2033
2034 let upstream_status = response.status().as_u16();
2036 let upstream_content_type = response
2037 .headers()
2038 .get("content-type")
2039 .and_then(|v| v.to_str().ok())
2040 .unwrap_or("application/octet-stream")
2041 .to_string();
2042
2043 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
2045 for (name, value) in response.headers() {
2046 let name_lower = name.as_str().to_ascii_lowercase();
2047 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
2048 continue;
2049 }
2050 if matches!(
2052 name_lower.as_str(),
2053 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
2054 ) {
2055 continue;
2056 }
2057 if let Ok(val_str) = value.to_str() {
2058 forwarded_headers.push((name_lower, val_str.to_string()));
2059 }
2060 }
2061
2062 let body_bytes = response
2063 .bytes()
2064 .await
2065 .map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
2066
2067 if body_bytes.len() > byte_cap {
2068 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
2069 "error": "proxied response exceeds byte cap",
2070 "limit": byte_cap
2071 })));
2072 }
2073
2074 let mut rsp = HttpResponse::build(
2076 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
2077 );
2078 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
2079 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
2080
2081 for (name, value) in &forwarded_headers {
2083 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
2084 if let Ok(hval) = header::HeaderValue::from_str(value) {
2085 rsp.insert_header((hname, hval));
2086 }
2087 }
2088 }
2089
2090 return Ok(rsp.body(body_bytes.to_vec()));
2091 }
2092}
2093
2094pub struct PathTraversalGuard;
2100
2101impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
2102where
2103 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2104 B: 'static,
2105{
2106 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2107 type Error = ActixError;
2108 type InitError = ();
2109 type Transform = PathTraversalGuardMiddleware<S>;
2110 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2111
2112 fn new_transform(&self, service: S) -> Self::Future {
2113 ready(Ok(PathTraversalGuardMiddleware { service }))
2114 }
2115}
2116
2117pub struct PathTraversalGuardMiddleware<S> {
2119 service: S,
2120}
2121
2122impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
2123where
2124 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2125 B: 'static,
2126{
2127 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2128 type Error = ActixError;
2129 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2130
2131 actix_web::dev::forward_ready!(service);
2132
2133 fn call(&self, req: ServiceRequest) -> Self::Future {
2134 let raw = req.path().to_string();
2137 if path_is_traversal(&raw) {
2138 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
2139 let sr = req.into_response(rsp.map_into_boxed_body());
2140 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2141 }
2142 let fut = self.service.call(req);
2143 Box::pin(async move {
2144 let resp = fut.await?;
2145 Ok(resp.map_into_left_body())
2146 })
2147 }
2148}
2149
2150fn path_is_traversal(path: &str) -> bool {
2151 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
2153 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
2154 for seg in once.split('/').chain(twice.split('/')) {
2155 if seg == ".." || seg == "." {
2156 return true;
2157 }
2158 }
2159 if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
2162 return true;
2163 }
2164 false
2165}
2166
2167pub struct CorsHeaders {
2178 pub allowed_origins: Arc<Vec<String>>,
2179}
2180
2181impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
2182where
2183 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2184 B: 'static,
2185{
2186 type Response = ServiceResponse<B>;
2187 type Error = ActixError;
2188 type InitError = ();
2189 type Transform = CorsHeadersMiddleware<S>;
2190 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2191
2192 fn new_transform(&self, service: S) -> Self::Future {
2193 ready(Ok(CorsHeadersMiddleware {
2194 service,
2195 allowed_origins: self.allowed_origins.clone(),
2196 }))
2197 }
2198}
2199
2200pub struct CorsHeadersMiddleware<S> {
2202 service: S,
2203 allowed_origins: Arc<Vec<String>>,
2204}
2205
2206impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
2207where
2208 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2209 B: 'static,
2210{
2211 type Response = ServiceResponse<B>;
2212 type Error = ActixError;
2213 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2214
2215 actix_web::dev::forward_ready!(service);
2216
2217 fn call(&self, req: ServiceRequest) -> Self::Future {
2218 let origin = req
2219 .headers()
2220 .get(header::ORIGIN)
2221 .and_then(|v| v.to_str().ok())
2222 .map(str::to_string);
2223 let allowed = self.allowed_origins.clone();
2224 let fut = self.service.call(req);
2225 Box::pin(async move {
2226 let mut resp = fut.await?;
2227 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
2228 Ok(resp)
2229 })
2230 }
2231}
2232
2233fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
2234 let effective_origin: Option<String> = if allowed.is_empty() {
2236 Some(origin.unwrap_or("*").to_string())
2238 } else {
2239 origin
2241 .filter(|o| allowed.iter().any(|a| a == *o))
2242 .map(str::to_string)
2243 };
2244
2245 let origin_value = match effective_origin {
2248 Some(ref v) => v.as_str(),
2249 None => return,
2250 };
2251
2252 let pairs = [
2253 ("access-control-allow-origin", origin_value),
2254 (
2255 "access-control-allow-methods",
2256 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
2257 ),
2258 (
2259 "access-control-allow-headers",
2260 "Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Range, Slug, Origin",
2261 ),
2262 (
2263 "access-control-expose-headers",
2264 "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",
2265 ),
2266 ("access-control-allow-credentials", "true"),
2267 ("access-control-max-age", "86400"),
2268 ];
2269
2270 for (name, value) in pairs {
2271 if let (Ok(name), Ok(value)) = (
2272 header::HeaderName::from_lowercase(name.as_bytes()),
2273 header::HeaderValue::from_str(value),
2274 ) {
2275 headers.insert(name, value);
2276 }
2277 }
2278}
2279
2280pub struct ErrorLoggingMiddleware;
2296
2297impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
2298where
2299 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2300 B: 'static,
2301{
2302 type Response = ServiceResponse<B>;
2303 type Error = ActixError;
2304 type InitError = ();
2305 type Transform = ErrorLoggingMiddlewareService<S>;
2306 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2307
2308 fn new_transform(&self, service: S) -> Self::Future {
2309 ready(Ok(ErrorLoggingMiddlewareService { service }))
2310 }
2311}
2312
2313pub struct ErrorLoggingMiddlewareService<S> {
2315 service: S,
2316}
2317
2318impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
2319where
2320 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2321 B: 'static,
2322{
2323 type Response = ServiceResponse<B>;
2324 type Error = ActixError;
2325 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2326
2327 actix_web::dev::forward_ready!(service);
2328
2329 fn call(&self, req: ServiceRequest) -> Self::Future {
2330 let method = req.method().as_str().to_string();
2333 let path = req.path().to_string();
2334
2335 let fut = self.service.call(req);
2336 Box::pin(async move {
2337 let response = fut.await?;
2338 let status = response.status();
2339 if status.is_server_error() {
2340 log_5xx(&method, &path, status, response.response().error());
2341 }
2342 Ok(response)
2343 })
2344 }
2345}
2346
2347fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
2351 let chain = match error {
2355 Some(e) => format_error_chain(e),
2356 None => "<no error attached to response>".to_string(),
2357 };
2358
2359 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
2360 Some(std::backtrace::Backtrace::force_capture().to_string())
2361 } else {
2362 None
2363 };
2364
2365 tracing::error!(
2366 target: "solid_pod_rs_server::http",
2367 method = %method,
2368 path = %path,
2369 status = %status.as_u16(),
2370 error.chain = %chain,
2371 backtrace = backtrace.as_deref().unwrap_or(""),
2372 "5xx response"
2373 );
2374}
2375
2376fn format_error_chain(e: &actix_web::Error) -> String {
2387 let summary = format!("{}", e.as_response_error());
2388 let debug = format!("{e:?}");
2389 if debug == summary || debug.is_empty() {
2390 summary
2391 } else {
2392 format!("{summary} -> {debug}")
2393 }
2394}
2395
2396pub struct DotfileGuard {
2402 allow: Arc<DotfileAllowlist>,
2403}
2404
2405impl DotfileGuard {
2406 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
2407 Self { allow }
2408 }
2409}
2410
2411impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
2412where
2413 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2414 B: 'static,
2415{
2416 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2417 type Error = ActixError;
2418 type InitError = ();
2419 type Transform = DotfileGuardMiddleware<S>;
2420 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2421
2422 fn new_transform(&self, service: S) -> Self::Future {
2423 ready(Ok(DotfileGuardMiddleware {
2424 service,
2425 allow: self.allow.clone(),
2426 }))
2427 }
2428}
2429
2430pub struct DotfileGuardMiddleware<S> {
2432 service: S,
2433 allow: Arc<DotfileAllowlist>,
2434}
2435
2436impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
2437where
2438 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2439 B: 'static,
2440{
2441 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2442 type Error = ActixError;
2443 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2444
2445 actix_web::dev::forward_ready!(service);
2446
2447 fn call(&self, req: ServiceRequest) -> Self::Future {
2448 let path = req.path().to_string();
2449 let allow_system_route =
2456 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
2457 if !allow_system_route {
2458 let pb = PathBuf::from(&path);
2459 if !self.allow.is_allowed(Path::new(&pb)) {
2460 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
2461 let sr = req.into_response(rsp.map_into_boxed_body());
2462 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2463 }
2464 }
2465 let fut = self.service.call(req);
2466 Box::pin(async move {
2467 let resp = fut.await?;
2468 Ok(resp.map_into_left_body())
2469 })
2470 }
2471}
2472
2473#[cfg(feature = "git")]
2478pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
2479 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
2480 return None;
2481 }
2482 state.data_root.as_ref().map(|root| root.join(pubkey))
2483}
2484
2485#[cfg(feature = "git")]
2515async fn git_mark_write(state: &AppState, resource_path: &str, agent: Option<&str>, message: &str) {
2516 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
2517 use solid_pod_rs_git::mark::ShellGitMarker;
2518
2519 if resource_path.ends_with(".acl")
2522 || resource_path.ends_with(".meta")
2523 || resource_path.ends_with(".prov.ttl")
2524 {
2525 return;
2526 }
2527 if resource_path.ends_with('/') {
2529 return;
2530 }
2531
2532 let Some(data_root) = state.data_root.as_ref() else {
2534 return;
2535 };
2536
2537 let trimmed = resource_path.trim_start_matches('/');
2539 let mut segments = trimmed.splitn(2, '/');
2540 let pod = segments.next().unwrap_or("");
2541 let rel = segments.next().unwrap_or("");
2542 if pod.is_empty() || rel.is_empty() {
2543 return;
2544 }
2545 let repo = data_root.join(pod);
2546
2547 if !repo.join(".git").is_dir() {
2551 return;
2552 }
2553
2554 let agent_did = agent.unwrap_or("urn:solid:anonymous");
2555 let created = std::time::SystemTime::now()
2556 .duration_since(std::time::UNIX_EPOCH)
2557 .map(|d| d.as_secs())
2558 .unwrap_or(0);
2559
2560 let (policy, ticker_override) =
2563 handlers::prov::resolve_anchor_policy(state, resource_path).await;
2564
2565 let marker = std::sync::Arc::new(ShellGitMarker::new());
2570 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
2571 None
2572 } else {
2573 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
2574 };
2575 let (log, ticker, network) = match &anchorer_bundle {
2576 Some((anchorer, ticker, network)) => (
2577 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
2578 ticker.clone(),
2579 network.clone(),
2580 ),
2581 None => (ProvenanceLog::new(marker.clone()), String::new(), String::new()),
2583 };
2584
2585 let record_policy = match policy {
2589 AnchorPolicy::Epoch => AnchorPolicy::Never,
2590 other => other,
2591 };
2592 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
2593
2594 let write_record = solid_pod_rs::provenance::WriteRecord {
2598 repo: &repo,
2599 path: rel,
2600 agent_did,
2601 message,
2602 policy: record_policy,
2603 high_value,
2604 ticker: &ticker,
2605 network: &network,
2606 created,
2607 };
2608 let mut mark = match log.record(write_record).await {
2609 Ok(m) => m,
2610 Err(e) => {
2611 tracing::warn!(
2612 target: "solid_pod_rs_server::git_mark",
2613 resource = %resource_path,
2614 "provenance record failed (swallowed, write already succeeded): {e}"
2615 );
2616 return;
2617 }
2618 };
2619 mark.resource = resource_path.to_string();
2622
2623 if matches!(policy, AnchorPolicy::Epoch) {
2627 if let Some((anchorer, _, _)) = &anchorer_bundle {
2628 match handlers::prov::epoch_push_and_maybe_anchor(
2629 state,
2630 anchorer,
2631 &ticker,
2632 &network,
2633 &mark.git.commit_sha,
2634 )
2635 .await
2636 {
2637 Ok(Some(closed)) => tracing::debug!(
2638 target: "solid_pod_rs_server::git_mark",
2639 root = %closed.root,
2640 n = closed.commits.len(),
2641 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
2642 ),
2643 Ok(None) => {}
2644 Err(e) => tracing::warn!(
2645 target: "solid_pod_rs_server::git_mark",
2646 "epoch batch/anchor failed (swallowed): {e}"
2647 ),
2648 }
2649 }
2650 }
2651
2652 let ttl = prov_ttl(&mark);
2657 let sidecar = format!("{resource_path}.prov.ttl");
2658 if let Err(e) = state
2659 .storage
2660 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
2661 .await
2662 {
2663 tracing::warn!(
2664 target: "solid_pod_rs_server::git_mark",
2665 sidecar = %sidecar,
2666 "provenance sidecar write failed (swallowed): {e}"
2667 );
2668 return;
2669 }
2670
2671 tracing::debug!(
2672 target: "solid_pod_rs_server::git_mark",
2673 resource = %resource_path,
2674 commit = %mark.git.commit_sha,
2675 anchored = mark.anchor.is_some(),
2676 "provenance recorded"
2677 );
2678}
2679
2680#[cfg(not(feature = "git"))]
2683#[inline]
2684async fn git_mark_write(_state: &AppState, _resource_path: &str, _agent: Option<&str>, _message: &str) {}
2685
2686#[cfg(feature = "git")]
2687pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
2688 let caller = extract_pubkey(req).await?;
2689 if caller != pod_pubkey {
2690 return None;
2691 }
2692 Some(caller)
2693}
2694
2695#[cfg(feature = "git")]
2696fn git_json_err(msg: &str, status: u16) -> HttpResponse {
2697 HttpResponse::build(
2698 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
2699 )
2700 .content_type("application/json")
2701 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
2702}
2703
2704#[cfg(feature = "git")]
2706#[derive(serde::Deserialize)]
2707struct GitStageBody {
2708 paths: Option<Vec<String>>,
2709 all: Option<bool>,
2710}
2711
2712#[cfg(feature = "git")]
2713#[derive(serde::Deserialize)]
2714struct GitCommitBody {
2715 message: String,
2716 author_name: Option<String>,
2717 author_email: Option<String>,
2718}
2719
2720#[cfg(feature = "git")]
2721#[derive(serde::Deserialize)]
2722struct GitBranchBody {
2723 name: String,
2724}
2725
2726#[cfg(feature = "git")]
2729async fn handle_git_status(
2730 path: web::Path<String>,
2731 req: HttpRequest,
2732 state: web::Data<AppState>,
2733) -> HttpResponse {
2734 let pubkey = path.into_inner();
2735 if require_pod_owner(&req, &pubkey).await.is_none() {
2736 return git_json_err("Authentication required", 401);
2737 }
2738 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2739 return git_json_err("Git not available (no FS backend)", 501);
2740 };
2741 match solid_pod_rs_git::api::git_status(&repo).await {
2742 Ok(s) => HttpResponse::Ok()
2743 .content_type("application/json")
2744 .body(serde_json::to_string(&s).unwrap_or_default()),
2745 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2746 }
2747}
2748
2749#[cfg(feature = "git")]
2750async fn handle_git_log(
2751 path: web::Path<String>,
2752 req: HttpRequest,
2753 state: web::Data<AppState>,
2754 query: web::Query<std::collections::HashMap<String, String>>,
2755) -> HttpResponse {
2756 let pubkey = path.into_inner();
2757 if require_pod_owner(&req, &pubkey).await.is_none() {
2758 return git_json_err("Authentication required", 401);
2759 }
2760 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2761 return git_json_err("Git not available (no FS backend)", 501);
2762 };
2763 let limit: u32 = query
2764 .get("limit")
2765 .and_then(|v| v.parse().ok())
2766 .unwrap_or(20);
2767 match solid_pod_rs_git::api::git_log(&repo, limit).await {
2768 Ok(entries) => HttpResponse::Ok()
2769 .content_type("application/json")
2770 .body(serde_json::to_string(&entries).unwrap_or_default()),
2771 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2772 }
2773}
2774
2775#[cfg(feature = "git")]
2776async fn handle_git_diff(
2777 path: web::Path<String>,
2778 req: HttpRequest,
2779 state: web::Data<AppState>,
2780 query: web::Query<std::collections::HashMap<String, String>>,
2781) -> HttpResponse {
2782 let pubkey = path.into_inner();
2783 if require_pod_owner(&req, &pubkey).await.is_none() {
2784 return git_json_err("Authentication required", 401);
2785 }
2786 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2787 return git_json_err("Git not available (no FS backend)", 501);
2788 };
2789 let file_path = query.get("path").map(String::as_str);
2790 let staged = query
2791 .get("staged")
2792 .map(|v| v == "true" || v == "1")
2793 .unwrap_or(false);
2794 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
2795 Ok(diff) => HttpResponse::Ok()
2796 .content_type("text/plain")
2797 .body(diff),
2798 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2799 }
2800}
2801
2802#[cfg(feature = "git")]
2803async fn handle_git_stage(
2804 path: web::Path<String>,
2805 req: HttpRequest,
2806 state: web::Data<AppState>,
2807 body: web::Bytes,
2808) -> HttpResponse {
2809 let pubkey = path.into_inner();
2810 if require_pod_owner(&req, &pubkey).await.is_none() {
2811 return git_json_err("Authentication required", 401);
2812 }
2813 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2814 return git_json_err("Git not available (no FS backend)", 501);
2815 };
2816 let parsed: GitStageBody = match serde_json::from_slice(&body) {
2817 Ok(v) => v,
2818 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2819 };
2820 let paths = parsed.paths.unwrap_or_default();
2821 let all = parsed.all.unwrap_or(false);
2822 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
2823 Ok(()) => HttpResponse::Ok()
2824 .content_type("application/json")
2825 .body(r#"{"ok":true}"#),
2826 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2827 }
2828}
2829
2830#[cfg(feature = "git")]
2831async fn handle_git_unstage(
2832 path: web::Path<String>,
2833 req: HttpRequest,
2834 state: web::Data<AppState>,
2835 body: web::Bytes,
2836) -> HttpResponse {
2837 let pubkey = path.into_inner();
2838 if require_pod_owner(&req, &pubkey).await.is_none() {
2839 return git_json_err("Authentication required", 401);
2840 }
2841 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2842 return git_json_err("Git not available (no FS backend)", 501);
2843 };
2844 let parsed: GitStageBody = match serde_json::from_slice(&body) {
2845 Ok(v) => v,
2846 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2847 };
2848 let paths = parsed.paths.unwrap_or_default();
2849 let all = parsed.all.unwrap_or(false);
2850 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
2851 Ok(()) => HttpResponse::Ok()
2852 .content_type("application/json")
2853 .body(r#"{"ok":true}"#),
2854 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2855 }
2856}
2857
2858#[cfg(feature = "git")]
2859async fn handle_git_commit(
2860 path: web::Path<String>,
2861 req: HttpRequest,
2862 state: web::Data<AppState>,
2863 body: web::Bytes,
2864) -> HttpResponse {
2865 let pubkey = path.into_inner();
2866 if require_pod_owner(&req, &pubkey).await.is_none() {
2867 return git_json_err("Authentication required", 401);
2868 }
2869 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2870 return git_json_err("Git not available (no FS backend)", 501);
2871 };
2872 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
2873 Ok(v) => v,
2874 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2875 };
2876 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
2877 let author_email = parsed
2878 .author_email
2879 .as_deref()
2880 .unwrap_or("pod@dreamlab-ai.com");
2881 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email)
2882 .await
2883 {
2884 Ok(result) => HttpResponse::Ok()
2885 .content_type("application/json")
2886 .body(serde_json::to_string(&result).unwrap_or_default()),
2887 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2888 }
2889}
2890
2891#[cfg(feature = "git")]
2892async fn handle_git_branches(
2893 path: web::Path<String>,
2894 req: HttpRequest,
2895 state: web::Data<AppState>,
2896) -> HttpResponse {
2897 let pubkey = path.into_inner();
2898 if require_pod_owner(&req, &pubkey).await.is_none() {
2899 return git_json_err("Authentication required", 401);
2900 }
2901 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2902 return git_json_err("Git not available (no FS backend)", 501);
2903 };
2904 match solid_pod_rs_git::api::git_branches(&repo).await {
2905 Ok(info) => HttpResponse::Ok()
2906 .content_type("application/json")
2907 .body(serde_json::to_string(&info).unwrap_or_default()),
2908 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2909 }
2910}
2911
2912#[cfg(feature = "git")]
2913async fn handle_git_create_branch(
2914 path: web::Path<String>,
2915 req: HttpRequest,
2916 state: web::Data<AppState>,
2917 body: web::Bytes,
2918) -> HttpResponse {
2919 let pubkey = path.into_inner();
2920 if require_pod_owner(&req, &pubkey).await.is_none() {
2921 return git_json_err("Authentication required", 401);
2922 }
2923 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2924 return git_json_err("Git not available (no FS backend)", 501);
2925 };
2926 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
2927 Ok(v) => v,
2928 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2929 };
2930 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
2931 Ok(()) => HttpResponse::Ok()
2932 .content_type("application/json")
2933 .body(r#"{"ok":true}"#),
2934 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2935 }
2936}
2937
2938#[cfg(feature = "git")]
2939async fn handle_git_discard(
2940 path: web::Path<String>,
2941 req: HttpRequest,
2942 state: web::Data<AppState>,
2943 body: web::Bytes,
2944) -> HttpResponse {
2945 let pubkey = path.into_inner();
2946 if require_pod_owner(&req, &pubkey).await.is_none() {
2947 return git_json_err("Authentication required", 401);
2948 }
2949 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2950 return git_json_err("Git not available (no FS backend)", 501);
2951 };
2952 let parsed: GitStageBody = match serde_json::from_slice(&body) {
2953 Ok(v) => v,
2954 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2955 };
2956 let paths = parsed.paths.unwrap_or_default();
2957 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
2958 Ok(()) => HttpResponse::Ok()
2959 .content_type("application/json")
2960 .body(r#"{"ok":true}"#),
2961 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2962 }
2963}
2964
2965async fn handle_git_panel_options(
2973 req: HttpRequest,
2974 state: web::Data<AppState>,
2975) -> HttpResponse {
2976 let origin = req
2977 .headers()
2978 .get(header::ORIGIN)
2979 .and_then(|v| v.to_str().ok())
2980 .map(str::to_string);
2981
2982 let mut rsp = HttpResponse::NoContent().finish();
2983 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
2984 rsp
2985}
2986
2987async fn handle_admin_provision(
2998 req: HttpRequest,
2999 state: web::Data<AppState>,
3000 path: web::Path<String>,
3001) -> HttpResponse {
3002 let expected = match &state.admin_key {
3004 Some(k) => k.clone(),
3005 None => {
3006 return HttpResponse::Forbidden().json(serde_json::json!({
3007 "error": "admin key not configured on this server"
3008 }));
3009 }
3010 };
3011 let provided = req
3012 .headers()
3013 .get("x-pod-admin-key")
3014 .and_then(|v| v.to_str().ok())
3015 .unwrap_or("");
3016 use subtle::ConstantTimeEq;
3021 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
3022 if !bool::from(key_match) {
3023 return HttpResponse::Forbidden()
3024 .json(serde_json::json!({"error": "invalid admin key"}));
3025 }
3026
3027 let pubkey = path.into_inner();
3029 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
3030 return HttpResponse::BadRequest()
3031 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
3032 }
3033
3034 let data_root = match &state.data_root {
3036 Some(r) => r.clone(),
3037 None => {
3038 return HttpResponse::InternalServerError().json(serde_json::json!({
3039 "error": "server has no fs-backend storage configured"
3040 }));
3041 }
3042 };
3043
3044 let pod_dir = data_root.join(&pubkey);
3045
3046 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
3048 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
3049 return HttpResponse::InternalServerError()
3050 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
3051 }
3052
3053 let acl_content = format!(
3055 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
3056 <#owner> a acl:Authorization ;\n\
3057 acl:agent <did:nostr:{pubkey}> ;\n\
3058 acl:accessTo <./> ;\n\
3059 acl:default <./> ;\n\
3060 acl:mode acl:Read, acl:Write, acl:Control .\n"
3061 );
3062 let acl_path = pod_dir.join(".acl");
3063 if !acl_path.exists() {
3064 if let Err(e) = tokio::fs::write(&acl_path, acl_content.as_bytes()).await {
3065 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write .acl failed");
3066 return HttpResponse::InternalServerError()
3067 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
3068 }
3069 }
3070
3071 #[cfg(feature = "git")]
3073 {
3074 use tokio::process::Command;
3075
3076 if !pod_dir.join(".git").exists() {
3078 let init_out = Command::new("git")
3079 .args([
3080 "init",
3081 "-b",
3082 "main",
3083 pod_dir.to_str().unwrap_or("."),
3084 ])
3085 .output()
3086 .await;
3087
3088 match init_out {
3089 Ok(out) if out.status.success() => {}
3090 Ok(out) => {
3091 let stderr = String::from_utf8_lossy(&out.stderr);
3092 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
3093 }
3094 Err(e) => {
3095 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
3096 }
3097 }
3098
3099 let cfg_out = Command::new("git")
3102 .args([
3103 "-C",
3104 pod_dir.to_str().unwrap_or("."),
3105 "config",
3106 "receive.denyCurrentBranch",
3107 "updateInstead",
3108 ])
3109 .output()
3110 .await;
3111
3112 if let Err(e) = cfg_out {
3113 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
3114 }
3115 }
3116 }
3117
3118 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
3120 HttpResponse::Ok().json(serde_json::json!({
3121 "podUrl": format!("{base_url}/pods/{pubkey}/"),
3122 "ok": true,
3123 }))
3124}
3125
3126async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
3131 let Some(ref data_root) = state.data_root else {
3132 return HttpResponse::Ok()
3133 .content_type("application/json")
3134 .json(serde_json::json!({"apps": [], "count": 0}));
3135 };
3136
3137 let server_url = state.nodeinfo.base_url.clone();
3138
3139 let mut read_dir = match tokio::fs::read_dir(data_root).await {
3141 Ok(rd) => rd,
3142 Err(_) => {
3143 return HttpResponse::Ok()
3144 .content_type("application/json")
3145 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
3146 }
3147 };
3148
3149 let mut apps: Vec<serde_json::Value> = Vec::new();
3150 let mut scanned = 0usize;
3151
3152 while scanned < 1000 {
3153 let entry = match read_dir.next_entry().await {
3154 Ok(Some(e)) => e,
3155 Ok(None) => break,
3156 Err(_) => break,
3157 };
3158
3159 let file_type = match entry.file_type().await {
3160 Ok(ft) => ft,
3161 Err(_) => continue,
3162 };
3163 if !file_type.is_dir() {
3164 continue;
3165 }
3166
3167 scanned += 1;
3168
3169 let manifest_path = entry.path().join("apps").join("manifest.json");
3170 let contents = match tokio::fs::read(&manifest_path).await {
3171 Ok(c) => c,
3172 Err(_) => continue,
3173 };
3174
3175 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
3176 Ok(v) => v,
3177 Err(_) => continue,
3178 };
3179
3180 if let Some(pod_name) = entry.file_name().to_str() {
3182 if manifest.get("podOwner").is_none() {
3183 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
3184 }
3185 }
3186
3187 apps.push(manifest);
3188 }
3189
3190 let count = apps.len();
3191 HttpResponse::Ok()
3192 .content_type("application/json")
3193 .json(serde_json::json!({
3194 "apps": apps,
3195 "serverUrl": server_url,
3196 "count": count,
3197 }))
3198}
3199
3200#[allow(dead_code)]
3213fn is_git_request(path: &str) -> bool {
3214 path.contains("/info/refs")
3215 || path.contains("/git-upload-pack")
3216 || path.contains("/git-receive-pack")
3217}
3218
3219#[allow(dead_code)]
3222fn is_dot_git_path(path: &str) -> bool {
3223 path.contains("/.git/") || path.ends_with("/.git")
3224}
3225
3226#[cfg(feature = "git")]
3227async fn handle_git(
3228 req: HttpRequest,
3229 body: web::Bytes,
3230 state: web::Data<AppState>,
3231) -> HttpResponse {
3232 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
3233 use solid_pod_rs_git::service::{GitHttpService, GitRequest};
3234
3235 let path = req.uri().path().to_string();
3236
3237 let pod_name = path
3240 .trim_start_matches('/')
3241 .split('/')
3242 .next()
3243 .unwrap_or("")
3244 .to_string();
3245 let Some(ref data_root) = state.data_root else {
3246 return HttpResponse::NotImplemented().json(serde_json::json!({
3247 "error": "git requires fs-backend storage",
3248 "reason": "data_root_not_configured"
3249 }));
3250 };
3251 let repo_root = data_root.join(&pod_name);
3252 if !repo_root.exists() {
3253 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
3254 }
3255
3256 let query = req.uri().query().unwrap_or("").to_string();
3257 let host_url = {
3258 let conn = req.connection_info();
3259 Some(format!("{}://{}", conn.scheme(), conn.host()))
3260 };
3261 let headers: Vec<(String, String)> = req
3262 .headers()
3263 .iter()
3264 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
3265 .collect();
3266
3267 let git_req = GitRequest {
3268 method: req.method().as_str().to_string(),
3269 path,
3270 query,
3271 headers,
3272 body: body.into(),
3273 host_url,
3274 };
3275
3276 let is_write = git_req.is_write();
3288 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
3289 Ok(pk) => Some(format!("did:nostr:{pk}")),
3290 Err(_) => None,
3291 };
3292 let wac_path = format!("/{pod_name}/");
3293 let wac = if is_write {
3294 enforce_write(&state, &wac_path, AccessMode::Write, agent.as_deref()).await
3295 } else {
3296 enforce_read(&state, &wac_path, agent.as_deref()).await
3297 };
3298 if let Err(e) = wac {
3299 return e.error_response();
3300 }
3301
3302 let service = GitHttpService::new(repo_root);
3303 match service.handle(git_req).await {
3304 Ok(git_resp) => {
3305 let mut builder = HttpResponse::build(
3306 actix_web::http::StatusCode::from_u16(git_resp.status)
3307 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3308 );
3309 for (k, v) in &git_resp.headers {
3310 builder.insert_header((k.as_str(), v.as_str()));
3311 }
3312 builder.body(git_resp.body)
3313 }
3314 Err(e) => {
3315 let status = e.status_code();
3316 HttpResponse::build(
3317 actix_web::http::StatusCode::from_u16(status)
3318 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3319 )
3320 .json(serde_json::json!({"error": e.to_string()}))
3321 }
3322 }
3323}
3324
3325pub fn build_app(
3337 state: AppState,
3338) -> App<
3339 impl actix_web::dev::ServiceFactory<
3340 ServiceRequest,
3341 Config = (),
3342 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
3343 Error = ActixError,
3344 InitError = (),
3345 >,
3346> {
3347 let body_cap = state.body_cap;
3348 let dotfiles = state.dotfiles.clone();
3349 let allowed_origins = Arc::new(state.allowed_origins.clone());
3350
3351 let mut app = App::new()
3352 .app_data(web::Data::new(state.clone()))
3353 .app_data(web::PayloadConfig::new(body_cap))
3354 .wrap(ErrorLoggingMiddleware)
3359 .wrap(CorsHeaders { allowed_origins })
3360 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
3364 .wrap(PathTraversalGuard)
3365 .wrap(DotfileGuard::new(dotfiles));
3366
3367 app = app
3373 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
3374 .route(
3375 "/.well-known/webfinger",
3376 web::get().to(handle_well_known_webfinger),
3377 )
3378 .route(
3379 "/.well-known/nodeinfo",
3380 web::get().to(handle_well_known_nodeinfo),
3381 )
3382 .route(
3383 "/.well-known/nodeinfo/2.1",
3384 web::get().to(handle_well_known_nodeinfo_2_1),
3385 );
3386
3387 #[cfg(feature = "did-nostr")]
3388 {
3389 app = app.route(
3390 "/.well-known/did/nostr/{pubkey}.json",
3391 web::get().to(handle_well_known_did_nostr),
3392 );
3393 }
3394
3395 #[cfg(feature = "nip05-endpoint")]
3399 {
3400 app = app.route(
3401 "/.well-known/nostr.json",
3402 web::get().to(handle_well_known_nip05),
3403 );
3404 }
3405
3406 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
3408
3409 app = app.route("/pay/.info", web::get().to(handle_pay_info));
3411
3412 app = app.configure(handlers::pay::register);
3417
3418 app = app.route("/proxy", web::get().to(handle_proxy));
3420
3421 if state.mcp_enabled {
3425 app = app
3426 .route("/mcp", web::post().to(mcp::handle_mcp))
3427 .route("/mcp", web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options));
3428 }
3429
3430 app = app.route(
3433 "/_admin/provision/{pubkey}",
3434 web::post().to(handle_admin_provision),
3435 );
3436
3437 app = app
3439 .route("/.pods", web::post().to(handle_create_pod))
3440 .route("/api/accounts/new", web::post().to(handle_create_account))
3441 .route("/pods/check/{name}", web::get().to(handle_pod_check))
3442 .route("/login/password", web::post().to(handle_login_password))
3443 .route(
3444 "/account/password/reset",
3445 web::post().to(handle_password_reset_request),
3446 )
3447 .route(
3448 "/account/password/change",
3449 web::post().to(handle_password_change),
3450 );
3451
3452 app = app
3457 .route(
3458 "/{tail:.*}/.git",
3460 web::route().to(|| async {
3461 HttpResponse::Forbidden()
3462 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
3463 }),
3464 )
3465 .route(
3466 "/{tail:.*}/.git/{rest:.*}",
3467 web::route().to(|| async {
3468 HttpResponse::Forbidden()
3469 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
3470 }),
3471 );
3472
3473 app = app.route(
3477 "/pods/{pk}/_git/{tail:.*}",
3478 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
3479 );
3480
3481 #[cfg(feature = "git")]
3482 {
3483 app = app
3485 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
3486 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
3487 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
3488
3489 app = app
3492 .route(
3493 "/pods/{pubkey}/_git/status",
3494 web::get().to(handle_git_status),
3495 )
3496 .route(
3497 "/pods/{pubkey}/_git/log",
3498 web::get().to(handle_git_log),
3499 )
3500 .route(
3501 "/pods/{pubkey}/_git/diff",
3502 web::get().to(handle_git_diff),
3503 )
3504 .route(
3505 "/pods/{pubkey}/_git/stage",
3506 web::post().to(handle_git_stage),
3507 )
3508 .route(
3509 "/pods/{pubkey}/_git/unstage",
3510 web::post().to(handle_git_unstage),
3511 )
3512 .route(
3513 "/pods/{pubkey}/_git/commit",
3514 web::post().to(handle_git_commit),
3515 )
3516 .route(
3517 "/pods/{pubkey}/_git/branches",
3518 web::get().to(handle_git_branches),
3519 )
3520 .route(
3521 "/pods/{pubkey}/_git/branch",
3522 web::post().to(handle_git_create_branch),
3523 )
3524 .route(
3525 "/pods/{pubkey}/_git/discard",
3526 web::post().to(handle_git_discard),
3527 );
3528
3529 app = app.configure(handlers::prov::register);
3536 }
3537 #[cfg(not(feature = "git"))]
3538 {
3539 let git_501 = || async {
3543 HttpResponse::NotImplemented()
3544 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
3545 };
3546 app = app
3547 .route("/{tail:.*}/info/refs", web::get().to(git_501))
3548 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
3549 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
3550 }
3551
3552 app.route("/{tail:.*}/", web::post().to(handle_post))
3555 .route("/{tail:.*}/", web::put().to(handle_put))
3556 .route("/{tail:.*}", web::get().to(handle_get))
3557 .route("/{tail:.*}", web::head().to(handle_get))
3558 .route("/{tail:.*}", web::put().to(handle_put))
3559 .route("/{tail:.*}", web::patch().to(handle_patch))
3560 .route("/{tail:.*}", web::delete().to(handle_delete))
3561 .route(
3562 "/{tail:.*}",
3563 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
3564 )
3565 .route(
3566 "/{tail:.*}",
3567 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
3568 )
3569}
3570
3571#[cfg(test)]
3576mod payment_gating_tests {
3577 use super::*;
3578 use solid_pod_rs::payments::WebLedger;
3579 use solid_pod_rs::storage::memory::MemoryBackend;
3580
3581 const PRINCIPAL: &str = "did:nostr:alice";
3582
3583 const PAID_WRITE_ACL: &str = r#"
3586@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3587
3588<#paid-write> a acl:Authorization ;
3589 acl:agent <did:nostr:alice> ;
3590 acl:accessTo </premium/inbox> ;
3591 acl:mode acl:Write ;
3592 acl:condition [
3593 a acl:PaymentCondition ;
3594 acl:costSats 100
3595 ] .
3596"#;
3597
3598 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
3599 let mut ledger = WebLedger::new("Test Pod Credits");
3600 if sats > 0 {
3601 ledger.credit(did, sats);
3602 }
3603 let body = serde_json::to_vec(&ledger).unwrap();
3604 storage
3605 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
3606 .await
3607 .unwrap();
3608 }
3609
3610 async fn seed_acl(storage: &dyn Storage) {
3611 storage
3612 .put(
3613 "/premium/inbox.acl",
3614 Bytes::from(PAID_WRITE_ACL),
3615 "text/turtle",
3616 )
3617 .await
3618 .unwrap();
3619 }
3620
3621 #[actix_web::test]
3623 async fn resolve_balance_reads_ledger_entry() {
3624 let storage = MemoryBackend::new();
3625 seed_ledger(&storage, PRINCIPAL, 250).await;
3626 assert_eq!(
3627 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
3628 Some(250)
3629 );
3630 }
3631
3632 #[actix_web::test]
3634 async fn resolve_balance_zero_when_no_entry() {
3635 let storage = MemoryBackend::new();
3636 seed_ledger(&storage, "did:nostr:bob", 500).await;
3637 assert_eq!(resolve_balance_sats(&storage, Some(PRINCIPAL)).await, Some(0));
3638 }
3639
3640 #[actix_web::test]
3642 async fn resolve_balance_none_when_anonymous() {
3643 let storage = MemoryBackend::new();
3644 seed_ledger(&storage, PRINCIPAL, 1_000).await;
3645 assert_eq!(resolve_balance_sats(&storage, None).await, None);
3646 }
3647
3648 #[actix_web::test]
3650 async fn paid_write_denied_below_balance() {
3651 let storage = Arc::new(MemoryBackend::new());
3652 seed_acl(storage.as_ref()).await;
3653 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
3655
3656 let result =
3657 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3658 assert!(
3659 result.is_err(),
3660 "balance 50 < cost 100 must be denied — sat-gating loop closed"
3661 );
3662 }
3663
3664 #[actix_web::test]
3666 async fn paid_write_allowed_at_balance() {
3667 let storage = Arc::new(MemoryBackend::new());
3668 seed_acl(storage.as_ref()).await;
3669 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
3671
3672 let result =
3673 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3674 assert!(
3675 result.is_ok(),
3676 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
3677 );
3678 }
3679
3680 #[actix_web::test]
3682 async fn paid_write_allowed_above_balance() {
3683 let storage = Arc::new(MemoryBackend::new());
3684 seed_acl(storage.as_ref()).await;
3685 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
3686 let state = AppState::new(storage);
3687
3688 let result =
3689 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3690 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
3691 }
3692
3693 #[actix_web::test]
3697 async fn paid_write_anonymous_denied() {
3698 let storage = Arc::new(MemoryBackend::new());
3699 seed_acl(storage.as_ref()).await;
3700 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
3701 let state = AppState::new(storage);
3702
3703 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
3704 assert!(
3705 result.is_err(),
3706 "anonymous caller has no ledger principal — PaymentCondition fails closed"
3707 );
3708 }
3709
3710 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
3717 let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
3718 let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
3719 ledger.get_balance(did)
3720 }
3721
3722 #[actix_web::test]
3724 async fn paid_write_debits_ledger() {
3725 let storage = Arc::new(MemoryBackend::new());
3726 seed_acl(storage.as_ref()).await;
3727 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
3729
3730 let result =
3731 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3732 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
3733 assert_eq!(
3734 read_balance(storage.as_ref(), PRINCIPAL).await,
3735 150,
3736 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
3737 );
3738 }
3739
3740 #[actix_web::test]
3743 async fn paid_write_debits_each_grant() {
3744 let storage = Arc::new(MemoryBackend::new());
3745 seed_acl(storage.as_ref()).await;
3746 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
3748
3749 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
3750 .await
3751 .unwrap();
3752 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
3753 .await
3754 .unwrap();
3755 assert_eq!(
3756 read_balance(storage.as_ref(), PRINCIPAL).await,
3757 50,
3758 "250 - 2*100: each granted request debits, no unmetered re-use"
3759 );
3760
3761 let third =
3763 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3764 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
3765 assert_eq!(
3766 read_balance(storage.as_ref(), PRINCIPAL).await,
3767 50,
3768 "a denied request must not debit"
3769 );
3770 }
3771
3772 #[actix_web::test]
3774 async fn paid_read_debits_ledger() {
3775 const PAID_READ_ACL: &str = r#"
3776@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3777
3778<#paid-read> a acl:Authorization ;
3779 acl:agent <did:nostr:alice> ;
3780 acl:accessTo </premium/feed> ;
3781 acl:mode acl:Read ;
3782 acl:condition [
3783 a acl:PaymentCondition ;
3784 acl:costSats 30
3785 ] .
3786"#;
3787 let storage = Arc::new(MemoryBackend::new());
3788 storage
3789 .put("/premium/feed.acl", Bytes::from(PAID_READ_ACL), "text/turtle")
3790 .await
3791 .unwrap();
3792 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
3793 let state = AppState::new(storage.clone());
3794
3795 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
3796 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
3797 assert_eq!(
3798 read_balance(storage.as_ref(), PRINCIPAL).await,
3799 70,
3800 "100 - 30 cost: a granted paid read must debit"
3801 );
3802 }
3803
3804 #[actix_web::test]
3807 async fn free_read_does_not_debit() {
3808 let storage = Arc::new(MemoryBackend::new());
3809 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
3811 let state = AppState::new(storage.clone());
3812
3813 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
3814 .await
3815 .unwrap();
3816 assert_eq!(
3817 read_balance(storage.as_ref(), PRINCIPAL).await,
3818 100,
3819 "a grant with no PaymentCondition must not debit"
3820 );
3821 }
3822
3823 const ALICE_ONLY_READ_ACL: &str = r#"
3829@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3830
3831<#alice> a acl:Authorization ;
3832 acl:agent <did:nostr:alice> ;
3833 acl:accessTo </private/secret> ;
3834 acl:default </private/> ;
3835 acl:mode acl:Read, acl:Write, acl:Control .
3836"#;
3837
3838 async fn seed_private_read_acl(storage: &dyn Storage) {
3839 storage
3844 .put(
3845 "/private.acl",
3846 Bytes::from(ALICE_ONLY_READ_ACL),
3847 "text/turtle",
3848 )
3849 .await
3850 .unwrap();
3851 }
3852
3853 #[actix_web::test]
3857 async fn enforce_read_grants_owner() {
3858 let storage = Arc::new(MemoryBackend::new());
3859 seed_private_read_acl(storage.as_ref()).await;
3860 let state = AppState::new(storage);
3861 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
3862 assert!(result.is_ok(), "owner alice must be granted Read");
3863 }
3864
3865 #[actix_web::test]
3868 async fn enforce_read_denies_other_principal() {
3869 let storage = Arc::new(MemoryBackend::new());
3870 seed_private_read_acl(storage.as_ref()).await;
3871 let state = AppState::new(storage);
3872 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
3873 assert!(
3874 result.is_err(),
3875 "bob has no Read grant — private resource must not be world-readable"
3876 );
3877 }
3878
3879 #[actix_web::test]
3882 async fn enforce_read_denies_anonymous() {
3883 let storage = Arc::new(MemoryBackend::new());
3884 seed_private_read_acl(storage.as_ref()).await;
3885 let state = AppState::new(storage);
3886 let result = enforce_read(&state, "/private/secret", None).await;
3887 assert!(result.is_err(), "anonymous Read must be denied");
3888 }
3889
3890 const WRITE_NOT_CONTROL_ACL: &str = r#"
3898@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3899
3900<#owner> a acl:Authorization ;
3901 acl:agent <did:nostr:alice> ;
3902 acl:accessTo </shared/doc> ;
3903 acl:default </shared/> ;
3904 acl:mode acl:Read, acl:Write, acl:Control .
3905
3906<#writer> a acl:Authorization ;
3907 acl:agent <did:nostr:writer> ;
3908 acl:accessTo </shared/doc> ;
3909 acl:default </shared/> ;
3910 acl:mode acl:Read, acl:Write .
3911"#;
3912
3913 async fn seed_shared_acl(storage: &dyn Storage) {
3914 storage
3919 .put(
3920 "/shared.acl",
3921 Bytes::from(WRITE_NOT_CONTROL_ACL),
3922 "text/turtle",
3923 )
3924 .await
3925 .unwrap();
3926 }
3927
3928 #[actix_web::test]
3932 async fn acl_put_denied_for_writer_without_control() {
3933 let storage = Arc::new(MemoryBackend::new());
3934 seed_shared_acl(storage.as_ref()).await;
3935 let state = AppState::new(storage);
3936 let result =
3940 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some("did:nostr:writer")).await;
3941 assert!(
3942 result.is_err(),
3943 "writer lacks Control — must not be able to PUT /shared/.acl"
3944 );
3945 }
3946
3947 #[actix_web::test]
3949 async fn acl_put_allowed_for_control_holder() {
3950 let storage = Arc::new(MemoryBackend::new());
3951 seed_shared_acl(storage.as_ref()).await;
3952 let state = AppState::new(storage);
3953 let result =
3954 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
3955 assert!(
3956 result.is_ok(),
3957 "alice holds Control — must be allowed to PUT /shared/.acl"
3958 );
3959 }
3960
3961 #[actix_web::test]
3963 async fn meta_put_denied_for_writer_without_control() {
3964 let storage = Arc::new(MemoryBackend::new());
3965 seed_shared_acl(storage.as_ref()).await;
3966 let state = AppState::new(storage);
3967 let result = enforce_write(
3968 &state,
3969 "/shared/doc.meta",
3970 AccessMode::Write,
3971 Some("did:nostr:writer"),
3972 )
3973 .await;
3974 assert!(
3975 result.is_err(),
3976 "writer lacks Control — must not be able to PUT a .meta sidecar"
3977 );
3978 }
3979
3980 #[test]
3982 fn protected_resource_for_acl_strips_suffixes() {
3983 assert_eq!(protected_resource_for_acl("/victim/.acl").as_deref(), Some("/victim/"));
3984 assert_eq!(protected_resource_for_acl("/a/b.acl").as_deref(), Some("/a/b"));
3985 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
3986 assert_eq!(protected_resource_for_acl("/a/b.meta").as_deref(), Some("/a/b"));
3987 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
3988 }
3989}