mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-26 15:49:58 -07:00
global: snapshot
This commit is contained in:
@@ -10,121 +10,63 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use brk_error::{Error, Result};
|
||||
use brk_error::Result;
|
||||
use quick_cache::sync::GuardResult;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{AppState, HeaderMapExtended, ModifiedState, ResponseExtended};
|
||||
use crate::{
|
||||
AppState, EMBEDDED_WEBSITE, HeaderMapExtended, ModifiedState, ResponseExtended, WebsiteSource,
|
||||
};
|
||||
|
||||
pub async fn file_handler(
|
||||
headers: HeaderMap,
|
||||
State(state): State<AppState>,
|
||||
path: extract::Path<String>,
|
||||
) -> Response {
|
||||
any_handler(headers, state, Some(path))
|
||||
any_handler(headers, state, Some(path.0))
|
||||
}
|
||||
|
||||
pub async fn index_handler(headers: HeaderMap, State(state): State<AppState>) -> Response {
|
||||
any_handler(headers, state, None)
|
||||
}
|
||||
|
||||
fn any_handler(
|
||||
headers: HeaderMap,
|
||||
state: AppState,
|
||||
path: Option<extract::Path<String>>,
|
||||
) -> Response {
|
||||
let files_path = state.files_path.as_ref().unwrap();
|
||||
|
||||
if let Some(path) = path.as_ref() {
|
||||
// Sanitize path components to prevent traversal attacks
|
||||
let sanitized: String = path
|
||||
.0
|
||||
.split('/')
|
||||
.filter(|component| !component.is_empty() && *component != "." && *component != "..")
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
|
||||
let mut path = files_path.join(&sanitized);
|
||||
|
||||
// Canonicalize and verify the path stays within the project root
|
||||
// (allows symlinks to modules/ which is outside the website directory)
|
||||
if let Ok(canonical) = path.canonicalize()
|
||||
&& let Ok(canonical_base) = files_path.canonicalize()
|
||||
{
|
||||
// Allow paths within files_path OR within project root (2 levels up)
|
||||
let project_root = canonical_base.parent().and_then(|p| p.parent());
|
||||
let allowed = canonical.starts_with(&canonical_base)
|
||||
|| project_root.is_some_and(|root| canonical.starts_with(root));
|
||||
if !allowed {
|
||||
let mut response: Response<Body> =
|
||||
(StatusCode::FORBIDDEN, "Access denied".to_string()).into_response();
|
||||
response.headers_mut().insert_cors();
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// Strip hash from import-mapped URLs (e.g., foo.abc12345.js -> foo.js)
|
||||
if !path.exists()
|
||||
&& let Some(unhashed) = strip_importmap_hash(&path)
|
||||
&& unhashed.exists()
|
||||
{
|
||||
path = unhashed;
|
||||
}
|
||||
|
||||
if !path.exists() || path.is_dir() {
|
||||
if path.extension().is_some() {
|
||||
let mut response: Response<Body> = (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"File doesn't exist".to_string(),
|
||||
)
|
||||
.into_response();
|
||||
|
||||
response.headers_mut().insert_cors();
|
||||
|
||||
return response;
|
||||
} else {
|
||||
path = files_path.join("index.html");
|
||||
}
|
||||
}
|
||||
|
||||
path_to_response(&headers, &state, &path)
|
||||
} else {
|
||||
path_to_response(&headers, &state, &files_path.join("index.html"))
|
||||
}
|
||||
}
|
||||
|
||||
fn path_to_response(headers: &HeaderMap, state: &AppState, path: &Path) -> Response {
|
||||
match path_to_response_(headers, state, path) {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
let mut response =
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response();
|
||||
|
||||
response.headers_mut().insert_cors();
|
||||
|
||||
response
|
||||
fn any_handler(headers: HeaderMap, state: AppState, path: Option<String>) -> Response {
|
||||
match &state.website {
|
||||
WebsiteSource::Disabled => unreachable!("routes not added when disabled"),
|
||||
WebsiteSource::Embedded => embedded_handler(&state, path),
|
||||
WebsiteSource::Filesystem(files_path) => {
|
||||
filesystem_handler(headers, &state, files_path, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path_to_response_(headers: &HeaderMap, state: &AppState, path: &Path) -> Result<Response> {
|
||||
let (modified, date) = headers.check_if_modified_since(path)?;
|
||||
if !cfg!(debug_assertions) && modified == ModifiedState::NotModifiedSince {
|
||||
return Ok(Response::new_not_modified());
|
||||
}
|
||||
/// Sanitize path to prevent traversal attacks
|
||||
fn sanitize_path(path: &str) -> String {
|
||||
path.split('/')
|
||||
.filter(|c| !c.is_empty() && *c != "." && *c != "..")
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
let serialized_path = path.to_str().unwrap();
|
||||
/// Check if path requires revalidation (HTML files, service worker)
|
||||
fn must_revalidate(path: &Path) -> bool {
|
||||
path.extension().is_some_and(|ext| ext == "html")
|
||||
|| path
|
||||
.to_str()
|
||||
.is_some_and(|p| p.ends_with("service-worker.js"))
|
||||
}
|
||||
|
||||
let must_revalidate = path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "html")
|
||||
|| serialized_path.ends_with("service-worker.js");
|
||||
/// Build response with proper headers and caching
|
||||
fn build_response(state: &AppState, path: &Path, content: Vec<u8>, cache_key: &str) -> Response {
|
||||
let must_revalidate = must_revalidate(path);
|
||||
|
||||
// Use cache for non-HTML files in release mode
|
||||
let guard_res = if !cfg!(debug_assertions) && !must_revalidate {
|
||||
Some(state.cache.get_value_or_guard(
|
||||
&path.to_str().unwrap().to_owned(),
|
||||
Some(Duration::from_millis(50)),
|
||||
))
|
||||
Some(
|
||||
state
|
||||
.cache
|
||||
.get_value_or_guard(&cache_key.to_owned(), Some(Duration::from_millis(50))),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -132,19 +74,10 @@ fn path_to_response_(headers: &HeaderMap, state: &AppState, path: &Path) -> Resu
|
||||
let mut response = if let Some(GuardResult::Value(v)) = guard_res {
|
||||
Response::new(Body::from(v))
|
||||
} else {
|
||||
let content = fs::read(path).unwrap_or_else(|error| {
|
||||
error!("{error}");
|
||||
let path = path.to_str().unwrap();
|
||||
info!("Can't read file {path}");
|
||||
panic!("")
|
||||
});
|
||||
|
||||
if let Some(GuardResult::Guard(g)) = guard_res {
|
||||
g.insert(content.clone().into())
|
||||
.map_err(|_| Error::QuickCacheError)?;
|
||||
let _ = g.insert(content.clone().into());
|
||||
}
|
||||
|
||||
Response::new(content.into())
|
||||
Response::new(Body::from(content))
|
||||
};
|
||||
|
||||
let headers = response.headers_mut();
|
||||
@@ -157,7 +90,129 @@ fn path_to_response_(headers: &HeaderMap, state: &AppState, path: &Path) -> Resu
|
||||
headers.insert_cache_control_immutable();
|
||||
}
|
||||
|
||||
headers.insert_last_modified(date);
|
||||
response
|
||||
}
|
||||
|
||||
fn embedded_handler(state: &AppState, path: Option<String>) -> Response {
|
||||
let path = path.unwrap_or_else(|| "index.html".to_string());
|
||||
let sanitized = sanitize_path(&path);
|
||||
|
||||
// Try to get file, with importmap hash stripping and SPA fallback
|
||||
let file = EMBEDDED_WEBSITE
|
||||
.get_file(&sanitized)
|
||||
.or_else(|| {
|
||||
strip_importmap_hash(Path::new(&sanitized))
|
||||
.and_then(|unhashed| EMBEDDED_WEBSITE.get_file(unhashed.to_str()?))
|
||||
})
|
||||
.or_else(|| {
|
||||
// If no extension, serve index.html (SPA routing)
|
||||
if Path::new(&sanitized).extension().is_none() {
|
||||
EMBEDDED_WEBSITE.get_file("index.html")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let Some(file) = file else {
|
||||
let mut response: Response<Body> =
|
||||
(StatusCode::NOT_FOUND, "File not found".to_string()).into_response();
|
||||
response.headers_mut().insert_cors();
|
||||
return response;
|
||||
};
|
||||
|
||||
build_response(
|
||||
state,
|
||||
Path::new(file.path()),
|
||||
file.contents().to_vec(),
|
||||
&file.path().to_string_lossy(),
|
||||
)
|
||||
}
|
||||
|
||||
fn filesystem_handler(
|
||||
headers: HeaderMap,
|
||||
state: &AppState,
|
||||
files_path: &Path,
|
||||
path: Option<String>,
|
||||
) -> Response {
|
||||
let path = if let Some(path) = path {
|
||||
let sanitized = sanitize_path(&path);
|
||||
let mut path = files_path.join(&sanitized);
|
||||
|
||||
// Canonicalize and verify the path stays within the project root
|
||||
// (allows symlinks to modules/ which is outside the website directory)
|
||||
if let Ok(canonical) = path.canonicalize()
|
||||
&& let Ok(canonical_base) = files_path.canonicalize()
|
||||
{
|
||||
let project_root = canonical_base.parent().and_then(|p| p.parent());
|
||||
let allowed = canonical.starts_with(&canonical_base)
|
||||
|| project_root.is_some_and(|root| canonical.starts_with(root));
|
||||
if !allowed {
|
||||
let mut response: Response<Body> =
|
||||
(StatusCode::FORBIDDEN, "Access denied".to_string()).into_response();
|
||||
response.headers_mut().insert_cors();
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// Strip hash from import-mapped URLs
|
||||
if !path.exists()
|
||||
&& let Some(unhashed) = strip_importmap_hash(&path)
|
||||
&& unhashed.exists()
|
||||
{
|
||||
path = unhashed;
|
||||
}
|
||||
|
||||
// SPA fallback
|
||||
if !path.exists() || path.is_dir() {
|
||||
if path.extension().is_some() {
|
||||
let mut response: Response<Body> = (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"File doesn't exist".to_string(),
|
||||
)
|
||||
.into_response();
|
||||
response.headers_mut().insert_cors();
|
||||
return response;
|
||||
} else {
|
||||
path = files_path.join("index.html");
|
||||
}
|
||||
}
|
||||
|
||||
path
|
||||
} else {
|
||||
files_path.join("index.html")
|
||||
};
|
||||
|
||||
path_to_response(&headers, state, &path)
|
||||
}
|
||||
|
||||
fn path_to_response(headers: &HeaderMap, state: &AppState, path: &Path) -> Response {
|
||||
match path_to_response_(headers, state, path) {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
let mut response =
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response();
|
||||
response.headers_mut().insert_cors();
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path_to_response_(headers: &HeaderMap, state: &AppState, path: &Path) -> Result<Response> {
|
||||
let (modified, date) = headers.check_if_modified_since(path)?;
|
||||
if !cfg!(debug_assertions) && modified == ModifiedState::NotModifiedSince {
|
||||
return Ok(Response::new_not_modified());
|
||||
}
|
||||
|
||||
let content = fs::read(path).unwrap_or_else(|error| {
|
||||
error!("{error}");
|
||||
let path = path.to_str().unwrap();
|
||||
info!("Can't read file {path}");
|
||||
panic!("")
|
||||
});
|
||||
|
||||
let cache_key = path.to_str().unwrap();
|
||||
let mut response = build_response(state, path, content, cache_key);
|
||||
response.headers_mut().insert_last_modified(date);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use aide::axum::ApiRouter;
|
||||
use axum::{response::Redirect, routing::get};
|
||||
|
||||
use super::AppState;
|
||||
use super::{AppState, WebsiteSource};
|
||||
|
||||
mod file;
|
||||
|
||||
use file::{file_handler, index_handler};
|
||||
|
||||
pub trait FilesRoutes {
|
||||
fn add_files_routes(self, path: Option<&PathBuf>) -> Self;
|
||||
fn add_files_routes(self, website: &WebsiteSource) -> Self;
|
||||
}
|
||||
|
||||
impl FilesRoutes for ApiRouter<AppState> {
|
||||
fn add_files_routes(self, path: Option<&PathBuf>) -> Self {
|
||||
if path.is_some() {
|
||||
fn add_files_routes(self, website: &WebsiteSource) -> Self {
|
||||
if website.is_enabled() {
|
||||
self.route("/{*path}", get(file_handler))
|
||||
.route("/", get(index_handler))
|
||||
} else {
|
||||
|
||||
@@ -15,11 +15,29 @@ use axum::{
|
||||
use brk_error::Result;
|
||||
use brk_mcp::route::mcp_router;
|
||||
use brk_query::AsyncQuery;
|
||||
use include_dir::{include_dir, Dir};
|
||||
use quick_cache::sync::Cache;
|
||||
use tokio::net::TcpListener;
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
use tracing::{error, info};
|
||||
|
||||
/// Embedded website assets
|
||||
pub static EMBEDDED_WEBSITE: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../website");
|
||||
|
||||
/// Source for serving the website
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WebsiteSource {
|
||||
Disabled,
|
||||
Embedded,
|
||||
Filesystem(PathBuf),
|
||||
}
|
||||
|
||||
impl WebsiteSource {
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
!matches!(self, Self::Disabled)
|
||||
}
|
||||
}
|
||||
|
||||
mod api;
|
||||
pub mod cache;
|
||||
mod extended;
|
||||
@@ -37,12 +55,12 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub struct Server(AppState);
|
||||
|
||||
impl Server {
|
||||
pub fn new(query: &AsyncQuery, data_path: PathBuf, files_path: Option<PathBuf>) -> Self {
|
||||
pub fn new(query: &AsyncQuery, data_path: PathBuf, website: WebsiteSource) -> Self {
|
||||
Self(AppState {
|
||||
client: query.client().clone(),
|
||||
query: query.clone(),
|
||||
data_path,
|
||||
files_path,
|
||||
website,
|
||||
cache: Arc::new(Cache::new(5_000)),
|
||||
started_at: jiff::Timestamp::now(),
|
||||
started_instant: Instant::now(),
|
||||
@@ -87,7 +105,7 @@ impl Server {
|
||||
let vecs = state.query.inner().vecs();
|
||||
let router = ApiRouter::new()
|
||||
.add_api_routes()
|
||||
.add_files_routes(state.files_path.as_ref())
|
||||
.add_files_routes(&state.website)
|
||||
.route(
|
||||
"/discord",
|
||||
get(Redirect::temporary("https://discord.gg/WACpShCB7M")),
|
||||
|
||||
@@ -13,7 +13,7 @@ use quick_cache::sync::Cache;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
CacheParams, CacheStrategy,
|
||||
CacheParams, CacheStrategy, WebsiteSource,
|
||||
extended::{ResponseExtended, ResultExtended},
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ pub struct AppState {
|
||||
#[deref]
|
||||
pub query: AsyncQuery,
|
||||
pub data_path: PathBuf,
|
||||
pub files_path: Option<PathBuf>,
|
||||
pub website: WebsiteSource,
|
||||
pub cache: Arc<Cache<String, Bytes>>,
|
||||
pub client: Client,
|
||||
pub started_at: Timestamp,
|
||||
|
||||
Reference in New Issue
Block a user