Skip to content

Commit b03575b

Browse files
committed
feat: Implement full-stack comment system with authentication, voting,
1 parent 9f649b0 commit b03575b

39 files changed

Lines changed: 2072 additions & 1649 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,9 @@ backend/public/
325325

326326
# Ignore generated migration files (if applicable)
327327
# migrations/
328+
!backend/migrations/*.sql
329+
!backend/migrations/20241119_add_votes_and_admin_to_comments.sql
330+
328331

329332

330333
# ==================== Keep These ====================

backend/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ chrono = { version = "0.4", features = ["serde"] }
2626
dotenv = "0.15"
2727
tracing = "0.1"
2828
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
29-
uuid = { version = "1.6", features = ["v4", "serde"] }
29+
uuid = { version = "1.4", features = ["v4", "fast-rng", "macro-diagnostics"] }
30+
infer = "0.15"
3031
url = "2.5"
3132
idna_adapter = "=1.2.1"
3233
regex = { version = "1.10", default-features = false, features = ["std"] }
@@ -37,6 +38,7 @@ sha2 = "0.10"
3738
hmac = "0.12"
3839
subtle = "2.5"
3940
reqwest = { version = "0.11", features = ["json"] }
41+
html-escape = "0.2"
4042

4143
[dependencies.home]
4244
version = "=0.5.12"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- Add votes and is_admin columns to comments table
2+
ALTER TABLE comments ADD COLUMN votes INTEGER NOT NULL DEFAULT 0;
3+
ALTER TABLE comments ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CREATE TABLE IF NOT EXISTS comment_votes (
2+
comment_id TEXT NOT NULL,
3+
voter_id TEXT NOT NULL,
4+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
5+
PRIMARY KEY (comment_id, voter_id),
6+
FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE
7+
);

backend/src/auth.rs

Lines changed: 22 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,16 @@ use axum::{
2626
HeaderMap, HeaderValue, StatusCode,
2727
},
2828
Json,
29+
extract::FromRef,
2930
};
3031
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
3132
use chrono::{Duration, Utc};
3233
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
34+
use once_cell::sync::Lazy;
3335
use serde::{Deserialize, Serialize};
34-
use std::collections::HashSet;
3536
use std::env;
36-
use std::sync::OnceLock;
37-
use time::{Duration as TimeDuration, OffsetDateTime};
37+
38+
use crate::db::{self, DbPool};
3839

3940
/// Global storage for the JWT secret key.
4041
/// Initialized once at application startup via init_jwt_secret().
@@ -357,10 +358,11 @@ pub fn build_cookie_removal() -> Cookie<'static> {
357358
impl<S> FromRequestParts<S> for Claims
358359
where
359360
S: Send + Sync,
361+
DbPool: FromRef<S>,
360362
{
361363
type Rejection = (StatusCode, String);
362364

363-
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
365+
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
364366
// Check if claims already extracted by middleware
365367
if let Some(claims) = parts.extensions.get::<Claims>() {
366368
return Ok(claims.clone());
@@ -378,6 +380,21 @@ where
378380
let claims = verify_jwt(&token)
379381
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid token: {}", e)))?;
380382

383+
// Check if token is blacklisted
384+
let pool = DbPool::from_ref(state);
385+
if db::is_token_blacklisted(&pool, &token)
386+
.await
387+
.map_err(|e| {
388+
tracing::error!("Database error checking token blacklist: {}", e);
389+
(
390+
StatusCode::INTERNAL_SERVER_ERROR,
391+
"Internal server error".to_string(),
392+
)
393+
})?
394+
{
395+
return Err((StatusCode::UNAUTHORIZED, "Token has been revoked".to_string()));
396+
}
397+
381398
Ok(claims)
382399
}
383400
}
@@ -495,7 +512,7 @@ pub fn cookies_should_be_secure() -> bool {
495512
///
496513
/// # Priority
497514
/// Authorization header is checked first, falling back to cookies
498-
fn extract_token(headers: &HeaderMap) -> Option<String> {
515+
pub fn extract_token(headers: &HeaderMap) -> Option<String> {
499516
// First check Authorization header
500517
if let Some(header_value) = headers.get(AUTHORIZATION) {
501518
if let Ok(value_str) = header_value.to_str() {
@@ -536,60 +553,3 @@ fn parse_bearer_token(value: &str) -> Option<String> {
536553
None
537554
}
538555

539-
/// AXUM middleware for protecting routes with authentication.
540-
///
541-
/// This middleware validates the JWT token and adds the claims to the
542-
/// request extensions, making them available to downstream handlers.
543-
///
544-
/// # Usage
545-
/// ```rust,no_run
546-
/// use axum::{Router, routing::get, middleware};
547-
/// use linux_tutorial_cms::auth;
548-
///
549-
/// let app = Router::new()
550-
/// .route("/protected", get(handler))
551-
/// .route_layer(middleware::from_fn(auth::auth_middleware));
552-
/// ```
553-
///
554-
/// # Authentication
555-
/// Accepts tokens from:
556-
/// - Authorization: Bearer <token> header
557-
/// - ltcms_session cookie
558-
///
559-
/// # Errors
560-
/// Returns 401 Unauthorized if:
561-
/// - No token provided
562-
/// - Token is invalid or expired
563-
///
564-
/// # Request Extensions
565-
/// On success, inserts Claims into request extensions for easy access
566-
/// by downstream handlers.
567-
pub async fn auth_middleware(
568-
mut request: axum::extract::Request,
569-
next: axum::middleware::Next,
570-
) -> Result<axum::response::Response, (StatusCode, Json<crate::models::ErrorResponse>)> {
571-
// Extract token from request
572-
let token = extract_token(request.headers()).ok_or_else(|| {
573-
(
574-
StatusCode::UNAUTHORIZED,
575-
Json(crate::models::ErrorResponse {
576-
error: "Missing authentication token".to_string(),
577-
}),
578-
)
579-
})?;
580-
581-
// Verify token and extract claims
582-
let claims = verify_jwt(&token).map_err(|e| {
583-
(
584-
StatusCode::UNAUTHORIZED,
585-
Json(crate::models::ErrorResponse {
586-
error: format!("Invalid token: {}", e),
587-
}),
588-
)
589-
})?;
590-
591-
// Add claims to request extensions for downstream handlers
592-
request.extensions_mut().insert(claims);
593-
594-
Ok(next.run(request).await)
595-
}

backend/src/csrf.rs

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,7 @@ pub struct CsrfGuard;
475475
impl<S> FromRequestParts<S> for CsrfGuard
476476
where
477477
S: Send + Sync,
478+
crate::db::DbPool: axum::extract::FromRef<S>,
478479
{
479480
type Rejection = (StatusCode, Json<ErrorResponse>);
480481

@@ -488,21 +489,28 @@ where
488489
}
489490

490491
// Ensure user is authenticated by checking existing claims or lazily extracting them
491-
let claims = if let Some(existing) = parts.extensions.get::<auth::Claims>() {
492-
existing.clone()
492+
// For mixed endpoints (like comments) that allow both auth and guest, we only enforce CSRF
493+
// if the user is actually authenticated.
494+
let claims_result = if let Some(existing) = parts.extensions.get::<auth::Claims>() {
495+
Ok(existing.clone())
493496
} else {
494-
let claims = auth::Claims::from_request_parts(parts, _state)
495-
.await
496-
.map_err(|(status, message)| {
497-
(
498-
status,
499-
Json(ErrorResponse {
500-
error: message,
501-
}),
502-
)
503-
})?;
504-
parts.extensions.insert(claims.clone());
505-
claims
497+
auth::Claims::from_request_parts(parts, _state).await
498+
};
499+
500+
let claims = match claims_result {
501+
Ok(claims) => {
502+
// User is authenticated, so we MUST enforce CSRF
503+
parts.extensions.insert(claims.clone());
504+
claims
505+
}
506+
Err(_) => {
507+
// User is NOT authenticated (Guest) or token is invalid.
508+
// In this case, we skip CSRF validation because:
509+
// 1. Guests don't have a session to protect
510+
// 2. Guests don't have a username to bind the token to
511+
// 3. Invalid tokens will be handled by the route handler (treated as guest or rejected)
512+
return Ok(Self);
513+
}
506514
};
507515

508516
// Extract CSRF token from HTTP header

0 commit comments

Comments
 (0)