
- Add event categories config endpoint (/api/config/event-categories) - Fix bulletin processing N+1: batch hymn lookups for single bulletins - Optimize V2 events list endpoint: replace memory pagination with database LIMIT/OFFSET - Add missing SQL functions: list_events_paginated() and count_events() - Preserve HTTP response compatibility for iOS app
331 lines
15 KiB
Rust
331 lines
15 KiB
Rust
use anyhow::{Context, Result};
|
|
use axum::{
|
|
extract::Path,
|
|
middleware,
|
|
response::Redirect,
|
|
routing::{delete, get, post, put},
|
|
Router,
|
|
response::Html,
|
|
};
|
|
use std::{env, sync::Arc};
|
|
use tower::ServiceBuilder;
|
|
use tower_http::{
|
|
cors::{Any, CorsLayer},
|
|
trace::TraceLayer,
|
|
};
|
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
|
|
|
mod auth;
|
|
mod sql;
|
|
mod email;
|
|
mod upload;
|
|
mod recurring;
|
|
mod error;
|
|
mod utils;
|
|
mod handlers;
|
|
mod models;
|
|
mod services;
|
|
mod app_state;
|
|
|
|
use email::{EmailConfig, Mailer};
|
|
use app_state::AppState;
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
// Initialize tracing
|
|
tracing_subscriber::registry()
|
|
.with(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| "church_api=debug,tower_http=debug".into()),
|
|
)
|
|
.with(tracing_subscriber::fmt::layer())
|
|
.init();
|
|
|
|
// Load environment variables
|
|
dotenvy::dotenv().ok();
|
|
|
|
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
|
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
|
|
|
// Initialize database
|
|
// Database connection
|
|
let pool = sqlx::PgPool::connect(&database_url)
|
|
.await
|
|
.context("Failed to connect to database")?;
|
|
|
|
// Run migrations (disabled temporarily)
|
|
// sqlx::migrate!("./migrations")
|
|
// .run(&pool)
|
|
// .await
|
|
// .context("Failed to run migrations")?;
|
|
let email_config = EmailConfig::from_env().map_err(|e| anyhow::anyhow!("Failed to load email config: {:?}", e))?;
|
|
let mailer = Arc::new(Mailer::new(email_config).map_err(|e| anyhow::anyhow!("Failed to initialize mailer: {:?}", e))?);
|
|
|
|
// Initialize Owncast service with default values
|
|
let owncast_host = env::var("OWNCAST_HOST").unwrap_or_else(|_| "stream.rockvilletollandsda.church".to_string());
|
|
let stream_host = env::var("STREAM_HOST").unwrap_or_else(|_| "stream.rockvilletollandsda.church".to_string());
|
|
let owncast_service = Some(Arc::new(services::OwncastService::new(&owncast_host, &stream_host)));
|
|
|
|
// Transcoding services removed - replaced by simple smart streaming system
|
|
tracing::info!("🎬 Using direct AV1/H.264 smart streaming - no complex transcoding needed");
|
|
|
|
let state = AppState {
|
|
pool: pool.clone(),
|
|
jwt_secret,
|
|
mailer,
|
|
owncast_service,
|
|
};
|
|
|
|
// Create protected admin routes
|
|
let admin_routes = Router::new()
|
|
.route("/users", get(handlers::auth::list_users))
|
|
.route("/bulletins", post(handlers::bulletins::create))
|
|
.route("/bulletins/:id", put(handlers::bulletins::update))
|
|
.route("/bulletins/:id", delete(handlers::bulletins::delete))
|
|
.route("/events/pending", get(handlers::events::list_pending))
|
|
.route("/events/pending/:id/approve", post(handlers::events::approve))
|
|
.route("/events/pending/:id/reject", post(handlers::events::reject))
|
|
.route("/events/pending/:id", delete(handlers::events::delete_pending))
|
|
.route("/events/:id", put(handlers::events::update))
|
|
.route("/events/:id", delete(handlers::events::delete))
|
|
.route("/config", get(handlers::config::get_admin_config))
|
|
.route("/config", put(handlers::config::update_config))
|
|
.route("/schedule", post(handlers::schedule::create_schedule))
|
|
.route("/schedule/:date", put(handlers::schedule::update_schedule))
|
|
.route("/schedule/:date", delete(handlers::schedule::delete_schedule))
|
|
.route("/schedule", get(handlers::schedule::list_schedules))
|
|
.route("/members", get(handlers::members::list))
|
|
.route("/members", post(handlers::members::create))
|
|
.route("/members/:id", delete(handlers::members::delete))
|
|
.route("/quarterlies", post(handlers::quarterlies::create))
|
|
.route("/quarterlies/:id", put(handlers::quarterlies::update))
|
|
.route("/quarterlies/:id", delete(handlers::quarterlies::delete))
|
|
.route("/quarterlies/import", post(handlers::quarterlies::import_from_json))
|
|
.route("/backup/create", post(handlers::backup::create_backup))
|
|
.route("/backup/list", get(handlers::backup::list_backups))
|
|
.route("/backup/cleanup", post(handlers::backup::cleanup_backups))
|
|
.route("/backup/now", post(handlers::backup::backup_now))
|
|
.layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware));
|
|
|
|
// Build our application with routes
|
|
let app = Router::new()
|
|
// Public routes (no auth required)
|
|
.route("/api/auth/login", post(handlers::auth::login))
|
|
.route("/api/bulletins", get(handlers::bulletins::list))
|
|
.route("/api/bulletins/current", get(handlers::bulletins::current))
|
|
.route("/api/bulletins/next", get(handlers::bulletins::next))
|
|
.route("/api/bulletins/:id", get(handlers::bulletins::get))
|
|
.route("/api/events", get(handlers::events::list))
|
|
.route("/api/events/upcoming", get(handlers::events::upcoming))
|
|
.route("/api/events/featured", get(handlers::events::featured))
|
|
.route("/api/events/submit", post(handlers::events::submit))
|
|
.route("/api/events/:id", get(handlers::events::get))
|
|
.route("/api/config", get(handlers::config::get_public_config))
|
|
.route("/api/config/recurring-types", get(handlers::config::get_recurring_types))
|
|
.route("/api/config/event-categories", get(handlers::config::get_event_categories))
|
|
.route("/api/collections/rtsda_android/records", get(handlers::legacy::android_update))
|
|
.route("/api/bible_verses/random", get(handlers::bible_verses::random))
|
|
.route("/api/bible_verses", get(handlers::bible_verses::list))
|
|
.route("/api/bible_verses/search", get(handlers::bible_verses::search))
|
|
.route("/api/contact", post(handlers::contact::submit_contact))
|
|
.route("/api/schedule", get(handlers::schedule::get_schedule))
|
|
.route("/api/conference-data", get(handlers::schedule::get_conference_data))
|
|
.route("/api/members/active", get(handlers::members::list_active))
|
|
.route("/api/quarterlies", get(handlers::quarterlies::list))
|
|
.route("/api/quarterlies/:id", get(handlers::quarterlies::get))
|
|
// Hymnal API endpoints
|
|
.route("/api/hymnals", get(handlers::hymnal::list_hymnals))
|
|
.route("/api/hymnals/:id", get(handlers::hymnal::get_hymnal))
|
|
.route("/api/hymnals/code/:code", get(handlers::hymnal::get_hymnal_by_code))
|
|
.route("/api/hymns", get(handlers::hymnal::list_hymns))
|
|
.route("/api/hymns/search", get(handlers::hymnal::search_hymns))
|
|
.route("/api/hymns/:hymnal_code/:number", get(handlers::hymnal::get_hymn_by_number))
|
|
.route("/api/hymnals/:id/themes", get(handlers::hymnal::list_thematic_lists))
|
|
.route("/api/hymnals/code/:code/themes", get(handlers::hymnal::list_thematic_lists_by_code))
|
|
.route("/api/responsive-readings", get(handlers::hymnal::list_responsive_readings))
|
|
.route("/api/responsive-readings/search", get(handlers::hymnal::search_responsive_readings))
|
|
.route("/api/responsive-readings/:number", get(handlers::hymnal::get_responsive_reading))
|
|
// New media library endpoints (replacing Jellyfin)
|
|
.route("/api/sermons", get(handlers::media::list_sermons))
|
|
.route("/api/livestreams", get(handlers::media::list_livestreams))
|
|
.route("/api/media/items", get(handlers::media::list_media_items))
|
|
.route("/api/media/items/:id", get(handlers::media::get_media_item))
|
|
// 🎯 SMART STREAMING - AV1 direct, HLS for legacy (like Jellyfin but not shit)
|
|
.route("/api/media/stream/:media_id", get(handlers::smart_streaming::smart_video_streaming))
|
|
.route("/api/media/stream/:media_id/playlist.m3u8", get(handlers::smart_streaming::generate_hls_playlist_for_segment_generation))
|
|
.route("/api/media/stream/:media_id/:segment_name", get(handlers::smart_streaming::serve_hls_segment))
|
|
.route("/api/media/:media_id/thumbnail", get(handlers::smart_streaming::serve_thumbnail))
|
|
// Legacy chunk streaming API removed - never released transcoding nightmare
|
|
// Test pages
|
|
.route("/chunk-test", get(serve_chunk_test_page))
|
|
.route("/smart-test", get(serve_smart_test_page))
|
|
.route("/api/media/thumbnail/:id", get(handlers::media::get_thumbnail))
|
|
// Legacy Jellyfin endpoints (keep for compatibility during transition)
|
|
// Jellyfin debug routes removed - clients hit Jellyfin directly now
|
|
.route("/api/stream/status", get(handlers::owncast::get_stream_status))
|
|
.route("/api/stream/live", get(handlers::owncast::get_live_status))
|
|
.route("/api/stream/hls/stream.m3u8", get(handlers::owncast::proxy_hls_playlist))
|
|
.route("/api/stream/hls/:variant/stream.m3u8", get(handlers::owncast::proxy_hls_variant))
|
|
.route("/api/stream/hls/:variant/:segment", get(handlers::owncast::proxy_hls_segment))
|
|
.route("/api/v1/stream/status", get(handlers::owncast::get_stream_status))
|
|
// V2 API routes with enhanced timezone handling
|
|
.route("/api/v2/events", get(handlers::v2::events::list))
|
|
.route("/api/v2/events/upcoming", get(handlers::v2::events::get_upcoming))
|
|
.route("/api/v2/events/featured", get(handlers::v2::events::get_featured))
|
|
.route("/api/v2/events/submit", post(handlers::v2::events::submit))
|
|
.route("/api/v2/events/:id", get(handlers::v2::events::get_by_id))
|
|
.route("/api/v2/bulletins", get(handlers::v2::bulletins::list))
|
|
.route("/api/v2/bulletins/current", get(handlers::v2::bulletins::get_current))
|
|
.route("/api/v2/bulletins/next", get(handlers::v2::bulletins::get_next))
|
|
.route("/api/v2/bulletins/:id", get(handlers::v2::bulletins::get_by_id))
|
|
.route("/api/v2/bible_verses/random", get(handlers::v2::bible_verses::get_random))
|
|
.route("/api/v2/bible_verses", get(handlers::v2::bible_verses::list))
|
|
.route("/api/v2/bible_verses/search", get(handlers::v2::bible_verses::search))
|
|
.route("/api/v2/contact", post(handlers::v2::contact::submit_contact))
|
|
.route("/api/v2/schedule", get(handlers::v2::schedule::get_schedule))
|
|
.route("/api/v2/conference-data", get(handlers::v2::schedule::get_conference_data))
|
|
// Redirect /api/v1/* to /api/*
|
|
.route("/api/v1/*path", get(redirect_v1_to_api))
|
|
// Mount protected admin routes (with chunk streaming cleanup)
|
|
.nest("/api/admin", admin_routes)
|
|
.nest("/api/upload", upload::routes())
|
|
.with_state(state)
|
|
.layer(
|
|
ServiceBuilder::new()
|
|
.layer(TraceLayer::new_for_http())
|
|
.layer(
|
|
CorsLayer::new()
|
|
.allow_origin(Any)
|
|
.allow_methods(Any)
|
|
.allow_headers(Any),
|
|
),
|
|
);
|
|
|
|
// Start recurring events scheduler
|
|
recurring::start_recurring_events_scheduler(pool.clone()).await;
|
|
|
|
// Start database backup scheduler
|
|
let backup_database_url = database_url.clone();
|
|
tokio::spawn(async move {
|
|
use services::BackupScheduler;
|
|
|
|
let scheduler = BackupScheduler::default_config(backup_database_url);
|
|
if let Err(e) = scheduler.start().await {
|
|
tracing::error!("Backup scheduler failed: {}", e);
|
|
}
|
|
});
|
|
|
|
// Start periodic media scanning (disabled initial scan for performance)
|
|
let media_pool = pool.clone();
|
|
tokio::spawn(async move {
|
|
use services::MediaScanner;
|
|
let scanner = MediaScanner::new(media_pool);
|
|
|
|
// Wait 30 seconds after startup before first scan to allow server to settle
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
|
|
|
|
// Periodic scanning every 10 minutes (reduced frequency for performance)
|
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(600));
|
|
loop {
|
|
interval.tick().await;
|
|
tracing::info!("Starting periodic media scan...");
|
|
|
|
if let Err(e) = scanner.scan_directory("/media/archive/jellyfin/sermons").await {
|
|
tracing::error!("Periodic sermons scan failed: {:?}", e);
|
|
}
|
|
if let Err(e) = scanner.scan_directory("/media/archive/jellyfin/livestreams").await {
|
|
tracing::error!("Periodic livestreams scan failed: {:?}", e);
|
|
}
|
|
|
|
tracing::info!("Periodic media scan completed");
|
|
}
|
|
});
|
|
|
|
// Start Intel Arc A770 background thumbnail generation
|
|
tokio::spawn(async move {
|
|
use services::ThumbnailGenerator;
|
|
|
|
// Wait 60 seconds after startup to allow server to settle and media scan to complete
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
|
|
|
|
tracing::info!("🚀 Starting Intel Arc A770 background thumbnail generation");
|
|
|
|
// Run thumbnail generation every 30 minutes
|
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1800));
|
|
loop {
|
|
interval.tick().await;
|
|
tracing::info!("📸 Starting Arc A770 thumbnail generation scan...");
|
|
|
|
// Generate thumbnails for sermons directory
|
|
if let Err(e) = ThumbnailGenerator::scan_and_generate_missing_thumbnails(
|
|
"/media/archive/jellyfin/sermons",
|
|
&format!("{}/thumbnails",
|
|
std::env::var("UPLOAD_DIR").unwrap_or_else(|_| "/opt/rtsda/church-api/uploads".to_string()))
|
|
).await {
|
|
tracing::error!("Sermons thumbnail generation failed: {:?}", e);
|
|
}
|
|
|
|
// Generate thumbnails for livestreams directory
|
|
if let Err(e) = ThumbnailGenerator::scan_and_generate_missing_thumbnails(
|
|
"/media/archive/jellyfin/livestreams",
|
|
&format!("{}/thumbnails",
|
|
std::env::var("UPLOAD_DIR").unwrap_or_else(|_| "/opt/rtsda/church-api/uploads".to_string()))
|
|
).await {
|
|
tracing::error!("Livestreams thumbnail generation failed: {:?}", e);
|
|
}
|
|
|
|
tracing::info!("📸 Arc A770 thumbnail generation scan completed");
|
|
}
|
|
});
|
|
|
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3002").await?;
|
|
tracing::info!("🚀 Church API server running on {}", listener.local_addr()?);
|
|
|
|
axum::serve(listener, app).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Serve the chunk streaming test page
|
|
async fn serve_chunk_test_page() -> Html<String> {
|
|
let html = include_str!("../chunk_streaming_test.html");
|
|
Html(html.to_string())
|
|
}
|
|
|
|
/// Serve the smart streaming test page
|
|
async fn serve_smart_test_page() -> Html<String> {
|
|
let html = include_str!("../smart_streaming_test.html");
|
|
Html(html.to_string())
|
|
}
|
|
|
|
/// Redirect /api/v1/* requests to /api/*
|
|
async fn redirect_v1_to_api(Path(path): Path<String>) -> Redirect {
|
|
let new_path = format!("/api/{}", path);
|
|
Redirect::permanent(&new_path)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use bcrypt::{hash, verify, DEFAULT_COST};
|
|
|
|
#[test]
|
|
fn test_bcrypt() {
|
|
let password = "test123";
|
|
let hashed = hash(password, DEFAULT_COST).unwrap();
|
|
println!("Hash: {}", hashed);
|
|
assert!(verify(password, &hashed).unwrap());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests4 {
|
|
use bcrypt::{hash, DEFAULT_COST};
|
|
|
|
#[test]
|
|
fn generate_real_password_hash() {
|
|
let password = "Alright8-Reapply-Shrewdly-Platter-Important-Keenness-Banking-Streak-Tactile";
|
|
let hashed = hash(password, DEFAULT_COST).unwrap();
|
|
println!("Hash for real password: {}", hashed);
|
|
}
|
|
}
|