juiceback/routes/
admin.rs

1//! Admin panel: login, JWT sessions, listing and deleting files.
2
3use axum::{
4    extract::{Path, State},
5    http::{header, HeaderMap, StatusCode},
6    response::{IntoResponse, Json, Response},
7    Form, Router,
8};
9use serde::{Deserialize, Serialize};
10use std::sync::Arc;
11use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
12use utoipa::ToSchema;
13
14use crate::auth;
15use crate::db;
16use crate::error::AppError;
17use crate::state::AppState;
18
19use super::noscript::{escape, page};
20
21#[derive(Deserialize, ToSchema)]
22pub struct LoginForm {
23    pub username: String,
24    pub password: String,
25}
26
27#[derive(Serialize, ToSchema)]
28pub struct LoginResponse {
29    pub token: String,
30    pub username: String,
31}
32
33#[derive(Serialize, ToSchema)]
34pub struct CheckResponse {
35    pub ok: bool,
36    pub username: String,
37}
38
39#[derive(Serialize, ToSchema)]
40pub struct AdminFileEntry {
41    pub id: String,
42    pub filename: String,
43    pub mime_type: String,
44    pub size_bytes: i64,
45    pub uploaded_at: i64,
46    pub expires_at: i64,
47    pub uploader_ip: Option<String>,
48}
49
50pub fn admin_routes() -> Router<Arc<AppState>> {
51    let login_limiter = {
52        let conf = Arc::new(
53            GovernorConfigBuilder::default()
54                .period(std::time::Duration::from_secs(60))
55                .burst_size(5)
56                .finish()
57                .unwrap(),
58        );
59        Router::new()
60            .route("/api/admin/login", axum::routing::post(login_handler))
61            .layer(GovernorLayer { config: conf })
62    };
63
64    Router::new()
65        .merge(login_limiter)
66        .route("/api/admin/logout", axum::routing::get(logout_handler))
67        .route(
68            "/api/admin/check",
69            axum::routing::get(check_handler),
70        )
71        .route("/api/admin/files", axum::routing::get(files_handler))
72        .route("/api/admin/file/:id", axum::routing::delete(delete_file_handler))
73        .route("/admin", axum::routing::get(admin_panel_handler))
74}
75
76pub async fn logout_handler() -> Response {
77    let cookie = "token=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict";
78    (
79        StatusCode::FOUND,
80        [(header::LOCATION, "/admin/login"), (header::SET_COOKIE, cookie)],
81    )
82        .into_response()
83}
84
85pub async fn login_handler(
86    State(state): State<Arc<AppState>>,
87    headers: HeaderMap,
88    Form(form): Form<LoginForm>,
89) -> Result<Response, AppError> {
90    let username = form.username.trim().to_lowercase();
91    if username.is_empty() || form.password.is_empty() {
92        return Err(AppError::InvalidMultipart(
93            "username and password are required".into(),
94        ));
95    }
96
97    let db = state
98        .db
99        .get()
100        .map_err(|e| AppError::DbPoolError(e.to_string()))?;
101
102    let user = db::get_admin_by_username(&db, &username)
103        .map_err(AppError::DatabaseError)?
104        .ok_or_else(|| {
105            AppError::InvalidMultipart("invalid credentials".into())
106        })?;
107
108    let valid = auth::verify_password(&form.password, &user.password_hash)
109        .map_err(|e| AppError::Internal(e))?;
110
111    if !valid {
112        return Err(AppError::InvalidMultipart("invalid credentials".into()));
113    }
114
115    let token = auth::create_jwt(&username, &state.config.jwt_secret)
116        .map_err(|e| AppError::Internal(e))?;
117
118    let is_localhost = headers
119        .get("host")
120        .and_then(|v| v.to_str().ok())
121        .map(|h| h.starts_with("localhost") || h.starts_with("127.0.0.1") || h.starts_with("[::1]"))
122        .unwrap_or(false);
123
124    let is_https = headers
125        .get("x-forwarded-proto")
126        .and_then(|v| v.to_str().ok())
127        .map(|p| p == "https")
128        .unwrap_or(false);
129
130    // Set Secure unless we're on plain HTTP localhost (dev mode)
131    let secure = !(is_localhost && !is_https);
132
133    let cookie = format!(
134        "token={}; Path=/; Max-Age=86400; HttpOnly; SameSite=Strict{}",
135        token,
136        if secure { "; Secure" } else { "" }
137    );
138
139    let body = Json(LoginResponse {
140        token,
141        username,
142    });
143
144    Ok((
145        StatusCode::OK,
146        [(header::SET_COOKIE, cookie)],
147        body,
148    )
149        .into_response())
150}
151
152pub async fn check_handler(
153    State(state): State<Arc<AppState>>,
154    headers: HeaderMap,
155) -> Result<Json<CheckResponse>, AppError> {
156    let token = extract_jwt(&headers).ok_or_else(|| {
157        AppError::InvalidMultipart("not authenticated".into())
158    })?;
159
160    let claims = auth::verify_jwt(&token, &state.config.jwt_secret)
161        .map_err(|_| AppError::InvalidMultipart("invalid token".into()))?;
162
163    Ok(Json(CheckResponse {
164        ok: true,
165        username: claims.sub,
166    }))
167}
168
169pub async fn admin_panel_handler(
170    State(state): State<Arc<AppState>>,
171    headers: HeaderMap,
172) -> Result<Response, AppError> {
173    let token = extract_jwt(&headers).ok_or_else(|| {
174        AppError::InvalidMultipart("not authenticated".into())
175    })?;
176
177    let claims = auth::verify_jwt(&token, &state.config.jwt_secret)
178        .map_err(|_| AppError::InvalidMultipart("invalid token".into()))?;
179
180    let db = state
181        .db
182        .get()
183        .map_err(|e| AppError::DbPoolError(e.to_string()))?;
184    let admins = db::list_admins(&db).map_err(AppError::DatabaseError)?;
185
186    let mut admin_rows = String::new();
187    for a in &admins {
188        admin_rows.push_str(&format!(
189            "<tr><td>{}</td><td>{}</td></tr>",
190            escape(&a.username),
191            a.created_at
192        ));
193    }
194
195    let body = format!(
196        "<h1>Admin Panel</h1>\
197         <p>Logged in as: <strong>{}</strong></p>\
198         <hr>\
199         <h2>Admins</h2>\
200         <table border=\"1\" cellpadding=\"8\" style=\"border-collapse:collapse;width:100%\">\
201         <tr><th>Username</th><th>Created</th></tr>{}</table>\
202         <hr>\
203         <p><a href=\"/api/admin/logout\">Logout</a></p>\
204         <p><a href=\"/index.html\">Back to home</a></p>",
205        escape(&claims.sub),
206        admin_rows
207    );
208    Ok(page("Admin Panel - Juicebox", &body).into_response())
209}
210
211pub async fn files_handler(
212    State(state): State<Arc<AppState>>,
213    headers: HeaderMap,
214) -> Result<Json<Vec<AdminFileEntry>>, AppError> {
215    let token = extract_jwt(&headers).ok_or_else(|| {
216        AppError::InvalidMultipart("not authenticated".into())
217    })?;
218
219    auth::verify_jwt(&token, &state.config.jwt_secret)
220        .map_err(|_| AppError::InvalidMultipart("invalid token".into()))?;
221
222    let db = state
223        .db
224        .get()
225        .map_err(|e| AppError::DbPoolError(e.to_string()))?;
226
227    let records = db::list_all_files(&db).map_err(AppError::DatabaseError)?;
228
229    let files: Vec<AdminFileEntry> = records
230        .into_iter()
231        .map(|r| AdminFileEntry {
232            id: r.id,
233            filename: r.filename,
234            mime_type: r.mime_type,
235            size_bytes: r.size_bytes,
236            uploaded_at: r.uploaded_at,
237            expires_at: r.expires_at,
238            uploader_ip: r.uploader_ip,
239        })
240        .collect();
241
242    Ok(Json(files))
243}
244
245pub async fn delete_file_handler(
246    State(state): State<Arc<AppState>>,
247    headers: HeaderMap,
248    Path(id): Path<String>,
249) -> Result<StatusCode, AppError> {
250    let token = extract_jwt(&headers).ok_or_else(|| {
251        AppError::InvalidMultipart("not authenticated".into())
252    })?;
253
254    auth::verify_jwt(&token, &state.config.jwt_secret)
255        .map_err(|_| AppError::InvalidMultipart("invalid token".into()))?;
256
257    let state2 = Arc::clone(&state);
258    let id2 = id.clone();
259
260    let record = tokio::task::spawn_blocking(move || -> Result<Option<db::FileRecord>, AppError> {
261        let db = state2.db.get().map_err(|e| AppError::DbPoolError(e.to_string()))?;
262        db::get_file(&db, &id2).map_err(AppError::DatabaseError)
263    })
264    .await
265    .map_err(|_| AppError::TaskPanicked("db task panicked".into()))?
266    .map_err(|e| e)?
267    .ok_or(AppError::NotFound)?;
268
269    let _ = tokio::fs::remove_file(&record.storage_path).await;
270
271    let state3 = Arc::clone(&state);
272    let id3 = id.clone();
273    tokio::task::spawn_blocking(move || -> Result<bool, AppError> {
274        let db = state3.db.get().map_err(|e| AppError::DbPoolError(e.to_string()))?;
275        db::delete_file(&db, &id3).map_err(AppError::DatabaseError)
276    })
277    .await
278    .map_err(|_| AppError::TaskPanicked("db task panicked".into()))?
279    .map_err(|e| e)?;
280
281    crate::juicehost::delete_file_on_juicehost(&state, &id, None).await;
282
283    tracing::info!("admin delete: id={}", id);
284
285    Ok(StatusCode::NO_CONTENT)
286}
287
288fn extract_jwt(headers: &HeaderMap) -> Option<String> {
289    let cookie = headers.get(header::COOKIE)?.to_str().ok()?;
290    for pair in cookie.split(';') {
291        let pair = pair.trim();
292        if let Some(value) = pair.strip_prefix("token=") {
293            return Some(value.to_string());
294        }
295    }
296    None
297}