Compare commits

..

28 Commits

Author SHA1 Message Date
Alphonse Paix
be69a54fd1 queries
All checks were successful
Rust / Test (push) Successful in 5m51s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Successful in 1m38s
Rust / Code coverage (push) Successful in 5m6s
2025-10-11 00:06:08 +02:00
Alphonse Paix
90aa4f8185 Templates update
Some checks failed
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
Rust / Test (push) Has been cancelled
2025-10-11 00:02:05 +02:00
Alphonse Paix
5d5f9ec765 Database worker
Worker used to clean up pending subscriptions and old idempotency
records
2025-10-11 00:02:05 +02:00
Alphonse Paix
7affe88d50 Queries
All checks were successful
Rust / Test (push) Successful in 5m43s
Rust / Rustfmt (push) Successful in 21s
Rust / Clippy (push) Successful in 1m38s
Rust / Code coverage (push) Successful in 5m3s
2025-10-09 23:48:10 +02:00
Alphonse Paix
e02139ff44 Record login for users
Some checks failed
Rust / Test (push) Failing after 4m54s
Rust / Rustfmt (push) Successful in 21s
Rust / Clippy (push) Failing after 1m36s
Rust / Code coverage (push) Successful in 5m4s
2025-10-09 21:05:48 +02:00
Alphonse Paix
45f529902d Moved logging for task worker inside task execution logic 2025-10-09 19:27:50 +02:00
Alphonse Paix
ef9f860da2 User comments
All checks were successful
Rust / Test (push) Successful in 6m17s
Rust / Rustfmt (push) Successful in 21s
Rust / Clippy (push) Successful in 1m37s
Rust / Code coverage (push) Successful in 5m5s
2025-10-08 14:23:43 +02:00
Alphonse Paix
8a5605812c Posts editing tests
All checks were successful
Rust / Test (push) Successful in 6m17s
Rust / Rustfmt (push) Successful in 24s
Rust / Clippy (push) Successful in 1m35s
Rust / Code coverage (push) Successful in 5m9s
2025-10-08 00:13:56 +02:00
Alphonse Paix
d27196d7e5 Merge branch 'tests' 2025-10-07 23:27:15 +02:00
Alphonse Paix
9cbcdc533e Update templates 2025-10-07 23:25:43 +02:00
Alphonse Paix
f18899b1a6 Update banner message 2025-10-07 23:10:52 +02:00
Alphonse Paix
3bfac6d012 Profile update tests 2025-10-07 23:07:16 +02:00
Alphonse Paix
0b402c6259 Warning banner 2025-10-07 19:43:31 +02:00
Alphonse Paix
8b5f55db6f Edit posts
All checks were successful
Rust / Test (push) Successful in 5m43s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Successful in 1m37s
Rust / Code coverage (push) Successful in 4m49s
Use fix routes for user profile edit handles to make it easier when user decides to change his username
2025-10-06 22:33:05 +02:00
Alphonse Paix
b252216709 Edit profile and templates update
All checks were successful
Rust / Test (push) Successful in 6m6s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Successful in 1m36s
Rust / Code coverage (push) Successful in 4m47s
2025-10-06 19:23:57 +02:00
Alphonse Paix
da590fb7c6 Templates update
All checks were successful
Rust / Test (push) Successful in 6m12s
Rust / Rustfmt (push) Successful in 21s
Rust / Clippy (push) Successful in 1m36s
Rust / Code coverage (push) Successful in 4m55s
2025-10-06 02:51:52 +02:00
Alphonse Paix
04c2d2b7f5 Test for user system and comments 2025-10-06 02:08:26 +02:00
Alphonse Paix
d96a29ee73 Comment posting is idempotent + tests 2025-10-05 15:01:57 +02:00
Alphonse Paix
8f62c2513e Refreh queries
All checks were successful
Rust / Test (push) Successful in 5m17s
Rust / Rustfmt (push) Successful in 23s
Rust / Clippy (push) Successful in 1m39s
Rust / Code coverage (push) Successful in 4m27s
2025-10-03 21:12:44 +02:00
Alphonse Paix
50a7af2b06 Comments management
Some checks failed
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
Rust / Test (push) Has been cancelled
2025-10-03 21:12:17 +02:00
Alphonse Paix
af9cbdcafb Manage posts on dashboard and templates fixes 2025-10-03 19:18:15 +02:00
Alphonse Paix
ce8c602ddb Posts management widget 2025-10-03 18:30:09 +02:00
Alphonse Paix
9296187181 Queries data
All checks were successful
Rust / Test (push) Successful in 4m55s
Rust / Rustfmt (push) Successful in 23s
Rust / Clippy (push) Successful in 1m39s
Rust / Code coverage (push) Successful in 4m26s
2025-10-02 23:08:48 +02:00
Alphonse Paix
42c1dd9fe3 Templates update
Some checks failed
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
Rust / Test (push) Has been cancelled
2025-10-02 23:05:42 +02:00
Alphonse Paix
96e5dd0f35 Manage users on admin panel
Some checks failed
Rust / Test (push) Failing after 4m18s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Failing after 1m39s
Rust / Code coverage (push) Successful in 4m25s
2025-10-02 22:13:02 +02:00
Alphonse Paix
91e80b4881 update workflow
All checks were successful
Rust / Test (push) Successful in 4m19s
Rust / Rustfmt (push) Successful in 23s
Rust / Clippy (push) Successful in 1m42s
Rust / Code coverage (push) Successful in 4m58s
2025-10-02 10:59:18 +02:00
Alphonse Paix
9e5d185aaf Support for comments
Some checks failed
Rust / Test (push) Successful in 5m31s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Failing after 27s
Rust / Code coverage (push) Successful in 4m28s
2025-10-02 00:50:01 +02:00
Alphonse Paix
2c7282475f Update workflow
Some checks failed
Rust / Test (push) Successful in 4m24s
Rust / Rustfmt (push) Successful in 23s
Rust / Clippy (push) Failing after 29s
Rust / Code coverage (push) Successful in 4m35s
2025-10-01 01:45:12 +02:00
98 changed files with 3907 additions and 3444 deletions

View File

@@ -40,7 +40,7 @@ jobs:
- name: Install mold linker - name: Install mold linker
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y mold sudo apt-get install -y mold clang
- name: Install the Rust toolchain - name: Install the Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1 uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
@@ -77,7 +77,12 @@ jobs:
env: env:
SQLX_OFFLINE: true SQLX_OFFLINE: true
steps: steps:
- uses: actions/checkout@v4 - name: Check out repository code
uses: actions/checkout@v4
- name: Install mold linker
run: |
sudo apt-get update
sudo apt-get install -y mold clang
- name: Install the Rust toolchain - name: Install the Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1 uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
@@ -107,7 +112,7 @@ jobs:
- name: Install mold linker - name: Install mold linker
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y mold sudo apt-get install -y mold clang
- name: Install the Rust toolchain - name: Install the Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1 uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
/target /target
/node_modules /node_modules
.env .env
/.idea
docker-compose.yml

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT user_id, username, role as \"role: Role\", full_name, bio, member_since\n FROM users\n WHERE user_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "role: Role",
"type_info": {
"Custom": {
"name": "user_role",
"kind": {
"Enum": [
"admin",
"writer"
]
}
}
}
},
{
"ordinal": 3,
"name": "full_name",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "bio",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "member_since",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
true,
true,
false
]
},
"hash": "601884180bc841dc0762008a819218620fc05169fe3bb80b7635fbe9e227056b"
}

View File

@@ -0,0 +1,60 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT user_id, username, role as \"role: Role\", full_name, bio, member_since\n FROM users\n ORDER BY member_since DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "role: Role",
"type_info": {
"Custom": {
"name": "user_role",
"kind": {
"Enum": [
"admin",
"writer"
]
}
}
}
},
{
"ordinal": 3,
"name": "full_name",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "bio",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "member_since",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
true,
true,
false
]
},
"hash": "73dbf3fb780272b1849cd8aa2ecfb59774b1c46bf52181b6298eebccbc86e438"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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 ", "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 idempotency_key = $1\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -44,7 +44,6 @@
], ],
"parameters": { "parameters": {
"Left": [ "Left": [
"Uuid",
"Text" "Text"
] ]
}, },
@@ -54,5 +53,5 @@
true true
] ]
}, },
"hash": "1fc498c8ccbf46f3e00b915e3b3973eb8d44a83a7df6dd7744dc56a2e94a0aa5" "hash": "74d92b078198c3f73edc272c788249b14b62c59365d745d6a2e314cd9c5db1e9"
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO users (user_id, username, password_hash, role)\n VALUES ($1, $2, $3, $4)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
{
"Custom": {
"name": "user_role",
"kind": {
"Enum": [
"admin",
"writer"
]
}
}
}
]
},
"nullable": []
},
"hash": "878036fa48e738387e4140d5dc7eccba477794a267f2952aab684028b7c6e286"
}

View File

@@ -1,30 +1,40 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n ORDER BY p.published_at DESC\n LIMIT $1\n OFFSET $2\n ", "query": "\n SELECT c.user_id as \"user_id?\", u.username as \"username?\", c.comment_id, c.post_id, c.author, c.content, c.published_at\n FROM comments c\n LEFT JOIN users u ON c.user_id = u.user_id AND c.user_id IS NOT NULL\n ORDER BY published_at DESC\n LIMIT $1\n OFFSET $2\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
"ordinal": 0, "ordinal": 0,
"name": "post_id", "name": "user_id?",
"type_info": "Uuid" "type_info": "Uuid"
}, },
{ {
"ordinal": 1, "ordinal": 1,
"name": "author", "name": "username?",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "title", "name": "comment_id",
"type_info": "Text" "type_info": "Uuid"
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "post_id",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "author",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "content", "name": "content",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 4, "ordinal": 6,
"name": "published_at", "name": "published_at",
"type_info": "Timestamptz" "type_info": "Timestamptz"
} }
@@ -36,12 +46,14 @@
] ]
}, },
"nullable": [ "nullable": [
true,
false, false,
false, false,
false, false,
true,
false, false,
false false
] ]
}, },
"hash": "1778ace39189532c2c69850ad7366bb36c8b5fe7491064a53190a324485f0e53" "hash": "886de678764ebf7f96fe683d3b685d176f0a41043c7ade8b659a9bd167a2d063"
} }

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE users\n SET username = $1, full_name = $2, bio = $3\n WHERE user_id = $4\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "8dc27ae224c7ae3c99c396302357514d66e843dc4b3ee4ab58c628b6c9797fdd"
}

View File

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

View File

@@ -1,11 +1,10 @@
{ {
"db_name": "PostgreSQL", "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 ", "query": "\n UPDATE idempotency\n SET\n response_status_code = $2,\n response_headers = $3,\n response_body = $4\n WHERE idempotency_key = $1\n ",
"describe": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
"Left": [ "Left": [
"Uuid",
"Text", "Text",
"Int2", "Int2",
{ {
@@ -37,5 +36,5 @@
}, },
"nullable": [] "nullable": []
}, },
"hash": "32701e61ea14e25608b5f6b05289d08d422e9629d6aee98ac1dcbd50f1edbfe1" "hash": "b64d5c2e51f328effc8f4687066db96ad695c575fb66195febcdf95c1539a153"
} }

View File

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

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM comments WHERE post_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "bd08bf95dc1c8c0c7678bc509df7ce776e839846f29981e2e0bdfd382de9370f"
}

View File

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

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM comments WHERE comment_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "caf9f2603db6bc8b715cad188501c12f5de5fae49cd04271471f1337a3232f58"
}

View File

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

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM users WHERE user_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "dfa520877c017cd5808d02c24ef2d71938b68093974f335a4d89df91874fdaa2"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM comments",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "e056c3230c1ccd1b3b62e902f49a41f21213e0f7da92b428065986d380676034"
}

View File

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

View File

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

View File

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

View File

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

4
Cargo.lock generated
View File

@@ -4070,7 +4070,6 @@ dependencies = [
"argon2", "argon2",
"askama", "askama",
"axum", "axum",
"base64 0.22.1",
"chrono", "chrono",
"claims", "claims",
"config", "config",
@@ -4087,7 +4086,6 @@ dependencies = [
"serde", "serde",
"serde-aux", "serde-aux",
"serde_json", "serde_json",
"serde_urlencoded",
"sqlx", "sqlx",
"thiserror", "thiserror",
"tokio", "tokio",
@@ -4096,8 +4094,6 @@ dependencies = [
"tower-sessions-redis-store", "tower-sessions-redis-store",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"unicode-segmentation",
"urlencoding",
"uuid", "uuid",
"validator", "validator",
"wiremock", "wiremock",

View File

@@ -28,7 +28,6 @@ anyhow = "1.0.99"
argon2 = { version = "0.5.3", features = ["std"] } argon2 = { version = "0.5.3", features = ["std"] }
askama = "0.14.0" askama = "0.14.0"
axum = { version = "0.8.4", features = ["macros"] } axum = { version = "0.8.4", features = ["macros"] }
base64 = "0.22.1"
chrono = { version = "0.4.41", default-features = false, features = ["clock"] } chrono = { version = "0.4.41", default-features = false, features = ["clock"] }
config = "0.15.14" config = "0.15.14"
markdown = "1.0.0" markdown = "1.0.0"
@@ -56,8 +55,6 @@ tower-sessions = "0.14.0"
tower-sessions-redis-store = "0.16.0" tower-sessions-redis-store = "0.16.0"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 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"] } uuid = { version = "1.18.0", features = ["v4", "serde"] }
validator = { version = "0.20.0", features = ["derive"] } validator = { version = "0.20.0", features = ["derive"] }
@@ -70,5 +67,4 @@ quickcheck = "1.0.3"
quickcheck_macros = "1.1.0" quickcheck_macros = "1.1.0"
scraper = "0.24.0" scraper = "0.24.0"
serde_json = "1.0.143" serde_json = "1.0.143"
serde_urlencoded = "0.7.1"
wiremock = "0.6.4" wiremock = "0.6.4"

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
CREATE TABLE comments (
comment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES posts (post_id) ON DELETE CASCADE,
author TEXT,
content TEXT NOT NULL,
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,11 @@
ALTER TABLE idempotency
DROP CONSTRAINT idempotency_user_id_fkey;
ALTER TABLE idempotency
DROP CONSTRAINT idempotency_pkey;
ALTER TABLE idempotency
ADD PRIMARY KEY (idempotency_key);
ALTER TABLE idempotency
DROP COLUMN user_id;

View File

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

View File

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

View File

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

3
package-lock.json generated
View File

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

View File

@@ -1,5 +1,3 @@
use std::fmt::Display;
use crate::telemetry::spawn_blocking_with_tracing; use crate::telemetry::spawn_blocking_with_tracing;
use anyhow::Context; use anyhow::Context;
use argon2::{ use argon2::{
@@ -8,6 +6,7 @@ use argon2::{
}; };
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use sqlx::PgPool; use sqlx::PgPool;
use std::fmt::Display;
use uuid::Uuid; use uuid::Uuid;
pub struct Credentials { pub struct Credentials {
@@ -43,7 +42,7 @@ pub async fn change_password(
Ok(()) Ok(())
} }
fn compute_pasword_hash(password: SecretString) -> Result<SecretString, anyhow::Error> { pub(crate) fn compute_pasword_hash(password: SecretString) -> Result<SecretString, anyhow::Error> {
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let password_hash = Argon2::new( let password_hash = Argon2::new(
Algorithm::Argon2id, Algorithm::Argon2id,

58
src/database_worker.rs Normal file
View File

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

View File

@@ -1,9 +1,11 @@
mod comment;
mod new_subscriber; mod new_subscriber;
mod post; mod post;
mod subscriber_email; mod subscriber_email;
mod subscribers; mod subscribers;
mod user; mod user;
pub use comment::CommentEntry;
pub use new_subscriber::NewSubscriber; pub use new_subscriber::NewSubscriber;
pub use post::PostEntry; pub use post::PostEntry;
pub use subscriber_email::SubscriberEmail; pub use subscriber_email::SubscriberEmail;

18
src/domain/comment.rs Normal file
View File

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

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
use crate::authentication::Role;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use uuid::Uuid; use uuid::Uuid;
use crate::authentication::Role;
pub struct UserEntry { pub struct UserEntry {
pub user_id: Uuid, pub user_id: Uuid,
pub username: String, pub username: String,

View File

@@ -7,7 +7,6 @@ use axum::{
use reqwest::StatusCode; use reqwest::StatusCode;
use sqlx::{Executor, PgPool, Postgres, Transaction}; use sqlx::{Executor, PgPool, Postgres, Transaction};
use std::str::FromStr; use std::str::FromStr;
use uuid::Uuid;
#[derive(Debug, sqlx::Type)] #[derive(Debug, sqlx::Type)]
#[sqlx(type_name = "header_pair")] #[sqlx(type_name = "header_pair")]
@@ -23,7 +22,6 @@ struct HeaderPairRecord {
pub async fn get_saved_response( pub async fn get_saved_response(
connection_pool: &PgPool, connection_pool: &PgPool,
idempotency_key: &IdempotencyKey, idempotency_key: &IdempotencyKey,
user_id: Uuid,
) -> Result<Option<Response>, anyhow::Error> { ) -> Result<Option<Response>, anyhow::Error> {
let saved_response = sqlx::query!( let saved_response = sqlx::query!(
r#" r#"
@@ -32,11 +30,8 @@ pub async fn get_saved_response(
response_headers as "response_headers!: Vec<HeaderPairRecord>", response_headers as "response_headers!: Vec<HeaderPairRecord>",
response_body as "response_body!" response_body as "response_body!"
FROM idempotency FROM idempotency
WHERE WHERE idempotency_key = $1
user_id = $1
AND idempotency_key = $2
"#, "#,
user_id,
idempotency_key.as_ref() idempotency_key.as_ref()
) )
.fetch_optional(connection_pool) .fetch_optional(connection_pool)
@@ -61,7 +56,6 @@ pub async fn get_saved_response(
pub async fn save_response( pub async fn save_response(
mut transaction: Transaction<'static, Postgres>, mut transaction: Transaction<'static, Postgres>,
idempotency_key: &IdempotencyKey, idempotency_key: &IdempotencyKey,
user_id: Uuid,
response: Response<Body>, response: Response<Body>,
) -> Result<Response<Body>, anyhow::Error> { ) -> Result<Response<Body>, anyhow::Error> {
let status_code = response.status().as_u16() as i16; let status_code = response.status().as_u16() as i16;
@@ -80,14 +74,11 @@ pub async fn save_response(
r#" r#"
UPDATE idempotency UPDATE idempotency
SET SET
response_status_code = $3, response_status_code = $2,
response_headers = $4, response_headers = $3,
response_body = $5 response_body = $4
WHERE WHERE idempotency_key = $1
user_id = $1
AND idempotency_key = $2
"#, "#,
user_id,
idempotency_key.as_ref(), idempotency_key.as_ref(),
status_code, status_code,
headers, headers,
@@ -109,23 +100,21 @@ pub enum NextAction {
pub async fn try_processing( pub async fn try_processing(
connection_pool: &PgPool, connection_pool: &PgPool,
idempotency_key: &IdempotencyKey, idempotency_key: &IdempotencyKey,
user_id: Uuid,
) -> Result<NextAction, anyhow::Error> { ) -> Result<NextAction, anyhow::Error> {
let mut transaction = connection_pool.begin().await?; let mut transaction = connection_pool.begin().await?;
let query = sqlx::query!( let query = sqlx::query!(
r#" r#"
INSERT INTO idempotency (user_id, idempotency_key, created_at) INSERT INTO idempotency (idempotency_key, created_at)
VALUES ($1, $2, now()) VALUES ($1, now())
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
"#, "#,
user_id,
idempotency_key.as_ref() idempotency_key.as_ref()
); );
let n_inserted_rows = transaction.execute(query).await?.rows_affected(); let n_inserted_rows = transaction.execute(query).await?.rows_affected();
if n_inserted_rows > 0 { if n_inserted_rows > 0 {
Ok(NextAction::StartProcessing(transaction)) Ok(NextAction::StartProcessing(transaction))
} else { } else {
let saved_response = get_saved_response(connection_pool, idempotency_key, user_id) let saved_response = get_saved_response(connection_pool, idempotency_key)
.await? .await?
.ok_or_else(|| anyhow::anyhow!("Could not find saved response."))?; .ok_or_else(|| anyhow::anyhow!("Could not find saved response."))?;
Ok(NextAction::ReturnSavedResponse(saved_response)) Ok(NextAction::ReturnSavedResponse(saved_response))

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
mod admin; mod admin;
mod comments;
mod health_check; mod health_check;
mod home; mod home;
mod login; mod login;
@@ -15,6 +16,7 @@ use axum::{
http::{HeaderMap, request::Parts}, http::{HeaderMap, request::Parts},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
pub use comments::*;
pub use health_check::*; pub use health_check::*;
pub use home::*; pub use home::*;
pub use login::*; pub use login::*;
@@ -26,6 +28,7 @@ pub use subscriptions::*;
pub use subscriptions_confirm::*; pub use subscriptions_confirm::*;
pub use unsubscribe::*; pub use unsubscribe::*;
pub use users::*; pub use users::*;
use validator::ValidationErrors;
use crate::{ use crate::{
authentication::AuthError, authentication::AuthError,
@@ -159,6 +162,7 @@ impl From<AuthError> for AppError {
} }
pub async fn not_found() -> Response { pub async fn not_found() -> Response {
tracing::error!("Not found.");
not_found_html() not_found_html()
} }
@@ -206,3 +210,20 @@ where
} }
} }
} }
pub fn join_error_messages(e: ValidationErrors) -> String {
let error_messages: Vec<_> = e
.field_errors()
.iter()
.flat_map(|(field, errors)| {
errors.iter().map(move |error| {
error
.message
.as_ref()
.map(|msg| msg.to_string())
.unwrap_or(format!("Invalid field: {}", field))
})
})
.collect();
error_messages.join("\n")
}

View File

@@ -6,12 +6,13 @@ mod posts;
mod subscribers; mod subscribers;
use crate::{ use crate::{
authentication::{AuthenticatedUser, Role}, authentication::AuthenticatedUser,
routes::{AppError, error_chain_fmt}, routes::{AppError, error_chain_fmt},
session_state::TypedSession, session_state::TypedSession,
templates::{HtmlTemplate, MessageTemplate}, templates::{HtmlTemplate, MessageTemplate},
}; };
use anyhow::Context; use anyhow::Context;
use axum::response::Redirect;
use axum::{ use axum::{
extract::Request, extract::Request,
middleware::Next, middleware::Next,
@@ -49,11 +50,17 @@ pub async fn require_auth(
mut request: Request, mut request: Request,
next: Next, next: Next,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let user_id = session let user_id = match session
.get_user_id() .get_user_id()
.await .await
.map_err(|e| AdminError::UnexpectedError(e.into()))? .map_err(|e| AdminError::UnexpectedError(e.into()))?
.ok_or(AdminError::NotAuthenticated)?; {
None => {
tracing::error!("Not authenticated. Redirecting to /login.");
return Ok(Redirect::to("/login").into_response());
}
Some(user_id) => user_id,
};
let username = session let username = session
.get_username() .get_username()
.await .await
@@ -81,11 +88,10 @@ pub async fn require_admin(
request: Request, request: Request,
next: Next, next: Next,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
if let Role::Admin = session if session
.get_role() .has_admin_permissions()
.await .await
.context("Error retrieving user role in session.")? .context("Error retrieving user role in session.")?
.ok_or(anyhow::anyhow!("Could not find user role in session."))?
{ {
Ok(next.run(request).await) Ok(next.run(request).await)
} else { } else {

View File

@@ -56,7 +56,7 @@ pub async fn change_password(
} }
} }
fn verify_password(password: &str) -> Result<(), anyhow::Error> { pub fn verify_password(password: &str) -> Result<(), anyhow::Error> {
if password.len() < 12 || password.len() > 128 { if password.len() < 12 || password.len() > 128 {
anyhow::bail!("The password must contain between 12 and 128 characters."); anyhow::bail!("The password must contain between 12 and 128 characters.");
} }

View File

@@ -1,3 +1,7 @@
use crate::routes::{
COMMENTS_PER_PAGE, POSTS_PER_PAGE, SUBS_PER_PAGE, get_comments_count, get_comments_page,
get_posts_count, get_posts_page, get_users,
};
use crate::{ use crate::{
authentication::AuthenticatedUser, authentication::AuthenticatedUser,
routes::{AppError, get_max_page, get_subs, get_total_subs}, routes::{AppError, get_max_page, get_subs, get_total_subs},
@@ -41,10 +45,29 @@ pub async fn admin_dashboard(
.await .await
.context("Could not fetch subscribers from database.") .context("Could not fetch subscribers from database.")
.map_err(AppError::unexpected_message)?; .map_err(AppError::unexpected_message)?;
let count = get_total_subs(&connection_pool) let subs_count = get_total_subs(&connection_pool)
.await .await
.context("Could not fetch total subscribers count from the database.")?; .context("Could not fetch total subscribers count from the database.")?;
let max_page = get_max_page(count); let max_page = get_max_page(subs_count, SUBS_PER_PAGE);
let users = get_users(&connection_pool)
.await
.context("Could not fetch users")?;
let posts = get_posts_page(&connection_pool, 1)
.await
.context("Could not fetch posts.")?;
let posts_current_page = 1;
let posts_count = get_posts_count(&connection_pool)
.await
.context("Could not fetch posts count.")?;
let posts_max_page = get_max_page(posts_count, POSTS_PER_PAGE);
let comments_current_page = 1;
let comments = get_comments_page(&connection_pool, comments_current_page)
.await
.context("Could not fetch comments.")?;
let comments_count = get_comments_count(&connection_pool)
.await
.context("Could not fetch comments count.")?;
let comments_max_page = get_max_page(comments_count, COMMENTS_PER_PAGE);
let template = DashboardTemplate { let template = DashboardTemplate {
user, user,
idempotency_key_1, idempotency_key_1,
@@ -53,6 +76,16 @@ pub async fn admin_dashboard(
subscribers, subscribers,
current_page, current_page,
max_page, max_page,
count: subs_count,
users,
posts,
posts_current_page,
posts_max_page,
posts_count,
comments,
comments_current_page,
comments_max_page,
comments_count,
}; };
Ok(Html(template.render().unwrap()).into_response()) Ok(Html(template.render().unwrap()).into_response())
} }

View File

@@ -1,5 +1,4 @@
use crate::{ use crate::{
authentication::AuthenticatedUser,
idempotency::{IdempotencyKey, save_response, try_processing}, idempotency::{IdempotencyKey, save_response, try_processing},
routes::{AdminError, AppError}, routes::{AdminError, AppError},
startup::AppState, startup::AppState,
@@ -8,7 +7,7 @@ use crate::{
use anyhow::Context; use anyhow::Context;
use askama::Template; use askama::Template;
use axum::{ use axum::{
Extension, Form, Form,
extract::State, extract::State,
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
@@ -95,7 +94,6 @@ pub async fn publish_newsletter(
base_url, base_url,
.. ..
}): State<AppState>, }): State<AppState>,
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Form(form): Form<BodyData>, Form(form): Form<BodyData>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?; validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
@@ -105,7 +103,7 @@ pub async fn publish_newsletter(
.try_into() .try_into()
.map_err(AdminError::Idempotency)?; .map_err(AdminError::Idempotency)?;
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? { let mut transaction = match try_processing(&connection_pool, &idempotency_key).await? {
crate::idempotency::NextAction::StartProcessing(t) => t, crate::idempotency::NextAction::StartProcessing(t) => t,
crate::idempotency::NextAction::ReturnSavedResponse(response) => { crate::idempotency::NextAction::ReturnSavedResponse(response) => {
return Ok(response); return Ok(response);
@@ -129,7 +127,7 @@ pub async fn publish_newsletter(
let message = String::from("Your email has been queued for delivery."); let message = String::from("Your email has been queued for delivery.");
let template = MessageTemplate::success(message); let template = MessageTemplate::success(message);
let response = Html(template.render().unwrap()).into_response(); let response = Html(template.render().unwrap()).into_response();
let response = save_response(transaction, &idempotency_key, user_id, response) let response = save_response(transaction, &idempotency_key, response)
.await .await
.map_err(AdminError::UnexpectedError)?; .map_err(AdminError::UnexpectedError)?;
Ok(response) Ok(response)

View File

@@ -54,7 +54,7 @@ pub async fn create_post(
.try_into() .try_into()
.map_err(AdminError::Idempotency)?; .map_err(AdminError::Idempotency)?;
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? { let mut transaction = match try_processing(&connection_pool, &idempotency_key).await? {
crate::idempotency::NextAction::StartProcessing(t) => t, crate::idempotency::NextAction::StartProcessing(t) => t,
crate::idempotency::NextAction::ReturnSavedResponse(response) => { crate::idempotency::NextAction::ReturnSavedResponse(response) => {
return Ok(response); return Ok(response);
@@ -75,7 +75,7 @@ pub async fn create_post(
let template = MessageTemplate::success("Your new post has been published!".into()); let template = MessageTemplate::success("Your new post has been published!".into());
let response = Html(template.render().unwrap()).into_response(); let response = Html(template.render().unwrap()).into_response();
let response = save_response(transaction, &idempotency_key, user_id, response) let response = save_response(transaction, &idempotency_key, response)
.await .await
.map_err(AdminError::UnexpectedError)?; .map_err(AdminError::UnexpectedError)?;
Ok(response) Ok(response)
@@ -136,7 +136,7 @@ pub async fn delete_post(
"We could not find the post in the database." "We could not find the post in the database."
))) )))
} else { } else {
let template = MessageTemplate::success("The subscriber has been deleted.".into()); let template = MessageTemplate::success("The post has been deleted.".into());
Ok(template.render().unwrap().into_response()) Ok(template.render().unwrap().into_response())
} }
} }

View File

@@ -13,12 +13,9 @@ use axum::{
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
const SUBS_PER_PAGE: i64 = 5; pub const SUBS_PER_PAGE: i64 = 5;
#[tracing::instrument( #[tracing::instrument(name = "Retrieving subscribers from database", skip(connection_pool))]
name = "Retrieving most recent subscribers from database",
skip(connection_pool)
)]
pub async fn get_subscribers_page( pub async fn get_subscribers_page(
State(AppState { State(AppState {
connection_pool, .. connection_pool, ..
@@ -29,7 +26,7 @@ pub async fn get_subscribers_page(
.await .await
.context("Could not fetch total subscribers count from the database.") .context("Could not fetch total subscribers count from the database.")
.map_err(AppError::unexpected_message)?; .map_err(AppError::unexpected_message)?;
let max_page = get_max_page(count); let max_page = get_max_page(count, SUBS_PER_PAGE);
let subscribers = get_subs(&connection_pool, page) let subscribers = get_subs(&connection_pool, page)
.await .await
.context("Could not fetch subscribers data.") .context("Could not fetch subscribers data.")
@@ -105,9 +102,9 @@ pub async fn get_total_subs(connection_pool: &PgPool) -> Result<i64, sqlx::Error
Ok(count) Ok(count)
} }
pub fn get_max_page(count: i64) -> i64 { pub fn get_max_page(count: i64, num_per_page: i64) -> i64 {
let mut max_page = count.div_euclid(SUBS_PER_PAGE); let mut max_page = count.div_euclid(num_per_page);
if count % SUBS_PER_PAGE > 0 { if count % num_per_page > 0 {
max_page += 1; max_page += 1;
} }
max_page max_page

264
src/routes/comments.rs Normal file
View File

@@ -0,0 +1,264 @@
use crate::idempotency::{IdempotencyKey, save_response, try_processing};
use crate::routes::{AdminError, get_max_page};
use crate::templates::CommentsPageDashboardTemplate;
use crate::{
domain::CommentEntry,
routes::AppError,
startup::AppState,
templates::{CommentsList, HtmlTemplate, MessageTemplate},
};
use anyhow::Context;
use askama::Template;
use axum::{
Form,
extract::{Path, Query, State},
response::{IntoResponse, Response},
};
use sqlx::{Executor, PgPool, Postgres, Transaction};
use uuid::Uuid;
#[derive(serde::Deserialize)]
pub struct CommentPathParam {
post_id: Uuid,
}
#[derive(serde::Deserialize)]
pub struct CommentForm {
pub author: Option<String>,
pub content: String,
pub idempotency_key: String,
pub user_id: Option<Uuid>,
}
#[tracing::instrument(name = "Posting new comment", skip_all, fields(post_id = %post_id))]
pub async fn post_comment(
Path(CommentPathParam { post_id }): Path<CommentPathParam>,
State(AppState {
connection_pool, ..
}): State<AppState>,
Form(form): Form<CommentForm>,
) -> Result<Response, AppError> {
validate_form(&form)?;
let idempotency_key: IdempotencyKey = form
.idempotency_key
.try_into()
.map_err(AdminError::Idempotency)?;
let mut transaction = match try_processing(&connection_pool, &idempotency_key).await? {
crate::idempotency::NextAction::StartProcessing(t) => t,
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
return Ok(response);
}
};
insert_comment(
&mut transaction,
post_id,
form.author,
form.user_id,
form.content,
)
.await
.context("Could not insert comment into database.")?;
let template = HtmlTemplate(MessageTemplate::success(
"Your comment has been posted.".into(),
));
let response = template.into_response();
let response = save_response(transaction, &idempotency_key, response).await?;
Ok(response)
}
fn validate_form(form: &CommentForm) -> Result<(), anyhow::Error> {
if form.content.is_empty() {
anyhow::bail!("Comment content cannot be empty.");
}
Ok(())
}
#[tracing::instrument(name = "Inserting new comment in database", skip_all, fields(comment_id = tracing::field::Empty))]
async fn insert_comment(
transaction: &mut Transaction<'static, Postgres>,
post_id: Uuid,
author: Option<String>,
user_id: Option<Uuid>,
content: String,
) -> Result<Uuid, sqlx::Error> {
let author = if user_id.is_some() {
None
} else {
author
.filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string())
};
let content = content.trim();
let comment_id = Uuid::new_v4();
tracing::Span::current().record("comment_id", comment_id.to_string());
let query = sqlx::query!(
"
INSERT INTO comments (user_id, comment_id, post_id, author, content)
VALUES ($1, $2, $3, $4, $5)
",
user_id,
comment_id,
post_id,
author,
content,
);
transaction.execute(query).await?;
Ok(comment_id)
}
pub const COMMENTS_PER_PAGE: i64 = 5;
#[derive(serde::Deserialize)]
pub struct GetCommentsQueryParams {
page: i64,
}
#[tracing::instrument(name = "Fetching comments", skip(connection_pool))]
pub async fn get_comments(
Path(CommentPathParam { post_id }): Path<CommentPathParam>,
Query(GetCommentsQueryParams { page }): Query<GetCommentsQueryParams>,
State(AppState {
connection_pool, ..
}): State<AppState>,
) -> Result<Response, AppError> {
let comments = get_comments_page_for_post(&connection_pool, post_id, page)
.await
.context("Could not fetch comments.")?;
let count = get_comments_count_for_post(&connection_pool, post_id)
.await
.context("Could not fetch comments count")?;
let max_page = get_max_page(count, COMMENTS_PER_PAGE);
let template = HtmlTemplate(CommentsList {
comments,
current_page: page,
max_page,
});
Ok(template.into_response())
}
#[tracing::instrument(name = "Fetching all comments", skip(connection_pool))]
pub async fn get_all_comments(
Query(GetCommentsQueryParams { page }): Query<GetCommentsQueryParams>,
State(AppState {
connection_pool, ..
}): State<AppState>,
) -> Result<Response, AppError> {
let comments = get_comments_page(&connection_pool, page)
.await
.context("Could not fetch comments.")?;
let count = get_comments_count(&connection_pool)
.await
.context("Could not fetch comments count")?;
let comments_max_page = get_max_page(count, COMMENTS_PER_PAGE);
let template = HtmlTemplate(CommentsPageDashboardTemplate {
comments,
comments_current_page: page,
comments_max_page,
});
Ok(template.into_response())
}
pub async fn delete_comment(
State(AppState {
connection_pool, ..
}): State<AppState>,
crate::routes::Path(comment_id): crate::routes::Path<Uuid>,
) -> Result<Response, AppError> {
let res = sqlx::query!("DELETE FROM comments WHERE comment_id = $1", comment_id)
.execute(&connection_pool)
.await
.context("Failed to delete comment from database.")
.map_err(AppError::unexpected_message)?;
if res.rows_affected() > 1 {
Err(AppError::unexpected_message(anyhow::anyhow!(
"We could not find the comment in the database."
)))
} else {
let template = MessageTemplate::success("The comment has been deleted.".into());
Ok(template.render().unwrap().into_response())
}
}
pub async fn get_comments_page_for_post(
connection_pool: &PgPool,
post_id: Uuid,
page: i64,
) -> Result<Vec<CommentEntry>, sqlx::Error> {
let offset = (page - 1) * COMMENTS_PER_PAGE;
let mut comments = sqlx::query_as!(
CommentEntry,
r#"
SELECT c.user_id as "user_id?", u.username as "username?", c.comment_id, c.post_id, c.author, c.content, c.published_at
FROM comments c
LEFT JOIN users u ON c.user_id = u.user_id AND c.user_id IS NOT NULL
WHERE c.post_id = $1
ORDER BY c.published_at DESC
LIMIT $2
OFFSET $3
"#,
post_id,
COMMENTS_PER_PAGE,
offset
)
.fetch_all(connection_pool)
.await?;
for comment in comments.iter_mut() {
if let Some(user_id) = comment.user_id {
let record = sqlx::query!(
"SELECT username, full_name FROM users WHERE user_id = $1",
user_id
)
.fetch_one(connection_pool)
.await?;
let author = record.full_name.unwrap_or(record.username);
comment.author = Some(author);
}
}
Ok(comments)
}
pub async fn get_comments_count_for_post(
connection_pool: &PgPool,
post_id: Uuid,
) -> Result<i64, sqlx::Error> {
let count = sqlx::query_scalar!("SELECT count(*) FROM comments WHERE post_id = $1", post_id)
.fetch_one(connection_pool)
.await?
.unwrap_or(0);
Ok(count)
}
pub async fn get_comments_page(
connection_pool: &PgPool,
page: i64,
) -> Result<Vec<CommentEntry>, sqlx::Error> {
let offset = (page - 1) * COMMENTS_PER_PAGE;
let comments = sqlx::query_as!(
CommentEntry,
r#"
SELECT c.user_id as "user_id?", u.username as "username?", c.comment_id, c.post_id, c.author, c.content, c.published_at
FROM comments c
LEFT JOIN users u ON c.user_id = u.user_id AND c.user_id IS NOT NULL
ORDER BY published_at DESC
LIMIT $1
OFFSET $2
"#,
COMMENTS_PER_PAGE,
offset
)
.fetch_all(connection_pool)
.await?;
Ok(comments)
}
pub async fn get_comments_count(connection_pool: &PgPool) -> Result<i64, sqlx::Error> {
let count = sqlx::query_scalar!("SELECT count(*) FROM comments")
.fetch_one(connection_pool)
.await?
.unwrap_or(0);
Ok(count)
}

View File

@@ -15,6 +15,8 @@ use axum::{
}; };
use axum::{http::StatusCode, response::Redirect}; use axum::{http::StatusCode, response::Redirect};
use secrecy::SecretString; use secrecy::SecretString;
use sqlx::PgPool;
use uuid::Uuid;
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct LoginFormData { pub struct LoginFormData {
@@ -29,7 +31,7 @@ pub async fn get_login(session: TypedSession) -> Result<Response, AppError> {
.context("Failed to retrieve user id from data store.")? .context("Failed to retrieve user id from data store.")?
.is_some() .is_some()
{ {
Ok(Redirect::to("/admin/dashboard").into_response()) Ok(Redirect::to("dashboard").into_response())
} else { } else {
Ok(Html(LoginTemplate.render().unwrap()).into_response()) Ok(Html(LoginTemplate.render().unwrap()).into_response())
} }
@@ -50,6 +52,9 @@ pub async fn post_login(
tracing::Span::current().record("username", tracing::field::display(&credentials.username)); tracing::Span::current().record("username", tracing::field::display(&credentials.username));
let (user_id, role) = validate_credentials(credentials, &connection_pool).await?; let (user_id, role) = validate_credentials(credentials, &connection_pool).await?;
tracing::Span::current().record("user_id", tracing::field::display(&user_id)); tracing::Span::current().record("user_id", tracing::field::display(&user_id));
record_login(&connection_pool, &user_id)
.await
.context("Failed to register new login event.")?;
session.renew().await.context("Failed to renew session.")?; session.renew().await.context("Failed to renew session.")?;
session session
@@ -66,6 +71,14 @@ pub async fn post_login(
.context("Failed to insert role in session data store.")?; .context("Failed to insert role in session data store.")?;
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap()); headers.insert("HX-Redirect", "/dashboard".parse().unwrap());
Ok((StatusCode::OK, headers).into_response()) Ok((StatusCode::OK, headers).into_response())
} }
#[tracing::instrument(name = "Recording new login event", skip_all, fields(user_id = %user_id))]
async fn record_login(connection_pool: &PgPool, user_id: &Uuid) -> Result<(), sqlx::Error> {
sqlx::query!("INSERT INTO user_logins (user_id) VALUES ($1)", user_id)
.execute(connection_pool)
.await?;
Ok(())
}

View File

@@ -1,19 +1,28 @@
use crate::authentication::AuthenticatedUser;
use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page, join_error_messages};
use crate::session_state::TypedSession;
use crate::templates::{ErrorTemplate, MessageTemplate, PostsPageDashboardTemplate};
use crate::{ use crate::{
domain::PostEntry, domain::PostEntry,
routes::{AppError, Path, Query, not_found_html}, routes::{
AppError, Path, get_comments_count_for_post, get_comments_page_for_post, not_found_html,
},
startup::AppState, startup::AppState,
templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate}, templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate},
}; };
use anyhow::Context; use anyhow::Context;
use askama::Template; use askama::Template;
use axum::{ use axum::{
Extension, Form,
extract::State, extract::State,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
use chrono::Utc;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use validator::Validate;
const NUM_PER_PAGE: i64 = 3; pub const POSTS_PER_PAGE: i64 = 3;
#[tracing::instrument(name = "Fetching most recent posts from database", skip_all)] #[tracing::instrument(name = "Fetching most recent posts from database", skip_all)]
pub async fn list_posts( pub async fn list_posts(
@@ -21,12 +30,16 @@ pub async fn list_posts(
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let count = get_posts_table_size(&connection_pool) let count = get_posts_count(&connection_pool)
.await .await
.context("Could not fetch posts table size.") .context("Could not fetch posts table size.")
.map_err(AppError::unexpected_page)?; .map_err(AppError::unexpected_page)?;
let next_page = if count > NUM_PER_PAGE { Some(2) } else { None }; let next_page = if count > POSTS_PER_PAGE {
let posts = get_posts(&connection_pool, NUM_PER_PAGE, None) Some(2)
} else {
None
};
let posts = get_posts(&connection_pool, POSTS_PER_PAGE, None)
.await .await
.context("Could not fetch latest posts") .context("Could not fetch latest posts")
.map_err(AppError::unexpected_page)?; .map_err(AppError::unexpected_page)?;
@@ -34,6 +47,29 @@ pub async fn list_posts(
Ok(Html(template.render().unwrap()).into_response()) Ok(Html(template.render().unwrap()).into_response())
} }
#[tracing::instrument(name = "Fetching next posts from database", skip_all)]
pub async fn get_posts_page_dashboard(
State(AppState {
connection_pool, ..
}): State<AppState>,
Query(LoadMoreParams { page }): Query<LoadMoreParams>,
) -> Result<Response, AppError> {
let posts = get_posts_page(&connection_pool, page)
.await
.context("Could not fetch next posts page.")?;
let posts_current_page = page;
let count = get_posts_count(&connection_pool)
.await
.context("Could not fetch number of posts.")?;
let posts_max_page = get_max_page(count, POSTS_PER_PAGE);
let template = HtmlTemplate(PostsPageDashboardTemplate {
posts,
posts_current_page,
posts_max_page,
});
Ok(template.into_response())
}
async fn get_posts( async fn get_posts(
connection_pool: &PgPool, connection_pool: &PgPool,
n: i64, n: i64,
@@ -42,7 +78,8 @@ async fn get_posts(
sqlx::query_as!( sqlx::query_as!(
PostEntry, PostEntry,
r#" r#"
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at SELECT p.post_id, p.author_id, u.username AS author, u.full_name,
p.title, p.content, p.published_at, p.last_modified
FROM posts p FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id LEFT JOIN users u ON p.author_id = u.user_id
ORDER BY p.published_at DESC ORDER BY p.published_at DESC
@@ -56,25 +93,106 @@ async fn get_posts(
.await .await
} }
async fn get_posts_table_size(connection_pool: &PgPool) -> Result<i64, sqlx::Error> { pub async fn get_posts_page(
connection_pool: &PgPool,
page: i64,
) -> Result<Vec<PostEntry>, sqlx::Error> {
let offset = (page - 1) * POSTS_PER_PAGE;
sqlx::query_as!(
PostEntry,
r#"
SELECT p.post_id, p.author_id, u.username AS author, u.full_name,
p.title, p.content, p.published_at, p.last_modified
FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id
ORDER BY p.published_at DESC
LIMIT $1
OFFSET $2
"#,
POSTS_PER_PAGE,
offset
)
.fetch_all(connection_pool)
.await
}
pub async fn get_posts_count(connection_pool: &PgPool) -> Result<i64, sqlx::Error> {
sqlx::query!("SELECT count(*) FROM posts") sqlx::query!("SELECT count(*) FROM posts")
.fetch_one(connection_pool) .fetch_one(connection_pool)
.await .await
.map(|r| r.count.unwrap()) .map(|r| r.count.unwrap())
} }
#[derive(Validate, serde::Deserialize)]
pub struct EditPostForm {
#[validate(length(min = 1, message = "Title must be at least one character."))]
pub title: String,
#[validate(length(min = 1, message = "Content must be at least one character."))]
pub content: String,
}
#[tracing::instrument(name = "Editing post", skip_all, fields(post_id = %post_id))]
pub async fn update_post(
State(AppState {
connection_pool, ..
}): State<AppState>,
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Path(post_id): Path<Uuid>,
Form(form): Form<EditPostForm>,
) -> Result<Response, AppError> {
let record = sqlx::query!("SELECT author_id FROM posts WHERE post_id = $1", post_id)
.fetch_optional(&connection_pool)
.await
.context("Could not fetch post author.")?;
match record {
None => Ok(HtmlTemplate(ErrorTemplate::NotFound).into_response()),
Some(record) if record.author_id == user_id => {
if let Err(e) = form.validate().map_err(join_error_messages) {
let template = HtmlTemplate(MessageTemplate::error(e));
return Ok(template.into_response());
}
sqlx::query!(
"
UPDATE posts
SET title = $1, content = $2, last_modified = $3 WHERE post_id = $4
",
form.title,
form.content,
Utc::now(),
post_id
)
.execute(&connection_pool)
.await
.context("Could not update post")?;
Ok(HtmlTemplate(MessageTemplate::success(
"Your changes have been saved.".into(),
))
.into_response())
}
_ => Ok(HtmlTemplate(MessageTemplate::error(
"You are not authorized. Only the author can edit his post.".into(),
))
.into_response()),
}
}
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct PostParams { pub struct OriginQueryParam {
origin: Option<Uuid>, origin: Option<Uuid>,
} }
#[tracing::instrument(name = "Fetching post from database", skip(connection_pool, origin))] #[tracing::instrument(
name = "Fetching post from database",
skip(connection_pool, origin, session)
)]
pub async fn see_post( pub async fn see_post(
session: TypedSession,
State(AppState { State(AppState {
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
Path(post_id): Path<Uuid>, Path(post_id): Path<Uuid>,
Query(PostParams { origin }): Query<PostParams>, Query(OriginQueryParam { origin }): Query<OriginQueryParam>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
if let Some(origin) = origin { if let Some(origin) = origin {
mark_email_as_opened(&connection_pool, origin).await?; mark_email_as_opened(&connection_pool, origin).await?;
@@ -83,13 +201,40 @@ pub async fn see_post(
if let Some(post) = get_post_data(&connection_pool, post_id) if let Some(post) = get_post_data(&connection_pool, post_id)
.await .await
.context(format!("Failed to fetch post #{}", post_id)) .context(format!("Failed to fetch post #{}.", post_id))
.map_err(AppError::unexpected_page)? .map_err(AppError::unexpected_page)?
{ {
let post = post let post_html = post
.to_html() .to_html()
.context("Could not render markdown with extension.")?; .context("Could not render markdown with extension.")?;
let template = HtmlTemplate(PostTemplate { post }); let current_page = 1;
let comments_count = get_comments_count_for_post(&connection_pool, post_id)
.await
.context("Could not fetch comment count.")?;
let max_page = get_max_page(comments_count, COMMENTS_PER_PAGE);
let comments = get_comments_page_for_post(&connection_pool, post_id, 1)
.await
.context("Failed to fetch latest comments.")?;
let idempotency_key = Uuid::new_v4().to_string();
let session_user_id = session
.get_user_id()
.await
.context("Could not check for session user id.")?;
let session_username = session
.get_username()
.await
.context("Could not check for session username.")?;
let template = HtmlTemplate(PostTemplate {
post,
post_html,
comments,
idempotency_key,
current_page,
max_page,
comments_count,
session_user_id,
session_username,
});
Ok(template.into_response()) Ok(template.into_response())
} else { } else {
Ok(not_found_html()) Ok(not_found_html())
@@ -116,7 +261,8 @@ async fn get_post_data(
sqlx::query_as!( sqlx::query_as!(
PostEntry, PostEntry,
r#" r#"
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at SELECT p.post_id, p.author_id, u.username AS author, u.full_name,
p.title, p.content, p.published_at, last_modified
FROM posts p FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id LEFT JOIN users u ON p.author_id = u.user_id
WHERE p.post_id = $1 WHERE p.post_id = $1
@@ -139,15 +285,17 @@ pub async fn load_more(
}): State<AppState>, }): State<AppState>,
Query(LoadMoreParams { page }): Query<LoadMoreParams>, Query(LoadMoreParams { page }): Query<LoadMoreParams>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let offset = (page - 1) * NUM_PER_PAGE; let posts = get_posts_page(&connection_pool, page)
let posts = get_posts(&connection_pool, NUM_PER_PAGE, Some(offset))
.await .await
.context("Could not fetch posts from database.")?; .context("Could not fetch posts from database.")?;
let count = posts.len(); let count = get_posts_count(&connection_pool)
.await
.context("Could not fetch posts count.")?;
let max_page = get_max_page(count, POSTS_PER_PAGE);
Ok(Html( Ok(Html(
PostListTemplate { PostListTemplate {
posts, posts,
next_page: if count as i64 == NUM_PER_PAGE { next_page: if page < max_page {
Some(page + 1) Some(page + 1)
} else { } else {
None None

View File

@@ -1,3 +1,7 @@
use crate::authentication::AuthenticatedUser;
use crate::routes::{join_error_messages, verify_password};
use crate::session_state::TypedSession;
use crate::templates::{MessageTemplate, UserEditTemplate};
use crate::{ use crate::{
authentication::Role, authentication::Role,
domain::{PostEntry, UserEntry}, domain::{PostEntry, UserEntry},
@@ -7,23 +11,245 @@ use crate::{
}; };
use anyhow::Context; use anyhow::Context;
use axum::{ use axum::{
Extension, Form,
extract::{Path, State}, extract::{Path, State},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use secrecy::{ExposeSecret, SecretString};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use validator::Validate;
#[derive(serde::Deserialize)] pub async fn user_edit_form(
pub struct ProfilePath { Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
username: String,
}
#[tracing::instrument(name = "Fetching user data", skip(connection_pool))]
pub async fn user_profile(
State(AppState { State(AppState {
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
Path(ProfilePath { username }): Path<ProfilePath>, ) -> Result<Response, AppError> {
let user = sqlx::query_as!(
UserEntry,
r#"
SELECT user_id, username, role as "role: Role", full_name, bio, member_since
FROM users
WHERE user_id = $1
"#,
user_id
)
.fetch_one(&connection_pool)
.await
.context("Could not fetch user in database.")?;
let template = HtmlTemplate(UserEditTemplate { user });
Ok(template.into_response())
}
#[derive(Debug, Validate, serde::Deserialize)]
pub struct EditProfileForm {
user_id: Uuid,
#[validate(length(min = 3, message = "Username must be at least 3 characters."))]
username: String,
full_name: String,
bio: String,
}
#[tracing::instrument(name = "Updating user profile", skip_all, fields(user_id = %form.user_id))]
pub async fn update_user(
State(AppState {
connection_pool, ..
}): State<AppState>,
session: TypedSession,
Extension(AuthenticatedUser {
user_id: session_user_id,
username: session_username,
..
}): Extension<AuthenticatedUser>,
Form(form): Form<EditProfileForm>,
) -> Result<Response, AppError> {
if let Err(e) = form.validate().map_err(join_error_messages) {
let template = HtmlTemplate(MessageTemplate::error(e));
return Ok(template.into_response());
}
if form.user_id != session_user_id {
let template = HtmlTemplate(MessageTemplate::error(
"You are not authorized. Refresh the page and try again.".into(),
));
return Ok(template.into_response());
}
let updated_username = form.username.trim();
if updated_username != session_username
&& sqlx::query!(
"SELECT user_id FROM users WHERE username = $1",
updated_username
)
.fetch_optional(&connection_pool)
.await
.context("Could not fetch users table.")?
.is_some()
{
let template = HtmlTemplate(MessageTemplate::error(
"This username is already taken.".into(),
));
return Ok(template.into_response());
}
let updated_full_name = form.full_name.trim();
let bio = {
let bio = form.bio.trim();
if bio.is_empty() { None } else { Some(bio) }
};
sqlx::query!(
"
UPDATE users
SET username = $1, full_name = $2, bio = $3
WHERE user_id = $4
",
updated_username,
updated_full_name,
bio,
form.user_id
)
.execute(&connection_pool)
.await
.context("Failed to apply changes.")
.map_err(AppError::FormError)?;
session
.insert_username(updated_username.to_owned())
.await
.context("Could not update session username.")?;
let template = HtmlTemplate(MessageTemplate::success(
"Your profile has been updated.".into(),
));
Ok(template.into_response())
}
#[tracing::instrument(name = "Get users from database", skip(connection_pool))]
pub async fn get_users(connection_pool: &PgPool) -> Result<Vec<UserEntry>, sqlx::Error> {
sqlx::query_as!(
UserEntry,
r#"
SELECT user_id, username, role as "role: Role", full_name, bio, member_since
FROM users
ORDER BY member_since DESC
"#
)
.fetch_all(connection_pool)
.await
}
#[derive(Debug, serde::Deserialize)]
pub struct CreateUserForm {
username: String,
password: SecretString,
password_check: SecretString,
admin: Option<bool>,
}
struct NewUser {
username: String,
password_hash: SecretString,
role: Role,
}
impl TryFrom<CreateUserForm> for NewUser {
type Error = anyhow::Error;
fn try_from(value: CreateUserForm) -> Result<Self, Self::Error> {
if value.username.trim().is_empty() {
anyhow::bail!("Username cannot be empty.");
}
verify_password(value.password.expose_secret())?;
if value.password.expose_secret() != value.password_check.expose_secret() {
anyhow::bail!("Password mismatch.");
}
let role = match value.admin {
Some(true) => Role::Admin,
_ => Role::Writer,
};
let password_hash = crate::authentication::compute_pasword_hash(value.password)
.context("Failed to hash password.")?;
Ok(Self {
username: value.username,
password_hash,
role,
})
}
}
#[tracing::instrument(name = "Creating new user", skip_all, fields(username = %form.username))]
pub async fn create_user(
State(AppState {
connection_pool, ..
}): State<AppState>,
Form(form): Form<CreateUserForm>,
) -> Result<Response, AppError> {
let new_user: NewUser = match form.try_into().map_err(|e: anyhow::Error| e.to_string()) {
Err(e) => {
let template = HtmlTemplate(MessageTemplate::error(e));
return Ok(template.into_response());
}
Ok(new_user) => new_user,
};
insert_user(&connection_pool, new_user)
.await
.context("Could not insert user in database.")?;
let template = HtmlTemplate(MessageTemplate::success(
"The new user has been created.".into(),
));
Ok(template.into_response())
}
async fn insert_user(connection_pool: &PgPool, new_user: NewUser) -> Result<Uuid, sqlx::Error> {
let user_id = Uuid::new_v4();
sqlx::query!(
r#"
INSERT INTO users (user_id, username, password_hash, role)
VALUES ($1, $2, $3, $4)
"#,
user_id,
new_user.username,
new_user.password_hash.expose_secret(),
new_user.role as _
)
.execute(connection_pool)
.await?;
Ok(user_id)
}
#[derive(serde::Deserialize)]
pub struct SubscriberPathParams {
pub user_id: Uuid,
}
#[tracing::instrument(name = "Delete user from database", skip(connection_pool))]
pub async fn delete_user(
State(AppState {
connection_pool, ..
}): State<AppState>,
Path(SubscriberPathParams { user_id }): Path<SubscriberPathParams>,
) -> Result<Response, AppError> {
let result = sqlx::query!("DELETE FROM users WHERE user_id = $1", user_id)
.execute(&connection_pool)
.await
.context("Failed to delete user from database.")?;
let template = if result.rows_affected() == 0 {
HtmlTemplate(MessageTemplate::error(
"The user could not be deleted.".into(),
))
} else {
HtmlTemplate(MessageTemplate::success(
"The user has been deleted.".into(),
))
};
Ok(template.into_response())
}
#[tracing::instrument(name = "Fetching user data", skip(connection_pool, session))]
pub async fn user_profile(
session: TypedSession,
State(AppState {
connection_pool, ..
}): State<AppState>,
Path(username): Path<String>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
match fetch_user_data(&connection_pool, &username) match fetch_user_data(&connection_pool, &username)
.await .await
@@ -33,7 +259,34 @@ pub async fn user_profile(
let posts = fetch_user_posts(&connection_pool, &user.user_id) let posts = fetch_user_posts(&connection_pool, &user.user_id)
.await .await
.context("Could not fetch user posts.")?; .context("Could not fetch user posts.")?;
let template = HtmlTemplate(UserTemplate { user, posts }); let session_user_id = session
.get_user_id()
.await
.context("Could not fetch session username.")?;
let profile_user_id =
sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
.fetch_one(&connection_pool)
.await
.context("Could not fetch profile user id.")?
.user_id;
let last_seen = sqlx::query!(
"
SELECT login_time FROM user_logins
WHERE user_id = $1
ORDER BY login_time DESC
",
profile_user_id
)
.fetch_optional(&connection_pool)
.await
.context("Failed to fetch last user login")?
.map(|r| r.login_time);
let template = HtmlTemplate(UserTemplate {
user,
session_user_id,
last_seen,
posts,
});
Ok(template.into_response()) Ok(template.into_response())
} }
None => { None => {
@@ -69,7 +322,8 @@ async fn fetch_user_posts(
sqlx::query_as!( sqlx::query_as!(
PostEntry, PostEntry,
r#" r#"
SELECT u.username as author, p.post_id, p.title, p.content, p.published_at SELECT p.author_id, u.username as author, u.full_name,
p.post_id, p.title, p.content, p.published_at, p.last_modified
FROM posts p FROM posts p
INNER JOIN users u ON p.author_id = u.user_id INNER JOIN users u ON p.author_id = u.user_id
WHERE p.author_id = $1 WHERE p.author_id = $1

View File

@@ -42,6 +42,15 @@ impl TypedSession {
self.0.get(Self::ROLE_KEY).await self.0.get(Self::ROLE_KEY).await
} }
pub async fn has_admin_permissions(&self) -> Result<bool> {
let role = self.0.get(Self::ROLE_KEY).await?;
if let Some(Role::Admin) = role {
Ok(true)
} else {
Ok(false)
}
}
pub async fn clear(&self) { pub async fn clear(&self) {
self.0.clear().await; self.0.clear().await;
} }

View File

@@ -7,7 +7,7 @@ use axum::{
http::Request, http::Request,
middleware, middleware,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{delete, get, post}, routing::{delete, get, post, put},
}; };
use reqwest::{StatusCode, header}; use reqwest::{StatusCode, header};
use sqlx::{PgPool, postgres::PgPoolOptions}; use sqlx::{PgPool, postgres::PgPoolOptions};
@@ -87,15 +87,22 @@ pub fn app(
let admin_routes = Router::new() let admin_routes = Router::new()
.route("/subscribers", get(get_subscribers_page)) .route("/subscribers", get(get_subscribers_page))
.route("/subscribers/{subscriber_id}", delete(delete_subscriber)) .route("/subscribers/{subscriber_id}", delete(delete_subscriber))
.route("/posts", get(get_posts_page_dashboard))
.route("/posts/{post_id}", delete(delete_post)) .route("/posts/{post_id}", delete(delete_post))
.route("/users", post(create_user))
.route("/users/{user_id}", delete(delete_user))
.route("/comments", get(get_all_comments))
.route("/comments/{comment_id}", delete(delete_comment))
.layer(middleware::from_fn(require_admin)); .layer(middleware::from_fn(require_admin));
let auth_routes = Router::new() let auth_routes = Router::new()
.route("/dashboard", get(admin_dashboard)) .route("/dashboard", get(admin_dashboard))
.route("/password", post(change_password)) .route("/password", post(change_password))
.route("/newsletters", post(publish_newsletter)) .route("/newsletters", post(publish_newsletter))
.route("/posts", post(create_post)) .route("/posts", post(create_post))
.route("/posts/{post_id}", put(update_post))
.route("/logout", get(logout)) .route("/logout", get(logout))
.merge(admin_routes) .route("/users/edit", get(user_edit_form).put(update_user))
.nest("/admin", admin_routes)
.layer(middleware::from_fn(require_auth)); .layer(middleware::from_fn(require_auth));
Router::new() Router::new()
.route("/", get(home)) .route("/", get(home))
@@ -108,9 +115,13 @@ pub fn app(
.route("/posts", get(list_posts)) .route("/posts", get(list_posts))
.route("/posts/load_more", get(load_more)) .route("/posts/load_more", get(load_more))
.route("/posts/{post_id}", get(see_post)) .route("/posts/{post_id}", get(see_post))
.route(
"/posts/{post_id}/comments",
post(post_comment).get(get_comments),
)
.route("/users/{username}", get(user_profile)) .route("/users/{username}", get(user_profile))
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))
.nest("/admin", auth_routes) .merge(auth_routes)
.nest_service("/assets", ServeDir::new("assets")) .nest_service("/assets", ServeDir::new("assets"))
.layer( .layer(
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {

View File

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

View File

@@ -1,10 +1,11 @@
use crate::{ use crate::{
authentication::AuthenticatedUser, authentication::AuthenticatedUser,
domain::{PostEntry, SubscriberEntry, UserEntry}, domain::{CommentEntry, PostEntry, SubscriberEntry, UserEntry},
routes::{AppError, DashboardStats}, routes::{AppError, DashboardStats},
}; };
use askama::Template; use askama::Template;
use axum::response::{Html, IntoResponse}; use axum::response::{Html, IntoResponse};
use chrono::{DateTime, Utc};
use uuid::Uuid; use uuid::Uuid;
pub struct HtmlTemplate<T>(pub T); pub struct HtmlTemplate<T>(pub T);
@@ -25,7 +26,15 @@ where
#[template(path = "user/profile.html")] #[template(path = "user/profile.html")]
pub struct UserTemplate { pub struct UserTemplate {
pub user: UserEntry, pub user: UserEntry,
pub session_user_id: Option<Uuid>,
pub posts: Vec<PostEntry>, pub posts: Vec<PostEntry>,
pub last_seen: Option<DateTime<Utc>>,
}
#[derive(Template)]
#[template(path = "user/edit.html")]
pub struct UserEditTemplate {
pub user: UserEntry,
} }
#[derive(Template)] #[derive(Template)]
@@ -64,6 +73,32 @@ pub struct DashboardTemplate {
pub subscribers: Vec<SubscriberEntry>, pub subscribers: Vec<SubscriberEntry>,
pub current_page: i64, pub current_page: i64,
pub max_page: i64, pub max_page: i64,
pub count: i64,
pub users: Vec<UserEntry>,
pub posts: Vec<PostEntry>,
pub posts_current_page: i64,
pub posts_max_page: i64,
pub posts_count: i64,
pub comments: Vec<CommentEntry>,
pub comments_current_page: i64,
pub comments_max_page: i64,
pub comments_count: i64,
}
#[derive(Template)]
#[template(path = "dashboard/posts/list.html", block = "posts")]
pub struct PostsPageDashboardTemplate {
pub posts: Vec<PostEntry>,
pub posts_current_page: i64,
pub posts_max_page: i64,
}
#[derive(Template)]
#[template(path = "dashboard/comments/list.html", block = "comments")]
pub struct CommentsPageDashboardTemplate {
pub comments: Vec<CommentEntry>,
pub comments_current_page: i64,
pub comments_max_page: i64,
} }
#[derive(Template)] #[derive(Template)]
@@ -88,6 +123,22 @@ pub struct PostListTemplate {
#[template(path = "posts/page.html")] #[template(path = "posts/page.html")]
pub struct PostTemplate { pub struct PostTemplate {
pub post: PostEntry, pub post: PostEntry,
pub post_html: String,
pub idempotency_key: String,
pub comments: Vec<CommentEntry>,
pub current_page: i64,
pub max_page: i64,
pub comments_count: i64,
pub session_user_id: Option<Uuid>,
pub session_username: Option<String>,
}
#[derive(Template)]
#[template(path = "posts/comments/list.html", block = "comments")]
pub struct CommentsList {
pub comments: Vec<CommentEntry>,
pub current_page: i64,
pub max_page: i64,
} }
#[derive(Template)] #[derive(Template)]
@@ -108,6 +159,8 @@ pub enum ErrorTemplate {
NotFound, NotFound,
#[template(path = "error/500.html")] #[template(path = "error/500.html")]
InternalServer, InternalServer,
#[template(path = "error/403.html")]
Forbidden,
} }
#[derive(Template)] #[derive(Template)]

View File

@@ -13,6 +13,21 @@
<script src="/assets/js/htmx.min.js"></script> <script src="/assets/js/htmx.min.js"></script>
</head> </head>
<body class="bg-gray-50 min-h-screen flex flex-col"> <body class="bg-gray-50 min-h-screen flex flex-col">
<div class="bg-yellow-50 border-b-2 border-yellow-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div class="flex items-center justify-center text-center">
<svg class="w-5 h-5 text-yellow-600 mr-2 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm text-yellow-800">
This is a personal project, <span class="font-semibold">not</span> a real website. Database is cleared periodically.
</p>
</div>
</div>
</div>
<header class="sticky top-0 bg-white/95 backdrop-blur-md shadow-sm border-b border-gray-200 z-40"> <header class="sticky top-0 bg-white/95 backdrop-blur-md shadow-sm border-b border-gray-200 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16"> <div class="flex justify-between items-center h-16">
@@ -39,7 +54,7 @@
</nav> </nav>
</div> </div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<a href="/admin/dashboard" <a href="/dashboard"
hx-boost="true" 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"> 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 Dashboard
@@ -59,7 +74,7 @@
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"> 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 Posts
</a> </a>
<a href="/admin/dashboard" <a href="/dashboard"
hx-boost="true" 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"> 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 Dashboard
@@ -103,8 +118,8 @@
</body> </body>
</html> </html>
<script> <script>
function toggleMobileMenu() { function toggleMobileMenu() {
const menu = document.getElementById('mobile-menu'); const menu = document.getElementById('mobile-menu');
menu.classList.toggle('hidden'); menu.classList.toggle('hidden');
} }
</script> </script>

View File

@@ -1,59 +0,0 @@
<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>

View File

@@ -0,0 +1,52 @@
<div id="comment-{{ comment.comment_id }}"
class="block py-4 hover:bg-gray-50 px-6 transition-colors group">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="mb-1">
{% if let Some(user_id) = comment.user_id %}
<a href="/users/{{ comment.username.as_ref().unwrap() }}"
class="font-semibold text-blue-800 hover:text-blue-600 hover:underline">
{{ comment.username.as_ref().unwrap() }}
</a>
{% else %}
<span class="font-medium text-gray-600">
{% if let Some(name) = comment.author %}
{{ name }}
{% else %}
Anonymous
{% endif %}
</span>
{% endif %}
</div>
<p class="text-sm text-gray-700 mb-2 line-clamp-2">{{ comment.content }}</p>
<div class="flex items-center text-xs text-gray-500 mb-1">
<svg class="w-4 h-4 mr-1.5"
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="{{ comment.published_at }}">
{{ comment.formatted_date() }}
</time>
</div>
<div class="flex items-center text-xs text-gray-500">
<span class="mr-1">on</span>
<a href="/posts/{{ comment.post_id }}"
class="text-blue-600 hover:underline truncate">#{{ comment.post_id }}</a>
</div>
</div>
<button hx-delete="/admin/comments/{{ comment.comment_id }}"
hx-target="#comment-{{ comment.comment_id }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this comment?"
class="inline-flex items-center p-2 text-sm font-medium text-red-500 bg-red-50 border-2 border-dashed border-red-300 rounded-md hover:bg-red-100 hover:border-red-400 hover:text-red-600 transition-all duration-200 flex-shrink-0 ml-4">
<svg class="w-4 h-4 group-hover:scale-110 transition-transform"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>

View File

@@ -0,0 +1,59 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-orange-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
<span>Comments ({{ comments_count }})</span>
</h2>
<p class="text-sm text-gray-600 mt-1">View and moderate all comments.</p>
</div>
</div>
</div>
<div id="comments-list">
{% block comments %}
{% if comments.is_empty() %}
<div class="p-8 text-center">
<div class="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">No comments to display</h3>
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
</div>
{% else %}
<div class="divide-y divide-gray-200 mb-6">
{% for comment in comments %}
{% include "dashboard/comments/card.html" %}
{% endfor %}
</div>
{% endif %}
<div class="flex items-center justify-center space-x-2 mb-6">
<button hx-get="/admin/comments?page={{ comments_current_page - 1 }}"
hx-target="#comments-list"
hx-swap="innerHTML"
hx-trigger="click"
class="px-3 py-1 text-sm font-medium text-gray-500 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 {% if comments_current_page <= 1 %}opacity-50 cursor-not-allowed{% endif %}"
{% if comments_current_page <= 1 %}disabled{% endif %}>&lt;</button>
<span class="px-3 py-1 text-sm font-medium text-gray-700 bg-gray-100 rounded-md">Page: {{ comments_current_page }}</span>
<button hx-get="/admin/comments?page={{ comments_current_page + 1 }}"
hx-target="#comments-list"
hx-swap="innerHTML"
hx-trigger="click"
class="px-3 py-1 text-sm font-medium text-gray-500 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 {% if comments_current_page >= comments_max_page %}opacity-50 cursor-not-allowed{% endif %}"
{% if comments_current_page >= comments_max_page %}disabled{% endif %}>&gt;</button>
</div>
{% endblock %}
</div>
</div>

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dashboard{% endblock %} {% block title %}Dashboard{% endblock %}
{% block content %} {% block content %}
<div class="max-w-5xl mx-auto p-4 sm:p-6"> <div class="max-w-5xl mx-auto p-4 sm:p-6">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1> <h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<p class="mt-2 text-gray-600 items-start"> <p class="mt-2 text-gray-600 items-start">
@@ -14,26 +14,46 @@
</span> </span>
{% endif %} {% endif %}
</p> </p>
<button hx-get="/admin/logout" <button hx-get="/logout"
type="submit" type="submit"
class="flex items-center text-sm text-gray-500 hover:text-red-600 transition-colors cursor-pointer gap-1 mt-2"> 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"> <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" /> <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> </svg>
<span>Logout</span> <span>Logout</span>
</button> </button>
</div> </div>
{% if user.is_admin() %}
<div class="mb-8 p-6 bg-gradient-to-br from-blue-50 to-indigo-50 bg-blue-50 rounded-lg border border-blue-200">
<h2 class="text-lg font-semibold text-blue-900 mb-6">Administration</h2>
{% include "stats.html" %} {% include "stats.html" %}
{% include "subscribers/list.html" %} <div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
</div>
{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
{% include "publish.html" %} {% include "publish.html" %}
{% include "send_email.html" %} {% include "send_email.html" %}
{% include "change_password.html" %} </div>
{% if user.is_admin() %}
<div class="relative my-12">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t-2 border-blue-300"></div>
</div>
<div class="relative flex justify-center">
<span class="bg-gray-50 px-4 text-sm font-semibold text-blue-900 flex items-center gap-2">
<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="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>
Administration
</span>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
{% include "subscribers/list.html" %}
{% include "users/list.html" %}
{% include "users/form.html" %}
{% include "posts/list.html" %}
{% include "comments/list.html" %}
</div>
{% endif %}
</div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,49 @@
<div id="post-{{ post.post_id }}"
class="block py-4 hover:bg-gray-50 px-6 transition-colors group">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<a href="/posts/{{ post.post_id }}" class="block mb-1">
<h3 class="text-base font-medium text-gray-900 group-hover:text-blue-600 transition-colors">
{{ post.title }}
</h3>
</a>
<div class="flex items-center text-sm text-gray-500 mb-1">
<svg class="w-4 h-4 mr-1.5"
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>
<a href="/users/{{ post.author }}" class="hover:text-blue-600 hover:underline">
{{ post.author }}
</a>
</div>
<div class="flex items-center text-sm text-gray-500">
<svg class="w-4 h-4 mr-1.5"
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>
<button hx-delete="/admin/posts/{{ post.post_id }}"
hx-target="#post-{{ post.post_id }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this post?"
class="inline-flex items-center p-2 text-sm font-medium text-red-500 bg-red-50 border-2 border-dashed border-red-300 rounded-md hover:bg-red-100 hover:border-red-400 hover:text-red-600 transition-all duration-200 flex-shrink-0 ml-4">
<svg class="w-4 h-4 group-hover:scale-110 transition-transform"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>

View File

@@ -0,0 +1,59 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-indigo-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<span>Posts ({{ posts_count }})</span>
</h2>
<p class="text-sm text-gray-600 mt-1">View and manage all published posts.</p>
</div>
</div>
</div>
<div id="posts-list">
{% block posts %}
{% if posts.is_empty() %}
<div class="p-8 text-center">
<div class="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts to display</h3>
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
</div>
{% else %}
<div class="divide-y divide-gray-200 mb-6">
{% for post in posts %}
{% include "dashboard/posts/card.html" %}
{% endfor %}
</div>
{% endif %}
<div class="flex items-center justify-center space-x-2 mb-6">
<button hx-get="/admin/posts?page={{ posts_current_page - 1 }}"
hx-target="#posts-list"
hx-swap="innerHTML"
hx-trigger="click"
class="px-3 py-1 text-sm font-medium text-gray-500 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 {% if posts_current_page <= 1 %}opacity-50 cursor-not-allowed{% endif %}"
{% if posts_current_page <= 1 %}disabled{% endif %}>&lt;</button>
<span class="px-3 py-1 text-sm font-medium text-gray-700 bg-gray-100 rounded-md">Page: {{ posts_current_page }}</span>
<button hx-get="/admin/posts?page={{ posts_current_page + 1 }}"
hx-target="#posts-list"
hx-swap="innerHTML"
hx-trigger="click"
class="px-3 py-1 text-sm font-medium text-gray-500 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 {% if posts_current_page >= posts_max_page %}opacity-50 cursor-not-allowed{% endif %}"
{% if posts_current_page >= posts_max_page %}disabled{% endif %}>&gt;</button>
</div>
{% endblock %}
</div>
</div>

View File

@@ -5,25 +5,26 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6"/>
</svg> </svg>
Write a new post Write a new post
</h2> </h2>
<p class="text-sm text-gray-600 mt-1">Publish a new post online. Subscribers will be notified.</p> <p class="text-sm text-gray-600 mt-1">Publish a new post online. Subscribers will be notified.</p>
</div> </div>
<div class="p-6"> <div class="p-6">
<form hx-post="/admin/posts" <form hx-post="/posts"
hx-target="#post-messages" hx-target="#post-messages"
hx-swap="innerHTML" hx-swap="innerHTML"
class="space-y-4"> class="space-y-4">
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_1 }}" /> <input type="hidden" name="idempotency_key" value="{{ idempotency_key_1 }}"/>
<div> <div>
<label for="post-title" class="block text-sm font-medium text-gray-700 mb-2">Title</label> <label for="post-title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
<input type="text" <input type="text"
id="post-title" id="post-title"
name="title" name="title"
required 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" /> 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>
<div> <div>
<label for="post-content" <label for="post-content"
@@ -40,7 +41,8 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6"/>
</svg> </svg>
Publish Publish
</button> </button>

View File

@@ -5,18 +5,19 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" /> <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> </svg>
Send an email Send an email
</h2> </h2>
<p class="text-sm text-gray-600 mt-1">Contact your subscribers directly.</p> <p class="text-sm text-gray-600 mt-1">Contact your subscribers directly.</p>
</div> </div>
<div class="p-6"> <div class="p-6">
<form hx-post="/admin/newsletters" <form hx-post="/newsletters"
hx-target="#newsletter-messages" hx-target="#newsletter-messages"
hx-swap="innerHTML" hx-swap="innerHTML"
class="space-y-4"> class="space-y-4">
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_2 }}" /> <input type="hidden" name="idempotency_key" value="{{ idempotency_key_2 }}"/>
<div> <div>
<label for="newsletter-title" <label for="newsletter-title"
class="block text-sm font-medium text-gray-700 mb-2">Subject</label> class="block text-sm font-medium text-gray-700 mb-2">Subject</label>
@@ -24,7 +25,7 @@
id="newsletter-title" id="newsletter-title"
name="title" name="title"
required 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" /> class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"/>
</div> </div>
<div> <div>
<label for="newsletter-html" <label for="newsletter-html"
@@ -50,7 +51,8 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg> </svg>
Send Send
</button> </button>

View File

@@ -1,24 +1,34 @@
<div id="subscriber-{{ subscriber.id }}" <div id="subscriber-{{ subscriber.id }}"
class="bg-gray-50 rounded-lg p-4 border border-gray-200 {% if subscriber.confirmed() %}border-l-4 border-l-green-500{% else %}border-l-4 border-l-yellow-500{% endif %}"> class="block py-4 hover:bg-gray-50 px-6 transition-colors group {% if subscriber.confirmed() %}border-l-4 border-l-green-500{% else %}border-l-4 border-l-yellow-500{% endif %}">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1 min-w-0">
<div class="text-md font-medium text-gray-900 break-all">{{ subscriber.email }}</div> <h3 class="text-sm truncate font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1 break-all">
<div class="text-sm text-gray-500 mt-1">{{ subscriber.formatted_date() }}</div> {{ subscriber.email }}</h3>
<div class="flex items-center text-sm text-gray-500">
<svg class="w-4 h-4 mr-1.5"
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="{{ subscriber.subscribed_at }}">
{{ subscriber.formatted_date() }}
</time>
</div>
</div> </div>
<div class="mt-3 sm:mt-0 sm:ml-4">
<button hx-delete="/admin/subscribers/{{ subscriber.id }}" <button hx-delete="/admin/subscribers/{{ subscriber.id }}"
hx-target="#subscriber-{{ subscriber.id }}" hx-target="#subscriber-{{ subscriber.id }}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this subscriber?" hx-confirm="Are you sure you want to delete this subscriber?"
class="inline-flex items-center py-1 px-2 text-sm font-medium text-red-500 bg-red-50 border-2 border-dashed border-red-300 rounded-md hover:bg-red-100 hover:border-red-400 hover:text-red-600 transition-all duration-200 group"> class="inline-flex items-center p-2 text-sm font-medium text-red-500 bg-red-50 border-2 border-dashed border-red-300 rounded-md hover:bg-red-100 hover:border-red-400 hover:text-red-600 transition-all duration-200 flex-shrink-0 ml-4">
<svg class="w-4 h-4 mr-2 group-hover:scale-110 transition-transform" <svg class="w-4 h-4 group-hover:scale-110 transition-transform"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg> </svg>
Delete
</button> </button>
</div> </div>
</div>
</div> </div>

View File

@@ -1,4 +1,4 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8"> <div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200"> <div class="p-6 border-b border-gray-200">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div> <div>
@@ -7,38 +7,42 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
<circle cx="9" cy="7" r="4" /> d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /> <circle cx="9" cy="7" r="4"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75" /> <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> </svg>
Subscribers management Subscribers ({{ count }})
</h2> </h2>
<p class="text-sm text-gray-600 mt-1">View and manage your subscribers.</p> <p class="text-sm text-gray-600 mt-1">View and manage your subscribers.</p>
</div> </div>
</div> </div>
</div> </div>
<div id="subscribers-list" class="p-6 space-y-4"> <div id="subscribers-list">
{% block subs %} {% block subs %}
{% if subscribers.is_empty() %} {% if subscribers.is_empty() %}
<div class="bg-gray-50 rounded-lg p-8 border-2 border-dashed border-gray-300 text-center"> <div class="p-8 text-center">
<div class="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4"> <div class="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-500" <svg class="w-8 h-8 text-gray-500"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg> </svg>
</div> </div>
<h3 class="text-lg font-medium text-gray-900 mb-2">No data available</h3> <h3 class="text-lg font-medium text-gray-900 mb-2">No subscribers to display</h3>
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p> <p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
</div> </div>
{% else %} {% else %}
<div class="divide-y divide-gray-200 mb-6">
{% for subscriber in subscribers %} {% for subscriber in subscribers %}
{% include "dashboard/subscribers/card.html" %} {% include "dashboard/subscribers/card.html" %}
{% endfor %} {% endfor %}
</div>
{% endif %} {% endif %}
<div class="flex items-center justify-center space-x-2"> <div class="flex items-center justify-center space-x-2 mb-6">
<button hx-get="/admin/subscribers?page={{ current_page - 1 }}" <button hx-get="/admin/subscribers?page={{ current_page - 1 }}"
hx-target="#subscribers-list" hx-target="#subscribers-list"
hx-swap="innerHTML" hx-swap="innerHTML"
@@ -51,7 +55,8 @@
hx-swap="innerHTML" hx-swap="innerHTML"
hx-trigger="click" hx-trigger="click"
class="px-3 py-1 text-sm font-medium text-gray-500 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 {% if current_page >= max_page %}opacity-50 cursor-not-allowed{% endif %}" class="px-3 py-1 text-sm font-medium text-gray-500 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 {% if current_page >= max_page %}opacity-50 cursor-not-allowed{% endif %}"
{% if current_page >= max_page %}disabled{% endif %}>&gt;</button> {% if current_page>= max_page %}disabled{% endif %}>&gt;
</button>
</div> </div>
{% endblock %} {% endblock %}
</div> </div>

View File

@@ -0,0 +1,47 @@
<div id="user-{{ user.user_id }}"
class="block py-4 hover:bg-gray-50 px-6 transition-colors group">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1">
<a href="/users/{{ user.username }}"
class="font-medium text-gray-900 group-hover:text-blue-600 hover:underline transition-colors truncate">
{{ user.username }}
</a>
{% if user.role.to_string() == "admin" %}
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 flex-shrink-0">
admin
</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 flex-shrink-0">
writer
</span>
{% endif %}
</div>
<div class="flex items-center text-sm text-gray-500">
<svg class="w-4 h-4 mr-1.5"
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="{{ user.member_since }}">
{{ user.formatted_date() }}
</time>
</div>
</div>
<button hx-delete="/admin/users/{{ user.user_id }}"
hx-target="#user-{{ user.user_id }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this user?"
class="inline-flex items-center p-2 text-sm font-medium text-red-500 bg-red-50 border-2 border-dashed border-red-300 rounded-md hover:bg-red-100 hover:border-red-400 hover:text-red-600 transition-all duration-200 flex-shrink-0 ml-4">
<svg class="w-4 h-4 group-hover:scale-110 transition-transform"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>

View File

@@ -0,0 +1,78 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div>
<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="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/>
</svg>
Create a new user
</h2>
<p class="text-sm text-gray-600 mt-1">Add a new user to the system.</p>
</div>
</div>
</div>
<div class="p-6">
<form hx-post="/admin/users"
hx-target="#user-form-messages"
hx-swap="innerHTML"
class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input type="text"
id="username"
name="username"
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="password" class="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input type="password"
id="password"
name="password"
required
minlength="12"
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="password_check" class="block text-sm font-medium text-gray-700 mb-1">
Confirm password
</label>
<input type="password"
id="password_check"
name="password_check"
required
minlength="12"
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 class="flex items-center">
<input type="checkbox"
id="admin"
name="admin"
value="true"
class="h-4 w-4 text-green-600 focus:ring-green-500 border-gray-300 rounded">
<label for="admin" class="ml-2 block text-sm text-gray-700">
Grant administrator privileges
</label>
</div>
<button type="submit"
class="w-full bg-green-600 text-white hover:bg-green-700 font-medium py-2 px-4 rounded-md transition-colors">
Create
</button>
<div id="user-form-messages"></div>
</form>
</div>
</div>

View File

@@ -0,0 +1,42 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div>
<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 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
Users
</h2>
<p class="text-sm text-gray-600 mt-1">View and manage users.</p>
</div>
</div>
</div>
<div id="users-list">
{% if users.is_empty() %}
<div class="py-6 p-8 text-center">
<div class="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-500"
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>
<h3 class="text-lg font-medium text-gray-900 mb-2">No users to display</h3>
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
</div>
{% else %}
<div class="divide-y divide-gray-200">
{% for user in users %}
{% include "dashboard/users/card.html" %}
{% endfor %}
</div>
{% endif %}
</div>
</div>

39
templates/error/403.html Normal file
View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}403{% 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">403</h1>
<h2 class="text-2xl font-semibold text-gray-500 mb-6">Forbidden</h2>
<p class="text-gray-600 mb-8 max-w-2xl mx-auto">
You don't have permission to access this page.
</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="/dashboard"
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="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/>
</svg>
Dashboard
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

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

View File

@@ -3,10 +3,12 @@
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<a href="/posts/{{ post.post_id }}"> <a href="/posts/{{ post.post_id }}">
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{ post.title }}</h2> <h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">
{{
post.title }}
</h2>
</a> </a>
<div class="flex items-center text-sm text-gray-500"> <div class="flex items-center text-sm text-gray-500 mb-1">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1" <svg class="w-4 h-4 mr-1"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -17,8 +19,7 @@
{{ post.formatted_date() }} {{ post.formatted_date() }}
</time> </time>
</div> </div>
<span class="mx-2"></span> <div class="flex items-center text-sm text-gray-500">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1" <svg class="w-4 h-4 mr-1"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -26,8 +27,13 @@
<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" /> <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> </svg>
<a href="/users/{{ post.author }}" <a href="/users/{{ post.author }}"
class="hover:text-blue-600 hover:underline">{{ post.author }}</a> class="hover:text-blue-600 hover:underline">
</div> {% if let Some(full_name) = post.full_name %}
{{ full_name }}
{% else %}
{{ post.author }}
{% endif %}
</a>
</div> </div>
</div> </div>
<a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4"> <a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4">

View File

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

View File

@@ -0,0 +1,84 @@
{%- import "macros.html" as macros -%}
<div class="border-t-2 border-gray-300 border-dashed pt-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Comments ({{ comments_count }})</h2>
<div class="bg-gray-50 rounded-lg p-6 mb-8 border border-gray-200">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Leave a comment</h3>
<form hx-post="/posts/{{ post.post_id }}/comments"
hx-target="#form-messages"
hx-swap="innerHTML"
class="space-y-4">
<input type="hidden" name="idempotency_key" value="{{ idempotency_key }}" />
{% if session_user_id.is_some() %}
<input type="hidden"
name="user_id"
value="{{ session_user_id.as_ref().unwrap() }}" />
{% endif %}
<div>
{% if session_username.is_none() %}
<input type="text"
name="author"
placeholder="Your name (optional)"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
{% else %}
<input type="text"
name="author"
value="{{ session_username.as_ref().unwrap() }}"
readonly
disabled
class="w-full px-4 py-2 border border-gray-200 rounded-md bg-gray-50 text-gray-600 cursor-not-allowed">
<p class="text-sm text-gray-500 mt-1">You are authenticated.</p>
{% endif %}
</div>
<div>
<textarea name="content"
rows="4"
required
placeholder="Write your comment..."
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea>
</div>
<button type="submit"
class="bg-blue-600 text-white hover:bg-blue-700 font-medium py-2 px-6 rounded-md transition-colors">
Submit
</button>
<div id="form-messages"></div>
</form>
</div>
{% block comments %}
{% if comments.is_empty() %}
<div id="comments-list" class="text-center py-8 text-gray-500">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<p>No comments yet. Be the first to comment!</p>
</div>
{% else %}
{% let post_id = comments[0].post_id %}
<div id="comments-list" class="space-y-4">
{% for comment in comments %}
{% include "posts/comments/card.html" %}
{% endfor %}
<div id="load-more-comments" class="text-center mt-6">
{% if current_page < max_page %}
<div class="flex flex-col items-center space-y-6">
<button hx-get="/posts/{{ post_id }}/comments?page={{ current_page + 1 }}"
hx-target="#load-more-comments"
hx-swap="outerHTML"
hx-indicator="#comment-indicator"
class="text-center bg-gray-200 text-gray-700 hover:bg-gray-300 font-medium py-2 px-6 rounded-md transition-colors">
Load more comments
</button>
<span id="comment-indicator" class="htmx-indicator">
{% call macros::spinner(class="text-gray-300 w-6 h-6", highlight_class="text-gray-700", size=24) %}
</span>
</div>
{% else %}
<p class="text-gray-600">No more comments. Check back later for more!</p>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -3,8 +3,8 @@
{% block content %} {% block content %}
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<article> <article>
<header class="pb-4 mb-2 border-b-2 border-gray-300 border-dashed"> <header class="mb-4 space-y-4">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1> <h1 class="text-4xl font-bold text-gray-900 leading-tight">{{ post.title }}</h1>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<div class="flex items-center"> <div class="flex items-center">
@@ -17,7 +17,13 @@
</svg> </svg>
</div> </div>
<a href="/users/{{ post.author }}" <a href="/users/{{ post.author }}"
class="hover:text-blue-600 hover:underline font-medium">{{ post.author }}</a> class="hover:text-blue-600 hover:underline font-medium">
{% if let Some(full_name) = post.full_name %}
{{ full_name }}
{% else %}
{{ post.author }}
{% endif %}
</a>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-4 h-4 mr-1 text-gray-400" <svg class="w-4 h-4 mr-1 text-gray-400"
@@ -31,10 +37,73 @@
</time> </time>
</div> </div>
</div> </div>
{% if session_user_id.as_ref() == Some(post.author_id) %}
<div class="mt-4 sm:mt-0">
<button onclick="document.getElementById('edit-form').classList.toggle('hidden')"
class="inline-flex items-center px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors">
<svg class="w-4 h-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
</div> </div>
{% endif %}
</div>
{% if let Some(modified) = post.last_modified %}
<span class="text-sm italic text-gray-500">Last modified on {{ modified.format("%B %d, %Y") }} at {{ modified.format("%H:%M") }}</span>
{% endif %}
</header> </header>
<div class="prose-compact">{{ post.content | safe }}</div> {% if session_user_id.as_ref() == Some(post.author_id) %}
<div id="edit-form"
class="hidden bg-gray-50 border border-gray-200 rounded-lg p-6">
<h2 class="text-xl font-bold text-gray-900 mb-4">Edit post</h2>
<form hx-put="/posts/{{ post.post_id }}"
hx-target="#edit-messages"
hx-swap="innerHTML">
<div class="mb-4">
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
<input type="text"
id="title"
name="title"
value="{{ post.title }}"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="mb-4">
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">Content (markdown)</label>
<textarea id="content"
name="content"
rows="12"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm">{{ post.content }}</textarea>
</div>
<div class="flex items-center space-x-3">
<button type="submit"
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md transition-colors">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Save changes
</button>
<button type="button"
onclick="document.getElementById('edit-form').classList.add('hidden')"
class="inline-flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-md transition-colors">
Cancel
</button>
</div>
</form>
<div id="edit-messages" class="mt-6"></div>
</div>
{% endif %}
<div id="content-display" class="prose-compact">{{ post_html | safe }}</div>
</article> </article>
<div class="mt-8">{% include "posts/comments/list.html" %}</div>
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center"> <div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3> <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> <p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>

View File

@@ -1,7 +1,7 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8"> <div class="bg-white rounded-lg shadow-md border border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Activity</h2> <h2 class="text-xl font-semibold text-gray-900 px-8 py-6 border-b border-gray-200">Activity</h2>
{% if posts.is_empty() %} {% if posts.is_empty() %}
<div class="text-center py-8 text-gray-500"> <div class="text-center text-gray-500 p-8">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-400" <svg class="w-12 h-12 mx-auto mb-3 text-gray-400"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -14,10 +14,13 @@
<div class="divide-y divide-gray-200"> <div class="divide-y divide-gray-200">
{% for post in posts %} {% for post in posts %}
<a href="/posts/{{ post.post_id }}" <a href="/posts/{{ post.post_id }}"
class="block py-4 hover:bg-gray-50 -mx-8 px-8 transition-colors group"> class="block hover:bg-gray-50 px-8 py-5 transition-colors group">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-base font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1">{{ post.title }}</h3> <h3 class="text-base font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1">
{{
post.title }}
</h3>
<div class="flex items-center text-sm text-gray-500"> <div class="flex items-center text-sm text-gray-500">
<svg class="w-4 h-4 mr-1.5" <svg class="w-4 h-4 mr-1.5"
fill="none" fill="none"

14
templates/user/edit.html Normal file
View File

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

View File

@@ -0,0 +1,46 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
<h2 class="text-xl font-semibold text-gray-900 mb-6">Password</h2>
<form hx-post="/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
</button>
<div id="password-messages" class="mt-4"></div>
</form>
</div>

View File

@@ -0,0 +1,42 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
<h2 class="text-xl font-semibold text-gray-900 mb-6">Profile settings</h2>
<form hx-put="/users/edit"
hx-target="#edit-messages"
hx-swap="innerHTML"
class="space-y-6">
<input type="hidden" name="user_id" value="{{ user.user_id }}" />
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input type="text"
name="username"
id="username"
value="{{ user.username }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
<input type="text"
id="full_name"
name="full_name"
value="{{ user.full_name.as_deref().unwrap_or("") }}"
placeholder="John Doe"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">Your real name (optional)</p>
</div>
<div>
<label for="bio" class="block text-sm font-medium text-gray-700 mb-1">Bio</label>
<textarea id="bio"
name="bio"
rows="4"
maxlength="500"
placeholder="Tell us about yourself..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{ user.bio.as_deref().unwrap_or("") }}</textarea>
<p class="mt-1 text-xs text-gray-500">Maximum 500 characters</p>
</div>
<button type="submit"
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-2 px-4 rounded-md transition-colors">
Save changes
</button>
<div id="edit-messages"></div>
</form>
</div>

View File

@@ -2,25 +2,39 @@
{% block title %}{{ user.username }}{% endblock %} {% block title %}{{ user.username }}{% endblock %}
{% block content %} {% block content %}
<div class="max-w-4xl mx-auto p-4 sm:p-6"> <div class="max-w-4xl mx-auto p-4 sm:p-6">
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8 mb-6"> <div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 sm:p-8 space-y-6">
<div class="flex flex-col sm:flex-row items-center sm:items-start gap-6"> <div class="flex flex-col sm:flex-row items-center sm:items-start gap-6">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-24 h-24 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center text-white text-3xl font-bold shadow-lg"> <div class="w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-3xl font-bold shadow-md">
{{ user.username }} {{ user.username }}
</div> </div>
</div> </div>
<div class="flex-1 text-center sm:text-left"> <div class="flex-1 text-center sm:text-left space-y-2">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mb-2"> <div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h1 class="text-3xl font-bold text-gray-900">{{ user.full_name.as_deref().unwrap_or(user.username) }}</h1> <h1 class="text-3xl font-semibold text-gray-900 tracking-tight">
{{ user.full_name.as_deref().unwrap_or(user.username) }}
</h1>
{% if user.is_admin() %} {% if user.is_admin() %}
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mx-auto sm:mx-0" <svg class="w-5 h-5 text-blue-600 flex-shrink-0 mx-auto sm:mx-0"
fill="currentColor" fill="currentColor"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" /> <path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" />
</svg> </svg>
{% endif %} {% endif %}
</div> </div>
<p class="text-gray-500 text-lg mb-3">@{{ user.username }}</p> {% if session_user_id.as_ref() == Some(user.user_id) %}
<a href="/users/edit"
class="inline-flex items-center text-sm font-medium text-blue-600 hover:text-blue-700 transition-colors">
<svg class="w-4 h-4 mr-1.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</a>
{% endif %}
<p class="text-gray-500 text-lg">@{{ user.username }}</p>
<div class="flex items-center justify-center sm:justify-start text-sm text-gray-500"> <div class="flex items-center justify-center sm:justify-start text-sm text-gray-500">
<svg class="w-4 h-4 mr-1.5" <svg class="w-4 h-4 mr-1.5"
fill="none" fill="none"
@@ -30,14 +44,20 @@
</svg> </svg>
{{ user.formatted_date() }} {{ user.formatted_date() }}
</div> </div>
{% if let Some(last_seen) = last_seen %}
<p class="text-sm italic text-gray-400">
Last seen on {{ last_seen.format("%h %d, %Y") }} at {{ last_seen.format("%H:%M") }}
</p>
{% endif %}
</div> </div>
</div> </div>
{% if user.bio.is_some() %} {% if user.bio.is_some() %}
<div class="mt-6 pt-6 border-t border-gray-200"> <div class="border-t border-gray-100 pt-6">
<p class="text-gray-700 whitespace-pre-line">{{ user.bio.as_deref().unwrap() }}</p> <p class="text-gray-700 leading-relaxed whitespace-pre-line">{{ user.bio.as_deref().unwrap() }}</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% include "activity.html" %} <!-- Activity -->
<div class="mt-8">{% include "activity.html" %}</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -21,7 +21,7 @@ async fn logout_clears_session_state(connection_pool: PgPool) {
"password": &app.test_user.password, "password": &app.test_user.password,
}); });
let response = app.post_login(&login_body).await; let response = app.post_login(&login_body).await;
assert_is_redirect_to(&response, "/admin/dashboard"); assert_is_redirect_to(&response, "/dashboard");
let html_page = app.get_admin_dashboard_html().await; let html_page = app.get_admin_dashboard_html().await;
assert!(html_page.contains("Connected as")); assert!(html_page.contains("Connected as"));
@@ -40,7 +40,7 @@ async fn subscribers_are_visible_on_the_dashboard(connection_pool: PgPool) {
app.admin_login().await; app.admin_login().await;
let response = app.get_admin_dashboard_html().await; let response = app.get_admin_dashboard_html().await;
assert!(response.contains("No data available")); assert!(response.contains("No subscribers to display"));
app.create_confirmed_subscriber().await; app.create_confirmed_subscriber().await;
let subscriber = sqlx::query!("SELECT id, email FROM subscriptions") let subscriber = sqlx::query!("SELECT id, email FROM subscriptions")
@@ -53,10 +53,90 @@ async fn subscribers_are_visible_on_the_dashboard(connection_pool: PgPool) {
app.delete_subscriber(subscriber.id).await; app.delete_subscriber(subscriber.id).await;
let response = app.get_admin_dashboard_html().await; let response = app.get_admin_dashboard_html().await;
assert!(response.contains("No data available")); assert!(response.contains("No subscribers to display"));
assert!(!response.contains(&subscriber.email)); assert!(!response.contains(&subscriber.email));
} }
#[sqlx::test]
async fn posts_are_visible_on_the_dashboard(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let response = app.get_admin_dashboard_html().await;
assert!(response.contains("No posts to display"));
let response = app.post_create_post(&fake_post_body()).await;
assert!(
response
.text()
.await
.unwrap()
.contains("Your new post has been published")
);
let (post_id, post_title) = {
let record = sqlx::query!("SELECT post_id, title FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap();
(record.post_id, record.title)
};
let html = app.get_admin_dashboard_html().await;
assert!(html.contains(&post_title));
app.delete_post(post_id).await;
let response = app.get_admin_dashboard_html().await;
assert!(response.contains("No posts to display"));
}
#[sqlx::test]
async fn comments_are_visible_on_the_dashboard(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let response = app.get_admin_dashboard_html().await;
assert!(response.contains("No comments to display"));
app.post_create_post(&fake_post_body()).await;
let (post_id, post_title) = {
let record = sqlx::query!("SELECT post_id, title FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap();
(record.post_id, record.title)
};
let author = "author";
let content = "comment";
let comment_body = serde_json::json!({
"author": author,
"content": content,
"idempotency_key": "key"
});
app.post_comment(&post_id, &comment_body).await;
let response = app.get_admin_dashboard_html().await;
assert!(response.contains(author));
assert!(response.contains(content));
let html = app.get_admin_dashboard_html().await;
assert!(html.contains(&post_title));
let comment_id = {
let record = sqlx::query!("SELECT comment_id FROM comments")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.comment_id
};
app.delete_comment(comment_id).await;
let response = app.get_admin_dashboard_html().await;
assert!(response.contains("No comments to display"));
}
#[sqlx::test] #[sqlx::test]
async fn dashboard_shows_correct_stats(connection_pool: PgPool) { async fn dashboard_shows_correct_stats(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await; let app = TestApp::spawn(connection_pool).await;

View File

@@ -85,5 +85,5 @@ async fn changing_password_works(connection_pool: PgPool) {
"password": new_password, "password": new_password,
}); });
let response = app.post_login(login_body).await; let response = app.post_login(login_body).await;
assert_is_redirect_to(&response, "/admin/dashboard"); assert_is_redirect_to(&response, "/dashboard");
} }

163
tests/api/comments.rs Normal file
View File

@@ -0,0 +1,163 @@
use crate::helpers::{TestApp, fake_post_body};
use sqlx::PgPool;
#[sqlx::test]
async fn visitor_can_leave_a_comment(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
app.post_create_post(&fake_post_body()).await;
let post_id = {
let record = sqlx::query!("SELECT post_id FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.post_id
};
let comment_author = "Author";
let comment_content = "Content";
let comment_body = serde_json::json!({
"author": comment_author,
"content": comment_content,
"idempotency_key": "key",
});
app.post_comment(&post_id, &comment_body).await;
let post = app.get_post_html(&post_id).await;
assert!(post.contains(comment_author));
assert!(post.contains(comment_content));
}
#[sqlx::test]
async fn visitor_can_comment_anonymously(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
app.post_create_post(&fake_post_body()).await;
let post_id = {
let record = sqlx::query!("SELECT post_id FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.post_id
};
let comment_content = "Content";
let comment_body = serde_json::json!({
"content": comment_content,
"idempotency_key": "key",
});
app.post_comment(&post_id, &comment_body).await;
let post = app.get_post_html(&post_id).await;
assert!(post.contains("Anonymous"));
assert!(post.contains(comment_content));
}
#[sqlx::test]
async fn comment_with_invalid_body_is_rejected(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
app.post_create_post(&fake_post_body()).await;
let post_id = {
let record = sqlx::query!("SELECT post_id FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.post_id
};
let test_cases = [
(
serde_json::json!({ "idempotency_key": "key" }),
"a missing content",
),
(
serde_json::json!({ "idempotency_key": "key", "content": "" }),
"an empty content",
),
];
for (invalid_body, message) in test_cases {
let response = app.post_comment(&post_id, &invalid_body).await;
let html = response.text().await.unwrap();
dbg!(&html);
assert!(
!html.contains("Your comment has been posted"),
"The API did not reject the request when the body had {}",
message
);
}
}
#[sqlx::test]
async fn comment_is_deleted_when_post_is_deleted(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
app.post_create_post(&fake_post_body()).await;
let post_id = {
let record = sqlx::query!("SELECT post_id FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.post_id
};
let comment_author = "Author";
let comment_content = "Content";
let comment_body = serde_json::json!({
"author": comment_author,
"content": comment_content,
"idempotency_key": "key",
});
app.post_comment(&post_id, &comment_body).await;
let comment_id = {
let record = sqlx::query!("SELECT comment_id FROM comments")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.comment_id
};
app.delete_post(post_id).await;
let record = sqlx::query!("SELECT * FROM comments WHERE comment_id = $1", comment_id)
.fetch_optional(&app.connection_pool)
.await
.unwrap();
assert!(record.is_none());
}
#[sqlx::test]
async fn comment_posting_is_idempotent(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
app.post_create_post(&fake_post_body()).await;
let post_id = {
let record = sqlx::query!("SELECT post_id FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.post_id
};
let comment_body = serde_json::json!({
"author": "author",
"content": "content",
"idempotency_key": "key",
});
let response = app.post_comment(&post_id, &comment_body).await;
assert!(
response
.text()
.await
.unwrap()
.contains("Your comment has been posted")
);
let response = app.post_comment(&post_id, &comment_body).await;
assert!(
response
.text()
.await
.unwrap()
.contains("Your comment has been posted")
);
let count = sqlx::query_scalar!("SELECT count(*) FROM comments")
.fetch_one(&app.connection_pool)
.await
.unwrap()
.unwrap_or(0);
assert_eq!(count, 1);
}

View File

@@ -130,6 +130,26 @@ impl TestApp {
app app
} }
pub async fn create_user(
&self,
username: &str,
password: &str,
admin: bool,
) -> reqwest::Response {
let body = serde_json::json!({
"username": username,
"password": password,
"password_check": password,
"admin": admin,
});
self.api_client
.post(format!("{}/admin/users", self.address))
.form(&body)
.send()
.await
.unwrap()
}
pub async fn create_unconfirmed_subscriber(&self) -> ConfirmationLinks { pub async fn create_unconfirmed_subscriber(&self) -> ConfirmationLinks {
let email: String = SafeEmail().fake(); let email: String = SafeEmail().fake();
let body = format!("email={email}"); let body = format!("email={email}");
@@ -166,7 +186,7 @@ impl TestApp {
.unwrap(); .unwrap();
} }
pub async fn delete_subscriber(&self, subscriber_id: Uuid) { pub async fn delete_subscriber(&self, subscriber_id: Uuid) -> reqwest::Response {
self.api_client self.api_client
.delete(format!( .delete(format!(
"{}/admin/subscribers/{}", "{}/admin/subscribers/{}",
@@ -174,7 +194,15 @@ impl TestApp {
)) ))
.send() .send()
.await .await
.expect("Could not delete subscriber"); .expect("Could not delete subscriber")
}
pub async fn delete_user(&self, user_id: Uuid) -> reqwest::Response {
self.api_client
.delete(format!("{}/admin/users/{}", self.address, user_id))
.send()
.await
.expect("Could not delete user")
} }
pub async fn dispatch_all_pending_emails(&self) { pub async fn dispatch_all_pending_emails(&self) {
@@ -251,7 +279,7 @@ impl TestApp {
pub async fn get_admin_dashboard(&self) -> reqwest::Response { pub async fn get_admin_dashboard(&self) -> reqwest::Response {
self.api_client self.api_client
.get(format!("{}/admin/dashboard", &self.address)) .get(format!("{}/dashboard", &self.address))
.send() .send()
.await .await
.expect("Failed to execute request") .expect("Failed to execute request")
@@ -261,6 +289,18 @@ impl TestApp {
self.get_admin_dashboard().await.text().await.unwrap() self.get_admin_dashboard().await.text().await.unwrap()
} }
pub async fn edit_post<Body>(&self, body: &Body, post_id: &Uuid) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.put(format!("{}/posts/{}", self.address, post_id))
.form(body)
.send()
.await
.expect("Failed to execute request")
}
pub async fn get_posts(&self) -> reqwest::Response { pub async fn get_posts(&self) -> reqwest::Response {
self.api_client self.api_client
.get(format!("{}/posts", &self.address)) .get(format!("{}/posts", &self.address))
@@ -273,7 +313,7 @@ impl TestApp {
self.get_posts().await.text().await.unwrap() self.get_posts().await.text().await.unwrap()
} }
pub async fn get_post(&self, post_id: Uuid) -> reqwest::Response { pub async fn get_post(&self, post_id: &Uuid) -> reqwest::Response {
self.api_client self.api_client
.get(format!("{}/posts/{}", &self.address, post_id)) .get(format!("{}/posts/{}", &self.address, post_id))
.send() .send()
@@ -281,7 +321,7 @@ impl TestApp {
.expect("Failed to execute request") .expect("Failed to execute request")
} }
pub async fn get_post_html(&self, post_id: Uuid) -> String { pub async fn get_post_html(&self, post_id: &Uuid) -> String {
self.get_post(post_id).await.text().await.unwrap() self.get_post(post_id).await.text().await.unwrap()
} }
@@ -300,7 +340,7 @@ impl TestApp {
Body: serde::Serialize, Body: serde::Serialize,
{ {
self.api_client self.api_client
.post(format!("{}/admin/newsletters", self.address)) .post(format!("{}/newsletters", self.address))
.form(body) .form(body)
.send() .send()
.await .await
@@ -329,7 +369,7 @@ impl TestApp {
pub async fn logout(&self) -> reqwest::Response { pub async fn logout(&self) -> reqwest::Response {
self.api_client self.api_client
.get(format!("{}/admin/logout", self.address)) .get(format!("{}/logout", self.address))
.send() .send()
.await .await
.expect("Failed to execute request") .expect("Failed to execute request")
@@ -340,31 +380,75 @@ impl TestApp {
Body: serde::Serialize, Body: serde::Serialize,
{ {
self.api_client self.api_client
.post(format!("{}/admin/password", self.address)) .post(format!("{}/password", self.address))
.form(body) .form(body)
.send() .send()
.await .await
.expect("Failed to execute request") .expect("Failed to execute request")
} }
pub async fn edit_profile<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.put(format!("{}/users/edit", self.address))
.form(body)
.send()
.await
.expect("Failed to execute request")
}
pub async fn get_profile(&self, username: &str) -> reqwest::Response {
self.api_client
.get(format!("{}/users/{}", self.address, username))
.send()
.await
.expect("Failed to execute request")
}
pub async fn get_profile_html(&self, username: &str) -> String {
self.get_profile(username).await.text().await.unwrap()
}
pub async fn post_create_post<Body>(&self, body: &Body) -> reqwest::Response pub async fn post_create_post<Body>(&self, body: &Body) -> reqwest::Response
where where
Body: serde::Serialize, Body: serde::Serialize,
{ {
self.api_client self.api_client
.post(format!("{}/admin/posts", self.address)) .post(format!("{}/posts", self.address))
.form(body) .form(body)
.send() .send()
.await .await
.expect("Failed to execute request") .expect("Failed to execute request")
} }
pub async fn delete_post(&self, post_id: Uuid) { pub async fn post_comment<Body>(&self, post_id: &Uuid, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(format!("{}/posts/{post_id}/comments", self.address))
.form(body)
.send()
.await
.expect("Failed to execute request")
}
pub async fn delete_post(&self, post_id: Uuid) -> reqwest::Response {
self.api_client self.api_client
.delete(format!("{}/admin/posts/{}", self.address, post_id)) .delete(format!("{}/admin/posts/{}", self.address, post_id))
.send() .send()
.await .await
.expect("Could not delete post"); .expect("Could not delete post")
}
pub async fn delete_comment(&self, comment_id: Uuid) -> reqwest::Response {
self.api_client
.delete(format!("{}/admin/comments/{}", self.address, comment_id))
.send()
.await
.expect("Could not delete comment")
} }
pub async fn post_unsubscribe<Body>(&self, body: &Body) -> reqwest::Response pub async fn post_unsubscribe<Body>(&self, body: &Body) -> reqwest::Response
@@ -378,6 +462,14 @@ impl TestApp {
.await .await
.expect("Failed to execute request") .expect("Failed to execute request")
} }
pub async fn get_user_id(&self, username: &str) -> Uuid {
let record = sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
.fetch_one(&self.connection_pool)
.await
.unwrap();
record.user_id
}
} }
pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) { pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {
@@ -406,8 +498,7 @@ pub fn fake_post_body() -> serde_json::Value {
serde_json::json!({ serde_json::json!({
"title": "Post title", "title": "Post title",
"content": "Post content", "content": "Post content",
"idempotency_key": Uuid::new_v4().to_string(), "idempotency_key": Uuid::new_v4().to_string()
}) })
} }

View File

@@ -28,7 +28,7 @@ async fn login_redirects_to_admin_dashboard_after_login_success(connection_pool:
}); });
let response = app.post_login(&login_body).await; let response = app.post_login(&login_body).await;
assert_is_redirect_to(&response, "/admin/dashboard"); assert_is_redirect_to(&response, "/dashboard");
let html_page = app.get_admin_dashboard_html().await; let html_page = app.get_admin_dashboard_html().await;
assert!(html_page.contains("Connected as")); assert!(html_page.contains("Connected as"));

View File

@@ -1,5 +1,6 @@
mod admin_dashboard; mod admin_dashboard;
mod change_password; mod change_password;
mod comments;
mod health_check; mod health_check;
mod helpers; mod helpers;
mod login; mod login;
@@ -9,3 +10,4 @@ mod subscriptions;
mod subscriptions_confirm; mod subscriptions_confirm;
mod unsubscribe; mod unsubscribe;
mod unsubscribe_confirm; mod unsubscribe_confirm;
mod users;

View File

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

527
tests/api/users.rs Normal file
View File

@@ -0,0 +1,527 @@
use crate::helpers::{TestApp, fake_newsletter_body, fake_post_body, when_sending_an_email};
use sqlx::PgPool;
use wiremock::ResponseTemplate;
use zero2prod::authentication::Role;
#[sqlx::test]
async fn admin_can_create_user(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let record = sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
.fetch_optional(&app.connection_pool)
.await
.unwrap();
assert!(record.is_some());
let html = app.get_admin_dashboard_html().await;
assert!(html.contains(username));
}
#[sqlx::test]
async fn admin_can_create_admin_user(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, true).await;
let record = sqlx::query!(
r#"
SELECT role as "role: Role"
FROM users WHERE username = $1
"#,
username
)
.fetch_one(&app.connection_pool)
.await
.unwrap();
matches!(record.role, Role::Admin);
app.logout().await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let html = app.get_admin_dashboard_html().await;
assert!(html.contains("Administration"));
}
#[sqlx::test]
async fn admin_users_can_create_posts(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, true).await;
app.logout().await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
app.create_confirmed_subscriber().await;
when_sending_an_email()
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&app.email_server)
.await;
app.post_create_post(&fake_post_body()).await;
app.dispatch_all_pending_emails().await;
}
#[sqlx::test]
async fn admin_users_can_send_emails(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, true).await;
app.logout().await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
app.create_confirmed_subscriber().await;
when_sending_an_email()
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&app.email_server)
.await;
app.post_newsletters(&fake_newsletter_body()).await;
app.dispatch_all_pending_emails().await;
}
#[sqlx::test]
async fn admin_users_can_create_users(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, true).await;
app.logout().await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let username = "other_user";
app.create_user(username, password, true).await;
let html = app.get_admin_dashboard_html().await;
assert!(html.contains(username));
}
#[sqlx::test]
async fn admin_users_can_delete_contents(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, true).await;
app.logout().await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
app.create_confirmed_subscriber().await;
let (subscriber_id, email) = {
let record = sqlx::query!("SELECT id, email FROM subscriptions")
.fetch_one(&app.connection_pool)
.await
.unwrap();
(record.id, record.email)
};
let response = app.delete_subscriber(subscriber_id).await;
let text = response.text().await.unwrap();
assert!(text.contains(&email));
assert!(text.contains("has been deleted"));
app.create_user("other_user", password, true).await;
let user_id = {
let record = sqlx::query!("SELECT user_id FROM users")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.user_id
};
let response = app.delete_user(user_id).await;
let text = response.text().await.unwrap();
assert!(text.contains("The user has been deleted"));
app.post_create_post(&fake_post_body()).await;
let post_id = {
let record = sqlx::query!("SELECT post_id FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.post_id
};
let comment_body = serde_json::json!({
"author": "author",
"content": "comment",
"idempotency_key": "key",
});
app.post_comment(&post_id, &comment_body).await;
let comment_id = {
let record = sqlx::query!("SELECT comment_id FROM comments")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.comment_id
};
let response = app.delete_comment(comment_id).await;
assert!(
response
.text()
.await
.unwrap()
.contains("The comment has been deleted")
);
let response = app.delete_post(post_id).await;
let text = response.text().await.unwrap();
assert!(text.contains("The post has been deleted"));
}
#[sqlx::test]
async fn admin_functions_are_hidden_for_non_admin_users(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let record = sqlx::query!(
r#"
SELECT role as "role: Role"
FROM users WHERE username = $1
"#,
username
)
.fetch_one(&app.connection_pool)
.await
.unwrap();
matches!(record.role, Role::Writer);
app.logout().await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
let response = app.post_login(&login_body).await;
assert!(!response.text().await.unwrap().contains("Administration"));
}
#[sqlx::test]
async fn writers_can_publish_posts_and_send_emails(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
app.logout().await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
app.create_confirmed_subscriber().await;
when_sending_an_email()
.respond_with(ResponseTemplate::new(200))
.expect(2)
.mount(&app.email_server)
.await;
app.post_create_post(&fake_post_body()).await;
app.post_newsletters(&fake_newsletter_body()).await;
app.dispatch_all_pending_emails().await;
}
#[sqlx::test]
async fn writers_cannot_perform_admin_functions(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
app.post_create_post(&fake_post_body()).await;
let post_id = {
let record = sqlx::query!("SELECT post_id FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.post_id
};
app.create_confirmed_subscriber().await;
let subscriber_id = {
let record = sqlx::query!("SELECT id FROM subscriptions")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.id
};
let comment_body = serde_json::json!({
"author": "author",
"content": "comment",
"idempotency_key": "key",
});
app.post_comment(&post_id, &comment_body).await;
let comment_id = {
let record = sqlx::query!("SELECT comment_id FROM comments")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.comment_id
};
app.logout().await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let response = app.delete_subscriber(subscriber_id).await;
let html = response.text().await.unwrap();
assert!(html.contains("requires administrator privileges"));
let record = sqlx::query!("SELECT id FROM subscriptions")
.fetch_optional(&app.connection_pool)
.await
.unwrap();
assert!(record.is_some());
let response = app.delete_comment(comment_id).await;
let html = response.text().await.unwrap();
assert!(html.contains("requires administrator privileges"));
let record = sqlx::query!("SELECT comment_id FROM comments")
.fetch_optional(&app.connection_pool)
.await
.unwrap();
assert!(record.is_some());
let response = app.delete_post(post_id).await;
let html = response.text().await.unwrap();
assert!(html.contains("requires administrator privileges"));
let record = sqlx::query!("SELECT post_id FROM posts")
.fetch_optional(&app.connection_pool)
.await
.unwrap();
assert!(record.is_some());
let user_id = {
let record = sqlx::query!("SELECT user_id FROM users")
.fetch_one(&app.connection_pool)
.await
.unwrap();
record.user_id
};
let response = app.delete_user(user_id).await;
let html = response.text().await.unwrap();
assert!(html.contains("requires administrator privileges"));
let record = sqlx::query_scalar!("SELECT username FROM users WHERE user_id = $1", user_id)
.fetch_optional(&app.connection_pool)
.await
.unwrap();
assert!(record.is_some());
let username = "friend";
let password = "123456789abc";
let response = app.create_user(username, password, false).await;
let html = response.text().await.unwrap();
assert!(html.contains("requires administrator privileges"));
let record = sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
.fetch_optional(&app.connection_pool)
.await
.unwrap();
assert!(record.is_none());
}
#[sqlx::test]
async fn user_can_change_his_display_name(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let user_id = app.get_user_id(username).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let full_name = "Alphonse Paix";
let edit_body = serde_json::json!( {
"user_id": user_id,
"username": username,
"full_name": full_name,
"bio": "",
});
let html = app.get_profile_html(username).await;
assert!(!html.contains(full_name));
let response = app.edit_profile(&edit_body).await;
assert!(dbg!(response.text().await.unwrap()).contains("Your profile has been updated"));
let html = app.get_profile_html(username).await;
assert!(html.contains(full_name));
}
#[sqlx::test]
async fn user_can_change_his_bio(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let user_id = app.get_user_id(username).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let bio = "This is me";
let edit_body = serde_json::json!( {
"user_id": user_id,
"username": username,
"full_name": "",
"bio": bio,
});
let html = app.get_profile_html(username).await;
assert!(!html.contains(bio));
let response = app.edit_profile(&edit_body).await;
assert!(dbg!(response.text().await.unwrap()).contains("Your profile has been updated"));
let html = app.get_profile_html(username).await;
assert!(html.contains(bio));
}
#[sqlx::test]
async fn user_can_change_his_username(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let user_id = app.get_user_id(username).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let new_username = "alphonsepaix";
let edit_body = serde_json::json!( {
"user_id": user_id,
"username": new_username,
"full_name": "",
"bio": "",
});
let html = app.get_profile_html(username).await;
assert!(html.contains(username));
let response = app.edit_profile(&edit_body).await;
assert!(dbg!(response.text().await.unwrap()).contains("Your profile has been updated"));
let html = app.get_profile_html(username).await;
assert!(html.contains("404"));
let html = app.get_profile_html(new_username).await;
assert!(html.contains(new_username));
}
#[sqlx::test]
async fn user_cannot_change_other_profiles(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let other_user_id = app.get_user_id("admin").await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let new_username = "alphonsepaix";
let edit_body = serde_json::json!( {
"user_id": other_user_id,
"username": new_username,
"full_name": "",
"bio": "",
});
let response = app.edit_profile(&edit_body).await;
assert!(
response
.text()
.await
.unwrap()
.contains("You are not authorized")
);
}
#[sqlx::test]
async fn user_cannot_take_an_existing_username(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let user_id = app.get_user_id(username).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let edit_body = serde_json::json!( {
"user_id": user_id,
"username": "admin",
"full_name": "",
"bio": "",
});
let response = app.edit_profile(&edit_body).await;
assert!(
response
.text()
.await
.unwrap()
.contains("This username is already taken")
);
let html = app.get_profile_html(username).await;
assert!(html.contains(username));
}
#[sqlx::test]
async fn invalid_fields_are_rejected(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let user_id = app.get_user_id(username).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
let test_cases = [(
serde_json::json!({
"user_id": user_id,
"username": "ab",
"full_name": "",
"bio": "",
}),
"Username must be at least 3 characters",
"the username was too short",
)];
for (invalid_body, expected_error_message, explaination) in test_cases {
let html = app.edit_profile(&invalid_body).await;
assert!(
html.text().await.unwrap().contains(expected_error_message),
"The API did not reject the changes when {}",
explaination
);
}
}