feat: Implement basic markdown rendering for post content and user-based visibility for private posts.

This commit is contained in:
2026-03-03 18:19:42 +00:00
parent 82f7e006cf
commit 347ac8af55
7 changed files with 247 additions and 33 deletions

13
Cargo.lock generated
View File

@@ -361,6 +361,7 @@ dependencies = [
"image",
"lofty",
"rand 0.10.0",
"regex",
"serde",
"sqlx",
"tokio",
@@ -2062,6 +2063,18 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"

View File

@@ -23,3 +23,4 @@ tower-http = { version = "0.6.8", features = ["trace"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
uuid = { version = "1.21.0", features = ["v4"] }
regex = "1.11.1"

View File

@@ -7,16 +7,18 @@ use axum::{
};
use std::sync::Arc;
use crate::models::BlogPost;
use crate::utils::HtmlTemplate;
use crate::models::{BlogPost, User};
use crate::utils::{HtmlTemplate, get_optional_user};
use crate::AppState;
use crate::error::AppError;
use anyhow::Context;
use axum_extra::extract::cookie::CookieJar;
#[derive(Template)]
#[template(path = "index.html")]
pub struct IndexTemplate {
pub posts: Vec<(BlogPost, String)>, // Post and Author Username
pub user: Option<User>,
}
#[derive(Template)]
@@ -24,6 +26,7 @@ pub struct IndexTemplate {
pub struct PostTemplate {
pub post: BlogPost,
pub author_username: String,
pub user: Option<User>,
}
pub fn router() -> Router<Arc<AppState>> {
@@ -34,21 +37,30 @@ pub fn router() -> Router<Arc<AppState>> {
pub async fn index(
State(state): State<Arc<AppState>>,
cookie_jar: CookieJar,
) -> Result<Response, AppError> {
// Fetch public posts and their author usernames
let user = get_optional_user(&cookie_jar, &state).await;
let is_logged_in = user.is_some();
let is_logged_in_val = if is_logged_in { 1 } else { 0 };
// Fetch posts and their author usernames
// Logged in users see public and private posts, guests only see public posts
let posts = sqlx::query!(
r#"
SELECT p.*, u.username as author_username
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.visibility = 'public'
WHERE p.visibility = 'public' OR (? = 1 AND p.visibility = 'private')
ORDER BY p.created_at DESC
"#
"#,
is_logged_in_val
)
.fetch_all(&state.db)
.await
.context("Failed to fetch public posts from database")?
.into_iter()
.context("Failed to fetch posts from database")?;
let posts = posts.into_iter()
.map(|row| {
let post = BlogPost {
id: row.id,
@@ -66,13 +78,17 @@ pub async fn index(
})
.collect();
Ok(HtmlTemplate(IndexTemplate { posts }).into_response())
Ok(HtmlTemplate(IndexTemplate { posts, user }).into_response())
}
pub async fn view_post(
State(state): State<Arc<AppState>>,
cookie_jar: CookieJar,
Path(id): Path<i64>,
) -> Result<Response, AppError> {
let user = get_optional_user(&cookie_jar, &state).await;
let is_logged_in = user.is_some();
// Fetch individual post and author username regardless of visibility
let post_result = sqlx::query!(
r#"
@@ -103,7 +119,13 @@ pub async fn view_post(
});
match post_result {
Some((post, author_username)) => Ok(HtmlTemplate(PostTemplate { post, author_username }).into_response()),
Some((post, author_username)) => {
// Check visibility
if post.visibility() == crate::models::Visibility::Private && !is_logged_in {
return Err(AppError(anyhow::anyhow!("You must be logged in to view this private post")));
}
Ok(HtmlTemplate(PostTemplate { post, author_username, user }).into_response())
},
None => Err(AppError(anyhow::anyhow!("Post not found"))), // In a real app we'd want a 404 page
}
}

View File

@@ -106,6 +106,10 @@ impl BlogPost {
let datetime = DateTime::from_timestamp(self.updated_at, 0).unwrap_or_default();
datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string()
}
pub fn rendered_content(&self) -> String {
crate::utils::render_markdown(&self.content)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]

View File

@@ -1,8 +1,15 @@
use crate::models::User;
use crate::AppState;
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use axum_extra::extract::cookie::CookieJar;
use regex::Regex;
use std::sync::Arc;
use std::sync::LazyLock;
use std::time::{SystemTime, UNIX_EPOCH};
pub struct HtmlTemplate<T>(pub T);
@@ -21,3 +28,129 @@ where
}
}
}
static RE_CODE_BLOCK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)```(?:\w+)?\n?(.*?)```").unwrap());
static RE_CODE_INLINE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`([^`]+)`").unwrap());
static RE_BOLD_STAR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*(.*?)\*\*").unwrap());
static RE_BOLD_UNDERSCORE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"__(.*?)__").unwrap());
static RE_ITALIC_STAR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*(.*?)\*").unwrap());
static RE_ITALIC_UNDERSCORE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"_(.*?)_").unwrap());
static RE_IMAGE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!\[(.*?)\]\((.*?)\)").unwrap());
pub fn render_markdown(text: &str) -> String {
// 1. Escape HTML to prevent XSS
let mut escaped = text
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;");
// 2. Code Blocks (triple backticks) - Processed before inline styles
// We trim the content to remove extra blank lines at top/bottom.
escaped = RE_CODE_BLOCK
.replace_all(&escaped, |caps: &regex::Captures| {
let content = caps.get(1).map_or("", |m| m.as_str()).trim();
format!("\n<pre><code>{}</code></pre>\n", content)
})
.to_string();
// 3. Inline Code (single backticks)
escaped = RE_CODE_INLINE
.replace_all(&escaped, "<code>$1</code>")
.to_string();
// 4. Bold
escaped = RE_BOLD_STAR
.replace_all(&escaped, "<strong>$1</strong>")
.to_string();
escaped = RE_BOLD_UNDERSCORE
.replace_all(&escaped, "<strong>$1</strong>")
.to_string();
// 5. Italic
escaped = RE_ITALIC_STAR
.replace_all(&escaped, "<em>$1</em>")
.to_string();
escaped = RE_ITALIC_UNDERSCORE
.replace_all(&escaped, "<em>$1</em>")
.to_string();
// 6. Image tags (![alt](url))
escaped = RE_IMAGE
.replace_all(&escaped, r#"<img src="$2" alt="$1">"#)
.to_string();
// 7. Handle newlines
// First, convert all newlines to <br>
let mut with_br = escaped.replace('\n', "<br>");
// Then, remove ALL <br> that are immediately adjacent to block-level <pre> tags
// to avoid extra spacing reported by the user.
while with_br.contains("<br><pre>") {
with_br = with_br.replace("<br><pre>", "<pre>");
}
while with_br.contains("</pre><br>") {
with_br = with_br.replace("</pre><br>", "</pre>");
}
with_br
}
pub async fn get_optional_user(
cookie_jar: &CookieJar,
state: &Arc<AppState>,
) -> Option<User> {
let session_cookie = cookie_jar.get("dungeon_session")?;
let session_id = session_cookie.value();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()?
.as_secs() as i64;
let session_result: Result<Option<crate::models::Session>, sqlx::Error> = sqlx::query_as("SELECT * FROM sessions WHERE id = ? AND expires_at > ?")
.bind(session_id)
.bind(now)
.fetch_optional(&state.db)
.await;
let session = session_result.ok()??;
let user: Option<User> = sqlx::query_as("SELECT * FROM users WHERE id = ?")
.bind(session.user_id)
.fetch_optional(&state.db)
.await
.ok()?;
user
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_markdown() {
let input = "Hello **world** and _italic_ text. `inline code`. \n```rust\nfn main() {}\n```\n![alt](http://example.com/img.png)";
let output = render_markdown(input);
assert!(output.contains("<strong>world</strong>"));
assert!(output.contains("<em>italic</em>"));
assert!(output.contains("<code>inline code</code>"));
// Fixed: No extra <br> adjacent to code block
assert!(output.contains("<pre><code>fn main() {}</code></pre>"));
assert!(!output.contains("<br><pre>"));
assert!(!output.contains("</pre><br>"));
assert!(output.contains(r#"<img src="http://example.com/img.png" alt="alt">"#));
// The image tag is next to the pre block (no br between </pre> and img)
assert!(output.contains("</pre><img"));
}
#[test]
fn test_bold_underscore() {
let input = "This is __bold__";
let output = render_markdown(input);
assert!(output.contains("<strong>bold</strong>"));
}
}

View File

@@ -12,17 +12,30 @@
</head>
<body bgcolor="#FFFFFF" text="#000000" link="#0000EE" vlink="#551A8B" alink="#FF0000">
<div style="text-align: right;">
{% if let Some(u) = user %}
Logged in as: <b>{{ u.username }}</b> | <form action="/__dungeon/logout" method="post" style="display: inline;">
<input type="submit" value="Logout">
</form>
{% else %}
Not logged in | <a href="/__dungeon/login">Login</a>
{% endif %}
</div>
<h1>blog</h1>
<p>Welcome to my corner of the web.</p>
<hr size="1">
<h2>Public Posts</h2>
<h2>Posts</h2>
{% if posts.is_empty() %}
<p>No public posts available yet.</p>
<p>No posts available yet.</p>
{% else %}
{% for (post, author) in posts %}
<p>
<b><a href="/post/{{ post.id }}">{{ post.title }}</a></b><br>
<b><a href="/post/{{ post.id }}">{{ post.title }}</a></b>
{% if post.visibility == "private" %}
<span style="background-color: #ffff00; color: #000000; padding: 0 4px; font-size: 0.8em;">PRIVATE</span>
{% endif %}
<br>
<i>Posted by {{ author }} on {{ post.formatted_created_at() }}</i><br>
{% if !post.categories.is_empty() %}
Categories: {{ post.categories }}<br>

View File

@@ -8,10 +8,41 @@
* {
font-family: monospace !important;
}
code {
background-color: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
}
pre {
background-color: #f8f8f8;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
overflow-x: auto;
display: block;
margin: 1em 0;
}
pre code {
background-color: transparent;
padding: 0;
border-radius: 0;
}
</style>
</head>
<body bgcolor="#FFFFFF" text="#000000" link="#0000EE" vlink="#551A8B" alink="#FF0000">
<div style="text-align: right;">
{% if let Some(u) = user %}
Logged in as: <b>{{ u.username }}</b> | <form action="/__dungeon/logout" method="post" style="display: inline;">
<input type="submit" value="Logout">
</form>
{% else %}
Not logged in | <a href="/__dungeon/login">Login</a>
{% endif %}
</div>
<p><a href="/">[Back to Home]</a></p>
<hr size="1">
@@ -28,15 +59,12 @@
{% if post.visibility == "password_protected" %}
<p><b>[This post is password protected. In a full implementation, you'd enter a password here.]</b></p>
<hr size="1">
{% else if post.visibility == "private" %}
{% else if post.visibility == "private" && user.is_none() %}
<p><b>[This post is private and only visible to authorized users.]</b></p>
<hr size="1">
{% else %}
<hr size="1">
<!-- Assuming content is plain text with newlines -->
<p>
<pre>{{ post.content|safe }}</pre>
</p>
{{ post.rendered_content()|safe }}
<hr size="1">
{% endif %}