1#![doc = include_str!("../README.md")]
72#![deny(unsafe_code)]
73#![warn(rust_2018_idioms)]
74
75pub mod cli;
77
78mod handlers;
83
84mod mcp;
87
88pub mod mempool;
93
94pub mod trail_store;
100
101use std::collections::HashMap;
102use std::net::{IpAddr, Ipv4Addr};
103use std::path::{Path, PathBuf};
104use std::sync::{Arc, Mutex};
105use std::time::{Duration, Instant};
106
107use actix_web::body::{BoxBody, EitherBody};
108use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
109use actix_web::http::{header, StatusCode};
110use actix_web::middleware::{NormalizePath, TrailingSlash};
111use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
112use bytes::Bytes;
113use futures_util::future::{ready, LocalBoxFuture, Ready};
114use percent_encoding::percent_decode_str;
115use serde::Deserialize;
116use solid_pod_rs::{
117 auth::nip98,
118 config::sources::parse_size,
119 interop,
120 ldp::{self, LdpContainerOps, PatchCreateOutcome},
121 mashlib::{self, MashlibConfig},
122 provision,
123 security::DotfileAllowlist,
124 storage::Storage,
125 wac::{
126 self, conditions::RequestContext, parse_jsonld_acl, parser::parse_turtle_acl, AccessMode,
127 },
128 PodError,
129};
130
131#[derive(Clone)]
137pub struct AppState {
138 pub storage: Arc<dyn Storage>,
139 pub dotfiles: Arc<DotfileAllowlist>,
140 pub body_cap: usize,
141 pub nodeinfo: NodeInfoMeta,
142 pub mashlib: MashlibConfig,
143 pub mashlib_cdn: Option<String>,
146 pub pay_config: solid_pod_rs::payments::PayConfig,
149 pub data_root: Option<PathBuf>,
154 pub pod_create_limiter: Arc<PodCreateLimiter>,
156 pub allowed_origins: Vec<String>,
163 pub admin_key: Option<String>,
168 pub mcp_enabled: bool,
173 pub mempool_url: Option<String>,
179}
180
181#[derive(Clone, Debug)]
183pub struct NodeInfoMeta {
184 pub software_name: String,
185 pub software_version: String,
186 pub open_registrations: bool,
187 pub total_users: u64,
188 pub base_url: String,
189}
190
191impl Default for NodeInfoMeta {
192 fn default() -> Self {
193 Self {
194 software_name: "solid-pod-rs-server".to_string(),
195 software_version: env!("CARGO_PKG_VERSION").to_string(),
196 open_registrations: false,
197 total_users: 0,
198 base_url: "http://localhost".to_string(),
199 }
200 }
201}
202
203pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
206
207pub fn body_cap_from_env() -> usize {
210 match std::env::var("JSS_MAX_REQUEST_BODY") {
211 Ok(v) => parse_size(&v)
212 .map(|u| u as usize)
213 .unwrap_or(DEFAULT_BODY_CAP),
214 Err(_) => DEFAULT_BODY_CAP,
215 }
216}
217
218impl AppState {
219 pub fn new(storage: Arc<dyn Storage>) -> Self {
222 Self {
223 storage,
224 dotfiles: Arc::new(DotfileAllowlist::from_env()),
225 body_cap: body_cap_from_env(),
226 nodeinfo: NodeInfoMeta::default(),
227 mashlib: MashlibConfig::default(),
228 mashlib_cdn: None,
229 pay_config: solid_pod_rs::payments::PayConfig::default(),
230 data_root: None,
231 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
232 allowed_origins: Vec::new(),
233 admin_key: None,
234 mcp_enabled: false,
235 mempool_url: None,
236 }
237 }
238}
239
240#[derive(Debug)]
242pub struct PodCreateLimiter {
243 hits: Mutex<HashMap<IpAddr, Instant>>,
244 window: Duration,
245}
246
247impl Default for PodCreateLimiter {
248 fn default() -> Self {
249 Self {
250 hits: Mutex::new(HashMap::new()),
251 window: Duration::from_secs(24 * 60 * 60),
252 }
253 }
254}
255
256impl PodCreateLimiter {
257 fn check(&self, ip: IpAddr) -> Result<(), u64> {
258 let now = Instant::now();
259 let mut hits = self.hits.lock().unwrap();
260 if let Some(last) = hits.get(&ip).copied() {
261 let elapsed = now.saturating_duration_since(last);
262 if elapsed < self.window {
263 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
264 }
265 }
266 hits.insert(ip, now);
267 Ok(())
268 }
269}
270
271pub(crate) fn to_actix(e: PodError) -> ActixError {
276 match e {
277 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
278 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
279 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
280 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
281 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
282 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
283 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
284 }
285}
286
287pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
293 let header_val = req
294 .headers()
295 .get(header::AUTHORIZATION)
296 .and_then(|v| v.to_str().ok())?;
297 let conn = req.connection_info();
305 let url = format!("{}://{}{}", conn.scheme(), conn.host(), req.uri().path());
306 nip98::verify(header_val, &url, req.method().as_str(), None)
307 .await
308 .ok()
309}
310
311pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
312 pubkey.map(|pk| format!("did:nostr:{pk}"))
313}
314
315pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
319
320async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
337 let did = agent_uri?;
338 let balance = match storage.get(WEBLEDGER_PATH).await {
339 Ok((bytes, _meta)) => {
340 match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
341 Ok(ledger) => ledger.get_balance(did),
342 Err(_) => 0,
346 }
347 }
348 Err(_) => 0,
351 };
352 Some(balance)
353}
354
355fn accept_includes_html(accept: &str) -> bool {
363 accept.split(',').any(|entry| {
364 let mime = entry.split(';').next().unwrap_or("").trim();
365 mime.eq_ignore_ascii_case("text/html")
366 })
367}
368
369fn protected_resource_for_acl(path: &str) -> Option<String> {
385 for suffix in [".acl", ".meta"] {
386 if let Some(stripped) = path.strip_suffix(suffix) {
387 if stripped.is_empty() {
391 return Some("/".to_string());
392 }
393 return Some(stripped.to_string());
394 }
395 }
396 None
397}
398
399fn proposed_acl_keeps_caller_control(body: &[u8], content_type: &str, caller: Option<&str>) -> bool {
407 let doc = match parse_jsonld_acl(body) {
408 Ok(d) => Some(d),
409 Err(_) => {
410 let ct = content_type.to_ascii_lowercase();
411 let text = std::str::from_utf8(body).unwrap_or("");
412 let looks_turtle = ct.starts_with("text/turtle")
413 || ct.starts_with("application/turtle")
414 || ct.starts_with("application/x-turtle")
415 || text.contains("@prefix")
416 || text.contains("acl:Authorization");
417 if looks_turtle {
418 parse_turtle_acl(text).ok()
419 } else {
420 None
421 }
422 }
423 };
424 let Some(doc) = doc else {
425 return true;
427 };
428 let Some(graph) = doc.graph.as_ref() else {
429 return false;
430 };
431 graph.iter().any(|auth| {
432 let grants_control = ids_of_acl_field(&auth.mode)
433 .iter()
434 .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
435 if !grants_control {
436 return false;
437 }
438 let agents = ids_of_acl_field(&auth.agent);
439 if let Some(web_id) = caller {
440 if agents.iter().any(|a| *a == web_id) {
441 return true;
442 }
443 }
444 let classes = ids_of_acl_field(&auth.agent_class);
445 if classes
446 .iter()
447 .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
448 {
449 return true;
450 }
451 if caller.is_some()
452 && classes.iter().any(|c| {
453 *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
454 || *c == "acl:AuthenticatedAgent"
455 })
456 {
457 return true;
458 }
459 false
460 })
461}
462
463fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
465 match field {
466 None => Vec::new(),
467 Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
468 Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
469 }
470}
471
472async fn enforce_write(
473 state: &AppState,
474 path: &str,
475 mode: AccessMode,
476 agent_uri: Option<&str>,
477) -> Result<(), ActixError> {
478 if let Some(protected) = protected_resource_for_acl(path) {
487 let control_acl = match find_effective_acl_dyn(&*state.storage, &protected).await {
488 Ok(doc) => doc,
489 Err(e) => return Err(to_actix(e)),
490 };
491 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
492 let ctx = RequestContext {
493 web_id: agent_uri,
494 client_id: None,
495 issuer: None,
496 payment_balance_sats,
497 };
498 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
499 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
500 let has_control = wac::evaluate_access_ctx_with_registry(
501 control_acl.as_ref(),
502 &ctx,
503 &protected,
504 AccessMode::Control,
505 None,
506 &groups,
507 ®istry,
508 );
509 if !has_control {
510 return Err(acl_denial(control_acl.as_ref(), agent_uri, &protected));
511 }
512 return Ok(());
513 }
514
515 let acl_doc = match find_effective_acl_dyn(&*state.storage, path).await {
520 Ok(doc) => doc,
521 Err(e) => return Err(to_actix(e)),
522 };
523
524 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
529
530 let ctx = RequestContext {
531 web_id: agent_uri,
532 client_id: None,
533 issuer: None,
534 payment_balance_sats,
535 };
536 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
537 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
538 let granted = wac::evaluate_access_ctx_with_registry(
539 acl_doc.as_ref(),
540 &ctx,
541 path,
542 mode,
543 None,
544 &groups,
545 ®istry,
546 );
547 if granted {
548 if let Err(e) =
556 charge_granted_payment(state, acl_doc.as_ref(), &ctx, path, mode, &groups, ®istry)
557 .await
558 {
559 return Err(e);
560 }
561 return Ok(());
562 }
563
564 Err(acl_denial(acl_doc.as_ref(), agent_uri, path))
565}
566
567async fn charge_granted_payment(
576 state: &AppState,
577 acl_doc: Option<&wac::AclDocument>,
578 ctx: &RequestContext<'_>,
579 path: &str,
580 mode: AccessMode,
581 groups: &wac::StaticGroupMembership,
582 registry: &wac::conditions::ConditionRegistry,
583) -> Result<(), ActixError> {
584 let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
585 if cost == 0 {
586 return Ok(());
587 }
588 if let Some(did) = ctx.web_id {
589 if debit_ledger(&*state.storage, did, cost).await.is_err() {
590 return Err(acl_denial(acl_doc, ctx.web_id, path));
591 }
592 }
593 Ok(())
594}
595
596fn acl_denial(
602 acl_doc: Option<&wac::AclDocument>,
603 agent_uri: Option<&str>,
604 path: &str,
605) -> ActixError {
606 let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
607 let (status, body, unauthenticated) = if agent_uri.is_none() {
608 (StatusCode::UNAUTHORIZED, "authentication required", true)
609 } else {
610 (StatusCode::FORBIDDEN, "access forbidden", false)
611 };
612 let mut rsp = HttpResponse::new(status);
613 rsp.headers_mut().insert(
614 header::HeaderName::from_static("wac-allow"),
615 header::HeaderValue::from_str(&allow_header)
616 .unwrap_or(header::HeaderValue::from_static("")),
617 );
618 if unauthenticated {
619 rsp.headers_mut().insert(
626 header::WWW_AUTHENTICATE,
627 header::HeaderValue::from_static(
628 "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
629 ),
630 );
631 }
632 actix_web::error::InternalError::from_response(body, rsp).into()
633}
634
635async fn enforce_read(
643 state: &AppState,
644 path: &str,
645 agent_uri: Option<&str>,
646) -> Result<(), ActixError> {
647 let acl_doc = match find_effective_acl_dyn(&*state.storage, path).await {
648 Ok(doc) => doc,
649 Err(e) => return Err(to_actix(e)),
650 };
651 let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
652 let ctx = RequestContext {
653 web_id: agent_uri,
654 client_id: None,
655 issuer: None,
656 payment_balance_sats,
657 };
658 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
659 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
660 let granted = wac::evaluate_access_ctx_with_registry(
661 acl_doc.as_ref(),
662 &ctx,
663 path,
664 AccessMode::Read,
665 None,
666 &groups,
667 ®istry,
668 );
669 if granted {
670 charge_granted_payment(
675 state,
676 acl_doc.as_ref(),
677 &ctx,
678 path,
679 AccessMode::Read,
680 &groups,
681 ®istry,
682 )
683 .await?;
684 return Ok(());
685 }
686 Err(acl_denial(acl_doc.as_ref(), agent_uri, path))
687}
688
689async fn debit_ledger(
698 storage: &dyn Storage,
699 did: &str,
700 cost: u64,
701) -> Result<(), solid_pod_rs::payments::PaymentError> {
702 use solid_pod_rs::payments::{PaymentError, WebLedger};
703
704 let (bytes, _meta) = storage
705 .get(WEBLEDGER_PATH)
706 .await
707 .map_err(|e| PaymentError::Store(e.to_string()))?;
708 let mut ledger: WebLedger = serde_json::from_slice(&bytes)
709 .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
710 ledger.debit(did, cost)?;
711 let body = serde_json::to_vec(&ledger)
712 .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
713 storage
714 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
715 .await
716 .map_err(|e| PaymentError::Store(e.to_string()))?;
717 Ok(())
718}
719
720fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
725 let links = ldp::link_headers(path).join(", ");
726 if let Ok(value) = header::HeaderValue::from_str(&links) {
727 rsp.headers_mut()
728 .insert(header::HeaderName::from_static("link"), value);
729 }
730}
731
732fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
733 if let Ok(v) = header::HeaderValue::from_str(header_value) {
734 rsp.headers_mut()
735 .insert(header::HeaderName::from_static("wac-allow"), v);
736 }
737}
738
739fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
740 let ws_base = base_url
741 .replacen("https://", "wss://", 1)
742 .replacen("http://", "ws://", 1);
743 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
744 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
745 rsp.headers_mut()
746 .insert(header::HeaderName::from_static("updates-via"), v);
747 }
748}
749
750async fn handle_get(
751 req: HttpRequest,
752 state: web::Data<AppState>,
753) -> Result<HttpResponse, ActixError> {
754 let path = req.uri().path().to_string();
755
756 if path.contains('*') {
757 return handle_glob_get(req, state).await;
758 }
759
760 let auth_pk = extract_pubkey(&req).await;
761 let agent = agent_uri(auth_pk.as_ref());
762
763 enforce_read(&state, &path, agent.as_deref()).await?;
768
769 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
770
771 if ldp::is_container(&path) {
772 let accept = req
773 .headers()
774 .get(header::ACCEPT)
775 .and_then(|v| v.to_str().ok())
776 .unwrap_or("");
777
778 if accept_includes_html(accept) {
784 let index_path = format!("{}index.html", &path);
785 if let Ok((body, _meta)) = state.storage.get(&index_path).await {
786 let mut rsp = HttpResponse::Ok()
787 .content_type("text/html; charset=utf-8")
788 .body(body.to_vec());
789 set_wac_allow(&mut rsp, &wac_allow);
790 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
791 set_link_headers(&mut rsp, &path);
792 return Ok(rsp);
793 }
794 }
795
796 let v = state
797 .storage
798 .container_representation(&path)
799 .await
800 .map_err(to_actix)?;
801
802 let sec_fetch_dest = req
804 .headers()
805 .get("sec-fetch-dest")
806 .and_then(|v| v.to_str().ok());
807 if mashlib::should_serve(
808 accept,
809 sec_fetch_dest,
810 "application/ld+json",
811 state.mashlib.enabled,
812 ) {
813 let json_ld = serde_json::to_string(&v).ok();
814 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
815 let mut rsp = HttpResponse::Ok()
816 .content_type("text/html; charset=utf-8")
817 .insert_header(("X-Frame-Options", "DENY"))
818 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
819 .insert_header(("Cache-Control", "no-store"))
820 .body(html);
821 set_wac_allow(&mut rsp, &wac_allow);
822 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
823 set_link_headers(&mut rsp, &path);
824 return Ok(rsp);
825 }
826
827 let mut rsp = HttpResponse::Ok().json(v);
828 rsp.headers_mut().insert(
829 header::CONTENT_TYPE,
830 header::HeaderValue::from_static("application/ld+json"),
831 );
832 set_wac_allow(&mut rsp, &wac_allow);
833 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
834 set_link_headers(&mut rsp, &path);
835 return Ok(rsp);
836 }
837
838 match state.storage.get(&path).await {
839 Ok((body, meta)) => {
840 let accept = req
842 .headers()
843 .get(header::ACCEPT)
844 .and_then(|v| v.to_str().ok())
845 .unwrap_or("");
846 let sec_fetch_dest = req
847 .headers()
848 .get("sec-fetch-dest")
849 .and_then(|v| v.to_str().ok());
850 if mashlib::should_serve(
851 accept,
852 sec_fetch_dest,
853 &meta.content_type,
854 state.mashlib.enabled,
855 ) {
856 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
857 std::str::from_utf8(&body).ok().map(|s| s.to_string())
858 } else {
859 None
860 };
861 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
862 let mut rsp = HttpResponse::Ok()
863 .content_type("text/html; charset=utf-8")
864 .insert_header(("X-Frame-Options", "DENY"))
865 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
866 .insert_header(("Cache-Control", "no-store"))
867 .body(html);
868 set_wac_allow(&mut rsp, &wac_allow);
869 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
870 set_link_headers(&mut rsp, &path);
871 return Ok(rsp);
872 }
873
874 if let Some((negotiated_body, negotiated_ct)) =
882 rdf_content_negotiate(&body, &meta.content_type, accept)
883 {
884 let mut rsp = HttpResponse::Ok().body(negotiated_body);
885 rsp.headers_mut().insert(
886 header::CONTENT_TYPE,
887 header::HeaderValue::from_str(negotiated_ct)
888 .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
889 );
890 rsp.headers_mut()
891 .insert(header::VARY, header::HeaderValue::from_static("Accept"));
892 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
893 rsp.headers_mut().insert(header::ETAG, etag);
894 }
895 set_wac_allow(&mut rsp, &wac_allow);
896 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
897 set_link_headers(&mut rsp, &path);
898 return Ok(rsp);
899 }
900
901 let mut rsp = HttpResponse::Ok().body(body.to_vec());
902 rsp.headers_mut().insert(
903 header::CONTENT_TYPE,
904 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
905 header::HeaderValue::from_static("application/octet-stream")
906 }),
907 );
908 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
909 rsp.headers_mut().insert(header::ETAG, etag);
910 }
911 set_wac_allow(&mut rsp, &wac_allow);
912 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
913 set_link_headers(&mut rsp, &path);
914 Ok(rsp)
915 }
916 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
917 Err(e) => Err(to_actix(e)),
918 }
919}
920
921fn has_basic_container_link(req: &HttpRequest) -> bool {
922 req.headers()
923 .get_all(header::LINK)
924 .filter_map(|v| v.to_str().ok())
925 .any(|v| {
926 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
927 })
928}
929
930async fn handle_put(
931 req: HttpRequest,
932 body: web::Bytes,
933 state: web::Data<AppState>,
934) -> Result<HttpResponse, ActixError> {
935 let path = req.uri().path().to_string();
936
937 if ldp::is_container(&path) {
938 if has_basic_container_link(&req) {
939 let auth_pk = extract_pubkey(&req).await;
940 let agent = agent_uri(auth_pk.as_ref());
941 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
942 let meta = state
943 .storage
944 .create_container(&path)
945 .await
946 .map_err(to_actix)?;
947 let mut rsp = HttpResponse::Created().finish();
948 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
949 rsp.headers_mut().insert(header::ETAG, etag);
950 }
951 set_link_headers(&mut rsp, &path);
952 return Ok(rsp);
953 }
954 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
955 }
956
957 let auth_pk = extract_pubkey(&req).await;
958 let agent = agent_uri(auth_pk.as_ref());
959 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
960
961 let ct = req
962 .headers()
963 .get(header::CONTENT_TYPE)
964 .and_then(|v| v.to_str().ok())
965 .unwrap_or("application/octet-stream");
966
967 if protected_resource_for_acl(&path).is_some()
972 && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
973 {
974 return Ok(HttpResponse::Conflict().body(
975 "refused: the proposed ACL would not grant Control to the caller \
976 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
977 ));
978 }
979
980 let meta = state
981 .storage
982 .put(&path, Bytes::from(body.to_vec()), ct)
983 .await
984 .map_err(to_actix)?;
985 git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
989 let mut rsp = HttpResponse::Created().finish();
990 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
991 rsp.headers_mut().insert(header::ETAG, etag);
992 }
993 set_link_headers(&mut rsp, &path);
994 Ok(rsp)
995}
996
997async fn handle_post(
998 req: HttpRequest,
999 body: web::Bytes,
1000 state: web::Data<AppState>,
1001) -> Result<HttpResponse, ActixError> {
1002 let path = req.uri().path().to_string();
1003 let auth_pk = extract_pubkey(&req).await;
1006 let agent = agent_uri(auth_pk.as_ref());
1007 enforce_write(&state, &path, AccessMode::Append, agent.as_deref()).await?;
1008
1009 let slug = req
1010 .headers()
1011 .get(header::HeaderName::from_static("slug"))
1012 .and_then(|v| v.to_str().ok());
1013 let target = match ldp::resolve_slug(&path, slug) {
1014 Ok(p) => p,
1015 Err(e) => return Err(to_actix(e)),
1016 };
1017 let ct = req
1018 .headers()
1019 .get(header::CONTENT_TYPE)
1020 .and_then(|v| v.to_str().ok())
1021 .unwrap_or("application/octet-stream");
1022 let meta = state
1023 .storage
1024 .put(&target, Bytes::from(body.to_vec()), ct)
1025 .await
1026 .map_err(to_actix)?;
1027 git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1030 let mut rsp = HttpResponse::Created().finish();
1031 if let Ok(loc) = header::HeaderValue::from_str(&target) {
1032 rsp.headers_mut().insert(header::LOCATION, loc);
1033 }
1034 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1035 rsp.headers_mut().insert(header::ETAG, etag);
1036 }
1037 set_link_headers(&mut rsp, &target);
1038 Ok(rsp)
1039}
1040
1041async fn handle_patch(
1042 req: HttpRequest,
1043 body: web::Bytes,
1044 state: web::Data<AppState>,
1045) -> Result<HttpResponse, ActixError> {
1046 let path = req.uri().path().to_string();
1047 if ldp::is_container(&path) {
1048 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1049 }
1050 let auth_pk = extract_pubkey(&req).await;
1051 let agent = agent_uri(auth_pk.as_ref());
1052 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
1058
1059 let ct = req
1060 .headers()
1061 .get(header::CONTENT_TYPE)
1062 .and_then(|v| v.to_str().ok())
1063 .unwrap_or("");
1064 let dialect = match ldp::patch_dialect_from_mime(ct) {
1065 Some(d) => d,
1066 None => {
1067 return Ok(HttpResponse::UnsupportedMediaType()
1068 .body(format!("unsupported patch dialect for content-type {ct:?}")))
1069 }
1070 };
1071 let body_str = match std::str::from_utf8(&body) {
1072 Ok(s) => s.to_string(),
1073 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1074 };
1075
1076 let existing = state.storage.get(&path).await;
1078 match existing {
1079 Ok((current_body, meta)) => {
1080 let out = match dialect {
1090 ldp::PatchDialect::N3 => {
1091 let seed = seed_graph_from_patch_target(¤t_body)?;
1092 ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1093 }
1094 ldp::PatchDialect::SparqlUpdate => {
1095 let seed = seed_graph_from_patch_target(¤t_body)?;
1096 ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1097 }
1098 ldp::PatchDialect::JsonPatch => {
1099 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
1100 Ok(v) => v,
1101 Err(_) => serde_json::json!({}),
1102 };
1103 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1104 Ok(v) => v,
1105 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1106 };
1107 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1108 let bytes = serde_json::to_vec(&json)
1109 .map_err(PodError::from)
1110 .map_err(to_actix)?;
1111 let _ = state
1112 .storage
1113 .put(&path, Bytes::from(bytes), &meta.content_type)
1114 .await
1115 .map_err(to_actix)?;
1116 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1117 return Ok(HttpResponse::NoContent().finish());
1118 }
1119 };
1120 let outcome = out?;
1121 let serialised = graph_to_turtle(&outcome.graph);
1124 let _ = state
1125 .storage
1126 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1127 .await
1128 .map_err(to_actix)?;
1129 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1130 Ok(HttpResponse::NoContent().finish())
1131 }
1132 Err(PodError::NotFound(_)) => {
1133 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
1135 let PatchCreateOutcome::Created { graph, .. } = create else {
1136 return Err(to_actix(PodError::Unsupported(
1137 "unexpected patch outcome on absent resource".into(),
1138 )));
1139 };
1140 let serialised = graph_to_turtle(&graph);
1141 let _ = state
1142 .storage
1143 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1144 .await
1145 .map_err(to_actix)?;
1146 git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1147 Ok(HttpResponse::Created().finish())
1148 }
1149 Err(e) => Err(to_actix(e)),
1150 }
1151}
1152
1153fn patch_parse_err(e: PodError) -> ActixError {
1157 match e {
1158 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
1159 actix_web::error::ErrorBadRequest(msg)
1160 }
1161 other => to_actix(other),
1162 }
1163}
1164
1165fn graph_to_turtle(g: &ldp::Graph) -> String {
1169 g.to_ntriples()
1170}
1171
1172fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
1179 let mut best: Option<(f32, ldp::RdfFormat)> = None;
1180 for entry in accept.split(',') {
1181 let entry = entry.trim();
1182 if entry.is_empty() {
1183 continue;
1184 }
1185 let mut parts = entry.split(';').map(|s| s.trim());
1186 let mime = match parts.next() {
1187 Some(m) => m,
1188 None => continue,
1189 };
1190 let mut q: f32 = 1.0;
1191 for token in parts {
1192 if let Some(v) = token.strip_prefix("q=") {
1193 if let Ok(parsed) = v.parse::<f32>() {
1194 q = parsed;
1195 }
1196 }
1197 }
1198 if let Some(format) = ldp::RdfFormat::from_mime(mime) {
1201 match best {
1202 None => best = Some((q, format)),
1203 Some((bq, _)) if q > bq => best = Some((q, format)),
1204 _ => {}
1205 }
1206 }
1207 }
1208 best.map(|(_, f)| f)
1209}
1210
1211fn rdf_content_negotiate(
1227 body: &[u8],
1228 stored_ct: &str,
1229 accept: &str,
1230) -> Option<(Vec<u8>, &'static str)> {
1231 if accept.trim().is_empty() {
1232 return None;
1233 }
1234 let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
1235 let target = best_explicit_rdf_format(accept)?;
1236 if target == stored_format {
1237 return None;
1238 }
1239 let text = std::str::from_utf8(body).ok()?;
1240 let graph = ldp::Graph::parse_ntriples(text).ok()?;
1241 match target {
1242 ldp::RdfFormat::Turtle => {
1245 Some((graph.to_ntriples().into_bytes(), ldp::RdfFormat::Turtle.mime()))
1246 }
1247 ldp::RdfFormat::NTriples => Some((
1248 graph.to_ntriples().into_bytes(),
1249 ldp::RdfFormat::NTriples.mime(),
1250 )),
1251 ldp::RdfFormat::JsonLd => {
1252 let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
1253 Some((json, ldp::RdfFormat::JsonLd.mime()))
1254 }
1255 ldp::RdfFormat::RdfXml => None,
1257 }
1258}
1259
1260fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
1269 let text = std::str::from_utf8(current_body).map_err(|_| {
1270 actix_web::error::ErrorConflict(
1271 "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
1272 )
1273 })?;
1274 if text.trim().is_empty() {
1275 return Ok(ldp::Graph::new());
1276 }
1277 ldp::Graph::parse_ntriples(text).map_err(|_| {
1278 actix_web::error::ErrorConflict(
1279 "existing resource is not N-Triples RDF and cannot be non-destructively \
1280 patched; PUT an N-Triples representation or use a JSON Patch",
1281 )
1282 })
1283}
1284
1285pub(crate) async fn find_effective_acl_dyn(
1291 storage: &dyn Storage,
1292 resource_path: &str,
1293) -> Result<Option<wac::AclDocument>, PodError> {
1294 let mut path = resource_path.to_string();
1295 let mut inherited = false;
1300 loop {
1301 let acl_key = if path == "/" {
1302 "/.acl".to_string()
1303 } else {
1304 format!("{}.acl", path.trim_end_matches('/'))
1305 };
1306 if let Ok((body, meta)) = storage.get(&acl_key).await {
1307 match parse_jsonld_acl(&body) {
1308 Ok(mut doc) => {
1309 doc.inherited = inherited;
1310 return Ok(Some(doc));
1311 }
1312 Err(PodError::BadRequest(_)) => {
1313 return Err(PodError::BadRequest("ACL document exceeds bounds".into()))
1314 }
1315 Err(_) => {}
1316 }
1317 let ct = meta.content_type.to_ascii_lowercase();
1318 let looks_turtle = ct.starts_with("text/turtle")
1319 || ct.starts_with("application/turtle")
1320 || ct.starts_with("application/x-turtle");
1321 let text = std::str::from_utf8(&body).unwrap_or("");
1322 if looks_turtle || text.contains("@prefix") || text.contains("acl:Authorization") {
1323 if let Ok(mut doc) = parse_turtle_acl(text) {
1324 doc.inherited = inherited;
1325 return Ok(Some(doc));
1326 }
1327 }
1328 }
1329 if path == "/" || path.is_empty() {
1330 break;
1331 }
1332 inherited = true;
1334 let trimmed = path.trim_end_matches('/');
1335 path = match trimmed.rfind('/') {
1336 Some(0) => "/".to_string(),
1337 Some(pos) => trimmed[..pos].to_string(),
1338 None => "/".to_string(),
1339 };
1340 }
1341 Ok(None)
1342}
1343
1344async fn handle_delete(
1345 req: HttpRequest,
1346 state: web::Data<AppState>,
1347) -> Result<HttpResponse, ActixError> {
1348 let path = req.uri().path().to_string();
1349 let auth_pk = extract_pubkey(&req).await;
1350 let agent = agent_uri(auth_pk.as_ref());
1351 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
1352
1353 match state.storage.delete(&path).await {
1354 Ok(()) => Ok(HttpResponse::NoContent().finish()),
1355 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1356 Err(e) => Err(to_actix(e)),
1357 }
1358}
1359
1360async fn handle_options(
1361 req: HttpRequest,
1362 state: web::Data<AppState>,
1363) -> Result<HttpResponse, ActixError> {
1364 let path = req.uri().path().to_string();
1365 let o = ldp::options_for(&path);
1366 let mut rsp = HttpResponse::NoContent().finish();
1367 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
1368 rsp.headers_mut()
1369 .insert(header::HeaderName::from_static("allow"), v);
1370 }
1371 if let Some(ap) = o.accept_post {
1372 if let Ok(v) = header::HeaderValue::from_str(ap) {
1373 rsp.headers_mut()
1374 .insert(header::HeaderName::from_static("accept-post"), v);
1375 }
1376 }
1377 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
1378 rsp.headers_mut()
1379 .insert(header::HeaderName::from_static("accept-patch"), v);
1380 }
1381 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
1382 rsp.headers_mut()
1383 .insert(header::HeaderName::from_static("accept-ranges"), v);
1384 }
1385 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1386 Ok(rsp)
1387}
1388
1389async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
1394 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
1395 HttpResponse::Ok()
1396 .content_type("application/ld+json")
1397 .json(doc)
1398}
1399
1400#[derive(Debug, Deserialize)]
1401struct WebFingerQuery {
1402 resource: Option<String>,
1403}
1404
1405async fn handle_well_known_webfinger(
1406 state: web::Data<AppState>,
1407 q: web::Query<WebFingerQuery>,
1408) -> HttpResponse {
1409 let resource = q.resource.clone().unwrap_or_else(|| {
1410 format!(
1411 "acct:anonymous@{}",
1412 state
1413 .nodeinfo
1414 .base_url
1415 .trim_start_matches("http://")
1416 .trim_start_matches("https://")
1417 )
1418 });
1419 let webid = format!(
1420 "{}/profile/card#me",
1421 state.nodeinfo.base_url.trim_end_matches('/')
1422 );
1423 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
1424 Some(jrd) => HttpResponse::Ok()
1425 .content_type("application/jrd+json")
1426 .json(jrd),
1427 None => HttpResponse::NotFound().finish(),
1428 }
1429}
1430
1431async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
1432 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
1433 HttpResponse::Ok()
1434 .content_type("application/json")
1435 .json(doc)
1436}
1437
1438async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
1439 let doc = interop::nodeinfo_2_1(
1440 &state.nodeinfo.software_name,
1441 &state.nodeinfo.software_version,
1442 state.nodeinfo.open_registrations,
1443 state.nodeinfo.total_users,
1444 );
1445 HttpResponse::Ok()
1446 .content_type("application/json")
1447 .json(doc)
1448}
1449
1450#[cfg(feature = "did-nostr")]
1451async fn handle_well_known_did_nostr(
1452 state: web::Data<AppState>,
1453 path: web::Path<String>,
1454) -> HttpResponse {
1455 let pubkey = path.into_inner();
1456 let also = vec![format!(
1457 "{}/profile/card#me",
1458 state.nodeinfo.base_url.trim_end_matches('/')
1459 )];
1460 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
1461 HttpResponse::Ok()
1462 .content_type("application/did+json")
1463 .json(doc)
1464}
1465
1466#[cfg(feature = "nip05-endpoint")]
1474#[derive(Debug, Deserialize)]
1475struct Nip05Query {
1476 name: Option<String>,
1479}
1480
1481#[cfg(feature = "nip05-endpoint")]
1482fn nip05_name_is_valid(name: &str) -> bool {
1483 if name.is_empty() {
1486 return false;
1487 }
1488 name.bytes()
1489 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
1490}
1491
1492#[cfg(feature = "nip05-endpoint")]
1493async fn handle_well_known_nip05(
1494 state: web::Data<AppState>,
1495 query: web::Query<Nip05Query>,
1496) -> HttpResponse {
1497 use solid_pod_rs::webid::extract_nostr_pubkey;
1498
1499 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
1501 if !nip05_name_is_valid(&name) {
1502 return HttpResponse::BadRequest().json(serde_json::json!({
1503 "error": "invalid NIP-05 local part",
1504 }));
1505 }
1506
1507 let profile_path = if name == "_" {
1513 "/profile/card".to_string()
1514 } else {
1515 format!("/{name}/profile/card")
1516 };
1517
1518 let (body, _meta) = match state.storage.get(&profile_path).await {
1519 Ok(v) => v,
1520 Err(_) => {
1521 return nip05_empty_response();
1525 }
1526 };
1527
1528 let pubkey_hex = match extract_nostr_pubkey(&body) {
1529 Ok(Some(p)) => p,
1530 _ => return nip05_empty_response(),
1531 };
1532
1533 let doc = interop::nip05_document([(name, pubkey_hex)]);
1534 HttpResponse::Ok()
1535 .insert_header(("Access-Control-Allow-Origin", "*"))
1536 .content_type("application/json")
1537 .json(doc)
1538}
1539
1540#[cfg(feature = "nip05-endpoint")]
1541fn nip05_empty_response() -> HttpResponse {
1542 HttpResponse::Ok()
1543 .insert_header(("Access-Control-Allow-Origin", "*"))
1544 .content_type("application/json")
1545 .json(serde_json::json!({ "names": {} }))
1546}
1547
1548#[derive(Debug, Deserialize)]
1553struct CreateAccountRequest {
1554 username: String,
1555 #[serde(default)]
1556 name: Option<String>,
1557}
1558
1559#[derive(Debug, Deserialize)]
1560struct CreatePodRequest {
1561 name: String,
1562}
1563
1564async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
1565 let pod_name = path.into_inner();
1566 let pod_root = format!("/{pod_name}/");
1567 match state.storage.exists(&pod_root).await {
1568 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
1569 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
1570 }
1571}
1572
1573fn valid_pod_name(name: &str) -> bool {
1574 !name.is_empty()
1575 && name
1576 .chars()
1577 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
1578}
1579
1580fn request_ip(req: &HttpRequest) -> IpAddr {
1581 req.peer_addr()
1582 .map(|addr| addr.ip())
1583 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
1584}
1585
1586async fn handle_create_account(
1587 state: web::Data<AppState>,
1588 body: web::Json<CreateAccountRequest>,
1589) -> Result<HttpResponse, ActixError> {
1590 let pod_root = format!("/{}/", body.username);
1591 if state.storage.exists(&pod_root).await.unwrap_or(false) {
1592 return Ok(
1593 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
1594 );
1595 }
1596
1597 let mut plan = provision::ProvisionPlan::new(
1598 body.username.clone(),
1599 format!(
1600 "{}/{}",
1601 state.nodeinfo.base_url.trim_end_matches('/'),
1602 body.username,
1603 ),
1604 );
1605 plan.display_name = body.name.clone();
1606 plan.containers = vec![
1607 format!("/{}/", body.username),
1608 format!("/{}/profile/", body.username),
1609 format!("/{}/inbox/", body.username),
1610 format!("/{}/public/", body.username),
1611 format!("/{}/private/", body.username),
1612 format!("/{}/settings/", body.username),
1613 ];
1614
1615 #[cfg(feature = "git")]
1619 let outcome = {
1620 use solid_pod_rs_git::init::GitAutoInit;
1621 let git_hook = state.data_root.as_ref().map(|root| {
1622 let fs_path = root.join(&body.username);
1623 (GitAutoInit::new(), fs_path)
1624 });
1625 match git_hook {
1626 Some((hook, ref fs_path)) => {
1627 provision::provision_pod_ext(state.storage.as_ref(), &plan, Some((&hook, fs_path)))
1628 .await
1629 }
1630 None => provision::provision_pod(state.storage.as_ref(), &plan).await,
1631 }
1632 };
1633 #[cfg(not(feature = "git"))]
1634 let outcome = provision::provision_pod(state.storage.as_ref(), &plan).await;
1635
1636 match outcome {
1637 Ok(outcome) => Ok(HttpResponse::Created().json(serde_json::json!({
1638 "webid": outcome.webid,
1639 "pod_root": outcome.pod_root,
1640 "username": body.username,
1641 }))),
1642 Err(e) => Err(to_actix(e)),
1643 }
1644}
1645
1646async fn handle_create_pod(
1647 req: HttpRequest,
1648 state: web::Data<AppState>,
1649 body: web::Json<CreatePodRequest>,
1650) -> Result<HttpResponse, ActixError> {
1651 let ip = request_ip(&req);
1652 if let Err(retry_after) = state.pod_create_limiter.check(ip) {
1653 return Ok(HttpResponse::TooManyRequests()
1654 .insert_header(("Retry-After", retry_after.to_string()))
1655 .json(serde_json::json!({
1656 "error": "Too Many Requests",
1657 "message": "Pod creation rate limit exceeded",
1658 "retryAfter": retry_after
1659 })));
1660 }
1661
1662 if !valid_pod_name(&body.name) {
1663 return Ok(HttpResponse::BadRequest().json(serde_json::json!({
1664 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
1665 })));
1666 }
1667
1668 let pod_root = format!("/{}/", body.name);
1669 if state.storage.exists(&pod_root).await.unwrap_or(false) {
1670 return Ok(
1671 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
1672 );
1673 }
1674
1675 let conn = req.connection_info();
1676 let base_uri = format!("{}://{}", conn.scheme(), conn.host());
1677 let pod_uri = format!("{}/{}/", base_uri.trim_end_matches('/'), body.name);
1678
1679 for container in [
1680 format!("/{}/", body.name),
1681 format!("/{}/profile/", body.name),
1682 format!("/{}/inbox/", body.name),
1683 format!("/{}/public/", body.name),
1684 format!("/{}/private/", body.name),
1685 format!("/{}/settings/", body.name),
1686 ] {
1687 let meta_key = format!("{}.meta", container.trim_end_matches('/'));
1688 state
1689 .storage
1690 .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
1691 .await
1692 .map_err(to_actix)?;
1693 }
1694
1695 let canonical_pods_prefix = format!("{}/pods/{}/", base_uri.trim_end_matches('/'), body.name);
1696 let webid = format!("{pod_uri}profile/card#me");
1697 let profile = solid_pod_rs::webid::generate_webid_html(&body.name, None, &base_uri)
1698 .replace(&canonical_pods_prefix, &pod_uri);
1699 state
1700 .storage
1701 .put(
1702 &format!("/{}/profile/card", body.name),
1703 Bytes::from(profile.into_bytes()),
1704 "text/html",
1705 )
1706 .await
1707 .map_err(to_actix)?;
1708
1709 Ok(HttpResponse::Created()
1710 .insert_header(("Location", pod_uri.clone()))
1711 .json(serde_json::json!({
1712 "name": body.name,
1713 "webId": webid,
1714 "podUri": pod_uri,
1715 })))
1716}
1717
1718async fn handle_copy(
1723 req: HttpRequest,
1724 state: web::Data<AppState>,
1725) -> Result<HttpResponse, ActixError> {
1726 let dest = req.uri().path().to_string();
1727 let auth_pk = extract_pubkey(&req).await;
1728 let agent = agent_uri(auth_pk.as_ref());
1729 enforce_write(&state, &dest, AccessMode::Write, agent.as_deref()).await?;
1730
1731 let source = req
1732 .headers()
1733 .get("source")
1734 .and_then(|v| v.to_str().ok())
1735 .map(|s| s.to_string());
1736 let source = match source {
1737 Some(s) => s,
1738 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
1739 };
1740
1741 let (body, meta) = match state.storage.get(&source).await {
1742 Ok(v) => v,
1743 Err(PodError::NotFound(_)) => {
1744 return Ok(HttpResponse::NotFound().body("source resource not found"))
1745 }
1746 Err(e) => return Err(to_actix(e)),
1747 };
1748
1749 state
1750 .storage
1751 .put(&dest, body, &meta.content_type)
1752 .await
1753 .map_err(to_actix)?;
1754
1755 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
1757 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
1758 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
1759 let _ = state
1760 .storage
1761 .put(&dst_acl, acl_body, &acl_meta.content_type)
1762 .await;
1763 }
1764
1765 let mut rsp = HttpResponse::Created().finish();
1766 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
1767 rsp.headers_mut().insert(header::LOCATION, loc);
1768 }
1769 Ok(rsp)
1770}
1771
1772async fn handle_glob_get(
1777 req: HttpRequest,
1778 state: web::Data<AppState>,
1779) -> Result<HttpResponse, ActixError> {
1780 let raw_path = req.uri().path().to_string();
1781 if !raw_path.ends_with("/*") {
1783 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
1784 }
1785 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
1787 folder.to_string()
1788 } else {
1789 format!("{folder}/")
1790 };
1791
1792 let auth_pk = extract_pubkey(&req).await;
1796 let agent = agent_uri(auth_pk.as_ref());
1797 enforce_read(&state, &folder, agent.as_deref()).await?;
1798
1799 let children = state.storage.list(&folder).await.map_err(to_actix)?;
1800 let mut merged = String::new();
1801
1802 for child in &children {
1803 if child.ends_with('/') {
1804 continue;
1805 }
1806 let child_path = format!("{folder}{child}");
1807 if let Ok((body, meta)) = state.storage.get(&child_path).await {
1808 if meta.content_type.contains("turtle")
1809 || meta.content_type.contains("n-triples")
1810 || meta.content_type.contains("n3")
1811 {
1812 if let Ok(text) = std::str::from_utf8(&body) {
1813 merged.push_str(text);
1814 merged.push('\n');
1815 }
1816 }
1817 }
1818 }
1819
1820 if merged.is_empty() {
1821 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
1822 }
1823
1824 Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
1825}
1826
1827#[derive(Debug, Deserialize)]
1832struct LoginPasswordRequest {
1833 username: String,
1834 password: String,
1835}
1836
1837async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
1838 let _ = (&body.username, &body.password);
1839 HttpResponse::Ok().json(serde_json::json!({
1840 "message": "login endpoint active"
1841 }))
1842}
1843
1844#[derive(Debug, Deserialize)]
1845struct PasswordResetRequest {
1846 username: String,
1847}
1848
1849async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
1850 let _ = &body.username;
1851 HttpResponse::Ok().json(serde_json::json!({
1852 "message": "if an account with that username exists, a reset link has been sent"
1853 }))
1854}
1855
1856#[derive(Debug, Deserialize)]
1857struct PasswordChangeRequest {
1858 token: String,
1859 new_password: String,
1860}
1861
1862async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
1863 let _ = (&body.token, &body.new_password);
1864 HttpResponse::Ok().json(serde_json::json!({
1865 "message": "password changed"
1866 }))
1867}
1868
1869async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
1874 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
1875 HttpResponse::Ok()
1876 .content_type("application/json")
1877 .json(body)
1878}
1879
1880pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
1895
1896#[derive(Debug, Deserialize)]
1898struct ProxyQuery {
1899 url: String,
1900}
1901
1902const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
1904 "set-cookie",
1905 "set-cookie2",
1906 "authorization",
1907 "www-authenticate",
1908 "proxy-authenticate",
1909 "proxy-authorization",
1910];
1911
1912fn validate_proxy_target(target: &str) -> Result<url::Url, HttpResponse> {
1918 let parsed = match url::Url::parse(target) {
1919 Ok(u) => u,
1920 Err(_) => {
1921 return Err(
1922 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
1923 );
1924 }
1925 };
1926
1927 match parsed.scheme() {
1929 "http" | "https" => {}
1930 scheme => {
1931 return Err(HttpResponse::BadRequest()
1932 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
1933 }
1934 }
1935
1936 if let Err(_e) = solid_pod_rs::security::is_safe_url(target) {
1938 return Err(HttpResponse::Forbidden()
1939 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1940 }
1941
1942 if let Some(host) = parsed.host_str() {
1944 let host_lower = host.to_ascii_lowercase();
1945 if host_lower == "localhost"
1947 || host_lower.ends_with(".localhost")
1948 || host_lower == "0.0.0.0"
1949 || host_lower == "[::1]"
1950 || host_lower == "[::0]"
1951 {
1952 return Err(HttpResponse::Forbidden()
1953 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1954 }
1955 } else {
1956 return Err(
1957 HttpResponse::BadRequest().json(serde_json::json!({"error": "target URL has no host"}))
1958 );
1959 }
1960
1961 Ok(parsed)
1962}
1963
1964async fn handle_proxy(
1965 req: HttpRequest,
1966 _state: web::Data<AppState>,
1967 query: web::Query<ProxyQuery>,
1968) -> Result<HttpResponse, ActixError> {
1969 let auth_pk = extract_pubkey(&req).await;
1971 let agent = agent_uri(auth_pk.as_ref());
1972 if agent.is_none() {
1973 return Ok(HttpResponse::Unauthorized()
1974 .json(serde_json::json!({"error": "authentication required"})));
1975 }
1976
1977 let _target_url = match validate_proxy_target(&query.url) {
1979 Ok(u) => u,
1980 Err(rsp) => return Ok(rsp),
1981 };
1982
1983 let client = reqwest::Client::builder()
1985 .redirect(reqwest::redirect::Policy::none())
1988 .build()
1989 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))?;
1990
1991 let mut current_url = query.url.clone();
1992 let mut redirect_count = 0u8;
1993 const MAX_REDIRECTS: u8 = 5;
1994
1995 let byte_cap = std::env::var("PROXY_BYTE_CAP")
1996 .ok()
1997 .and_then(|v| {
1998 solid_pod_rs::config::sources::parse_size(&v)
1999 .map(|u| u as usize)
2000 .ok()
2001 })
2002 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
2003
2004 loop {
2005 if redirect_count > 0 {
2007 match validate_proxy_target(¤t_url) {
2008 Ok(_) => {}
2009 Err(rsp) => return Ok(rsp),
2010 }
2011 }
2012
2013 let mut upstream_req = client.get(¤t_url);
2014
2015 if let Some(auth_val) = req
2017 .headers()
2018 .get("x-upstream-authorization")
2019 .and_then(|v| v.to_str().ok())
2020 {
2021 upstream_req = upstream_req.header("Authorization", auth_val);
2022 }
2023
2024 let response = upstream_req
2025 .send()
2026 .await
2027 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
2028
2029 if response.status().is_redirection() {
2031 if redirect_count >= MAX_REDIRECTS {
2032 return Ok(HttpResponse::BadGateway()
2033 .json(serde_json::json!({"error": "too many redirects"})));
2034 }
2035 if let Some(location) = response.headers().get("location") {
2036 let loc_str = location
2037 .to_str()
2038 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
2039 let base = url::Url::parse(¤t_url)
2041 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
2042 let resolved = base
2043 .join(loc_str)
2044 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
2045 current_url = resolved.to_string();
2046 redirect_count += 1;
2047 continue;
2048 }
2049 return Ok(HttpResponse::BadGateway()
2050 .json(serde_json::json!({"error": "redirect without location"})));
2051 }
2052
2053 let upstream_status = response.status().as_u16();
2055 let upstream_content_type = response
2056 .headers()
2057 .get("content-type")
2058 .and_then(|v| v.to_str().ok())
2059 .unwrap_or("application/octet-stream")
2060 .to_string();
2061
2062 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
2064 for (name, value) in response.headers() {
2065 let name_lower = name.as_str().to_ascii_lowercase();
2066 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
2067 continue;
2068 }
2069 if matches!(
2071 name_lower.as_str(),
2072 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
2073 ) {
2074 continue;
2075 }
2076 if let Ok(val_str) = value.to_str() {
2077 forwarded_headers.push((name_lower, val_str.to_string()));
2078 }
2079 }
2080
2081 let body_bytes = response
2082 .bytes()
2083 .await
2084 .map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
2085
2086 if body_bytes.len() > byte_cap {
2087 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
2088 "error": "proxied response exceeds byte cap",
2089 "limit": byte_cap
2090 })));
2091 }
2092
2093 let mut rsp = HttpResponse::build(
2095 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
2096 );
2097 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
2098 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
2099
2100 for (name, value) in &forwarded_headers {
2102 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
2103 if let Ok(hval) = header::HeaderValue::from_str(value) {
2104 rsp.insert_header((hname, hval));
2105 }
2106 }
2107 }
2108
2109 return Ok(rsp.body(body_bytes.to_vec()));
2110 }
2111}
2112
2113pub struct PathTraversalGuard;
2119
2120impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
2121where
2122 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2123 B: 'static,
2124{
2125 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2126 type Error = ActixError;
2127 type InitError = ();
2128 type Transform = PathTraversalGuardMiddleware<S>;
2129 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2130
2131 fn new_transform(&self, service: S) -> Self::Future {
2132 ready(Ok(PathTraversalGuardMiddleware { service }))
2133 }
2134}
2135
2136pub struct PathTraversalGuardMiddleware<S> {
2138 service: S,
2139}
2140
2141impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
2142where
2143 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2144 B: 'static,
2145{
2146 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2147 type Error = ActixError;
2148 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2149
2150 actix_web::dev::forward_ready!(service);
2151
2152 fn call(&self, req: ServiceRequest) -> Self::Future {
2153 let raw = req.path().to_string();
2156 if path_is_traversal(&raw) {
2157 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
2158 let sr = req.into_response(rsp.map_into_boxed_body());
2159 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2160 }
2161 let fut = self.service.call(req);
2162 Box::pin(async move {
2163 let resp = fut.await?;
2164 Ok(resp.map_into_left_body())
2165 })
2166 }
2167}
2168
2169fn path_is_traversal(path: &str) -> bool {
2170 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
2172 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
2173 for seg in once.split('/').chain(twice.split('/')) {
2174 if seg == ".." || seg == "." {
2175 return true;
2176 }
2177 }
2178 if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
2181 return true;
2182 }
2183 false
2184}
2185
2186pub struct CorsHeaders {
2197 pub allowed_origins: Arc<Vec<String>>,
2198}
2199
2200impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
2201where
2202 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2203 B: 'static,
2204{
2205 type Response = ServiceResponse<B>;
2206 type Error = ActixError;
2207 type InitError = ();
2208 type Transform = CorsHeadersMiddleware<S>;
2209 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2210
2211 fn new_transform(&self, service: S) -> Self::Future {
2212 ready(Ok(CorsHeadersMiddleware {
2213 service,
2214 allowed_origins: self.allowed_origins.clone(),
2215 }))
2216 }
2217}
2218
2219pub struct CorsHeadersMiddleware<S> {
2221 service: S,
2222 allowed_origins: Arc<Vec<String>>,
2223}
2224
2225impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
2226where
2227 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2228 B: 'static,
2229{
2230 type Response = ServiceResponse<B>;
2231 type Error = ActixError;
2232 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2233
2234 actix_web::dev::forward_ready!(service);
2235
2236 fn call(&self, req: ServiceRequest) -> Self::Future {
2237 let origin = req
2238 .headers()
2239 .get(header::ORIGIN)
2240 .and_then(|v| v.to_str().ok())
2241 .map(str::to_string);
2242 let allowed = self.allowed_origins.clone();
2243 let fut = self.service.call(req);
2244 Box::pin(async move {
2245 let mut resp = fut.await?;
2246 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
2247 Ok(resp)
2248 })
2249 }
2250}
2251
2252fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
2253 let effective_origin: Option<String> = if allowed.is_empty() {
2255 Some(origin.unwrap_or("*").to_string())
2257 } else {
2258 origin
2260 .filter(|o| allowed.iter().any(|a| a == *o))
2261 .map(str::to_string)
2262 };
2263
2264 let origin_value = match effective_origin {
2267 Some(ref v) => v.as_str(),
2268 None => return,
2269 };
2270
2271 let pairs = [
2272 ("access-control-allow-origin", origin_value),
2273 (
2274 "access-control-allow-methods",
2275 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
2276 ),
2277 (
2278 "access-control-allow-headers",
2279 "Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Range, Slug, Origin",
2280 ),
2281 (
2282 "access-control-expose-headers",
2283 "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",
2284 ),
2285 ("access-control-allow-credentials", "true"),
2286 ("access-control-max-age", "86400"),
2287 ];
2288
2289 for (name, value) in pairs {
2290 if let (Ok(name), Ok(value)) = (
2291 header::HeaderName::from_lowercase(name.as_bytes()),
2292 header::HeaderValue::from_str(value),
2293 ) {
2294 headers.insert(name, value);
2295 }
2296 }
2297}
2298
2299pub struct ErrorLoggingMiddleware;
2315
2316impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
2317where
2318 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2319 B: 'static,
2320{
2321 type Response = ServiceResponse<B>;
2322 type Error = ActixError;
2323 type InitError = ();
2324 type Transform = ErrorLoggingMiddlewareService<S>;
2325 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2326
2327 fn new_transform(&self, service: S) -> Self::Future {
2328 ready(Ok(ErrorLoggingMiddlewareService { service }))
2329 }
2330}
2331
2332pub struct ErrorLoggingMiddlewareService<S> {
2334 service: S,
2335}
2336
2337impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
2338where
2339 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2340 B: 'static,
2341{
2342 type Response = ServiceResponse<B>;
2343 type Error = ActixError;
2344 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2345
2346 actix_web::dev::forward_ready!(service);
2347
2348 fn call(&self, req: ServiceRequest) -> Self::Future {
2349 let method = req.method().as_str().to_string();
2352 let path = req.path().to_string();
2353
2354 let fut = self.service.call(req);
2355 Box::pin(async move {
2356 let response = fut.await?;
2357 let status = response.status();
2358 if status.is_server_error() {
2359 log_5xx(&method, &path, status, response.response().error());
2360 }
2361 Ok(response)
2362 })
2363 }
2364}
2365
2366fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
2370 let chain = match error {
2374 Some(e) => format_error_chain(e),
2375 None => "<no error attached to response>".to_string(),
2376 };
2377
2378 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
2379 Some(std::backtrace::Backtrace::force_capture().to_string())
2380 } else {
2381 None
2382 };
2383
2384 tracing::error!(
2385 target: "solid_pod_rs_server::http",
2386 method = %method,
2387 path = %path,
2388 status = %status.as_u16(),
2389 error.chain = %chain,
2390 backtrace = backtrace.as_deref().unwrap_or(""),
2391 "5xx response"
2392 );
2393}
2394
2395fn format_error_chain(e: &actix_web::Error) -> String {
2406 let summary = format!("{}", e.as_response_error());
2407 let debug = format!("{e:?}");
2408 if debug == summary || debug.is_empty() {
2409 summary
2410 } else {
2411 format!("{summary} -> {debug}")
2412 }
2413}
2414
2415pub struct DotfileGuard {
2421 allow: Arc<DotfileAllowlist>,
2422}
2423
2424impl DotfileGuard {
2425 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
2426 Self { allow }
2427 }
2428}
2429
2430impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
2431where
2432 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2433 B: 'static,
2434{
2435 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2436 type Error = ActixError;
2437 type InitError = ();
2438 type Transform = DotfileGuardMiddleware<S>;
2439 type Future = Ready<Result<Self::Transform, Self::InitError>>;
2440
2441 fn new_transform(&self, service: S) -> Self::Future {
2442 ready(Ok(DotfileGuardMiddleware {
2443 service,
2444 allow: self.allow.clone(),
2445 }))
2446 }
2447}
2448
2449pub struct DotfileGuardMiddleware<S> {
2451 service: S,
2452 allow: Arc<DotfileAllowlist>,
2453}
2454
2455impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
2456where
2457 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2458 B: 'static,
2459{
2460 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2461 type Error = ActixError;
2462 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2463
2464 actix_web::dev::forward_ready!(service);
2465
2466 fn call(&self, req: ServiceRequest) -> Self::Future {
2467 let path = req.path().to_string();
2468 let allow_system_route =
2475 path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
2476 if !allow_system_route {
2477 let pb = PathBuf::from(&path);
2478 if !self.allow.is_allowed(Path::new(&pb)) {
2479 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
2480 let sr = req.into_response(rsp.map_into_boxed_body());
2481 return Box::pin(async move { Ok(sr.map_into_right_body()) });
2482 }
2483 }
2484 let fut = self.service.call(req);
2485 Box::pin(async move {
2486 let resp = fut.await?;
2487 Ok(resp.map_into_left_body())
2488 })
2489 }
2490}
2491
2492#[cfg(feature = "git")]
2497pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
2498 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
2499 return None;
2500 }
2501 state.data_root.as_ref().map(|root| root.join(pubkey))
2502}
2503
2504#[cfg(feature = "git")]
2534async fn git_mark_write(state: &AppState, resource_path: &str, agent: Option<&str>, message: &str) {
2535 use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
2536 use solid_pod_rs_git::mark::ShellGitMarker;
2537
2538 if resource_path.ends_with(".acl")
2541 || resource_path.ends_with(".meta")
2542 || resource_path.ends_with(".prov.ttl")
2543 {
2544 return;
2545 }
2546 if resource_path.ends_with('/') {
2548 return;
2549 }
2550
2551 let Some(data_root) = state.data_root.as_ref() else {
2553 return;
2554 };
2555
2556 let trimmed = resource_path.trim_start_matches('/');
2558 let mut segments = trimmed.splitn(2, '/');
2559 let pod = segments.next().unwrap_or("");
2560 let rel = segments.next().unwrap_or("");
2561 if pod.is_empty() || rel.is_empty() {
2562 return;
2563 }
2564 let repo = data_root.join(pod);
2565
2566 if !repo.join(".git").is_dir() {
2570 return;
2571 }
2572
2573 let agent_did = agent.unwrap_or("urn:solid:anonymous");
2574 let created = std::time::SystemTime::now()
2575 .duration_since(std::time::UNIX_EPOCH)
2576 .map(|d| d.as_secs())
2577 .unwrap_or(0);
2578
2579 let (policy, ticker_override) =
2582 handlers::prov::resolve_anchor_policy(state, resource_path).await;
2583
2584 let marker = std::sync::Arc::new(ShellGitMarker::new());
2589 let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
2590 None
2591 } else {
2592 handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
2593 };
2594 let (log, ticker, network) = match &anchorer_bundle {
2595 Some((anchorer, ticker, network)) => (
2596 ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
2597 ticker.clone(),
2598 network.clone(),
2599 ),
2600 None => (ProvenanceLog::new(marker.clone()), String::new(), String::new()),
2602 };
2603
2604 let record_policy = match policy {
2608 AnchorPolicy::Epoch => AnchorPolicy::Never,
2609 other => other,
2610 };
2611 let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
2612
2613 let write_record = solid_pod_rs::provenance::WriteRecord {
2617 repo: &repo,
2618 path: rel,
2619 agent_did,
2620 message,
2621 policy: record_policy,
2622 high_value,
2623 ticker: &ticker,
2624 network: &network,
2625 created,
2626 };
2627 let mut mark = match log.record(write_record).await {
2628 Ok(m) => m,
2629 Err(e) => {
2630 tracing::warn!(
2631 target: "solid_pod_rs_server::git_mark",
2632 resource = %resource_path,
2633 "provenance record failed (swallowed, write already succeeded): {e}"
2634 );
2635 return;
2636 }
2637 };
2638 mark.resource = resource_path.to_string();
2641
2642 if matches!(policy, AnchorPolicy::Epoch) {
2646 if let Some((anchorer, _, _)) = &anchorer_bundle {
2647 match handlers::prov::epoch_push_and_maybe_anchor(
2648 state,
2649 anchorer,
2650 &ticker,
2651 &network,
2652 &mark.git.commit_sha,
2653 )
2654 .await
2655 {
2656 Ok(Some(closed)) => tracing::debug!(
2657 target: "solid_pod_rs_server::git_mark",
2658 root = %closed.root,
2659 n = closed.commits.len(),
2660 "epoch anchored (one tx notarises {} commits)", closed.commits.len()
2661 ),
2662 Ok(None) => {}
2663 Err(e) => tracing::warn!(
2664 target: "solid_pod_rs_server::git_mark",
2665 "epoch batch/anchor failed (swallowed): {e}"
2666 ),
2667 }
2668 }
2669 }
2670
2671 let ttl = prov_ttl(&mark);
2676 let sidecar = format!("{resource_path}.prov.ttl");
2677 if let Err(e) = state
2678 .storage
2679 .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
2680 .await
2681 {
2682 tracing::warn!(
2683 target: "solid_pod_rs_server::git_mark",
2684 sidecar = %sidecar,
2685 "provenance sidecar write failed (swallowed): {e}"
2686 );
2687 return;
2688 }
2689
2690 tracing::debug!(
2691 target: "solid_pod_rs_server::git_mark",
2692 resource = %resource_path,
2693 commit = %mark.git.commit_sha,
2694 anchored = mark.anchor.is_some(),
2695 "provenance recorded"
2696 );
2697}
2698
2699#[cfg(not(feature = "git"))]
2702#[inline]
2703async fn git_mark_write(_state: &AppState, _resource_path: &str, _agent: Option<&str>, _message: &str) {}
2704
2705#[cfg(feature = "git")]
2706pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
2707 let caller = extract_pubkey(req).await?;
2708 if caller != pod_pubkey {
2709 return None;
2710 }
2711 Some(caller)
2712}
2713
2714#[cfg(feature = "git")]
2715fn git_json_err(msg: &str, status: u16) -> HttpResponse {
2716 HttpResponse::build(
2717 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
2718 )
2719 .content_type("application/json")
2720 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
2721}
2722
2723#[cfg(feature = "git")]
2725#[derive(serde::Deserialize)]
2726struct GitStageBody {
2727 paths: Option<Vec<String>>,
2728 all: Option<bool>,
2729}
2730
2731#[cfg(feature = "git")]
2732#[derive(serde::Deserialize)]
2733struct GitCommitBody {
2734 message: String,
2735 author_name: Option<String>,
2736 author_email: Option<String>,
2737}
2738
2739#[cfg(feature = "git")]
2740#[derive(serde::Deserialize)]
2741struct GitBranchBody {
2742 name: String,
2743}
2744
2745#[cfg(feature = "git")]
2748async fn handle_git_status(
2749 path: web::Path<String>,
2750 req: HttpRequest,
2751 state: web::Data<AppState>,
2752) -> HttpResponse {
2753 let pubkey = path.into_inner();
2754 if require_pod_owner(&req, &pubkey).await.is_none() {
2755 return git_json_err("Authentication required", 401);
2756 }
2757 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2758 return git_json_err("Git not available (no FS backend)", 501);
2759 };
2760 match solid_pod_rs_git::api::git_status(&repo).await {
2761 Ok(s) => HttpResponse::Ok()
2762 .content_type("application/json")
2763 .body(serde_json::to_string(&s).unwrap_or_default()),
2764 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2765 }
2766}
2767
2768#[cfg(feature = "git")]
2769async fn handle_git_log(
2770 path: web::Path<String>,
2771 req: HttpRequest,
2772 state: web::Data<AppState>,
2773 query: web::Query<std::collections::HashMap<String, String>>,
2774) -> HttpResponse {
2775 let pubkey = path.into_inner();
2776 if require_pod_owner(&req, &pubkey).await.is_none() {
2777 return git_json_err("Authentication required", 401);
2778 }
2779 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2780 return git_json_err("Git not available (no FS backend)", 501);
2781 };
2782 let limit: u32 = query
2783 .get("limit")
2784 .and_then(|v| v.parse().ok())
2785 .unwrap_or(20);
2786 match solid_pod_rs_git::api::git_log(&repo, limit).await {
2787 Ok(entries) => HttpResponse::Ok()
2788 .content_type("application/json")
2789 .body(serde_json::to_string(&entries).unwrap_or_default()),
2790 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2791 }
2792}
2793
2794#[cfg(feature = "git")]
2795async fn handle_git_diff(
2796 path: web::Path<String>,
2797 req: HttpRequest,
2798 state: web::Data<AppState>,
2799 query: web::Query<std::collections::HashMap<String, String>>,
2800) -> HttpResponse {
2801 let pubkey = path.into_inner();
2802 if require_pod_owner(&req, &pubkey).await.is_none() {
2803 return git_json_err("Authentication required", 401);
2804 }
2805 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2806 return git_json_err("Git not available (no FS backend)", 501);
2807 };
2808 let file_path = query.get("path").map(String::as_str);
2809 let staged = query
2810 .get("staged")
2811 .map(|v| v == "true" || v == "1")
2812 .unwrap_or(false);
2813 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
2814 Ok(diff) => HttpResponse::Ok()
2815 .content_type("text/plain")
2816 .body(diff),
2817 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2818 }
2819}
2820
2821#[cfg(feature = "git")]
2822async fn handle_git_stage(
2823 path: web::Path<String>,
2824 req: HttpRequest,
2825 state: web::Data<AppState>,
2826 body: web::Bytes,
2827) -> HttpResponse {
2828 let pubkey = path.into_inner();
2829 if require_pod_owner(&req, &pubkey).await.is_none() {
2830 return git_json_err("Authentication required", 401);
2831 }
2832 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2833 return git_json_err("Git not available (no FS backend)", 501);
2834 };
2835 let parsed: GitStageBody = match serde_json::from_slice(&body) {
2836 Ok(v) => v,
2837 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2838 };
2839 let paths = parsed.paths.unwrap_or_default();
2840 let all = parsed.all.unwrap_or(false);
2841 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
2842 Ok(()) => HttpResponse::Ok()
2843 .content_type("application/json")
2844 .body(r#"{"ok":true}"#),
2845 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2846 }
2847}
2848
2849#[cfg(feature = "git")]
2850async fn handle_git_unstage(
2851 path: web::Path<String>,
2852 req: HttpRequest,
2853 state: web::Data<AppState>,
2854 body: web::Bytes,
2855) -> HttpResponse {
2856 let pubkey = path.into_inner();
2857 if require_pod_owner(&req, &pubkey).await.is_none() {
2858 return git_json_err("Authentication required", 401);
2859 }
2860 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2861 return git_json_err("Git not available (no FS backend)", 501);
2862 };
2863 let parsed: GitStageBody = match serde_json::from_slice(&body) {
2864 Ok(v) => v,
2865 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2866 };
2867 let paths = parsed.paths.unwrap_or_default();
2868 let all = parsed.all.unwrap_or(false);
2869 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
2870 Ok(()) => HttpResponse::Ok()
2871 .content_type("application/json")
2872 .body(r#"{"ok":true}"#),
2873 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2874 }
2875}
2876
2877#[cfg(feature = "git")]
2878async fn handle_git_commit(
2879 path: web::Path<String>,
2880 req: HttpRequest,
2881 state: web::Data<AppState>,
2882 body: web::Bytes,
2883) -> HttpResponse {
2884 let pubkey = path.into_inner();
2885 if require_pod_owner(&req, &pubkey).await.is_none() {
2886 return git_json_err("Authentication required", 401);
2887 }
2888 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2889 return git_json_err("Git not available (no FS backend)", 501);
2890 };
2891 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
2892 Ok(v) => v,
2893 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2894 };
2895 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
2896 let author_email = parsed
2897 .author_email
2898 .as_deref()
2899 .unwrap_or("pod@dreamlab-ai.com");
2900 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email)
2901 .await
2902 {
2903 Ok(result) => HttpResponse::Ok()
2904 .content_type("application/json")
2905 .body(serde_json::to_string(&result).unwrap_or_default()),
2906 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2907 }
2908}
2909
2910#[cfg(feature = "git")]
2911async fn handle_git_branches(
2912 path: web::Path<String>,
2913 req: HttpRequest,
2914 state: web::Data<AppState>,
2915) -> HttpResponse {
2916 let pubkey = path.into_inner();
2917 if require_pod_owner(&req, &pubkey).await.is_none() {
2918 return git_json_err("Authentication required", 401);
2919 }
2920 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2921 return git_json_err("Git not available (no FS backend)", 501);
2922 };
2923 match solid_pod_rs_git::api::git_branches(&repo).await {
2924 Ok(info) => HttpResponse::Ok()
2925 .content_type("application/json")
2926 .body(serde_json::to_string(&info).unwrap_or_default()),
2927 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2928 }
2929}
2930
2931#[cfg(feature = "git")]
2932async fn handle_git_create_branch(
2933 path: web::Path<String>,
2934 req: HttpRequest,
2935 state: web::Data<AppState>,
2936 body: web::Bytes,
2937) -> HttpResponse {
2938 let pubkey = path.into_inner();
2939 if require_pod_owner(&req, &pubkey).await.is_none() {
2940 return git_json_err("Authentication required", 401);
2941 }
2942 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2943 return git_json_err("Git not available (no FS backend)", 501);
2944 };
2945 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
2946 Ok(v) => v,
2947 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2948 };
2949 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
2950 Ok(()) => HttpResponse::Ok()
2951 .content_type("application/json")
2952 .body(r#"{"ok":true}"#),
2953 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2954 }
2955}
2956
2957#[cfg(feature = "git")]
2958async fn handle_git_discard(
2959 path: web::Path<String>,
2960 req: HttpRequest,
2961 state: web::Data<AppState>,
2962 body: web::Bytes,
2963) -> HttpResponse {
2964 let pubkey = path.into_inner();
2965 if require_pod_owner(&req, &pubkey).await.is_none() {
2966 return git_json_err("Authentication required", 401);
2967 }
2968 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2969 return git_json_err("Git not available (no FS backend)", 501);
2970 };
2971 let parsed: GitStageBody = match serde_json::from_slice(&body) {
2972 Ok(v) => v,
2973 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2974 };
2975 let paths = parsed.paths.unwrap_or_default();
2976 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
2977 Ok(()) => HttpResponse::Ok()
2978 .content_type("application/json")
2979 .body(r#"{"ok":true}"#),
2980 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2981 }
2982}
2983
2984async fn handle_git_panel_options(
2992 req: HttpRequest,
2993 state: web::Data<AppState>,
2994) -> HttpResponse {
2995 let origin = req
2996 .headers()
2997 .get(header::ORIGIN)
2998 .and_then(|v| v.to_str().ok())
2999 .map(str::to_string);
3000
3001 let mut rsp = HttpResponse::NoContent().finish();
3002 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
3003 rsp
3004}
3005
3006async fn handle_admin_provision(
3017 req: HttpRequest,
3018 state: web::Data<AppState>,
3019 path: web::Path<String>,
3020) -> HttpResponse {
3021 let expected = match &state.admin_key {
3023 Some(k) => k.clone(),
3024 None => {
3025 return HttpResponse::Forbidden().json(serde_json::json!({
3026 "error": "admin key not configured on this server"
3027 }));
3028 }
3029 };
3030 let provided = req
3031 .headers()
3032 .get("x-pod-admin-key")
3033 .and_then(|v| v.to_str().ok())
3034 .unwrap_or("");
3035 use subtle::ConstantTimeEq;
3040 let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
3041 if !bool::from(key_match) {
3042 return HttpResponse::Forbidden()
3043 .json(serde_json::json!({"error": "invalid admin key"}));
3044 }
3045
3046 let pubkey = path.into_inner();
3048 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
3049 return HttpResponse::BadRequest()
3050 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
3051 }
3052
3053 let data_root = match &state.data_root {
3055 Some(r) => r.clone(),
3056 None => {
3057 return HttpResponse::InternalServerError().json(serde_json::json!({
3058 "error": "server has no fs-backend storage configured"
3059 }));
3060 }
3061 };
3062
3063 let pod_dir = data_root.join(&pubkey);
3064
3065 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
3067 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
3068 return HttpResponse::InternalServerError()
3069 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
3070 }
3071
3072 let acl_content = format!(
3074 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
3075 <#owner> a acl:Authorization ;\n\
3076 acl:agent <did:nostr:{pubkey}> ;\n\
3077 acl:accessTo <./> ;\n\
3078 acl:default <./> ;\n\
3079 acl:mode acl:Read, acl:Write, acl:Control .\n"
3080 );
3081 let acl_path = pod_dir.join(".acl");
3082 if !acl_path.exists() {
3083 if let Err(e) = tokio::fs::write(&acl_path, acl_content.as_bytes()).await {
3084 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write .acl failed");
3085 return HttpResponse::InternalServerError()
3086 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
3087 }
3088 }
3089
3090 #[cfg(feature = "git")]
3092 {
3093 use tokio::process::Command;
3094
3095 if !pod_dir.join(".git").exists() {
3097 let init_out = Command::new("git")
3098 .args([
3099 "init",
3100 "-b",
3101 "main",
3102 pod_dir.to_str().unwrap_or("."),
3103 ])
3104 .output()
3105 .await;
3106
3107 match init_out {
3108 Ok(out) if out.status.success() => {}
3109 Ok(out) => {
3110 let stderr = String::from_utf8_lossy(&out.stderr);
3111 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
3112 }
3113 Err(e) => {
3114 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
3115 }
3116 }
3117
3118 let cfg_out = Command::new("git")
3121 .args([
3122 "-C",
3123 pod_dir.to_str().unwrap_or("."),
3124 "config",
3125 "receive.denyCurrentBranch",
3126 "updateInstead",
3127 ])
3128 .output()
3129 .await;
3130
3131 if let Err(e) = cfg_out {
3132 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
3133 }
3134 }
3135 }
3136
3137 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
3139 HttpResponse::Ok().json(serde_json::json!({
3140 "podUrl": format!("{base_url}/pods/{pubkey}/"),
3141 "ok": true,
3142 }))
3143}
3144
3145async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
3150 let Some(ref data_root) = state.data_root else {
3151 return HttpResponse::Ok()
3152 .content_type("application/json")
3153 .json(serde_json::json!({"apps": [], "count": 0}));
3154 };
3155
3156 let server_url = state.nodeinfo.base_url.clone();
3157
3158 let mut read_dir = match tokio::fs::read_dir(data_root).await {
3160 Ok(rd) => rd,
3161 Err(_) => {
3162 return HttpResponse::Ok()
3163 .content_type("application/json")
3164 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
3165 }
3166 };
3167
3168 let mut apps: Vec<serde_json::Value> = Vec::new();
3169 let mut scanned = 0usize;
3170
3171 while scanned < 1000 {
3172 let entry = match read_dir.next_entry().await {
3173 Ok(Some(e)) => e,
3174 Ok(None) => break,
3175 Err(_) => break,
3176 };
3177
3178 let file_type = match entry.file_type().await {
3179 Ok(ft) => ft,
3180 Err(_) => continue,
3181 };
3182 if !file_type.is_dir() {
3183 continue;
3184 }
3185
3186 scanned += 1;
3187
3188 let manifest_path = entry.path().join("apps").join("manifest.json");
3189 let contents = match tokio::fs::read(&manifest_path).await {
3190 Ok(c) => c,
3191 Err(_) => continue,
3192 };
3193
3194 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
3195 Ok(v) => v,
3196 Err(_) => continue,
3197 };
3198
3199 if let Some(pod_name) = entry.file_name().to_str() {
3201 if manifest.get("podOwner").is_none() {
3202 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
3203 }
3204 }
3205
3206 apps.push(manifest);
3207 }
3208
3209 let count = apps.len();
3210 HttpResponse::Ok()
3211 .content_type("application/json")
3212 .json(serde_json::json!({
3213 "apps": apps,
3214 "serverUrl": server_url,
3215 "count": count,
3216 }))
3217}
3218
3219#[allow(dead_code)]
3232fn is_git_request(path: &str) -> bool {
3233 path.contains("/info/refs")
3234 || path.contains("/git-upload-pack")
3235 || path.contains("/git-receive-pack")
3236}
3237
3238#[allow(dead_code)]
3241fn is_dot_git_path(path: &str) -> bool {
3242 path.contains("/.git/") || path.ends_with("/.git")
3243}
3244
3245#[cfg(feature = "git")]
3246async fn handle_git(
3247 req: HttpRequest,
3248 body: web::Bytes,
3249 state: web::Data<AppState>,
3250) -> HttpResponse {
3251 use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
3252 use solid_pod_rs_git::service::{GitHttpService, GitRequest};
3253
3254 let path = req.uri().path().to_string();
3255
3256 let pod_name = path
3259 .trim_start_matches('/')
3260 .split('/')
3261 .next()
3262 .unwrap_or("")
3263 .to_string();
3264 let Some(ref data_root) = state.data_root else {
3265 return HttpResponse::NotImplemented().json(serde_json::json!({
3266 "error": "git requires fs-backend storage",
3267 "reason": "data_root_not_configured"
3268 }));
3269 };
3270 let repo_root = data_root.join(&pod_name);
3271 if !repo_root.exists() {
3272 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
3273 }
3274
3275 let query = req.uri().query().unwrap_or("").to_string();
3276 let host_url = {
3277 let conn = req.connection_info();
3278 Some(format!("{}://{}", conn.scheme(), conn.host()))
3279 };
3280 let headers: Vec<(String, String)> = req
3281 .headers()
3282 .iter()
3283 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
3284 .collect();
3285
3286 let git_req = GitRequest {
3287 method: req.method().as_str().to_string(),
3288 path,
3289 query,
3290 headers,
3291 body: body.into(),
3292 host_url,
3293 };
3294
3295 let is_write = git_req.is_write();
3307 let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
3308 Ok(pk) => Some(format!("did:nostr:{pk}")),
3309 Err(_) => None,
3310 };
3311 let wac_path = format!("/{pod_name}/");
3312 let wac = if is_write {
3313 enforce_write(&state, &wac_path, AccessMode::Write, agent.as_deref()).await
3314 } else {
3315 enforce_read(&state, &wac_path, agent.as_deref()).await
3316 };
3317 if let Err(e) = wac {
3318 return e.error_response();
3319 }
3320
3321 let service = GitHttpService::new(repo_root);
3322 match service.handle(git_req).await {
3323 Ok(git_resp) => {
3324 let mut builder = HttpResponse::build(
3325 actix_web::http::StatusCode::from_u16(git_resp.status)
3326 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3327 );
3328 for (k, v) in &git_resp.headers {
3329 builder.insert_header((k.as_str(), v.as_str()));
3330 }
3331 builder.body(git_resp.body)
3332 }
3333 Err(e) => {
3334 let status = e.status_code();
3335 HttpResponse::build(
3336 actix_web::http::StatusCode::from_u16(status)
3337 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3338 )
3339 .json(serde_json::json!({"error": e.to_string()}))
3340 }
3341 }
3342}
3343
3344pub fn build_app(
3356 state: AppState,
3357) -> App<
3358 impl actix_web::dev::ServiceFactory<
3359 ServiceRequest,
3360 Config = (),
3361 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
3362 Error = ActixError,
3363 InitError = (),
3364 >,
3365> {
3366 let body_cap = state.body_cap;
3367 let dotfiles = state.dotfiles.clone();
3368 let allowed_origins = Arc::new(state.allowed_origins.clone());
3369
3370 let mut app = App::new()
3371 .app_data(web::Data::new(state.clone()))
3372 .app_data(web::PayloadConfig::new(body_cap))
3373 .wrap(ErrorLoggingMiddleware)
3378 .wrap(CorsHeaders { allowed_origins })
3379 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
3383 .wrap(PathTraversalGuard)
3384 .wrap(DotfileGuard::new(dotfiles));
3385
3386 app = app
3392 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
3393 .route(
3394 "/.well-known/webfinger",
3395 web::get().to(handle_well_known_webfinger),
3396 )
3397 .route(
3398 "/.well-known/nodeinfo",
3399 web::get().to(handle_well_known_nodeinfo),
3400 )
3401 .route(
3402 "/.well-known/nodeinfo/2.1",
3403 web::get().to(handle_well_known_nodeinfo_2_1),
3404 );
3405
3406 #[cfg(feature = "did-nostr")]
3407 {
3408 app = app.route(
3409 "/.well-known/did/nostr/{pubkey}.json",
3410 web::get().to(handle_well_known_did_nostr),
3411 );
3412 }
3413
3414 #[cfg(feature = "nip05-endpoint")]
3418 {
3419 app = app.route(
3420 "/.well-known/nostr.json",
3421 web::get().to(handle_well_known_nip05),
3422 );
3423 }
3424
3425 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
3427
3428 app = app.route("/pay/.info", web::get().to(handle_pay_info));
3430
3431 app = app.configure(handlers::pay::register);
3436
3437 app = app.route("/proxy", web::get().to(handle_proxy));
3439
3440 if state.mcp_enabled {
3444 app = app
3445 .route("/mcp", web::post().to(mcp::handle_mcp))
3446 .route("/mcp", web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options));
3447 }
3448
3449 app = app.route(
3452 "/_admin/provision/{pubkey}",
3453 web::post().to(handle_admin_provision),
3454 );
3455
3456 app = app
3458 .route("/.pods", web::post().to(handle_create_pod))
3459 .route("/api/accounts/new", web::post().to(handle_create_account))
3460 .route("/pods/check/{name}", web::get().to(handle_pod_check))
3461 .route("/login/password", web::post().to(handle_login_password))
3462 .route(
3463 "/account/password/reset",
3464 web::post().to(handle_password_reset_request),
3465 )
3466 .route(
3467 "/account/password/change",
3468 web::post().to(handle_password_change),
3469 );
3470
3471 app = app
3476 .route(
3477 "/{tail:.*}/.git",
3479 web::route().to(|| async {
3480 HttpResponse::Forbidden()
3481 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
3482 }),
3483 )
3484 .route(
3485 "/{tail:.*}/.git/{rest:.*}",
3486 web::route().to(|| async {
3487 HttpResponse::Forbidden()
3488 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
3489 }),
3490 );
3491
3492 app = app.route(
3496 "/pods/{pk}/_git/{tail:.*}",
3497 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
3498 );
3499
3500 #[cfg(feature = "git")]
3501 {
3502 app = app
3504 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
3505 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
3506 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
3507
3508 app = app
3511 .route(
3512 "/pods/{pubkey}/_git/status",
3513 web::get().to(handle_git_status),
3514 )
3515 .route(
3516 "/pods/{pubkey}/_git/log",
3517 web::get().to(handle_git_log),
3518 )
3519 .route(
3520 "/pods/{pubkey}/_git/diff",
3521 web::get().to(handle_git_diff),
3522 )
3523 .route(
3524 "/pods/{pubkey}/_git/stage",
3525 web::post().to(handle_git_stage),
3526 )
3527 .route(
3528 "/pods/{pubkey}/_git/unstage",
3529 web::post().to(handle_git_unstage),
3530 )
3531 .route(
3532 "/pods/{pubkey}/_git/commit",
3533 web::post().to(handle_git_commit),
3534 )
3535 .route(
3536 "/pods/{pubkey}/_git/branches",
3537 web::get().to(handle_git_branches),
3538 )
3539 .route(
3540 "/pods/{pubkey}/_git/branch",
3541 web::post().to(handle_git_create_branch),
3542 )
3543 .route(
3544 "/pods/{pubkey}/_git/discard",
3545 web::post().to(handle_git_discard),
3546 );
3547
3548 app = app.configure(handlers::prov::register);
3555 }
3556 #[cfg(not(feature = "git"))]
3557 {
3558 let git_501 = || async {
3562 HttpResponse::NotImplemented()
3563 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
3564 };
3565 app = app
3566 .route("/{tail:.*}/info/refs", web::get().to(git_501))
3567 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
3568 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
3569 }
3570
3571 app.route("/{tail:.*}/", web::post().to(handle_post))
3574 .route("/{tail:.*}/", web::put().to(handle_put))
3575 .route("/{tail:.*}", web::get().to(handle_get))
3576 .route("/{tail:.*}", web::head().to(handle_get))
3577 .route("/{tail:.*}", web::put().to(handle_put))
3578 .route("/{tail:.*}", web::patch().to(handle_patch))
3579 .route("/{tail:.*}", web::delete().to(handle_delete))
3580 .route(
3581 "/{tail:.*}",
3582 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
3583 )
3584 .route(
3585 "/{tail:.*}",
3586 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
3587 )
3588}
3589
3590#[cfg(test)]
3595mod payment_gating_tests {
3596 use super::*;
3597 use solid_pod_rs::payments::WebLedger;
3598 use solid_pod_rs::storage::memory::MemoryBackend;
3599
3600 const PRINCIPAL: &str = "did:nostr:alice";
3601
3602 const PAID_WRITE_ACL: &str = r#"
3605@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3606
3607<#paid-write> a acl:Authorization ;
3608 acl:agent <did:nostr:alice> ;
3609 acl:accessTo </premium/inbox> ;
3610 acl:mode acl:Write ;
3611 acl:condition [
3612 a acl:PaymentCondition ;
3613 acl:costSats 100
3614 ] .
3615"#;
3616
3617 async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
3618 let mut ledger = WebLedger::new("Test Pod Credits");
3619 if sats > 0 {
3620 ledger.credit(did, sats);
3621 }
3622 let body = serde_json::to_vec(&ledger).unwrap();
3623 storage
3624 .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
3625 .await
3626 .unwrap();
3627 }
3628
3629 async fn seed_acl(storage: &dyn Storage) {
3630 storage
3631 .put(
3632 "/premium/inbox.acl",
3633 Bytes::from(PAID_WRITE_ACL),
3634 "text/turtle",
3635 )
3636 .await
3637 .unwrap();
3638 }
3639
3640 #[actix_web::test]
3642 async fn resolve_balance_reads_ledger_entry() {
3643 let storage = MemoryBackend::new();
3644 seed_ledger(&storage, PRINCIPAL, 250).await;
3645 assert_eq!(
3646 resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
3647 Some(250)
3648 );
3649 }
3650
3651 #[actix_web::test]
3653 async fn resolve_balance_zero_when_no_entry() {
3654 let storage = MemoryBackend::new();
3655 seed_ledger(&storage, "did:nostr:bob", 500).await;
3656 assert_eq!(resolve_balance_sats(&storage, Some(PRINCIPAL)).await, Some(0));
3657 }
3658
3659 #[actix_web::test]
3661 async fn resolve_balance_none_when_anonymous() {
3662 let storage = MemoryBackend::new();
3663 seed_ledger(&storage, PRINCIPAL, 1_000).await;
3664 assert_eq!(resolve_balance_sats(&storage, None).await, None);
3665 }
3666
3667 #[actix_web::test]
3669 async fn paid_write_denied_below_balance() {
3670 let storage = Arc::new(MemoryBackend::new());
3671 seed_acl(storage.as_ref()).await;
3672 seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
3674
3675 let result =
3676 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3677 assert!(
3678 result.is_err(),
3679 "balance 50 < cost 100 must be denied — sat-gating loop closed"
3680 );
3681 }
3682
3683 #[actix_web::test]
3685 async fn paid_write_allowed_at_balance() {
3686 let storage = Arc::new(MemoryBackend::new());
3687 seed_acl(storage.as_ref()).await;
3688 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
3690
3691 let result =
3692 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3693 assert!(
3694 result.is_ok(),
3695 "balance 100 >= cost 100 must be granted — sat-gating loop closed"
3696 );
3697 }
3698
3699 #[actix_web::test]
3701 async fn paid_write_allowed_above_balance() {
3702 let storage = Arc::new(MemoryBackend::new());
3703 seed_acl(storage.as_ref()).await;
3704 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
3705 let state = AppState::new(storage);
3706
3707 let result =
3708 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3709 assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
3710 }
3711
3712 #[actix_web::test]
3716 async fn paid_write_anonymous_denied() {
3717 let storage = Arc::new(MemoryBackend::new());
3718 seed_acl(storage.as_ref()).await;
3719 seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
3720 let state = AppState::new(storage);
3721
3722 let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
3723 assert!(
3724 result.is_err(),
3725 "anonymous caller has no ledger principal — PaymentCondition fails closed"
3726 );
3727 }
3728
3729 async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
3736 let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
3737 let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
3738 ledger.get_balance(did)
3739 }
3740
3741 #[actix_web::test]
3743 async fn paid_write_debits_ledger() {
3744 let storage = Arc::new(MemoryBackend::new());
3745 seed_acl(storage.as_ref()).await;
3746 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
3748
3749 let result =
3750 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3751 assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
3752 assert_eq!(
3753 read_balance(storage.as_ref(), PRINCIPAL).await,
3754 150,
3755 "250 - 100 cost: the grant must debit exactly the matched rule's cost"
3756 );
3757 }
3758
3759 #[actix_web::test]
3762 async fn paid_write_debits_each_grant() {
3763 let storage = Arc::new(MemoryBackend::new());
3764 seed_acl(storage.as_ref()).await;
3765 seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
3767
3768 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
3769 .await
3770 .unwrap();
3771 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
3772 .await
3773 .unwrap();
3774 assert_eq!(
3775 read_balance(storage.as_ref(), PRINCIPAL).await,
3776 50,
3777 "250 - 2*100: each granted request debits, no unmetered re-use"
3778 );
3779
3780 let third =
3782 enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3783 assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
3784 assert_eq!(
3785 read_balance(storage.as_ref(), PRINCIPAL).await,
3786 50,
3787 "a denied request must not debit"
3788 );
3789 }
3790
3791 #[actix_web::test]
3793 async fn paid_read_debits_ledger() {
3794 const PAID_READ_ACL: &str = r#"
3795@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3796
3797<#paid-read> a acl:Authorization ;
3798 acl:agent <did:nostr:alice> ;
3799 acl:accessTo </premium/feed> ;
3800 acl:mode acl:Read ;
3801 acl:condition [
3802 a acl:PaymentCondition ;
3803 acl:costSats 30
3804 ] .
3805"#;
3806 let storage = Arc::new(MemoryBackend::new());
3807 storage
3808 .put("/premium/feed.acl", Bytes::from(PAID_READ_ACL), "text/turtle")
3809 .await
3810 .unwrap();
3811 seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
3812 let state = AppState::new(storage.clone());
3813
3814 let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
3815 assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
3816 assert_eq!(
3817 read_balance(storage.as_ref(), PRINCIPAL).await,
3818 70,
3819 "100 - 30 cost: a granted paid read must debit"
3820 );
3821 }
3822
3823 #[actix_web::test]
3826 async fn free_read_does_not_debit() {
3827 let storage = Arc::new(MemoryBackend::new());
3828 seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
3830 let state = AppState::new(storage.clone());
3831
3832 enforce_read(&state, "/private/secret", Some(PRINCIPAL))
3833 .await
3834 .unwrap();
3835 assert_eq!(
3836 read_balance(storage.as_ref(), PRINCIPAL).await,
3837 100,
3838 "a grant with no PaymentCondition must not debit"
3839 );
3840 }
3841
3842 const ALICE_ONLY_READ_ACL: &str = r#"
3848@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3849
3850<#alice> a acl:Authorization ;
3851 acl:agent <did:nostr:alice> ;
3852 acl:accessTo </private/secret> ;
3853 acl:default </private/> ;
3854 acl:mode acl:Read, acl:Write, acl:Control .
3855"#;
3856
3857 async fn seed_private_read_acl(storage: &dyn Storage) {
3858 storage
3863 .put(
3864 "/private.acl",
3865 Bytes::from(ALICE_ONLY_READ_ACL),
3866 "text/turtle",
3867 )
3868 .await
3869 .unwrap();
3870 }
3871
3872 #[actix_web::test]
3876 async fn enforce_read_grants_owner() {
3877 let storage = Arc::new(MemoryBackend::new());
3878 seed_private_read_acl(storage.as_ref()).await;
3879 let state = AppState::new(storage);
3880 let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
3881 assert!(result.is_ok(), "owner alice must be granted Read");
3882 }
3883
3884 #[actix_web::test]
3887 async fn enforce_read_denies_other_principal() {
3888 let storage = Arc::new(MemoryBackend::new());
3889 seed_private_read_acl(storage.as_ref()).await;
3890 let state = AppState::new(storage);
3891 let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
3892 assert!(
3893 result.is_err(),
3894 "bob has no Read grant — private resource must not be world-readable"
3895 );
3896 }
3897
3898 #[actix_web::test]
3901 async fn enforce_read_denies_anonymous() {
3902 let storage = Arc::new(MemoryBackend::new());
3903 seed_private_read_acl(storage.as_ref()).await;
3904 let state = AppState::new(storage);
3905 let result = enforce_read(&state, "/private/secret", None).await;
3906 assert!(result.is_err(), "anonymous Read must be denied");
3907 }
3908
3909 const WRITE_NOT_CONTROL_ACL: &str = r#"
3917@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3918
3919<#owner> a acl:Authorization ;
3920 acl:agent <did:nostr:alice> ;
3921 acl:accessTo </shared/doc> ;
3922 acl:default </shared/> ;
3923 acl:mode acl:Read, acl:Write, acl:Control .
3924
3925<#writer> a acl:Authorization ;
3926 acl:agent <did:nostr:writer> ;
3927 acl:accessTo </shared/doc> ;
3928 acl:default </shared/> ;
3929 acl:mode acl:Read, acl:Write .
3930"#;
3931
3932 async fn seed_shared_acl(storage: &dyn Storage) {
3933 storage
3938 .put(
3939 "/shared.acl",
3940 Bytes::from(WRITE_NOT_CONTROL_ACL),
3941 "text/turtle",
3942 )
3943 .await
3944 .unwrap();
3945 }
3946
3947 #[actix_web::test]
3951 async fn acl_put_denied_for_writer_without_control() {
3952 let storage = Arc::new(MemoryBackend::new());
3953 seed_shared_acl(storage.as_ref()).await;
3954 let state = AppState::new(storage);
3955 let result =
3959 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some("did:nostr:writer")).await;
3960 assert!(
3961 result.is_err(),
3962 "writer lacks Control — must not be able to PUT /shared/.acl"
3963 );
3964 }
3965
3966 #[actix_web::test]
3968 async fn acl_put_allowed_for_control_holder() {
3969 let storage = Arc::new(MemoryBackend::new());
3970 seed_shared_acl(storage.as_ref()).await;
3971 let state = AppState::new(storage);
3972 let result =
3973 enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
3974 assert!(
3975 result.is_ok(),
3976 "alice holds Control — must be allowed to PUT /shared/.acl"
3977 );
3978 }
3979
3980 #[actix_web::test]
3982 async fn meta_put_denied_for_writer_without_control() {
3983 let storage = Arc::new(MemoryBackend::new());
3984 seed_shared_acl(storage.as_ref()).await;
3985 let state = AppState::new(storage);
3986 let result = enforce_write(
3987 &state,
3988 "/shared/doc.meta",
3989 AccessMode::Write,
3990 Some("did:nostr:writer"),
3991 )
3992 .await;
3993 assert!(
3994 result.is_err(),
3995 "writer lacks Control — must not be able to PUT a .meta sidecar"
3996 );
3997 }
3998
3999 #[test]
4001 fn protected_resource_for_acl_strips_suffixes() {
4002 assert_eq!(protected_resource_for_acl("/victim/.acl").as_deref(), Some("/victim/"));
4003 assert_eq!(protected_resource_for_acl("/a/b.acl").as_deref(), Some("/a/b"));
4004 assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
4005 assert_eq!(protected_resource_for_acl("/a/b.meta").as_deref(), Some("/a/b"));
4006 assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
4007 }
4008}