Skip to content

Commit fd182fe

Browse files
author
zerox80
committed
feat: Implement initial CMS frontend with core components, page and post management forms.
1 parent feacb2d commit fd182fe

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+3997
-475
lines changed

1_backend-test.txt

Lines changed: 3251 additions & 0 deletions
Large diffs are not rendered by default.

2_frontend-test.txt

Lines changed: 342 additions & 0 deletions
Large diffs are not rendered by default.

backend/src/auth.rs

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,23 @@
1919
//! ```
2020
2121
use axum::{
22+
extract::FromRef,
2223
extract::FromRequestParts,
2324
http::{
2425
header::{AUTHORIZATION, SET_COOKIE},
2526
request::Parts,
2627
HeaderMap, HeaderValue, StatusCode,
2728
},
2829
Json,
29-
extract::FromRef,
3030
};
3131
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
3232
use chrono::{Duration, Utc};
3333
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
34-
use std::sync::LazyLock;
3534
use serde::{Deserialize, Serialize};
36-
use std::sync::OnceLock;
3735
use std::collections::HashSet;
3836
use std::env;
37+
use std::sync::LazyLock;
38+
use std::sync::OnceLock;
3939
use time::{Duration as TimeDuration, OffsetDateTime};
4040

4141
use crate::db::{self, DbPool};
@@ -46,9 +46,8 @@ pub static JWT_SECRET: OnceLock<String> = OnceLock::new();
4646

4747
/// Global storage for the JWT decoding key.
4848
/// Derived from JWT_SECRET once it's initialized.
49-
pub static DECODING_KEY: LazyLock<DecodingKey> = LazyLock::new(|| {
50-
DecodingKey::from_secret(get_jwt_secret().as_bytes())
51-
});
49+
pub static DECODING_KEY: LazyLock<DecodingKey> =
50+
LazyLock::new(|| DecodingKey::from_secret(get_jwt_secret().as_bytes()));
5251

5352
/// List of known placeholder secrets that must not be used in production.
5453
/// These are common defaults found in example configurations.
@@ -391,18 +390,22 @@ where
391390

392391
// Check if token is blacklisted
393392
let pool = DbPool::from_ref(state);
394-
let is_blacklisted = crate::repositories::token_blacklist::is_token_blacklisted(&pool, &token)
395-
.await
396-
.map_err(|e| {
397-
tracing::error!("Database error checking token blacklist: {}", e);
398-
(
399-
StatusCode::INTERNAL_SERVER_ERROR,
400-
"Internal server error".to_string(),
401-
)
402-
})?;
393+
let is_blacklisted =
394+
crate::repositories::token_blacklist::is_token_blacklisted(&pool, &token)
395+
.await
396+
.map_err(|e| {
397+
tracing::error!("Database error checking token blacklist: {}", e);
398+
(
399+
StatusCode::INTERNAL_SERVER_ERROR,
400+
"Internal server error".to_string(),
401+
)
402+
})?;
403403

404404
if is_blacklisted {
405-
return Err((StatusCode::UNAUTHORIZED, "Token has been revoked".to_string()));
405+
return Err((
406+
StatusCode::UNAUTHORIZED,
407+
"Token has been revoked".to_string(),
408+
));
406409
}
407410

408411
Ok(claims)
@@ -563,7 +566,6 @@ fn parse_bearer_token(value: &str) -> Option<String> {
563566
None
564567
}
565568

566-
567569
/// Optional Claims extractor for endpoints that support both authenticated and anonymous access.
568570
///
569571
/// If a valid token is provided, it extracts the claims.
@@ -596,18 +598,22 @@ where
596598

597599
// Check if token is blacklisted
598600
let pool = DbPool::from_ref(state);
599-
let is_blacklisted = crate::repositories::token_blacklist::is_token_blacklisted(&pool, &token)
600-
.await
601-
.map_err(|e| {
602-
tracing::error!("Database error checking token blacklist: {}", e);
603-
(
604-
StatusCode::INTERNAL_SERVER_ERROR,
605-
"Internal server error".to_string(),
606-
)
607-
})?;
601+
let is_blacklisted =
602+
crate::repositories::token_blacklist::is_token_blacklisted(&pool, &token)
603+
.await
604+
.map_err(|e| {
605+
tracing::error!("Database error checking token blacklist: {}", e);
606+
(
607+
StatusCode::INTERNAL_SERVER_ERROR,
608+
"Internal server error".to_string(),
609+
)
610+
})?;
608611

609612
if is_blacklisted {
610-
return Err((StatusCode::UNAUTHORIZED, "Token has been revoked".to_string()));
613+
return Err((
614+
StatusCode::UNAUTHORIZED,
615+
"Token has been revoked".to_string(),
616+
));
611617
}
612618

613619
Ok(OptionalClaims(Some(claims)))

backend/src/bin/export_content.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
2-
31
/**
42
* Content Export Utility
53
*
@@ -32,7 +30,6 @@
3230
* - Handles database errors safely
3331
* - Uses proper error handling for file operations
3432
*/
35-
3633
use std::{env, fs, path::Path};
3734

3835
use anyhow::{Context, Result};
@@ -169,7 +166,6 @@ struct ExportBundle {
169166

170167
#[tokio::main]
171168
async fn main() -> Result<()> {
172-
173169
dotenv::dotenv().ok();
174170

175171
let args: Vec<String> = env::args().collect();

backend/src/bin/import_content.rs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
2-
31
/**
42
* Content Import Utility
53
*
@@ -39,7 +37,6 @@
3937
* - Database connection or transaction errors
4038
* - Data validation failures
4139
*/
42-
4340
use std::{env, fs, path::Path};
4441

4542
use anyhow::{anyhow, Context, Result};
@@ -51,7 +48,6 @@ use linux_tutorial_backend::db;
5148

5249
#[derive(Debug, Deserialize)]
5350
struct SiteContentImport {
54-
5551
section: String,
5652

5753
content: Value,
@@ -62,7 +58,6 @@ struct SiteContentImport {
6258

6359
#[derive(Debug, Deserialize)]
6460
struct SitePageImport {
65-
6661
id: String,
6762

6863
slug: String,
@@ -92,7 +87,6 @@ struct SitePageImport {
9287

9388
#[derive(Debug, Deserialize)]
9489
struct SitePostImport {
95-
9690
id: String,
9791

9892
page_id: String,
@@ -120,7 +114,6 @@ struct SitePostImport {
120114

121115
#[derive(Debug, Deserialize)]
122116
struct ImportBundle {
123-
124117
site_content: Vec<SiteContentImport>,
125118

126119
pages: Vec<SitePageImport>,
@@ -130,7 +123,6 @@ struct ImportBundle {
130123

131124
#[tokio::main]
132125
async fn main() -> Result<()> {
133-
134126
dotenv::dotenv().ok();
135127

136128
let args: Vec<String> = env::args().collect();
@@ -178,9 +170,7 @@ async fn import_site_content(
178170
tx: &mut Transaction<'_, Sqlite>,
179171
items: &[SiteContentImport],
180172
) -> Result<()> {
181-
182173
for item in items {
183-
184174
let serialized = serde_json::to_string(&item.content)
185175
.context("Failed to serialize site_content entry")?;
186176

@@ -203,9 +193,7 @@ async fn import_site_pages(
203193
tx: &mut Transaction<'_, Sqlite>,
204194
items: &[SitePageImport],
205195
) -> Result<()> {
206-
207196
for item in items {
208-
209197
let hero_serialized =
210198
serde_json::to_string(&item.hero).context("Failed to serialize page hero JSON")?;
211199
let layout_serialized =
@@ -241,9 +229,7 @@ async fn import_site_posts(
241229
tx: &mut Transaction<'_, Sqlite>,
242230
items: &[SitePostImport],
243231
) -> Result<()> {
244-
245232
for item in items {
246-
247233
sqlx::query(
248234
"INSERT INTO site_posts (id, page_id, title, slug, excerpt, content_markdown, is_published, published_at, order_index, created_at, updated_at) \
249235
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) \

backend/src/db.rs

Lines changed: 18 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -125,14 +125,6 @@ pub async fn create_pool() -> Result<DbPool, sqlx::Error> {
125125
Ok(pool)
126126
}
127127

128-
129-
130-
131-
132-
133-
134-
135-
136128
async fn ensure_site_page_schema(pool: &DbPool) -> Result<(), sqlx::Error> {
137129
let mut tx = pool.begin().await?;
138130

@@ -210,7 +202,6 @@ async fn ensure_site_page_schema(pool: &DbPool) -> Result<(), sqlx::Error> {
210202
async fn apply_core_migrations(
211203
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
212204
) -> Result<(), sqlx::Error> {
213-
214205
sqlx::query(
215206
r#"
216207
CREATE TABLE IF NOT EXISTS users (
@@ -398,8 +389,6 @@ async fn apply_core_migrations(
398389
Ok(())
399390
}
400391

401-
402-
403392
async fn seed_site_content_tx(
404393
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
405394
) -> Result<(), sqlx::Error> {
@@ -482,14 +471,14 @@ fn default_site_content() -> Vec<(&'static str, serde_json::Value)> {
482471
"target": { "type": "section", "value": "home" }
483472
},
484473
"tutorialCardButton": "Zum Tutorial"
485-
})
474+
}),
486475
),
487476
(
488477
"site_meta",
489478
json!({
490479
"title": "Linux Tutorial - Lerne Linux Schritt für Schritt",
491480
"description": "Lerne Linux von Grund auf - Interaktiv, modern und praxisnah."
492-
})
481+
}),
493482
),
494483
(
495484
"header",
@@ -744,7 +733,6 @@ pub async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::Error> {
744733

745734
match (admin_username, admin_password) {
746735
(Some(username), Some(password)) if !username.is_empty() && !password.is_empty() => {
747-
748736
if password.len() < 12 {
749737
tracing::error!(
750738
"ADMIN_PASSWORD must be at least 12 characters long (NIST recommendation)!"
@@ -775,7 +763,6 @@ pub async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::Error> {
775763
}
776764
},
777765
None => {
778-
779766
let password_hash =
780767
bcrypt::hash(&password, bcrypt::DEFAULT_COST).map_err(|e| {
781768
tracing::error!("Failed to hash admin password: {}", e);
@@ -809,7 +796,6 @@ pub async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::Error> {
809796
let mut tx = pool.begin().await?;
810797

811798
if seed_enabled {
812-
813799
let already_seeded: Option<(String,)> =
814800
sqlx::query_as("SELECT value FROM app_metadata WHERE key = 'default_tutorials_seeded'")
815801
.fetch_optional(&mut *tx)
@@ -1004,10 +990,6 @@ async fn insert_default_tutorials_tx(
1004990
Ok(())
1005991
}
1006992

1007-
1008-
1009-
1010-
1011993
async fn apply_comment_migrations(
1012994
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
1013995
) -> Result<(), sqlx::Error> {
@@ -1024,7 +1006,7 @@ async fn apply_comment_migrations(
10241006
sqlx::query("ALTER TABLE comments ADD COLUMN post_id TEXT")
10251007
.execute(&mut **tx)
10261008
.await?;
1027-
1009+
10281010
// Add index for post_id
10291011
sqlx::query("CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id)")
10301012
.execute(&mut **tx)
@@ -1038,17 +1020,18 @@ async fn apply_vote_migration(
10381020
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
10391021
) -> Result<(), sqlx::Error> {
10401022
// Create comment_votes table
1041-
sqlx::query(include_str!("../migrations/20241119_create_comment_votes.sql"))
1042-
.execute(&mut **tx)
1043-
.await?;
1023+
sqlx::query(include_str!(
1024+
"../migrations/20241119_create_comment_votes.sql"
1025+
))
1026+
.execute(&mut **tx)
1027+
.await?;
10441028

10451029
// Add votes column to comments if missing
1046-
let has_votes: bool = sqlx::query_scalar(
1047-
"SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='votes'",
1048-
)
1049-
.fetch_one(&mut **tx)
1050-
.await
1051-
.map(|count: i64| count > 0)?;
1030+
let has_votes: bool =
1031+
sqlx::query_scalar("SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='votes'")
1032+
.fetch_one(&mut **tx)
1033+
.await
1034+
.map(|count: i64| count > 0)?;
10521035

10531036
if !has_votes {
10541037
tracing::info!("Adding votes column to comments table");
@@ -1121,10 +1104,10 @@ async fn fix_comment_schema(
11211104
// Actually, apply_comment_migrations adds post_id.
11221105
// Let's check columns in comments_old to be safe, or just assume standard flow.
11231106
// To be safe, we'll select specific columns.
1124-
1107+
11251108
// Note: We need to handle the case where tutorial_id was NOT NULL.
11261109
// If we have data, it's fine.
1127-
1110+
11281111
sqlx::query(
11291112
r#"
11301113
INSERT INTO comments (id, tutorial_id, post_id, author, content, created_at, votes, is_admin)
@@ -1148,11 +1131,9 @@ async fn fix_comment_schema(
11481131
.await?;
11491132

11501133
// 6. Mark as fixed
1151-
sqlx::query(
1152-
"INSERT INTO app_metadata (key, value) VALUES ('comment_schema_fixed_v1', 'true')"
1153-
)
1154-
.execute(&mut **tx)
1155-
.await?;
1134+
sqlx::query("INSERT INTO app_metadata (key, value) VALUES ('comment_schema_fixed_v1', 'true')")
1135+
.execute(&mut **tx)
1136+
.await?;
11561137

11571138
Ok(())
11581139
}

backend/src/handlers/auth.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ use sha2::{Digest, Sha256};
3333
use sqlx::{self, FromRow};
3434
use std::{env, sync::OnceLock, time::Duration};
3535

36-
3736
/// Global salt for hashing login attempt identifiers.
3837
/// Initialized once at startup via init_login_attempt_salt().
3938
static LOGIN_ATTEMPT_SALT: OnceLock<String> = OnceLock::new();
@@ -61,7 +60,10 @@ pub fn init_login_attempt_salt() -> Result<(), String> {
6160
return Err("LOGIN_ATTEMPT_SALT must be at least 32 characters long".to_string());
6261
}
6362

64-
let unique_chars = trimmed.chars().collect::<std::collections::HashSet<_>>().len();
63+
let unique_chars = trimmed
64+
.chars()
65+
.collect::<std::collections::HashSet<_>>()
66+
.len();
6567
if unique_chars < 10 {
6668
return Err("LOGIN_ATTEMPT_SALT must contain at least 10 unique characters".to_string());
6769
}
@@ -479,7 +481,9 @@ pub async fn logout(
479481
) -> (StatusCode, HeaderMap) {
480482
// Extract token to blacklist it
481483
if let Some(token) = auth::extract_token(&headers) {
482-
if let Err(e) = repositories::token_blacklist::blacklist_token(&pool, &token, claims.exp as i64).await {
484+
if let Err(e) =
485+
repositories::token_blacklist::blacklist_token(&pool, &token, claims.exp as i64).await
486+
{
483487
tracing::error!("Failed to blacklist token on logout: {}", e);
484488
}
485489
}

0 commit comments

Comments
 (0)