1use 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 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}