Compare commits
70 Commits
ab650fdd35
...
9bf8263d99
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bf8263d99 | ||
|
|
998c156d3c | ||
|
|
f1ce77a762 | ||
|
|
a37123a32d | ||
|
|
1cc4871dd2 | ||
|
|
72fa283a6d | ||
|
|
7af07ea0dd | ||
|
|
6a963a8c0d | ||
|
|
abca3a23b7 | ||
|
|
6b3cea9fb2 | ||
|
|
79979e6a58 | ||
|
|
771130e9f0 | ||
|
|
c618de9732 | ||
|
|
c11e552d0a | ||
|
|
6308ac279e | ||
|
|
ddb837be56 | ||
|
|
8f0b59775e | ||
|
|
6d2d486866 | ||
|
|
40dfe1aed8 | ||
|
|
b52b676dc0 | ||
|
|
f5cd91108a | ||
|
|
01d2add44b | ||
|
|
46a6905b12 | ||
|
|
91e8b5f001 | ||
|
|
a75c410948 | ||
|
|
95c4d3fdd0 | ||
|
|
71d4872878 | ||
|
|
3120c700a4 | ||
|
|
08d5f611b5 | ||
|
|
54218f92a9 | ||
|
|
044991d623 | ||
|
|
72d0306e35 | ||
|
|
e191d35664 | ||
|
|
b5f0f448d7 | ||
|
|
859247d900 | ||
|
|
2d336ed000 | ||
|
|
88dad022ce | ||
|
|
1d027b5460 | ||
|
|
38208654dc | ||
|
|
0a2c4a32c1 | ||
|
|
b736e2fe8d | ||
|
|
f948728348 | ||
|
|
5cdc3ea29d | ||
|
|
56035fab30 | ||
|
|
a9c6cb36a5 | ||
|
|
ba6b2dbd93 | ||
|
|
1ec51f0caf | ||
|
|
54b0512f3f | ||
|
|
8d6cab41d0 | ||
|
|
a4104ca1b2 | ||
|
|
f8dee295cd | ||
|
|
9a184b93ac | ||
|
|
d96a401d99 | ||
|
|
3dce578ba0 | ||
|
|
8447d050d6 | ||
|
|
9193f2020d | ||
|
|
4ce25a8136 | ||
|
|
dfd3300371 | ||
|
|
d1cf1f6c4f | ||
|
|
73ff7c04fe | ||
|
|
954772e9db | ||
|
|
4389873bf4 | ||
|
|
85ab04f254 | ||
|
|
4d049a744a | ||
|
|
a7473bb7f5 | ||
|
|
1567f94b1f | ||
|
|
59817083eb | ||
|
|
b280f10c40 | ||
|
|
5cc5758097 | ||
|
|
ded2a611e2 |
3
.cargo/config.toml
Normal file
3
.cargo/config.toml
Normal 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
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/target
|
||||||
|
.env
|
||||||
|
/tests
|
||||||
|
Dockerfile
|
||||||
|
/scripts
|
||||||
|
/migrations
|
||||||
1
.env
Normal file
1
.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DATABASE_URL="postgres://postgres:Jq09NF6Y8ZXJS4jd9c8U@localhost:5432/newsletter"
|
||||||
173
.github/workflows/general.yml
vendored
Normal file
173
.github/workflows/general.yml
vendored
Normal 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 container’s 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
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
/node_modules
|
||||||
|
|||||||
15
.sqlx/query-0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e.json
generated
Normal file
15
.sqlx/query-0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e.json
generated
Normal 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"
|
||||||
|
}
|
||||||
58
.sqlx/query-1fc498c8ccbf46f3e00b915e3b3973eb8d44a83a7df6dd7744dc56a2e94a0aa5.json
generated
Normal file
58
.sqlx/query-1fc498c8ccbf46f3e00b915e3b3973eb8d44a83a7df6dd7744dc56a2e94a0aa5.json
generated
Normal 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"
|
||||||
|
}
|
||||||
41
.sqlx/query-32701e61ea14e25608b5f6b05289d08d422e9629d6aee98ac1dcbd50f1edbfe1.json
generated
Normal file
41
.sqlx/query-32701e61ea14e25608b5f6b05289d08d422e9629d6aee98ac1dcbd50f1edbfe1.json
generated
Normal 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"
|
||||||
|
}
|
||||||
18
.sqlx/query-3d6654896cea2ea1405f7ee5088da406ebe3d829380e3719b23b9bdf08affcfc.json
generated
Normal file
18
.sqlx/query-3d6654896cea2ea1405f7ee5088da406ebe3d829380e3719b23b9bdf08affcfc.json
generated
Normal 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"
|
||||||
|
}
|
||||||
15
.sqlx/query-409cb2c83e34fba77b76f031cb0846a8f2716d775c3748887fb0c50f0e0a565b.json
generated
Normal file
15
.sqlx/query-409cb2c83e34fba77b76f031cb0846a8f2716d775c3748887fb0c50f0e0a565b.json
generated
Normal 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"
|
||||||
|
}
|
||||||
34
.sqlx/query-43116d4e670155129aa69a7563ddc3f7d01ef3689bb8de9ee1757b401ad95b46.json
generated
Normal file
34
.sqlx/query-43116d4e670155129aa69a7563ddc3f7d01ef3689bb8de9ee1757b401ad95b46.json
generated
Normal 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"
|
||||||
|
}
|
||||||
17
.sqlx/query-605c5893a2a89a84c201a6a2ae52a3c00cb4db064a52ea9f198c24de4b877ba2.json
generated
Normal file
17
.sqlx/query-605c5893a2a89a84c201a6a2ae52a3c00cb4db064a52ea9f198c24de4b877ba2.json
generated
Normal 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"
|
||||||
|
}
|
||||||
16
.sqlx/query-61eb9d8067d08c12b6f703d3100cda08bd84a53e54a49bf072758a59a375dc14.json
generated
Normal file
16
.sqlx/query-61eb9d8067d08c12b6f703d3100cda08bd84a53e54a49bf072758a59a375dc14.json
generated
Normal 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"
|
||||||
|
}
|
||||||
32
.sqlx/query-6d21a0dd6ef2ea03ce82248ceceab76bb486237ff8e4a2ccd4dbf2b73c496048.json
generated
Normal file
32
.sqlx/query-6d21a0dd6ef2ea03ce82248ceceab76bb486237ff8e4a2ccd4dbf2b73c496048.json
generated
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT newsletter_issue_id, subscriber_email, unsubscribe_token\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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "unsubscribe_token",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "6d21a0dd6ef2ea03ce82248ceceab76bb486237ff8e4a2ccd4dbf2b73c496048"
|
||||||
|
}
|
||||||
22
.sqlx/query-8d72bcc059606a15aef7e3c2455b9cc44427356b4ab772f0f1fb3dfd318c4561.json
generated
Normal file
22
.sqlx/query-8d72bcc059606a15aef7e3c2455b9cc44427356b4ab772f0f1fb3dfd318c4561.json
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT unsubscribe_token FROM subscriptions WHERE email = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "unsubscribe_token",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "8d72bcc059606a15aef7e3c2455b9cc44427356b4ab772f0f1fb3dfd318c4561"
|
||||||
|
}
|
||||||
46
.sqlx/query-9ba5df2593c5dc21de727c16f03a76e4922b940c0877132cd5f622c725b9b123.json
generated
Normal file
46
.sqlx/query-9ba5df2593c5dc21de727c16f03a76e4922b940c0877132cd5f622c725b9b123.json
generated
Normal 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"
|
||||||
|
}
|
||||||
22
.sqlx/query-aa7e732d453403819a489e1a4ac5c56cd3b57bc882c8b1e96a887811f8f999cd.json
generated
Normal file
22
.sqlx/query-aa7e732d453403819a489e1a4ac5c56cd3b57bc882c8b1e96a887811f8f999cd.json
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id FROM subscriptions WHERE email = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "aa7e732d453403819a489e1a4ac5c56cd3b57bc882c8b1e96a887811f8f999cd"
|
||||||
|
}
|
||||||
28
.sqlx/query-acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58.json
generated
Normal file
28
.sqlx/query-acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58.json
generated
Normal 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"
|
||||||
|
}
|
||||||
22
.sqlx/query-ad120337ee606be7b8d87238e2bb765d0da8ee61b1a3bc142414c4305ec5e17f.json
generated
Normal file
22
.sqlx/query-ad120337ee606be7b8d87238e2bb765d0da8ee61b1a3bc142414c4305ec5e17f.json
generated
Normal 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"
|
||||||
|
}
|
||||||
15
.sqlx/query-b399033752641396cfe752e930e073765335a6c6e84935f60f4918576b47c249.json
generated
Normal file
15
.sqlx/query-b399033752641396cfe752e930e073765335a6c6e84935f60f4918576b47c249.json
generated
Normal 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"
|
||||||
|
}
|
||||||
14
.sqlx/query-ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf.json
generated
Normal file
14
.sqlx/query-ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf.json
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM subscriptions WHERE unsubscribe_token = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf"
|
||||||
|
}
|
||||||
46
.sqlx/query-bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f.json
generated
Normal file
46
.sqlx/query-bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f.json
generated
Normal 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"
|
||||||
|
}
|
||||||
14
.sqlx/query-ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944.json
generated
Normal file
14
.sqlx/query-ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944.json
generated
Normal 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"
|
||||||
|
}
|
||||||
15
.sqlx/query-eae27786a7c81ee2199fe3d5c10ac52c8067c61d6992f8f5045b908eb73bab8b.json
generated
Normal file
15
.sqlx/query-eae27786a7c81ee2199fe3d5c10ac52c8067c61d6992f8f5045b908eb73bab8b.json
generated
Normal 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"
|
||||||
|
}
|
||||||
15
.sqlx/query-fa625c0844ec26b7f59ce885d6fe0b9a4f4676946706cb926c21da6ab1b89d90.json
generated
Normal file
15
.sqlx/query-fa625c0844ec26b7f59ce885d6fe0b9a4f4676946706cb926c21da6ab1b89d90.json
generated
Normal 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
3356
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
59
Cargo.toml
59
Cargo.toml
@@ -2,7 +2,62 @@
|
|||||||
name = "zero2prod"
|
name = "zero2prod"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
path = "src/main.rs"
|
||||||
|
name = "zero2prod"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8.4"
|
anyhow = "1.0.99"
|
||||||
tokio = { version = "1.47.1", features = ["rt-multi-thread"] }
|
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
27
Dockerfile
Normal 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
22
README.md
Normal 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
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
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
4
configuration/base.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
email_client:
|
||||||
|
timeout_milliseconds: 10000
|
||||||
|
base_url: "https://api.alphonsepaix.xyz"
|
||||||
|
sender_email: "newsletter@alphonsepaix.xyz"
|
||||||
16
configuration/local.yaml
Normal file
16
configuration/local.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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
|
||||||
|
timeout_millis: 1000
|
||||||
|
email_client:
|
||||||
|
authorization_token: "secret-token"
|
||||||
|
redis_uri: "redis://127.0.0.1:6379"
|
||||||
|
require_tls: false
|
||||||
4
configuration/production.yaml
Normal file
4
configuration/production.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
application:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
database:
|
||||||
|
timeout_millis: 500
|
||||||
7
migrations/20250821091459_create_subscriptions_table.sql
Normal file
7
migrations/20250821091459_create_subscriptions_table.sql
Normal 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)
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE subscriptions ADD COLUMN status TEXT NULL;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
BEGIN;
|
||||||
|
UPDATE subscriptions SET status = 'confirmed' WHERE status IS NULL;
|
||||||
|
ALTER TABLE subscriptions ALTER COLUMN status SET NOT NULL;
|
||||||
|
COMMIT;
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
5
migrations/20250828142613_create_users_table.sql
Normal file
5
migrations/20250828142613_create_users_table.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
user_id UUID PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL
|
||||||
|
);
|
||||||
1
migrations/20250828161700_rename_password_column.sql
Normal file
1
migrations/20250828161700_rename_password_column.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users RENAME password TO password_hash;
|
||||||
1
migrations/20250828204455_add_salt_to_users.sql
Normal file
1
migrations/20250828204455_add_salt_to_users.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN salt TEXT NOT NULL;
|
||||||
1
migrations/20250828212543_remove_salt_from_users.sql
Normal file
1
migrations/20250828212543_remove_salt_from_users.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN salt;
|
||||||
6
migrations/20250831121659_seed_user.sql
Normal file
6
migrations/20250831121659_seed_user.sql
Normal 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'
|
||||||
|
);
|
||||||
14
migrations/20250901135528_create_idempotency_table.sql
Normal file
14
migrations/20250901135528_create_idempotency_table.sql
Normal 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)
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE subscriptions DROP COLUMN name;
|
||||||
7
migrations/20250918120924_create_posts_table.sql
Normal file
7
migrations/20250918120924_create_posts_table.sql
Normal 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
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE issue_delivery_queue ADD COLUMN unsubscribe_token TEXT NOT NULL;
|
||||||
1187
package-lock.json
generated
Normal file
1187
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
package.json
Normal file
12
package.json
Normal 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
45
scripts/init_db.sh
Executable 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
18
scripts/init_redis.sh
Executable 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
166
src/authentication.rs
Normal 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,
|
||||||
|
}
|
||||||
134
src/configuration.rs
Normal file
134
src/configuration.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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,
|
||||||
|
pub timeout_millis: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
7
src/domain.rs
Normal 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;
|
||||||
5
src/domain/new_subscriber.rs
Normal file
5
src/domain/new_subscriber.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
use crate::domain::subscriber_email::SubscriberEmail;
|
||||||
|
|
||||||
|
pub struct NewSubscriber {
|
||||||
|
pub email: SubscriberEmail,
|
||||||
|
}
|
||||||
28
src/domain/post.rs
Normal file
28
src/domain/post.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/domain/subscriber_email.rs
Normal file
67
src/domain/subscriber_email.rs
Normal 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
197
src/email_client.rs
Normal 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
5
src/idempotency.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mod key;
|
||||||
|
mod persistance;
|
||||||
|
|
||||||
|
pub use key::*;
|
||||||
|
pub use persistance::*;
|
||||||
28
src/idempotency/key.rs
Normal file
28
src/idempotency/key.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/idempotency/persistance.rs
Normal file
128
src/idempotency/persistance.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/issue_delivery_worker.rs
Normal file
171
src/issue_delivery_worker.rs
Normal 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
11
src/lib.rs
Normal 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;
|
||||||
23
src/main.rs
23
src/main.rs
@@ -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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> Result<(), anyhow::Error> {
|
||||||
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
|
init_subscriber(std::io::stdout);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
let configuration = get_configuration().expect("Failed to read configuration");
|
||||||
axum::serve(listener, app).await.unwrap();
|
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
163
src/routes.rs
Normal 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())
|
||||||
|
};
|
||||||
|
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
62
src/routes/admin.rs
Normal 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)
|
||||||
|
}
|
||||||
64
src/routes/admin/change_password.rs
Normal file
64
src/routes/admin/change_password.rs
Normal 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(())
|
||||||
|
}
|
||||||
20
src/routes/admin/dashboard.rs
Normal file
20
src/routes/admin/dashboard.rs
Normal 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()
|
||||||
|
}
|
||||||
13
src/routes/admin/logout.rs
Normal file
13
src/routes/admin/logout.rs
Normal 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()
|
||||||
|
}
|
||||||
129
src/routes/admin/newsletters.rs
Normal file
129
src/routes/admin/newsletters.rs
Normal 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
123
src/routes/admin/posts.rs
Normal 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
|
||||||
|
}
|
||||||
5
src/routes/health_check.rs
Normal file
5
src/routes/health_check.rs
Normal 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
8
src/routes/home.rs
Normal 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
66
src/routes/login.rs
Normal 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
74
src/routes/posts.rs
Normal 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
|
||||||
|
}
|
||||||
171
src/routes/subscriptions.rs
Normal file
171
src/routes/subscriptions.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
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.")?;
|
||||||
|
if let Some(subscriber_id) = insert_subscriber(&mut transaction, &new_subscriber)
|
||||||
|
.await
|
||||||
|
.context("Failed to insert new subscriber in the database.")
|
||||||
|
.map_err(AppError::unexpected_message)?
|
||||||
|
{
|
||||||
|
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.")
|
||||||
|
.map_err(AppError::unexpected_message)?;
|
||||||
|
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: "You'll receive a confirmation email shortly.".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<Option<Uuid>, sqlx::Error> {
|
||||||
|
let query = sqlx::query!(
|
||||||
|
"SELECT id FROM subscriptions WHERE email = $1",
|
||||||
|
new_subscriber.email.as_ref()
|
||||||
|
);
|
||||||
|
let existing = transaction.fetch_optional(query).await?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
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(Some(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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/routes/subscriptions_confirm.rs
Normal file
84
src/routes/subscriptions_confirm.rs
Normal 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, ¶ms.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,
|
||||||
|
}
|
||||||
132
src/routes/unsubscribe.rs
Normal file
132
src/routes/unsubscribe.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
use crate::{
|
||||||
|
domain::SubscriberEmail,
|
||||||
|
email_client::EmailClient,
|
||||||
|
routes::AppError,
|
||||||
|
startup::AppState,
|
||||||
|
templates::{
|
||||||
|
MessageTemplate, NotFoundTemplate, UnsubscribeConfirmTemplate, UnsubscribeTemplate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::Context;
|
||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
Form,
|
||||||
|
extract::{Query, State},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use sqlx::{Executor, PgPool};
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct UnsubQueryParams {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_unsubscribe() -> Response {
|
||||||
|
Html(UnsubscribeTemplate.render().unwrap()).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct UnsubFormData {
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_unsubscribe(
|
||||||
|
State(AppState {
|
||||||
|
connection_pool,
|
||||||
|
email_client,
|
||||||
|
base_url,
|
||||||
|
}): State<AppState>,
|
||||||
|
Form(UnsubFormData { email }): Form<UnsubFormData>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let subscriber_email = SubscriberEmail::parse(email)?;
|
||||||
|
if let Some(token) = fetch_unsubscribe_token(&connection_pool, &subscriber_email)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch unsubscribe token.")?
|
||||||
|
{
|
||||||
|
send_unsubscribe_email(&email_client, &subscriber_email, &base_url, &token)
|
||||||
|
.await
|
||||||
|
.context("Failed to send a confirmation email.")?;
|
||||||
|
}
|
||||||
|
let template = MessageTemplate::Success {
|
||||||
|
message: "If you are a subscriber, you'll receive a confirmation link shortly.".into(),
|
||||||
|
};
|
||||||
|
Ok(Html(template.render().unwrap()).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Fetching unsubscribe token from the database", skip_all)]
|
||||||
|
async fn fetch_unsubscribe_token(
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
subscriber_email: &SubscriberEmail,
|
||||||
|
) -> Result<Option<String>, sqlx::Error> {
|
||||||
|
let r = sqlx::query!(
|
||||||
|
"SELECT unsubscribe_token FROM subscriptions WHERE email = $1",
|
||||||
|
subscriber_email.as_ref()
|
||||||
|
)
|
||||||
|
.fetch_optional(connection_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(r.and_then(|r| r.unsubscribe_token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Send an unsubscribe confirmation email", skip_all)]
|
||||||
|
pub async fn send_unsubscribe_email(
|
||||||
|
email_client: &EmailClient,
|
||||||
|
subscriber_email: &SubscriberEmail,
|
||||||
|
base_url: &str,
|
||||||
|
unsubscribe_token: &str,
|
||||||
|
) -> Result<(), reqwest::Error> {
|
||||||
|
let confirmation_link = format!(
|
||||||
|
"{}/unsubscribe/confirm?token={}",
|
||||||
|
base_url, unsubscribe_token
|
||||||
|
);
|
||||||
|
let html_content = format!(
|
||||||
|
"You've requested to unsubscribe from my emails. To confirm, please click the link below:<br />\
|
||||||
|
<a href=\"{}\">Confirm unsubscribe</a><br />\
|
||||||
|
If you did not request this, you can safely ignore this email.",
|
||||||
|
confirmation_link
|
||||||
|
);
|
||||||
|
let text_content = format!(
|
||||||
|
"You've requested to unsubscribe from my emails. To confirm, please follow the link below:\
|
||||||
|
{}\
|
||||||
|
If you did not request this, you can safely ignore this email.",
|
||||||
|
confirmation_link
|
||||||
|
);
|
||||||
|
email_client
|
||||||
|
.send_email(
|
||||||
|
subscriber_email,
|
||||||
|
"I will miss you",
|
||||||
|
&html_content,
|
||||||
|
&text_content,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Removing user from database if he exists", skip_all)]
|
||||||
|
pub async fn unsubscribe_confirm(
|
||||||
|
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 {
|
||||||
|
tracing::info!("Unsubscribe token is not tied to any confirmed user");
|
||||||
|
Ok((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Html(NotFoundTemplate.render().unwrap()),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
} else {
|
||||||
|
tracing::info!("User successfully removed");
|
||||||
|
Ok(Html(UnsubscribeConfirmTemplate.render().unwrap()).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/session_state.rs
Normal file
53
src/session_state.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/startup.rs
Normal file
156
src/startup.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
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, time::Duration};
|
||||||
|
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()
|
||||||
|
.acquire_timeout(Duration::from_millis(configuration.database.timeout_millis))
|
||||||
|
.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(get_unsubscribe).post(post_unsubscribe))
|
||||||
|
.route("/unsubscribe/confirm", get(unsubscribe_confirm))
|
||||||
|
.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
32
src/telemetry.rs
Normal 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))
|
||||||
|
}
|
||||||
137
src/templates.rs
Normal file
137
src/templates.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
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_confirm.html")]
|
||||||
|
pub struct UnsubscribeConfirmTemplate;
|
||||||
|
|
||||||
|
#[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/confirm?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/confirm?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
37
templates/404.html
Normal 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
37
templates/500.html
Normal 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 %}
|
||||||
119
templates/base.html
Normal file
119
templates/base.html
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<!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>
|
||||||
|
<span class="text-gray-300">•</span>
|
||||||
|
<a href="/unsubscribe"
|
||||||
|
class="text-sm text-gray-500 hover:text-gray-900 transition-colors">Unsubscribe</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
24
templates/confirm.html
Normal 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
267
templates/dashboard.html
Normal 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
145
templates/email/base.html
Normal 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/confirm?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>
|
||||||
20
templates/email/new_post.html
Normal file
20
templates/email/new_post.html
Normal 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 %}
|
||||||
2
templates/email/standalone.html
Normal file
2
templates/email/standalone.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}{{ html_content|safe }}{% endblock %}
|
||||||
9
templates/error.html
Normal file
9
templates/error.html
Normal 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
82
templates/home.html
Normal 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
132
templates/input.css
Normal 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
46
templates/login.html
Normal 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
46
templates/post.html
Normal 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 %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user