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
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|
||||||
|
attachments
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -364,6 +364,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
50
src/utils.rs
50
src/utils.rs
@@ -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: ®ex::Captures| {
|
.replace_all(&escaped, |caps: ®ex::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('_', "_").replace('*', "*");
|
||||||
|
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: ®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();
|
.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
|
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 ()
|
|
||||||
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 = "";
|
||||||
|
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;
|
font-family: monospace !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
max-width: 780px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
* {
|
* {
|
||||||
font-family: monospace !important;
|
font-family: monospace !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
max-width: 780px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user