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]
|
[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
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;
|
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
|
||||||
|
|||||||
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 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());
|
||||||
|
|||||||
@@ -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/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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>
|
<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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user