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

View file

@ -5,16 +5,15 @@ use axum::{
response::Response, 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. /// Resolves a Bearer token to Claims, trying JWT then API key.
/// Inserts Claims into request extensions on success so downstream /// Inserts Claims into request extensions on success so downstream
/// middleware can reuse them without an additional DB round-trip. /// middleware can reuse them without an additional DB round-trip.
async fn authenticate( async fn authenticate(token: &str, state: &AppState, req: &mut Request) -> Option<Claims> {
token: &str,
state: &AppState,
req: &mut Request,
) -> Option<Claims> {
if let Some(existing) = req.extensions().get::<Claims>().cloned() { if let Some(existing) = req.extensions().get::<Claims>().cloned() {
return Some(existing); return Some(existing);
} }
@ -42,7 +41,11 @@ pub async fn blacklist_layer(
} else { } else {
0 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); return Err(StatusCode::FORBIDDEN);
} }
Ok(next.run(req).await) Ok(next.run(req).await)
@ -62,7 +65,9 @@ pub async fn require_auth(
next: Next, next: Next,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; 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) Ok(next.run(req).await)
} }
@ -72,7 +77,9 @@ pub async fn require_super_admin(
next: Next, next: Next,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; 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) { if !claims.has_permission(crate::auth::permissions::SUPER_ADMIN) {
return Err(StatusCode::FORBIDDEN); return Err(StatusCode::FORBIDDEN);
} }
@ -85,7 +92,9 @@ pub async fn require_admin_query(
next: Next, next: Next,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; 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) { if !claims.has_permission(crate::auth::permissions::ADMIN_QUERY) {
return Err(StatusCode::FORBIDDEN); return Err(StatusCode::FORBIDDEN);
} }
@ -98,7 +107,9 @@ pub async fn require_admin_cache(
next: Next, next: Next,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; 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) { if !claims.has_permission(crate::auth::permissions::ADMIN_CACHE) {
return Err(StatusCode::FORBIDDEN); 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 crate::state::QueryCache;
use std::time::Duration;
pub fn spawn_sweep_task(cache: QueryCache, idle_timeout_secs: u64, sweep_interval_secs: u64) { pub fn spawn_sweep_task(cache: QueryCache, idle_timeout_secs: u64, sweep_interval_secs: u64) {
tokio::spawn(async move { tokio::spawn(async move {
@ -7,9 +7,9 @@ pub fn spawn_sweep_task(cache: QueryCache, idle_timeout_secs: u64, sweep_interva
loop { loop {
tokio::time::sleep(interval).await; tokio::time::sleep(interval).await;
let now = crate::state::unix_now(); let now = crate::state::unix_now();
cache.map.retain(|_, entry| { cache
now.saturating_sub(entry.last_accessed()) < idle_timeout_secs .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::io::{self, Write};
use std::sync::Arc; use std::sync::Arc;
use axum::{extract::DefaultBodyLimit, http::{header, HeaderValue, Method}, middleware, routing::{get, post}, Router}; use axum::{
use tower_http::{cors::CorsLayer, services::{ServeDir, ServeFile}}; 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 tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{ use crate::{
@ -19,11 +28,7 @@ use crate::{
config::Config, config::Config,
db::create_pool, db::create_pool,
models::blacklist::BlacklistEntry, models::blacklist::BlacklistEntry,
routes::{ routes::{admin::admin_router, auth::login, crud::handle_crud},
admin::admin_router,
auth::login,
crud::handle_crud,
},
state::{AppState, BlacklistCache, QueryCache}, state::{AppState, BlacklistCache, QueryCache},
}; };
@ -34,13 +39,16 @@ fn build_cors(origins: &[String]) -> CorsLayer {
if origins.iter().any(|o| o == "*") { if origins.iter().any(|o| o == "*") {
return CorsLayer::permissive(); return CorsLayer::permissive();
} }
let parsed: Vec<HeaderValue> = origins let parsed: Vec<HeaderValue> = origins.iter().filter_map(|o| o.parse().ok()).collect();
.iter()
.filter_map(|o| o.parse().ok())
.collect();
CorsLayer::new() CorsLayer::new()
.allow_origin(parsed) .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]) .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
} }
@ -49,8 +57,10 @@ async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
tracing_subscriber::registry() tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env() .with(
.unwrap_or_else(|_| "mercury=info".into())) tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "mercury=info".into()),
)
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
@ -122,13 +132,22 @@ async fn main() -> anyhow::Result<()> {
let crud_routes = Router::new() 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/", 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(
.route("/api/:table/:id/", get(handle_crud).put(handle_crud).delete(handle_crud)) "/api/:table/:id",
.layer(DefaultBodyLimit::max(1 * 1024 * 1024)) 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). // require_auth is inner (added first); blacklist_layer is outer (added last, runs first).
// Order: blacklist check → auth check → handler. // 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(), 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); let cors_layer = build_cors(&config.cors_origins);
@ -136,7 +155,10 @@ async fn main() -> anyhow::Result<()> {
.route("/auth/login", post(login)) .route("/auth/login", post(login))
.merge(crud_routes) .merge(crud_routes)
.nest("/api/admin", admin_router(state.clone())) .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) .layer(cors_layer)
.with_state(state); .with_state(state);

View file

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

View file

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

View file

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

View file

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

View file

@ -11,9 +11,7 @@ use crate::{
state::AppState, state::AppState,
}; };
pub async fn list_api_keys( pub async fn list_api_keys(State(state): State<AppState>) -> Result<Json<Vec<ApiKey>>, StatusCode> {
State(state): State<AppState>,
) -> Result<Json<Vec<ApiKey>>, StatusCode> {
let keys = sqlx::query_as::<_, ApiKey>( let keys = sqlx::query_as::<_, ApiKey>(
"SELECT id, name, key_prefix, permissions_mask, created_at, expires_at, last_used_at "SELECT id, name, key_prefix, permissions_mask, created_at, expires_at, last_used_at
FROM api_keys ORDER BY created_at DESC", 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 axum::{extract::State, http::StatusCode, Json};
use serde_json::{json, Value}; use serde_json::{json, Value};
use axum::extract::Extension;
use crate::{auth::Claims, state::AppState}; 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() 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( .route(
"/tables/:name", "/tables/:name",
get(tables::get_table_preview).delete(tables::drop_table), get(tables::get_table_preview).delete(tables::drop_table),

View file

@ -42,13 +42,10 @@ pub async fn execute_query(
q = q.bind(val.as_str()); q = q.bind(val.as_str());
} }
let rows = q let rows = q.fetch_all(&state.pool).await.map_err(|e| {
.fetch_all(&state.pool) tracing::error!("query execution error: {}", e);
.await StatusCode::INTERNAL_SERVER_ERROR
.map_err(|e| { })?;
tracing::error!("query execution error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let json_rows: Vec<Value> = rows let json_rows: Vec<Value> = rows
.into_iter() .into_iter()
@ -72,8 +69,16 @@ mod tests {
for (i, (name, _)) in params.iter().enumerate() { for (i, (name, _)) in params.iter().enumerate() {
sql = sql.replace(&format!(":{}", name), &format!("${}", i + 1)); sql = sql.replace(&format!(":{}", name), &format!("${}", i + 1));
} }
assert!(!sql.contains(":username"), "placeholder not replaced: {}", sql); assert!(
assert!(!sql.contains(":user_id"), "placeholder not replaced: {}", sql); !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 // username (len 8) comes first → $1; user_id (len 7) → $2
assert_eq!(sql, "SELECT * FROM t WHERE user_id = $2 AND username = $1"); 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}; use crate::{auth::Claims, routes::crud::pg_row_to_json, state::AppState};
const ALLOWED_TYPES: &[&str] = &[ const ALLOWED_TYPES: &[&str] = &[
"TEXT", "INTEGER", "BIGINT", "SMALLINT", "BOOLEAN", "NUMERIC", "TEXT",
"FLOAT4", "FLOAT8", "UUID", "TIMESTAMPTZ", "DATE", "JSONB", "INTEGER",
"BIGINT",
"SMALLINT",
"BOOLEAN",
"NUMERIC",
"FLOAT4",
"FLOAT8",
"UUID",
"TIMESTAMPTZ",
"DATE",
"JSONB",
]; ];
const PROTECTED_TABLES: &[&str] = &["users", "blacklist", "api_keys", "queries", "permissions"]; 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) PROTECTED_TABLES.iter().any(|&t| t == lower)
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ColumnDef { pub struct ColumnDef {
pub name: String, pub name: String,
@ -179,13 +188,10 @@ pub async fn create_table(
let sql = format!("CREATE TABLE {} ({})", body.name, col_defs.join(", ")); let sql = format!("CREATE TABLE {} ({})", body.name, col_defs.join(", "));
sqlx::query(&sql) sqlx::query(&sql).execute(&state.pool).await.map_err(|e| {
.execute(&state.pool) tracing::error!("create table error: {}", e);
.await StatusCode::INTERNAL_SERVER_ERROR
.map_err(|e| { })?;
tracing::error!("create table error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(TableInfo { Ok(Json(TableInfo {
table_name: body.name, table_name: body.name,
@ -206,13 +212,10 @@ pub async fn drop_table(
} }
let sql = format!("DROP TABLE IF EXISTS {}", name); let sql = format!("DROP TABLE IF EXISTS {}", name);
sqlx::query(&sql) sqlx::query(&sql).execute(&state.pool).await.map_err(|e| {
.execute(&state.pool) tracing::error!("drop table error: {}", e);
.await StatusCode::INTERNAL_SERVER_ERROR
.map_err(|e| { })?;
tracing::error!("drop table error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(serde_json::json!({ "dropped": true }))) Ok(Json(serde_json::json!({ "dropped": true })))
} }

View file

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

View file

@ -11,7 +11,11 @@ use sqlx::Row;
use sqlx::TypeInfo; use sqlx::TypeInfo;
use std::collections::HashMap; 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). /// Returns (sql, ordered_param_values, cache_key).
/// body_cols: (col_name, typed_value) pairs from request body. /// body_cols: (col_name, typed_value) pairs from request body.
@ -53,14 +57,22 @@ pub fn build_query(
let key = format!("GET:{}:", table); let key = format!("GET:{}:", table);
Ok((sql, vec![], key)) Ok((sql, vec![], key))
} else { } 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 let where_clause: Vec<String> = col_names
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, c)| format!("{} = ${}", c, i + 1)) .map(|(i, c)| format!("{} = ${}", c, i + 1))
.collect(); .collect();
let sql = format!("SELECT * FROM {} WHERE {}", table, where_clause.join(" AND ")); let sql = format!(
let params: Vec<Value> = sorted_filters.iter().map(|(_, v)| Value::String(v.clone())).collect(); "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(",")); let key = format!("GET:{}:{}", table, col_names.join(","));
Ok((sql, params, key)) Ok((sql, params, key))
} }
@ -136,7 +148,7 @@ pub fn pg_row_to_json(row: PgRow) -> Value {
"FLOAT4" | "FLOAT8" => row "FLOAT4" | "FLOAT8" => row
.try_get::<f64, _>(col.ordinal()) .try_get::<f64, _>(col.ordinal())
.ok() .ok()
.and_then(|v| serde_json::Number::from_f64(v)) .and_then(serde_json::Number::from_f64)
.map(Value::Number) .map(Value::Number)
.unwrap_or(Value::Null), .unwrap_or(Value::Null),
"BOOL" => row "BOOL" => row
@ -207,10 +219,10 @@ pub async fn handle_crud(
// Enforce permission bits before doing any work. // Enforce permission bits before doing any work.
let required_bit = match method_str.to_uppercase().as_str() { let required_bit = match method_str.to_uppercase().as_str() {
"GET" => crate::auth::permissions::READ, "GET" => crate::auth::permissions::READ,
"POST" | "PUT" => crate::auth::permissions::WRITE, "POST" | "PUT" => crate::auth::permissions::WRITE,
"DELETE" => crate::auth::permissions::DELETE, "DELETE" => crate::auth::permissions::DELETE,
_ => return Err(StatusCode::METHOD_NOT_ALLOWED), _ => return Err(StatusCode::METHOD_NOT_ALLOWED),
}; };
if !claims.has_permission(required_bit) { if !claims.has_permission(required_bit) {
return Err(StatusCode::FORBIDDEN); return Err(StatusCode::FORBIDDEN);
@ -281,16 +293,13 @@ pub async fn handle_crud(
v v
} }
"POST" | "PUT" => { "POST" | "PUT" => {
let row = q let row = q.fetch_one(&state.pool).await.map_err(|e| {
.fetch_one(&state.pool) if e.to_string().contains("no rows") {
.await StatusCode::NOT_FOUND
.map_err(|e| { } else {
if e.to_string().contains("no rows") { StatusCode::INTERNAL_SERVER_ERROR
StatusCode::NOT_FOUND }
} else { })?;
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
let mut v = pg_row_to_json(row); let mut v = pg_row_to_json(row);
if table == "users" { if table == "users" {
v = strip_password_hash(v); v = strip_password_hash(v);
@ -341,8 +350,17 @@ mod tests {
("name".into(), Value::String("Alice".into())), ("name".into(), Value::String("Alice".into())),
]; ];
let (sql, params, key) = build_query("POST", "users", None, &cols, &[]).unwrap(); 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!(
assert_eq!(params, vec![Value::String("a@b.com".into()), Value::String("Alice".into())]); 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"); assert_eq!(key, "POST:users:email,name");
} }
@ -351,7 +369,10 @@ mod tests {
let cols = vec![("name".into(), Value::String("Bob".into()))]; let cols = vec![("name".into(), Value::String("Bob".into()))];
let (sql, params, key) = build_query("PUT", "users", Some("7"), &cols, &[]).unwrap(); 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!(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"); assert_eq!(key, "PUT:users:name:by_id");
} }
@ -365,10 +386,19 @@ mod tests {
#[test] #[test]
fn test_build_select_with_filters() { 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(); 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!(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"); assert_eq!(key, "GET:users:role,status");
} }

View file

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