1#![doc = include_str!("../README.md")]
53#![deny(unsafe_code)]
54#![warn(rust_2018_idioms)]
55
56pub mod cli;
58
59mod mcp;
62
63use std::collections::HashMap;
64use std::net::{IpAddr, Ipv4Addr};
65use std::path::{Path, PathBuf};
66use std::sync::{Arc, Mutex};
67use std::time::{Duration, Instant};
68
69use actix_web::body::{BoxBody, EitherBody};
70use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
71use actix_web::http::{header, StatusCode};
72use actix_web::middleware::{NormalizePath, TrailingSlash};
73use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
74use bytes::Bytes;
75use futures_util::future::{ready, LocalBoxFuture, Ready};
76use percent_encoding::percent_decode_str;
77use serde::Deserialize;
78use solid_pod_rs::{
79 auth::nip98,
80 config::sources::parse_size,
81 interop,
82 ldp::{self, LdpContainerOps, PatchCreateOutcome},
83 mashlib::{self, MashlibConfig},
84 provision,
85 security::DotfileAllowlist,
86 storage::Storage,
87 wac::{
88 self, conditions::RequestContext, parse_jsonld_acl, parser::parse_turtle_acl, AccessMode,
89 },
90 PodError,
91};
92
93#[derive(Clone)]
99pub struct AppState {
100 pub storage: Arc<dyn Storage>,
101 pub dotfiles: Arc<DotfileAllowlist>,
102 pub body_cap: usize,
103 pub nodeinfo: NodeInfoMeta,
104 pub mashlib: MashlibConfig,
105 pub mashlib_cdn: Option<String>,
108 pub pay_config: solid_pod_rs::payments::PayConfig,
111 pub data_root: Option<PathBuf>,
116 pub pod_create_limiter: Arc<PodCreateLimiter>,
118 pub allowed_origins: Vec<String>,
125 pub admin_key: Option<String>,
130 pub mcp_enabled: bool,
135}
136
137#[derive(Clone, Debug)]
139pub struct NodeInfoMeta {
140 pub software_name: String,
141 pub software_version: String,
142 pub open_registrations: bool,
143 pub total_users: u64,
144 pub base_url: String,
145}
146
147impl Default for NodeInfoMeta {
148 fn default() -> Self {
149 Self {
150 software_name: "solid-pod-rs-server".to_string(),
151 software_version: env!("CARGO_PKG_VERSION").to_string(),
152 open_registrations: false,
153 total_users: 0,
154 base_url: "http://localhost".to_string(),
155 }
156 }
157}
158
159pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
162
163pub fn body_cap_from_env() -> usize {
166 match std::env::var("JSS_MAX_REQUEST_BODY") {
167 Ok(v) => parse_size(&v)
168 .map(|u| u as usize)
169 .unwrap_or(DEFAULT_BODY_CAP),
170 Err(_) => DEFAULT_BODY_CAP,
171 }
172}
173
174impl AppState {
175 pub fn new(storage: Arc<dyn Storage>) -> Self {
178 Self {
179 storage,
180 dotfiles: Arc::new(DotfileAllowlist::from_env()),
181 body_cap: body_cap_from_env(),
182 nodeinfo: NodeInfoMeta::default(),
183 mashlib: MashlibConfig::default(),
184 mashlib_cdn: None,
185 pay_config: solid_pod_rs::payments::PayConfig::default(),
186 data_root: None,
187 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
188 allowed_origins: Vec::new(),
189 admin_key: None,
190 mcp_enabled: false,
191 }
192 }
193}
194
195#[derive(Debug)]
197pub struct PodCreateLimiter {
198 hits: Mutex<HashMap<IpAddr, Instant>>,
199 window: Duration,
200}
201
202impl Default for PodCreateLimiter {
203 fn default() -> Self {
204 Self {
205 hits: Mutex::new(HashMap::new()),
206 window: Duration::from_secs(24 * 60 * 60),
207 }
208 }
209}
210
211impl PodCreateLimiter {
212 fn check(&self, ip: IpAddr) -> Result<(), u64> {
213 let now = Instant::now();
214 let mut hits = self.hits.lock().unwrap();
215 if let Some(last) = hits.get(&ip).copied() {
216 let elapsed = now.saturating_duration_since(last);
217 if elapsed < self.window {
218 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
219 }
220 }
221 hits.insert(ip, now);
222 Ok(())
223 }
224}
225
226fn to_actix(e: PodError) -> ActixError {
231 match e {
232 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
233 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
234 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
235 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
236 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
237 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
238 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
239 }
240}
241
242async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
248 let header_val = req
249 .headers()
250 .get(header::AUTHORIZATION)
251 .and_then(|v| v.to_str().ok())?;
252 let conn = req.connection_info();
260 let url = format!("{}://{}{}", conn.scheme(), conn.host(), req.uri().path());
261 nip98::verify(header_val, &url, req.method().as_str(), None)
262 .await
263 .ok()
264}
265
266fn agent_uri(pubkey: Option<&String>) -> Option<String> {
267 pubkey.map(|pk| format!("did:nostr:{pk}"))
268}
269
270const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
274
275async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
292 let did = agent_uri?;
293 let balance = match storage.get(WEBLEDGER_PATH).await {
294 Ok((bytes, _meta)) => {
295 match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
296 Ok(ledger) => ledger.get_balance(did),
297 Err(_) => 0,
301 }
302 }
303 Err(_) => 0,
306 };
307 Some(balance)
308}
309
310fn accept_includes_html(accept: &str) -> bool {
318 accept.split(',').any(|entry| {
319 let mime = entry.split(';').next().unwrap_or("").trim();
320 mime.eq_ignore_ascii_case("text/html")
321 })
322}
323
324fn protected_resource_for_acl(path: &str) -> Option<String> {
340 for suffix in [".acl", ".meta"] {
341 if let Some(stripped) = path.strip_suffix(suffix) {
342 if stripped.is_empty() {
346 return Some("/".to_string());
347 }
348 return Some(stripped.to_string());
349 }
350 }
351 None
352}
353
354fn proposed_acl_keeps_caller_control(body: &[u8], content_type: &str, caller: Option<&str>) -> bool {
362 let doc = match parse_jsonld_acl(body) {
363 Ok(d) => Some(d),
364 Err(_) => {
365 let ct = content_type.to_ascii_lowercase();
366 let text = std::str::from_utf8(body).unwrap_or("");
367 let looks_turtle = ct.starts_with("text/turtle")
368 || ct.starts_with("application/turtle")
369 || ct.starts_with("application/x-turtle")
370 || text.contains("@prefix")
371 || text.contains("acl:Authorization");
372 if looks_turtle {
373 parse_turtle_acl(text).ok()
374 } else {
375 None
376 }
377 }
378 };
379 let Some(doc) = doc else {
380 return true;
382 };
383 let Some(graph) = doc.graph.as_ref() else {
384 return false;
385 };
386 graph.iter().any(|auth| {
387 let grants_control = ids_of_acl_field(&auth.mode)
388 .iter()
389 .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
390 if !grants_control {
391 return false;
392 }
393 let agents = ids_of_acl_field(&auth.agent);
394 if let Some(web_id) = caller {
395 if agents.iter().any(|a| *a == web_id) {
396 return true;
397 }
398 }
399 let classes = ids_of_acl_field(&auth.agent_class);
400 if classes
401 .iter()
402 .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
403 {
404 return true;
405 }
406 if caller.is_some()
407 && classes.iter().any(|c| {
408 *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
409 || *c == "acl:AuthenticatedAgent"
410 })
411 {
412 return true;
413 }
414 false
415 })
416}
417
418fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
420 match field {
421 None => Vec::new(),
422 Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
423 Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
424 }
425}
426
427async fn enforce_write(
428 state: &AppState,
429 path: &str,
430 mode: AccessMode,
431 agent_uri: Option<&str>,
432) -> Result<(), ActixError> {
433 if let Some(protected) = protected_resource_for_acl(path) {
442 let control_acl = match find_effective_acl_dyn(&*state.storage, &protected).await {
443 Ok(doc) => doc,
444 Err(e) => return Err(to_actix(e)),
445 };
446 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
447 let ctx = RequestContext {
448 web_id: agent_uri,
449 client_id: None,
450 issuer: None,
451 payment_balance_sats,
452 };
453 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
454 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
455 let has_control = wac::evaluate_access_ctx_with_registry(
456 control_acl.as_ref(),
457 &ctx,
458 &protected,
459 AccessMode::Control,
460 None,
461 &groups,
462 ®istry,
463 );
464 if !has_control {
465 return Err(acl_denial(control_acl.as_ref(), agent_uri, &protected));
466 }
467 return Ok(());
468 }
469
470 let acl_doc = match find_effective_acl_dyn(&*state.storage, path).await {
475 Ok(doc) => doc,
476 Err(e) => return Err(to_actix(e)),
477 };
478
479 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
484
485 let ctx = RequestContext {
486 web_id: agent_uri,
487 client_id: None,
488 issuer: None,
489 payment_balance_sats,
490 };
491 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
492 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
493 let granted = wac::evaluate_access_ctx_with_registry(
494 acl_doc.as_ref(),
495 &ctx,
496 path,
497 mode,
498 None,
499 &groups,
500 ®istry,
501 );
502 if granted {
503 if let Err(e) =
511 charge_granted_payment(state, acl_doc.as_ref(), &ctx, path, mode, &groups, ®istry)
512 .await
513 {
514 return Err(e);
515 }
516 return Ok(());
517 }
518
519 Err(acl_denial(acl_doc.as_ref(), agent_uri, path))
520}
521
522async fn charge_granted_payment(
531 state: &AppState,
532 acl_doc: Option<&wac::AclDocument>,
533 ctx: &RequestContext<'_>,
534 path: &str,
535 mode: AccessMode,
536 groups: &wac::StaticGroupMembership,
537 registry: &wac::conditions::ConditionRegistry,
538) -> Result<(), ActixError> {
539 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
540 if cost == 0 {
541 return Ok(());
542 }
543 if let Some(did) = ctx.web_id {
544 if debit_ledger(&*state.storage, did, cost).await.is_err() {
545 return Err(acl_denial(acl_doc, ctx.web_id, path));
546 }
547 }
548 Ok(())
549}
550
551fn acl_denial(
557 acl_doc: Option<&wac::AclDocument>,
558 agent_uri: Option<&str>,
559 path: &str,
560) -> ActixError {
561 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
562 let (status, body, unauthenticated) = if agent_uri.is_none() {
563 (StatusCode::UNAUTHORIZED, "authentication required", true)
564 } else {
565 (StatusCode::FORBIDDEN, "access forbidden", false)
566 };
567 let mut rsp = HttpResponse::new(status);
568 rsp.headers_mut().insert(
569 header::HeaderName::from_static("wac-allow"),
570 header::HeaderValue::from_str(&allow_header)
571 .unwrap_or(header::HeaderValue::from_static("")),
572 );
573 if unauthenticated {
574 rsp.headers_mut().insert(
581 header::WWW_AUTHENTICATE,
582 header::HeaderValue::from_static(
583 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
584 ),
585 );
586 }
587 actix_web::error::InternalError::from_response(body, rsp).into()
588}
589
590async fn enforce_read(
598 state: &AppState,
599 path: &str,
600 agent_uri: Option<&str>,
601) -> Result<(), ActixError> {
602 let acl_doc = match find_effective_acl_dyn(&*state.storage, path).await {
603 Ok(doc) => doc,
604 Err(e) => return Err(to_actix(e)),
605 };
606 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
607 let ctx = RequestContext {
608 web_id: agent_uri,
609 client_id: None,
610 issuer: None,
611 payment_balance_sats,
612 };
613 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
614 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
615 let granted = wac::evaluate_access_ctx_with_registry(
616 acl_doc.as_ref(),
617 &ctx,
618 path,
619 AccessMode::Read,
620 None,
621 &groups,
622 ®istry,
623 );
624 if granted {
625 charge_granted_payment(
630 state,
631 acl_doc.as_ref(),
632 &ctx,
633 path,
634 AccessMode::Read,
635 &groups,
636 ®istry,
637 )
638 .await?;
639 return Ok(());
640 }
641 Err(acl_denial(acl_doc.as_ref(), agent_uri, path))
642}
643
644async fn debit_ledger(
653 storage: &dyn Storage,
654 did: &str,
655 cost: u64,
656) -> Result<(), solid_pod_rs::payments::PaymentError> {
657 use solid_pod_rs::payments::{PaymentError, WebLedger};
658
659 let (bytes, _meta) = storage
660 .get(WEBLEDGER_PATH)
661 .await
662 .map_err(|e| PaymentError::Store(e.to_string()))?;
663 let mut ledger: WebLedger = serde_json::from_slice(&bytes)
664 .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
665 ledger.debit(did, cost)?;
666 let body = serde_json::to_vec(&ledger)
667 .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
668 storage
669 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
670 .await
671 .map_err(|e| PaymentError::Store(e.to_string()))?;
672 Ok(())
673}
674
675fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
680 let links = ldp::link_headers(path).join(", ");
681 if let Ok(value) = header::HeaderValue::from_str(&links) {
682 rsp.headers_mut()
683 .insert(header::HeaderName::from_static("link"), value);
684 }
685}
686
687fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
688 if let Ok(v) = header::HeaderValue::from_str(header_value) {
689 rsp.headers_mut()
690 .insert(header::HeaderName::from_static("wac-allow"), v);
691 }
692}
693
694fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
695 let ws_base = base_url
696 .replacen("https://", "wss://", 1)
697 .replacen("http://", "ws://", 1);
698 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
699 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
700 rsp.headers_mut()
701 .insert(header::HeaderName::from_static("updates-via"), v);
702 }
703}
704
705async fn handle_get(
706 req: HttpRequest,
707 state: web::Data<AppState>,
708) -> Result<HttpResponse, ActixError> {
709 let path = req.uri().path().to_string();
710
711 if path.contains('*') {
712 return handle_glob_get(req, state).await;
713 }
714
715 let auth_pk = extract_pubkey(&req).await;
716 let agent = agent_uri(auth_pk.as_ref());
717
718 enforce_read(&state, &path, agent.as_deref()).await?;
723
724 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
725
726 if ldp::is_container(&path) {
727 let accept = req
728 .headers()
729 .get(header::ACCEPT)
730 .and_then(|v| v.to_str().ok())
731 .unwrap_or("");
732
733 if accept_includes_html(accept) {
739 let index_path = format!("{}index.html", &path);
740 if let Ok((body, _meta)) = state.storage.get(&index_path).await {
741 let mut rsp = HttpResponse::Ok()
742 .content_type("text/html; charset=utf-8")
743 .body(body.to_vec());
744 set_wac_allow(&mut rsp, &wac_allow);
745 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
746 set_link_headers(&mut rsp, &path);
747 return Ok(rsp);
748 }
749 }
750
751 let v = state
752 .storage
753 .container_representation(&path)
754 .await
755 .map_err(to_actix)?;
756
757 let sec_fetch_dest = req
759 .headers()
760 .get("sec-fetch-dest")
761 .and_then(|v| v.to_str().ok());
762 if mashlib::should_serve(
763 accept,
764 sec_fetch_dest,
765 "application/ld+json",
766 state.mashlib.enabled,
767 ) {
768 let json_ld = serde_json::to_string(&v).ok();
769 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
770 let mut rsp = HttpResponse::Ok()
771 .content_type("text/html; charset=utf-8")
772 .insert_header(("X-Frame-Options", "DENY"))
773 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
774 .insert_header(("Cache-Control", "no-store"))
775 .body(html);
776 set_wac_allow(&mut rsp, &wac_allow);
777 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
778 set_link_headers(&mut rsp, &path);
779 return Ok(rsp);
780 }
781
782 let mut rsp = HttpResponse::Ok().json(v);
783 rsp.headers_mut().insert(
784 header::CONTENT_TYPE,
785 header::HeaderValue::from_static("application/ld+json"),
786 );
787 set_wac_allow(&mut rsp, &wac_allow);
788 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
789 set_link_headers(&mut rsp, &path);
790 return Ok(rsp);
791 }
792
793 match state.storage.get(&path).await {
794 Ok((body, meta)) => {
795 let accept = req
797 .headers()
798 .get(header::ACCEPT)
799 .and_then(|v| v.to_str().ok())
800 .unwrap_or("");
801 let sec_fetch_dest = req
802 .headers()
803 .get("sec-fetch-dest")
804 .and_then(|v| v.to_str().ok());
805 if mashlib::should_serve(
806 accept,
807 sec_fetch_dest,
808 &meta.content_type,
809 state.mashlib.enabled,
810 ) {
811 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
812 std::str::from_utf8(&body).ok().map(|s| s.to_string())
813 } else {
814 None
815 };
816 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
817 let mut rsp = HttpResponse::Ok()
818 .content_type("text/html; charset=utf-8")
819 .insert_header(("X-Frame-Options", "DENY"))
820 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
821 .insert_header(("Cache-Control", "no-store"))
822 .body(html);
823 set_wac_allow(&mut rsp, &wac_allow);
824 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
825 set_link_headers(&mut rsp, &path);
826 return Ok(rsp);
827 }
828
829 if let Some((negotiated_body, negotiated_ct)) =
837 rdf_content_negotiate(&body, &meta.content_type, accept)
838 {
839 let mut rsp = HttpResponse::Ok().body(negotiated_body);
840 rsp.headers_mut().insert(
841 header::CONTENT_TYPE,
842 header::HeaderValue::from_str(negotiated_ct)
843 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
844 );
845 rsp.headers_mut()
846 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
847 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
848 rsp.headers_mut().insert(header::ETAG, etag);
849 }
850 set_wac_allow(&mut rsp, &wac_allow);
851 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
852 set_link_headers(&mut rsp, &path);
853 return Ok(rsp);
854 }
855
856 let mut rsp = HttpResponse::Ok().body(body.to_vec());
857 rsp.headers_mut().insert(
858 header::CONTENT_TYPE,
859 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
860 header::HeaderValue::from_static("application/octet-stream")
861 }),
862 );
863 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
864 rsp.headers_mut().insert(header::ETAG, etag);
865 }
866 set_wac_allow(&mut rsp, &wac_allow);
867 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
868 set_link_headers(&mut rsp, &path);
869 Ok(rsp)
870 }
871 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
872 Err(e) => Err(to_actix(e)),
873 }
874}
875
876fn has_basic_container_link(req: &HttpRequest) -> bool {
877 req.headers()
878 .get_all(header::LINK)
879 .filter_map(|v| v.to_str().ok())
880 .any(|v| {
881 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
882 })
883}
884
885async fn handle_put(
886 req: HttpRequest,
887 body: web::Bytes,
888 state: web::Data<AppState>,
889) -> Result<HttpResponse, ActixError> {
890 let path = req.uri().path().to_string();
891
892 if ldp::is_container(&path) {
893 if has_basic_container_link(&req) {
894 let auth_pk = extract_pubkey(&req).await;
895 let agent = agent_uri(auth_pk.as_ref());
896 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
897 let meta = state
898 .storage
899 .create_container(&path)
900 .await
901 .map_err(to_actix)?;
902 let mut rsp = HttpResponse::Created().finish();
903 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
904 rsp.headers_mut().insert(header::ETAG, etag);
905 }
906 set_link_headers(&mut rsp, &path);
907 return Ok(rsp);
908 }
909 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
910 }
911
912 let auth_pk = extract_pubkey(&req).await;
913 let agent = agent_uri(auth_pk.as_ref());
914 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
915
916 let ct = req
917 .headers()
918 .get(header::CONTENT_TYPE)
919 .and_then(|v| v.to_str().ok())
920 .unwrap_or("application/octet-stream");
921
922 if protected_resource_for_acl(&path).is_some()
927 && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
928 {
929 return Ok(HttpResponse::Conflict().body(
930 "refused: the proposed ACL would not grant Control to the caller \
931 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
932 ));
933 }
934
935 let meta = state
936 .storage
937 .put(&path, Bytes::from(body.to_vec()), ct)
938 .await
939 .map_err(to_actix)?;
940 let mut rsp = HttpResponse::Created().finish();
941 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
942 rsp.headers_mut().insert(header::ETAG, etag);
943 }
944 set_link_headers(&mut rsp, &path);
945 Ok(rsp)
946}
947
948async fn handle_post(
949 req: HttpRequest,
950 body: web::Bytes,
951 state: web::Data<AppState>,
952) -> Result<HttpResponse, ActixError> {
953 let path = req.uri().path().to_string();
954 let auth_pk = extract_pubkey(&req).await;
957 let agent = agent_uri(auth_pk.as_ref());
958 enforce_write(&state, &path, AccessMode::Append, agent.as_deref()).await?;
959
960 let slug = req
961 .headers()
962 .get(header::HeaderName::from_static("slug"))
963 .and_then(|v| v.to_str().ok());
964 let target = match ldp::resolve_slug(&path, slug) {
965 Ok(p) => p,
966 Err(e) => return Err(to_actix(e)),
967 };
968 let ct = req
969 .headers()
970 .get(header::CONTENT_TYPE)
971 .and_then(|v| v.to_str().ok())
972 .unwrap_or("application/octet-stream");
973 let meta = state
974 .storage
975 .put(&target, Bytes::from(body.to_vec()), ct)
976 .await
977 .map_err(to_actix)?;
978 let mut rsp = HttpResponse::Created().finish();
979 if let Ok(loc) = header::HeaderValue::from_str(&target) {
980 rsp.headers_mut().insert(header::LOCATION, loc);
981 }
982 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
983 rsp.headers_mut().insert(header::ETAG, etag);
984 }
985 set_link_headers(&mut rsp, &target);
986 Ok(rsp)
987}
988
989async fn handle_patch(
990 req: HttpRequest,
991 body: web::Bytes,
992 state: web::Data<AppState>,
993) -> Result<HttpResponse, ActixError> {
994 let path = req.uri().path().to_string();
995 if ldp::is_container(&path) {
996 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
997 }
998 let auth_pk = extract_pubkey(&req).await;
999 let agent = agent_uri(auth_pk.as_ref());
1000 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
1006
1007 let ct = req
1008 .headers()
1009 .get(header::CONTENT_TYPE)
1010 .and_then(|v| v.to_str().ok())
1011 .unwrap_or("");
1012 let dialect = match ldp::patch_dialect_from_mime(ct) {
1013 Some(d) => d,
1014 None => {
1015 return Ok(HttpResponse::UnsupportedMediaType()
1016 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1017 }
1018 };
1019 let body_str = match std::str::from_utf8(&body) {
1020 Ok(s) => s.to_string(),
1021 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1022 };
1023
1024 let existing = state.storage.get(&path).await;
1026 match existing {
1027 Ok((current_body, meta)) => {
1028 let out = match dialect {
1038 ldp::PatchDialect::N3 => {
1039 let seed = seed_graph_from_patch_target(¤t_body)?;
1040 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1041 }
1042 ldp::PatchDialect::SparqlUpdate => {
1043 let seed = seed_graph_from_patch_target(¤t_body)?;
1044 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1045 }
1046 ldp::PatchDialect::JsonPatch => {
1047 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1048 Ok(v) => v,
1049 Err(_) => serde_json::json!({}),
1050 };
1051 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1052 Ok(v) => v,
1053 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1054 };
1055 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1056 let bytes = serde_json::to_vec(&json)
1057 .map_err(PodError::from)
1058 .map_err(to_actix)?;
1059 let _ = state
1060 .storage
1061 .put(&path, Bytes::from(bytes), &meta.content_type)
1062 .await
1063 .map_err(to_actix)?;
1064 return Ok(HttpResponse::NoContent().finish());
1065 }
1066 };
1067 let outcome = out?;
1068 let serialised = graph_to_turtle(&outcome.graph);
1071 let _ = state
1072 .storage
1073 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1074 .await
1075 .map_err(to_actix)?;
1076 Ok(HttpResponse::NoContent().finish())
1077 }
1078 Err(PodError::NotFound(_)) => {
1079 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
1081 let PatchCreateOutcome::Created { graph, .. } = create else {
1082 return Err(to_actix(PodError::Unsupported(
1083 "unexpected patch outcome on absent resource".into(),
1084 )));
1085 };
1086 let serialised = graph_to_turtle(&graph);
1087 let _ = state
1088 .storage
1089 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1090 .await
1091 .map_err(to_actix)?;
1092 Ok(HttpResponse::Created().finish())
1093 }
1094 Err(e) => Err(to_actix(e)),
1095 }
1096}
1097
1098fn patch_parse_err(e: PodError) -> ActixError {
1102 match e {
1103 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
1104 actix_web::error::ErrorBadRequest(msg)
1105 }
1106 other => to_actix(other),
1107 }
1108}
1109
1110fn graph_to_turtle(g: &ldp::Graph) -> String {
1114 g.to_ntriples()
1115}
1116
1117fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
1124 let mut best: Option<(f32, ldp::RdfFormat)> = None;
1125 for entry in accept.split(',') {
1126 let entry = entry.trim();
1127 if entry.is_empty() {
1128 continue;
1129 }
1130 let mut parts = entry.split(';').map(|s| s.trim());
1131 let mime = match parts.next() {
1132 Some(m) => m,
1133 None => continue,
1134 };
1135 let mut q: f32 = 1.0;
1136 for token in parts {
1137 if let Some(v) = token.strip_prefix("q=") {
1138 if let Ok(parsed) = v.parse::<f32>() {
1139 q = parsed;
1140 }
1141 }
1142 }
1143 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
1146 match best {
1147 None => best = Some((q, format)),
1148 Some((bq, _)) if q > bq => best = Some((q, format)),
1149 _ => {}
1150 }
1151 }
1152 }
1153 best.map(|(_, f)| f)
1154}
1155
1156fn rdf_content_negotiate(
1172 body: &[u8],
1173 stored_ct: &str,
1174 accept: &str,
1175) -> Option<(Vec<u8>, &'static str)> {
1176 if accept.trim().is_empty() {
1177 return None;
1178 }
1179 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
1180 let target = best_explicit_rdf_format(accept)?;
1181 if target == stored_format {
1182 return None;
1183 }
1184 let text = std::str::from_utf8(body).ok()?;
1185 let graph = ldp::Graph::parse_ntriples(text).ok()?;
1186 match target {
1187 ldp::RdfFormat::Turtle => {
1190 Some((graph.to_ntriples().into_bytes(), ldp::RdfFormat::Turtle.mime()))
1191 }
1192 ldp::RdfFormat::NTriples => Some((
1193 graph.to_ntriples().into_bytes(),
1194 ldp::RdfFormat::NTriples.mime(),
1195 )),
1196 ldp::RdfFormat::JsonLd => {
1197 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
1198 Some((json, ldp::RdfFormat::JsonLd.mime()))
1199 }
1200 ldp::RdfFormat::RdfXml => None,
1202 }
1203}
1204
1205fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
1214 let text = std::str::from_utf8(current_body).map_err(|_| {
1215 actix_web::error::ErrorConflict(
1216 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
1217 )
1218 })?;
1219 if text.trim().is_empty() {
1220 return Ok(ldp::Graph::new());
1221 }
1222 ldp::Graph::parse_ntriples(text).map_err(|_| {
1223 actix_web::error::ErrorConflict(
1224 "existing resource is not N-Triples RDF and cannot be non-destructively \
1225 patched; PUT an N-Triples representation or use a JSON Patch",
1226 )
1227 })
1228}
1229
1230async fn find_effective_acl_dyn(
1236 storage: &dyn Storage,
1237 resource_path: &str,
1238) -> Result<Option<wac::AclDocument>, PodError> {
1239 let mut path = resource_path.to_string();
1240 let mut inherited = false;
1245 loop {
1246 let acl_key = if path == "/" {
1247 "/.acl".to_string()
1248 } else {
1249 format!("{}.acl", path.trim_end_matches('/'))
1250 };
1251 if let Ok((body, meta)) = storage.get(&acl_key).await {
1252 match parse_jsonld_acl(&body) {
1253 Ok(mut doc) => {
1254 doc.inherited = inherited;
1255 return Ok(Some(doc));
1256 }
1257 Err(PodError::BadRequest(_)) => {
1258 return Err(PodError::BadRequest("ACL document exceeds bounds".into()))
1259 }
1260 Err(_) => {}
1261 }
1262 let ct = meta.content_type.to_ascii_lowercase();
1263 let looks_turtle = ct.starts_with("text/turtle")
1264 || ct.starts_with("application/turtle")
1265 || ct.starts_with("application/x-turtle");
1266 let text = std::str::from_utf8(&body).unwrap_or("");
1267 if looks_turtle || text.contains("@prefix") || text.contains("acl:Authorization") {
1268 if let Ok(mut doc) = parse_turtle_acl(text) {
1269 doc.inherited = inherited;
1270 return Ok(Some(doc));
1271 }
1272 }
1273 }
1274 if path == "/" || path.is_empty() {
1275 break;
1276 }
1277 inherited = true;
1279 let trimmed = path.trim_end_matches('/');
1280 path = match trimmed.rfind('/') {
1281 Some(0) => "/".to_string(),
1282 Some(pos) => trimmed[..pos].to_string(),
1283 None => "/".to_string(),
1284 };
1285 }
1286 Ok(None)
1287}
1288
1289async fn handle_delete(
1290 req: HttpRequest,
1291 state: web::Data<AppState>,
1292) -> Result<HttpResponse, ActixError> {
1293 let path = req.uri().path().to_string();
1294 let auth_pk = extract_pubkey(&req).await;
1295 let agent = agent_uri(auth_pk.as_ref());
1296 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
1297
1298 match state.storage.delete(&path).await {
1299 Ok(()) => Ok(HttpResponse::NoContent().finish()),
1300 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1301 Err(e) => Err(to_actix(e)),
1302 }
1303}
1304
1305async fn handle_options(
1306 req: HttpRequest,
1307 state: web::Data<AppState>,
1308) -> Result<HttpResponse, ActixError> {
1309 let path = req.uri().path().to_string();
1310 let o = ldp::options_for(&path);
1311 let mut rsp = HttpResponse::NoContent().finish();
1312 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
1313 rsp.headers_mut()
1314 .insert(header::HeaderName::from_static("allow"), v);
1315 }
1316 if let Some(ap) = o.accept_post {
1317 if let Ok(v) = header::HeaderValue::from_str(ap) {
1318 rsp.headers_mut()
1319 .insert(header::HeaderName::from_static("accept-post"), v);
1320 }
1321 }
1322 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
1323 rsp.headers_mut()
1324 .insert(header::HeaderName::from_static("accept-patch"), v);
1325 }
1326 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
1327 rsp.headers_mut()
1328 .insert(header::HeaderName::from_static("accept-ranges"), v);
1329 }
1330 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1331 Ok(rsp)
1332}
1333
1334async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
1339 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
1340 HttpResponse::Ok()
1341 .content_type("application/ld+json")
1342 .json(doc)
1343}
1344
1345#[derive(Debug, Deserialize)]
1346struct WebFingerQuery {
1347 resource: Option<String>,
1348}
1349
1350async fn handle_well_known_webfinger(
1351 state: web::Data<AppState>,
1352 q: web::Query<WebFingerQuery>,
1353) -> HttpResponse {
1354 let resource = q.resource.clone().unwrap_or_else(|| {
1355 format!(
1356 "acct:anonymous@{}",
1357 state
1358 .nodeinfo
1359 .base_url
1360 .trim_start_matches("http://")
1361 .trim_start_matches("https://")
1362 )
1363 });
1364 let webid = format!(
1365 "{}/profile/card#me",
1366 state.nodeinfo.base_url.trim_end_matches('/')
1367 );
1368 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
1369 Some(jrd) => HttpResponse::Ok()
1370 .content_type("application/jrd+json")
1371 .json(jrd),
1372 None => HttpResponse::NotFound().finish(),
1373 }
1374}
1375
1376async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
1377 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
1378 HttpResponse::Ok()
1379 .content_type("application/json")
1380 .json(doc)
1381}
1382
1383async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
1384 let doc = interop::nodeinfo_2_1(
1385 &state.nodeinfo.software_name,
1386 &state.nodeinfo.software_version,
1387 state.nodeinfo.open_registrations,
1388 state.nodeinfo.total_users,
1389 );
1390 HttpResponse::Ok()
1391 .content_type("application/json")
1392 .json(doc)
1393}
1394
1395#[cfg(feature = "did-nostr")]
1396async fn handle_well_known_did_nostr(
1397 state: web::Data<AppState>,
1398 path: web::Path<String>,
1399) -> HttpResponse {
1400 let pubkey = path.into_inner();
1401 let also = vec![format!(
1402 "{}/profile/card#me",
1403 state.nodeinfo.base_url.trim_end_matches('/')
1404 )];
1405 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
1406 HttpResponse::Ok()
1407 .content_type("application/did+json")
1408 .json(doc)
1409}
1410
1411#[cfg(feature = "nip05-endpoint")]
1419#[derive(Debug, Deserialize)]
1420struct Nip05Query {
1421 name: Option<String>,
1424}
1425
1426#[cfg(feature = "nip05-endpoint")]
1427fn nip05_name_is_valid(name: &str) -> bool {
1428 if name.is_empty() {
1431 return false;
1432 }
1433 name.bytes()
1434 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
1435}
1436
1437#[cfg(feature = "nip05-endpoint")]
1438async fn handle_well_known_nip05(
1439 state: web::Data<AppState>,
1440 query: web::Query<Nip05Query>,
1441) -> HttpResponse {
1442 use solid_pod_rs::webid::extract_nostr_pubkey;
1443
1444 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
1446 if !nip05_name_is_valid(&name) {
1447 return HttpResponse::BadRequest().json(serde_json::json!({
1448 "error": "invalid NIP-05 local part",
1449 }));
1450 }
1451
1452 let profile_path = if name == "_" {
1458 "/profile/card".to_string()
1459 } else {
1460 format!("/{name}/profile/card")
1461 };
1462
1463 let (body, _meta) = match state.storage.get(&profile_path).await {
1464 Ok(v) => v,
1465 Err(_) => {
1466 return nip05_empty_response();
1470 }
1471 };
1472
1473 let pubkey_hex = match extract_nostr_pubkey(&body) {
1474 Ok(Some(p)) => p,
1475 _ => return nip05_empty_response(),
1476 };
1477
1478 let doc = interop::nip05_document([(name, pubkey_hex)]);
1479 HttpResponse::Ok()
1480 .insert_header(("Access-Control-Allow-Origin", "*"))
1481 .content_type("application/json")
1482 .json(doc)
1483}
1484
1485#[cfg(feature = "nip05-endpoint")]
1486fn nip05_empty_response() -> HttpResponse {
1487 HttpResponse::Ok()
1488 .insert_header(("Access-Control-Allow-Origin", "*"))
1489 .content_type("application/json")
1490 .json(serde_json::json!({ "names": {} }))
1491}
1492
1493#[derive(Debug, Deserialize)]
1498struct CreateAccountRequest {
1499 username: String,
1500 #[serde(default)]
1501 name: Option<String>,
1502}
1503
1504#[derive(Debug, Deserialize)]
1505struct CreatePodRequest {
1506 name: String,
1507}
1508
1509async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
1510 let pod_name = path.into_inner();
1511 let pod_root = format!("/{pod_name}/");
1512 match state.storage.exists(&pod_root).await {
1513 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
1514 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
1515 }
1516}
1517
1518fn valid_pod_name(name: &str) -> bool {
1519 !name.is_empty()
1520 && name
1521 .chars()
1522 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
1523}
1524
1525fn request_ip(req: &HttpRequest) -> IpAddr {
1526 req.peer_addr()
1527 .map(|addr| addr.ip())
1528 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
1529}
1530
1531async fn handle_create_account(
1532 state: web::Data<AppState>,
1533 body: web::Json<CreateAccountRequest>,
1534) -> Result<HttpResponse, ActixError> {
1535 let pod_root = format!("/{}/", body.username);
1536 if state.storage.exists(&pod_root).await.unwrap_or(false) {
1537 return Ok(
1538 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
1539 );
1540 }
1541
1542 let mut plan = provision::ProvisionPlan::new(
1543 body.username.clone(),
1544 format!(
1545 "{}/{}",
1546 state.nodeinfo.base_url.trim_end_matches('/'),
1547 body.username,
1548 ),
1549 );
1550 plan.display_name = body.name.clone();
1551 plan.containers = vec![
1552 format!("/{}/", body.username),
1553 format!("/{}/profile/", body.username),
1554 format!("/{}/inbox/", body.username),
1555 format!("/{}/public/", body.username),
1556 format!("/{}/private/", body.username),
1557 format!("/{}/settings/", body.username),
1558 ];
1559
1560 #[cfg(feature = "git")]
1564 let outcome = {
1565 use solid_pod_rs_git::init::GitAutoInit;
1566 let git_hook = state.data_root.as_ref().map(|root| {
1567 let fs_path = root.join(&body.username);
1568 (GitAutoInit::new(), fs_path)
1569 });
1570 match git_hook {
1571 Some((hook, ref fs_path)) => {
1572 provision::provision_pod_ext(state.storage.as_ref(), &plan, Some((&hook, fs_path)))
1573 .await
1574 }
1575 None => provision::provision_pod(state.storage.as_ref(), &plan).await,
1576 }
1577 };
1578 #[cfg(not(feature = "git"))]
1579 let outcome = provision::provision_pod(state.storage.as_ref(), &plan).await;
1580
1581 match outcome {
1582 Ok(outcome) => Ok(HttpResponse::Created().json(serde_json::json!({
1583 "webid": outcome.webid,
1584 "pod_root": outcome.pod_root,
1585 "username": body.username,
1586 }))),
1587 Err(e) => Err(to_actix(e)),
1588 }
1589}
1590
1591async fn handle_create_pod(
1592 req: HttpRequest,
1593 state: web::Data<AppState>,
1594 body: web::Json<CreatePodRequest>,
1595) -> Result<HttpResponse, ActixError> {
1596 let ip = request_ip(&req);
1597 if let Err(retry_after) = state.pod_create_limiter.check(ip) {
1598 return Ok(HttpResponse::TooManyRequests()
1599 .insert_header(("Retry-After", retry_after.to_string()))
1600 .json(serde_json::json!({
1601 "error": "Too Many Requests",
1602 "message": "Pod creation rate limit exceeded",
1603 "retryAfter": retry_after
1604 })));
1605 }
1606
1607 if !valid_pod_name(&body.name) {
1608 return Ok(HttpResponse::BadRequest().json(serde_json::json!({
1609 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
1610 })));
1611 }
1612
1613 let pod_root = format!("/{}/", body.name);
1614 if state.storage.exists(&pod_root).await.unwrap_or(false) {
1615 return Ok(
1616 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
1617 );
1618 }
1619
1620 let conn = req.connection_info();
1621 let base_uri = format!("{}://{}", conn.scheme(), conn.host());
1622 let pod_uri = format!("{}/{}/", base_uri.trim_end_matches('/'), body.name);
1623
1624 for container in [
1625 format!("/{}/", body.name),
1626 format!("/{}/profile/", body.name),
1627 format!("/{}/inbox/", body.name),
1628 format!("/{}/public/", body.name),
1629 format!("/{}/private/", body.name),
1630 format!("/{}/settings/", body.name),
1631 ] {
1632 let meta_key = format!("{}.meta", container.trim_end_matches('/'));
1633 state
1634 .storage
1635 .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
1636 .await
1637 .map_err(to_actix)?;
1638 }
1639
1640 let canonical_pods_prefix = format!("{}/pods/{}/", base_uri.trim_end_matches('/'), body.name);
1641 let webid = format!("{pod_uri}profile/card#me");
1642 let profile = solid_pod_rs::webid::generate_webid_html(&body.name, None, &base_uri)
1643 .replace(&canonical_pods_prefix, &pod_uri);
1644 state
1645 .storage
1646 .put(
1647 &format!("/{}/profile/card", body.name),
1648 Bytes::from(profile.into_bytes()),
1649 "text/html",
1650 )
1651 .await
1652 .map_err(to_actix)?;
1653
1654 Ok(HttpResponse::Created()
1655 .insert_header(("Location", pod_uri.clone()))
1656 .json(serde_json::json!({
1657 "name": body.name,
1658 "webId": webid,
1659 "podUri": pod_uri,
1660 })))
1661}
1662
1663async fn handle_copy(
1668 req: HttpRequest,
1669 state: web::Data<AppState>,
1670) -> Result<HttpResponse, ActixError> {
1671 let dest = req.uri().path().to_string();
1672 let auth_pk = extract_pubkey(&req).await;
1673 let agent = agent_uri(auth_pk.as_ref());
1674 enforce_write(&state, &dest, AccessMode::Write, agent.as_deref()).await?;
1675
1676 let source = req
1677 .headers()
1678 .get("source")
1679 .and_then(|v| v.to_str().ok())
1680 .map(|s| s.to_string());
1681 let source = match source {
1682 Some(s) => s,
1683 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
1684 };
1685
1686 let (body, meta) = match state.storage.get(&source).await {
1687 Ok(v) => v,
1688 Err(PodError::NotFound(_)) => {
1689 return Ok(HttpResponse::NotFound().body("source resource not found"))
1690 }
1691 Err(e) => return Err(to_actix(e)),
1692 };
1693
1694 state
1695 .storage
1696 .put(&dest, body, &meta.content_type)
1697 .await
1698 .map_err(to_actix)?;
1699
1700 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
1702 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
1703 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
1704 let _ = state
1705 .storage
1706 .put(&dst_acl, acl_body, &acl_meta.content_type)
1707 .await;
1708 }
1709
1710 let mut rsp = HttpResponse::Created().finish();
1711 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
1712 rsp.headers_mut().insert(header::LOCATION, loc);
1713 }
1714 Ok(rsp)
1715}
1716
1717async fn handle_glob_get(
1722 req: HttpRequest,
1723 state: web::Data<AppState>,
1724) -> Result<HttpResponse, ActixError> {
1725 let raw_path = req.uri().path().to_string();
1726 if !raw_path.ends_with("/*") {
1728 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
1729 }
1730 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
1732 folder.to_string()
1733 } else {
1734 format!("{folder}/")
1735 };
1736
1737 let auth_pk = extract_pubkey(&req).await;
1741 let agent = agent_uri(auth_pk.as_ref());
1742 enforce_read(&state, &folder, agent.as_deref()).await?;
1743
1744 let children = state.storage.list(&folder).await.map_err(to_actix)?;
1745 let mut merged = String::new();
1746
1747 for child in &children {
1748 if child.ends_with('/') {
1749 continue;
1750 }
1751 let child_path = format!("{folder}{child}");
1752 if let Ok((body, meta)) = state.storage.get(&child_path).await {
1753 if meta.content_type.contains("turtle")
1754 || meta.content_type.contains("n-triples")
1755 || meta.content_type.contains("n3")
1756 {
1757 if let Ok(text) = std::str::from_utf8(&body) {
1758 merged.push_str(text);
1759 merged.push('\n');
1760 }
1761 }
1762 }
1763 }
1764
1765 if merged.is_empty() {
1766 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
1767 }
1768
1769 Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
1770}
1771
1772#[derive(Debug, Deserialize)]
1777struct LoginPasswordRequest {
1778 username: String,
1779 password: String,
1780}
1781
1782async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
1783 let _ = (&body.username, &body.password);
1784 HttpResponse::Ok().json(serde_json::json!({
1785 "message": "login endpoint active"
1786 }))
1787}
1788
1789#[derive(Debug, Deserialize)]
1790struct PasswordResetRequest {
1791 username: String,
1792}
1793
1794async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
1795 let _ = &body.username;
1796 HttpResponse::Ok().json(serde_json::json!({
1797 "message": "if an account with that username exists, a reset link has been sent"
1798 }))
1799}
1800
1801#[derive(Debug, Deserialize)]
1802struct PasswordChangeRequest {
1803 token: String,
1804 new_password: String,
1805}
1806
1807async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
1808 let _ = (&body.token, &body.new_password);
1809 HttpResponse::Ok().json(serde_json::json!({
1810 "message": "password changed"
1811 }))
1812}
1813
1814async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
1819 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
1820 HttpResponse::Ok()
1821 .content_type("application/json")
1822 .json(body)
1823}
1824
1825pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
1840
1841#[derive(Debug, Deserialize)]
1843struct ProxyQuery {
1844 url: String,
1845}
1846
1847const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
1849 "set-cookie",
1850 "set-cookie2",
1851 "authorization",
1852 "www-authenticate",
1853 "proxy-authenticate",
1854 "proxy-authorization",
1855];
1856
1857fn validate_proxy_target(target: &str) -> Result<url::Url, HttpResponse> {
1863 let parsed = match url::Url::parse(target) {
1864 Ok(u) => u,
1865 Err(_) => {
1866 return Err(
1867 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
1868 );
1869 }
1870 };
1871
1872 match parsed.scheme() {
1874 "http" | "https" => {}
1875 scheme => {
1876 return Err(HttpResponse::BadRequest()
1877 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
1878 }
1879 }
1880
1881 if let Err(_e) = solid_pod_rs::security::is_safe_url(target) {
1883 return Err(HttpResponse::Forbidden()
1884 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1885 }
1886
1887 if let Some(host) = parsed.host_str() {
1889 let host_lower = host.to_ascii_lowercase();
1890 if host_lower == "localhost"
1892 || host_lower.ends_with(".localhost")
1893 || host_lower == "0.0.0.0"
1894 || host_lower == "[::1]"
1895 || host_lower == "[::0]"
1896 {
1897 return Err(HttpResponse::Forbidden()
1898 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1899 }
1900 } else {
1901 return Err(
1902 HttpResponse::BadRequest().json(serde_json::json!({"error": "target URL has no host"}))
1903 );
1904 }
1905
1906 Ok(parsed)
1907}
1908
1909async fn handle_proxy(
1910 req: HttpRequest,
1911 _state: web::Data<AppState>,
1912 query: web::Query<ProxyQuery>,
1913) -> Result<HttpResponse, ActixError> {
1914 let auth_pk = extract_pubkey(&req).await;
1916 let agent = agent_uri(auth_pk.as_ref());
1917 if agent.is_none() {
1918 return Ok(HttpResponse::Unauthorized()
1919 .json(serde_json::json!({"error": "authentication required"})));
1920 }
1921
1922 let _target_url = match validate_proxy_target(&query.url) {
1924 Ok(u) => u,
1925 Err(rsp) => return Ok(rsp),
1926 };
1927
1928 let client = reqwest::Client::builder()
1930 .redirect(reqwest::redirect::Policy::none())
1933 .build()
1934 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))?;
1935
1936 let mut current_url = query.url.clone();
1937 let mut redirect_count = 0u8;
1938 const MAX_REDIRECTS: u8 = 5;
1939
1940 let byte_cap = std::env::var("PROXY_BYTE_CAP")
1941 .ok()
1942 .and_then(|v| {
1943 solid_pod_rs::config::sources::parse_size(&v)
1944 .map(|u| u as usize)
1945 .ok()
1946 })
1947 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
1948
1949 loop {
1950 if redirect_count > 0 {
1952 match validate_proxy_target(¤t_url) {
1953 Ok(_) => {}
1954 Err(rsp) => return Ok(rsp),
1955 }
1956 }
1957
1958 let mut upstream_req = client.get(¤t_url);
1959
1960 if let Some(auth_val) = req
1962 .headers()
1963 .get("x-upstream-authorization")
1964 .and_then(|v| v.to_str().ok())
1965 {
1966 upstream_req = upstream_req.header("Authorization", auth_val);
1967 }
1968
1969 let response = upstream_req
1970 .send()
1971 .await
1972 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
1973
1974 if response.status().is_redirection() {
1976 if redirect_count >= MAX_REDIRECTS {
1977 return Ok(HttpResponse::BadGateway()
1978 .json(serde_json::json!({"error": "too many redirects"})));
1979 }
1980 if let Some(location) = response.headers().get("location") {
1981 let loc_str = location
1982 .to_str()
1983 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
1984 let base = url::Url::parse(¤t_url)
1986 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
1987 let resolved = base
1988 .join(loc_str)
1989 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
1990 current_url = resolved.to_string();
1991 redirect_count += 1;
1992 continue;
1993 }
1994 return Ok(HttpResponse::BadGateway()
1995 .json(serde_json::json!({"error": "redirect without location"})));
1996 }
1997
1998 let upstream_status = response.status().as_u16();
2000 let upstream_content_type = response
2001 .headers()
2002 .get("content-type")
2003 .and_then(|v| v.to_str().ok())
2004 .unwrap_or("application/octet-stream")
2005 .to_string();
2006
2007 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
2009 for (name, value) in response.headers() {
2010 let name_lower = name.as_str().to_ascii_lowercase();
2011 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
2012 continue;
2013 }
2014 if matches!(
2016 name_lower.as_str(),
2017 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
2018 ) {
2019 continue;
2020 }
2021 if let Ok(val_str) = value.to_str() {
2022 forwarded_headers.push((name_lower, val_str.to_string()));
2023 }
2024 }
2025
2026 let body_bytes = response
2027 .bytes()
2028 .await
2029 .map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
2030
2031 if body_bytes.len() > byte_cap {
2032 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
2033 "error": "proxied response exceeds byte cap",
2034 "limit": byte_cap
2035 })));
2036 }
2037
2038 let mut rsp = HttpResponse::build(
2040 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
2041 );
2042 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
2043 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
2044
2045 for (name, value) in &forwarded_headers {
2047 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
2048 if let Ok(hval) = header::HeaderValue::from_str(value) {
2049 rsp.insert_header((hname, hval));
2050 }
2051 }
2052 }
2053
2054 return Ok(rsp.body(body_bytes.to_vec()));
2055 }
2056}
2057
2058pub struct PathTraversalGuard;
2064
2065impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
2066where
2067 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2068 B: 'static,
2069{
2070 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2071 type Error = ActixError;
2072 type InitError = ();
2073 type Transform = PathTraversalGuardMiddleware<S>;
2074 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2075
2076 fn new_transform(&self, service: S) -> Self::Future {
2077 ready(Ok(PathTraversalGuardMiddleware { service }))
2078 }
2079}
2080
2081pub struct PathTraversalGuardMiddleware<S> {
2083 service: S,
2084}
2085
2086impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
2087where
2088 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2089 B: 'static,
2090{
2091 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2092 type Error = ActixError;
2093 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2094
2095 actix_web::dev::forward_ready!(service);
2096
2097 fn call(&self, req: ServiceRequest) -> Self::Future {
2098 let raw = req.path().to_string();
2101 if path_is_traversal(&raw) {
2102 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
2103 let sr = req.into_response(rsp.map_into_boxed_body());
2104 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2105 }
2106 let fut = self.service.call(req);
2107 Box::pin(async move {
2108 let resp = fut.await?;
2109 Ok(resp.map_into_left_body())
2110 })
2111 }
2112}
2113
2114fn path_is_traversal(path: &str) -> bool {
2115 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
2117 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
2118 for seg in once.split('/').chain(twice.split('/')) {
2119 if seg == ".." || seg == "." {
2120 return true;
2121 }
2122 }
2123 if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
2126 return true;
2127 }
2128 false
2129}
2130
2131pub struct CorsHeaders {
2142 pub allowed_origins: Arc<Vec<String>>,
2143}
2144
2145impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
2146where
2147 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2148 B: 'static,
2149{
2150 type Response = ServiceResponse<B>;
2151 type Error = ActixError;
2152 type InitError = ();
2153 type Transform = CorsHeadersMiddleware<S>;
2154 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2155
2156 fn new_transform(&self, service: S) -> Self::Future {
2157 ready(Ok(CorsHeadersMiddleware {
2158 service,
2159 allowed_origins: self.allowed_origins.clone(),
2160 }))
2161 }
2162}
2163
2164pub struct CorsHeadersMiddleware<S> {
2166 service: S,
2167 allowed_origins: Arc<Vec<String>>,
2168}
2169
2170impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
2171where
2172 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2173 B: 'static,
2174{
2175 type Response = ServiceResponse<B>;
2176 type Error = ActixError;
2177 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2178
2179 actix_web::dev::forward_ready!(service);
2180
2181 fn call(&self, req: ServiceRequest) -> Self::Future {
2182 let origin = req
2183 .headers()
2184 .get(header::ORIGIN)
2185 .and_then(|v| v.to_str().ok())
2186 .map(str::to_string);
2187 let allowed = self.allowed_origins.clone();
2188 let fut = self.service.call(req);
2189 Box::pin(async move {
2190 let mut resp = fut.await?;
2191 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
2192 Ok(resp)
2193 })
2194 }
2195}
2196
2197fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
2198 let effective_origin: Option<String> = if allowed.is_empty() {
2200 Some(origin.unwrap_or("*").to_string())
2202 } else {
2203 origin
2205 .filter(|o| allowed.iter().any(|a| a == *o))
2206 .map(str::to_string)
2207 };
2208
2209 let origin_value = match effective_origin {
2212 Some(ref v) => v.as_str(),
2213 None => return,
2214 };
2215
2216 let pairs = [
2217 ("access-control-allow-origin", origin_value),
2218 (
2219 "access-control-allow-methods",
2220 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
2221 ),
2222 (
2223 "access-control-allow-headers",
2224 "Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Range, Slug, Origin",
2225 ),
2226 (
2227 "access-control-expose-headers",
2228 "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",
2229 ),
2230 ("access-control-allow-credentials", "true"),
2231 ("access-control-max-age", "86400"),
2232 ];
2233
2234 for (name, value) in pairs {
2235 if let (Ok(name), Ok(value)) = (
2236 header::HeaderName::from_lowercase(name.as_bytes()),
2237 header::HeaderValue::from_str(value),
2238 ) {
2239 headers.insert(name, value);
2240 }
2241 }
2242}
2243
2244pub struct ErrorLoggingMiddleware;
2260
2261impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
2262where
2263 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2264 B: 'static,
2265{
2266 type Response = ServiceResponse<B>;
2267 type Error = ActixError;
2268 type InitError = ();
2269 type Transform = ErrorLoggingMiddlewareService<S>;
2270 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2271
2272 fn new_transform(&self, service: S) -> Self::Future {
2273 ready(Ok(ErrorLoggingMiddlewareService { service }))
2274 }
2275}
2276
2277pub struct ErrorLoggingMiddlewareService<S> {
2279 service: S,
2280}
2281
2282impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
2283where
2284 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2285 B: 'static,
2286{
2287 type Response = ServiceResponse<B>;
2288 type Error = ActixError;
2289 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2290
2291 actix_web::dev::forward_ready!(service);
2292
2293 fn call(&self, req: ServiceRequest) -> Self::Future {
2294 let method = req.method().as_str().to_string();
2297 let path = req.path().to_string();
2298
2299 let fut = self.service.call(req);
2300 Box::pin(async move {
2301 let response = fut.await?;
2302 let status = response.status();
2303 if status.is_server_error() {
2304 log_5xx(&method, &path, status, response.response().error());
2305 }
2306 Ok(response)
2307 })
2308 }
2309}
2310
2311fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
2315 let chain = match error {
2319 Some(e) => format_error_chain(e),
2320 None => "<no error attached to response>".to_string(),
2321 };
2322
2323 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
2324 Some(std::backtrace::Backtrace::force_capture().to_string())
2325 } else {
2326 None
2327 };
2328
2329 tracing::error!(
2330 target: "solid_pod_rs_server::http",
2331 method = %method,
2332 path = %path,
2333 status = %status.as_u16(),
2334 error.chain = %chain,
2335 backtrace = backtrace.as_deref().unwrap_or(""),
2336 "5xx response"
2337 );
2338}
2339
2340fn format_error_chain(e: &actix_web::Error) -> String {
2351 let summary = format!("{}", e.as_response_error());
2352 let debug = format!("{e:?}");
2353 if debug == summary || debug.is_empty() {
2354 summary
2355 } else {
2356 format!("{summary} -> {debug}")
2357 }
2358}
2359
2360pub struct DotfileGuard {
2366 allow: Arc<DotfileAllowlist>,
2367}
2368
2369impl DotfileGuard {
2370 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
2371 Self { allow }
2372 }
2373}
2374
2375impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
2376where
2377 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2378 B: 'static,
2379{
2380 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2381 type Error = ActixError;
2382 type InitError = ();
2383 type Transform = DotfileGuardMiddleware<S>;
2384 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2385
2386 fn new_transform(&self, service: S) -> Self::Future {
2387 ready(Ok(DotfileGuardMiddleware {
2388 service,
2389 allow: self.allow.clone(),
2390 }))
2391 }
2392}
2393
2394pub struct DotfileGuardMiddleware<S> {
2396 service: S,
2397 allow: Arc<DotfileAllowlist>,
2398}
2399
2400impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
2401where
2402 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2403 B: 'static,
2404{
2405 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2406 type Error = ActixError;
2407 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2408
2409 actix_web::dev::forward_ready!(service);
2410
2411 fn call(&self, req: ServiceRequest) -> Self::Future {
2412 let path = req.path().to_string();
2413 let allow_system_route = path.starts_with("/.well-known/") || path == "/.pods";
2417 if !allow_system_route {
2418 let pb = PathBuf::from(&path);
2419 if !self.allow.is_allowed(Path::new(&pb)) {
2420 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
2421 let sr = req.into_response(rsp.map_into_boxed_body());
2422 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2423 }
2424 }
2425 let fut = self.service.call(req);
2426 Box::pin(async move {
2427 let resp = fut.await?;
2428 Ok(resp.map_into_left_body())
2429 })
2430 }
2431}
2432
2433#[cfg(feature = "git")]
2438fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
2439 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
2440 return None;
2441 }
2442 state.data_root.as_ref().map(|root| root.join(pubkey))
2443}
2444
2445#[cfg(feature = "git")]
2446async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
2447 let caller = extract_pubkey(req).await?;
2448 if caller != pod_pubkey {
2449 return None;
2450 }
2451 Some(caller)
2452}
2453
2454#[cfg(feature = "git")]
2455fn git_json_err(msg: &str, status: u16) -> HttpResponse {
2456 HttpResponse::build(
2457 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
2458 )
2459 .content_type("application/json")
2460 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
2461}
2462
2463#[cfg(feature = "git")]
2465#[derive(serde::Deserialize)]
2466struct GitStageBody {
2467 paths: Option<Vec<String>>,
2468 all: Option<bool>,
2469}
2470
2471#[cfg(feature = "git")]
2472#[derive(serde::Deserialize)]
2473struct GitCommitBody {
2474 message: String,
2475 author_name: Option<String>,
2476 author_email: Option<String>,
2477}
2478
2479#[cfg(feature = "git")]
2480#[derive(serde::Deserialize)]
2481struct GitBranchBody {
2482 name: String,
2483}
2484
2485#[cfg(feature = "git")]
2488async fn handle_git_status(
2489 path: web::Path<String>,
2490 req: HttpRequest,
2491 state: web::Data<AppState>,
2492) -> HttpResponse {
2493 let pubkey = path.into_inner();
2494 if require_pod_owner(&req, &pubkey).await.is_none() {
2495 return git_json_err("Authentication required", 401);
2496 }
2497 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2498 return git_json_err("Git not available (no FS backend)", 501);
2499 };
2500 match solid_pod_rs_git::api::git_status(&repo).await {
2501 Ok(s) => HttpResponse::Ok()
2502 .content_type("application/json")
2503 .body(serde_json::to_string(&s).unwrap_or_default()),
2504 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2505 }
2506}
2507
2508#[cfg(feature = "git")]
2509async fn handle_git_log(
2510 path: web::Path<String>,
2511 req: HttpRequest,
2512 state: web::Data<AppState>,
2513 query: web::Query<std::collections::HashMap<String, String>>,
2514) -> HttpResponse {
2515 let pubkey = path.into_inner();
2516 if require_pod_owner(&req, &pubkey).await.is_none() {
2517 return git_json_err("Authentication required", 401);
2518 }
2519 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2520 return git_json_err("Git not available (no FS backend)", 501);
2521 };
2522 let limit: u32 = query
2523 .get("limit")
2524 .and_then(|v| v.parse().ok())
2525 .unwrap_or(20);
2526 match solid_pod_rs_git::api::git_log(&repo, limit).await {
2527 Ok(entries) => HttpResponse::Ok()
2528 .content_type("application/json")
2529 .body(serde_json::to_string(&entries).unwrap_or_default()),
2530 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2531 }
2532}
2533
2534#[cfg(feature = "git")]
2535async fn handle_git_diff(
2536 path: web::Path<String>,
2537 req: HttpRequest,
2538 state: web::Data<AppState>,
2539 query: web::Query<std::collections::HashMap<String, String>>,
2540) -> HttpResponse {
2541 let pubkey = path.into_inner();
2542 if require_pod_owner(&req, &pubkey).await.is_none() {
2543 return git_json_err("Authentication required", 401);
2544 }
2545 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2546 return git_json_err("Git not available (no FS backend)", 501);
2547 };
2548 let file_path = query.get("path").map(String::as_str);
2549 let staged = query
2550 .get("staged")
2551 .map(|v| v == "true" || v == "1")
2552 .unwrap_or(false);
2553 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
2554 Ok(diff) => HttpResponse::Ok()
2555 .content_type("text/plain")
2556 .body(diff),
2557 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2558 }
2559}
2560
2561#[cfg(feature = "git")]
2562async fn handle_git_stage(
2563 path: web::Path<String>,
2564 req: HttpRequest,
2565 state: web::Data<AppState>,
2566 body: web::Bytes,
2567) -> HttpResponse {
2568 let pubkey = path.into_inner();
2569 if require_pod_owner(&req, &pubkey).await.is_none() {
2570 return git_json_err("Authentication required", 401);
2571 }
2572 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2573 return git_json_err("Git not available (no FS backend)", 501);
2574 };
2575 let parsed: GitStageBody = match serde_json::from_slice(&body) {
2576 Ok(v) => v,
2577 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2578 };
2579 let paths = parsed.paths.unwrap_or_default();
2580 let all = parsed.all.unwrap_or(false);
2581 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
2582 Ok(()) => HttpResponse::Ok()
2583 .content_type("application/json")
2584 .body(r#"{"ok":true}"#),
2585 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2586 }
2587}
2588
2589#[cfg(feature = "git")]
2590async fn handle_git_unstage(
2591 path: web::Path<String>,
2592 req: HttpRequest,
2593 state: web::Data<AppState>,
2594 body: web::Bytes,
2595) -> HttpResponse {
2596 let pubkey = path.into_inner();
2597 if require_pod_owner(&req, &pubkey).await.is_none() {
2598 return git_json_err("Authentication required", 401);
2599 }
2600 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2601 return git_json_err("Git not available (no FS backend)", 501);
2602 };
2603 let parsed: GitStageBody = match serde_json::from_slice(&body) {
2604 Ok(v) => v,
2605 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2606 };
2607 let paths = parsed.paths.unwrap_or_default();
2608 let all = parsed.all.unwrap_or(false);
2609 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
2610 Ok(()) => HttpResponse::Ok()
2611 .content_type("application/json")
2612 .body(r#"{"ok":true}"#),
2613 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2614 }
2615}
2616
2617#[cfg(feature = "git")]
2618async fn handle_git_commit(
2619 path: web::Path<String>,
2620 req: HttpRequest,
2621 state: web::Data<AppState>,
2622 body: web::Bytes,
2623) -> HttpResponse {
2624 let pubkey = path.into_inner();
2625 if require_pod_owner(&req, &pubkey).await.is_none() {
2626 return git_json_err("Authentication required", 401);
2627 }
2628 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2629 return git_json_err("Git not available (no FS backend)", 501);
2630 };
2631 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
2632 Ok(v) => v,
2633 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2634 };
2635 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
2636 let author_email = parsed
2637 .author_email
2638 .as_deref()
2639 .unwrap_or("pod@dreamlab-ai.com");
2640 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email)
2641 .await
2642 {
2643 Ok(result) => HttpResponse::Ok()
2644 .content_type("application/json")
2645 .body(serde_json::to_string(&result).unwrap_or_default()),
2646 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2647 }
2648}
2649
2650#[cfg(feature = "git")]
2651async fn handle_git_branches(
2652 path: web::Path<String>,
2653 req: HttpRequest,
2654 state: web::Data<AppState>,
2655) -> HttpResponse {
2656 let pubkey = path.into_inner();
2657 if require_pod_owner(&req, &pubkey).await.is_none() {
2658 return git_json_err("Authentication required", 401);
2659 }
2660 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2661 return git_json_err("Git not available (no FS backend)", 501);
2662 };
2663 match solid_pod_rs_git::api::git_branches(&repo).await {
2664 Ok(info) => HttpResponse::Ok()
2665 .content_type("application/json")
2666 .body(serde_json::to_string(&info).unwrap_or_default()),
2667 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2668 }
2669}
2670
2671#[cfg(feature = "git")]
2672async fn handle_git_create_branch(
2673 path: web::Path<String>,
2674 req: HttpRequest,
2675 state: web::Data<AppState>,
2676 body: web::Bytes,
2677) -> HttpResponse {
2678 let pubkey = path.into_inner();
2679 if require_pod_owner(&req, &pubkey).await.is_none() {
2680 return git_json_err("Authentication required", 401);
2681 }
2682 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2683 return git_json_err("Git not available (no FS backend)", 501);
2684 };
2685 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
2686 Ok(v) => v,
2687 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2688 };
2689 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
2690 Ok(()) => HttpResponse::Ok()
2691 .content_type("application/json")
2692 .body(r#"{"ok":true}"#),
2693 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2694 }
2695}
2696
2697#[cfg(feature = "git")]
2698async fn handle_git_discard(
2699 path: web::Path<String>,
2700 req: HttpRequest,
2701 state: web::Data<AppState>,
2702 body: web::Bytes,
2703) -> HttpResponse {
2704 let pubkey = path.into_inner();
2705 if require_pod_owner(&req, &pubkey).await.is_none() {
2706 return git_json_err("Authentication required", 401);
2707 }
2708 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2709 return git_json_err("Git not available (no FS backend)", 501);
2710 };
2711 let parsed: GitStageBody = match serde_json::from_slice(&body) {
2712 Ok(v) => v,
2713 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2714 };
2715 let paths = parsed.paths.unwrap_or_default();
2716 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
2717 Ok(()) => HttpResponse::Ok()
2718 .content_type("application/json")
2719 .body(r#"{"ok":true}"#),
2720 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2721 }
2722}
2723
2724async fn handle_git_panel_options(
2732 req: HttpRequest,
2733 state: web::Data<AppState>,
2734) -> HttpResponse {
2735 let origin = req
2736 .headers()
2737 .get(header::ORIGIN)
2738 .and_then(|v| v.to_str().ok())
2739 .map(str::to_string);
2740
2741 let mut rsp = HttpResponse::NoContent().finish();
2742 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
2743 rsp
2744}
2745
2746async fn handle_admin_provision(
2757 req: HttpRequest,
2758 state: web::Data<AppState>,
2759 path: web::Path<String>,
2760) -> HttpResponse {
2761 let expected = match &state.admin_key {
2763 Some(k) => k.clone(),
2764 None => {
2765 return HttpResponse::Forbidden().json(serde_json::json!({
2766 "error": "admin key not configured on this server"
2767 }));
2768 }
2769 };
2770 let provided = req
2771 .headers()
2772 .get("x-pod-admin-key")
2773 .and_then(|v| v.to_str().ok())
2774 .unwrap_or("");
2775 use subtle::ConstantTimeEq;
2780 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
2781 if !bool::from(key_match) {
2782 return HttpResponse::Forbidden()
2783 .json(serde_json::json!({"error": "invalid admin key"}));
2784 }
2785
2786 let pubkey = path.into_inner();
2788 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
2789 return HttpResponse::BadRequest()
2790 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
2791 }
2792
2793 let data_root = match &state.data_root {
2795 Some(r) => r.clone(),
2796 None => {
2797 return HttpResponse::InternalServerError().json(serde_json::json!({
2798 "error": "server has no fs-backend storage configured"
2799 }));
2800 }
2801 };
2802
2803 let pod_dir = data_root.join(&pubkey);
2804
2805 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
2807 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
2808 return HttpResponse::InternalServerError()
2809 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
2810 }
2811
2812 let acl_content = format!(
2814 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
2815 <#owner> a acl:Authorization ;\n\
2816 acl:agent <did:nostr:{pubkey}> ;\n\
2817 acl:accessTo <./> ;\n\
2818 acl:default <./> ;\n\
2819 acl:mode acl:Read, acl:Write, acl:Control .\n"
2820 );
2821 let acl_path = pod_dir.join(".acl");
2822 if !acl_path.exists() {
2823 if let Err(e) = tokio::fs::write(&acl_path, acl_content.as_bytes()).await {
2824 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write .acl failed");
2825 return HttpResponse::InternalServerError()
2826 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
2827 }
2828 }
2829
2830 #[cfg(feature = "git")]
2832 {
2833 use tokio::process::Command;
2834
2835 if !pod_dir.join(".git").exists() {
2837 let init_out = Command::new("git")
2838 .args([
2839 "init",
2840 "-b",
2841 "main",
2842 pod_dir.to_str().unwrap_or("."),
2843 ])
2844 .output()
2845 .await;
2846
2847 match init_out {
2848 Ok(out) if out.status.success() => {}
2849 Ok(out) => {
2850 let stderr = String::from_utf8_lossy(&out.stderr);
2851 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
2852 }
2853 Err(e) => {
2854 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
2855 }
2856 }
2857
2858 let cfg_out = Command::new("git")
2861 .args([
2862 "-C",
2863 pod_dir.to_str().unwrap_or("."),
2864 "config",
2865 "receive.denyCurrentBranch",
2866 "updateInstead",
2867 ])
2868 .output()
2869 .await;
2870
2871 if let Err(e) = cfg_out {
2872 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
2873 }
2874 }
2875 }
2876
2877 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
2879 HttpResponse::Ok().json(serde_json::json!({
2880 "podUrl": format!("{base_url}/pods/{pubkey}/"),
2881 "ok": true,
2882 }))
2883}
2884
2885async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
2890 let Some(ref data_root) = state.data_root else {
2891 return HttpResponse::Ok()
2892 .content_type("application/json")
2893 .json(serde_json::json!({"apps": [], "count": 0}));
2894 };
2895
2896 let server_url = state.nodeinfo.base_url.clone();
2897
2898 let mut read_dir = match tokio::fs::read_dir(data_root).await {
2900 Ok(rd) => rd,
2901 Err(_) => {
2902 return HttpResponse::Ok()
2903 .content_type("application/json")
2904 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
2905 }
2906 };
2907
2908 let mut apps: Vec<serde_json::Value> = Vec::new();
2909 let mut scanned = 0usize;
2910
2911 while scanned < 1000 {
2912 let entry = match read_dir.next_entry().await {
2913 Ok(Some(e)) => e,
2914 Ok(None) => break,
2915 Err(_) => break,
2916 };
2917
2918 let file_type = match entry.file_type().await {
2919 Ok(ft) => ft,
2920 Err(_) => continue,
2921 };
2922 if !file_type.is_dir() {
2923 continue;
2924 }
2925
2926 scanned += 1;
2927
2928 let manifest_path = entry.path().join("apps").join("manifest.json");
2929 let contents = match tokio::fs::read(&manifest_path).await {
2930 Ok(c) => c,
2931 Err(_) => continue,
2932 };
2933
2934 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
2935 Ok(v) => v,
2936 Err(_) => continue,
2937 };
2938
2939 if let Some(pod_name) = entry.file_name().to_str() {
2941 if manifest.get("podOwner").is_none() {
2942 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
2943 }
2944 }
2945
2946 apps.push(manifest);
2947 }
2948
2949 let count = apps.len();
2950 HttpResponse::Ok()
2951 .content_type("application/json")
2952 .json(serde_json::json!({
2953 "apps": apps,
2954 "serverUrl": server_url,
2955 "count": count,
2956 }))
2957}
2958
2959#[allow(dead_code)]
2972fn is_git_request(path: &str) -> bool {
2973 path.contains("/info/refs")
2974 || path.contains("/git-upload-pack")
2975 || path.contains("/git-receive-pack")
2976}
2977
2978#[allow(dead_code)]
2981fn is_dot_git_path(path: &str) -> bool {
2982 path.contains("/.git/") || path.ends_with("/.git")
2983}
2984
2985#[cfg(feature = "git")]
2986async fn handle_git(
2987 req: HttpRequest,
2988 body: web::Bytes,
2989 state: web::Data<AppState>,
2990) -> HttpResponse {
2991 use solid_pod_rs_git::service::{GitHttpService, GitRequest};
2992
2993 let path = req.uri().path().to_string();
2994
2995 let pod_name = path.trim_start_matches('/').split('/').next().unwrap_or("");
2998 let Some(ref data_root) = state.data_root else {
2999 return HttpResponse::NotImplemented().json(serde_json::json!({
3000 "error": "git requires fs-backend storage",
3001 "reason": "data_root_not_configured"
3002 }));
3003 };
3004 let repo_root = data_root.join(pod_name);
3005 if !repo_root.exists() {
3006 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
3007 }
3008
3009 let query = req.uri().query().unwrap_or("").to_string();
3010 let host_url = {
3011 let conn = req.connection_info();
3012 Some(format!("{}://{}", conn.scheme(), conn.host()))
3013 };
3014 let headers: Vec<(String, String)> = req
3015 .headers()
3016 .iter()
3017 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
3018 .collect();
3019
3020 let git_req = GitRequest {
3021 method: req.method().as_str().to_string(),
3022 path,
3023 query,
3024 headers,
3025 body: body.into(),
3026 host_url,
3027 };
3028
3029 let service = GitHttpService::new(repo_root);
3030 match service.handle(git_req).await {
3031 Ok(git_resp) => {
3032 let mut builder = HttpResponse::build(
3033 actix_web::http::StatusCode::from_u16(git_resp.status)
3034 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3035 );
3036 for (k, v) in &git_resp.headers {
3037 builder.insert_header((k.as_str(), v.as_str()));
3038 }
3039 builder.body(git_resp.body)
3040 }
3041 Err(e) => {
3042 let status = e.status_code();
3043 HttpResponse::build(
3044 actix_web::http::StatusCode::from_u16(status)
3045 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3046 )
3047 .json(serde_json::json!({"error": e.to_string()}))
3048 }
3049 }
3050}
3051
3052pub fn build_app(
3064 state: AppState,
3065) -> App<
3066 impl actix_web::dev::ServiceFactory<
3067 ServiceRequest,
3068 Config = (),
3069 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
3070 Error = ActixError,
3071 InitError = (),
3072 >,
3073> {
3074 let body_cap = state.body_cap;
3075 let dotfiles = state.dotfiles.clone();
3076 let allowed_origins = Arc::new(state.allowed_origins.clone());
3077
3078 let mut app = App::new()
3079 .app_data(web::Data::new(state.clone()))
3080 .app_data(web::PayloadConfig::new(body_cap))
3081 .wrap(ErrorLoggingMiddleware)
3086 .wrap(CorsHeaders { allowed_origins })
3087 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
3091 .wrap(PathTraversalGuard)
3092 .wrap(DotfileGuard::new(dotfiles));
3093
3094 app = app
3100 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
3101 .route(
3102 "/.well-known/webfinger",
3103 web::get().to(handle_well_known_webfinger),
3104 )
3105 .route(
3106 "/.well-known/nodeinfo",
3107 web::get().to(handle_well_known_nodeinfo),
3108 )
3109 .route(
3110 "/.well-known/nodeinfo/2.1",
3111 web::get().to(handle_well_known_nodeinfo_2_1),
3112 );
3113
3114 #[cfg(feature = "did-nostr")]
3115 {
3116 app = app.route(
3117 "/.well-known/did/nostr/{pubkey}.json",
3118 web::get().to(handle_well_known_did_nostr),
3119 );
3120 }
3121
3122 #[cfg(feature = "nip05-endpoint")]
3126 {
3127 app = app.route(
3128 "/.well-known/nostr.json",
3129 web::get().to(handle_well_known_nip05),
3130 );
3131 }
3132
3133 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
3135
3136 app = app.route("/pay/.info", web::get().to(handle_pay_info));
3138
3139 app = app.route("/proxy", web::get().to(handle_proxy));
3141
3142 if state.mcp_enabled {
3146 app = app
3147 .route("/mcp", web::post().to(mcp::handle_mcp))
3148 .route("/mcp", web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options));
3149 }
3150
3151 app = app.route(
3154 "/_admin/provision/{pubkey}",
3155 web::post().to(handle_admin_provision),
3156 );
3157
3158 app = app
3160 .route("/.pods", web::post().to(handle_create_pod))
3161 .route("/api/accounts/new", web::post().to(handle_create_account))
3162 .route("/pods/check/{name}", web::get().to(handle_pod_check))
3163 .route("/login/password", web::post().to(handle_login_password))
3164 .route(
3165 "/account/password/reset",
3166 web::post().to(handle_password_reset_request),
3167 )
3168 .route(
3169 "/account/password/change",
3170 web::post().to(handle_password_change),
3171 );
3172
3173 app = app
3178 .route(
3179 "/{tail:.*}/.git",
3181 web::route().to(|| async {
3182 HttpResponse::Forbidden()
3183 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
3184 }),
3185 )
3186 .route(
3187 "/{tail:.*}/.git/{rest:.*}",
3188 web::route().to(|| async {
3189 HttpResponse::Forbidden()
3190 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
3191 }),
3192 );
3193
3194 app = app.route(
3198 "/pods/{pk}/_git/{tail:.*}",
3199 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
3200 );
3201
3202 #[cfg(feature = "git")]
3203 {
3204 app = app
3206 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
3207 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
3208 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
3209
3210 app = app
3213 .route(
3214 "/pods/{pubkey}/_git/status",
3215 web::get().to(handle_git_status),
3216 )
3217 .route(
3218 "/pods/{pubkey}/_git/log",
3219 web::get().to(handle_git_log),
3220 )
3221 .route(
3222 "/pods/{pubkey}/_git/diff",
3223 web::get().to(handle_git_diff),
3224 )
3225 .route(
3226 "/pods/{pubkey}/_git/stage",
3227 web::post().to(handle_git_stage),
3228 )
3229 .route(
3230 "/pods/{pubkey}/_git/unstage",
3231 web::post().to(handle_git_unstage),
3232 )
3233 .route(
3234 "/pods/{pubkey}/_git/commit",
3235 web::post().to(handle_git_commit),
3236 )
3237 .route(
3238 "/pods/{pubkey}/_git/branches",
3239 web::get().to(handle_git_branches),
3240 )
3241 .route(
3242 "/pods/{pubkey}/_git/branch",
3243 web::post().to(handle_git_create_branch),
3244 )
3245 .route(
3246 "/pods/{pubkey}/_git/discard",
3247 web::post().to(handle_git_discard),
3248 );
3249 }
3250 #[cfg(not(feature = "git"))]
3251 {
3252 let git_501 = || async {
3256 HttpResponse::NotImplemented()
3257 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
3258 };
3259 app = app
3260 .route("/{tail:.*}/info/refs", web::get().to(git_501))
3261 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
3262 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
3263 }
3264
3265 app.route("/{tail:.*}/", web::post().to(handle_post))
3268 .route("/{tail:.*}/", web::put().to(handle_put))
3269 .route("/{tail:.*}", web::get().to(handle_get))
3270 .route("/{tail:.*}", web::head().to(handle_get))
3271 .route("/{tail:.*}", web::put().to(handle_put))
3272 .route("/{tail:.*}", web::patch().to(handle_patch))
3273 .route("/{tail:.*}", web::delete().to(handle_delete))
3274 .route(
3275 "/{tail:.*}",
3276 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
3277 )
3278 .route(
3279 "/{tail:.*}",
3280 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
3281 )
3282}
3283
3284#[cfg(test)]
3289mod payment_gating_tests {
3290 use super::*;
3291 use solid_pod_rs::payments::WebLedger;
3292 use solid_pod_rs::storage::memory::MemoryBackend;
3293
3294 const PRINCIPAL: &str = "did:nostr:alice";
3295
3296 const PAID_WRITE_ACL: &str = r#"
3299@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3300
3301<#paid-write> a acl:Authorization ;
3302 acl:agent <did:nostr:alice> ;
3303 acl:accessTo </premium/inbox> ;
3304 acl:mode acl:Write ;
3305 acl:condition [
3306 a acl:PaymentCondition ;
3307 acl:costSats 100
3308 ] .
3309"#;
3310
3311 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
3312 let mut ledger = WebLedger::new("Test Pod Credits");
3313 if sats > 0 {
3314 ledger.credit(did, sats);
3315 }
3316 let body = serde_json::to_vec(&ledger).unwrap();
3317 storage
3318 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
3319 .await
3320 .unwrap();
3321 }
3322
3323 async fn seed_acl(storage: &dyn Storage) {
3324 storage
3325 .put(
3326 "/premium/inbox.acl",
3327 Bytes::from(PAID_WRITE_ACL),
3328 "text/turtle",
3329 )
3330 .await
3331 .unwrap();
3332 }
3333
3334 #[actix_web::test]
3336 async fn resolve_balance_reads_ledger_entry() {
3337 let storage = MemoryBackend::new();
3338 seed_ledger(&storage, PRINCIPAL, 250).await;
3339 assert_eq!(
3340 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
3341 Some(250)
3342 );
3343 }
3344
3345 #[actix_web::test]
3347 async fn resolve_balance_zero_when_no_entry() {
3348 let storage = MemoryBackend::new();
3349 seed_ledger(&storage, "did:nostr:bob", 500).await;
3350 assert_eq!(resolve_balance_sats(&storage, Some(PRINCIPAL)).await, Some(0));
3351 }
3352
3353 #[actix_web::test]
3355 async fn resolve_balance_none_when_anonymous() {
3356 let storage = MemoryBackend::new();
3357 seed_ledger(&storage, PRINCIPAL, 1_000).await;
3358 assert_eq!(resolve_balance_sats(&storage, None).await, None);
3359 }
3360
3361 #[actix_web::test]
3363 async fn paid_write_denied_below_balance() {
3364 let storage = Arc::new(MemoryBackend::new());
3365 seed_acl(storage.as_ref()).await;
3366 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
3368
3369 let result =
3370 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3371 assert!(
3372 result.is_err(),
3373 "balance 50 < cost 100 must be denied — sat-gating loop closed"
3374 );
3375 }
3376
3377 #[actix_web::test]
3379 async fn paid_write_allowed_at_balance() {
3380 let storage = Arc::new(MemoryBackend::new());
3381 seed_acl(storage.as_ref()).await;
3382 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
3384
3385 let result =
3386 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3387 assert!(
3388 result.is_ok(),
3389 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
3390 );
3391 }
3392
3393 #[actix_web::test]
3395 async fn paid_write_allowed_above_balance() {
3396 let storage = Arc::new(MemoryBackend::new());
3397 seed_acl(storage.as_ref()).await;
3398 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
3399 let state = AppState::new(storage);
3400
3401 let result =
3402 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3403 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
3404 }
3405
3406 #[actix_web::test]
3410 async fn paid_write_anonymous_denied() {
3411 let storage = Arc::new(MemoryBackend::new());
3412 seed_acl(storage.as_ref()).await;
3413 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
3414 let state = AppState::new(storage);
3415
3416 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
3417 assert!(
3418 result.is_err(),
3419 "anonymous caller has no ledger principal — PaymentCondition fails closed"
3420 );
3421 }
3422
3423 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
3430 let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
3431 let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
3432 ledger.get_balance(did)
3433 }
3434
3435 #[actix_web::test]
3437 async fn paid_write_debits_ledger() {
3438 let storage = Arc::new(MemoryBackend::new());
3439 seed_acl(storage.as_ref()).await;
3440 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
3442
3443 let result =
3444 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3445 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
3446 assert_eq!(
3447 read_balance(storage.as_ref(), PRINCIPAL).await,
3448 150,
3449 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
3450 );
3451 }
3452
3453 #[actix_web::test]
3456 async fn paid_write_debits_each_grant() {
3457 let storage = Arc::new(MemoryBackend::new());
3458 seed_acl(storage.as_ref()).await;
3459 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
3461
3462 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
3463 .await
3464 .unwrap();
3465 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
3466 .await
3467 .unwrap();
3468 assert_eq!(
3469 read_balance(storage.as_ref(), PRINCIPAL).await,
3470 50,
3471 "250 - 2*100: each granted request debits, no unmetered re-use"
3472 );
3473
3474 let third =
3476 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3477 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
3478 assert_eq!(
3479 read_balance(storage.as_ref(), PRINCIPAL).await,
3480 50,
3481 "a denied request must not debit"
3482 );
3483 }
3484
3485 #[actix_web::test]
3487 async fn paid_read_debits_ledger() {
3488 const PAID_READ_ACL: &str = r#"
3489@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3490
3491<#paid-read> a acl:Authorization ;
3492 acl:agent <did:nostr:alice> ;
3493 acl:accessTo </premium/feed> ;
3494 acl:mode acl:Read ;
3495 acl:condition [
3496 a acl:PaymentCondition ;
3497 acl:costSats 30
3498 ] .
3499"#;
3500 let storage = Arc::new(MemoryBackend::new());
3501 storage
3502 .put("/premium/feed.acl", Bytes::from(PAID_READ_ACL), "text/turtle")
3503 .await
3504 .unwrap();
3505 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
3506 let state = AppState::new(storage.clone());
3507
3508 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
3509 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
3510 assert_eq!(
3511 read_balance(storage.as_ref(), PRINCIPAL).await,
3512 70,
3513 "100 - 30 cost: a granted paid read must debit"
3514 );
3515 }
3516
3517 #[actix_web::test]
3520 async fn free_read_does_not_debit() {
3521 let storage = Arc::new(MemoryBackend::new());
3522 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
3524 let state = AppState::new(storage.clone());
3525
3526 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
3527 .await
3528 .unwrap();
3529 assert_eq!(
3530 read_balance(storage.as_ref(), PRINCIPAL).await,
3531 100,
3532 "a grant with no PaymentCondition must not debit"
3533 );
3534 }
3535
3536 const ALICE_ONLY_READ_ACL: &str = r#"
3542@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3543
3544<#alice> a acl:Authorization ;
3545 acl:agent <did:nostr:alice> ;
3546 acl:accessTo </private/secret> ;
3547 acl:default </private/> ;
3548 acl:mode acl:Read, acl:Write, acl:Control .
3549"#;
3550
3551 async fn seed_private_read_acl(storage: &dyn Storage) {
3552 storage
3557 .put(
3558 "/private.acl",
3559 Bytes::from(ALICE_ONLY_READ_ACL),
3560 "text/turtle",
3561 )
3562 .await
3563 .unwrap();
3564 }
3565
3566 #[actix_web::test]
3570 async fn enforce_read_grants_owner() {
3571 let storage = Arc::new(MemoryBackend::new());
3572 seed_private_read_acl(storage.as_ref()).await;
3573 let state = AppState::new(storage);
3574 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
3575 assert!(result.is_ok(), "owner alice must be granted Read");
3576 }
3577
3578 #[actix_web::test]
3581 async fn enforce_read_denies_other_principal() {
3582 let storage = Arc::new(MemoryBackend::new());
3583 seed_private_read_acl(storage.as_ref()).await;
3584 let state = AppState::new(storage);
3585 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
3586 assert!(
3587 result.is_err(),
3588 "bob has no Read grant — private resource must not be world-readable"
3589 );
3590 }
3591
3592 #[actix_web::test]
3595 async fn enforce_read_denies_anonymous() {
3596 let storage = Arc::new(MemoryBackend::new());
3597 seed_private_read_acl(storage.as_ref()).await;
3598 let state = AppState::new(storage);
3599 let result = enforce_read(&state, "/private/secret", None).await;
3600 assert!(result.is_err(), "anonymous Read must be denied");
3601 }
3602
3603 const WRITE_NOT_CONTROL_ACL: &str = r#"
3611@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3612
3613<#owner> a acl:Authorization ;
3614 acl:agent <did:nostr:alice> ;
3615 acl:accessTo </shared/doc> ;
3616 acl:default </shared/> ;
3617 acl:mode acl:Read, acl:Write, acl:Control .
3618
3619<#writer> a acl:Authorization ;
3620 acl:agent <did:nostr:writer> ;
3621 acl:accessTo </shared/doc> ;
3622 acl:default </shared/> ;
3623 acl:mode acl:Read, acl:Write .
3624"#;
3625
3626 async fn seed_shared_acl(storage: &dyn Storage) {
3627 storage
3632 .put(
3633 "/shared.acl",
3634 Bytes::from(WRITE_NOT_CONTROL_ACL),
3635 "text/turtle",
3636 )
3637 .await
3638 .unwrap();
3639 }
3640
3641 #[actix_web::test]
3645 async fn acl_put_denied_for_writer_without_control() {
3646 let storage = Arc::new(MemoryBackend::new());
3647 seed_shared_acl(storage.as_ref()).await;
3648 let state = AppState::new(storage);
3649 let result =
3653 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some("did:nostr:writer")).await;
3654 assert!(
3655 result.is_err(),
3656 "writer lacks Control — must not be able to PUT /shared/.acl"
3657 );
3658 }
3659
3660 #[actix_web::test]
3662 async fn acl_put_allowed_for_control_holder() {
3663 let storage = Arc::new(MemoryBackend::new());
3664 seed_shared_acl(storage.as_ref()).await;
3665 let state = AppState::new(storage);
3666 let result =
3667 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
3668 assert!(
3669 result.is_ok(),
3670 "alice holds Control — must be allowed to PUT /shared/.acl"
3671 );
3672 }
3673
3674 #[actix_web::test]
3676 async fn meta_put_denied_for_writer_without_control() {
3677 let storage = Arc::new(MemoryBackend::new());
3678 seed_shared_acl(storage.as_ref()).await;
3679 let state = AppState::new(storage);
3680 let result = enforce_write(
3681 &state,
3682 "/shared/doc.meta",
3683 AccessMode::Write,
3684 Some("did:nostr:writer"),
3685 )
3686 .await;
3687 assert!(
3688 result.is_err(),
3689 "writer lacks Control — must not be able to PUT a .meta sidecar"
3690 );
3691 }
3692
3693 #[test]
3695 fn protected_resource_for_acl_strips_suffixes() {
3696 assert_eq!(protected_resource_for_acl("/victim/.acl").as_deref(), Some("/victim/"));
3697 assert_eq!(protected_resource_for_acl("/a/b.acl").as_deref(), Some("/a/b"));
3698 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
3699 assert_eq!(protected_resource_for_acl("/a/b.meta").as_deref(), Some("/a/b"));
3700 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
3701 }
3702}