Skip to content
Merged
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

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

23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,28 @@ Requires `Authorization: Bearer <ADMIN_TOKEN>`.
| `POST` | `/api/admin/models` | Create model with optional HF link |
| `PUT` | `/api/admin/models/{id}` | Update a model's HF link |
| `DELETE` | `/api/admin/models/{id}` | Delete a model and its votes |
| `DELETE` | `/api/admin/deliveries/{id}` | Delete an abliterated model delivery |
| `DELETE` | `/api/admin/prune` | Delete all zero-vote models |
| `POST` | `/api/admin/deliver` | Record an abliterated model delivery |

### Admin UI

Available at `/admin/login`. Enter the `ADMIN_TOKEN` to access the dashboard.

| Path | Description |
|------|-------------|
| `/admin/login` | Login form (public) |
| `/admin` | Dashboard — manage models and deliveries (requires auth) |

The admin dashboard provides:
- **Stats overview** — model count, total votes, deliveries
- **Model management** — view leaderboard, delete models, prune zero-vote models
- **Delivery management** — record new deliveries, view/delete existing ones
- **Auto-refresh** — model list refreshes every 10s, deliveries every 30s

Auth uses `localStorage`: the token is stored client-side after login and sent as
a `Bearer` header on every subsequent request.

### Rate Limits

- **Voting:** 3 votes per IP per hour, 1 vote per model per UUID
Expand All @@ -70,7 +89,7 @@ Requires `Authorization: Bearer <ADMIN_TOKEN>`.
```bash
make help # list all targets
make run # run locally (ADMIN_TOKEN=dev-secret)
make test # run all 81 tests
make test # run all tests
make lint # clippy
make ci # lint + test
make sqlx-prepare # regenerate offline query cache
Expand All @@ -85,6 +104,6 @@ make clean # remove artifacts
make test
```

81 tests covering queries, extractors, HF validation, DB migrations, all API endpoints, rate limiting, auth, pagination, and edge cases.
88 tests covering queries, extractors, HF validation, DB migrations, all API endpoints (including admin delivery deletion), rate limiting, auth, pagination, and edge cases.

See [DEVELOPMENT.md](DEVELOPMENT.md) for test architecture, security hardening details, and development workflow.
9 changes: 9 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ pub fn app(state: AppState) -> Router {
.route("/model-select", get(pages::model_select))
.route("/vote", post(pages::vote_page))
.route("/add-model", post(pages::create_model_page))
// Admin UI
.route("/admin", get(admin::admin_page))
.route("/admin/login", get(admin::admin_login_page).post(admin::admin_login))
.route("/admin/models", get(admin::admin_models))
.route("/admin/deliveries", get(admin::admin_deliveries))
// JSON API
.route("/api/leaderboard", get(api::get_leaderboard))
.route("/api/deliveries", get(api::get_deliveries))
Expand All @@ -41,6 +46,10 @@ pub fn app(state: AppState) -> Router {
)
.route("/api/admin/prune", delete(api::admin_prune_models))
.route("/api/admin/deliver", post(api::admin_deliver))
.route(
"/api/admin/deliveries/{delivery_id}",
delete(api::admin_delete_delivery),
)
// Static files
.nest_service("/static", ServeDir::new("static"))
.with_state(state)
Expand Down
40 changes: 40 additions & 0 deletions src/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,19 @@ pub async fn prune_zero_vote_models(
Ok(result.rows_affected() as i64)
}

/// Delete a delivery by ID.
///
/// Returns `true` if the delivery existed and was deleted.
pub async fn delete_delivery(
pool: &sqlx::SqlitePool,
delivery_id: i64,
) -> Result<bool, AppError> {
let result = sqlx::query!("DELETE FROM deliveries WHERE id = ?", delivery_id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -593,4 +606,31 @@ mod tests {
let pruned = prune_zero_vote_models(&pool).await.unwrap();
assert_eq!(pruned, 0);
}

#[sqlx::test]
async fn delete_delivery_removes_row(pool: SqlitePool) {
insert_delivery(
&pool, "test/del-delivery", 5, "https://hf.co/test", &None,
0.02, 1, 128,
)
.await
.unwrap();

let deliveries = fetch_deliveries(&pool, 100, 0, DeliverySort::Date).await.unwrap();
let ours = deliveries.iter().find(|d| d.model_id == "test/del-delivery").unwrap();
let id = ours.id;

let deleted = delete_delivery(&pool, id).await.unwrap();
assert!(deleted);

// Verify it's gone
let deliveries = fetch_deliveries(&pool, 100, 0, DeliverySort::Date).await.unwrap();
assert!(deliveries.iter().all(|d| d.model_id != "test/del-delivery"));
}

#[sqlx::test]
async fn delete_delivery_nonexistent_returns_false(pool: SqlitePool) {
let deleted = delete_delivery(&pool, 999999).await.unwrap();
assert!(!deleted);
}
}
128 changes: 128 additions & 0 deletions src/routes/admin.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
use askama::Template;
use axum::extract::State;
use axum::response::Html;

use crate::error::AppError;
use crate::extractors::AdminAuth;
use crate::models::{DeliveryEntry, LeaderboardEntry};
use crate::queries;
use crate::state::AppState;

// ---------------------------------------------------------------------------
// Template types
// ---------------------------------------------------------------------------

#[derive(Template)]
#[template(path = "admin.html")]
struct AdminTemplate {
models: Vec<LeaderboardEntry>,
deliveries: Vec<DeliveryEntry>,
total_deliveries: i64,
total_votes: i64,
}

#[derive(Template)]
#[template(path = "admin_models.html")]
struct AdminModelsTemplate {
models: Vec<LeaderboardEntry>,
}

#[derive(Template)]
#[template(path = "admin_deliveries.html")]
struct AdminDeliveriesTemplate {
deliveries: Vec<DeliveryEntry>,
}

// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------

/// GET /admin
///
/// Admin dashboard. Requires `AdminAuth` extractor (401 if unauthenticated).
pub async fn admin_page(
State(state): State<AppState>,
_auth: AdminAuth,
) -> Result<Html<String>, AppError> {
let models = queries::fetch_leaderboard(&state.db).await?;
let total_deliveries = queries::count_deliveries(&state.db).await?;
let deliveries = queries::fetch_deliveries(
&state.db, 100, 0, queries::DeliverySort::Date,
).await?;
let total_votes = models.iter().map(|m| m.vote_count).sum::<i64>();

let tpl = AdminTemplate {
models,
deliveries,
total_deliveries,
total_votes,
};
Ok(Html(tpl.render().map_err(|e| AppError::Internal(e.to_string()))?))
}

/// GET /admin/login
///
/// Public login form. No authentication required.
pub async fn admin_login_page() -> Result<Html<String>, AppError> {
let tpl = AdminLoginTemplate;
Ok(Html(tpl.render().map_err(|e| AppError::Internal(e.to_string()))?))
}

/// POST /admin/login
///
/// Validates the admin token via `AdminAuth` extractor.
/// Returns the full dashboard HTML. Client stores token in localStorage
/// for subsequent HTMX requests.
pub async fn admin_login(
State(state): State<AppState>,
_auth: AdminAuth,
) -> Result<Html<String>, AppError> {
let models = queries::fetch_leaderboard(&state.db).await?;
let total_deliveries = queries::count_deliveries(&state.db).await?;
let deliveries = queries::fetch_deliveries(
&state.db, 100, 0, queries::DeliverySort::Date,
).await?;
let total_votes = models.iter().map(|m| m.vote_count).sum::<i64>();

let tpl = AdminTemplate {
models,
deliveries,
total_deliveries,
total_votes,
};
Ok(Html(tpl.render().map_err(|e| AppError::Internal(e.to_string()))?))
}

/// GET /admin/models
///
/// HTMX partial: model rows for the admin table. Requires `AdminAuth`.
pub async fn admin_models(
State(state): State<AppState>,
_auth: AdminAuth,
) -> Result<Html<String>, AppError> {
let models = queries::fetch_leaderboard(&state.db).await?;
let tpl = AdminModelsTemplate { models };
Ok(Html(tpl.render().map_err(|e| AppError::Internal(e.to_string()))?))
}

/// GET /admin/deliveries
///
/// HTMX partial: delivery rows for the admin table. Requires `AdminAuth`.
pub async fn admin_deliveries(
State(state): State<AppState>,
_auth: AdminAuth,
) -> Result<Html<String>, AppError> {
let deliveries = queries::fetch_deliveries(
&state.db, 100, 0, queries::DeliverySort::Date,
).await?;
let tpl = AdminDeliveriesTemplate { deliveries };
Ok(Html(tpl.render().map_err(|e| AppError::Internal(e.to_string()))?))
}

// ---------------------------------------------------------------------------
// Login form template
// ---------------------------------------------------------------------------

#[derive(Template)]
#[template(path = "admin_login.html")]
struct AdminLoginTemplate;
20 changes: 20 additions & 0 deletions src/routes/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,23 @@ pub async fn admin_prune_models(
"deleted": deleted,
})))
}

/// DELETE /api/admin/deliveries/:delivery_id
///
/// Admin endpoint to delete an abliterated model delivery.
pub async fn admin_delete_delivery(
State(state): State<AppState>,
Path(delivery_id): Path<i64>,
_auth: AdminAuth,
) -> Result<Json<serde_json::Value>, AppError> {
let deleted = queries::delete_delivery(&state.db, delivery_id).await?;
if !deleted {
return Err(AppError::NotFound("Delivery not found".to_string()));
}

tracing::info!(delivery_id = delivery_id, "Admin deleted delivery");
Ok(Json(serde_json::json!({
"message": "Delivery deleted successfully",
"delivery_id": delivery_id,
})))
}
1 change: 1 addition & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod admin;
pub mod api;
pub mod pages;
39 changes: 39 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,37 @@ body {
@keyframes toast-in { from { opacity: 0; transform: translateY(-12px) scale(0.96); } }
@keyframes toast-out { to { opacity: 0; transform: translateY(-8px) scale(0.97); } }

/* -- Admin actions (inline in card header) -- */
.admin-actions {
display: flex; align-items: center; gap: 0.375rem;
}

/* -- Admin model list -- */
.admin-model-list { list-style: none; }
.admin-model-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.75rem 1.25rem; transition: background 0.15s;
}
.admin-model-row:hover { background: var(--surface-2); }
.admin-model-row + .admin-model-row { border-top: 1px solid var(--border-subtle); }
.admin-model-rank {
width: 28px; height: 28px; border-radius: var(--radius-sm);
display: grid; place-items: center; font-size: 0.75rem; font-weight: 700;
flex-shrink: 0; background: var(--surface-2); color: var(--text-3);
}
.admin-model-info { flex: 1; min-width: 0; }
.admin-model-name { font-weight: 600; font-size: 0.875rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.admin-model-hf { font-size: 0.7rem; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.admin-model-hf a { color: var(--accent); text-decoration: none; }
.admin-model-hf a:hover { text-decoration: underline; }
.admin-model-votes {
font-variant-numeric: tabular-nums; font-weight: 700; font-size: 0.85rem;
color: var(--accent); flex-shrink: 0; min-width: 60px; text-align: right;
}
.admin-model-votes small { font-size: 0.6rem; font-weight: 500; color: var(--text-3); margin-left: 2px; }
.btn-danger { background: var(--red-dim); color: var(--red); border: 1px solid rgba(248,113,113,0.15); }
.btn-danger:hover { background: rgba(248,113,113,0.15); }

/* ================================================================
Mobile responsive (<= 540px)
================================================================ */
Expand Down Expand Up @@ -441,4 +472,12 @@ body {
/* Toasts full-width on mobile */
#toasts { left: 1rem; right: 1rem; }
.toast { max-width: none; }

/* Admin compact on mobile */
.card-head { flex-direction: column; align-items: stretch; gap: 0.75rem; }
.admin-actions { justify-content: flex-end; }
.admin-model-row { padding: 0.625rem 1rem; gap: 0.5rem; }
.admin-model-rank { width: 24px; height: 24px; font-size: 0.68rem; }
.admin-model-name { font-size: 0.78rem; }
.admin-model-votes { min-width: 50px; font-size: 0.78rem; }
}
Loading