mod auth_guard; mod routes; mod state; mod storage; use std::sync::Arc; use axum::{Router, routing::{get, post}}; use tower_http::services::ServeDir; use openid::DiscoveredClient; use state::AppState; use storage::Storage; use tower_sessions::{MemoryStore, SessionManagerLayer, cookie::SameSite}; use irc_now_common::auth::OidcConfig; use irc_now_common::db::DbConfig; async fn health() -> &'static str { "ok" } async fn cleanup_expired_images(db: sqlx::PgPool, storage: storage::Storage) { let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600)); loop { interval.tick().await; let rows: Vec<(String, String)> = match sqlx::query_as( "SELECT id, filename FROM images WHERE expires_at IS NOT NULL AND expires_at < now()", ) .fetch_all(&db) .await { Ok(r) => r, Err(e) => { tracing::error!("expired image query failed: {e}"); continue; } }; if rows.is_empty() { continue; } let count = rows.len(); for (id, filename) in &rows { let original_key = format!("{id}/{filename}"); let thumb_key = format!("{id}/thumb.webp"); let _ = storage.delete(&original_key).await; let _ = storage.delete(&thumb_key).await; } match sqlx::query("DELETE FROM images WHERE expires_at IS NOT NULL AND expires_at < now()") .execute(&db) .await { Ok(_) => tracing::info!("cleaned up {count} expired images"), Err(e) => tracing::error!("expired image delete failed: {e}"), } } } #[tokio::main] async fn main() { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let db = DbConfig::from_env("PICS").connect().await.unwrap(); let oidc = OidcConfig::from_env(); let issuer_url = url::Url::parse(&oidc.issuer_url).expect("invalid OIDC issuer URL"); tracing::info!("discovering OIDC provider at {issuer_url}"); let oidc_client = DiscoveredClient::discover( oidc.client_id.clone(), oidc.client_secret.clone(), Some(oidc.redirect_url.clone()), issuer_url, ) .await .expect("OIDC discovery failed"); tracing::info!("OIDC provider discovered"); let s3_endpoint = std::env::var("S3_ENDPOINT").expect("S3_ENDPOINT required"); let s3_bucket = std::env::var("S3_BUCKET").unwrap_or_else(|_| "pics".to_string()); let s3_access_key = std::env::var("S3_ACCESS_KEY").expect("S3_ACCESS_KEY required"); let s3_secret_key = std::env::var("S3_SECRET_KEY").expect("S3_SECRET_KEY required"); let storage = Storage::new(&s3_endpoint, &s3_bucket, &s3_access_key, &s3_secret_key) .expect("S3 storage init failed"); let state = AppState { db: db.clone(), oidc, oidc_client: Arc::new(oidc_client), storage: storage.clone(), }; tokio::spawn(cleanup_expired_images(db, storage)); let session_store = MemoryStore::default(); let session_layer = SessionManagerLayer::new(session_store).with_same_site(SameSite::Lax); let app = Router::new() .route("/health", get(health)) .route("/", get(routes::image::index)) .route("/upload", post(routes::image::upload)) .route("/my", get(routes::image::my_images)) .route("/api/usage/{sub}", get(routes::usage::user_usage)) .route("/{id}", get(routes::image::view)) .route("/{id}/raw", get(routes::image::raw)) .route("/{id}/thumb", get(routes::image::thumb)) .route("/{id}/delete", post(routes::image::delete)) .route("/auth/login", get(routes::auth::login)) .route("/auth/callback", get(routes::auth::callback)) .route("/auth/logout", get(routes::auth::logout)) .nest_service("/static", ServeDir::new("static")) .layer(session_layer) .with_state(state); let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); tracing::info!("listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); }