#![doc = include_str!("../README.md")]
#![deny(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod cli;
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use actix_web::body::{BoxBody, EitherBody};
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::{header, StatusCode};
use actix_web::middleware::{NormalizePath, TrailingSlash};
use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
use bytes::Bytes;
use futures_util::future::{ready, LocalBoxFuture, Ready};
use percent_encoding::percent_decode_str;
use serde::Deserialize;
use solid_pod_rs::{
auth::nip98,
config::sources::parse_size,
interop,
ldp::{self, LdpContainerOps, PatchCreateOutcome},
mashlib::{self, MashlibConfig},
provision,
security::DotfileAllowlist,
storage::Storage,
wac::{
self, conditions::RequestContext, parse_jsonld_acl, parser::parse_turtle_acl, AccessMode,
},
PodError,
};
#[derive(Clone)]
pub struct AppState {
pub storage: Arc<dyn Storage>,
pub dotfiles: Arc<DotfileAllowlist>,
pub body_cap: usize,
pub nodeinfo: NodeInfoMeta,
pub mashlib: MashlibConfig,
pub mashlib_cdn: Option<String>,
pub pay_config: solid_pod_rs::payments::PayConfig,
pub data_root: Option<PathBuf>,
pub pod_create_limiter: Arc<PodCreateLimiter>,
pub allowed_origins: Vec<String>,
pub admin_key: Option<String>,
}
#[derive(Clone, Debug)]
pub struct NodeInfoMeta {
pub software_name: String,
pub software_version: String,
pub open_registrations: bool,
pub total_users: u64,
pub base_url: String,
}
impl Default for NodeInfoMeta {
fn default() -> Self {
Self {
software_name: "solid-pod-rs-server".to_string(),
software_version: env!("CARGO_PKG_VERSION").to_string(),
open_registrations: false,
total_users: 0,
base_url: "http://localhost".to_string(),
}
}
}
pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
pub fn body_cap_from_env() -> usize {
match std::env::var("JSS_MAX_REQUEST_BODY") {
Ok(v) => parse_size(&v)
.map(|u| u as usize)
.unwrap_or(DEFAULT_BODY_CAP),
Err(_) => DEFAULT_BODY_CAP,
}
}
impl AppState {
pub fn new(storage: Arc<dyn Storage>) -> Self {
Self {
storage,
dotfiles: Arc::new(DotfileAllowlist::from_env()),
body_cap: body_cap_from_env(),
nodeinfo: NodeInfoMeta::default(),
mashlib: MashlibConfig::default(),
mashlib_cdn: None,
pay_config: solid_pod_rs::payments::PayConfig::default(),
data_root: None,
pod_create_limiter: Arc::new(PodCreateLimiter::default()),
allowed_origins: Vec::new(),
admin_key: None,
}
}
}
#[derive(Debug)]
pub struct PodCreateLimiter {
hits: Mutex<HashMap<IpAddr, Instant>>,
window: Duration,
}
impl Default for PodCreateLimiter {
fn default() -> Self {
Self {
hits: Mutex::new(HashMap::new()),
window: Duration::from_secs(24 * 60 * 60),
}
}
}
impl PodCreateLimiter {
fn check(&self, ip: IpAddr) -> Result<(), u64> {
let now = Instant::now();
let mut hits = self.hits.lock().unwrap();
if let Some(last) = hits.get(&ip).copied() {
let elapsed = now.saturating_duration_since(last);
if elapsed < self.window {
return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
}
}
hits.insert(ip, now);
Ok(())
}
}
fn to_actix(e: PodError) -> ActixError {
match e {
PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
_ => actix_web::error::ErrorInternalServerError(e.to_string()),
}
}
async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
let header_val = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())?;
let url = format!(
"http://{}{}",
req.connection_info().host(),
req.uri().path()
);
nip98::verify(header_val, &url, req.method().as_str(), None)
.await
.ok()
}
fn agent_uri(pubkey: Option<&String>) -> Option<String> {
pubkey.map(|pk| format!("did:nostr:{pk}"))
}
fn accept_includes_html(accept: &str) -> bool {
accept.split(',').any(|entry| {
let mime = entry.split(';').next().unwrap_or("").trim();
mime.eq_ignore_ascii_case("text/html")
})
}
async fn enforce_write(
state: &AppState,
path: &str,
mode: AccessMode,
agent_uri: Option<&str>,
) -> Result<(), ActixError> {
let acl_doc = match find_effective_acl_dyn(&*state.storage, path).await {
Ok(doc) => doc,
Err(e) => return Err(to_actix(e)),
};
let ctx = RequestContext {
web_id: agent_uri,
client_id: None,
issuer: None,
payment_balance_sats: None,
};
let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
let granted = wac::evaluate_access_ctx_with_registry(
acl_doc.as_ref(),
&ctx,
path,
mode,
None,
&groups,
®istry,
);
if granted {
return Ok(());
}
let allow_header = wac::wac_allow_header(acl_doc.as_ref(), agent_uri, path);
let (status, body, unauthenticated) = if agent_uri.is_none() {
(StatusCode::UNAUTHORIZED, "authentication required", true)
} else {
(StatusCode::FORBIDDEN, "access forbidden", false)
};
let mut rsp = HttpResponse::new(status);
rsp.headers_mut().insert(
header::HeaderName::from_static("wac-allow"),
header::HeaderValue::from_str(&allow_header)
.unwrap_or(header::HeaderValue::from_static("")),
);
if unauthenticated {
rsp.headers_mut().insert(
header::WWW_AUTHENTICATE,
header::HeaderValue::from_static("DPoP realm=\"Solid\", Bearer realm=\"Solid\""),
);
}
Err(actix_web::error::InternalError::from_response(body, rsp).into())
}
fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
let links = ldp::link_headers(path).join(", ");
if let Ok(value) = header::HeaderValue::from_str(&links) {
rsp.headers_mut()
.insert(header::HeaderName::from_static("link"), value);
}
}
fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
if let Ok(v) = header::HeaderValue::from_str(header_value) {
rsp.headers_mut()
.insert(header::HeaderName::from_static("wac-allow"), v);
}
}
fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
let ws_base = base_url
.replacen("https://", "wss://", 1)
.replacen("http://", "ws://", 1);
let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
rsp.headers_mut()
.insert(header::HeaderName::from_static("updates-via"), v);
}
}
async fn handle_get(
req: HttpRequest,
state: web::Data<AppState>,
) -> Result<HttpResponse, ActixError> {
let path = req.uri().path().to_string();
if path.contains('*') {
return handle_glob_get(req, state).await;
}
let auth_pk = extract_pubkey(&req).await;
let agent = agent_uri(auth_pk.as_ref());
let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
if ldp::is_container(&path) {
let accept = req
.headers()
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept_includes_html(accept) {
let index_path = format!("{}index.html", &path);
if let Ok((body, _meta)) = state.storage.get(&index_path).await {
let mut rsp = HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(body.to_vec());
set_wac_allow(&mut rsp, &wac_allow);
set_updates_via(&mut rsp, &state.nodeinfo.base_url);
set_link_headers(&mut rsp, &path);
return Ok(rsp);
}
}
let v = state
.storage
.container_representation(&path)
.await
.map_err(to_actix)?;
let sec_fetch_dest = req
.headers()
.get("sec-fetch-dest")
.and_then(|v| v.to_str().ok());
if mashlib::should_serve(
accept,
sec_fetch_dest,
"application/ld+json",
state.mashlib.enabled,
) {
let json_ld = serde_json::to_string(&v).ok();
let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
let mut rsp = HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.insert_header(("X-Frame-Options", "DENY"))
.insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
.insert_header(("Cache-Control", "no-store"))
.body(html);
set_wac_allow(&mut rsp, &wac_allow);
set_updates_via(&mut rsp, &state.nodeinfo.base_url);
set_link_headers(&mut rsp, &path);
return Ok(rsp);
}
let mut rsp = HttpResponse::Ok().json(v);
rsp.headers_mut().insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/ld+json"),
);
set_wac_allow(&mut rsp, &wac_allow);
set_updates_via(&mut rsp, &state.nodeinfo.base_url);
set_link_headers(&mut rsp, &path);
return Ok(rsp);
}
match state.storage.get(&path).await {
Ok((body, meta)) => {
let accept = req
.headers()
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let sec_fetch_dest = req
.headers()
.get("sec-fetch-dest")
.and_then(|v| v.to_str().ok());
if mashlib::should_serve(
accept,
sec_fetch_dest,
&meta.content_type,
state.mashlib.enabled,
) {
let embed = if body.len() <= state.mashlib.data_island_max_bytes {
std::str::from_utf8(&body).ok().map(|s| s.to_string())
} else {
None
};
let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
let mut rsp = HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.insert_header(("X-Frame-Options", "DENY"))
.insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
.insert_header(("Cache-Control", "no-store"))
.body(html);
set_wac_allow(&mut rsp, &wac_allow);
set_updates_via(&mut rsp, &state.nodeinfo.base_url);
set_link_headers(&mut rsp, &path);
return Ok(rsp);
}
let mut rsp = HttpResponse::Ok().body(body.to_vec());
rsp.headers_mut().insert(
header::CONTENT_TYPE,
header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
header::HeaderValue::from_static("application/octet-stream")
}),
);
if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
rsp.headers_mut().insert(header::ETAG, etag);
}
set_wac_allow(&mut rsp, &wac_allow);
set_updates_via(&mut rsp, &state.nodeinfo.base_url);
set_link_headers(&mut rsp, &path);
Ok(rsp)
}
Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
Err(e) => Err(to_actix(e)),
}
}
fn has_basic_container_link(req: &HttpRequest) -> bool {
req.headers()
.get_all(header::LINK)
.filter_map(|v| v.to_str().ok())
.any(|v| {
v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
})
}
async fn handle_put(
req: HttpRequest,
body: web::Bytes,
state: web::Data<AppState>,
) -> Result<HttpResponse, ActixError> {
let path = req.uri().path().to_string();
if ldp::is_container(&path) {
if has_basic_container_link(&req) {
let auth_pk = extract_pubkey(&req).await;
let agent = agent_uri(auth_pk.as_ref());
enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
let meta = state
.storage
.create_container(&path)
.await
.map_err(to_actix)?;
let mut rsp = HttpResponse::Created().finish();
if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
rsp.headers_mut().insert(header::ETAG, etag);
}
set_link_headers(&mut rsp, &path);
return Ok(rsp);
}
return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
}
let auth_pk = extract_pubkey(&req).await;
let agent = agent_uri(auth_pk.as_ref());
enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
let ct = req
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream");
let meta = state
.storage
.put(&path, Bytes::from(body.to_vec()), ct)
.await
.map_err(to_actix)?;
let mut rsp = HttpResponse::Created().finish();
if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
rsp.headers_mut().insert(header::ETAG, etag);
}
set_link_headers(&mut rsp, &path);
Ok(rsp)
}
async fn handle_post(
req: HttpRequest,
body: web::Bytes,
state: web::Data<AppState>,
) -> Result<HttpResponse, ActixError> {
let path = req.uri().path().to_string();
let auth_pk = extract_pubkey(&req).await;
let agent = agent_uri(auth_pk.as_ref());
enforce_write(&state, &path, AccessMode::Append, agent.as_deref()).await?;
let slug = req
.headers()
.get(header::HeaderName::from_static("slug"))
.and_then(|v| v.to_str().ok());
let target = match ldp::resolve_slug(&path, slug) {
Ok(p) => p,
Err(e) => return Err(to_actix(e)),
};
let ct = req
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream");
let meta = state
.storage
.put(&target, Bytes::from(body.to_vec()), ct)
.await
.map_err(to_actix)?;
let mut rsp = HttpResponse::Created().finish();
if let Ok(loc) = header::HeaderValue::from_str(&target) {
rsp.headers_mut().insert(header::LOCATION, loc);
}
if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
rsp.headers_mut().insert(header::ETAG, etag);
}
set_link_headers(&mut rsp, &target);
Ok(rsp)
}
async fn handle_patch(
req: HttpRequest,
body: web::Bytes,
state: web::Data<AppState>,
) -> Result<HttpResponse, ActixError> {
let path = req.uri().path().to_string();
if ldp::is_container(&path) {
return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
}
let auth_pk = extract_pubkey(&req).await;
let agent = agent_uri(auth_pk.as_ref());
enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
let ct = req
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let dialect = match ldp::patch_dialect_from_mime(ct) {
Some(d) => d,
None => {
return Ok(HttpResponse::UnsupportedMediaType()
.body(format!("unsupported patch dialect for content-type {ct:?}")))
}
};
let body_str = match std::str::from_utf8(&body) {
Ok(s) => s.to_string(),
Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
};
let existing = state.storage.get(&path).await;
match existing {
Ok((current_body, meta)) => {
let out = match dialect {
ldp::PatchDialect::N3 => {
ldp::apply_n3_patch(ldp::Graph::new(), &body_str).map_err(patch_parse_err)
}
ldp::PatchDialect::SparqlUpdate => {
ldp::apply_sparql_patch(ldp::Graph::new(), &body_str).map_err(patch_parse_err)
}
ldp::PatchDialect::JsonPatch => {
let mut json: serde_json::Value = match serde_json::from_slice(¤t_body) {
Ok(v) => v,
Err(_) => serde_json::json!({}),
};
let patch: serde_json::Value = match serde_json::from_str(&body_str) {
Ok(v) => v,
Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
};
ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
let bytes = serde_json::to_vec(&json)
.map_err(PodError::from)
.map_err(to_actix)?;
let _ = state
.storage
.put(&path, Bytes::from(bytes), &meta.content_type)
.await
.map_err(to_actix)?;
return Ok(HttpResponse::NoContent().finish());
}
};
let outcome = out?;
let serialised = graph_to_turtle(&outcome.graph);
let _ = state
.storage
.put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
.await
.map_err(to_actix)?;
Ok(HttpResponse::NoContent().finish())
}
Err(PodError::NotFound(_)) => {
let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
let PatchCreateOutcome::Created { graph, .. } = create else {
return Err(to_actix(PodError::Unsupported(
"unexpected patch outcome on absent resource".into(),
)));
};
let serialised = graph_to_turtle(&graph);
let _ = state
.storage
.put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
.await
.map_err(to_actix)?;
Ok(HttpResponse::Created().finish())
}
Err(e) => Err(to_actix(e)),
}
}
fn patch_parse_err(e: PodError) -> ActixError {
match e {
PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
actix_web::error::ErrorBadRequest(msg)
}
other => to_actix(other),
}
}
fn graph_to_turtle(g: &ldp::Graph) -> String {
g.to_ntriples()
}
async fn find_effective_acl_dyn(
storage: &dyn Storage,
resource_path: &str,
) -> Result<Option<wac::AclDocument>, PodError> {
let mut path = resource_path.to_string();
loop {
let acl_key = if path == "/" {
"/.acl".to_string()
} else {
format!("{}.acl", path.trim_end_matches('/'))
};
if let Ok((body, meta)) = storage.get(&acl_key).await {
match parse_jsonld_acl(&body) {
Ok(doc) => return Ok(Some(doc)),
Err(PodError::BadRequest(_)) => {
return Err(PodError::BadRequest("ACL document exceeds bounds".into()))
}
Err(_) => {}
}
let ct = meta.content_type.to_ascii_lowercase();
let looks_turtle = ct.starts_with("text/turtle")
|| ct.starts_with("application/turtle")
|| ct.starts_with("application/x-turtle");
let text = std::str::from_utf8(&body).unwrap_or("");
if looks_turtle || text.contains("@prefix") || text.contains("acl:Authorization") {
if let Ok(doc) = parse_turtle_acl(text) {
return Ok(Some(doc));
}
}
}
if path == "/" || path.is_empty() {
break;
}
let trimmed = path.trim_end_matches('/');
path = match trimmed.rfind('/') {
Some(0) => "/".to_string(),
Some(pos) => trimmed[..pos].to_string(),
None => "/".to_string(),
};
}
Ok(None)
}
async fn handle_delete(
req: HttpRequest,
state: web::Data<AppState>,
) -> Result<HttpResponse, ActixError> {
let path = req.uri().path().to_string();
let auth_pk = extract_pubkey(&req).await;
let agent = agent_uri(auth_pk.as_ref());
enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
match state.storage.delete(&path).await {
Ok(()) => Ok(HttpResponse::NoContent().finish()),
Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
Err(e) => Err(to_actix(e)),
}
}
async fn handle_options(
req: HttpRequest,
state: web::Data<AppState>,
) -> Result<HttpResponse, ActixError> {
let path = req.uri().path().to_string();
let o = ldp::options_for(&path);
let mut rsp = HttpResponse::NoContent().finish();
if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
rsp.headers_mut()
.insert(header::HeaderName::from_static("allow"), v);
}
if let Some(ap) = o.accept_post {
if let Ok(v) = header::HeaderValue::from_str(ap) {
rsp.headers_mut()
.insert(header::HeaderName::from_static("accept-post"), v);
}
}
if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
rsp.headers_mut()
.insert(header::HeaderName::from_static("accept-patch"), v);
}
if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
rsp.headers_mut()
.insert(header::HeaderName::from_static("accept-ranges"), v);
}
set_updates_via(&mut rsp, &state.nodeinfo.base_url);
Ok(rsp)
}
async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
HttpResponse::Ok()
.content_type("application/ld+json")
.json(doc)
}
#[derive(Debug, Deserialize)]
struct WebFingerQuery {
resource: Option<String>,
}
async fn handle_well_known_webfinger(
state: web::Data<AppState>,
q: web::Query<WebFingerQuery>,
) -> HttpResponse {
let resource = q.resource.clone().unwrap_or_else(|| {
format!(
"acct:anonymous@{}",
state
.nodeinfo
.base_url
.trim_start_matches("http://")
.trim_start_matches("https://")
)
});
let webid = format!(
"{}/profile/card#me",
state.nodeinfo.base_url.trim_end_matches('/')
);
match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
Some(jrd) => HttpResponse::Ok()
.content_type("application/jrd+json")
.json(jrd),
None => HttpResponse::NotFound().finish(),
}
}
async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
HttpResponse::Ok()
.content_type("application/json")
.json(doc)
}
async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
let doc = interop::nodeinfo_2_1(
&state.nodeinfo.software_name,
&state.nodeinfo.software_version,
state.nodeinfo.open_registrations,
state.nodeinfo.total_users,
);
HttpResponse::Ok()
.content_type("application/json")
.json(doc)
}
#[cfg(feature = "did-nostr")]
async fn handle_well_known_did_nostr(
state: web::Data<AppState>,
path: web::Path<String>,
) -> HttpResponse {
let pubkey = path.into_inner();
let also = vec![format!(
"{}/profile/card#me",
state.nodeinfo.base_url.trim_end_matches('/')
)];
let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
HttpResponse::Ok()
.content_type("application/did+json")
.json(doc)
}
#[cfg(feature = "nip05-endpoint")]
#[derive(Debug, Deserialize)]
struct Nip05Query {
name: Option<String>,
}
#[cfg(feature = "nip05-endpoint")]
fn nip05_name_is_valid(name: &str) -> bool {
if name.is_empty() {
return false;
}
name.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
}
#[cfg(feature = "nip05-endpoint")]
async fn handle_well_known_nip05(
state: web::Data<AppState>,
query: web::Query<Nip05Query>,
) -> HttpResponse {
use solid_pod_rs::webid::extract_nostr_pubkey;
let name = query.name.clone().unwrap_or_else(|| "_".to_string());
if !nip05_name_is_valid(&name) {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "invalid NIP-05 local part",
}));
}
let profile_path = if name == "_" {
"/profile/card".to_string()
} else {
format!("/{name}/profile/card")
};
let (body, _meta) = match state.storage.get(&profile_path).await {
Ok(v) => v,
Err(_) => {
return nip05_empty_response();
}
};
let pubkey_hex = match extract_nostr_pubkey(&body) {
Ok(Some(p)) => p,
_ => return nip05_empty_response(),
};
let doc = interop::nip05_document([(name, pubkey_hex)]);
HttpResponse::Ok()
.insert_header(("Access-Control-Allow-Origin", "*"))
.content_type("application/json")
.json(doc)
}
#[cfg(feature = "nip05-endpoint")]
fn nip05_empty_response() -> HttpResponse {
HttpResponse::Ok()
.insert_header(("Access-Control-Allow-Origin", "*"))
.content_type("application/json")
.json(serde_json::json!({ "names": {} }))
}
#[derive(Debug, Deserialize)]
struct CreateAccountRequest {
username: String,
#[serde(default)]
name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CreatePodRequest {
name: String,
}
async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
let pod_name = path.into_inner();
let pod_root = format!("/{pod_name}/");
match state.storage.exists(&pod_root).await {
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
_ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
}
}
fn valid_pod_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
}
fn request_ip(req: &HttpRequest) -> IpAddr {
req.peer_addr()
.map(|addr| addr.ip())
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
}
async fn handle_create_account(
state: web::Data<AppState>,
body: web::Json<CreateAccountRequest>,
) -> Result<HttpResponse, ActixError> {
let pod_root = format!("/{}/", body.username);
if state.storage.exists(&pod_root).await.unwrap_or(false) {
return Ok(
HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
);
}
let mut plan = provision::ProvisionPlan::new(
body.username.clone(),
format!(
"{}/{}",
state.nodeinfo.base_url.trim_end_matches('/'),
body.username,
),
);
plan.display_name = body.name.clone();
plan.containers = vec![
format!("/{}/", body.username),
format!("/{}/profile/", body.username),
format!("/{}/inbox/", body.username),
format!("/{}/public/", body.username),
format!("/{}/private/", body.username),
format!("/{}/settings/", body.username),
];
#[cfg(feature = "git")]
let outcome = {
use solid_pod_rs_git::init::GitAutoInit;
let git_hook = state.data_root.as_ref().map(|root| {
let fs_path = root.join(&body.username);
(GitAutoInit::new(), fs_path)
});
match git_hook {
Some((hook, ref fs_path)) => {
provision::provision_pod_ext(state.storage.as_ref(), &plan, Some((&hook, fs_path)))
.await
}
None => provision::provision_pod(state.storage.as_ref(), &plan).await,
}
};
#[cfg(not(feature = "git"))]
let outcome = provision::provision_pod(state.storage.as_ref(), &plan).await;
match outcome {
Ok(outcome) => Ok(HttpResponse::Created().json(serde_json::json!({
"webid": outcome.webid,
"pod_root": outcome.pod_root,
"username": body.username,
}))),
Err(e) => Err(to_actix(e)),
}
}
async fn handle_create_pod(
req: HttpRequest,
state: web::Data<AppState>,
body: web::Json<CreatePodRequest>,
) -> Result<HttpResponse, ActixError> {
let ip = request_ip(&req);
if let Err(retry_after) = state.pod_create_limiter.check(ip) {
return Ok(HttpResponse::TooManyRequests()
.insert_header(("Retry-After", retry_after.to_string()))
.json(serde_json::json!({
"error": "Too Many Requests",
"message": "Pod creation rate limit exceeded",
"retryAfter": retry_after
})));
}
if !valid_pod_name(&body.name) {
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
})));
}
let pod_root = format!("/{}/", body.name);
if state.storage.exists(&pod_root).await.unwrap_or(false) {
return Ok(
HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
);
}
let conn = req.connection_info();
let base_uri = format!("{}://{}", conn.scheme(), conn.host());
let pod_uri = format!("{}/{}/", base_uri.trim_end_matches('/'), body.name);
for container in [
format!("/{}/", body.name),
format!("/{}/profile/", body.name),
format!("/{}/inbox/", body.name),
format!("/{}/public/", body.name),
format!("/{}/private/", body.name),
format!("/{}/settings/", body.name),
] {
let meta_key = format!("{}.meta", container.trim_end_matches('/'));
state
.storage
.put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
.await
.map_err(to_actix)?;
}
let canonical_pods_prefix = format!("{}/pods/{}/", base_uri.trim_end_matches('/'), body.name);
let webid = format!("{pod_uri}profile/card#me");
let profile = solid_pod_rs::webid::generate_webid_html(&body.name, None, &base_uri)
.replace(&canonical_pods_prefix, &pod_uri);
state
.storage
.put(
&format!("/{}/profile/card", body.name),
Bytes::from(profile.into_bytes()),
"text/html",
)
.await
.map_err(to_actix)?;
Ok(HttpResponse::Created()
.insert_header(("Location", pod_uri.clone()))
.json(serde_json::json!({
"name": body.name,
"webId": webid,
"podUri": pod_uri,
})))
}
async fn handle_copy(
req: HttpRequest,
state: web::Data<AppState>,
) -> Result<HttpResponse, ActixError> {
let dest = req.uri().path().to_string();
let auth_pk = extract_pubkey(&req).await;
let agent = agent_uri(auth_pk.as_ref());
enforce_write(&state, &dest, AccessMode::Write, agent.as_deref()).await?;
let source = req
.headers()
.get("source")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let source = match source {
Some(s) => s,
None => return Ok(HttpResponse::BadRequest().body("Source header required")),
};
let (body, meta) = match state.storage.get(&source).await {
Ok(v) => v,
Err(PodError::NotFound(_)) => {
return Ok(HttpResponse::NotFound().body("source resource not found"))
}
Err(e) => return Err(to_actix(e)),
};
state
.storage
.put(&dest, body, &meta.content_type)
.await
.map_err(to_actix)?;
let src_acl = format!("{}.acl", source.trim_end_matches('/'));
let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
let _ = state
.storage
.put(&dst_acl, acl_body, &acl_meta.content_type)
.await;
}
let mut rsp = HttpResponse::Created().finish();
if let Ok(loc) = header::HeaderValue::from_str(&dest) {
rsp.headers_mut().insert(header::LOCATION, loc);
}
Ok(rsp)
}
async fn handle_glob_get(
req: HttpRequest,
state: web::Data<AppState>,
) -> Result<HttpResponse, ActixError> {
let raw_path = req.uri().path().to_string();
if !raw_path.ends_with("/*") {
return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
}
let folder = &raw_path[..raw_path.len() - 1]; let folder = if folder.ends_with('/') {
folder.to_string()
} else {
format!("{folder}/")
};
let children = state.storage.list(&folder).await.map_err(to_actix)?;
let mut merged = String::new();
for child in &children {
if child.ends_with('/') {
continue;
}
let child_path = format!("{folder}{child}");
if let Ok((body, meta)) = state.storage.get(&child_path).await {
if meta.content_type.contains("turtle")
|| meta.content_type.contains("n-triples")
|| meta.content_type.contains("n3")
{
if let Ok(text) = std::str::from_utf8(&body) {
merged.push_str(text);
merged.push('\n');
}
}
}
}
if merged.is_empty() {
return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
}
Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
}
#[derive(Debug, Deserialize)]
struct LoginPasswordRequest {
username: String,
password: String,
}
async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
let _ = (&body.username, &body.password);
HttpResponse::Ok().json(serde_json::json!({
"message": "login endpoint active"
}))
}
#[derive(Debug, Deserialize)]
struct PasswordResetRequest {
username: String,
}
async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
let _ = &body.username;
HttpResponse::Ok().json(serde_json::json!({
"message": "if an account with that username exists, a reset link has been sent"
}))
}
#[derive(Debug, Deserialize)]
struct PasswordChangeRequest {
token: String,
new_password: String,
}
async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
let _ = (&body.token, &body.new_password);
HttpResponse::Ok().json(serde_json::json!({
"message": "password changed"
}))
}
async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
let body = solid_pod_rs::payments::pay_info(&state.pay_config);
HttpResponse::Ok()
.content_type("application/json")
.json(body)
}
pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
#[derive(Debug, Deserialize)]
struct ProxyQuery {
url: String,
}
const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
"set-cookie",
"set-cookie2",
"authorization",
"www-authenticate",
"proxy-authenticate",
"proxy-authorization",
];
fn validate_proxy_target(target: &str) -> Result<url::Url, HttpResponse> {
let parsed = match url::Url::parse(target) {
Ok(u) => u,
Err(_) => {
return Err(
HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
);
}
};
match parsed.scheme() {
"http" | "https" => {}
scheme => {
return Err(HttpResponse::BadRequest()
.json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
}
}
if let Err(_e) = solid_pod_rs::security::is_safe_url(target) {
return Err(HttpResponse::Forbidden()
.json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
}
if let Some(host) = parsed.host_str() {
let host_lower = host.to_ascii_lowercase();
if host_lower == "localhost"
|| host_lower.ends_with(".localhost")
|| host_lower == "0.0.0.0"
|| host_lower == "[::1]"
|| host_lower == "[::0]"
{
return Err(HttpResponse::Forbidden()
.json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
}
} else {
return Err(
HttpResponse::BadRequest().json(serde_json::json!({"error": "target URL has no host"}))
);
}
Ok(parsed)
}
async fn handle_proxy(
req: HttpRequest,
_state: web::Data<AppState>,
query: web::Query<ProxyQuery>,
) -> Result<HttpResponse, ActixError> {
let auth_pk = extract_pubkey(&req).await;
let agent = agent_uri(auth_pk.as_ref());
if agent.is_none() {
return Ok(HttpResponse::Unauthorized()
.json(serde_json::json!({"error": "authentication required"})));
}
let _target_url = match validate_proxy_target(&query.url) {
Ok(u) => u,
Err(rsp) => return Ok(rsp),
};
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))?;
let mut current_url = query.url.clone();
let mut redirect_count = 0u8;
const MAX_REDIRECTS: u8 = 5;
let byte_cap = std::env::var("PROXY_BYTE_CAP")
.ok()
.and_then(|v| {
solid_pod_rs::config::sources::parse_size(&v)
.map(|u| u as usize)
.ok()
})
.unwrap_or(DEFAULT_PROXY_BYTE_CAP);
loop {
if redirect_count > 0 {
match validate_proxy_target(¤t_url) {
Ok(_) => {}
Err(rsp) => return Ok(rsp),
}
}
let mut upstream_req = client.get(¤t_url);
if let Some(auth_val) = req
.headers()
.get("x-upstream-authorization")
.and_then(|v| v.to_str().ok())
{
upstream_req = upstream_req.header("Authorization", auth_val);
}
let response = upstream_req
.send()
.await
.map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
if response.status().is_redirection() {
if redirect_count >= MAX_REDIRECTS {
return Ok(HttpResponse::BadGateway()
.json(serde_json::json!({"error": "too many redirects"})));
}
if let Some(location) = response.headers().get("location") {
let loc_str = location
.to_str()
.map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
let base = url::Url::parse(¤t_url)
.map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
let resolved = base
.join(loc_str)
.map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
current_url = resolved.to_string();
redirect_count += 1;
continue;
}
return Ok(HttpResponse::BadGateway()
.json(serde_json::json!({"error": "redirect without location"})));
}
let upstream_status = response.status().as_u16();
let upstream_content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let mut forwarded_headers: Vec<(String, String)> = Vec::new();
for (name, value) in response.headers() {
let name_lower = name.as_str().to_ascii_lowercase();
if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
continue;
}
if matches!(
name_lower.as_str(),
"transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
) {
continue;
}
if let Ok(val_str) = value.to_str() {
forwarded_headers.push((name_lower, val_str.to_string()));
}
}
let body_bytes = response
.bytes()
.await
.map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
if body_bytes.len() > byte_cap {
return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
"error": "proxied response exceeds byte cap",
"limit": byte_cap
})));
}
let mut rsp = HttpResponse::build(
StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
);
rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
for (name, value) in &forwarded_headers {
if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
if let Ok(hval) = header::HeaderValue::from_str(value) {
rsp.insert_header((hname, hval));
}
}
}
return Ok(rsp.body(body_bytes.to_vec()));
}
}
pub struct PathTraversalGuard;
impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B, BoxBody>>;
type Error = ActixError;
type InitError = ();
type Transform = PathTraversalGuardMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(PathTraversalGuardMiddleware { service }))
}
}
pub struct PathTraversalGuardMiddleware<S> {
service: S,
}
impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B, BoxBody>>;
type Error = ActixError;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
actix_web::dev::forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let raw = req.path().to_string();
if path_is_traversal(&raw) {
let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
let sr = req.into_response(rsp.map_into_boxed_body());
return Box::pin(async move { Ok(sr.map_into_right_body()) });
}
let fut = self.service.call(req);
Box::pin(async move {
let resp = fut.await?;
Ok(resp.map_into_left_body())
})
}
}
fn path_is_traversal(path: &str) -> bool {
let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
for seg in once.split('/').chain(twice.split('/')) {
if seg == ".." || seg == "." {
return true;
}
}
if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
return true;
}
false
}
pub struct CorsHeaders {
pub allowed_origins: Arc<Vec<String>>,
}
impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = ActixError;
type InitError = ();
type Transform = CorsHeadersMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(CorsHeadersMiddleware {
service,
allowed_origins: self.allowed_origins.clone(),
}))
}
}
pub struct CorsHeadersMiddleware<S> {
service: S,
allowed_origins: Arc<Vec<String>>,
}
impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = ActixError;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
actix_web::dev::forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let origin = req
.headers()
.get(header::ORIGIN)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let allowed = self.allowed_origins.clone();
let fut = self.service.call(req);
Box::pin(async move {
let mut resp = fut.await?;
add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
Ok(resp)
})
}
}
fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
let effective_origin: Option<String> = if allowed.is_empty() {
Some(origin.unwrap_or("*").to_string())
} else {
origin
.filter(|o| allowed.iter().any(|a| a == *o))
.map(str::to_string)
};
let origin_value = match effective_origin {
Some(ref v) => v.as_str(),
None => return,
};
let pairs = [
("access-control-allow-origin", origin_value),
(
"access-control-allow-methods",
"GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
),
(
"access-control-allow-headers",
"Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Range, Slug, Origin",
),
(
"access-control-expose-headers",
"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",
),
("access-control-allow-credentials", "true"),
("access-control-max-age", "86400"),
];
for (name, value) in pairs {
if let (Ok(name), Ok(value)) = (
header::HeaderName::from_lowercase(name.as_bytes()),
header::HeaderValue::from_str(value),
) {
headers.insert(name, value);
}
}
}
pub struct ErrorLoggingMiddleware;
impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = ActixError;
type InitError = ();
type Transform = ErrorLoggingMiddlewareService<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(ErrorLoggingMiddlewareService { service }))
}
}
pub struct ErrorLoggingMiddlewareService<S> {
service: S,
}
impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = ActixError;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
actix_web::dev::forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let method = req.method().as_str().to_string();
let path = req.path().to_string();
let fut = self.service.call(req);
Box::pin(async move {
let response = fut.await?;
let status = response.status();
if status.is_server_error() {
log_5xx(&method, &path, status, response.response().error());
}
Ok(response)
})
}
}
fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
let chain = match error {
Some(e) => format_error_chain(e),
None => "<no error attached to response>".to_string(),
};
let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
Some(std::backtrace::Backtrace::force_capture().to_string())
} else {
None
};
tracing::error!(
target: "solid_pod_rs_server::http",
method = %method,
path = %path,
status = %status.as_u16(),
error.chain = %chain,
backtrace = backtrace.as_deref().unwrap_or(""),
"5xx response"
);
}
fn format_error_chain(e: &actix_web::Error) -> String {
let summary = format!("{}", e.as_response_error());
let debug = format!("{e:?}");
if debug == summary || debug.is_empty() {
summary
} else {
format!("{summary} -> {debug}")
}
}
pub struct DotfileGuard {
allow: Arc<DotfileAllowlist>,
}
impl DotfileGuard {
pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
Self { allow }
}
}
impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B, BoxBody>>;
type Error = ActixError;
type InitError = ();
type Transform = DotfileGuardMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(DotfileGuardMiddleware {
service,
allow: self.allow.clone(),
}))
}
}
pub struct DotfileGuardMiddleware<S> {
service: S,
allow: Arc<DotfileAllowlist>,
}
impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B, BoxBody>>;
type Error = ActixError;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
actix_web::dev::forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let path = req.path().to_string();
let allow_system_route = path.starts_with("/.well-known/") || path == "/.pods";
if !allow_system_route {
let pb = PathBuf::from(&path);
if !self.allow.is_allowed(Path::new(&pb)) {
let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
let sr = req.into_response(rsp.map_into_boxed_body());
return Box::pin(async move { Ok(sr.map_into_right_body()) });
}
}
let fut = self.service.call(req);
Box::pin(async move {
let resp = fut.await?;
Ok(resp.map_into_left_body())
})
}
}
#[cfg(feature = "git")]
fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
state.data_root.as_ref().map(|root| root.join(pubkey))
}
#[cfg(feature = "git")]
async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
let caller = extract_pubkey(req).await?;
if caller != pod_pubkey {
return None;
}
Some(caller)
}
#[cfg(feature = "git")]
fn git_json_err(msg: &str, status: u16) -> HttpResponse {
HttpResponse::build(
StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
)
.content_type("application/json")
.body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
}
#[cfg(feature = "git")]
#[derive(serde::Deserialize)]
struct GitStageBody {
paths: Option<Vec<String>>,
all: Option<bool>,
}
#[cfg(feature = "git")]
#[derive(serde::Deserialize)]
struct GitCommitBody {
message: String,
author_name: Option<String>,
author_email: Option<String>,
}
#[cfg(feature = "git")]
#[derive(serde::Deserialize)]
struct GitBranchBody {
name: String,
}
#[cfg(feature = "git")]
async fn handle_git_status(
path: web::Path<String>,
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
let pubkey = path.into_inner();
if require_pod_owner(&req, &pubkey).await.is_none() {
return git_json_err("Authentication required", 401);
}
let Some(repo) = pod_repo_path(&state, &pubkey) else {
return git_json_err("Git not available (no FS backend)", 501);
};
match solid_pod_rs_git::api::git_status(&repo).await {
Ok(s) => HttpResponse::Ok()
.content_type("application/json")
.body(serde_json::to_string(&s).unwrap_or_default()),
Err(e) => git_json_err(&e.to_string(), e.status_code()),
}
}
#[cfg(feature = "git")]
async fn handle_git_log(
path: web::Path<String>,
req: HttpRequest,
state: web::Data<AppState>,
query: web::Query<std::collections::HashMap<String, String>>,
) -> HttpResponse {
let pubkey = path.into_inner();
if require_pod_owner(&req, &pubkey).await.is_none() {
return git_json_err("Authentication required", 401);
}
let Some(repo) = pod_repo_path(&state, &pubkey) else {
return git_json_err("Git not available (no FS backend)", 501);
};
let limit: u32 = query
.get("limit")
.and_then(|v| v.parse().ok())
.unwrap_or(20);
match solid_pod_rs_git::api::git_log(&repo, limit).await {
Ok(entries) => HttpResponse::Ok()
.content_type("application/json")
.body(serde_json::to_string(&entries).unwrap_or_default()),
Err(e) => git_json_err(&e.to_string(), e.status_code()),
}
}
#[cfg(feature = "git")]
async fn handle_git_diff(
path: web::Path<String>,
req: HttpRequest,
state: web::Data<AppState>,
query: web::Query<std::collections::HashMap<String, String>>,
) -> HttpResponse {
let pubkey = path.into_inner();
if require_pod_owner(&req, &pubkey).await.is_none() {
return git_json_err("Authentication required", 401);
}
let Some(repo) = pod_repo_path(&state, &pubkey) else {
return git_json_err("Git not available (no FS backend)", 501);
};
let file_path = query.get("path").map(String::as_str);
let staged = query
.get("staged")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
Ok(diff) => HttpResponse::Ok()
.content_type("text/plain")
.body(diff),
Err(e) => git_json_err(&e.to_string(), e.status_code()),
}
}
#[cfg(feature = "git")]
async fn handle_git_stage(
path: web::Path<String>,
req: HttpRequest,
state: web::Data<AppState>,
body: web::Bytes,
) -> HttpResponse {
let pubkey = path.into_inner();
if require_pod_owner(&req, &pubkey).await.is_none() {
return git_json_err("Authentication required", 401);
}
let Some(repo) = pod_repo_path(&state, &pubkey) else {
return git_json_err("Git not available (no FS backend)", 501);
};
let parsed: GitStageBody = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
};
let paths = parsed.paths.unwrap_or_default();
let all = parsed.all.unwrap_or(false);
match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
Ok(()) => HttpResponse::Ok()
.content_type("application/json")
.body(r#"{"ok":true}"#),
Err(e) => git_json_err(&e.to_string(), e.status_code()),
}
}
#[cfg(feature = "git")]
async fn handle_git_unstage(
path: web::Path<String>,
req: HttpRequest,
state: web::Data<AppState>,
body: web::Bytes,
) -> HttpResponse {
let pubkey = path.into_inner();
if require_pod_owner(&req, &pubkey).await.is_none() {
return git_json_err("Authentication required", 401);
}
let Some(repo) = pod_repo_path(&state, &pubkey) else {
return git_json_err("Git not available (no FS backend)", 501);
};
let parsed: GitStageBody = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
};
let paths = parsed.paths.unwrap_or_default();
let all = parsed.all.unwrap_or(false);
match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
Ok(()) => HttpResponse::Ok()
.content_type("application/json")
.body(r#"{"ok":true}"#),
Err(e) => git_json_err(&e.to_string(), e.status_code()),
}
}
#[cfg(feature = "git")]
async fn handle_git_commit(
path: web::Path<String>,
req: HttpRequest,
state: web::Data<AppState>,
body: web::Bytes,
) -> HttpResponse {
let pubkey = path.into_inner();
if require_pod_owner(&req, &pubkey).await.is_none() {
return git_json_err("Authentication required", 401);
}
let Some(repo) = pod_repo_path(&state, &pubkey) else {
return git_json_err("Git not available (no FS backend)", 501);
};
let parsed: GitCommitBody = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
};
let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
let author_email = parsed
.author_email
.as_deref()
.unwrap_or("pod@dreamlab-ai.com");
match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email)
.await
{
Ok(result) => HttpResponse::Ok()
.content_type("application/json")
.body(serde_json::to_string(&result).unwrap_or_default()),
Err(e) => git_json_err(&e.to_string(), e.status_code()),
}
}
#[cfg(feature = "git")]
async fn handle_git_branches(
path: web::Path<String>,
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
let pubkey = path.into_inner();
if require_pod_owner(&req, &pubkey).await.is_none() {
return git_json_err("Authentication required", 401);
}
let Some(repo) = pod_repo_path(&state, &pubkey) else {
return git_json_err("Git not available (no FS backend)", 501);
};
match solid_pod_rs_git::api::git_branches(&repo).await {
Ok(info) => HttpResponse::Ok()
.content_type("application/json")
.body(serde_json::to_string(&info).unwrap_or_default()),
Err(e) => git_json_err(&e.to_string(), e.status_code()),
}
}
#[cfg(feature = "git")]
async fn handle_git_create_branch(
path: web::Path<String>,
req: HttpRequest,
state: web::Data<AppState>,
body: web::Bytes,
) -> HttpResponse {
let pubkey = path.into_inner();
if require_pod_owner(&req, &pubkey).await.is_none() {
return git_json_err("Authentication required", 401);
}
let Some(repo) = pod_repo_path(&state, &pubkey) else {
return git_json_err("Git not available (no FS backend)", 501);
};
let parsed: GitBranchBody = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
};
match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
Ok(()) => HttpResponse::Ok()
.content_type("application/json")
.body(r#"{"ok":true}"#),
Err(e) => git_json_err(&e.to_string(), e.status_code()),
}
}
#[cfg(feature = "git")]
async fn handle_git_discard(
path: web::Path<String>,
req: HttpRequest,
state: web::Data<AppState>,
body: web::Bytes,
) -> HttpResponse {
let pubkey = path.into_inner();
if require_pod_owner(&req, &pubkey).await.is_none() {
return git_json_err("Authentication required", 401);
}
let Some(repo) = pod_repo_path(&state, &pubkey) else {
return git_json_err("Git not available (no FS backend)", 501);
};
let parsed: GitStageBody = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
};
let paths = parsed.paths.unwrap_or_default();
match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
Ok(()) => HttpResponse::Ok()
.content_type("application/json")
.body(r#"{"ok":true}"#),
Err(e) => git_json_err(&e.to_string(), e.status_code()),
}
}
async fn handle_git_panel_options(
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
let origin = req
.headers()
.get(header::ORIGIN)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let mut rsp = HttpResponse::NoContent().finish();
add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
rsp
}
async fn handle_admin_provision(
req: HttpRequest,
state: web::Data<AppState>,
path: web::Path<String>,
) -> HttpResponse {
let expected = match &state.admin_key {
Some(k) => k.clone(),
None => {
return HttpResponse::Forbidden().json(serde_json::json!({
"error": "admin key not configured on this server"
}));
}
};
let provided = req
.headers()
.get("x-pod-admin-key")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if provided != expected {
return HttpResponse::Forbidden()
.json(serde_json::json!({"error": "invalid admin key"}));
}
let pubkey = path.into_inner();
if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
}
let data_root = match &state.data_root {
Some(r) => r.clone(),
None => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "server has no fs-backend storage configured"
}));
}
};
let pod_dir = data_root.join(&pubkey);
if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
}
let acl_content = format!(
"@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
<#owner> a acl:Authorization ;\n\
acl:agent <did:nostr:{pubkey}> ;\n\
acl:accessTo <./> ;\n\
acl:default <./> ;\n\
acl:mode acl:Read, acl:Write, acl:Control .\n"
);
let acl_path = pod_dir.join(".acl");
if !acl_path.exists() {
if let Err(e) = tokio::fs::write(&acl_path, acl_content.as_bytes()).await {
tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write .acl failed");
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
}
}
#[cfg(feature = "git")]
{
use tokio::process::Command;
if !pod_dir.join(".git").exists() {
let init_out = Command::new("git")
.args([
"init",
"-b",
"main",
pod_dir.to_str().unwrap_or("."),
])
.output()
.await;
match init_out {
Ok(out) if out.status.success() => {}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
}
Err(e) => {
tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
}
}
let cfg_out = Command::new("git")
.args([
"-C",
pod_dir.to_str().unwrap_or("."),
"config",
"receive.denyCurrentBranch",
"updateInstead",
])
.output()
.await;
if let Err(e) = cfg_out {
tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
}
}
}
let base_url = state.nodeinfo.base_url.trim_end_matches('/');
HttpResponse::Ok().json(serde_json::json!({
"podUrl": format!("{base_url}/pods/{pubkey}/"),
"ok": true,
}))
}
async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
let Some(ref data_root) = state.data_root else {
return HttpResponse::Ok()
.content_type("application/json")
.json(serde_json::json!({"apps": [], "count": 0}));
};
let server_url = state.nodeinfo.base_url.clone();
let mut read_dir = match tokio::fs::read_dir(data_root).await {
Ok(rd) => rd,
Err(_) => {
return HttpResponse::Ok()
.content_type("application/json")
.json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
}
};
let mut apps: Vec<serde_json::Value> = Vec::new();
let mut scanned = 0usize;
while scanned < 1000 {
let entry = match read_dir.next_entry().await {
Ok(Some(e)) => e,
Ok(None) => break,
Err(_) => break,
};
let file_type = match entry.file_type().await {
Ok(ft) => ft,
Err(_) => continue,
};
if !file_type.is_dir() {
continue;
}
scanned += 1;
let manifest_path = entry.path().join("apps").join("manifest.json");
let contents = match tokio::fs::read(&manifest_path).await {
Ok(c) => c,
Err(_) => continue,
};
let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(pod_name) = entry.file_name().to_str() {
if manifest.get("podOwner").is_none() {
manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
}
}
apps.push(manifest);
}
let count = apps.len();
HttpResponse::Ok()
.content_type("application/json")
.json(serde_json::json!({
"apps": apps,
"serverUrl": server_url,
"count": count,
}))
}
#[allow(dead_code)]
fn is_git_request(path: &str) -> bool {
path.contains("/info/refs")
|| path.contains("/git-upload-pack")
|| path.contains("/git-receive-pack")
}
#[allow(dead_code)]
fn is_dot_git_path(path: &str) -> bool {
path.contains("/.git/") || path.ends_with("/.git")
}
#[cfg(feature = "git")]
async fn handle_git(
req: HttpRequest,
body: web::Bytes,
state: web::Data<AppState>,
) -> HttpResponse {
use solid_pod_rs_git::service::{GitHttpService, GitRequest};
let path = req.uri().path().to_string();
let pod_name = path.trim_start_matches('/').split('/').next().unwrap_or("");
let Some(ref data_root) = state.data_root else {
return HttpResponse::NotImplemented().json(serde_json::json!({
"error": "git requires fs-backend storage",
"reason": "data_root_not_configured"
}));
};
let repo_root = data_root.join(pod_name);
if !repo_root.exists() {
return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
}
let query = req.uri().query().unwrap_or("").to_string();
let host_url = {
let conn = req.connection_info();
Some(format!("{}://{}", conn.scheme(), conn.host()))
};
let headers: Vec<(String, String)> = req
.headers()
.iter()
.map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let git_req = GitRequest {
method: req.method().as_str().to_string(),
path,
query,
headers,
body: body.into(),
host_url,
};
let service = GitHttpService::new(repo_root);
match service.handle(git_req).await {
Ok(git_resp) => {
let mut builder = HttpResponse::build(
actix_web::http::StatusCode::from_u16(git_resp.status)
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
);
for (k, v) in &git_resp.headers {
builder.insert_header((k.as_str(), v.as_str()));
}
builder.body(git_resp.body)
}
Err(e) => {
let status = e.status_code();
HttpResponse::build(
actix_web::http::StatusCode::from_u16(status)
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
)
.json(serde_json::json!({"error": e.to_string()}))
}
}
}
pub fn build_app(
state: AppState,
) -> App<
impl actix_web::dev::ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
Error = ActixError,
InitError = (),
>,
> {
let body_cap = state.body_cap;
let dotfiles = state.dotfiles.clone();
let allowed_origins = Arc::new(state.allowed_origins.clone());
let mut app = App::new()
.app_data(web::Data::new(state.clone()))
.app_data(web::PayloadConfig::new(body_cap))
.wrap(ErrorLoggingMiddleware)
.wrap(CorsHeaders { allowed_origins })
.wrap(NormalizePath::new(TrailingSlash::MergeOnly))
.wrap(PathTraversalGuard)
.wrap(DotfileGuard::new(dotfiles));
app = app
.route("/.well-known/solid", web::get().to(handle_well_known_solid))
.route(
"/.well-known/webfinger",
web::get().to(handle_well_known_webfinger),
)
.route(
"/.well-known/nodeinfo",
web::get().to(handle_well_known_nodeinfo),
)
.route(
"/.well-known/nodeinfo/2.1",
web::get().to(handle_well_known_nodeinfo_2_1),
);
#[cfg(feature = "did-nostr")]
{
app = app.route(
"/.well-known/did/nostr/{pubkey}.json",
web::get().to(handle_well_known_did_nostr),
);
}
#[cfg(feature = "nip05-endpoint")]
{
app = app.route(
"/.well-known/nostr.json",
web::get().to(handle_well_known_nip05),
);
}
app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
app = app.route("/pay/.info", web::get().to(handle_pay_info));
app = app.route("/proxy", web::get().to(handle_proxy));
app = app.route(
"/_admin/provision/{pubkey}",
web::post().to(handle_admin_provision),
);
app = app
.route("/.pods", web::post().to(handle_create_pod))
.route("/api/accounts/new", web::post().to(handle_create_account))
.route("/pods/check/{name}", web::get().to(handle_pod_check))
.route("/login/password", web::post().to(handle_login_password))
.route(
"/account/password/reset",
web::post().to(handle_password_reset_request),
)
.route(
"/account/password/change",
web::post().to(handle_password_change),
);
app = app
.route(
"/{tail:.*}/.git",
web::route().to(|| async {
HttpResponse::Forbidden()
.json(serde_json::json!({"error": "direct .git access is forbidden"}))
}),
)
.route(
"/{tail:.*}/.git/{rest:.*}",
web::route().to(|| async {
HttpResponse::Forbidden()
.json(serde_json::json!({"error": "direct .git access is forbidden"}))
}),
);
app = app.route(
"/pods/{pk}/_git/{tail:.*}",
web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
);
#[cfg(feature = "git")]
{
app = app
.route("/{tail:.*}/info/refs", web::get().to(handle_git))
.route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
.route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
app = app
.route(
"/pods/{pubkey}/_git/status",
web::get().to(handle_git_status),
)
.route(
"/pods/{pubkey}/_git/log",
web::get().to(handle_git_log),
)
.route(
"/pods/{pubkey}/_git/diff",
web::get().to(handle_git_diff),
)
.route(
"/pods/{pubkey}/_git/stage",
web::post().to(handle_git_stage),
)
.route(
"/pods/{pubkey}/_git/unstage",
web::post().to(handle_git_unstage),
)
.route(
"/pods/{pubkey}/_git/commit",
web::post().to(handle_git_commit),
)
.route(
"/pods/{pubkey}/_git/branches",
web::get().to(handle_git_branches),
)
.route(
"/pods/{pubkey}/_git/branch",
web::post().to(handle_git_create_branch),
)
.route(
"/pods/{pubkey}/_git/discard",
web::post().to(handle_git_discard),
);
}
#[cfg(not(feature = "git"))]
{
let git_501 = || async {
HttpResponse::NotImplemented()
.json(serde_json::json!({"error": "git feature not enabled in this build"}))
};
app = app
.route("/{tail:.*}/info/refs", web::get().to(git_501))
.route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
.route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
}
app.route("/{tail:.*}/", web::post().to(handle_post))
.route("/{tail:.*}/", web::put().to(handle_put))
.route("/{tail:.*}", web::get().to(handle_get))
.route("/{tail:.*}", web::head().to(handle_get))
.route("/{tail:.*}", web::put().to(handle_put))
.route("/{tail:.*}", web::patch().to(handle_patch))
.route("/{tail:.*}", web::delete().to(handle_delete))
.route(
"/{tail:.*}",
web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
)
.route(
"/{tail:.*}",
web::method(actix_web::http::Method::OPTIONS).to(handle_options),
)
}