mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-24 17:38:09 -07:00
global: snapshot
This commit is contained in:
@@ -24,6 +24,7 @@ brk_rpc = { workspace = true }
|
||||
brk_types = { workspace = true }
|
||||
brk_traversable = { workspace = true }
|
||||
derive_more = { workspace = true }
|
||||
include_dir = "0.7"
|
||||
vecdb = { workspace = true }
|
||||
jiff = { workspace = true }
|
||||
quick_cache = "0.6.18"
|
||||
@@ -34,5 +35,12 @@ tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tower-http = { version = "0.6.8", features = ["compression-full", "trace"] }
|
||||
|
||||
[build-dependencies]
|
||||
# importmap = { path = "../../../importmap" }
|
||||
importmap = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
brk_mempool = { workspace = true }
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = ["importmap"]
|
||||
|
||||
@@ -8,7 +8,7 @@ Serve BRK data via REST API with OpenAPI documentation, response caching, MCP en
|
||||
|
||||
## Key Features
|
||||
|
||||
- **OpenAPI spec**: Auto-generated documentation at `/api/openapi.json`
|
||||
- **OpenAPI spec**: Auto-generated documentation at `/api.json` (interactive docs at `/api`)
|
||||
- **Response caching**: LRU cache with 5000 entries for repeated queries
|
||||
- **Compression**: Brotli, gzip, deflate, zstd support
|
||||
- **Static files**: Optional web interface hosting
|
||||
@@ -17,7 +17,8 @@ Serve BRK data via REST API with OpenAPI documentation, response caching, MCP en
|
||||
## Core API
|
||||
|
||||
```rust,ignore
|
||||
let server = Server::new(&async_query, Some(files_path));
|
||||
let server = Server::new(&async_query, data_path, WebsiteSource::Filesystem(files_path));
|
||||
// Or WebsiteSource::Embedded, or WebsiteSource::Disabled
|
||||
server.serve(true).await?; // true enables MCP endpoint
|
||||
```
|
||||
|
||||
@@ -25,17 +26,18 @@ server.serve(true).await?; // true enables MCP endpoint
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/api/blocks/{height}` | Block info, transactions, status |
|
||||
| `/api/txs/{txid}` | Transaction details, status, merkle proof |
|
||||
| `/api/addresses/{addr}` | Address stats, transactions, UTXOs |
|
||||
| `/api/block-height/{height}` | Block by height |
|
||||
| `/api/block/{hash}` | Block info, transactions, status |
|
||||
| `/api/tx/{txid}` | Transaction details, status, hex |
|
||||
| `/api/address/{addr}` | Address stats, transactions, UTXOs |
|
||||
| `/api/metrics` | Metric catalog and data queries |
|
||||
| `/api/mining/*` | Hashrate, difficulty, pools, epochs |
|
||||
| `/api/v1/mining/*` | Hashrate, difficulty, pools, rewards |
|
||||
| `/api/mempool/*` | Fee estimates, projected blocks |
|
||||
| `/mcp` | MCP endpoint (if enabled) |
|
||||
|
||||
## Caching
|
||||
|
||||
Uses ETag-based caching with stale-while-revalidate semantics:
|
||||
Uses ETag-based caching with must-revalidate semantics:
|
||||
- Height-indexed data: Cache until height changes
|
||||
- Date-indexed data: Cache with longer TTL
|
||||
- Mempool data: Short TTL, frequent updates
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
fn main() {
|
||||
let profile = std::env::var("PROFILE").unwrap_or_default();
|
||||
use std::{env, path::Path};
|
||||
|
||||
if profile == "release" {
|
||||
println!("cargo:rustc-flag=-C");
|
||||
println!("cargo:rustc-flag=target-cpu=native");
|
||||
fn main() {
|
||||
let is_dev = env::var("PROFILE").is_ok_and(|p| p == "debug");
|
||||
|
||||
// Generate importmap for website (updates index.html in place)
|
||||
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let website_path = Path::new(&manifest_dir).join("../../website");
|
||||
|
||||
println!("cargo:rerun-if-changed=../../website");
|
||||
|
||||
if website_path.exists() {
|
||||
// Skip importmap hashing in dev mode (files change often)
|
||||
let map = if is_dev {
|
||||
importmap::ImportMap::empty()
|
||||
} else {
|
||||
importmap::ImportMap::scan(&website_path, "").unwrap_or_else(|_| importmap::ImportMap::empty())
|
||||
};
|
||||
|
||||
let _ = map.update_html_file(&website_path.join("index.html"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use brk_mempool::Mempool;
|
||||
use brk_query::AsyncQuery;
|
||||
use brk_reader::Reader;
|
||||
use brk_rpc::{Auth, Client};
|
||||
use brk_server::Server;
|
||||
use brk_server::{Server, WebsiteSource};
|
||||
use tracing::info;
|
||||
use vecdb::Exit;
|
||||
|
||||
@@ -54,7 +54,7 @@ fn run() -> Result<()> {
|
||||
|
||||
// Option 1: block_on to run and properly propagate errors
|
||||
runtime.block_on(async move {
|
||||
let server = Server::new(&query, outputs_dir, None);
|
||||
let server = Server::new(&query, outputs_dir, WebsiteSource::Disabled);
|
||||
|
||||
let handle = tokio::spawn(async move { server.serve(true).await });
|
||||
|
||||
|
||||
+159
-104
@@ -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