Compare commits

...

67 Commits

Author SHA1 Message Date
Alphonse Paix
a37123a32d Tests update
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-22 12:40:17 +02:00
Alphonse Paix
1cc4871dd2 Templates refactoring
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
Inject HTML directly and fix footer out of main content <div>
2025-09-22 01:43:36 +02:00
Alphonse Paix
72fa283a6d Unsubscribe link in emails sent
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-22 01:25:36 +02:00
Alphonse Paix
7af07ea0dd Basic unsubscribe endpoint 2025-09-21 17:49:31 +02:00
Alphonse Paix
6a963a8c0d HTML and plain text for new post mail notifications
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-21 03:45:29 +02:00
Alphonse Paix
abca3a23b7 Dashboard template revision
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-21 02:01:42 +02:00
Alphonse Paix
6b3cea9fb2 table wrapping
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-20 19:19:57 +02:00
Alphonse Paix
79979e6a58 responsive tables and post page redesign for narrow viewport
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-20 17:39:25 +02:00
Alphonse Paix
771130e9f0 remove unused use statement
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-20 16:46:21 +02:00
Alphonse Paix
c618de9732 markdown with GFM extension
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-20 16:45:16 +02:00
Alphonse Paix
c11e552d0a fix mobile menu margins
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-20 16:25:47 +02:00
Alphonse Paix
6308ac279e <pre> element overflow 2025-09-20 16:05:20 +02:00
Alphonse Paix
ddb837be56 Markdown rendering 2025-09-20 16:01:51 +02:00
Alphonse Paix
8f0b59775e Basic markdown with Tailwind CSS Typography 2025-09-20 14:03:13 +02:00
Alphonse Paix
6d2d486866 Responsive navbar
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-20 05:05:49 +02:00
Alphonse Paix
40dfe1aed8 Templates refactoring
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-20 04:43:55 +02:00
Alphonse Paix
b52b676dc0 Error handling refactor and 500 page/message templates 2025-09-20 04:06:48 +02:00
Alphonse Paix
f5cd91108a Refactor admin routes to use new AppError struct in responses 2025-09-20 01:08:05 +02:00
Alphonse Paix
01d2add44b Askama message template 2025-09-20 00:51:46 +02:00
Alphonse Paix
46a6905b12 Logo in the navbar
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-20 00:20:58 +02:00
Alphonse Paix
91e8b5f001 Templates adjustments
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-19 20:07:38 +02:00
Alphonse Paix
a75c410948 404 page 2025-09-19 19:34:06 +02:00
Alphonse Paix
95c4d3fdd0 Posts dedicated page with cards linking to specific post 2025-09-19 01:04:10 +02:00
Alphonse Paix
71d4872878 Refactor test suite to handle 303 See Other 2025-09-18 21:00:01 +02:00
Alphonse Paix
3120c700a4 query metadata 2025-09-18 20:09:03 +02:00
Alphonse Paix
08d5f611b5 Brought back newsletter form on admin page 2025-09-18 18:40:03 +02:00
Alphonse Paix
54218f92a9 Admin can now write posts
Posts can be displayed on the website. Subscribers are automatically
notified by email. This gives the opportunity to track explicitly how
many people followed the link provided in the emails sent without being
intrusive (no invisible image).
2025-09-18 17:22:33 +02:00
Alphonse Paix
044991d623 Fix redirect issues
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
Dashboard button now correctly redirects to login page if user is not
logged in while login page redirects to dashboard the other way around.
2025-09-17 23:57:45 +02:00
Alphonse Paix
72d0306e35 Update README
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-17 14:35:39 +02:00
Alphonse Paix
e191d35664 Formatting 2025-09-17 14:24:45 +02:00
Alphonse Paix
b5f0f448d7 Test suite refactoring to match new htmx HTML swapping in pages 2025-09-17 14:16:27 +02:00
Alphonse Paix
859247d900 HX-Redirect to handle redirections with htmx 2025-09-17 13:16:56 +02:00
Alphonse Paix
2d336ed000 Use HTML swap to display success and error messages 2025-09-17 03:40:23 +02:00
Alphonse Paix
88dad022ce Basic dashboard for newsletter issue and password systems 2025-09-17 01:47:03 +02:00
Alphonse Paix
1d027b5460 htmx and Tailwind CSS production setup 2025-09-16 20:30:34 +02:00
Alphonse Paix
38208654dc Run on port 8080 for local env + minor fix for subscription confirm page 2025-09-16 19:09:11 +02:00
Alphonse Paix
0a2c4a32c1 Update README
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-16 16:53:45 +02:00
Alphonse Paix
b736e2fe8d Confirmation page and minor improvements to homepage and form messages
Basic redirect with flash messages for success and error messages
2025-09-16 16:47:28 +02:00
Alphonse Paix
f948728348 Merge remote-tracking branch 'origin/main' into askama 2025-09-16 15:25:28 +02:00
Alphonse Paix
5cdc3ea29d Remove name from subscriptions table 2025-09-16 15:24:08 +02:00
Alphonse Paix
56035fab30 Askama + htmx for frontend
Server-side rendering with htmx and Tailwind CSS for the styling
2025-09-16 01:47:18 +02:00
Alphonse Paix
a9c6cb36a5 Update config to use self-hosted email server 2025-09-14 19:29:02 +00:00
Alphonse Paix
ba6b2dbd93 Better datastores security
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
Localhost only exposed ports and stronger password for Postgres local
environment instance
2025-09-07 12:46:27 +02:00
Alphonse Paix
1ec51f0caf Update workflow to include redis 2025-09-05 19:23:11 +02:00
Alphonse Paix
54b0512f3f Use env vars for TLS files 2025-09-05 18:27:46 +02:00
Alphonse Paix
8d6cab41d0 Support for TLS encryption 2025-09-05 18:13:35 +02:00
Alphonse Paix
a4104ca1b2 Register form and confirmation messages 2025-09-04 23:39:53 +02:00
Alphonse Paix
f8dee295cd Fault-tolerant delivery system 2025-09-04 02:54:49 +02:00
Alphonse Paix
9a184b93ac Authentication and form for newsletter publishing 2025-09-01 15:47:27 +02:00
Alphonse Paix
d96a401d99 Admin dashboard and sessions 2025-09-01 03:08:43 +02:00
Alphonse Paix
3dce578ba0 Flash messages using axum-messages 2025-08-30 01:39:12 +02:00
Alphonse Paix
8447d050d6 Handler to send emails to confirmed subscribers 2025-08-27 12:14:11 +02:00
Alphonse Paix
9193f2020d Error handling with thiserror and anyhow 2025-08-26 12:47:22 +02:00
Alphonse Paix
4ce25a8136 Fix send email request body 2025-08-25 18:51:37 +02:00
Alphonse Paix
dfd3300371 Query metadata 2025-08-25 17:52:44 +02:00
Alphonse Paix
d1cf1f6c4f Confirm subscription endpoint 2025-08-25 17:46:03 +02:00
Alphonse Paix
73ff7c04fe Query metadata, migrations and formatting 2025-08-24 12:45:32 +02:00
Alphonse Paix
954772e9db Set status to 'confirmed' for new subscribers 2025-08-24 11:43:08 +02:00
Alphonse Paix
4389873bf4 Email client, application startup logic and tests 2025-08-24 11:31:03 +02:00
Alphonse Paix
85ab04f254 Parse data from incoming request 2025-08-23 11:13:57 +02:00
Alphonse Paix
4d049a744a Fix bug when reading environment variables 2025-08-22 16:29:11 +02:00
Alphonse Paix
a7473bb7f5 Environment variables at runtime to connect to database 2025-08-22 16:01:20 +02:00
Alphonse Paix
1567f94b1f Docker for deployment 2025-08-22 14:25:34 +02:00
Alphonse Paix
59817083eb Update GitHub workflow 2025-08-22 08:44:02 +02:00
Alphonse Paix
b280f10c40 Fix incorrect database query in test suite 2025-08-22 08:28:22 +02:00
Alphonse Paix
5cc5758097 Telemetry 2025-08-22 08:14:59 +02:00
Alphonse Paix
ded2a611e2 Database connection and user registration 2025-08-21 15:38:12 +02:00
112 changed files with 10100 additions and 21 deletions

3
.cargo/config.toml Normal file
View File

@@ -0,0 +1,3 @@
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=/usr/bin/mold"]

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
/target
.env
/tests
Dockerfile
/scripts
/migrations

1
.env Normal file
View File

@@ -0,0 +1 @@
DATABASE_URL="postgres://postgres:Jq09NF6Y8ZXJS4jd9c8U@localhost:5432/newsletter"

173
.github/workflows/general.yml vendored Normal file
View File

@@ -0,0 +1,173 @@
# The name of your workflow. GitHub displays the names of your workflows on your repository's "Actions" tab
name: Rust
# To automatically trigger the workflow
on:
# NB: this differs from the book's project!
# These settings allow us to run this specific CI pipeline for PRs against
# this specific branch (a.k.a. book chapter).
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
branches:
- main
env:
CARGO_TERM_COLOR: always
SQLX_VERSION: 0.8.6
SQLX_FEATURES: "rustls,postgres"
APP_USER: app
APP_USER_PWD: secret
APP_DB_NAME: newsletter
# A workflow run is made up of one or more jobs, which run in parallel by default
# Each job runs in a runner environment specified by runs-on
jobs:
# Unique identifier of our job (`job_id`)
test:
# Sets the name `Test` for the job, which is displayed in the GitHub UI
name: Test
# Containers must run in Linux based operating systems
runs-on: ubuntu-latest
# Service containers to run alongside the `test` container job
services:
# Label used to access the service container
postgres:
# Docker Hub image
image: postgres
# Environment variables scoped only for the `postgres` element
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: postgres
# When you map ports using the ports keyword, GitHub uses the --publish command to publish the containers ports to the Docker host
# Opens tcp port 5432 on the host and service container
ports:
- 5432:5432
redis:
image: redis:7
ports:
- 6379:6379
steps:
# Downloads a copy of the code in your repository before running CI tests
- name: Check out repository code
# The uses keyword specifies that this step will run v4 of the actions/checkout action.
# This is an action that checks out your repository onto the runner, allowing you to run scripts or other actions against your code (such as build and test tools).
# You should use the checkout action any time your workflow will run against the repository's code.
uses: actions/checkout@v4
# This GitHub Action installs a Rust toolchain using rustup. It is designed for one-line concise usage and good defaults.
# It also takes care of caching intermediate build artifacts.
- name: Install the Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install sqlx-cli
run: cargo install sqlx-cli
--version=${{ env.SQLX_VERSION }}
--features ${{ env.SQLX_FEATURES }}
--no-default-features
--locked
- name: Create app user in Postgres
run: |
sudo apt-get install postgresql-client
# Create the application user
CREATE_QUERY="CREATE USER ${APP_USER} WITH PASSWORD '${APP_USER_PWD}';"
PGPASSWORD="password" psql -U "postgres" -h "localhost" -c "${CREATE_QUERY}"
# Grant create db privileges to the app user
GRANT_QUERY="ALTER USER ${APP_USER} CREATEDB;"
PGPASSWORD="password" psql -U "postgres" -h "localhost" -c "${GRANT_QUERY}"
- name: Migrate database
run: SKIP_DOCKER=true ./scripts/init_db.sh
- name: Run tests
run: cargo test
- name: Check that queries are fresh
run: cargo sqlx prepare --check --workspace
# `fmt` container job
fmt:
name: Rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install the Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: rustfmt
- name: Enforce formatting
run: cargo fmt --check
# `clippy` container job
clippy:
name: Clippy
runs-on: ubuntu-latest
env:
# This environment variable forces sqlx to use its offline mode,
# which means that it will not attempt to connect to a database
# when running the tests. It'll instead use the cached query results.
# We check that the cached query results are up-to-date in another job,
# to speed up the overall CI pipeline.
# This will all be covered in detail in chapter 5.
SQLX_OFFLINE: true
steps:
- uses: actions/checkout@v4
- name: Install the Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: clippy
- name: Linting
run: cargo clippy -- -D warnings
# `coverage` container job
coverage:
name: Code coverage
runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: postgres
ports:
- 5432:5432
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- name: Install the Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: llvm-tools-preview
- name: Install sqlx-cli
run: cargo install sqlx-cli
--version=${{ env.SQLX_VERSION }}
--features ${{ env.SQLX_FEATURES }}
--no-default-features
--locked
- name: Create app user in Postgres
run: |
sudo apt-get install postgresql-client
# Create the application user
CREATE_QUERY="CREATE USER ${APP_USER} WITH PASSWORD '${APP_USER_PWD}';"
PGPASSWORD="password" psql -U "postgres" -h "localhost" -c "${CREATE_QUERY}"
# Grant create db privileges to the app user
GRANT_QUERY="ALTER USER ${APP_USER} CREATEDB;"
PGPASSWORD="password" psql -U "postgres" -h "localhost" -c "${GRANT_QUERY}"
- name: Migrate database
run: SKIP_DOCKER=true ./scripts/init_db.sh
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate code coverage
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
- name: Generate report
run: cargo llvm-cov report --html --output-dir coverage
- uses: actions/upload-artifact@v4
with:
name: "Coverage report"
path: coverage/

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/target
/node_modules

View File

@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT newsletter_issue_id, subscriber_email\n FROM issue_delivery_queue\n FOR UPDATE\n SKIP LOCKED\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "newsletter_issue_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "subscriber_email",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "06f83a51e9d2ca842dc0d6947ad39d9be966636700de58d404d8e1471a260c9a"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE subscriptions SET status = 'confirmed', unsubscribe_token = $1 WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e"
}

View File

@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n response_status_code as \"response_status_code!\",\n response_headers as \"response_headers!: Vec<HeaderPairRecord>\",\n response_body as \"response_body!\"\n FROM idempotency\n WHERE\n user_id = $1\n AND idempotency_key = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "response_status_code!",
"type_info": "Int2"
},
{
"ordinal": 1,
"name": "response_headers!: Vec<HeaderPairRecord>",
"type_info": {
"Custom": {
"name": "header_pair[]",
"kind": {
"Array": {
"Custom": {
"name": "header_pair",
"kind": {
"Composite": [
[
"name",
"Text"
],
[
"value",
"Bytea"
]
]
}
}
}
}
}
}
},
{
"ordinal": 2,
"name": "response_body!",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
true,
true,
true
]
},
"hash": "1fc498c8ccbf46f3e00b915e3b3973eb8d44a83a7df6dd7744dc56a2e94a0aa5"
}

View File

@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE idempotency\n SET\n response_status_code = $3,\n response_headers = $4,\n response_body = $5\n WHERE\n user_id = $1\n AND idempotency_key = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Int2",
{
"Custom": {
"name": "header_pair[]",
"kind": {
"Array": {
"Custom": {
"name": "header_pair",
"kind": {
"Composite": [
[
"name",
"Text"
],
[
"value",
"Bytea"
]
]
}
}
}
}
}
},
"Bytea"
]
},
"nullable": []
},
"hash": "32701e61ea14e25608b5f6b05289d08d422e9629d6aee98ac1dcbd50f1edbfe1"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO posts (post_id, author_id, title, content, published_at)\n VALUES ($1, $2, $3, $4, $5)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "3d6654896cea2ea1405f7ee5088da406ebe3d829380e3719b23b9bdf08affcfc"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO idempotency (user_id, idempotency_key, created_at)\n VALUES ($1, $2, now())\n ON CONFLICT DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "409cb2c83e34fba77b76f031cb0846a8f2716d775c3748887fb0c50f0e0a565b"
}

View File

@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT title, text_content, html_content\n FROM newsletter_issues\n WHERE newsletter_issue_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "text_content",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "html_content",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "43116d4e670155129aa69a7563ddc3f7d01ef3689bb8de9ee1757b401ad95b46"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO newsletter_issues (\n newsletter_issue_id, title, text_content, html_content, published_at\n )\n VALUES ($1, $2, $3, $4, now())\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "605c5893a2a89a84c201a6a2ae52a3c00cb4db064a52ea9f198c24de4b877ba2"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO subscriptions (id, email, subscribed_at, status)\n VALUES ($1, $2, $3, 'pending_confirmation')\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "61eb9d8067d08c12b6f703d3100cda08bd84a53e54a49bf072758a59a375dc14"
}

View File

@@ -0,0 +1,46 @@
{
"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 ",
"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"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "9ba5df2593c5dc21de727c16f03a76e4922b940c0877132cd5f622c725b9b123"
}

View File

@@ -0,0 +1,28 @@
{
"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"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT subscriber_id FROM subscription_tokens WHERE subscription_token = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "subscriber_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "ad120337ee606be7b8d87238e2bb765d0da8ee61b1a3bc142414c4305ec5e17f"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM issue_delivery_queue\n WHERE\n newsletter_issue_id = $1\n AND subscriber_email = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "b399033752641396cfe752e930e073765335a6c6e84935f60f4918576b47c249"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM subscriptions WHERE unsubscribe_token = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf"
}

View File

@@ -0,0 +1,46 @@
{
"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,14 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO issue_delivery_queue (\n newsletter_issue_id,\n subscriber_email,\n unsubscribe_token\n )\n SELECT $1, email, unsubscribe_token\n FROM subscriptions\n WHERE status = 'confirmed'\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET password_hash = $1 WHERE user_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "eae27786a7c81ee2199fe3d5c10ac52c8067c61d6992f8f5045b908eb73bab8b"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO subscription_tokens (subscription_token, subscriber_id)\n VALUES ($1, $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "fa625c0844ec26b7f59ce885d6fe0b9a4f4676946706cb926c21da6ab1b89d90"
}

3356
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,62 @@
name = "zero2prod"
version = "0.1.0"
edition = "2024"
resolver = "2"
[lib]
path = "src/lib.rs"
[[bin]]
path = "src/main.rs"
name = "zero2prod"
[dependencies]
axum = "0.8.4"
tokio = { version = "1.47.1", features = ["rt-multi-thread"] }
anyhow = "1.0.99"
argon2 = { version = "0.5.3", features = ["std"] }
askama = "0.14.0"
axum = { version = "0.8.4", features = ["macros"] }
axum-server = { version = "0.7.2", features = ["tls-rustls-no-provider"] }
base64 = "0.22.1"
chrono = { version = "0.4.41", default-features = false, features = ["clock"] }
config = "0.15.14"
markdown = "1.0.0"
rand = { version = "0.9.2", features = ["std_rng"] }
reqwest = { version = "0.12.23", default-features = false, features = [
"rustls-tls",
"json",
"cookies",
] }
secrecy = { version = "0.10.3", features = ["serde"] }
serde = { version = "1.0.219", features = ["derive"] }
serde-aux = "4.7.0"
sqlx = { version = "0.8.6", features = [
"runtime-tokio-rustls",
"macros",
"postgres",
"uuid",
"chrono",
"migrate",
] }
thiserror = "2.0.16"
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.6.6", features = ["fs", "trace"] }
tower-sessions = "0.14.0"
tower-sessions-redis-store = "0.16.0"
tracing = "0.1.41"
tracing-bunyan-formatter = "0.3.10"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
unicode-segmentation = "1.12.0"
urlencoding = "2.1.3"
uuid = { version = "1.18.0", features = ["v4", "serde"] }
validator = { version = "0.20.0", features = ["derive"] }
[dev-dependencies]
claims = "0.8.0"
fake = "4.4.0"
linkify = "0.10.0"
once_cell = "1.21.3"
quickcheck = "1.0.3"
quickcheck_macros = "1.1.0"
serde_json = "1.0.143"
serde_urlencoded = "0.7.1"
wiremock = "0.6.4"

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM lukemathwalker/cargo-chef:latest-rust-1.89.0 AS chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN apt update -y \
&& apt install -y --no-install-recommends clang mold
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
ENV SQLX_OFFLINE=true
RUN cargo build --release --bin zero2prod
FROM debian:bookworm-slim AS runtime
WORKDIR /app
RUN apt update -y \
&& apt install -y --no-install-recommends openssl ca-certificates \
&& apt autoremove -y \
&& apt clean -y \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/zero2prod zero2prod
COPY configuration configuration
ENV APP_ENVIRONMENT=production
ENTRYPOINT [ "./zero2prod" ]

22
README.md Normal file
View File

@@ -0,0 +1,22 @@
# zero2prod
## Packages
```
sudo apt install postgresql-client
sudo apt install pkg-config
sudo apt install libssl-dev
cargo install sqlx-cli --no-default-features --features rustls,postgres
```
## Documentation
- [axum](https://docs.rs/axum/latest/axum/) + [examples](https://github.com/tokio-rs/axum/tree/main/examples)
- [Tailwind CSS](https://tailwindcss.com)
- [htmx](https://htmx.org)
- [Rust](https://rust-lang.org)
## Repositories
- [Book repository](https://github.com/LukeMathWalker/zero-to-production)
- [Gitea](https://gitea.alphonsepaix.xyz/alphonse/zero2prod.git)

2
assets/css/main.css Normal file

File diff suppressed because one or more lines are too long

1
assets/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

4
configuration/base.yaml Normal file
View File

@@ -0,0 +1,4 @@
email_client:
timeout_milliseconds: 10000
base_url: "https://api.alphonsepaix.xyz"
sender_email: "newsletter@alphonsepaix.xyz"

15
configuration/local.yaml Normal file
View File

@@ -0,0 +1,15 @@
application:
port: 8080
host: "127.0.0.1"
base_url: "http://127.0.0.1:8080"
database:
host: "127.0.0.1"
port: 5432
database_name: "newsletter"
username: "postgres"
password: "Jq09NF6Y8ZXJS4jd9c8U"
require_ssl: false
email_client:
authorization_token: "secret-token"
redis_uri: "redis://127.0.0.1:6379"
require_tls: false

View File

@@ -0,0 +1,2 @@
application:
host: "0.0.0.0"

View File

@@ -0,0 +1,7 @@
CREATE TABLE subscriptions (
id UUID NOT NULL,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
subscribed_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (id)
);

View File

@@ -0,0 +1 @@
ALTER TABLE subscriptions ADD COLUMN status TEXT NULL;

View File

@@ -0,0 +1,4 @@
BEGIN;
UPDATE subscriptions SET status = 'confirmed' WHERE status IS NULL;
ALTER TABLE subscriptions ALTER COLUMN status SET NOT NULL;
COMMIT;

View File

@@ -0,0 +1,5 @@
CREATE TABLE subscription_tokens (
subscription_token TEXT NOT NULL,
subscriber_id UUID NOT NULL REFERENCES subscriptions (id),
PRIMARY KEY (subscription_token)
);

View File

@@ -0,0 +1,5 @@
CREATE TABLE users (
user_id UUID PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
);

View File

@@ -0,0 +1 @@
ALTER TABLE users RENAME password TO password_hash;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN salt TEXT NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN salt;

View File

@@ -0,0 +1,6 @@
INSERT INTO users (user_id, username, password_hash)
VALUES (
'd2492680-6e45-4179-b369-1439b8f22051',
'admin',
'$argon2id$v=19$m=19456,t=2,p=1$oWy180x7KxJYiTHzoN3sVw$vTgzvEqACiXjGalYUJHgb329Eb+s6wu5r+Cw8dHR5YE'
);

View File

@@ -0,0 +1,14 @@
CREATE TYPE header_pair AS (
name TEXT,
value BYTEA
);
CREATE TABLE idempotency (
user_id UUID NOT NULL REFERENCES users (user_id),
idempotency_key TEXT NOT NULL,
response_status_code SMALLINT NOT NULL,
response_headers header_pair[] NOT NULL,
response_body BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (user_id, idempotency_key)
);

View File

@@ -0,0 +1,3 @@
ALTER TABLE idempotency ALTER COLUMN response_status_code DROP NOT NULL;
ALTER TABLE idempotency ALTER COLUMN response_body DROP NOT NULL;
ALTER TABLE idempotency ALTER COLUMN response_headers DROP NOT NULL;

View File

@@ -0,0 +1,8 @@
CREATE TABLE newsletter_issues (
newsletter_issue_id UUID NOT NULL,
title TEXT NOT NULL,
text_content TEXT NOT NULL,
html_content TEXT NOT NULL,
published_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (newsletter_issue_id)
);

View File

@@ -0,0 +1,6 @@
CREATE TABLE issue_delivery_queue (
newsletter_issue_id UUID NOT NULL
REFERENCES newsletter_issues (newsletter_issue_id),
subscriber_email TEXT NOT NULL,
PRIMARY KEY (newsletter_issue_id, subscriber_email)
);

View File

@@ -0,0 +1 @@
ALTER TABLE subscriptions DROP COLUMN name;

View File

@@ -0,0 +1,7 @@
CREATE TABLE posts (
post_id UUID PRIMARY KEY,
author_id UUID NOT NULL REFERENCES users (user_id),
title TEXT NOT NULL,
content TEXT NOT NULL,
published_at TIMESTAMPTZ NOT NULL
);

View File

@@ -0,0 +1,5 @@
ALTER TABLE subscriptions ADD COLUMN unsubscribe_token TEXT UNIQUE;
UPDATE subscriptions
SET unsubscribe_token = left(md5(random()::text), 25)
WHERE status = 'confirmed' AND unsubscribe_token IS NULL;

View File

@@ -0,0 +1,8 @@
ALTER TABLE subscription_tokens
DROP CONSTRAINT subscription_tokens_subscriber_id_fkey;
ALTER TABLE subscription_tokens
ADD CONSTRAINT subscription_tokens_subscriber_id_fkey
FOREIGN KEY (subscriber_id)
REFERENCES subscriptions (id)
ON DELETE CASCADE;

View File

@@ -0,0 +1 @@
ALTER TABLE issue_delivery_queue ADD COLUMN unsubscribe_token TEXT NOT NULL;

1187
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"scripts": {
"build-css": "tailwindcss -i ./templates/input.css -o ./assets/css/main.css --minify --watch"
},
"dependencies": {
"@tailwindcss/cli": "^4.1.13",
"tailwindcss": "^4.1.13"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.18"
}
}

45
scripts/init_db.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -x
set -eo pipefail
if ! [ -x "$(command -v psql)" ]; then
echo >&2 "Error: psql is not installed."
exit 1
fi
if ! [ -x "$(command -v sqlx)" ]; then
echo >&2 "Error: sqlx is not installed."
exit 1
fi
DB_USER="${POSTGRES_USER:=postgres}"
DB_PASSWORD="${POSTGRES_PASSWORD:=Jq09NF6Y8ZXJS4jd9c8U}"
DB_NAME="${POSTGRES_DB:=newsletter}"
DB_PORT="${POSTGRES_PORT:=5432}"
DB_HOST="${POSTGRES_HOST:=localhost}"
if [[ -z "${SKIP_DOCKER}" ]]; then
docker run \
-e POSTGRES_USER=${DB_USER} \
-e POSTGRES_PASSWORD=${DB_PASSWORD} \
-e POSTGRES_DB=${DB_NAME} \
-p "127.0.0.1:${DB_PORT}":5432 \
-d postgres \
postgres -N 1000
fi
export PGPASSWORD="${DB_PASSWORD}"
until psql -h "${DB_HOST}" -U "${DB_USER}" -p "${DB_PORT}" -d "postgres" -c '\q'; do
>&2 echo "Postgres is still unavailable - sleeping"
sleep 1
done
>&2 echo "Postgres is up and running on port ${DB_PORT} - running migrations now!"
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
export DATABASE_URL
sqlx database create
sqlx migrate run
>&2 echo "Postgres has been migrated, ready to go!"

18
scripts/init_redis.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -x
set -eo pipefail
RUNNING_CONTAINER=$(docker ps --filter 'name=redis' --format '{{.ID}}')
if [[ -n $RUNNING_CONTAINER ]]; then
echo >&2 "A redis container is already running (${RUNNING_CONTAINER})."
exit 1
fi
docker run \
-p "127.0.0.1:6379:6379" \
-d \
--name "redis_$(date '+%s')" \
redis
>&2 echo "Redis is ready to go!"

166
src/authentication.rs Normal file
View File

@@ -0,0 +1,166 @@
use crate::{
routes::AdminError, session_state::TypedSession, telemetry::spawn_blocking_with_tracing,
};
use anyhow::Context;
use argon2::{
Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version,
password_hash::{SaltString, rand_core::OsRng},
};
use axum::{extract::Request, middleware::Next, response::Response};
use secrecy::{ExposeSecret, SecretString};
use sqlx::PgPool;
use uuid::Uuid;
pub struct Credentials {
pub username: String,
pub password: SecretString,
}
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
#[error("Invalid credentials.")]
InvalidCredentials(#[source] anyhow::Error),
}
#[tracing::instrument(name = "Change password", skip(password, connection_pool))]
pub async fn change_password(
user_id: Uuid,
password: SecretString,
connection_pool: &PgPool,
) -> Result<(), anyhow::Error> {
let password_hash = spawn_blocking_with_tracing(move || compute_pasword_hash(password))
.await?
.context("Failed to hash password.")?;
sqlx::query!(
"UPDATE users SET password_hash = $1 WHERE user_id = $2",
password_hash.expose_secret(),
user_id
)
.execute(connection_pool)
.await
.context("Failed to update user password in the database.")?;
Ok(())
}
fn compute_pasword_hash(password: SecretString) -> Result<SecretString, anyhow::Error> {
let salt = SaltString::generate(&mut OsRng);
let password_hash = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
Params::new(1500, 2, 1, None).unwrap(),
)
.hash_password(password.expose_secret().as_bytes(), &salt)?
.to_string();
Ok(SecretString::from(password_hash))
}
#[tracing::instrument(
name = "Validate credentials",
skip(username, password, connection_pool)
)]
pub async fn validate_credentials(
Credentials { username, password }: Credentials,
connection_pool: &PgPool,
) -> Result<Uuid, AuthError> {
let mut user_id = None;
let mut expected_password_hash = SecretString::from(
"$argon2id$v=19$m=15000,t=2,p=1$\
gZiV/M1gPc22ElAH/Jh1Hw$\
CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
.to_string(),
);
if let Some((stored_user_id, stored_expected_password_hash)) =
get_stored_credentials(&username, connection_pool)
.await
.context("Failed to retrieve credentials from database.")
.map_err(AuthError::UnexpectedError)?
{
user_id = Some(stored_user_id);
expected_password_hash = stored_expected_password_hash;
}
let handle =
spawn_blocking_with_tracing(|| verify_password_hash(expected_password_hash, password));
let uuid = user_id
.ok_or_else(|| anyhow::anyhow!("Unknown username."))
.map_err(AuthError::InvalidCredentials)?;
handle
.await
.context("Failed to spawn blocking task.")
.map_err(AuthError::UnexpectedError)?
.map_err(AuthError::InvalidCredentials)
.map(|_| uuid)
}
#[tracing::instrument(
name = "Verify password",
skip(expected_password_hash, password_candidate)
)]
fn verify_password_hash(
expected_password_hash: SecretString,
password_candidate: SecretString,
) -> Result<(), anyhow::Error> {
let expected_password_hash = PasswordHash::new(expected_password_hash.expose_secret())
.context("Failed to parse hash in PHC string format.")?;
Argon2::default()
.verify_password(
password_candidate.expose_secret().as_bytes(),
&expected_password_hash,
)
.context("Password verification failed.")
}
#[tracing::instrument(name = "Get stored credentials", skip(username, connection_pool))]
async fn get_stored_credentials(
username: &str,
connection_pool: &PgPool,
) -> Result<Option<(Uuid, SecretString)>, sqlx::Error> {
let row = sqlx::query!(
r#"
SELECT user_id, password_hash
FROM users
WHERE username = $1
"#,
username,
)
.fetch_optional(connection_pool)
.await?
.map(|row| (row.user_id, SecretString::from(row.password_hash)));
Ok(row)
}
pub async fn require_auth(
session: TypedSession,
mut request: Request,
next: Next,
) -> Result<Response, AdminError> {
let user_id = session
.get_user_id()
.await
.map_err(|e| AdminError::UnexpectedError(e.into()))?
.ok_or(AdminError::NotAuthenticated)?;
let username = session
.get_username()
.await
.map_err(|e| AdminError::UnexpectedError(e.into()))?
.ok_or(AdminError::UnexpectedError(anyhow::anyhow!(
"Could not find username in session."
)))?;
request
.extensions_mut()
.insert(AuthenticatedUser { user_id, username });
Ok(next.run(request).await)
}
#[derive(Clone)]
pub struct AuthenticatedUser {
pub user_id: Uuid,
pub username: String,
}

133
src/configuration.rs Normal file
View File

@@ -0,0 +1,133 @@
use crate::domain::SubscriberEmail;
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_number_from_string;
use sqlx::postgres::{PgConnectOptions, PgSslMode};
pub fn get_configuration() -> Result<Settings, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
let config_dir = base_path.join("configuration");
let environment: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.try_into()
.expect("Failed to parse APP_ENVIRONMENT");
let environment_filename = format!("{}.yaml", environment.as_str());
let settings = config::Config::builder()
.add_source(config::File::from(config_dir.join("base.yaml")))
.add_source(config::File::from(config_dir.join(environment_filename)))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?;
settings.try_deserialize::<Settings>()
}
pub enum Environment {
Local,
Production,
}
impl Environment {
pub fn as_str(&self) -> &str {
match self {
Environment::Local => "local",
Environment::Production => "production",
}
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.to_lowercase().as_str() {
"local" => Ok(Environment::Local),
"production" => Ok(Environment::Production),
other => Err(format!(
"{} is not a supported environment. Use either `local` or `production`.",
other
)),
}
}
}
#[derive(Clone, Deserialize)]
pub struct Settings {
pub application: ApplicationSettings,
pub database: DatabaseSettings,
pub email_client: EmailClientSettings,
pub redis_uri: SecretString,
pub require_tls: bool,
}
#[derive(Clone, Deserialize)]
pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
pub base_url: String,
}
#[derive(Clone, Deserialize)]
pub struct EmailClientSettings {
pub base_url: String,
sender_email: String,
pub authorization_token: SecretString,
pub timeout_milliseconds: u64,
}
impl EmailClientSettings {
pub fn sender(&self) -> Result<SubscriberEmail, anyhow::Error> {
SubscriberEmail::parse(self.sender_email.clone())
}
pub fn new(
base_url: String,
sender_email: String,
authorization_token: String,
timeout_milliseconds: u64,
) -> Self {
let authorization_token = SecretString::from(authorization_token);
Self {
base_url,
sender_email,
authorization_token,
timeout_milliseconds,
}
}
}
#[derive(Clone, Deserialize)]
pub struct DatabaseSettings {
pub username: String,
pub password: SecretString,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
pub database_name: String,
pub require_ssl: bool,
}
impl DatabaseSettings {
pub fn with_db(&self) -> PgConnectOptions {
self.without_db().database(&self.database_name)
}
pub fn without_db(&self) -> PgConnectOptions {
let ssl_mode = if self.require_ssl {
PgSslMode::Require
} else {
PgSslMode::Prefer
};
PgConnectOptions::new()
.host(&self.host)
.username(&self.username)
.password(self.password.expose_secret())
.port(self.port)
.ssl_mode(ssl_mode)
}
}

7
src/domain.rs Normal file
View File

@@ -0,0 +1,7 @@
mod new_subscriber;
mod post;
mod subscriber_email;
pub use new_subscriber::NewSubscriber;
pub use post::PostEntry;
pub use subscriber_email::SubscriberEmail;

View File

@@ -0,0 +1,5 @@
use crate::domain::subscriber_email::SubscriberEmail;
pub struct NewSubscriber {
pub email: SubscriberEmail,
}

28
src/domain/post.rs Normal file
View File

@@ -0,0 +1,28 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
pub struct PostEntry {
pub post_id: Uuid,
pub author: Option<String>,
pub title: String,
pub content: String,
pub published_at: DateTime<Utc>,
}
impl PostEntry {
#[allow(dead_code)]
pub fn formatted_date(&self) -> String {
self.published_at.format("%B %d, %Y").to_string()
}
pub fn to_html(self) -> Result<Self, anyhow::Error> {
match markdown::to_html_with_options(&self.content, &markdown::Options::gfm()) {
Ok(mut content) => {
content = content.replace("<table>", r#"<div class="table-wrapper"><table>"#);
content = content.replace("</table>", r#"</table></div>"#);
Ok(Self { content, ..self })
}
Err(e) => anyhow::bail!(e),
}
}
}

View File

@@ -0,0 +1,67 @@
use validator::Validate;
#[derive(Debug, Validate)]
pub struct SubscriberEmail {
#[validate(email)]
email: String,
}
impl SubscriberEmail {
pub fn parse(email: String) -> Result<Self, anyhow::Error> {
let subscriber_email = SubscriberEmail { email };
if subscriber_email.validate().is_err() {
anyhow::bail!("{} is not a valid email.", subscriber_email.email);
}
Ok(subscriber_email)
}
}
impl AsRef<str> for SubscriberEmail {
fn as_ref(&self) -> &str {
self.email.as_str()
}
}
#[cfg(test)]
mod tests {
use super::SubscriberEmail;
use claims::assert_err;
use fake::Fake;
use fake::faker::internet::en::SafeEmail;
use fake::rand::SeedableRng;
use fake::rand::rngs::StdRng;
#[derive(Clone, Debug)]
struct ValidEmailFixture(pub String);
impl quickcheck::Arbitrary for ValidEmailFixture {
fn arbitrary(g: &mut quickcheck::Gen) -> Self {
let mut rng = StdRng::seed_from_u64(u64::arbitrary(g));
let email = SafeEmail().fake_with_rng(&mut rng);
Self(email)
}
}
#[test]
fn empty_string_is_rejected() {
let email = "".to_string();
assert_err!(SubscriberEmail::parse(email));
}
#[test]
fn email_missing_at_symbol_is_rejected() {
let email = "alphonse.paixoutlook.com".to_string();
assert_err!(SubscriberEmail::parse(email));
}
#[test]
fn email_missing_subject_is_rejected() {
let email = "@outlook.com".to_string();
assert_err!(SubscriberEmail::parse(email));
}
#[quickcheck_macros::quickcheck]
fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool {
SubscriberEmail::parse(valid_email.0).is_ok()
}
}

197
src/email_client.rs Normal file
View File

@@ -0,0 +1,197 @@
use crate::{configuration::EmailClientSettings, domain::SubscriberEmail};
use reqwest::Client;
use secrecy::{ExposeSecret, SecretString};
use std::time::Duration;
pub struct EmailClient {
http_client: Client,
base_url: reqwest::Url,
sender: SubscriberEmail,
authorization_token: SecretString,
}
impl EmailClient {
pub fn build(config: EmailClientSettings) -> Result<Self, anyhow::Error> {
let client = Self {
http_client: Client::builder()
.timeout(Duration::from_millis(config.timeout_milliseconds))
.build()
.unwrap(),
base_url: reqwest::Url::parse(&config.base_url)?,
sender: config.sender().map_err(|e| anyhow::anyhow!(e))?,
authorization_token: config.authorization_token,
};
Ok(client)
}
pub async fn send_email(
&self,
recipient: &SubscriberEmail,
subject: &str,
html_content: &str,
text_content: &str,
) -> Result<(), reqwest::Error> {
let url = self.base_url.join("email").unwrap();
let request_body = SendEmailRequest {
from: self.sender.as_ref(),
to: recipient.as_ref(),
subject,
text: text_content,
html: html_content,
};
self.http_client
.post(url)
.header("X-Requested-With", "XMLHttpRequest")
.header(
"Authorization",
format!("Bearer {}", self.authorization_token.expose_secret()),
)
.json(&request_body)
.send()
.await?
.error_for_status()?;
Ok(())
}
}
#[derive(serde::Serialize)]
struct SendEmailRequest<'a> {
from: &'a str,
to: &'a str,
subject: &'a str,
text: &'a str,
html: &'a str,
}
#[cfg(test)]
mod tests {
use crate::{
configuration::EmailClientSettings, domain::SubscriberEmail, email_client::EmailClient,
};
use claims::{assert_err, assert_ok};
use fake::{
Fake, Faker,
faker::{
internet::en::SafeEmail,
lorem::en::{Paragraph, Sentence},
},
};
use std::time::Duration;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{any, header, header_exists, method, path},
};
struct SendEmailBodyMatcher;
impl wiremock::Match for SendEmailBodyMatcher {
fn matches(&self, request: &wiremock::Request) -> bool {
let result: Result<serde_json::Value, _> = serde_json::from_slice(&request.body);
if let Ok(body) = result {
body.get("from").is_some()
&& body.get("to").is_some()
&& body.get("subject").is_some()
&& body.get("html").is_some()
&& body.get("text").is_some()
} else {
false
}
}
}
fn subject() -> String {
Sentence(1..2).fake()
}
fn content() -> String {
Paragraph(1..10).fake()
}
fn email() -> SubscriberEmail {
SubscriberEmail::parse(SafeEmail().fake()).unwrap()
}
fn email_client(base_url: String) -> EmailClient {
let sender_email = SafeEmail().fake();
let token: String = Faker.fake();
let settings = EmailClientSettings::new(base_url, sender_email, token, 200);
EmailClient::build(settings).unwrap()
}
#[tokio::test]
async fn send_email_sends_the_expected_request() {
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());
Mock::given(header_exists("Authorization"))
.and(header("Content-Type", "application/json"))
.and(header("X-Requested-With", "XMLHttpRequest"))
.and(path("email"))
.and(method("POST"))
.and(SendEmailBodyMatcher)
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
email_client
.send_email(&email(), &subject(), &content(), &content())
.await
.unwrap();
}
#[tokio::test]
async fn send_email_succeeds_if_the_server_returns_200() {
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());
Mock::given(any())
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
let response = email_client
.send_email(&email(), &subject(), &content(), &content())
.await;
assert_ok!(response);
}
#[tokio::test]
async fn send_email_fails_if_the_server_retuns_500() {
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());
Mock::given(any())
.respond_with(ResponseTemplate::new(500))
.expect(1)
.mount(&mock_server)
.await;
let response = email_client
.send_email(&email(), &subject(), &content(), &content())
.await;
assert_err!(response);
}
#[tokio::test]
async fn send_email_times_out_if_the_server_takes_too_long() {
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());
Mock::given(any())
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(180)))
.expect(1)
.mount(&mock_server)
.await;
let response = email_client
.send_email(&email(), &subject(), &content(), &content())
.await;
assert_err!(response);
}
}

5
src/idempotency.rs Normal file
View File

@@ -0,0 +1,5 @@
mod key;
mod persistance;
pub use key::*;
pub use persistance::*;

28
src/idempotency/key.rs Normal file
View File

@@ -0,0 +1,28 @@
pub struct IdempotencyKey(String);
impl TryFrom<String> for IdempotencyKey {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
if value.is_empty() {
return Err("The idempotency key cannot be empty.".into());
}
let max_length = 50;
if value.len() >= max_length {
return Err("The idempotency key must be shorter than {max_length} characters.".into());
}
Ok(Self(value))
}
}
impl From<IdempotencyKey> for String {
fn from(value: IdempotencyKey) -> Self {
value.0
}
}
impl AsRef<str> for IdempotencyKey {
fn as_ref(&self) -> &str {
&self.0
}
}

View File

@@ -0,0 +1,128 @@
use crate::idempotency::IdempotencyKey;
use axum::{
body::{self, Body},
http::{HeaderName, HeaderValue},
response::{IntoResponse, Response},
};
use reqwest::StatusCode;
use sqlx::{Executor, PgPool, Postgres, Transaction};
use std::str::FromStr;
use uuid::Uuid;
#[derive(Debug, sqlx::Type)]
#[sqlx(type_name = "header_pair")]
struct HeaderPairRecord {
name: String,
value: Vec<u8>,
}
pub async fn get_saved_response(
connection_pool: &PgPool,
idempotency_key: &IdempotencyKey,
user_id: Uuid,
) -> Result<Option<Response>, anyhow::Error> {
let saved_response = sqlx::query!(
r#"
SELECT
response_status_code as "response_status_code!",
response_headers as "response_headers!: Vec<HeaderPairRecord>",
response_body as "response_body!"
FROM idempotency
WHERE
user_id = $1
AND idempotency_key = $2
"#,
user_id,
idempotency_key.as_ref()
)
.fetch_optional(connection_pool)
.await?;
if let Some(r) = saved_response {
let status_code = StatusCode::from_u16(r.response_status_code.try_into()?)?;
let mut response = status_code.into_response();
for HeaderPairRecord { name, value } in r.response_headers {
response.headers_mut().insert(
HeaderName::from_str(&name).unwrap(),
HeaderValue::from_bytes(&value).unwrap(),
);
}
*response.body_mut() = r.response_body.into();
Ok(Some(response))
} else {
Ok(None)
}
}
pub async fn save_response(
mut transaction: Transaction<'static, Postgres>,
idempotency_key: &IdempotencyKey,
user_id: Uuid,
response: Response<Body>,
) -> Result<Response<Body>, anyhow::Error> {
let status_code = response.status().as_u16() as i16;
let headers = response
.headers()
.into_iter()
.map(|(name, value)| HeaderPairRecord {
name: name.to_string(),
value: value.as_bytes().to_vec(),
})
.collect::<Vec<_>>();
let (response_head, body) = response.into_parts();
let body = body::to_bytes(body, usize::MAX).await?.to_vec();
let query = sqlx::query_unchecked!(
r#"
UPDATE idempotency
SET
response_status_code = $3,
response_headers = $4,
response_body = $5
WHERE
user_id = $1
AND idempotency_key = $2
"#,
user_id,
idempotency_key.as_ref(),
status_code,
headers,
&body,
);
transaction.execute(query).await?;
transaction.commit().await?;
let mut r = response_head.into_response();
*r.body_mut() = body.into();
Ok(r)
}
pub enum NextAction {
StartProcessing(Transaction<'static, Postgres>),
ReturnSavedResponse(Response),
}
pub async fn try_processing(
connection_pool: &PgPool,
idempotency_key: &IdempotencyKey,
user_id: Uuid,
) -> Result<NextAction, anyhow::Error> {
let mut transaction = connection_pool.begin().await?;
let query = sqlx::query!(
r#"
INSERT INTO idempotency (user_id, idempotency_key, created_at)
VALUES ($1, $2, now())
ON CONFLICT DO NOTHING
"#,
user_id,
idempotency_key.as_ref()
);
let n_inserted_rows = transaction.execute(query).await?.rows_affected();
if n_inserted_rows > 0 {
Ok(NextAction::StartProcessing(transaction))
} else {
let saved_response = get_saved_response(connection_pool, idempotency_key, user_id)
.await?
.ok_or_else(|| anyhow::anyhow!("Could not find saved response."))?;
Ok(NextAction::ReturnSavedResponse(saved_response))
}
}

View File

@@ -0,0 +1,171 @@
use crate::{configuration::Settings, domain::SubscriberEmail, email_client::EmailClient};
use sqlx::{Executor, PgPool, Postgres, Row, Transaction, postgres::PgPoolOptions};
use std::time::Duration;
use tracing::{Span, field::display};
use uuid::Uuid;
pub async fn run_worker_until_stopped(configuration: Settings) -> Result<(), anyhow::Error> {
let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
let email_client = EmailClient::build(configuration.email_client).unwrap();
worker_loop(connection_pool, email_client).await
}
async fn worker_loop(
connection_pool: PgPool,
email_client: EmailClient,
) -> Result<(), anyhow::Error> {
loop {
match try_execute_task(&connection_pool, &email_client).await {
Ok(ExecutionOutcome::EmptyQueue) => tokio::time::sleep(Duration::from_secs(10)).await,
Ok(ExecutionOutcome::TaskCompleted) => (),
Err(_) => tokio::time::sleep(Duration::from_secs(1)).await,
}
}
}
pub enum ExecutionOutcome {
TaskCompleted,
EmptyQueue,
}
#[tracing::instrument(
skip_all,
fields(
newsletter_issue_id=tracing::field::Empty,
subscriber_email=tracing::field::Empty
),
err
)]
pub async fn try_execute_task(
connection_pool: &PgPool,
email_client: &EmailClient,
) -> Result<ExecutionOutcome, anyhow::Error> {
let task = dequeue_task(connection_pool).await?;
if task.is_none() {
return Ok(ExecutionOutcome::EmptyQueue);
}
let (transaction, task) = task.unwrap();
Span::current()
.record("newsletter_issue_id", display(task.newsletter_issue_id))
.record("subscriber_email", display(&task.subscriber_email));
match SubscriberEmail::parse(task.subscriber_email.clone()) {
Ok(email) => {
let mut issue = get_issue(connection_pool, task.newsletter_issue_id).await?;
issue.inject_unsubscribe_token(&task.unsubscribe_token);
if let Err(e) = email_client
.send_email(
&email,
&issue.title,
&issue.html_content,
&issue.text_content,
)
.await
{
tracing::error!(
error.message = %e,
"Failed to deliver issue to confirmed subscriber. Skipping."
);
}
}
Err(e) => {
tracing::error!(
error.message = %e,
"Skipping a subscriber. Their stored contact details are invalid."
);
}
}
delete_task(
transaction,
task.newsletter_issue_id,
&task.subscriber_email,
)
.await?;
Ok(ExecutionOutcome::TaskCompleted)
}
struct NewsletterIssue {
title: String,
text_content: String,
html_content: String,
}
impl NewsletterIssue {
fn inject_unsubscribe_token(&mut self, token: &str) {
self.text_content = self.text_content.replace("UNSUBSCRIBE_TOKEN", token);
self.html_content = self.html_content.replace("UNSUBSCRIBE_TOKEN", token);
}
}
#[tracing::instrument(skip_all)]
async fn get_issue(
connection_pool: &PgPool,
issue_id: Uuid,
) -> Result<NewsletterIssue, anyhow::Error> {
let issue = sqlx::query_as!(
NewsletterIssue,
r#"
SELECT title, text_content, html_content
FROM newsletter_issues
WHERE newsletter_issue_id = $1
"#,
issue_id
)
.fetch_one(connection_pool)
.await?;
Ok(issue)
}
pub struct Task {
pub newsletter_issue_id: Uuid,
pub subscriber_email: String,
pub unsubscribe_token: String,
}
#[tracing::instrument(skip_all)]
async fn dequeue_task(
connection_pool: &PgPool,
) -> Result<Option<(Transaction<'static, Postgres>, Task)>, anyhow::Error> {
let mut transaction = connection_pool.begin().await?;
let query = sqlx::query!(
r#"
SELECT newsletter_issue_id, subscriber_email, unsubscribe_token
FROM issue_delivery_queue
FOR UPDATE
SKIP LOCKED
LIMIT 1
"#
);
let r = transaction.fetch_optional(query).await?;
if let Some(row) = r {
let task = Task {
newsletter_issue_id: row.get("newsletter_issue_id"),
subscriber_email: row.get("subscriber_email"),
unsubscribe_token: row.get("unsubscribe_token"),
};
Ok(Some((transaction, task)))
} else {
Ok(None)
}
}
#[tracing::instrument(skip_all)]
async fn delete_task(
mut transaction: Transaction<'static, Postgres>,
issue_id: Uuid,
email: &str,
) -> Result<(), anyhow::Error> {
let query = sqlx::query!(
r#"
DELETE FROM issue_delivery_queue
WHERE
newsletter_issue_id = $1
AND subscriber_email = $2
"#,
issue_id,
email
);
transaction.execute(query).await?;
transaction.commit().await?;
Ok(())
}

11
src/lib.rs Normal file
View File

@@ -0,0 +1,11 @@
pub mod authentication;
pub mod configuration;
pub mod domain;
pub mod email_client;
pub mod idempotency;
pub mod issue_delivery_worker;
pub mod routes;
pub mod session_state;
pub mod startup;
pub mod telemetry;
pub mod templates;

View File

@@ -1,9 +1,22 @@
use axum::{Router, routing::get};
use zero2prod::{
configuration::get_configuration, issue_delivery_worker::run_worker_until_stopped,
startup::Application, telemetry::init_subscriber,
};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
async fn main() -> Result<(), anyhow::Error> {
init_subscriber(std::io::stdout);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
let configuration = get_configuration().expect("Failed to read configuration");
let application = Application::build(configuration.clone()).await?;
let application_task = tokio::spawn(application.run_until_stopped());
let worker_task = tokio::spawn(run_worker_until_stopped(configuration));
tokio::select! {
_ = application_task => {},
_ = worker_task => {},
};
Ok(())
}

163
src/routes.rs Normal file
View File

@@ -0,0 +1,163 @@
mod admin;
mod health_check;
mod home;
mod login;
mod posts;
mod subscriptions;
mod subscriptions_confirm;
mod unsubscribe;
pub use admin::*;
use askama::Template;
use axum::{
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
pub use health_check::*;
pub use home::*;
pub use login::*;
pub use posts::*;
use rand::{Rng, distr::Alphanumeric};
use reqwest::StatusCode;
pub use subscriptions::*;
pub use subscriptions_confirm::*;
pub use unsubscribe::*;
use crate::{
authentication::AuthError,
templates::{InternalErrorTemplate, MessageTemplate, NotFoundTemplate},
};
pub fn generate_token() -> String {
let mut rng = rand::rng();
std::iter::repeat_with(|| rng.sample(Alphanumeric))
.map(char::from)
.take(25)
.collect()
}
fn error_chain_fmt(e: &impl std::error::Error, f: &mut std::fmt::Formatter) -> std::fmt::Result {
writeln!(f, "{}", e)?;
let mut current = e.source();
while let Some(cause) = current {
write!(f, "Caused by:\n\t{}", cause)?;
current = cause.source();
if current.is_some() {
writeln!(f)?;
}
}
Ok(())
}
#[derive(thiserror::Error)]
pub enum AppError {
#[error("An unexpected error was encountered.")]
UnexpectedError {
#[source]
error: anyhow::Error,
full_page: bool,
},
#[error("A validation error happened.")]
FormError(#[source] anyhow::Error),
#[error("Authentication is required.")]
NotAuthenticated,
}
impl From<anyhow::Error> for AppError {
fn from(value: anyhow::Error) -> Self {
Self::UnexpectedError {
error: value,
full_page: false,
}
}
}
impl AppError {
pub fn unexpected_page(error: anyhow::Error) -> Self {
Self::UnexpectedError {
error,
full_page: true,
}
}
pub fn unexpected_message(error: anyhow::Error) -> Self {
Self::UnexpectedError {
error,
full_page: false,
}
}
}
impl std::fmt::Debug for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
tracing::error!("{:?}", self);
match &self {
AppError::UnexpectedError {
error: _,
full_page,
} => {
let html = if *full_page {
Html(InternalErrorTemplate.render().unwrap())
} else {
let template = MessageTemplate::Error {
message: "An internal server error occured.".into(),
};
Html(template.render().unwrap())
};
(StatusCode::INTERNAL_SERVER_ERROR, html).into_response()
}
AppError::FormError(error) => {
let template = MessageTemplate::Error {
message: error.to_string(),
};
Html(template.render().unwrap()).into_response()
}
AppError::NotAuthenticated => {
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/login".parse().unwrap());
(StatusCode::OK, headers).into_response()
}
}
}
}
impl From<AdminError> for AppError {
fn from(value: AdminError) -> Self {
match value {
AdminError::UnexpectedError(error) => AppError::unexpected_message(error),
AdminError::NotAuthenticated => AppError::NotAuthenticated,
AdminError::ChangePassword(s) => AppError::FormError(anyhow::anyhow!(s)),
AdminError::Publish(e) => AppError::FormError(e),
AdminError::Idempotency(s) => AppError::UnexpectedError {
error: anyhow::anyhow!(s),
full_page: false,
},
}
}
}
impl From<AuthError> for AppError {
fn from(value: AuthError) -> Self {
match value {
AuthError::UnexpectedError(error) => AppError::unexpected_message(error),
AuthError::InvalidCredentials(error) => {
AppError::FormError(error.context("Invalid credentials."))
}
}
}
}
pub async fn not_found() -> Response {
(
StatusCode::NOT_FOUND,
Html(NotFoundTemplate.render().unwrap()),
)
.into_response()
}

62
src/routes/admin.rs Normal file
View File

@@ -0,0 +1,62 @@
mod change_password;
mod dashboard;
mod logout;
mod newsletters;
mod posts;
use crate::{
authentication::AuthenticatedUser,
routes::{AppError, error_chain_fmt},
session_state::TypedSession,
};
use axum::{extract::Request, middleware::Next, response::Response};
pub use change_password::*;
pub use dashboard::*;
pub use logout::*;
pub use newsletters::*;
pub use posts::*;
#[derive(thiserror::Error)]
pub enum AdminError {
#[error("Something went wrong while performing an admin action.")]
UnexpectedError(#[from] anyhow::Error),
#[error("Trying to access admin dashboard without authentication.")]
NotAuthenticated,
#[error("Updating password failed.")]
ChangePassword(String),
#[error("Could not publish newsletter.")]
Publish(#[source] anyhow::Error),
#[error("The idempotency key was invalid.")]
Idempotency(String),
}
impl std::fmt::Debug for AdminError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}
pub async fn require_auth(
session: TypedSession,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
let user_id = session
.get_user_id()
.await
.map_err(|e| AdminError::UnexpectedError(e.into()))?
.ok_or(AdminError::NotAuthenticated)?;
let username = session
.get_username()
.await
.map_err(|e| AdminError::UnexpectedError(e.into()))?
.ok_or(AdminError::UnexpectedError(anyhow::anyhow!(
"Could not find username in session."
)))?;
request
.extensions_mut()
.insert(AuthenticatedUser { user_id, username });
Ok(next.run(request).await)
}

View File

@@ -0,0 +1,64 @@
use crate::{
authentication::{self, AuthError, AuthenticatedUser, Credentials, validate_credentials},
routes::{AdminError, AppError},
startup::AppState,
templates::MessageTemplate,
};
use askama::Template;
use axum::{
Extension, Form,
extract::State,
response::{Html, IntoResponse, Response},
};
use secrecy::{ExposeSecret, SecretString};
#[derive(serde::Deserialize)]
pub struct PasswordFormData {
pub current_password: SecretString,
pub new_password: SecretString,
pub new_password_check: SecretString,
}
pub async fn change_password(
Extension(AuthenticatedUser { user_id, username }): Extension<AuthenticatedUser>,
State(AppState {
connection_pool, ..
}): State<AppState>,
Form(form): Form<PasswordFormData>,
) -> Result<Response, AppError> {
let credentials = Credentials {
username,
password: form.current_password,
};
if form.new_password.expose_secret() != form.new_password_check.expose_secret() {
Err(AdminError::ChangePassword(
"You entered two different passwords - the field values must match.".to_string(),
)
.into())
} else if let Err(e) = validate_credentials(credentials, &connection_pool).await {
match e {
AuthError::UnexpectedError(error) => Err(AdminError::UnexpectedError(error).into()),
AuthError::InvalidCredentials(_) => Err(AdminError::ChangePassword(
"The current password is incorrect.".to_string(),
)
.into()),
}
} else if let Err(e) = verify_password(form.new_password.expose_secret()) {
Err(AdminError::ChangePassword(e).into())
} else {
authentication::change_password(user_id, form.new_password, &connection_pool)
.await
.map_err(|e| AdminError::ChangePassword(e.to_string()))?;
let template = MessageTemplate::Success {
message: "Your password has been changed.".to_string(),
};
Ok(Html(template.render().unwrap()).into_response())
}
}
fn verify_password(password: &str) -> Result<(), String> {
if password.len() < 12 || password.len() > 128 {
return Err("The password must contain between 12 and 128 characters.".into());
}
Ok(())
}

View File

@@ -0,0 +1,20 @@
use crate::{authentication::AuthenticatedUser, templates::DashboardTemplate};
use askama::Template;
use axum::{
Extension,
response::{Html, IntoResponse, Response},
};
use uuid::Uuid;
pub async fn admin_dashboard(
Extension(AuthenticatedUser { username, .. }): Extension<AuthenticatedUser>,
) -> Response {
let idempotency_key_1 = Uuid::new_v4().to_string();
let idempotency_key_2 = Uuid::new_v4().to_string();
let template = DashboardTemplate {
username,
idempotency_key_1,
idempotency_key_2,
};
Html(template.render().unwrap()).into_response()
}

View File

@@ -0,0 +1,13 @@
use crate::session_state::TypedSession;
use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
#[tracing::instrument(name = "Logging out", skip(session))]
pub async fn logout(session: TypedSession) -> Response {
session.clear().await;
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/login".parse().unwrap());
(StatusCode::OK, headers).into_response()
}

View File

@@ -0,0 +1,129 @@
use crate::{
authentication::AuthenticatedUser,
idempotency::{IdempotencyKey, save_response, try_processing},
routes::{AdminError, AppError},
startup::AppState,
templates::{EmailTemplate, MessageTemplate, StandaloneEmailTemplate},
};
use anyhow::Context;
use askama::Template;
use axum::{
Extension, Form,
extract::State,
response::{Html, IntoResponse, Response},
};
use sqlx::{Executor, Postgres, Transaction};
use uuid::Uuid;
#[derive(serde::Deserialize)]
pub struct BodyData {
title: String,
html: String,
text: String,
idempotency_key: String,
}
#[tracing::instrument(skip_all)]
pub async fn insert_newsletter_issue(
transaction: &mut Transaction<'static, Postgres>,
title: &str,
email_template: &dyn EmailTemplate,
) -> Result<Uuid, sqlx::Error> {
let newsletter_issue_id = Uuid::new_v4();
let query = sqlx::query!(
r#"
INSERT INTO newsletter_issues (
newsletter_issue_id, title, text_content, html_content, published_at
)
VALUES ($1, $2, $3, $4, now())
"#,
newsletter_issue_id,
title,
email_template.text(),
email_template.html(),
);
transaction.execute(query).await?;
Ok(newsletter_issue_id)
}
#[tracing::instrument(skip_all)]
pub async fn enqueue_delivery_tasks(
transaction: &mut Transaction<'static, Postgres>,
newsletter_issue_id: Uuid,
) -> Result<(), sqlx::Error> {
let query = sqlx::query!(
r#"
INSERT INTO issue_delivery_queue (
newsletter_issue_id,
subscriber_email,
unsubscribe_token
)
SELECT $1, email, unsubscribe_token
FROM subscriptions
WHERE status = 'confirmed'
"#,
newsletter_issue_id,
);
transaction.execute(query).await?;
Ok(())
}
#[tracing::instrument(name = "Publishing a newsletter", skip(connection_pool, form))]
pub async fn publish_newsletter(
State(AppState {
connection_pool,
base_url,
..
}): State<AppState>,
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Form(form): Form<BodyData>,
) -> Result<Response, AppError> {
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
let idempotency_key: IdempotencyKey = form
.idempotency_key
.try_into()
.map_err(AdminError::Idempotency)?;
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? {
crate::idempotency::NextAction::StartProcessing(t) => t,
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
return Ok(response);
}
};
let email_template = StandaloneEmailTemplate {
base_url: &base_url,
text_content: &form.text,
html_content: &form.html,
};
let issue_id = insert_newsletter_issue(&mut transaction, &form.title, &email_template)
.await
.context("Failed to store newsletter issue details.")?;
enqueue_delivery_tasks(&mut transaction, issue_id)
.await
.context("Failed to enqueue delivery tasks.")?;
let message = format!(
r#"The newsletter issue "{}" has been published!"#,
form.title
);
let template = MessageTemplate::Success { message };
let response = Html(template.render().unwrap()).into_response();
let response = save_response(transaction, &idempotency_key, user_id, response)
.await
.map_err(AdminError::UnexpectedError)?;
Ok(response)
}
fn validate_form(form: &BodyData) -> Result<(), &'static str> {
if form.title.is_empty() {
return Err("The title was empty.");
}
if form.html.is_empty() || form.text.is_empty() {
return Err("The content was empty.");
}
Ok(())
}

123
src/routes/admin/posts.rs Normal file
View File

@@ -0,0 +1,123 @@
use crate::{
authentication::AuthenticatedUser,
idempotency::{IdempotencyKey, save_response, try_processing},
routes::{AdminError, AppError, enqueue_delivery_tasks, insert_newsletter_issue},
startup::AppState,
templates::{MessageTemplate, NewPostEmailTemplate},
};
use anyhow::Context;
use askama::Template;
use axum::{
Extension, Form,
extract::State,
response::{Html, IntoResponse, Response},
};
use chrono::Utc;
use sqlx::{Executor, Postgres, Transaction};
use uuid::Uuid;
#[derive(serde::Deserialize)]
pub struct CreatePostForm {
title: String,
content: String,
idempotency_key: String,
}
fn validate_form(form: &CreatePostForm) -> Result<(), anyhow::Error> {
if form.title.is_empty() || form.content.is_empty() {
anyhow::bail!("Fields cannot be empty.")
} else {
Ok(())
}
}
#[tracing::instrument(name = "Creating a post", skip(connection_pool, form))]
pub async fn create_post(
State(AppState {
connection_pool,
base_url,
..
}): State<AppState>,
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Form(form): Form<CreatePostForm>,
) -> Result<Response, AppError> {
validate_form(&form).map_err(AdminError::Publish)?;
let idempotency_key: IdempotencyKey = form
.idempotency_key
.try_into()
.map_err(AdminError::Idempotency)?;
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? {
crate::idempotency::NextAction::StartProcessing(t) => t,
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
return Ok(response);
}
};
let post_id = insert_post(&mut transaction, &form.title, &form.content, &user_id)
.await
.context("Failed to insert new post in the database.")?;
let newsletter_uuid = create_newsletter(&mut transaction, &base_url, &form.title, &post_id)
.await
.context("Failed to create newsletter.")?;
enqueue_delivery_tasks(&mut transaction, newsletter_uuid)
.await
.context("Failed to enqueue delivery tasks.")?;
let template = MessageTemplate::Success {
message: "Your new post has been saved. Subscribers will be notified.".into(),
};
let response = Html(template.render().unwrap()).into_response();
let response = save_response(transaction, &idempotency_key, user_id, response)
.await
.map_err(AdminError::UnexpectedError)?;
Ok(response)
}
#[tracing::instrument(
name = "Saving new post in the database",
skip(transaction, title, content, author)
)]
pub async fn insert_post(
transaction: &mut Transaction<'static, Postgres>,
title: &str,
content: &str,
author: &Uuid,
) -> Result<Uuid, sqlx::Error> {
let post_id = Uuid::new_v4();
let query = sqlx::query!(
r#"
INSERT INTO posts (post_id, author_id, title, content, published_at)
VALUES ($1, $2, $3, $4, $5)
"#,
post_id,
author,
title,
content,
Utc::now()
);
transaction.execute(query).await?;
Ok(post_id)
}
#[tracing::instrument(
name = "Creating newsletter for new post",
skip(transaction, post_title, post_id)
)]
pub async fn create_newsletter(
transaction: &mut Transaction<'static, Postgres>,
base_url: &str,
post_title: &str,
post_id: &Uuid,
) -> Result<Uuid, sqlx::Error> {
let template = NewPostEmailTemplate {
base_url,
post_title,
post_id,
post_excerpt: "",
};
insert_newsletter_issue(transaction, post_title, &template).await
}

View File

@@ -0,0 +1,5 @@
use axum::{http::StatusCode, response::IntoResponse};
pub async fn health_check() -> impl IntoResponse {
StatusCode::OK
}

8
src/routes/home.rs Normal file
View File

@@ -0,0 +1,8 @@
use askama::Template;
use axum::response::Html;
use crate::templates::HomeTemplate;
pub async fn home() -> Html<String> {
Html(HomeTemplate.render().unwrap())
}

66
src/routes/login.rs Normal file
View File

@@ -0,0 +1,66 @@
use crate::{
authentication::{Credentials, validate_credentials},
routes::AppError,
session_state::TypedSession,
startup::AppState,
templates::LoginTemplate,
};
use anyhow::Context;
use askama::Template;
use axum::{
Form,
extract::State,
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
use axum::{http::StatusCode, response::Redirect};
use secrecy::SecretString;
#[derive(serde::Deserialize)]
pub struct LoginFormData {
username: String,
password: SecretString,
}
pub async fn get_login(session: TypedSession) -> Result<Response, AppError> {
if session
.get_user_id()
.await
.context("Failed to retrieve user id from data store.")?
.is_some()
{
Ok(Redirect::to("/admin/dashboard").into_response())
} else {
Ok(Html(LoginTemplate.render().unwrap()).into_response())
}
}
pub async fn post_login(
session: TypedSession,
State(AppState {
connection_pool, ..
}): State<AppState>,
Form(form): Form<LoginFormData>,
) -> Result<Response, AppError> {
let credentials = Credentials {
username: form.username.clone(),
password: form.password,
};
tracing::Span::current().record("username", tracing::field::display(&credentials.username));
let user_id = validate_credentials(credentials, &connection_pool).await?;
tracing::Span::current().record("user_id", tracing::field::display(&user_id));
session.renew().await.context("Failed to renew session.")?;
session
.insert_user_id(user_id)
.await
.context("Failed to insert user id in session data store.")?;
session
.insert_username(form.username)
.await
.context("Failed to insert username in session data store.")?;
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());
Ok((StatusCode::OK, headers).into_response())
}

74
src/routes/posts.rs Normal file
View File

@@ -0,0 +1,74 @@
use crate::{
domain::PostEntry,
routes::AppError,
startup::AppState,
templates::{PostTemplate, PostsTemplate},
};
use anyhow::Context;
use askama::Template;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse, Response},
};
use sqlx::PgPool;
use uuid::Uuid;
pub async fn list_posts(
State(AppState {
connection_pool, ..
}): State<AppState>,
) -> Result<Response, AppError> {
let posts = get_latest_posts(&connection_pool, 5)
.await
.context("Could not fetch latest posts")
.map_err(AppError::unexpected_page)?;
let template = PostsTemplate { posts };
Ok(Html(template.render().unwrap()).into_response())
}
async fn get_latest_posts(connection_pool: &PgPool, n: i64) -> Result<Vec<PostEntry>, sqlx::Error> {
sqlx::query_as!(
PostEntry,
r#"
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at
FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id
ORDER BY p.published_at DESC
LIMIT $1
"#,
n
)
.fetch_all(connection_pool)
.await
}
pub async fn see_post(
State(AppState {
connection_pool, ..
}): State<AppState>,
Path(post_id): Path<Uuid>,
) -> Result<Response, AppError> {
let post = get_post(&connection_pool, post_id)
.await
.context(format!("Failed to fetch post #{}", post_id))
.map_err(AppError::unexpected_page)?
.to_html()
.context("Could not render markdown with extension.")?;
let template = PostTemplate { post };
Ok(Html(template.render().unwrap()).into_response())
}
async fn get_post(connection_pool: &PgPool, post_id: Uuid) -> Result<PostEntry, sqlx::Error> {
sqlx::query_as!(
PostEntry,
r#"
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at
FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id
WHERE p.post_id = $1
"#,
post_id
)
.fetch_one(connection_pool)
.await
}

158
src/routes/subscriptions.rs Normal file
View File

@@ -0,0 +1,158 @@
use crate::{
domain::{NewSubscriber, SubscriberEmail},
email_client::EmailClient,
routes::{AppError, generate_token},
startup::AppState,
templates::MessageTemplate,
};
use anyhow::Context;
use askama::Template;
use axum::{
Form,
extract::State,
response::{Html, IntoResponse, Response},
};
use chrono::Utc;
use serde::Deserialize;
use sqlx::{Executor, Postgres, Transaction};
use uuid::Uuid;
#[tracing::instrument(
name = "Adding a new subscriber",
skip(connection_pool, email_client, base_url, form),
fields(
subscriber_email = %form.email,
)
)]
pub async fn subscribe(
State(AppState {
connection_pool,
email_client,
base_url,
..
}): State<AppState>,
Form(form): Form<SubscriptionFormData>,
) -> Result<Response, AppError> {
let new_subscriber = form
.try_into()
.context("Failed to parse subscription form data.")
.map_err(AppError::FormError)?;
let mut transaction = connection_pool
.begin()
.await
.context("Failed to acquire a Postgres connection from the pool.")?;
let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber)
.await
.context("Failed to insert new subscriber in the database.")?;
let subscription_token = generate_token();
store_token(&mut transaction, &subscription_token, &subscriber_id)
.await
.context("Failed to store the confirmation token for a new subscriber.")?;
send_confirmation_email(
&email_client,
&new_subscriber,
&base_url,
&subscription_token,
)
.await
.context("Failed to send a confirmation email.")?;
transaction
.commit()
.await
.context("Failed to commit the database transaction to store a new subscriber.")?;
let template = MessageTemplate::Success {
message: "A confirmation email has been sent.".to_string(),
};
Ok(Html(template.render().unwrap()).into_response())
}
#[tracing::instrument(
name = "Saving new subscriber details in the database",
skip(transaction, new_subscriber)
)]
pub async fn insert_subscriber(
transaction: &mut Transaction<'_, Postgres>,
new_subscriber: &NewSubscriber,
) -> Result<Uuid, sqlx::Error> {
let subscriber_id = Uuid::new_v4();
let query = sqlx::query!(
r#"
INSERT INTO subscriptions (id, email, subscribed_at, status)
VALUES ($1, $2, $3, 'pending_confirmation')
"#,
subscriber_id,
new_subscriber.email.as_ref(),
Utc::now()
);
transaction.execute(query).await?;
Ok(subscriber_id)
}
#[tracing::instrument(
name = "Store subscription token in the database",
skip(transaction, subscription_token)
)]
async fn store_token(
transaction: &mut Transaction<'_, Postgres>,
subscription_token: &str,
subscriber_id: &Uuid,
) -> Result<(), sqlx::Error> {
let query = sqlx::query!(
r#"
INSERT INTO subscription_tokens (subscription_token, subscriber_id)
VALUES ($1, $2)
"#,
subscription_token,
subscriber_id,
);
transaction.execute(query).await?;
Ok(())
}
#[tracing::instrument(
name = "Send a confirmation email to a new subscriber",
skip(email_client, new_subscriber, base_url, subscription_token)
)]
pub async fn send_confirmation_email(
email_client: &EmailClient,
new_subscriber: &NewSubscriber,
base_url: &str,
subscription_token: &str,
) -> Result<(), reqwest::Error> {
let confirmation_link = format!(
"{}/subscriptions/confirm?subscription_token={}",
base_url, subscription_token
);
let html_content = format!(
"Welcome to our newsletter!<br />\
Click <a href=\"{}\">here</a> to confirm your subscription.",
confirmation_link
);
let text_content = format!(
"Welcome to our newsletter!\nVisit {} to confirm your subscription.",
confirmation_link
);
email_client
.send_email(
&new_subscriber.email,
"Welcome!",
&html_content,
&text_content,
)
.await
}
#[derive(Debug, Deserialize)]
pub struct SubscriptionFormData {
email: String,
}
impl TryFrom<SubscriptionFormData> for NewSubscriber {
type Error = anyhow::Error;
fn try_from(value: SubscriptionFormData) -> Result<Self, Self::Error> {
let email = SubscriberEmail::parse(value.email)?;
Ok(Self { email })
}
}

View File

@@ -0,0 +1,84 @@
use crate::{routes::generate_token, startup::AppState, templates::ConfirmTemplate};
use askama::Template;
use axum::{
extract::{Query, State},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
#[tracing::instrument(name = "Confirming new subscriber", skip(params))]
pub async fn confirm(
State(AppState {
connection_pool, ..
}): State<AppState>,
Query(params): Query<Params>,
) -> Response {
let Ok(subscriber_id) =
get_subscriber_id_from_token(&connection_pool, &params.subscription_token).await
else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
if let Some(subscriber_id) = subscriber_id {
if confirm_subscriber(&connection_pool, &subscriber_id)
.await
.is_err()
{
StatusCode::INTERNAL_SERVER_ERROR.into_response()
} else {
Html(ConfirmTemplate.render().unwrap()).into_response()
}
} else {
StatusCode::UNAUTHORIZED.into_response()
}
}
#[tracing::instrument(
name = "Mark subscriber as confirmed",
skip(connection_pool, subscriber_id)
)]
async fn confirm_subscriber(
connection_pool: &PgPool,
subscriber_id: &Uuid,
) -> Result<(), sqlx::Error> {
sqlx::query!(
"UPDATE subscriptions SET status = 'confirmed', unsubscribe_token = $1 WHERE id = $2",
generate_token(),
subscriber_id
)
.execute(connection_pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(())
}
#[tracing::instrument(
name = "Get subscriber_id from token",
skip(connection, subscription_token)
)]
async fn get_subscriber_id_from_token(
connection: &PgPool,
subscription_token: &str,
) -> Result<Option<Uuid>, sqlx::Error> {
let saved = sqlx::query!(
"SELECT subscriber_id FROM subscription_tokens WHERE subscription_token = $1",
subscription_token
)
.fetch_optional(connection)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(saved.map(|r| r.subscriber_id))
}
#[derive(Debug, Deserialize)]
pub struct Params {
subscription_token: String,
}

44
src/routes/unsubscribe.rs Normal file
View File

@@ -0,0 +1,44 @@
use crate::{
routes::AppError,
startup::AppState,
templates::{NotFoundTemplate, UnsubscribeTemplate},
};
use anyhow::Context;
use askama::Template;
use axum::{
extract::{Query, State},
response::{Html, IntoResponse, Response},
};
use reqwest::StatusCode;
use sqlx::Executor;
#[derive(serde::Deserialize)]
pub struct UnsubQueryParams {
token: String,
}
pub async fn unsubscribe(
Query(UnsubQueryParams { token }): Query<UnsubQueryParams>,
State(AppState {
connection_pool, ..
}): State<AppState>,
) -> Result<Response, AppError> {
let query = sqlx::query!(
"DELETE FROM subscriptions WHERE unsubscribe_token = $1",
token
);
let result = connection_pool
.execute(query)
.await
.context("Could not update subscriptions table.")?;
if result.rows_affected() == 0 {
Ok((
StatusCode::NOT_FOUND,
Html(NotFoundTemplate.render().unwrap()),
)
.into_response())
} else {
Ok(Html(UnsubscribeTemplate.render().unwrap()).into_response())
}
}

53
src/session_state.rs Normal file
View File

@@ -0,0 +1,53 @@
use axum::{extract::FromRequestParts, http::request::Parts};
use std::result;
use tower_sessions::{Session, session::Error};
use uuid::Uuid;
pub struct TypedSession(Session);
type Result<T> = result::Result<T, Error>;
impl TypedSession {
const USER_ID_KEY: &'static str = "user_id";
const USERNAME_KEY: &'static str = "username";
pub async fn renew(&self) -> Result<()> {
self.0.cycle_id().await
}
pub async fn insert_user_id(&self, user_id: Uuid) -> Result<()> {
self.0.insert(Self::USER_ID_KEY, user_id).await
}
pub async fn get_user_id(&self) -> Result<Option<Uuid>> {
self.0.get(Self::USER_ID_KEY).await
}
pub async fn insert_username(&self, username: String) -> Result<()> {
self.0.insert(Self::USERNAME_KEY, username).await
}
pub async fn get_username(&self) -> Result<Option<String>> {
self.0.get(Self::USERNAME_KEY).await
}
pub async fn clear(&self) {
self.0.clear().await;
}
}
impl<S> FromRequestParts<S> for TypedSession
where
S: Sync + Send,
{
type Rejection = <Session as FromRequestParts<S>>::Rejection;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> result::Result<Self, Self::Rejection> {
Session::from_request_parts(parts, state)
.await
.map(TypedSession)
}
}

154
src/startup.rs Normal file
View File

@@ -0,0 +1,154 @@
use crate::{configuration::Settings, email_client::EmailClient, routes::require_auth, routes::*};
use axum::{
Router,
extract::MatchedPath,
http::Request,
middleware,
routing::{get, post},
};
use axum_server::tls_rustls::RustlsConfig;
use secrecy::ExposeSecret;
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::{net::TcpListener, sync::Arc};
use tower_http::{services::ServeDir, trace::TraceLayer};
use tower_sessions::SessionManagerLayer;
use tower_sessions_redis_store::{
RedisStore,
fred::prelude::{ClientLike, Config, Pool},
};
use uuid::Uuid;
#[derive(Clone)]
pub struct AppState {
pub connection_pool: PgPool,
pub email_client: Arc<EmailClient>,
pub base_url: String,
}
pub struct Application {
listener: TcpListener,
router: Router,
tls_config: Option<RustlsConfig>,
}
impl Application {
pub async fn build(configuration: Settings) -> Result<Self, anyhow::Error> {
let address = format!(
"{}:{}",
configuration.application.host, configuration.application.port
);
let connection_pool =
PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
let email_client = EmailClient::build(configuration.email_client).unwrap();
let pool = Pool::new(
Config::from_url(configuration.redis_uri.expose_secret())
.expect("Failed to parse Redis URL string"),
None,
None,
None,
6,
)
.unwrap();
pool.connect();
pool.wait_for_connect().await.unwrap();
let redis_store = RedisStore::new(pool);
let router = app(
connection_pool,
email_client,
configuration.application.base_url,
redis_store,
);
let tls_config = if configuration.require_tls {
Some(
RustlsConfig::from_pem_file(
std::env::var("APP_TLS_CERT")
.expect("Failed to read TLS certificate environment variable"),
std::env::var("APP_TLS_KEY")
.expect("Feiled to read TLS private key environment variable"),
)
.await
.expect("Could not create TLS configuration"),
)
} else {
None
};
let listener = TcpListener::bind(address).unwrap();
Ok(Self {
listener,
router,
tls_config,
})
}
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
tracing::debug!("listening on {}", self.local_addr());
if let Some(tls_config) = self.tls_config {
axum_server::from_tcp_rustls(self.listener, tls_config)
.serve(self.router.into_make_service())
.await
} else {
axum_server::from_tcp(self.listener)
.serve(self.router.into_make_service())
.await
}
}
pub fn local_addr(&self) -> String {
self.listener.local_addr().unwrap().to_string()
}
pub fn port(&self) -> u16 {
self.listener.local_addr().unwrap().port()
}
}
pub fn app(
connection_pool: PgPool,
email_client: EmailClient,
base_url: String,
redis_store: RedisStore<Pool>,
) -> Router {
let app_state = AppState {
connection_pool,
email_client: Arc::new(email_client),
base_url,
};
let admin_routes = Router::new()
.route("/dashboard", get(admin_dashboard))
.route("/password", post(change_password))
.route("/newsletters", post(publish_newsletter))
.route("/posts", post(create_post))
.route("/logout", post(logout))
.layer(middleware::from_fn(require_auth));
Router::new()
.nest_service("/assets", ServeDir::new("assets"))
.route("/", get(home))
.route("/login", get(get_login).post(post_login))
.route("/health_check", get(health_check))
.route("/subscriptions", post(subscribe))
.route("/subscriptions/confirm", get(confirm))
.route("/unsubscribe", get(unsubscribe))
.route("/posts", get(list_posts))
.route("/posts/{post_id}", get(see_post))
.nest("/admin", admin_routes)
.layer(
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
let matched_path = request
.extensions()
.get::<MatchedPath>()
.map(MatchedPath::as_str);
let request_id = Uuid::new_v4().to_string();
tracing::info_span!(
"http_request",
method = ?request.method(),
matched_path,
request_id,
some_other_field = tracing::field::Empty,
)
}),
)
.layer(SessionManagerLayer::new(redis_store).with_secure(false))
.fallback(not_found)
.with_state(app_state)
}

32
src/telemetry.rs Normal file
View File

@@ -0,0 +1,32 @@
use tokio::task::JoinHandle;
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, util::SubscriberInitExt};
pub fn init_subscriber<Sink>(sink: Sink)
where
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
{
let formatting_layer = BunyanFormattingLayer::new(env!("CARGO_CRATE_NAME").into(), sink);
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
format!(
"{}=debug,tower_http=debug,axum::rejection=trace",
env!("CARGO_CRATE_NAME")
)
.into()
}),
)
.with(JsonStorageLayer)
.with(formatting_layer)
.init();
}
pub fn spawn_blocking_with_tracing<F, R>(f: F) -> JoinHandle<R>
where
F: FnOnce() -> R + Send + 'static,
R: Send + 'static,
{
let current_span = tracing::Span::current();
tokio::task::spawn_blocking(move || current_span.in_scope(f))
}

133
src/templates.rs Normal file
View File

@@ -0,0 +1,133 @@
use crate::domain::PostEntry;
use askama::Template;
use uuid::Uuid;
#[derive(Template)]
pub enum MessageTemplate {
#[template(path = "../templates/success.html")]
Success { message: String },
#[template(path = "../templates/error.html")]
Error { message: String },
}
#[derive(Template)]
#[template(path = "../templates/500.html")]
pub struct InternalErrorTemplate;
#[derive(Template)]
#[template(path = "../templates/login.html")]
pub struct LoginTemplate;
#[derive(Template)]
#[template(path = "../templates/dashboard.html")]
pub struct DashboardTemplate {
pub username: String,
pub idempotency_key_1: String,
pub idempotency_key_2: String,
}
#[derive(Template)]
#[template(path = "../templates/home.html")]
pub struct HomeTemplate;
#[derive(Template)]
#[template(path = "../templates/posts.html")]
pub struct PostsTemplate {
pub posts: Vec<PostEntry>,
}
#[derive(Template)]
#[template(path = "../templates/post.html")]
pub struct PostTemplate {
pub post: PostEntry,
}
#[derive(Template)]
#[template(path = "../templates/confirm.html")]
pub struct ConfirmTemplate;
#[derive(Template)]
#[template(path = "../templates/404.html")]
pub struct NotFoundTemplate;
#[derive(Template)]
#[template(path = "../templates/unsubscribe.html")]
pub struct UnsubscribeTemplate;
#[derive(Template)]
#[template(path = "../templates/email/new_post.html")]
pub struct NewPostEmailTemplate<'a> {
pub base_url: &'a str,
pub post_title: &'a str,
pub post_id: &'a Uuid,
pub post_excerpt: &'a str,
}
#[derive(Template)]
#[template(path = "../templates/email/standalone.html")]
pub struct StandaloneEmailTemplate<'a> {
pub base_url: &'a str,
pub html_content: &'a str,
pub text_content: &'a str,
}
pub trait EmailTemplate: Sync {
fn html(&self) -> String;
fn text(&self) -> String;
}
impl<'a> EmailTemplate for NewPostEmailTemplate<'a> {
fn html(&self) -> String {
self.render().unwrap()
}
fn text(&self) -> String {
format!(
r#"New post available!
Hello there!
I just published a new post that I think you'll find interesting:
"{}"
Read the full post: {}/posts/{}
This post covers practical insights and real-world examples that I hope will be valuable for your backend development journey.
Thanks for being a subscriber!
Best regards,
Alphonse
---
zero2prod - Building better backends with Rust
Visit the blog: {}
Unsubscribe: {}/unsubscribe?token=UNSUBSCRIBE_TOKEN
You're receiving this because you subscribed to the zero2prod newsletter."#,
self.post_title, self.base_url, self.post_id, self.base_url, self.base_url,
)
}
}
impl<'a> EmailTemplate for StandaloneEmailTemplate<'a> {
fn html(&self) -> String {
self.render().unwrap()
}
fn text(&self) -> String {
format!(
r#"{}
---
zero2prod - Building better backends with Rust
Visit the blog: {}
Unsubscribe: {}/unsubscribe?token=UNSUBSCRIBE_TOKEN
You're receiving this because you subscribed to the zero2prod newsletter."#,
self.text_content, self.base_url, self.base_url
)
}
}

37
templates/404.html Normal file
View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}404{% endblock %}
{% block content %}
<div class="flex-1 flex items-center justify-center">
<div class="max-w-4xl mx-auto text-center">
<div class="mb-8">
<h1 class="text-4xl font-semibold text-gray-700 mb-4">404</h1>
<h2 class="text-2xl font-semibold text-gray-500 mb-6">Not Found</h2>
<p class="text-gray-600 mb-8 max-w-2xl mx-auto">
Sorry, I couldn't find the page you're looking for. The page may have been moved, deleted, or the URL might be incorrect.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="/"
class="bg-blue-600 text-white hover:bg-blue-700 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Home
</a>
<a href="/posts"
class="bg-white text-gray-700 hover:text-blue-600 hover:bg-blue-50 border border-gray-300 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
Read posts
</a>
</div>
</div>
</div>
</div>
{% endblock %}

37
templates/500.html Normal file
View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Internal Server Error{% endblock %}
{% block content %}
<div class="flex-1 flex items-center justify-center">
<div class="max-w-4xl mx-auto text-center">
<div class="mb-8">
<h1 class="text-4xl font-semibold text-red-600 mb-4">500</h1>
<h2 class="text-2xl font-semibold text-gray-500 mb-6">Internal Server Error</h2>
<p class="text-gray-600 mb-8 max-w-2xl mx-auto">
Something went wrong. Please try again in a few minutes or contact me if the problem persists.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="/"
class="bg-blue-600 text-white hover:bg-blue-700 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Home
</a>
<button onclick="window.location.reload()"
class="bg-white text-gray-700 hover:text-blue-600 hover:bg-blue-50 border border-gray-300 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
Try again
</button>
</div>
</div>
</div>
</div>
{% endblock %}

116
templates/base.html Normal file
View File

@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="zero2prod newsletter" />
<meta name="keywords" content="newsletter, rust, axum, htmx" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>
{% block title %}{% endblock %}
- zero2prod
</title>
<link href="/assets/css/main.css" rel="stylesheet" />
<script src="/assets/js/htmx.min.js"></script>
</head>
<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="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 items-center space-x-4">
<div class="flex items-center space-x-2 group">
<div class="w-6 h-6 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-all duration-200">
<svg class="w-4 h-4 text-white group-hover:scale-110 transition-transform duration-200"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<span class="text-sm font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
zero2prod
</span>
</div>
<nav class="hidden md:flex items-center">
<a href="/"
class="text-gray-700 hover:text-blue-600 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 relative group">
home
<span class="absolute inset-x-4 bottom-0 h-0.5 bg-blue-600 scale-x-0 group-hover:scale-x-100 transition-transform duration-200"></span>
</a>
<a href="/posts"
class="text-gray-700 hover:text-blue-600 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 relative group">
posts
<span class="absolute inset-x-4 bottom-0 h-0.5 bg-blue-600 scale-x-0 group-hover:scale-x-100 transition-transform duration-200"></span>
</a>
</nav>
</div>
<div class="flex items-center space-x-4">
<a href="/admin/dashboard"
hx-boost="true"
class="hidden md:flex bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:scale-105">
dashboard
</a>
<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()">
<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" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
<div id="mobile-menu"
class="hidden md:hidden border-t border-gray-100 pb-4 pt-4">
<nav class="flex flex-col space-y-2">
<a href="/"
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
home
</a>
<a href="/posts"
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
posts
</a>
<a href="/admin/dashboard"
hx-boost="true"
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
dashboard
</a>
</nav>
</div>
</div>
</header>
<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">
{% block content %}{% endblock %}
</main>
</div>
<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="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 items-center space-x-4">
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
target="_blank"
class="text-sm text-gray-500 hover:text-gray-900 transition-colors flex items-center">
Gitea
<svg class="ml-1 h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<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" />
</svg>
</a>
</div>
</div>
<div class="mt-4 md:mt-0">
<p class="text-xs text-gray-500">Built with ❤️ using Rust, axum & htmx</p>
</div>
</div>
</div>
</footer>
</body>
</html>
<script>
function toggleMobileMenu() {
const menu = document.getElementById('mobile-menu');
menu.classList.toggle('hidden');
}
</script>

24
templates/confirm.html Normal file
View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}zero2prod{% endblock %}
{% block content %}
<div class="flex-1 flex items-center justify-center">
<div class="max-w-md w-full space-y-8">
<div class="text-center">
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-green-100 mb-6">
<svg class="h-8 w-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h1 class="text-3xl font-bold text-gray-900 mb-4">Subscription confirmed</h1>
<p class="text-lg text-gray-600 mb-8">Your email has been confirmed! You're all set to receive the latest updates.</p>
</div>
<div class="text-center">
<a href="/"
class="text-sm text-blue-600 hover:text-blue-500 transition-colors">← Back to homepage</a>
</div>
</div>
</div>
{% endblock %}

267
templates/dashboard.html Normal file
View File

@@ -0,0 +1,267 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="min-w-6/12 mx-auto p-4 sm:p-6">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<p class="mt-2 text-gray-600">
Connected as <span class="font-bold">{{ username }}</span>
</p>
<button hx-post="/admin/logout"
type="submit"
class="flex items-center text-sm text-gray-500 hover:text-red-600 transition-colors cursor-pointer gap-1 mt-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Logout</span>
</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-blue-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Total subscribers</p>
<p class="text-2xl font-semibold text-gray-900">2,143</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Issues sent</p>
<p class="text-2xl font-semibold text-gray-900">23</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-orange-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Open rate</p>
<p class="text-2xl font-semibold text-gray-900">68%</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Growth</p>
<p class="text-2xl font-semibold text-gray-900">+12%</p>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-purple-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
</svg>
Write a new post
</h2>
<p class="text-sm text-gray-600 mt-1">Publish a new post online.</p>
</div>
<div class="p-6">
<form hx-post="/admin/posts"
hx-target="#post-messages"
hx-swap="innerHTML"
class="space-y-4">
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_1 }}" />
<div>
<label for="post-title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
<input type="text"
id="post-title"
name="title"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
</div>
<div>
<label for="post-content"
class="block text-sm font-medium text-gray-700 mb-2">Markdown content</label>
<textarea id="post-content"
name="content"
rows="6"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"></textarea>
</div>
<button type="submit"
class="w-full bg-purple-600 text-white hover:bg-purple-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
</svg>
Publish
</button>
<div id="post-messages" class="mt-4"></div>
</form>
</div>
</div>
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
Send an email
</h2>
<p class="text-sm text-gray-600 mt-1">Contact your subscribers directly.</p>
</div>
<div class="p-6">
<form hx-post="/admin/newsletters"
hx-target="#newsletter-messages"
hx-swap="innerHTML"
class="space-y-4">
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_2 }}" />
<div>
<label for="newsletter-title"
class="block text-sm font-medium text-gray-700 mb-2">Subject</label>
<input type="text"
id="newsletter-title"
name="title"
placeholder="Subject"
required
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>
<label for="newsletter-html"
class="block text-sm font-medium text-gray-700 mb-2">HTML content</label>
<textarea id="newsletter-html"
name="html"
rows="6"
placeholder="HTML version"
required
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 font-mono text-sm"></textarea>
</div>
<div>
<label for="newsletter-text"
class="block text-sm font-medium text-gray-700 mb-2">Text content</label>
<textarea id="newsletter-text"
name="text"
rows="6"
placeholder="Plain text version"
required
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 font-mono text-sm"></textarea>
</div>
<button type="submit"
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Send
</button>
<div id="newsletter-messages" class="mt-4"></div>
</form>
</div>
</div>
<div class="bg-white rounded-lg shadow-md border border-gray-200 lg:col-span-2">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-green-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Change your password
</h2>
<p class="text-sm text-gray-600 mt-1">Set a new password for your account.</p>
</div>
<div class="p-6">
<form hx-post="/admin/password"
hx-target="#password-messages"
hx-swap="innerHTML"
class="space-y-4">
<div>
<label for="current_password"
class="block text-sm font-medium text-gray-700 mb-2">Current password</label>
<input type="password"
id="current_password"
name="current_password"
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" />
</div>
<div>
<label for="new_password"
class="block text-sm font-medium text-gray-700 mb-2">New password</label>
<input type="password"
id="new_password"
name="new_password"
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" />
</div>
<div>
<label for="new_password_check"
class="block text-sm font-medium text-gray-700 mb-2">Confirm new password</label>
<input type="password"
id="new_password_check"
name="new_password_check"
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" />
</div>
<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">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
Update password
</button>
<div id="password-messages" class="mt-4"></div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

145
templates/email/base.html Normal file
View File

@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="zero2prod">
<meta name="keywords" content="rust backend programming">
<title>
{% block title %}Updates{% endblock %}
- zero2prod</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #374151;
margin: 0;
padding: 0;
background-color: #f9fafb;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.header {
background: linear-gradient(135deg, #3b82f6, #6366f1);
color: white;
padding: 32px 24px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
}
.header p {
margin: 8px 0 0 0;
opacity: 0.9;
font-size: 16px;
}
.content {
padding: 32px 24px;
}
.post-preview {
background-color: #f8fafc;
border-left: 4px solid #3b82f6;
padding: 24px;
margin: 24px 0;
border-radius: 0 6px 6px 0;
}
.post-title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin: 0 0 12px 0;
line-height: 1.3;
}
.post-excerpt {
color: #6b7280;
margin: 0;
font-size: 15px;
}
.cta-button {
display: inline-block;
background: linear-gradient(135deg, #3b82f6, #6366f1);
color: white;
padding: 14px 28px;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
margin: 24px 0;
transition: transform 0.2s;
}
.cta-button:hover {
transform: translateY(-1px);
}
.footer {
background-color: #f8fafc;
padding: 24px;
border-top: 1px solid #e5e7eb;
text-align: center;
}
.footer p {
margin: 0;
font-size: 14px;
color: #6b7280;
}
.footer a {
color: #3b82f6;
text-decoration: none;
}
.unsubscribe {
font-size: 12px;
color: #9ca3af;
margin-top: 16px;
}
@media only screen and (max-width: 600px) {
.container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 24px 16px;
}
.header h1 {
font-size: 20px;
}
.post-preview {
padding: 16px;
margin: 16px 0;
}
.post-title {
font-size: 18px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Updates from zero2prod</h1>
<p>Fresh insights on Rust backend development</p>
</div>
<div class="content">
{% block content %}{% endblock %}
</div>
<div class="footer">
<p>
<strong>zero2prod</strong>
<br>
Building better backends with Rust
</p>
<p>
<a href="{{ base_url }}">Visit the blog</a>
<a href="{{ base_url }}/unsubscribe?token=UNSUBSCRIBE_TOKEN">Unsubscribe</a>
</p>
<p class="unsubscribe">You're receiving this because you subscribed to the zero2prod newsletter.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}New post available: {{ post_title }}{% endblock %}
{% block content %}
<p>Hello there!</p>
<p>I just published a new post that I think you'll find interesting:</p>
<div class="post-preview">
<h2 class="post-title">{{ post_title }}</h2>
{% if !post_excerpt.is_empty() %}<p class="post-excerpt">{{ post_excerpt }}</p>{% endif %}
</div>
<a href="{{ base_url }}/posts/{{ post_id }}" class="cta-button">Read the full post →</a>
<p>
This post covers practical insights and real-world examples that I hope will be valuable for your backend development journey.
</p>
<p>Thanks for being a subscriber!</p>
<p>
Best regards,
<br>
<strong>Alphonse</strong>
</p>
{% endblock %}

View File

@@ -0,0 +1,2 @@
{% extends "base.html" %}
{% block content %}{{ html_content|safe }}{% endblock %}

9
templates/error.html Normal file
View File

@@ -0,0 +1,9 @@
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-4">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd">
</path>
</svg>
<span class="font-medium">{{ message }}</span>
</div>
</div>

82
templates/home.html Normal file
View File

@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block title %}Home{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 mb-8">
<div class="max-w-3xl">
<h1 class="text-4xl font-bold mb-4">zero2prod</h1>
<p class="text-xl text-blue-100 mb-6">
Welcome to my blog! Stay updated on my latest projects and
thoughts. Subscribe (and unsubscribe) at any time.
</p>
<div class="flex flex-col sm:flex-row gap-4">
<a href="#newsletter-signup"
class="bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors text-center">
Subscribe
</a>
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
target="_blank"
class="border border-white text-white hover:bg-white hover:text-blue-600 font-semibold py-3 px-6 rounded-md transition-colors text-center">
View code
</a>
</div>
</div>
</div>
<div class="grid md:grid-cols-2 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Privacy first</h3>
<p class="text-gray-600 text-sm">
Zero spam, zero tracking, zero data sharing. Your email stays private
and secure. Unsubscribe at any time.
</p>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Quality content</h3>
<p class="text-gray-600 text-sm">
Curated insights on Rust backend development, performance tips, and
production war stories.
</p>
</div>
</div>
<div id="newsletter-signup"
class="bg-white rounded-lg shadow-md p-8 border border-gray-200">
<div class="max-w-2xl mx-auto text-center">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Stay updated</h2>
<p class="text-gray-600 mb-6">Subscribe to my newsletter to get the latest updates.</p>
<form hx-post="/subscriptions"
hx-target="#subscribe-messages"
hx-swap="innerHTML"
class="max-w-md mx-auto">
<div class="flex flex-col sm:flex-row gap-3">
<input type="email"
name="email"
placeholder="you@example.com"
required
class="flex-1 px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
<button type="submit"
class="bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-6 rounded-md transition-colors">
Subscribe
</button>
</div>
<div id="subscribe-messages" class="mt-4"></div>
</form>
</div>
</div>
</div>
{% endblock %}

132
templates/input.css Normal file
View File

@@ -0,0 +1,132 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@layer components {
.prose-compact {
@apply prose prose-slate max-w-none;
--tw-prose-body: theme(colors.gray.700);
--tw-prose-headings: theme(colors.gray.900);
--tw-prose-links: theme(colors.blue.600);
--tw-prose-code: theme(colors.gray.800);
}
.prose-compact p {
@apply mb-2 mt-0;
}
.prose-compact h1 {
@apply pb-2 mb-3 border-b-2 border-gray-100;
}
.prose-compact h2 {
@apply mt-4 pb-2 mb-3 border-b-2 border-gray-100 font-semibold;
}
.prose-compact h3 {
@apply mt-3 mb-1;
}
.prose-compact h4,
.prose-compact h5,
.prose-compact h6 {
@apply mt-2 mb-1;
}
.prose-compact ul,
.prose-compact ol {
@apply my-2 space-y-0;
}
.prose-compact li {
@apply my-0;
}
.prose-compact blockquote {
@apply my-3 py-2;
}
.prose-compact img {
@apply m-0 align-top;
}
.prose-compact a:has(img) {
@apply no-underline border-0 inline-block align-top;
}
.prose-compact a img {
@apply inline-block align-top;
}
.prose-compact :not(pre) > code {
@apply bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono font-normal;
}
.prose-compact :not(pre) > code::before,
.prose-compact :not(pre) > code::after {
content: none !important;
}
.prose-compact pre {
@apply my-3 p-4 bg-gray-100 text-gray-800 rounded-sm overflow-x-auto border border-gray-200;
overflow-x: auto;
max-width: 100%;
width: 0;
min-width: 100%;
}
.prose-compact pre code {
@apply bg-transparent text-gray-800 p-0 rounded-none;
}
.prose-compact pre code::before,
.prose-compact pre code::after {
content: none !important;
}
.prose-compact table {
border-collapse: collapse;
border-spacing: 0;
@apply my-6;
font-size: 14px;
line-height: 1.45;
display: block;
overflow-x: auto;
width: 100%;
min-width: 100%;
max-width: 100%;
}
.prose-compact table thead,
.prose-compact table tbody {
display: table;
width: 100%;
table-layout: fixed;
}
.prose-compact table tr {
display: table-row;
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
.prose-compact table tr:nth-child(2n) {
background-color: #f6f8fa;
}
.prose-compact table th,
.prose-compact table td {
display: table-cell;
padding: 6px 13px;
border: 1px solid #dfe2e5;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
min-width: 0;
}
.prose-compact table th {
font-weight: 600;
background-color: #f6f8fa;
}
}

46
templates/login.html Normal file
View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Login - zero2prod{% endblock %}
{% block content %}
<div class="flex-1 flex items-center justify-center">
<div class="max-w-md w-full space-y-8">
<div class="text-center">
<h2 class="text-3xl font-bold text-gray-900">Login</h2>
<p class="mt-2 text-sm text-gray-600">Sign in to access the admin dashboard.</p>
</div>
<div class="bg-white rounded-lg shadow-md p-8 border border-gray-200">
<form hx-post="/login"
hx-target="#login-messages"
hx-swap="innerHTML"
class="space-y-6">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">Username</label>
<input type="text"
id="username"
name="username"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">Password</label>
<input type="password"
id="password"
name="password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div>
<button type="submit"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
Continue
</button>
</div>
<div id="login-messages" class="mt-4"></div>
</form>
</div>
<div class="text-center">
<a href="/"
class="text-sm text-blue-600 hover:text-blue-500 transition-colors">← Back to homepage</a>
</div>
</div>
</div>
{% endblock %}

46
templates/post.html Normal file
View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<article>
<header class="pb-4 mb-2 border-b-2 border-gray-300 border-dashed">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
<div class="flex items-center space-x-4">
<div class="flex items-center">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-2">
<svg class="w-4 h-4 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<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" />
</svg>
</div>
<span class="font-medium">{{ post.author.as_deref().unwrap_or("Unknown") }}</span>
</div>
<div class="flex items-center">
<svg class="w-4 h-4 mr-1 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<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" />
</svg>
<time datetime="{{ post.published_at }}">
{{ post.formatted_date() }}
</time>
</div>
</div>
</div>
</header>
<div class="prose-compact">{{ post.content | safe }}</div>
</article>
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
<a href="/#newsletter-signup"
class="inline-block bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors">
Subscribe
</a>
</div>
</div>
{% endblock %}

75
templates/posts.html Normal file
View File

@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Posts{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Posts</h1>
<p class="mt-2 text-gray-600">Insights on Rust backend development, performance tips, and production stories.</p>
</div>
{% if posts.is_empty() %}
<div class="bg-white rounded-lg shadow-md p-8 border border-gray-200 text-center">
<div class="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts yet</h3>
<p class="text-gray-600">Check back later for new content!</p>
</div>
{% else %}
<div class="space-y-6">
{% for post in posts %}
<article class="bg-white rounded-lg shadow-md border border-gray-200 hover:shadow-lg transition-shadow duration-200">
<a href="/posts/{{ post.post_id }}"
class="block p-6 hover:bg-gray-50 transition-colors duration-200">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{ post.title }}</h2>
<div class="flex items-center text-sm text-gray-500">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
<time datetime="{{ post.published_at }}">
{{ post.formatted_date() }}
</time>
</div>
<span class="mx-2"></span>
<div class="flex items-center">
<svg class="w-4 h-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>{{ post.author.as_deref().unwrap_or("Unknown") }}</span>
</div>
</div>
</div>
<div class="flex-shrink-0 ml-4">
<svg class="w-5 h-5 text-gray-400 group-hover:text-blue-600 transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</a>
</article>
{% endfor %}
</div>
<div class="mt-8 text-center">
<button class="bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-6 rounded-md transition-colors">
Load more
</button>
</div>
{% endif %}
</div>
{% endblock %}

9
templates/success.html Normal file
View File

@@ -0,0 +1,9 @@
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md mb-4">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd">
</path>
</svg>
<span class="font-medium">{{ message }}</span>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More