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 ()
+ escaped = RE_IMAGE
+ .replace_all(&escaped, r#"
"#)
+ .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 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 %}