diff --git a/.gitignore b/.gitignore index e4cd66a..bf98c36 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ *.db *.db-shm *.db-wal + +attachments \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index b5d700b..8b94b20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,6 +364,7 @@ dependencies = [ "regex", "serde", "sqlx", + "time", "tokio", "tokio-util", "tower-http", diff --git a/Cargo.toml b/Cargo.toml index 070b26c..7c44fa0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,4 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } uuid = { version = "1.21.0", features = ["v4"] } regex = "1.11.1" +time = { version = "0.3.47", features = ["macros"] } diff --git a/src/handlers/attachments.rs b/src/handlers/attachments.rs index 3e898ea..268c454 100644 --- a/src/handlers/attachments.rs +++ b/src/handlers/attachments.rs @@ -3,7 +3,7 @@ use axum::{ http::{header, StatusCode}, response::{IntoResponse, Redirect, Response}, routing::{get, post}, - Router, Error, + Router, }; use std::sync::Arc; use std::path::PathBuf; diff --git a/src/handlers/public.rs b/src/handlers/public.rs index 491a40f..a2d1f2c 100644 --- a/src/handlers/public.rs +++ b/src/handlers/public.rs @@ -1,9 +1,10 @@ use askama::Template; use axum::{ extract::{Path, State}, - response::{IntoResponse, Response}, - routing::get, + response::{IntoResponse, Response, Redirect}, + routing::{get, post}, Router, + Form, }; use std::sync::Arc; @@ -12,7 +13,7 @@ use crate::utils::{HtmlTemplate, get_optional_user}; use crate::AppState; use crate::error::AppError; use anyhow::Context; -use axum_extra::extract::cookie::CookieJar; +use axum_extra::extract::cookie::{CookieJar, Cookie}; #[derive(Template)] #[template(path = "index.html")] @@ -27,12 +28,15 @@ pub struct PostTemplate { pub post: BlogPost, pub author_username: String, pub user: Option, + pub unlocked: bool, } pub fn router() -> Router> { Router::new() .route("/", get(index)) .route("/post/{id}", get(view_post)) + .route("/post/{id}/unlock", post(unlock_post)) + .route("/post/{id}/lock", post(lock_post)) } pub async fn index( @@ -124,8 +128,74 @@ pub async fn view_post( 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()) + + let mut unlocked = true; + if post.visibility() == crate::models::Visibility::PasswordProtected { + unlocked = false; + if let Some(cookie) = cookie_jar.get(&format!("unlocked_post_{}", post.id)) { + if cookie.value() == "true" { + unlocked = true; + } + } + } + + Ok(HtmlTemplate(PostTemplate { post, author_username, user, unlocked }).into_response()) }, None => Err(AppError(anyhow::anyhow!("Post not found"))), // In a real app we'd want a 404 page } } + +#[derive(serde::Deserialize)] +pub struct UnlockForm { + pub password: String, +} + +pub async fn unlock_post( + State(state): State>, + cookie_jar: CookieJar, + Path(id): Path, + Form(form): Form, +) -> Result<(CookieJar, Response), AppError> { + let post = sqlx::query!( + "SELECT password FROM posts WHERE id = ?", + id + ) + .fetch_optional(&state.db) + .await + .context("Failed to fetch post from database")?; + + match post { + Some(row) => { + if let Some(stored_hash) = row.password { + if bcrypt::verify(&form.password, &stored_hash).unwrap_or(false) { + let cookie = Cookie::build((format!("unlocked_post_{}", id), "true")) + .path("/") + .http_only(true) + .permanent(); + + return Ok(( + cookie_jar.add(cookie), + Redirect::to(&format!("/post/{}", id)).into_response() + )); + } + } + Err(AppError(anyhow::anyhow!("Incorrect password"))) + }, + None => Err(AppError(anyhow::anyhow!("Post not found"))), + } +} + +pub async fn lock_post( + cookie_jar: CookieJar, + Path(id): Path, +) -> (CookieJar, Redirect) { + let cookie = Cookie::build((format!("unlocked_post_{}", id), "")) + .path("/") + .http_only(true) + .max_age(time::Duration::ZERO); + + ( + cookie_jar.add(cookie), + Redirect::to(&format!("/post/{}", id)) + ) +} diff --git a/src/utils.rs b/src/utils.rs index bdf4baf..bd9d40e 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -32,10 +32,10 @@ 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_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 { @@ -52,16 +52,35 @@ pub fn render_markdown(text: &str) -> String { 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) + // Protect content inside code from subsequent markdown passes + let safe = content.replace('_', "_").replace('*', "*"); + format!("\n
{}
\n", safe) }) .to_string(); // 3. Inline Code (single backticks) escaped = RE_CODE_INLINE - .replace_all(&escaped, "$1") + .replace_all(&escaped, |caps: ®ex::Captures| { + let content = caps.get(1).map_or("", |m| m.as_str()); + // Protect content inside code from subsequent markdown passes + let safe = content.replace('_', "_").replace('*', "*"); + format!("{}", safe) + }) .to_string(); - // 4. Bold + // 4. Image tags (![alt](url)) - Processed before bold/italic to avoid mangling URLs + escaped = RE_IMAGE + .replace_all(&escaped, |caps: ®ex::Captures| { + let alt = caps.get(1).map_or("", |m| m.as_str()); + let url = caps.get(2).map_or("", |m| m.as_str()); + // Protect underscores and stars in URL and alt text from subsequent passes + let safe_url = url.replace('_', "_").replace('*', "*"); + let safe_alt = alt.replace('_', "_").replace('*', "*"); + format!(r#"{}"#, safe_url, safe_alt) + }) + .to_string(); + + // 5. Bold escaped = RE_BOLD_STAR .replace_all(&escaped, "$1") .to_string(); @@ -69,7 +88,7 @@ pub fn render_markdown(text: &str) -> String { .replace_all(&escaped, "$1") .to_string(); - // 5. Italic + // 6. Italic escaped = RE_ITALIC_STAR .replace_all(&escaped, "$1") .to_string(); @@ -77,11 +96,6 @@ pub fn render_markdown(text: &str) -> String { .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', "
"); @@ -153,4 +167,14 @@ mod tests { let output = render_markdown(input); assert!(output.contains("bold")); } + + #[test] + fn test_image_with_underscores() { + let input = "![alt](/__attachments/7gWzt7F7Sr32llbHWebWPHvA4z5v7ZvS)"; + let output = render_markdown(input); + // The path should be protected from markdown mangling (using HTML entities for safety) + assert!(output.contains(r#"src="/__attachments/7gWzt7F7Sr32llbHWebWPHvA4z5v7ZvS""#)); + assert!(output.contains(r#"alt="alt""#)); + assert!(!output.contains("")); + } } diff --git a/templates/base.html b/templates/base.html index 1124b2a..7bb0cdb 100644 --- a/templates/base.html +++ b/templates/base.html @@ -8,6 +8,14 @@ * { font-family: monospace !important; } + + body { + max-width: 780px; + } + + img { + max-width: 100%; + } diff --git a/templates/index.html b/templates/index.html index cc6d98e..a7a7819 100644 --- a/templates/index.html +++ b/templates/index.html @@ -8,6 +8,14 @@ * { font-family: monospace !important; } + + body { + max-width: 780px; + } + + img { + max-width: 100%; + } diff --git a/templates/post.html b/templates/post.html index a21c32d..becb698 100644 --- a/templates/post.html +++ b/templates/post.html @@ -9,6 +9,14 @@ font-family: monospace !important; } + body { + max-width: 780px; + } + + img { + max-width: 100%; + } + code { background-color: #f0f0f0; padding: 2px 4px; @@ -56,14 +64,25 @@

Tags: {{ post.tags }}

{% endif %} - {% if post.visibility == "password_protected" %} -

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

+ {% if post.visibility == "password_protected" && !unlocked %} +

This post is password protected. Please enter the password to view it:

+
+ + +

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

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


{% else %}
+ {% if post.visibility == "password_protected" && unlocked %} +
+
+ +
+
+ {% endif %} {{ post.rendered_content()|safe }}
{% endif %}