Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(wc:*)",
"Bash(tree:*)"
"Bash(tree:*)",
"mcp__ide__getDiagnostics"
],
"deny": [],
"ask": []
Expand Down
3 changes: 0 additions & 3 deletions backend/src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@



use axum::{
extract::FromRequestParts,
http::{
Expand Down
43 changes: 30 additions & 13 deletions backend/src/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,39 @@ struct LoginAttempt {
blocked_until: Option<String>,
}

fn hash_login_identifier(username: &str) -> String {
static SALT: OnceLock<String> = OnceLock::new();
static LOGIN_ATTEMPT_SALT: OnceLock<String> = OnceLock::new();

let salt = SALT.get_or_init(|| {
env::var("LOGIN_ATTEMPT_SALT").unwrap_or_else(|_| {
tracing::error!(
"LOGIN_ATTEMPT_SALT environment variable is missing. Set a high-entropy value to enable secure brute-force protection."
);
panic!(
"LOGIN_ATTEMPT_SALT must be set to a random, high-entropy string. See .env.example for guidance."
);
})
});
pub fn init_login_attempt_salt() -> Result<(), String> {
let raw = env::var("LOGIN_ATTEMPT_SALT")
.map_err(|_| "LOGIN_ATTEMPT_SALT environment variable not set".to_string())?;
let trimmed = raw.trim();

if trimmed.len() < 32 {
return Err("LOGIN_ATTEMPT_SALT must be at least 32 characters long".to_string());
}

let unique_chars = trimmed.chars().collect::<std::collections::HashSet<_>>().len();
if unique_chars < 10 {
return Err("LOGIN_ATTEMPT_SALT must contain at least 10 unique characters".to_string());
}

LOGIN_ATTEMPT_SALT
.set(trimmed.to_string())
.map_err(|_| "LOGIN_ATTEMPT_SALT already initialized".to_string())?;

Ok(())
}

fn login_attempt_salt() -> &'static str {
LOGIN_ATTEMPT_SALT
.get()
.expect("LOGIN_ATTEMPT_SALT not initialized. Call init_login_attempt_salt() first.")
.as_str()
}

fn hash_login_identifier(username: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(salt.as_bytes());
hasher.update(login_attempt_salt().as_bytes());
hasher.update(username.trim().to_ascii_lowercase().as_bytes());
format!("{:x}", hasher.finalize())
}
Expand Down
25 changes: 20 additions & 5 deletions backend/src/handlers/site_pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,9 @@ fn map_page(
)
})?;

let sanitized_slug = slug.trim().to_lowercase();
let sanitized_title = match title.trim() {
"" => slug.clone(),
"" => sanitized_slug.clone(),
value => value.to_string(),
};

Expand All @@ -304,7 +305,7 @@ fn map_page(

Ok(SitePageResponse {
id,
slug,
slug: sanitized_slug,
title: sanitized_title,
description: sanitized_description,
nav_label: sanitized_nav_label,
Expand Down Expand Up @@ -480,14 +481,18 @@ pub async fn get_navigation(

let mut items = Vec::with_capacity(pages.len());
for page in pages {
let normalized_slug = page.slug.trim().to_lowercase();
if normalized_slug.is_empty() {
continue;
}
items.push(NavigationItemResponse {
id: page.id,
slug: page.slug,
slug: normalized_slug.clone(),
label: page
.nav_label
.clone()
.filter(|label| !label.trim().is_empty())
.unwrap_or(page.title.clone()),
.unwrap_or_else(|| page.title.trim().to_string()),
order_index: page.order_index,
});
}
Expand Down Expand Up @@ -557,7 +562,17 @@ pub async fn list_published_page_slugs(
.await
.map_err(|err| map_sqlx_error(err, "Navigation"))?;

let slugs = pages.into_iter().map(|page| page.slug).collect();
let slugs = pages
.into_iter()
.filter_map(|page| {
let normalized = page.slug.trim().to_lowercase();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
})
.collect();

Ok(Json(slugs))
}
13 changes: 10 additions & 3 deletions backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ async fn main() {
csrf::init_csrf_secret().expect("Failed to initialize CSRF secret");
tracing::info!("CSRF secret initialized successfully");

handlers::auth::init_login_attempt_salt().expect("Failed to initialize login attempt salt");
tracing::info!("Login attempt salt initialized successfully");

let pool = db::create_pool()
.await
.expect("Failed to create database pool");
Expand Down Expand Up @@ -260,7 +263,11 @@ async fn main() {
Method::OPTIONS,
Method::HEAD,
]))
.allow_headers(AllowHeaders::list(vec![AUTHORIZATION, CONTENT_TYPE]))
.allow_headers(AllowHeaders::list(vec![
AUTHORIZATION,
CONTENT_TYPE,
HeaderName::from_static(csrf::csrf_header_name()),
]))
.allow_credentials(true);

tracing::info!(origins = ?allowed_origins, "Configured CORS origins");
Expand Down Expand Up @@ -340,10 +347,10 @@ async fn main() {
delete(handlers::comments::delete_comment),
)

.route_layer(middleware::from_extractor::<csrf::CsrfGuard>())

.route_layer(middleware::from_fn(auth::auth_middleware))

.route_layer(middleware::from_extractor::<csrf::CsrfGuard>())

.layer(RequestBodyLimitLayer::new(ADMIN_BODY_LIMIT))

.layer(GovernorLayer::new(admin_rate_limit_config.clone()));
Expand Down
10 changes: 8 additions & 2 deletions src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,17 @@ class ApiClient {
setToken(token) {
if (token && typeof token !== 'string') {
console.warn('Attempted to set invalid JWT token; ignoring')
this.token = null
return
}
this.token = null
this.token = typeof token === 'string' && token.trim() !== '' ? token.trim() : null
}
getHeaders() {
return {}
const headers = {}
if (this.token) {
headers.Authorization = `Bearer ${this.token}`
}
return headers
}
async request(endpoint, options = {}) {
const {
Expand Down
58 changes: 47 additions & 11 deletions src/components/Comments.jsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,65 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { MessageSquare, Send, Trash2 } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { api } from '../api/client';
import PropTypes from 'prop-types';
const VALID_TUTORIAL_ID = /^[a-zA-Z0-9_-]+$/;

const Comments = ({ tutorialId }) => {
const [comments, setComments] = useState([]);
const [newComment, setNewComment] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [loadingComments, setLoadingComments] = useState(false);
const [loadError, setLoadError] = useState(null);
const { isAuthenticated, user } = useAuth();
const isAdmin = Boolean(user && user.role === 'admin');
useEffect(() => {
loadComments();
const normalizedTutorialId = useMemo(() => {
if (typeof tutorialId !== 'string') {
return null;
}
const trimmed = tutorialId.trim();
if (!trimmed || trimmed.length > 100 || !VALID_TUTORIAL_ID.test(trimmed)) {
return null;
}
return trimmed;
}, [tutorialId]);
const loadComments = async () => {

const loadComments = useCallback(async () => {
if (!normalizedTutorialId) {
setComments([]);
setLoadError(new Error('Kommentare für diese Ressource sind deaktiviert.'));
return;
}
setLoadingComments(true);
setLoadError(null);
try {
// Fetch comments from the API using the tutorial ID
const data = await api.listTutorialComments(tutorialId);
const data = await api.listTutorialComments(normalizedTutorialId);
// Ensure we always have an array, even if API returns unexpected data
setComments(Array.isArray(data) ? data : []);
} catch (error) {
// Log error for debugging but don't expose to user
console.error('Failed to load comments:', error);
// Set empty array to maintain consistent state
setComments([]);
setLoadError(error);
}
};
setLoadingComments(false);
}, [normalizedTutorialId]);

useEffect(() => {
loadComments();
}, [loadComments]);
const handleSubmit = async (e) => {
// Prevent default form submission behavior
e.preventDefault();
// Validate form data and user permissions
if (!newComment.trim() || !isAuthenticated || !isAdmin) return;
if (!canManageComments || !newComment.trim()) return;
// Set loading state to prevent duplicate submissions
setIsLoading(true);
try {
// Submit comment to API
await api.createComment(tutorialId, newComment);
await api.createComment(normalizedTutorialId, newComment);
// Clear form for next comment
setNewComment('');
// Refresh comments list to show new comment
Expand All @@ -49,9 +74,9 @@ const Comments = ({ tutorialId }) => {
};
const handleDelete = async (commentId) => {
// Double-check admin permissions
if (!isAdmin) return;
if (!canManageComments) return;
// Show confirmation dialog to prevent accidental deletion
if (!confirm('Kommentar wirklich löschen?')) return;
if (typeof window !== 'undefined' && !window.confirm('Kommentar wirklich löschen?')) return;
try {
// Delete comment from API
await api.deleteComment(commentId);
Expand All @@ -62,6 +87,8 @@ const Comments = ({ tutorialId }) => {
console.error('Failed to delete comment:', error);
}
};
const canManageComments = isAdmin && normalizedTutorialId;

return (
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
{}
Expand All @@ -70,7 +97,7 @@ const Comments = ({ tutorialId }) => {
Kommentare ({comments.length})
</h2>
{}
{isAdmin && (
{canManageComments && (
<form onSubmit={handleSubmit} className="mb-8">
<textarea
value={newComment}
Expand Down Expand Up @@ -115,6 +142,15 @@ const Comments = ({ tutorialId }) => {
</div>
)}
{}
{loadingComments && (
<div className="mb-6 text-sm text-gray-500 dark:text-gray-400">Kommentare werden geladen…</div>
)}
{loadError && !loadingComments && (
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-200">
Kommentare konnten nicht geladen werden.
</div>
)}
{}
<div className="space-y-4">
{comments.length === 0 ? (
<p className="text-center text-gray-500 dark:text-gray-400 py-8">
Expand Down
Loading
Loading