ui redesign, markdown fix + metadata and auth header

This commit is contained in:
2026-05-09 05:09:07 +02:00
parent 7f8a66f360
commit bc6a34cf1f
42 changed files with 3093 additions and 517 deletions
+40 -3
View File
@@ -1,11 +1,48 @@
use crate::error::AppError;
use axum::http::HeaderMap;
use subtle::ConstantTimeEq;
use tracing::warn;
const COOKIE_NAME: &str = "admin";
fn extract_token(headers: &HeaderMap) -> Option<String> {
if let Some(auth) = headers.get("Authorization").and_then(|h| h.to_str().ok()) {
if let Some(token) = auth.strip_prefix("Bearer ") {
return Some(token.to_string());
}
}
if let Some(cookie_header) = headers.get("Cookie").and_then(|h| h.to_str().ok()) {
for part in cookie_header.split(';') {
let part = part.trim();
if let Some(value) = part.strip_prefix(&format!("{}=", COOKIE_NAME)) {
return Some(value.to_string());
}
}
}
None
}
fn token_matches(provided: &str, expected: &str) -> bool {
let a = provided.as_bytes();
let b = expected.as_bytes();
if a.len() != b.len() {
// Still do a constant-time compare to make timing uniform on the same-length path.
let _ = a.ct_eq(a);
return false;
}
a.ct_eq(b).into()
}
pub fn is_authed(headers: &HeaderMap, admin_token: &str) -> bool {
match extract_token(headers) {
Some(t) => token_matches(&t, admin_token),
None => false,
}
}
pub fn check_auth(headers: &HeaderMap, admin_token: &str) -> Result<(), AppError> {
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
if auth_header != Some(&format!("Bearer {}", admin_token)) {
warn!("Unauthorized access attempt detected");
if !is_authed(headers, admin_token) {
warn!("Unauthorized access attempt");
return Err(AppError::Unauthorized);
}
Ok(())
+14 -6
View File
@@ -3,6 +3,7 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use tracing::error;
use crate::models::ErrorResponse;
@@ -10,21 +11,28 @@ pub enum AppError {
Unauthorized,
NotFound(String),
BadRequest(String),
/// (public_message, internal_details) — details are logged but not returned.
Internal(String, Option<String>),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_message, details) = match self {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string(), None),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg, None),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg, None),
AppError::Internal(msg, details) => (StatusCode::INTERNAL_SERVER_ERROR, msg, details),
let (status, error_message) = match self {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::Internal(msg, details) => {
if let Some(d) = details {
error!("Internal error: {} — {}", msg, d);
} else {
error!("Internal error: {}", msg);
}
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string())
}
};
let body = Json(ErrorResponse {
error: error_message,
details,
});
(status, body).into_response()
+116
View File
@@ -0,0 +1,116 @@
use axum::{
Json,
extract::State,
http::{HeaderMap, HeaderValue, StatusCode},
response::{AppendHeaders, IntoResponse},
};
use serde::Deserialize;
use std::sync::Arc;
use subtle::ConstantTimeEq;
use tracing::{info, warn};
use crate::{AppState, error::AppError};
const COOKIE_TOKEN: &str = "admin";
const COOKIE_FLAG: &str = "admin_session";
const MAX_AGE_SECS: i64 = 60 * 60 * 24 * 30; // 30 days
#[derive(Deserialize)]
pub struct LoginRequest {
pub token: String,
}
fn cookie_attrs(secure: bool) -> String {
let secure_part = if secure { "; Secure" } else { "" };
format!(
"; HttpOnly{}; SameSite=Strict; Path=/; Max-Age={}",
secure_part, MAX_AGE_SECS
)
}
fn flag_cookie_attrs(secure: bool) -> String {
let secure_part = if secure { "; Secure" } else { "" };
format!(
"{}; SameSite=Strict; Path=/; Max-Age={}",
secure_part, MAX_AGE_SECS
)
}
pub async fn login(
State(state): State<Arc<AppState>>,
Json(payload): Json<LoginRequest>,
) -> Result<impl IntoResponse, AppError> {
let provided = payload.token.as_bytes();
let expected = state.admin_token.as_bytes();
let ok = if provided.len() == expected.len() {
provided.ct_eq(expected).into()
} else {
// Run constant-time compare anyway to flatten timing.
let _: bool = provided.ct_eq(provided).into();
false
};
if !ok {
warn!("Failed login attempt");
return Err(AppError::Unauthorized);
}
info!("Admin logged in");
let token_cookie = format!(
"{}={}{}",
COOKIE_TOKEN,
state.admin_token,
cookie_attrs(state.cookie_secure)
);
let flag_cookie = format!(
"{}=1{}",
COOKIE_FLAG,
flag_cookie_attrs(state.cookie_secure)
);
let headers = AppendHeaders([
(
axum::http::header::SET_COOKIE,
HeaderValue::from_str(&token_cookie)
.map_err(|e| AppError::Internal("Cookie".to_string(), Some(e.to_string())))?,
),
(
axum::http::header::SET_COOKIE,
HeaderValue::from_str(&flag_cookie)
.map_err(|e| AppError::Internal("Cookie".to_string(), Some(e.to_string())))?,
),
]);
Ok((StatusCode::NO_CONTENT, headers))
}
pub async fn logout(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let secure_part = if state.cookie_secure { "; Secure" } else { "" };
let token_cookie = format!(
"{}=; HttpOnly{}; SameSite=Strict; Path=/; Max-Age=0",
COOKIE_TOKEN, secure_part
);
let flag_cookie = format!(
"{}={}; SameSite=Strict; Path=/; Max-Age=0",
COOKIE_FLAG, secure_part
);
let headers = AppendHeaders([
(
axum::http::header::SET_COOKIE,
HeaderValue::from_str(&token_cookie).unwrap(),
),
(
axum::http::header::SET_COOKIE,
HeaderValue::from_str(&flag_cookie).unwrap(),
),
]);
(StatusCode::NO_CONTENT, headers)
}
pub async fn me(State(state): State<Arc<AppState>>, headers: HeaderMap) -> StatusCode {
if crate::auth::is_authed(&headers, &state.admin_token) {
StatusCode::NO_CONTENT
} else {
StatusCode::UNAUTHORIZED
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod auth;
pub mod config;
pub mod posts;
pub mod upload;
+182 -105
View File
@@ -2,79 +2,164 @@ use axum::{
Json,
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use chrono::Utc;
use std::{fs, sync::Arc};
use tracing::{error, info, warn};
use crate::{
AppState,
auth::check_auth,
auth::is_authed,
error::AppError,
models::{CreatePostRequest, PostDetail, PostInfo},
models::{CreatePostRequest, PostDetail, PostInfo, PostMeta},
};
const WORDS_PER_MINUTE: u32 = 200;
fn validate_slug(s: &str) -> Result<(), AppError> {
if s.is_empty()
|| s.contains('/')
|| s.contains('\\')
|| s.contains("..")
|| s.contains('\0')
{
return Err(AppError::BadRequest("Invalid slug".to_string()));
}
Ok(())
}
fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
let raw = raw.strip_prefix("---\n").or_else(|| raw.strip_prefix("---\r\n"))?;
let end_marker = raw.find("\n---\n").or_else(|| raw.find("\r\n---\r\n"))?;
let yaml = &raw[..end_marker];
let body_start = end_marker
+ raw[end_marker..]
.find("---\n")
.or_else(|| raw[end_marker..].find("---\r\n"))?
+ "---\n".len();
let body = raw[body_start..].trim_start_matches('\n').trim_start_matches('\r');
Some((yaml, body))
}
fn parse_post(raw: &str) -> Result<(PostMeta, String), AppError> {
let (yaml, body) = split_frontmatter(raw).ok_or_else(|| {
AppError::Internal(
"Missing frontmatter".to_string(),
Some("post is missing the YAML --- block".to_string()),
)
})?;
let meta: PostMeta = serde_yaml::from_str(yaml).map_err(|e| {
AppError::Internal(
"Invalid frontmatter".to_string(),
Some(format!("YAML parse error: {}", e)),
)
})?;
Ok((meta, body.to_string()))
}
fn serialize_post(meta: &PostMeta, body: &str) -> Result<String, AppError> {
let yaml = serde_yaml::to_string(meta).map_err(|e| {
AppError::Internal(
"Serialization error".to_string(),
Some(e.to_string()),
)
})?;
Ok(format!("---\n{}---\n{}", yaml, body))
}
fn reading_time(body: &str) -> u32 {
let words = body.split_whitespace().count() as u32;
(words + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE.max(1)
}
fn excerpt_from(meta: &PostMeta, body: &str) -> String {
if let Some(s) = meta.summary.as_ref() {
if !s.trim().is_empty() {
return s.trim().to_string();
}
}
let plain = body
.replace(['#', '*', '_', '`'], "")
.replace('\n', " ");
let mut out: String = plain.chars().take(200).collect();
if plain.chars().count() > 200 {
out.push_str("...");
}
out.trim().to_string()
}
async fn write_post_atomic(
state: &AppState,
slug: &str,
contents: &str,
) -> Result<(), AppError> {
let _guard = state.post_lock.lock().await;
let final_path = state.data_dir.join("posts").join(format!("{}.md", slug));
let tmp_path = state.data_dir.join("posts").join(format!(".{}.md.tmp", slug));
fs::write(&tmp_path, contents).map_err(|e| {
AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?;
fs::rename(&tmp_path, &final_path).map_err(|e| {
let _ = fs::remove_file(&tmp_path);
AppError::Internal("Rename error".to_string(), Some(e.to_string()))
})?;
Ok(())
}
pub async fn create_post(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(payload): Json<CreatePostRequest>,
) -> Result<Json<PostDetail>, AppError> {
check_auth(&headers, &state.admin_token)?;
if payload.slug.contains('/') || payload.slug.contains('\\') || payload.slug.contains("..") {
return Err(AppError::BadRequest("Invalid slug".to_string()));
if !is_authed(&headers, &state.admin_token) {
return Err(AppError::Unauthorized);
}
let file_path = state
.data_dir
.join("posts")
.join(format!("{}.md", payload.slug));
validate_slug(&payload.slug)?;
if let Some(ref old) = payload.old_slug {
validate_slug(old)?;
}
let posts_dir = state.data_dir.join("posts");
let file_path = posts_dir.join(format!("{}.md", payload.slug));
// Handle renaming
if let Some(ref old_slug) = payload.old_slug {
if old_slug != &payload.slug {
let old_file_path = state
.data_dir
.join("posts")
.join(format!("{}.md", old_slug));
if old_file_path.exists() {
// If new path already exists and it's different from old path, error out
let old_path = posts_dir.join(format!("{}.md", old_slug));
if old_path.exists() {
if file_path.exists() {
return Err(AppError::BadRequest(
"A post with this new title already exists".to_string(),
));
}
if let Err(e) = fs::rename(&old_file_path, &file_path) {
let _guard = state.post_lock.lock().await;
fs::rename(&old_path, &file_path).map_err(|e| {
error!("Rename error from {} to {}: {}", old_slug, payload.slug, e);
return Err(AppError::Internal(
"Rename error".to_string(),
Some(e.to_string()),
));
}
AppError::Internal("Rename error".to_string(), Some(e.to_string()))
})?;
drop(_guard);
info!("Renamed post from {} to {}", old_slug, payload.slug);
}
}
}
let mut file_content = String::new();
if let Some(ref summary) = payload.summary {
if !summary.trim().is_empty() {
file_content.push_str("---\nsummary: ");
file_content.push_str(&summary.replace('\n', " "));
file_content.push_str("\n---\n");
}
}
file_content.push_str(&payload.content);
let meta = PostMeta {
date: payload.date.unwrap_or_else(|| Utc::now().date_naive()),
summary: payload.summary.filter(|s| !s.trim().is_empty()),
tags: payload.tags,
draft: payload.draft,
};
let contents = serialize_post(&meta, &payload.content)?;
write_post_atomic(&state, &payload.slug, &contents).await?;
fs::write(&file_path, &file_content).map_err(|e| {
error!("Write error for post {}: {}", payload.slug, e);
AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?;
info!("Post created/updated: {}", payload.slug);
info!("Post saved: {}", payload.slug);
Ok(Json(PostDetail {
slug: payload.slug,
summary: payload.summary,
date: meta.date,
summary: meta.summary,
tags: meta.tags,
draft: meta.draft,
reading_time: reading_time(&payload.content),
content: payload.content,
}))
}
@@ -84,10 +169,13 @@ pub async fn delete_post(
headers: HeaderMap,
Path(slug): Path<String>,
) -> Result<StatusCode, AppError> {
check_auth(&headers, &state.admin_token)?;
if !is_authed(&headers, &state.admin_token) {
return Err(AppError::Unauthorized);
}
validate_slug(&slug)?;
let _guard = state.post_lock.lock().await;
let file_path = state.data_dir.join("posts").join(format!("{}.md", slug));
info!("Attempting to delete post at: {:?}", file_path);
if !file_path.exists() {
warn!("Post not found for deletion: {}", slug);
@@ -103,87 +191,76 @@ pub async fn delete_post(
Ok(StatusCode::NO_CONTENT)
}
pub async fn list_posts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
pub async fn list_posts(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Json<Vec<PostInfo>> {
let admin = is_authed(&headers, &state.admin_token);
let posts_dir = state.data_dir.join("posts");
let mut posts = Vec::new();
let mut posts: Vec<PostInfo> = Vec::new();
if let Ok(entries) = fs::read_dir(posts_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
if let Some(slug) = path.file_stem().and_then(|s| s.to_str()) {
let mut excerpt = String::new();
if let Ok(content) = fs::read_to_string(&path) {
if content.starts_with("---\n") {
let parts: Vec<&str> = content.splitn(3, "---\n").collect();
if parts.len() == 3 {
let frontmatter = parts[1];
for line in frontmatter.lines() {
if line.starts_with("summary: ") {
excerpt = line.trim_start_matches("summary: ").to_string();
break;
}
}
if excerpt.is_empty() {
let body = parts[2];
let clean_content = body.replace("#", "").replace("\n", " ");
excerpt = clean_content.chars().take(200).collect::<String>();
if clean_content.len() > 200 {
excerpt.push_str("...");
}
}
}
} else {
let clean_content = content.replace("#", "").replace("\n", " ");
excerpt = clean_content.chars().take(200).collect::<String>();
if clean_content.len() > 200 {
excerpt.push_str("...");
}
}
}
posts.push(PostInfo {
slug: slug.to_string(),
excerpt: excerpt.trim().to_string(),
});
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if slug.starts_with('.') {
continue;
}
let Ok(raw) = fs::read_to_string(&path) else {
continue;
};
let Ok((meta, body)) = parse_post(&raw) else {
warn!("Skipping post with bad frontmatter: {}", slug);
continue;
};
if meta.draft && !admin {
continue;
}
posts.push(PostInfo {
slug: slug.to_string(),
date: meta.date,
summary: meta.summary.clone(),
tags: meta.tags.clone(),
draft: meta.draft,
reading_time: reading_time(&body),
excerpt: excerpt_from(&meta, &body),
});
}
}
posts.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug)));
Json(posts)
}
pub async fn get_post(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(slug): Path<String>,
) -> Result<Json<PostDetail>, AppError> {
validate_slug(&slug)?;
let admin = is_authed(&headers, &state.admin_token);
let file_path = state.data_dir.join("posts").join(format!("{}.md", slug));
match fs::read_to_string(&file_path) {
Ok(raw_content) => {
let mut summary = None;
let mut content = raw_content.clone();
let raw = fs::read_to_string(&file_path)
.map_err(|_| AppError::NotFound("Post not found".to_string()))?;
let (meta, body) = parse_post(&raw)?;
if raw_content.starts_with("---\n") {
let parts: Vec<&str> = raw_content.splitn(3, "---\n").collect();
if parts.len() == 3 {
let frontmatter = parts[1];
for line in frontmatter.lines() {
if line.starts_with("summary: ") {
summary = Some(line.trim_start_matches("summary: ").to_string());
break;
}
}
content = parts[2].to_string();
}
}
Ok(Json(PostDetail {
slug,
summary,
content,
}))
}
Err(_) => Err(AppError::NotFound("Post not found".to_string())),
if meta.draft && !admin {
return Err(AppError::NotFound("Post not found".to_string()));
}
Ok(Json(PostDetail {
slug,
date: meta.date,
summary: meta.summary,
tags: meta.tags,
draft: meta.draft,
reading_time: reading_time(&body),
content: body,
}))
}
+113 -38
View File
@@ -19,22 +19,72 @@ pub struct UploadQuery {
pub replace: Option<bool>,
}
/// Allowed upload extensions. SVG, HTML, JS, executables intentionally absent —
/// /uploads/* is served as-is, so any active content there is XSS waiting to happen.
const ALLOWED_EXTS: &[&str] = &[
"jpg", "jpeg", "png", "webp", "gif", "avif",
"pdf", "txt", "md",
"mp3", "wav", "ogg",
"mp4", "webm", "mov",
];
fn validate_filename(name: &str) -> Result<(), AppError> {
if name.is_empty()
|| name.contains('/')
|| name.contains('\\')
|| name.contains("..")
|| name.contains('\0')
|| name.starts_with('.')
{
return Err(AppError::BadRequest("Invalid filename".to_string()));
}
Ok(())
}
fn check_mime_matches_ext(bytes: &[u8], ext: &str) -> bool {
let Some(kind) = infer::get(bytes) else {
// Plain text formats (txt, md) won't be detected by magic bytes.
return matches!(ext, "txt" | "md");
};
let mime = kind.mime_type();
match ext {
"jpg" | "jpeg" => mime == "image/jpeg",
"png" => mime == "image/png",
"webp" => mime == "image/webp",
"gif" => mime == "image/gif",
"avif" => mime == "image/avif",
"pdf" => mime == "application/pdf",
"mp3" => mime == "audio/mpeg",
"wav" => mime == "audio/x-wav" || mime == "audio/wav",
"ogg" => mime == "audio/ogg" || mime == "video/ogg",
"mp4" => mime == "video/mp4",
"webm" => mime == "video/webm",
"mov" => mime == "video/quicktime",
"txt" | "md" => true,
_ => false,
}
}
pub async fn delete_upload(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(filename): Path<String>,
) -> Result<StatusCode, AppError> {
check_auth(&headers, &state.admin_token)?;
validate_filename(&filename)?;
let file_path = state.data_dir.join("uploads").join(&filename);
let uploads_dir = state.data_dir.join("uploads");
let file_path = uploads_dir.join(&filename);
// Security check to prevent directory traversal
if file_path.parent() != Some(&state.data_dir.join("uploads")) {
return Err(AppError::BadRequest("Invalid filename".to_string()));
}
if file_path.exists() {
fs::remove_file(file_path).map_err(|e| {
let canonical_dir = uploads_dir.canonicalize().map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
})?;
if let Ok(canonical_file) = file_path.canonicalize() {
if !canonical_file.starts_with(&canonical_dir) {
warn!("Refused delete outside uploads dir: {}", filename);
return Err(AppError::BadRequest("Invalid filename".to_string()));
}
fs::remove_file(canonical_file).map_err(|e| {
error!("Delete error for file {}: {}", filename, e);
AppError::Internal("Delete error".to_string(), Some(e.to_string()))
})?;
@@ -82,57 +132,82 @@ pub async fn upload_file(
info!("Upload requested");
while let Ok(Some(field)) = multipart.next_field().await {
let file_name = match field.file_name() {
let original_name = match field.file_name() {
Some(name) => name.to_string(),
None => continue,
};
info!("Processing upload for: {}", file_name);
let slugified_name = slug::slugify(&file_name);
info!("Processing upload for: {}", original_name);
let extension = std::path::Path::new(&file_name)
let extension = std::path::Path::new(&original_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default();
let final_name = if !extension.is_empty() {
format!("{}.{}", slugified_name, extension)
} else {
slugified_name
};
if !ALLOWED_EXTS.contains(&extension.as_str()) {
warn!("Upload rejected: extension '{}' not allowed", extension);
return Err(AppError::BadRequest(format!(
"File type '.{}' not allowed",
extension
)));
}
let stem = std::path::Path::new(&original_name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("file");
let slugified = slug::slugify(stem);
let final_name = format!("{}.{}", slugified, extension);
let data = field.bytes().await.map_err(|e| {
error!("Failed to read multipart bytes: {}", e);
AppError::BadRequest(format!("Read error: {}", e))
})?;
if !check_mime_matches_ext(&data, &extension) {
warn!(
"Upload rejected: magic bytes don't match extension '{}'",
extension
);
return Err(AppError::BadRequest(
"File contents don't match extension".to_string(),
));
}
let uploads_dir = state.data_dir.join("uploads");
let file_path = uploads_dir.join(&final_name);
let target_path = uploads_dir.join(&final_name);
let final_path = if file_path.exists() && !query.replace.unwrap_or(false) {
let final_path = if target_path.exists() && !query.replace.unwrap_or(false) {
let timestamp = chrono::Utc::now().timestamp();
uploads_dir.join(format!("{}_{}", timestamp, final_name))
} else {
file_path
target_path
};
// Final containment check.
let canonical_dir = uploads_dir.canonicalize().map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
})?;
if let Some(parent) = final_path.parent() {
let canonical_parent = parent.canonicalize().map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
})?;
if canonical_parent != canonical_dir {
return Err(AppError::BadRequest("Invalid filename".to_string()));
}
}
let final_name_str = final_path
.file_name()
.unwrap()
.to_str()
.unwrap()
.and_then(|n| n.to_str())
.unwrap_or(&final_name)
.to_string();
let data = match field.bytes().await {
Ok(bytes) => bytes,
Err(e) => {
error!("Failed to read multipart bytes: {}", e);
return Err(AppError::BadRequest(format!("Read error: {}", e)));
}
};
if let Err(e) = fs::write(&final_path, &data) {
fs::write(&final_path, &data).map_err(|e| {
error!("Failed to write file to {:?}: {}", final_path, e);
return Err(AppError::Internal(
"Write error".to_string(),
Some(e.to_string()),
));
}
AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?;
info!("File uploaded successfully to {:?}", final_path);
return Ok(Json(UploadResponse {
+45 -9
View File
@@ -6,19 +6,22 @@ pub mod models;
use axum::{
Router,
extract::DefaultBodyLimit,
http::{HeaderValue, header},
routing::{delete, get, post},
};
use std::{env, fs, path::PathBuf, sync::Arc};
use tokio::sync::Mutex;
use tower_http::{
cors::{Any, CorsLayer},
cors::{AllowOrigin, CorsLayer},
services::ServeDir,
};
use tracing::{error, info};
use tracing::{error, info, warn};
#[derive(Clone)]
pub struct AppState {
pub admin_token: String,
pub data_dir: PathBuf,
pub cookie_secure: bool,
pub post_lock: Mutex<()>,
}
#[tokio::main]
@@ -27,13 +30,23 @@ async fn main() {
dotenvy::dotenv().ok();
let port = env::var("PORT").unwrap_or_else(|_| "3000".to_string());
let admin_token = env::var("ADMIN_TOKEN").unwrap_or_else(|_| "secret".to_string());
let admin_token = env::var("ADMIN_TOKEN")
.ok()
.filter(|t| !t.trim().is_empty())
.expect("ADMIN_TOKEN must be set to a non-empty value");
if admin_token.len() < 16 {
warn!(
"ADMIN_TOKEN is shorter than 16 characters. Use a long random string in production."
);
}
let data_dir_str = env::var("DATA_DIR").unwrap_or_else(|_| "../data".to_string());
let data_dir = PathBuf::from(data_dir_str);
let cookie_secure = env::var("COOKIE_SECURE")
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
info!("Initializing backend with data dir: {:?}", data_dir);
// Ensure directories exist
let posts_dir = data_dir.join("posts");
let uploads_dir = data_dir.join("uploads");
if let Err(e) = fs::create_dir_all(&posts_dir) {
@@ -46,14 +59,36 @@ async fn main() {
let state = Arc::new(AppState {
admin_token,
data_dir,
cookie_secure,
post_lock: Mutex::new(()),
});
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
// CORS — locked down by default. Set FRONTEND_ORIGIN to the public URL of
// the frontend if you ever expose the backend directly to browsers.
// Normal deployments hit the backend through the Astro proxy, which is
// server-to-server and not subject to CORS.
let cors = match env::var("FRONTEND_ORIGIN").ok().filter(|s| !s.is_empty()) {
Some(origin) => {
let value = HeaderValue::from_str(&origin)
.expect("FRONTEND_ORIGIN must be a valid origin URL");
CorsLayer::new()
.allow_origin(AllowOrigin::exact(value))
.allow_methods([
axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::DELETE,
axum::http::Method::OPTIONS,
])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
.allow_credentials(true)
}
None => CorsLayer::new(),
};
let app = Router::new()
.route("/api/auth/login", post(handlers::auth::login))
.route("/api/auth/logout", post(handlers::auth::logout))
.route("/api/auth/me", get(handlers::auth::me))
.route(
"/api/config",
get(handlers::config::get_config).post(handlers::config::update_config),
@@ -72,6 +107,7 @@ async fn main() {
delete(handlers::upload::delete_upload),
)
.route("/api/upload", post(handlers::upload::upload_file))
.route("/healthz", get(|| async { "ok" }))
.nest_service("/uploads", ServeDir::new(uploads_dir))
.layer(DefaultBodyLimit::max(50 * 1024 * 1024))
.layer(cors)
+31 -2
View File
@@ -1,3 +1,4 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
@@ -28,32 +29,60 @@ impl Default for SiteConfig {
}
}
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct PostMeta {
pub date: NaiveDate,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default)]
pub draft: bool,
}
#[derive(Serialize)]
pub struct PostInfo {
pub slug: String,
pub date: NaiveDate,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
pub tags: Vec<String>,
pub draft: bool,
pub reading_time: u32,
pub excerpt: String,
}
#[derive(Serialize)]
pub struct PostDetail {
pub slug: String,
pub date: NaiveDate,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
pub tags: Vec<String>,
pub draft: bool,
pub reading_time: u32,
pub content: String,
}
#[derive(Deserialize)]
pub struct CreatePostRequest {
pub slug: String,
#[serde(default)]
pub old_slug: Option<String>,
#[serde(default)]
pub date: Option<NaiveDate>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub draft: bool,
pub content: String,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
#[derive(Serialize)]