1#![doc = include_str!("../README.md")]
53#![deny(unsafe_code)]
54#![warn(rust_2018_idioms)]
55
56pub mod cli;
58
59use std::collections::HashMap;
60use std::net::{IpAddr, Ipv4Addr};
61use std::path::{Path, PathBuf};
62use std::sync::{Arc, Mutex};
63use std::time::{Duration, Instant};
64
65use actix_web::body::{BoxBody, EitherBody};
66use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
67use actix_web::http::{header, StatusCode};
68use actix_web::middleware::{NormalizePath, TrailingSlash};
69use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
70use bytes::Bytes;
71use futures_util::future::{ready, LocalBoxFuture, Ready};
72use percent_encoding::percent_decode_str;
73use serde::Deserialize;
74use solid_pod_rs::{
75 auth::nip98,
76 config::sources::parse_size,
77 interop,
78 ldp::{self, LdpContainerOps, PatchCreateOutcome},
79 mashlib::{self, MashlibConfig},
80 provision,
81 security::DotfileAllowlist,
82 storage::Storage,
83 wac::{
84 self, conditions::RequestContext, parse_jsonld_acl, parser::parse_turtle_acl, AccessMode,
85 },
86 PodError,
87};
88
89#[derive(Clone)]
95pub struct AppState {
96 pub storage: Arc<dyn Storage>,
97 pub dotfiles: Arc<DotfileAllowlist>,
98 pub body_cap: usize,
99 pub nodeinfo: NodeInfoMeta,
100 pub mashlib: MashlibConfig,
101 pub mashlib_cdn: Option<String>,
104 pub pay_config: solid_pod_rs::payments::PayConfig,
107 pub data_root: Option<PathBuf>,
112 pub pod_create_limiter: Arc<PodCreateLimiter>,
114 pub allowed_origins: Vec<String>,
121 pub admin_key: Option<String>,
126}
127
128#[derive(Clone, Debug)]
130pub struct NodeInfoMeta {
131 pub software_name: String,
132 pub software_version: String,
133 pub open_registrations: bool,
134 pub total_users: u64,
135 pub base_url: String,
136}
137
138impl Default for NodeInfoMeta {
139 fn default() -> Self {
140 Self {
141 software_name: "solid-pod-rs-server".to_string(),
142 software_version: env!("CARGO_PKG_VERSION").to_string(),
143 open_registrations: false,
144 total_users: 0,
145 base_url: "http://localhost".to_string(),
146 }
147 }
148}
149
150pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
153
154pub fn body_cap_from_env() -> usize {
157 match std::env::var("JSS_MAX_REQUEST_BODY") {
158 Ok(v) => parse_size(&v)
159 .map(|u| u as usize)
160 .unwrap_or(DEFAULT_BODY_CAP),
161 Err(_) => DEFAULT_BODY_CAP,
162 }
163}
164
165impl AppState {
166 pub fn new(storage: Arc<dyn Storage>) -> Self {
169 Self {
170 storage,
171 dotfiles: Arc::new(DotfileAllowlist::from_env()),
172 body_cap: body_cap_from_env(),
173 nodeinfo: NodeInfoMeta::default(),
174 mashlib: MashlibConfig::default(),
175 mashlib_cdn: None,
176 pay_config: solid_pod_rs::payments::PayConfig::default(),
177 data_root: None,
178 pod_create_limiter: Arc::new(PodCreateLimiter::default()),
179 allowed_origins: Vec::new(),
180 admin_key: None,
181 }
182 }
183}
184
185#[derive(Debug)]
187pub struct PodCreateLimiter {
188 hits: Mutex<HashMap<IpAddr, Instant>>,
189 window: Duration,
190}
191
192impl Default for PodCreateLimiter {
193 fn default() -> Self {
194 Self {
195 hits: Mutex::new(HashMap::new()),
196 window: Duration::from_secs(24 * 60 * 60),
197 }
198 }
199}
200
201impl PodCreateLimiter {
202 fn check(&self, ip: IpAddr) -> Result<(), u64> {
203 let now = Instant::now();
204 let mut hits = self.hits.lock().unwrap();
205 if let Some(last) = hits.get(&ip).copied() {
206 let elapsed = now.saturating_duration_since(last);
207 if elapsed < self.window {
208 return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
209 }
210 }
211 hits.insert(ip, now);
212 Ok(())
213 }
214}
215
216fn to_actix(e: PodError) -> ActixError {
221 match e {
222 PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
223 PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
224 PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
225 PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
226 PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
227 PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
228 _ => actix_web::error::ErrorInternalServerError(e.to_string()),
229 }
230}
231
232async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
238 let header_val = req
239 .headers()
240 .get(header::AUTHORIZATION)
241 .and_then(|v| v.to_str().ok())?;
242 let url = format!(
243 "http://{}{}",
244 req.connection_info().host(),
245 req.uri().path()
246 );
247 nip98::verify(header_val, &url, req.method().as_str(), None)
248 .await
249 .ok()
250}
251
252fn agent_uri(pubkey: Option<&String>) -> Option<String> {
253 pubkey.map(|pk| format!("did:nostr:{pk}"))
254}
255
256fn accept_includes_html(accept: &str) -> bool {
264 accept.split(',').any(|entry| {
265 let mime = entry.split(';').next().unwrap_or("").trim();
266 mime.eq_ignore_ascii_case("text/html")
267 })
268}
269
270async fn enforce_write(
282 state: &AppState,
283 path: &str,
284 mode: AccessMode,
285 agent_uri: Option<&str>,
286) -> Result<(), ActixError> {
287 let acl_doc = match find_effective_acl_dyn(&*state.storage, path).await {
292 Ok(doc) => doc,
293 Err(e) => return Err(to_actix(e)),
294 };
295
296 let ctx = RequestContext {
297 web_id: agent_uri,
298 client_id: None,
299 issuer: None,
300 payment_balance_sats: None,
301 };
302 let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
303 let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
304 let granted = wac::evaluate_access_ctx_with_registry(
305 acl_doc.as_ref(),
306 &ctx,
307 path,
308 mode,
309 None,
310 &groups,
311 ®istry,
312 );
313 if granted {
314 return Ok(());
315 }
316
317 let allow_header = wac::wac_allow_header(acl_doc.as_ref(), agent_uri, path);
318 let (status, body, unauthenticated) = if agent_uri.is_none() {
319 (StatusCode::UNAUTHORIZED, "authentication required", true)
320 } else {
321 (StatusCode::FORBIDDEN, "access forbidden", false)
322 };
323 let mut rsp = HttpResponse::new(status);
324 rsp.headers_mut().insert(
325 header::HeaderName::from_static("wac-allow"),
326 header::HeaderValue::from_str(&allow_header)
327 .unwrap_or(header::HeaderValue::from_static("")),
328 );
329 if unauthenticated {
330 rsp.headers_mut().insert(
331 header::WWW_AUTHENTICATE,
332 header::HeaderValue::from_static("DPoP realm=\"Solid\", Bearer realm=\"Solid\""),
333 );
334 }
335 Err(actix_web::error::InternalError::from_response(body, rsp).into())
336}
337
338fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
343 let links = ldp::link_headers(path).join(", ");
344 if let Ok(value) = header::HeaderValue::from_str(&links) {
345 rsp.headers_mut()
346 .insert(header::HeaderName::from_static("link"), value);
347 }
348}
349
350fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
351 if let Ok(v) = header::HeaderValue::from_str(header_value) {
352 rsp.headers_mut()
353 .insert(header::HeaderName::from_static("wac-allow"), v);
354 }
355}
356
357fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
358 let ws_base = base_url
359 .replacen("https://", "wss://", 1)
360 .replacen("http://", "ws://", 1);
361 let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
362 if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
363 rsp.headers_mut()
364 .insert(header::HeaderName::from_static("updates-via"), v);
365 }
366}
367
368async fn handle_get(
369 req: HttpRequest,
370 state: web::Data<AppState>,
371) -> Result<HttpResponse, ActixError> {
372 let path = req.uri().path().to_string();
373
374 if path.contains('*') {
375 return handle_glob_get(req, state).await;
376 }
377
378 let auth_pk = extract_pubkey(&req).await;
379 let agent = agent_uri(auth_pk.as_ref());
380 let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
381
382 if ldp::is_container(&path) {
383 let accept = req
384 .headers()
385 .get(header::ACCEPT)
386 .and_then(|v| v.to_str().ok())
387 .unwrap_or("");
388
389 if accept_includes_html(accept) {
395 let index_path = format!("{}index.html", &path);
396 if let Ok((body, _meta)) = state.storage.get(&index_path).await {
397 let mut rsp = HttpResponse::Ok()
398 .content_type("text/html; charset=utf-8")
399 .body(body.to_vec());
400 set_wac_allow(&mut rsp, &wac_allow);
401 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
402 set_link_headers(&mut rsp, &path);
403 return Ok(rsp);
404 }
405 }
406
407 let v = state
408 .storage
409 .container_representation(&path)
410 .await
411 .map_err(to_actix)?;
412
413 let sec_fetch_dest = req
415 .headers()
416 .get("sec-fetch-dest")
417 .and_then(|v| v.to_str().ok());
418 if mashlib::should_serve(
419 accept,
420 sec_fetch_dest,
421 "application/ld+json",
422 state.mashlib.enabled,
423 ) {
424 let json_ld = serde_json::to_string(&v).ok();
425 let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
426 let mut rsp = HttpResponse::Ok()
427 .content_type("text/html; charset=utf-8")
428 .insert_header(("X-Frame-Options", "DENY"))
429 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
430 .insert_header(("Cache-Control", "no-store"))
431 .body(html);
432 set_wac_allow(&mut rsp, &wac_allow);
433 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
434 set_link_headers(&mut rsp, &path);
435 return Ok(rsp);
436 }
437
438 let mut rsp = HttpResponse::Ok().json(v);
439 rsp.headers_mut().insert(
440 header::CONTENT_TYPE,
441 header::HeaderValue::from_static("application/ld+json"),
442 );
443 set_wac_allow(&mut rsp, &wac_allow);
444 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
445 set_link_headers(&mut rsp, &path);
446 return Ok(rsp);
447 }
448
449 match state.storage.get(&path).await {
450 Ok((body, meta)) => {
451 let accept = req
453 .headers()
454 .get(header::ACCEPT)
455 .and_then(|v| v.to_str().ok())
456 .unwrap_or("");
457 let sec_fetch_dest = req
458 .headers()
459 .get("sec-fetch-dest")
460 .and_then(|v| v.to_str().ok());
461 if mashlib::should_serve(
462 accept,
463 sec_fetch_dest,
464 &meta.content_type,
465 state.mashlib.enabled,
466 ) {
467 let embed = if body.len() <= state.mashlib.data_island_max_bytes {
468 std::str::from_utf8(&body).ok().map(|s| s.to_string())
469 } else {
470 None
471 };
472 let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
473 let mut rsp = HttpResponse::Ok()
474 .content_type("text/html; charset=utf-8")
475 .insert_header(("X-Frame-Options", "DENY"))
476 .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
477 .insert_header(("Cache-Control", "no-store"))
478 .body(html);
479 set_wac_allow(&mut rsp, &wac_allow);
480 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
481 set_link_headers(&mut rsp, &path);
482 return Ok(rsp);
483 }
484
485 let mut rsp = HttpResponse::Ok().body(body.to_vec());
486 rsp.headers_mut().insert(
487 header::CONTENT_TYPE,
488 header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
489 header::HeaderValue::from_static("application/octet-stream")
490 }),
491 );
492 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
493 rsp.headers_mut().insert(header::ETAG, etag);
494 }
495 set_wac_allow(&mut rsp, &wac_allow);
496 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
497 set_link_headers(&mut rsp, &path);
498 Ok(rsp)
499 }
500 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
501 Err(e) => Err(to_actix(e)),
502 }
503}
504
505fn has_basic_container_link(req: &HttpRequest) -> bool {
506 req.headers()
507 .get_all(header::LINK)
508 .filter_map(|v| v.to_str().ok())
509 .any(|v| {
510 v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
511 })
512}
513
514async fn handle_put(
515 req: HttpRequest,
516 body: web::Bytes,
517 state: web::Data<AppState>,
518) -> Result<HttpResponse, ActixError> {
519 let path = req.uri().path().to_string();
520
521 if ldp::is_container(&path) {
522 if has_basic_container_link(&req) {
523 let auth_pk = extract_pubkey(&req).await;
524 let agent = agent_uri(auth_pk.as_ref());
525 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
526 let meta = state
527 .storage
528 .create_container(&path)
529 .await
530 .map_err(to_actix)?;
531 let mut rsp = HttpResponse::Created().finish();
532 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
533 rsp.headers_mut().insert(header::ETAG, etag);
534 }
535 set_link_headers(&mut rsp, &path);
536 return Ok(rsp);
537 }
538 return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
539 }
540
541 let auth_pk = extract_pubkey(&req).await;
542 let agent = agent_uri(auth_pk.as_ref());
543 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
544
545 let ct = req
546 .headers()
547 .get(header::CONTENT_TYPE)
548 .and_then(|v| v.to_str().ok())
549 .unwrap_or("application/octet-stream");
550 let meta = state
551 .storage
552 .put(&path, Bytes::from(body.to_vec()), ct)
553 .await
554 .map_err(to_actix)?;
555 let mut rsp = HttpResponse::Created().finish();
556 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
557 rsp.headers_mut().insert(header::ETAG, etag);
558 }
559 set_link_headers(&mut rsp, &path);
560 Ok(rsp)
561}
562
563async fn handle_post(
564 req: HttpRequest,
565 body: web::Bytes,
566 state: web::Data<AppState>,
567) -> Result<HttpResponse, ActixError> {
568 let path = req.uri().path().to_string();
569 let auth_pk = extract_pubkey(&req).await;
572 let agent = agent_uri(auth_pk.as_ref());
573 enforce_write(&state, &path, AccessMode::Append, agent.as_deref()).await?;
574
575 let slug = req
576 .headers()
577 .get(header::HeaderName::from_static("slug"))
578 .and_then(|v| v.to_str().ok());
579 let target = match ldp::resolve_slug(&path, slug) {
580 Ok(p) => p,
581 Err(e) => return Err(to_actix(e)),
582 };
583 let ct = req
584 .headers()
585 .get(header::CONTENT_TYPE)
586 .and_then(|v| v.to_str().ok())
587 .unwrap_or("application/octet-stream");
588 let meta = state
589 .storage
590 .put(&target, Bytes::from(body.to_vec()), ct)
591 .await
592 .map_err(to_actix)?;
593 let mut rsp = HttpResponse::Created().finish();
594 if let Ok(loc) = header::HeaderValue::from_str(&target) {
595 rsp.headers_mut().insert(header::LOCATION, loc);
596 }
597 if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
598 rsp.headers_mut().insert(header::ETAG, etag);
599 }
600 set_link_headers(&mut rsp, &target);
601 Ok(rsp)
602}
603
604async fn handle_patch(
605 req: HttpRequest,
606 body: web::Bytes,
607 state: web::Data<AppState>,
608) -> Result<HttpResponse, ActixError> {
609 let path = req.uri().path().to_string();
610 if ldp::is_container(&path) {
611 return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
612 }
613 let auth_pk = extract_pubkey(&req).await;
614 let agent = agent_uri(auth_pk.as_ref());
615 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
621
622 let ct = req
623 .headers()
624 .get(header::CONTENT_TYPE)
625 .and_then(|v| v.to_str().ok())
626 .unwrap_or("");
627 let dialect = match ldp::patch_dialect_from_mime(ct) {
628 Some(d) => d,
629 None => {
630 return Ok(HttpResponse::UnsupportedMediaType()
631 .body(format!("unsupported patch dialect for content-type {ct:?}")))
632 }
633 };
634 let body_str = match std::str::from_utf8(&body) {
635 Ok(s) => s.to_string(),
636 Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
637 };
638
639 let existing = state.storage.get(&path).await;
641 match existing {
642 Ok((current_body, meta)) => {
643 let out = match dialect {
650 ldp::PatchDialect::N3 => {
651 ldp::apply_n3_patch(ldp::Graph::new(), &body_str).map_err(patch_parse_err)
652 }
653 ldp::PatchDialect::SparqlUpdate => {
654 ldp::apply_sparql_patch(ldp::Graph::new(), &body_str).map_err(patch_parse_err)
655 }
656 ldp::PatchDialect::JsonPatch => {
657 let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
658 Ok(v) => v,
659 Err(_) => serde_json::json!({}),
660 };
661 let patch: serde_json::Value = match serde_json::from_str(&body_str) {
662 Ok(v) => v,
663 Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
664 };
665 ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
666 let bytes = serde_json::to_vec(&json)
667 .map_err(PodError::from)
668 .map_err(to_actix)?;
669 let _ = state
670 .storage
671 .put(&path, Bytes::from(bytes), &meta.content_type)
672 .await
673 .map_err(to_actix)?;
674 return Ok(HttpResponse::NoContent().finish());
675 }
676 };
677 let outcome = out?;
678 let serialised = graph_to_turtle(&outcome.graph);
681 let _ = state
682 .storage
683 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
684 .await
685 .map_err(to_actix)?;
686 Ok(HttpResponse::NoContent().finish())
687 }
688 Err(PodError::NotFound(_)) => {
689 let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
691 let PatchCreateOutcome::Created { graph, .. } = create else {
692 return Err(to_actix(PodError::Unsupported(
693 "unexpected patch outcome on absent resource".into(),
694 )));
695 };
696 let serialised = graph_to_turtle(&graph);
697 let _ = state
698 .storage
699 .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
700 .await
701 .map_err(to_actix)?;
702 Ok(HttpResponse::Created().finish())
703 }
704 Err(e) => Err(to_actix(e)),
705 }
706}
707
708fn patch_parse_err(e: PodError) -> ActixError {
712 match e {
713 PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
714 actix_web::error::ErrorBadRequest(msg)
715 }
716 other => to_actix(other),
717 }
718}
719
720fn graph_to_turtle(g: &ldp::Graph) -> String {
724 g.to_ntriples()
725}
726
727async fn find_effective_acl_dyn(
733 storage: &dyn Storage,
734 resource_path: &str,
735) -> Result<Option<wac::AclDocument>, PodError> {
736 let mut path = resource_path.to_string();
737 loop {
738 let acl_key = if path == "/" {
739 "/.acl".to_string()
740 } else {
741 format!("{}.acl", path.trim_end_matches('/'))
742 };
743 if let Ok((body, meta)) = storage.get(&acl_key).await {
744 match parse_jsonld_acl(&body) {
745 Ok(doc) => return Ok(Some(doc)),
746 Err(PodError::BadRequest(_)) => {
747 return Err(PodError::BadRequest("ACL document exceeds bounds".into()))
748 }
749 Err(_) => {}
750 }
751 let ct = meta.content_type.to_ascii_lowercase();
752 let looks_turtle = ct.starts_with("text/turtle")
753 || ct.starts_with("application/turtle")
754 || ct.starts_with("application/x-turtle");
755 let text = std::str::from_utf8(&body).unwrap_or("");
756 if looks_turtle || text.contains("@prefix") || text.contains("acl:Authorization") {
757 if let Ok(doc) = parse_turtle_acl(text) {
758 return Ok(Some(doc));
759 }
760 }
761 }
762 if path == "/" || path.is_empty() {
763 break;
764 }
765 let trimmed = path.trim_end_matches('/');
766 path = match trimmed.rfind('/') {
767 Some(0) => "/".to_string(),
768 Some(pos) => trimmed[..pos].to_string(),
769 None => "/".to_string(),
770 };
771 }
772 Ok(None)
773}
774
775async fn handle_delete(
776 req: HttpRequest,
777 state: web::Data<AppState>,
778) -> Result<HttpResponse, ActixError> {
779 let path = req.uri().path().to_string();
780 let auth_pk = extract_pubkey(&req).await;
781 let agent = agent_uri(auth_pk.as_ref());
782 enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
783
784 match state.storage.delete(&path).await {
785 Ok(()) => Ok(HttpResponse::NoContent().finish()),
786 Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
787 Err(e) => Err(to_actix(e)),
788 }
789}
790
791async fn handle_options(
792 req: HttpRequest,
793 state: web::Data<AppState>,
794) -> Result<HttpResponse, ActixError> {
795 let path = req.uri().path().to_string();
796 let o = ldp::options_for(&path);
797 let mut rsp = HttpResponse::NoContent().finish();
798 if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
799 rsp.headers_mut()
800 .insert(header::HeaderName::from_static("allow"), v);
801 }
802 if let Some(ap) = o.accept_post {
803 if let Ok(v) = header::HeaderValue::from_str(ap) {
804 rsp.headers_mut()
805 .insert(header::HeaderName::from_static("accept-post"), v);
806 }
807 }
808 if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
809 rsp.headers_mut()
810 .insert(header::HeaderName::from_static("accept-patch"), v);
811 }
812 if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
813 rsp.headers_mut()
814 .insert(header::HeaderName::from_static("accept-ranges"), v);
815 }
816 set_updates_via(&mut rsp, &state.nodeinfo.base_url);
817 Ok(rsp)
818}
819
820async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
825 let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
826 HttpResponse::Ok()
827 .content_type("application/ld+json")
828 .json(doc)
829}
830
831#[derive(Debug, Deserialize)]
832struct WebFingerQuery {
833 resource: Option<String>,
834}
835
836async fn handle_well_known_webfinger(
837 state: web::Data<AppState>,
838 q: web::Query<WebFingerQuery>,
839) -> HttpResponse {
840 let resource = q.resource.clone().unwrap_or_else(|| {
841 format!(
842 "acct:anonymous@{}",
843 state
844 .nodeinfo
845 .base_url
846 .trim_start_matches("http://")
847 .trim_start_matches("https://")
848 )
849 });
850 let webid = format!(
851 "{}/profile/card#me",
852 state.nodeinfo.base_url.trim_end_matches('/')
853 );
854 match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
855 Some(jrd) => HttpResponse::Ok()
856 .content_type("application/jrd+json")
857 .json(jrd),
858 None => HttpResponse::NotFound().finish(),
859 }
860}
861
862async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
863 let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
864 HttpResponse::Ok()
865 .content_type("application/json")
866 .json(doc)
867}
868
869async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
870 let doc = interop::nodeinfo_2_1(
871 &state.nodeinfo.software_name,
872 &state.nodeinfo.software_version,
873 state.nodeinfo.open_registrations,
874 state.nodeinfo.total_users,
875 );
876 HttpResponse::Ok()
877 .content_type("application/json")
878 .json(doc)
879}
880
881#[cfg(feature = "did-nostr")]
882async fn handle_well_known_did_nostr(
883 state: web::Data<AppState>,
884 path: web::Path<String>,
885) -> HttpResponse {
886 let pubkey = path.into_inner();
887 let also = vec![format!(
888 "{}/profile/card#me",
889 state.nodeinfo.base_url.trim_end_matches('/')
890 )];
891 let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
892 HttpResponse::Ok()
893 .content_type("application/did+json")
894 .json(doc)
895}
896
897#[cfg(feature = "nip05-endpoint")]
905#[derive(Debug, Deserialize)]
906struct Nip05Query {
907 name: Option<String>,
910}
911
912#[cfg(feature = "nip05-endpoint")]
913fn nip05_name_is_valid(name: &str) -> bool {
914 if name.is_empty() {
917 return false;
918 }
919 name.bytes()
920 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
921}
922
923#[cfg(feature = "nip05-endpoint")]
924async fn handle_well_known_nip05(
925 state: web::Data<AppState>,
926 query: web::Query<Nip05Query>,
927) -> HttpResponse {
928 use solid_pod_rs::webid::extract_nostr_pubkey;
929
930 let name = query.name.clone().unwrap_or_else(|| "_".to_string());
932 if !nip05_name_is_valid(&name) {
933 return HttpResponse::BadRequest().json(serde_json::json!({
934 "error": "invalid NIP-05 local part",
935 }));
936 }
937
938 let profile_path = if name == "_" {
944 "/profile/card".to_string()
945 } else {
946 format!("/{name}/profile/card")
947 };
948
949 let (body, _meta) = match state.storage.get(&profile_path).await {
950 Ok(v) => v,
951 Err(_) => {
952 return nip05_empty_response();
956 }
957 };
958
959 let pubkey_hex = match extract_nostr_pubkey(&body) {
960 Ok(Some(p)) => p,
961 _ => return nip05_empty_response(),
962 };
963
964 let doc = interop::nip05_document([(name, pubkey_hex)]);
965 HttpResponse::Ok()
966 .insert_header(("Access-Control-Allow-Origin", "*"))
967 .content_type("application/json")
968 .json(doc)
969}
970
971#[cfg(feature = "nip05-endpoint")]
972fn nip05_empty_response() -> HttpResponse {
973 HttpResponse::Ok()
974 .insert_header(("Access-Control-Allow-Origin", "*"))
975 .content_type("application/json")
976 .json(serde_json::json!({ "names": {} }))
977}
978
979#[derive(Debug, Deserialize)]
984struct CreateAccountRequest {
985 username: String,
986 #[serde(default)]
987 name: Option<String>,
988}
989
990#[derive(Debug, Deserialize)]
991struct CreatePodRequest {
992 name: String,
993}
994
995async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
996 let pod_name = path.into_inner();
997 let pod_root = format!("/{pod_name}/");
998 match state.storage.exists(&pod_root).await {
999 Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
1000 _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
1001 }
1002}
1003
1004fn valid_pod_name(name: &str) -> bool {
1005 !name.is_empty()
1006 && name
1007 .chars()
1008 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
1009}
1010
1011fn request_ip(req: &HttpRequest) -> IpAddr {
1012 req.peer_addr()
1013 .map(|addr| addr.ip())
1014 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
1015}
1016
1017async fn handle_create_account(
1018 state: web::Data<AppState>,
1019 body: web::Json<CreateAccountRequest>,
1020) -> Result<HttpResponse, ActixError> {
1021 let pod_root = format!("/{}/", body.username);
1022 if state.storage.exists(&pod_root).await.unwrap_or(false) {
1023 return Ok(
1024 HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
1025 );
1026 }
1027
1028 let mut plan = provision::ProvisionPlan::new(
1029 body.username.clone(),
1030 format!(
1031 "{}/{}",
1032 state.nodeinfo.base_url.trim_end_matches('/'),
1033 body.username,
1034 ),
1035 );
1036 plan.display_name = body.name.clone();
1037 plan.containers = vec![
1038 format!("/{}/", body.username),
1039 format!("/{}/profile/", body.username),
1040 format!("/{}/inbox/", body.username),
1041 format!("/{}/public/", body.username),
1042 format!("/{}/private/", body.username),
1043 format!("/{}/settings/", body.username),
1044 ];
1045
1046 #[cfg(feature = "git")]
1050 let outcome = {
1051 use solid_pod_rs_git::init::GitAutoInit;
1052 let git_hook = state.data_root.as_ref().map(|root| {
1053 let fs_path = root.join(&body.username);
1054 (GitAutoInit::new(), fs_path)
1055 });
1056 match git_hook {
1057 Some((hook, ref fs_path)) => {
1058 provision::provision_pod_ext(state.storage.as_ref(), &plan, Some((&hook, fs_path)))
1059 .await
1060 }
1061 None => provision::provision_pod(state.storage.as_ref(), &plan).await,
1062 }
1063 };
1064 #[cfg(not(feature = "git"))]
1065 let outcome = provision::provision_pod(state.storage.as_ref(), &plan).await;
1066
1067 match outcome {
1068 Ok(outcome) => Ok(HttpResponse::Created().json(serde_json::json!({
1069 "webid": outcome.webid,
1070 "pod_root": outcome.pod_root,
1071 "username": body.username,
1072 }))),
1073 Err(e) => Err(to_actix(e)),
1074 }
1075}
1076
1077async fn handle_create_pod(
1078 req: HttpRequest,
1079 state: web::Data<AppState>,
1080 body: web::Json<CreatePodRequest>,
1081) -> Result<HttpResponse, ActixError> {
1082 let ip = request_ip(&req);
1083 if let Err(retry_after) = state.pod_create_limiter.check(ip) {
1084 return Ok(HttpResponse::TooManyRequests()
1085 .insert_header(("Retry-After", retry_after.to_string()))
1086 .json(serde_json::json!({
1087 "error": "Too Many Requests",
1088 "message": "Pod creation rate limit exceeded",
1089 "retryAfter": retry_after
1090 })));
1091 }
1092
1093 if !valid_pod_name(&body.name) {
1094 return Ok(HttpResponse::BadRequest().json(serde_json::json!({
1095 "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
1096 })));
1097 }
1098
1099 let pod_root = format!("/{}/", body.name);
1100 if state.storage.exists(&pod_root).await.unwrap_or(false) {
1101 return Ok(
1102 HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
1103 );
1104 }
1105
1106 let conn = req.connection_info();
1107 let base_uri = format!("{}://{}", conn.scheme(), conn.host());
1108 let pod_uri = format!("{}/{}/", base_uri.trim_end_matches('/'), body.name);
1109
1110 for container in [
1111 format!("/{}/", body.name),
1112 format!("/{}/profile/", body.name),
1113 format!("/{}/inbox/", body.name),
1114 format!("/{}/public/", body.name),
1115 format!("/{}/private/", body.name),
1116 format!("/{}/settings/", body.name),
1117 ] {
1118 let meta_key = format!("{}.meta", container.trim_end_matches('/'));
1119 state
1120 .storage
1121 .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
1122 .await
1123 .map_err(to_actix)?;
1124 }
1125
1126 let canonical_pods_prefix = format!("{}/pods/{}/", base_uri.trim_end_matches('/'), body.name);
1127 let webid = format!("{pod_uri}profile/card#me");
1128 let profile = solid_pod_rs::webid::generate_webid_html(&body.name, None, &base_uri)
1129 .replace(&canonical_pods_prefix, &pod_uri);
1130 state
1131 .storage
1132 .put(
1133 &format!("/{}/profile/card", body.name),
1134 Bytes::from(profile.into_bytes()),
1135 "text/html",
1136 )
1137 .await
1138 .map_err(to_actix)?;
1139
1140 Ok(HttpResponse::Created()
1141 .insert_header(("Location", pod_uri.clone()))
1142 .json(serde_json::json!({
1143 "name": body.name,
1144 "webId": webid,
1145 "podUri": pod_uri,
1146 })))
1147}
1148
1149async fn handle_copy(
1154 req: HttpRequest,
1155 state: web::Data<AppState>,
1156) -> Result<HttpResponse, ActixError> {
1157 let dest = req.uri().path().to_string();
1158 let auth_pk = extract_pubkey(&req).await;
1159 let agent = agent_uri(auth_pk.as_ref());
1160 enforce_write(&state, &dest, AccessMode::Write, agent.as_deref()).await?;
1161
1162 let source = req
1163 .headers()
1164 .get("source")
1165 .and_then(|v| v.to_str().ok())
1166 .map(|s| s.to_string());
1167 let source = match source {
1168 Some(s) => s,
1169 None => return Ok(HttpResponse::BadRequest().body("Source header required")),
1170 };
1171
1172 let (body, meta) = match state.storage.get(&source).await {
1173 Ok(v) => v,
1174 Err(PodError::NotFound(_)) => {
1175 return Ok(HttpResponse::NotFound().body("source resource not found"))
1176 }
1177 Err(e) => return Err(to_actix(e)),
1178 };
1179
1180 state
1181 .storage
1182 .put(&dest, body, &meta.content_type)
1183 .await
1184 .map_err(to_actix)?;
1185
1186 let src_acl = format!("{}.acl", source.trim_end_matches('/'));
1188 let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
1189 if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
1190 let _ = state
1191 .storage
1192 .put(&dst_acl, acl_body, &acl_meta.content_type)
1193 .await;
1194 }
1195
1196 let mut rsp = HttpResponse::Created().finish();
1197 if let Ok(loc) = header::HeaderValue::from_str(&dest) {
1198 rsp.headers_mut().insert(header::LOCATION, loc);
1199 }
1200 Ok(rsp)
1201}
1202
1203async fn handle_glob_get(
1208 req: HttpRequest,
1209 state: web::Data<AppState>,
1210) -> Result<HttpResponse, ActixError> {
1211 let raw_path = req.uri().path().to_string();
1212 if !raw_path.ends_with("/*") {
1214 return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
1215 }
1216 let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
1218 folder.to_string()
1219 } else {
1220 format!("{folder}/")
1221 };
1222
1223 let children = state.storage.list(&folder).await.map_err(to_actix)?;
1224 let mut merged = String::new();
1225
1226 for child in &children {
1227 if child.ends_with('/') {
1228 continue;
1229 }
1230 let child_path = format!("{folder}{child}");
1231 if let Ok((body, meta)) = state.storage.get(&child_path).await {
1232 if meta.content_type.contains("turtle")
1233 || meta.content_type.contains("n-triples")
1234 || meta.content_type.contains("n3")
1235 {
1236 if let Ok(text) = std::str::from_utf8(&body) {
1237 merged.push_str(text);
1238 merged.push('\n');
1239 }
1240 }
1241 }
1242 }
1243
1244 if merged.is_empty() {
1245 return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
1246 }
1247
1248 Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
1249}
1250
1251#[derive(Debug, Deserialize)]
1256struct LoginPasswordRequest {
1257 username: String,
1258 password: String,
1259}
1260
1261async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
1262 let _ = (&body.username, &body.password);
1263 HttpResponse::Ok().json(serde_json::json!({
1264 "message": "login endpoint active"
1265 }))
1266}
1267
1268#[derive(Debug, Deserialize)]
1269struct PasswordResetRequest {
1270 username: String,
1271}
1272
1273async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
1274 let _ = &body.username;
1275 HttpResponse::Ok().json(serde_json::json!({
1276 "message": "if an account with that username exists, a reset link has been sent"
1277 }))
1278}
1279
1280#[derive(Debug, Deserialize)]
1281struct PasswordChangeRequest {
1282 token: String,
1283 new_password: String,
1284}
1285
1286async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
1287 let _ = (&body.token, &body.new_password);
1288 HttpResponse::Ok().json(serde_json::json!({
1289 "message": "password changed"
1290 }))
1291}
1292
1293async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
1298 let body = solid_pod_rs::payments::pay_info(&state.pay_config);
1299 HttpResponse::Ok()
1300 .content_type("application/json")
1301 .json(body)
1302}
1303
1304pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
1319
1320#[derive(Debug, Deserialize)]
1322struct ProxyQuery {
1323 url: String,
1324}
1325
1326const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
1328 "set-cookie",
1329 "set-cookie2",
1330 "authorization",
1331 "www-authenticate",
1332 "proxy-authenticate",
1333 "proxy-authorization",
1334];
1335
1336fn validate_proxy_target(target: &str) -> Result<url::Url, HttpResponse> {
1342 let parsed = match url::Url::parse(target) {
1343 Ok(u) => u,
1344 Err(_) => {
1345 return Err(
1346 HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
1347 );
1348 }
1349 };
1350
1351 match parsed.scheme() {
1353 "http" | "https" => {}
1354 scheme => {
1355 return Err(HttpResponse::BadRequest()
1356 .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
1357 }
1358 }
1359
1360 if let Err(_e) = solid_pod_rs::security::is_safe_url(target) {
1362 return Err(HttpResponse::Forbidden()
1363 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1364 }
1365
1366 if let Some(host) = parsed.host_str() {
1368 let host_lower = host.to_ascii_lowercase();
1369 if host_lower == "localhost"
1371 || host_lower.ends_with(".localhost")
1372 || host_lower == "0.0.0.0"
1373 || host_lower == "[::1]"
1374 || host_lower == "[::0]"
1375 {
1376 return Err(HttpResponse::Forbidden()
1377 .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1378 }
1379 } else {
1380 return Err(
1381 HttpResponse::BadRequest().json(serde_json::json!({"error": "target URL has no host"}))
1382 );
1383 }
1384
1385 Ok(parsed)
1386}
1387
1388async fn handle_proxy(
1389 req: HttpRequest,
1390 _state: web::Data<AppState>,
1391 query: web::Query<ProxyQuery>,
1392) -> Result<HttpResponse, ActixError> {
1393 let auth_pk = extract_pubkey(&req).await;
1395 let agent = agent_uri(auth_pk.as_ref());
1396 if agent.is_none() {
1397 return Ok(HttpResponse::Unauthorized()
1398 .json(serde_json::json!({"error": "authentication required"})));
1399 }
1400
1401 let _target_url = match validate_proxy_target(&query.url) {
1403 Ok(u) => u,
1404 Err(rsp) => return Ok(rsp),
1405 };
1406
1407 let client = reqwest::Client::builder()
1409 .redirect(reqwest::redirect::Policy::none())
1412 .build()
1413 .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))?;
1414
1415 let mut current_url = query.url.clone();
1416 let mut redirect_count = 0u8;
1417 const MAX_REDIRECTS: u8 = 5;
1418
1419 let byte_cap = std::env::var("PROXY_BYTE_CAP")
1420 .ok()
1421 .and_then(|v| {
1422 solid_pod_rs::config::sources::parse_size(&v)
1423 .map(|u| u as usize)
1424 .ok()
1425 })
1426 .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
1427
1428 loop {
1429 if redirect_count > 0 {
1431 match validate_proxy_target(¤t_url) {
1432 Ok(_) => {}
1433 Err(rsp) => return Ok(rsp),
1434 }
1435 }
1436
1437 let mut upstream_req = client.get(¤t_url);
1438
1439 if let Some(auth_val) = req
1441 .headers()
1442 .get("x-upstream-authorization")
1443 .and_then(|v| v.to_str().ok())
1444 {
1445 upstream_req = upstream_req.header("Authorization", auth_val);
1446 }
1447
1448 let response = upstream_req
1449 .send()
1450 .await
1451 .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
1452
1453 if response.status().is_redirection() {
1455 if redirect_count >= MAX_REDIRECTS {
1456 return Ok(HttpResponse::BadGateway()
1457 .json(serde_json::json!({"error": "too many redirects"})));
1458 }
1459 if let Some(location) = response.headers().get("location") {
1460 let loc_str = location
1461 .to_str()
1462 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
1463 let base = url::Url::parse(¤t_url)
1465 .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
1466 let resolved = base
1467 .join(loc_str)
1468 .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
1469 current_url = resolved.to_string();
1470 redirect_count += 1;
1471 continue;
1472 }
1473 return Ok(HttpResponse::BadGateway()
1474 .json(serde_json::json!({"error": "redirect without location"})));
1475 }
1476
1477 let upstream_status = response.status().as_u16();
1479 let upstream_content_type = response
1480 .headers()
1481 .get("content-type")
1482 .and_then(|v| v.to_str().ok())
1483 .unwrap_or("application/octet-stream")
1484 .to_string();
1485
1486 let mut forwarded_headers: Vec<(String, String)> = Vec::new();
1488 for (name, value) in response.headers() {
1489 let name_lower = name.as_str().to_ascii_lowercase();
1490 if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
1491 continue;
1492 }
1493 if matches!(
1495 name_lower.as_str(),
1496 "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
1497 ) {
1498 continue;
1499 }
1500 if let Ok(val_str) = value.to_str() {
1501 forwarded_headers.push((name_lower, val_str.to_string()));
1502 }
1503 }
1504
1505 let body_bytes = response
1506 .bytes()
1507 .await
1508 .map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
1509
1510 if body_bytes.len() > byte_cap {
1511 return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
1512 "error": "proxied response exceeds byte cap",
1513 "limit": byte_cap
1514 })));
1515 }
1516
1517 let mut rsp = HttpResponse::build(
1519 StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
1520 );
1521 rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
1522 rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
1523
1524 for (name, value) in &forwarded_headers {
1526 if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
1527 if let Ok(hval) = header::HeaderValue::from_str(value) {
1528 rsp.insert_header((hname, hval));
1529 }
1530 }
1531 }
1532
1533 return Ok(rsp.body(body_bytes.to_vec()));
1534 }
1535}
1536
1537pub struct PathTraversalGuard;
1543
1544impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
1545where
1546 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1547 B: 'static,
1548{
1549 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1550 type Error = ActixError;
1551 type InitError = ();
1552 type Transform = PathTraversalGuardMiddleware<S>;
1553 type Future = Ready<Result<Self::Transform, Self::InitError>>;
1554
1555 fn new_transform(&self, service: S) -> Self::Future {
1556 ready(Ok(PathTraversalGuardMiddleware { service }))
1557 }
1558}
1559
1560pub struct PathTraversalGuardMiddleware<S> {
1562 service: S,
1563}
1564
1565impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
1566where
1567 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1568 B: 'static,
1569{
1570 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1571 type Error = ActixError;
1572 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1573
1574 actix_web::dev::forward_ready!(service);
1575
1576 fn call(&self, req: ServiceRequest) -> Self::Future {
1577 let raw = req.path().to_string();
1580 if path_is_traversal(&raw) {
1581 let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
1582 let sr = req.into_response(rsp.map_into_boxed_body());
1583 return Box::pin(async move { Ok(sr.map_into_right_body()) });
1584 }
1585 let fut = self.service.call(req);
1586 Box::pin(async move {
1587 let resp = fut.await?;
1588 Ok(resp.map_into_left_body())
1589 })
1590 }
1591}
1592
1593fn path_is_traversal(path: &str) -> bool {
1594 let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
1596 let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
1597 for seg in once.split('/').chain(twice.split('/')) {
1598 if seg == ".." || seg == "." {
1599 return true;
1600 }
1601 }
1602 if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
1605 return true;
1606 }
1607 false
1608}
1609
1610pub struct CorsHeaders {
1621 pub allowed_origins: Arc<Vec<String>>,
1622}
1623
1624impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
1625where
1626 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1627 B: 'static,
1628{
1629 type Response = ServiceResponse<B>;
1630 type Error = ActixError;
1631 type InitError = ();
1632 type Transform = CorsHeadersMiddleware<S>;
1633 type Future = Ready<Result<Self::Transform, Self::InitError>>;
1634
1635 fn new_transform(&self, service: S) -> Self::Future {
1636 ready(Ok(CorsHeadersMiddleware {
1637 service,
1638 allowed_origins: self.allowed_origins.clone(),
1639 }))
1640 }
1641}
1642
1643pub struct CorsHeadersMiddleware<S> {
1645 service: S,
1646 allowed_origins: Arc<Vec<String>>,
1647}
1648
1649impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
1650where
1651 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1652 B: 'static,
1653{
1654 type Response = ServiceResponse<B>;
1655 type Error = ActixError;
1656 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1657
1658 actix_web::dev::forward_ready!(service);
1659
1660 fn call(&self, req: ServiceRequest) -> Self::Future {
1661 let origin = req
1662 .headers()
1663 .get(header::ORIGIN)
1664 .and_then(|v| v.to_str().ok())
1665 .map(str::to_string);
1666 let allowed = self.allowed_origins.clone();
1667 let fut = self.service.call(req);
1668 Box::pin(async move {
1669 let mut resp = fut.await?;
1670 add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
1671 Ok(resp)
1672 })
1673 }
1674}
1675
1676fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
1677 let effective_origin: Option<String> = if allowed.is_empty() {
1679 Some(origin.unwrap_or("*").to_string())
1681 } else {
1682 origin
1684 .filter(|o| allowed.iter().any(|a| a == *o))
1685 .map(str::to_string)
1686 };
1687
1688 let origin_value = match effective_origin {
1691 Some(ref v) => v.as_str(),
1692 None => return,
1693 };
1694
1695 let pairs = [
1696 ("access-control-allow-origin", origin_value),
1697 (
1698 "access-control-allow-methods",
1699 "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
1700 ),
1701 (
1702 "access-control-allow-headers",
1703 "Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Range, Slug, Origin",
1704 ),
1705 (
1706 "access-control-expose-headers",
1707 "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",
1708 ),
1709 ("access-control-allow-credentials", "true"),
1710 ("access-control-max-age", "86400"),
1711 ];
1712
1713 for (name, value) in pairs {
1714 if let (Ok(name), Ok(value)) = (
1715 header::HeaderName::from_lowercase(name.as_bytes()),
1716 header::HeaderValue::from_str(value),
1717 ) {
1718 headers.insert(name, value);
1719 }
1720 }
1721}
1722
1723pub struct ErrorLoggingMiddleware;
1739
1740impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
1741where
1742 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1743 B: 'static,
1744{
1745 type Response = ServiceResponse<B>;
1746 type Error = ActixError;
1747 type InitError = ();
1748 type Transform = ErrorLoggingMiddlewareService<S>;
1749 type Future = Ready<Result<Self::Transform, Self::InitError>>;
1750
1751 fn new_transform(&self, service: S) -> Self::Future {
1752 ready(Ok(ErrorLoggingMiddlewareService { service }))
1753 }
1754}
1755
1756pub struct ErrorLoggingMiddlewareService<S> {
1758 service: S,
1759}
1760
1761impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
1762where
1763 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1764 B: 'static,
1765{
1766 type Response = ServiceResponse<B>;
1767 type Error = ActixError;
1768 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1769
1770 actix_web::dev::forward_ready!(service);
1771
1772 fn call(&self, req: ServiceRequest) -> Self::Future {
1773 let method = req.method().as_str().to_string();
1776 let path = req.path().to_string();
1777
1778 let fut = self.service.call(req);
1779 Box::pin(async move {
1780 let response = fut.await?;
1781 let status = response.status();
1782 if status.is_server_error() {
1783 log_5xx(&method, &path, status, response.response().error());
1784 }
1785 Ok(response)
1786 })
1787 }
1788}
1789
1790fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
1794 let chain = match error {
1798 Some(e) => format_error_chain(e),
1799 None => "<no error attached to response>".to_string(),
1800 };
1801
1802 let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
1803 Some(std::backtrace::Backtrace::force_capture().to_string())
1804 } else {
1805 None
1806 };
1807
1808 tracing::error!(
1809 target: "solid_pod_rs_server::http",
1810 method = %method,
1811 path = %path,
1812 status = %status.as_u16(),
1813 error.chain = %chain,
1814 backtrace = backtrace.as_deref().unwrap_or(""),
1815 "5xx response"
1816 );
1817}
1818
1819fn format_error_chain(e: &actix_web::Error) -> String {
1830 let summary = format!("{}", e.as_response_error());
1831 let debug = format!("{e:?}");
1832 if debug == summary || debug.is_empty() {
1833 summary
1834 } else {
1835 format!("{summary} -> {debug}")
1836 }
1837}
1838
1839pub struct DotfileGuard {
1845 allow: Arc<DotfileAllowlist>,
1846}
1847
1848impl DotfileGuard {
1849 pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
1850 Self { allow }
1851 }
1852}
1853
1854impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
1855where
1856 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1857 B: 'static,
1858{
1859 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1860 type Error = ActixError;
1861 type InitError = ();
1862 type Transform = DotfileGuardMiddleware<S>;
1863 type Future = Ready<Result<Self::Transform, Self::InitError>>;
1864
1865 fn new_transform(&self, service: S) -> Self::Future {
1866 ready(Ok(DotfileGuardMiddleware {
1867 service,
1868 allow: self.allow.clone(),
1869 }))
1870 }
1871}
1872
1873pub struct DotfileGuardMiddleware<S> {
1875 service: S,
1876 allow: Arc<DotfileAllowlist>,
1877}
1878
1879impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
1880where
1881 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1882 B: 'static,
1883{
1884 type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1885 type Error = ActixError;
1886 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1887
1888 actix_web::dev::forward_ready!(service);
1889
1890 fn call(&self, req: ServiceRequest) -> Self::Future {
1891 let path = req.path().to_string();
1892 let allow_system_route = path.starts_with("/.well-known/") || path == "/.pods";
1896 if !allow_system_route {
1897 let pb = PathBuf::from(&path);
1898 if !self.allow.is_allowed(Path::new(&pb)) {
1899 let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
1900 let sr = req.into_response(rsp.map_into_boxed_body());
1901 return Box::pin(async move { Ok(sr.map_into_right_body()) });
1902 }
1903 }
1904 let fut = self.service.call(req);
1905 Box::pin(async move {
1906 let resp = fut.await?;
1907 Ok(resp.map_into_left_body())
1908 })
1909 }
1910}
1911
1912#[cfg(feature = "git")]
1917fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
1918 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
1919 return None;
1920 }
1921 state.data_root.as_ref().map(|root| root.join(pubkey))
1922}
1923
1924#[cfg(feature = "git")]
1925async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
1926 let caller = extract_pubkey(req).await?;
1927 if caller != pod_pubkey {
1928 return None;
1929 }
1930 Some(caller)
1931}
1932
1933#[cfg(feature = "git")]
1934fn git_json_err(msg: &str, status: u16) -> HttpResponse {
1935 HttpResponse::build(
1936 StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
1937 )
1938 .content_type("application/json")
1939 .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
1940}
1941
1942#[cfg(feature = "git")]
1944#[derive(serde::Deserialize)]
1945struct GitStageBody {
1946 paths: Option<Vec<String>>,
1947 all: Option<bool>,
1948}
1949
1950#[cfg(feature = "git")]
1951#[derive(serde::Deserialize)]
1952struct GitCommitBody {
1953 message: String,
1954 author_name: Option<String>,
1955 author_email: Option<String>,
1956}
1957
1958#[cfg(feature = "git")]
1959#[derive(serde::Deserialize)]
1960struct GitBranchBody {
1961 name: String,
1962}
1963
1964#[cfg(feature = "git")]
1967async fn handle_git_status(
1968 path: web::Path<String>,
1969 req: HttpRequest,
1970 state: web::Data<AppState>,
1971) -> HttpResponse {
1972 let pubkey = path.into_inner();
1973 if require_pod_owner(&req, &pubkey).await.is_none() {
1974 return git_json_err("Authentication required", 401);
1975 }
1976 let Some(repo) = pod_repo_path(&state, &pubkey) else {
1977 return git_json_err("Git not available (no FS backend)", 501);
1978 };
1979 match solid_pod_rs_git::api::git_status(&repo).await {
1980 Ok(s) => HttpResponse::Ok()
1981 .content_type("application/json")
1982 .body(serde_json::to_string(&s).unwrap_or_default()),
1983 Err(e) => git_json_err(&e.to_string(), e.status_code()),
1984 }
1985}
1986
1987#[cfg(feature = "git")]
1988async fn handle_git_log(
1989 path: web::Path<String>,
1990 req: HttpRequest,
1991 state: web::Data<AppState>,
1992 query: web::Query<std::collections::HashMap<String, String>>,
1993) -> HttpResponse {
1994 let pubkey = path.into_inner();
1995 if require_pod_owner(&req, &pubkey).await.is_none() {
1996 return git_json_err("Authentication required", 401);
1997 }
1998 let Some(repo) = pod_repo_path(&state, &pubkey) else {
1999 return git_json_err("Git not available (no FS backend)", 501);
2000 };
2001 let limit: u32 = query
2002 .get("limit")
2003 .and_then(|v| v.parse().ok())
2004 .unwrap_or(20);
2005 match solid_pod_rs_git::api::git_log(&repo, limit).await {
2006 Ok(entries) => HttpResponse::Ok()
2007 .content_type("application/json")
2008 .body(serde_json::to_string(&entries).unwrap_or_default()),
2009 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2010 }
2011}
2012
2013#[cfg(feature = "git")]
2014async fn handle_git_diff(
2015 path: web::Path<String>,
2016 req: HttpRequest,
2017 state: web::Data<AppState>,
2018 query: web::Query<std::collections::HashMap<String, String>>,
2019) -> HttpResponse {
2020 let pubkey = path.into_inner();
2021 if require_pod_owner(&req, &pubkey).await.is_none() {
2022 return git_json_err("Authentication required", 401);
2023 }
2024 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2025 return git_json_err("Git not available (no FS backend)", 501);
2026 };
2027 let file_path = query.get("path").map(String::as_str);
2028 let staged = query
2029 .get("staged")
2030 .map(|v| v == "true" || v == "1")
2031 .unwrap_or(false);
2032 match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
2033 Ok(diff) => HttpResponse::Ok()
2034 .content_type("text/plain")
2035 .body(diff),
2036 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2037 }
2038}
2039
2040#[cfg(feature = "git")]
2041async fn handle_git_stage(
2042 path: web::Path<String>,
2043 req: HttpRequest,
2044 state: web::Data<AppState>,
2045 body: web::Bytes,
2046) -> HttpResponse {
2047 let pubkey = path.into_inner();
2048 if require_pod_owner(&req, &pubkey).await.is_none() {
2049 return git_json_err("Authentication required", 401);
2050 }
2051 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2052 return git_json_err("Git not available (no FS backend)", 501);
2053 };
2054 let parsed: GitStageBody = match serde_json::from_slice(&body) {
2055 Ok(v) => v,
2056 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2057 };
2058 let paths = parsed.paths.unwrap_or_default();
2059 let all = parsed.all.unwrap_or(false);
2060 match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
2061 Ok(()) => HttpResponse::Ok()
2062 .content_type("application/json")
2063 .body(r#"{"ok":true}"#),
2064 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2065 }
2066}
2067
2068#[cfg(feature = "git")]
2069async fn handle_git_unstage(
2070 path: web::Path<String>,
2071 req: HttpRequest,
2072 state: web::Data<AppState>,
2073 body: web::Bytes,
2074) -> HttpResponse {
2075 let pubkey = path.into_inner();
2076 if require_pod_owner(&req, &pubkey).await.is_none() {
2077 return git_json_err("Authentication required", 401);
2078 }
2079 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2080 return git_json_err("Git not available (no FS backend)", 501);
2081 };
2082 let parsed: GitStageBody = match serde_json::from_slice(&body) {
2083 Ok(v) => v,
2084 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2085 };
2086 let paths = parsed.paths.unwrap_or_default();
2087 let all = parsed.all.unwrap_or(false);
2088 match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
2089 Ok(()) => HttpResponse::Ok()
2090 .content_type("application/json")
2091 .body(r#"{"ok":true}"#),
2092 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2093 }
2094}
2095
2096#[cfg(feature = "git")]
2097async fn handle_git_commit(
2098 path: web::Path<String>,
2099 req: HttpRequest,
2100 state: web::Data<AppState>,
2101 body: web::Bytes,
2102) -> HttpResponse {
2103 let pubkey = path.into_inner();
2104 if require_pod_owner(&req, &pubkey).await.is_none() {
2105 return git_json_err("Authentication required", 401);
2106 }
2107 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2108 return git_json_err("Git not available (no FS backend)", 501);
2109 };
2110 let parsed: GitCommitBody = match serde_json::from_slice(&body) {
2111 Ok(v) => v,
2112 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2113 };
2114 let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
2115 let author_email = parsed
2116 .author_email
2117 .as_deref()
2118 .unwrap_or("pod@dreamlab-ai.com");
2119 match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email)
2120 .await
2121 {
2122 Ok(result) => HttpResponse::Ok()
2123 .content_type("application/json")
2124 .body(serde_json::to_string(&result).unwrap_or_default()),
2125 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2126 }
2127}
2128
2129#[cfg(feature = "git")]
2130async fn handle_git_branches(
2131 path: web::Path<String>,
2132 req: HttpRequest,
2133 state: web::Data<AppState>,
2134) -> HttpResponse {
2135 let pubkey = path.into_inner();
2136 if require_pod_owner(&req, &pubkey).await.is_none() {
2137 return git_json_err("Authentication required", 401);
2138 }
2139 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2140 return git_json_err("Git not available (no FS backend)", 501);
2141 };
2142 match solid_pod_rs_git::api::git_branches(&repo).await {
2143 Ok(info) => HttpResponse::Ok()
2144 .content_type("application/json")
2145 .body(serde_json::to_string(&info).unwrap_or_default()),
2146 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2147 }
2148}
2149
2150#[cfg(feature = "git")]
2151async fn handle_git_create_branch(
2152 path: web::Path<String>,
2153 req: HttpRequest,
2154 state: web::Data<AppState>,
2155 body: web::Bytes,
2156) -> HttpResponse {
2157 let pubkey = path.into_inner();
2158 if require_pod_owner(&req, &pubkey).await.is_none() {
2159 return git_json_err("Authentication required", 401);
2160 }
2161 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2162 return git_json_err("Git not available (no FS backend)", 501);
2163 };
2164 let parsed: GitBranchBody = match serde_json::from_slice(&body) {
2165 Ok(v) => v,
2166 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2167 };
2168 match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
2169 Ok(()) => HttpResponse::Ok()
2170 .content_type("application/json")
2171 .body(r#"{"ok":true}"#),
2172 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2173 }
2174}
2175
2176#[cfg(feature = "git")]
2177async fn handle_git_discard(
2178 path: web::Path<String>,
2179 req: HttpRequest,
2180 state: web::Data<AppState>,
2181 body: web::Bytes,
2182) -> HttpResponse {
2183 let pubkey = path.into_inner();
2184 if require_pod_owner(&req, &pubkey).await.is_none() {
2185 return git_json_err("Authentication required", 401);
2186 }
2187 let Some(repo) = pod_repo_path(&state, &pubkey) else {
2188 return git_json_err("Git not available (no FS backend)", 501);
2189 };
2190 let parsed: GitStageBody = match serde_json::from_slice(&body) {
2191 Ok(v) => v,
2192 Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2193 };
2194 let paths = parsed.paths.unwrap_or_default();
2195 match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
2196 Ok(()) => HttpResponse::Ok()
2197 .content_type("application/json")
2198 .body(r#"{"ok":true}"#),
2199 Err(e) => git_json_err(&e.to_string(), e.status_code()),
2200 }
2201}
2202
2203async fn handle_git_panel_options(
2211 req: HttpRequest,
2212 state: web::Data<AppState>,
2213) -> HttpResponse {
2214 let origin = req
2215 .headers()
2216 .get(header::ORIGIN)
2217 .and_then(|v| v.to_str().ok())
2218 .map(str::to_string);
2219
2220 let mut rsp = HttpResponse::NoContent().finish();
2221 add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
2222 rsp
2223}
2224
2225async fn handle_admin_provision(
2236 req: HttpRequest,
2237 state: web::Data<AppState>,
2238 path: web::Path<String>,
2239) -> HttpResponse {
2240 let expected = match &state.admin_key {
2242 Some(k) => k.clone(),
2243 None => {
2244 return HttpResponse::Forbidden().json(serde_json::json!({
2245 "error": "admin key not configured on this server"
2246 }));
2247 }
2248 };
2249 let provided = req
2250 .headers()
2251 .get("x-pod-admin-key")
2252 .and_then(|v| v.to_str().ok())
2253 .unwrap_or("");
2254 if provided != expected {
2255 return HttpResponse::Forbidden()
2256 .json(serde_json::json!({"error": "invalid admin key"}));
2257 }
2258
2259 let pubkey = path.into_inner();
2261 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
2262 return HttpResponse::BadRequest()
2263 .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
2264 }
2265
2266 let data_root = match &state.data_root {
2268 Some(r) => r.clone(),
2269 None => {
2270 return HttpResponse::InternalServerError().json(serde_json::json!({
2271 "error": "server has no fs-backend storage configured"
2272 }));
2273 }
2274 };
2275
2276 let pod_dir = data_root.join(&pubkey);
2277
2278 if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
2280 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
2281 return HttpResponse::InternalServerError()
2282 .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
2283 }
2284
2285 let acl_content = format!(
2287 "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
2288 <#owner> a acl:Authorization ;\n\
2289 acl:agent <did:nostr:{pubkey}> ;\n\
2290 acl:accessTo <./> ;\n\
2291 acl:default <./> ;\n\
2292 acl:mode acl:Read, acl:Write, acl:Control .\n"
2293 );
2294 let acl_path = pod_dir.join(".acl");
2295 if !acl_path.exists() {
2296 if let Err(e) = tokio::fs::write(&acl_path, acl_content.as_bytes()).await {
2297 tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write .acl failed");
2298 return HttpResponse::InternalServerError()
2299 .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
2300 }
2301 }
2302
2303 #[cfg(feature = "git")]
2305 {
2306 use tokio::process::Command;
2307
2308 if !pod_dir.join(".git").exists() {
2310 let init_out = Command::new("git")
2311 .args([
2312 "init",
2313 "-b",
2314 "main",
2315 pod_dir.to_str().unwrap_or("."),
2316 ])
2317 .output()
2318 .await;
2319
2320 match init_out {
2321 Ok(out) if out.status.success() => {}
2322 Ok(out) => {
2323 let stderr = String::from_utf8_lossy(&out.stderr);
2324 tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
2325 }
2326 Err(e) => {
2327 tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
2328 }
2329 }
2330
2331 let cfg_out = Command::new("git")
2334 .args([
2335 "-C",
2336 pod_dir.to_str().unwrap_or("."),
2337 "config",
2338 "receive.denyCurrentBranch",
2339 "updateInstead",
2340 ])
2341 .output()
2342 .await;
2343
2344 if let Err(e) = cfg_out {
2345 tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
2346 }
2347 }
2348 }
2349
2350 let base_url = state.nodeinfo.base_url.trim_end_matches('/');
2352 HttpResponse::Ok().json(serde_json::json!({
2353 "podUrl": format!("{base_url}/pods/{pubkey}/"),
2354 "ok": true,
2355 }))
2356}
2357
2358async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
2363 let Some(ref data_root) = state.data_root else {
2364 return HttpResponse::Ok()
2365 .content_type("application/json")
2366 .json(serde_json::json!({"apps": [], "count": 0}));
2367 };
2368
2369 let server_url = state.nodeinfo.base_url.clone();
2370
2371 let mut read_dir = match tokio::fs::read_dir(data_root).await {
2373 Ok(rd) => rd,
2374 Err(_) => {
2375 return HttpResponse::Ok()
2376 .content_type("application/json")
2377 .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
2378 }
2379 };
2380
2381 let mut apps: Vec<serde_json::Value> = Vec::new();
2382 let mut scanned = 0usize;
2383
2384 while scanned < 1000 {
2385 let entry = match read_dir.next_entry().await {
2386 Ok(Some(e)) => e,
2387 Ok(None) => break,
2388 Err(_) => break,
2389 };
2390
2391 let file_type = match entry.file_type().await {
2392 Ok(ft) => ft,
2393 Err(_) => continue,
2394 };
2395 if !file_type.is_dir() {
2396 continue;
2397 }
2398
2399 scanned += 1;
2400
2401 let manifest_path = entry.path().join("apps").join("manifest.json");
2402 let contents = match tokio::fs::read(&manifest_path).await {
2403 Ok(c) => c,
2404 Err(_) => continue,
2405 };
2406
2407 let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
2408 Ok(v) => v,
2409 Err(_) => continue,
2410 };
2411
2412 if let Some(pod_name) = entry.file_name().to_str() {
2414 if manifest.get("podOwner").is_none() {
2415 manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
2416 }
2417 }
2418
2419 apps.push(manifest);
2420 }
2421
2422 let count = apps.len();
2423 HttpResponse::Ok()
2424 .content_type("application/json")
2425 .json(serde_json::json!({
2426 "apps": apps,
2427 "serverUrl": server_url,
2428 "count": count,
2429 }))
2430}
2431
2432#[allow(dead_code)]
2445fn is_git_request(path: &str) -> bool {
2446 path.contains("/info/refs")
2447 || path.contains("/git-upload-pack")
2448 || path.contains("/git-receive-pack")
2449}
2450
2451#[allow(dead_code)]
2454fn is_dot_git_path(path: &str) -> bool {
2455 path.contains("/.git/") || path.ends_with("/.git")
2456}
2457
2458#[cfg(feature = "git")]
2459async fn handle_git(
2460 req: HttpRequest,
2461 body: web::Bytes,
2462 state: web::Data<AppState>,
2463) -> HttpResponse {
2464 use solid_pod_rs_git::service::{GitHttpService, GitRequest};
2465
2466 let path = req.uri().path().to_string();
2467
2468 let pod_name = path.trim_start_matches('/').split('/').next().unwrap_or("");
2471 let Some(ref data_root) = state.data_root else {
2472 return HttpResponse::NotImplemented().json(serde_json::json!({
2473 "error": "git requires fs-backend storage",
2474 "reason": "data_root_not_configured"
2475 }));
2476 };
2477 let repo_root = data_root.join(pod_name);
2478 if !repo_root.exists() {
2479 return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
2480 }
2481
2482 let query = req.uri().query().unwrap_or("").to_string();
2483 let host_url = {
2484 let conn = req.connection_info();
2485 Some(format!("{}://{}", conn.scheme(), conn.host()))
2486 };
2487 let headers: Vec<(String, String)> = req
2488 .headers()
2489 .iter()
2490 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
2491 .collect();
2492
2493 let git_req = GitRequest {
2494 method: req.method().as_str().to_string(),
2495 path,
2496 query,
2497 headers,
2498 body: body.into(),
2499 host_url,
2500 };
2501
2502 let service = GitHttpService::new(repo_root);
2503 match service.handle(git_req).await {
2504 Ok(git_resp) => {
2505 let mut builder = HttpResponse::build(
2506 actix_web::http::StatusCode::from_u16(git_resp.status)
2507 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
2508 );
2509 for (k, v) in &git_resp.headers {
2510 builder.insert_header((k.as_str(), v.as_str()));
2511 }
2512 builder.body(git_resp.body)
2513 }
2514 Err(e) => {
2515 let status = e.status_code();
2516 HttpResponse::build(
2517 actix_web::http::StatusCode::from_u16(status)
2518 .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
2519 )
2520 .json(serde_json::json!({"error": e.to_string()}))
2521 }
2522 }
2523}
2524
2525pub fn build_app(
2537 state: AppState,
2538) -> App<
2539 impl actix_web::dev::ServiceFactory<
2540 ServiceRequest,
2541 Config = (),
2542 Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
2543 Error = ActixError,
2544 InitError = (),
2545 >,
2546> {
2547 let body_cap = state.body_cap;
2548 let dotfiles = state.dotfiles.clone();
2549 let allowed_origins = Arc::new(state.allowed_origins.clone());
2550
2551 let mut app = App::new()
2552 .app_data(web::Data::new(state.clone()))
2553 .app_data(web::PayloadConfig::new(body_cap))
2554 .wrap(ErrorLoggingMiddleware)
2559 .wrap(CorsHeaders { allowed_origins })
2560 .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
2564 .wrap(PathTraversalGuard)
2565 .wrap(DotfileGuard::new(dotfiles));
2566
2567 app = app
2573 .route("/.well-known/solid", web::get().to(handle_well_known_solid))
2574 .route(
2575 "/.well-known/webfinger",
2576 web::get().to(handle_well_known_webfinger),
2577 )
2578 .route(
2579 "/.well-known/nodeinfo",
2580 web::get().to(handle_well_known_nodeinfo),
2581 )
2582 .route(
2583 "/.well-known/nodeinfo/2.1",
2584 web::get().to(handle_well_known_nodeinfo_2_1),
2585 );
2586
2587 #[cfg(feature = "did-nostr")]
2588 {
2589 app = app.route(
2590 "/.well-known/did/nostr/{pubkey}.json",
2591 web::get().to(handle_well_known_did_nostr),
2592 );
2593 }
2594
2595 #[cfg(feature = "nip05-endpoint")]
2599 {
2600 app = app.route(
2601 "/.well-known/nostr.json",
2602 web::get().to(handle_well_known_nip05),
2603 );
2604 }
2605
2606 app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
2608
2609 app = app.route("/pay/.info", web::get().to(handle_pay_info));
2611
2612 app = app.route("/proxy", web::get().to(handle_proxy));
2614
2615 app = app.route(
2618 "/_admin/provision/{pubkey}",
2619 web::post().to(handle_admin_provision),
2620 );
2621
2622 app = app
2624 .route("/.pods", web::post().to(handle_create_pod))
2625 .route("/api/accounts/new", web::post().to(handle_create_account))
2626 .route("/pods/check/{name}", web::get().to(handle_pod_check))
2627 .route("/login/password", web::post().to(handle_login_password))
2628 .route(
2629 "/account/password/reset",
2630 web::post().to(handle_password_reset_request),
2631 )
2632 .route(
2633 "/account/password/change",
2634 web::post().to(handle_password_change),
2635 );
2636
2637 app = app
2642 .route(
2643 "/{tail:.*}/.git",
2645 web::route().to(|| async {
2646 HttpResponse::Forbidden()
2647 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
2648 }),
2649 )
2650 .route(
2651 "/{tail:.*}/.git/{rest:.*}",
2652 web::route().to(|| async {
2653 HttpResponse::Forbidden()
2654 .json(serde_json::json!({"error": "direct .git access is forbidden"}))
2655 }),
2656 );
2657
2658 app = app.route(
2662 "/pods/{pk}/_git/{tail:.*}",
2663 web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
2664 );
2665
2666 #[cfg(feature = "git")]
2667 {
2668 app = app
2670 .route("/{tail:.*}/info/refs", web::get().to(handle_git))
2671 .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
2672 .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
2673
2674 app = app
2677 .route(
2678 "/pods/{pubkey}/_git/status",
2679 web::get().to(handle_git_status),
2680 )
2681 .route(
2682 "/pods/{pubkey}/_git/log",
2683 web::get().to(handle_git_log),
2684 )
2685 .route(
2686 "/pods/{pubkey}/_git/diff",
2687 web::get().to(handle_git_diff),
2688 )
2689 .route(
2690 "/pods/{pubkey}/_git/stage",
2691 web::post().to(handle_git_stage),
2692 )
2693 .route(
2694 "/pods/{pubkey}/_git/unstage",
2695 web::post().to(handle_git_unstage),
2696 )
2697 .route(
2698 "/pods/{pubkey}/_git/commit",
2699 web::post().to(handle_git_commit),
2700 )
2701 .route(
2702 "/pods/{pubkey}/_git/branches",
2703 web::get().to(handle_git_branches),
2704 )
2705 .route(
2706 "/pods/{pubkey}/_git/branch",
2707 web::post().to(handle_git_create_branch),
2708 )
2709 .route(
2710 "/pods/{pubkey}/_git/discard",
2711 web::post().to(handle_git_discard),
2712 );
2713 }
2714 #[cfg(not(feature = "git"))]
2715 {
2716 let git_501 = || async {
2720 HttpResponse::NotImplemented()
2721 .json(serde_json::json!({"error": "git feature not enabled in this build"}))
2722 };
2723 app = app
2724 .route("/{tail:.*}/info/refs", web::get().to(git_501))
2725 .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
2726 .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
2727 }
2728
2729 app.route("/{tail:.*}/", web::post().to(handle_post))
2732 .route("/{tail:.*}/", web::put().to(handle_put))
2733 .route("/{tail:.*}", web::get().to(handle_get))
2734 .route("/{tail:.*}", web::head().to(handle_get))
2735 .route("/{tail:.*}", web::put().to(handle_put))
2736 .route("/{tail:.*}", web::patch().to(handle_patch))
2737 .route("/{tail:.*}", web::delete().to(handle_delete))
2738 .route(
2739 "/{tail:.*}",
2740 web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
2741 )
2742 .route(
2743 "/{tail:.*}",
2744 web::method(actix_web::http::Method::OPTIONS).to(handle_options),
2745 )
2746}