feat: Add password protection for posts, improve markdown rendering for code and images, and introduce basic responsive styling.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,3 +9,5 @@
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
attachments
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -364,6 +364,7 @@ dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
"sqlx",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-http",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<User>,
|
||||
pub unlocked: bool,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<Arc<AppState>> {
|
||||
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<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))
|
||||
)
|
||||
}
|
||||
|
||||
50
src/utils.rs
50
src/utils.rs
@@ -32,10 +32,10 @@ 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_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 {
|
||||
@@ -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<pre><code>{}</code></pre>\n", content)
|
||||
// Protect content inside code from subsequent markdown passes
|
||||
let safe = content.replace('_', "_").replace('*', "*");
|
||||
format!("\n<pre><code>{}</code></pre>\n", safe)
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// 3. Inline Code (single backticks)
|
||||
escaped = RE_CODE_INLINE
|
||||
.replace_all(&escaped, "<code>$1</code>")
|
||||
.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!("<code>{}</code>", safe)
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// 4. Bold
|
||||
// 4. Image tags () - 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#"<img src="{}" alt="{}">"#, safe_url, safe_alt)
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// 5. Bold
|
||||
escaped = RE_BOLD_STAR
|
||||
.replace_all(&escaped, "<strong>$1</strong>")
|
||||
.to_string();
|
||||
@@ -69,7 +88,7 @@ pub fn render_markdown(text: &str) -> String {
|
||||
.replace_all(&escaped, "<strong>$1</strong>")
|
||||
.to_string();
|
||||
|
||||
// 5. Italic
|
||||
// 6. Italic
|
||||
escaped = RE_ITALIC_STAR
|
||||
.replace_all(&escaped, "<em>$1</em>")
|
||||
.to_string();
|
||||
@@ -77,11 +96,6 @@ pub fn render_markdown(text: &str) -> String {
|
||||
.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>");
|
||||
@@ -153,4 +167,14 @@ mod tests {
|
||||
let output = render_markdown(input);
|
||||
assert!(output.contains("<strong>bold</strong>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_with_underscores() {
|
||||
let input = "";
|
||||
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("<em>"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
* {
|
||||
font-family: monospace !important;
|
||||
}
|
||||
|
||||
body {
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
* {
|
||||
font-family: monospace !important;
|
||||
}
|
||||
|
||||
body {
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<p>Tags: {{ post.tags }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if post.visibility == "password_protected" %}
|
||||
<p><b>[This post is password protected. In a full implementation, you'd enter a password here.]</b></p>
|
||||
{% if post.visibility == "password_protected" && !unlocked %}
|
||||
<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">
|
||||
{% 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">
|
||||
{% 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 }}
|
||||
<hr size="1">
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user