fix: ci
This commit is contained in:
parent
ba74ea7a6f
commit
1b2d1e7471
16 changed files with 190 additions and 105 deletions
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
8
src/cache/sweep.rs
vendored
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
60
src/main.rs
60
src/main.rs
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -42,13 +42,10 @@ pub async fn execute_query(
|
|||
q = q.bind(val.as_str());
|
||||
}
|
||||
|
||||
let rows = q
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("query execution error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let rows = q.fetch_all(&state.pool).await.map_err(|e| {
|
||||
tracing::error!("query execution error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let json_rows: Vec<Value> = rows
|
||||
.into_iter()
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,13 +188,10 @@ 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| {
|
||||
tracing::error!("create table error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
sqlx::query(&sql).execute(&state.pool).await.map_err(|e| {
|
||||
tracing::error!("create table error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(TableInfo {
|
||||
table_name: body.name,
|
||||
|
|
@ -206,13 +212,10 @@ pub async fn drop_table(
|
|||
}
|
||||
|
||||
let sql = format!("DROP TABLE IF EXISTS {}", name);
|
||||
sqlx::query(&sql)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("drop table error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
sqlx::query(&sql).execute(&state.pool).await.map_err(|e| {
|
||||
tracing::error!("drop table error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "dropped": true })))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,8 +24,13 @@ 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)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
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 })))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -207,10 +219,10 @@ pub async fn handle_crud(
|
|||
|
||||
// Enforce permission bits before doing any work.
|
||||
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,
|
||||
"DELETE" => crate::auth::permissions::DELETE,
|
||||
_ => return Err(StatusCode::METHOD_NOT_ALLOWED),
|
||||
"DELETE" => crate::auth::permissions::DELETE,
|
||||
_ => return Err(StatusCode::METHOD_NOT_ALLOWED),
|
||||
};
|
||||
if !claims.has_permission(required_bit) {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
|
|
@ -281,16 +293,13 @@ pub async fn handle_crud(
|
|||
v
|
||||
}
|
||||
"POST" | "PUT" => {
|
||||
let row = q
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("no rows") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
})?;
|
||||
let row = q.fetch_one(&state.pool).await.map_err(|e| {
|
||||
if e.to_string().contains("no rows") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
})?;
|
||||
let mut v = pg_row_to_json(row);
|
||||
if table == "users" {
|
||||
v = strip_password_hash(v);
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
19
src/state.rs
19
src/state.rs
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue