Skip to main content

solid_pod_rs_server/
lib.rs

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