From 5a49c5b097a26e11016a80f49da65ab5e5fa0a54 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 09:35:28 +0100 Subject: [PATCH 1/4] refactor(config): move config.rs and cli.rs to their own directories Signed-off-by: InstaZDLL --- src/{cli.rs => cli/mod.rs} | 0 src/{config.rs => config/mod.rs} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/{cli.rs => cli/mod.rs} (100%) rename src/{config.rs => config/mod.rs} (100%) diff --git a/src/cli.rs b/src/cli/mod.rs similarity index 100% rename from src/cli.rs rename to src/cli/mod.rs diff --git a/src/config.rs b/src/config/mod.rs similarity index 100% rename from src/config.rs rename to src/config/mod.rs From 9247edc5dc14e2dd957d705bd83b10b0a3f50e09 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 09:51:49 +0100 Subject: [PATCH 2/4] refactor(app): split the crate root into an app module of state, boot, router and openapi Signed-off-by: InstaZDLL --- src/app/boot.rs | 234 ++++++++ src/app/mod.rs | 20 + src/app/openapi.rs | 625 ++++++++++++++++++++ src/app/router.rs | 222 +++++++ src/app/state.rs | 70 +++ src/app/tests.rs | 256 ++++++++ src/lib.rs | 1387 +------------------------------------------- 7 files changed, 1429 insertions(+), 1385 deletions(-) create mode 100644 src/app/boot.rs create mode 100644 src/app/mod.rs create mode 100644 src/app/openapi.rs create mode 100644 src/app/router.rs create mode 100644 src/app/state.rs create mode 100644 src/app/tests.rs diff --git a/src/app/boot.rs b/src/app/boot.rs new file mode 100644 index 00000000..dedabc2a --- /dev/null +++ b/src/app/boot.rs @@ -0,0 +1,234 @@ +//! Bringing the state up — the migrations, the instance key, every service +//! and outbound adapter — and the signal that takes the server down. + +use std::{sync::Arc, time::Duration}; + +use super::state::{chunk_body_limit, negotiation_body_limit, AppState}; +use crate::{ + authentication, database, enrichment, link_roots, lrclib, media, migration_backup, quality, + scanner, scrobblers, security, services, setup_token, sync, Config, +}; + +pub async fn initialize(config: &Config) -> anyhow::Result { + let db = database::Database::open(config).await?; + let booted = boot(db.clone(), config).await; + if booted.is_err() { + // A refused boot closes what it opened before it says so. Dropping the + // pool would not be enough: sqlx closes each connection on its own + // thread after the drop has returned, and the last one to close + // checkpoints the WAL under an exclusive lock. Whoever opens the file + // next — the operator retrying, or a test that has just watched the + // refusal — races that close and can find the database locked. + db.pool().close().await; + } + booted +} + +async fn boot(db: database::Database, config: &Config) -> anyhow::Result { + // Before anything is applied, and only when something will be (RFC-019 + // decision 3): the copy is the database as this boot found it. + let copy = migration_backup::before_migrating(&db, config).await?; + if let Err(error) = db.migrate().await { + return Err(match copy { + Some(copy) => error.context(migration_backup::restore_hint(©)), + None => error, + }); + } + let secret_box = Arc::new(security::SecretBox::load_or_create( + &config.instance_key_path, + )?); + let instance_key = std::fs::read(&config.instance_key_path)?; + let fingerprint = security::bytes_hash(&instance_key); + if !db + .bind_instance_key(&fingerprint, authentication::now_ms()) + .await? + { + anyhow::bail!( + "instance.key does not match waveflow.db; restore the database and key from the same backup bundle" + ); + } + // After the key check, not before: a mismatched pair aborts the boot, and + // scheduling a rescan on a database this instance is about to refuse would + // be writing to a catalogue that is not ours. + db.reconcile_catalog_identity(&config.pid).await?; + // After the key check too: a boot that is about to be refused has no + // business writing a token into a data directory that is not its own. + let setup = Arc::new(setup_token::SetupGate::prepare( + config, + db.setup_required().await?, + )?); + let auth = authentication::AuthService::new(db.clone(), config); + let scanner = scanner::ScanManager::new( + db.clone(), + config.artwork_dir.clone(), + config.scan_parallelism, + ) + .with_link_roots(link_roots::LinkRoots::new(config.link_roots.clone())); + let media = media::MediaService::initialize(config).await?; + let sync = sync::SyncService::new(db.clone()); + let services = services::DomainServices::new( + db.clone(), + Arc::clone(&secret_box), + sync.clone(), + scanner.clone(), + config, + ); + // One of the two outbound surfaces this server has — metadata enrichment, + // below, is the other — and it is switched on by the operator naming a + // destination rather than by anybody linking an account. + // Nothing leaves until both are true: an adapter with no authorisation + // behind it is never handed a listen. RFC-010 decision 11. + for destination in &config.destinations { + // `Config::from_env` refuses to boot on a malformed destination, so the + // URL reaching here has already been through `validate_destination` — + // and `for_data_dir` builds an empty list rather than an unchecked one. + // + // Building the client is a different matter and really can fail at + // runtime, for reasons that have nothing to do with the operator's URL. + let target = match destination.provider { + services::ScrobbleProvider::ListenBrainz => { + scrobblers::outbound_client(config.scrobbling.request_timeout) + .and_then(|client| { + scrobblers::listenbrainz::ListenBrainz::new(client, &destination.url) + }) + .map(|target| Arc::new(target) as Arc) + } + services::ScrobbleProvider::Maloja => { + scrobblers::outbound_client(config.scrobbling.request_timeout) + .and_then(|client| scrobblers::maloja::Maloja::new(client, &destination.url)) + .map(|target| Arc::new(target) as Arc) + } + // **Last.fm needs more than a URL**, and a destination declared + // without it is not an error. Decision 4 forbids shipping any + // provider credential in an AGPL binary, so the application belongs + // to the operator; without one, the name stays listed — with the + // reason, which `GET /api/v2/scrobble-destinations` publishes — and + // nothing drains for it. + services::ScrobbleProvider::LastFm => { + let Some(application) = config.lastfm.clone() else { + continue; + }; + scrobblers::outbound_client(config.scrobbling.request_timeout) + .and_then(|client| { + scrobblers::lastfm::LastFm::new( + client, + &destination.url, + application, + config.scrobbling.request_timeout, + ) + }) + .map(|target| { + let target = Arc::new(target); + // The same object in both registries: submitting a + // listen and exchanging a token are two calls to one + // destination, signed by one application, and splitting + // them would be two places for that to drift. + services.register_lastfm_exchange( + &destination.name, + Arc::clone(&target) as Arc, + ); + target as Arc + }) + } + }; + match target { + Ok(target) => { + services.register_scrobble_target(destination.provider, &destination.name, target) + } + Err(error) => { + // **Never the URL itself.** `validate_destination` refuses a + // destination that carries credentials, so the one thing this + // branch is most likely to be handed is exactly the thing that + // must not be printed: `http://user:secret@host` would land in + // a startup log, or a support paste, in full. The variant names + // the fault precisely enough without it, and this repository's + // rule about secrets has no exception for an error path. + anyhow::bail!( + "the configured {} destination {:?} is unusable: {error}", + destination.provider.as_str(), + destination.name + ) + } + } + } + // Catching up and reconciling, once, under the writer gate — and *before* + // `main` starts the drain. The order is half the rule: reconciling while + // the background task already runs leaves a window, short and sufficient, + // in which listens leave for yesterday's destination. RFC-010 decision 10. + services.reconcile_scrobble_links().await?; + // A portrait search left fetching has no task across a restart; left at + // `complete = 0` it would hold its account's next search at 429 until its + // deadline passed (RFC-013 decision 4). + services.forget_unfinished_image_searches().await?; + // A podcast download the last process left running has no task any more + // (RFC-025): it goes back to `new`, and its staging file goes. + services.reconcile_podcasts().await?; + // Off unless the operator named a provider, and then it still makes no + // request until somebody asks about an artist (RFC-011 decisions 3 and 6). + services.use_enrichment(enrichment::Enrichment::new( + db.clone(), + &config.metadata, + config.artwork_dir.clone(), + )?); + // Off unless the operator named `lrclib`, and then it still asks nothing + // until somebody asks for the lyrics of a track that has none (RFC-024 + // decisions 1 and 3). + services.use_lyrics_lookup(lrclib::LyricsLookup::new(db.clone(), &config.lyrics)?); + // Before anything reads `track_quality`: a measurement another analyser or + // another FFmpeg made is deleted here, so every read can call a row current + // on its content hash alone (RFC-016 decision 5). + services + .use_quality_decoder(quality::Decoder { + digest: media.ffmpeg_digest().to_owned(), + webp: quality::probe_webp(&config.ffmpeg_path).await, + }) + .await?; + Ok(AppState { + db, + auth, + secret_box, + scanner, + media, + services, + sync, + artwork_dir: config.artwork_dir.clone(), + instance_key_path: config.instance_key_path.clone(), + public_url: config.public_url.clone(), + stream_ticket_ttl: config.stream_ticket_ttl, + refresh_token_ttl: config.refresh_token_ttl, + upload_batch_body_limit: negotiation_body_limit(&config.uploads), + upload_chunk_body_limit: chunk_body_limit(&config.uploads), + // Refused at startup if it does not fit, so this conversion cannot be + // the place that decides what a ceiling falls back to. + canvas_body_limit: usize::try_from(config.canvas.max_bytes).unwrap_or(usize::MAX), + trusted_proxies: config.trusted_proxies.clone().into(), + setup, + }) +} + +pub async fn shutdown_signal() { + let ctrl_c = async { + if let Err(error) = tokio::signal::ctrl_c().await { + tracing::error!(error = %error, "failed to install Ctrl-C handler"); + } + }; + + #[cfg(unix)] + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(error) => tracing::error!(error = %error, "failed to install terminate handler"), + } + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + () = ctrl_c => {}, + () = terminate => {}, + } + tokio::time::sleep(Duration::from_millis(25)).await; +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 00000000..54d31485 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,20 @@ +//! The assembled server: the state every surface shares, the boot that +//! builds it, the router that serves it and the OpenAPI document it +//! publishes. +//! +//! Private on purpose. What it holds was public at the crate root before it +//! had a module of its own, and is still reached there through the +//! re-exports in `lib.rs`. + +mod boot; +mod openapi; +mod router; +mod state; + +pub use boot::{initialize, shutdown_signal}; +pub use openapi::{ApiDoc, OPENAPI_JSON_PATH, SCALAR_PATH}; +pub use router::app; +pub use state::AppState; + +#[cfg(test)] +mod tests; diff --git a/src/app/openapi.rs b/src/app/openapi.rs new file mode 100644 index 00000000..eb56d758 --- /dev/null +++ b/src/app/openapi.rs @@ -0,0 +1,625 @@ +//! The OpenAPI document: the routes and schemas it lists, the passes that +//! state once what holds of every route in a class, and the route that +//! serves it. + +use axum::response::{IntoResponse, Response}; +use utoipa::OpenApi; + +use crate::{api, authentication, catalog, database, lyrics, media, services, sync}; + +pub const OPENAPI_JSON_PATH: &str = "/openapi.json"; +pub const SCALAR_PATH: &str = "/reference"; + +#[derive(OpenApi)] +#[openapi( + info( + title = "WaveFlow Server API", + version = env!("CARGO_PKG_VERSION"), + description = "Self-hosted WaveFlow music server v2.", + license(name = "AGPL-3.0-only") + ), + paths( + api::health, + api::ready, + api::setup_status, + api::setup, + api::login, + api::refresh, + api::logout, + api::web_login, + api::web_refresh, + api::web_logout, + api::oauth_authorize, + api::oauth_token, + api::start_scan, + api::list_libraries, + api::create_library, + api::update_library, + api::delete_library, + api::library_settings, + api::unavailable_tracks, + api::purge_unavailable_tracks, + api::purged_tracks, + api::restore_purged_tracks, + api::list_library_members, + api::set_library_member, + api::remove_library_member, + api::scan_status, + api::scan_events, + api::library_events, + api::library_events_ack, + api::negotiate_uploads, + api::upload_session, + api::upload_chunk, + api::commit_upload, + api::list_tracks, + api::library_tree, + api::list_folders, + api::create_folder, + api::update_folder, + api::delete_folder, + api::folder_tracks, + api::place_tracks, + api::attention_summary, + api::attention_credits, + api::attention_tracks, + api::list_credit_decisions, + api::create_credit_decision, + api::delete_credit_decision, + api::quality_summary, + api::quality_tracks, + api::request_quality_analysis, + api::track_quality, + api::track_spectrogram, + api::get_track, + api::update_track, + api::get_track_lyrics, + api::put_track_lyrics, + api::delete_track_lyrics, + api::get_track_lyrics_file, + api::get_track_overrides, + api::list_albums, + api::list_genres, + api::get_album, + api::list_artists, + api::get_artist, + api::put_artist_image, + api::delete_artist_image, + api::list_artist_image_candidates, + api::put_artist_deezer_portrait, + api::list_artist_top_songs, + api::list_artist_albums, + api::search_catalog, + api::list_songs, + api::list_random_songs, + api::get_listening_stats, + api::list_mixes, + api::get_mix, + api::list_similar_songs, + api::list_songs_by_genre, + api::list_playlists, + api::create_playlist, + api::import_playlist, + api::export_playlist, + api::get_playlist, + api::update_playlist, + api::move_playlist_entry, + api::remove_playlist_entry, + api::delete_playlist, + api::list_smart_playlists, + api::create_smart_playlist, + api::preview_smart_playlist, + api::get_smart_playlist, + api::update_smart_playlist, + api::delete_smart_playlist, + api::podcast_overview, + api::create_podcast_channel, + api::refresh_podcasts, + api::get_podcast_channel, + api::delete_podcast_channel, + api::newest_podcast_episodes, + api::get_podcast_episode, + api::download_podcast_episode, + api::delete_podcast_episode, + api::list_favorites, + api::add_favorite, + api::remove_favorite, + api::set_rating, + api::list_ratings, + api::create_scrobble, + api::list_history, + api::list_now_playing, + api::list_scrobble_links, + api::list_scrobble_destinations, + api::link_scrobble, + api::unlink_scrobble, + api::authorize_lastfm, + api::lastfm_callback, + api::list_uncertain_scrobbles, + api::discard_uncertain_scrobble, + api::retry_uncertain_scrobble, + api::get_queue, + api::save_queue, + api::list_shares, + api::create_share, + api::update_share, + api::delete_share, + api::sync_changes, + api::sync_snapshot, + api::sync_ack, + api::sync_socket, + api::transcode_status, + api::list_users, + api::create_user, + api::update_user, + api::delete_user, + api::set_subsonic_credential, + api::revoke_subsonic_credential, + api::list_radio_stations, + api::get_radio_station, + api::create_radio_station, + api::replace_radio_station, + api::delete_radio_station, + api::list_bookmarks, + api::set_bookmark, + api::delete_bookmark, + api::list_own_api_tokens, + api::create_own_api_token, + api::revoke_own_api_token, + api::own_subsonic_credential, + api::rotate_own_subsonic_credential, + api::revoke_own_subsonic_credential, + api::list_api_tokens, + api::create_api_token, + api::revoke_api_token, + media::stream_track, + media::create_stream_ticket, + media::stream_with_ticket, + media::artwork, + media::canvas_by_hash, + media::canvas_for_track, + media::put_canvas, + media::delete_canvas, + media::create_canvas_ticket, + media::canvas_with_ticket + ), + components(schemas( + api::ProbeResponse, + api::ReadyResponse, + api::SetupStatusResponse, + api::SetupRequest, + api::SetupResponse, + api::LoginRequest, + api::RefreshRequest, + api::WebAuthResponse, + api::ErrorResponse, + authentication::AuthTokens, + authentication::AuthUser, + database::AccountRole, + api::ScanQueuedResponse, + catalog::ScanJobRecord, + catalog::TrackRecord, + catalog::LibraryAccess, + api::DeleteLibraryRequest, + services::LibraryUpdate, + services::LibraryUpdated, + services::LibrarySettings, + services::LibraryDeleted, + services::UnavailableTrack, + services::UnavailableTracks, + services::PurgeRequest, + services::PurgeOutcome, + services::RestoreOutcome, + services::AlbumItem, + services::ReleaseGroup, + services::ArtistItem, + services::GenreItem, + services::LibraryMember, + services::SongItem, + services::TrackDetail, + services::TrackOverrides, + services::TrackOverrideValues, + services::TrackSourceTags, + lyrics::LyricsList, + lyrics::StructuredLyrics, + lyrics::LyricsLine, + services::ArtistSummary, + services::AlbumDetail, + services::ArtistDetail, + services::ArtistEnrichment, + services::EnrichmentState, + services::EnrichmentSource, + services::EnrichedBio, + services::EnrichedImage, + services::EnrichedSimilar, + services::ListeningStats, + services::StatsBucketSize, + services::StatsTotals, + services::StatsBucket, + services::TopTrack, + services::TopAlbum, + services::TopArtist, + services::TopGenre, + services::PlayMoment, + services::MixSummary, + services::Mix, + services::SearchResult, + services::PlaylistItem, + services::SmartPlaylistItem, + services::SmartPlaylistPreview, + services::SmartRules, + services::SmartCondition, + services::SmartMatch, + services::SmartField, + services::SmartOperator, + services::SmartSort, + services::SmartOrder, + api::SmartPlaylistRequest, + api::SmartPlaylistPatch, + api::SmartPreviewRequest, + api::RuleErrorResponse, + services::PodcastOverview, + services::PodcastChannelItem, + services::PodcastEpisodeItem, + api::CreatePodcastChannelRequest, + api::FeedErrorResponse, + services::QueueItem, + services::RatingItem, + services::HistoryItem, + services::ScrobbleProvider, + services::ScrobbleLinkState, + services::ScrobbleDestinationName, + services::ScrobbleUnavailable, + api::LastFmAuthorizationResponse, + services::UncertainScrobble, + api::LinkScrobbleRequest, + api::RetriedScrobbleResponse, + services::BookmarkItem, + services::RadioStationItem, + services::UserItem, + api::CreatePlaylistRequest, + api::UpdatePlaylistRequest, + api::MovePlaylistEntryRequest, + api::RemovePlaylistEntryRequest, + api::RatingRequest, + api::ScrobbleRequest, + api::SaveQueueRequest, + api::CreateShareRequest, + api::UpdateShareRequest, + api::ShareResponse, + api::AuthorizeRequest, + api::AuthorizeResponse, + api::TokenRequest, + api::StarredEntry, + api::NowPlayingEntry, + api::SyncAckRequest, + api::SyncSnapshot, + services::LibraryEventPage, + services::DiskListing, + services::DiskFolder, + services::DiskTrack, + services::FiledFolder, + services::ImageChoices, + services::ImageChoice, + services::ArtistImageCandidate, + api::DeezerPortraitPick, + services::FiledTree, + services::AttentionCategory, + services::AttentionCount, + services::AttentionTrack, + services::CreditCandidate, + services::CreditFragment, + services::CreditDecisionKind, + services::CreditDecisionRecord, + services::QualitySummary, + services::QualityAnalysis, + services::AnalysisState, + services::QualityVerdict, + services::QualityVerdictCount, + services::QualityTrack, + services::TrackQuality, + services::TrackQualityDetail, + api::CreditDecisionRequest, + services::FolderPatch, + services::FolderRemoved, + services::Placed, + api::CreateFolderRequest, + api::PlacementRequest, + services::TrackMetadataPatch, + services::UploadOffer, + services::UploadVerdict, + services::UploadDecision, + services::UploadSessionState, + services::CommittedUpload, + api::NegotiateUploadsRequest, + api::NegotiateUploadsResponse, + services::LibraryEvent, + api::TranscodeStatusResponse, + api::CreateUserRequest, + api::UpdateUserRequest, + api::SetSubsonicCredentialRequest, + api::SubsonicCredentialResponse, + crate::database::SubsonicCredentialStatus, + api::BookmarkRequest, + api::RadioStationRequest, + api::CreateApiTokenRequest, + api::CreateApiTokenResponse, + database::ApiTokenRecord, + api::CreateLibraryRequest, + api::CreateLibraryResponse, + api::SetLibraryMemberRequest, + sync::SyncChange, + sync::SyncPage, + media::StreamTicketResponse, + media::CanvasResponse, + api::LibraryEventAckRequest + )), + modifiers(&SecurityAddon), + tags( + (name = "probes", description = "Process and SQLite health"), + (name = "authentication", description = "Local WaveFlow sessions") + ,(name = "catalog", description = "Authoritative library scans and catalogue reads") + ,(name = "user-data", description = "Cross-protocol playlists and playback state") + ,(name = "sync", description = "Durable WaveFlow Desktop user-data synchronization") + ,(name = "administration", description = "Administrative user and credential management") + ,(name = "scrobbling", description = "Per-account authorisations at external listening services, and the queue behind them") + ,(name = "radio", description = "Internet radio stations, one list per account") + ,(name = "smart-playlists", description = "Playlists defined by rules, evaluated when read (RFC-025)") + ,(name = "podcasts", description = "Podcast channels and episodes, one list per instance (RFC-025)") + ) +)] +pub struct ApiDoc; + +/// Declares the bearer scheme and the two mutation headers. +/// +/// Without this, a client generated from the document alone authenticates +/// nowhere and never sends `X-WaveFlow-Operation-Id` — losing replay safety, +/// which is the one thing the native API offers over the Subsonic facade. +struct SecurityAddon; + +impl utoipa::Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme}; + + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + BEARER_SECURITY_SCHEME, + SecurityScheme::Http( + Http::builder() + .scheme(HttpAuthScheme::Bearer) + .description(Some( + "Access token from /api/v2/auth/login, /api/v2/auth/refresh or the \ + PKCE exchange, or a long-lived `wfapi_` token minted by the CLI.", + )) + .build(), + ), + ); + openapi.security = Some(vec![utoipa::openapi::security::SecurityRequirement::new( + BEARER_SECURITY_SCHEME, + Vec::::new(), + )]); + + clear_security_on_public_operations(openapi); + annotate_scope_refusals(openapi); + annotate_mutation_headers(openapi); + } +} + +const BEARER_SECURITY_SCHEME: &str = "bearer"; + +/// Operations that carry their own credential, or none at all. The global +/// requirement above would otherwise claim a token is needed to log in. +/// +/// The two `{ticket}` routes belong here on purpose: the sealed ticket in the +/// path *is* the credential, because neither `