Initial commit
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use axum::{
|
||||
extract::{Form, Path, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use rusqlite::Connection;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::db;
|
||||
|
||||
type Db = Arc<Mutex<Connection>>;
|
||||
|
||||
pub async fn serve(addr: &str, db: Db) {
|
||||
let app = Router::new()
|
||||
.route("/", get(management_page))
|
||||
.route("/api/announcements", get(list_announcements).post(create_announcement))
|
||||
.route("/api/announcements/:id/delete", post(delete_announcement))
|
||||
.with_state(db);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
println!("Management server listening on {addr}");
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn check_auth(headers: &HeaderMap) -> bool {
|
||||
let expected_user =
|
||||
std::env::var("MANAGEMENT_USERNAME").unwrap_or_else(|_| "admin".to_string());
|
||||
let expected_pass =
|
||||
std::env::var("MANAGEMENT_PASSWORD").unwrap_or_else(|_| "admin".to_string());
|
||||
let expected = format!("Basic {}", base64_encode(&format!("{expected_user}:{expected_pass}")));
|
||||
|
||||
headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v == expected)
|
||||
}
|
||||
|
||||
fn unauthorized() -> Response {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[(header::WWW_AUTHENTICATE, "Basic realm=\"Management\"")],
|
||||
"Unauthorized",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
use axum::response::Response;
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn management_page(headers: HeaderMap, State(db): State<Db>) -> impl IntoResponse {
|
||||
if !check_auth(&headers) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let conn = db.lock().await;
|
||||
let announcements = db::list_all(&conn).unwrap_or_default();
|
||||
drop(conn);
|
||||
|
||||
let rows: String = announcements
|
||||
.iter()
|
||||
.map(|a| {
|
||||
format!(
|
||||
include_str!("../../templates/announcement_row.html"),
|
||||
id = a.id,
|
||||
author = escape_html(&a.author),
|
||||
text = escape_html(&a.text_content),
|
||||
start = a.start_date,
|
||||
end = a.end_date,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let html = format!(
|
||||
include_str!("../../templates/management_page.html"),
|
||||
rows = if announcements.is_empty() {
|
||||
include_str!("../../templates/empty_table.html").to_string()
|
||||
} else {
|
||||
format!(
|
||||
include_str!("../../templates/table_wrapper.html"),
|
||||
rows = rows
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateInput {
|
||||
author: String,
|
||||
text_content: String,
|
||||
start_date: String,
|
||||
end_date: String,
|
||||
}
|
||||
|
||||
async fn create_announcement(
|
||||
headers: HeaderMap,
|
||||
State(db): State<Db>,
|
||||
Form(input): Form<CreateInput>,
|
||||
) -> impl IntoResponse {
|
||||
if !check_auth(&headers) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let conn = db.lock().await;
|
||||
if let Err(e) = db::create(&conn, &input.author, &input.text_content, &input.start_date, &input.end_date) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[("location", "/")],
|
||||
format!("Failed to create: {e}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(StatusCode::FOUND, [("location", "/")], "").into_response()
|
||||
}
|
||||
|
||||
async fn list_announcements(
|
||||
headers: HeaderMap,
|
||||
State(db): State<Db>,
|
||||
) -> impl IntoResponse {
|
||||
if !check_auth(&headers) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let conn = db.lock().await;
|
||||
match db::list_all(&conn) {
|
||||
Ok(list) => (StatusCode::OK, Json(serde_json::json!(list))).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_announcement(
|
||||
headers: HeaderMap,
|
||||
State(db): State<Db>,
|
||||
Path(id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
if !check_auth(&headers) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let conn = db.lock().await;
|
||||
db::delete(&conn, id).ok();
|
||||
|
||||
(StatusCode::FOUND, [("location", "/")], "").into_response()
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn escape_html(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn base64_encode(input: &str) -> String {
|
||||
// minimal Base64 implementation to avoid extra dependencies
|
||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = String::new();
|
||||
|
||||
for chunk in bytes.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
|
||||
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
|
||||
let triple = (b0 << 16) | (b1 << 8) | b2;
|
||||
|
||||
out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
|
||||
out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
|
||||
|
||||
if chunk.len() > 1 {
|
||||
out.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
out.push(CHARS[(triple & 0x3F) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user