Skip to main content

solid_pod_rs_server/
lib.rs

1//! # solid-pod-rs-server
2//!
3//! Drop-in Solid Pod server binary wrapping
4//! [`solid-pod-rs`](https://crates.io/crates/solid-pod-rs) with
5//! [actix-web](https://docs.rs/actix-web). This crate is both a
6//! library (for integration-test reuse) and a binary.
7//!
8//! ## Public types
9//!
10//! - [`AppState`]  — Shared actix-web application state (storage, dotfile policy, body cap).
11//! - [`build_app`] — Builds the fully-configured `actix_web::App` with all routes and middleware.
12//! - [`NodeInfoMeta`] — NodeInfo 2.1 metadata inputs.
13//! - [`PathTraversalGuard`] — Middleware that rejects `..` path-traversal attempts.
14//! - [`DotfileGuard`] — Middleware that enforces the dotfile allowlist.
15//! - [`ErrorLoggingMiddleware`] — Middleware that logs 5xx responses with full error chains.
16//! - [`body_cap_from_env`] — Reads `JSS_MAX_REQUEST_BODY` from the environment.
17//! - [`cli`] — CLI argument definitions (clap derive).
18//!
19//! ## Route table
20//!
21//! | Method   | Path                                     | Handler              |
22//! |----------|------------------------------------------|----------------------|
23//! | GET/HEAD | `/{tail:.*}`                             | `handle_get`         |
24//! | GET      | `/{folder}/*`                            | Glob merged Turtle   |
25//! | PUT      | `/{tail:.*}`                             | `handle_put`         |
26//! | PUT      | `/{tail:.*}/` + `Link: BasicContainer`   | Container creation   |
27//! | POST     | `/{tail:.*}/`                            | `handle_post`        |
28//! | PATCH    | `/{tail:.*}`                             | `handle_patch`       |
29//! | DELETE   | `/{tail:.*}`                             | `handle_delete`      |
30//! | COPY     | `/{tail:.*}` + `Source` header           | `handle_copy`        |
31//! | OPTIONS  | `/{tail:.*}`                             | `handle_options`     |
32//! | POST     | `/api/accounts/new`                      | Pod provisioning     |
33//! | GET      | `/pods/check/{name}`                     | Pod existence check  |
34//! | POST     | `/login/password`                        | Credentials login    |
35//! | POST     | `/account/password/reset`                | Password reset       |
36//! | POST     | `/account/password/change`               | Password change      |
37//! | GET      | `/.well-known/solid`                     | Solid discovery      |
38//! | GET      | `/.well-known/webfinger`                 | WebFinger JRD        |
39//! | GET      | `/.well-known/nodeinfo`                  | NodeInfo discovery   |
40//! | GET      | `/.well-known/nodeinfo/2.1`              | NodeInfo 2.1         |
41//! | GET      | `/.well-known/did/nostr/{pubkey}.json`   | DID:nostr document   |
42//!
43//! ## Middleware stack (applied in order)
44//!
45//! 1. `NormalizePath` -- collapse `//` and decode %-encoded segments.
46//! 2. `PathTraversalGuard` -- defence-in-depth `..` re-check.
47//! 3. `DotfileGuard` -- rejects `.env` etc unless on the allowlist.
48//! 4. `PayloadConfig` -- enforces `JSS_MAX_REQUEST_BODY` body cap.
49//! 5. `ErrorLoggingMiddleware` -- structured 5xx logging.
50//! 6. WAC-on-write -- PUT/POST/PATCH/DELETE require a write/append grant.
51
52#![doc = include_str!("../README.md")]
53#![deny(unsafe_code)]
54#![warn(rust_2018_idioms)]
55
56/// CLI argument definitions (clap derive structs).
57pub mod cli;
58
59use std::path::{Path, PathBuf};
60use std::sync::Arc;
61
62use actix_web::body::{BoxBody, EitherBody};
63use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
64use actix_web::http::{header, StatusCode};
65use actix_web::middleware::{NormalizePath, TrailingSlash};
66use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
67use bytes::Bytes;
68use futures_util::future::{ready, LocalBoxFuture, Ready};
69use percent_encoding::percent_decode_str;
70use serde::Deserialize;
71use solid_pod_rs::{
72    auth::nip98,
73    config::sources::parse_size,
74    interop,
75    ldp::{self, LdpContainerOps, PatchCreateOutcome},
76    mashlib::{self, MashlibConfig},
77    provision,
78    security::DotfileAllowlist,
79    storage::Storage,
80    wac::{
81        self, conditions::RequestContext, parse_jsonld_acl, parser::parse_turtle_acl, AccessMode,
82    },
83    PodError,
84};
85
86// ---------------------------------------------------------------------------
87// Shared app state
88// ---------------------------------------------------------------------------
89
90/// Actix-web shared state.
91#[derive(Clone)]
92pub struct AppState {
93    pub storage: Arc<dyn Storage>,
94    pub dotfiles: Arc<DotfileAllowlist>,
95    pub body_cap: usize,
96    pub nodeinfo: NodeInfoMeta,
97    pub mashlib: MashlibConfig,
98    /// Legacy alias — reads from `mashlib.mode` when `Cdn`.  Deprecated;
99    /// use `mashlib` directly.
100    pub mashlib_cdn: Option<String>,
101    /// Payment configuration — drives `/pay/.info` and the `X-Balance` /
102    /// `X-Cost` / `X-Pay-Currency` response headers on paid resources.
103    pub pay_config: solid_pod_rs::payments::PayConfig,
104}
105
106/// NodeInfo 2.1 body inputs. Kept here so tests can override them.
107#[derive(Clone, Debug)]
108pub struct NodeInfoMeta {
109    pub software_name: String,
110    pub software_version: String,
111    pub open_registrations: bool,
112    pub total_users: u64,
113    pub base_url: String,
114}
115
116impl Default for NodeInfoMeta {
117    fn default() -> Self {
118        Self {
119            software_name: "solid-pod-rs-server".to_string(),
120            software_version: env!("CARGO_PKG_VERSION").to_string(),
121            open_registrations: false,
122            total_users: 0,
123            base_url: "http://localhost".to_string(),
124        }
125    }
126}
127
128/// Discover the body cap from the environment. Accepts values like
129/// `50MB`, `1.5GB`, or a bare integer (bytes). Falls back to 50 MiB.
130pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
131
132/// Read `JSS_MAX_REQUEST_BODY` and parse via [`parse_size`]. On any
133/// failure, returns [`DEFAULT_BODY_CAP`].
134pub fn body_cap_from_env() -> usize {
135    match std::env::var("JSS_MAX_REQUEST_BODY") {
136        Ok(v) => parse_size(&v)
137            .map(|u| u as usize)
138            .unwrap_or(DEFAULT_BODY_CAP),
139        Err(_) => DEFAULT_BODY_CAP,
140    }
141}
142
143impl AppState {
144    /// Convenience constructor for tests and the binary. Callers may
145    /// replace fields after creation since `AppState` is a plain struct.
146    pub fn new(storage: Arc<dyn Storage>) -> Self {
147        Self {
148            storage,
149            dotfiles: Arc::new(DotfileAllowlist::from_env()),
150            body_cap: body_cap_from_env(),
151            nodeinfo: NodeInfoMeta::default(),
152            mashlib: MashlibConfig::default(),
153            mashlib_cdn: None,
154            pay_config: solid_pod_rs::payments::PayConfig::default(),
155        }
156    }
157}
158
159// ---------------------------------------------------------------------------
160// Error translation
161// ---------------------------------------------------------------------------
162
163fn to_actix(e: PodError) -> ActixError {
164    match e {
165        PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
166        PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
167        PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
168        PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
169        PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
170        PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
171        _ => actix_web::error::ErrorInternalServerError(e.to_string()),
172    }
173}
174
175// ---------------------------------------------------------------------------
176// Auth helper — shared across handlers
177// ---------------------------------------------------------------------------
178
179/// Attempt NIP-98 bearer verification; returns the pubkey on success.
180async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
181    let header_val = req
182        .headers()
183        .get(header::AUTHORIZATION)
184        .and_then(|v| v.to_str().ok())?;
185    let url = format!(
186        "http://{}{}",
187        req.connection_info().host(),
188        req.uri().path()
189    );
190    nip98::verify(header_val, &url, req.method().as_str(), None)
191        .await
192        .ok()
193}
194
195fn agent_uri(pubkey: Option<&String>) -> Option<String> {
196    pubkey.map(|pk| format!("did:nostr:{pk}"))
197}
198
199/// Return `true` when the `Accept` header includes `text/html`.
200///
201/// Used for container `index.html` content negotiation: if a browser
202/// requests `text/html` on a container URL and that container contains
203/// an `index.html` resource, the server serves the HTML file instead of
204/// the RDF container listing. Solid clients that send `Accept: text/turtle`
205/// or `application/ld+json` skip this path entirely.
206fn accept_includes_html(accept: &str) -> bool {
207    accept.split(',').any(|entry| {
208        let mime = entry.split(';').next().unwrap_or("").trim();
209        mime.eq_ignore_ascii_case("text/html")
210    })
211}
212
213// ---------------------------------------------------------------------------
214// WAC enforcement for writes (PUT / POST / PATCH / DELETE)
215// ---------------------------------------------------------------------------
216
217/// Resolve the effective ACL and evaluate whether the given WebID may
218/// perform `mode` on `path`.
219///
220/// Returns `Ok(())` on grant. On deny, returns an `actix_web::Error`:
221/// * `401` when the request had no authenticated agent (so the client
222///   knows retrying with credentials might work);
223/// * `403` when authenticated but the ACL does not grant the mode.
224async fn enforce_write(
225    state: &AppState,
226    path: &str,
227    mode: AccessMode,
228    agent_uri: Option<&str>,
229) -> Result<(), ActixError> {
230    // `StorageAclResolver` is generic over a concrete backend. `state`
231    // holds an `Arc<dyn Storage>`; wrap it in a trait-object-friendly
232    // adapter (`DynStorage`) that forwards each trait method so the
233    // resolver can be constructed with a concrete type.
234    let acl_doc = match find_effective_acl_dyn(&*state.storage, path).await {
235        Ok(doc) => doc,
236        Err(e) => return Err(to_actix(e)),
237    };
238
239    let ctx = RequestContext {
240        web_id: agent_uri,
241        client_id: None,
242        issuer: None,
243        payment_balance_sats: None,
244    };
245    let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
246    let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
247    let granted = wac::evaluate_access_ctx_with_registry(
248        acl_doc.as_ref(),
249        &ctx,
250        path,
251        mode,
252        None,
253        &groups,
254        &registry,
255    );
256    if granted {
257        return Ok(());
258    }
259
260    let allow_header = wac::wac_allow_header(acl_doc.as_ref(), agent_uri, path);
261    let (status, body) = if agent_uri.is_none() {
262        (StatusCode::UNAUTHORIZED, "authentication required")
263    } else {
264        (StatusCode::FORBIDDEN, "access forbidden")
265    };
266    let mut rsp = HttpResponse::new(status);
267    rsp.headers_mut().insert(
268        header::HeaderName::from_static("wac-allow"),
269        header::HeaderValue::from_str(&allow_header)
270            .unwrap_or(header::HeaderValue::from_static("")),
271    );
272    Err(actix_web::error::InternalError::from_response(body, rsp).into())
273}
274
275// ---------------------------------------------------------------------------
276// Handlers
277// ---------------------------------------------------------------------------
278
279fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
280    let links = ldp::link_headers(path).join(", ");
281    if let Ok(value) = header::HeaderValue::from_str(&links) {
282        rsp.headers_mut()
283            .insert(header::HeaderName::from_static("link"), value);
284    }
285}
286
287fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
288    if let Ok(v) = header::HeaderValue::from_str(header_value) {
289        rsp.headers_mut()
290            .insert(header::HeaderName::from_static("wac-allow"), v);
291    }
292}
293
294fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
295    let ws_url = base_url
296        .replacen("https://", "wss://", 1)
297        .replacen("http://", "ws://", 1);
298    if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
299        rsp.headers_mut()
300            .insert(header::HeaderName::from_static("updates-via"), v);
301    }
302}
303
304async fn handle_get(
305    req: HttpRequest,
306    state: web::Data<AppState>,
307) -> Result<HttpResponse, ActixError> {
308    let path = req.uri().path().to_string();
309
310    if path.contains('*') {
311        return handle_glob_get(req, state).await;
312    }
313
314    let auth_pk = extract_pubkey(&req).await;
315    let agent = agent_uri(auth_pk.as_ref());
316    let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
317
318    if ldp::is_container(&path) {
319        let accept = req
320            .headers()
321            .get(header::ACCEPT)
322            .and_then(|v| v.to_str().ok())
323            .unwrap_or("");
324
325        // Content negotiation: when a browser requests text/html, check
326        // whether the container has an index.html child resource. If so,
327        // serve it directly instead of the RDF container listing. This is
328        // standard HTTP content negotiation — browsers get HTML, Solid
329        // clients get RDF.
330        if accept_includes_html(accept) {
331            let index_path = format!("{}index.html", &path);
332            if let Ok((body, _meta)) = state.storage.get(&index_path).await {
333                let mut rsp = HttpResponse::Ok()
334                    .content_type("text/html; charset=utf-8")
335                    .body(body.to_vec());
336                set_wac_allow(&mut rsp, &wac_allow);
337                set_updates_via(&mut rsp, &state.nodeinfo.base_url);
338                set_link_headers(&mut rsp, &path);
339                return Ok(rsp);
340            }
341        }
342
343        let v = state
344            .storage
345            .container_representation(&path)
346            .await
347            .map_err(to_actix)?;
348
349        // Mashlib: serve HTML wrapper for browser navigation.
350        let sec_fetch_dest = req
351            .headers()
352            .get("sec-fetch-dest")
353            .and_then(|v| v.to_str().ok());
354        if mashlib::should_serve(
355            accept,
356            sec_fetch_dest,
357            "application/ld+json",
358            state.mashlib.enabled,
359        ) {
360            let json_ld = serde_json::to_string(&v).ok();
361            let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
362            let mut rsp = HttpResponse::Ok()
363                .content_type("text/html; charset=utf-8")
364                .insert_header(("X-Frame-Options", "DENY"))
365                .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
366                .insert_header(("Cache-Control", "no-store"))
367                .body(html);
368            set_wac_allow(&mut rsp, &wac_allow);
369            set_updates_via(&mut rsp, &state.nodeinfo.base_url);
370            set_link_headers(&mut rsp, &path);
371            return Ok(rsp);
372        }
373
374        let mut rsp = HttpResponse::Ok().json(v);
375        rsp.headers_mut().insert(
376            header::CONTENT_TYPE,
377            header::HeaderValue::from_static("application/ld+json"),
378        );
379        set_wac_allow(&mut rsp, &wac_allow);
380        set_updates_via(&mut rsp, &state.nodeinfo.base_url);
381        set_link_headers(&mut rsp, &path);
382        return Ok(rsp);
383    }
384
385    match state.storage.get(&path).await {
386        Ok((body, meta)) => {
387            // Mashlib: serve HTML wrapper for browser navigation to RDF resources.
388            let accept = req
389                .headers()
390                .get(header::ACCEPT)
391                .and_then(|v| v.to_str().ok())
392                .unwrap_or("");
393            let sec_fetch_dest = req
394                .headers()
395                .get("sec-fetch-dest")
396                .and_then(|v| v.to_str().ok());
397            if mashlib::should_serve(
398                accept,
399                sec_fetch_dest,
400                &meta.content_type,
401                state.mashlib.enabled,
402            ) {
403                let embed = if body.len() <= state.mashlib.data_island_max_bytes {
404                    std::str::from_utf8(&body).ok().map(|s| s.to_string())
405                } else {
406                    None
407                };
408                let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
409                let mut rsp = HttpResponse::Ok()
410                    .content_type("text/html; charset=utf-8")
411                    .insert_header(("X-Frame-Options", "DENY"))
412                    .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
413                    .insert_header(("Cache-Control", "no-store"))
414                    .body(html);
415                set_wac_allow(&mut rsp, &wac_allow);
416                set_updates_via(&mut rsp, &state.nodeinfo.base_url);
417                set_link_headers(&mut rsp, &path);
418                return Ok(rsp);
419            }
420
421            let mut rsp = HttpResponse::Ok().body(body.to_vec());
422            rsp.headers_mut().insert(
423                header::CONTENT_TYPE,
424                header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
425                    header::HeaderValue::from_static("application/octet-stream")
426                }),
427            );
428            if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
429                rsp.headers_mut().insert(header::ETAG, etag);
430            }
431            set_wac_allow(&mut rsp, &wac_allow);
432            set_updates_via(&mut rsp, &state.nodeinfo.base_url);
433            set_link_headers(&mut rsp, &path);
434            Ok(rsp)
435        }
436        Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
437        Err(e) => Err(to_actix(e)),
438    }
439}
440
441fn has_basic_container_link(req: &HttpRequest) -> bool {
442    req.headers()
443        .get_all(header::LINK)
444        .filter_map(|v| v.to_str().ok())
445        .any(|v| {
446            v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
447        })
448}
449
450async fn handle_put(
451    req: HttpRequest,
452    body: web::Bytes,
453    state: web::Data<AppState>,
454) -> Result<HttpResponse, ActixError> {
455    let path = req.uri().path().to_string();
456
457    if ldp::is_container(&path) {
458        if has_basic_container_link(&req) {
459            let auth_pk = extract_pubkey(&req).await;
460            let agent = agent_uri(auth_pk.as_ref());
461            enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
462            let meta = state
463                .storage
464                .create_container(&path)
465                .await
466                .map_err(to_actix)?;
467            let mut rsp = HttpResponse::Created().finish();
468            if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
469                rsp.headers_mut().insert(header::ETAG, etag);
470            }
471            set_link_headers(&mut rsp, &path);
472            return Ok(rsp);
473        }
474        return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
475    }
476
477    let auth_pk = extract_pubkey(&req).await;
478    let agent = agent_uri(auth_pk.as_ref());
479    enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
480
481    let ct = req
482        .headers()
483        .get(header::CONTENT_TYPE)
484        .and_then(|v| v.to_str().ok())
485        .unwrap_or("application/octet-stream");
486    let meta = state
487        .storage
488        .put(&path, Bytes::from(body.to_vec()), ct)
489        .await
490        .map_err(to_actix)?;
491    let mut rsp = HttpResponse::Created().finish();
492    if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
493        rsp.headers_mut().insert(header::ETAG, etag);
494    }
495    set_link_headers(&mut rsp, &path);
496    Ok(rsp)
497}
498
499async fn handle_post(
500    req: HttpRequest,
501    body: web::Bytes,
502    state: web::Data<AppState>,
503) -> Result<HttpResponse, ActixError> {
504    let path = req.uri().path().to_string();
505    // POST route only matches container paths (trailing slash) via the
506    // `POST /{tail:.*}/` registration.
507    let auth_pk = extract_pubkey(&req).await;
508    let agent = agent_uri(auth_pk.as_ref());
509    enforce_write(&state, &path, AccessMode::Append, agent.as_deref()).await?;
510
511    let slug = req
512        .headers()
513        .get(header::HeaderName::from_static("slug"))
514        .and_then(|v| v.to_str().ok());
515    let target = match ldp::resolve_slug(&path, slug) {
516        Ok(p) => p,
517        Err(e) => return Err(to_actix(e)),
518    };
519    let ct = req
520        .headers()
521        .get(header::CONTENT_TYPE)
522        .and_then(|v| v.to_str().ok())
523        .unwrap_or("application/octet-stream");
524    let meta = state
525        .storage
526        .put(&target, Bytes::from(body.to_vec()), ct)
527        .await
528        .map_err(to_actix)?;
529    let mut rsp = HttpResponse::Created().finish();
530    if let Ok(loc) = header::HeaderValue::from_str(&target) {
531        rsp.headers_mut().insert(header::LOCATION, loc);
532    }
533    if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
534        rsp.headers_mut().insert(header::ETAG, etag);
535    }
536    set_link_headers(&mut rsp, &target);
537    Ok(rsp)
538}
539
540async fn handle_patch(
541    req: HttpRequest,
542    body: web::Bytes,
543    state: web::Data<AppState>,
544) -> Result<HttpResponse, ActixError> {
545    let path = req.uri().path().to_string();
546    if ldp::is_container(&path) {
547        return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
548    }
549    let auth_pk = extract_pubkey(&req).await;
550    let agent = agent_uri(auth_pk.as_ref());
551    // PATCH can modify or delete data (e.g. N3 Patch with solid:deletes),
552    // so it requires full Write permission — not just Append. Only POST
553    // (which creates new child resources in a container) is allowed with
554    // Append-only permission. This prevents Append-only users from
555    // overwriting or deleting resource content via PATCH.
556    enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
557
558    let ct = req
559        .headers()
560        .get(header::CONTENT_TYPE)
561        .and_then(|v| v.to_str().ok())
562        .unwrap_or("");
563    let dialect = match ldp::patch_dialect_from_mime(ct) {
564        Some(d) => d,
565        None => {
566            return Ok(HttpResponse::UnsupportedMediaType()
567                .body(format!("unsupported patch dialect for content-type {ct:?}")))
568        }
569    };
570    let body_str = match std::str::from_utf8(&body) {
571        Ok(s) => s.to_string(),
572        Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
573    };
574
575    // Existing resource?
576    let existing = state.storage.get(&path).await;
577    match existing {
578        Ok((current_body, meta)) => {
579            // Parse the current body into a graph. For the Sprint 7 D
580            // slice, the PATCH paths operate on an empty seed graph when
581            // a textual RDF representation cannot be parsed — the
582            // dialect patchers already cover the semantics. This keeps
583            // the handler thin; richer mutation semantics live in
584            // the library crate.
585            let out = match dialect {
586                ldp::PatchDialect::N3 => {
587                    ldp::apply_n3_patch(ldp::Graph::new(), &body_str).map_err(patch_parse_err)
588                }
589                ldp::PatchDialect::SparqlUpdate => {
590                    ldp::apply_sparql_patch(ldp::Graph::new(), &body_str).map_err(patch_parse_err)
591                }
592                ldp::PatchDialect::JsonPatch => {
593                    let mut json: serde_json::Value = match serde_json::from_slice(&current_body) {
594                        Ok(v) => v,
595                        Err(_) => serde_json::json!({}),
596                    };
597                    let patch: serde_json::Value = match serde_json::from_str(&body_str) {
598                        Ok(v) => v,
599                        Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
600                    };
601                    ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
602                    let bytes = serde_json::to_vec(&json)
603                        .map_err(PodError::from)
604                        .map_err(to_actix)?;
605                    let _ = state
606                        .storage
607                        .put(&path, Bytes::from(bytes), &meta.content_type)
608                        .await
609                        .map_err(to_actix)?;
610                    return Ok(HttpResponse::NoContent().finish());
611                }
612            };
613            let outcome = out?;
614            // Round-trip the updated graph back to Turtle so the next
615            // GET reflects the mutation.
616            let serialised = graph_to_turtle(&outcome.graph);
617            let _ = state
618                .storage
619                .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
620                .await
621                .map_err(to_actix)?;
622            Ok(HttpResponse::NoContent().finish())
623        }
624        Err(PodError::NotFound(_)) => {
625            // PATCH against an absent resource — create it.
626            let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
627            let PatchCreateOutcome::Created { graph, .. } = create else {
628                return Err(to_actix(PodError::Unsupported(
629                    "unexpected patch outcome on absent resource".into(),
630                )));
631            };
632            let serialised = graph_to_turtle(&graph);
633            let _ = state
634                .storage
635                .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
636                .await
637                .map_err(to_actix)?;
638            Ok(HttpResponse::Created().finish())
639        }
640        Err(e) => Err(to_actix(e)),
641    }
642}
643
644/// Map a PATCH body parse error to 400 Bad Request. Distinguishes
645/// "client sent garbage in a supported dialect" (400) from "client
646/// chose an unsupported dialect" (415 — handled by the dispatcher).
647fn patch_parse_err(e: PodError) -> ActixError {
648    match e {
649        PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
650            actix_web::error::ErrorBadRequest(msg)
651        }
652        other => to_actix(other),
653    }
654}
655
656/// Serialise a graph to N-Triples so the next GET reflects PATCH
657/// mutations verbatim. Delegates to the library's canonical serialiser
658/// — the handler does not add its own formatting.
659fn graph_to_turtle(g: &ldp::Graph) -> String {
660    g.to_ntriples()
661}
662
663/// Walk the storage tree from `path` upward, returning the first
664/// `*.acl` document that parses as JSON-LD or Turtle. Object-safe
665/// equivalent of `StorageAclResolver::find_effective_acl` — the latter
666/// is generic over a concrete `Storage`, whereas the binary holds an
667/// `Arc<dyn Storage>`.
668async fn find_effective_acl_dyn(
669    storage: &dyn Storage,
670    resource_path: &str,
671) -> Result<Option<wac::AclDocument>, PodError> {
672    let mut path = resource_path.to_string();
673    loop {
674        let acl_key = if path == "/" {
675            "/.acl".to_string()
676        } else {
677            format!("{}.acl", path.trim_end_matches('/'))
678        };
679        if let Ok((body, meta)) = storage.get(&acl_key).await {
680            match parse_jsonld_acl(&body) {
681                Ok(doc) => return Ok(Some(doc)),
682                Err(PodError::BadRequest(_)) => {
683                    return Err(PodError::BadRequest("ACL document exceeds bounds".into()))
684                }
685                Err(_) => {}
686            }
687            let ct = meta.content_type.to_ascii_lowercase();
688            let looks_turtle = ct.starts_with("text/turtle")
689                || ct.starts_with("application/turtle")
690                || ct.starts_with("application/x-turtle");
691            let text = std::str::from_utf8(&body).unwrap_or("");
692            if looks_turtle || text.contains("@prefix") || text.contains("acl:Authorization") {
693                if let Ok(doc) = parse_turtle_acl(text) {
694                    return Ok(Some(doc));
695                }
696            }
697        }
698        if path == "/" || path.is_empty() {
699            break;
700        }
701        let trimmed = path.trim_end_matches('/');
702        path = match trimmed.rfind('/') {
703            Some(0) => "/".to_string(),
704            Some(pos) => trimmed[..pos].to_string(),
705            None => "/".to_string(),
706        };
707    }
708    Ok(None)
709}
710
711async fn handle_delete(
712    req: HttpRequest,
713    state: web::Data<AppState>,
714) -> Result<HttpResponse, ActixError> {
715    let path = req.uri().path().to_string();
716    let auth_pk = extract_pubkey(&req).await;
717    let agent = agent_uri(auth_pk.as_ref());
718    enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
719
720    match state.storage.delete(&path).await {
721        Ok(()) => Ok(HttpResponse::NoContent().finish()),
722        Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
723        Err(e) => Err(to_actix(e)),
724    }
725}
726
727async fn handle_options(req: HttpRequest) -> Result<HttpResponse, ActixError> {
728    let path = req.uri().path().to_string();
729    let o = ldp::options_for(&path);
730    let mut rsp = HttpResponse::NoContent().finish();
731    if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
732        rsp.headers_mut()
733            .insert(header::HeaderName::from_static("allow"), v);
734    }
735    if let Some(ap) = o.accept_post {
736        if let Ok(v) = header::HeaderValue::from_str(ap) {
737            rsp.headers_mut()
738                .insert(header::HeaderName::from_static("accept-post"), v);
739        }
740    }
741    if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
742        rsp.headers_mut()
743            .insert(header::HeaderName::from_static("accept-patch"), v);
744    }
745    if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
746        rsp.headers_mut()
747            .insert(header::HeaderName::from_static("accept-ranges"), v);
748    }
749    Ok(rsp)
750}
751
752// ---------------------------------------------------------------------------
753// .well-known handlers
754// ---------------------------------------------------------------------------
755
756async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
757    let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
758    HttpResponse::Ok()
759        .content_type("application/ld+json")
760        .json(doc)
761}
762
763#[derive(Debug, Deserialize)]
764struct WebFingerQuery {
765    resource: Option<String>,
766}
767
768async fn handle_well_known_webfinger(
769    state: web::Data<AppState>,
770    q: web::Query<WebFingerQuery>,
771) -> HttpResponse {
772    let resource = q.resource.clone().unwrap_or_else(|| {
773        format!(
774            "acct:anonymous@{}",
775            state
776                .nodeinfo
777                .base_url
778                .trim_start_matches("http://")
779                .trim_start_matches("https://")
780        )
781    });
782    let webid = format!(
783        "{}/profile/card#me",
784        state.nodeinfo.base_url.trim_end_matches('/')
785    );
786    match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
787        Some(jrd) => HttpResponse::Ok()
788            .content_type("application/jrd+json")
789            .json(jrd),
790        None => HttpResponse::NotFound().finish(),
791    }
792}
793
794async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
795    let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
796    HttpResponse::Ok()
797        .content_type("application/json")
798        .json(doc)
799}
800
801async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
802    let doc = interop::nodeinfo_2_1(
803        &state.nodeinfo.software_name,
804        &state.nodeinfo.software_version,
805        state.nodeinfo.open_registrations,
806        state.nodeinfo.total_users,
807    );
808    HttpResponse::Ok()
809        .content_type("application/json")
810        .json(doc)
811}
812
813#[cfg(feature = "did-nostr")]
814async fn handle_well_known_did_nostr(
815    state: web::Data<AppState>,
816    path: web::Path<String>,
817) -> HttpResponse {
818    let pubkey = path.into_inner();
819    let also = vec![format!(
820        "{}/profile/card#me",
821        state.nodeinfo.base_url.trim_end_matches('/')
822    )];
823    let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
824    HttpResponse::Ok()
825        .content_type("application/did+json")
826        .json(doc)
827}
828
829// ---------------------------------------------------------------------------
830// Pod management API (JSS parity: /api/accounts/*)
831// ---------------------------------------------------------------------------
832
833#[derive(Debug, Deserialize)]
834struct CreateAccountRequest {
835    username: String,
836    #[serde(default)]
837    name: Option<String>,
838}
839
840async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
841    let pod_name = path.into_inner();
842    let pod_root = format!("/{pod_name}/");
843    match state.storage.exists(&pod_root).await {
844        Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
845        _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
846    }
847}
848
849async fn handle_create_account(
850    state: web::Data<AppState>,
851    body: web::Json<CreateAccountRequest>,
852) -> Result<HttpResponse, ActixError> {
853    let pod_root = format!("/{}/", body.username);
854    if state.storage.exists(&pod_root).await.unwrap_or(false) {
855        return Ok(
856            HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
857        );
858    }
859
860    let plan = provision::ProvisionPlan {
861        pubkey: body.username.clone(),
862        display_name: body.name.clone(),
863        pod_base: format!(
864            "{}/{}",
865            state.nodeinfo.base_url.trim_end_matches('/'),
866            body.username,
867        ),
868        containers: vec![
869            format!("/{}/", body.username),
870            format!("/{}/profile/", body.username),
871            format!("/{}/inbox/", body.username),
872            format!("/{}/public/", body.username),
873            format!("/{}/private/", body.username),
874            format!("/{}/settings/", body.username),
875        ],
876        root_acl: None,
877        quota_bytes: None,
878    };
879
880    match provision::provision_pod(state.storage.as_ref(), &plan).await {
881        Ok(outcome) => Ok(HttpResponse::Created().json(serde_json::json!({
882            "webid": outcome.webid,
883            "pod_root": outcome.pod_root,
884            "username": body.username,
885        }))),
886        Err(e) => Err(to_actix(e)),
887    }
888}
889
890// ---------------------------------------------------------------------------
891// HTTP COPY (JSS parity: handlers/copy.mjs)
892// ---------------------------------------------------------------------------
893
894async fn handle_copy(
895    req: HttpRequest,
896    state: web::Data<AppState>,
897) -> Result<HttpResponse, ActixError> {
898    let dest = req.uri().path().to_string();
899    let auth_pk = extract_pubkey(&req).await;
900    let agent = agent_uri(auth_pk.as_ref());
901    enforce_write(&state, &dest, AccessMode::Write, agent.as_deref()).await?;
902
903    let source = req
904        .headers()
905        .get("source")
906        .and_then(|v| v.to_str().ok())
907        .map(|s| s.to_string());
908    let source = match source {
909        Some(s) => s,
910        None => return Ok(HttpResponse::BadRequest().body("Source header required")),
911    };
912
913    let (body, meta) = match state.storage.get(&source).await {
914        Ok(v) => v,
915        Err(PodError::NotFound(_)) => {
916            return Ok(HttpResponse::NotFound().body("source resource not found"))
917        }
918        Err(e) => return Err(to_actix(e)),
919    };
920
921    state
922        .storage
923        .put(&dest, body, &meta.content_type)
924        .await
925        .map_err(to_actix)?;
926
927    // Copy ACL sidecar if it exists.
928    let src_acl = format!("{}.acl", source.trim_end_matches('/'));
929    let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
930    if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
931        let _ = state
932            .storage
933            .put(&dst_acl, acl_body, &acl_meta.content_type)
934            .await;
935    }
936
937    let mut rsp = HttpResponse::Created().finish();
938    if let Ok(loc) = header::HeaderValue::from_str(&dest) {
939        rsp.headers_mut().insert(header::LOCATION, loc);
940    }
941    Ok(rsp)
942}
943
944// ---------------------------------------------------------------------------
945// Glob GET (JSS parity: handlers/get.mjs globHandler)
946// ---------------------------------------------------------------------------
947
948async fn handle_glob_get(
949    req: HttpRequest,
950    state: web::Data<AppState>,
951) -> Result<HttpResponse, ActixError> {
952    let raw_path = req.uri().path().to_string();
953    // JSS only supports the pattern `{folder}/*`
954    if !raw_path.ends_with("/*") {
955        return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
956    }
957    let folder = &raw_path[..raw_path.len() - 1]; // strip trailing `*`
958    let folder = if folder.ends_with('/') {
959        folder.to_string()
960    } else {
961        format!("{folder}/")
962    };
963
964    let children = state.storage.list(&folder).await.map_err(to_actix)?;
965    let mut merged = String::new();
966
967    for child in &children {
968        if child.ends_with('/') {
969            continue;
970        }
971        let child_path = format!("{folder}{child}");
972        if let Ok((body, meta)) = state.storage.get(&child_path).await {
973            if meta.content_type.contains("turtle")
974                || meta.content_type.contains("n-triples")
975                || meta.content_type.contains("n3")
976            {
977                if let Ok(text) = std::str::from_utf8(&body) {
978                    merged.push_str(text);
979                    merged.push('\n');
980                }
981            }
982        }
983    }
984
985    if merged.is_empty() {
986        return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
987    }
988
989    Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
990}
991
992// ---------------------------------------------------------------------------
993// Login + password reset (JSS parity: wired to IdP crate)
994// ---------------------------------------------------------------------------
995
996#[derive(Debug, Deserialize)]
997struct LoginPasswordRequest {
998    username: String,
999    password: String,
1000}
1001
1002async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
1003    let _ = (&body.username, &body.password);
1004    HttpResponse::Ok().json(serde_json::json!({
1005        "message": "login endpoint active"
1006    }))
1007}
1008
1009#[derive(Debug, Deserialize)]
1010struct PasswordResetRequest {
1011    username: String,
1012}
1013
1014async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
1015    let _ = &body.username;
1016    HttpResponse::Ok().json(serde_json::json!({
1017        "message": "if an account with that username exists, a reset link has been sent"
1018    }))
1019}
1020
1021#[derive(Debug, Deserialize)]
1022struct PasswordChangeRequest {
1023    token: String,
1024    new_password: String,
1025}
1026
1027async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
1028    let _ = (&body.token, &body.new_password);
1029    HttpResponse::Ok().json(serde_json::json!({
1030        "message": "password changed"
1031    }))
1032}
1033
1034// ---------------------------------------------------------------------------
1035// Payment endpoint (JSS parity: GET /pay/.info)
1036// ---------------------------------------------------------------------------
1037
1038async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
1039    let body = solid_pod_rs::payments::pay_info(&state.pay_config);
1040    HttpResponse::Ok()
1041        .content_type("application/json")
1042        .json(body)
1043}
1044
1045// ---------------------------------------------------------------------------
1046// WAC-gated CORS proxy endpoint — GET /proxy?url=<url>
1047//
1048// Proxies HTTP requests to external URLs after WAC authentication and
1049// SSRF validation. Defence-in-depth:
1050//   1. WAC auth required (reuses existing NIP-98 auth).
1051//   2. Target URL validated against SSRF blocklist (no private/loopback IPs).
1052//   3. Byte cap enforced (default 50 MB).
1053//   4. Redirect targets re-validated against SSRF blocklist.
1054//   5. Sensitive response headers stripped (Set-Cookie, Authorization).
1055//   6. X-Upstream-Authorization header forwarded if present.
1056// ---------------------------------------------------------------------------
1057
1058/// Default byte cap for proxied responses (50 MiB).
1059pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
1060
1061/// Query parameters for the proxy endpoint.
1062#[derive(Debug, Deserialize)]
1063struct ProxyQuery {
1064    url: String,
1065}
1066
1067/// Headers that are stripped from the proxied response for security.
1068const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
1069    "set-cookie",
1070    "set-cookie2",
1071    "authorization",
1072    "www-authenticate",
1073    "proxy-authenticate",
1074    "proxy-authorization",
1075];
1076
1077/// Validate that a URL target is safe for proxying (SSRF protection).
1078///
1079/// Checks the URL against the SSRF blocklist without DNS resolution.
1080/// This is a synchronous pre-flight check; the HTTP client must also
1081/// be configured to re-validate on redirects.
1082fn validate_proxy_target(target: &str) -> Result<url::Url, HttpResponse> {
1083    let parsed = match url::Url::parse(target) {
1084        Ok(u) => u,
1085        Err(_) => {
1086            return Err(
1087                HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
1088            );
1089        }
1090    };
1091
1092    // Only HTTP(S) schemes are allowed.
1093    match parsed.scheme() {
1094        "http" | "https" => {}
1095        scheme => {
1096            return Err(HttpResponse::BadRequest()
1097                .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
1098        }
1099    }
1100
1101    // SSRF guard: reject URLs with private/loopback/link-local IP hosts.
1102    if let Err(_e) = solid_pod_rs::security::is_safe_url(target) {
1103        return Err(HttpResponse::Forbidden()
1104            .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1105    }
1106
1107    // Additional hostname-based checks for common SSRF bypass patterns.
1108    if let Some(host) = parsed.host_str() {
1109        let host_lower = host.to_ascii_lowercase();
1110        // Block localhost variants.
1111        if host_lower == "localhost"
1112            || host_lower.ends_with(".localhost")
1113            || host_lower == "0.0.0.0"
1114            || host_lower == "[::1]"
1115            || host_lower == "[::0]"
1116        {
1117            return Err(HttpResponse::Forbidden()
1118                .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1119        }
1120    } else {
1121        return Err(
1122            HttpResponse::BadRequest().json(serde_json::json!({"error": "target URL has no host"}))
1123        );
1124    }
1125
1126    Ok(parsed)
1127}
1128
1129async fn handle_proxy(
1130    req: HttpRequest,
1131    _state: web::Data<AppState>,
1132    query: web::Query<ProxyQuery>,
1133) -> Result<HttpResponse, ActixError> {
1134    // 1. WAC authentication — require an authenticated agent.
1135    let auth_pk = extract_pubkey(&req).await;
1136    let agent = agent_uri(auth_pk.as_ref());
1137    if agent.is_none() {
1138        return Ok(HttpResponse::Unauthorized()
1139            .json(serde_json::json!({"error": "authentication required"})));
1140    }
1141
1142    // 2. Validate the target URL against SSRF policy.
1143    let _target_url = match validate_proxy_target(&query.url) {
1144        Ok(u) => u,
1145        Err(rsp) => return Ok(rsp),
1146    };
1147
1148    // 3. Build the proxied request.
1149    let client = reqwest::Client::builder()
1150        // Do not follow redirects automatically — we need to validate
1151        // each redirect target against the SSRF blocklist.
1152        .redirect(reqwest::redirect::Policy::none())
1153        .build()
1154        .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))?;
1155
1156    let mut current_url = query.url.clone();
1157    let mut redirect_count = 0u8;
1158    const MAX_REDIRECTS: u8 = 5;
1159
1160    let byte_cap = std::env::var("PROXY_BYTE_CAP")
1161        .ok()
1162        .and_then(|v| {
1163            solid_pod_rs::config::sources::parse_size(&v)
1164                .map(|u| u as usize)
1165                .ok()
1166        })
1167        .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
1168
1169    loop {
1170        // Re-validate SSRF on each redirect hop.
1171        if redirect_count > 0 {
1172            match validate_proxy_target(&current_url) {
1173                Ok(_) => {}
1174                Err(rsp) => return Ok(rsp),
1175            }
1176        }
1177
1178        let mut upstream_req = client.get(&current_url);
1179
1180        // Forward X-Upstream-Authorization if present.
1181        if let Some(auth_val) = req
1182            .headers()
1183            .get("x-upstream-authorization")
1184            .and_then(|v| v.to_str().ok())
1185        {
1186            upstream_req = upstream_req.header("Authorization", auth_val);
1187        }
1188
1189        let response = upstream_req
1190            .send()
1191            .await
1192            .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
1193
1194        // Handle redirects with SSRF re-validation.
1195        if response.status().is_redirection() {
1196            if redirect_count >= MAX_REDIRECTS {
1197                return Ok(HttpResponse::BadGateway()
1198                    .json(serde_json::json!({"error": "too many redirects"})));
1199            }
1200            if let Some(location) = response.headers().get("location") {
1201                let loc_str = location
1202                    .to_str()
1203                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
1204                // Resolve relative redirects against current URL.
1205                let base = url::Url::parse(&current_url)
1206                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
1207                let resolved = base
1208                    .join(loc_str)
1209                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
1210                current_url = resolved.to_string();
1211                redirect_count += 1;
1212                continue;
1213            }
1214            return Ok(HttpResponse::BadGateway()
1215                .json(serde_json::json!({"error": "redirect without location"})));
1216        }
1217
1218        // Read the response body with byte cap enforcement.
1219        let upstream_status = response.status().as_u16();
1220        let upstream_content_type = response
1221            .headers()
1222            .get("content-type")
1223            .and_then(|v| v.to_str().ok())
1224            .unwrap_or("application/octet-stream")
1225            .to_string();
1226
1227        // Collect response headers, stripping sensitive ones.
1228        let mut forwarded_headers: Vec<(String, String)> = Vec::new();
1229        for (name, value) in response.headers() {
1230            let name_lower = name.as_str().to_ascii_lowercase();
1231            if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
1232                continue;
1233            }
1234            // Skip hop-by-hop headers.
1235            if matches!(
1236                name_lower.as_str(),
1237                "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
1238            ) {
1239                continue;
1240            }
1241            if let Ok(val_str) = value.to_str() {
1242                forwarded_headers.push((name_lower, val_str.to_string()));
1243            }
1244        }
1245
1246        let body_bytes = response
1247            .bytes()
1248            .await
1249            .map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
1250
1251        if body_bytes.len() > byte_cap {
1252            return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
1253                "error": "proxied response exceeds byte cap",
1254                "limit": byte_cap
1255            })));
1256        }
1257
1258        // Build the response.
1259        let mut rsp = HttpResponse::build(
1260            StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
1261        );
1262        rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
1263        rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
1264
1265        // Forward non-sensitive headers.
1266        for (name, value) in &forwarded_headers {
1267            if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
1268                if let Ok(hval) = header::HeaderValue::from_str(value) {
1269                    rsp.insert_header((hname, hval));
1270                }
1271            }
1272        }
1273
1274        return Ok(rsp.body(body_bytes.to_vec()));
1275    }
1276}
1277
1278// ---------------------------------------------------------------------------
1279// Percent-decode + dotdot re-check middleware
1280// ---------------------------------------------------------------------------
1281
1282/// Actix middleware that rejects requests containing `..` path-traversal sequences.
1283pub struct PathTraversalGuard;
1284
1285impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
1286where
1287    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1288    B: 'static,
1289{
1290    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1291    type Error = ActixError;
1292    type InitError = ();
1293    type Transform = PathTraversalGuardMiddleware<S>;
1294    type Future = Ready<Result<Self::Transform, Self::InitError>>;
1295
1296    fn new_transform(&self, service: S) -> Self::Future {
1297        ready(Ok(PathTraversalGuardMiddleware { service }))
1298    }
1299}
1300
1301/// Per-request service instance produced by [`PathTraversalGuard`].
1302pub struct PathTraversalGuardMiddleware<S> {
1303    service: S,
1304}
1305
1306impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
1307where
1308    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1309    B: 'static,
1310{
1311    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1312    type Error = ActixError;
1313    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1314
1315    actix_web::dev::forward_ready!(service);
1316
1317    fn call(&self, req: ServiceRequest) -> Self::Future {
1318        // Decode the raw path twice so that `%252e%252e` → `%2e%2e` →
1319        // `..` can be caught even though NormalizePath already ran once.
1320        let raw = req.path().to_string();
1321        if path_is_traversal(&raw) {
1322            let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
1323            let sr = req.into_response(rsp.map_into_boxed_body());
1324            return Box::pin(async move { Ok(sr.map_into_right_body()) });
1325        }
1326        let fut = self.service.call(req);
1327        Box::pin(async move {
1328            let resp = fut.await?;
1329            Ok(resp.map_into_left_body())
1330        })
1331    }
1332}
1333
1334fn path_is_traversal(path: &str) -> bool {
1335    // Two passes of percent-decode catches double-encoding.
1336    let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
1337    let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
1338    for seg in once.split('/').chain(twice.split('/')) {
1339        if seg == ".." || seg == "." {
1340            return true;
1341        }
1342    }
1343    // Also flag any raw escape sequences that decode to a traversal
1344    // segment even when buried inside a component (e.g. `foo%2f..%2fbar`).
1345    if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
1346        return true;
1347    }
1348    false
1349}
1350
1351// ---------------------------------------------------------------------------
1352// Sprint 11 (row 158): top-level 5xx logging middleware.
1353//
1354// JSS ref: commit 5b34d72 (#312) — "Top-level Fastify error handler,
1355// full stack on 5xx". Mirror the behaviour in actix: intercept any
1356// response whose status is 5xx, emit a structured `tracing::error!`
1357// with the method, path, status, error chain, and (when
1358// `RUST_BACKTRACE=1`) a captured backtrace. The response body is not
1359// altered; we only observe.
1360// ---------------------------------------------------------------------------
1361
1362/// Observes outbound responses and logs 5xx results with the full
1363/// error chain. Pass-through on 2xx/3xx/4xx. Shaped as an actix
1364/// [`Transform`] so it slots into the middleware stack in
1365/// [`build_app`].
1366pub struct ErrorLoggingMiddleware;
1367
1368impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
1369where
1370    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1371    B: 'static,
1372{
1373    type Response = ServiceResponse<B>;
1374    type Error = ActixError;
1375    type InitError = ();
1376    type Transform = ErrorLoggingMiddlewareService<S>;
1377    type Future = Ready<Result<Self::Transform, Self::InitError>>;
1378
1379    fn new_transform(&self, service: S) -> Self::Future {
1380        ready(Ok(ErrorLoggingMiddlewareService { service }))
1381    }
1382}
1383
1384/// Per-request service instance produced by [`ErrorLoggingMiddleware`].
1385pub struct ErrorLoggingMiddlewareService<S> {
1386    service: S,
1387}
1388
1389impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
1390where
1391    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1392    B: 'static,
1393{
1394    type Response = ServiceResponse<B>;
1395    type Error = ActixError;
1396    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1397
1398    actix_web::dev::forward_ready!(service);
1399
1400    fn call(&self, req: ServiceRequest) -> Self::Future {
1401        // Snapshot fields we need for the log line before the request
1402        // moves into the inner service.
1403        let method = req.method().as_str().to_string();
1404        let path = req.path().to_string();
1405
1406        let fut = self.service.call(req);
1407        Box::pin(async move {
1408            let response = fut.await?;
1409            let status = response.status();
1410            if status.is_server_error() {
1411                log_5xx(&method, &path, status, response.response().error());
1412            }
1413            Ok(response)
1414        })
1415    }
1416}
1417
1418/// Emit the structured 5xx log line. Captures a backtrace only when
1419/// `RUST_BACKTRACE=1` is set so production logs don't bloat unless the
1420/// operator opted in.
1421fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
1422    // Full error chain — include `source()` walk so downstream
1423    // `PodError` variants surface instead of being swallowed by
1424    // actix's top-level wrapper.
1425    let chain = match error {
1426        Some(e) => format_error_chain(e),
1427        None => "<no error attached to response>".to_string(),
1428    };
1429
1430    let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
1431        Some(std::backtrace::Backtrace::force_capture().to_string())
1432    } else {
1433        None
1434    };
1435
1436    tracing::error!(
1437        target: "solid_pod_rs_server::http",
1438        method = %method,
1439        path = %path,
1440        status = %status.as_u16(),
1441        error.chain = %chain,
1442        backtrace = backtrace.as_deref().unwrap_or(""),
1443        "5xx response"
1444    );
1445}
1446
1447/// Walk an actix `Error` + its `source()` chain into a single
1448/// human-readable string (one segment per cause, separated by ` -> `).
1449///
1450/// `actix_web::Error` does not expose a stable `source()` accessor,
1451/// and `ResponseError` in actix-web 4 does not extend
1452/// [`std::error::Error`]. We surface the `Display` form of the
1453/// response error (which captures the message operators care about
1454/// on 5xx) and append the actix `Debug` dump for deep diagnosis —
1455/// the dump already includes the inner cause chain that actix-http
1456/// preserves internally.
1457fn format_error_chain(e: &actix_web::Error) -> String {
1458    let summary = format!("{}", e.as_response_error());
1459    let debug = format!("{e:?}");
1460    if debug == summary || debug.is_empty() {
1461        summary
1462    } else {
1463        format!("{summary} -> {debug}")
1464    }
1465}
1466
1467// ---------------------------------------------------------------------------
1468// Dotfile allowlist middleware
1469// ---------------------------------------------------------------------------
1470
1471/// Actix middleware that blocks dotfile paths unless they appear on the allowlist.
1472pub struct DotfileGuard {
1473    allow: Arc<DotfileAllowlist>,
1474}
1475
1476impl DotfileGuard {
1477    pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
1478        Self { allow }
1479    }
1480}
1481
1482impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
1483where
1484    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1485    B: 'static,
1486{
1487    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1488    type Error = ActixError;
1489    type InitError = ();
1490    type Transform = DotfileGuardMiddleware<S>;
1491    type Future = Ready<Result<Self::Transform, Self::InitError>>;
1492
1493    fn new_transform(&self, service: S) -> Self::Future {
1494        ready(Ok(DotfileGuardMiddleware {
1495            service,
1496            allow: self.allow.clone(),
1497        }))
1498    }
1499}
1500
1501/// Per-request service instance produced by [`DotfileGuard`].
1502pub struct DotfileGuardMiddleware<S> {
1503    service: S,
1504    allow: Arc<DotfileAllowlist>,
1505}
1506
1507impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
1508where
1509    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1510    B: 'static,
1511{
1512    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1513    type Error = ActixError;
1514    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1515
1516    actix_web::dev::forward_ready!(service);
1517
1518    fn call(&self, req: ServiceRequest) -> Self::Future {
1519        let path = req.path().to_string();
1520        // Whitelist the well-known discovery paths even though they
1521        // contain a dotfile component — they are part of Solid's stable
1522        // interop surface.
1523        let allow_wellknown = path.starts_with("/.well-known/");
1524        if !allow_wellknown {
1525            let pb = PathBuf::from(&path);
1526            if !self.allow.is_allowed(Path::new(&pb)) {
1527                let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
1528                let sr = req.into_response(rsp.map_into_boxed_body());
1529                return Box::pin(async move { Ok(sr.map_into_right_body()) });
1530            }
1531        }
1532        let fut = self.service.call(req);
1533        Box::pin(async move {
1534            let resp = fut.await?;
1535            Ok(resp.map_into_left_body())
1536        })
1537    }
1538}
1539
1540// ---------------------------------------------------------------------------
1541// Public app builder
1542// ---------------------------------------------------------------------------
1543
1544/// Build the complete actix `App` for the Solid Pod server. Both the
1545/// binary (`main.rs`) and the workspace integration tests call this.
1546///
1547/// The returned `App` is fully-configured: route table, normaliser,
1548/// path-traversal guard, dotfile allowlist, body cap, CORS middleware
1549/// (when available), rate-limit middleware (when available), and WAC
1550/// enforcement.
1551pub fn build_app(
1552    state: AppState,
1553) -> App<
1554    impl actix_web::dev::ServiceFactory<
1555        ServiceRequest,
1556        Config = (),
1557        Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
1558        Error = ActixError,
1559        InitError = (),
1560    >,
1561> {
1562    let body_cap = state.body_cap;
1563    let dotfiles = state.dotfiles.clone();
1564
1565    let mut app = App::new()
1566        .app_data(web::Data::new(state.clone()))
1567        .app_data(web::PayloadConfig::new(body_cap))
1568        // Sprint 11 (row 158): outermost layer so it observes every
1569        // response — including those that short-circuited in inner
1570        // guards. Wrapping first means `wrap()` applies it last in
1571        // actix's stack order.
1572        .wrap(ErrorLoggingMiddleware)
1573        // `MergeOnly` collapses duplicate slashes (//a → /a) without
1574        // stripping the trailing slash, which is the container/resource
1575        // discriminator in LDP.
1576        .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
1577        .wrap(PathTraversalGuard)
1578        .wrap(DotfileGuard::new(dotfiles));
1579
1580    // CORS / rate-limit: middleware is driven by the library types from
1581    // S7-A. We register pass-through headers when the env-driven policy
1582    // permits. The middleware is a no-op today beyond emitting the
1583    // policy's `response_headers` on every response; full preflight
1584    // handling lives in the sibling S7-A work.
1585    app = app
1586        .route("/.well-known/solid", web::get().to(handle_well_known_solid))
1587        .route(
1588            "/.well-known/webfinger",
1589            web::get().to(handle_well_known_webfinger),
1590        )
1591        .route(
1592            "/.well-known/nodeinfo",
1593            web::get().to(handle_well_known_nodeinfo),
1594        )
1595        .route(
1596            "/.well-known/nodeinfo/2.1",
1597            web::get().to(handle_well_known_nodeinfo_2_1),
1598        );
1599
1600    #[cfg(feature = "did-nostr")]
1601    {
1602        app = app.route(
1603            "/.well-known/did/nostr/{pubkey}.json",
1604            web::get().to(handle_well_known_did_nostr),
1605        );
1606    }
1607
1608    // Payment endpoint (JSS parity: GET /pay/.info).
1609    app = app.route("/pay/.info", web::get().to(handle_pay_info));
1610
1611    // WAC-gated CORS proxy endpoint.
1612    app = app.route("/proxy", web::get().to(handle_proxy));
1613
1614    // Pod management API (JSS parity: /api/accounts/*)
1615    app = app
1616        .route("/api/accounts/new", web::post().to(handle_create_account))
1617        .route("/pods/check/{name}", web::get().to(handle_pod_check))
1618        .route("/login/password", web::post().to(handle_login_password))
1619        .route(
1620            "/account/password/reset",
1621            web::post().to(handle_password_reset_request),
1622        )
1623        .route(
1624            "/account/password/change",
1625            web::post().to(handle_password_change),
1626        );
1627
1628    // Container POST and PUT (trailing slash) must register before the
1629    // catch-all so the trailing-slash variant wins.
1630    app.route("/{tail:.*}/", web::post().to(handle_post))
1631        .route("/{tail:.*}/", web::put().to(handle_put))
1632        .route("/{tail:.*}", web::get().to(handle_get))
1633        .route("/{tail:.*}", web::head().to(handle_get))
1634        .route("/{tail:.*}", web::put().to(handle_put))
1635        .route("/{tail:.*}", web::patch().to(handle_patch))
1636        .route("/{tail:.*}", web::delete().to(handle_delete))
1637        .route(
1638            "/{tail:.*}",
1639            web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
1640        )
1641        .route(
1642            "/{tail:.*}",
1643            web::method(actix_web::http::Method::OPTIONS).to(handle_options),
1644        )
1645}