feat: Implement basic markdown rendering for post content and user-based visibility for private posts.
This commit is contained in:
13
Cargo.lock
generated
13
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
133
src/utils.rs
133
src/utils.rs
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'");
|
||||
|
||||
// 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: ®ex::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 ()
|
||||
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";
|
||||
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>"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user