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