feat: Add attachment management with upload, deletion, serving, and media metadata stripping.

This commit is contained in:
2026-03-03 17:24:45 +00:00
parent 57f2610164
commit 82f7e006cf
8 changed files with 1085 additions and 3 deletions

803
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,16 +6,19 @@ edition = "2024"
[dependencies] [dependencies]
anyhow = "1.0.102" anyhow = "1.0.102"
askama = "0.15.4" askama = "0.15.4"
axum = "0.8.8" axum = { version = "0.8.8", features = ["multipart"] }
axum-extra = { version = "0.12.5", features = ["cookie"] } axum-extra = { version = "0.12.5", features = ["cookie"] }
base64 = "0.22.1" base64 = "0.22.1"
bcrypt = "0.18.0" bcrypt = "0.18.0"
chrono = { version = "0.4.44", features = ["serde"] } chrono = { version = "0.4.44", features = ["serde"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
image = "0.25.5"
lofty = "0.22.1"
rand = "0.10.0" rand = "0.10.0"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] } sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] } tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] }
tokio-util = { version = "0.7.13", features = ["io"] }
tower-http = { version = "0.6.8", features = ["trace"] } tower-http = { version = "0.6.8", features = ["trace"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }

184
src/handlers/attachments.rs Normal file
View File

@@ -0,0 +1,184 @@
use axum::{
extract::{Multipart, Path, State},
http::{header, StatusCode},
response::{IntoResponse, Redirect, Response},
routing::{get, post},
Router, Error,
};
use std::sync::Arc;
use std::path::PathBuf;
use tokio::fs;
use tokio_util::io::ReaderStream;
use anyhow::{Context, Result};
use rand::distr::Alphanumeric;
use rand::RngExt;
use chrono::Utc;
use lofty::file::TaggedFileExt;
use lofty::prelude::*;
use std::io::Cursor;
use image::ImageReader;
use crate::models::{Attachment, User};
use crate::AppState;
use crate::error::AppError;
use crate::utils::HtmlTemplate;
use askama::Template;
#[derive(Template)]
#[template(path = "attachments.html")]
pub struct AttachmentsTemplate<'a> {
pub current_user: &'a User,
pub attachments: Vec<Attachment>,
pub error: Option<String>,
}
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(list_attachments))
.route("/upload", post(upload_attachment))
.route("/delete/{id}", post(delete_attachment))
}
pub async fn list_attachments(
State(state): State<Arc<AppState>>,
axum::Extension(current_user): axum::Extension<User>,
) -> Result<Response, AppError> {
let attachments: Vec<Attachment> = sqlx::query_as("SELECT * FROM attachments ORDER BY created_at DESC")
.fetch_all(&state.db)
.await
.context("Failed to fetch attachments")?;
Ok(HtmlTemplate(AttachmentsTemplate {
current_user: &current_user,
attachments,
error: None,
})
.into_response())
}
pub async fn upload_attachment(
State(state): State<Arc<AppState>>,
axum::Extension(_current_user): axum::Extension<User>,
mut multipart: Multipart,
) -> Result<Response, AppError> {
while let Some(field) = multipart.next_field().await.map_err(|e| AppError::from(anyhow::anyhow!(e)))? {
let filename = field.file_name().unwrap_or("unknown").to_string();
let content_type = field.content_type().unwrap_or("application/octet-stream").to_string();
let data = field.bytes().await.map_err(|e| AppError::from(anyhow::anyhow!(e)))?;
let id: String = rand::rng()
.sample_iter(&Alphanumeric)
.take(32)
.map(char::from)
.collect();
let processed_data = if content_type.starts_with("image/") {
process_image(&data, &content_type).await.unwrap_or(data.to_vec())
} else if content_type.starts_with("video/") {
process_video(&data).await.unwrap_or(data.to_vec())
} else {
data.to_vec()
};
let size = processed_data.len() as i64;
let file_path = PathBuf::from("attachments").join(&id);
fs::write(&file_path, &processed_data).await
.context("Failed to write attachment to disk")?;
sqlx::query("INSERT INTO attachments (id, filename, content_type, size, created_at) VALUES (?, ?, ?, ?, ?)")
.bind(&id)
.bind(&filename)
.bind(&content_type)
.bind(size)
.bind(Utc::now().timestamp())
.execute(&state.db)
.await
.context("Failed to insert attachment into database")?;
}
Ok(Redirect::to("/__dungeon/attachments").into_response())
}
pub async fn delete_attachment(
State(state): State<Arc<AppState>>,
axum::Extension(_current_user): axum::Extension<User>,
Path(id): Path<String>,
) -> Result<Response, AppError> {
// Delete from disk
let file_path = PathBuf::from("attachments").join(&id);
if file_path.exists() {
fs::remove_file(file_path).await.context("Failed to delete attachment from disk")?;
}
// Delete from database
sqlx::query("DELETE FROM attachments WHERE id = ?")
.bind(&id)
.execute(&state.db)
.await
.context("Failed to delete attachment from database")?;
Ok(Redirect::to("/__dungeon/attachments").into_response())
}
pub async fn serve_attachment(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Response, AppError> {
let attachment: Attachment = sqlx::query_as("SELECT * FROM attachments WHERE id = ?")
.bind(&id)
.fetch_optional(&state.db)
.await
.context("Failed to fetch attachment from database")?
.ok_or_else(|| AppError::from(anyhow::anyhow!("Attachment not found")))?;
let file_path = PathBuf::from("attachments").join(&id);
if !file_path.exists() {
return Ok((StatusCode::NOT_FOUND, "File not found").into_response());
}
let file = fs::File::open(file_path).await.context("Failed to open attachment file")?;
let stream = ReaderStream::new(file);
let body = axum::body::Body::from_stream(stream);
Ok(Response::builder()
.header(header::CONTENT_TYPE, attachment.content_type)
.header(header::CONTENT_DISPOSITION, format!("inline; filename=\"{}\"", attachment.filename))
.body(body)
.unwrap())
}
async fn process_image(data: &[u8], content_type: &str) -> Result<Vec<u8>> {
let format = match content_type {
"image/jpeg" | "image/jpg" => image::ImageFormat::Jpeg,
"image/png" => image::ImageFormat::Png,
"image/gif" => image::ImageFormat::Gif,
"image/webp" => image::ImageFormat::WebP,
_ => return Ok(data.to_vec()),
};
let img = ImageReader::new(Cursor::new(data))
.with_guessed_format()?
.decode()
.context("Failed to decode image")?;
let mut out = Cursor::new(Vec::new());
img.write_to(&mut out, format).context("Failed to re-encode image (stripping metadata)")?;
Ok(out.into_inner())
}
async fn process_video(data: &[u8]) -> Result<Vec<u8>> {
// Lofty can remove tags from various formats
let mut cursor = Cursor::new(data.to_vec());
if let Ok(probed) = lofty::probe::Probe::new(&mut cursor).guess_file_type() {
if let Ok(mut file) = probed.read() {
file.clear();
// We need to save it back. Lofty modify the file in place if it's a file but here we have a cursor.
let mut out = Cursor::new(Vec::new());
if file.save_to(&mut out, lofty::config::WriteOptions::default()).is_ok() {
return Ok(out.into_inner());
}
}
}
Ok(data.to_vec())
}

View File

@@ -3,6 +3,7 @@ use axum::{middleware, Router};
use std::sync::Arc; use std::sync::Arc;
pub mod admin; pub mod admin;
pub mod attachments;
pub mod auth; pub mod auth;
pub mod posts; pub mod posts;
pub mod public; pub mod public;
@@ -13,6 +14,8 @@ pub fn router(state: &Arc<AppState>) -> Router<Arc<AppState>> {
.merge(admin::router()) .merge(admin::router())
// Auth routes under /__dungeon // Auth routes under /__dungeon
.merge(auth::router()) .merge(auth::router())
// Attachments routes under /__dungeon/attachments
.nest("/attachments", attachments::router())
// Posts routes under /__dungeon/posts // Posts routes under /__dungeon/posts
.nest("/posts", posts::router()) .nest("/posts", posts::router())
// Apply middleware to all /__dungeon routes // Apply middleware to all /__dungeon routes

View File

@@ -1,4 +1,4 @@
use axum::Router; use axum::{routing::get, Router};
use sqlx::{sqlite::{SqliteConnectOptions, SqlitePoolOptions}, SqlitePool}; use sqlx::{sqlite::{SqliteConnectOptions, SqlitePoolOptions}, SqlitePool};
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
@@ -95,10 +95,29 @@ async fn main() {
.expect("Failed to create posts table"); .expect("Failed to create posts table");
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
"#,
)
.execute(&db_pool)
.await
.expect("Failed to create attachments table");
// Create attachments directory
std::fs::create_dir_all("attachments").expect("Failed to create attachments directory");
let app_state = Arc::new(AppState { db: db_pool }); let app_state = Arc::new(AppState { db: db_pool });
let app = Router::new() let app = Router::new()
.merge(handlers::public::router()) .merge(handlers::public::router())
.route("/__attachments/{id}", get(handlers::attachments::serve_attachment))
.nest("/__dungeon", handlers::router(&app_state)) // I'll create a single `handlers::router` .nest("/__dungeon", handlers::router(&app_state)) // I'll create a single `handlers::router`
.with_state(app_state.clone()) .with_state(app_state.clone())
.layer(tower_http::trace::TraceLayer::new_for_http()); .layer(tower_http::trace::TraceLayer::new_for_http());

View File

@@ -107,3 +107,27 @@ impl BlogPost {
datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string() datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string()
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Attachment {
pub id: String,
pub filename: String,
pub content_type: String,
pub size: i64,
pub created_at: i64,
}
impl Attachment {
pub fn formatted_created_at(&self) -> String {
let datetime = DateTime::from_timestamp(self.created_at, 0).unwrap_or_default();
datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string()
}
pub fn is_image(&self) -> bool {
self.content_type.starts_with("image/")
}
pub fn is_video(&self) -> bool {
self.content_type.starts_with("video/")
}
}

View File

@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Manage Attachments{% endblock %}
{% block content %}
<h2>Manage Attachments</h2>
<a href="/__dungeon">&lt; Back to Dashboard</a><br><br>
<b>Upload New Attachment</b><br>
<form method="POST" action="/__dungeon/attachments/upload" enctype="multipart/form-data">
File: <input type="file" name="file" required><br><br>
<input type="submit" value="Upload Attachment">
</form>
<br>
<b>Existing Attachments</b><br>
{% if attachments.is_empty() %}
<p>No attachments found.</p>
{% else %}
<table border="1" cellpadding="5" cellspacing="0">
<tr>
<th>ID</th>
<th>Filename</th>
<th>Type</th>
<th>Size</th>
<th>Created At</th>
<th>Actions</th>
</tr>
{% for att in attachments %}
<tr>
<td><code>{{ att.id }}</code></td>
<td><a href="/__attachments/{{ att.id }}" target="_blank">{{ att.filename }}</a></td>
<td>{{ att.content_type }}</td>
<td>{{ att.size }} bytes</td>
<td>{{ att.formatted_created_at() }}</td>
<td>
<form method="POST" action="/__dungeon/attachments/delete/{{ att.id }}" style="display:inline;"
onsubmit="return confirm('Are you sure you want to delete this attachment?');">
<input type="submit" value="Delete">
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock %}

View File

@@ -12,6 +12,7 @@
<b>Features</b><br> <b>Features</b><br>
<ul> <ul>
<li><a href="/__dungeon/posts">Manage Blog Posts</a></li> <li><a href="/__dungeon/posts">Manage Blog Posts</a></li>
<li><a href="/__dungeon/attachments">Manage Attachments</a></li>
</ul> </ul>
<br> <br>