diff --git a/Cargo.lock b/Cargo.lock index 2b8aed1..b5d700b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 06343bd..070b26c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/handlers/public.rs b/src/handlers/public.rs index 5d6f247..491a40f 100644 --- a/src/handlers/public.rs +++ b/src/handlers/public.rs @@ -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, } #[derive(Template)] @@ -24,6 +26,7 @@ pub struct IndexTemplate { pub struct PostTemplate { pub post: BlogPost, pub author_username: String, + pub user: Option, } pub fn router() -> Router> { @@ -34,45 +37,58 @@ pub fn router() -> Router> { pub async fn index( State(state): State>, + cookie_jar: CookieJar, ) -> Result { - // 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() - .map(|row| { - let post = BlogPost { - id: row.id, - author_id: row.author_id, - title: row.title, - content: row.content, - tags: row.tags, - categories: row.categories, - visibility: row.visibility, - password: row.password, - created_at: row.created_at, - updated_at: row.updated_at, - }; - (post, row.author_username) - }) - .collect(); + .context("Failed to fetch posts from database")?; - Ok(HtmlTemplate(IndexTemplate { posts }).into_response()) + let posts = posts.into_iter() + .map(|row| { + let post = BlogPost { + id: row.id, + author_id: row.author_id, + title: row.title, + content: row.content, + tags: row.tags, + categories: row.categories, + visibility: row.visibility, + password: row.password, + created_at: row.created_at, + updated_at: row.updated_at, + }; + (post, row.author_username) + }) + .collect(); + + Ok(HtmlTemplate(IndexTemplate { posts, user }).into_response()) } pub async fn view_post( State(state): State>, + cookie_jar: CookieJar, Path(id): Path, ) -> Result { + 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 } } diff --git a/src/models.rs b/src/models.rs index b53d8c6..25f23e4 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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)] diff --git a/src/utils.rs b/src/utils.rs index 3b384c2..bdf4baf 100644 --- a/src/utils.rs +++ b/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(pub T); @@ -21,3 +28,129 @@ where } } } + +static RE_CODE_BLOCK: LazyLock = + LazyLock::new(|| Regex::new(r"(?s)```(?:\w+)?\n?(.*?)```").unwrap()); +static RE_CODE_INLINE: LazyLock = LazyLock::new(|| Regex::new(r"`([^`]+)`").unwrap()); +static RE_BOLD_STAR: LazyLock = LazyLock::new(|| Regex::new(r"\*\*(.*?)\*\*").unwrap()); +static RE_BOLD_UNDERSCORE: LazyLock = LazyLock::new(|| Regex::new(r"__(.*?)__").unwrap()); +static RE_ITALIC_STAR: LazyLock = LazyLock::new(|| Regex::new(r"\*(.*?)\*").unwrap()); +static RE_ITALIC_UNDERSCORE: LazyLock = LazyLock::new(|| Regex::new(r"_(.*?)_").unwrap()); +static RE_IMAGE: LazyLock = 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
{}
\n", content) + }) + .to_string(); + + // 3. Inline Code (single backticks) + escaped = RE_CODE_INLINE + .replace_all(&escaped, "$1") + .to_string(); + + // 4. Bold + escaped = RE_BOLD_STAR + .replace_all(&escaped, "$1") + .to_string(); + escaped = RE_BOLD_UNDERSCORE + .replace_all(&escaped, "$1") + .to_string(); + + // 5. Italic + escaped = RE_ITALIC_STAR + .replace_all(&escaped, "$1") + .to_string(); + escaped = RE_ITALIC_UNDERSCORE + .replace_all(&escaped, "$1") + .to_string(); + + // 6. Image tags (![alt](url)) + escaped = RE_IMAGE + .replace_all(&escaped, r#"$1"#) + .to_string(); + + // 7. Handle newlines + // First, convert all newlines to
+ let mut with_br = escaped.replace('\n', "
"); + + // Then, remove ALL
that are immediately adjacent to block-level
 tags
+    // to avoid extra spacing reported by the user.
+    while with_br.contains("
") {
+        with_br = with_br.replace("
", "
");
+    }
+    while with_br.contains("

") { + with_br = with_br.replace("

", "
"); + } + + with_br +} + +pub async fn get_optional_user( + cookie_jar: &CookieJar, + state: &Arc, +) -> Option { + 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, 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 = 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("world")); + assert!(output.contains("italic")); + assert!(output.contains("inline code")); + // Fixed: No extra
adjacent to code block + assert!(output.contains("
fn main() {}
")); + assert!(!output.contains("
"));
+        assert!(!output.contains("

")); + assert!(output.contains(r#"alt"#)); + // The image tag is next to the pre block (no br between
and img) + assert!(output.contains("bold")); + } +} diff --git a/templates/index.html b/templates/index.html index 365cace..cc6d98e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -12,17 +12,30 @@ +
+ {% if let Some(u) = user %} + Logged in as: {{ u.username }} |
+ +
+ {% else %} + Not logged in | Login + {% endif %} +

blog

Welcome to my corner of the web.


-

Public Posts

+

Posts

{% if posts.is_empty() %} -

No public posts available yet.

+

No posts available yet.

{% else %} {% for (post, author) in posts %}

- {{ post.title }}
+ {{ post.title }} + {% if post.visibility == "private" %} + PRIVATE + {% endif %} +
Posted by {{ author }} on {{ post.formatted_created_at() }}
{% if !post.categories.is_empty() %} Categories: {{ post.categories }}
diff --git a/templates/post.html b/templates/post.html index fc0cd16..a21c32d 100644 --- a/templates/post.html +++ b/templates/post.html @@ -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; + } +

+ {% if let Some(u) = user %} + Logged in as: {{ u.username }} |
+ +
+ {% else %} + Not logged in | Login + {% endif %} +

[Back to Home]


@@ -28,15 +59,12 @@ {% if post.visibility == "password_protected" %}

[This post is password protected. In a full implementation, you'd enter a password here.]


- {% else if post.visibility == "private" %} + {% else if post.visibility == "private" && user.is_none() %}

[This post is private and only visible to authorized users.]


{% else %}
- -

-

{{ post.content|safe }}
-

+ {{ post.rendered_content()|safe }}
{% endif %}