feat: feature flags

This commit is contained in:
2026-05-10 02:26:18 +02:00
parent 597685520c
commit ebf74a59fd
10 changed files with 399 additions and 209 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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),
}
}

View File

@@ -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<domain::ports::FollowingFilter> = 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::<Vec<domain::ports::RemoteActorInfo>, domain::errors::DomainError>(vec![]),
);
match (users_result, actors_result) {
(Ok(users), Ok(remote_actors)) => {
@@ -480,26 +495,29 @@ pub mod html {
Extension(csrf): Extension<CsrfToken>,
) -> 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<application::ports::RemoteActorView> = 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<application::ports::RemoteActorView> = 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<AppState>,
@@ -662,6 +690,7 @@ pub mod html {
}
}
#[cfg(feature = "federation")]
pub async fn unfollow_remote_user(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
@@ -693,6 +722,7 @@ pub mod html {
}
}
#[cfg(feature = "federation")]
pub async fn accept_follower(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
@@ -719,6 +749,7 @@ pub mod html {
}
}
#[cfg(feature = "federation")]
pub async fn reject_follower(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
@@ -745,6 +776,7 @@ pub mod html {
}
}
#[cfg(feature = "federation")]
pub async fn get_following_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
@@ -793,6 +825,7 @@ pub mod html {
}
}
#[cfg(feature = "federation")]
pub async fn get_followers_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
@@ -845,6 +878,7 @@ pub mod html {
}
}
#[cfg(feature = "federation")]
pub async fn remove_follower(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
@@ -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(),

View File

@@ -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<dyn AuthService> = Arc::new(JwtAuthService::new(auth_config));
let password_hasher: Arc<dyn PasswordHasher> = 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<sqlx::SqlitePool> = None;
#[cfg(feature = "postgres-federation")]
let mut pg_pool: Option<sqlx::PgPool> = None;
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository):
(Arc<dyn MovieRepository>, Arc<dyn ReviewRepository>, Arc<dyn DiaryRepository>,
Arc<dyn StatsRepository>, Arc<dyn UserRepository>) =
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<dyn ActivityPubPort> = 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<dyn activitypub::FederationRepository>,
Arc<dyn domain::ports::SocialQueryPort>,
Arc<dyn activitypub::RemoteReviewRepository>,
) = 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<dyn ActivityPubPort> = 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<dyn domain::ports::EventPublisher> = Arc::new(event_publisher);
(ep, ap_router, ap_service_arc, social_query_arc)
};
#[cfg(not(feature = "federation"))]
let (event_publisher_arc, ap_router): (Arc<dyn domain::ports::EventPublisher>, 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<dyn MovieRepository>,
Arc<dyn ReviewRepository>,
Arc<dyn DiaryRepository>,
Arc<dyn StatsRepository>,
Arc<dyn UserRepository>,
Arc<dyn FederationRepository>,
Arc<dyn activitypub::RemoteReviewRepository>,
Arc<dyn domain::ports::SocialQueryPort>,
)>;
)> {
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<dyn UserRepository> =
Arc::new(SqliteUserRepository::new(pool.clone()));
let fed = Arc::new(SqliteFederationRepository::new(pool));
let federation_repo_dyn: Arc<dyn FederationRepository> = Arc::clone(&fed) as _;
let review_store: Arc<dyn activitypub::RemoteReviewRepository> = Arc::clone(&fed) as _;
let social_query: Arc<dyn domain::ports::SocialQueryPort> = 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<dyn MovieRepository>,
Arc<dyn ReviewRepository>,
Arc<dyn DiaryRepository>,
Arc<dyn StatsRepository>,
Arc<dyn UserRepository>,
)> {
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<dyn UserRepository> =
Arc::new(PostgresUserRepository::new(pool.clone()));
let fed = Arc::new(PostgresFederationRepository::new(pool));
let federation_repo_dyn: Arc<dyn FederationRepository> = Arc::clone(&fed) as _;
let review_store: Arc<dyn activitypub::RemoteReviewRepository> = Arc::clone(&fed) as _;
let social_query: Arc<dyn domain::ports::SocialQueryPort> = 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() {

View File

@@ -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(

View File

@@ -43,13 +43,44 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
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<AppState> {
Router::new()
.route(
"/users/{id}/follow",
routing::post(handlers::html::follow_remote_user),
@@ -78,27 +109,6 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
"/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<AppState> {
@@ -109,58 +119,64 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.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<AppState> {
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),
)
}

View File

@@ -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<dyn HtmlRenderer>,
pub rss_renderer: Arc<dyn RssFeedRenderer>,
pub ap_service: Arc<dyn ActivityPubPort>,
#[cfg(feature = "federation")]
pub ap_service: Arc<dyn activitypub::ActivityPubPort>,
#[cfg(feature = "federation")]
pub social_query: Arc<dyn domain::ports::SocialQueryPort>,
}

View File

@@ -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),
};