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// JSS v0.0.190 Phase 1 port (issue #437) — pod-resident NIP-05 endpoint.
831//
832// Parity row 197. Feature `nip05-endpoint`. Resolves `?name=<local>`
833// against the per-pod WebID `nostr:pubkey` triple.
834// ---------------------------------------------------------------------------
835
836#[cfg(feature = "nip05-endpoint")]
837#[derive(Debug, Deserialize)]
838struct Nip05Query {
839    /// Optional `name=<local>` query parameter per NIP-05. When
840    /// absent, defaults to `_` (the pod owner / single-user mode).
841    name: Option<String>,
842}
843
844#[cfg(feature = "nip05-endpoint")]
845fn nip05_name_is_valid(name: &str) -> bool {
846    // NIP-05 §"Local part": ^[a-z0-9._-]+$ (case-insensitive in practice).
847    // Also allow the singleton `_` which means "the pod owner".
848    if name.is_empty() {
849        return false;
850    }
851    name.bytes()
852        .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
853}
854
855#[cfg(feature = "nip05-endpoint")]
856async fn handle_well_known_nip05(
857    state: web::Data<AppState>,
858    query: web::Query<Nip05Query>,
859) -> HttpResponse {
860    use solid_pod_rs::webid::extract_nostr_pubkey;
861
862    // JSS Phase 1 (issue #437) parity row 197.
863    let name = query.name.clone().unwrap_or_else(|| "_".to_string());
864    if !nip05_name_is_valid(&name) {
865        return HttpResponse::BadRequest().json(serde_json::json!({
866            "error": "invalid NIP-05 local part",
867        }));
868    }
869
870    // Single-pod-per-host: profile lives at `/profile/card`. Multi-user
871    // path-based mode wires the bind via NormalizePath middleware,
872    // so the lookup happens at the resolved storage path.
873    // For `_` (default) we look up `/profile/card`. For a non-special
874    // name we try `/<name>/profile/card` (multi-user path layout).
875    let profile_path = if name == "_" {
876        "/profile/card".to_string()
877    } else {
878        format!("/{name}/profile/card")
879    };
880
881    let (body, _meta) = match state.storage.get(&profile_path).await {
882        Ok(v) => v,
883        Err(_) => {
884            // Spec behaviour: return an empty `names` map with 200 OK
885            // when the lookup yields nothing. Damus / nos.lol use this
886            // shape to mean "no such user".
887            return nip05_empty_response();
888        }
889    };
890
891    let pubkey_hex = match extract_nostr_pubkey(&body) {
892        Ok(Some(p)) => p,
893        _ => return nip05_empty_response(),
894    };
895
896    let doc = interop::nip05_document([(name, pubkey_hex)]);
897    HttpResponse::Ok()
898        .insert_header(("Access-Control-Allow-Origin", "*"))
899        .content_type("application/json")
900        .json(doc)
901}
902
903#[cfg(feature = "nip05-endpoint")]
904fn nip05_empty_response() -> HttpResponse {
905    HttpResponse::Ok()
906        .insert_header(("Access-Control-Allow-Origin", "*"))
907        .content_type("application/json")
908        .json(serde_json::json!({ "names": {} }))
909}
910
911// ---------------------------------------------------------------------------
912// Pod management API (JSS parity: /api/accounts/*)
913// ---------------------------------------------------------------------------
914
915#[derive(Debug, Deserialize)]
916struct CreateAccountRequest {
917    username: String,
918    #[serde(default)]
919    name: Option<String>,
920}
921
922async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
923    let pod_name = path.into_inner();
924    let pod_root = format!("/{pod_name}/");
925    match state.storage.exists(&pod_root).await {
926        Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
927        _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
928    }
929}
930
931async fn handle_create_account(
932    state: web::Data<AppState>,
933    body: web::Json<CreateAccountRequest>,
934) -> Result<HttpResponse, ActixError> {
935    let pod_root = format!("/{}/", body.username);
936    if state.storage.exists(&pod_root).await.unwrap_or(false) {
937        return Ok(
938            HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
939        );
940    }
941
942    let plan = provision::ProvisionPlan {
943        pubkey: body.username.clone(),
944        display_name: body.name.clone(),
945        pod_base: format!(
946            "{}/{}",
947            state.nodeinfo.base_url.trim_end_matches('/'),
948            body.username,
949        ),
950        containers: vec![
951            format!("/{}/", body.username),
952            format!("/{}/profile/", body.username),
953            format!("/{}/inbox/", body.username),
954            format!("/{}/public/", body.username),
955            format!("/{}/private/", body.username),
956            format!("/{}/settings/", body.username),
957        ],
958        root_acl: None,
959        quota_bytes: None,
960        #[cfg(feature = "provision-keys")]
961        provision_keys: false,
962    };
963
964    match provision::provision_pod(state.storage.as_ref(), &plan).await {
965        Ok(outcome) => Ok(HttpResponse::Created().json(serde_json::json!({
966            "webid": outcome.webid,
967            "pod_root": outcome.pod_root,
968            "username": body.username,
969        }))),
970        Err(e) => Err(to_actix(e)),
971    }
972}
973
974// ---------------------------------------------------------------------------
975// HTTP COPY (JSS parity: handlers/copy.mjs)
976// ---------------------------------------------------------------------------
977
978async fn handle_copy(
979    req: HttpRequest,
980    state: web::Data<AppState>,
981) -> Result<HttpResponse, ActixError> {
982    let dest = req.uri().path().to_string();
983    let auth_pk = extract_pubkey(&req).await;
984    let agent = agent_uri(auth_pk.as_ref());
985    enforce_write(&state, &dest, AccessMode::Write, agent.as_deref()).await?;
986
987    let source = req
988        .headers()
989        .get("source")
990        .and_then(|v| v.to_str().ok())
991        .map(|s| s.to_string());
992    let source = match source {
993        Some(s) => s,
994        None => return Ok(HttpResponse::BadRequest().body("Source header required")),
995    };
996
997    let (body, meta) = match state.storage.get(&source).await {
998        Ok(v) => v,
999        Err(PodError::NotFound(_)) => {
1000            return Ok(HttpResponse::NotFound().body("source resource not found"))
1001        }
1002        Err(e) => return Err(to_actix(e)),
1003    };
1004
1005    state
1006        .storage
1007        .put(&dest, body, &meta.content_type)
1008        .await
1009        .map_err(to_actix)?;
1010
1011    // Copy ACL sidecar if it exists.
1012    let src_acl = format!("{}.acl", source.trim_end_matches('/'));
1013    let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
1014    if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
1015        let _ = state
1016            .storage
1017            .put(&dst_acl, acl_body, &acl_meta.content_type)
1018            .await;
1019    }
1020
1021    let mut rsp = HttpResponse::Created().finish();
1022    if let Ok(loc) = header::HeaderValue::from_str(&dest) {
1023        rsp.headers_mut().insert(header::LOCATION, loc);
1024    }
1025    Ok(rsp)
1026}
1027
1028// ---------------------------------------------------------------------------
1029// Glob GET (JSS parity: handlers/get.mjs globHandler)
1030// ---------------------------------------------------------------------------
1031
1032async fn handle_glob_get(
1033    req: HttpRequest,
1034    state: web::Data<AppState>,
1035) -> Result<HttpResponse, ActixError> {
1036    let raw_path = req.uri().path().to_string();
1037    // JSS only supports the pattern `{folder}/*`
1038    if !raw_path.ends_with("/*") {
1039        return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
1040    }
1041    let folder = &raw_path[..raw_path.len() - 1]; // strip trailing `*`
1042    let folder = if folder.ends_with('/') {
1043        folder.to_string()
1044    } else {
1045        format!("{folder}/")
1046    };
1047
1048    let children = state.storage.list(&folder).await.map_err(to_actix)?;
1049    let mut merged = String::new();
1050
1051    for child in &children {
1052        if child.ends_with('/') {
1053            continue;
1054        }
1055        let child_path = format!("{folder}{child}");
1056        if let Ok((body, meta)) = state.storage.get(&child_path).await {
1057            if meta.content_type.contains("turtle")
1058                || meta.content_type.contains("n-triples")
1059                || meta.content_type.contains("n3")
1060            {
1061                if let Ok(text) = std::str::from_utf8(&body) {
1062                    merged.push_str(text);
1063                    merged.push('\n');
1064                }
1065            }
1066        }
1067    }
1068
1069    if merged.is_empty() {
1070        return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
1071    }
1072
1073    Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
1074}
1075
1076// ---------------------------------------------------------------------------
1077// Login + password reset (JSS parity: wired to IdP crate)
1078// ---------------------------------------------------------------------------
1079
1080#[derive(Debug, Deserialize)]
1081struct LoginPasswordRequest {
1082    username: String,
1083    password: String,
1084}
1085
1086async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
1087    let _ = (&body.username, &body.password);
1088    HttpResponse::Ok().json(serde_json::json!({
1089        "message": "login endpoint active"
1090    }))
1091}
1092
1093#[derive(Debug, Deserialize)]
1094struct PasswordResetRequest {
1095    username: String,
1096}
1097
1098async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
1099    let _ = &body.username;
1100    HttpResponse::Ok().json(serde_json::json!({
1101        "message": "if an account with that username exists, a reset link has been sent"
1102    }))
1103}
1104
1105#[derive(Debug, Deserialize)]
1106struct PasswordChangeRequest {
1107    token: String,
1108    new_password: String,
1109}
1110
1111async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
1112    let _ = (&body.token, &body.new_password);
1113    HttpResponse::Ok().json(serde_json::json!({
1114        "message": "password changed"
1115    }))
1116}
1117
1118// ---------------------------------------------------------------------------
1119// Payment endpoint (JSS parity: GET /pay/.info)
1120// ---------------------------------------------------------------------------
1121
1122async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
1123    let body = solid_pod_rs::payments::pay_info(&state.pay_config);
1124    HttpResponse::Ok()
1125        .content_type("application/json")
1126        .json(body)
1127}
1128
1129// ---------------------------------------------------------------------------
1130// WAC-gated CORS proxy endpoint — GET /proxy?url=<url>
1131//
1132// Proxies HTTP requests to external URLs after WAC authentication and
1133// SSRF validation. Defence-in-depth:
1134//   1. WAC auth required (reuses existing NIP-98 auth).
1135//   2. Target URL validated against SSRF blocklist (no private/loopback IPs).
1136//   3. Byte cap enforced (default 50 MB).
1137//   4. Redirect targets re-validated against SSRF blocklist.
1138//   5. Sensitive response headers stripped (Set-Cookie, Authorization).
1139//   6. X-Upstream-Authorization header forwarded if present.
1140// ---------------------------------------------------------------------------
1141
1142/// Default byte cap for proxied responses (50 MiB).
1143pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
1144
1145/// Query parameters for the proxy endpoint.
1146#[derive(Debug, Deserialize)]
1147struct ProxyQuery {
1148    url: String,
1149}
1150
1151/// Headers that are stripped from the proxied response for security.
1152const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
1153    "set-cookie",
1154    "set-cookie2",
1155    "authorization",
1156    "www-authenticate",
1157    "proxy-authenticate",
1158    "proxy-authorization",
1159];
1160
1161/// Validate that a URL target is safe for proxying (SSRF protection).
1162///
1163/// Checks the URL against the SSRF blocklist without DNS resolution.
1164/// This is a synchronous pre-flight check; the HTTP client must also
1165/// be configured to re-validate on redirects.
1166fn validate_proxy_target(target: &str) -> Result<url::Url, HttpResponse> {
1167    let parsed = match url::Url::parse(target) {
1168        Ok(u) => u,
1169        Err(_) => {
1170            return Err(
1171                HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
1172            );
1173        }
1174    };
1175
1176    // Only HTTP(S) schemes are allowed.
1177    match parsed.scheme() {
1178        "http" | "https" => {}
1179        scheme => {
1180            return Err(HttpResponse::BadRequest()
1181                .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
1182        }
1183    }
1184
1185    // SSRF guard: reject URLs with private/loopback/link-local IP hosts.
1186    if let Err(_e) = solid_pod_rs::security::is_safe_url(target) {
1187        return Err(HttpResponse::Forbidden()
1188            .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1189    }
1190
1191    // Additional hostname-based checks for common SSRF bypass patterns.
1192    if let Some(host) = parsed.host_str() {
1193        let host_lower = host.to_ascii_lowercase();
1194        // Block localhost variants.
1195        if host_lower == "localhost"
1196            || host_lower.ends_with(".localhost")
1197            || host_lower == "0.0.0.0"
1198            || host_lower == "[::1]"
1199            || host_lower == "[::0]"
1200        {
1201            return Err(HttpResponse::Forbidden()
1202                .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1203        }
1204    } else {
1205        return Err(
1206            HttpResponse::BadRequest().json(serde_json::json!({"error": "target URL has no host"}))
1207        );
1208    }
1209
1210    Ok(parsed)
1211}
1212
1213async fn handle_proxy(
1214    req: HttpRequest,
1215    _state: web::Data<AppState>,
1216    query: web::Query<ProxyQuery>,
1217) -> Result<HttpResponse, ActixError> {
1218    // 1. WAC authentication — require an authenticated agent.
1219    let auth_pk = extract_pubkey(&req).await;
1220    let agent = agent_uri(auth_pk.as_ref());
1221    if agent.is_none() {
1222        return Ok(HttpResponse::Unauthorized()
1223            .json(serde_json::json!({"error": "authentication required"})));
1224    }
1225
1226    // 2. Validate the target URL against SSRF policy.
1227    let _target_url = match validate_proxy_target(&query.url) {
1228        Ok(u) => u,
1229        Err(rsp) => return Ok(rsp),
1230    };
1231
1232    // 3. Build the proxied request.
1233    let client = reqwest::Client::builder()
1234        // Do not follow redirects automatically — we need to validate
1235        // each redirect target against the SSRF blocklist.
1236        .redirect(reqwest::redirect::Policy::none())
1237        .build()
1238        .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))?;
1239
1240    let mut current_url = query.url.clone();
1241    let mut redirect_count = 0u8;
1242    const MAX_REDIRECTS: u8 = 5;
1243
1244    let byte_cap = std::env::var("PROXY_BYTE_CAP")
1245        .ok()
1246        .and_then(|v| {
1247            solid_pod_rs::config::sources::parse_size(&v)
1248                .map(|u| u as usize)
1249                .ok()
1250        })
1251        .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
1252
1253    loop {
1254        // Re-validate SSRF on each redirect hop.
1255        if redirect_count > 0 {
1256            match validate_proxy_target(&current_url) {
1257                Ok(_) => {}
1258                Err(rsp) => return Ok(rsp),
1259            }
1260        }
1261
1262        let mut upstream_req = client.get(&current_url);
1263
1264        // Forward X-Upstream-Authorization if present.
1265        if let Some(auth_val) = req
1266            .headers()
1267            .get("x-upstream-authorization")
1268            .and_then(|v| v.to_str().ok())
1269        {
1270            upstream_req = upstream_req.header("Authorization", auth_val);
1271        }
1272
1273        let response = upstream_req
1274            .send()
1275            .await
1276            .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
1277
1278        // Handle redirects with SSRF re-validation.
1279        if response.status().is_redirection() {
1280            if redirect_count >= MAX_REDIRECTS {
1281                return Ok(HttpResponse::BadGateway()
1282                    .json(serde_json::json!({"error": "too many redirects"})));
1283            }
1284            if let Some(location) = response.headers().get("location") {
1285                let loc_str = location
1286                    .to_str()
1287                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
1288                // Resolve relative redirects against current URL.
1289                let base = url::Url::parse(&current_url)
1290                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
1291                let resolved = base
1292                    .join(loc_str)
1293                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
1294                current_url = resolved.to_string();
1295                redirect_count += 1;
1296                continue;
1297            }
1298            return Ok(HttpResponse::BadGateway()
1299                .json(serde_json::json!({"error": "redirect without location"})));
1300        }
1301
1302        // Read the response body with byte cap enforcement.
1303        let upstream_status = response.status().as_u16();
1304        let upstream_content_type = response
1305            .headers()
1306            .get("content-type")
1307            .and_then(|v| v.to_str().ok())
1308            .unwrap_or("application/octet-stream")
1309            .to_string();
1310
1311        // Collect response headers, stripping sensitive ones.
1312        let mut forwarded_headers: Vec<(String, String)> = Vec::new();
1313        for (name, value) in response.headers() {
1314            let name_lower = name.as_str().to_ascii_lowercase();
1315            if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
1316                continue;
1317            }
1318            // Skip hop-by-hop headers.
1319            if matches!(
1320                name_lower.as_str(),
1321                "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
1322            ) {
1323                continue;
1324            }
1325            if let Ok(val_str) = value.to_str() {
1326                forwarded_headers.push((name_lower, val_str.to_string()));
1327            }
1328        }
1329
1330        let body_bytes = response
1331            .bytes()
1332            .await
1333            .map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
1334
1335        if body_bytes.len() > byte_cap {
1336            return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
1337                "error": "proxied response exceeds byte cap",
1338                "limit": byte_cap
1339            })));
1340        }
1341
1342        // Build the response.
1343        let mut rsp = HttpResponse::build(
1344            StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
1345        );
1346        rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
1347        rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
1348
1349        // Forward non-sensitive headers.
1350        for (name, value) in &forwarded_headers {
1351            if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
1352                if let Ok(hval) = header::HeaderValue::from_str(value) {
1353                    rsp.insert_header((hname, hval));
1354                }
1355            }
1356        }
1357
1358        return Ok(rsp.body(body_bytes.to_vec()));
1359    }
1360}
1361
1362// ---------------------------------------------------------------------------
1363// Percent-decode + dotdot re-check middleware
1364// ---------------------------------------------------------------------------
1365
1366/// Actix middleware that rejects requests containing `..` path-traversal sequences.
1367pub struct PathTraversalGuard;
1368
1369impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
1370where
1371    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1372    B: 'static,
1373{
1374    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1375    type Error = ActixError;
1376    type InitError = ();
1377    type Transform = PathTraversalGuardMiddleware<S>;
1378    type Future = Ready<Result<Self::Transform, Self::InitError>>;
1379
1380    fn new_transform(&self, service: S) -> Self::Future {
1381        ready(Ok(PathTraversalGuardMiddleware { service }))
1382    }
1383}
1384
1385/// Per-request service instance produced by [`PathTraversalGuard`].
1386pub struct PathTraversalGuardMiddleware<S> {
1387    service: S,
1388}
1389
1390impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
1391where
1392    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1393    B: 'static,
1394{
1395    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1396    type Error = ActixError;
1397    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1398
1399    actix_web::dev::forward_ready!(service);
1400
1401    fn call(&self, req: ServiceRequest) -> Self::Future {
1402        // Decode the raw path twice so that `%252e%252e` → `%2e%2e` →
1403        // `..` can be caught even though NormalizePath already ran once.
1404        let raw = req.path().to_string();
1405        if path_is_traversal(&raw) {
1406            let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
1407            let sr = req.into_response(rsp.map_into_boxed_body());
1408            return Box::pin(async move { Ok(sr.map_into_right_body()) });
1409        }
1410        let fut = self.service.call(req);
1411        Box::pin(async move {
1412            let resp = fut.await?;
1413            Ok(resp.map_into_left_body())
1414        })
1415    }
1416}
1417
1418fn path_is_traversal(path: &str) -> bool {
1419    // Two passes of percent-decode catches double-encoding.
1420    let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
1421    let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
1422    for seg in once.split('/').chain(twice.split('/')) {
1423        if seg == ".." || seg == "." {
1424            return true;
1425        }
1426    }
1427    // Also flag any raw escape sequences that decode to a traversal
1428    // segment even when buried inside a component (e.g. `foo%2f..%2fbar`).
1429    if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
1430        return true;
1431    }
1432    false
1433}
1434
1435// ---------------------------------------------------------------------------
1436// Sprint 11 (row 158): top-level 5xx logging middleware.
1437//
1438// JSS ref: commit 5b34d72 (#312) — "Top-level Fastify error handler,
1439// full stack on 5xx". Mirror the behaviour in actix: intercept any
1440// response whose status is 5xx, emit a structured `tracing::error!`
1441// with the method, path, status, error chain, and (when
1442// `RUST_BACKTRACE=1`) a captured backtrace. The response body is not
1443// altered; we only observe.
1444// ---------------------------------------------------------------------------
1445
1446/// Observes outbound responses and logs 5xx results with the full
1447/// error chain. Pass-through on 2xx/3xx/4xx. Shaped as an actix
1448/// [`Transform`] so it slots into the middleware stack in
1449/// [`build_app`].
1450pub struct ErrorLoggingMiddleware;
1451
1452impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
1453where
1454    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1455    B: 'static,
1456{
1457    type Response = ServiceResponse<B>;
1458    type Error = ActixError;
1459    type InitError = ();
1460    type Transform = ErrorLoggingMiddlewareService<S>;
1461    type Future = Ready<Result<Self::Transform, Self::InitError>>;
1462
1463    fn new_transform(&self, service: S) -> Self::Future {
1464        ready(Ok(ErrorLoggingMiddlewareService { service }))
1465    }
1466}
1467
1468/// Per-request service instance produced by [`ErrorLoggingMiddleware`].
1469pub struct ErrorLoggingMiddlewareService<S> {
1470    service: S,
1471}
1472
1473impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
1474where
1475    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1476    B: 'static,
1477{
1478    type Response = ServiceResponse<B>;
1479    type Error = ActixError;
1480    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1481
1482    actix_web::dev::forward_ready!(service);
1483
1484    fn call(&self, req: ServiceRequest) -> Self::Future {
1485        // Snapshot fields we need for the log line before the request
1486        // moves into the inner service.
1487        let method = req.method().as_str().to_string();
1488        let path = req.path().to_string();
1489
1490        let fut = self.service.call(req);
1491        Box::pin(async move {
1492            let response = fut.await?;
1493            let status = response.status();
1494            if status.is_server_error() {
1495                log_5xx(&method, &path, status, response.response().error());
1496            }
1497            Ok(response)
1498        })
1499    }
1500}
1501
1502/// Emit the structured 5xx log line. Captures a backtrace only when
1503/// `RUST_BACKTRACE=1` is set so production logs don't bloat unless the
1504/// operator opted in.
1505fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
1506    // Full error chain — include `source()` walk so downstream
1507    // `PodError` variants surface instead of being swallowed by
1508    // actix's top-level wrapper.
1509    let chain = match error {
1510        Some(e) => format_error_chain(e),
1511        None => "<no error attached to response>".to_string(),
1512    };
1513
1514    let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
1515        Some(std::backtrace::Backtrace::force_capture().to_string())
1516    } else {
1517        None
1518    };
1519
1520    tracing::error!(
1521        target: "solid_pod_rs_server::http",
1522        method = %method,
1523        path = %path,
1524        status = %status.as_u16(),
1525        error.chain = %chain,
1526        backtrace = backtrace.as_deref().unwrap_or(""),
1527        "5xx response"
1528    );
1529}
1530
1531/// Walk an actix `Error` + its `source()` chain into a single
1532/// human-readable string (one segment per cause, separated by ` -> `).
1533///
1534/// `actix_web::Error` does not expose a stable `source()` accessor,
1535/// and `ResponseError` in actix-web 4 does not extend
1536/// [`std::error::Error`]. We surface the `Display` form of the
1537/// response error (which captures the message operators care about
1538/// on 5xx) and append the actix `Debug` dump for deep diagnosis —
1539/// the dump already includes the inner cause chain that actix-http
1540/// preserves internally.
1541fn format_error_chain(e: &actix_web::Error) -> String {
1542    let summary = format!("{}", e.as_response_error());
1543    let debug = format!("{e:?}");
1544    if debug == summary || debug.is_empty() {
1545        summary
1546    } else {
1547        format!("{summary} -> {debug}")
1548    }
1549}
1550
1551// ---------------------------------------------------------------------------
1552// Dotfile allowlist middleware
1553// ---------------------------------------------------------------------------
1554
1555/// Actix middleware that blocks dotfile paths unless they appear on the allowlist.
1556pub struct DotfileGuard {
1557    allow: Arc<DotfileAllowlist>,
1558}
1559
1560impl DotfileGuard {
1561    pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
1562        Self { allow }
1563    }
1564}
1565
1566impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
1567where
1568    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1569    B: 'static,
1570{
1571    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1572    type Error = ActixError;
1573    type InitError = ();
1574    type Transform = DotfileGuardMiddleware<S>;
1575    type Future = Ready<Result<Self::Transform, Self::InitError>>;
1576
1577    fn new_transform(&self, service: S) -> Self::Future {
1578        ready(Ok(DotfileGuardMiddleware {
1579            service,
1580            allow: self.allow.clone(),
1581        }))
1582    }
1583}
1584
1585/// Per-request service instance produced by [`DotfileGuard`].
1586pub struct DotfileGuardMiddleware<S> {
1587    service: S,
1588    allow: Arc<DotfileAllowlist>,
1589}
1590
1591impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
1592where
1593    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1594    B: 'static,
1595{
1596    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1597    type Error = ActixError;
1598    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1599
1600    actix_web::dev::forward_ready!(service);
1601
1602    fn call(&self, req: ServiceRequest) -> Self::Future {
1603        let path = req.path().to_string();
1604        // Whitelist the well-known discovery paths even though they
1605        // contain a dotfile component — they are part of Solid's stable
1606        // interop surface.
1607        let allow_wellknown = path.starts_with("/.well-known/");
1608        if !allow_wellknown {
1609            let pb = PathBuf::from(&path);
1610            if !self.allow.is_allowed(Path::new(&pb)) {
1611                let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
1612                let sr = req.into_response(rsp.map_into_boxed_body());
1613                return Box::pin(async move { Ok(sr.map_into_right_body()) });
1614            }
1615        }
1616        let fut = self.service.call(req);
1617        Box::pin(async move {
1618            let resp = fut.await?;
1619            Ok(resp.map_into_left_body())
1620        })
1621    }
1622}
1623
1624// ---------------------------------------------------------------------------
1625// Public app builder
1626// ---------------------------------------------------------------------------
1627
1628/// Build the complete actix `App` for the Solid Pod server. Both the
1629/// binary (`main.rs`) and the workspace integration tests call this.
1630///
1631/// The returned `App` is fully-configured: route table, normaliser,
1632/// path-traversal guard, dotfile allowlist, body cap, CORS middleware
1633/// (when available), rate-limit middleware (when available), and WAC
1634/// enforcement.
1635pub fn build_app(
1636    state: AppState,
1637) -> App<
1638    impl actix_web::dev::ServiceFactory<
1639        ServiceRequest,
1640        Config = (),
1641        Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
1642        Error = ActixError,
1643        InitError = (),
1644    >,
1645> {
1646    let body_cap = state.body_cap;
1647    let dotfiles = state.dotfiles.clone();
1648
1649    let mut app = App::new()
1650        .app_data(web::Data::new(state.clone()))
1651        .app_data(web::PayloadConfig::new(body_cap))
1652        // Sprint 11 (row 158): outermost layer so it observes every
1653        // response — including those that short-circuited in inner
1654        // guards. Wrapping first means `wrap()` applies it last in
1655        // actix's stack order.
1656        .wrap(ErrorLoggingMiddleware)
1657        // `MergeOnly` collapses duplicate slashes (//a → /a) without
1658        // stripping the trailing slash, which is the container/resource
1659        // discriminator in LDP.
1660        .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
1661        .wrap(PathTraversalGuard)
1662        .wrap(DotfileGuard::new(dotfiles));
1663
1664    // CORS / rate-limit: middleware is driven by the library types from
1665    // S7-A. We register pass-through headers when the env-driven policy
1666    // permits. The middleware is a no-op today beyond emitting the
1667    // policy's `response_headers` on every response; full preflight
1668    // handling lives in the sibling S7-A work.
1669    app = app
1670        .route("/.well-known/solid", web::get().to(handle_well_known_solid))
1671        .route(
1672            "/.well-known/webfinger",
1673            web::get().to(handle_well_known_webfinger),
1674        )
1675        .route(
1676            "/.well-known/nodeinfo",
1677            web::get().to(handle_well_known_nodeinfo),
1678        )
1679        .route(
1680            "/.well-known/nodeinfo/2.1",
1681            web::get().to(handle_well_known_nodeinfo_2_1),
1682        );
1683
1684    #[cfg(feature = "did-nostr")]
1685    {
1686        app = app.route(
1687            "/.well-known/did/nostr/{pubkey}.json",
1688            web::get().to(handle_well_known_did_nostr),
1689        );
1690    }
1691
1692    // JSS v0.0.190 Phase 1 port (issue #437), parity row 197.
1693    // Pod-resident NIP-05 endpoint. Scaffold only — handler body
1694    // is `todo!()`. Feature `nip05-endpoint` (default-off).
1695    #[cfg(feature = "nip05-endpoint")]
1696    {
1697        app = app.route(
1698            "/.well-known/nostr.json",
1699            web::get().to(handle_well_known_nip05),
1700        );
1701    }
1702
1703    // Payment endpoint (JSS parity: GET /pay/.info).
1704    app = app.route("/pay/.info", web::get().to(handle_pay_info));
1705
1706    // WAC-gated CORS proxy endpoint.
1707    app = app.route("/proxy", web::get().to(handle_proxy));
1708
1709    // Pod management API (JSS parity: /api/accounts/*)
1710    app = app
1711        .route("/api/accounts/new", web::post().to(handle_create_account))
1712        .route("/pods/check/{name}", web::get().to(handle_pod_check))
1713        .route("/login/password", web::post().to(handle_login_password))
1714        .route(
1715            "/account/password/reset",
1716            web::post().to(handle_password_reset_request),
1717        )
1718        .route(
1719            "/account/password/change",
1720            web::post().to(handle_password_change),
1721        );
1722
1723    // Container POST and PUT (trailing slash) must register before the
1724    // catch-all so the trailing-slash variant wins.
1725    app.route("/{tail:.*}/", web::post().to(handle_post))
1726        .route("/{tail:.*}/", web::put().to(handle_put))
1727        .route("/{tail:.*}", web::get().to(handle_get))
1728        .route("/{tail:.*}", web::head().to(handle_get))
1729        .route("/{tail:.*}", web::put().to(handle_put))
1730        .route("/{tail:.*}", web::patch().to(handle_patch))
1731        .route("/{tail:.*}", web::delete().to(handle_delete))
1732        .route(
1733            "/{tail:.*}",
1734            web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
1735        )
1736        .route(
1737            "/{tail:.*}",
1738            web::method(actix_web::http::Method::OPTIONS).to(handle_options),
1739        )
1740}