Skip to content

Commit fa19e83

Browse files
committed
commenting backend
1 parent fdce27d commit fa19e83

36 files changed

Lines changed: 927 additions & 264 deletions

backend/src/db/migrations.rs

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -558,12 +558,12 @@ async fn fix_comment_schema(
558558

559559
tracing::info!("Fixing comment schema: Making tutorial_id nullable");
560560

561-
// 1. Rename existing table
561+
// 1. Rename existing table to avoid name collision during schema swap
562562
sqlx::query("ALTER TABLE comments RENAME TO comments_old")
563563
.execute(&mut **tx)
564564
.await?;
565565

566-
// 2. Create new table with nullable tutorial_id and post_id
566+
// 2. Create new table with nullable tutorial_id and post_id (the fix)
567567
sqlx::query(
568568
r#"
569569
CREATE TABLE comments (
@@ -577,21 +577,12 @@ async fn fix_comment_schema(
577577
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
578578
CONSTRAINT fk_comments_tutorial FOREIGN KEY (tutorial_id) REFERENCES tutorials(id) ON DELETE CASCADE
579579
)
580-
"#,
580+
"# ,
581581
)
582582
.execute(&mut **tx)
583583
.await?;
584584

585-
// 3. Copy data from old table
586-
// We need to handle the case where post_id might not exist in comments_old if the previous migration failed or wasn't run fully,
587-
// but we assume apply_comment_migrations ran before this or we handle it.
588-
// Actually, apply_comment_migrations adds post_id.
589-
// Let's check columns in comments_old to be safe, or just assume standard flow.
590-
// To be safe, we'll select specific columns.
591-
592-
// Note: We need to handle the case where tutorial_id was NOT NULL.
593-
// If we have data, it's fine.
594-
585+
// 3. Migrate data from the old schema to the new one
595586
sqlx::query(
596587
r#"
597588
INSERT INTO comments (id, tutorial_id, post_id, author, content, created_at, votes, is_admin)
@@ -601,20 +592,20 @@ async fn fix_comment_schema(
601592
.execute(&mut **tx)
602593
.await?;
603594

604-
// 4. Drop old table
595+
// 4. Cleanup old temporary table
605596
sqlx::query("DROP TABLE comments_old")
606597
.execute(&mut **tx)
607598
.await?;
608599

609-
// 5. Recreate indices
600+
// 5. Recreate performance indices on the new table
610601
sqlx::query("CREATE INDEX IF NOT EXISTS idx_comments_tutorial ON comments(tutorial_id)")
611602
.execute(&mut **tx)
612603
.await?;
613604
sqlx::query("CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id)")
614605
.execute(&mut **tx)
615606
.await?;
616607

617-
// 6. Mark as fixed
608+
// 6. Persist migration state to prevent re-execution
618609
sqlx::query("INSERT INTO app_metadata (key, value) VALUES ('comment_schema_fixed_v1', 'true')")
619610
.execute(&mut **tx)
620611
.await?;

backend/src/db/mod.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
pub mod migrations;
2-
pub mod pool;
3-
pub mod seed;
1+
//! Database Layer
2+
//!
3+
//! This module coordinates database initialization, schema migrations,
4+
//! and initial data seeding. It provides a shared connection pool
5+
//! used by all repository instances.
6+
7+
pub mod migrations; // SQL schema versioning
8+
pub mod pool; // Connection lifecycle management
9+
pub mod seed; // Initial data (Default User, etc.)
410

511
pub use pool::{create_pool, DbPool};

backend/src/db/pool.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,11 @@ pub async fn create_pool() -> Result<DbPool, sqlx::Error> {
8282
}
8383

8484
fn ensure_sqlite_directory(database_url: &str) -> Result<(), sqlx::Error> {
85+
// Step 1: Extract file path from connection string
8586
if let Some(db_path) = sqlite_file_path(database_url) {
87+
// Step 2: Get parent directory
8688
if let Some(parent) = db_path.parent() {
89+
// Step 3: Create directory if not current working dir
8790
if parent != Path::new("") && parent != Path::new(".") {
8891
if let Err(err) = std::fs::create_dir_all(parent) {
8992
tracing::error!(error = %err, path = ?parent, "Failed to create SQLite directory");
@@ -100,20 +103,25 @@ fn ensure_sqlite_directory(database_url: &str) -> Result<(), sqlx::Error> {
100103
fn sqlite_file_path(database_url: &str) -> Option<PathBuf> {
101104
const PREFIX: &str = "sqlite:";
102105

106+
// Verify scheme
103107
if !database_url.starts_with(PREFIX) {
104108
return None;
105109
}
106110

111+
// Extract path part after prefix
107112
let mut remainder = &database_url[PREFIX.len()..];
108113

114+
// Reject memory-only databases or empty paths for directory creation
109115
if remainder.starts_with(':') || remainder.is_empty() {
110116
return None;
111117
}
112118

119+
// Strip optional query parameters (e.g., ?mode=rwc)
113120
if let Some((path_part, _)) = remainder.split_once('?') {
114121
remainder = path_part;
115122
}
116123

124+
// Normalize slashes for mixed OS environments
117125
let normalized = if remainder.starts_with("///") {
118126
&remainder[2..]
119127
} else if remainder.starts_with("//") {

backend/src/db/seed.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub async fn seed_site_content_tx(
55
tx: &mut Transaction<'_, Sqlite>,
66
) -> Result<(), sqlx::Error> {
77
for (section, content) in default_site_content() {
8+
// Step 1: Check if this content section already exists (Idempotency)
89
let exists: Option<(String,)> =
910
sqlx::query_as("SELECT section FROM site_content WHERE section = ?")
1011
.bind(section)
@@ -15,6 +16,7 @@ pub async fn seed_site_content_tx(
1516
continue;
1617
}
1718

19+
// Step 2: Persist the default JSON content
1820
sqlx::query("INSERT INTO site_content (section, content_json) VALUES (?, ?)")
1921
.bind(section)
2022
.bind(content.to_string())

backend/src/handlers/comments.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,27 @@ use serde::{Deserialize, Serialize};
3030
use std::net::SocketAddr;
3131
use html_escape;
3232

33+
/// Request payload for creating a comment
3334
#[derive(Deserialize)]
3435
pub struct CreateCommentRequest {
36+
/// The actual comment text
3537
content: String,
36-
author: Option<String>, // For guest comments
38+
/// The author's name (optional for guests)
39+
author: Option<String>,
3740
}
3841

42+
/// Query parameters for listing comments with pagination and sorting
3943
#[derive(Deserialize)]
4044
pub struct CommentListQuery {
45+
/// Maximum number of comments to return (default: 50)
4146
#[serde(default = "default_comment_limit")]
4247
limit: i64,
4348

49+
/// Number of comments to skip for pagination
4450
#[serde(default)]
4551
offset: i64,
4652

53+
/// Sorting criteria (e.g., "created_at:desc")
4754
#[serde(default)]
4855
sort: Option<String>,
4956
}
@@ -52,18 +59,30 @@ fn default_comment_limit() -> i64 {
5259
50
5360
}
5461

62+
/// Local DTO for comment responses, mapping from the database model
5563
#[derive(Serialize, sqlx::FromRow)]
5664
pub struct Comment {
65+
/// Unique identifier for the comment
5766
pub id: String,
67+
/// Optional parent tutorial ID
5868
pub tutorial_id: Option<String>,
69+
/// Optional parent post ID
5970
pub post_id: Option<String>,
71+
/// Display name of the author
6072
pub author: String,
73+
/// The comment content (HTML escaped)
6174
pub content: String,
75+
/// RFC3339 formatted creation timestamp
6276
pub created_at: String,
77+
/// Total number of votes/likes
6378
pub votes: i64,
79+
/// Whether the comment was posted by an administrator
6480
pub is_admin: bool,
6581
}
6682

83+
/// Validates and sanitizes comment content
84+
///
85+
/// Trims whitespace, checks length constraints, and escapes HTML characters.
6786
fn sanitize_comment_content(raw: &str) -> Result<String, (StatusCode, Json<ErrorResponse>)> {
6887
let trimmed = raw.trim();
6988

@@ -90,6 +109,9 @@ fn sanitize_comment_content(raw: &str) -> Result<String, (StatusCode, Json<Error
90109
Ok(sanitized)
91110
}
92111

112+
/// Handler for listing comments on a tutorial
113+
///
114+
/// Returns a paginated list of comments for the specified tutorial.
93115
pub async fn list_comments(
94116
State(pool): State<DbPool>,
95117
Path(tutorial_id): Path<String>,
@@ -158,6 +180,9 @@ pub async fn list_comments(
158180
Ok(Json(response_comments))
159181
}
160182

183+
/// Handler for creating a comment on a tutorial
184+
///
185+
/// Validates the tutorial existence and delegates to internal creation logic.
161186
pub async fn create_comment(
162187
State(pool): State<DbPool>,
163188
ConnectInfo(addr): ConnectInfo<SocketAddr>,
@@ -193,6 +218,9 @@ pub async fn create_comment(
193218
create_comment_internal(pool, Some(tutorial_id), None, payload, None, addr.ip().to_string()).await
194219
}
195220

221+
/// Handler for listing comments on a blog post
222+
///
223+
/// Returns a paginated list of comments for the specified post.
196224
pub async fn list_post_comments(
197225
State(pool): State<DbPool>,
198226
Path(post_id): Path<String>,
@@ -258,6 +286,9 @@ pub async fn list_post_comments(
258286
Ok(Json(response_comments))
259287
}
260288

289+
/// Handler for creating a comment on a blog post
290+
///
291+
/// Supports both authenticated users and guest comments.
261292
pub async fn create_post_comment(
262293
State(pool): State<DbPool>,
263294
ConnectInfo(addr): ConnectInfo<SocketAddr>,
@@ -290,6 +321,9 @@ pub async fn create_post_comment(
290321
create_comment_internal(pool, None, Some(post_id), payload, claims, addr.ip().to_string()).await
291322
}
292323

324+
/// Internal logic for creating a comment on either a tutorial or a post
325+
///
326+
/// Handles author resolution (admin/user vs guest), rate limiting, and database insertion.
293327
async fn create_comment_internal(
294328
pool: DbPool,
295329
tutorial_id: Option<String>,
@@ -435,6 +469,9 @@ async fn create_comment_internal(
435469
Ok(Json(response_comment))
436470
}
437471

472+
/// Handler for deleting a comment
473+
///
474+
/// Requires the user to be either an administrator or the original author.
438475
pub async fn delete_comment(
439476
claims: auth::Claims,
440477
State(pool): State<DbPool>,
@@ -510,6 +547,9 @@ pub async fn delete_comment(
510547
Ok(StatusCode::NO_CONTENT)
511548
}
512549

550+
/// Handler for voting on a comment
551+
///
552+
/// Authenticated users can upvote/downvote comments. Prevention logic ensures one vote per user.
513553
pub async fn vote_comment(
514554
State(pool): State<DbPool>,
515555
claims: auth::Claims,

backend/src/handlers/frontend_proxy.rs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
//! Frontend Proxy and SEO Meta Injection
2+
//!
3+
//! This module acts as a bridge between the Rust backend and the static frontend.
4+
//! Instead of serving files directly, it proxies requests to the frontend service
5+
//! and dynamically injects SEO metadata (title, description) from the database
6+
//! into the HTML response. This ensures search engines and social media crawlers
7+
//! see relevant page information even for a Single Page Application (SPA).
8+
19
use crate::db;
210
use axum::{
311
extract::State,
@@ -6,15 +14,22 @@ use axum::{
614
use reqwest::Client;
715
use std::env;
816

9-
// Default frontend URL (internal Docker network)
17+
/// Internal URL for the frontend service in the container network
1018
const DEFAULT_FRONTEND_URL: &str = "http://frontend";
1119

20+
/// Core handler to serve the application entry point (index.html).
21+
///
22+
/// This function:
23+
/// 1. Proxies the raw index.html from the frontend service.
24+
/// 2. Fetches global site metadata (site_meta section) from the database.
25+
/// 3. Performs string-based injection of <title> and <meta> tags.
26+
/// 4. Provides fallback defaults if database records are missing.
1227
pub async fn serve_index(State(pool): State<db::DbPool>) -> impl IntoResponse {
1328
let frontend_url =
1429
env::var("FRONTEND_URL").unwrap_or_else(|_| DEFAULT_FRONTEND_URL.to_string());
1530
let index_url = format!("{}/index.html", frontend_url);
1631

17-
// Fetch index.html from frontend container
32+
// Proxied Fetch: Retrieve the template from the frontend service
1833
let client = Client::new();
1934
let html_content = match client.get(&index_url).send().await {
2035
Ok(resp) => match resp.text().await {
@@ -37,7 +52,7 @@ pub async fn serve_index(State(pool): State<db::DbPool>) -> impl IntoResponse {
3752
}
3853
};
3954

40-
// Fetch site meta from DB
55+
// Metadata Retrieval: Fetch SEO config from database section 'site_meta'
4156
let site_meta =
4257
match crate::repositories::content::fetch_site_content_by_section(&pool, "site_meta").await
4358
{
@@ -50,20 +65,24 @@ pub async fn serve_index(State(pool): State<db::DbPool>) -> impl IntoResponse {
5065
_ => serde_json::json!({}),
5166
};
5267

68+
// Extract title from JSON, providing a sensible fallback
5369
let title = site_meta
5470
.get("title")
5571
.and_then(|v| v.as_str())
5672
.unwrap_or("Linux Tutorial - Lerne Linux Schritt für Schritt");
5773

74+
// Extract description from JSON, providing a sensible fallback
5875
let description = site_meta
5976
.get("description")
6077
.and_then(|v| v.as_str())
6178
.unwrap_or("Lerne Linux von Grund auf - Interaktiv, modern und praxisnah.");
6279

63-
// Inject meta tags using simple string replacement
64-
// We target the specific default tags to replace them
80+
// Injection Phase:
81+
// We use simple string replacement to swap hardcoded defaults in the build
82+
// for dynamic database-driven values.
6583
let mut injected_html = html_content;
6684

85+
// SECURITY: Thoroughly escape database-sourced text to prevent XSS via meta tags
6786
let safe_title = html_escape::encode_text(&title);
6887
let safe_description = html_escape::encode_text(&description);
6988

0 commit comments

Comments
 (0)