Skip to content

Commit 15aa151

Browse files
committed
feat: Implement atomic file uploads, enhance login rate limiting with IP+username, add CSRF protection to comment endpoints, and refine post title validation.
1 parent bd89b92 commit 15aa151

5 files changed

Lines changed: 69 additions & 29 deletions

File tree

backend/src/handlers/auth.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
2525
use crate::{security::{auth, csrf}, db::DbPool, models::*, repositories};
2626
use axum::{
27-
extract::State,
27+
extract::{ConnectInfo, State},
2828
http::{HeaderMap, StatusCode},
2929
Json,
3030
};
@@ -255,6 +255,7 @@ fn validate_password(password: &str) -> Result<(), String> {
255255
/// - Lockout countdown shown to user
256256
pub async fn login(
257257
State(pool): State<DbPool>,
258+
ConnectInfo(addr): ConnectInfo<SocketAddr>,
258259
Json(payload): Json<LoginRequest>,
259260
) -> Result<(HeaderMap, Json<LoginResponse>), (StatusCode, Json<ErrorResponse>)> {
260261
let username = payload.username.trim().to_string();
@@ -266,7 +267,11 @@ pub async fn login(
266267
return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })));
267268
}
268269

269-
let attempt_key = hash_login_identifier(&username);
270+
// Rate limit based on (IP + Username) to prevent DoS against specific users
271+
// If we only used username, an attacker could lock out 'admin' by spamming bad passwords.
272+
// If we only used IP, an attacker could rotate IPs to brute force.
273+
// Using both is a balanced approach.
274+
let attempt_key = hash_login_identifier(&format!("{}:{}", addr.ip(), username));
270275

271276
let attempt_record = repositories::users::get_login_attempt(&pool, &attempt_key)
272277
.await

backend/src/handlers/comments.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ pub async fn create_comment(
187187
State(pool): State<DbPool>,
188188
ConnectInfo(addr): ConnectInfo<SocketAddr>,
189189
Path(tutorial_id): Path<String>,
190+
_csrf: crate::security::csrf::CsrfGuard,
190191
Json(payload): Json<CreateCommentRequest>,
191192
) -> Result<Json<Comment>, (StatusCode, Json<ErrorResponse>)> {
192193
if let Err(e) = validate_tutorial_id(&tutorial_id) {
@@ -294,6 +295,7 @@ pub async fn create_post_comment(
294295
ConnectInfo(addr): ConnectInfo<SocketAddr>,
295296
Path(post_id): Path<String>,
296297
auth::OptionalClaims(claims): auth::OptionalClaims,
298+
_csrf: crate::security::csrf::CsrfGuard,
297299
Json(payload): Json<CreateCommentRequest>,
298300
) -> Result<Json<Comment>, (StatusCode, Json<ErrorResponse>)> {
299301
// Verify post exists
@@ -356,7 +358,11 @@ async fn create_comment_internal(
356358
}
357359

358360
// Enforce strict name validation (alphanumeric and spaces)
359-
let name_regex = regex::Regex::new(r"^[a-zA-Z0-9 ]+$").unwrap();
361+
static NAME_REGEX: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
362+
let name_regex = NAME_REGEX.get_or_init(|| {
363+
regex::Regex::new(r"^[a-zA-Z0-9 ]+$").unwrap()
364+
});
365+
360366
if !name_regex.is_match(trimmed) {
361367
return Err((
362368
StatusCode::BAD_REQUEST,
@@ -476,6 +482,7 @@ pub async fn delete_comment(
476482
claims: auth::Claims,
477483
State(pool): State<DbPool>,
478484
Path(id): Path<String>,
485+
_csrf: crate::security::csrf::CsrfGuard,
479486
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
480487
// Fetch the comment first to check ownership
481488
let comment = repositories::comments::get_comment(&pool, &id)
@@ -554,6 +561,7 @@ pub async fn vote_comment(
554561
State(pool): State<DbPool>,
555562
claims: auth::Claims,
556563
Path(id): Path<String>,
564+
_csrf: crate::security::csrf::CsrfGuard,
557565
) -> Result<Json<Comment>, (StatusCode, Json<ErrorResponse>)> {
558566
// Check if comment exists
559567
let exists = repositories::comments::check_comment_exists(&pool, &id)
@@ -606,6 +614,16 @@ pub async fn vote_comment(
606614
repositories::comments::add_vote(&pool, &id, &voter_id)
607615
.await
608616
.map_err(|e| {
617+
if let sqlx::Error::Database(db_err) = &e {
618+
if db_err.is_unique_violation() {
619+
return (
620+
StatusCode::CONFLICT,
621+
Json(ErrorResponse {
622+
error: "You have already voted on this comment".to_string(),
623+
}),
624+
);
625+
}
626+
}
609627
tracing::error!("Database error recording vote: {}", e);
610628
(
611629
StatusCode::INTERNAL_SERVER_ERROR,

backend/src/handlers/site_posts.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,16 @@ pub async fn update_post(
356356
}
357357

358358
if let Some(ref title) = payload.title {
359-
if title.trim().is_empty() || title.trim().len() > MAX_TITLE_LEN {
359+
let trimmed = title.trim();
360+
if trimmed.is_empty() {
361+
return Err((
362+
StatusCode::BAD_REQUEST,
363+
Json(ErrorResponse {
364+
error: "Title cannot be empty".to_string(),
365+
}),
366+
));
367+
}
368+
if trimmed.len() > MAX_TITLE_LEN {
360369
return Err((
361370
StatusCode::BAD_REQUEST,
362371
Json(ErrorResponse {
@@ -367,6 +376,9 @@ pub async fn update_post(
367376
}
368377

369378
let mut payload = payload;
379+
if let Some(title) = payload.title.as_mut() {
380+
*title = title.trim().to_string();
381+
}
370382
if let Some(slug) = payload.slug.as_mut() {
371383
*slug = sanitize_slug(slug);
372384
}

backend/src/handlers/tutorials/mod.rs

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -563,16 +563,7 @@ pub async fn update_tutorial(
563563
return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })));
564564
}
565565

566-
// Step 4: Handle version increment for optimistic concurrency control
567-
let new_version = tutorial.version.checked_add(1).ok_or_else(|| {
568-
tracing::error!("Tutorial version overflow for id: {}", id);
569-
(
570-
StatusCode::INTERNAL_SERVER_ERROR,
571-
Json(ErrorResponse {
572-
error: "Tutorial version overflow".to_string(),
573-
}),
574-
)
575-
})?;
566+
576567

577568
// Step 5: Handle topics serialization
578569
let (topics_json, topics_vec) = if let Some(t) = payload.topics {
@@ -612,7 +603,7 @@ pub async fn update_tutorial(
612603
};
613604

614605
// Step 6: Atomic Update operation in repository
615-
// Note: The repository update should include a WHERE version = old_version check
606+
// The repository handles the version increment and the WHERE version = current_version check
616607
let updated_tutorial = repositories::tutorials::update_tutorial(
617608
&pool,
618609
&id,
@@ -623,7 +614,7 @@ pub async fn update_tutorial(
623614
&color,
624615
&topics_json,
625616
&topics_vec,
626-
new_version.try_into().unwrap_or(1),
617+
tutorial.version, // Fix: Pass current version, not new_version
627618
)
628619
.await
629620
.map_err(|e| {

backend/src/handlers/upload.rs

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,15 @@ pub async fn upload_image(
144144
}
145145

146146
let filepath = upload_path_base.join(&new_filename);
147+
// Create a temporary file path
148+
let temp_filename = format!("{}.tmp", id);
149+
let temp_filepath = upload_path_base.join(&temp_filename);
147150

148-
// Open the file for writing (using async tokio file)
149-
let mut file = match tokio::fs::File::create(&filepath).await {
151+
// Open the temp file for writing
152+
let mut file = match tokio::fs::File::create(&temp_filepath).await {
150153
Ok(file) => file,
151154
Err(e) => {
152-
tracing::error!("Failed to create file {}: {}", filepath.display(), e);
155+
tracing::error!("Failed to create temp file {}: {}", temp_filepath.display(), e);
153156
return Err((
154157
StatusCode::INTERNAL_SERVER_ERROR,
155158
Json(ErrorResponse {
@@ -161,10 +164,10 @@ pub async fn upload_image(
161164

162165
use tokio::io::AsyncWriteExt; // Required for write_all and flush
163166

164-
// Write the first chunk (the one we peeked at for inference)
167+
// Write the first chunk
165168
if let Err(e) = file.write_all(&first_chunk).await {
166-
tracing::error!("Failed to write first chunk to {}: {}", filepath.display(), e);
167-
let _ = tokio::fs::remove_file(&filepath).await; // Cleanup partially written file
169+
tracing::error!("Failed to write first chunk to {}: {}", temp_filepath.display(), e);
170+
let _ = tokio::fs::remove_file(&temp_filepath).await;
168171
return Err((
169172
StatusCode::INTERNAL_SERVER_ERROR,
170173
Json(ErrorResponse {
@@ -181,7 +184,7 @@ pub async fn upload_image(
181184
Ok(opt) => opt,
182185
Err(err) => {
183186
tracing::error!("Failed to read chunk: {}", err);
184-
let _ = tokio::fs::remove_file(&filepath).await; // Cleanup on network/parsing error
187+
let _ = tokio::fs::remove_file(&temp_filepath).await;
185188
return Err((
186189
StatusCode::INTERNAL_SERVER_ERROR,
187190
Json(ErrorResponse {
@@ -191,7 +194,6 @@ pub async fn upload_image(
191194
}
192195
};
193196

194-
// Check if we hit the end of the field
195197
let chunk = match chunk_option {
196198
Some(c) => c,
197199
None => break,
@@ -200,7 +202,7 @@ pub async fn upload_image(
200202
// ENFORCEMENT: Track total size to prevent Disk Space exhaustion (DoS)
201203
total_size += chunk.len();
202204
if total_size > MAX_FILE_SIZE {
203-
let _ = tokio::fs::remove_file(&filepath).await;
205+
let _ = tokio::fs::remove_file(&temp_filepath).await;
204206
return Err((
205207
StatusCode::BAD_REQUEST,
206208
Json(ErrorResponse {
@@ -211,8 +213,8 @@ pub async fn upload_image(
211213

212214
// Write chunk to disk
213215
if let Err(e) = file.write_all(&chunk).await {
214-
tracing::error!("Failed to write chunk to {}: {}", filepath.display(), e);
215-
let _ = tokio::fs::remove_file(&filepath).await; // Cleanup on disk error
216+
tracing::error!("Failed to write chunk to {}: {}", temp_filepath.display(), e);
217+
let _ = tokio::fs::remove_file(&temp_filepath).await;
216218
return Err((
217219
StatusCode::INTERNAL_SERVER_ERROR,
218220
Json(ErrorResponse {
@@ -224,8 +226,8 @@ pub async fn upload_image(
224226

225227
// Sync buffers to disk
226228
if let Err(e) = file.flush().await {
227-
tracing::error!("Failed to flush file {}: {}", filepath.display(), e);
228-
let _ = tokio::fs::remove_file(&filepath).await;
229+
tracing::error!("Failed to flush file {}: {}", temp_filepath.display(), e);
230+
let _ = tokio::fs::remove_file(&temp_filepath).await;
229231
return Err((
230232
StatusCode::INTERNAL_SERVER_ERROR,
231233
Json(ErrorResponse {
@@ -234,6 +236,18 @@ pub async fn upload_image(
234236
));
235237
}
236238

239+
// Atomic rename from temp to final
240+
if let Err(e) = tokio::fs::rename(&temp_filepath, &filepath).await {
241+
tracing::error!("Failed to rename temp file {} to {}: {}", temp_filepath.display(), filepath.display(), e);
242+
let _ = tokio::fs::remove_file(&temp_filepath).await;
243+
return Err((
244+
StatusCode::INTERNAL_SERVER_ERROR,
245+
Json(ErrorResponse {
246+
error: "Failed to save file".to_string(),
247+
}),
248+
));
249+
}
250+
237251
// SUCCESS path
238252
tracing::info!("Successfully uploaded image: {}", filepath.display());
239253

0 commit comments

Comments
 (0)