Edit posts
All checks were successful
Rust / Test (push) Successful in 5m43s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Successful in 1m37s
Rust / Code coverage (push) Successful in 4m49s

Use fix routes for user profile edit handles to make it easier when user decides to change his username
This commit is contained in:
Alphonse Paix
2025-10-06 22:33:05 +02:00
parent b252216709
commit 8b5f55db6f
12 changed files with 313 additions and 180 deletions

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE posts\n SET title = $1, content = $2 WHERE post_id = $3\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "790175eabe78857d16f1890ef4a1cd71f3a9c5f3e98a7c00553196f7a48d3bc3"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT author_id FROM posts WHERE post_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "author_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "84fcada696e1be5db55ef276e120ffef9adf7f5a4f5c4d5975b85e008e15620b"
}

View File

@@ -34,14 +34,12 @@
--color-green-800: oklch(44.8% 0.119 151.328); --color-green-800: oklch(44.8% 0.119 151.328);
--color-blue-50: oklch(97% 0.014 254.604); --color-blue-50: oklch(97% 0.014 254.604);
--color-blue-100: oklch(93.2% 0.032 255.585); --color-blue-100: oklch(93.2% 0.032 255.585);
--color-blue-200: oklch(88.2% 0.059 254.128);
--color-blue-300: oklch(80.9% 0.105 251.813); --color-blue-300: oklch(80.9% 0.105 251.813);
--color-blue-500: oklch(62.3% 0.214 259.815); --color-blue-500: oklch(62.3% 0.214 259.815);
--color-blue-600: oklch(54.6% 0.245 262.881); --color-blue-600: oklch(54.6% 0.245 262.881);
--color-blue-700: oklch(48.8% 0.243 264.376); --color-blue-700: oklch(48.8% 0.243 264.376);
--color-blue-800: oklch(42.4% 0.199 265.638); --color-blue-800: oklch(42.4% 0.199 265.638);
--color-blue-900: oklch(37.9% 0.146 265.522); --color-blue-900: oklch(37.9% 0.146 265.522);
--color-indigo-50: oklch(96.2% 0.018 272.314);
--color-indigo-500: oklch(58.5% 0.233 277.117); --color-indigo-500: oklch(58.5% 0.233 277.117);
--color-indigo-600: oklch(51.1% 0.262 276.966); --color-indigo-600: oklch(51.1% 0.262 276.966);
--color-indigo-700: oklch(45.7% 0.24 277.023); --color-indigo-700: oklch(45.7% 0.24 277.023);
@@ -294,9 +292,6 @@
max-width: 96rem; max-width: 96rem;
} }
} }
.mx-2 {
margin-inline: calc(var(--spacing) * 2);
}
.mx-auto { .mx-auto {
margin-inline: auto; margin-inline: auto;
} }
@@ -321,9 +316,6 @@
.mt-8 { .mt-8 {
margin-top: calc(var(--spacing) * 8); margin-top: calc(var(--spacing) * 8);
} }
.mt-12 {
margin-top: calc(var(--spacing) * 12);
}
.mt-auto { .mt-auto {
margin-top: auto; margin-top: auto;
} }
@@ -641,10 +633,6 @@
border-bottom-style: var(--tw-border-style); border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px; border-bottom-width: 1px;
} }
.border-b-2 {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 2px;
}
.border-l-4 { .border-l-4 {
border-left-style: var(--tw-border-style); border-left-style: var(--tw-border-style);
border-left-width: 4px; border-left-width: 4px;
@@ -810,6 +798,9 @@
.py-1 { .py-1 {
padding-block: calc(var(--spacing) * 1); padding-block: calc(var(--spacing) * 1);
} }
.py-1\.5 {
padding-block: calc(var(--spacing) * 1.5);
}
.py-2 { .py-2 {
padding-block: calc(var(--spacing) * 2); padding-block: calc(var(--spacing) * 2);
} }
@@ -837,9 +828,6 @@
.pb-4 { .pb-4 {
padding-bottom: calc(var(--spacing) * 4); padding-bottom: calc(var(--spacing) * 4);
} }
.pb-8 {
padding-bottom: calc(var(--spacing) * 8);
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
@@ -1295,6 +1283,11 @@
margin-inline: calc(var(--spacing) * 0); margin-inline: calc(var(--spacing) * 0);
} }
} }
.sm\:mt-0 {
@media (width >= 40rem) {
margin-top: calc(var(--spacing) * 0);
}
}
.sm\:inline { .sm\:inline {
@media (width >= 40rem) { @media (width >= 40rem) {
display: inline; display: inline;
@@ -1394,11 +1387,6 @@
line-height: var(--tw-leading, var(--text-4xl--line-height)); line-height: var(--tw-leading, var(--text-4xl--line-height));
} }
} }
.lg\:col-span-2 {
@media (width >= 64rem) {
grid-column: span 2 / span 2;
}
}
.lg\:ml-0 { .lg\:ml-0 {
@media (width >= 64rem) { @media (width >= 64rem) {
margin-left: calc(var(--spacing) * 0); margin-left: calc(var(--spacing) * 0);
@@ -1892,7 +1880,7 @@
margin-bottom: calc(var(--spacing) * 3); margin-bottom: calc(var(--spacing) * 3);
border-bottom-style: var(--tw-border-style); border-bottom-style: var(--tw-border-style);
border-bottom-width: 2px; border-bottom-width: 2px;
border-color: var(--color-gray-100); border-color: var(--color-gray-200);
padding-bottom: calc(var(--spacing) * 2); padding-bottom: calc(var(--spacing) * 2);
} }
.prose-compact h2 { .prose-compact h2 {
@@ -1900,7 +1888,7 @@
margin-bottom: calc(var(--spacing) * 3); margin-bottom: calc(var(--spacing) * 3);
border-bottom-style: var(--tw-border-style); border-bottom-style: var(--tw-border-style);
border-bottom-width: 2px; border-bottom-width: 2px;
border-color: var(--color-gray-100); border-color: var(--color-gray-200);
padding-bottom: calc(var(--spacing) * 2); padding-bottom: calc(var(--spacing) * 2);
--tw-font-weight: var(--font-weight-semibold); --tw-font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);

View File

@@ -14,13 +14,9 @@ impl PostEntry {
self.published_at.format("%B %d, %Y").to_string() self.published_at.format("%B %d, %Y").to_string()
} }
pub fn to_html(self) -> Result<Self, anyhow::Error> { pub fn to_html(&self) -> anyhow::Result<String> {
match markdown::to_html_with_options(&self.content, &markdown::Options::gfm()) { match markdown::to_html_with_options(&self.content, &markdown::Options::gfm()) {
Ok(mut content) => { Ok(content) => Ok(content),
content = content.replace("<table>", r#"<div class="table-wrapper"><table>"#);
content = content.replace("</table>", r#"</table></div>"#);
Ok(Self { content, ..self })
}
Err(e) => anyhow::bail!(e), Err(e) => anyhow::bail!(e),
} }
} }

View File

@@ -1,10 +1,11 @@
use crate::routes::{COMMENTS_PER_PAGE, get_max_page}; use crate::authentication::AuthenticatedUser;
use crate::templates::PostsPageDashboardTemplate; use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page};
use crate::session_state::TypedSession;
use crate::templates::{ErrorTemplate, MessageTemplate, PostsPageDashboardTemplate};
use crate::{ use crate::{
domain::PostEntry, domain::PostEntry,
routes::{ routes::{
AppError, Path, Query, get_comments_count_for_post, get_comments_page_for_post, AppError, Path, get_comments_count_for_post, get_comments_page_for_post, not_found_html,
not_found_html,
}, },
startup::AppState, startup::AppState,
templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate}, templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate},
@@ -12,6 +13,7 @@ use crate::{
use anyhow::Context; use anyhow::Context;
use askama::Template; use askama::Template;
use axum::{ use axum::{
Extension, Form,
extract::State, extract::State,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
@@ -118,17 +120,65 @@ pub async fn get_posts_count(connection_pool: &PgPool) -> Result<i64, sqlx::Erro
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct PostParams { pub struct EditPostForm {
pub title: String,
pub content: String,
}
#[tracing::instrument(name = "Editing post", skip_all, fields(post_id = %post_id))]
pub async fn update_post(
State(AppState {
connection_pool, ..
}): State<AppState>,
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Path(post_id): Path<Uuid>,
Form(form): Form<EditPostForm>,
) -> Result<Response, AppError> {
let record = sqlx::query!("SELECT author_id FROM posts WHERE post_id = $1", post_id)
.fetch_optional(&connection_pool)
.await
.context("Could not fetch post author.")?;
match record {
None => Ok(HtmlTemplate(ErrorTemplate::NotFound).into_response()),
Some(record) if record.author_id == user_id => {
sqlx::query!(
"
UPDATE posts
SET title = $1, content = $2 WHERE post_id = $3
",
form.title,
form.content,
post_id
)
.execute(&connection_pool)
.await
.context("Could not update post")?;
Ok(HtmlTemplate(MessageTemplate::success(
"Your changes have been saved.".into(),
))
.into_response())
}
_ => Ok(HtmlTemplate(ErrorTemplate::Forbidden).into_response()),
}
}
#[derive(serde::Deserialize)]
pub struct OriginQueryParam {
origin: Option<Uuid>, origin: Option<Uuid>,
} }
#[tracing::instrument(name = "Fetching post from database", skip(connection_pool, origin))] #[tracing::instrument(
name = "Fetching post from database",
skip(connection_pool, origin, session)
)]
pub async fn see_post( pub async fn see_post(
session: TypedSession,
State(AppState { State(AppState {
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
Path(post_id): Path<Uuid>, Path(post_id): Path<Uuid>,
Query(PostParams { origin }): Query<PostParams>, Query(OriginQueryParam { origin }): Query<OriginQueryParam>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
if let Some(origin) = origin { if let Some(origin) = origin {
mark_email_as_opened(&connection_pool, origin).await?; mark_email_as_opened(&connection_pool, origin).await?;
@@ -140,7 +190,7 @@ pub async fn see_post(
.context(format!("Failed to fetch post #{}", post_id)) .context(format!("Failed to fetch post #{}", post_id))
.map_err(AppError::unexpected_page)? .map_err(AppError::unexpected_page)?
{ {
let post = post let post_html = post
.to_html() .to_html()
.context("Could not render markdown with extension.")?; .context("Could not render markdown with extension.")?;
let current_page = 1; let current_page = 1;
@@ -152,13 +202,19 @@ pub async fn see_post(
.await .await
.context("Failed to fetch latest comments")?; .context("Failed to fetch latest comments")?;
let idempotency_key = Uuid::new_v4().to_string(); let idempotency_key = Uuid::new_v4().to_string();
let session_username = session
.get_username()
.await
.context("Could not check for session username")?;
let template = HtmlTemplate(PostTemplate { let template = HtmlTemplate(PostTemplate {
post, post,
post_html,
comments, comments,
idempotency_key, idempotency_key,
current_page, current_page,
max_page, max_page,
comments_count, comments_count,
session_username,
}); });
Ok(template.into_response()) Ok(template.into_response())
} else { } else {

View File

@@ -19,21 +19,12 @@ use secrecy::{ExposeSecret, SecretString};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
pub async fn get_user_edit( pub async fn user_edit_form(
Path(username): Path<String>, Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Extension(AuthenticatedUser {
user_id,
username: session_username,
..
}): Extension<AuthenticatedUser>,
State(AppState { State(AppState {
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
if username != session_username {
let template = HtmlTemplate(ErrorTemplate::Forbidden);
return Ok(template.into_response());
}
let user = sqlx::query_as!( let user = sqlx::query_as!(
UserEntry, UserEntry,
r#" r#"
@@ -52,25 +43,26 @@ pub async fn get_user_edit(
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct EditProfileForm { pub struct EditProfileForm {
user_id: Uuid,
username: String, username: String,
full_name: String, full_name: String,
bio: String, bio: String,
} }
pub async fn put_user_edit( #[tracing::instrument(name = "Updating user profile", skip_all, fields(user_id = %form.user_id))]
pub async fn update_user(
State(AppState { State(AppState {
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
session: TypedSession, session: TypedSession,
Extension(AuthenticatedUser { Extension(AuthenticatedUser {
user_id, user_id: session_user_id,
username: session_username, username: session_username,
.. ..
}): Extension<AuthenticatedUser>, }): Extension<AuthenticatedUser>,
Path(username): Path<String>,
Form(form): Form<EditProfileForm>, Form(form): Form<EditProfileForm>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
if username != session_username { if form.user_id != session_user_id {
let template = HtmlTemplate(ErrorTemplate::Forbidden); let template = HtmlTemplate(ErrorTemplate::Forbidden);
return Ok(template.into_response()); return Ok(template.into_response());
} }
@@ -101,7 +93,7 @@ pub async fn put_user_edit(
updated_username, updated_username,
updated_full_name, updated_full_name,
bio, bio,
user_id form.user_id
) )
.execute(&connection_pool) .execute(&connection_pool)
.await .await

View File

@@ -7,7 +7,7 @@ use axum::{
http::Request, http::Request,
middleware, middleware,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{delete, get, post}, routing::{delete, get, post, put},
}; };
use reqwest::{StatusCode, header}; use reqwest::{StatusCode, header};
use sqlx::{PgPool, postgres::PgPoolOptions}; use sqlx::{PgPool, postgres::PgPoolOptions};
@@ -99,11 +99,9 @@ pub fn app(
.route("/password", post(change_password)) .route("/password", post(change_password))
.route("/newsletters", post(publish_newsletter)) .route("/newsletters", post(publish_newsletter))
.route("/posts", post(create_post)) .route("/posts", post(create_post))
.route("/posts/{post_id}", put(update_post))
.route("/logout", get(logout)) .route("/logout", get(logout))
.route( .route("/users/edit", get(user_edit_form).put(update_user))
"/users/{username}/edit",
get(get_user_edit).put(put_user_edit),
)
.nest("/admin", admin_routes) .nest("/admin", admin_routes)
.layer(middleware::from_fn(require_auth)); .layer(middleware::from_fn(require_auth));
Router::new() Router::new()

View File

@@ -121,11 +121,13 @@ pub struct PostListTemplate {
#[template(path = "posts/page.html")] #[template(path = "posts/page.html")]
pub struct PostTemplate { pub struct PostTemplate {
pub post: PostEntry, pub post: PostEntry,
pub post_html: String,
pub idempotency_key: String, pub idempotency_key: String,
pub comments: Vec<CommentEntry>, pub comments: Vec<CommentEntry>,
pub current_page: i64, pub current_page: i64,
pub max_page: i64, pub max_page: i64,
pub comments_count: i64, pub comments_count: i64,
pub session_username: Option<String>,
} }
#[derive(Template)] #[derive(Template)]

View File

@@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@layer utilities { @layer utilities {
@@ -34,11 +35,11 @@
} }
.prose-compact h1 { .prose-compact h1 {
@apply pb-2 mb-3 border-b-2 border-gray-100; @apply pb-2 mb-3 border-b-2 border-gray-200;
} }
.prose-compact h2 { .prose-compact h2 {
@apply mt-4 pb-2 mb-3 border-b-2 border-gray-100 font-semibold; @apply mt-4 pb-2 mb-3 border-b-2 border-gray-200 font-semibold;
} }
.prose-compact h3 { .prose-compact h3 {

View File

@@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ post.title }}{% endblock %} {% block title %}Edit: {{ post.title }}{% endblock %}
{% block content %} {% block content %}
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<article> <article>
<header class="mb-4"> <header class="mb-4">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1> <h1 class="text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<div class="flex items-center"> <div class="flex items-center">
@@ -33,9 +33,70 @@
</time> </time>
</div> </div>
</div> </div>
{% if session_username.as_deref() == Some(post.author) %}
<div class="mt-4 sm:mt-0">
<button onclick="document.getElementById('edit-form').classList.toggle('hidden')"
class="inline-flex items-center px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors">
<svg class="w-4 h-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
Edit
</button>
</div>
{% endif %}
</div> </div>
</header> </header>
<div class="prose-compact">{{ post.content | safe }}</div>
{% if session_username.as_deref() == Some(post.author) %}
<div id="edit-form" class="hidden bg-gray-50 border border-gray-200 rounded-lg p-6">
<h2 class="text-xl font-bold text-gray-900 mb-4">Edit post</h2>
<form hx-put="/posts/{{ post.post_id }}"
hx-target="#edit-messages"
hx-swap="innerHTML">
<div class="mb-4">
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
<input type="text"
id="title"
name="title"
value="{{ post.title }}"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="mb-4">
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">Content (markdown)</label>
<textarea id="content"
name="content"
rows="12"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm">{{ post.content }}</textarea>
</div>
<div class="flex items-center space-x-3">
<button type="submit"
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md transition-colors">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 13l4 4L19 7"/>
</svg>
Save changes
</button>
<button type="button"
onclick="document.getElementById('edit-form').classList.add('hidden')"
class="inline-flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-md transition-colors">
Cancel
</button>
</div>
</form>
<div id="edit-messages" class="mt-6"></div>
</div>
{% endif %}
<div id="content-display" class="prose-compact">{{ post_html | safe }}</div>
</article> </article>
<div class="mt-8">{% include "posts/comments/list.html" %}</div> <div class="mt-8">{% include "posts/comments/list.html" %}</div>
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center"> <div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">

View File

@@ -1,10 +1,11 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8"> <div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
<h2 class="text-xl font-semibold text-gray-900 mb-6">Profile Information</h2> <h2 class="text-xl font-semibold text-gray-900 mb-6">Profile Information</h2>
<form hx-put="/users/{{ user.username }}/edit" <form hx-put="/users/edit"
hx-target="#edit-messages" hx-target="#edit-messages"
hx-swap="innerHTML" hx-swap="innerHTML"
class="space-y-6"> class="space-y-6">
<input type="hidden" name="user_id" value="{{ user.user_id }}" />
<div> <div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1"> <label for="username" class="block text-sm font-medium text-gray-700 mb-1">
@@ -45,7 +46,7 @@
<button type="submit" <button type="submit"
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-2 px-4 rounded-md transition-colors"> class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-2 px-4 rounded-md transition-colors">
Save Changes Save changes
</button> </button>
<div id="edit-messages"></div> <div id="edit-messages"></div>

View File

@@ -22,7 +22,7 @@
{% endif %} {% endif %}
</div> </div>
{% if session_username.as_deref() == Some(user.username) %} {% if session_username.as_deref() == Some(user.username) %}
<a href="/users/{{ user.username }}/edit" <a href="/users/edit"
class="inline-flex items-center text-sm text-blue-600 hover:text-blue-700 font-medium mb-3"> class="inline-flex items-center text-sm text-blue-600 hover:text-blue-700 font-medium mb-3">
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"