diff --git a/.dockerignore b/.dockerignore index 2f11176..970c269 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,7 +4,6 @@ target/ *.db *.db-shm *.db-wal -.cargo/ -.sqlx/ +# .cargo and .sqlx are needed at build time (SQLX_OFFLINE mode) docs/ dev.db diff --git a/Dockerfile b/Dockerfile index aaf6355..8d058cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ # ----- build ----- FROM rust:slim-bookworm AS builder -RUN apt-get update && apt-get install -y --no-install-recommends sqlite3 && rm -rf /var/lib/apt/lists/* - WORKDIR /build # Cache dependency compilation separately from source COPY Cargo.toml Cargo.lock ./ +COPY .cargo ./.cargo +COPY .sqlx ./.sqlx COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml COPY crates/adapters/activitypub-base/Cargo.toml crates/adapters/activitypub-base/Cargo.toml COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml @@ -36,25 +36,13 @@ RUN cargo fetch # Now copy real sources (invalidates cache only on source changes) COPY crates ./crates -# sqlx macros verify queries at compile time; create a real DB from migrations -RUN sqlite3 /build/dev.db \ - < crates/adapters/sqlite/migrations/0001_initial.sql && \ - sqlite3 /build/dev.db \ - < crates/adapters/sqlite/migrations/0002_users.sql && \ - sqlite3 /build/dev.db \ - < crates/adapters/sqlite/migrations/0003_activitypub.sql && \ - sqlite3 /build/dev.db \ - < crates/adapters/sqlite/migrations/0004_username.sql && \ - sqlite3 /build/dev.db \ - < crates/adapters/sqlite/migrations/0005_activitypub_v2.sql && \ - sqlite3 /build/dev.db \ - < crates/adapters/sqlite/migrations/0006_follower_activity_id.sql && \ - sqlite3 /build/dev.db \ - < crates/adapters/sqlite/migrations/0007_user_role.sql - -ENV DATABASE_URL=sqlite:///build/dev.db - -RUN cargo build --release -p presentation +# .cargo/config.toml sets SQLX_OFFLINE=true; .sqlx contains the pre-verified query cache. +# No live database needed at compile time. +# +# To build with PostgreSQL backend instead: +# --build-arg FEATURES=postgres,postgres-federation +ARG FEATURES=sqlite,sqlite-federation +RUN cargo build --release -p presentation --no-default-features --features "${FEATURES}" # ----- runtime ----- FROM debian:bookworm-slim diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 97f547d..2abf1ff 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -3,6 +3,15 @@ name = "presentation" version = "0.1.0" edition = "2024" +[features] +default = ["sqlite", "sqlite-federation"] +sqlite = ["dep:sqlite"] +postgres = ["dep:postgres"] +# Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working +federation = [] +sqlite-federation = ["sqlite", "dep:sqlite-federation", "dep:activitypub", "federation"] +postgres-federation = ["postgres", "dep:postgres-federation", "dep:activitypub", "federation"] + [dependencies] tower-http = { version = "0.6.8", features = ["fs", "trace", "tracing"] } infer = "0.19.0" @@ -28,19 +37,23 @@ auth = { workspace = true } metadata = { workspace = true } poster-fetcher = { workspace = true } poster-storage = { workspace = true } -sqlite = { workspace = true } -sqlite-federation = { workspace = true } -postgres = { workspace = true } -postgres-federation = { workspace = true } -activitypub = { workspace = true } -sqlx = { workspace = true } template-askama = { workspace = true } event-publisher = { workspace = true } rss = { workspace = true } export = { workspace = true } doc = { workspace = true } +sqlx = { workspace = true } utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } +# Optional — database backends +sqlite = { workspace = true, optional = true } +postgres = { workspace = true, optional = true } + +# Optional — federation +activitypub = { workspace = true, optional = true } +sqlite-federation = { workspace = true, optional = true } +postgres-federation = { workspace = true, optional = true } + [dev-dependencies] tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index 911b7e1..275cdb3 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -220,6 +220,7 @@ mod tests { panic!() } } + #[cfg(feature = "federation")] #[async_trait::async_trait] impl domain::ports::SocialQueryPort for Panic { async fn get_accepted_following_urls( @@ -435,7 +436,9 @@ mod tests { }, html_renderer: Arc::new(Panic), rss_renderer: Arc::new(Panic), + #[cfg(feature = "federation")] ap_service: Arc::new(activitypub::NoopActivityPubService), + #[cfg(feature = "federation")] social_query: Arc::new(Panic), } } diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index 2d98f94..9e9376b 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -15,27 +15,25 @@ pub mod html { use application::{ commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand}, - ports::{ - FollowersPageData, FollowingPageData, HtmlPageContext, LoginPageData, - NewReviewPageData, RegisterPageData, RemoteActorView, - }, + ports::{HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView}, use_cases::{ delete_review, export_diary as export_diary_uc, log_review, login as login_uc, register as register_uc, }, }; + #[cfg(feature = "federation")] + use application::ports::{FollowersPageData, FollowingPageData}; use domain::models::ExportFormat; use domain::{errors::DomainError, value_objects::UserId}; use crate::{ csrf::CsrfToken, - dtos::{ - ErrorQuery, FeedQueryParams, FollowForm, FollowerActionForm, LogReviewData, - LogReviewForm, LoginForm, RegisterForm, UnfollowForm, - }, + dtos::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm}, extractors::{OptionalCookieUser, RequiredCookieUser}, state::AppState, }; + #[cfg(feature = "federation")] + use crate::dtos::{FollowForm, FollowerActionForm, UnfollowForm}; async fn build_page_context( state: &AppState, @@ -349,11 +347,15 @@ pub mod html { let limit = params.limit.unwrap_or(20); let offset = params.offset.unwrap_or(0); + #[cfg(feature = "federation")] let filter_str = if params.filter == "following" && user_id.is_some() { "following" } else { "all" }; + #[cfg(not(feature = "federation"))] + let filter_str = "all"; + let sort_by_str = match params.sort_by.as_str() { "date_asc" => "date_asc", "rating" => "rating", @@ -361,6 +363,7 @@ pub mod html { _ => "date", }; + #[cfg(feature = "federation")] let following = if filter_str == "following" { if let Some(uid) = user_id { let urls = state.social_query @@ -389,6 +392,8 @@ pub mod html { } else { None }; + #[cfg(not(feature = "federation"))] + let following: Option = None; let search_opt = if params.search.is_empty() { None @@ -438,6 +443,7 @@ pub mod html { ctx.page_title = "Members — Movies Diary".to_string(); ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); + #[cfg(feature = "federation")] let (users_result, actors_result) = tokio::join!( application::use_cases::get_users::execute( &state.app_ctx, @@ -445,6 +451,15 @@ pub mod html { ), state.social_query.list_all_followed_remote_actors() ); + #[cfg(not(feature = "federation"))] + let (users_result, actors_result) = ( + application::use_cases::get_users::execute( + &state.app_ctx, + application::queries::GetUsersQuery, + ) + .await, + Ok::, domain::errors::DomainError>(vec![]), + ); match (users_result, actors_result) { (Ok(users), Ok(remote_actors)) => { @@ -480,26 +495,29 @@ pub mod html { Extension(csrf): Extension, ) -> impl IntoResponse { // Content negotiation: AP clients request application/activity+json - let accept = headers - .get(axum::http::header::ACCEPT) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if accept.contains("application/activity+json") || accept.contains("application/ld+json") { - return match state - .ap_service - .actor_json(&profile_user_uuid.to_string()) - .await - { - Ok(json) => ( - [( - axum::http::header::CONTENT_TYPE, - "application/activity+json", - )], - json, - ) - .into_response(), - Err(_) => StatusCode::NOT_FOUND.into_response(), - }; + #[cfg(feature = "federation")] + { + let accept = headers + .get(axum::http::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if accept.contains("application/activity+json") || accept.contains("application/ld+json") { + return match state + .ap_service + .actor_json(&profile_user_uuid.to_string()) + .await + { + Ok(json) => ( + [( + axum::http::header::CONTENT_TYPE, + "application/activity+json", + )], + json, + ) + .into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + }; + } } let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await; @@ -545,6 +563,7 @@ pub mod html { .map(|u| u.value() == profile_user_uuid) .unwrap_or(false); + #[cfg(feature = "federation")] let following_count = if is_own_profile { if let Some(ref uid) = user_id { state @@ -558,7 +577,10 @@ pub mod html { } else { 0 }; + #[cfg(not(feature = "federation"))] + let following_count = 0usize; + #[cfg(feature = "federation")] let followers_count = if is_own_profile { state .ap_service @@ -568,8 +590,11 @@ pub mod html { } else { 0 }; + #[cfg(not(feature = "federation"))] + let followers_count = 0usize; - let pending_followers = if is_own_profile { + #[cfg(feature = "federation")] + let pending_followers: Vec = if is_own_profile { state .ap_service .get_pending_followers(profile_user_uuid) @@ -585,6 +610,8 @@ pub mod html { } else { vec![] }; + #[cfg(not(feature = "federation"))] + let pending_followers: Vec = vec![]; let query = application::queries::GetUserProfileQuery { user_id: profile_user_uuid, @@ -639,6 +666,7 @@ pub mod html { } } + #[cfg(feature = "federation")] pub async fn follow_remote_user( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, @@ -662,6 +690,7 @@ pub mod html { } } + #[cfg(feature = "federation")] pub async fn unfollow_remote_user( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, @@ -693,6 +722,7 @@ pub mod html { } } + #[cfg(feature = "federation")] pub async fn accept_follower( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, @@ -719,6 +749,7 @@ pub mod html { } } + #[cfg(feature = "federation")] pub async fn reject_follower( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, @@ -745,6 +776,7 @@ pub mod html { } } + #[cfg(feature = "federation")] pub async fn get_following_page( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, @@ -793,6 +825,7 @@ pub mod html { } } + #[cfg(feature = "federation")] pub async fn get_followers_page( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, @@ -845,6 +878,7 @@ pub mod html { } } + #[cfg(feature = "federation")] pub async fn remove_follower( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, @@ -1010,17 +1044,19 @@ pub mod api { use crate::{ dtos::{ - ActivityFeedQueryParams, ActivityFeedResponse, ActorListResponse, ActorUrlRequest, + ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, - FeedEntryDto, FollowRequest, LogReviewData, LogReviewRequest, LoginRequest, + FeedEntryDto, LogReviewData, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, - RemoteActorDto, ReviewDto, ReviewHistoryResponse, UserProfileQueryParams, + ReviewDto, ReviewHistoryResponse, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, }, errors::ApiError, extractors::AuthenticatedUser, state::AppState, }; + #[cfg(feature = "federation")] + use crate::dtos::{ActorListResponse, ActorUrlRequest, FollowRequest, RemoteActorDto}; #[utoipa::path( get, path = "/api/v1/diary", @@ -1246,11 +1282,13 @@ pub mod api { } } + #[cfg(feature = "federation")] fn ap_err(e: anyhow::Error) -> impl IntoResponse { tracing::error!("ActivityPub error: {:?}", e); StatusCode::INTERNAL_SERVER_ERROR } + #[cfg(feature = "federation")] #[utoipa::path( get, path = "/api/v1/social/following", responses( @@ -1279,6 +1317,7 @@ pub mod api { } } + #[cfg(feature = "federation")] #[utoipa::path( get, path = "/api/v1/social/followers", responses( @@ -1307,6 +1346,7 @@ pub mod api { } } + #[cfg(feature = "federation")] #[utoipa::path( post, path = "/api/v1/social/follow", request_body = FollowRequest, @@ -1327,6 +1367,7 @@ pub mod api { } } + #[cfg(feature = "federation")] #[utoipa::path( post, path = "/api/v1/social/unfollow", request_body = ActorUrlRequest, @@ -1347,6 +1388,7 @@ pub mod api { } } + #[cfg(feature = "federation")] #[utoipa::path( post, path = "/api/v1/social/followers/accept", request_body = ActorUrlRequest, @@ -1367,6 +1409,7 @@ pub mod api { } } + #[cfg(feature = "federation")] #[utoipa::path( post, path = "/api/v1/social/followers/reject", request_body = ActorUrlRequest, @@ -1387,6 +1430,7 @@ pub mod api { } } + #[cfg(feature = "federation")] #[utoipa::path( post, path = "/api/v1/social/followers/remove", request_body = ActorUrlRequest, @@ -1407,6 +1451,7 @@ pub mod api { } } + #[cfg(feature = "federation")] #[utoipa::path( get, path = "/api/v1/social/followers/pending", responses( @@ -1549,12 +1594,19 @@ pub mod api { } }; + #[cfg(feature = "federation")] let following_count = state.ap_service.count_following(user_id).await.unwrap_or(0); + #[cfg(not(feature = "federation"))] + let following_count = 0usize; + + #[cfg(feature = "federation")] let followers_count = state .ap_service .count_accepted_followers(user_id) .await .unwrap_or(0); + #[cfg(not(feature = "federation"))] + let followers_count = 0usize; let entries = profile.entries.map(|p| DiaryResponse { items: p.items.iter().map(entry_to_dto).collect(), diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 5df6e04..c776203 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -5,16 +5,25 @@ use event_publisher::{EventPublisherConfig, NoopEventPublisher, create_event_cha use presentation::event_handlers::PosterSyncHandler; use std::str::FromStr; -use sqlx::SqlitePool; -use sqlx::sqlite::SqliteConnectOptions; use tokio::net::TcpListener; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +#[cfg(feature = "sqlite")] +use sqlite::{SqliteMovieRepository, SqliteUserRepository}; +#[cfg(feature = "sqlite-federation")] +use sqlite_federation::SqliteFederationRepository; + +#[cfg(feature = "postgres")] +use postgres::{PostgresRepository, PostgresUserRepository}; +#[cfg(feature = "postgres-federation")] +use postgres_federation::PostgresFederationRepository; + +#[cfg(feature = "federation")] use activitypub::{ ActivityPubEventHandler, ActivityPubPort, ActivityPubService, DomainUserRepoAdapter, ReviewObjectHandler, }; -use activitypub::FederationRepository; + use application::{config::AppConfig, context::AppContext}; use auth::{Argon2PasswordHasher, AuthConfig, JwtAuthService}; use export::ExportAdapter; @@ -22,10 +31,6 @@ use metadata::MetadataClientImpl; use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher}; use poster_storage::{PosterStorageAdapter, StorageConfig}; use rss::RssAdapter; -use sqlite::{SqliteMovieRepository, SqliteUserRepository}; -use sqlite_federation::SqliteFederationRepository; -use postgres::{PostgresRepository, PostgresUserRepository}; -use postgres_federation::PostgresFederationRepository; use template_askama::AskamaHtmlRenderer; use doc::ApiDocExt; @@ -38,6 +43,9 @@ use domain::ports::{ UserRepository, }; +#[cfg(not(any(feature = "sqlite", feature = "postgres")))] +compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres"); + #[tokio::main] async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); @@ -77,14 +85,35 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let auth_service: Arc = Arc::new(JwtAuthService::new(auth_config)); let password_hasher: Arc = Arc::new(Argon2PasswordHasher); - let (movie_repository, review_repository, diary_repository, stats_repository, - user_repository, federation_repo_dyn, review_store, social_query) = - if backend == "postgres" { - wire_postgres(&database_url).await? - } else { - wire_sqlite(&database_url).await? + // Only track pools when the federation feature for that backend needs them + #[cfg(feature = "sqlite-federation")] + let mut sqlite_pool: Option = None; + #[cfg(feature = "postgres-federation")] + let mut pg_pool: Option = None; + + let (movie_repository, review_repository, diary_repository, stats_repository, user_repository): + (Arc, Arc, Arc, + Arc, Arc) = + match backend.as_str() { + #[cfg(feature = "postgres")] + "postgres" => { + let (_pool, m, r, d, s, u) = wire_postgres(&database_url).await?; + #[cfg(feature = "postgres-federation")] + { pg_pool = Some(_pool); } + (m, r, d, s, u) + } + #[cfg(feature = "sqlite")] + _ => { + let (_pool, m, r, d, s, u) = wire_sqlite(&database_url).await?; + #[cfg(feature = "sqlite-federation")] + { sqlite_pool = Some(_pool); } + (m, r, d, s, u) + } + #[cfg(not(feature = "sqlite"))] + _ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"), }; + // Build handler context (used for poster sync handler) let handler_ctx = AppContext { movie_repository: Arc::clone(&movie_repository), review_repository: Arc::clone(&review_repository), @@ -101,38 +130,77 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { config: app_config.clone(), }; - let user_repo_adapter = Arc::new(DomainUserRepoAdapter(Arc::clone(&user_repository))); - let review_handler = Arc::new(ReviewObjectHandler { - movie_repository: Arc::clone(&movie_repository), - diary_repository: Arc::clone(&diary_repository), - review_store, - base_url: app_config.base_url.clone(), - }); - let concrete_ap_service = Arc::new( - ActivityPubService::new( - federation_repo_dyn, - user_repo_adapter, - review_handler, - app_config.base_url.clone(), - cfg!(debug_assertions), - ) - .await?, - ); - let ap_router = concrete_ap_service.router(); - let ap_event_handler = ActivityPubEventHandler::new( - Arc::clone(&concrete_ap_service), - Arc::clone(&movie_repository), - Arc::clone(&review_repository), - app_config.base_url.clone(), - ); - let ap_service: Arc = concrete_ap_service; + // Wire up event channel, federation service, and ap_router + #[cfg(feature = "federation")] + let (event_publisher_arc, ap_router, ap_service, social_query) = { + let (federation_repo, social_query_arc, review_store): ( + Arc, + Arc, + Arc, + ) = match backend.as_str() { + #[cfg(feature = "postgres-federation")] + "postgres" => { + let pool = pg_pool.as_ref().unwrap().clone(); + let fed = Arc::new(PostgresFederationRepository::new(pool)); + (Arc::clone(&fed) as _, Arc::clone(&fed) as _, fed as _) + } + #[cfg(feature = "sqlite-federation")] + _ => { + let pool = sqlite_pool.as_ref().unwrap().clone(); + let fed = Arc::new(SqliteFederationRepository::new(pool)); + (Arc::clone(&fed) as _, Arc::clone(&fed) as _, fed as _) + } + #[cfg(not(feature = "sqlite-federation"))] + _ => anyhow::bail!("DATABASE_BACKEND={backend} federation is not supported by this build"), + }; - let poster_handler = PosterSyncHandler::new(handler_ctx, 3); - let (event_publisher, event_worker) = create_event_channel( - EventPublisherConfig::from_env(), - vec![Box::new(poster_handler), Box::new(ap_event_handler)], - ); - tokio::spawn(event_worker.run()); + let user_repo_adapter = Arc::new(DomainUserRepoAdapter(Arc::clone(&user_repository))); + let review_handler = Arc::new(ReviewObjectHandler { + movie_repository: Arc::clone(&movie_repository), + diary_repository: Arc::clone(&diary_repository), + review_store, + base_url: app_config.base_url.clone(), + }); + let concrete_ap_service = Arc::new( + ActivityPubService::new( + federation_repo, + user_repo_adapter, + review_handler, + app_config.base_url.clone(), + cfg!(debug_assertions), + ) + .await?, + ); + let ap_router = concrete_ap_service.router(); + let ap_event_handler = ActivityPubEventHandler::new( + Arc::clone(&concrete_ap_service), + Arc::clone(&movie_repository), + Arc::clone(&review_repository), + app_config.base_url.clone(), + ); + let ap_service_arc: Arc = concrete_ap_service; + + let poster_handler = PosterSyncHandler::new(handler_ctx, 3); + let (event_publisher, event_worker) = create_event_channel( + EventPublisherConfig::from_env(), + vec![Box::new(poster_handler), Box::new(ap_event_handler)], + ); + tokio::spawn(event_worker.run()); + + let ep: Arc = Arc::new(event_publisher); + (ep, ap_router, ap_service_arc, social_query_arc) + }; + + #[cfg(not(feature = "federation"))] + let (event_publisher_arc, ap_router): (Arc, axum::Router) = { + let poster_handler = PosterSyncHandler::new(handler_ctx, 3); + let (event_publisher, event_worker) = create_event_channel( + EventPublisherConfig::from_env(), + vec![Box::new(poster_handler)], + ); + tokio::spawn(event_worker.run()); + (Arc::new(event_publisher), axum::Router::new()) + }; let app_ctx = AppContext { movie_repository, @@ -143,7 +211,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { metadata_client, poster_fetcher, poster_storage, - event_publisher: Arc::new(event_publisher), + event_publisher: event_publisher_arc, auth_service, password_hasher, user_repository, @@ -156,30 +224,31 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { rss_renderer: Arc::new(RssAdapter::new( std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()), )), + #[cfg(feature = "federation")] ap_service, + #[cfg(feature = "federation")] social_query, }; Ok((state, ap_router)) } -type WireResult = anyhow::Result<( +#[cfg(feature = "sqlite")] +async fn wire_sqlite(database_url: &str) -> anyhow::Result<( + sqlx::SqlitePool, Arc, Arc, Arc, Arc, Arc, - Arc, - Arc, - Arc, -)>; +)> { + use sqlx::sqlite::SqliteConnectOptions; -async fn wire_sqlite(database_url: &str) -> WireResult { let opts = SqliteConnectOptions::from_str(database_url) .context("Invalid DATABASE_URL")? .create_if_missing(true) .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) .busy_timeout(std::time::Duration::from_secs(5)); - let pool = SqlitePool::connect_with(opts) + let pool = sqlx::SqlitePool::connect_with(opts) .await .context("Failed to connect to SQLite database")?; @@ -197,16 +266,18 @@ async fn wire_sqlite(database_url: &str) -> WireResult { let user_repository: Arc = Arc::new(SqliteUserRepository::new(pool.clone())); - let fed = Arc::new(SqliteFederationRepository::new(pool)); - let federation_repo_dyn: Arc = Arc::clone(&fed) as _; - let review_store: Arc = Arc::clone(&fed) as _; - let social_query: Arc = fed; - - Ok((movie_repository, review_repository, diary_repository, stats_repository, - user_repository, federation_repo_dyn, review_store, social_query)) + Ok((pool, movie_repository, review_repository, diary_repository, stats_repository, user_repository)) } -async fn wire_postgres(database_url: &str) -> WireResult { +#[cfg(feature = "postgres")] +async fn wire_postgres(database_url: &str) -> anyhow::Result<( + sqlx::PgPool, + Arc, + Arc, + Arc, + Arc, + Arc, +)> { let pool = sqlx::PgPool::connect(database_url) .await .context("Failed to connect to PostgreSQL database")?; @@ -225,13 +296,7 @@ async fn wire_postgres(database_url: &str) -> WireResult { let user_repository: Arc = Arc::new(PostgresUserRepository::new(pool.clone())); - let fed = Arc::new(PostgresFederationRepository::new(pool)); - let federation_repo_dyn: Arc = Arc::clone(&fed) as _; - let review_store: Arc = Arc::clone(&fed) as _; - let social_query: Arc = fed; - - Ok((movie_repository, review_repository, diary_repository, stats_repository, - user_repository, federation_repo_dyn, review_store, social_query)) + Ok((pool, movie_repository, review_repository, diary_repository, stats_repository, user_repository)) } fn init_tracing() { diff --git a/crates/presentation/src/openapi.rs b/crates/presentation/src/openapi.rs index 6c4d2c0..1fcaf0c 100644 --- a/crates/presentation/src/openapi.rs +++ b/crates/presentation/src/openapi.rs @@ -4,12 +4,14 @@ use utoipa::{ }; use crate::dtos::{ - ActivityFeedResponse, ActorListResponse, ActorUrlRequest, DiaryEntryDto, DiaryResponse, - DirectorStatDto, FeedEntryDto, FollowRequest, LoginRequest, LoginResponse, LogReviewRequest, - MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, RemoteActorDto, ReviewDto, + ActivityFeedResponse, DiaryEntryDto, DiaryResponse, + DirectorStatDto, FeedEntryDto, LoginRequest, LoginResponse, LogReviewRequest, + MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, }; +#[cfg(feature = "federation")] +use crate::dtos::{ActorListResponse, ActorUrlRequest, FollowRequest, RemoteActorDto}; struct SecurityAddon; @@ -23,6 +25,53 @@ impl Modify for SecurityAddon { } } +#[cfg(not(feature = "federation"))] +#[derive(OpenApi)] +#[openapi( + info( + title = "Movies Diary API", + version = "1.0.0", + description = "REST API for Movies Diary. Authenticate with `POST /api/v1/auth/login` to get a Bearer token." + ), + paths( + crate::handlers::api::get_diary, + crate::handlers::api::get_review_history, + crate::handlers::api::post_review, + crate::handlers::api::delete_review, + crate::handlers::api::sync_poster, + crate::handlers::api::login, + crate::handlers::api::register, + crate::handlers::api::export_diary, + crate::handlers::api::get_activity_feed, + crate::handlers::api::list_users, + crate::handlers::api::get_user_profile, + ), + components(schemas( + DiaryResponse, + DiaryEntryDto, + MovieDto, + ReviewDto, + LogReviewRequest, + LoginRequest, + LoginResponse, + RegisterRequest, + ReviewHistoryResponse, + ActivityFeedResponse, + FeedEntryDto, + UsersResponse, + UserSummaryDto, + UserProfileResponse, + UserStatsDto, + MonthActivityDto, + MonthlyRatingDto, + DirectorStatDto, + UserTrendsDto, + )), + modifiers(&SecurityAddon), +)] +pub struct ApiDoc; + +#[cfg(feature = "federation")] #[derive(OpenApi)] #[openapi( info( diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 48b618d..60ca219 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -43,13 +43,44 @@ fn html_routes(rate_limit: u64) -> Router { GovernorLayer::new(cfg) }); - Router::new() + let base = Router::new() .route("/", routing::get(handlers::html::get_activity_feed)) .route("/users", routing::get(handlers::html::get_users_list)) .route( "/users/{id}", routing::get(handlers::html::get_user_profile), ) + .merge(auth) + .route( + "/reviews/new", + routing::get(handlers::html::get_new_review_page), + ) + .route("/reviews", routing::post(handlers::html::post_review)) + .route( + "/reviews/{id}/delete", + routing::post(handlers::html::post_delete_review), + ) + .route( + "/posters/{*path}", + routing::get(handlers::posters::get_poster), + ) + .route("/diary/export", routing::get(handlers::html::get_export)) + .route("/feed.rss", routing::get(handlers::rss::get_feed)) + .route( + "/users/{id}/feed.rss", + routing::get(handlers::rss::get_user_feed), + ) + .layer(axum::middleware::from_fn(crate::csrf::csrf_middleware)); + + #[cfg(feature = "federation")] + let base = base.merge(federation_html_routes()); + + base +} + +#[cfg(feature = "federation")] +fn federation_html_routes() -> Router { + Router::new() .route( "/users/{id}/follow", routing::post(handlers::html::follow_remote_user), @@ -78,27 +109,6 @@ fn html_routes(rate_limit: u64) -> Router { "/users/{id}/followers/remove", routing::post(handlers::html::remove_follower), ) - .merge(auth) - .route( - "/reviews/new", - routing::get(handlers::html::get_new_review_page), - ) - .route("/reviews", routing::post(handlers::html::post_review)) - .route( - "/reviews/{id}/delete", - routing::post(handlers::html::post_delete_review), - ) - .route( - "/posters/{*path}", - routing::get(handlers::posters::get_poster), - ) - .route("/diary/export", routing::get(handlers::html::get_export)) - .route("/feed.rss", routing::get(handlers::rss::get_feed)) - .route( - "/users/{id}/feed.rss", - routing::get(handlers::rss::get_user_feed), - ) - .layer(axum::middleware::from_fn(crate::csrf::csrf_middleware)) } fn api_routes(rate_limit: u64) -> Router { @@ -109,58 +119,64 @@ fn api_routes(rate_limit: u64) -> Router { .finish() .unwrap(); - Router::new().nest( - "/api/v1", - Router::new() - .route("/diary", routing::get(handlers::api::get_diary)) - .route( - "/movies/{id}/history", - routing::get(handlers::api::get_review_history), - ) - .route("/reviews", routing::post(handlers::api::post_review)) - .route( - "/reviews/{id}", - routing::delete(handlers::api::delete_review), - ) - .route( - "/movies/{id}/sync-poster", - routing::post(handlers::api::sync_poster), - ) - .route("/auth/login", routing::post(handlers::api::login)) - .route("/auth/register", routing::post(handlers::api::register)) - .route("/diary/export", routing::get(handlers::api::export_diary)) - .route( - "/activity-feed", - routing::get(handlers::api::get_activity_feed), - ) - .route("/users", routing::get(handlers::api::list_users)) - .route("/users/{id}", routing::get(handlers::api::get_user_profile)) - .route( - "/social/following", - routing::get(handlers::api::get_following), - ) - .route( - "/social/followers", - routing::get(handlers::api::get_followers), - ) - .route( - "/social/followers/pending", - routing::get(handlers::api::get_pending_followers), - ) - .route("/social/follow", routing::post(handlers::api::follow)) - .route("/social/unfollow", routing::post(handlers::api::unfollow)) - .route( - "/social/followers/accept", - routing::post(handlers::api::accept_follower), - ) - .route( - "/social/followers/reject", - routing::post(handlers::api::reject_follower), - ) - .route( - "/social/followers/remove", - routing::post(handlers::api::remove_follower), - ) - .layer(GovernorLayer::new(cfg)), - ) + let base = Router::new() + .route("/diary", routing::get(handlers::api::get_diary)) + .route( + "/movies/{id}/history", + routing::get(handlers::api::get_review_history), + ) + .route("/reviews", routing::post(handlers::api::post_review)) + .route( + "/reviews/{id}", + routing::delete(handlers::api::delete_review), + ) + .route( + "/movies/{id}/sync-poster", + routing::post(handlers::api::sync_poster), + ) + .route("/auth/login", routing::post(handlers::api::login)) + .route("/auth/register", routing::post(handlers::api::register)) + .route("/diary/export", routing::get(handlers::api::export_diary)) + .route( + "/activity-feed", + routing::get(handlers::api::get_activity_feed), + ) + .route("/users", routing::get(handlers::api::list_users)) + .route("/users/{id}", routing::get(handlers::api::get_user_profile)); + + #[cfg(feature = "federation")] + let base = base.merge(federation_api_routes()); + + Router::new().nest("/api/v1", base.layer(GovernorLayer::new(cfg))) +} + +#[cfg(feature = "federation")] +fn federation_api_routes() -> Router { + Router::new() + .route( + "/social/following", + routing::get(handlers::api::get_following), + ) + .route( + "/social/followers", + routing::get(handlers::api::get_followers), + ) + .route( + "/social/followers/pending", + routing::get(handlers::api::get_pending_followers), + ) + .route("/social/follow", routing::post(handlers::api::follow)) + .route("/social/unfollow", routing::post(handlers::api::unfollow)) + .route( + "/social/followers/accept", + routing::post(handlers::api::accept_follower), + ) + .route( + "/social/followers/reject", + routing::post(handlers::api::reject_follower), + ) + .route( + "/social/followers/remove", + routing::post(handlers::api::remove_follower), + ) } diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index 8c1abcb..e4c34f0 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use activitypub::ActivityPubPort; use application::context::AppContext; use crate::ports::{HtmlRenderer, RssFeedRenderer}; @@ -10,6 +9,8 @@ pub struct AppState { pub app_ctx: AppContext, pub html_renderer: Arc, pub rss_renderer: Arc, - pub ap_service: Arc, + #[cfg(feature = "federation")] + pub ap_service: Arc, + #[cfg(feature = "federation")] pub social_query: Arc, } diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index c548060..45edea5 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -125,7 +125,9 @@ impl domain::ports::DiaryExporter for PanicExporter { } } +#[cfg(feature = "federation")] struct PanicSocialQuery; +#[cfg(feature = "federation")] #[async_trait::async_trait] impl domain::ports::SocialQueryPort for PanicSocialQuery { async fn get_accepted_following_urls( @@ -171,7 +173,9 @@ async fn test_app() -> Router { }, html_renderer: Arc::new(AskamaHtmlRenderer::new()), rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())), + #[cfg(feature = "federation")] ap_service: Arc::new(activitypub::NoopActivityPubService), + #[cfg(feature = "federation")] social_query: Arc::new(PanicSocialQuery), };