From ef068f7dfaecda53cd5b6329175977d745eb6224 Mon Sep 17 00:00:00 2001 From: 51l3nt51n <51l3nt51n@proton.me> Date: Tue, 3 Mar 2026 16:34:38 +0000 Subject: [PATCH] feat: Implement blog post management including database schema, models, handlers, and UI. --- src/handlers/mod.rs | 3 + src/handlers/posts.rs | 228 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 22 ++++ src/models.rs | 49 ++++++++ templates/dashboard.html | 6 + templates/post_edit.html | 65 +++++++++++ templates/posts_list.html | 42 +++++++ 7 files changed, 415 insertions(+) create mode 100644 src/handlers/posts.rs create mode 100644 templates/post_edit.html create mode 100644 templates/posts_list.html diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index f8f0088..0b55285 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -4,6 +4,7 @@ use std::sync::Arc; pub mod admin; pub mod auth; +pub mod posts; pub fn router(state: &Arc) -> Router> { Router::new() @@ -11,6 +12,8 @@ pub fn router(state: &Arc) -> Router> { .merge(admin::router()) // Auth routes under /__dungeon .merge(auth::router()) + // Posts routes under /__dungeon/posts + .nest("/posts", posts::router()) // Apply middleware to all /__dungeon routes .route_layer(middleware::from_fn_with_state( state.clone(), diff --git a/src/handlers/posts.rs b/src/handlers/posts.rs new file mode 100644 index 0000000..10f755d --- /dev/null +++ b/src/handlers/posts.rs @@ -0,0 +1,228 @@ +use askama::Template; +use axum::{ + extract::{Extension, Form, Path, State}, + response::{IntoResponse, Redirect, Response}, + routing::{get, post}, + Router, +}; +use serde::Deserialize; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::models::{BlogPost, User}; + +use crate::utils::HtmlTemplate; +use crate::AppState; +use crate::error::AppError; +use anyhow::Context; + +#[derive(Template)] +#[template(path = "posts_list.html")] +pub struct PostsListTemplate<'a> { + pub current_user: &'a User, + pub posts: Vec, + pub error: Option<&'a str>, +} + +#[derive(Template)] +#[template(path = "post_edit.html")] +pub struct PostEditTemplate<'a> { + pub current_user: &'a User, + pub post: Option<&'a BlogPost>, + pub error: Option<&'a str>, +} + +pub fn router() -> Router> { + Router::new() + .route("/", get(list_posts)) + .route("/new", get(new_post).post(create_post)) + .route("/edit/{id}", get(edit_post).post(update_post)) + .route("/delete/{id}", post(delete_post)) +} + +fn get_current_time() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64 +} + +pub async fn list_posts( + State(state): State>, + Extension(current_user): Extension, +) -> Result { + render_posts_list(&state, ¤t_user, None).await +} + +async fn render_posts_list( + state: &AppState, + current_user: &User, + error: Option<&str>, +) -> Result { + let posts: Vec = sqlx::query_as("SELECT * FROM posts ORDER BY created_at DESC") + .fetch_all(&state.db) + .await + .context("Failed to fetch posts from database")?; + + Ok(HtmlTemplate(PostsListTemplate { + current_user, + posts, + error, + }) + .into_response()) +} + +pub async fn new_post( + Extension(current_user): Extension, +) -> Result { + Ok(HtmlTemplate(PostEditTemplate { + current_user: ¤t_user, + post: None, + error: None, + }) + .into_response()) +} + +#[derive(Deserialize)] +pub struct PostPayload { + pub title: String, + pub content: String, + pub tags: Option, + pub categories: Option, + pub visibility: String, + pub password: Option, +} + +pub async fn create_post( + State(state): State>, + Extension(current_user): Extension, + Form(payload): Form, +) -> Result { + let now = get_current_time(); + let tags = payload.tags.unwrap_or_default(); + let categories = payload.categories.unwrap_or_default(); + let password = if payload.visibility == "password_protected" { + payload.password.filter(|p| !p.is_empty()) + } else { + None + }; + + let hashed_password = match password { + Some(p) => Some(bcrypt::hash(p, bcrypt::DEFAULT_COST).context("Failed to hash password")?), + None => None, + }; + + sqlx::query( + r#" + INSERT INTO posts (author_id, title, content, tags, categories, visibility, password, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(current_user.id) + .bind(&payload.title) + .bind(&payload.content) + .bind(&tags) + .bind(&categories) + .bind(&payload.visibility) + .bind(&hashed_password) + .bind(now) + .bind(now) + .execute(&state.db) + .await + .context("Failed to insert new post into database")?; + + Ok(Redirect::to("/__dungeon/posts").into_response()) +} + +pub async fn edit_post( + State(state): State>, + Extension(current_user): Extension, + Path(id): Path, +) -> Result { + let post: Option = sqlx::query_as("SELECT * FROM posts WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await + .context("Failed to fetch post from database")?; + + match post { + Some(post) => Ok(HtmlTemplate(PostEditTemplate { + current_user: ¤t_user, + post: Some(&post), + error: None, + }) + .into_response()), + None => render_posts_list(&state, ¤t_user, Some("Post not found")).await, + } +} + +pub async fn update_post( + State(state): State>, + Extension(_current_user): Extension, + + Path(id): Path, + Form(payload): Form, +) -> Result { + let now = get_current_time(); + let tags = payload.tags.unwrap_or_default(); + let categories = payload.categories.unwrap_or_default(); + + let mut query_str = r#" + UPDATE posts + SET title = ?, content = ?, tags = ?, categories = ?, visibility = ?, updated_at = ? + "#.to_string(); + + let password = if payload.visibility == "password_protected" { + payload.password.filter(|p| !p.is_empty()) + } else { + None + }; + + let hashed_password = match password { + Some(p) => { + query_str.push_str(", password = ?"); + Some(bcrypt::hash(p, bcrypt::DEFAULT_COST).context("Failed to hash password")?) + }, + None => { + if payload.visibility != "password_protected" { + query_str.push_str(", password = NULL"); + } + None + } + }; + + query_str.push_str(" WHERE id = ?"); + + let mut query = sqlx::query(&query_str) + .bind(&payload.title) + .bind(&payload.content) + .bind(&tags) + .bind(&categories) + .bind(&payload.visibility) + .bind(now); + + if let Some(h) = hashed_password { + query = query.bind(h); + } + + query = query.bind(id); + + query.execute(&state.db) + .await + .context("Failed to update post in database")?; + + Ok(Redirect::to("/__dungeon/posts").into_response()) +} + +pub async fn delete_post( + State(state): State>, + Path(id): Path, +) -> Result { + sqlx::query("DELETE FROM posts WHERE id = ?") + .bind(id) + .execute(&state.db) + .await + .context(format!("Failed to delete post with id {}", id))?; + + Ok(Redirect::to("/__dungeon/posts").into_response()) +} diff --git a/src/main.rs b/src/main.rs index d3d1364..688e3de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -90,6 +90,28 @@ async fn main() { .await .expect("Failed to create sessions table"); + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author_id INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '', + categories TEXT NOT NULL DEFAULT '', + visibility TEXT NOT NULL DEFAULT 'public', + password TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(author_id) REFERENCES users(id) ON DELETE CASCADE + ); + "#, + ) + .execute(&db_pool) + .await + .expect("Failed to create posts table"); + + let app_state = Arc::new(AppState { db: db_pool }); let app = Router::new() diff --git a/src/models.rs b/src/models.rs index d005e08..44bd3d7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -47,3 +47,52 @@ pub struct Session { pub user_id: i64, pub expires_at: i64, // Unix timestamp in seconds } + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub enum Visibility { + Public, + Private, + PasswordProtected, +} + +impl ToString for Visibility { + fn to_string(&self) -> String { + match self { + Visibility::Public => "public".to_string(), + Visibility::Private => "private".to_string(), + Visibility::PasswordProtected => "password_protected".to_string(), + } + } +} + +impl std::str::FromStr for Visibility { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "public" => Ok(Visibility::Public), + "private" => Ok(Visibility::Private), + "password_protected" => Ok(Visibility::PasswordProtected), + _ => Err(()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct BlogPost { + pub id: i64, + pub author_id: i64, + pub title: String, + pub content: String, + pub tags: String, + pub categories: String, + pub visibility: String, + pub password: Option, + pub created_at: i64, + pub updated_at: i64, +} + +impl BlogPost { + pub fn visibility(&self) -> Visibility { + self.visibility.parse().unwrap_or(Visibility::Public) + } +} diff --git a/templates/dashboard.html b/templates/dashboard.html index bcbf2c7..d0d392a 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -9,6 +9,12 @@

+Features
+ +
+ System Users
{% if let Some(err) = error %} {{ err }}
diff --git a/templates/post_edit.html b/templates/post_edit.html new file mode 100644 index 0000000..d6cecd4 --- /dev/null +++ b/templates/post_edit.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %} +{% if post.is_some() %}Edit Post{% else %}New Post{% endif %} +{% endblock %} +{% block content %} + +

{% if post.is_some() %}Edit Post{% else %}New Post{% endif %}

+ +Back to Posts +

+ +{% if let Some(err) = error %} +{{ err }}

+{% endif %} + +{% if let Some(p) = post %} +
+ {% else %} + + {% endif %} + + Title:
+

+ + Content (Plain Text):
+

+ + Tags (comma separated):
+

+ + Categories (comma separated):
+

+ + Visibility:
+

+ + Password (only used if Visibility is Password Protected):
+ + {% if let Some(p) = post %} + {% if p.visibility == "password_protected" %} + (Leave blank to keep existing password) + {% endif %} + {% endif %} +

+ + +
+ + {% endblock %} \ No newline at end of file diff --git a/templates/posts_list.html b/templates/posts_list.html new file mode 100644 index 0000000..c3c4fd8 --- /dev/null +++ b/templates/posts_list.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}Manage Blog Posts{% endblock %} +{% block content %} + +

Manage Blog Posts

+ +Back to Dashboard | Create New Post +

+ +{% if let Some(err) = error %} +{{ err }}

+{% endif %} + +{% if posts.is_empty() %} +No posts found. +{% else %} + + + + + + + + + {% for post in posts %} + + + + + + + + {% endfor %} +
IDTitleVisibilityCreated AtActions
{{ post.id }}{{ post.title }}{{ post.visibility }}{{ post.created_at }} + Edit | +
+ +
+
+{% endif %} + +{% endblock %} \ No newline at end of file