diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..d29d6c3 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[target.x86_64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=mold"] diff --git a/.sqlx/query-22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1.json b/.sqlx/query-22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1.json new file mode 100644 index 0000000..a576ad4 --- /dev/null +++ b/.sqlx/query-22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_id, password_hash, role as \"role: Role\"\n FROM users\n WHERE username = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "role: Role", + "type_info": { + "Custom": { + "name": "user_role", + "kind": { + "Enum": [ + "admin", + "writer" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1" +} diff --git a/.sqlx/query-acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58.json b/.sqlx/query-acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58.json deleted file mode 100644 index 1ac5932..0000000 --- a/.sqlx/query-acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT user_id, password_hash\n FROM users\n WHERE username = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "password_hash", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58" -} diff --git a/.sqlx/query-e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271.json b/.sqlx/query-e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271.json new file mode 100644 index 0000000..0a05fbf --- /dev/null +++ b/.sqlx/query-e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_id, username, full_name, role as \"role: Role\", member_since, bio\n FROM users\n WHERE username = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "full_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "role: Role", + "type_info": { + "Custom": { + "name": "user_role", + "kind": { + "Enum": [ + "admin", + "writer" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "member_since", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "bio", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + true + ] + }, + "hash": "e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271" +} diff --git a/.sqlx/query-fba37bafbf190369575b92c91d32f57336e8c7d42d5698e2b22e2b5c0427de75.json b/.sqlx/query-fba37bafbf190369575b92c91d32f57336e8c7d42d5698e2b22e2b5c0427de75.json new file mode 100644 index 0000000..7d76975 --- /dev/null +++ b/.sqlx/query-fba37bafbf190369575b92c91d32f57336e8c7d42d5698e2b22e2b5c0427de75.json @@ -0,0 +1,46 @@ +{ + "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" +} diff --git a/Dockerfile b/Dockerfile index b3e33e4..64ba6be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM lukemathwalker/cargo-chef:latest-rust-1.90.0 AS chef WORKDIR /app -RUN apt update && apt install -y nodejs npm && rm -rf /var/lib/apt/lists/* +RUN apt update && apt install -y nodejs npm clang mold && rm -rf /var/lib/apt/lists/* FROM chef AS planner COPY . . diff --git a/assets/css/main.css b/assets/css/main.css index 1207898..d9ef5ad 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -70,6 +70,8 @@ --text-xs--line-height: calc(1 / 0.75); --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; @@ -288,6 +290,9 @@ max-width: 96rem; } } + .-mx-8 { + margin-inline: calc(var(--spacing) * -8); + } .mx-2 { margin-inline: calc(var(--spacing) * 2); } @@ -727,6 +732,9 @@ .mt-4 { margin-top: calc(var(--spacing) * 4); } + .mt-6 { + margin-top: calc(var(--spacing) * 6); + } .mt-8 { margin-top: calc(var(--spacing) * 8); } @@ -736,9 +744,15 @@ .mr-1 { margin-right: calc(var(--spacing) * 1); } + .mr-1\.5 { + margin-right: calc(var(--spacing) * 1.5); + } .mr-2 { margin-right: calc(var(--spacing) * 2); } + .mb-1 { + margin-bottom: calc(var(--spacing) * 1); + } .mb-2 { margin-bottom: calc(var(--spacing) * 2); } @@ -989,6 +1003,20 @@ margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); } } + .divide-y { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 0; + border-bottom-style: var(--tw-border-style); + border-top-style: var(--tw-border-style); + border-top-width: calc(1px * var(--tw-divide-y-reverse)); + border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + } + } + .divide-gray-200 { + :where(& > :not(:last-child)) { + border-color: var(--color-gray-200); + } + } .rounded-full { border-radius: calc(infinity * 1px); } @@ -1188,6 +1216,9 @@ .px-6 { padding-inline: calc(var(--spacing) * 6); } + .px-8 { + padding-inline: calc(var(--spacing) * 8); + } .py-0 { padding-block: calc(var(--spacing) * 0); } @@ -1203,6 +1234,9 @@ .py-3 { padding-block: calc(var(--spacing) * 3); } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } .py-6 { padding-block: calc(var(--spacing) * 6); } @@ -1239,6 +1273,10 @@ font-size: var(--text-4xl); line-height: var(--tw-leading, var(--text-4xl--line-height)); } + .text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); @@ -1274,6 +1312,9 @@ .break-all { word-break: break-all; } + .whitespace-pre-line { + white-space: pre-line; + } .text-amber-600 { color: var(--color-amber-600); } @@ -1584,6 +1625,13 @@ } } } + .hover\:underline { + &:hover { + @media (hover: hover) { + text-decoration-line: underline; + } + } + } .hover\:shadow-lg { &:hover { @media (hover: hover) { @@ -1655,6 +1703,11 @@ outline-style: none; } } + .sm\:mx-0 { + @media (width >= 40rem) { + margin-inline: calc(var(--spacing) * 0); + } + } .sm\:mt-0 { @media (width >= 40rem) { margin-top: calc(var(--spacing) * 0); @@ -1695,6 +1748,11 @@ justify-content: space-between; } } + .sm\:justify-start { + @media (width >= 40rem) { + justify-content: flex-start; + } + } .sm\:p-6 { @media (width >= 40rem) { padding: calc(var(--spacing) * 6); @@ -1705,6 +1763,11 @@ padding-inline: calc(var(--spacing) * 6); } } + .sm\:text-left { + @media (width >= 40rem) { + text-align: left; + } + } .md\:mt-0 { @media (width >= 48rem) { margin-top: calc(var(--spacing) * 0); @@ -1725,6 +1788,11 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } + .md\:grid-cols-3 { + @media (width >= 48rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } .md\:flex-row { @media (width >= 48rem) { flex-direction: row; @@ -2425,6 +2493,11 @@ inherits: false; initial-value: 0; } +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} @property --tw-border-style { syntax: "*"; inherits: false; @@ -2628,6 +2701,7 @@ --tw-skew-y: initial; --tw-space-y-reverse: 0; --tw-space-x-reverse: 0; + --tw-divide-y-reverse: 0; --tw-border-style: solid; --tw-gradient-position: initial; --tw-gradient-from: #0000; diff --git a/migrations/20250930181830_add_data_fields_to_users.sql b/migrations/20250930181830_add_data_fields_to_users.sql new file mode 100644 index 0000000..abac0cf --- /dev/null +++ b/migrations/20250930181830_add_data_fields_to_users.sql @@ -0,0 +1,4 @@ +ALTER TABLE users +ADD COLUMN full_name TEXT, +ADD COLUMN bio TEXT, +ADD COLUMN member_since TIMESTAMPTZ NOT NULL DEFAULT NOW(); diff --git a/src/authentication.rs b/src/authentication.rs index fe6d5f1..eacbc1b 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use crate::telemetry::spawn_blocking_with_tracing; use anyhow::Context; use argon2::{ @@ -138,6 +140,15 @@ pub enum Role { Writer, } +impl Display for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Role::Admin => write!(f, "admin"), + Role::Writer => write!(f, "writer"), + } + } +} + #[derive(Clone)] pub struct AuthenticatedUser { pub user_id: Uuid, diff --git a/src/domain.rs b/src/domain.rs index e9c36ce..7c82ab8 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -2,8 +2,10 @@ mod new_subscriber; mod post; mod subscriber_email; mod subscribers; +mod user; pub use new_subscriber::NewSubscriber; pub use post::PostEntry; pub use subscriber_email::SubscriberEmail; pub use subscribers::SubscriberEntry; +pub use user::UserEntry; diff --git a/src/domain/post.rs b/src/domain/post.rs index 2fbba8f..c881695 100644 --- a/src/domain/post.rs +++ b/src/domain/post.rs @@ -3,14 +3,13 @@ use uuid::Uuid; pub struct PostEntry { pub post_id: Uuid, - pub author: Option, + pub author: String, pub title: String, pub content: String, pub published_at: DateTime, } impl PostEntry { - #[allow(dead_code)] pub fn formatted_date(&self) -> String { self.published_at.format("%B %d, %Y").to_string() } diff --git a/src/domain/user.rs b/src/domain/user.rs new file mode 100644 index 0000000..cab8222 --- /dev/null +++ b/src/domain/user.rs @@ -0,0 +1,23 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::authentication::Role; + +pub struct UserEntry { + pub user_id: Uuid, + pub username: String, + pub role: Role, + pub full_name: Option, + pub bio: Option, + pub member_since: DateTime, +} + +impl UserEntry { + pub fn formatted_date(&self) -> String { + self.member_since.format("%B %d, %Y").to_string() + } + + pub fn is_admin(&self) -> bool { + matches!(self.role, Role::Admin) + } +} diff --git a/src/routes.rs b/src/routes.rs index 78f0914..66ec3b2 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -6,6 +6,7 @@ mod posts; mod subscriptions; mod subscriptions_confirm; mod unsubscribe; +mod users; pub use admin::*; use askama::Template; @@ -24,6 +25,7 @@ use serde::de::DeserializeOwned; pub use subscriptions::*; pub use subscriptions_confirm::*; pub use unsubscribe::*; +pub use users::*; use crate::{ authentication::AuthError, diff --git a/src/routes/users.rs b/src/routes/users.rs new file mode 100644 index 0000000..5673c38 --- /dev/null +++ b/src/routes/users.rs @@ -0,0 +1,82 @@ +use crate::{ + authentication::Role, + domain::{PostEntry, UserEntry}, + routes::{AppError, not_found_html}, + startup::AppState, + templates::{HtmlTemplate, UserTemplate}, +}; +use anyhow::Context; +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, +}; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(serde::Deserialize)] +pub struct ProfilePath { + username: String, +} + +#[tracing::instrument(name = "Fetching user data", skip(connection_pool))] +pub async fn user_profile( + State(AppState { + connection_pool, .. + }): State, + Path(ProfilePath { username }): Path, +) -> Result { + match fetch_user_data(&connection_pool, &username) + .await + .context("Failed to fetch user data.")? + { + Some(user) => { + let posts = fetch_user_posts(&connection_pool, &user.user_id) + .await + .context("Could not fetch user posts.")?; + let template = HtmlTemplate(UserTemplate { user, posts }); + Ok(template.into_response()) + } + None => { + tracing::error!(username = %username, "user not found"); + Ok(not_found_html().into_response()) + } + } +} + +#[tracing::instrument(name = "Fetching user profile", skip_all)] +async fn fetch_user_data( + connection_pool: &PgPool, + username: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + UserEntry, + r#" + SELECT user_id, username, full_name, role as "role: Role", member_since, bio + FROM users + WHERE username = $1 + "#, + username + ) + .fetch_optional(connection_pool) + .await +} + +#[tracing::instrument(name = "Fetching user posts", skip_all)] +async fn fetch_user_posts( + connection_pool: &PgPool, + user_id: &Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as!( + PostEntry, + r#" + SELECT u.username as author, p.post_id, p.title, p.content, p.published_at + FROM posts p + INNER JOIN users u ON p.author_id = u.user_id + WHERE p.author_id = $1 + ORDER BY p.published_at DESC + "#, + user_id + ) + .fetch_all(connection_pool) + .await +} diff --git a/src/startup.rs b/src/startup.rs index 6cdd46f..89dcdab 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -108,6 +108,7 @@ pub fn app( .route("/posts", get(list_posts)) .route("/posts/load_more", get(load_more)) .route("/posts/{post_id}", get(see_post)) + .route("/users/{username}", get(user_profile)) .route("/favicon.ico", get(favicon)) .nest("/admin", auth_routes) .nest_service("/assets", ServeDir::new("assets")) diff --git a/src/telemetry.rs b/src/telemetry.rs index 4fe0894..c5b497b 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -21,7 +21,7 @@ where ) .with( tracing_subscriber::fmt::layer() - .pretty() + .compact() .with_writer(sink) .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE), ) diff --git a/src/templates.rs b/src/templates.rs index 00c9fdf..78404e7 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,6 +1,6 @@ use crate::{ authentication::AuthenticatedUser, - domain::{PostEntry, SubscriberEntry}, + domain::{PostEntry, SubscriberEntry, UserEntry}, routes::{AppError, DashboardStats}, }; use askama::Template; @@ -21,6 +21,13 @@ where } } +#[derive(Template)] +#[template(path = "user/profile.html")] +pub struct UserTemplate { + pub user: UserEntry, + pub posts: Vec, +} + #[derive(Template)] #[template(path = "message.html")] pub struct MessageTemplate { diff --git a/templates/dashboard/dashboard.html b/templates/dashboard/dashboard.html index c0f6d8a..75c308b 100644 --- a/templates/dashboard/dashboard.html +++ b/templates/dashboard/dashboard.html @@ -5,7 +5,9 @@

Dashboard

- Connected as {{ user.username }} + Connected as + {{ user.username }} {% if user.is_admin() %} admin diff --git a/templates/posts/card.html b/templates/posts/card.html index 72a7e63..ed425c2 100644 --- a/templates/posts/card.html +++ b/templates/posts/card.html @@ -1,9 +1,10 @@

diff --git a/templates/posts/page.html b/templates/posts/page.html index a92b44b..7571b8a 100644 --- a/templates/posts/page.html +++ b/templates/posts/page.html @@ -16,7 +16,8 @@
- {{ post.author.as_deref().unwrap_or("Unknown") }} + {{ post.author }}
+

Activity

+ {% if posts.is_empty() %} +
+ + + +

No posts yet

+
+ {% else %} + + {% endif %} +
diff --git a/templates/user/profile.html b/templates/user/profile.html new file mode 100644 index 0000000..6ea11d1 --- /dev/null +++ b/templates/user/profile.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}{{ user.username }}{% endblock %} +{% block content %} +
+
+
+
+
+ {{ user.username }} +
+
+
+
+

{{ user.full_name.as_deref().unwrap_or(user.username) }}

+ {% if user.is_admin() %} + + + + {% endif %} +
+

@{{ user.username }}

+
+ + + + {{ user.formatted_date() }} +
+
+
+ {% if user.bio.is_some() %} +
+

{{ user.bio.as_deref().unwrap() }}

+
+ {% endif %} +
+ {% include "activity.html" %} +
+{% endblock %}