fix: ci
Some checks failed
ci / test (push) Failing after 11s
ci / build-ui (push) Failing after 6s
ci / publish (push) Has been skipped

This commit is contained in:
Matthew McPeak 2026-06-17 15:48:30 -04:00
parent ba74ea7a6f
commit 1b2d1e7471
Signed by: McPeakML
GPG key ID: 3D64A2E70F58D07C
16 changed files with 190 additions and 105 deletions

View file

@ -41,8 +41,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Inject bunfig.toml
run: printf '[install.scopes]\n"@nychthemeron" = { token = "${BUN_AUTH_TOKEN}", url = "https://git.mcpeakdev.com/api/packages/McPeakDev/npm/" }\n' > ./bunfig.toml
- name: Inject Auth
run: echo "//git.mcpeakdev.com/api/packages/mcpeakdev/npm/:_authToken=${BUN_AUTH_TOKEN}" > /root/.npmrc
env:
BUN_AUTH_TOKEN: ${{ secrets.BUN_AUTH_TOKEN }}

View file

@ -5,16 +5,15 @@ use axum::{
response::Response,
};
use crate::{auth::{decode_jwt, resolve_api_key, Claims}, state::AppState};
use crate::{
auth::{decode_jwt, resolve_api_key, Claims},
state::AppState,
};
/// Resolves a Bearer token to Claims, trying JWT then API key.
/// Inserts Claims into request extensions on success so downstream
/// middleware can reuse them without an additional DB round-trip.
async fn authenticate(
token: &str,
state: &AppState,
req: &mut Request,
) -> Option<Claims> {
async fn authenticate(token: &str, state: &AppState, req: &mut Request) -> Option<Claims> {
if let Some(existing) = req.extensions().get::<Claims>().cloned() {
return Some(existing);
}
@ -42,7 +41,11 @@ pub async fn blacklist_layer(
} else {
0
};
if state.blacklist_cache.is_blocked(&method, &path, caller_mask).await {
if state
.blacklist_cache
.is_blocked(&method, &path, caller_mask)
.await
{
return Err(StatusCode::FORBIDDEN);
}
Ok(next.run(req).await)
@ -62,7 +65,9 @@ pub async fn require_auth(
next: Next,
) -> Result<Response, StatusCode> {
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?;
authenticate(&token, &state, &mut req)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
Ok(next.run(req).await)
}
@ -72,7 +77,9 @@ pub async fn require_super_admin(
next: Next,
) -> Result<Response, StatusCode> {
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
let claims = authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?;
let claims = authenticate(&token, &state, &mut req)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
if !claims.has_permission(crate::auth::permissions::SUPER_ADMIN) {
return Err(StatusCode::FORBIDDEN);
}
@ -85,7 +92,9 @@ pub async fn require_admin_query(
next: Next,
) -> Result<Response, StatusCode> {
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
let claims = authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?;
let claims = authenticate(&token, &state, &mut req)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
if !claims.has_permission(crate::auth::permissions::ADMIN_QUERY) {
return Err(StatusCode::FORBIDDEN);
}
@ -98,7 +107,9 @@ pub async fn require_admin_cache(
next: Next,
) -> Result<Response, StatusCode> {
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
let claims = authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?;
let claims = authenticate(&token, &state, &mut req)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
if !claims.has_permission(crate::auth::permissions::ADMIN_CACHE) {
return Err(StatusCode::FORBIDDEN);
}

8
src/cache/sweep.rs vendored
View file

@ -1,5 +1,5 @@
use std::time::Duration;
use crate::state::QueryCache;
use std::time::Duration;
pub fn spawn_sweep_task(cache: QueryCache, idle_timeout_secs: u64, sweep_interval_secs: u64) {
tokio::spawn(async move {
@ -7,9 +7,9 @@ pub fn spawn_sweep_task(cache: QueryCache, idle_timeout_secs: u64, sweep_interva
loop {
tokio::time::sleep(interval).await;
let now = crate::state::unix_now();
cache.map.retain(|_, entry| {
now.saturating_sub(entry.last_accessed()) < idle_timeout_secs
});
cache
.map
.retain(|_, entry| now.saturating_sub(entry.last_accessed()) < idle_timeout_secs);
}
});
}

View file

@ -9,8 +9,17 @@ mod state;
use std::io::{self, Write};
use std::sync::Arc;
use axum::{extract::DefaultBodyLimit, http::{header, HeaderValue, Method}, middleware, routing::{get, post}, Router};
use tower_http::{cors::CorsLayer, services::{ServeDir, ServeFile}};
use axum::{
extract::DefaultBodyLimit,
http::{header, HeaderValue, Method},
middleware,
routing::{get, post},
Router,
};
use tower_http::{
cors::CorsLayer,
services::{ServeDir, ServeFile},
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{
@ -19,11 +28,7 @@ use crate::{
config::Config,
db::create_pool,
models::blacklist::BlacklistEntry,
routes::{
admin::admin_router,
auth::login,
crud::handle_crud,
},
routes::{admin::admin_router, auth::login, crud::handle_crud},
state::{AppState, BlacklistCache, QueryCache},
};
@ -34,13 +39,16 @@ fn build_cors(origins: &[String]) -> CorsLayer {
if origins.iter().any(|o| o == "*") {
return CorsLayer::permissive();
}
let parsed: Vec<HeaderValue> = origins
.iter()
.filter_map(|o| o.parse().ok())
.collect();
let parsed: Vec<HeaderValue> = origins.iter().filter_map(|o| o.parse().ok()).collect();
CorsLayer::new()
.allow_origin(parsed)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
}
@ -49,8 +57,10 @@ async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "mercury=info".into()))
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "mercury=info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
@ -122,13 +132,22 @@ async fn main() -> anyhow::Result<()> {
let crud_routes = Router::new()
.route("/api/:table", get(handle_crud).post(handle_crud))
.route("/api/:table/", get(handle_crud).post(handle_crud))
.route("/api/:table/:id", get(handle_crud).put(handle_crud).delete(handle_crud))
.route("/api/:table/:id/", get(handle_crud).put(handle_crud).delete(handle_crud))
.layer(DefaultBodyLimit::max(1 * 1024 * 1024))
.route(
"/api/:table/:id",
get(handle_crud).put(handle_crud).delete(handle_crud),
)
.route(
"/api/:table/:id/",
get(handle_crud).put(handle_crud).delete(handle_crud),
)
.layer(DefaultBodyLimit::max(1024 * 1024))
// require_auth is inner (added first); blacklist_layer is outer (added last, runs first).
// Order: blacklist check → auth check → handler.
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
.route_layer(middleware::from_fn_with_state(state.clone(), blacklist_layer));
.route_layer(middleware::from_fn_with_state(
state.clone(),
blacklist_layer,
));
let cors_layer = build_cors(&config.cors_origins);
@ -136,7 +155,10 @@ async fn main() -> anyhow::Result<()> {
.route("/auth/login", post(login))
.merge(crud_routes)
.nest("/api/admin", admin_router(state.clone()))
.nest_service("/", ServeDir::new("ui/dist").fallback(ServeFile::new("ui/dist/index.html")))
.nest_service(
"/",
ServeDir::new("ui/dist").fallback(ServeFile::new("ui/dist/index.html")),
)
.layer(cors_layer)
.with_state(state);

View file

@ -12,6 +12,7 @@ pub struct BlacklistEntry {
pub created_at: DateTime<Utc>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct CreateBlacklistEntry {
pub pattern: String,
@ -19,6 +20,7 @@ pub struct CreateBlacklistEntry {
pub reason: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct UpdateBlacklistEntry {
pub pattern: Option<String>,

View file

@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Permission {
pub id: i32,
@ -8,12 +9,14 @@ pub struct Permission {
pub description: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct CreatePermission {
pub name: String,
pub description: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct UpdatePermission {
pub name: Option<String>,

View file

@ -12,6 +12,7 @@ pub struct StoredQuery {
pub updated_at: DateTime<Utc>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct CreateQuery {
pub identifier: String,
@ -19,6 +20,7 @@ pub struct CreateQuery {
pub description: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct UpdateQuery {
pub sql_template: Option<String>,

View file

@ -11,6 +11,7 @@ pub struct User {
pub created_at: DateTime<Utc>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct CreateUser {
pub username: String,
@ -18,6 +19,7 @@ pub struct CreateUser {
pub permissions_mask: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct UpdateUser {
pub username: Option<String>,

View file

@ -11,9 +11,7 @@ use crate::{
state::AppState,
};
pub async fn list_api_keys(
State(state): State<AppState>,
) -> Result<Json<Vec<ApiKey>>, StatusCode> {
pub async fn list_api_keys(State(state): State<AppState>) -> Result<Json<Vec<ApiKey>>, StatusCode> {
let keys = sqlx::query_as::<_, ApiKey>(
"SELECT id, name, key_prefix, permissions_mask, created_at, expires_at, last_used_at
FROM api_keys ORDER BY created_at DESC",

View file

@ -1,6 +1,6 @@
use axum::extract::Extension;
use axum::{extract::State, http::StatusCode, Json};
use serde_json::{json, Value};
use axum::extract::Extension;
use crate::{auth::Claims, state::AppState};

View file

@ -31,7 +31,10 @@ pub fn admin_router(state: AppState) -> Router<AppState> {
));
let super_routes = Router::new()
.route("/tables", get(tables::list_tables).post(tables::create_table))
.route(
"/tables",
get(tables::list_tables).post(tables::create_table),
)
.route(
"/tables/:name",
get(tables::get_table_preview).delete(tables::drop_table),

View file

@ -42,10 +42,7 @@ pub async fn execute_query(
q = q.bind(val.as_str());
}
let rows = q
.fetch_all(&state.pool)
.await
.map_err(|e| {
let rows = q.fetch_all(&state.pool).await.map_err(|e| {
tracing::error!("query execution error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@ -72,8 +69,16 @@ mod tests {
for (i, (name, _)) in params.iter().enumerate() {
sql = sql.replace(&format!(":{}", name), &format!("${}", i + 1));
}
assert!(!sql.contains(":username"), "placeholder not replaced: {}", sql);
assert!(!sql.contains(":user_id"), "placeholder not replaced: {}", sql);
assert!(
!sql.contains(":username"),
"placeholder not replaced: {}",
sql
);
assert!(
!sql.contains(":user_id"),
"placeholder not replaced: {}",
sql
);
// username (len 8) comes first → $1; user_id (len 7) → $2
assert_eq!(sql, "SELECT * FROM t WHERE user_id = $2 AND username = $1");
}

View file

@ -10,8 +10,18 @@ use sqlx::Row;
use crate::{auth::Claims, routes::crud::pg_row_to_json, state::AppState};
const ALLOWED_TYPES: &[&str] = &[
"TEXT", "INTEGER", "BIGINT", "SMALLINT", "BOOLEAN", "NUMERIC",
"FLOAT4", "FLOAT8", "UUID", "TIMESTAMPTZ", "DATE", "JSONB",
"TEXT",
"INTEGER",
"BIGINT",
"SMALLINT",
"BOOLEAN",
"NUMERIC",
"FLOAT4",
"FLOAT8",
"UUID",
"TIMESTAMPTZ",
"DATE",
"JSONB",
];
const PROTECTED_TABLES: &[&str] = &["users", "blacklist", "api_keys", "queries", "permissions"];
@ -21,7 +31,6 @@ fn is_protected(name: &str) -> bool {
PROTECTED_TABLES.iter().any(|&t| t == lower)
}
#[derive(Debug, Deserialize)]
pub struct ColumnDef {
pub name: String,
@ -179,10 +188,7 @@ pub async fn create_table(
let sql = format!("CREATE TABLE {} ({})", body.name, col_defs.join(", "));
sqlx::query(&sql)
.execute(&state.pool)
.await
.map_err(|e| {
sqlx::query(&sql).execute(&state.pool).await.map_err(|e| {
tracing::error!("create table error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@ -206,10 +212,7 @@ pub async fn drop_table(
}
let sql = format!("DROP TABLE IF EXISTS {}", name);
sqlx::query(&sql)
.execute(&state.pool)
.await
.map_err(|e| {
sqlx::query(&sql).execute(&state.pool).await.map_err(|e| {
tracing::error!("drop table error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;

View file

@ -1,11 +1,7 @@
use axum::{extract::State, http::StatusCode, Json};
use serde_json::{json, Value};
use crate::{
auth::encode_jwt,
models::user::LoginRequest,
state::AppState,
};
use crate::{auth::encode_jwt, models::user::LoginRequest, state::AppState};
pub async fn login(
State(state): State<AppState>,
@ -28,7 +24,12 @@ pub async fn login(
}
let mask: u128 = user.permissions_mask.parse().unwrap_or(0);
let token = encode_jwt(&user.username, mask, &state.config.jwt_secret, state.config.jwt_expiry_secs)
let token = encode_jwt(
&user.username,
mask,
&state.config.jwt_secret,
state.config.jwt_expiry_secs,
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(json!({ "token": token })))

View file

@ -11,7 +11,11 @@ use sqlx::Row;
use sqlx::TypeInfo;
use std::collections::HashMap;
use crate::{auth::Claims, models::blacklist::BlacklistEntry, state::{AppState, CacheEntry}};
use crate::{
auth::Claims,
models::blacklist::BlacklistEntry,
state::{AppState, CacheEntry},
};
/// Returns (sql, ordered_param_values, cache_key).
/// body_cols: (col_name, typed_value) pairs from request body.
@ -53,14 +57,22 @@ pub fn build_query(
let key = format!("GET:{}:", table);
Ok((sql, vec![], key))
} else {
let col_names: Vec<String> = sorted_filters.iter().map(|(c, _)| c.clone()).collect();
let col_names: Vec<String> =
sorted_filters.iter().map(|(c, _)| c.clone()).collect();
let where_clause: Vec<String> = col_names
.iter()
.enumerate()
.map(|(i, c)| format!("{} = ${}", c, i + 1))
.collect();
let sql = format!("SELECT * FROM {} WHERE {}", table, where_clause.join(" AND "));
let params: Vec<Value> = sorted_filters.iter().map(|(_, v)| Value::String(v.clone())).collect();
let sql = format!(
"SELECT * FROM {} WHERE {}",
table,
where_clause.join(" AND ")
);
let params: Vec<Value> = sorted_filters
.iter()
.map(|(_, v)| Value::String(v.clone()))
.collect();
let key = format!("GET:{}:{}", table, col_names.join(","));
Ok((sql, params, key))
}
@ -136,7 +148,7 @@ pub fn pg_row_to_json(row: PgRow) -> Value {
"FLOAT4" | "FLOAT8" => row
.try_get::<f64, _>(col.ordinal())
.ok()
.and_then(|v| serde_json::Number::from_f64(v))
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.unwrap_or(Value::Null),
"BOOL" => row
@ -281,10 +293,7 @@ pub async fn handle_crud(
v
}
"POST" | "PUT" => {
let row = q
.fetch_one(&state.pool)
.await
.map_err(|e| {
let row = q.fetch_one(&state.pool).await.map_err(|e| {
if e.to_string().contains("no rows") {
StatusCode::NOT_FOUND
} else {
@ -341,8 +350,17 @@ mod tests {
("name".into(), Value::String("Alice".into())),
];
let (sql, params, key) = build_query("POST", "users", None, &cols, &[]).unwrap();
assert_eq!(sql, "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *");
assert_eq!(params, vec![Value::String("a@b.com".into()), Value::String("Alice".into())]);
assert_eq!(
sql,
"INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *"
);
assert_eq!(
params,
vec![
Value::String("a@b.com".into()),
Value::String("Alice".into())
]
);
assert_eq!(key, "POST:users:email,name");
}
@ -351,7 +369,10 @@ mod tests {
let cols = vec![("name".into(), Value::String("Bob".into()))];
let (sql, params, key) = build_query("PUT", "users", Some("7"), &cols, &[]).unwrap();
assert_eq!(sql, "UPDATE users SET name = $1 WHERE id = $2 RETURNING *");
assert_eq!(params, vec![Value::String("Bob".into()), Value::String("7".into())]);
assert_eq!(
params,
vec![Value::String("Bob".into()), Value::String("7".into())]
);
assert_eq!(key, "PUT:users:name:by_id");
}
@ -365,10 +386,19 @@ mod tests {
#[test]
fn test_build_select_with_filters() {
let filters = vec![("status".into(), "active".into()), ("role".into(), "admin".into())];
let filters = vec![
("status".into(), "active".into()),
("role".into(), "admin".into()),
];
let (sql, params, key) = build_query("GET", "users", None, &[], &filters).unwrap();
assert_eq!(sql, "SELECT * FROM users WHERE role = $1 AND status = $2");
assert_eq!(params, vec![Value::String("admin".into()), Value::String("active".into())]);
assert_eq!(
params,
vec![
Value::String("admin".into()),
Value::String("active".into())
]
);
assert_eq!(key, "GET:users:role,status");
}

View file

@ -91,6 +91,7 @@ impl QueryCache {
self.map.insert(key, entry);
}
#[allow(dead_code)]
pub fn remove(&self, key: &str) {
self.map.remove(key);
}
@ -139,9 +140,11 @@ impl BlacklistCache {
.bypass_mask
.as_deref()
.and_then(|s| s.parse::<u128>().ok());
Pattern::new(&e.pattern)
.ok()
.map(|pattern| CompiledEntry { entry: e, pattern, bypass_mask })
Pattern::new(&e.pattern).ok().map(|pattern| CompiledEntry {
entry: e,
pattern,
bypass_mask,
})
})
.collect();
let mut guard = self.inner.write().await;
@ -163,16 +166,16 @@ impl BlacklistCache {
.entry
.method
.as_deref()
.map(|m| m.split(',').any(|part| part.trim().eq_ignore_ascii_case(method)))
.map(|m| {
m.split(',')
.any(|part| part.trim().eq_ignore_ascii_case(method))
})
.unwrap_or(true);
if !method_matches || !compiled.pattern.matches(path) {
return false;
}
// If caller holds the bypass permission, they are not blocked.
match compiled.bypass_mask {
Some(mask) if caller_mask & mask != 0 => false,
_ => true,
}
!matches!(compiled.bypass_mask, Some(mask) if caller_mask & mask != 0)
})
}
}