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
5 changes: 5 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,8 @@ SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
# Redis connection URL for caching popular events
# Default: redis://127.0.0.1:6379
REDIS_URL=redis://127.0.0.1:6379

# JWT Configuration
# Required: Secret used to sign JWT tokens.
# Must be a cryptographically random string of at least 32 bytes.
JWT_SECRET=your_super_secret_jwt_key_that_is_at_least_32_bytes_long
4 changes: 2 additions & 2 deletions server/src/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ pub struct Claims {
pub exp: i64,
}

fn jwt_secret() -> String {
env::var("JWT_SECRET").unwrap_or_else(|_| "fallback_dev_secret_change_in_prod".to_string())
pub fn jwt_secret() -> String {
env::var("JWT_SECRET").expect("JWT_SECRET environment variable is missing. It is required for signing JWTs.")
}

/// Encode a JWT for the given Stellar address with a 24-hour expiry.
Expand Down
16 changes: 16 additions & 0 deletions server/src/handlers/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ mod tests {
#[test]
fn build_where_clause_includes_min_tickets_available() {
let filters = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: None,
location: None,
Expand Down Expand Up @@ -438,6 +439,7 @@ mod tests {
fn test_event_filters_deserialization() {
// Test that filters can be deserialized from query params
let filters = EventFilters {
is_featured: None,
organizer_id: Some(Uuid::new_v4()),
organizer_wallet: Some("GABC123".to_string()),
location: Some("New York".to_string()),
Expand All @@ -461,6 +463,7 @@ mod tests {
#[test]
fn test_organizer_wallet_filter() {
let filters = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: Some("GBXXX".to_string()),
location: None,
Expand Down Expand Up @@ -511,6 +514,7 @@ mod tests {
#[test]
fn test_is_free_filter() {
let filters_free = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: None,
location: None,
Expand All @@ -528,6 +532,7 @@ mod tests {
assert_eq!(filters_free.is_free, Some(true));

let filters_paid = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: None,
location: None,
Expand All @@ -545,6 +550,7 @@ mod tests {
assert_eq!(filters_paid.is_free, Some(false));

let filters_none = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: None,
location: None,
Expand All @@ -565,6 +571,7 @@ mod tests {
#[test]
fn test_start_date_filter_generates_where_clause() {
let filters = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: None,
location: None,
Expand All @@ -590,6 +597,7 @@ mod tests {
#[test]
fn test_end_date_filter_generates_where_clause() {
let filters = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: None,
location: None,
Expand All @@ -615,6 +623,7 @@ mod tests {
#[test]
fn test_followers_only_filter() {
let filters = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: None,
location: None,
Expand Down Expand Up @@ -1013,6 +1022,7 @@ mod tests {
#[test]
fn test_invalid_sort_by_returns_validation_error() {
let filters = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: None,
location: None,
Expand All @@ -1035,6 +1045,7 @@ mod tests {
#[test]
fn test_invalid_sort_order_returns_validation_error() {
let filters = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: None,
location: None,
Expand Down Expand Up @@ -2639,6 +2650,7 @@ pub async fn get_event_revenue(
fn test_event_filters_deserialization() {
// Test that filters can be deserialized from query params
let filters = EventFilters {
is_featured: None,
organizer_id: Some(Uuid::new_v4()),
organizer_wallet: Some("GABC123".to_string()),
location: Some("New York".to_string()),
Expand All @@ -2662,6 +2674,7 @@ fn test_event_filters_deserialization() {
#[test]
fn test_organizer_wallet_filter() {
let filters = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: Some("GBXXX".to_string()),
location: None,
Expand All @@ -2682,6 +2695,7 @@ fn test_organizer_wallet_filter() {
#[test]
fn test_is_free_filter() {
let filters_free = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: None,
location: None,
Expand All @@ -2699,6 +2713,7 @@ fn test_is_free_filter() {
assert_eq!(filters_free.is_free, Some(true));

let filters_paid = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: None,
location: None,
Expand All @@ -2716,6 +2731,7 @@ fn test_is_free_filter() {
assert_eq!(filters_paid.is_free, Some(false));

let filters_none = EventFilters {
is_featured: None,
organizer_id: None,
organizer_wallet: None,
location: None,
Expand Down
25 changes: 25 additions & 0 deletions server/src/handlers/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,31 @@ pub async fn health_check_blockchain() -> Response {
}
}

#[derive(Serialize)]
struct HealthRedisResponse {
status: &'static str,
timestamp: String,
}

/// GET /health/redis – Redis connectivity check.
///
/// Returns 200 when Redis is reachable.
/// Returns a structured JSON error (via [`AppError`]) when it is not.
pub async fn health_check_redis(State(mut redis): State<crate::cache::RedisCache>) -> Response {
// Perform a basic Redis command to verify connectivity
match redis.ping().await {
Ok(_) => {
let payload = HealthRedisResponse {
status: "ok",
timestamp: Utc::now().to_rfc3339(),
};
success(payload, "Redis is healthy").into_response()
}
Err(e) => AppError::ExternalServiceError(format!("Redis health check failed: {e}"))
.into_response(),
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
48 changes: 7 additions & 41 deletions server/src/handlers/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
http::HeaderMap,
response::{IntoResponse, Response},
Json,
};
Expand Down Expand Up @@ -63,15 +63,15 @@ fn validate_upsert(req: &UpsertProfileRequest) -> Result<(), AppError> {
));
}
if req.display_name.len() > MAX_DISPLAY_NAME {
return Err(AppError::ValidationError(format!(
"display_name must be at most {MAX_DISPLAY_NAME} characters"
)));
return Err(AppError::ValidationError(
"displayName must not exceed 50 characters".to_string()
));
}
if let Some(ref bio) = req.bio {
if bio.len() > MAX_BIO {
return Err(AppError::ValidationError(format!(
"bio must be at most {MAX_BIO} characters"
)));
return Err(AppError::ValidationError(
"bio must not exceed 500 characters".to_string()
));
}
}
Ok(())
Expand Down Expand Up @@ -345,40 +345,6 @@ pub async fn get_my_profile(State(mut state): State<ProfileState>, headers: Head
fetch_profile_by_address(&state.pool, &mut state.redis, &address).await
}

/// `DELETE /api/v1/profile`
///
/// Deletes the authenticated organizer's profile row from `organizer_profiles`.
/// Returns 204 No Content on success, 404 if no profile exists.
pub async fn delete_profile(State(mut state): State<ProfileState>, headers: HeaderMap) -> Response {
let address = match extract_auth(&headers) {
Ok(a) => a,
Err(e) => return e.into_response(),
};

let result = match sqlx::query("DELETE FROM organizer_profiles WHERE address = $1")
.bind(&address)
.execute(&state.pool)
.await
{
Ok(r) => r,
Err(e) => {
tracing::error!("Failed to delete organizer profile: {:?}", e);
return AppError::DatabaseError(e).into_response();
}
};

if result.rows_affected() == 0 {
return AppError::NotFound(format!("No profile found for address '{address}'"))
.into_response();
}

let cache_key = format!("profile:{address}");
if let Err(e) = state.redis.delete(&cache_key).await {
tracing::warn!("Failed to invalidate profile cache for {address}: {:?}", e);
}

StatusCode::NO_CONTENT.into_response()
}

/// Summary of a payment transaction returned by `GET /api/v1/profile/transactions`.
#[derive(Debug, Clone, Serialize, FromRow)]
Expand Down
28 changes: 28 additions & 0 deletions server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ async fn main() {
tracing::info!("Configuration: REDIS_URL={}", config.redis_url);
// Note: DATABASE_URL is strictly excluded from logging for security reasons.

// Validate that JWT_SECRET is set before binding
let _ = agora_server::handlers::auth::jwt_secret();

let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&config.database_url)
Expand Down Expand Up @@ -80,6 +83,31 @@ async fn main() {
tracing::info!("🚀 Server running at http://localhost:{}", config.port);
tracing::info!("Request IDs will be set via '{REQUEST_ID_HEADER}' header");

// Spawn periodic background task to clean up old nonces (Issue #823)
let cleanup_pool = pool.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(15 * 60)); // 15 minutes
loop {
interval.tick().await;
tracing::info!("Running periodic cleanup of jwt_nonces...");
match sqlx::query(
"DELETE FROM jwt_nonces WHERE expires_at < NOW() OR (used = TRUE AND created_at < NOW() - INTERVAL '7 days')"
)
.execute(&cleanup_pool)
.await
{
Ok(result) => {
if result.rows_affected() > 0 {
tracing::info!("Cleaned up {} expired/used nonces.", result.rows_affected());
}
}
Err(e) => {
tracing::error!("Failed to clean up jwt_nonces: {:?}", e);
}
}
}
});

let listener = TcpListener::bind(addr)
.await
.expect("Failed to bind address");
Expand Down
1 change: 1 addition & 0 deletions server/src/middleware/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod admin_auth;
pub mod audit;
pub mod content_type;
pub mod monitoring_auth;
Expand Down
6 changes: 6 additions & 0 deletions server/src/models/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ mod tests {
fn test_is_free_defaults_false() {
// When sqlx skips the field, the default is false.
let event = Event {
is_featured: false,
id: Uuid::new_v4(),
organizer_id: Uuid::new_v4(),
title: "Test".into(),
Expand All @@ -155,6 +156,7 @@ mod tests {
#[test]
fn test_is_free_serializes() {
let mut event = Event {
is_featured: false,
id: Uuid::new_v4(),
organizer_id: Uuid::new_v4(),
title: "Free Concert".into(),
Expand All @@ -179,6 +181,7 @@ mod tests {
#[test]
fn test_average_rating_none_when_no_ratings() {
let event = Event {
is_featured: false,
id: Uuid::new_v4(),
organizer_id: Uuid::new_v4(),
title: "T".into(),
Expand All @@ -201,6 +204,7 @@ mod tests {
#[test]
fn test_average_rating_serialized_when_ratings_exist() {
let event = Event {
is_featured: false,
id: Uuid::new_v4(),
organizer_id: Uuid::new_v4(),
title: "Rated".into(),
Expand All @@ -227,6 +231,7 @@ mod tests {
let created = Utc.with_ymd_and_hms(2026, 5, 1, 10, 0, 0).unwrap();
let updated = Utc.with_ymd_and_hms(2026, 5, 20, 14, 30, 0).unwrap();
let event = Event {
is_featured: false,
id: Uuid::new_v4(),
organizer_id: Uuid::new_v4(),
title: "T".into(),
Expand All @@ -251,6 +256,7 @@ mod tests {
#[test]
fn test_average_rating_serialized_null_when_no_ratings() {
let event = Event {
is_featured: false,
id: Uuid::new_v4(),
organizer_id: Uuid::new_v4(),
title: "Unrated".into(),
Expand Down
Loading
Loading