Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ futures = "0.3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
chrono = { version = "0.4", default-features = false, features = ["std"] }
rand = "0.9"
uuid = { version = "1", features = ["v4"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "migrate"] }
Expand Down
8 changes: 4 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
use serde::Deserialize;
use std::path::Path;
use std::path::{Path, PathBuf};

#[derive(Deserialize)]
pub struct Config {
pub log_dir: String,
pub log_dir: PathBuf,
pub database_url: String,
pub registry_url: String,
pub rate_limit_global_rpm: u64,
pub rate_limit_per_key_rpm: u64,
pub docs_dir: String,
pub local_db_path: String,
pub docs_dir: PathBuf,
pub local_db_path: PathBuf,
}

impl Config {
Expand Down
60 changes: 60 additions & 0 deletions src/log_files.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
use chrono::NaiveDate;
use std::path::{Path, PathBuf};

pub const LOG_FILE_BASENAME: &str = "st0x-rest-api.log";

#[derive(Debug, Clone)]
pub struct LogFiles {
root: PathBuf,
}

#[derive(Debug, Clone)]
pub struct LogFile {
path: PathBuf,
download_filename: String,
}

impl LogFiles {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}

pub fn file_for_date(&self, date: NaiveDate) -> LogFile {
let formatted_date = date.format("%Y-%m-%d");
LogFile {
path: self
.root
.join(format!("{LOG_FILE_BASENAME}.{formatted_date}")),
download_filename: format!("st0x-rest-api-{formatted_date}.log"),
}
}
}

impl LogFile {
pub fn path(&self) -> &Path {
&self.path
}

pub fn download_filename(&self) -> &str {
&self.download_filename
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn builds_expected_daily_log_path_and_filename() {
let logs = LogFiles::new("/tmp/st0x-logs");
let date = NaiveDate::from_ymd_opt(2026, 3, 13).expect("valid test date");

let file = logs.file_for_date(date);

assert_eq!(
file.path(),
Path::new("/tmp/st0x-logs/st0x-rest-api.log.2026-03-13")
);
assert_eq!(file.download_filename(), "st0x-rest-api-2026-03-13.log");
}
}
23 changes: 16 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod config;
mod db;
mod error;
mod fairings;
mod log_files;
mod raindex;
mod routes;
mod telemetry;
Expand Down Expand Up @@ -114,7 +115,8 @@ pub(crate) fn rocket(
pool: db::DbPool,
rate_limiter: fairings::RateLimiter,
raindex_config: raindex::SharedRaindexProvider,
docs_dir: String,
docs_dir: std::path::PathBuf,
log_dir: std::path::PathBuf,
) -> Result<rocket::Rocket<rocket::Build>, StartupError> {
let cors = configure_cors()?;

Expand All @@ -126,6 +128,7 @@ pub(crate) fn rocket(
.manage(pool)
.manage(rate_limiter)
.manage(raindex_config)
.manage(log_files::LogFiles::new(log_dir))
.mount("/", routes::health::routes())
.mount("/v1/tokens", routes::tokens::routes())
.mount("/v1/swap", routes::swap::routes())
Expand Down Expand Up @@ -222,7 +225,7 @@ async fn main() {
}
};

let local_db_path = std::path::PathBuf::from(&cfg.local_db_path);
let local_db_path = cfg.local_db_path.clone();
if let Some(parent) = local_db_path.parent() {
if !parent.exists() {
if let Err(e) = std::fs::create_dir_all(parent) {
Expand Down Expand Up @@ -254,14 +257,20 @@ async fn main() {
let rate_limiter =
fairings::RateLimiter::new(cfg.rate_limit_global_rpm, cfg.rate_limit_per_key_rpm);

if !std::path::Path::new(&cfg.docs_dir).is_dir() {
tracing::error!(docs_dir = %cfg.docs_dir, "docs_dir is not a valid directory");
if !cfg.docs_dir.is_dir() {
tracing::error!(docs_dir = %cfg.docs_dir.display(), "docs_dir is not a valid directory");
drop(log_guard);
std::process::exit(1);
}
tracing::info!(docs_dir = %cfg.docs_dir, "serving documentation at /docs");

let rocket = match rocket(pool, rate_limiter, shared_raindex, cfg.docs_dir) {
tracing::info!(docs_dir = %cfg.docs_dir.display(), "serving documentation at /docs");

let rocket = match rocket(
pool,
rate_limiter,
shared_raindex,
cfg.docs_dir.clone(),
cfg.log_dir.clone(),
) {
Ok(r) => r,
Err(e) => {
tracing::error!(error = %e, "failed to build Rocket instance");
Expand Down
229 changes: 229 additions & 0 deletions src/routes/admin/logs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
use crate::auth::AdminKey;
use crate::error::ApiError;
use crate::fairings::{GlobalRateLimit, TracingSpan};
use crate::log_files::LogFiles;
use chrono::NaiveDate;
use rocket::form::FromForm;
use rocket::fs::NamedFile;
use rocket::http::{ContentType, Header};
use rocket::request::Request;
use rocket::response::Responder;
use rocket::{Route, State};
use tracing::Instrument;

#[derive(Debug, Clone, FromForm)]
pub struct AdminLogDownloadParams {
pub date: Option<String>,
}

pub struct AdminLogDownload {
file: NamedFile,
filename: String,
}

impl<'r> Responder<'r, 'static> for AdminLogDownload {
fn respond_to(self, req: &'r Request<'_>) -> rocket::response::Result<'static> {
let mut response = self.file.respond_to(req)?;
response.set_header(ContentType::Binary);
response.set_header(Header::new(
"Content-Disposition",
format!("attachment; filename=\"{}\"", self.filename),
));
Ok(response)
}
}

fn parse_log_date(params: &AdminLogDownloadParams) -> Result<NaiveDate, ApiError> {
let Some(date) = params.date.as_deref() else {
tracing::warn!("missing required date query parameter");
return Err(ApiError::BadRequest(
"date query parameter is required".into(),
));
};

NaiveDate::parse_from_str(date, "%Y-%m-%d").map_err(|_| {
tracing::warn!(date = %date, "rejected invalid log download date");
ApiError::BadRequest("date must use YYYY-MM-DD format".into())
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[get("/logs?<params..>")]
pub async fn get_logs(
_global: GlobalRateLimit,
_admin: AdminKey,
log_files: &State<LogFiles>,
span: TracingSpan,
params: AdminLogDownloadParams,
) -> Result<AdminLogDownload, ApiError> {
async move {
tracing::info!(date = ?params.date, "request received");

let date = parse_log_date(&params)?;
let log_file = log_files.file_for_date(date);
let log_path = log_file.path().to_path_buf();

tracing::info!(path = %log_path.display(), "resolved log file path");

let file = NamedFile::open(&log_path).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
tracing::warn!(path = %log_path.display(), "requested log file not found");
ApiError::NotFound("log file not found".into())
} else {
tracing::error!(path = %log_path.display(), error = %e, "failed to open log file");
ApiError::Internal("failed to open log file".into())
}
})?;

tracing::info!(
path = %log_path.display(),
filename = %log_file.download_filename(),
"serving log file"
);

Ok(AdminLogDownload {
file,
filename: log_file.download_filename().to_string(),
})
}
.instrument(span.0)
.await
}

pub fn routes() -> Vec<Route> {
rocket::routes![get_logs]
}

#[cfg(test)]
mod tests {
use crate::test_helpers::{basic_auth_header, seed_admin_key, seed_api_key, TestClientBuilder};
use rocket::http::{ContentType, Header, Status};
use tempfile::TempDir;

fn write_log_file(temp_dir: &TempDir, date: &str, contents: &str) {
let path = temp_dir
.path()
.join(format!("{}.{date}", crate::log_files::LOG_FILE_BASENAME));
std::fs::write(path, contents).expect("write log file");
}

#[rocket::async_test]
async fn test_get_logs_with_admin_key() {
let temp_dir = TempDir::new().expect("temp dir");
write_log_file(&temp_dir, "2026-03-13", "line one\nline two\n");

let client = TestClientBuilder::new()
.log_dir(temp_dir.path())
.build()
.await;
let (key_id, secret) = seed_admin_key(&client).await;
let header = basic_auth_header(&key_id, &secret);

let response = client
.get("/admin/logs?date=2026-03-13")
.header(Header::new("Authorization", header))
.dispatch()
.await;

assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::Binary));
assert_eq!(
response.headers().get_one("Content-Disposition"),
Some("attachment; filename=\"st0x-rest-api-2026-03-13.log\"")
);
assert_eq!(
response.into_string().await.unwrap(),
"line one\nline two\n".to_string()
);
}

#[rocket::async_test]
async fn test_get_logs_with_non_admin_key_returns_403() {
let temp_dir = TempDir::new().expect("temp dir");
write_log_file(&temp_dir, "2026-03-13", "line one\n");

let client = TestClientBuilder::new()
.log_dir(temp_dir.path())
.build()
.await;
let (key_id, secret) = seed_api_key(&client).await;
let header = basic_auth_header(&key_id, &secret);

let response = client
.get("/admin/logs?date=2026-03-13")
.header(Header::new("Authorization", header))
.dispatch()
.await;

assert_eq!(response.status(), Status::Forbidden);
}

#[rocket::async_test]
async fn test_get_logs_without_auth_returns_401() {
let client = TestClientBuilder::new().build().await;

let response = client.get("/admin/logs?date=2026-03-13").dispatch().await;

assert_eq!(response.status(), Status::Unauthorized);
}

#[rocket::async_test]
async fn test_get_logs_with_invalid_date_returns_400() {
let client = TestClientBuilder::new().build().await;
let (key_id, secret) = seed_admin_key(&client).await;
let header = basic_auth_header(&key_id, &secret);

let response = client
.get("/admin/logs?date=2026-13-40")
.header(Header::new("Authorization", header))
.dispatch()
.await;

assert_eq!(response.status(), Status::BadRequest);
let body: serde_json::Value =
serde_json::from_str(&response.into_string().await.unwrap()).unwrap();
assert_eq!(body["error"]["code"], "BAD_REQUEST");
assert_eq!(body["error"]["message"], "date must use YYYY-MM-DD format");
}

#[rocket::async_test]
async fn test_get_logs_without_date_returns_400() {
let client = TestClientBuilder::new().build().await;
let (key_id, secret) = seed_admin_key(&client).await;
let header = basic_auth_header(&key_id, &secret);

let response = client
.get("/admin/logs")
.header(Header::new("Authorization", header))
.dispatch()
.await;

assert_eq!(response.status(), Status::BadRequest);
let body: serde_json::Value =
serde_json::from_str(&response.into_string().await.unwrap()).unwrap();
assert_eq!(body["error"]["code"], "BAD_REQUEST");
assert_eq!(body["error"]["message"], "date query parameter is required");
}

#[rocket::async_test]
async fn test_get_logs_when_file_is_missing_returns_404() {
let temp_dir = TempDir::new().expect("temp dir");
let client = TestClientBuilder::new()
.log_dir(temp_dir.path())
.build()
.await;
let (key_id, secret) = seed_admin_key(&client).await;
let header = basic_auth_header(&key_id, &secret);

let response = client
.get("/admin/logs?date=2026-03-13")
.header(Header::new("Authorization", header))
.dispatch()
.await;

assert_eq!(response.status(), Status::NotFound);
let body: serde_json::Value =
serde_json::from_str(&response.into_string().await.unwrap()).unwrap();
assert_eq!(body["error"]["code"], "NOT_FOUND");
assert_eq!(body["error"]["message"], "log file not found");
}
}
11 changes: 11 additions & 0 deletions src/routes/admin/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
mod logs;
mod registry;

use rocket::Route;

pub fn routes() -> Vec<Route> {
let mut routes = Vec::new();
routes.extend(registry::routes());
routes.extend(logs::routes());
routes
}
Loading
Loading