#![doc = include_str!("../README.md")]
#![deny(unsafe_code)]
#![warn(rust_2018_idioms)]
#[cfg(feature = "activitypub")]
pub mod activitypub;
pub mod cli;
pub mod cross_identity;
pub mod scheme;
pub(crate) mod hardened_git;
mod handlers;
#[cfg(feature = "jev")]
pub mod jev;
mod mcp;
pub mod mempool;
pub mod trail_store;
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 futures_util::StreamExt;
use percent_encoding::percent_decode_str;
use serde::Deserialize;
use solid_pod_rs::{
auth::{nip98, replay::ReplayStore},
config::sources::parse_size,
interop,
ldp::{
self, cache_control_for_response, LdpContainerOps, PatchCreateOutcome, ResponseAudience,
},
mashlib::{self, MashlibConfig},
provenance::{ProvenanceReceipt, ProvenanceSkip},
security::DotfileAllowlist,
storage::Storage,
wac::{
self, conditions::RequestContext, effective_acl_target, parse_jsonld_acl,
parser::parse_turtle_acl, protected_resource_for_acl, AccessMode,
},
PodError,
};
const _: () = solid_pod_rs::auth::nip98::assert_schnorr_verification_enabled();
static NIP98_REPLAY: std::sync::LazyLock<solid_pod_rs::auth::replay::Nip98ReplayCache> =
std::sync::LazyLock::new(solid_pod_rs::auth::replay::Nip98ReplayCache::from_env);
pub(crate) static PAYMENT_STATE_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
#[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 live_reload: bool,
pub pay_config: solid_pod_rs::payments::PayConfig,
pub data_root: Option<PathBuf>,
pub quota: Option<Arc<dyn solid_pod_rs::quota::QuotaPolicy>>,
pub pod_create_limiter: Arc<PodCreateLimiter>,
pub allowed_origins: Vec<String>,
pub admin_key: Option<String>,
pub mcp_enabled: bool,
pub mempool_url: Option<String>,
pub conneg_enabled: bool,
pub trust_proxy: bool,
pub public_base_url: Option<String>,
pub allowed_hosts: Vec<String>,
pub cross_identity: Option<Arc<dyn cross_identity::CrossIdentityResolver>>,
pub activitypub: Option<Arc<dyn ActorDocument>>,
}
pub trait ActorDocument: Send + Sync {
fn actor_json(&self, base_url: &str) -> serde_json::Value;
}
#[cfg(test)]
mod nip98_request_binding_tests {
use super::*;
use actix_web::test::TestRequest;
const SECRET: &str = "3333333333333333333333333333333333333333333333333333333333333333";
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[actix_web::test]
async fn authentication_binds_the_complete_query_string() {
let signed_url = "http://localhost:8080/private?include=true";
let token = nip98::mint(signed_url, "GET", SECRET, now()).unwrap();
let request = TestRequest::get()
.uri("/private?include=true")
.insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
.to_http_request();
assert!(extract_pubkey(&request).await.is_some());
let path_only = nip98::mint("http://localhost:8080/private", "GET", SECRET, now()).unwrap();
let request = TestRequest::get()
.uri("/private?include=true")
.insert_header((header::AUTHORIZATION, format!("Nostr {path_only}")))
.to_http_request();
assert!(extract_pubkey(&request).await.is_none());
}
#[actix_web::test]
async fn authentication_binds_the_exact_raw_body() {
let expected = br#"{"value":"authorised"}"#;
let token = nip98::mint_with_payload(
"http://localhost:8080/resource",
"PUT",
Some(expected),
SECRET,
now(),
)
.unwrap();
let request = TestRequest::put()
.uri("/resource")
.insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
.to_http_request();
assert!(
extract_pubkey_with_body(&request, Some(br#"{"value":"tampered"}"#))
.await
.is_none()
);
assert!(extract_pubkey_with_body(&request, Some(expected))
.await
.is_some());
}
}
#[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").or_else(|_| std::env::var("JSS_BODY_LIMIT")) {
Ok(v) => parse_size(&v)
.map(|u| u as usize)
.unwrap_or(DEFAULT_BODY_CAP),
Err(_) => DEFAULT_BODY_CAP,
}
}
impl AppState {
pub fn public_origin(&self) -> Option<String> {
self.public_base_url
.as_deref()
.and_then(scheme::normalise_origin)
}
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,
live_reload: false,
pay_config: solid_pod_rs::payments::PayConfig::default(),
data_root: None,
quota: None,
pod_create_limiter: Arc::new(PodCreateLimiter::default()),
allowed_origins: Vec::new(),
admin_key: None,
mcp_enabled: false,
mempool_url: None,
conneg_enabled: false,
trust_proxy: false,
public_base_url: None,
allowed_hosts: Vec::new(),
cross_identity: None,
activitypub: None,
}
}
}
const LIVE_RELOAD_SCRIPT: &str = "<script>(function(){var ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/.notifications');ws.onopen=function(){ws.send('sub '+location.href)};ws.onmessage=function(e){if(e.data.startsWith('pub '))location.reload()};ws.onclose=function(){setTimeout(function(){location.reload()},1000)}})();</script>";
fn inject_live_reload(html: impl AsRef<[u8]>, enabled: bool) -> Vec<u8> {
let bytes = html.as_ref();
if !enabled {
return bytes.to_vec();
}
let html = String::from_utf8_lossy(bytes);
if let Some(index) = html.find("</body>") {
let mut out = String::with_capacity(html.len() + LIVE_RELOAD_SCRIPT.len());
out.push_str(&html[..index]);
out.push_str(LIVE_RELOAD_SCRIPT);
out.push_str(&html[index..]);
out.into_bytes()
} else {
format!("{html}{LIVE_RELOAD_SCRIPT}").into_bytes()
}
}
#[cfg(test)]
mod live_reload_tests {
use super::*;
#[test]
fn injects_before_body_close_and_is_disabled_by_default() {
let html = b"<html><body>ok</body></html>";
assert_eq!(inject_live_reload(html, false), html);
let injected = String::from_utf8(inject_live_reload(html, true)).unwrap();
assert!(injected.contains("/.notifications"));
assert!(injected.find("<script>").unwrap() < injected.find("</body>").unwrap());
}
}
#[cfg(all(test, feature = "dev-bearer"))]
mod dev_bearer_tests {
use super::*;
use base64::Engine as _;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::sync::Mutex;
static ENV: Mutex<()> = Mutex::new(());
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn token_with(iat: u64, exp: u64, aud: Option<&str>) -> String {
let mut claims = serde_json::json!({"webId":"https://alice.example/profile/card#me","iat":iat,"exp":exp});
if let Some(a) = aud {
claims["aud"] = serde_json::json!(a);
}
let data = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string());
let mut mac = Hmac::<Sha256>::new_from_slice(b"01234567890123456789012345678901").unwrap();
mac.update(data.as_bytes());
let sig =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
format!("{data}.{sig}")
}
fn token(exp: u64) -> String {
token_with(now().saturating_sub(10), exp, Some(DEV_BEARER_AUDIENCE))
}
#[test]
fn simple_bearer_verifies_signature_and_expiry() {
let _guard = ENV.lock().unwrap();
std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
assert_eq!(
verify_dev_bearer(&token(now() + 3600)).as_deref(),
Some("https://alice.example/profile/card#me")
);
assert!(verify_dev_bearer(&(token(now() + 3600) + "x")).is_none());
assert!(verify_dev_bearer(&token(2)).is_none());
std::env::remove_var("TOKEN_SECRET");
}
#[test]
fn dev_bearer_enforces_audience_and_max_lifetime() {
let _guard = ENV.lock().unwrap();
std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
let n = now();
assert!(verify_dev_bearer(&token_with(n, n + 60, None)).is_none());
assert!(verify_dev_bearer(&token_with(n, n + 60, Some("solid"))).is_none());
assert!(verify_dev_bearer(&token_with(
n,
n + DEV_BEARER_MAX_LIFETIME_SECS,
Some(DEV_BEARER_AUDIENCE)
))
.is_some());
assert!(verify_dev_bearer(&token_with(
n,
n + DEV_BEARER_MAX_LIFETIME_SECS + 1,
Some(DEV_BEARER_AUDIENCE)
))
.is_none());
assert!(verify_dev_bearer(&token_with(1, n + 3600, Some(DEV_BEARER_AUDIENCE))).is_none());
assert!(
verify_dev_bearer(&token_with(1, u64::MAX / 2, Some(DEV_BEARER_AUDIENCE))).is_none()
);
std::env::remove_var("TOKEN_SECRET");
}
}
#[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(())
}
}
pub(crate) 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()),
}
}
pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
extract_pubkey_with_body(req, None).await
}
pub(crate) async fn extract_pubkey_with_body(
req: &HttpRequest,
body: Option<&[u8]>,
) -> Option<String> {
let header_val = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())?;
if let Some(_token) = header_val.strip_prefix("Bearer ") {
#[cfg(feature = "dev-bearer")]
return verify_dev_bearer(_token);
#[cfg(not(feature = "dev-bearer"))]
return None;
}
let url = scheme::request_url(req);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let verified = nip98::verify_at(header_val, &url, req.method().as_str(), body, now).ok()?;
if NIP98_REPLAY
.check_and_record(&verified.event_id)
.await
.is_err()
{
tracing::warn!(
pubkey = %verified.pubkey,
method = %req.method(),
"NIP-98 replay rejected: token id already used within window"
);
return None;
}
Some(verified.pubkey)
}
pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
pubkey.map(|pk| {
if pk.starts_with("https://") || pk.starts_with("http://") {
pk.clone()
} else {
format!("did:nostr:{pk}")
}
})
}
#[cfg(feature = "dev-bearer")]
pub const DEV_BEARER_AUDIENCE: &str = "solid-pod-rs-dev-bearer";
#[cfg(feature = "dev-bearer")]
pub const DEV_BEARER_MAX_LIFETIME_SECS: u64 = 24 * 60 * 60;
#[cfg(feature = "dev-bearer")]
fn verify_dev_bearer(token: &str) -> Option<String> {
use base64::Engine as _;
use hmac::{Hmac, Mac};
use sha2::Sha256;
let secret = std::env::var("TOKEN_SECRET").ok()?;
if secret.len() < 32 {
tracing::warn!("TOKEN_SECRET shorter than 32 bytes; refusing development bearer token");
return None;
}
let (data, signature) = token.split_once('.')?;
if signature.contains('.') {
return None;
}
let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(signature)
.ok()?;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
mac.update(data.as_bytes());
mac.verify_slice(&signature).ok()?;
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Claims {
web_id: String,
iat: u64,
exp: u64,
#[serde(default)]
aud: Option<String>,
}
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(data)
.ok()?;
let claims: Claims = serde_json::from_slice(&payload).ok()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
if claims.aud.as_deref() != Some(DEV_BEARER_AUDIENCE) {
return None;
}
if claims.exp < now || claims.iat > now.saturating_add(60) || claims.exp <= claims.iat {
return None;
}
if claims.exp - claims.iat > DEV_BEARER_MAX_LIFETIME_SECS
|| claims.exp - now > DEV_BEARER_MAX_LIFETIME_SECS
{
return None;
}
let url = url::Url::parse(&claims.web_id).ok()?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return None;
}
Some(claims.web_id)
}
fn req_origin(req: &HttpRequest) -> Option<&str> {
req.headers()
.get(header::ORIGIN)
.and_then(|v| v.to_str().ok())
}
pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
let did = agent_uri?;
let balance = match storage.get(WEBLEDGER_PATH).await {
Ok((bytes, _meta)) => {
match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
Ok(ledger) => ledger.get_balance(did),
Err(_) => 0,
}
}
Err(_) => 0,
};
Some(balance)
}
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")
})
}
pub(crate) fn guarded_acl_target(path: &str, mode: AccessMode) -> (String, AccessMode) {
let (resource, mode) = effective_acl_target(path, mode);
match canonical_pod_path(&resource) {
Some(canon)
if !trail_store::is_secret_trail_path(&canon)
&& !trail_store::is_prov_state_path(&canon)
&& !trail_store::is_server_state_path(&canon) =>
{
(resource, mode)
}
_ => (resource, AccessMode::Control),
}
}
pub(crate) fn canonical_pod_path(raw: &str) -> Option<String> {
let decoded = percent_decode_str(raw).decode_utf8().ok()?;
if decoded.contains(['\\', '\0']) {
return None;
}
let mut out = String::with_capacity(decoded.len() + 1);
for seg in decoded.split('/') {
match seg {
"" => {}
"." | ".." => return None,
_ => {
out.push('/');
out.push_str(seg);
}
}
}
if out.is_empty() || decoded.ends_with('/') {
out.push('/');
}
Some(out)
}
fn proposed_acl_keeps_caller_control(
body: &[u8],
content_type: &str,
caller: Option<&str>,
) -> bool {
let doc = match parse_jsonld_acl(body) {
Ok(d) => Some(d),
Err(_) => {
let ct = content_type.to_ascii_lowercase();
let text = std::str::from_utf8(body).unwrap_or("");
let looks_turtle = ct.starts_with("text/turtle")
|| ct.starts_with("application/turtle")
|| ct.starts_with("application/x-turtle")
|| ct.starts_with("application/n-triples")
|| text.contains("@prefix")
|| text.contains("acl:Authorization")
|| text.contains("auth/acl#Authorization");
if looks_turtle {
parse_turtle_acl(text).ok()
} else {
None
}
}
};
let Some(doc) = doc else {
return true;
};
let Some(graph) = doc.graph.as_ref() else {
return false;
};
graph.iter().any(|auth| {
let grants_control = ids_of_acl_field(&auth.mode)
.iter()
.any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
if !grants_control {
return false;
}
let agents = ids_of_acl_field(&auth.agent);
if let Some(web_id) = caller {
if agents.contains(&web_id) {
return true;
}
}
let classes = ids_of_acl_field(&auth.agent_class);
if classes
.iter()
.any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
{
return true;
}
if caller.is_some()
&& classes.iter().any(|c| {
*c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
|| *c == "acl:AuthenticatedAgent"
})
{
return true;
}
false
})
}
fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
match field {
None => Vec::new(),
Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
}
}
#[cfg_attr(not(test), allow(dead_code))]
async fn enforce_write(
state: &AppState,
path: &str,
mode: AccessMode,
agent_uri: Option<&str>,
) -> Result<(), ActixError> {
enforce_write_ctx(state, path, mode, agent_uri, None).await
}
async fn enforce_write_ctx(
state: &AppState,
path: &str,
mode: AccessMode,
agent_uri: Option<&str>,
request_origin: Option<&str>,
) -> Result<(), ActixError> {
let origin = request_origin.and_then(wac::Origin::parse);
let (resource, eff_mode) = guarded_acl_target(path, mode);
let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
if outcome.is_failure() {
return Err(policy_failure_to_actix(&outcome, &resource));
}
let acl_doc = outcome.document().cloned();
let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
let linked = cross_identity::linked_identities(state.cross_identity.as_ref(), agent_uri).await;
let Some(ctx) = grant_for_identities(
acl_doc.as_ref(),
agent_uri,
&linked,
payment_balance_sats,
&resource,
eff_mode,
origin.as_ref(),
&groups,
®istry,
) else {
return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
};
if resource.as_str() == path {
charge_granted_payment(
state,
acl_doc.as_ref(),
&ctx,
&resource,
eff_mode,
&groups,
®istry,
agent_uri,
)
.await?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn charge_granted_payment(
state: &AppState,
acl_doc: Option<&wac::AclDocument>,
ctx: &RequestContext<'_>,
path: &str,
mode: AccessMode,
groups: &wac::StaticGroupMembership,
registry: &wac::conditions::ConditionRegistry,
payer: Option<&str>,
) -> Result<(), ActixError> {
let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
if cost == 0 {
return Ok(());
}
if let Some(did) = payer {
if debit_ledger(&*state.storage, did, cost).await.is_err() {
return Err(acl_denial(acl_doc, payer, path));
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn grant_for_identities<'a>(
acl_doc: Option<&wac::AclDocument>,
primary: Option<&'a str>,
linked: &'a [String],
payment_balance_sats: Option<u64>,
resource: &str,
mode: AccessMode,
origin: Option<&wac::Origin>,
groups: &wac::StaticGroupMembership,
registry: &wac::conditions::ConditionRegistry,
) -> Option<RequestContext<'a>> {
std::iter::once(primary)
.chain(linked.iter().map(|s| Some(s.as_str())))
.map(|web_id| RequestContext {
web_id,
client_id: None,
issuer: None,
payment_balance_sats,
})
.find(|ctx| {
wac::evaluate_access_ctx_with_registry(
acl_doc, ctx, resource, mode, origin, groups, registry,
)
})
}
fn acl_denial(
acl_doc: Option<&wac::AclDocument>,
agent_uri: Option<&str>,
path: &str,
) -> ActixError {
let allow_header = wac::wac_allow_header(acl_doc, 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(
"Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
),
);
}
actix_web::error::InternalError::from_response(body, rsp).into()
}
#[cfg_attr(not(test), allow(dead_code))]
async fn enforce_read(
state: &AppState,
path: &str,
agent_uri: Option<&str>,
) -> Result<ResponseAudience, ActixError> {
enforce_read_ctx(state, path, agent_uri, None).await
}
async fn enforce_read_ctx(
state: &AppState,
path: &str,
agent_uri: Option<&str>,
request_origin: Option<&str>,
) -> Result<ResponseAudience, ActixError> {
enforce_read_inner(state, path, agent_uri, request_origin, true).await
}
async fn enforce_read_inner(
state: &AppState,
path: &str,
agent_uri: Option<&str>,
request_origin: Option<&str>,
charge: bool,
) -> Result<ResponseAudience, ActixError> {
let origin = request_origin.and_then(wac::Origin::parse);
let (resource, eff_mode) = guarded_acl_target(path, AccessMode::Read);
let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
if outcome.is_failure() {
return Err(policy_failure_to_actix(&outcome, &resource));
}
let acl_doc = outcome.document().cloned();
let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
let linked = cross_identity::linked_identities(state.cross_identity.as_ref(), agent_uri).await;
let Some(ctx) = grant_for_identities(
acl_doc.as_ref(),
agent_uri,
&linked,
payment_balance_sats,
&resource,
eff_mode,
origin.as_ref(),
&groups,
®istry,
) else {
return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
};
if resource.as_str() == path && !charge {
if wac::granted_payment_cost(
acl_doc.as_ref(),
&ctx,
&resource,
eff_mode,
&groups,
®istry,
) > 0
{
return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
}
} else if resource.as_str() == path {
charge_granted_payment(
state,
acl_doc.as_ref(),
&ctx,
&resource,
eff_mode,
&groups,
®istry,
agent_uri,
)
.await?;
}
let anon_ctx = RequestContext {
web_id: None,
client_id: None,
issuer: None,
payment_balance_sats: None,
};
let anonymous_would_be_granted = wac::evaluate_access_ctx_with_registry(
acl_doc.as_ref(),
&anon_ctx,
&resource,
eff_mode,
origin.as_ref(),
&groups,
®istry,
);
Ok(if anonymous_would_be_granted && resource.as_str() == path {
ResponseAudience::Public
} else {
ResponseAudience::Private
})
}
async fn debit_ledger(
storage: &dyn Storage,
did: &str,
cost: u64,
) -> Result<(), solid_pod_rs::payments::PaymentError> {
use solid_pod_rs::payments::{PaymentError, WebLedger};
let _transaction = PAYMENT_STATE_LOCK.lock().await;
let (bytes, _meta) = storage
.get(WEBLEDGER_PATH)
.await
.map_err(|e| PaymentError::Store(e.to_string()))?;
let mut ledger: WebLedger = serde_json::from_slice(&bytes)
.map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
let _charge = ledger.charge(did, cost)?;
let body = serde_json::to_vec(&ledger)
.map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
storage
.put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
.await
.map_err(|e| PaymentError::Store(e.to_string()))?;
Ok(())
}
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);
}
}
fn set_provenance_headers(rsp: &mut HttpResponse, receipt: &ProvenanceReceipt) {
if let Ok(v) = header::HeaderValue::from_str(&receipt.summary()) {
rsp.headers_mut()
.insert(header::HeaderName::from_static("x-provenance"), v);
}
if let Some(sha) = receipt.commit_sha() {
if let Ok(v) = header::HeaderValue::from_str(sha) {
rsp.headers_mut()
.insert(header::HeaderName::from_static("x-provenance-commit"), v);
}
}
}
fn set_cache_policy(rsp: &mut HttpResponse, content_type: &str, audience: ResponseAudience) {
if audience == ResponseAudience::Private {
append_vary(rsp, "Authorization");
}
if rsp.headers().contains_key(header::CACHE_CONTROL) {
return;
}
if let Some(value) = cache_control_for_response(content_type, audience) {
rsp.headers_mut().insert(
header::CACHE_CONTROL,
header::HeaderValue::from_static(value),
);
}
}
fn append_vary(rsp: &mut HttpResponse, field: &str) {
let existing = rsp
.headers()
.get(header::VARY)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if existing
.split(',')
.any(|f| f.trim().eq_ignore_ascii_case(field))
{
return;
}
let merged = if existing.trim().is_empty() {
field.to_string()
} else {
format!("{existing}, {field}")
};
if let Ok(v) = header::HeaderValue::from_str(&merged) {
rsp.headers_mut().insert(header::VARY, v);
}
}
async fn handle_get(
req: HttpRequest,
state: web::Data<AppState>,
) -> Result<HttpResponse, ActixError> {
let path = req.uri().path().to_string();
#[cfg(feature = "activitypub")]
if let (Some(ap), "/profile/card.jsonld") = (state.activitypub.as_ref(), path.as_str()) {
let accept = req
.headers()
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if solid_pod_rs_activitypub::negotiate_actor_format(accept)
== solid_pod_rs_activitypub::ActorFormat::ActivityJson
{
let doc = ap.actor_json(&scheme::request_origin(&req));
return Ok(HttpResponse::Ok()
.content_type("application/activity+json")
.insert_header((header::VARY, "Accept"))
.json(doc));
}
}
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 audience = enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
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!("{path}index.html");
let index_audience =
enforce_read_ctx(&state, &index_path, agent.as_deref(), req_origin(&req))
.await
.ok();
if let (Some(index_audience), Ok((body, _meta))) =
(index_audience, state.storage.get(&index_path).await)
{
let mut rsp = HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(inject_live_reload(&body, state.live_reload));
set_cache_policy(&mut rsp, "text/html", index_audience);
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(inject_live_reload(html, state.live_reload));
set_cache_policy(&mut rsp, "text/html", audience);
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_cache_policy(&mut rsp, "application/ld+json", audience);
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(inject_live_reload(html, state.live_reload));
set_cache_policy(&mut rsp, "text/html", audience);
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);
}
if let Some((negotiated_body, negotiated_ct)) =
rdf_content_negotiate(&body, &meta.content_type, accept)
{
let mut rsp = HttpResponse::Ok().body(negotiated_body);
rsp.headers_mut().insert(
header::CONTENT_TYPE,
header::HeaderValue::from_str(negotiated_ct)
.unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
);
rsp.headers_mut()
.insert(header::VARY, header::HeaderValue::from_static("Accept"));
if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
rsp.headers_mut().insert(header::ETAG, etag);
}
set_cache_policy(&mut rsp, negotiated_ct, audience);
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 response_body = if meta.content_type.starts_with("text/html") {
inject_live_reload(&body, state.live_reload)
} else {
body.to_vec()
};
let mut rsp = HttpResponse::Ok().body(response_body);
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_cache_policy(&mut rsp, &meta.content_type, audience);
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\"")
})
}
fn validate_rdf_body(
req: &HttpRequest,
state: &AppState,
resource_path: &str,
content_type: &str,
body: &[u8],
) -> Result<(), ActixError> {
use solid_pod_rs::rdf_input::{parse_rdf_body, RdfInputSyntax};
let Some(syntax) = RdfInputSyntax::from_content_type(content_type) else {
return Ok(());
};
if syntax == RdfInputSyntax::N3 && !state.conneg_enabled {
return Err(actix_web::error::ErrorUnsupportedMediaType(
"text/n3 request bodies require content negotiation (conneg) to be enabled",
));
}
let base = format!("{}{}", scheme::request_origin(req), resource_path);
match parse_rdf_body(syntax, body, &base) {
Ok(_) => Ok(()),
Err(solid_pod_rs::PodError::PayloadTooLarge(m)) => {
Err(actix_web::error::ErrorPayloadTooLarge(m))
}
Err(e) => Err(to_actix(e)),
}
}
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_with_body(&req, Some(&body)).await;
let agent = agent_uri(auth_pk.as_ref());
enforce_write_ctx(
&state,
&path,
AccessMode::Write,
agent.as_deref(),
req_origin(&req),
)
.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_with_body(&req, Some(&body)).await;
let agent = agent_uri(auth_pk.as_ref());
enforce_write_ctx(
&state,
&path,
AccessMode::Write,
agent.as_deref(),
req_origin(&req),
)
.await?;
let ct = req
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream");
if protected_resource_for_acl(&path).is_some()
&& !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
{
return Ok(HttpResponse::Conflict().body(
"refused: the proposed ACL would not grant Control to the caller \
(use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
));
}
validate_rdf_body(&req, &state, &path, ct, &body)?;
let quota = reserve_quota_for_size(&state, &path, body.len() as u64).await?;
let write = state
.storage
.put(&path, Bytes::from(body.to_vec()), ct)
.await;
finish_quota_reservation(&state, quota, write.is_ok()).await;
let meta = write.map_err(to_actix)?;
let provenance = git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
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_provenance_headers(&mut rsp, &provenance);
set_link_headers(&mut rsp, &path);
Ok(rsp)
}
async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
if !storage.exists(target).await.unwrap_or(false) {
return target.to_string();
}
let seg_start = target.rfind('/').map(|s| s + 1).unwrap_or(0);
let (stem, ext) = match target.rfind('.') {
Some(dot) if dot > seg_start => (&target[..dot], &target[dot..]),
_ => (target, ""),
};
for n in 1..10_000u32 {
let candidate = format!("{stem}-{n}{ext}");
if !storage.exists(&candidate).await.unwrap_or(false) {
return candidate;
}
}
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
target.hash(&mut h);
format!("{stem}-{:x}{ext}", h.finish())
}
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_with_body(&req, Some(&body)).await;
let agent = agent_uri(auth_pk.as_ref());
enforce_write_ctx(
&state,
&path,
AccessMode::Append,
agent.as_deref(),
req_origin(&req),
)
.await?;
let slug = req
.headers()
.get(header::HeaderName::from_static("slug"))
.and_then(|v| v.to_str().ok());
let mut 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");
if protected_resource_for_acl(&target).is_some() {
enforce_write_ctx(
&state,
&target,
AccessMode::Write,
agent.as_deref(),
req_origin(&req),
)
.await?;
if !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref()) {
return Ok(HttpResponse::Conflict().body(
"refused: the proposed ACL would not grant Control to the caller \
(use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
));
}
} else {
target = mint_unique_target(&*state.storage, &target).await;
}
validate_rdf_body(&req, &state, &target, ct, &body)?;
let quota = reserve_quota_for_size(&state, &target, body.len() as u64).await?;
let write = state
.storage
.put(&target, Bytes::from(body.to_vec()), ct)
.await;
finish_quota_reservation(&state, quota, write.is_ok()).await;
let meta = write.map_err(to_actix)?;
let provenance = git_mark_write(&state, &target, agent.as_deref(), "POST").await;
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_provenance_headers(&mut rsp, &provenance);
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_with_body(&req, Some(&body)).await;
let agent = agent_uri(auth_pk.as_ref());
enforce_write_ctx(
&state,
&path,
AccessMode::Write,
agent.as_deref(),
req_origin(&req),
)
.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 => {
let seed = seed_graph_from_patch_target(¤t_body)?;
ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
}
ldp::PatchDialect::SparqlUpdate => {
let seed = seed_graph_from_patch_target(¤t_body)?;
ldp::apply_sparql_patch(seed, &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 quota = reserve_quota_for_size(&state, &path, bytes.len() as u64).await?;
let write = state
.storage
.put(&path, Bytes::from(bytes), &meta.content_type)
.await;
finish_quota_reservation(&state, quota, write.is_ok()).await;
let _ = write.map_err(to_actix)?;
let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
let mut rsp = HttpResponse::NoContent().finish();
set_provenance_headers(&mut rsp, &provenance);
return Ok(rsp);
}
};
let outcome = out?;
let serialised = graph_to_turtle(&outcome.graph);
if protected_resource_for_acl(&path).is_some()
&& !proposed_acl_keeps_caller_control(
serialised.as_bytes(),
"application/n-triples",
agent.as_deref(),
)
{
return Ok(HttpResponse::Conflict().body(
"refused: the patched ACL would not grant Control to the caller \
(use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
));
}
let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
let write = state
.storage
.put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
.await;
finish_quota_reservation(&state, quota, write.is_ok()).await;
let _ = write.map_err(to_actix)?;
let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
let mut rsp = HttpResponse::NoContent().finish();
set_provenance_headers(&mut rsp, &provenance);
Ok(rsp)
}
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);
if protected_resource_for_acl(&path).is_some()
&& !proposed_acl_keeps_caller_control(
serialised.as_bytes(),
"application/n-triples",
agent.as_deref(),
)
{
return Ok(HttpResponse::Conflict().body(
"refused: the patched ACL would not grant Control to the caller \
(use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
));
}
let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
let write = state
.storage
.put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
.await;
finish_quota_reservation(&state, quota, write.is_ok()).await;
let _ = write.map_err(to_actix)?;
let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
let mut rsp = HttpResponse::Created().finish();
set_provenance_headers(&mut rsp, &provenance);
Ok(rsp)
}
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()
}
fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
let mut best: Option<(f32, ldp::RdfFormat)> = None;
for entry in accept.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let mut parts = entry.split(';').map(|s| s.trim());
let mime = match parts.next() {
Some(m) => m,
None => continue,
};
let mut q: f32 = 1.0;
for token in parts {
if let Some(v) = token.strip_prefix("q=") {
if let Ok(parsed) = v.parse::<f32>() {
q = parsed;
}
}
}
if let Some(format) = ldp::RdfFormat::from_mime(mime) {
match best {
None => best = Some((q, format)),
Some((bq, _)) if q > bq => best = Some((q, format)),
_ => {}
}
}
}
best.map(|(_, f)| f)
}
fn rdf_content_negotiate(
body: &[u8],
stored_ct: &str,
accept: &str,
) -> Option<(Vec<u8>, &'static str)> {
if accept.trim().is_empty() {
return None;
}
let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
let target = best_explicit_rdf_format(accept)?;
if target == stored_format {
return None;
}
let text = std::str::from_utf8(body).ok()?;
let graph = ldp::Graph::parse_ntriples(text).ok()?;
match target {
ldp::RdfFormat::Turtle => Some((
graph.to_ntriples().into_bytes(),
ldp::RdfFormat::Turtle.mime(),
)),
ldp::RdfFormat::NTriples => Some((
graph.to_ntriples().into_bytes(),
ldp::RdfFormat::NTriples.mime(),
)),
ldp::RdfFormat::JsonLd => {
let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
Some((json, ldp::RdfFormat::JsonLd.mime()))
}
ldp::RdfFormat::RdfXml => None,
}
}
fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
let text = std::str::from_utf8(current_body).map_err(|_| {
actix_web::error::ErrorConflict(
"existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
)
})?;
if text.trim().is_empty() {
return Ok(ldp::Graph::new());
}
ldp::Graph::parse_ntriples(text).map_err(|_| {
actix_web::error::ErrorConflict(
"existing resource is not N-Triples RDF and cannot be non-destructively \
patched; PUT an N-Triples representation or use a JSON Patch",
)
})
}
pub(crate) async fn find_effective_acl_dyn(
storage: &dyn Storage,
resource_path: &str,
) -> Result<Option<wac::AclDocument>, PodError> {
resolve_policy_dyn(storage, resource_path)
.await
.into_result()
}
pub(crate) async fn resolve_policy_dyn(
storage: &dyn Storage,
resource_path: &str,
) -> wac::PolicyOutcome {
wac::resolve_policy_from_storage(storage, resource_path).await
}
fn policy_failure_to_actix(outcome: &wac::PolicyOutcome, resource: &str) -> ActixError {
match outcome {
wac::PolicyOutcome::Invalid {
policy_path,
reason,
} => {
tracing::warn!(
target: "solid_pod_rs_server::wac",
resource = %resource,
policy = %policy_path,
"denying: effective ACL is invalid and MUST NOT inherit: {reason}"
);
actix_web::error::ErrorForbidden("access forbidden: governing ACL is invalid")
}
wac::PolicyOutcome::Unavailable {
policy_path,
detail,
} => {
tracing::error!(
target: "solid_pod_rs_server::wac",
resource = %resource,
policy = %policy_path,
"denying: effective ACL could not be read: {detail}"
);
actix_web::error::ErrorServiceUnavailable(
"access control unavailable: governing ACL could not be read",
)
}
_ => actix_web::error::ErrorInternalServerError("policy resolution error"),
}
}
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_ctx(
&state,
&path,
AccessMode::Write,
agent.as_deref(),
req_origin(&req),
)
.await?;
let quota = reserve_quota_for_size(&state, &path, 0).await?;
match state.storage.delete(&path).await {
Ok(()) => {
finish_quota_reservation(&state, quota, true).await;
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_conneg(&path, state.conneg_enabled);
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 pubkey_is_valid = pubkey.len() == 64
&& pubkey
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
if !pubkey_is_valid {
return HttpResponse::BadRequest()
.insert_header(("Cache-Control", "no-store"))
.json(serde_json::json!({
"error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
}));
}
let owner_pubkey = match state.storage.get("/profile/card").await {
Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
.ok()
.flatten(),
Err(_) => None,
};
let owner_claims_key = owner_pubkey
.as_deref()
.is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
if !owner_claims_key {
return HttpResponse::NotFound()
.insert_header(("Cache-Control", "no-store"))
.json(serde_json::json!({
"error": "no account on this pod claims this did:nostr pubkey"
}));
}
let also = vec![format!(
"{}/profile/card#me",
state.nodeinfo.base_url.trim_end_matches('/')
)];
let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
body.hash(&mut hasher);
let etag = format!("\"{:016x}\"", hasher.finish());
HttpResponse::Ok()
.content_type("application/did+json")
.insert_header(("Cache-Control", "max-age=3600"))
.insert_header(("ETag", etag))
.body(body)
}
#[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": {} }))
}
#[cfg(feature = "export-jsonld")]
async fn handle_export_all(
req: HttpRequest,
state: web::Data<AppState>,
) -> Result<HttpResponse, ActixError> {
let auth_pk = extract_pubkey(&req).await;
let agent = agent_uri(auth_pk.as_ref());
enforce_write_ctx(
&state,
"/",
AccessMode::Control,
agent.as_deref(),
req_origin(&req),
)
.await?;
let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
.ok()
.and_then(|q| q.get("include_private").map(|v| v == "true"))
.unwrap_or(false);
let pod_base = format!("{}/", scheme::request_origin(&req));
let options = solid_pod_rs::ExportOptions { include_private };
let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
.await
.map_err(to_actix)?;
let body = serde_json::to_vec(&bundle).map_err(|e| {
actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
})?;
Ok(HttpResponse::Ok()
.content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
.body(body))
}
#[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))
}
#[derive(Debug)]
struct QuotaReservation {
pod: String,
delta: i64,
}
fn pod_name_from_path(path: &str) -> Option<&str> {
path.trim_start_matches('/')
.split('/')
.next()
.filter(|p| !p.is_empty())
}
async fn reserve_quota_for_size(
state: &AppState,
path: &str,
new_size: u64,
) -> Result<Option<QuotaReservation>, ActixError> {
let (Some(policy), Some(pod)) = (&state.quota, pod_name_from_path(path)) else {
return Ok(None);
};
let old_size = state
.storage
.head(path)
.await
.map(|meta| meta.size)
.unwrap_or(0);
let delta = i128::from(new_size) - i128::from(old_size);
let delta = delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64;
if delta > 0 {
policy
.reserve(pod, delta as u64)
.await
.map_err(|error| actix_web::error::ErrorInsufficientStorage(error.to_string()))?;
}
Ok(Some(QuotaReservation {
pod: pod.to_string(),
delta,
}))
}
async fn finish_quota_reservation(
state: &AppState,
reservation: Option<QuotaReservation>,
write_succeeded: bool,
) {
let (Some(policy), Some(reservation)) = (&state.quota, reservation) else {
return;
};
if write_succeeded {
if reservation.delta < 0 {
policy.record(&reservation.pod, reservation.delta).await;
}
} else if reservation.delta > 0 {
policy.record(&reservation.pod, -reservation.delta).await;
}
}
async fn handle_create_account(
req: HttpRequest,
state: web::Data<AppState>,
body: web::Json<CreateAccountRequest>,
) -> Result<HttpResponse, ActixError> {
if let Some(response) = provisioning_gate(&req, &state, &body.username).await {
return Ok(response);
}
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 base_uri = state.nodeinfo.base_url.trim_end_matches('/');
let outcome =
provision_named_pod(&state, &body.username, body.name.as_deref(), base_uri).await?;
Ok(HttpResponse::Created().json(serde_json::json!({
"webid": outcome.webid,
"pod_root": outcome.pod_uri,
"username": body.username,
})))
}
struct NamedPodOutcome {
webid: String,
pod_uri: String,
}
async fn provisioning_gate(
req: &HttpRequest,
state: &AppState,
name: &str,
) -> Option<HttpResponse> {
if !valid_pod_name(name) {
return Some(HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
})));
}
let supplied_key = req
.headers()
.get("x-pod-admin-key")
.and_then(|value| value.to_str().ok())
.unwrap_or("");
use subtle::ConstantTimeEq;
let admin_override = state
.admin_key
.as_deref()
.is_some_and(|expected| bool::from(supplied_key.as_bytes().ct_eq(expected.as_bytes())));
let invite = req
.headers()
.get("x-invite-token")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|t| !t.is_empty());
let closed = !state.nodeinfo.open_registrations && !admin_override;
if closed && invite.is_none() {
return Some(HttpResponse::Forbidden().json(serde_json::json!({
"error": "pod registration is closed"
})));
}
if let Err(retry_after) = state.pod_create_limiter.check(request_ip(req)) {
return Some(
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 closed {
let (Some(token), Some(root)) = (invite, state.data_root.as_deref()) else {
return Some(HttpResponse::Forbidden().json(serde_json::json!({
"error": "pod registration is closed"
})));
};
let refusal =
|why: &str| HttpResponse::Forbidden().json(serde_json::json!({ "error": why }));
match cli::FileInviteStore::new(root).consume(token).await {
Ok(cli::Consume::Granted) => {}
Ok(cli::Consume::Expired) => return Some(refusal("invite has expired")),
Ok(cli::Consume::Exhausted) => return Some(refusal("invite has been used")),
Ok(cli::Consume::Unknown) => return Some(refusal("invalid invite code")),
Err(e) => {
tracing::error!("invite store: {e}");
return Some(
HttpResponse::InternalServerError()
.json(serde_json::json!({ "error": "invite store unavailable" })),
);
}
}
}
None
}
async fn provision_named_pod(
state: &AppState,
name: &str,
display_name: Option<&str>,
base_uri: &str,
) -> Result<NamedPodOutcome, ActixError> {
for container in ["", "profile", "inbox", "public", "private", "settings"] {
let path = if container.is_empty() {
format!("/{name}")
} else {
format!("/{name}/{container}")
};
state
.storage
.put(
&format!("{path}.meta"),
Bytes::from_static(b"{}"),
"application/ld+json",
)
.await
.map_err(to_actix)?;
}
let base_uri = base_uri.trim_end_matches('/');
let pod_uri = format!("{base_uri}/{name}/");
let canonical_prefix = format!("{base_uri}/pods/{name}/");
let webid = format!("{pod_uri}profile/card#me");
let profile = solid_pod_rs::webid::generate_webid_html(name, display_name, base_uri)
.replace(&canonical_prefix, &pod_uri);
state
.storage
.put(
&format!("/{name}/profile/card"),
Bytes::from(profile.into_bytes()),
"text/html",
)
.await
.map_err(to_actix)?;
#[cfg(feature = "git")]
if let Some(root) = &state.data_root {
let hook = solid_pod_rs_git::init::GitAutoInit::new();
if let Err(error) = hook.init_repo_at(&root.join(name)).await {
tracing::warn!(pod = name, %error, "git auto-init failed after pod creation");
}
}
Ok(NamedPodOutcome { webid, pod_uri })
}
async fn handle_create_pod(
req: HttpRequest,
state: web::Data<AppState>,
body: web::Json<CreatePodRequest>,
) -> Result<HttpResponse, ActixError> {
if let Some(response) = provisioning_gate(&req, &state, &body.name).await {
return Ok(response);
}
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 base_uri = scheme::request_origin(&req);
let outcome = provision_named_pod(&state, &body.name, None, &base_uri).await?;
Ok(HttpResponse::Created()
.insert_header(("Location", outcome.pod_uri.clone()))
.json(serde_json::json!({
"name": body.name,
"webId": outcome.webid,
"podUri": outcome.pod_uri,
})))
}
fn normalise_copy_source(raw: &str, host: &str) -> Option<String> {
let path = if raw.starts_with("http://") || raw.starts_with("https://") {
let url = url::Url::parse(raw).ok()?;
let authority = match url.port() {
Some(port) => format!("{}:{port}", url.host_str()?),
None => url.host_str()?.to_string(),
};
if !authority.eq_ignore_ascii_case(host)
|| url.query().is_some()
|| url.fragment().is_some()
{
return None;
}
url.path().to_string()
} else if raw.starts_with('/') && !raw.contains(['?', '#']) {
raw.to_string()
} else {
return None;
};
canonical_pod_path(&path)
}
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_ctx(
&state,
&dest,
AccessMode::Write,
agent.as_deref(),
req_origin(&req),
)
.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 host = scheme::request_host(&req);
let source = match normalise_copy_source(&source, &host) {
Some(s) => s,
None => return Ok(HttpResponse::BadRequest().body("invalid Source")),
};
if !state.dotfiles.is_allowed(Path::new(&source)) {
return Ok(HttpResponse::Forbidden().body("dotfile path denied by allowlist"));
}
enforce_read_ctx(&state, &source, agent.as_deref(), req_origin(&req)).await?;
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)),
};
if protected_resource_for_acl(&dest).is_some()
&& !proposed_acl_keeps_caller_control(&body, &meta.content_type, agent.as_deref())
{
return Ok(HttpResponse::Conflict().body(
"refused: the proposed ACL would not grant Control to the caller \
(use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
));
}
let quota = reserve_quota_for_size(&state, &dest, body.len() as u64).await?;
let write = state.storage.put(&dest, body, &meta.content_type).await;
finish_quota_reservation(&state, quota, write.is_ok()).await;
write.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 origin = req_origin(&req);
let permitted = enforce_read_ctx(&state, &src_acl, agent.as_deref(), origin)
.await
.is_ok()
&& enforce_write_ctx(
&state,
&dst_acl,
AccessMode::Write,
agent.as_deref(),
origin,
)
.await
.is_ok()
&& proposed_acl_keeps_caller_control(
&acl_body,
&acl_meta.content_type,
agent.as_deref(),
);
if permitted {
let quota = reserve_quota_for_size(&state, &dst_acl, acl_body.len() as u64).await?;
let write = state
.storage
.put(&dst_acl, acl_body, &acl_meta.content_type)
.await;
finish_quota_reservation(&state, quota, write.is_ok()).await;
write.map_err(to_actix)?;
}
}
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 auth_pk = extract_pubkey(&req).await;
let agent = agent_uri(auth_pk.as_ref());
let origin = req_origin(&req);
let audience = enforce_read_ctx(&state, &folder, agent.as_deref(), origin).await?;
let children = state.storage.list(&folder).await.map_err(to_actix)?;
let mut merged = String::new();
let mut audience = audience;
for child in &children {
if child.ends_with('/')
|| child.ends_with(".acl")
|| child.ends_with(".meta")
|| child.ends_with(".prov.ttl")
{
continue;
}
let child_path = format!("{folder}{child}");
match enforce_read_inner(&state, &child_path, agent.as_deref(), origin, false).await {
Ok(ResponseAudience::Private) => audience = ResponseAudience::Private,
Ok(ResponseAudience::Public) => {}
Err(_) => continue,
}
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"));
}
let mut rsp = HttpResponse::Ok().content_type("text/turtle").body(merged);
set_cache_policy(&mut rsp, "text/turtle", audience);
Ok(rsp)
}
#[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::NotImplemented().json(serde_json::json!({
"error": "password login is not implemented on this pod"
}))
}
#[derive(Debug, Deserialize)]
struct PasswordResetRequest {
username: String,
}
async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
let _ = &body.username;
HttpResponse::NotImplemented().json(serde_json::json!({
"error": "password reset is not implemented on this pod"
}))
}
#[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::NotImplemented().json(serde_json::json!({
"error": "password change is not implemented on this pod"
}))
}
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",
];
async fn validate_proxy_target(target: &str) -> Result<(url::Url, IpAddr), 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 solid_pod_rs::security::is_safe_url(target).is_err() {
return Err(HttpResponse::Forbidden()
.json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
}
let host = match parsed.host_str() {
Some(h) => h.to_string(),
None => {
return Err(HttpResponse::BadRequest()
.json(serde_json::json!({"error": "target URL has no host"})))
}
};
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"})));
}
match solid_pod_rs::security::resolve_and_check(&host).await {
Ok(ip) => Ok((parsed, ip)),
Err(_) => Err(HttpResponse::Forbidden()
.json(serde_json::json!({"error": "target URL blocked by SSRF policy"}))),
}
}
fn build_pinned_proxy_client(url: &url::Url, ip: IpAddr) -> Result<reqwest::Client, ActixError> {
let mut builder = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none());
if let Some(host) = url.host_str() {
let port = url.port_or_known_default().unwrap_or(0);
builder = builder.resolve(host, std::net::SocketAddr::new(ip, port));
}
builder
.build()
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))
}
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 mut current_url = query.url.clone();
let mut redirect_count = 0u8;
const MAX_REDIRECTS: u8 = 5;
const TOTAL_TIMEOUT: Duration = Duration::from_secs(30);
let started = Instant::now();
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 {
let Some(remaining) = TOTAL_TIMEOUT.checked_sub(started.elapsed()) else {
return Ok(HttpResponse::GatewayTimeout()
.json(serde_json::json!({"error": "proxy operation timed out"})));
};
let (target_url, pinned_ip) = match validate_proxy_target(¤t_url).await {
Ok(pair) => pair,
Err(rsp) => return Ok(rsp),
};
let client = build_pinned_proxy_client(&target_url, pinned_ip)?;
let mut upstream_req = client.get(¤t_url).timeout(remaining);
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();
if response
.content_length()
.is_some_and(|length| length > byte_cap as u64)
{
return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
"error": "proxied response exceeds byte cap",
"limit": byte_cap
})));
}
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 mut stream = response.bytes_stream();
let mut body_bytes = Vec::with_capacity(byte_cap.min(64 * 1024));
while let Some(chunk) = stream.next().await {
let chunk =
chunk.map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
if body_bytes.len().saturating_add(chunk.len()) > byte_cap {
return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
"error": "proxied response exceeds byte cap",
"limit": byte_cap
})));
}
body_bytes.extend_from_slice(&chunk);
}
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));
}
}
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;
}
}
twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..")
}
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)
})
}
}
pub(crate) const CORS_EXPOSE_HEADERS: &str = "Accept-Patch, Accept-Post, Accept-Query, Accept-Ranges, Allow, Content-Length, Content-Range, Content-Type, ETag, Last-Modified, Link, Location, Updates-Via, WAC-Allow, WWW-Authenticate, X-Cost, X-Balance, X-Pay-Currency";
fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
if headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
return;
}
let (origin_value, allow_credentials): (String, bool) = if allowed.is_empty() {
("*".to_string(), false)
} else {
match origin.filter(|o| allowed.iter().any(|a| a == *o)) {
Some(o) => (o.to_string(), true),
None => return,
}
};
let mut pairs = vec![
("access-control-allow-origin", origin_value.as_str()),
(
"access-control-allow-methods",
"GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
),
(
"access-control-allow-headers",
"Accept, Authorization, Content-Type, DPoP, Git-Protocol, If-Match, If-None-Match, Link, Range, Slug, Origin",
),
(
"access-control-expose-headers",
CORS_EXPOSE_HEADERS,
),
("access-control-max-age", "86400"),
];
if allow_credentials {
pairs.push(("access-control-allow-credentials", "true"));
}
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>,
hosts: Option<Arc<HostPolicy>>,
}
pub struct HostPolicy {
pub allowed: Vec<String>,
pub trust_proxy: bool,
}
impl DotfileGuard {
pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
Self { allow, hosts: None }
}
pub fn with_host_policy(mut self, policy: HostPolicy) -> Self {
if !policy.allowed.is_empty() {
self.hosts = Some(Arc::new(policy));
}
self
}
}
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(),
hosts: self.hosts.clone(),
}))
}
}
pub struct DotfileGuardMiddleware<S> {
service: S,
allow: Arc<DotfileAllowlist>,
hosts: Option<Arc<HostPolicy>>,
}
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 {
if let Some(p) = &self.hosts {
let host = scheme::host_with(req.request(), None, p.trust_proxy);
if !scheme::host_allowed(&host, &p.allowed) {
let rsp = HttpResponse::build(actix_web::http::StatusCode::MISDIRECTED_REQUEST)
.body("host not served here");
let sr = req.into_response(rsp.map_into_boxed_body());
return Box::pin(async move { Ok(sr.map_into_right_body()) });
}
}
let path = req.path().to_string();
let allow_system_route =
path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
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")]
pub(crate) 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 git_mark_write(
state: &AppState,
resource_path: &str,
agent: Option<&str>,
message: &str,
) -> ProvenanceReceipt {
use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
use solid_pod_rs_git::mark::ShellGitMarker;
if resource_path.ends_with(".acl")
|| resource_path.ends_with(".meta")
|| resource_path.ends_with(".prov.ttl")
{
return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::ExcludedPath);
}
if resource_path.ends_with('/') {
return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::Container);
}
let Some(data_root) = state.data_root.as_ref() else {
return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured);
};
let trimmed = resource_path.trim_start_matches('/');
let mut segments = trimmed.splitn(2, '/');
let pod = segments.next().unwrap_or("");
let rel = segments.next().unwrap_or("");
if pod.is_empty() || rel.is_empty() {
return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::UnresolvablePath);
}
let repo = data_root.join(pod);
if !repo.join(".git").is_dir() {
return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotGitBacked);
}
let agent_did = agent.unwrap_or("urn:solid:anonymous");
let created = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (policy, ticker_override) =
handlers::prov::resolve_anchor_policy(state, resource_path).await;
let marker = std::sync::Arc::new(ShellGitMarker::new());
let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
None
} else {
handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
};
let (log, ticker, network) = match &anchorer_bundle {
Some((anchorer, ticker, network)) => (
ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
ticker.clone(),
network.clone(),
),
None => (
ProvenanceLog::new(marker.clone()),
String::new(),
String::new(),
),
};
let record_policy = match policy {
AnchorPolicy::Epoch => AnchorPolicy::Never,
other => other,
};
let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
let write_record = solid_pod_rs::provenance::WriteRecord {
repo: &repo,
path: rel,
agent_did,
message,
policy: record_policy,
high_value,
ticker: &ticker,
network: &network,
created,
};
let mut receipt = log.record_receipt(write_record).await;
receipt.resource = resource_path.to_string();
let Some(git) = receipt.mark.clone() else {
tracing::warn!(
target: "solid_pod_rs_server::git_mark",
resource = %resource_path,
"provenance record failed (write already succeeded): {}",
receipt.mark_error.as_deref().unwrap_or("unspecified")
);
return receipt;
};
if let Some(err) = &receipt.anchor_error {
tracing::warn!(
target: "solid_pod_rs_server::git_mark",
resource = %resource_path,
commit = %git.commit_sha,
"block-trail anchor failed (git-mark stands): {err}"
);
}
let mark = solid_pod_rs::provenance::ProvenanceMark {
resource: resource_path.to_string(),
git: git.clone(),
anchor: receipt.anchor.clone(),
agent_did: agent_did.to_string(),
created,
};
if matches!(policy, AnchorPolicy::Epoch) {
if let Some((anchorer, _, _)) = &anchorer_bundle {
match handlers::prov::epoch_push_and_maybe_anchor(
state,
anchorer,
&ticker,
&network,
&mark.git.commit_sha,
)
.await
{
Ok(Some(closed)) => tracing::debug!(
target: "solid_pod_rs_server::git_mark",
root = %closed.root,
n = closed.commits.len(),
"epoch anchored (one tx notarises {} commits)", closed.commits.len()
),
Ok(None) => {}
Err(e) => tracing::warn!(
target: "solid_pod_rs_server::git_mark",
"epoch batch/anchor failed (swallowed): {e}"
),
}
}
}
let ttl = prov_ttl(&mark);
let sidecar = format!("{resource_path}.prov.ttl");
if let Err(e) = state
.storage
.put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
.await
{
tracing::warn!(
target: "solid_pod_rs_server::git_mark",
sidecar = %sidecar,
"provenance sidecar write failed: {e}"
);
receipt.mark_error = Some(format!("PROV-O sidecar write failed: {e}"));
return receipt;
}
tracing::debug!(
target: "solid_pod_rs_server::git_mark",
resource = %resource_path,
commit = %mark.git.commit_sha,
stage = %receipt.stage(),
"provenance recorded"
);
receipt
}
#[cfg(not(feature = "git"))]
#[inline]
async fn git_mark_write(
_state: &AppState,
resource_path: &str,
_agent: Option<&str>,
_message: &str,
) -> ProvenanceReceipt {
ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured)
}
#[cfg(feature = "git")]
pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
require_pod_owner_with_body(req, pod_pubkey, None).await
}
#[cfg(feature = "git")]
pub(crate) async fn require_pod_owner_with_body(
req: &HttpRequest,
pod_pubkey: &str,
body: Option<&[u8]>,
) -> Option<String> {
let caller = extract_pubkey_with_body(req, body).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("");
use subtle::ConstantTimeEq;
let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
if !bool::from(key_match) {
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 pods_root = data_root.join("pods");
let pod_dir = pods_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 </pods/{pubkey}/> ;\n\
acl:default </pods/{pubkey}/> ;\n\
acl:mode acl:Read, acl:Write, acl:Control .\n"
);
let sibling_acl_path = pods_root.join(format!("{pubkey}.acl"));
if !sibling_acl_path.exists() {
if let Err(e) = tokio::fs::write(&sibling_acl_path, acl_content.as_bytes()).await {
tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write sibling .acl failed");
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
}
}
let inner_acl_path = pod_dir.join(".acl");
if !inner_acl_path.exists() {
if let Err(e) = tokio::fs::write(&inner_acl_path, acl_content.as_bytes()).await {
tracing::warn!(pubkey = %pubkey, error = %e, "/_admin/provision: write inner .acl failed (non-fatal; sibling ACL governs)");
}
}
#[cfg(feature = "git")]
{
if !pod_dir.join(".git").exists() {
let init_out = crate::hardened_git::hardened_git_async()
.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 = crate::hardened_git::hardened_git_async()
.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::auth::{BasicNostrExtractor, GitAuth};
use solid_pod_rs_git::service::GitRequest;
let path = req.uri().path().to_string();
let pod_name = path
.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("")
.to_string();
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 = Some(scheme::request_origin(&req));
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,
host_url,
};
if git_req.is_write() {
let mut builder = HttpResponse::Forbidden();
for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
builder.insert_header((k, v));
}
return builder.json(serde_json::json!({
"error": "git push is not served by this server",
"reason": "push_not_served"
}));
}
let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
Ok(pk) => Some(format!("did:nostr:{pk}")),
Err(_) => None,
};
let wac_path = format!("/{pod_name}/");
let origin = req_origin(&req);
let wac = enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin)
.await
.map(|_audience| ());
if let Err(e) = wac {
let mut resp = e.error_response();
for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
if let (Ok(name), Ok(value)) = (
actix_web::http::header::HeaderName::from_bytes(k.as_bytes()),
actix_web::http::header::HeaderValue::from_str(v),
) {
resp.headers_mut().insert(name, value);
}
}
return resp;
}
let service = solid_pod_rs_git::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();
let mut builder = HttpResponse::build(
actix_web::http::StatusCode::from_u16(status)
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
);
for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
builder.insert_header((k, v));
}
builder.json(serde_json::json!({"error": e.to_string()}))
}
}
}
#[cfg(feature = "forge")]
fn forge_plugin_dir(state: &AppState) -> Option<PathBuf> {
state.data_root.as_ref().map(|r| r.join(".forge"))
}
#[cfg(feature = "forge")]
struct ServerLoopback {
client: reqwest::Client,
}
#[cfg(feature = "forge")]
#[async_trait::async_trait]
impl solid_pod_rs_forge::LoopbackFetch for ServerLoopback {
async fn get(
&self,
url: &str,
max_bytes: usize,
timeout_secs: u64,
) -> solid_pod_rs_forge::bodies::FetchResult {
use solid_pod_rs_forge::bodies::FetchResult;
let resp = match self
.client
.get(url)
.timeout(Duration::from_secs(timeout_secs.max(1)))
.send()
.await
{
Ok(r) => r,
Err(e) => return FetchResult::Error(e.to_string()),
};
let code = resp.status().as_u16();
if code == 404 || code == 410 {
return FetchResult::Removed;
}
if !resp.status().is_success() {
return FetchResult::Error(format!("status {code}"));
}
if resp
.content_length()
.is_some_and(|length| length > max_bytes as u64)
{
return FetchResult::TooLarge;
}
let mut stream = resp.bytes_stream();
let mut body = Vec::with_capacity(max_bytes.min(16 * 1024));
while let Some(chunk) = stream.next().await {
match chunk {
Ok(chunk) if body.len().saturating_add(chunk.len()) <= max_bytes => {
body.extend_from_slice(&chunk);
}
Ok(_) => return FetchResult::TooLarge,
Err(error) => return FetchResult::Error(error.to_string()),
}
}
FetchResult::Body(body)
}
}
#[cfg(feature = "forge")]
fn env_list(name: &str) -> Vec<String> {
std::env::var(name)
.ok()
.map(|v| {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[cfg(feature = "forge")]
fn forge_config() -> solid_pod_rs_forge::ForgeConfig {
use serde_json::{json, Map, Value};
let mut o = Map::new();
let flag = |n: &str| {
std::env::var(n)
.ok()
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
};
if let Some(b) = flag("FORGE_PRIVATE_REPOS") {
o.insert("privateRepos".into(), json!(b));
}
if let Some(b) = flag("FORGE_ALLOW_MAINNET") {
o.insert("allowMainnet".into(), json!(b));
}
if let Some(b) = flag("FORGE_OPEN_EDIT") {
o.insert("openEdit".into(), json!(b));
}
if let Some(b) = flag("FORGE_ANCHORING_UI") {
o.insert("anchoringUi".into(), json!(b));
}
if let Some(b) = flag("FORGE_SPARSE_MARKS") {
o.insert("sparseMarks".into(), json!(b));
}
if let Ok(c) = std::env::var("FORGE_CHAIN") {
if !c.trim().is_empty() {
o.insert("chain".into(), Value::String(c.trim().to_string()));
}
}
if let Ok(u) = std::env::var("FORGE_BASE_URL") {
if !u.trim().is_empty() {
o.insert("baseUrl".into(), Value::String(u.trim().to_string()));
}
}
let relays = env_list("FORGE_ANNOUNCE_RELAYS");
if !relays.is_empty() {
o.insert("announceRelays".into(), json!(relays));
}
solid_pod_rs_forge::ForgeConfig::from_env(Value::Object(o))
}
#[cfg(feature = "forge-mirror")]
fn mirror_config() -> Option<solid_pod_rs_forge::mirror::MirrorConfig> {
use serde_json::json;
let relays = env_list("FORGE_MIRROR_RELAYS");
let follow: Vec<serde_json::Value> = env_list("FORGE_MIRROR_FOLLOW")
.iter()
.filter_map(|e| e.split_once('/'))
.map(|(pk, name)| json!({"pubkey": pk.trim(), "name": name.trim()}))
.collect();
let mut cfg = json!({"relays": relays, "follow": follow});
if let Some(ms) = std::env::var("FORGE_MIRROR_MIN_INTERVAL_MS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
{
cfg["minIntervalMs"] = json!(ms);
}
solid_pod_rs_forge::mirror::parse_mirror_config(&cfg)
}
#[cfg(feature = "forge")]
struct ForgeRuntime {
service: Arc<solid_pod_rs_forge::ForgeService>,
#[cfg(feature = "forge-mirror")]
_mirror: Option<solid_pod_rs_forge::mirror::MirrorHandle>,
}
#[cfg(feature = "forge")]
fn forge_service(
plugin_dir: PathBuf,
) -> Result<Arc<solid_pod_rs_forge::ForgeService>, solid_pod_rs_forge::ForgeError> {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static REGISTRY: OnceLock<Mutex<HashMap<PathBuf, Arc<ForgeRuntime>>>> = OnceLock::new();
let reg = REGISTRY.get_or_init(|| Mutex::new(HashMap::new()));
let mut reg = reg
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(rt) = reg.get(&plugin_dir) {
return Ok(Arc::clone(&rt.service));
}
let cfg = forge_config();
#[cfg(feature = "forge-announce")]
let relay_policy: Arc<dyn solid_pod_rs_forge::publish::SsrfCheck> = Arc::new(
solid_pod_rs_forge::publish::OperatorRelays::new(&cfg.announce_relays),
);
let loopback: Arc<dyn solid_pod_rs_forge::LoopbackFetch> = Arc::new(ServerLoopback {
client: reqwest::Client::new(),
});
let svc =
solid_pod_rs_forge::ForgeService::new(cfg, plugin_dir.clone())?.with_loopback(loopback);
#[cfg(feature = "forge-announce")]
let svc = svc.with_announce_ssrf(relay_policy);
let service = Arc::new(svc);
#[cfg(feature = "forge-mirror")]
let mirror = mirror_config().map(|mcfg| {
use solid_pod_rs_forge::mirror::{GitCli, Mirror, MirrorStore};
let git = Arc::new(GitCli::new(plugin_dir.join("repos")));
let engine = Arc::new(Mirror::new(mcfg, MirrorStore::new(&plugin_dir), git));
tracing::info!("forge: mirror on kind-30618 started");
engine.start()
});
reg.insert(
plugin_dir,
Arc::new(ForgeRuntime {
service: Arc::clone(&service),
#[cfg(feature = "forge-mirror")]
_mirror: mirror,
}),
);
Ok(service)
}
#[cfg(feature = "forge")]
pub fn start_forge(state: &AppState) {
if let Some(dir) = forge_plugin_dir(state) {
match forge_service(dir) {
Ok(_) => tracing::info!("forge: started"),
Err(e) => tracing::error!("forge: could not start: {e}"),
}
}
}
#[cfg(feature = "forge")]
async fn handle_forge(
req: HttpRequest,
body: web::Bytes,
state: web::Data<AppState>,
) -> HttpResponse {
use solid_pod_rs_forge::ForgeRequest;
let Some(plugin_dir) = forge_plugin_dir(&state) else {
return HttpResponse::NotImplemented().json(serde_json::json!({
"error": "forge requires fs-backend storage",
"reason": "data_root_not_configured"
}));
};
let service = match forge_service(plugin_dir) {
Ok(s) => s,
Err(e) => {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()}));
}
};
let path = req.uri().path().to_string();
let query = req.uri().query().unwrap_or("").to_string();
let host_url = Some(scheme::request_origin(&req));
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 forge_req = ForgeRequest {
method: req.method().as_str().to_string(),
path,
query,
headers,
raw_body: body,
host_url,
};
let agent = service.resolve_agent(&forge_req).await;
#[cfg(feature = "jev")]
let jev_ctx = (
forge_req.method.clone(),
forge_req.path.clone(),
forge_req.query.clone(),
);
match service.handle(forge_req, agent).await {
Ok(resp) => {
let mut builder = HttpResponse::build(
StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
);
for (k, v) in &resp.headers {
#[cfg(feature = "jev")]
if k.eq_ignore_ascii_case("content-length") {
continue;
}
builder.insert_header((k.as_str(), v.as_str()));
}
#[cfg(feature = "jev")]
if jev::enabled_in_process_env() {
let rt = jev::JevRuntime::from_process_env();
if let Some(body) = jev::rerank_forge_listing(
&rt,
&jev_ctx.0,
&jev_ctx.1,
&jev_ctx.2,
resp.status,
&resp.body,
)
.await
{
return builder.body(body);
}
}
builder.body(resp.body)
}
Err(e) => {
let status = e.status_code();
HttpResponse::build(
StatusCode::from_u16(status).unwrap_or(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 allowed_hosts = state.allowed_hosts.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).with_host_policy(HostPolicy {
allowed: allowed_hosts,
trust_proxy: state.trust_proxy,
}));
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),
);
}
#[cfg(feature = "export-jsonld")]
{
app = app.route("/api/exports/all", web::get().to(handle_export_all));
}
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.configure(handlers::pay::register);
app = app.route("/proxy", web::get().to(handle_proxy));
if state.mcp_enabled {
app = app.route("/mcp", web::post().to(mcp::handle_mcp)).route(
"/mcp",
web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options),
);
}
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),
);
#[cfg(feature = "forge")]
{
app = app
.route("/forge", web::route().to(handle_forge))
.route("/forge/{tail:.*}", web::route().to(handle_forge));
}
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),
);
app = app.configure(handlers::prov::register);
}
#[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),
)
}
#[cfg(test)]
mod payment_gating_tests {
use super::*;
use solid_pod_rs::payments::WebLedger;
use solid_pod_rs::storage::memory::MemoryBackend;
macro_rules! principal {
() => {
"did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
};
}
const PRINCIPAL: &str = principal!();
const OTHER_PRINCIPAL: &str =
"did:nostr:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const PAID_WRITE_ACL: &str = concat!(
r#"
@prefix acl: <http://www.w3.org/ns/auth/acl#> .
<#paid-write> a acl:Authorization ;
acl:agent <"#,
principal!(),
r#"> ;
acl:accessTo </premium/inbox> ;
acl:mode acl:Write ;
acl:condition [
a acl:PaymentCondition ;
acl:costSats 100
] .
"#
);
async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
let mut ledger = WebLedger::new("Test Pod Credits");
if sats > 0 {
ledger
.credit_by_outpoint(did, "satoshi", &"ab".repeat(32), 0, sats)
.unwrap();
}
let body = serde_json::to_vec(&ledger).unwrap();
storage
.put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
.await
.unwrap();
}
async fn seed_acl(storage: &dyn Storage) {
storage
.put(
"/premium/inbox.acl",
Bytes::from(PAID_WRITE_ACL),
"text/turtle",
)
.await
.unwrap();
}
#[actix_web::test]
async fn resolve_balance_reads_ledger_entry() {
let storage = MemoryBackend::new();
seed_ledger(&storage, PRINCIPAL, 250).await;
assert_eq!(
resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
Some(250)
);
}
#[actix_web::test]
async fn resolve_balance_zero_when_no_entry() {
let storage = MemoryBackend::new();
seed_ledger(&storage, OTHER_PRINCIPAL, 500).await;
assert_eq!(
resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
Some(0)
);
}
#[test]
fn fixtures_use_teller_accounts_and_grant_the_principal() {
use solid_pod_rs::payments::account_of;
for did in [PRINCIPAL, OTHER_PRINCIPAL] {
assert_eq!(account_of(did).unwrap(), did, "{did} must be canonical");
}
assert_ne!(PRINCIPAL, OTHER_PRINCIPAL);
assert!(account_of("did:nostr:bob").is_err());
let agent = format!("acl:agent <{PRINCIPAL}>");
for (name, acl) in [
("PAID_WRITE_ACL", PAID_WRITE_ACL),
("WRITE_NOT_CONTROL_ACL", WRITE_NOT_CONTROL_ACL),
] {
assert!(acl.contains(&agent), "{name} must grant PRINCIPAL");
}
assert!(!WRITE_NOT_CONTROL_ACL.contains("did:nostr:alice"));
}
#[actix_web::test]
async fn uncharged_read_skips_pay_per_read_resource() {
let storage = Arc::new(MemoryBackend::new());
let acl = format!(
"@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
<#paid> a acl:Authorization ; acl:agent <{PRINCIPAL}> ;\n\
acl:accessTo </premium/doc> ; acl:mode acl:Read ;\n\
acl:condition [ a acl:PaymentCondition ; acl:costSats 100 ] .\n"
);
storage
.put("/premium/doc.acl", Bytes::from(acl), "text/turtle")
.await
.unwrap();
seed_ledger(storage.as_ref(), PRINCIPAL, 500).await;
let state = AppState::new(storage.clone());
let skipped =
enforce_read_inner(&state, "/premium/doc", Some(PRINCIPAL), None, false).await;
assert!(skipped.is_err(), "glob must not serve a pay-per-read child");
assert_eq!(
resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
Some(500)
);
enforce_read_ctx(&state, "/premium/doc", Some(PRINCIPAL), None)
.await
.expect("a direct read pays and is served");
assert_eq!(
resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
Some(400)
);
}
#[actix_web::test]
async fn resolve_balance_none_when_anonymous() {
let storage = MemoryBackend::new();
seed_ledger(&storage, PRINCIPAL, 1_000).await;
assert_eq!(resolve_balance_sats(&storage, None).await, None);
}
#[actix_web::test]
async fn paid_write_denied_below_balance() {
let storage = Arc::new(MemoryBackend::new());
seed_acl(storage.as_ref()).await;
seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; let state = AppState::new(storage);
let result =
enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
assert!(
result.is_err(),
"balance 50 < cost 100 must be denied — sat-gating loop closed"
);
}
#[actix_web::test]
async fn paid_write_allowed_at_balance() {
let storage = Arc::new(MemoryBackend::new());
seed_acl(storage.as_ref()).await;
seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; let state = AppState::new(storage);
let result =
enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
assert!(
result.is_ok(),
"balance 100 >= cost 100 must be granted — sat-gating loop closed"
);
}
#[actix_web::test]
async fn paid_write_allowed_above_balance() {
let storage = Arc::new(MemoryBackend::new());
seed_acl(storage.as_ref()).await;
seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
let state = AppState::new(storage);
let result =
enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
}
#[actix_web::test]
async fn paid_write_anonymous_denied() {
let storage = Arc::new(MemoryBackend::new());
seed_acl(storage.as_ref()).await;
seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
let state = AppState::new(storage);
let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
assert!(
result.is_err(),
"anonymous caller has no ledger principal — PaymentCondition fails closed"
);
}
async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
ledger.get_balance(did)
}
#[actix_web::test]
async fn paid_write_debits_ledger() {
let storage = Arc::new(MemoryBackend::new());
seed_acl(storage.as_ref()).await;
seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
let result =
enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
assert_eq!(
read_balance(storage.as_ref(), PRINCIPAL).await,
150,
"250 - 100 cost: the grant must debit exactly the matched rule's cost"
);
}
#[actix_web::test]
async fn paid_write_debits_each_grant() {
let storage = Arc::new(MemoryBackend::new());
seed_acl(storage.as_ref()).await;
seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; let state = AppState::new(storage.clone());
enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
.await
.unwrap();
enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
.await
.unwrap();
assert_eq!(
read_balance(storage.as_ref(), PRINCIPAL).await,
50,
"250 - 2*100: each granted request debits, no unmetered re-use"
);
let third =
enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
assert_eq!(
read_balance(storage.as_ref(), PRINCIPAL).await,
50,
"a denied request must not debit"
);
}
#[actix_web::test]
async fn paid_read_debits_ledger() {
const PAID_READ_ACL: &str = r#"
@prefix acl: <http://www.w3.org/ns/auth/acl#> .
<#paid-read> a acl:Authorization ;
acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
acl:accessTo </premium/feed> ;
acl:mode acl:Read ;
acl:condition [
a acl:PaymentCondition ;
acl:costSats 30
] .
"#;
let storage = Arc::new(MemoryBackend::new());
storage
.put(
"/premium/feed.acl",
Bytes::from(PAID_READ_ACL),
"text/turtle",
)
.await
.unwrap();
seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
let state = AppState::new(storage.clone());
let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
assert_eq!(
read_balance(storage.as_ref(), PRINCIPAL).await,
70,
"100 - 30 cost: a granted paid read must debit"
);
}
#[actix_web::test]
async fn free_read_does_not_debit() {
let storage = Arc::new(MemoryBackend::new());
seed_private_read_acl(storage.as_ref()).await; seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
let state = AppState::new(storage.clone());
enforce_read(&state, "/private/secret", Some(PRINCIPAL))
.await
.unwrap();
assert_eq!(
read_balance(storage.as_ref(), PRINCIPAL).await,
100,
"a grant with no PaymentCondition must not debit"
);
}
const ALICE_ONLY_READ_ACL: &str = r#"
@prefix acl: <http://www.w3.org/ns/auth/acl#> .
<#alice> a acl:Authorization ;
acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
acl:accessTo </private/secret> ;
acl:default </private/> ;
acl:mode acl:Read, acl:Write, acl:Control .
"#;
async fn seed_private_read_acl(storage: &dyn Storage) {
storage
.put(
"/private.acl",
Bytes::from(ALICE_ONLY_READ_ACL),
"text/turtle",
)
.await
.unwrap();
}
#[actix_web::test]
async fn enforce_read_grants_owner() {
let storage = Arc::new(MemoryBackend::new());
seed_private_read_acl(storage.as_ref()).await;
let state = AppState::new(storage);
let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
assert!(result.is_ok(), "owner alice must be granted Read");
}
#[actix_web::test]
async fn enforce_read_denies_other_principal() {
let storage = Arc::new(MemoryBackend::new());
seed_private_read_acl(storage.as_ref()).await;
let state = AppState::new(storage);
let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
assert!(
result.is_err(),
"bob has no Read grant — private resource must not be world-readable"
);
}
#[actix_web::test]
async fn enforce_read_denies_anonymous() {
let storage = Arc::new(MemoryBackend::new());
seed_private_read_acl(storage.as_ref()).await;
let state = AppState::new(storage);
let result = enforce_read(&state, "/private/secret", None).await;
assert!(result.is_err(), "anonymous Read must be denied");
}
const WRITE_NOT_CONTROL_ACL: &str = concat!(
r#"
@prefix acl: <http://www.w3.org/ns/auth/acl#> .
<#owner> a acl:Authorization ;
acl:agent <"#,
principal!(),
r#"> ;
acl:accessTo </shared/doc> ;
acl:default </shared/> ;
acl:mode acl:Read, acl:Write, acl:Control .
<#writer> a acl:Authorization ;
acl:agent <did:nostr:writer> ;
acl:accessTo </shared/doc> ;
acl:default </shared/> ;
acl:mode acl:Read, acl:Write .
"#
);
async fn seed_shared_acl(storage: &dyn Storage) {
storage
.put(
"/shared.acl",
Bytes::from(WRITE_NOT_CONTROL_ACL),
"text/turtle",
)
.await
.unwrap();
}
#[actix_web::test]
async fn acl_put_denied_for_writer_without_control() {
let storage = Arc::new(MemoryBackend::new());
seed_shared_acl(storage.as_ref()).await;
let state = AppState::new(storage);
let result = enforce_write(
&state,
"/shared/.acl",
AccessMode::Write,
Some("did:nostr:writer"),
)
.await;
assert!(
result.is_err(),
"writer lacks Control — must not be able to PUT /shared/.acl"
);
}
#[actix_web::test]
async fn acl_put_allowed_for_control_holder() {
let storage = Arc::new(MemoryBackend::new());
seed_shared_acl(storage.as_ref()).await;
let state = AppState::new(storage);
let result =
enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
assert!(
result.is_ok(),
"the owner holds Control — must be allowed to PUT /shared/.acl"
);
}
#[actix_web::test]
async fn meta_put_denied_for_writer_without_control() {
let storage = Arc::new(MemoryBackend::new());
seed_shared_acl(storage.as_ref()).await;
let state = AppState::new(storage);
let result = enforce_write(
&state,
"/shared/doc.meta",
AccessMode::Write,
Some("did:nostr:writer"),
)
.await;
assert!(
result.is_err(),
"writer lacks Control — must not be able to PUT a .meta sidecar"
);
}
#[test]
fn canonical_pod_path_vectors() {
let ok = [
("a/b", "/a/b"),
("", "/"),
("/", "/"),
("//a//b/", "/a/b/"),
("/%2ewell-known/x", "/.well-known/x"),
("/a%2fb", "/a/b"),
("/a%2520b", "/a%20b"),
];
for (raw, want) in ok {
assert_eq!(canonical_pod_path(raw).as_deref(), Some(want), "{raw}");
}
for bad in [
"/a/./b",
"/a/..",
"../a",
"/a\\b",
"/a%00b",
"/a%ffb",
"/%2e%2e/x",
] {
assert_eq!(canonical_pod_path(bad), None, "{bad}");
}
}
#[test]
fn guarded_acl_target_vectors() {
use AccessMode::{Control, Read, Write};
let c = |p: &str, m| guarded_acl_target(p, m);
assert_eq!(
c("/.well-known/token/x.json", Read),
("/.well-known/token/x.json".into(), Control)
);
assert_eq!(
c("/.well-known/gitmark/p.json", Write),
("/.well-known/gitmark/p.json".into(), Control)
);
assert_eq!(c("/.well-known/%74oken/x.json", Read).1, Control);
assert_eq!(
c("/.well-known/token/x.json.acl", Read),
("/.well-known/token/x.json".into(), Control)
);
assert_eq!(
c("/.well-known/gitmark/p/blocktrails.json", Read),
("/.well-known/gitmark/p/blocktrails.json".into(), Read)
);
assert_eq!(
c("/.well-known/webledgers/webledgers.json", Write),
("/.well-known/webledgers/webledgers.json".into(), Control)
);
assert_eq!(c("/.well-known/%57ebledgers/state.json", Read).1, Control);
assert_eq!(c("/.well-known/webledgersx/y", Read).1, Read);
assert_eq!(c("/pub/x", Read), ("/pub/x".into(), Read));
}
#[test]
fn protected_resource_for_acl_strips_suffixes() {
assert_eq!(
protected_resource_for_acl("/victim/.acl").as_deref(),
Some("/victim/")
);
assert_eq!(
protected_resource_for_acl("/a/b.acl").as_deref(),
Some("/a/b")
);
assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
assert_eq!(
protected_resource_for_acl("/a/b.meta").as_deref(),
Some("/a/b")
);
assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
}
}
#[cfg(all(test, feature = "forge-mirror"))]
mod forge_env_tests {
use super::*;
#[test]
fn mirror_config_comes_from_the_environment() {
let pk = "ab".repeat(32);
std::env::remove_var("FORGE_MIRROR_RELAYS");
std::env::remove_var("FORGE_MIRROR_FOLLOW");
assert!(mirror_config().is_none(), "nothing configured: mirror off");
std::env::set_var(
"FORGE_MIRROR_RELAYS",
"ws://127.0.0.1:7777, wss://r.example , nope",
);
assert!(mirror_config().is_none(), "relays alone follow nothing");
std::env::set_var(
"FORGE_MIRROR_FOLLOW",
format!("{pk}/agent, bad/entry, {pk}/../x"),
);
std::env::set_var("FORGE_MIRROR_MIN_INTERVAL_MS", "1000");
let cfg = mirror_config().expect("one relay and one valid entry");
assert_eq!(cfg.relays, ["ws://127.0.0.1:7777", "wss://r.example"]);
assert_eq!(cfg.follow.len(), 1);
assert_eq!(cfg.follow[0].name, "agent");
assert_eq!(cfg.min_interval_ms, 1000);
std::env::remove_var("FORGE_MIRROR_RELAYS");
std::env::remove_var("FORGE_MIRROR_FOLLOW");
std::env::remove_var("FORGE_MIRROR_MIN_INTERVAL_MS");
}
#[test]
fn the_forge_service_is_built_once_per_data_directory() {
let td = tempfile::tempdir().unwrap();
let a = forge_service(td.path().join(".forge")).unwrap();
let b = forge_service(td.path().join(".forge")).unwrap();
assert!(Arc::ptr_eq(&a, &b));
let other = tempfile::tempdir().unwrap();
let c = forge_service(other.path().join(".forge")).unwrap();
assert!(!Arc::ptr_eq(&a, &c));
}
}