Compare commits

...

13 Commits

Author SHA1 Message Date
Alphonse Paix
be69a54fd1 queries
All checks were successful
Rust / Test (push) Successful in 5m51s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Successful in 1m38s
Rust / Code coverage (push) Successful in 5m6s
2025-10-11 00:06:08 +02:00
Alphonse Paix
90aa4f8185 Templates update
Some checks failed
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
Rust / Test (push) Has been cancelled
2025-10-11 00:02:05 +02:00
Alphonse Paix
5d5f9ec765 Database worker
Worker used to clean up pending subscriptions and old idempotency
records
2025-10-11 00:02:05 +02:00
Alphonse Paix
7affe88d50 Queries
All checks were successful
Rust / Test (push) Successful in 5m43s
Rust / Rustfmt (push) Successful in 21s
Rust / Clippy (push) Successful in 1m38s
Rust / Code coverage (push) Successful in 5m3s
2025-10-09 23:48:10 +02:00
Alphonse Paix
e02139ff44 Record login for users
Some checks failed
Rust / Test (push) Failing after 4m54s
Rust / Rustfmt (push) Successful in 21s
Rust / Clippy (push) Failing after 1m36s
Rust / Code coverage (push) Successful in 5m4s
2025-10-09 21:05:48 +02:00
Alphonse Paix
45f529902d Moved logging for task worker inside task execution logic 2025-10-09 19:27:50 +02:00
Alphonse Paix
ef9f860da2 User comments
All checks were successful
Rust / Test (push) Successful in 6m17s
Rust / Rustfmt (push) Successful in 21s
Rust / Clippy (push) Successful in 1m37s
Rust / Code coverage (push) Successful in 5m5s
2025-10-08 14:23:43 +02:00
Alphonse Paix
8a5605812c Posts editing tests
All checks were successful
Rust / Test (push) Successful in 6m17s
Rust / Rustfmt (push) Successful in 24s
Rust / Clippy (push) Successful in 1m35s
Rust / Code coverage (push) Successful in 5m9s
2025-10-08 00:13:56 +02:00
Alphonse Paix
d27196d7e5 Merge branch 'tests' 2025-10-07 23:27:15 +02:00
Alphonse Paix
9cbcdc533e Update templates 2025-10-07 23:25:43 +02:00
Alphonse Paix
f18899b1a6 Update banner message 2025-10-07 23:10:52 +02:00
Alphonse Paix
3bfac6d012 Profile update tests 2025-10-07 23:07:16 +02:00
Alphonse Paix
0b402c6259 Warning banner 2025-10-07 19:43:31 +02:00
50 changed files with 1438 additions and 2987 deletions

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO comments (user_id, comment_id, post_id, author, content)\n VALUES ($1, $2, $3, $4, $5)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Uuid",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "02fff619c0ff8cb4f9946991be0ce795385b9e6697dcaa52f915acdbb1460e65"
}

View File

@@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT p.post_id, p.author_id, u.username AS author, u.full_name,\n p.title, p.content, p.published_at, last_modified\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n WHERE p.post_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "post_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "author_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "author",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "full_name",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "published_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "last_modified",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
true
]
},
"hash": "059162eba48cf5f519d0d8b6ce63575ced91941b8c55c986b8c5591c7d9b09e4"
}

View File

@@ -1,47 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n ORDER BY p.published_at DESC\n LIMIT $1\n OFFSET $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "post_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "author",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "published_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "1778ace39189532c2c69850ad7366bb36c8b5fe7491064a53190a324485f0e53"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM idempotency\n WHERE created_at < NOW() - INTERVAL '1 hour'\n ",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "1e1a90042e89bd8662df3bae15bc7506146cff102034664c77ab0fc68b9480f5"
}

View File

@@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT p.author_id, u.username as author, u.full_name,\n p.post_id, p.title, p.content, p.published_at, p.last_modified\n FROM posts p\n INNER JOIN users u ON p.author_id = u.user_id\n WHERE p.author_id = $1\n ORDER BY p.published_at DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "author_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "author",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "full_name",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "post_id",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "published_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "last_modified",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true
]
},
"hash": "1fc92c14786c21d24951341e3a8149964533b7627d2d073eeac7b7d3230513ce"
}

View File

@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO comments (comment_id, post_id, author, content)\n VALUES ($1, $2, $3, $4)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "767386497874bbf3988938d62112be9479d5dc7eb523246ac98816ff3e8d2754"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT login_time FROM user_logins\n WHERE user_id = $1\n ORDER BY login_time DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "login_time",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "769e8762bd2173c088d85fc132326b05a08e67092eac4c3a7aff8a49d086b5a0"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM subscriptions\n WHERE status = 'pending_confirmation'\n AND subscribed_at < NOW() - INTERVAL '24 hours'\n ",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "7eccf0027753bc1c42897aef12c9350eca023f3be52e24530127d06c3c449104"
}

View File

@@ -1,42 +1,53 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT comment_id, post_id, author, content, published_at\n FROM comments\n WHERE post_id = $1\n ORDER BY published_at DESC\n LIMIT $2\n OFFSET $3\n ", "query": "\n SELECT c.user_id as \"user_id?\", u.username as \"username?\", c.comment_id, c.post_id, c.author, c.content, c.published_at\n FROM comments c\n LEFT JOIN users u ON c.user_id = u.user_id AND c.user_id IS NOT NULL\n ORDER BY published_at DESC\n LIMIT $1\n OFFSET $2\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
"ordinal": 0, "ordinal": 0,
"name": "comment_id", "name": "user_id?",
"type_info": "Uuid" "type_info": "Uuid"
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "username?",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "comment_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "post_id", "name": "post_id",
"type_info": "Uuid" "type_info": "Uuid"
}, },
{ {
"ordinal": 2, "ordinal": 4,
"name": "author", "name": "author",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 3, "ordinal": 5,
"name": "content", "name": "content",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 4, "ordinal": 6,
"name": "published_at", "name": "published_at",
"type_info": "Timestamptz" "type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
"Left": [ "Left": [
"Uuid",
"Int8", "Int8",
"Int8" "Int8"
] ]
}, },
"nullable": [ "nullable": [
true,
false,
false, false,
false, false,
true, true,
@@ -44,5 +55,5 @@
false false
] ]
}, },
"hash": "0b5951c4e52f86101b33606abf65cdb411578eea4e0635a2b0ea14600e6d5031" "hash": "886de678764ebf7f96fe683d3b685d176f0a41043c7ade8b659a9bd167a2d063"
} }

View File

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

View File

@@ -1,46 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n WHERE p.post_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "post_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "author",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "published_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f"
}

View File

@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username, full_name FROM users WHERE user_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "full_name",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true
]
},
"hash": "bfd02c92fb5e0c8748b172bf59a77a477b432ada1f41090571f4fe0e685b1b1b"
}

View File

@@ -0,0 +1,65 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT p.post_id, p.author_id, u.username AS author, u.full_name,\n p.title, p.content, p.published_at, p.last_modified\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n ORDER BY p.published_at DESC\n LIMIT $1\n OFFSET $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "post_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "author_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "author",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "full_name",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "published_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "last_modified",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
true
]
},
"hash": "dc3c1b786b4f4bd65f625922ce05eab4cb161f3de6c6e676af778f7749af5710"
}

View File

@@ -1,41 +1,54 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT comment_id, post_id, author, content, published_at\n FROM comments\n ORDER BY published_at DESC\n LIMIT $1\n OFFSET $2\n ", "query": "\n SELECT c.user_id as \"user_id?\", u.username as \"username?\", c.comment_id, c.post_id, c.author, c.content, c.published_at\n FROM comments c\n LEFT JOIN users u ON c.user_id = u.user_id AND c.user_id IS NOT NULL\n WHERE c.post_id = $1\n ORDER BY c.published_at DESC\n LIMIT $2\n OFFSET $3\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
"ordinal": 0, "ordinal": 0,
"name": "comment_id", "name": "user_id?",
"type_info": "Uuid" "type_info": "Uuid"
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "username?",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "comment_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "post_id", "name": "post_id",
"type_info": "Uuid" "type_info": "Uuid"
}, },
{ {
"ordinal": 2, "ordinal": 4,
"name": "author", "name": "author",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 3, "ordinal": 5,
"name": "content", "name": "content",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 4, "ordinal": 6,
"name": "published_at", "name": "published_at",
"type_info": "Timestamptz" "type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
"Left": [ "Left": [
"Uuid",
"Int8", "Int8",
"Int8" "Int8"
] ]
}, },
"nullable": [ "nullable": [
true,
false,
false, false,
false, false,
true, true,
@@ -43,5 +56,5 @@
false false
] ]
}, },
"hash": "dcfd46faa2f2e590bcea3ade11d48cc632d8e0027faa95142f0a698f6b06a378" "hash": "fb280849a8a1fce21ec52cd9df73492d965357c9a410eb3b43b1a2e1cc8a0259"
} }

View File

@@ -1,46 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT u.username as author, p.post_id, p.title, p.content, p.published_at\n FROM posts p\n INNER JOIN users u ON p.author_id = u.user_id\n WHERE p.author_id = $1\n ORDER BY p.published_at DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "author",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "post_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "published_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "fba37bafbf190369575b92c91d32f57336e8c7d42d5698e2b22e2b5c0427de75"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO user_logins (user_id) VALUES ($1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "fc383671ada951baa611ab7dd00efcc7f4f2aea7c22e4c0865e5c766ed7f99b3"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
ALTER TABLE comments
ADD COLUMN user_id UUID
REFERENCES users (user_id) ON DELETE SET NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE posts
ADD COLUMN last_modified TIMESTAMPTZ;

View File

@@ -0,0 +1,5 @@
CREATE TABLE user_logins (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
login_time TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

3
package-lock.json generated
View File

@@ -1123,7 +1123,8 @@
"version": "4.1.13", "version": "4.1.13",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz",
"integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.2.3", "version": "2.2.3",

58
src/database_worker.rs Normal file
View File

@@ -0,0 +1,58 @@
use anyhow::Context;
use sqlx::{
PgPool,
postgres::{PgConnectOptions, PgPoolOptions},
};
use std::time::Duration;
pub async fn run_until_stopped(configuration: PgConnectOptions) -> Result<(), anyhow::Error> {
let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration);
worker_loop(connection_pool).await
}
async fn worker_loop(connection_pool: PgPool) -> Result<(), anyhow::Error> {
loop {
if let Err(e) = clean_pending_subscriptions(&connection_pool).await {
tracing::error!("{:?}", e);
}
if let Err(e) = clean_idempotency_keys(&connection_pool).await {
tracing::error!("{:?}", e);
}
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
async fn clean_pending_subscriptions(connection_pool: &PgPool) -> Result<(), anyhow::Error> {
let result = sqlx::query!(
"
DELETE FROM subscriptions
WHERE status = 'pending_confirmation'
AND subscribed_at < NOW() - INTERVAL '24 hours'
"
)
.execute(connection_pool)
.await
.context("Failed to clean up subscriptions table.")?;
match result.rows_affected() {
n if n > 0 => tracing::info!("Cleaned up {} expired subscriptions.", n),
_ => (),
}
Ok(())
}
async fn clean_idempotency_keys(connection_pool: &PgPool) -> Result<(), anyhow::Error> {
let result = sqlx::query!(
"
DELETE FROM idempotency
WHERE created_at < NOW() - INTERVAL '1 hour'
"
)
.execute(connection_pool)
.await
.context("Failed to clean up idempontency table.")?;
match result.rows_affected() {
n if n > 0 => tracing::info!("Cleaned up {} old idempotency records.", n),
_ => (),
}
Ok(())
}

View File

@@ -2,6 +2,8 @@ use chrono::{DateTime, Utc};
use uuid::Uuid; use uuid::Uuid;
pub struct CommentEntry { pub struct CommentEntry {
pub user_id: Option<Uuid>,
pub username: Option<String>,
pub comment_id: Uuid, pub comment_id: Uuid,
pub post_id: Uuid, pub post_id: Uuid,
pub author: Option<String>, pub author: Option<String>,
@@ -11,6 +13,6 @@ pub struct CommentEntry {
impl CommentEntry { impl CommentEntry {
pub fn formatted_date(&self) -> String { pub fn formatted_date(&self) -> String {
self.published_at.format("%B %d, %Y").to_string() self.published_at.format("%B %d, %Y %H:%M").to_string()
} }
} }

View File

@@ -3,15 +3,18 @@ use uuid::Uuid;
pub struct PostEntry { pub struct PostEntry {
pub post_id: Uuid, pub post_id: Uuid,
pub author_id: Uuid,
pub author: String, pub author: String,
pub full_name: Option<String>,
pub title: String, pub title: String,
pub content: String, pub content: String,
pub published_at: DateTime<Utc>, pub published_at: DateTime<Utc>,
pub last_modified: Option<DateTime<Utc>>,
} }
impl PostEntry { impl PostEntry {
pub fn formatted_date(&self) -> String { pub fn formatted_date(&self) -> String {
self.published_at.format("%B %d, %Y").to_string() self.published_at.format("%B %d, %Y %H:%M").to_string()
} }
pub fn to_html(&self) -> anyhow::Result<String> { pub fn to_html(&self) -> anyhow::Result<String> {

View File

@@ -1,3 +1,5 @@
use std::fmt::Display;
use validator::Validate; use validator::Validate;
#[derive(Debug, Validate)] #[derive(Debug, Validate)]
@@ -22,6 +24,12 @@ impl AsRef<str> for SubscriberEmail {
} }
} }
impl Display for SubscriberEmail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.email)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::SubscriberEmail; use super::SubscriberEmail;

View File

@@ -7,7 +7,7 @@ use std::time::Duration;
use tracing::{Span, field::display}; use tracing::{Span, field::display};
use uuid::Uuid; use uuid::Uuid;
pub async fn run_worker_until_stopped(configuration: Settings) -> Result<(), anyhow::Error> { pub async fn run_until_stopped(configuration: Settings) -> Result<(), anyhow::Error> {
let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db()); let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
let email_client = EmailClient::build(configuration.email_client).unwrap(); let email_client = EmailClient::build(configuration.email_client).unwrap();
worker_loop(connection_pool, email_client).await worker_loop(connection_pool, email_client).await
@@ -31,14 +31,6 @@ pub enum ExecutionOutcome {
EmptyQueue, EmptyQueue,
} }
#[tracing::instrument(
skip_all,
fields(
newsletter_issue_id=tracing::field::Empty,
subscriber_email=tracing::field::Empty
),
err
)]
pub async fn try_execute_task( pub async fn try_execute_task(
connection_pool: &PgPool, connection_pool: &PgPool,
email_client: &EmailClient, email_client: &EmailClient,
@@ -53,25 +45,14 @@ pub async fn try_execute_task(
.record("subscriber_email", display(&task.subscriber_email)); .record("subscriber_email", display(&task.subscriber_email));
match SubscriberEmail::parse(task.subscriber_email.clone()) { match SubscriberEmail::parse(task.subscriber_email.clone()) {
Ok(email) => { Ok(email) => {
let mut issue = get_issue(connection_pool, task.newsletter_issue_id).await?; execute_task(
issue.inject_unsubscribe_token(&task.unsubscribe_token); connection_pool,
if task.kind == EmailType::NewPost.to_string() { &mut transaction,
issue.inject_tracking_info(&mut transaction).await?; &task,
} email,
if let Err(e) = email_client email_client,
.send_email(
&email,
&issue.title,
&issue.html_content,
&issue.text_content,
) )
.await .await?;
{
tracing::error!(
error = %e,
"Failed to deliver issue to confirmed subscriber. Skipping."
);
}
} }
Err(e) => { Err(e) => {
tracing::error!( tracing::error!(
@@ -178,6 +159,35 @@ async fn dequeue_task(
} }
} }
#[tracing::instrument(
name = "Executing task",
skip_all,
fields(email = %email),
)]
async fn execute_task(
connection_pool: &PgPool,
transaction: &mut Transaction<'static, Postgres>,
task: &Task,
email: SubscriberEmail,
email_client: &EmailClient,
) -> Result<(), anyhow::Error> {
let mut issue = get_issue(connection_pool, task.newsletter_issue_id).await?;
issue.inject_unsubscribe_token(&task.unsubscribe_token);
if task.kind == EmailType::NewPost.to_string() {
issue.inject_tracking_info(transaction).await?;
}
email_client
.send_email(
&email,
&issue.title,
&issue.html_content,
&issue.text_content,
)
.await
.context("Failed to deliver newsletter issue to subscriber..")?;
Ok(())
}
async fn delete_task( async fn delete_task(
mut transaction: Transaction<'static, Postgres>, mut transaction: Transaction<'static, Postgres>,
issue_id: Uuid, issue_id: Uuid,

View File

@@ -1,5 +1,6 @@
pub mod authentication; pub mod authentication;
pub mod configuration; pub mod configuration;
pub mod database_worker;
pub mod domain; pub mod domain;
pub mod email_client; pub mod email_client;
pub mod idempotency; pub mod idempotency;

View File

@@ -1,6 +1,6 @@
use zero2prod::{ use zero2prod::{
configuration::get_configuration, issue_delivery_worker::run_worker_until_stopped, configuration::get_configuration, database_worker, issue_delivery_worker, startup::Application,
startup::Application, telemetry::init_subscriber, telemetry::init_subscriber,
}; };
#[tokio::main] #[tokio::main]
@@ -11,11 +11,16 @@ async fn main() -> Result<(), anyhow::Error> {
let application = Application::build(configuration.clone()).await?; let application = Application::build(configuration.clone()).await?;
let application_task = tokio::spawn(application.run_until_stopped()); let application_task = tokio::spawn(application.run_until_stopped());
let worker_task = tokio::spawn(run_worker_until_stopped(configuration)); let database_worker_task = tokio::spawn(database_worker::run_until_stopped(
configuration.database.with_db(),
));
let delivery_worker_task =
tokio::spawn(issue_delivery_worker::run_until_stopped(configuration));
tokio::select! { tokio::select! {
_ = application_task => {}, _ = application_task => {},
_ = worker_task => {}, _ = database_worker_task => {},
_ = delivery_worker_task => {},
}; };
Ok(()) Ok(())

View File

@@ -28,6 +28,7 @@ pub use subscriptions::*;
pub use subscriptions_confirm::*; pub use subscriptions_confirm::*;
pub use unsubscribe::*; pub use unsubscribe::*;
pub use users::*; pub use users::*;
use validator::ValidationErrors;
use crate::{ use crate::{
authentication::AuthError, authentication::AuthError,
@@ -209,3 +210,20 @@ where
} }
} }
} }
pub fn join_error_messages(e: ValidationErrors) -> String {
let error_messages: Vec<_> = e
.field_errors()
.iter()
.flat_map(|(field, errors)| {
errors.iter().map(move |error| {
error
.message
.as_ref()
.map(|msg| msg.to_string())
.unwrap_or(format!("Invalid field: {}", field))
})
})
.collect();
error_messages.join("\n")
}

View File

@@ -27,6 +27,7 @@ pub struct CommentForm {
pub author: Option<String>, pub author: Option<String>,
pub content: String, pub content: String,
pub idempotency_key: String, pub idempotency_key: String,
pub user_id: Option<Uuid>,
} }
#[tracing::instrument(name = "Posting new comment", skip_all, fields(post_id = %post_id))] #[tracing::instrument(name = "Posting new comment", skip_all, fields(post_id = %post_id))]
@@ -51,7 +52,13 @@ pub async fn post_comment(
} }
}; };
insert_comment(&mut transaction, post_id, form.author, form.content) insert_comment(
&mut transaction,
post_id,
form.author,
form.user_id,
form.content,
)
.await .await
.context("Could not insert comment into database.")?; .context("Could not insert comment into database.")?;
@@ -75,20 +82,25 @@ async fn insert_comment(
transaction: &mut Transaction<'static, Postgres>, transaction: &mut Transaction<'static, Postgres>,
post_id: Uuid, post_id: Uuid,
author: Option<String>, author: Option<String>,
user_id: Option<Uuid>,
content: String, content: String,
) -> Result<Uuid, sqlx::Error> { ) -> Result<Uuid, sqlx::Error> {
let author = author let author = if user_id.is_some() {
None
} else {
author
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string()); .map(|s| s.trim().to_string())
};
let content = content.trim(); let content = content.trim();
let comment_id = Uuid::new_v4(); let comment_id = Uuid::new_v4();
tracing::Span::current().record("comment_id", comment_id.to_string()); tracing::Span::current().record("comment_id", comment_id.to_string());
let query = sqlx::query!( let query = sqlx::query!(
" "
INSERT INTO comments (comment_id, post_id, author, content) INSERT INTO comments (user_id, comment_id, post_id, author, content)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
", ",
user_id,
comment_id, comment_id,
post_id, post_id,
author, author,
@@ -177,22 +189,35 @@ pub async fn get_comments_page_for_post(
page: i64, page: i64,
) -> Result<Vec<CommentEntry>, sqlx::Error> { ) -> Result<Vec<CommentEntry>, sqlx::Error> {
let offset = (page - 1) * COMMENTS_PER_PAGE; let offset = (page - 1) * COMMENTS_PER_PAGE;
let comments = sqlx::query_as!( let mut comments = sqlx::query_as!(
CommentEntry, CommentEntry,
" r#"
SELECT comment_id, post_id, author, content, published_at SELECT c.user_id as "user_id?", u.username as "username?", c.comment_id, c.post_id, c.author, c.content, c.published_at
FROM comments FROM comments c
WHERE post_id = $1 LEFT JOIN users u ON c.user_id = u.user_id AND c.user_id IS NOT NULL
ORDER BY published_at DESC WHERE c.post_id = $1
ORDER BY c.published_at DESC
LIMIT $2 LIMIT $2
OFFSET $3 OFFSET $3
", "#,
post_id, post_id,
COMMENTS_PER_PAGE, COMMENTS_PER_PAGE,
offset offset
) )
.fetch_all(connection_pool) .fetch_all(connection_pool)
.await?; .await?;
for comment in comments.iter_mut() {
if let Some(user_id) = comment.user_id {
let record = sqlx::query!(
"SELECT username, full_name FROM users WHERE user_id = $1",
user_id
)
.fetch_one(connection_pool)
.await?;
let author = record.full_name.unwrap_or(record.username);
comment.author = Some(author);
}
}
Ok(comments) Ok(comments)
} }
@@ -214,13 +239,14 @@ pub async fn get_comments_page(
let offset = (page - 1) * COMMENTS_PER_PAGE; let offset = (page - 1) * COMMENTS_PER_PAGE;
let comments = sqlx::query_as!( let comments = sqlx::query_as!(
CommentEntry, CommentEntry,
" r#"
SELECT comment_id, post_id, author, content, published_at SELECT c.user_id as "user_id?", u.username as "username?", c.comment_id, c.post_id, c.author, c.content, c.published_at
FROM comments FROM comments c
LEFT JOIN users u ON c.user_id = u.user_id AND c.user_id IS NOT NULL
ORDER BY published_at DESC ORDER BY published_at DESC
LIMIT $1 LIMIT $1
OFFSET $2 OFFSET $2
", "#,
COMMENTS_PER_PAGE, COMMENTS_PER_PAGE,
offset offset
) )

View File

@@ -15,6 +15,8 @@ use axum::{
}; };
use axum::{http::StatusCode, response::Redirect}; use axum::{http::StatusCode, response::Redirect};
use secrecy::SecretString; use secrecy::SecretString;
use sqlx::PgPool;
use uuid::Uuid;
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct LoginFormData { pub struct LoginFormData {
@@ -50,6 +52,9 @@ pub async fn post_login(
tracing::Span::current().record("username", tracing::field::display(&credentials.username)); tracing::Span::current().record("username", tracing::field::display(&credentials.username));
let (user_id, role) = validate_credentials(credentials, &connection_pool).await?; let (user_id, role) = validate_credentials(credentials, &connection_pool).await?;
tracing::Span::current().record("user_id", tracing::field::display(&user_id)); tracing::Span::current().record("user_id", tracing::field::display(&user_id));
record_login(&connection_pool, &user_id)
.await
.context("Failed to register new login event.")?;
session.renew().await.context("Failed to renew session.")?; session.renew().await.context("Failed to renew session.")?;
session session
@@ -69,3 +74,11 @@ pub async fn post_login(
headers.insert("HX-Redirect", "/dashboard".parse().unwrap()); headers.insert("HX-Redirect", "/dashboard".parse().unwrap());
Ok((StatusCode::OK, headers).into_response()) Ok((StatusCode::OK, headers).into_response())
} }
#[tracing::instrument(name = "Recording new login event", skip_all, fields(user_id = %user_id))]
async fn record_login(connection_pool: &PgPool, user_id: &Uuid) -> Result<(), sqlx::Error> {
sqlx::query!("INSERT INTO user_logins (user_id) VALUES ($1)", user_id)
.execute(connection_pool)
.await?;
Ok(())
}

View File

@@ -1,5 +1,5 @@
use crate::authentication::AuthenticatedUser; use crate::authentication::AuthenticatedUser;
use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page}; use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page, join_error_messages};
use crate::session_state::TypedSession; use crate::session_state::TypedSession;
use crate::templates::{ErrorTemplate, MessageTemplate, PostsPageDashboardTemplate}; use crate::templates::{ErrorTemplate, MessageTemplate, PostsPageDashboardTemplate};
use crate::{ use crate::{
@@ -17,8 +17,10 @@ use axum::{
extract::State, extract::State,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
use chrono::Utc;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use validator::Validate;
pub const POSTS_PER_PAGE: i64 = 3; pub const POSTS_PER_PAGE: i64 = 3;
@@ -76,7 +78,8 @@ async fn get_posts(
sqlx::query_as!( sqlx::query_as!(
PostEntry, PostEntry,
r#" r#"
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at SELECT p.post_id, p.author_id, u.username AS author, u.full_name,
p.title, p.content, p.published_at, p.last_modified
FROM posts p FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id LEFT JOIN users u ON p.author_id = u.user_id
ORDER BY p.published_at DESC ORDER BY p.published_at DESC
@@ -98,7 +101,8 @@ pub async fn get_posts_page(
sqlx::query_as!( sqlx::query_as!(
PostEntry, PostEntry,
r#" r#"
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at SELECT p.post_id, p.author_id, u.username AS author, u.full_name,
p.title, p.content, p.published_at, p.last_modified
FROM posts p FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id LEFT JOIN users u ON p.author_id = u.user_id
ORDER BY p.published_at DESC ORDER BY p.published_at DESC
@@ -119,9 +123,11 @@ pub async fn get_posts_count(connection_pool: &PgPool) -> Result<i64, sqlx::Erro
.map(|r| r.count.unwrap()) .map(|r| r.count.unwrap())
} }
#[derive(serde::Deserialize)] #[derive(Validate, serde::Deserialize)]
pub struct EditPostForm { pub struct EditPostForm {
#[validate(length(min = 1, message = "Title must be at least one character."))]
pub title: String, pub title: String,
#[validate(length(min = 1, message = "Content must be at least one character."))]
pub content: String, pub content: String,
} }
@@ -141,13 +147,18 @@ pub async fn update_post(
match record { match record {
None => Ok(HtmlTemplate(ErrorTemplate::NotFound).into_response()), None => Ok(HtmlTemplate(ErrorTemplate::NotFound).into_response()),
Some(record) if record.author_id == user_id => { Some(record) if record.author_id == user_id => {
if let Err(e) = form.validate().map_err(join_error_messages) {
let template = HtmlTemplate(MessageTemplate::error(e));
return Ok(template.into_response());
}
sqlx::query!( sqlx::query!(
" "
UPDATE posts UPDATE posts
SET title = $1, content = $2 WHERE post_id = $3 SET title = $1, content = $2, last_modified = $3 WHERE post_id = $4
", ",
form.title, form.title,
form.content, form.content,
Utc::now(),
post_id post_id
) )
.execute(&connection_pool) .execute(&connection_pool)
@@ -158,7 +169,10 @@ pub async fn update_post(
)) ))
.into_response()) .into_response())
} }
_ => Ok(HtmlTemplate(ErrorTemplate::Forbidden).into_response()), _ => Ok(HtmlTemplate(MessageTemplate::error(
"You are not authorized. Only the author can edit his post.".into(),
))
.into_response()),
} }
} }
@@ -187,7 +201,7 @@ pub async fn see_post(
if let Some(post) = get_post_data(&connection_pool, post_id) if let Some(post) = get_post_data(&connection_pool, post_id)
.await .await
.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_html = post let post_html = post
@@ -196,16 +210,20 @@ pub async fn see_post(
let current_page = 1; let current_page = 1;
let comments_count = get_comments_count_for_post(&connection_pool, post_id) let comments_count = get_comments_count_for_post(&connection_pool, post_id)
.await .await
.context("Could not fetch comment count")?; .context("Could not fetch comment count.")?;
let max_page = get_max_page(comments_count, COMMENTS_PER_PAGE); let max_page = get_max_page(comments_count, COMMENTS_PER_PAGE);
let comments = get_comments_page_for_post(&connection_pool, post_id, 1) let comments = get_comments_page_for_post(&connection_pool, post_id, 1)
.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_user_id = session
.get_user_id()
.await
.context("Could not check for session user id.")?;
let session_username = session let session_username = session
.get_username() .get_username()
.await .await
.context("Could not check for session username")?; .context("Could not check for session username.")?;
let template = HtmlTemplate(PostTemplate { let template = HtmlTemplate(PostTemplate {
post, post,
post_html, post_html,
@@ -214,6 +232,7 @@ pub async fn see_post(
current_page, current_page,
max_page, max_page,
comments_count, comments_count,
session_user_id,
session_username, session_username,
}); });
Ok(template.into_response()) Ok(template.into_response())
@@ -242,7 +261,8 @@ async fn get_post_data(
sqlx::query_as!( sqlx::query_as!(
PostEntry, PostEntry,
r#" r#"
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at SELECT p.post_id, p.author_id, u.username AS author, u.full_name,
p.title, p.content, p.published_at, last_modified
FROM posts p FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id LEFT JOIN users u ON p.author_id = u.user_id
WHERE p.post_id = $1 WHERE p.post_id = $1

View File

@@ -1,7 +1,7 @@
use crate::authentication::AuthenticatedUser; use crate::authentication::AuthenticatedUser;
use crate::routes::verify_password; use crate::routes::{join_error_messages, verify_password};
use crate::session_state::TypedSession; use crate::session_state::TypedSession;
use crate::templates::{ErrorTemplate, MessageTemplate, UserEditTemplate}; use crate::templates::{MessageTemplate, UserEditTemplate};
use crate::{ use crate::{
authentication::Role, authentication::Role,
domain::{PostEntry, UserEntry}, domain::{PostEntry, UserEntry},
@@ -18,6 +18,7 @@ use axum::{
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use validator::Validate;
pub async fn user_edit_form( pub async fn user_edit_form(
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>, Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
@@ -41,9 +42,10 @@ pub async fn user_edit_form(
Ok(template.into_response()) Ok(template.into_response())
} }
#[derive(serde::Deserialize)] #[derive(Debug, Validate, serde::Deserialize)]
pub struct EditProfileForm { pub struct EditProfileForm {
user_id: Uuid, user_id: Uuid,
#[validate(length(min = 3, message = "Username must be at least 3 characters."))]
username: String, username: String,
full_name: String, full_name: String,
bio: String, bio: String,
@@ -62,8 +64,14 @@ pub async fn update_user(
}): Extension<AuthenticatedUser>, }): Extension<AuthenticatedUser>,
Form(form): Form<EditProfileForm>, Form(form): Form<EditProfileForm>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
if let Err(e) = form.validate().map_err(join_error_messages) {
let template = HtmlTemplate(MessageTemplate::error(e));
return Ok(template.into_response());
}
if form.user_id != session_user_id { if form.user_id != session_user_id {
let template = HtmlTemplate(ErrorTemplate::Forbidden); let template = HtmlTemplate(MessageTemplate::error(
"You are not authorized. Refresh the page and try again.".into(),
));
return Ok(template.into_response()); return Ok(template.into_response());
} }
let updated_username = form.username.trim(); let updated_username = form.username.trim();
@@ -78,12 +86,16 @@ pub async fn update_user(
.is_some() .is_some()
{ {
let template = HtmlTemplate(MessageTemplate::error( let template = HtmlTemplate(MessageTemplate::error(
"The username is already taken.".into(), "This username is already taken.".into(),
)); ));
return Ok(template.into_response()); return Ok(template.into_response());
} }
let updated_full_name = form.full_name.trim(); let updated_full_name = form.full_name.trim();
let bio = {
let bio = form.bio.trim(); let bio = form.bio.trim();
if bio.is_empty() { None } else { Some(bio) }
};
sqlx::query!( sqlx::query!(
" "
UPDATE users UPDATE users
@@ -247,13 +259,32 @@ pub async fn user_profile(
let posts = fetch_user_posts(&connection_pool, &user.user_id) let posts = fetch_user_posts(&connection_pool, &user.user_id)
.await .await
.context("Could not fetch user posts.")?; .context("Could not fetch user posts.")?;
let session_username = session let session_user_id = session
.get_username() .get_user_id()
.await .await
.context("Could not fetch session username.")?; .context("Could not fetch session username.")?;
let profile_user_id =
sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
.fetch_one(&connection_pool)
.await
.context("Could not fetch profile user id.")?
.user_id;
let last_seen = sqlx::query!(
"
SELECT login_time FROM user_logins
WHERE user_id = $1
ORDER BY login_time DESC
",
profile_user_id
)
.fetch_optional(&connection_pool)
.await
.context("Failed to fetch last user login")?
.map(|r| r.login_time);
let template = HtmlTemplate(UserTemplate { let template = HtmlTemplate(UserTemplate {
user, user,
session_username, session_user_id,
last_seen,
posts, posts,
}); });
Ok(template.into_response()) Ok(template.into_response())
@@ -291,7 +322,8 @@ async fn fetch_user_posts(
sqlx::query_as!( sqlx::query_as!(
PostEntry, PostEntry,
r#" r#"
SELECT u.username as author, p.post_id, p.title, p.content, p.published_at SELECT p.author_id, u.username as author, u.full_name,
p.post_id, p.title, p.content, p.published_at, p.last_modified
FROM posts p FROM posts p
INNER JOIN users u ON p.author_id = u.user_id INNER JOIN users u ON p.author_id = u.user_id
WHERE p.author_id = $1 WHERE p.author_id = $1

View File

@@ -21,7 +21,7 @@ where
) )
.with( .with(
tracing_subscriber::fmt::layer() tracing_subscriber::fmt::layer()
.compact() .pretty()
.with_writer(sink) .with_writer(sink)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE), .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
) )

View File

@@ -5,6 +5,7 @@ use crate::{
}; };
use askama::Template; use askama::Template;
use axum::response::{Html, IntoResponse}; use axum::response::{Html, IntoResponse};
use chrono::{DateTime, Utc};
use uuid::Uuid; use uuid::Uuid;
pub struct HtmlTemplate<T>(pub T); pub struct HtmlTemplate<T>(pub T);
@@ -25,8 +26,9 @@ where
#[template(path = "user/profile.html")] #[template(path = "user/profile.html")]
pub struct UserTemplate { pub struct UserTemplate {
pub user: UserEntry, pub user: UserEntry,
pub session_username: Option<String>, pub session_user_id: Option<Uuid>,
pub posts: Vec<PostEntry>, pub posts: Vec<PostEntry>,
pub last_seen: Option<DateTime<Utc>>,
} }
#[derive(Template)] #[derive(Template)]
@@ -127,6 +129,7 @@ pub struct PostTemplate {
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_user_id: Option<Uuid>,
pub session_username: Option<String>, pub session_username: Option<String>,
} }

View File

@@ -1,19 +1,34 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8" />
<meta name="description" content="zero2prod newsletter"/> <meta name="description" content="zero2prod newsletter" />
<meta name="keywords" content="newsletter, rust, axum, htmx"/> <meta name="keywords" content="newsletter, rust, axum, htmx" />
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title> <title>
{% block title %}{% endblock %} {% block title %}{% endblock %}
- zero2prod - zero2prod
</title> </title>
<link href="/assets/css/main.css" rel="stylesheet"/> <link href="/assets/css/main.css" rel="stylesheet" />
<script src="/assets/js/htmx.min.js"></script> <script src="/assets/js/htmx.min.js"></script>
</head> </head>
<body class="bg-gray-50 min-h-screen flex flex-col"> <body class="bg-gray-50 min-h-screen flex flex-col">
<header class="sticky top-0 bg-white/95 backdrop-blur-md shadow-sm border-b border-gray-200 z-40"> <div class="bg-yellow-50 border-b-2 border-yellow-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div class="flex items-center justify-center text-center">
<svg class="w-5 h-5 text-yellow-600 mr-2 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm text-yellow-800">
This is a personal project, <span class="font-semibold">not</span> a real website. Database is cleared periodically.
</p>
</div>
</div>
</div>
<header class="sticky top-0 bg-white/95 backdrop-blur-md shadow-sm border-b border-gray-200 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16"> <div class="flex justify-between items-center h-16">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
@@ -23,8 +38,7 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg> </svg>
</div> </div>
<span class="text-sm font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent"> <span class="text-sm font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
@@ -48,8 +62,7 @@
<button class="md:hidden p-2 text-gray-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors duration-200" <button class="md:hidden p-2 text-gray-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors duration-200"
onclick="toggleMobileMenu()"> onclick="toggleMobileMenu()">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="w-5 h-5" 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" d="M4 6h16M4 12h16M4 18h16" />
d="M4 6h16M4 12h16M4 18h16"/>
</svg> </svg>
</button> </button>
</div> </div>
@@ -69,13 +82,13 @@
</nav> </nav>
</div> </div>
</div> </div>
</header> </header>
<div class="flex flex-1"> <div class="flex flex-1">
<main class="flex-1 lg:ml-0 flex flex-col py-8 px-4 sm:px-6 lg:px-8"> <main class="flex-1 lg:ml-0 flex flex-col py-8 px-4 sm:px-6 lg:px-8">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</div> </div>
<footer class="bg-white border-t border-gray-200 mt-auto"> <footer class="bg-white border-t border-gray-200 mt-auto">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div class="flex flex-col md:flex-row justify-between items-center"> <div class="flex flex-col md:flex-row justify-between items-center">
<div class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-6"> <div class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-6">
@@ -88,8 +101,7 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg> </svg>
</a> </a>
<span class="text-gray-300"></span> <span class="text-gray-300"></span>
@@ -102,8 +114,8 @@
</div> </div>
</div> </div>
</div> </div>
</footer> </footer>
</body> </body>
</html> </html>
<script> <script>
function toggleMobileMenu() { function toggleMobileMenu() {

View File

@@ -3,13 +3,20 @@
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="mb-1"> <div class="mb-1">
<span class="text-sm font-medium text-gray-900"> {% if let Some(user_id) = comment.user_id %}
<a href="/users/{{ comment.username.as_ref().unwrap() }}"
class="font-semibold text-blue-800 hover:text-blue-600 hover:underline">
{{ comment.username.as_ref().unwrap() }}
</a>
{% else %}
<span class="font-medium text-gray-600">
{% if let Some(name) = comment.author %} {% if let Some(name) = comment.author %}
{{ name }} {{ name }}
{% else %} {% else %}
Anonymous Anonymous
{% endif %} {% endif %}
</span> </span>
{% endif %}
</div> </div>
<p class="text-sm text-gray-700 mb-2 line-clamp-2">{{ comment.content }}</p> <p class="text-sm text-gray-700 mb-2 line-clamp-2">{{ comment.content }}</p>
<div class="flex items-center text-xs text-gray-500 mb-1"> <div class="flex items-center text-xs text-gray-500 mb-1">
@@ -17,8 +24,7 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg> </svg>
<time datetime="{{ comment.published_at }}"> <time datetime="{{ comment.published_at }}">
{{ comment.formatted_date() }} {{ comment.formatted_date() }}
@@ -26,9 +32,8 @@
</div> </div>
<div class="flex items-center text-xs text-gray-500"> <div class="flex items-center text-xs text-gray-500">
<span class="mr-1">on</span> <span class="mr-1">on</span>
<a href="/posts/{{ comment.post_id }}" class="text-blue-600 hover:underline truncate"> <a href="/posts/{{ comment.post_id }}"
#{{ comment.post_id }} class="text-blue-600 hover:underline truncate">#{{ comment.post_id }}</a>
</a>
</div> </div>
</div> </div>
<button hx-delete="/admin/comments/{{ comment.comment_id }}" <button hx-delete="/admin/comments/{{ comment.comment_id }}"
@@ -40,8 +45,7 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg> </svg>
</button> </button>
</div> </div>

View File

@@ -3,16 +3,17 @@
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<a href="/posts/{{ post.post_id }}"> <a href="/posts/{{ post.post_id }}">
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{ <h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">
post.title }}</h2> {{
post.title }}
</h2>
</a> </a>
<div class="flex items-center text-sm text-gray-500 mb-1"> <div class="flex items-center text-sm text-gray-500 mb-1">
<svg class="w-4 h-4 mr-1" <svg class="w-4 h-4 mr-1"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg> </svg>
<time datetime="{{ post.published_at }}"> <time datetime="{{ post.published_at }}">
{{ post.formatted_date() }} {{ post.formatted_date() }}
@@ -23,11 +24,16 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg> </svg>
<a href="/users/{{ post.author }}" <a href="/users/{{ post.author }}"
class="hover:text-blue-600 hover:underline">{{ post.author }}</a> class="hover:text-blue-600 hover:underline">
{% if let Some(full_name) = post.full_name %}
{{ full_name }}
{% else %}
{{ post.author }}
{% endif %}
</a>
</div> </div>
</div> </div>
<a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4"> <a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4">
@@ -35,7 +41,7 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</a> </a>
</div> </div>

View File

@@ -1,5 +1,5 @@
<div class="bg-white rounded-lg p-4 border border-gray-200"> <div class="bg-white rounded-lg p-4 border border-gray-200">
<div class="flex items-start space-x-3"> <div class="flex items-center space-x-3">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center"> <div class="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-500" <svg class="w-5 h-5 text-gray-500"
@@ -11,20 +11,29 @@
</div> </div>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1"> {% if let Some(user_id) = comment.user_id %}
<span class="font-medium text-gray-900"> <a href="/users/{{ comment.username.as_ref().unwrap() }}"
class="font-semibold text-blue-600 hover:text-blue-800 hover:underline">
{% if let Some(name) = comment.author %}
{{ name }}
{% else %}
{{ comment.username.as_ref().unwrap() }}
{% endif %}
</a>
{% else %}
<span class="font-medium text-gray-600">
{% if let Some(name) = comment.author %} {% if let Some(name) = comment.author %}
{{ name }} {{ name }}
{% else %} {% else %}
Anonymous Anonymous
{% endif %} {% endif %}
</span> </span>
<span class="text-gray-400"></span> {% endif %}
<time class="text-sm text-gray-500" datetime="{{ comment.published_at }}"> <time class="block text-sm text-gray-500 mt-0.5"
datetime="{{ comment.published_at }}">
{{ comment.formatted_date() }} {{ comment.formatted_date() }}
</time> </time>
</div> </div>
<p class="text-gray-700 whitespace-pre-line">{{ comment.content }}</p>
</div>
</div> </div>
<p class="text-gray-700 whitespace-pre-line mt-2">{{ comment.content }}</p>
</div> </div>

View File

@@ -7,12 +7,27 @@
hx-target="#form-messages" hx-target="#form-messages"
hx-swap="innerHTML" hx-swap="innerHTML"
class="space-y-4"> class="space-y-4">
<input type="hidden" name="idempotency_key" value="{{ idempotency_key }}"/> <input type="hidden" name="idempotency_key" value="{{ idempotency_key }}" />
{% if session_user_id.is_some() %}
<input type="hidden"
name="user_id"
value="{{ session_user_id.as_ref().unwrap() }}" />
{% endif %}
<div> <div>
{% if session_username.is_none() %}
<input type="text" <input type="text"
name="author" name="author"
placeholder="Your name (optional)" placeholder="Your name (optional)"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
{% else %}
<input type="text"
name="author"
value="{{ session_username.as_ref().unwrap() }}"
readonly
disabled
class="w-full px-4 py-2 border border-gray-200 rounded-md bg-gray-50 text-gray-600 cursor-not-allowed">
<p class="text-sm text-gray-500 mt-1">You are authenticated.</p>
{% endif %}
</div> </div>
<div> <div>
<textarea name="content" <textarea name="content"
@@ -35,8 +50,7 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg> </svg>
<p>No comments yet. Be the first to comment!</p> <p>No comments yet. Be the first to comment!</p>
</div> </div>
@@ -66,5 +80,5 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Edit: {{ post.title }}{% endblock %} {% block title %}{{ 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 space-y-4">
<h1 class="text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1> <h1 class="text-4xl font-bold text-gray-900 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">
@@ -13,27 +13,31 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg> </svg>
</div> </div>
<a href="/users/{{ post.author }}" <a href="/users/{{ post.author }}"
class="hover:text-blue-600 hover:underline font-medium">{{ post.author }}</a> class="hover:text-blue-600 hover:underline font-medium">
{% if let Some(full_name) = post.full_name %}
{{ full_name }}
{% else %}
{{ post.author }}
{% endif %}
</a>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-4 h-4 mr-1 text-gray-400" <svg class="w-4 h-4 mr-1 text-gray-400"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg> </svg>
<time datetime="{{ post.published_at }}"> <time datetime="{{ post.published_at }}">
{{ post.formatted_date() }} {{ post.formatted_date() }}
</time> </time>
</div> </div>
</div> </div>
{% if session_username.as_deref() == Some(post.author) %} {% if session_user_id.as_ref() == Some(post.author_id) %}
<div class="mt-4 sm:mt-0"> <div class="mt-4 sm:mt-0">
<button onclick="document.getElementById('edit-form').classList.toggle('hidden')" <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"> 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">
@@ -41,18 +45,20 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <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" />
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> </svg>
Edit Edit
</button> </button>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if let Some(modified) = post.last_modified %}
<span class="text-sm italic text-gray-500">Last modified on {{ modified.format("%B %d, %Y") }} at {{ modified.format("%H:%M") }}</span>
{% endif %}
</header> </header>
{% if session_user_id.as_ref() == Some(post.author_id) %}
{% if session_username.as_deref() == Some(post.author) %} <div id="edit-form"
<div id="edit-form" class="hidden bg-gray-50 border border-gray-200 rounded-lg p-6"> 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> <h2 class="text-xl font-bold text-gray-900 mb-4">Edit post</h2>
<form hx-put="/posts/{{ post.post_id }}" <form hx-put="/posts/{{ post.post_id }}"
hx-target="#edit-messages" hx-target="#edit-messages"
@@ -81,8 +87,7 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
d="M5 13l4 4L19 7"/>
</svg> </svg>
Save changes Save changes
</button> </button>
@@ -107,5 +112,5 @@
Subscribe Subscribe
</a> </a>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -6,8 +6,7 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg> </svg>
<p>No posts yet</p> <p>No posts yet</p>
</div> </div>
@@ -15,18 +14,19 @@
<div class="divide-y divide-gray-200"> <div class="divide-y divide-gray-200">
{% for post in posts %} {% for post in posts %}
<a href="/posts/{{ post.post_id }}" <a href="/posts/{{ post.post_id }}"
class="block py-4 hover:bg-gray-50 px-8 transition-colors group"> class="block hover:bg-gray-50 px-8 py-5 transition-colors group">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-base font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1">{{ <h3 class="text-base font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1">
post.title }}</h3> {{
post.title }}
</h3>
<div class="flex items-center text-sm text-gray-500"> <div class="flex items-center text-sm text-gray-500">
<svg class="w-4 h-4 mr-1.5" <svg class="w-4 h-4 mr-1.5"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg> </svg>
<time datetime="{{ post.published_at }}"> <time datetime="{{ post.published_at }}">
{{ post.formatted_date() }} {{ post.formatted_date() }}
@@ -37,7 +37,7 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</div> </div>
</a> </a>

View File

@@ -1,15 +1,14 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Edit profile{% endblock %} {% block title %}Edit profile{% endblock %}
{% block content %} {% block content %}
<div class="max-w-5xl mx-auto p-4 sm:p-6"> <div class="max-w-5xl mx-auto p-4 sm:p-6">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Edit Profile</h1> <h1 class="text-3xl font-bold text-gray-900">Edit your profile</h1>
<p class="mt-2 text-gray-600">Manage your profile and account settings.</p> <p class="mt-2 text-gray-600">Manage your profile and account settings.</p>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
{% include "edit/update_profile.html" %} {% include "edit/update_profile.html" %}
{% include "edit/change_password.html" %} {% include "edit/change_password.html" %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,5 @@
<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">Change Password</h2> <h2 class="text-xl font-semibold text-gray-900 mb-6">Password</h2>
<form hx-post="/password" <form hx-post="/password"
hx-target="#password-messages" hx-target="#password-messages"
hx-swap="innerHTML" hx-swap="innerHTML"
@@ -12,7 +11,7 @@
id="current_password" id="current_password"
name="current_password" name="current_password"
required required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"/> class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div> </div>
<div> <div>
<label for="new_password" <label for="new_password"
@@ -21,7 +20,7 @@
id="new_password" id="new_password"
name="new_password" name="new_password"
required required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"/> class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div> </div>
<div> <div>
<label for="new_password_check" <label for="new_password_check"
@@ -30,7 +29,7 @@
id="new_password_check" id="new_password_check"
name="new_password_check" name="new_password_check"
required required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"/> class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div> </div>
<button type="submit" <button type="submit"
class="w-full bg-green-600 text-white hover:bg-green-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center"> class="w-full bg-green-600 text-white hover:bg-green-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
@@ -38,10 +37,9 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg> </svg>
Update password Update
</button> </button>
<div id="password-messages" class="mt-4"></div> <div id="password-messages" class="mt-4"></div>
</form> </form>

View File

@@ -1,27 +1,20 @@
<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 settings</h2>
<form hx-put="/users/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 }}" /> <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">Username</label>
Username
</label>
<input type="text" <input type="text"
name="username" name="username"
id="username" id="username"
value="{{ user.username }}" value="{{ user.username }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div> </div>
<div> <div>
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1"> <label for="full_name" class="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
Full Name
</label>
<input type="text" <input type="text"
id="full_name" id="full_name"
name="full_name" name="full_name"
@@ -30,11 +23,8 @@
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">Your real name (optional)</p> <p class="mt-1 text-xs text-gray-500">Your real name (optional)</p>
</div> </div>
<div> <div>
<label for="bio" class="block text-sm font-medium text-gray-700 mb-1"> <label for="bio" class="block text-sm font-medium text-gray-700 mb-1">Bio</label>
Bio
</label>
<textarea id="bio" <textarea id="bio"
name="bio" name="bio"
rows="4" rows="4"
@@ -43,12 +33,10 @@
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{ user.bio.as_deref().unwrap_or("") }}</textarea> class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{ user.bio.as_deref().unwrap_or("") }}</textarea>
<p class="mt-1 text-xs text-gray-500">Maximum 500 characters</p> <p class="mt-1 text-xs text-gray-500">Maximum 500 characters</p>
</div> </div>
<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>
</form> </form>
</div> </div>

View File

@@ -1,55 +1,63 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ user.username }}{% endblock %} {% block title %}{{ user.username }}{% endblock %}
{% block content %} {% block content %}
<div class="max-w-4xl mx-auto p-4 sm:p-6"> <div class="max-w-4xl mx-auto p-4 sm:p-6">
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8 mb-6"> <div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 sm:p-8 space-y-6">
<div class="flex flex-col sm:flex-row items-center sm:items-start gap-6"> <div class="flex flex-col sm:flex-row items-center sm:items-start gap-6">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-24 h-24 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center text-white text-3xl font-bold shadow-lg"> <div class="w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-3xl font-bold shadow-md">
{{ user.username }} {{ user.username }}
</div> </div>
</div> </div>
<div class="flex-1 text-center sm:text-left"> <div class="flex-1 text-center sm:text-left space-y-2">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mb-2"> <div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h1 class="text-3xl font-bold text-gray-900">{{ user.full_name.as_deref().unwrap_or(user.username) <h1 class="text-3xl font-semibold text-gray-900 tracking-tight">
}}</h1> {{ user.full_name.as_deref().unwrap_or(user.username) }}
</h1>
{% if user.is_admin() %} {% if user.is_admin() %}
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mx-auto sm:mx-0" <svg class="w-5 h-5 text-blue-600 flex-shrink-0 mx-auto sm:mx-0"
fill="currentColor" fill="currentColor"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/> <path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" />
</svg> </svg>
{% endif %} {% endif %}
</div> </div>
{% if session_username.as_deref() == Some(user.username) %} {% if session_user_id.as_ref() == Some(user.user_id) %}
<a href="/users/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 font-medium text-blue-600 hover:text-blue-700 transition-colors">
<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.5"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" fill="none"
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"/> 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> </svg>
Edit Edit
</a> </a>
{% endif %} {% endif %}
<p class="text-gray-500 text-lg mb-3">@{{ user.username }}</p> <p class="text-gray-500 text-lg">@{{ user.username }}</p>
<div class="flex items-center justify-center sm:justify-start text-sm text-gray-500"> <div class="flex items-center justify-center sm:justify-start text-sm text-gray-500">
<svg class="w-4 h-4 mr-1.5" <svg class="w-4 h-4 mr-1.5"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg> </svg>
{{ user.formatted_date() }} {{ user.formatted_date() }}
</div> </div>
{% if let Some(last_seen) = last_seen %}
<p class="text-sm italic text-gray-400">
Last seen on {{ last_seen.format("%h %d, %Y") }} at {{ last_seen.format("%H:%M") }}
</p>
{% endif %}
</div> </div>
</div> </div>
{% if user.bio.is_some() %} {% if user.bio.is_some() %}
<div class="mt-6 pt-6 border-t border-gray-200"> <div class="border-t border-gray-100 pt-6">
<p class="text-gray-700 whitespace-pre-line">{{ user.bio.as_deref().unwrap() }}</p> <p class="text-gray-700 leading-relaxed whitespace-pre-line">{{ user.bio.as_deref().unwrap() }}</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% include "activity.html" %} <!-- Activity -->
</div> <div class="mt-8">{% include "activity.html" %}</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -21,7 +21,7 @@ async fn visitor_can_leave_a_comment(connection_pool: PgPool) {
"idempotency_key": "key", "idempotency_key": "key",
}); });
app.post_comment(&post_id, &comment_body).await; app.post_comment(&post_id, &comment_body).await;
let post = app.get_post_html(post_id).await; let post = app.get_post_html(&post_id).await;
assert!(post.contains(comment_author)); assert!(post.contains(comment_author));
assert!(post.contains(comment_content)); assert!(post.contains(comment_content));
} }
@@ -44,7 +44,7 @@ async fn visitor_can_comment_anonymously(connection_pool: PgPool) {
"idempotency_key": "key", "idempotency_key": "key",
}); });
app.post_comment(&post_id, &comment_body).await; app.post_comment(&post_id, &comment_body).await;
let post = app.get_post_html(post_id).await; let post = app.get_post_html(&post_id).await;
assert!(post.contains("Anonymous")); assert!(post.contains("Anonymous"));
assert!(post.contains(comment_content)); assert!(post.contains(comment_content));
} }

View File

@@ -289,6 +289,18 @@ impl TestApp {
self.get_admin_dashboard().await.text().await.unwrap() self.get_admin_dashboard().await.text().await.unwrap()
} }
pub async fn edit_post<Body>(&self, body: &Body, post_id: &Uuid) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.put(format!("{}/posts/{}", self.address, post_id))
.form(body)
.send()
.await
.expect("Failed to execute request")
}
pub async fn get_posts(&self) -> reqwest::Response { pub async fn get_posts(&self) -> reqwest::Response {
self.api_client self.api_client
.get(format!("{}/posts", &self.address)) .get(format!("{}/posts", &self.address))
@@ -301,7 +313,7 @@ impl TestApp {
self.get_posts().await.text().await.unwrap() self.get_posts().await.text().await.unwrap()
} }
pub async fn get_post(&self, post_id: Uuid) -> reqwest::Response { pub async fn get_post(&self, post_id: &Uuid) -> reqwest::Response {
self.api_client self.api_client
.get(format!("{}/posts/{}", &self.address, post_id)) .get(format!("{}/posts/{}", &self.address, post_id))
.send() .send()
@@ -309,7 +321,7 @@ impl TestApp {
.expect("Failed to execute request") .expect("Failed to execute request")
} }
pub async fn get_post_html(&self, post_id: Uuid) -> String { pub async fn get_post_html(&self, post_id: &Uuid) -> String {
self.get_post(post_id).await.text().await.unwrap() self.get_post(post_id).await.text().await.unwrap()
} }
@@ -375,6 +387,30 @@ impl TestApp {
.expect("Failed to execute request") .expect("Failed to execute request")
} }
pub async fn edit_profile<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.put(format!("{}/users/edit", self.address))
.form(body)
.send()
.await
.expect("Failed to execute request")
}
pub async fn get_profile(&self, username: &str) -> reqwest::Response {
self.api_client
.get(format!("{}/users/{}", self.address, username))
.send()
.await
.expect("Failed to execute request")
}
pub async fn get_profile_html(&self, username: &str) -> String {
self.get_profile(username).await.text().await.unwrap()
}
pub async fn post_create_post<Body>(&self, body: &Body) -> reqwest::Response pub async fn post_create_post<Body>(&self, body: &Body) -> reqwest::Response
where where
Body: serde::Serialize, Body: serde::Serialize,
@@ -426,6 +462,14 @@ impl TestApp {
.await .await
.expect("Failed to execute request") .expect("Failed to execute request")
} }
pub async fn get_user_id(&self, username: &str) -> Uuid {
let record = sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
.fetch_one(&self.connection_pool)
.await
.unwrap();
record.user_id
}
} }
pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) { pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {

View File

@@ -144,7 +144,7 @@ async fn new_posts_are_visible_on_the_website(connection_pool: PgPool) {
.fetch_one(&app.connection_pool) .fetch_one(&app.connection_pool)
.await .await
.unwrap(); .unwrap();
let html = app.get_post_html(post.post_id).await; let html = app.get_post_html(&post.post_id).await;
assert!(html.contains(&title)); assert!(html.contains(&title));
} }
@@ -171,7 +171,7 @@ async fn visitor_can_read_a_blog_post(connection_pool: PgPool) {
.fetch_one(&app.connection_pool) .fetch_one(&app.connection_pool)
.await .await
.unwrap(); .unwrap();
let html = app.get_post_html(post.post_id).await; let html = app.get_post_html(&post.post_id).await;
assert!(html.contains(&title)); assert!(html.contains(&title));
} }
@@ -197,7 +197,7 @@ async fn a_deleted_blog_post_returns_404(connection_pool: PgPool) {
app.delete_post(post.post_id).await; app.delete_post(post.post_id).await;
let html = app.get_post_html(post.post_id).await; let html = app.get_post_html(&post.post_id).await;
assert!(html.contains("Not Found")); assert!(html.contains("Not Found"));
} }
@@ -234,3 +234,109 @@ async fn clicking_the_notification_link_marks_the_email_as_opened(connection_poo
.opened .opened
); );
} }
#[sqlx::test]
async fn only_post_author_can_access_the_edit_form(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
app.post_create_post(&fake_post_body()).await;
let post_id = sqlx::query!("SELECT post_id FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap()
.post_id;
let html = app.get_post_html(&post_id).await;
assert!(html.contains("Edit"));
app.logout().await;
app.admin_login().await;
let html = app.get_post_html(&post_id).await;
assert!(!html.contains("Edit"));
}
#[sqlx::test]
async fn only_post_author_can_edit_post(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
app.post_create_post(&fake_post_body()).await;
let post_id = sqlx::query!("SELECT post_id FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap()
.post_id;
let new_title = "Stunning new title";
let new_content = "Astonishing content";
let edit_body = serde_json::json!({
"title": new_title,
"content": new_content,
});
let response = app.edit_post(&edit_body, &post_id).await;
let text = response.text().await.unwrap();
assert!(text.contains("Your changes have been saved"));
let text = app.get_post_html(&post_id).await;
assert!(text.contains(new_title));
assert!(text.contains(new_content));
app.logout().await;
app.admin_login().await;
let response = app.edit_post(&edit_body, &post_id).await;
let text = response.text().await.unwrap();
assert!(text.contains("You are not authorized."));
}
#[sqlx::test]
async fn invalid_fields_are_rejected(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
app.post_create_post(&fake_post_body()).await;
let post_id = sqlx::query!("SELECT post_id FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap()
.post_id;
let test_cases = [
(
serde_json::json!({
"title": "",
"content": "content"
}),
"Title must be at least one character",
"title was empty",
),
(
serde_json::json!({
"title": "Title",
"content": ""
}),
"Content must be at least one character",
"content was empty",
),
];
for (invalid_body, expected_error_message, explaination) in test_cases {
let response = app.edit_post(&invalid_body, &post_id).await;
let text = response.text().await.unwrap();
assert!(
text.contains(expected_error_message),
"The API did not reject the changes when the {}",
explaination
);
}
}

View File

@@ -344,3 +344,184 @@ async fn writers_cannot_perform_admin_functions(connection_pool: PgPool) {
.unwrap(); .unwrap();
assert!(record.is_none()); assert!(record.is_none());
} }
#[sqlx::test]
async fn user_can_change_his_display_name(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let user_id = app.get_user_id(username).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let full_name = "Alphonse Paix";
let edit_body = serde_json::json!( {
"user_id": user_id,
"username": username,
"full_name": full_name,
"bio": "",
});
let html = app.get_profile_html(username).await;
assert!(!html.contains(full_name));
let response = app.edit_profile(&edit_body).await;
assert!(dbg!(response.text().await.unwrap()).contains("Your profile has been updated"));
let html = app.get_profile_html(username).await;
assert!(html.contains(full_name));
}
#[sqlx::test]
async fn user_can_change_his_bio(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let user_id = app.get_user_id(username).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let bio = "This is me";
let edit_body = serde_json::json!( {
"user_id": user_id,
"username": username,
"full_name": "",
"bio": bio,
});
let html = app.get_profile_html(username).await;
assert!(!html.contains(bio));
let response = app.edit_profile(&edit_body).await;
assert!(dbg!(response.text().await.unwrap()).contains("Your profile has been updated"));
let html = app.get_profile_html(username).await;
assert!(html.contains(bio));
}
#[sqlx::test]
async fn user_can_change_his_username(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let user_id = app.get_user_id(username).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let new_username = "alphonsepaix";
let edit_body = serde_json::json!( {
"user_id": user_id,
"username": new_username,
"full_name": "",
"bio": "",
});
let html = app.get_profile_html(username).await;
assert!(html.contains(username));
let response = app.edit_profile(&edit_body).await;
assert!(dbg!(response.text().await.unwrap()).contains("Your profile has been updated"));
let html = app.get_profile_html(username).await;
assert!(html.contains("404"));
let html = app.get_profile_html(new_username).await;
assert!(html.contains(new_username));
}
#[sqlx::test]
async fn user_cannot_change_other_profiles(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let other_user_id = app.get_user_id("admin").await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let new_username = "alphonsepaix";
let edit_body = serde_json::json!( {
"user_id": other_user_id,
"username": new_username,
"full_name": "",
"bio": "",
});
let response = app.edit_profile(&edit_body).await;
assert!(
response
.text()
.await
.unwrap()
.contains("You are not authorized")
);
}
#[sqlx::test]
async fn user_cannot_take_an_existing_username(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let user_id = app.get_user_id(username).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let edit_body = serde_json::json!( {
"user_id": user_id,
"username": "admin",
"full_name": "",
"bio": "",
});
let response = app.edit_profile(&edit_body).await;
assert!(
response
.text()
.await
.unwrap()
.contains("This username is already taken")
);
let html = app.get_profile_html(username).await;
assert!(html.contains(username));
}
#[sqlx::test]
async fn invalid_fields_are_rejected(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let user_id = app.get_user_id(username).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let test_cases = [(
serde_json::json!({
"user_id": user_id,
"username": "ab",
"full_name": "",
"bio": "",
}),
"Username must be at least 3 characters",
"the username was too short",
)];
for (invalid_body, expected_error_message, explaination) in test_cases {
let html = app.edit_profile(&invalid_body).await;
assert!(
html.text().await.unwrap().contains(expected_error_message),
"The API did not reject the changes when {}",
explaination
);
}
}