feat: Add attachment management with upload, deletion, serving, and media metadata stripping.
This commit is contained in:
803
Cargo.lock
generated
803
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,16 +6,19 @@ edition = "2024"
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
askama = "0.15.4"
|
||||
axum = "0.8.8"
|
||||
axum = { version = "0.8.8", features = ["multipart"] }
|
||||
axum-extra = { version = "0.12.5", features = ["cookie"] }
|
||||
base64 = "0.22.1"
|
||||
bcrypt = "0.18.0"
|
||||
chrono = { version = "0.4.44", features = ["serde"] }
|
||||
dotenvy = "0.15.7"
|
||||
image = "0.25.5"
|
||||
lofty = "0.22.1"
|
||||
rand = "0.10.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] }
|
||||
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"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
|
||||
|
||||
184
src/handlers/attachments.rs
Normal file
184
src/handlers/attachments.rs
Normal 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: ¤t_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())
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use axum::{middleware, Router};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub mod admin;
|
||||
pub mod attachments;
|
||||
pub mod auth;
|
||||
pub mod posts;
|
||||
pub mod public;
|
||||
@@ -13,6 +14,8 @@ pub fn router(state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
.merge(admin::router())
|
||||
// Auth routes under /__dungeon
|
||||
.merge(auth::router())
|
||||
// Attachments routes under /__dungeon/attachments
|
||||
.nest("/attachments", attachments::router())
|
||||
// Posts routes under /__dungeon/posts
|
||||
.nest("/posts", posts::router())
|
||||
// Apply middleware to all /__dungeon routes
|
||||
|
||||
21
src/main.rs
21
src/main.rs
@@ -1,4 +1,4 @@
|
||||
use axum::Router;
|
||||
use axum::{routing::get, Router};
|
||||
use sqlx::{sqlite::{SqliteConnectOptions, SqlitePoolOptions}, SqlitePool};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -95,10 +95,29 @@ async fn main() {
|
||||
.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 = Router::new()
|
||||
.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`
|
||||
.with_state(app_state.clone())
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http());
|
||||
|
||||
@@ -107,3 +107,27 @@ impl BlogPost {
|
||||
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/")
|
||||
}
|
||||
}
|
||||
|
||||
47
templates/attachments.html
Normal file
47
templates/attachments.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Manage Attachments{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2>Manage Attachments</h2>
|
||||
|
||||
<a href="/__dungeon">< 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 %}
|
||||
@@ -12,6 +12,7 @@
|
||||
<b>Features</b><br>
|
||||
<ul>
|
||||
<li><a href="/__dungeon/posts">Manage Blog Posts</a></li>
|
||||
<li><a href="/__dungeon/attachments">Manage Attachments</a></li>
|
||||
</ul>
|
||||
<br>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user