Compare commits
35 Commits
0a11047ae7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be69a54fd1 | ||
|
|
90aa4f8185 | ||
|
|
5d5f9ec765 | ||
|
|
7affe88d50 | ||
|
|
e02139ff44 | ||
|
|
45f529902d | ||
|
|
ef9f860da2 | ||
|
|
8a5605812c | ||
|
|
d27196d7e5 | ||
|
|
9cbcdc533e | ||
|
|
f18899b1a6 | ||
|
|
3bfac6d012 | ||
|
|
0b402c6259 | ||
|
|
8b5f55db6f | ||
|
|
b252216709 | ||
|
|
da590fb7c6 | ||
|
|
04c2d2b7f5 | ||
|
|
d96a29ee73 | ||
|
|
8f62c2513e | ||
|
|
50a7af2b06 | ||
|
|
af9cbdcafb | ||
|
|
ce8c602ddb | ||
|
|
9296187181 | ||
|
|
42c1dd9fe3 | ||
|
|
96e5dd0f35 | ||
|
|
91e80b4881 | ||
|
|
9e5d185aaf | ||
|
|
2c7282475f | ||
|
|
402c560354 | ||
|
|
3e81c27ab3 | ||
|
|
b5b00152cd | ||
|
|
22c462fba3 | ||
|
|
de44564ba0 | ||
|
|
3b727269c5 | ||
|
|
271aa87b9e |
@@ -1,3 +1,3 @@
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=/usr/bin/mold"]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
|
||||
@@ -6,7 +6,6 @@ Dockerfile
|
||||
/migrations
|
||||
/node_modules
|
||||
/assets/css/main.css
|
||||
/.cargo
|
||||
/.github
|
||||
README.md
|
||||
/tests
|
||||
|
||||
59
.github/workflows/general.yml
vendored
59
.github/workflows/general.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- workflow-test
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
@@ -14,11 +13,9 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
SQLX_VERSION: 0.8.6
|
||||
SQLX_FEATURES: "rustls,postgres"
|
||||
APP_USER: app
|
||||
APP_USER_PWD: secret
|
||||
APP_DB_NAME: newsletter
|
||||
APP_DATABASE__PORT: 15432
|
||||
APP_REDIS_URI: redis://127.0.0.1:16379
|
||||
DATABASE_URL: postgres://postgres:password@postgres:5432/newsletter
|
||||
APP_DATABASE__HOST: postgres
|
||||
APP_KV_STORE__HOST: redis
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -30,7 +27,7 @@ jobs:
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_DB: newsletter
|
||||
ports:
|
||||
- 15432:5432
|
||||
redis:
|
||||
@@ -40,6 +37,10 @@ jobs:
|
||||
steps:
|
||||
- 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
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
@@ -50,19 +51,10 @@ jobs:
|
||||
--features ${{ env.SQLX_FEATURES }}
|
||||
--no-default-features
|
||||
--locked
|
||||
- name: Create app user in Postgres
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install postgresql-client
|
||||
|
||||
CREATE_QUERY="CREATE USER ${APP_USER} WITH PASSWORD '${APP_USER_PWD}';"
|
||||
PGPASSWORD="password" psql -U postgres -h localhost -p 15432 -c "${CREATE_QUERY}"
|
||||
|
||||
GRANT_QUERY="ALTER USER ${APP_USER} CREATEDB;"
|
||||
PGPASSWORD="password" psql -U postgres -h localhost -p 15432 -c "${GRANT_QUERY}"
|
||||
- name: Migrate database
|
||||
run: SKIP_DOCKER=true ./scripts/init_db.sh
|
||||
run: cargo sqlx migrate run
|
||||
- name: Run tests
|
||||
run: cargo test
|
||||
run: TEST_LOG=true cargo test
|
||||
- name: Check that queries are fresh
|
||||
run: cargo sqlx prepare --check --workspace
|
||||
|
||||
@@ -85,13 +77,17 @@ jobs:
|
||||
env:
|
||||
SQLX_OFFLINE: true
|
||||
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
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: false
|
||||
with:
|
||||
components: clippy
|
||||
cache: false
|
||||
- name: Linting
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
@@ -104,7 +100,7 @@ jobs:
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_DB: newsletter
|
||||
ports:
|
||||
- 15432:5432
|
||||
redis:
|
||||
@@ -113,6 +109,10 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- 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
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
@@ -124,24 +124,11 @@ jobs:
|
||||
--features ${{ env.SQLX_FEATURES }}
|
||||
--no-default-features
|
||||
--locked
|
||||
- name: Create app user in Postgres
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install postgresql-client
|
||||
|
||||
CREATE_QUERY="CREATE USER ${APP_USER} WITH PASSWORD '${APP_USER_PWD}';"
|
||||
PGPASSWORD="password" psql -U postgres -h localhost -p 15432 -c "${CREATE_QUERY}"
|
||||
|
||||
GRANT_QUERY="ALTER USER ${APP_USER} CREATEDB;"
|
||||
PGPASSWORD="password" psql -U postgres -h localhost -p 15432 -c "${GRANT_QUERY}"
|
||||
- name: Migrate database
|
||||
run: SKIP_DOCKER=true ./scripts/init_db.sh
|
||||
run: cargo sqlx migrate run
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
- name: Generate code coverage
|
||||
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
|
||||
- name: Generate report
|
||||
run: cargo llvm-cov report --html --output-dir coverage
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: "Coverage report"
|
||||
path: coverage/
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
/target
|
||||
/node_modules
|
||||
.env
|
||||
/.idea
|
||||
docker-compose.yml
|
||||
|
||||
|
||||
18
.sqlx/query-02fff619c0ff8cb4f9946991be0ce795385b9e6697dcaa52f915acdbb1460e65.json
generated
Normal file
18
.sqlx/query-02fff619c0ff8cb4f9946991be0ce795385b9e6697dcaa52f915acdbb1460e65.json
generated
Normal 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"
|
||||
}
|
||||
64
.sqlx/query-059162eba48cf5f519d0d8b6ce63575ced91941b8c55c986b8c5591c7d9b09e4.json
generated
Normal file
64
.sqlx/query-059162eba48cf5f519d0d8b6ce63575ced91941b8c55c986b8c5591c7d9b09e4.json
generated
Normal 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"
|
||||
}
|
||||
12
.sqlx/query-1e1a90042e89bd8662df3bae15bc7506146cff102034664c77ab0fc68b9480f5.json
generated
Normal file
12
.sqlx/query-1e1a90042e89bd8662df3bae15bc7506146cff102034664c77ab0fc68b9480f5.json
generated
Normal 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"
|
||||
}
|
||||
64
.sqlx/query-1fc92c14786c21d24951341e3a8149964533b7627d2d073eeac7b7d3230513ce.json
generated
Normal file
64
.sqlx/query-1fc92c14786c21d24951341e3a8149964533b7627d2d073eeac7b7d3230513ce.json
generated
Normal 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"
|
||||
}
|
||||
44
.sqlx/query-22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1.json
generated
Normal file
44
.sqlx/query-22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1.json
generated
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT user_id, password_hash, role as \"role: Role\"\n FROM users\n WHERE username = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "password_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "role: Role",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "user_role",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"admin",
|
||||
"writer"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1"
|
||||
}
|
||||
14
.sqlx/query-3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65.json
generated
Normal file
14
.sqlx/query-3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
62
.sqlx/query-601884180bc841dc0762008a819218620fc05169fe3bb80b7635fbe9e227056b.json
generated
Normal file
62
.sqlx/query-601884180bc841dc0762008a819218620fc05169fe3bb80b7635fbe9e227056b.json
generated
Normal 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"
|
||||
}
|
||||
60
.sqlx/query-73dbf3fb780272b1849cd8aa2ecfb59774b1c46bf52181b6298eebccbc86e438.json
generated
Normal file
60
.sqlx/query-73dbf3fb780272b1849cd8aa2ecfb59774b1c46bf52181b6298eebccbc86e438.json
generated
Normal 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"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -44,7 +44,6 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
@@ -54,5 +53,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1fc498c8ccbf46f3e00b915e3b3973eb8d44a83a7df6dd7744dc56a2e94a0aa5"
|
||||
"hash": "74d92b078198c3f73edc272c788249b14b62c59365d745d6a2e314cd9c5db1e9"
|
||||
}
|
||||
22
.sqlx/query-769e8762bd2173c088d85fc132326b05a08e67092eac4c3a7aff8a49d086b5a0.json
generated
Normal file
22
.sqlx/query-769e8762bd2173c088d85fc132326b05a08e67092eac4c3a7aff8a49d086b5a0.json
generated
Normal 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"
|
||||
}
|
||||
12
.sqlx/query-7eccf0027753bc1c42897aef12c9350eca023f3be52e24530127d06c3c449104.json
generated
Normal file
12
.sqlx/query-7eccf0027753bc1c42897aef12c9350eca023f3be52e24530127d06c3c449104.json
generated
Normal 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"
|
||||
}
|
||||
22
.sqlx/query-84fcada696e1be5db55ef276e120ffef9adf7f5a4f5c4d5975b85e008e15620b.json
generated
Normal file
22
.sqlx/query-84fcada696e1be5db55ef276e120ffef9adf7f5a4f5c4d5975b85e008e15620b.json
generated
Normal 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"
|
||||
}
|
||||
27
.sqlx/query-878036fa48e738387e4140d5dc7eccba477794a267f2952aab684028b7c6e286.json
generated
Normal file
27
.sqlx/query-878036fa48e738387e4140d5dc7eccba477794a267f2952aab684028b7c6e286.json
generated
Normal 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"
|
||||
}
|
||||
@@ -1,30 +1,40 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "post_id",
|
||||
"name": "user_id?",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "author",
|
||||
"name": "username?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
"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": 4,
|
||||
"ordinal": 6,
|
||||
"name": "published_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
@@ -36,12 +46,14 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1778ace39189532c2c69850ad7366bb36c8b5fe7491064a53190a324485f0e53"
|
||||
"hash": "886de678764ebf7f96fe683d3b685d176f0a41043c7ade8b659a9bd167a2d063"
|
||||
}
|
||||
17
.sqlx/query-8dc27ae224c7ae3c99c396302357514d66e843dc4b3ee4ab58c628b6c9797fdd.json
generated
Normal file
17
.sqlx/query-8dc27ae224c7ae3c99c396302357514d66e843dc4b3ee4ab58c628b6c9797fdd.json
generated
Normal 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"
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT user_id, password_hash\n FROM users\n WHERE username = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "password_hash",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58"
|
||||
}
|
||||
17
.sqlx/query-aef1e780d14be61aa66ae8771309751741068694b291499ee1371de693c6a654.json
generated
Normal file
17
.sqlx/query-aef1e780d14be61aa66ae8771309751741068694b291499ee1371de693c6a654.json
generated
Normal 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"
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Int2",
|
||||
{
|
||||
@@ -37,5 +36,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "32701e61ea14e25608b5f6b05289d08d422e9629d6aee98ac1dcbd50f1edbfe1"
|
||||
"hash": "b64d5c2e51f328effc8f4687066db96ad695c575fb66195febcdf95c1539a153"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
22
.sqlx/query-bd08bf95dc1c8c0c7678bc509df7ce776e839846f29981e2e0bdfd382de9370f.json
generated
Normal file
22
.sqlx/query-bd08bf95dc1c8c0c7678bc509df7ce776e839846f29981e2e0bdfd382de9370f.json
generated
Normal 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"
|
||||
}
|
||||
28
.sqlx/query-bfd02c92fb5e0c8748b172bf59a77a477b432ada1f41090571f4fe0e685b1b1b.json
generated
Normal file
28
.sqlx/query-bfd02c92fb5e0c8748b172bf59a77a477b432ada1f41090571f4fe0e685b1b1b.json
generated
Normal 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"
|
||||
}
|
||||
14
.sqlx/query-caf9f2603db6bc8b715cad188501c12f5de5fae49cd04271471f1337a3232f58.json
generated
Normal file
14
.sqlx/query-caf9f2603db6bc8b715cad188501c12f5de5fae49cd04271471f1337a3232f58.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM comments WHERE comment_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "caf9f2603db6bc8b715cad188501c12f5de5fae49cd04271471f1337a3232f58"
|
||||
}
|
||||
65
.sqlx/query-dc3c1b786b4f4bd65f625922ce05eab4cb161f3de6c6e676af778f7749af5710.json
generated
Normal file
65
.sqlx/query-dc3c1b786b4f4bd65f625922ce05eab4cb161f3de6c6e676af778f7749af5710.json
generated
Normal 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"
|
||||
}
|
||||
14
.sqlx/query-dfa520877c017cd5808d02c24ef2d71938b68093974f335a4d89df91874fdaa2.json
generated
Normal file
14
.sqlx/query-dfa520877c017cd5808d02c24ef2d71938b68093974f335a4d89df91874fdaa2.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM users WHERE user_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dfa520877c017cd5808d02c24ef2d71938b68093974f335a4d89df91874fdaa2"
|
||||
}
|
||||
62
.sqlx/query-e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271.json
generated
Normal file
62
.sqlx/query-e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271.json
generated
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT user_id, username, full_name, role as \"role: Role\", member_since, bio\n FROM users\n WHERE username = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "full_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "role: Role",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "user_role",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"admin",
|
||||
"writer"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "member_since",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "bio",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271"
|
||||
}
|
||||
20
.sqlx/query-e056c3230c1ccd1b3b62e902f49a41f21213e0f7da92b428065986d380676034.json
generated
Normal file
20
.sqlx/query-e056c3230c1ccd1b3b62e902f49a41f21213e0f7da92b428065986d380676034.json
generated
Normal 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"
|
||||
}
|
||||
22
.sqlx/query-f4ea2ad9ba4f26093152e4a0e008ef6c3114fbe9e51301611c5633e1cc944c05.json
generated
Normal file
22
.sqlx/query-f4ea2ad9ba4f26093152e4a0e008ef6c3114fbe9e51301611c5633e1cc944c05.json
generated
Normal 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"
|
||||
}
|
||||
60
.sqlx/query-fb280849a8a1fce21ec52cd9df73492d965357c9a410eb3b43b1a2e1cc8a0259.json
generated
Normal file
60
.sqlx/query-fb280849a8a1fce21ec52cd9df73492d965357c9a410eb3b43b1a2e1cc8a0259.json
generated
Normal 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"
|
||||
}
|
||||
14
.sqlx/query-fc383671ada951baa611ab7dd00efcc7f4f2aea7c22e4c0865e5c766ed7f99b3.json
generated
Normal file
14
.sqlx/query-fc383671ada951baa611ab7dd00efcc7f4f2aea7c22e4c0865e5c766ed7f99b3.json
generated
Normal 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"
|
||||
}
|
||||
424
Cargo.lock
generated
424
Cargo.lock
generated
@@ -339,6 +339,12 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.41"
|
||||
@@ -523,6 +529,29 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser-macros"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
@@ -597,6 +626,26 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deunicode"
|
||||
version = "1.6.2"
|
||||
@@ -650,6 +699,27 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa-short"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||
dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ego-tree"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -805,6 +875,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||
dependencies = [
|
||||
"mac",
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
@@ -905,6 +985,15 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxhash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -915,6 +1004,15 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
@@ -922,8 +1020,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -933,9 +1033,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasi 0.14.2+wasi-0.2.4",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1034,6 +1136,17 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever",
|
||||
"match_token",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.3.1"
|
||||
@@ -1109,6 +1222,23 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.16"
|
||||
@@ -1414,6 +1544,18 @@ version = "0.4.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "1.0.0"
|
||||
@@ -1423,6 +1565,28 @@ dependencies = [
|
||||
"unicode-id",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "match_token"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.1.0"
|
||||
@@ -1496,6 +1660,12 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -1730,6 +1900,58 @@ dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
@@ -1793,6 +2015,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr2"
|
||||
version = "2.0.0"
|
||||
@@ -1862,6 +2090,61 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.6.0",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.3",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.0",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
@@ -2018,16 +2301,21 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
@@ -2035,6 +2323,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2148,6 +2437,7 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2180,6 +2470,21 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scraper"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5f3a24d916e78954af99281a455168d4a9515d65eca99a18da1b813689c4ad9"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"ego-tree",
|
||||
"getopts",
|
||||
"html5ever",
|
||||
"precomputed-hash",
|
||||
"selectors",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secrecy"
|
||||
version = "0.10.3"
|
||||
@@ -2190,6 +2495,25 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5685b6ae43bfcf7d2e7dfcfb5d8e8f61b46442c902531e41a32a9a8bf0ee0fb6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"fxhash",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"servo_arc",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.26"
|
||||
@@ -2292,6 +2616,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "204ea332803bd95a0b60388590d59cf6468ec9becf626e2451f1d26a1d972de4"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -2339,6 +2672,12 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.11"
|
||||
@@ -2597,6 +2936,31 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
@@ -2651,6 +3015,17 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
||||
dependencies = [
|
||||
"futf",
|
||||
"mac",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.16"
|
||||
@@ -2774,6 +3149,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.17"
|
||||
@@ -3103,6 +3488,12 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -3126,6 +3517,12 @@ version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -3303,6 +3700,28 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
@@ -3651,7 +4070,6 @@ dependencies = [
|
||||
"argon2",
|
||||
"askama",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"claims",
|
||||
"config",
|
||||
@@ -3663,11 +4081,11 @@ dependencies = [
|
||||
"quickcheck_macros",
|
||||
"rand 0.9.2",
|
||||
"reqwest",
|
||||
"scraper",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde-aux",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sqlx",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -3676,8 +4094,6 @@ dependencies = [
|
||||
"tower-sessions-redis-store",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unicode-segmentation",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"validator",
|
||||
"wiremock",
|
||||
|
||||
@@ -28,14 +28,14 @@ anyhow = "1.0.99"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
askama = "0.14.0"
|
||||
axum = { version = "0.8.4", features = ["macros"] }
|
||||
base64 = "0.22.1"
|
||||
chrono = { version = "0.4.41", default-features = false, features = ["clock"] }
|
||||
config = "0.15.14"
|
||||
markdown = "1.0.0"
|
||||
rand = { version = "0.9.2", features = ["std_rng"] }
|
||||
reqwest = { version = "0.12.23", default-features = false, features = [
|
||||
"json",
|
||||
"cookies",
|
||||
"json",
|
||||
"rustls-tls",
|
||||
] }
|
||||
secrecy = { version = "0.10.3", features = ["serde"] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
@@ -55,8 +55,6 @@ tower-sessions = "0.14.0"
|
||||
tower-sessions-redis-store = "0.16.0"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
unicode-segmentation = "1.12.0"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.18.0", features = ["v4", "serde"] }
|
||||
validator = { version = "0.20.0", features = ["derive"] }
|
||||
|
||||
@@ -67,6 +65,6 @@ linkify = "0.10.0"
|
||||
once_cell = "1.21.3"
|
||||
quickcheck = "1.0.3"
|
||||
quickcheck_macros = "1.1.0"
|
||||
scraper = "0.24.0"
|
||||
serde_json = "1.0.143"
|
||||
serde_urlencoded = "0.7.1"
|
||||
wiremock = "0.6.4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM lukemathwalker/cargo-chef:latest-rust-1.90.0 AS chef
|
||||
WORKDIR /app
|
||||
RUN apt update && apt install -y nodejs npm && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt update && apt install -y nodejs npm clang mold && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM chef AS planner
|
||||
COPY . .
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,6 +2,8 @@ application:
|
||||
port: 8080
|
||||
host: "127.0.0.1"
|
||||
base_url: "http://127.0.0.1:8080"
|
||||
email_client:
|
||||
authorization_token: "secret-token"
|
||||
database:
|
||||
host: "127.0.0.1"
|
||||
port: 5432
|
||||
@@ -10,6 +12,6 @@ database:
|
||||
password: "password"
|
||||
require_ssl: false
|
||||
timeout_milliseconds: 1000
|
||||
email_client:
|
||||
authorization_token: "secret-token"
|
||||
redis_uri: "redis://127.0.0.1:6379"
|
||||
kv_store:
|
||||
host: "127.0.0.1"
|
||||
port: 6379
|
||||
|
||||
7
migrations/20250930145931_add_role_to_users.sql
Normal file
7
migrations/20250930145931_add_role_to_users.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TYPE user_role AS ENUM ('admin', 'writer');
|
||||
|
||||
ALTER TABLE users ADD COLUMN role user_role;
|
||||
|
||||
UPDATE users SET role = 'admin' WHERE role IS NULL;
|
||||
|
||||
ALTER TABLE users ALTER COLUMN role SET NOT NULL;
|
||||
4
migrations/20250930181830_add_data_fields_to_users.sql
Normal file
4
migrations/20250930181830_add_data_fields_to_users.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN full_name TEXT,
|
||||
ADD COLUMN bio TEXT,
|
||||
ADD COLUMN member_since TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
7
migrations/20251001165158_create_comments_table.sql
Normal file
7
migrations/20251001165158_create_comments_table.sql
Normal 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()
|
||||
);
|
||||
@@ -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;
|
||||
3
migrations/20251008112745_add_user_id_to_comments.sql
Normal file
3
migrations/20251008112745_add_user_id_to_comments.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE comments
|
||||
ADD COLUMN user_id UUID
|
||||
REFERENCES users (user_id) ON DELETE SET NULL;
|
||||
2
migrations/20251009173005_add_last_modified_to_posts.sql
Normal file
2
migrations/20251009173005_add_last_modified_to_posts.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN last_modified TIMESTAMPTZ;
|
||||
5
migrations/20251009180347_create_user_logins_table.sql
Normal file
5
migrations/20251009180347_create_user_logins_table.sql
Normal 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
3
package-lock.json
generated
@@ -1123,7 +1123,8 @@
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz",
|
||||
"integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.3",
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
use crate::{
|
||||
routes::AdminError, session_state::TypedSession, telemetry::spawn_blocking_with_tracing,
|
||||
};
|
||||
use crate::telemetry::spawn_blocking_with_tracing;
|
||||
use anyhow::Context;
|
||||
use argon2::{
|
||||
Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version,
|
||||
password_hash::{SaltString, rand_core::OsRng},
|
||||
};
|
||||
use axum::{extract::Request, middleware::Next, response::Response};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use sqlx::PgPool;
|
||||
use std::fmt::Display;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct Credentials {
|
||||
@@ -44,7 +42,7 @@ pub async fn change_password(
|
||||
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 password_hash = Argon2::new(
|
||||
Algorithm::Argon2id,
|
||||
@@ -60,8 +58,9 @@ fn compute_pasword_hash(password: SecretString) -> Result<SecretString, anyhow::
|
||||
pub async fn validate_credentials(
|
||||
Credentials { username, password }: Credentials,
|
||||
connection_pool: &PgPool,
|
||||
) -> Result<Uuid, AuthError> {
|
||||
) -> Result<(Uuid, Role), AuthError> {
|
||||
let mut user_id = None;
|
||||
let mut role = None;
|
||||
let mut expected_password_hash = SecretString::from(
|
||||
"$argon2id$v=19$m=15000,t=2,p=1$\
|
||||
gZiV/M1gPc22ElAH/Jh1Hw$\
|
||||
@@ -69,13 +68,14 @@ CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
if let Some((stored_user_id, stored_expected_password_hash)) =
|
||||
if let Some((stored_user_id, stored_expected_password_hash, stored_role)) =
|
||||
get_stored_credentials(&username, connection_pool)
|
||||
.await
|
||||
.context("Failed to retrieve credentials from database.")
|
||||
.map_err(AuthError::UnexpectedError)?
|
||||
{
|
||||
user_id = Some(stored_user_id);
|
||||
role = Some(stored_role);
|
||||
expected_password_hash = stored_expected_password_hash;
|
||||
}
|
||||
|
||||
@@ -86,12 +86,16 @@ CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown username."))
|
||||
.map_err(AuthError::InvalidCredentials)?;
|
||||
|
||||
let role = role
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown role."))
|
||||
.map_err(AuthError::UnexpectedError)?;
|
||||
|
||||
handle
|
||||
.await
|
||||
.context("Failed to spawn blocking task.")
|
||||
.map_err(AuthError::UnexpectedError)?
|
||||
.map_err(AuthError::InvalidCredentials)
|
||||
.map(|_| uuid)
|
||||
.map(|_| (uuid, role))
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "Verify password", skip_all)]
|
||||
@@ -113,10 +117,10 @@ fn verify_password_hash(
|
||||
async fn get_stored_credentials(
|
||||
username: &str,
|
||||
connection_pool: &PgPool,
|
||||
) -> Result<Option<(Uuid, SecretString)>, sqlx::Error> {
|
||||
) -> Result<Option<(Uuid, SecretString, Role)>, sqlx::Error> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT user_id, password_hash
|
||||
SELECT user_id, password_hash, role as "role: Role"
|
||||
FROM users
|
||||
WHERE username = $1
|
||||
"#,
|
||||
@@ -124,37 +128,35 @@ async fn get_stored_credentials(
|
||||
)
|
||||
.fetch_optional(connection_pool)
|
||||
.await?
|
||||
.map(|row| (row.user_id, SecretString::from(row.password_hash)));
|
||||
.map(|row| (row.user_id, SecretString::from(row.password_hash), row.role));
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn require_auth(
|
||||
session: TypedSession,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AdminError> {
|
||||
let user_id = session
|
||||
.get_user_id()
|
||||
.await
|
||||
.map_err(|e| AdminError::UnexpectedError(e.into()))?
|
||||
.ok_or(AdminError::NotAuthenticated)?;
|
||||
let username = session
|
||||
.get_username()
|
||||
.await
|
||||
.map_err(|e| AdminError::UnexpectedError(e.into()))?
|
||||
.ok_or(AdminError::UnexpectedError(anyhow::anyhow!(
|
||||
"Could not find username in session."
|
||||
)))?;
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
|
||||
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
Admin,
|
||||
Writer,
|
||||
}
|
||||
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(AuthenticatedUser { user_id, username });
|
||||
|
||||
Ok(next.run(request).await)
|
||||
impl Display for Role {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Role::Admin => write!(f, "admin"),
|
||||
Role::Writer => write!(f, "writer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthenticatedUser {
|
||||
pub user_id: Uuid,
|
||||
pub username: String,
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
impl AuthenticatedUser {
|
||||
pub fn is_admin(&self) -> bool {
|
||||
matches!(self.role, Role::Admin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use crate::domain::SubscriberEmail;
|
||||
use anyhow::Context;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::Deserialize;
|
||||
use serde_aux::field_attributes::deserialize_number_from_string;
|
||||
use sqlx::postgres::{PgConnectOptions, PgSslMode};
|
||||
use tower_sessions_redis_store::{
|
||||
RedisStore,
|
||||
fred::prelude::{ClientLike, Pool},
|
||||
};
|
||||
|
||||
pub fn get_configuration() -> Result<Settings, config::ConfigError> {
|
||||
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
|
||||
@@ -60,7 +65,7 @@ pub struct Settings {
|
||||
pub application: ApplicationSettings,
|
||||
pub database: DatabaseSettings,
|
||||
pub email_client: EmailClientSettings,
|
||||
pub redis_uri: SecretString,
|
||||
pub kv_store: RedisSettings,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
@@ -100,6 +105,35 @@ impl EmailClientSettings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct RedisSettings {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl RedisSettings {
|
||||
pub fn connection_string(&self) -> String {
|
||||
format!("redis://{}:{}", self.host, self.port)
|
||||
}
|
||||
|
||||
pub async fn session_store(&self) -> Result<RedisStore<Pool>, anyhow::Error> {
|
||||
let pool = Pool::new(
|
||||
tower_sessions_redis_store::fred::prelude::Config::from_url(&self.connection_string())
|
||||
.context("Failed to parse Redis URL string.")?,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
6,
|
||||
)
|
||||
.unwrap();
|
||||
pool.connect();
|
||||
pool.wait_for_connect()
|
||||
.await
|
||||
.context("Failed to connect to the Redis server.")?;
|
||||
Ok(RedisStore::new(pool))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct DatabaseSettings {
|
||||
pub username: String,
|
||||
|
||||
58
src/database_worker.rs
Normal file
58
src/database_worker.rs
Normal 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(())
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
mod comment;
|
||||
mod new_subscriber;
|
||||
mod post;
|
||||
mod subscriber_email;
|
||||
mod subscribers;
|
||||
mod user;
|
||||
|
||||
pub use comment::CommentEntry;
|
||||
pub use new_subscriber::NewSubscriber;
|
||||
pub use post::PostEntry;
|
||||
pub use subscriber_email::SubscriberEmail;
|
||||
pub use subscribers::SubscriberEntry;
|
||||
pub use user::UserEntry;
|
||||
|
||||
18
src/domain/comment.rs
Normal file
18
src/domain/comment.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -3,25 +3,23 @@ use uuid::Uuid;
|
||||
|
||||
pub struct PostEntry {
|
||||
pub post_id: Uuid,
|
||||
pub author: Option<String>,
|
||||
pub author_id: Uuid,
|
||||
pub author: String,
|
||||
pub full_name: Option<String>,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub published_at: DateTime<Utc>,
|
||||
pub last_modified: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl PostEntry {
|
||||
#[allow(dead_code)]
|
||||
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()) {
|
||||
Ok(mut content) => {
|
||||
content = content.replace("<table>", r#"<div class="table-wrapper"><table>"#);
|
||||
content = content.replace("</table>", r#"</table></div>"#);
|
||||
Ok(Self { content, ..self })
|
||||
}
|
||||
Ok(content) => Ok(content),
|
||||
Err(e) => anyhow::bail!(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use validator::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)]
|
||||
mod tests {
|
||||
use super::SubscriberEmail;
|
||||
|
||||
22
src/domain/user.rs
Normal file
22
src/domain/user.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use crate::authentication::Role;
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct UserEntry {
|
||||
pub user_id: Uuid,
|
||||
pub username: String,
|
||||
pub role: Role,
|
||||
pub full_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub member_since: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl UserEntry {
|
||||
pub fn formatted_date(&self) -> String {
|
||||
self.member_since.format("%B %d, %Y").to_string()
|
||||
}
|
||||
|
||||
pub fn is_admin(&self) -> bool {
|
||||
matches!(self.role, Role::Admin)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use axum::{
|
||||
use reqwest::StatusCode;
|
||||
use sqlx::{Executor, PgPool, Postgres, Transaction};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, sqlx::Type)]
|
||||
#[sqlx(type_name = "header_pair")]
|
||||
@@ -23,7 +22,6 @@ struct HeaderPairRecord {
|
||||
pub async fn get_saved_response(
|
||||
connection_pool: &PgPool,
|
||||
idempotency_key: &IdempotencyKey,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<Response>, anyhow::Error> {
|
||||
let saved_response = sqlx::query!(
|
||||
r#"
|
||||
@@ -32,11 +30,8 @@ pub async fn get_saved_response(
|
||||
response_headers as "response_headers!: Vec<HeaderPairRecord>",
|
||||
response_body as "response_body!"
|
||||
FROM idempotency
|
||||
WHERE
|
||||
user_id = $1
|
||||
AND idempotency_key = $2
|
||||
WHERE idempotency_key = $1
|
||||
"#,
|
||||
user_id,
|
||||
idempotency_key.as_ref()
|
||||
)
|
||||
.fetch_optional(connection_pool)
|
||||
@@ -61,7 +56,6 @@ pub async fn get_saved_response(
|
||||
pub async fn save_response(
|
||||
mut transaction: Transaction<'static, Postgres>,
|
||||
idempotency_key: &IdempotencyKey,
|
||||
user_id: Uuid,
|
||||
response: Response<Body>,
|
||||
) -> Result<Response<Body>, anyhow::Error> {
|
||||
let status_code = response.status().as_u16() as i16;
|
||||
@@ -80,14 +74,11 @@ pub async fn save_response(
|
||||
r#"
|
||||
UPDATE idempotency
|
||||
SET
|
||||
response_status_code = $3,
|
||||
response_headers = $4,
|
||||
response_body = $5
|
||||
WHERE
|
||||
user_id = $1
|
||||
AND idempotency_key = $2
|
||||
response_status_code = $2,
|
||||
response_headers = $3,
|
||||
response_body = $4
|
||||
WHERE idempotency_key = $1
|
||||
"#,
|
||||
user_id,
|
||||
idempotency_key.as_ref(),
|
||||
status_code,
|
||||
headers,
|
||||
@@ -109,23 +100,21 @@ pub enum NextAction {
|
||||
pub async fn try_processing(
|
||||
connection_pool: &PgPool,
|
||||
idempotency_key: &IdempotencyKey,
|
||||
user_id: Uuid,
|
||||
) -> Result<NextAction, anyhow::Error> {
|
||||
let mut transaction = connection_pool.begin().await?;
|
||||
let query = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO idempotency (user_id, idempotency_key, created_at)
|
||||
VALUES ($1, $2, now())
|
||||
INSERT INTO idempotency (idempotency_key, created_at)
|
||||
VALUES ($1, now())
|
||||
ON CONFLICT DO NOTHING
|
||||
"#,
|
||||
user_id,
|
||||
idempotency_key.as_ref()
|
||||
);
|
||||
let n_inserted_rows = transaction.execute(query).await?.rows_affected();
|
||||
if n_inserted_rows > 0 {
|
||||
Ok(NextAction::StartProcessing(transaction))
|
||||
} else {
|
||||
let saved_response = get_saved_response(connection_pool, idempotency_key, user_id)
|
||||
let saved_response = get_saved_response(connection_pool, idempotency_key)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not find saved response."))?;
|
||||
Ok(NextAction::ReturnSavedResponse(saved_response))
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::time::Duration;
|
||||
use tracing::{Span, field::display};
|
||||
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 email_client = EmailClient::build(configuration.email_client).unwrap();
|
||||
worker_loop(connection_pool, email_client).await
|
||||
@@ -31,14 +31,6 @@ pub enum ExecutionOutcome {
|
||||
EmptyQueue,
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
skip_all,
|
||||
fields(
|
||||
newsletter_issue_id=tracing::field::Empty,
|
||||
subscriber_email=tracing::field::Empty
|
||||
),
|
||||
err
|
||||
)]
|
||||
pub async fn try_execute_task(
|
||||
connection_pool: &PgPool,
|
||||
email_client: &EmailClient,
|
||||
@@ -53,25 +45,14 @@ pub async fn try_execute_task(
|
||||
.record("subscriber_email", display(&task.subscriber_email));
|
||||
match SubscriberEmail::parse(task.subscriber_email.clone()) {
|
||||
Ok(email) => {
|
||||
let mut issue = get_issue(connection_pool, task.newsletter_issue_id).await?;
|
||||
issue.inject_unsubscribe_token(&task.unsubscribe_token);
|
||||
if task.kind == EmailType::NewPost.to_string() {
|
||||
issue.inject_tracking_info(&mut transaction).await?;
|
||||
}
|
||||
if let Err(e) = email_client
|
||||
.send_email(
|
||||
&email,
|
||||
&issue.title,
|
||||
&issue.html_content,
|
||||
&issue.text_content,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"Failed to deliver issue to confirmed subscriber. Skipping."
|
||||
);
|
||||
}
|
||||
execute_task(
|
||||
connection_pool,
|
||||
&mut transaction,
|
||||
&task,
|
||||
email,
|
||||
email_client,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Err(e) => {
|
||||
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(
|
||||
mut transaction: Transaction<'static, Postgres>,
|
||||
issue_id: Uuid,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod authentication;
|
||||
pub mod configuration;
|
||||
pub mod database_worker;
|
||||
pub mod domain;
|
||||
pub mod email_client;
|
||||
pub mod idempotency;
|
||||
|
||||
13
src/main.rs
13
src/main.rs
@@ -1,6 +1,6 @@
|
||||
use zero2prod::{
|
||||
configuration::get_configuration, issue_delivery_worker::run_worker_until_stopped,
|
||||
startup::Application, telemetry::init_subscriber,
|
||||
configuration::get_configuration, database_worker, issue_delivery_worker, startup::Application,
|
||||
telemetry::init_subscriber,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
@@ -11,11 +11,16 @@ async fn main() -> Result<(), anyhow::Error> {
|
||||
let application = Application::build(configuration.clone()).await?;
|
||||
|
||||
let application_task = tokio::spawn(application.run_until_stopped());
|
||||
let worker_task = tokio::spawn(run_worker_until_stopped(configuration));
|
||||
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! {
|
||||
_ = application_task => {},
|
||||
_ = worker_task => {},
|
||||
_ = database_worker_task => {},
|
||||
_ = delivery_worker_task => {},
|
||||
};
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod admin;
|
||||
mod comments;
|
||||
mod health_check;
|
||||
mod home;
|
||||
mod login;
|
||||
@@ -6,6 +7,7 @@ mod posts;
|
||||
mod subscriptions;
|
||||
mod subscriptions_confirm;
|
||||
mod unsubscribe;
|
||||
mod users;
|
||||
|
||||
pub use admin::*;
|
||||
use askama::Template;
|
||||
@@ -14,6 +16,7 @@ use axum::{
|
||||
http::{HeaderMap, request::Parts},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
pub use comments::*;
|
||||
pub use health_check::*;
|
||||
pub use home::*;
|
||||
pub use login::*;
|
||||
@@ -24,10 +27,12 @@ use serde::de::DeserializeOwned;
|
||||
pub use subscriptions::*;
|
||||
pub use subscriptions_confirm::*;
|
||||
pub use unsubscribe::*;
|
||||
pub use users::*;
|
||||
use validator::ValidationErrors;
|
||||
|
||||
use crate::{
|
||||
authentication::AuthError,
|
||||
templates::{HtmlTemplate, InternalErrorTemplate, MessageTemplate, NotFoundTemplate},
|
||||
templates::{ErrorTemplate, HtmlTemplate, MessageTemplate},
|
||||
};
|
||||
|
||||
pub fn generate_token() -> String {
|
||||
@@ -108,19 +113,16 @@ impl IntoResponse for AppError {
|
||||
full_page,
|
||||
} => {
|
||||
let html = if *full_page {
|
||||
Html(InternalErrorTemplate.render().unwrap())
|
||||
Html(ErrorTemplate::InternalServer.render().unwrap())
|
||||
} else {
|
||||
let template = MessageTemplate::Error {
|
||||
message: "An internal server error occured.".into(),
|
||||
};
|
||||
let template =
|
||||
MessageTemplate::error("An internal server error occured.".into());
|
||||
Html(template.render().unwrap())
|
||||
};
|
||||
html.into_response()
|
||||
}
|
||||
AppError::FormError(error) => {
|
||||
let template = MessageTemplate::Error {
|
||||
message: error.to_string(),
|
||||
};
|
||||
let template = MessageTemplate::error(error.to_string());
|
||||
Html(template.render().unwrap()).into_response()
|
||||
}
|
||||
AppError::NotAuthenticated => {
|
||||
@@ -160,11 +162,12 @@ impl From<AuthError> for AppError {
|
||||
}
|
||||
|
||||
pub async fn not_found() -> Response {
|
||||
tracing::error!("Not found.");
|
||||
not_found_html()
|
||||
}
|
||||
|
||||
pub fn not_found_html() -> Response {
|
||||
let template = HtmlTemplate(NotFoundTemplate);
|
||||
let template = HtmlTemplate(ErrorTemplate::NotFound);
|
||||
(StatusCode::NOT_FOUND, template).into_response()
|
||||
}
|
||||
|
||||
@@ -207,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")
|
||||
}
|
||||
|
||||
@@ -9,8 +9,15 @@ use crate::{
|
||||
authentication::AuthenticatedUser,
|
||||
routes::{AppError, error_chain_fmt},
|
||||
session_state::TypedSession,
|
||||
templates::{HtmlTemplate, MessageTemplate},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use axum::response::Redirect;
|
||||
use axum::{
|
||||
extract::Request,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum::{extract::Request, middleware::Next, response::Response};
|
||||
pub use change_password::*;
|
||||
pub use dashboard::*;
|
||||
pub use logout::*;
|
||||
@@ -43,11 +50,17 @@ pub async fn require_auth(
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let user_id = session
|
||||
let user_id = match session
|
||||
.get_user_id()
|
||||
.await
|
||||
.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
|
||||
.get_username()
|
||||
.await
|
||||
@@ -55,10 +68,36 @@ pub async fn require_auth(
|
||||
.ok_or(AdminError::UnexpectedError(anyhow::anyhow!(
|
||||
"Could not find username in session."
|
||||
)))?;
|
||||
let role = session
|
||||
.get_role()
|
||||
.await
|
||||
.context("Error retrieving user role in session.")?
|
||||
.ok_or(anyhow::anyhow!("Could not find user role in session."))?;
|
||||
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(AuthenticatedUser { user_id, username });
|
||||
request.extensions_mut().insert(AuthenticatedUser {
|
||||
user_id,
|
||||
username,
|
||||
role,
|
||||
});
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
pub async fn require_admin(
|
||||
session: TypedSession,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
if session
|
||||
.has_admin_permissions()
|
||||
.await
|
||||
.context("Error retrieving user role in session.")?
|
||||
{
|
||||
Ok(next.run(request).await)
|
||||
} else {
|
||||
Ok(HtmlTemplate(MessageTemplate::error(
|
||||
"This action requires administrator privileges.".into(),
|
||||
))
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ pub struct PasswordFormData {
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
Extension(AuthenticatedUser { user_id, username }): Extension<AuthenticatedUser>,
|
||||
Extension(AuthenticatedUser {
|
||||
user_id, username, ..
|
||||
}): Extension<AuthenticatedUser>,
|
||||
State(AppState {
|
||||
connection_pool, ..
|
||||
}): State<AppState>,
|
||||
@@ -49,14 +51,12 @@ pub async fn change_password(
|
||||
authentication::change_password(user_id, form.new_password, &connection_pool)
|
||||
.await
|
||||
.map_err(AdminError::ChangePassword)?;
|
||||
let template = MessageTemplate::Success {
|
||||
message: "Your password has been changed.".to_string(),
|
||||
};
|
||||
let template = MessageTemplate::success("Your password has been changed.".to_string());
|
||||
Ok(Html(template.render().unwrap()).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_password(password: &str) -> Result<(), anyhow::Error> {
|
||||
pub fn verify_password(password: &str) -> Result<(), anyhow::Error> {
|
||||
if password.len() < 12 || password.len() > 128 {
|
||||
anyhow::bail!("The password must contain between 12 and 128 characters.");
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
authentication::AuthenticatedUser,
|
||||
routes::{AppError, get_max_page, get_subs, get_total_subs},
|
||||
@@ -31,7 +35,7 @@ pub async fn admin_dashboard(
|
||||
State(AppState {
|
||||
connection_pool, ..
|
||||
}): State<AppState>,
|
||||
Extension(AuthenticatedUser { username, .. }): Extension<AuthenticatedUser>,
|
||||
Extension(user): Extension<AuthenticatedUser>,
|
||||
) -> Result<Response, AppError> {
|
||||
let stats = get_stats(&connection_pool).await?;
|
||||
let idempotency_key_1 = Uuid::new_v4().to_string();
|
||||
@@ -41,18 +45,47 @@ pub async fn admin_dashboard(
|
||||
.await
|
||||
.context("Could not fetch subscribers from database.")
|
||||
.map_err(AppError::unexpected_message)?;
|
||||
let count = get_total_subs(&connection_pool)
|
||||
let subs_count = get_total_subs(&connection_pool)
|
||||
.await
|
||||
.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 {
|
||||
username,
|
||||
user,
|
||||
idempotency_key_1,
|
||||
idempotency_key_2,
|
||||
stats,
|
||||
subscribers,
|
||||
current_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())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::{
|
||||
authentication::AuthenticatedUser,
|
||||
idempotency::{IdempotencyKey, save_response, try_processing},
|
||||
routes::{AdminError, AppError},
|
||||
startup::AppState,
|
||||
@@ -8,7 +7,7 @@ use crate::{
|
||||
use anyhow::Context;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Extension, Form,
|
||||
Form,
|
||||
extract::State,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
@@ -95,7 +94,6 @@ pub async fn publish_newsletter(
|
||||
base_url,
|
||||
..
|
||||
}): State<AppState>,
|
||||
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||
Form(form): Form<BodyData>,
|
||||
) -> Result<Response, AppError> {
|
||||
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
|
||||
@@ -105,7 +103,7 @@ pub async fn publish_newsletter(
|
||||
.try_into()
|
||||
.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::ReturnSavedResponse(response) => {
|
||||
return Ok(response);
|
||||
@@ -127,9 +125,9 @@ pub async fn publish_newsletter(
|
||||
.context("Failed to enqueue delivery tasks.")?;
|
||||
|
||||
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 = save_response(transaction, &idempotency_key, user_id, response)
|
||||
let response = save_response(transaction, &idempotency_key, response)
|
||||
.await
|
||||
.map_err(AdminError::UnexpectedError)?;
|
||||
Ok(response)
|
||||
|
||||
@@ -54,7 +54,7 @@ pub async fn create_post(
|
||||
.try_into()
|
||||
.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::ReturnSavedResponse(response) => {
|
||||
return Ok(response);
|
||||
@@ -73,11 +73,9 @@ pub async fn create_post(
|
||||
.await
|
||||
.context("Failed to enqueue delivery tasks.")?;
|
||||
|
||||
let template = MessageTemplate::Success {
|
||||
message: "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 = save_response(transaction, &idempotency_key, user_id, response)
|
||||
let response = save_response(transaction, &idempotency_key, response)
|
||||
.await
|
||||
.map_err(AdminError::UnexpectedError)?;
|
||||
Ok(response)
|
||||
@@ -138,9 +136,7 @@ pub async fn delete_post(
|
||||
"We could not find the post in the database."
|
||||
)))
|
||||
} else {
|
||||
let template = MessageTemplate::Success {
|
||||
message: "The subscriber has been deleted.".into(),
|
||||
};
|
||||
let template = MessageTemplate::success("The post has been deleted.".into());
|
||||
Ok(template.render().unwrap().into_response())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,9 @@ use axum::{
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
const SUBS_PER_PAGE: i64 = 5;
|
||||
pub const SUBS_PER_PAGE: i64 = 5;
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "Retrieving most recent subscribers from database",
|
||||
skip(connection_pool)
|
||||
)]
|
||||
#[tracing::instrument(name = "Retrieving subscribers from database", skip(connection_pool))]
|
||||
pub async fn get_subscribers_page(
|
||||
State(AppState {
|
||||
connection_pool, ..
|
||||
@@ -29,7 +26,7 @@ pub async fn get_subscribers_page(
|
||||
.await
|
||||
.context("Could not fetch total subscribers count from the database.")
|
||||
.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)
|
||||
.await
|
||||
.context("Could not fetch subscribers data.")
|
||||
@@ -63,12 +60,10 @@ pub async fn delete_subscriber(
|
||||
.map_err(AppError::unexpected_message)?;
|
||||
if let Some(record) = res {
|
||||
tracing::Span::current().record("email", tracing::field::display(&record.email));
|
||||
let template = MessageTemplate::Success {
|
||||
message: format!(
|
||||
"The subscriber with email '{}' has been deleted.",
|
||||
record.email
|
||||
),
|
||||
};
|
||||
let template = MessageTemplate::success(format!(
|
||||
"The subscriber with email '{}' has been deleted.",
|
||||
record.email
|
||||
));
|
||||
Ok(template.render().unwrap().into_response())
|
||||
} else {
|
||||
Err(AppError::unexpected_message(anyhow::anyhow!(
|
||||
@@ -107,9 +102,9 @@ pub async fn get_total_subs(connection_pool: &PgPool) -> Result<i64, sqlx::Error
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn get_max_page(count: i64) -> i64 {
|
||||
let mut max_page = count.div_euclid(SUBS_PER_PAGE);
|
||||
if count % SUBS_PER_PAGE > 0 {
|
||||
pub fn get_max_page(count: i64, num_per_page: i64) -> i64 {
|
||||
let mut max_page = count.div_euclid(num_per_page);
|
||||
if count % num_per_page > 0 {
|
||||
max_page += 1;
|
||||
}
|
||||
max_page
|
||||
|
||||
264
src/routes/comments.rs
Normal file
264
src/routes/comments.rs
Normal 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)
|
||||
}
|
||||
@@ -15,6 +15,8 @@ use axum::{
|
||||
};
|
||||
use axum::{http::StatusCode, response::Redirect};
|
||||
use secrecy::SecretString;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
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.")?
|
||||
.is_some()
|
||||
{
|
||||
Ok(Redirect::to("/admin/dashboard").into_response())
|
||||
Ok(Redirect::to("dashboard").into_response())
|
||||
} else {
|
||||
Ok(Html(LoginTemplate.render().unwrap()).into_response())
|
||||
}
|
||||
@@ -48,8 +50,11 @@ pub async fn post_login(
|
||||
password: form.password,
|
||||
};
|
||||
tracing::Span::current().record("username", tracing::field::display(&credentials.username));
|
||||
let user_id = 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));
|
||||
record_login(&connection_pool, &user_id)
|
||||
.await
|
||||
.context("Failed to register new login event.")?;
|
||||
|
||||
session.renew().await.context("Failed to renew session.")?;
|
||||
session
|
||||
@@ -60,8 +65,20 @@ pub async fn post_login(
|
||||
.insert_username(form.username)
|
||||
.await
|
||||
.context("Failed to insert username in session data store.")?;
|
||||
session
|
||||
.insert_role(role)
|
||||
.await
|
||||
.context("Failed to insert role in session data store.")?;
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
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,
|
||||
templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Extension, Form,
|
||||
extract::State,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
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)]
|
||||
pub async fn list_posts(
|
||||
@@ -21,12 +30,16 @@ pub async fn list_posts(
|
||||
connection_pool, ..
|
||||
}): State<AppState>,
|
||||
) -> Result<Response, AppError> {
|
||||
let count = get_posts_table_size(&connection_pool)
|
||||
let count = get_posts_count(&connection_pool)
|
||||
.await
|
||||
.context("Could not fetch posts table size.")
|
||||
.map_err(AppError::unexpected_page)?;
|
||||
let next_page = if count > NUM_PER_PAGE { Some(2) } else { None };
|
||||
let posts = get_posts(&connection_pool, NUM_PER_PAGE, None)
|
||||
let next_page = if count > POSTS_PER_PAGE {
|
||||
Some(2)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let posts = get_posts(&connection_pool, POSTS_PER_PAGE, None)
|
||||
.await
|
||||
.context("Could not fetch latest posts")
|
||||
.map_err(AppError::unexpected_page)?;
|
||||
@@ -34,6 +47,29 @@ pub async fn list_posts(
|
||||
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(
|
||||
connection_pool: &PgPool,
|
||||
n: i64,
|
||||
@@ -42,7 +78,8 @@ async fn get_posts(
|
||||
sqlx::query_as!(
|
||||
PostEntry,
|
||||
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
|
||||
LEFT JOIN users u ON p.author_id = u.user_id
|
||||
ORDER BY p.published_at DESC
|
||||
@@ -56,25 +93,106 @@ async fn get_posts(
|
||||
.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")
|
||||
.fetch_one(connection_pool)
|
||||
.await
|
||||
.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)]
|
||||
pub struct PostParams {
|
||||
pub struct OriginQueryParam {
|
||||
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(
|
||||
session: TypedSession,
|
||||
State(AppState {
|
||||
connection_pool, ..
|
||||
}): State<AppState>,
|
||||
Path(post_id): Path<Uuid>,
|
||||
Query(PostParams { origin }): Query<PostParams>,
|
||||
Query(OriginQueryParam { origin }): Query<OriginQueryParam>,
|
||||
) -> Result<Response, AppError> {
|
||||
if let Some(origin) = origin {
|
||||
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)
|
||||
.await
|
||||
.context(format!("Failed to fetch post #{}", post_id))
|
||||
.context(format!("Failed to fetch post #{}.", post_id))
|
||||
.map_err(AppError::unexpected_page)?
|
||||
{
|
||||
let post = post
|
||||
let post_html = post
|
||||
.to_html()
|
||||
.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())
|
||||
} else {
|
||||
Ok(not_found_html())
|
||||
@@ -116,7 +261,8 @@ async fn get_post_data(
|
||||
sqlx::query_as!(
|
||||
PostEntry,
|
||||
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
|
||||
LEFT JOIN users u ON p.author_id = u.user_id
|
||||
WHERE p.post_id = $1
|
||||
@@ -139,15 +285,17 @@ pub async fn load_more(
|
||||
}): State<AppState>,
|
||||
Query(LoadMoreParams { page }): Query<LoadMoreParams>,
|
||||
) -> Result<Response, AppError> {
|
||||
let offset = (page - 1) * NUM_PER_PAGE;
|
||||
let posts = get_posts(&connection_pool, NUM_PER_PAGE, Some(offset))
|
||||
let posts = get_posts_page(&connection_pool, page)
|
||||
.await
|
||||
.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(
|
||||
PostListTemplate {
|
||||
posts,
|
||||
next_page: if count as i64 == NUM_PER_PAGE {
|
||||
next_page: if page < max_page {
|
||||
Some(page + 1)
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -66,9 +66,8 @@ pub async fn subscribe(
|
||||
.context("Failed to commit the database transaction to store a new subscriber.")?;
|
||||
}
|
||||
|
||||
let template = MessageTemplate::Success {
|
||||
message: "You'll receive a confirmation email shortly.".to_string(),
|
||||
};
|
||||
let template =
|
||||
MessageTemplate::success("You'll receive a confirmation email shortly.".to_string());
|
||||
Ok(Html(template.render().unwrap()).into_response())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
use crate::{
|
||||
domain::SubscriberEmail,
|
||||
email_client::EmailClient,
|
||||
routes::AppError,
|
||||
routes::{AppError, not_found_html},
|
||||
startup::AppState,
|
||||
templates::{
|
||||
MessageTemplate, NotFoundTemplate, UnsubscribeConfirmTemplate, UnsubscribeTemplate,
|
||||
},
|
||||
templates::{MessageTemplate, UnsubscribeConfirmTemplate, UnsubscribeTemplate},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use askama::Template;
|
||||
@@ -14,7 +12,6 @@ use axum::{
|
||||
extract::{Query, State},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use sqlx::{Executor, PgPool};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -52,9 +49,9 @@ pub async fn post_unsubscribe(
|
||||
.await
|
||||
.context("Failed to send a confirmation email.")?;
|
||||
}
|
||||
let template = MessageTemplate::Success {
|
||||
message: "If you are a subscriber, you'll receive a confirmation link shortly.".into(),
|
||||
};
|
||||
let template = MessageTemplate::success(
|
||||
"If you are a subscriber, you'll receive a confirmation link shortly.".into(),
|
||||
);
|
||||
Ok(Html(template.render().unwrap()).into_response())
|
||||
}
|
||||
|
||||
@@ -124,11 +121,7 @@ pub async fn unsubscribe_confirm(
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
tracing::info!("Unsubscribe token is not tied to any confirmed user");
|
||||
Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Html(NotFoundTemplate.render().unwrap()),
|
||||
)
|
||||
.into_response())
|
||||
Ok(not_found_html())
|
||||
} else {
|
||||
tracing::info!("User successfully removed");
|
||||
Ok(Html(UnsubscribeConfirmTemplate.render().unwrap()).into_response())
|
||||
|
||||
336
src/routes/users.rs
Normal file
336
src/routes/users.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
use crate::authentication::AuthenticatedUser;
|
||||
use crate::routes::{join_error_messages, verify_password};
|
||||
use crate::session_state::TypedSession;
|
||||
use crate::templates::{MessageTemplate, UserEditTemplate};
|
||||
use crate::{
|
||||
authentication::Role,
|
||||
domain::{PostEntry, UserEntry},
|
||||
routes::{AppError, not_found_html},
|
||||
startup::AppState,
|
||||
templates::{HtmlTemplate, UserTemplate},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
Extension, Form,
|
||||
extract::{Path, State},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
pub async fn user_edit_form(
|
||||
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||
State(AppState {
|
||||
connection_pool, ..
|
||||
}): State<AppState>,
|
||||
) -> 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> {
|
||||
match fetch_user_data(&connection_pool, &username)
|
||||
.await
|
||||
.context("Failed to fetch user data.")?
|
||||
{
|
||||
Some(user) => {
|
||||
let posts = fetch_user_posts(&connection_pool, &user.user_id)
|
||||
.await
|
||||
.context("Could not fetch user posts.")?;
|
||||
let 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())
|
||||
}
|
||||
None => {
|
||||
tracing::error!(username = %username, "user not found");
|
||||
Ok(not_found_html().into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "Fetching user profile", skip_all)]
|
||||
async fn fetch_user_data(
|
||||
connection_pool: &PgPool,
|
||||
username: &str,
|
||||
) -> Result<Option<UserEntry>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
UserEntry,
|
||||
r#"
|
||||
SELECT user_id, username, full_name, role as "role: Role", member_since, bio
|
||||
FROM users
|
||||
WHERE username = $1
|
||||
"#,
|
||||
username
|
||||
)
|
||||
.fetch_optional(connection_pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "Fetching user posts", skip_all)]
|
||||
async fn fetch_user_posts(
|
||||
connection_pool: &PgPool,
|
||||
user_id: &Uuid,
|
||||
) -> Result<Vec<PostEntry>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
PostEntry,
|
||||
r#"
|
||||
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
|
||||
INNER JOIN users u ON p.author_id = u.user_id
|
||||
WHERE p.author_id = $1
|
||||
ORDER BY p.published_at DESC
|
||||
"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_all(connection_pool)
|
||||
.await
|
||||
}
|
||||
@@ -3,6 +3,8 @@ use std::result;
|
||||
use tower_sessions::{Session, session::Error};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::authentication::Role;
|
||||
|
||||
pub struct TypedSession(Session);
|
||||
|
||||
type Result<T> = result::Result<T, Error>;
|
||||
@@ -10,6 +12,7 @@ type Result<T> = result::Result<T, Error>;
|
||||
impl TypedSession {
|
||||
const USER_ID_KEY: &'static str = "user_id";
|
||||
const USERNAME_KEY: &'static str = "username";
|
||||
const ROLE_KEY: &'static str = "role";
|
||||
|
||||
pub async fn renew(&self) -> Result<()> {
|
||||
self.0.cycle_id().await
|
||||
@@ -31,6 +34,23 @@ impl TypedSession {
|
||||
self.0.get(Self::USERNAME_KEY).await
|
||||
}
|
||||
|
||||
pub async fn insert_role(&self, role: Role) -> Result<()> {
|
||||
self.0.insert(Self::ROLE_KEY, role).await
|
||||
}
|
||||
|
||||
pub async fn get_role(&self) -> Result<Option<Role>> {
|
||||
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) {
|
||||
self.0.clear().await;
|
||||
}
|
||||
|
||||
@@ -7,19 +7,15 @@ use axum::{
|
||||
http::Request,
|
||||
middleware,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
use reqwest::{StatusCode, header};
|
||||
use secrecy::ExposeSecret;
|
||||
use sqlx::{PgPool, postgres::PgPoolOptions};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::net::TcpListener;
|
||||
use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||
use tower_sessions::SessionManagerLayer;
|
||||
use tower_sessions_redis_store::{
|
||||
RedisStore,
|
||||
fred::prelude::{ClientLike, Config, Pool},
|
||||
};
|
||||
use tower_sessions_redis_store::{RedisStore, fred::prelude::Pool};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -46,18 +42,11 @@ impl Application {
|
||||
))
|
||||
.connect_lazy_with(configuration.database.with_db());
|
||||
let email_client = EmailClient::build(configuration.email_client).unwrap();
|
||||
let pool = Pool::new(
|
||||
Config::from_url(configuration.redis_uri.expose_secret())
|
||||
.expect("Failed to parse Redis URL string"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
6,
|
||||
)
|
||||
.unwrap();
|
||||
pool.connect();
|
||||
pool.wait_for_connect().await.unwrap();
|
||||
let redis_store = RedisStore::new(pool);
|
||||
let redis_store = configuration
|
||||
.kv_store
|
||||
.session_store()
|
||||
.await
|
||||
.context("Failed to acquire Redis session store.")?;
|
||||
let router = app(
|
||||
connection_pool,
|
||||
email_client,
|
||||
@@ -96,14 +85,24 @@ pub fn app(
|
||||
base_url,
|
||||
};
|
||||
let admin_routes = Router::new()
|
||||
.route("/subscribers", get(get_subscribers_page))
|
||||
.route("/subscribers/{subscriber_id}", delete(delete_subscriber))
|
||||
.route("/posts", get(get_posts_page_dashboard))
|
||||
.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));
|
||||
let auth_routes = Router::new()
|
||||
.route("/dashboard", get(admin_dashboard))
|
||||
.route("/password", post(change_password))
|
||||
.route("/newsletters", post(publish_newsletter))
|
||||
.route("/posts", post(create_post))
|
||||
.route("/posts/{post_id}", delete(delete_post))
|
||||
.route("/posts/{post_id}", put(update_post))
|
||||
.route("/logout", get(logout))
|
||||
.route("/subscribers", get(get_subscribers_page))
|
||||
.route("/subscribers/{subscriber_id}", delete(delete_subscriber))
|
||||
.route("/users/edit", get(user_edit_form).put(update_user))
|
||||
.nest("/admin", admin_routes)
|
||||
.layer(middleware::from_fn(require_auth));
|
||||
Router::new()
|
||||
.route("/", get(home))
|
||||
@@ -116,8 +115,13 @@ pub fn app(
|
||||
.route("/posts", get(list_posts))
|
||||
.route("/posts/load_more", get(load_more))
|
||||
.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("/favicon.ico", get(favicon))
|
||||
.nest("/admin", admin_routes)
|
||||
.merge(auth_routes)
|
||||
.nest_service("/assets", ServeDir::new("assets"))
|
||||
.layer(
|
||||
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
|
||||
|
||||
113
src/templates.rs
113
src/templates.rs
@@ -1,9 +1,11 @@
|
||||
use crate::{
|
||||
domain::{PostEntry, SubscriberEntry},
|
||||
authentication::AuthenticatedUser,
|
||||
domain::{CommentEntry, PostEntry, SubscriberEntry, UserEntry},
|
||||
routes::{AppError, DashboardStats},
|
||||
};
|
||||
use askama::Template;
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct HtmlTemplate<T>(pub T);
|
||||
@@ -21,31 +23,82 @@ where
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
pub enum MessageTemplate {
|
||||
#[template(path = "../templates/success.html")]
|
||||
Success { message: String },
|
||||
#[template(path = "../templates/error.html")]
|
||||
Error { message: String },
|
||||
#[template(path = "user/profile.html")]
|
||||
pub struct UserTemplate {
|
||||
pub user: UserEntry,
|
||||
pub session_user_id: Option<Uuid>,
|
||||
pub posts: Vec<PostEntry>,
|
||||
pub last_seen: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/500.html")]
|
||||
pub struct InternalErrorTemplate;
|
||||
#[template(path = "user/edit.html")]
|
||||
pub struct UserEditTemplate {
|
||||
pub user: UserEntry,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "message.html")]
|
||||
pub struct MessageTemplate {
|
||||
pub message: String,
|
||||
pub error: bool,
|
||||
}
|
||||
|
||||
impl MessageTemplate {
|
||||
pub fn success(message: String) -> Self {
|
||||
Self {
|
||||
message,
|
||||
error: false,
|
||||
}
|
||||
}
|
||||
pub fn error(message: String) -> Self {
|
||||
Self {
|
||||
message,
|
||||
error: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/login.html")]
|
||||
pub struct LoginTemplate;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/dashboard.html")]
|
||||
#[template(path = "dashboard/dashboard.html")]
|
||||
pub struct DashboardTemplate {
|
||||
pub username: String,
|
||||
pub user: AuthenticatedUser,
|
||||
pub idempotency_key_1: String,
|
||||
pub idempotency_key_2: String,
|
||||
pub stats: DashboardStats,
|
||||
pub subscribers: Vec<SubscriberEntry>,
|
||||
pub current_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)]
|
||||
@@ -53,27 +106,43 @@ pub struct DashboardTemplate {
|
||||
pub struct HomeTemplate;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "posts.html")]
|
||||
#[template(path = "posts/list.html")]
|
||||
pub struct PostsTemplate {
|
||||
pub posts: Vec<PostEntry>,
|
||||
pub next_page: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "posts.html", block = "posts")]
|
||||
#[template(path = "posts/list.html", block = "posts")]
|
||||
pub struct PostListTemplate {
|
||||
pub posts: Vec<PostEntry>,
|
||||
pub next_page: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "post.html")]
|
||||
#[template(path = "posts/page.html")]
|
||||
pub struct PostTemplate {
|
||||
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 = "dashboard.html", block = "subs")]
|
||||
#[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)]
|
||||
#[template(path = "dashboard/subscribers/list.html", block = "subs")]
|
||||
pub struct SubListTemplate {
|
||||
pub subscribers: Vec<SubscriberEntry>,
|
||||
pub current_page: i64,
|
||||
@@ -81,19 +150,25 @@ pub struct SubListTemplate {
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "confirm.html")]
|
||||
#[template(path = "subscribe/confirm.html")]
|
||||
pub struct ConfirmTemplate;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "404.html")]
|
||||
pub struct NotFoundTemplate;
|
||||
pub enum ErrorTemplate {
|
||||
#[template(path = "error/404.html")]
|
||||
NotFound,
|
||||
#[template(path = "error/500.html")]
|
||||
InternalServer,
|
||||
#[template(path = "error/403.html")]
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "unsubscribe_confirm.html")]
|
||||
#[template(path = "unsubscribe/confirm.html")]
|
||||
pub struct UnsubscribeConfirmTemplate;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "unsubscribe.html")]
|
||||
#[template(path = "unsubscribe/form.html")]
|
||||
pub struct UnsubscribeTemplate;
|
||||
|
||||
#[derive(Template)]
|
||||
|
||||
@@ -1,110 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description" content="zero2prod newsletter" />
|
||||
<meta name="keywords" content="newsletter, rust, axum, htmx" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>
|
||||
{% block title %}{% endblock %}
|
||||
- zero2prod
|
||||
</title>
|
||||
<link href="/assets/css/main.css" rel="stylesheet" />
|
||||
<script src="/assets/js/htmx.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<header class="sticky top-0 bg-white/95 backdrop-blur-md shadow-sm border-b border-gray-200 z-40">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="flex items-center space-x-2 group">
|
||||
<div class="w-6 h-6 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-all duration-200">
|
||||
<svg class="w-4 h-4 text-white group-hover:scale-110 transition-transform duration-200"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
zero2prod
|
||||
</span>
|
||||
</a>
|
||||
<nav class="hidden md:flex items-center">
|
||||
<a href="/posts"
|
||||
class="text-gray-700 hover:text-blue-600 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 relative group">
|
||||
Posts
|
||||
<span class="absolute inset-x-4 bottom-0 h-0.5 bg-blue-600 scale-x-0 group-hover:scale-x-100 transition-transform duration-200"></span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/admin/dashboard"
|
||||
hx-boost="true"
|
||||
class="hidden md:flex bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:scale-105">
|
||||
Dashboard
|
||||
</a>
|
||||
<button class="md:hidden p-2 text-gray-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors duration-200"
|
||||
onclick="toggleMobileMenu()">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mobile-menu"
|
||||
class="hidden md:hidden border-t border-gray-100 pb-4 pt-4">
|
||||
<nav class="flex flex-col space-y-2">
|
||||
<a href="/posts"
|
||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||
Posts
|
||||
</a>
|
||||
<a href="/admin/dashboard"
|
||||
hx-boost="true"
|
||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||
Dashboard
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex flex-1">
|
||||
<main class="flex-1 lg:ml-0 flex flex-col py-8 px-4 sm:px-6 lg:px-8">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center">
|
||||
<div class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
|
||||
target="_blank"
|
||||
class="text-sm text-gray-500 hover:text-gray-900 transition-colors flex items-center">
|
||||
Gitea
|
||||
<svg class="ml-1 h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<span class="text-gray-300">•</span>
|
||||
<a href="/unsubscribe"
|
||||
class="text-sm text-gray-500 hover:text-gray-900 transition-colors">Unsubscribe</a>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description" content="zero2prod newsletter" />
|
||||
<meta name="keywords" content="newsletter, rust, axum, htmx" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>
|
||||
{% block title %}{% endblock %}
|
||||
- zero2prod
|
||||
</title>
|
||||
<link href="/assets/css/main.css" rel="stylesheet" />
|
||||
<script src="/assets/js/htmx.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<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>
|
||||
<div class="mt-4 md:mt-0">
|
||||
<p class="text-xs text-gray-500">Built with ❤️ using Rust, axum & htmx</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
<header class="sticky top-0 bg-white/95 backdrop-blur-md shadow-sm border-b border-gray-200 z-40">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="flex items-center space-x-2 group">
|
||||
<div class="w-6 h-6 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-all duration-200">
|
||||
<svg class="w-4 h-4 text-white group-hover:scale-110 transition-transform duration-200"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
zero2prod
|
||||
</span>
|
||||
</a>
|
||||
<nav class="hidden md:flex items-center">
|
||||
<a href="/posts"
|
||||
class="text-gray-700 hover:text-blue-600 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 relative group">
|
||||
Posts
|
||||
<span class="absolute inset-x-4 bottom-0 h-0.5 bg-blue-600 scale-x-0 group-hover:scale-x-100 transition-transform duration-200"></span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/dashboard"
|
||||
hx-boost="true"
|
||||
class="hidden md:flex bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:scale-105">
|
||||
Dashboard
|
||||
</a>
|
||||
<button class="md:hidden p-2 text-gray-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors duration-200"
|
||||
onclick="toggleMobileMenu()">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mobile-menu"
|
||||
class="hidden md:hidden border-t border-gray-100 pb-4 pt-4">
|
||||
<nav class="flex flex-col space-y-2">
|
||||
<a href="/posts"
|
||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||
Posts
|
||||
</a>
|
||||
<a href="/dashboard"
|
||||
hx-boost="true"
|
||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||
Dashboard
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex flex-1">
|
||||
<main class="flex-1 lg:ml-0 flex flex-col py-8 px-4 sm:px-6 lg:px-8">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center">
|
||||
<div class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
|
||||
target="_blank"
|
||||
class="text-sm text-gray-500 hover:text-gray-900 transition-colors flex items-center">
|
||||
Gitea
|
||||
<svg class="ml-1 h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<span class="text-gray-300">•</span>
|
||||
<a href="/unsubscribe"
|
||||
class="text-sm text-gray-500 hover:text-gray-900 transition-colors">Unsubscribe</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 md:mt-0">
|
||||
<p class="text-xs text-gray-500">Built with ❤️ using Rust, axum & htmx</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
<script>
|
||||
function toggleMobileMenu() {
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
menu.classList.toggle('hidden');
|
||||
}
|
||||
function toggleMobileMenu() {
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
menu.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
<div class="min-w-6/12 mx-auto p-4 sm:p-6">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p class="mt-2 text-gray-600">
|
||||
Connected as <span class="font-bold">{{ username }}</span>
|
||||
</p>
|
||||
<button hx-get="/admin/logout"
|
||||
type="submit"
|
||||
class="flex items-center text-sm text-gray-500 hover:text-red-600 transition-colors cursor-pointer gap-1 mt-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center shrink-0">
|
||||
<svg class="w-6 h-6 text-blue-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-500">Subscribers</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">{{ stats.subscribers }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center shrink-0">
|
||||
<svg class="w-6 h-6 text-purple-600 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-500">Posts</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">{{ stats.posts }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center shrink-0">
|
||||
<svg class="w-6 h-6 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-500">Notifications</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">{{ stats.notifications_sent }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center shrink-0">
|
||||
<svg class="w-6 h-6 text-orange-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-500">Open rate</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">{{ stats.formatted_rate() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8">
|
||||
<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-blue-600 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
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" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
Subscribers management
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">View and manage your subscribers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subscribers-list" class="p-6 space-y-4">
|
||||
{% block subs %}
|
||||
{% if subscribers.is_empty() %}
|
||||
<div class="bg-gray-50 rounded-lg p-8 border-2 border-dashed border-gray-300 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 data available</h3>
|
||||
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for subscriber in subscribers %}
|
||||
{% include "sub_card_fragment.html" %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<div class="flex items-center justify-center space-x-2">
|
||||
<button hx-get="/admin/subscribers?page={{ current_page - 1 }}"
|
||||
hx-target="#subscribers-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 current_page <= 1 %}opacity-50 cursor-not-allowed{% endif %}"
|
||||
{% if current_page <= 1 %}disabled{% endif %}><</button>
|
||||
<span class="px-3 py-1 text-sm font-medium text-gray-700 bg-gray-100 rounded-md">Page: {{ current_page }}</span>
|
||||
<button hx-get="/admin/subscribers?page={{ current_page + 1 }}"
|
||||
hx-target="#subscribers-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 current_page >= max_page %}opacity-50 cursor-not-allowed{% endif %}"
|
||||
{% if current_page >= max_page %}disabled{% endif %}>></button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<svg class="w-5 h-5 text-purple-600 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
|
||||
</svg>
|
||||
Write a new post
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Publish a new post online. Subscribers will be notified.</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form hx-post="/admin/posts"
|
||||
hx-target="#post-messages"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4">
|
||||
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_1 }}" />
|
||||
<div>
|
||||
<label for="post-title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
||||
<input type="text"
|
||||
id="post-title"
|
||||
name="title"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="post-content"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Markdown content</label>
|
||||
<textarea id="post-content"
|
||||
name="content"
|
||||
rows="6"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"></textarea>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full bg-purple-600 text-white hover:bg-purple-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
||||
<svg class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
|
||||
</svg>
|
||||
Publish
|
||||
</button>
|
||||
<div id="post-messages" class="mt-4"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<svg class="w-5 h-5 text-blue-600 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
||||
</svg>
|
||||
Send an email
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Contact your subscribers directly.</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form hx-post="/admin/newsletters"
|
||||
hx-target="#newsletter-messages"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4">
|
||||
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_2 }}" />
|
||||
<div>
|
||||
<label for="newsletter-title"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Subject</label>
|
||||
<input type="text"
|
||||
id="newsletter-title"
|
||||
name="title"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="newsletter-html"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">HTML content</label>
|
||||
<textarea id="newsletter-html"
|
||||
name="html"
|
||||
rows="6"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="newsletter-text"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Text content</label>
|
||||
<textarea id="newsletter-text"
|
||||
name="text"
|
||||
rows="6"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
||||
<svg class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
Send
|
||||
</button>
|
||||
<div id="newsletter-messages" class="mt-4"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 lg:col-span-2">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<svg class="w-5 h-5 text-green-600 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Change your password
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Set a new password for your account.</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form hx-post="/admin/password"
|
||||
hx-target="#password-messages"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4">
|
||||
<div>
|
||||
<label for="current_password"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Current password</label>
|
||||
<input type="password"
|
||||
id="current_password"
|
||||
name="current_password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="new_password"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">New password</label>
|
||||
<input type="password"
|
||||
id="new_password"
|
||||
name="new_password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="new_password_check"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Confirm new password</label>
|
||||
<input type="password"
|
||||
id="new_password_check"
|
||||
name="new_password_check"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full bg-green-600 text-white hover:bg-green-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
||||
<svg class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Update password
|
||||
</button>
|
||||
<div id="password-messages" class="mt-4"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
52
templates/dashboard/comments/card.html
Normal file
52
templates/dashboard/comments/card.html
Normal 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>
|
||||
59
templates/dashboard/comments/list.html
Normal file
59
templates/dashboard/comments/list.html
Normal 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 %}><</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 %}>></button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
59
templates/dashboard/dashboard.html
Normal file
59
templates/dashboard/dashboard.html
Normal file
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% 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">Dashboard</h1>
|
||||
<p class="mt-2 text-gray-600 items-start">
|
||||
<span>Connected as
|
||||
<a href="/users/{{ user.username }}"
|
||||
class="hover:text-blue-600 hover:underline font-bold">{{ user.username }}</a></span>
|
||||
{% if user.is_admin() %}
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
admin
|
||||
</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<button hx-get="/logout"
|
||||
type="submit"
|
||||
class="flex items-center text-sm text-gray-500 hover:text-red-600 transition-colors cursor-pointer gap-1 mt-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% include "stats.html" %}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{% include "publish.html" %}
|
||||
{% include "send_email.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 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 %}
|
||||
49
templates/dashboard/posts/card.html
Normal file
49
templates/dashboard/posts/card.html
Normal 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>
|
||||
59
templates/dashboard/posts/list.html
Normal file
59
templates/dashboard/posts/list.html
Normal 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 %}><</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 %}>></button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
52
templates/dashboard/publish.html
Normal file
52
templates/dashboard/publish.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<svg class="w-5 h-5 text-purple-600 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6"/>
|
||||
</svg>
|
||||
Write a new post
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Publish a new post online. Subscribers will be notified.</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form hx-post="/posts"
|
||||
hx-target="#post-messages"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4">
|
||||
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_1 }}"/>
|
||||
<div>
|
||||
<label for="post-title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
||||
<input type="text"
|
||||
id="post-title"
|
||||
name="title"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="post-content"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Markdown content</label>
|
||||
<textarea id="post-content"
|
||||
name="content"
|
||||
rows="6"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"></textarea>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full bg-purple-600 text-white hover:bg-purple-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
||||
<svg class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6"/>
|
||||
</svg>
|
||||
Publish
|
||||
</button>
|
||||
<div id="post-messages" class="mt-4"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
62
templates/dashboard/send_email.html
Normal file
62
templates/dashboard/send_email.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<svg class="w-5 h-5 text-blue-600 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"/>
|
||||
</svg>
|
||||
Send an email
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Contact your subscribers directly.</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form hx-post="/newsletters"
|
||||
hx-target="#newsletter-messages"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4">
|
||||
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_2 }}"/>
|
||||
<div>
|
||||
<label for="newsletter-title"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Subject</label>
|
||||
<input type="text"
|
||||
id="newsletter-title"
|
||||
name="title"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="newsletter-html"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">HTML content</label>
|
||||
<textarea id="newsletter-html"
|
||||
name="html"
|
||||
rows="6"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="newsletter-text"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Text content</label>
|
||||
<textarea id="newsletter-text"
|
||||
name="text"
|
||||
rows="6"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
||||
<svg class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
|
||||
</svg>
|
||||
Send
|
||||
</button>
|
||||
<div id="newsletter-messages" class="mt-4"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
73
templates/dashboard/stats.html
Normal file
73
templates/dashboard/stats.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center shrink-0">
|
||||
<svg class="w-6 h-6 text-blue-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-500">Subscribers</p>
|
||||
<p class="text-2xl font-semibold text-gray-900" id="subscribers-count">{{ stats.subscribers }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center shrink-0">
|
||||
<svg class="w-6 h-6 text-purple-600 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-500">Posts</p>
|
||||
<p class="text-2xl font-semibold text-gray-900" id="posts-count">{{ stats.posts }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center shrink-0">
|
||||
<svg class="w-6 h-6 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-500">Notifications</p>
|
||||
<p class="text-2xl font-semibold text-gray-900" id="notifications-sent">{{ stats.notifications_sent }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center shrink-0">
|
||||
<svg class="w-6 h-6 text-orange-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-500">Open rate</p>
|
||||
<p class="text-2xl font-semibold text-gray-900" id="open-rate">{{ stats.formatted_rate() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
34
templates/dashboard/subscribers/card.html
Normal file
34
templates/dashboard/subscribers/card.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<div id="subscriber-{{ subscriber.id }}"
|
||||
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 items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm truncate font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1 break-all">
|
||||
{{ 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>
|
||||
<button hx-delete="/admin/subscribers/{{ subscriber.id }}"
|
||||
hx-target="#subscriber-{{ subscriber.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this subscriber?"
|
||||
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>
|
||||
63
templates/dashboard/subscribers/list.html
Normal file
63
templates/dashboard/subscribers/list.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<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-blue-600 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
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"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
Subscribers ({{ count }})
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">View and manage your subscribers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subscribers-list">
|
||||
{% block subs %}
|
||||
{% if subscribers.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 subscribers 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 subscriber in subscribers %}
|
||||
{% include "dashboard/subscribers/card.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex items-center justify-center space-x-2 mb-6">
|
||||
<button hx-get="/admin/subscribers?page={{ current_page - 1 }}"
|
||||
hx-target="#subscribers-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 current_page <= 1 %}opacity-50 cursor-not-allowed{% endif %}"
|
||||
{% if current_page <= 1 %}disabled{% endif %}><</button>
|
||||
<span class="px-3 py-1 text-sm font-medium text-gray-700 bg-gray-100 rounded-md">Page: {{ current_page }}</span>
|
||||
<button hx-get="/admin/subscribers?page={{ current_page + 1 }}"
|
||||
hx-target="#subscribers-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 current_page >= max_page %}opacity-50 cursor-not-allowed{% endif %}"
|
||||
{% if current_page>= max_page %}disabled{% endif %}>>
|
||||
</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
47
templates/dashboard/users/card.html
Normal file
47
templates/dashboard/users/card.html
Normal 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>
|
||||
78
templates/dashboard/users/form.html
Normal file
78
templates/dashboard/users/form.html
Normal 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>
|
||||
42
templates/dashboard/users/list.html
Normal file
42
templates/dashboard/users/list.html
Normal 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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd">
|
||||
</path>
|
||||
</svg>
|
||||
<span class="font-medium">{{ message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
39
templates/error/403.html
Normal file
39
templates/error/403.html
Normal 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 %}
|
||||
@@ -1,150 +1,151 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@layer utilities {
|
||||
.htmx-indicator {
|
||||
@apply hidden;
|
||||
}
|
||||
.htmx-indicator {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
@apply inline-flex items-center ml-2;
|
||||
}
|
||||
.htmx-request .htmx-indicator {
|
||||
@apply inline-flex items-center ml-2;
|
||||
}
|
||||
|
||||
#load-more .htmx-indicator {
|
||||
@apply block;
|
||||
}
|
||||
#load-more .htmx-indicator {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.htmx-request .continue-text {
|
||||
@apply hidden;
|
||||
}
|
||||
.htmx-request .continue-text {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.prose-compact {
|
||||
@apply prose prose-slate max-w-none;
|
||||
.prose-compact {
|
||||
@apply prose prose-slate max-w-none;
|
||||
|
||||
--tw-prose-body: theme(colors.gray.700);
|
||||
--tw-prose-headings: theme(colors.gray.900);
|
||||
--tw-prose-links: theme(colors.blue.600);
|
||||
--tw-prose-code: theme(colors.gray.800);
|
||||
}
|
||||
--tw-prose-body: theme(colors.gray.700);
|
||||
--tw-prose-headings: theme(colors.gray.900);
|
||||
--tw-prose-links: theme(colors.blue.600);
|
||||
--tw-prose-code: theme(colors.gray.800);
|
||||
}
|
||||
|
||||
.prose-compact p {
|
||||
@apply mb-2 mt-0;
|
||||
}
|
||||
.prose-compact p {
|
||||
@apply mb-2 mt-0;
|
||||
}
|
||||
|
||||
.prose-compact h1 {
|
||||
@apply pb-2 mb-3 border-b-2 border-gray-100;
|
||||
}
|
||||
.prose-compact h1 {
|
||||
@apply pb-2 mb-3 border-b-2 border-gray-200;
|
||||
}
|
||||
|
||||
.prose-compact h2 {
|
||||
@apply mt-4 pb-2 mb-3 border-b-2 border-gray-100 font-semibold;
|
||||
}
|
||||
.prose-compact h2 {
|
||||
@apply mt-4 pb-2 mb-3 border-b-2 border-gray-200 font-semibold;
|
||||
}
|
||||
|
||||
.prose-compact h3 {
|
||||
@apply mt-3 mb-1;
|
||||
}
|
||||
.prose-compact h3 {
|
||||
@apply mt-3 mb-1;
|
||||
}
|
||||
|
||||
.prose-compact h4,
|
||||
.prose-compact h5,
|
||||
.prose-compact h6 {
|
||||
@apply mt-2 mb-1;
|
||||
}
|
||||
.prose-compact h4,
|
||||
.prose-compact h5,
|
||||
.prose-compact h6 {
|
||||
@apply mt-2 mb-1;
|
||||
}
|
||||
|
||||
.prose-compact ul,
|
||||
.prose-compact ol {
|
||||
@apply my-2 space-y-0;
|
||||
}
|
||||
.prose-compact ul,
|
||||
.prose-compact ol {
|
||||
@apply my-2 space-y-0;
|
||||
}
|
||||
|
||||
.prose-compact li {
|
||||
@apply my-0;
|
||||
}
|
||||
.prose-compact li {
|
||||
@apply my-0;
|
||||
}
|
||||
|
||||
.prose-compact blockquote {
|
||||
@apply my-3 py-2;
|
||||
}
|
||||
.prose-compact blockquote {
|
||||
@apply my-3 py-2;
|
||||
}
|
||||
|
||||
.prose-compact img {
|
||||
@apply m-0 align-top;
|
||||
}
|
||||
.prose-compact img {
|
||||
@apply m-0 align-top;
|
||||
}
|
||||
|
||||
.prose-compact a:has(img) {
|
||||
@apply no-underline border-0 inline-block align-top;
|
||||
}
|
||||
.prose-compact a:has(img) {
|
||||
@apply no-underline border-0 inline-block align-top;
|
||||
}
|
||||
|
||||
.prose-compact a img {
|
||||
@apply inline-block align-top;
|
||||
}
|
||||
.prose-compact a img {
|
||||
@apply inline-block align-top;
|
||||
}
|
||||
|
||||
.prose-compact :not(pre) > code {
|
||||
@apply bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono font-normal;
|
||||
}
|
||||
.prose-compact :not(pre) > code {
|
||||
@apply bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono font-normal;
|
||||
}
|
||||
|
||||
.prose-compact :not(pre) > code::before,
|
||||
.prose-compact :not(pre) > code::after {
|
||||
content: none !important;
|
||||
}
|
||||
.prose-compact :not(pre) > code::before,
|
||||
.prose-compact :not(pre) > code::after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
.prose-compact pre {
|
||||
@apply my-3 p-4 bg-gray-100 text-gray-800 rounded-sm overflow-x-auto border border-gray-200;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
width: 0;
|
||||
min-width: 100%;
|
||||
}
|
||||
.prose-compact pre {
|
||||
@apply my-3 p-4 bg-gray-100 text-gray-800 rounded-sm overflow-x-auto border border-gray-200;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
width: 0;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.prose-compact pre code {
|
||||
@apply bg-transparent text-gray-800 p-0 rounded-none;
|
||||
}
|
||||
.prose-compact pre code {
|
||||
@apply bg-transparent text-gray-800 p-0 rounded-none;
|
||||
}
|
||||
|
||||
.prose-compact pre code::before,
|
||||
.prose-compact pre code::after {
|
||||
content: none !important;
|
||||
}
|
||||
.prose-compact pre code::before,
|
||||
.prose-compact pre code::after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
.prose-compact table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
@apply my-6;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.prose-compact table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
@apply my-6;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prose-compact table thead,
|
||||
.prose-compact table tbody {
|
||||
display: table;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.prose-compact table thead,
|
||||
.prose-compact table tbody {
|
||||
display: table;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.prose-compact table tr {
|
||||
display: table-row;
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #c6cbd1;
|
||||
}
|
||||
.prose-compact table tr {
|
||||
display: table-row;
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #c6cbd1;
|
||||
}
|
||||
|
||||
.prose-compact table tr:nth-child(2n) {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
.prose-compact table tr:nth-child(2n) {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
.prose-compact table th,
|
||||
.prose-compact table td {
|
||||
display: table-cell;
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #dfe2e5;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
.prose-compact table th,
|
||||
.prose-compact table td {
|
||||
display: table-cell;
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #dfe2e5;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prose-compact table th {
|
||||
font-weight: 600;
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
.prose-compact table th {
|
||||
font-weight: 600;
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
}
|
||||
|
||||
14
templates/message.html
Normal file
14
templates/message.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<div class="{% if self.error %}bg-red-50 border border-red-200 text-red-700{% else %}bg-green-50 border border-green-200 text-green-700{% endif %} px-4 py-3 rounded-md">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
{% if self.error %}
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd">
|
||||
</path>
|
||||
{% else %}
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd">
|
||||
</path>
|
||||
{% endif %}
|
||||
</svg>
|
||||
<span class="font-medium">{{ message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,46 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ post.title }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<article>
|
||||
<header class="pb-4 mb-2 border-b-2 border-gray-300 border-dashed">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-2">
|
||||
<svg class="w-4 h-4 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium">{{ post.author.as_deref().unwrap_or("Unknown") }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<time datetime="{{ post.published_at }}">
|
||||
{{ post.formatted_date() }}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="prose-compact">{{ post.content | safe }}</div>
|
||||
</article>
|
||||
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
|
||||
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
|
||||
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
|
||||
<a href="/#newsletter-signup"
|
||||
class="inline-block bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors">
|
||||
Subscribe
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,41 +0,0 @@
|
||||
<article class="bg-white rounded-lg shadow-md border border-gray-200 hover:shadow-lg transition-shadow duration-200">
|
||||
<a href="/posts/{{ post.post_id }}"
|
||||
class="block p-6 hover:bg-gray-50 transition-colors duration-200">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{ post.title }}</h2>
|
||||
<div class="flex items-center text-sm text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<time datetime="{{ post.published_at }}">
|
||||
{{ post.formatted_date() }}
|
||||
</time>
|
||||
</div>
|
||||
<span class="mx-2">•</span>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span>{{ post.author.as_deref().unwrap_or("Unknown") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
<svg class="w-5 h-5 text-gray-400 group-hover:text-blue-600 transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
49
templates/posts/card.html
Normal file
49
templates/posts/card.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<article class="bg-white rounded-lg shadow-md border border-gray-200 hover:shadow-lg transition-shadow duration-200">
|
||||
<div class="block p-6 hover:bg-gray-50 transition-colors duration-200">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<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>
|
||||
</a>
|
||||
<div class="flex items-center text-sm text-gray-500 mb-1">
|
||||
<svg class="w-4 h-4 mr-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<time datetime="{{ post.published_at }}">
|
||||
{{ post.formatted_date() }}
|
||||
</time>
|
||||
</div>
|
||||
<div class="flex items-center text-sm text-gray-500">
|
||||
<svg class="w-4 h-4 mr-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<a href="/users/{{ post.author }}"
|
||||
class="hover:text-blue-600 hover:underline">
|
||||
{% if let Some(full_name) = post.full_name %}
|
||||
{{ full_name }}
|
||||
{% else %}
|
||||
{{ post.author }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4">
|
||||
<svg class="w-5 h-5 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
39
templates/posts/comments/card.html
Normal file
39
templates/posts/comments/card.html
Normal 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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user