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::collections::HashMap;
60use std::net::{IpAddr, Ipv4Addr};
61use std::path::{Path, PathBuf};
62use std::sync::{Arc, Mutex};
63use std::time::{Duration, Instant};
64
65use actix_web::body::{BoxBody, EitherBody};
66use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
67use actix_web::http::{header, StatusCode};
68use actix_web::middleware::{NormalizePath, TrailingSlash};
69use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
70use bytes::Bytes;
71use futures_util::future::{ready, LocalBoxFuture, Ready};
72use percent_encoding::percent_decode_str;
73use serde::Deserialize;
74use solid_pod_rs::{
75    auth::nip98,
76    config::sources::parse_size,
77    interop,
78    ldp::{self, LdpContainerOps, PatchCreateOutcome},
79    mashlib::{self, MashlibConfig},
80    provision,
81    security::DotfileAllowlist,
82    storage::Storage,
83    wac::{
84        self, conditions::RequestContext, parse_jsonld_acl, parser::parse_turtle_acl, AccessMode,
85    },
86    PodError,
87};
88
89// ---------------------------------------------------------------------------
90// Shared app state
91// ---------------------------------------------------------------------------
92
93/// Actix-web shared state.
94#[derive(Clone)]
95pub struct AppState {
96    pub storage: Arc<dyn Storage>,
97    pub dotfiles: Arc<DotfileAllowlist>,
98    pub body_cap: usize,
99    pub nodeinfo: NodeInfoMeta,
100    pub mashlib: MashlibConfig,
101    /// Legacy alias — reads from `mashlib.mode` when `Cdn`.  Deprecated;
102    /// use `mashlib` directly.
103    pub mashlib_cdn: Option<String>,
104    /// Payment configuration — drives `/pay/.info` and the `X-Balance` /
105    /// `X-Cost` / `X-Pay-Currency` response headers on paid resources.
106    pub pay_config: solid_pod_rs::payments::PayConfig,
107    /// Absolute filesystem root of the pod storage tree. `Some` when the
108    /// backend is `FsBackend`; `None` for in-memory or cloud-backed
109    /// storage. Required by the `git` feature to locate pod directories
110    /// for `GitAutoInit` (provisioning) and `GitHttpService` (serving).
111    pub data_root: Option<PathBuf>,
112    /// JSS-compatible pod creation limiter: one `POST /.pods` per IP per day.
113    pub pod_create_limiter: Arc<PodCreateLimiter>,
114    /// When non-empty, CORS responses are only reflected for origins in this
115    /// list. Origins not in the list receive no `Access-Control-Allow-Origin`
116    /// header. When empty (the default), the request `Origin` is echoed back
117    /// (wildcard-equivalent behaviour, suitable for local dev).
118    ///
119    /// Configured via `--allowed-origins` / `SOLID_ALLOWED_ORIGINS` (comma-separated).
120    pub allowed_origins: Vec<String>,
121    /// Pre-shared key for the `POST /_admin/provision/{pubkey}` endpoint.
122    /// When `None`, the endpoint returns 403 unconditionally.
123    ///
124    /// Configured via `--admin-key` / `SOLID_ADMIN_KEY`.
125    pub admin_key: Option<String>,
126}
127
128/// NodeInfo 2.1 body inputs. Kept here so tests can override them.
129#[derive(Clone, Debug)]
130pub struct NodeInfoMeta {
131    pub software_name: String,
132    pub software_version: String,
133    pub open_registrations: bool,
134    pub total_users: u64,
135    pub base_url: String,
136}
137
138impl Default for NodeInfoMeta {
139    fn default() -> Self {
140        Self {
141            software_name: "solid-pod-rs-server".to_string(),
142            software_version: env!("CARGO_PKG_VERSION").to_string(),
143            open_registrations: false,
144            total_users: 0,
145            base_url: "http://localhost".to_string(),
146        }
147    }
148}
149
150/// Discover the body cap from the environment. Accepts values like
151/// `50MB`, `1.5GB`, or a bare integer (bytes). Falls back to 50 MiB.
152pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
153
154/// Read `JSS_MAX_REQUEST_BODY` and parse via [`parse_size`]. On any
155/// failure, returns [`DEFAULT_BODY_CAP`].
156pub fn body_cap_from_env() -> usize {
157    match std::env::var("JSS_MAX_REQUEST_BODY") {
158        Ok(v) => parse_size(&v)
159            .map(|u| u as usize)
160            .unwrap_or(DEFAULT_BODY_CAP),
161        Err(_) => DEFAULT_BODY_CAP,
162    }
163}
164
165impl AppState {
166    /// Convenience constructor for tests and the binary. Callers may
167    /// replace fields after creation since `AppState` is a plain struct.
168    pub fn new(storage: Arc<dyn Storage>) -> Self {
169        Self {
170            storage,
171            dotfiles: Arc::new(DotfileAllowlist::from_env()),
172            body_cap: body_cap_from_env(),
173            nodeinfo: NodeInfoMeta::default(),
174            mashlib: MashlibConfig::default(),
175            mashlib_cdn: None,
176            pay_config: solid_pod_rs::payments::PayConfig::default(),
177            data_root: None,
178            pod_create_limiter: Arc::new(PodCreateLimiter::default()),
179            allowed_origins: Vec::new(),
180            admin_key: None,
181        }
182    }
183}
184
185/// In-process sliding-window limiter for JSS-compatible `POST /.pods`.
186#[derive(Debug)]
187pub struct PodCreateLimiter {
188    hits: Mutex<HashMap<IpAddr, Instant>>,
189    window: Duration,
190}
191
192impl Default for PodCreateLimiter {
193    fn default() -> Self {
194        Self {
195            hits: Mutex::new(HashMap::new()),
196            window: Duration::from_secs(24 * 60 * 60),
197        }
198    }
199}
200
201impl PodCreateLimiter {
202    fn check(&self, ip: IpAddr) -> Result<(), u64> {
203        let now = Instant::now();
204        let mut hits = self.hits.lock().unwrap();
205        if let Some(last) = hits.get(&ip).copied() {
206            let elapsed = now.saturating_duration_since(last);
207            if elapsed < self.window {
208                return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
209            }
210        }
211        hits.insert(ip, now);
212        Ok(())
213    }
214}
215
216// ---------------------------------------------------------------------------
217// Error translation
218// ---------------------------------------------------------------------------
219
220fn to_actix(e: PodError) -> ActixError {
221    match e {
222        PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
223        PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
224        PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
225        PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
226        PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
227        PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
228        _ => actix_web::error::ErrorInternalServerError(e.to_string()),
229    }
230}
231
232// ---------------------------------------------------------------------------
233// Auth helper — shared across handlers
234// ---------------------------------------------------------------------------
235
236/// Attempt NIP-98 bearer verification; returns the pubkey on success.
237async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
238    let header_val = req
239        .headers()
240        .get(header::AUTHORIZATION)
241        .and_then(|v| v.to_str().ok())?;
242    let url = format!(
243        "http://{}{}",
244        req.connection_info().host(),
245        req.uri().path()
246    );
247    nip98::verify(header_val, &url, req.method().as_str(), None)
248        .await
249        .ok()
250}
251
252fn agent_uri(pubkey: Option<&String>) -> Option<String> {
253    pubkey.map(|pk| format!("did:nostr:{pk}"))
254}
255
256/// Return `true` when the `Accept` header includes `text/html`.
257///
258/// Used for container `index.html` content negotiation: if a browser
259/// requests `text/html` on a container URL and that container contains
260/// an `index.html` resource, the server serves the HTML file instead of
261/// the RDF container listing. Solid clients that send `Accept: text/turtle`
262/// or `application/ld+json` skip this path entirely.
263fn accept_includes_html(accept: &str) -> bool {
264    accept.split(',').any(|entry| {
265        let mime = entry.split(';').next().unwrap_or("").trim();
266        mime.eq_ignore_ascii_case("text/html")
267    })
268}
269
270// ---------------------------------------------------------------------------
271// WAC enforcement for writes (PUT / POST / PATCH / DELETE)
272// ---------------------------------------------------------------------------
273
274/// Resolve the effective ACL and evaluate whether the given WebID may
275/// perform `mode` on `path`.
276///
277/// Returns `Ok(())` on grant. On deny, returns an `actix_web::Error`:
278/// * `401` when the request had no authenticated agent (so the client
279///   knows retrying with credentials might work);
280/// * `403` when authenticated but the ACL does not grant the mode.
281async fn enforce_write(
282    state: &AppState,
283    path: &str,
284    mode: AccessMode,
285    agent_uri: Option<&str>,
286) -> Result<(), ActixError> {
287    // `StorageAclResolver` is generic over a concrete backend. `state`
288    // holds an `Arc<dyn Storage>`; wrap it in a trait-object-friendly
289    // adapter (`DynStorage`) that forwards each trait method so the
290    // resolver can be constructed with a concrete type.
291    let acl_doc = match find_effective_acl_dyn(&*state.storage, path).await {
292        Ok(doc) => doc,
293        Err(e) => return Err(to_actix(e)),
294    };
295
296    let ctx = RequestContext {
297        web_id: agent_uri,
298        client_id: None,
299        issuer: None,
300        payment_balance_sats: None,
301    };
302    let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
303    let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
304    let granted = wac::evaluate_access_ctx_with_registry(
305        acl_doc.as_ref(),
306        &ctx,
307        path,
308        mode,
309        None,
310        &groups,
311        &registry,
312    );
313    if granted {
314        return Ok(());
315    }
316
317    let allow_header = wac::wac_allow_header(acl_doc.as_ref(), agent_uri, path);
318    let (status, body, unauthenticated) = if agent_uri.is_none() {
319        (StatusCode::UNAUTHORIZED, "authentication required", true)
320    } else {
321        (StatusCode::FORBIDDEN, "access forbidden", false)
322    };
323    let mut rsp = HttpResponse::new(status);
324    rsp.headers_mut().insert(
325        header::HeaderName::from_static("wac-allow"),
326        header::HeaderValue::from_str(&allow_header)
327            .unwrap_or(header::HeaderValue::from_static("")),
328    );
329    if unauthenticated {
330        rsp.headers_mut().insert(
331            header::WWW_AUTHENTICATE,
332            header::HeaderValue::from_static("DPoP realm=\"Solid\", Bearer realm=\"Solid\""),
333        );
334    }
335    Err(actix_web::error::InternalError::from_response(body, rsp).into())
336}
337
338// ---------------------------------------------------------------------------
339// Handlers
340// ---------------------------------------------------------------------------
341
342fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
343    let links = ldp::link_headers(path).join(", ");
344    if let Ok(value) = header::HeaderValue::from_str(&links) {
345        rsp.headers_mut()
346            .insert(header::HeaderName::from_static("link"), value);
347    }
348}
349
350fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
351    if let Ok(v) = header::HeaderValue::from_str(header_value) {
352        rsp.headers_mut()
353            .insert(header::HeaderName::from_static("wac-allow"), v);
354    }
355}
356
357fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
358    let ws_base = base_url
359        .replacen("https://", "wss://", 1)
360        .replacen("http://", "ws://", 1);
361    let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
362    if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
363        rsp.headers_mut()
364            .insert(header::HeaderName::from_static("updates-via"), v);
365    }
366}
367
368async fn handle_get(
369    req: HttpRequest,
370    state: web::Data<AppState>,
371) -> Result<HttpResponse, ActixError> {
372    let path = req.uri().path().to_string();
373
374    if path.contains('*') {
375        return handle_glob_get(req, state).await;
376    }
377
378    let auth_pk = extract_pubkey(&req).await;
379    let agent = agent_uri(auth_pk.as_ref());
380    let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
381
382    if ldp::is_container(&path) {
383        let accept = req
384            .headers()
385            .get(header::ACCEPT)
386            .and_then(|v| v.to_str().ok())
387            .unwrap_or("");
388
389        // Content negotiation: when a browser requests text/html, check
390        // whether the container has an index.html child resource. If so,
391        // serve it directly instead of the RDF container listing. This is
392        // standard HTTP content negotiation — browsers get HTML, Solid
393        // clients get RDF.
394        if accept_includes_html(accept) {
395            let index_path = format!("{}index.html", &path);
396            if let Ok((body, _meta)) = state.storage.get(&index_path).await {
397                let mut rsp = HttpResponse::Ok()
398                    .content_type("text/html; charset=utf-8")
399                    .body(body.to_vec());
400                set_wac_allow(&mut rsp, &wac_allow);
401                set_updates_via(&mut rsp, &state.nodeinfo.base_url);
402                set_link_headers(&mut rsp, &path);
403                return Ok(rsp);
404            }
405        }
406
407        let v = state
408            .storage
409            .container_representation(&path)
410            .await
411            .map_err(to_actix)?;
412
413        // Mashlib: serve HTML wrapper for browser navigation.
414        let sec_fetch_dest = req
415            .headers()
416            .get("sec-fetch-dest")
417            .and_then(|v| v.to_str().ok());
418        if mashlib::should_serve(
419            accept,
420            sec_fetch_dest,
421            "application/ld+json",
422            state.mashlib.enabled,
423        ) {
424            let json_ld = serde_json::to_string(&v).ok();
425            let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
426            let mut rsp = HttpResponse::Ok()
427                .content_type("text/html; charset=utf-8")
428                .insert_header(("X-Frame-Options", "DENY"))
429                .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
430                .insert_header(("Cache-Control", "no-store"))
431                .body(html);
432            set_wac_allow(&mut rsp, &wac_allow);
433            set_updates_via(&mut rsp, &state.nodeinfo.base_url);
434            set_link_headers(&mut rsp, &path);
435            return Ok(rsp);
436        }
437
438        let mut rsp = HttpResponse::Ok().json(v);
439        rsp.headers_mut().insert(
440            header::CONTENT_TYPE,
441            header::HeaderValue::from_static("application/ld+json"),
442        );
443        set_wac_allow(&mut rsp, &wac_allow);
444        set_updates_via(&mut rsp, &state.nodeinfo.base_url);
445        set_link_headers(&mut rsp, &path);
446        return Ok(rsp);
447    }
448
449    match state.storage.get(&path).await {
450        Ok((body, meta)) => {
451            // Mashlib: serve HTML wrapper for browser navigation to RDF resources.
452            let accept = req
453                .headers()
454                .get(header::ACCEPT)
455                .and_then(|v| v.to_str().ok())
456                .unwrap_or("");
457            let sec_fetch_dest = req
458                .headers()
459                .get("sec-fetch-dest")
460                .and_then(|v| v.to_str().ok());
461            if mashlib::should_serve(
462                accept,
463                sec_fetch_dest,
464                &meta.content_type,
465                state.mashlib.enabled,
466            ) {
467                let embed = if body.len() <= state.mashlib.data_island_max_bytes {
468                    std::str::from_utf8(&body).ok().map(|s| s.to_string())
469                } else {
470                    None
471                };
472                let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
473                let mut rsp = HttpResponse::Ok()
474                    .content_type("text/html; charset=utf-8")
475                    .insert_header(("X-Frame-Options", "DENY"))
476                    .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
477                    .insert_header(("Cache-Control", "no-store"))
478                    .body(html);
479                set_wac_allow(&mut rsp, &wac_allow);
480                set_updates_via(&mut rsp, &state.nodeinfo.base_url);
481                set_link_headers(&mut rsp, &path);
482                return Ok(rsp);
483            }
484
485            let mut rsp = HttpResponse::Ok().body(body.to_vec());
486            rsp.headers_mut().insert(
487                header::CONTENT_TYPE,
488                header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
489                    header::HeaderValue::from_static("application/octet-stream")
490                }),
491            );
492            if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
493                rsp.headers_mut().insert(header::ETAG, etag);
494            }
495            set_wac_allow(&mut rsp, &wac_allow);
496            set_updates_via(&mut rsp, &state.nodeinfo.base_url);
497            set_link_headers(&mut rsp, &path);
498            Ok(rsp)
499        }
500        Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
501        Err(e) => Err(to_actix(e)),
502    }
503}
504
505fn has_basic_container_link(req: &HttpRequest) -> bool {
506    req.headers()
507        .get_all(header::LINK)
508        .filter_map(|v| v.to_str().ok())
509        .any(|v| {
510            v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
511        })
512}
513
514async fn handle_put(
515    req: HttpRequest,
516    body: web::Bytes,
517    state: web::Data<AppState>,
518) -> Result<HttpResponse, ActixError> {
519    let path = req.uri().path().to_string();
520
521    if ldp::is_container(&path) {
522        if has_basic_container_link(&req) {
523            let auth_pk = extract_pubkey(&req).await;
524            let agent = agent_uri(auth_pk.as_ref());
525            enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
526            let meta = state
527                .storage
528                .create_container(&path)
529                .await
530                .map_err(to_actix)?;
531            let mut rsp = HttpResponse::Created().finish();
532            if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
533                rsp.headers_mut().insert(header::ETAG, etag);
534            }
535            set_link_headers(&mut rsp, &path);
536            return Ok(rsp);
537        }
538        return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
539    }
540
541    let auth_pk = extract_pubkey(&req).await;
542    let agent = agent_uri(auth_pk.as_ref());
543    enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
544
545    let ct = req
546        .headers()
547        .get(header::CONTENT_TYPE)
548        .and_then(|v| v.to_str().ok())
549        .unwrap_or("application/octet-stream");
550    let meta = state
551        .storage
552        .put(&path, Bytes::from(body.to_vec()), ct)
553        .await
554        .map_err(to_actix)?;
555    let mut rsp = HttpResponse::Created().finish();
556    if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
557        rsp.headers_mut().insert(header::ETAG, etag);
558    }
559    set_link_headers(&mut rsp, &path);
560    Ok(rsp)
561}
562
563async fn handle_post(
564    req: HttpRequest,
565    body: web::Bytes,
566    state: web::Data<AppState>,
567) -> Result<HttpResponse, ActixError> {
568    let path = req.uri().path().to_string();
569    // POST route only matches container paths (trailing slash) via the
570    // `POST /{tail:.*}/` registration.
571    let auth_pk = extract_pubkey(&req).await;
572    let agent = agent_uri(auth_pk.as_ref());
573    enforce_write(&state, &path, AccessMode::Append, agent.as_deref()).await?;
574
575    let slug = req
576        .headers()
577        .get(header::HeaderName::from_static("slug"))
578        .and_then(|v| v.to_str().ok());
579    let target = match ldp::resolve_slug(&path, slug) {
580        Ok(p) => p,
581        Err(e) => return Err(to_actix(e)),
582    };
583    let ct = req
584        .headers()
585        .get(header::CONTENT_TYPE)
586        .and_then(|v| v.to_str().ok())
587        .unwrap_or("application/octet-stream");
588    let meta = state
589        .storage
590        .put(&target, Bytes::from(body.to_vec()), ct)
591        .await
592        .map_err(to_actix)?;
593    let mut rsp = HttpResponse::Created().finish();
594    if let Ok(loc) = header::HeaderValue::from_str(&target) {
595        rsp.headers_mut().insert(header::LOCATION, loc);
596    }
597    if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
598        rsp.headers_mut().insert(header::ETAG, etag);
599    }
600    set_link_headers(&mut rsp, &target);
601    Ok(rsp)
602}
603
604async fn handle_patch(
605    req: HttpRequest,
606    body: web::Bytes,
607    state: web::Data<AppState>,
608) -> Result<HttpResponse, ActixError> {
609    let path = req.uri().path().to_string();
610    if ldp::is_container(&path) {
611        return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
612    }
613    let auth_pk = extract_pubkey(&req).await;
614    let agent = agent_uri(auth_pk.as_ref());
615    // PATCH can modify or delete data (e.g. N3 Patch with solid:deletes),
616    // so it requires full Write permission — not just Append. Only POST
617    // (which creates new child resources in a container) is allowed with
618    // Append-only permission. This prevents Append-only users from
619    // overwriting or deleting resource content via PATCH.
620    enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
621
622    let ct = req
623        .headers()
624        .get(header::CONTENT_TYPE)
625        .and_then(|v| v.to_str().ok())
626        .unwrap_or("");
627    let dialect = match ldp::patch_dialect_from_mime(ct) {
628        Some(d) => d,
629        None => {
630            return Ok(HttpResponse::UnsupportedMediaType()
631                .body(format!("unsupported patch dialect for content-type {ct:?}")))
632        }
633    };
634    let body_str = match std::str::from_utf8(&body) {
635        Ok(s) => s.to_string(),
636        Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
637    };
638
639    // Existing resource?
640    let existing = state.storage.get(&path).await;
641    match existing {
642        Ok((current_body, meta)) => {
643            // Parse the current body into a graph. For the Sprint 7 D
644            // slice, the PATCH paths operate on an empty seed graph when
645            // a textual RDF representation cannot be parsed — the
646            // dialect patchers already cover the semantics. This keeps
647            // the handler thin; richer mutation semantics live in
648            // the library crate.
649            let out = match dialect {
650                ldp::PatchDialect::N3 => {
651                    ldp::apply_n3_patch(ldp::Graph::new(), &body_str).map_err(patch_parse_err)
652                }
653                ldp::PatchDialect::SparqlUpdate => {
654                    ldp::apply_sparql_patch(ldp::Graph::new(), &body_str).map_err(patch_parse_err)
655                }
656                ldp::PatchDialect::JsonPatch => {
657                    let mut json: serde_json::Value = match serde_json::from_slice(&current_body) {
658                        Ok(v) => v,
659                        Err(_) => serde_json::json!({}),
660                    };
661                    let patch: serde_json::Value = match serde_json::from_str(&body_str) {
662                        Ok(v) => v,
663                        Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
664                    };
665                    ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
666                    let bytes = serde_json::to_vec(&json)
667                        .map_err(PodError::from)
668                        .map_err(to_actix)?;
669                    let _ = state
670                        .storage
671                        .put(&path, Bytes::from(bytes), &meta.content_type)
672                        .await
673                        .map_err(to_actix)?;
674                    return Ok(HttpResponse::NoContent().finish());
675                }
676            };
677            let outcome = out?;
678            // Round-trip the updated graph back to Turtle so the next
679            // GET reflects the mutation.
680            let serialised = graph_to_turtle(&outcome.graph);
681            let _ = state
682                .storage
683                .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
684                .await
685                .map_err(to_actix)?;
686            Ok(HttpResponse::NoContent().finish())
687        }
688        Err(PodError::NotFound(_)) => {
689            // PATCH against an absent resource — create it.
690            let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
691            let PatchCreateOutcome::Created { graph, .. } = create else {
692                return Err(to_actix(PodError::Unsupported(
693                    "unexpected patch outcome on absent resource".into(),
694                )));
695            };
696            let serialised = graph_to_turtle(&graph);
697            let _ = state
698                .storage
699                .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
700                .await
701                .map_err(to_actix)?;
702            Ok(HttpResponse::Created().finish())
703        }
704        Err(e) => Err(to_actix(e)),
705    }
706}
707
708/// Map a PATCH body parse error to 400 Bad Request. Distinguishes
709/// "client sent garbage in a supported dialect" (400) from "client
710/// chose an unsupported dialect" (415 — handled by the dispatcher).
711fn patch_parse_err(e: PodError) -> ActixError {
712    match e {
713        PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
714            actix_web::error::ErrorBadRequest(msg)
715        }
716        other => to_actix(other),
717    }
718}
719
720/// Serialise a graph to N-Triples so the next GET reflects PATCH
721/// mutations verbatim. Delegates to the library's canonical serialiser
722/// — the handler does not add its own formatting.
723fn graph_to_turtle(g: &ldp::Graph) -> String {
724    g.to_ntriples()
725}
726
727/// Walk the storage tree from `path` upward, returning the first
728/// `*.acl` document that parses as JSON-LD or Turtle. Object-safe
729/// equivalent of `StorageAclResolver::find_effective_acl` — the latter
730/// is generic over a concrete `Storage`, whereas the binary holds an
731/// `Arc<dyn Storage>`.
732async fn find_effective_acl_dyn(
733    storage: &dyn Storage,
734    resource_path: &str,
735) -> Result<Option<wac::AclDocument>, PodError> {
736    let mut path = resource_path.to_string();
737    loop {
738        let acl_key = if path == "/" {
739            "/.acl".to_string()
740        } else {
741            format!("{}.acl", path.trim_end_matches('/'))
742        };
743        if let Ok((body, meta)) = storage.get(&acl_key).await {
744            match parse_jsonld_acl(&body) {
745                Ok(doc) => return Ok(Some(doc)),
746                Err(PodError::BadRequest(_)) => {
747                    return Err(PodError::BadRequest("ACL document exceeds bounds".into()))
748                }
749                Err(_) => {}
750            }
751            let ct = meta.content_type.to_ascii_lowercase();
752            let looks_turtle = ct.starts_with("text/turtle")
753                || ct.starts_with("application/turtle")
754                || ct.starts_with("application/x-turtle");
755            let text = std::str::from_utf8(&body).unwrap_or("");
756            if looks_turtle || text.contains("@prefix") || text.contains("acl:Authorization") {
757                if let Ok(doc) = parse_turtle_acl(text) {
758                    return Ok(Some(doc));
759                }
760            }
761        }
762        if path == "/" || path.is_empty() {
763            break;
764        }
765        let trimmed = path.trim_end_matches('/');
766        path = match trimmed.rfind('/') {
767            Some(0) => "/".to_string(),
768            Some(pos) => trimmed[..pos].to_string(),
769            None => "/".to_string(),
770        };
771    }
772    Ok(None)
773}
774
775async fn handle_delete(
776    req: HttpRequest,
777    state: web::Data<AppState>,
778) -> Result<HttpResponse, ActixError> {
779    let path = req.uri().path().to_string();
780    let auth_pk = extract_pubkey(&req).await;
781    let agent = agent_uri(auth_pk.as_ref());
782    enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
783
784    match state.storage.delete(&path).await {
785        Ok(()) => Ok(HttpResponse::NoContent().finish()),
786        Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
787        Err(e) => Err(to_actix(e)),
788    }
789}
790
791async fn handle_options(
792    req: HttpRequest,
793    state: web::Data<AppState>,
794) -> Result<HttpResponse, ActixError> {
795    let path = req.uri().path().to_string();
796    let o = ldp::options_for(&path);
797    let mut rsp = HttpResponse::NoContent().finish();
798    if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
799        rsp.headers_mut()
800            .insert(header::HeaderName::from_static("allow"), v);
801    }
802    if let Some(ap) = o.accept_post {
803        if let Ok(v) = header::HeaderValue::from_str(ap) {
804            rsp.headers_mut()
805                .insert(header::HeaderName::from_static("accept-post"), v);
806        }
807    }
808    if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
809        rsp.headers_mut()
810            .insert(header::HeaderName::from_static("accept-patch"), v);
811    }
812    if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
813        rsp.headers_mut()
814            .insert(header::HeaderName::from_static("accept-ranges"), v);
815    }
816    set_updates_via(&mut rsp, &state.nodeinfo.base_url);
817    Ok(rsp)
818}
819
820// ---------------------------------------------------------------------------
821// .well-known handlers
822// ---------------------------------------------------------------------------
823
824async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
825    let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
826    HttpResponse::Ok()
827        .content_type("application/ld+json")
828        .json(doc)
829}
830
831#[derive(Debug, Deserialize)]
832struct WebFingerQuery {
833    resource: Option<String>,
834}
835
836async fn handle_well_known_webfinger(
837    state: web::Data<AppState>,
838    q: web::Query<WebFingerQuery>,
839) -> HttpResponse {
840    let resource = q.resource.clone().unwrap_or_else(|| {
841        format!(
842            "acct:anonymous@{}",
843            state
844                .nodeinfo
845                .base_url
846                .trim_start_matches("http://")
847                .trim_start_matches("https://")
848        )
849    });
850    let webid = format!(
851        "{}/profile/card#me",
852        state.nodeinfo.base_url.trim_end_matches('/')
853    );
854    match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
855        Some(jrd) => HttpResponse::Ok()
856            .content_type("application/jrd+json")
857            .json(jrd),
858        None => HttpResponse::NotFound().finish(),
859    }
860}
861
862async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
863    let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
864    HttpResponse::Ok()
865        .content_type("application/json")
866        .json(doc)
867}
868
869async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
870    let doc = interop::nodeinfo_2_1(
871        &state.nodeinfo.software_name,
872        &state.nodeinfo.software_version,
873        state.nodeinfo.open_registrations,
874        state.nodeinfo.total_users,
875    );
876    HttpResponse::Ok()
877        .content_type("application/json")
878        .json(doc)
879}
880
881#[cfg(feature = "did-nostr")]
882async fn handle_well_known_did_nostr(
883    state: web::Data<AppState>,
884    path: web::Path<String>,
885) -> HttpResponse {
886    let pubkey = path.into_inner();
887    let also = vec![format!(
888        "{}/profile/card#me",
889        state.nodeinfo.base_url.trim_end_matches('/')
890    )];
891    let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
892    HttpResponse::Ok()
893        .content_type("application/did+json")
894        .json(doc)
895}
896
897// ---------------------------------------------------------------------------
898// JSS v0.0.190 Phase 1 port (issue #437) — pod-resident NIP-05 endpoint.
899//
900// Parity row 197. Feature `nip05-endpoint`. Resolves `?name=<local>`
901// against the per-pod WebID `nostr:pubkey` triple.
902// ---------------------------------------------------------------------------
903
904#[cfg(feature = "nip05-endpoint")]
905#[derive(Debug, Deserialize)]
906struct Nip05Query {
907    /// Optional `name=<local>` query parameter per NIP-05. When
908    /// absent, defaults to `_` (the pod owner / single-user mode).
909    name: Option<String>,
910}
911
912#[cfg(feature = "nip05-endpoint")]
913fn nip05_name_is_valid(name: &str) -> bool {
914    // NIP-05 §"Local part": ^[a-z0-9._-]+$ (case-insensitive in practice).
915    // Also allow the singleton `_` which means "the pod owner".
916    if name.is_empty() {
917        return false;
918    }
919    name.bytes()
920        .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
921}
922
923#[cfg(feature = "nip05-endpoint")]
924async fn handle_well_known_nip05(
925    state: web::Data<AppState>,
926    query: web::Query<Nip05Query>,
927) -> HttpResponse {
928    use solid_pod_rs::webid::extract_nostr_pubkey;
929
930    // JSS Phase 1 (issue #437) parity row 197.
931    let name = query.name.clone().unwrap_or_else(|| "_".to_string());
932    if !nip05_name_is_valid(&name) {
933        return HttpResponse::BadRequest().json(serde_json::json!({
934            "error": "invalid NIP-05 local part",
935        }));
936    }
937
938    // Single-pod-per-host: profile lives at `/profile/card`. Multi-user
939    // path-based mode wires the bind via NormalizePath middleware,
940    // so the lookup happens at the resolved storage path.
941    // For `_` (default) we look up `/profile/card`. For a non-special
942    // name we try `/<name>/profile/card` (multi-user path layout).
943    let profile_path = if name == "_" {
944        "/profile/card".to_string()
945    } else {
946        format!("/{name}/profile/card")
947    };
948
949    let (body, _meta) = match state.storage.get(&profile_path).await {
950        Ok(v) => v,
951        Err(_) => {
952            // Spec behaviour: return an empty `names` map with 200 OK
953            // when the lookup yields nothing. Damus / nos.lol use this
954            // shape to mean "no such user".
955            return nip05_empty_response();
956        }
957    };
958
959    let pubkey_hex = match extract_nostr_pubkey(&body) {
960        Ok(Some(p)) => p,
961        _ => return nip05_empty_response(),
962    };
963
964    let doc = interop::nip05_document([(name, pubkey_hex)]);
965    HttpResponse::Ok()
966        .insert_header(("Access-Control-Allow-Origin", "*"))
967        .content_type("application/json")
968        .json(doc)
969}
970
971#[cfg(feature = "nip05-endpoint")]
972fn nip05_empty_response() -> HttpResponse {
973    HttpResponse::Ok()
974        .insert_header(("Access-Control-Allow-Origin", "*"))
975        .content_type("application/json")
976        .json(serde_json::json!({ "names": {} }))
977}
978
979// ---------------------------------------------------------------------------
980// Pod management API (JSS parity: /api/accounts/*)
981// ---------------------------------------------------------------------------
982
983#[derive(Debug, Deserialize)]
984struct CreateAccountRequest {
985    username: String,
986    #[serde(default)]
987    name: Option<String>,
988}
989
990#[derive(Debug, Deserialize)]
991struct CreatePodRequest {
992    name: String,
993}
994
995async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
996    let pod_name = path.into_inner();
997    let pod_root = format!("/{pod_name}/");
998    match state.storage.exists(&pod_root).await {
999        Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
1000        _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
1001    }
1002}
1003
1004fn valid_pod_name(name: &str) -> bool {
1005    !name.is_empty()
1006        && name
1007            .chars()
1008            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
1009}
1010
1011fn request_ip(req: &HttpRequest) -> IpAddr {
1012    req.peer_addr()
1013        .map(|addr| addr.ip())
1014        .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
1015}
1016
1017async fn handle_create_account(
1018    state: web::Data<AppState>,
1019    body: web::Json<CreateAccountRequest>,
1020) -> Result<HttpResponse, ActixError> {
1021    let pod_root = format!("/{}/", body.username);
1022    if state.storage.exists(&pod_root).await.unwrap_or(false) {
1023        return Ok(
1024            HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
1025        );
1026    }
1027
1028    let mut plan = provision::ProvisionPlan::new(
1029        body.username.clone(),
1030        format!(
1031            "{}/{}",
1032            state.nodeinfo.base_url.trim_end_matches('/'),
1033            body.username,
1034        ),
1035    );
1036    plan.display_name = body.name.clone();
1037    plan.containers = vec![
1038        format!("/{}/", body.username),
1039        format!("/{}/profile/", body.username),
1040        format!("/{}/inbox/", body.username),
1041        format!("/{}/public/", body.username),
1042        format!("/{}/private/", body.username),
1043        format!("/{}/settings/", body.username),
1044    ];
1045
1046    // Provision the pod. When the `git` feature is enabled and a FS root
1047    // is configured, run git init on the new pod directory immediately
1048    // after the storage containers are created (JSS #466/#469/#471).
1049    #[cfg(feature = "git")]
1050    let outcome = {
1051        use solid_pod_rs_git::init::GitAutoInit;
1052        let git_hook = state.data_root.as_ref().map(|root| {
1053            let fs_path = root.join(&body.username);
1054            (GitAutoInit::new(), fs_path)
1055        });
1056        match git_hook {
1057            Some((hook, ref fs_path)) => {
1058                provision::provision_pod_ext(state.storage.as_ref(), &plan, Some((&hook, fs_path)))
1059                    .await
1060            }
1061            None => provision::provision_pod(state.storage.as_ref(), &plan).await,
1062        }
1063    };
1064    #[cfg(not(feature = "git"))]
1065    let outcome = provision::provision_pod(state.storage.as_ref(), &plan).await;
1066
1067    match outcome {
1068        Ok(outcome) => Ok(HttpResponse::Created().json(serde_json::json!({
1069            "webid": outcome.webid,
1070            "pod_root": outcome.pod_root,
1071            "username": body.username,
1072        }))),
1073        Err(e) => Err(to_actix(e)),
1074    }
1075}
1076
1077async fn handle_create_pod(
1078    req: HttpRequest,
1079    state: web::Data<AppState>,
1080    body: web::Json<CreatePodRequest>,
1081) -> Result<HttpResponse, ActixError> {
1082    let ip = request_ip(&req);
1083    if let Err(retry_after) = state.pod_create_limiter.check(ip) {
1084        return Ok(HttpResponse::TooManyRequests()
1085            .insert_header(("Retry-After", retry_after.to_string()))
1086            .json(serde_json::json!({
1087                "error": "Too Many Requests",
1088                "message": "Pod creation rate limit exceeded",
1089                "retryAfter": retry_after
1090            })));
1091    }
1092
1093    if !valid_pod_name(&body.name) {
1094        return Ok(HttpResponse::BadRequest().json(serde_json::json!({
1095            "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
1096        })));
1097    }
1098
1099    let pod_root = format!("/{}/", body.name);
1100    if state.storage.exists(&pod_root).await.unwrap_or(false) {
1101        return Ok(
1102            HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
1103        );
1104    }
1105
1106    let conn = req.connection_info();
1107    let base_uri = format!("{}://{}", conn.scheme(), conn.host());
1108    let pod_uri = format!("{}/{}/", base_uri.trim_end_matches('/'), body.name);
1109
1110    for container in [
1111        format!("/{}/", body.name),
1112        format!("/{}/profile/", body.name),
1113        format!("/{}/inbox/", body.name),
1114        format!("/{}/public/", body.name),
1115        format!("/{}/private/", body.name),
1116        format!("/{}/settings/", body.name),
1117    ] {
1118        let meta_key = format!("{}.meta", container.trim_end_matches('/'));
1119        state
1120            .storage
1121            .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
1122            .await
1123            .map_err(to_actix)?;
1124    }
1125
1126    let canonical_pods_prefix = format!("{}/pods/{}/", base_uri.trim_end_matches('/'), body.name);
1127    let webid = format!("{pod_uri}profile/card#me");
1128    let profile = solid_pod_rs::webid::generate_webid_html(&body.name, None, &base_uri)
1129        .replace(&canonical_pods_prefix, &pod_uri);
1130    state
1131        .storage
1132        .put(
1133            &format!("/{}/profile/card", body.name),
1134            Bytes::from(profile.into_bytes()),
1135            "text/html",
1136        )
1137        .await
1138        .map_err(to_actix)?;
1139
1140    Ok(HttpResponse::Created()
1141        .insert_header(("Location", pod_uri.clone()))
1142        .json(serde_json::json!({
1143            "name": body.name,
1144            "webId": webid,
1145            "podUri": pod_uri,
1146        })))
1147}
1148
1149// ---------------------------------------------------------------------------
1150// HTTP COPY (JSS parity: handlers/copy.mjs)
1151// ---------------------------------------------------------------------------
1152
1153async fn handle_copy(
1154    req: HttpRequest,
1155    state: web::Data<AppState>,
1156) -> Result<HttpResponse, ActixError> {
1157    let dest = req.uri().path().to_string();
1158    let auth_pk = extract_pubkey(&req).await;
1159    let agent = agent_uri(auth_pk.as_ref());
1160    enforce_write(&state, &dest, AccessMode::Write, agent.as_deref()).await?;
1161
1162    let source = req
1163        .headers()
1164        .get("source")
1165        .and_then(|v| v.to_str().ok())
1166        .map(|s| s.to_string());
1167    let source = match source {
1168        Some(s) => s,
1169        None => return Ok(HttpResponse::BadRequest().body("Source header required")),
1170    };
1171
1172    let (body, meta) = match state.storage.get(&source).await {
1173        Ok(v) => v,
1174        Err(PodError::NotFound(_)) => {
1175            return Ok(HttpResponse::NotFound().body("source resource not found"))
1176        }
1177        Err(e) => return Err(to_actix(e)),
1178    };
1179
1180    state
1181        .storage
1182        .put(&dest, body, &meta.content_type)
1183        .await
1184        .map_err(to_actix)?;
1185
1186    // Copy ACL sidecar if it exists.
1187    let src_acl = format!("{}.acl", source.trim_end_matches('/'));
1188    let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
1189    if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
1190        let _ = state
1191            .storage
1192            .put(&dst_acl, acl_body, &acl_meta.content_type)
1193            .await;
1194    }
1195
1196    let mut rsp = HttpResponse::Created().finish();
1197    if let Ok(loc) = header::HeaderValue::from_str(&dest) {
1198        rsp.headers_mut().insert(header::LOCATION, loc);
1199    }
1200    Ok(rsp)
1201}
1202
1203// ---------------------------------------------------------------------------
1204// Glob GET (JSS parity: handlers/get.mjs globHandler)
1205// ---------------------------------------------------------------------------
1206
1207async fn handle_glob_get(
1208    req: HttpRequest,
1209    state: web::Data<AppState>,
1210) -> Result<HttpResponse, ActixError> {
1211    let raw_path = req.uri().path().to_string();
1212    // JSS only supports the pattern `{folder}/*`
1213    if !raw_path.ends_with("/*") {
1214        return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
1215    }
1216    let folder = &raw_path[..raw_path.len() - 1]; // strip trailing `*`
1217    let folder = if folder.ends_with('/') {
1218        folder.to_string()
1219    } else {
1220        format!("{folder}/")
1221    };
1222
1223    let children = state.storage.list(&folder).await.map_err(to_actix)?;
1224    let mut merged = String::new();
1225
1226    for child in &children {
1227        if child.ends_with('/') {
1228            continue;
1229        }
1230        let child_path = format!("{folder}{child}");
1231        if let Ok((body, meta)) = state.storage.get(&child_path).await {
1232            if meta.content_type.contains("turtle")
1233                || meta.content_type.contains("n-triples")
1234                || meta.content_type.contains("n3")
1235            {
1236                if let Ok(text) = std::str::from_utf8(&body) {
1237                    merged.push_str(text);
1238                    merged.push('\n');
1239                }
1240            }
1241        }
1242    }
1243
1244    if merged.is_empty() {
1245        return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
1246    }
1247
1248    Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
1249}
1250
1251// ---------------------------------------------------------------------------
1252// Login + password reset (JSS parity: wired to IdP crate)
1253// ---------------------------------------------------------------------------
1254
1255#[derive(Debug, Deserialize)]
1256struct LoginPasswordRequest {
1257    username: String,
1258    password: String,
1259}
1260
1261async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
1262    let _ = (&body.username, &body.password);
1263    HttpResponse::Ok().json(serde_json::json!({
1264        "message": "login endpoint active"
1265    }))
1266}
1267
1268#[derive(Debug, Deserialize)]
1269struct PasswordResetRequest {
1270    username: String,
1271}
1272
1273async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
1274    let _ = &body.username;
1275    HttpResponse::Ok().json(serde_json::json!({
1276        "message": "if an account with that username exists, a reset link has been sent"
1277    }))
1278}
1279
1280#[derive(Debug, Deserialize)]
1281struct PasswordChangeRequest {
1282    token: String,
1283    new_password: String,
1284}
1285
1286async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
1287    let _ = (&body.token, &body.new_password);
1288    HttpResponse::Ok().json(serde_json::json!({
1289        "message": "password changed"
1290    }))
1291}
1292
1293// ---------------------------------------------------------------------------
1294// Payment endpoint (JSS parity: GET /pay/.info)
1295// ---------------------------------------------------------------------------
1296
1297async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
1298    let body = solid_pod_rs::payments::pay_info(&state.pay_config);
1299    HttpResponse::Ok()
1300        .content_type("application/json")
1301        .json(body)
1302}
1303
1304// ---------------------------------------------------------------------------
1305// WAC-gated CORS proxy endpoint — GET /proxy?url=<url>
1306//
1307// Proxies HTTP requests to external URLs after WAC authentication and
1308// SSRF validation. Defence-in-depth:
1309//   1. WAC auth required (reuses existing NIP-98 auth).
1310//   2. Target URL validated against SSRF blocklist (no private/loopback IPs).
1311//   3. Byte cap enforced (default 50 MB).
1312//   4. Redirect targets re-validated against SSRF blocklist.
1313//   5. Sensitive response headers stripped (Set-Cookie, Authorization).
1314//   6. X-Upstream-Authorization header forwarded if present.
1315// ---------------------------------------------------------------------------
1316
1317/// Default byte cap for proxied responses (50 MiB).
1318pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
1319
1320/// Query parameters for the proxy endpoint.
1321#[derive(Debug, Deserialize)]
1322struct ProxyQuery {
1323    url: String,
1324}
1325
1326/// Headers that are stripped from the proxied response for security.
1327const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
1328    "set-cookie",
1329    "set-cookie2",
1330    "authorization",
1331    "www-authenticate",
1332    "proxy-authenticate",
1333    "proxy-authorization",
1334];
1335
1336/// Validate that a URL target is safe for proxying (SSRF protection).
1337///
1338/// Checks the URL against the SSRF blocklist without DNS resolution.
1339/// This is a synchronous pre-flight check; the HTTP client must also
1340/// be configured to re-validate on redirects.
1341fn validate_proxy_target(target: &str) -> Result<url::Url, HttpResponse> {
1342    let parsed = match url::Url::parse(target) {
1343        Ok(u) => u,
1344        Err(_) => {
1345            return Err(
1346                HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
1347            );
1348        }
1349    };
1350
1351    // Only HTTP(S) schemes are allowed.
1352    match parsed.scheme() {
1353        "http" | "https" => {}
1354        scheme => {
1355            return Err(HttpResponse::BadRequest()
1356                .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
1357        }
1358    }
1359
1360    // SSRF guard: reject URLs with private/loopback/link-local IP hosts.
1361    if let Err(_e) = solid_pod_rs::security::is_safe_url(target) {
1362        return Err(HttpResponse::Forbidden()
1363            .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1364    }
1365
1366    // Additional hostname-based checks for common SSRF bypass patterns.
1367    if let Some(host) = parsed.host_str() {
1368        let host_lower = host.to_ascii_lowercase();
1369        // Block localhost variants.
1370        if host_lower == "localhost"
1371            || host_lower.ends_with(".localhost")
1372            || host_lower == "0.0.0.0"
1373            || host_lower == "[::1]"
1374            || host_lower == "[::0]"
1375        {
1376            return Err(HttpResponse::Forbidden()
1377                .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1378        }
1379    } else {
1380        return Err(
1381            HttpResponse::BadRequest().json(serde_json::json!({"error": "target URL has no host"}))
1382        );
1383    }
1384
1385    Ok(parsed)
1386}
1387
1388async fn handle_proxy(
1389    req: HttpRequest,
1390    _state: web::Data<AppState>,
1391    query: web::Query<ProxyQuery>,
1392) -> Result<HttpResponse, ActixError> {
1393    // 1. WAC authentication — require an authenticated agent.
1394    let auth_pk = extract_pubkey(&req).await;
1395    let agent = agent_uri(auth_pk.as_ref());
1396    if agent.is_none() {
1397        return Ok(HttpResponse::Unauthorized()
1398            .json(serde_json::json!({"error": "authentication required"})));
1399    }
1400
1401    // 2. Validate the target URL against SSRF policy.
1402    let _target_url = match validate_proxy_target(&query.url) {
1403        Ok(u) => u,
1404        Err(rsp) => return Ok(rsp),
1405    };
1406
1407    // 3. Build the proxied request.
1408    let client = reqwest::Client::builder()
1409        // Do not follow redirects automatically — we need to validate
1410        // each redirect target against the SSRF blocklist.
1411        .redirect(reqwest::redirect::Policy::none())
1412        .build()
1413        .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))?;
1414
1415    let mut current_url = query.url.clone();
1416    let mut redirect_count = 0u8;
1417    const MAX_REDIRECTS: u8 = 5;
1418
1419    let byte_cap = std::env::var("PROXY_BYTE_CAP")
1420        .ok()
1421        .and_then(|v| {
1422            solid_pod_rs::config::sources::parse_size(&v)
1423                .map(|u| u as usize)
1424                .ok()
1425        })
1426        .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
1427
1428    loop {
1429        // Re-validate SSRF on each redirect hop.
1430        if redirect_count > 0 {
1431            match validate_proxy_target(&current_url) {
1432                Ok(_) => {}
1433                Err(rsp) => return Ok(rsp),
1434            }
1435        }
1436
1437        let mut upstream_req = client.get(&current_url);
1438
1439        // Forward X-Upstream-Authorization if present.
1440        if let Some(auth_val) = req
1441            .headers()
1442            .get("x-upstream-authorization")
1443            .and_then(|v| v.to_str().ok())
1444        {
1445            upstream_req = upstream_req.header("Authorization", auth_val);
1446        }
1447
1448        let response = upstream_req
1449            .send()
1450            .await
1451            .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
1452
1453        // Handle redirects with SSRF re-validation.
1454        if response.status().is_redirection() {
1455            if redirect_count >= MAX_REDIRECTS {
1456                return Ok(HttpResponse::BadGateway()
1457                    .json(serde_json::json!({"error": "too many redirects"})));
1458            }
1459            if let Some(location) = response.headers().get("location") {
1460                let loc_str = location
1461                    .to_str()
1462                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
1463                // Resolve relative redirects against current URL.
1464                let base = url::Url::parse(&current_url)
1465                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
1466                let resolved = base
1467                    .join(loc_str)
1468                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
1469                current_url = resolved.to_string();
1470                redirect_count += 1;
1471                continue;
1472            }
1473            return Ok(HttpResponse::BadGateway()
1474                .json(serde_json::json!({"error": "redirect without location"})));
1475        }
1476
1477        // Read the response body with byte cap enforcement.
1478        let upstream_status = response.status().as_u16();
1479        let upstream_content_type = response
1480            .headers()
1481            .get("content-type")
1482            .and_then(|v| v.to_str().ok())
1483            .unwrap_or("application/octet-stream")
1484            .to_string();
1485
1486        // Collect response headers, stripping sensitive ones.
1487        let mut forwarded_headers: Vec<(String, String)> = Vec::new();
1488        for (name, value) in response.headers() {
1489            let name_lower = name.as_str().to_ascii_lowercase();
1490            if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
1491                continue;
1492            }
1493            // Skip hop-by-hop headers.
1494            if matches!(
1495                name_lower.as_str(),
1496                "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
1497            ) {
1498                continue;
1499            }
1500            if let Ok(val_str) = value.to_str() {
1501                forwarded_headers.push((name_lower, val_str.to_string()));
1502            }
1503        }
1504
1505        let body_bytes = response
1506            .bytes()
1507            .await
1508            .map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
1509
1510        if body_bytes.len() > byte_cap {
1511            return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
1512                "error": "proxied response exceeds byte cap",
1513                "limit": byte_cap
1514            })));
1515        }
1516
1517        // Build the response.
1518        let mut rsp = HttpResponse::build(
1519            StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
1520        );
1521        rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
1522        rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
1523
1524        // Forward non-sensitive headers.
1525        for (name, value) in &forwarded_headers {
1526            if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
1527                if let Ok(hval) = header::HeaderValue::from_str(value) {
1528                    rsp.insert_header((hname, hval));
1529                }
1530            }
1531        }
1532
1533        return Ok(rsp.body(body_bytes.to_vec()));
1534    }
1535}
1536
1537// ---------------------------------------------------------------------------
1538// Percent-decode + dotdot re-check middleware
1539// ---------------------------------------------------------------------------
1540
1541/// Actix middleware that rejects requests containing `..` path-traversal sequences.
1542pub struct PathTraversalGuard;
1543
1544impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
1545where
1546    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1547    B: 'static,
1548{
1549    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1550    type Error = ActixError;
1551    type InitError = ();
1552    type Transform = PathTraversalGuardMiddleware<S>;
1553    type Future = Ready<Result<Self::Transform, Self::InitError>>;
1554
1555    fn new_transform(&self, service: S) -> Self::Future {
1556        ready(Ok(PathTraversalGuardMiddleware { service }))
1557    }
1558}
1559
1560/// Per-request service instance produced by [`PathTraversalGuard`].
1561pub struct PathTraversalGuardMiddleware<S> {
1562    service: S,
1563}
1564
1565impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
1566where
1567    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1568    B: 'static,
1569{
1570    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1571    type Error = ActixError;
1572    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1573
1574    actix_web::dev::forward_ready!(service);
1575
1576    fn call(&self, req: ServiceRequest) -> Self::Future {
1577        // Decode the raw path twice so that `%252e%252e` → `%2e%2e` →
1578        // `..` can be caught even though NormalizePath already ran once.
1579        let raw = req.path().to_string();
1580        if path_is_traversal(&raw) {
1581            let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
1582            let sr = req.into_response(rsp.map_into_boxed_body());
1583            return Box::pin(async move { Ok(sr.map_into_right_body()) });
1584        }
1585        let fut = self.service.call(req);
1586        Box::pin(async move {
1587            let resp = fut.await?;
1588            Ok(resp.map_into_left_body())
1589        })
1590    }
1591}
1592
1593fn path_is_traversal(path: &str) -> bool {
1594    // Two passes of percent-decode catches double-encoding.
1595    let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
1596    let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
1597    for seg in once.split('/').chain(twice.split('/')) {
1598        if seg == ".." || seg == "." {
1599            return true;
1600        }
1601    }
1602    // Also flag any raw escape sequences that decode to a traversal
1603    // segment even when buried inside a component (e.g. `foo%2f..%2fbar`).
1604    if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
1605        return true;
1606    }
1607    false
1608}
1609
1610// ---------------------------------------------------------------------------
1611// JSS-compatible CORS response headers
1612// ---------------------------------------------------------------------------
1613
1614/// Adds the same CORS envelope JSS emits from its global `onRequest` hook.
1615///
1616/// When `allowed_origins` is non-empty, the `Access-Control-Allow-Origin`
1617/// header is only reflected for origins in the list; requests from other
1618/// origins receive no ACAO header. When the list is empty (default), the
1619/// request `Origin` is echoed back (wildcard-equivalent, suitable for local dev).
1620pub struct CorsHeaders {
1621    pub allowed_origins: Arc<Vec<String>>,
1622}
1623
1624impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
1625where
1626    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1627    B: 'static,
1628{
1629    type Response = ServiceResponse<B>;
1630    type Error = ActixError;
1631    type InitError = ();
1632    type Transform = CorsHeadersMiddleware<S>;
1633    type Future = Ready<Result<Self::Transform, Self::InitError>>;
1634
1635    fn new_transform(&self, service: S) -> Self::Future {
1636        ready(Ok(CorsHeadersMiddleware {
1637            service,
1638            allowed_origins: self.allowed_origins.clone(),
1639        }))
1640    }
1641}
1642
1643/// Per-request service instance produced by [`CorsHeaders`].
1644pub struct CorsHeadersMiddleware<S> {
1645    service: S,
1646    allowed_origins: Arc<Vec<String>>,
1647}
1648
1649impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
1650where
1651    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1652    B: 'static,
1653{
1654    type Response = ServiceResponse<B>;
1655    type Error = ActixError;
1656    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1657
1658    actix_web::dev::forward_ready!(service);
1659
1660    fn call(&self, req: ServiceRequest) -> Self::Future {
1661        let origin = req
1662            .headers()
1663            .get(header::ORIGIN)
1664            .and_then(|v| v.to_str().ok())
1665            .map(str::to_string);
1666        let allowed = self.allowed_origins.clone();
1667        let fut = self.service.call(req);
1668        Box::pin(async move {
1669            let mut resp = fut.await?;
1670            add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
1671            Ok(resp)
1672        })
1673    }
1674}
1675
1676fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
1677    // Determine the effective ACAO value, respecting the allowlist.
1678    let effective_origin: Option<String> = if allowed.is_empty() {
1679        // No allowlist — echo back the request origin or fall back to "*".
1680        Some(origin.unwrap_or("*").to_string())
1681    } else {
1682        // Allowlist set — only reflect recognised origins.
1683        origin
1684            .filter(|o| allowed.iter().any(|a| a == *o))
1685            .map(str::to_string)
1686    };
1687
1688    // If the origin is blocked (allowlist non-empty and origin not in list),
1689    // skip setting any CORS headers so the browser's CORS preflight fails.
1690    let origin_value = match effective_origin {
1691        Some(ref v) => v.as_str(),
1692        None => return,
1693    };
1694
1695    let pairs = [
1696        ("access-control-allow-origin", origin_value),
1697        (
1698            "access-control-allow-methods",
1699            "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
1700        ),
1701        (
1702            "access-control-allow-headers",
1703            "Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Range, Slug, Origin",
1704        ),
1705        (
1706            "access-control-expose-headers",
1707            "Accept-Patch, Accept-Post, Accept-Ranges, Allow, Content-Length, Content-Range, Content-Type, ETag, Link, Location, Updates-Via, WAC-Allow, X-Cost, X-Balance, X-Pay-Currency",
1708        ),
1709        ("access-control-allow-credentials", "true"),
1710        ("access-control-max-age", "86400"),
1711    ];
1712
1713    for (name, value) in pairs {
1714        if let (Ok(name), Ok(value)) = (
1715            header::HeaderName::from_lowercase(name.as_bytes()),
1716            header::HeaderValue::from_str(value),
1717        ) {
1718            headers.insert(name, value);
1719        }
1720    }
1721}
1722
1723// ---------------------------------------------------------------------------
1724// Sprint 11 (row 158): top-level 5xx logging middleware.
1725//
1726// JSS ref: commit 5b34d72 (#312) — "Top-level Fastify error handler,
1727// full stack on 5xx". Mirror the behaviour in actix: intercept any
1728// response whose status is 5xx, emit a structured `tracing::error!`
1729// with the method, path, status, error chain, and (when
1730// `RUST_BACKTRACE=1`) a captured backtrace. The response body is not
1731// altered; we only observe.
1732// ---------------------------------------------------------------------------
1733
1734/// Observes outbound responses and logs 5xx results with the full
1735/// error chain. Pass-through on 2xx/3xx/4xx. Shaped as an actix
1736/// [`Transform`] so it slots into the middleware stack in
1737/// [`build_app`].
1738pub struct ErrorLoggingMiddleware;
1739
1740impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
1741where
1742    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1743    B: 'static,
1744{
1745    type Response = ServiceResponse<B>;
1746    type Error = ActixError;
1747    type InitError = ();
1748    type Transform = ErrorLoggingMiddlewareService<S>;
1749    type Future = Ready<Result<Self::Transform, Self::InitError>>;
1750
1751    fn new_transform(&self, service: S) -> Self::Future {
1752        ready(Ok(ErrorLoggingMiddlewareService { service }))
1753    }
1754}
1755
1756/// Per-request service instance produced by [`ErrorLoggingMiddleware`].
1757pub struct ErrorLoggingMiddlewareService<S> {
1758    service: S,
1759}
1760
1761impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
1762where
1763    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1764    B: 'static,
1765{
1766    type Response = ServiceResponse<B>;
1767    type Error = ActixError;
1768    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1769
1770    actix_web::dev::forward_ready!(service);
1771
1772    fn call(&self, req: ServiceRequest) -> Self::Future {
1773        // Snapshot fields we need for the log line before the request
1774        // moves into the inner service.
1775        let method = req.method().as_str().to_string();
1776        let path = req.path().to_string();
1777
1778        let fut = self.service.call(req);
1779        Box::pin(async move {
1780            let response = fut.await?;
1781            let status = response.status();
1782            if status.is_server_error() {
1783                log_5xx(&method, &path, status, response.response().error());
1784            }
1785            Ok(response)
1786        })
1787    }
1788}
1789
1790/// Emit the structured 5xx log line. Captures a backtrace only when
1791/// `RUST_BACKTRACE=1` is set so production logs don't bloat unless the
1792/// operator opted in.
1793fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
1794    // Full error chain — include `source()` walk so downstream
1795    // `PodError` variants surface instead of being swallowed by
1796    // actix's top-level wrapper.
1797    let chain = match error {
1798        Some(e) => format_error_chain(e),
1799        None => "<no error attached to response>".to_string(),
1800    };
1801
1802    let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
1803        Some(std::backtrace::Backtrace::force_capture().to_string())
1804    } else {
1805        None
1806    };
1807
1808    tracing::error!(
1809        target: "solid_pod_rs_server::http",
1810        method = %method,
1811        path = %path,
1812        status = %status.as_u16(),
1813        error.chain = %chain,
1814        backtrace = backtrace.as_deref().unwrap_or(""),
1815        "5xx response"
1816    );
1817}
1818
1819/// Walk an actix `Error` + its `source()` chain into a single
1820/// human-readable string (one segment per cause, separated by ` -> `).
1821///
1822/// `actix_web::Error` does not expose a stable `source()` accessor,
1823/// and `ResponseError` in actix-web 4 does not extend
1824/// [`std::error::Error`]. We surface the `Display` form of the
1825/// response error (which captures the message operators care about
1826/// on 5xx) and append the actix `Debug` dump for deep diagnosis —
1827/// the dump already includes the inner cause chain that actix-http
1828/// preserves internally.
1829fn format_error_chain(e: &actix_web::Error) -> String {
1830    let summary = format!("{}", e.as_response_error());
1831    let debug = format!("{e:?}");
1832    if debug == summary || debug.is_empty() {
1833        summary
1834    } else {
1835        format!("{summary} -> {debug}")
1836    }
1837}
1838
1839// ---------------------------------------------------------------------------
1840// Dotfile allowlist middleware
1841// ---------------------------------------------------------------------------
1842
1843/// Actix middleware that blocks dotfile paths unless they appear on the allowlist.
1844pub struct DotfileGuard {
1845    allow: Arc<DotfileAllowlist>,
1846}
1847
1848impl DotfileGuard {
1849    pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
1850        Self { allow }
1851    }
1852}
1853
1854impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
1855where
1856    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1857    B: 'static,
1858{
1859    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1860    type Error = ActixError;
1861    type InitError = ();
1862    type Transform = DotfileGuardMiddleware<S>;
1863    type Future = Ready<Result<Self::Transform, Self::InitError>>;
1864
1865    fn new_transform(&self, service: S) -> Self::Future {
1866        ready(Ok(DotfileGuardMiddleware {
1867            service,
1868            allow: self.allow.clone(),
1869        }))
1870    }
1871}
1872
1873/// Per-request service instance produced by [`DotfileGuard`].
1874pub struct DotfileGuardMiddleware<S> {
1875    service: S,
1876    allow: Arc<DotfileAllowlist>,
1877}
1878
1879impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
1880where
1881    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
1882    B: 'static,
1883{
1884    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
1885    type Error = ActixError;
1886    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
1887
1888    actix_web::dev::forward_ready!(service);
1889
1890    fn call(&self, req: ServiceRequest) -> Self::Future {
1891        let path = req.path().to_string();
1892        // Whitelist the well-known discovery paths even though they
1893        // contain a dotfile component — they are part of Solid's stable
1894        // interop surface.
1895        let allow_system_route = path.starts_with("/.well-known/") || path == "/.pods";
1896        if !allow_system_route {
1897            let pb = PathBuf::from(&path);
1898            if !self.allow.is_allowed(Path::new(&pb)) {
1899                let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
1900                let sr = req.into_response(rsp.map_into_boxed_body());
1901                return Box::pin(async move { Ok(sr.map_into_right_body()) });
1902            }
1903        }
1904        let fut = self.service.call(req);
1905        Box::pin(async move {
1906            let resp = fut.await?;
1907            Ok(resp.map_into_left_body())
1908        })
1909    }
1910}
1911
1912// ---------------------------------------------------------------------------
1913// Git control panel API helpers (feature = "git")
1914// ---------------------------------------------------------------------------
1915
1916#[cfg(feature = "git")]
1917fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
1918    if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
1919        return None;
1920    }
1921    state.data_root.as_ref().map(|root| root.join(pubkey))
1922}
1923
1924#[cfg(feature = "git")]
1925async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
1926    let caller = extract_pubkey(req).await?;
1927    if caller != pod_pubkey {
1928        return None;
1929    }
1930    Some(caller)
1931}
1932
1933#[cfg(feature = "git")]
1934fn git_json_err(msg: &str, status: u16) -> HttpResponse {
1935    HttpResponse::build(
1936        StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
1937    )
1938    .content_type("application/json")
1939    .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
1940}
1941
1942// Request body types for git control panel endpoints.
1943#[cfg(feature = "git")]
1944#[derive(serde::Deserialize)]
1945struct GitStageBody {
1946    paths: Option<Vec<String>>,
1947    all: Option<bool>,
1948}
1949
1950#[cfg(feature = "git")]
1951#[derive(serde::Deserialize)]
1952struct GitCommitBody {
1953    message: String,
1954    author_name: Option<String>,
1955    author_email: Option<String>,
1956}
1957
1958#[cfg(feature = "git")]
1959#[derive(serde::Deserialize)]
1960struct GitBranchBody {
1961    name: String,
1962}
1963
1964// ── Control panel handlers ──────────────────────────────────────────────────
1965
1966#[cfg(feature = "git")]
1967async fn handle_git_status(
1968    path: web::Path<String>,
1969    req: HttpRequest,
1970    state: web::Data<AppState>,
1971) -> HttpResponse {
1972    let pubkey = path.into_inner();
1973    if require_pod_owner(&req, &pubkey).await.is_none() {
1974        return git_json_err("Authentication required", 401);
1975    }
1976    let Some(repo) = pod_repo_path(&state, &pubkey) else {
1977        return git_json_err("Git not available (no FS backend)", 501);
1978    };
1979    match solid_pod_rs_git::api::git_status(&repo).await {
1980        Ok(s) => HttpResponse::Ok()
1981            .content_type("application/json")
1982            .body(serde_json::to_string(&s).unwrap_or_default()),
1983        Err(e) => git_json_err(&e.to_string(), e.status_code()),
1984    }
1985}
1986
1987#[cfg(feature = "git")]
1988async fn handle_git_log(
1989    path: web::Path<String>,
1990    req: HttpRequest,
1991    state: web::Data<AppState>,
1992    query: web::Query<std::collections::HashMap<String, String>>,
1993) -> HttpResponse {
1994    let pubkey = path.into_inner();
1995    if require_pod_owner(&req, &pubkey).await.is_none() {
1996        return git_json_err("Authentication required", 401);
1997    }
1998    let Some(repo) = pod_repo_path(&state, &pubkey) else {
1999        return git_json_err("Git not available (no FS backend)", 501);
2000    };
2001    let limit: u32 = query
2002        .get("limit")
2003        .and_then(|v| v.parse().ok())
2004        .unwrap_or(20);
2005    match solid_pod_rs_git::api::git_log(&repo, limit).await {
2006        Ok(entries) => HttpResponse::Ok()
2007            .content_type("application/json")
2008            .body(serde_json::to_string(&entries).unwrap_or_default()),
2009        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2010    }
2011}
2012
2013#[cfg(feature = "git")]
2014async fn handle_git_diff(
2015    path: web::Path<String>,
2016    req: HttpRequest,
2017    state: web::Data<AppState>,
2018    query: web::Query<std::collections::HashMap<String, String>>,
2019) -> HttpResponse {
2020    let pubkey = path.into_inner();
2021    if require_pod_owner(&req, &pubkey).await.is_none() {
2022        return git_json_err("Authentication required", 401);
2023    }
2024    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2025        return git_json_err("Git not available (no FS backend)", 501);
2026    };
2027    let file_path = query.get("path").map(String::as_str);
2028    let staged = query
2029        .get("staged")
2030        .map(|v| v == "true" || v == "1")
2031        .unwrap_or(false);
2032    match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
2033        Ok(diff) => HttpResponse::Ok()
2034            .content_type("text/plain")
2035            .body(diff),
2036        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2037    }
2038}
2039
2040#[cfg(feature = "git")]
2041async fn handle_git_stage(
2042    path: web::Path<String>,
2043    req: HttpRequest,
2044    state: web::Data<AppState>,
2045    body: web::Bytes,
2046) -> HttpResponse {
2047    let pubkey = path.into_inner();
2048    if require_pod_owner(&req, &pubkey).await.is_none() {
2049        return git_json_err("Authentication required", 401);
2050    }
2051    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2052        return git_json_err("Git not available (no FS backend)", 501);
2053    };
2054    let parsed: GitStageBody = match serde_json::from_slice(&body) {
2055        Ok(v) => v,
2056        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2057    };
2058    let paths = parsed.paths.unwrap_or_default();
2059    let all = parsed.all.unwrap_or(false);
2060    match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
2061        Ok(()) => HttpResponse::Ok()
2062            .content_type("application/json")
2063            .body(r#"{"ok":true}"#),
2064        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2065    }
2066}
2067
2068#[cfg(feature = "git")]
2069async fn handle_git_unstage(
2070    path: web::Path<String>,
2071    req: HttpRequest,
2072    state: web::Data<AppState>,
2073    body: web::Bytes,
2074) -> HttpResponse {
2075    let pubkey = path.into_inner();
2076    if require_pod_owner(&req, &pubkey).await.is_none() {
2077        return git_json_err("Authentication required", 401);
2078    }
2079    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2080        return git_json_err("Git not available (no FS backend)", 501);
2081    };
2082    let parsed: GitStageBody = match serde_json::from_slice(&body) {
2083        Ok(v) => v,
2084        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2085    };
2086    let paths = parsed.paths.unwrap_or_default();
2087    let all = parsed.all.unwrap_or(false);
2088    match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
2089        Ok(()) => HttpResponse::Ok()
2090            .content_type("application/json")
2091            .body(r#"{"ok":true}"#),
2092        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2093    }
2094}
2095
2096#[cfg(feature = "git")]
2097async fn handle_git_commit(
2098    path: web::Path<String>,
2099    req: HttpRequest,
2100    state: web::Data<AppState>,
2101    body: web::Bytes,
2102) -> HttpResponse {
2103    let pubkey = path.into_inner();
2104    if require_pod_owner(&req, &pubkey).await.is_none() {
2105        return git_json_err("Authentication required", 401);
2106    }
2107    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2108        return git_json_err("Git not available (no FS backend)", 501);
2109    };
2110    let parsed: GitCommitBody = match serde_json::from_slice(&body) {
2111        Ok(v) => v,
2112        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2113    };
2114    let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
2115    let author_email = parsed
2116        .author_email
2117        .as_deref()
2118        .unwrap_or("pod@dreamlab-ai.com");
2119    match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email)
2120        .await
2121    {
2122        Ok(result) => HttpResponse::Ok()
2123            .content_type("application/json")
2124            .body(serde_json::to_string(&result).unwrap_or_default()),
2125        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2126    }
2127}
2128
2129#[cfg(feature = "git")]
2130async fn handle_git_branches(
2131    path: web::Path<String>,
2132    req: HttpRequest,
2133    state: web::Data<AppState>,
2134) -> HttpResponse {
2135    let pubkey = path.into_inner();
2136    if require_pod_owner(&req, &pubkey).await.is_none() {
2137        return git_json_err("Authentication required", 401);
2138    }
2139    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2140        return git_json_err("Git not available (no FS backend)", 501);
2141    };
2142    match solid_pod_rs_git::api::git_branches(&repo).await {
2143        Ok(info) => HttpResponse::Ok()
2144            .content_type("application/json")
2145            .body(serde_json::to_string(&info).unwrap_or_default()),
2146        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2147    }
2148}
2149
2150#[cfg(feature = "git")]
2151async fn handle_git_create_branch(
2152    path: web::Path<String>,
2153    req: HttpRequest,
2154    state: web::Data<AppState>,
2155    body: web::Bytes,
2156) -> HttpResponse {
2157    let pubkey = path.into_inner();
2158    if require_pod_owner(&req, &pubkey).await.is_none() {
2159        return git_json_err("Authentication required", 401);
2160    }
2161    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2162        return git_json_err("Git not available (no FS backend)", 501);
2163    };
2164    let parsed: GitBranchBody = match serde_json::from_slice(&body) {
2165        Ok(v) => v,
2166        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2167    };
2168    match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
2169        Ok(()) => HttpResponse::Ok()
2170            .content_type("application/json")
2171            .body(r#"{"ok":true}"#),
2172        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2173    }
2174}
2175
2176#[cfg(feature = "git")]
2177async fn handle_git_discard(
2178    path: web::Path<String>,
2179    req: HttpRequest,
2180    state: web::Data<AppState>,
2181    body: web::Bytes,
2182) -> HttpResponse {
2183    let pubkey = path.into_inner();
2184    if require_pod_owner(&req, &pubkey).await.is_none() {
2185        return git_json_err("Authentication required", 401);
2186    }
2187    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2188        return git_json_err("Git not available (no FS backend)", 501);
2189    };
2190    let parsed: GitStageBody = match serde_json::from_slice(&body) {
2191        Ok(v) => v,
2192        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2193    };
2194    let paths = parsed.paths.unwrap_or_default();
2195    match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
2196        Ok(()) => HttpResponse::Ok()
2197            .content_type("application/json")
2198            .body(r#"{"ok":true}"#),
2199        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2200    }
2201}
2202
2203// ---------------------------------------------------------------------------
2204// OPTIONS preflight for /_git/{pubkey}/{tail:.*} — alpha.15
2205// ---------------------------------------------------------------------------
2206
2207/// Handles CORS preflight (OPTIONS) requests for the `/_git/` REST API
2208/// namespace. Returns 204 with full CORS headers, respecting the
2209/// `allowed_origins` allowlist from `AppState`.
2210async fn handle_git_panel_options(
2211    req: HttpRequest,
2212    state: web::Data<AppState>,
2213) -> HttpResponse {
2214    let origin = req
2215        .headers()
2216        .get(header::ORIGIN)
2217        .and_then(|v| v.to_str().ok())
2218        .map(str::to_string);
2219
2220    let mut rsp = HttpResponse::NoContent().finish();
2221    add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
2222    rsp
2223}
2224
2225// ---------------------------------------------------------------------------
2226// POST /_admin/provision/{pubkey} — alpha.15
2227// ---------------------------------------------------------------------------
2228
2229/// PSK-gated endpoint that provisions a bare pod directory for a given
2230/// Nostr pubkey. Used by the forum auth-worker to create native pods on
2231/// behalf of users when the "native pods" admin panel action is triggered.
2232///
2233/// Protection: `X-Pod-Admin-Key` header must match `state.admin_key`.
2234/// When `state.admin_key` is `None` the endpoint always returns 403.
2235async fn handle_admin_provision(
2236    req: HttpRequest,
2237    state: web::Data<AppState>,
2238    path: web::Path<String>,
2239) -> HttpResponse {
2240    // --- PSK check -------------------------------------------------------
2241    let expected = match &state.admin_key {
2242        Some(k) => k.clone(),
2243        None => {
2244            return HttpResponse::Forbidden().json(serde_json::json!({
2245                "error": "admin key not configured on this server"
2246            }));
2247        }
2248    };
2249    let provided = req
2250        .headers()
2251        .get("x-pod-admin-key")
2252        .and_then(|v| v.to_str().ok())
2253        .unwrap_or("");
2254    if provided != expected {
2255        return HttpResponse::Forbidden()
2256            .json(serde_json::json!({"error": "invalid admin key"}));
2257    }
2258
2259    // --- Pubkey validation -----------------------------------------------
2260    let pubkey = path.into_inner();
2261    if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
2262        return HttpResponse::BadRequest()
2263            .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
2264    }
2265
2266    // --- Locate FS root --------------------------------------------------
2267    let data_root = match &state.data_root {
2268        Some(r) => r.clone(),
2269        None => {
2270            return HttpResponse::InternalServerError().json(serde_json::json!({
2271                "error": "server has no fs-backend storage configured"
2272            }));
2273        }
2274    };
2275
2276    let pod_dir = data_root.join(&pubkey);
2277
2278    // --- Create directory (idempotent) -----------------------------------
2279    if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
2280        tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
2281        return HttpResponse::InternalServerError()
2282            .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
2283    }
2284
2285    // --- Write owner-only WAC ACL ----------------------------------------
2286    let acl_content = format!(
2287        "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
2288         <#owner> a acl:Authorization ;\n\
2289             acl:agent <did:nostr:{pubkey}> ;\n\
2290             acl:accessTo <./> ;\n\
2291             acl:default <./> ;\n\
2292             acl:mode acl:Read, acl:Write, acl:Control .\n"
2293    );
2294    let acl_path = pod_dir.join(".acl");
2295    if !acl_path.exists() {
2296        if let Err(e) = tokio::fs::write(&acl_path, acl_content.as_bytes()).await {
2297            tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write .acl failed");
2298            return HttpResponse::InternalServerError()
2299                .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
2300        }
2301    }
2302
2303    // --- Git init (feature-gated) ----------------------------------------
2304    #[cfg(feature = "git")]
2305    {
2306        use tokio::process::Command;
2307
2308        // Only init if .git does not yet exist (idempotent).
2309        if !pod_dir.join(".git").exists() {
2310            let init_out = Command::new("git")
2311                .args([
2312                    "init",
2313                    "-b",
2314                    "main",
2315                    pod_dir.to_str().unwrap_or("."),
2316                ])
2317                .output()
2318                .await;
2319
2320            match init_out {
2321                Ok(out) if out.status.success() => {}
2322                Ok(out) => {
2323                    let stderr = String::from_utf8_lossy(&out.stderr);
2324                    tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
2325                }
2326                Err(e) => {
2327                    tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
2328                }
2329            }
2330
2331            // Configure receive.denyCurrentBranch=updateInstead so the forum
2332            // client can push directly into the working tree.
2333            let cfg_out = Command::new("git")
2334                .args([
2335                    "-C",
2336                    pod_dir.to_str().unwrap_or("."),
2337                    "config",
2338                    "receive.denyCurrentBranch",
2339                    "updateInstead",
2340                ])
2341                .output()
2342                .await;
2343
2344            if let Err(e) = cfg_out {
2345                tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
2346            }
2347        }
2348    }
2349
2350    // --- Build response --------------------------------------------------
2351    let base_url = state.nodeinfo.base_url.trim_end_matches('/');
2352    HttpResponse::Ok().json(serde_json::json!({
2353        "podUrl": format!("{base_url}/pods/{pubkey}/"),
2354        "ok": true,
2355    }))
2356}
2357
2358// ---------------------------------------------------------------------------
2359// /.well-known/apps  (JSS #464 Phase 2 — public app discovery)
2360// ---------------------------------------------------------------------------
2361
2362async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
2363    let Some(ref data_root) = state.data_root else {
2364        return HttpResponse::Ok()
2365            .content_type("application/json")
2366            .json(serde_json::json!({"apps": [], "count": 0}));
2367    };
2368
2369    let server_url = state.nodeinfo.base_url.clone();
2370
2371    // Collect pod directories (up to 1000).
2372    let mut read_dir = match tokio::fs::read_dir(data_root).await {
2373        Ok(rd) => rd,
2374        Err(_) => {
2375            return HttpResponse::Ok()
2376                .content_type("application/json")
2377                .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
2378        }
2379    };
2380
2381    let mut apps: Vec<serde_json::Value> = Vec::new();
2382    let mut scanned = 0usize;
2383
2384    while scanned < 1000 {
2385        let entry = match read_dir.next_entry().await {
2386            Ok(Some(e)) => e,
2387            Ok(None) => break,
2388            Err(_) => break,
2389        };
2390
2391        let file_type = match entry.file_type().await {
2392            Ok(ft) => ft,
2393            Err(_) => continue,
2394        };
2395        if !file_type.is_dir() {
2396            continue;
2397        }
2398
2399        scanned += 1;
2400
2401        let manifest_path = entry.path().join("apps").join("manifest.json");
2402        let contents = match tokio::fs::read(&manifest_path).await {
2403            Ok(c) => c,
2404            Err(_) => continue,
2405        };
2406
2407        let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
2408            Ok(v) => v,
2409            Err(_) => continue,
2410        };
2411
2412        // Inject podOwner from the directory name (pubkey).
2413        if let Some(pod_name) = entry.file_name().to_str() {
2414            if manifest.get("podOwner").is_none() {
2415                manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
2416            }
2417        }
2418
2419        apps.push(manifest);
2420    }
2421
2422    let count = apps.len();
2423    HttpResponse::Ok()
2424        .content_type("application/json")
2425        .json(serde_json::json!({
2426            "apps": apps,
2427            "serverUrl": server_url,
2428            "count": count,
2429        }))
2430}
2431
2432// ---------------------------------------------------------------------------
2433// Git HTTP backend handler (JSS #466/#469/#471, feature = "git")
2434// ---------------------------------------------------------------------------
2435
2436/// Returns `true` if `path` is a git smart-HTTP protocol request.
2437///
2438/// Mirrors JSS `src/handlers/git.js` `isGitRequest`:
2439/// ```text
2440/// return urlPath.includes('/info/refs') ||
2441///   urlPath.includes('/git-upload-pack') ||
2442///   urlPath.includes('/git-receive-pack');
2443/// ```
2444#[allow(dead_code)]
2445fn is_git_request(path: &str) -> bool {
2446    path.contains("/info/refs")
2447        || path.contains("/git-upload-pack")
2448        || path.contains("/git-receive-pack")
2449}
2450
2451/// Returns `true` if `path` targets `.git/` internals directly — always
2452/// blocked (security, matches JSS lines 52-68).
2453#[allow(dead_code)]
2454fn is_dot_git_path(path: &str) -> bool {
2455    path.contains("/.git/") || path.ends_with("/.git")
2456}
2457
2458#[cfg(feature = "git")]
2459async fn handle_git(
2460    req: HttpRequest,
2461    body: web::Bytes,
2462    state: web::Data<AppState>,
2463) -> HttpResponse {
2464    use solid_pod_rs_git::service::{GitHttpService, GitRequest};
2465
2466    let path = req.uri().path().to_string();
2467
2468    // Locate the pod's FS root: the first path segment after "/" is the
2469    // pod name (username/pubkey). The FS root is data_root/{pod_name}/.
2470    let pod_name = path.trim_start_matches('/').split('/').next().unwrap_or("");
2471    let Some(ref data_root) = state.data_root else {
2472        return HttpResponse::NotImplemented().json(serde_json::json!({
2473            "error": "git requires fs-backend storage",
2474            "reason": "data_root_not_configured"
2475        }));
2476    };
2477    let repo_root = data_root.join(pod_name);
2478    if !repo_root.exists() {
2479        return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
2480    }
2481
2482    let query = req.uri().query().unwrap_or("").to_string();
2483    let host_url = {
2484        let conn = req.connection_info();
2485        Some(format!("{}://{}", conn.scheme(), conn.host()))
2486    };
2487    let headers: Vec<(String, String)> = req
2488        .headers()
2489        .iter()
2490        .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
2491        .collect();
2492
2493    let git_req = GitRequest {
2494        method: req.method().as_str().to_string(),
2495        path,
2496        query,
2497        headers,
2498        body: body.into(),
2499        host_url,
2500    };
2501
2502    let service = GitHttpService::new(repo_root);
2503    match service.handle(git_req).await {
2504        Ok(git_resp) => {
2505            let mut builder = HttpResponse::build(
2506                actix_web::http::StatusCode::from_u16(git_resp.status)
2507                    .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
2508            );
2509            for (k, v) in &git_resp.headers {
2510                builder.insert_header((k.as_str(), v.as_str()));
2511            }
2512            builder.body(git_resp.body)
2513        }
2514        Err(e) => {
2515            let status = e.status_code();
2516            HttpResponse::build(
2517                actix_web::http::StatusCode::from_u16(status)
2518                    .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
2519            )
2520            .json(serde_json::json!({"error": e.to_string()}))
2521        }
2522    }
2523}
2524
2525// ---------------------------------------------------------------------------
2526// Public app builder
2527// ---------------------------------------------------------------------------
2528
2529/// Build the complete actix `App` for the Solid Pod server. Both the
2530/// binary (`main.rs`) and the workspace integration tests call this.
2531///
2532/// The returned `App` is fully-configured: route table, normaliser,
2533/// path-traversal guard, dotfile allowlist, body cap, CORS middleware
2534/// (when available), rate-limit middleware (when available), and WAC
2535/// enforcement.
2536pub fn build_app(
2537    state: AppState,
2538) -> App<
2539    impl actix_web::dev::ServiceFactory<
2540        ServiceRequest,
2541        Config = (),
2542        Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
2543        Error = ActixError,
2544        InitError = (),
2545    >,
2546> {
2547    let body_cap = state.body_cap;
2548    let dotfiles = state.dotfiles.clone();
2549    let allowed_origins = Arc::new(state.allowed_origins.clone());
2550
2551    let mut app = App::new()
2552        .app_data(web::Data::new(state.clone()))
2553        .app_data(web::PayloadConfig::new(body_cap))
2554        // Sprint 11 (row 158): outermost layer so it observes every
2555        // response — including those that short-circuited in inner
2556        // guards. Wrapping first means `wrap()` applies it last in
2557        // actix's stack order.
2558        .wrap(ErrorLoggingMiddleware)
2559        .wrap(CorsHeaders { allowed_origins })
2560        // `MergeOnly` collapses duplicate slashes (//a → /a) without
2561        // stripping the trailing slash, which is the container/resource
2562        // discriminator in LDP.
2563        .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
2564        .wrap(PathTraversalGuard)
2565        .wrap(DotfileGuard::new(dotfiles));
2566
2567    // CORS / rate-limit: middleware is driven by the library types from
2568    // S7-A. We register pass-through headers when the env-driven policy
2569    // permits. The middleware is a no-op today beyond emitting the
2570    // policy's `response_headers` on every response; full preflight
2571    // handling lives in the sibling S7-A work.
2572    app = app
2573        .route("/.well-known/solid", web::get().to(handle_well_known_solid))
2574        .route(
2575            "/.well-known/webfinger",
2576            web::get().to(handle_well_known_webfinger),
2577        )
2578        .route(
2579            "/.well-known/nodeinfo",
2580            web::get().to(handle_well_known_nodeinfo),
2581        )
2582        .route(
2583            "/.well-known/nodeinfo/2.1",
2584            web::get().to(handle_well_known_nodeinfo_2_1),
2585        );
2586
2587    #[cfg(feature = "did-nostr")]
2588    {
2589        app = app.route(
2590            "/.well-known/did/nostr/{pubkey}.json",
2591            web::get().to(handle_well_known_did_nostr),
2592        );
2593    }
2594
2595    // JSS v0.0.190 Phase 1 port (issue #437), parity row 197.
2596    // Pod-resident NIP-05 endpoint. Scaffold only — handler body
2597    // is `todo!()`. Feature `nip05-endpoint` (default-off).
2598    #[cfg(feature = "nip05-endpoint")]
2599    {
2600        app = app.route(
2601            "/.well-known/nostr.json",
2602            web::get().to(handle_well_known_nip05),
2603        );
2604    }
2605
2606    // App discovery endpoint (JSS #464 Phase 2 — public, no auth required).
2607    app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
2608
2609    // Payment endpoint (JSS parity: GET /pay/.info).
2610    app = app.route("/pay/.info", web::get().to(handle_pay_info));
2611
2612    // WAC-gated CORS proxy endpoint.
2613    app = app.route("/proxy", web::get().to(handle_proxy));
2614
2615    // Admin provisioning endpoint (alpha.15). Must be before the LDP
2616    // catch-all so `_admin` is never treated as a pod name.
2617    app = app.route(
2618        "/_admin/provision/{pubkey}",
2619        web::post().to(handle_admin_provision),
2620    );
2621
2622    // Pod management API (JSS parity: /api/accounts/*)
2623    app = app
2624        .route("/.pods", web::post().to(handle_create_pod))
2625        .route("/api/accounts/new", web::post().to(handle_create_account))
2626        .route("/pods/check/{name}", web::get().to(handle_pod_check))
2627        .route("/login/password", web::post().to(handle_login_password))
2628        .route(
2629            "/account/password/reset",
2630            web::post().to(handle_password_reset_request),
2631        )
2632        .route(
2633            "/account/password/change",
2634            web::post().to(handle_password_change),
2635        );
2636
2637    // Git smart-HTTP protocol routes (JSS #466/#469/#471).
2638    // Must be registered before the LDP catch-all. Direct .git/ access is
2639    // always blocked (security). Smart-HTTP paths are served by
2640    // GitHttpService when the `git` feature is enabled; otherwise 501.
2641    app = app
2642        .route(
2643            // Block direct .git/ access (JSS: "BLOCK: Direct access to .git contents")
2644            "/{tail:.*}/.git",
2645            web::route().to(|| async {
2646                HttpResponse::Forbidden()
2647                    .json(serde_json::json!({"error": "direct .git access is forbidden"}))
2648            }),
2649        )
2650        .route(
2651            "/{tail:.*}/.git/{rest:.*}",
2652            web::route().to(|| async {
2653                HttpResponse::Forbidden()
2654                    .json(serde_json::json!({"error": "direct .git access is forbidden"}))
2655            }),
2656        );
2657
2658    // OPTIONS preflight for /_git panel REST API (alpha.15). Registered
2659    // unconditionally (before the feature block) so browsers get a valid
2660    // CORS response regardless of whether the git feature is compiled in.
2661    app = app.route(
2662        "/pods/{pk}/_git/{tail:.*}",
2663        web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
2664    );
2665
2666    #[cfg(feature = "git")]
2667    {
2668        // Git smart-HTTP: info/refs discovery + upload/receive pack.
2669        app = app
2670            .route("/{tail:.*}/info/refs", web::get().to(handle_git))
2671            .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
2672            .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
2673
2674        // Git control panel REST API. Routes registered before the LDP
2675        // catch-all so `_git` segments are never treated as LDP resources.
2676        app = app
2677            .route(
2678                "/pods/{pubkey}/_git/status",
2679                web::get().to(handle_git_status),
2680            )
2681            .route(
2682                "/pods/{pubkey}/_git/log",
2683                web::get().to(handle_git_log),
2684            )
2685            .route(
2686                "/pods/{pubkey}/_git/diff",
2687                web::get().to(handle_git_diff),
2688            )
2689            .route(
2690                "/pods/{pubkey}/_git/stage",
2691                web::post().to(handle_git_stage),
2692            )
2693            .route(
2694                "/pods/{pubkey}/_git/unstage",
2695                web::post().to(handle_git_unstage),
2696            )
2697            .route(
2698                "/pods/{pubkey}/_git/commit",
2699                web::post().to(handle_git_commit),
2700            )
2701            .route(
2702                "/pods/{pubkey}/_git/branches",
2703                web::get().to(handle_git_branches),
2704            )
2705            .route(
2706                "/pods/{pubkey}/_git/branch",
2707                web::post().to(handle_git_create_branch),
2708            )
2709            .route(
2710                "/pods/{pubkey}/_git/discard",
2711                web::post().to(handle_git_discard),
2712            );
2713    }
2714    #[cfg(not(feature = "git"))]
2715    {
2716        // Without the git feature: return 501 for git protocol paths so
2717        // callers get a clear "not compiled in" signal rather than falling
2718        // through to LDP.
2719        let git_501 = || async {
2720            HttpResponse::NotImplemented()
2721                .json(serde_json::json!({"error": "git feature not enabled in this build"}))
2722        };
2723        app = app
2724            .route("/{tail:.*}/info/refs", web::get().to(git_501))
2725            .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
2726            .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
2727    }
2728
2729    // Container POST and PUT (trailing slash) must register before the
2730    // catch-all so the trailing-slash variant wins.
2731    app.route("/{tail:.*}/", web::post().to(handle_post))
2732        .route("/{tail:.*}/", web::put().to(handle_put))
2733        .route("/{tail:.*}", web::get().to(handle_get))
2734        .route("/{tail:.*}", web::head().to(handle_get))
2735        .route("/{tail:.*}", web::put().to(handle_put))
2736        .route("/{tail:.*}", web::patch().to(handle_patch))
2737        .route("/{tail:.*}", web::delete().to(handle_delete))
2738        .route(
2739            "/{tail:.*}",
2740            web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
2741        )
2742        .route(
2743            "/{tail:.*}",
2744            web::method(actix_web::http::Method::OPTIONS).to(handle_options),
2745        )
2746}