Files
stripstream-librarian/apps/api/src/main.rs
Froidefond Julien bc796f4ee5 feat: multi-user reading progress & backoffice impersonation
- Scope all reading progress (books, series, stats) by user via
  Option<Extension<AuthUser>> — admin sees aggregate, read token sees own data
- Fix duplicate book rows when admin views lists (IS NOT NULL guard on JOIN)
- Add X-As-User header support: admin can impersonate any user from backoffice
- UserSwitcher dropdown in nav header (persisted via as_user_id cookie)
- Per-user filter pills on "Currently reading" and "Recently read" dashboard sections
- Inline username editing (UsernameEdit component with optimistic update)
- PATCH /admin/users/:id endpoint to rename a user
- Unassigned read tokens row in users table
- Komga sync now requires a user_id — reading progress attributed to selected user
- Migration 0051: add user_id column to komga_sync_reports
- Nav breakpoints: icons-only from md, labels from xl, hamburger until md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:47:58 +01:00

191 lines
8.4 KiB
Rust

mod auth;
mod authors;
mod books;
mod error;
mod handlers;
mod index_jobs;
mod job_poller;
mod komga;
mod libraries;
mod metadata;
mod metadata_batch;
mod metadata_refresh;
mod metadata_providers;
mod api_middleware;
mod openapi;
mod pages;
mod prowlarr;
mod qbittorrent;
mod reading_progress;
mod search;
mod series;
mod settings;
mod state;
mod stats;
mod telegram;
mod thumbnails;
mod tokens;
mod users;
use std::sync::Arc;
use std::time::Instant;
use axum::{
middleware,
routing::{delete, get},
Router,
};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use lru::LruCache;
use std::num::NonZeroUsize;
use stripstream_core::config::ApiConfig;
use sqlx::postgres::PgPoolOptions;
use tokio::sync::{Mutex, RwLock, Semaphore};
use tracing::info;
use crate::state::{load_concurrent_renders, load_dynamic_settings, AppState, Metrics, ReadRateLimit};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
std::env::var("RUST_LOG").unwrap_or_else(|_| "api=info,axum=info".to_string()),
)
.init();
let config = ApiConfig::from_env()?;
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&config.database_url)
.await?;
// Load concurrent_renders from settings, default to 8
let concurrent_renders = load_concurrent_renders(&pool).await;
info!("Using concurrent_renders limit: {}", concurrent_renders);
let dynamic_settings = load_dynamic_settings(&pool).await;
info!(
"Dynamic settings: rate_limit={}, timeout={}s, format={}, quality={}, filter={}, max_width={}, cache_dir={}",
dynamic_settings.rate_limit_per_second,
dynamic_settings.timeout_seconds,
dynamic_settings.image_format,
dynamic_settings.image_quality,
dynamic_settings.image_filter,
dynamic_settings.image_max_width,
dynamic_settings.cache_directory,
);
let state = AppState {
pool,
bootstrap_token: Arc::from(config.api_bootstrap_token),
page_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(512).expect("non-zero")))),
page_render_limit: Arc::new(Semaphore::new(concurrent_renders)),
metrics: Arc::new(Metrics::new()),
read_rate_limit: Arc::new(Mutex::new(ReadRateLimit {
window_started_at: Instant::now(),
requests_in_window: 0,
})),
settings: Arc::new(RwLock::new(dynamic_settings)),
};
let admin_routes = Router::new()
.route("/libraries", axum::routing::post(libraries::create_library))
.route("/libraries/:id", delete(libraries::delete_library))
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
.route("/libraries/:id/metadata-provider", axum::routing::patch(libraries::update_metadata_provider))
.route("/books/:id", axum::routing::patch(books::update_book))
.route("/books/:id/convert", axum::routing::post(books::convert_book))
.route("/libraries/:library_id/series/:name", axum::routing::patch(series::update_series))
.route("/index/rebuild", axum::routing::post(index_jobs::enqueue_rebuild))
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
.route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate))
.route("/index/status", get(index_jobs::list_index_jobs))
.route("/index/jobs/active", get(index_jobs::get_active_jobs))
.route("/index/jobs/:id", get(index_jobs::get_job_details))
.route("/index/jobs/:id/stream", get(index_jobs::stream_job_progress))
.route("/index/jobs/:id/errors", get(index_jobs::get_job_errors))
.route("/index/cancel/:id", axum::routing::post(index_jobs::cancel_job))
.route("/folders", get(index_jobs::list_folders))
.route("/admin/users", get(users::list_users).post(users::create_user))
.route("/admin/users/:id", delete(users::delete_user).patch(users::update_user))
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
.route("/admin/tokens/:id", delete(tokens::revoke_token).patch(tokens::update_token))
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
.route("/qbittorrent/add", axum::routing::post(qbittorrent::add_torrent))
.route("/qbittorrent/test", get(qbittorrent::test_qbittorrent))
.route("/telegram/test", get(telegram::test_telegram))
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
.route("/komga/reports", get(komga::list_sync_reports))
.route("/komga/reports/:id", get(komga::get_sync_report))
.route("/metadata/search", axum::routing::post(metadata::search_metadata))
.route("/metadata/match", axum::routing::post(metadata::create_metadata_match))
.route("/metadata/approve/:id", axum::routing::post(metadata::approve_metadata))
.route("/metadata/reject/:id", axum::routing::post(metadata::reject_metadata))
.route("/metadata/links", get(metadata::get_metadata_links))
.route("/metadata/missing/:id", get(metadata::get_missing_books))
.route("/metadata/links/:id", delete(metadata::delete_metadata_link))
.route("/metadata/batch", axum::routing::post(metadata_batch::start_batch))
.route("/metadata/batch/:id/report", get(metadata_batch::get_batch_report))
.route("/metadata/batch/:id/results", get(metadata_batch::get_batch_results))
.route("/metadata/refresh", axum::routing::post(metadata_refresh::start_refresh))
.route("/metadata/refresh/:id/report", get(metadata_refresh::get_refresh_report))
.merge(settings::settings_routes())
.route_layer(middleware::from_fn_with_state(
state.clone(),
auth::require_admin,
));
let read_routes = Router::new()
.route("/libraries", get(libraries::list_libraries))
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
.route("/books", get(books::list_books))
.route("/books/ongoing", get(series::ongoing_books))
.route("/books/:id", get(books::get_book))
.route("/books/:id/thumbnail", get(books::get_thumbnail))
.route("/books/:id/pages/:n", get(pages::get_page))
.route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress))
.route("/libraries/:library_id/series", get(series::list_series))
.route("/libraries/:library_id/series/:name/metadata", get(series::get_series_metadata))
.route("/series", get(series::list_all_series))
.route("/series/ongoing", get(series::ongoing_series))
.route("/series/statuses", get(series::series_statuses))
.route("/series/provider-statuses", get(series::provider_statuses))
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
.route("/authors", get(authors::list_authors))
.route("/stats", get(stats::get_stats))
.route("/search", get(search::search_books))
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
.route_layer(middleware::from_fn_with_state(
state.clone(),
auth::require_read,
));
// Clone pool before state is moved into the router
let poller_pool = state.pool.clone();
let app = Router::new()
.route("/health", get(handlers::health))
.route("/ready", get(handlers::ready))
.route("/metrics", get(handlers::metrics))
.route("/docs", get(handlers::docs_redirect))
.merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", openapi::ApiDoc::openapi()))
.merge(admin_routes)
.merge(read_routes)
.layer(middleware::from_fn_with_state(state.clone(), api_middleware::request_counter))
.with_state(state);
// Start background poller for API-only jobs (metadata_batch, metadata_refresh)
tokio::spawn(async move {
job_poller::run_job_poller(poller_pool, 5).await;
});
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
info!(addr = %config.listen_addr, "api listening");
axum::serve(listener, app).await?;
Ok(())
}