feat: Add password protection for posts, improve markdown rendering for code and images, and introduce basic responsive styling.

This commit is contained in:
2026-03-03 19:13:28 +00:00
parent 347ac8af55
commit bfcf45343f
9 changed files with 153 additions and 20 deletions

2
.gitignore vendored
View File

@@ -9,3 +9,5 @@
*.db *.db
*.db-shm *.db-shm
*.db-wal *.db-wal
attachments

1
Cargo.lock generated
View File

@@ -364,6 +364,7 @@ dependencies = [
"regex", "regex",
"serde", "serde",
"sqlx", "sqlx",
"time",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tower-http", "tower-http",

View File

@@ -24,3 +24,4 @@ tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
uuid = { version = "1.21.0", features = ["v4"] } uuid = { version = "1.21.0", features = ["v4"] }
regex = "1.11.1" regex = "1.11.1"
time = { version = "0.3.47", features = ["macros"] }

View File

@@ -3,7 +3,7 @@ use axum::{
http::{header, StatusCode}, http::{header, StatusCode},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
routing::{get, post}, routing::{get, post},
Router, Error, Router,
}; };
use std::sync::Arc; use std::sync::Arc;
use std::path::PathBuf; use std::path::PathBuf;

View File

@@ -1,9 +1,10 @@
use askama::Template; use askama::Template;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{IntoResponse, Response}, response::{IntoResponse, Response, Redirect},
routing::get, routing::{get, post},
Router, Router,
Form,
}; };
use std::sync::Arc; use std::sync::Arc;
@@ -12,7 +13,7 @@ use crate::utils::{HtmlTemplate, get_optional_user};
use crate::AppState; use crate::AppState;
use crate::error::AppError; use crate::error::AppError;
use anyhow::Context; use anyhow::Context;
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::{CookieJar, Cookie};
#[derive(Template)] #[derive(Template)]
#[template(path = "index.html")] #[template(path = "index.html")]
@@ -27,12 +28,15 @@ pub struct PostTemplate {
pub post: BlogPost, pub post: BlogPost,
pub author_username: String, pub author_username: String,
pub user: Option<User>, pub user: Option<User>,
pub unlocked: bool,
} }
pub fn router() -> Router<Arc<AppState>> { pub fn router() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/", get(index)) .route("/", get(index))
.route("/post/{id}", get(view_post)) .route("/post/{id}", get(view_post))
.route("/post/{id}/unlock", post(unlock_post))
.route("/post/{id}/lock", post(lock_post))
} }
pub async fn index( pub async fn index(
@@ -124,8 +128,74 @@ pub async fn view_post(
if post.visibility() == crate::models::Visibility::Private && !is_logged_in { 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"))); 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 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<Arc<AppState>>,
cookie_jar: CookieJar,
Path(id): Path<i64>,
Form(form): Form<UnlockForm>,
) -> 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<i64>,
) -> (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))
)
}

View File

@@ -32,10 +32,10 @@ where
static RE_CODE_BLOCK: LazyLock<Regex> = static RE_CODE_BLOCK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)```(?:\w+)?\n?(.*?)```").unwrap()); LazyLock::new(|| Regex::new(r"(?s)```(?:\w+)?\n?(.*?)```").unwrap());
static RE_CODE_INLINE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`([^`]+)`").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_STAR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
static RE_BOLD_UNDERSCORE: 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_STAR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*(.+?)\*").unwrap());
static RE_ITALIC_UNDERSCORE: 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()); static RE_IMAGE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!\[(.*?)\]\((.*?)\)").unwrap());
pub fn render_markdown(text: &str) -> String { pub fn render_markdown(text: &str) -> String {
@@ -52,16 +52,35 @@ pub fn render_markdown(text: &str) -> String {
escaped = RE_CODE_BLOCK escaped = RE_CODE_BLOCK
.replace_all(&escaped, |caps: &regex::Captures| { .replace_all(&escaped, |caps: &regex::Captures| {
let content = caps.get(1).map_or("", |m| m.as_str()).trim(); let content = caps.get(1).map_or("", |m| m.as_str()).trim();
format!("\n<pre><code>{}</code></pre>\n", content) // Protect content inside code from subsequent markdown passes
let safe = content.replace('_', "&#95;").replace('*', "&#42;");
format!("\n<pre><code>{}</code></pre>\n", safe)
}) })
.to_string(); .to_string();
// 3. Inline Code (single backticks) // 3. Inline Code (single backticks)
escaped = RE_CODE_INLINE escaped = RE_CODE_INLINE
.replace_all(&escaped, "<code>$1</code>") .replace_all(&escaped, |caps: &regex::Captures| {
let content = caps.get(1).map_or("", |m| m.as_str());
// Protect content inside code from subsequent markdown passes
let safe = content.replace('_', "&#95;").replace('*', "&#42;");
format!("<code>{}</code>", safe)
})
.to_string(); .to_string();
// 4. Bold // 4. Image tags (![alt](url)) - Processed before bold/italic to avoid mangling URLs
escaped = RE_IMAGE
.replace_all(&escaped, |caps: &regex::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('_', "&#95;").replace('*', "&#42;");
let safe_alt = alt.replace('_', "&#95;").replace('*', "&#42;");
format!(r#"<img src="{}" alt="{}">"#, safe_url, safe_alt)
})
.to_string();
// 5. Bold
escaped = RE_BOLD_STAR escaped = RE_BOLD_STAR
.replace_all(&escaped, "<strong>$1</strong>") .replace_all(&escaped, "<strong>$1</strong>")
.to_string(); .to_string();
@@ -69,7 +88,7 @@ pub fn render_markdown(text: &str) -> String {
.replace_all(&escaped, "<strong>$1</strong>") .replace_all(&escaped, "<strong>$1</strong>")
.to_string(); .to_string();
// 5. Italic // 6. Italic
escaped = RE_ITALIC_STAR escaped = RE_ITALIC_STAR
.replace_all(&escaped, "<em>$1</em>") .replace_all(&escaped, "<em>$1</em>")
.to_string(); .to_string();
@@ -77,11 +96,6 @@ pub fn render_markdown(text: &str) -> String {
.replace_all(&escaped, "<em>$1</em>") .replace_all(&escaped, "<em>$1</em>")
.to_string(); .to_string();
// 6. Image tags (![alt](url))
escaped = RE_IMAGE
.replace_all(&escaped, r#"<img src="$2" alt="$1">"#)
.to_string();
// 7. Handle newlines // 7. Handle newlines
// First, convert all newlines to <br> // First, convert all newlines to <br>
let mut with_br = escaped.replace('\n', "<br>"); let mut with_br = escaped.replace('\n', "<br>");
@@ -153,4 +167,14 @@ mod tests {
let output = render_markdown(input); let output = render_markdown(input);
assert!(output.contains("<strong>bold</strong>")); assert!(output.contains("<strong>bold</strong>"));
} }
#[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="/&#95;&#95;attachments/7gWzt7F7Sr32llbHWebWPHvA4z5v7ZvS""#));
assert!(output.contains(r#"alt="alt""#));
assert!(!output.contains("<em>"));
}
} }

View File

@@ -8,6 +8,14 @@
* { * {
font-family: monospace !important; font-family: monospace !important;
} }
body {
max-width: 780px;
}
img {
max-width: 100%;
}
</style> </style>
</head> </head>

View File

@@ -8,6 +8,14 @@
* { * {
font-family: monospace !important; font-family: monospace !important;
} }
body {
max-width: 780px;
}
img {
max-width: 100%;
}
</style> </style>
</head> </head>

View File

@@ -9,6 +9,14 @@
font-family: monospace !important; font-family: monospace !important;
} }
body {
max-width: 780px;
}
img {
max-width: 100%;
}
code { code {
background-color: #f0f0f0; background-color: #f0f0f0;
padding: 2px 4px; padding: 2px 4px;
@@ -56,14 +64,25 @@
<p>Tags: {{ post.tags }}</p> <p>Tags: {{ post.tags }}</p>
{% endif %} {% endif %}
{% if post.visibility == "password_protected" %} {% if post.visibility == "password_protected" && !unlocked %}
<p><b>[This post is password protected. In a full implementation, you'd enter a password here.]</b></p> <p><b>This post is password protected. Please enter the password to view it:</b></p>
<form action="/post/{{ post.id }}/unlock" method="post">
<input type="password" name="password" placeholder="Enter password" required>
<input type="submit" value="Unlock">
</form>
<hr size="1"> <hr size="1">
{% else if post.visibility == "private" && user.is_none() %} {% else if post.visibility == "private" && user.is_none() %}
<p><b>[This post is private and only visible to authorized users.]</b></p> <p><b>[This post is private and only visible to authorized users.]</b></p>
<hr size="1"> <hr size="1">
{% else %} {% else %}
<hr size="1"> <hr size="1">
{% if post.visibility == "password_protected" && unlocked %}
<div style="text-align: right;">
<form action="/post/{{ post.id }}/lock" method="post" style="display: inline;">
<input type="submit" value="Lock Post">
</form>
</div>
{% endif %}
{{ post.rendered_content()|safe }} {{ post.rendered_content()|safe }}
<hr size="1"> <hr size="1">
{% endif %} {% endif %}