use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::{delete, get, put},
Json, Router,
};
use serde::Deserialize;
use tracing::instrument;
use super::{error_json, extract_auth_event, verify_auth_event, SharedState};
use crate::access::Action;
pub fn admin_router(state: SharedState) -> Router {
Router::new()
.route("/admin/stats", get(handle_admin_stats))
.route("/admin/users", get(handle_list_users))
.route("/admin/users/{pubkey}", get(handle_get_user))
.route("/admin/users/{pubkey}/quota", put(handle_set_quota))
.route("/admin/users/{pubkey}/role", put(handle_set_role))
.route("/admin/roles", get(handle_list_roles))
.route("/admin/lfs-stats", get(handle_lfs_stats))
.route("/admin/blobs", get(handle_list_all_blobs))
.route("/admin/blobs/{sha256}", delete(handle_admin_delete_blob))
.route("/admin/whitelist", get(handle_whitelist_list))
.route(
"/admin/whitelist/{pubkey}",
put(handle_whitelist_add).delete(handle_whitelist_remove),
)
.with_state(state)
}
fn extract_admin_pubkey(
headers: &HeaderMap,
access: &dyn crate::access::AccessControl,
) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
let event = extract_auth_event(headers)
.map_err(|e| (StatusCode::UNAUTHORIZED, error_json(&e.to_string())))?;
verify_auth_event(&event, None)
.map_err(|e| (StatusCode::UNAUTHORIZED, error_json(&e.to_string())))?;
if !access.is_allowed(&event.pubkey, Action::Admin) {
return Err((StatusCode::FORBIDDEN, error_json("admin access required")));
}
Ok(event.pubkey)
}
#[instrument(name = "admin.stats", skip_all)]
async fn handle_admin_stats(
State(state): State<SharedState>,
headers: HeaderMap,
) -> impl IntoResponse {
let s = state.lock().await;
if let Err(e) = extract_admin_pubkey(&headers, &*s.access) {
return e;
}
(
StatusCode::OK,
Json(serde_json::json!({
"blobs": s.backend.len(),
"total_bytes": s.backend.total_bytes(),
"uploads": s.database.upload_count(),
"users": s.database.user_count(),
"tracked_stats": s.stats.tracked_count(),
})),
)
}
#[instrument(name = "admin.lfs_stats", skip_all)]
async fn handle_lfs_stats(
State(state): State<SharedState>,
headers: HeaderMap,
) -> impl IntoResponse {
let s = state.lock().await;
if let Err(e) = extract_admin_pubkey(&headers, &*s.access) {
return e;
}
match &s.lfs_version_db {
Some(db) => match db.lfs_stats() {
Ok(stats) => {
let savings_pct = if stats.total_original_bytes > 0 {
(1.0 - stats.total_stored_bytes as f64 / stats.total_original_bytes as f64)
* 100.0
} else {
0.0
};
(
StatusCode::OK,
Json(serde_json::json!({
"total_versions": stats.total_versions,
"total_original_bytes": stats.total_original_bytes,
"total_stored_bytes": stats.total_stored_bytes,
"savings_percent": (savings_pct * 100.0).round() / 100.0,
"by_storage_type": stats.by_storage_type,
})),
)
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
error_json(&format!("lfs stats: {e}")),
),
},
None => (
StatusCode::NOT_FOUND,
error_json("LFS version database not configured"),
),
}
}
#[instrument(name = "admin.list_users", skip_all)]
async fn handle_list_users(
State(state): State<SharedState>,
headers: HeaderMap,
) -> impl IntoResponse {
let s = state.lock().await;
if let Err(e) = extract_admin_pubkey(&headers, &*s.access) {
return e;
}
(
StatusCode::OK,
Json(serde_json::json!({
"user_count": s.database.user_count(),
})),
)
}
#[instrument(name = "admin.get_user", skip_all, fields(user.pubkey = %pubkey))]
async fn handle_get_user(
State(state): State<SharedState>,
Path(pubkey): Path<String>,
headers: HeaderMap,
) -> impl IntoResponse {
let mut s = state.lock().await;
if let Err(e) = extract_admin_pubkey(&headers, &*s.access) {
return e;
}
match s.database.get_or_create_user(&pubkey) {
Ok(user) => (
StatusCode::OK,
Json(serde_json::json!({
"pubkey": user.pubkey,
"quota_bytes": user.quota_bytes,
"used_bytes": user.used_bytes,
})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
error_json(&e.to_string()),
),
}
}
#[derive(Deserialize)]
struct SetQuotaRequest {
quota_bytes: Option<u64>,
}
#[instrument(name = "admin.set_quota", skip_all, fields(user.pubkey = %pubkey))]
async fn handle_set_quota(
State(state): State<SharedState>,
Path(pubkey): Path<String>,
headers: HeaderMap,
Json(req): Json<SetQuotaRequest>,
) -> impl IntoResponse {
let mut s = state.lock().await;
if let Err(e) = extract_admin_pubkey(&headers, &*s.access) {
return e;
}
match s.database.set_quota(&pubkey, req.quota_bytes) {
Ok(()) => {
tracing::info!(
user.pubkey = %pubkey,
quota_bytes = ?req.quota_bytes,
"quota updated"
);
(
StatusCode::OK,
Json(serde_json::json!({
"pubkey": pubkey,
"quota_bytes": req.quota_bytes,
})),
)
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
error_json(&e.to_string()),
),
}
}
#[instrument(name = "admin.list_blobs", skip_all)]
async fn handle_list_all_blobs(
State(state): State<SharedState>,
headers: HeaderMap,
) -> impl IntoResponse {
let s = state.lock().await;
if let Err(e) = extract_admin_pubkey(&headers, &*s.access) {
return e;
}
(
StatusCode::OK,
Json(serde_json::json!({
"blob_count": s.backend.len(),
"total_bytes": s.backend.total_bytes(),
})),
)
}
#[instrument(name = "admin.delete_blob", skip_all, fields(blob.sha256 = %sha256))]
async fn handle_admin_delete_blob(
State(state): State<SharedState>,
Path(sha256): Path<String>,
headers: HeaderMap,
) -> impl IntoResponse {
let mut s = state.lock().await;
if let Err(e) = extract_admin_pubkey(&headers, &*s.access) {
return e;
}
if s.backend.delete(&sha256) {
let _ = s.database.delete_upload(&sha256);
tracing::info!(blob.sha256 = %sha256, "admin deleted blob");
(StatusCode::OK, Json(serde_json::json!({"deleted": true})))
} else {
(StatusCode::NOT_FOUND, error_json("blob not found"))
}
}
#[derive(Deserialize)]
struct SetRoleRequest {
role: String,
}
#[instrument(name = "admin.set_role", skip_all, fields(target.pubkey = %pubkey))]
async fn handle_set_role(
State(state): State<SharedState>,
Path(pubkey): Path<String>,
headers: HeaderMap,
Json(body): Json<SetRoleRequest>,
) -> impl IntoResponse {
let mut s = state.lock().await;
if let Err(e) = extract_admin_pubkey(&headers, &*s.access) {
return e;
}
let normalized = crate::access::normalize_pubkey(&pubkey).unwrap_or(pubkey);
let role = body.role.to_lowercase();
if !matches!(role.as_str(), "admin" | "member" | "denied") {
return (
StatusCode::BAD_REQUEST,
error_json("role must be 'admin', 'member', or 'denied'"),
);
}
if let Err(e) = s.database.set_role(&normalized, &role) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
error_json(&format!("set role: {e}")),
);
}
tracing::info!(target.pubkey = %normalized, role = %role, "role updated via admin API");
(
StatusCode::OK,
Json(serde_json::json!({
"pubkey": normalized,
"role": role,
})),
)
}
#[instrument(name = "admin.list_roles", skip_all)]
async fn handle_list_roles(
State(state): State<SharedState>,
headers: HeaderMap,
) -> impl IntoResponse {
let s = state.lock().await;
if let Err(e) = extract_admin_pubkey(&headers, &*s.access) {
return e;
}
let admins = s
.database
.list_users_by_role("admin")
.unwrap_or_default()
.into_iter()
.map(|u| u.pubkey)
.collect::<Vec<_>>();
let members = s
.database
.list_users_by_role("member")
.unwrap_or_default()
.into_iter()
.map(|u| u.pubkey)
.collect::<Vec<_>>();
let denied = s
.database
.list_users_by_role("denied")
.unwrap_or_default()
.into_iter()
.map(|u| u.pubkey)
.collect::<Vec<_>>();
(
StatusCode::OK,
Json(serde_json::json!({
"admins": admins,
"members": members,
"denied": denied,
})),
)
}
#[instrument(name = "admin.whitelist_list", skip_all)]
async fn handle_whitelist_list(
State(state): State<SharedState>,
headers: HeaderMap,
) -> impl IntoResponse {
let s = state.lock().await;
if let Err(e) = extract_admin_pubkey(&headers, &*s.access) {
return e;
}
match &s.whitelist {
Some(wl) => {
let keys: Vec<String> = wl.list().await;
(
StatusCode::OK,
Json(serde_json::json!({
"count": keys.len(),
"pubkeys": keys,
})),
)
}
None => (StatusCode::NOT_FOUND, error_json("no whitelist configured")),
}
}
#[instrument(name = "admin.whitelist_add", skip_all, fields(whitelist.pubkey = %pubkey))]
async fn handle_whitelist_add(
State(state): State<SharedState>,
Path(pubkey): Path<String>,
headers: HeaderMap,
) -> impl IntoResponse {
let s = state.lock().await;
if let Err(e) = extract_admin_pubkey(&headers, &*s.access) {
return e;
}
match &s.whitelist {
Some(wl) => {
wl.add(pubkey.clone()).await;
tracing::info!(whitelist.pubkey = %pubkey, "pubkey added to whitelist");
(StatusCode::OK, Json(serde_json::json!({"added": pubkey})))
}
None => (StatusCode::NOT_FOUND, error_json("no whitelist configured")),
}
}
#[instrument(name = "admin.whitelist_remove", skip_all, fields(whitelist.pubkey = %pubkey))]
async fn handle_whitelist_remove(
State(state): State<SharedState>,
Path(pubkey): Path<String>,
headers: HeaderMap,
) -> impl IntoResponse {
let s = state.lock().await;
if let Err(e) = extract_admin_pubkey(&headers, &*s.access) {
return e;
}
match &s.whitelist {
Some(wl) => {
wl.remove(&pubkey).await;
tracing::info!(whitelist.pubkey = %pubkey, "pubkey removed from whitelist");
(StatusCode::OK, Json(serde_json::json!({"removed": pubkey})))
}
None => (StatusCode::NOT_FOUND, error_json("no whitelist configured")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::access::Whitelist;
use crate::auth::{auth_header_value, build_blossom_auth, Signer};
use crate::db::MemoryDatabase;
use crate::server::nip96::nip96_router;
use crate::storage::MemoryBackend;
use crate::{BlobServer, BlossomSigner};
use std::collections::HashSet;
async fn spawn_admin_server() -> (String, Signer) {
let admin_signer = Signer::generate();
let admin_pubkey = admin_signer.public_key_hex();
let mut keys = HashSet::new();
keys.insert(admin_pubkey);
let whitelist = Whitelist::new(keys);
let server = BlobServer::builder(MemoryBackend::new(), "http://localhost:3000")
.database(MemoryDatabase::new())
.access_control(whitelist)
.require_auth()
.build();
let state = server.shared_state();
let app = server
.router()
.merge(nip96_router(state.clone()))
.merge(admin_router(state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
tokio::spawn(async move { axum::serve(listener, app).await.ok() });
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
(url, admin_signer)
}
fn admin_auth(signer: &Signer) -> String {
let event = build_blossom_auth(signer, "admin", None, None, "");
auth_header_value(&event)
}
#[tokio::test]
async fn test_admin_stats() {
let (url, signer) = spawn_admin_server().await;
let http = reqwest::Client::new();
let resp = http
.get(format!("{}/admin/stats", url))
.header("Authorization", admin_auth(&signer))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["blobs"], 0);
}
#[tokio::test]
async fn test_admin_requires_auth() {
let (url, _signer) = spawn_admin_server().await;
let http = reqwest::Client::new();
let resp = http
.get(format!("{}/admin/stats", url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test]
async fn test_admin_non_admin_rejected() {
let (url, _signer) = spawn_admin_server().await;
let http = reqwest::Client::new();
let other = Signer::generate();
let resp = http
.get(format!("{}/admin/stats", url))
.header("Authorization", admin_auth(&other))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 403);
}
#[tokio::test]
async fn test_admin_set_and_get_quota() {
let (url, signer) = spawn_admin_server().await;
let http = reqwest::Client::new();
let target_pubkey = "a".repeat(64);
let resp = http
.put(format!("{}/admin/users/{}/quota", url, target_pubkey))
.header("Authorization", admin_auth(&signer))
.json(&serde_json::json!({"quota_bytes": 1048576}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["quota_bytes"], 1048576);
let resp = http
.get(format!("{}/admin/users/{}", url, target_pubkey))
.header("Authorization", admin_auth(&signer))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["quota_bytes"], 1048576);
assert_eq!(body["used_bytes"], 0);
}
#[tokio::test]
async fn test_admin_delete_blob() {
let (url, signer) = spawn_admin_server().await;
let http = reqwest::Client::new();
let data = b"admin delete test";
let upload_event = build_blossom_auth(&signer, "upload", None, None, "");
let auth = auth_header_value(&upload_event);
let resp = http
.put(format!("{}/upload", url))
.header("Authorization", &auth)
.body(data.to_vec())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let desc: serde_json::Value = resp.json().await.unwrap();
let sha = desc["sha256"].as_str().unwrap();
let resp = http
.delete(format!("{}/admin/blobs/{}", url, sha))
.header("Authorization", admin_auth(&signer))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let resp = http.head(format!("{}/{}", url, sha)).send().await.unwrap();
assert_eq!(resp.status(), 404);
}
}