Compare commits

...

2 Commits

12 changed files with 592 additions and 212 deletions

View File

@@ -4,7 +4,6 @@ target/
*.db *.db
*.db-shm *.db-shm
*.db-wal *.db-wal
.cargo/ # .cargo and .sqlx are needed at build time (SQLX_OFFLINE mode)
.sqlx/
docs/ docs/
dev.db dev.db

View File

@@ -1,12 +1,12 @@
# ----- build ----- # ----- build -----
FROM rust:slim-bookworm AS builder 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 WORKDIR /build
# Cache dependency compilation separately from source # Cache dependency compilation separately from source
COPY Cargo.toml Cargo.lock ./ 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/Cargo.toml crates/adapters/activitypub/Cargo.toml
COPY crates/adapters/activitypub-base/Cargo.toml crates/adapters/activitypub-base/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 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) # Now copy real sources (invalidates cache only on source changes)
COPY crates ./crates COPY crates ./crates
# sqlx macros verify queries at compile time; create a real DB from migrations # .cargo/config.toml sets SQLX_OFFLINE=true; .sqlx contains the pre-verified query cache.
RUN sqlite3 /build/dev.db \ # No live database needed at compile time.
< crates/adapters/sqlite/migrations/0001_initial.sql && \ #
sqlite3 /build/dev.db \ # To build with PostgreSQL backend instead:
< crates/adapters/sqlite/migrations/0002_users.sql && \ # --build-arg FEATURES=postgres,postgres-federation
sqlite3 /build/dev.db \ ARG FEATURES=sqlite,sqlite-federation
< crates/adapters/sqlite/migrations/0003_activitypub.sql && \ RUN cargo build --release -p presentation --no-default-features --features "${FEATURES}"
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
# ----- runtime ----- # ----- runtime -----
FROM debian:bookworm-slim FROM debian:bookworm-slim

View File

@@ -7,6 +7,7 @@ use domain::{
}; };
mod omdb; mod omdb;
mod tmdb;
pub(crate) struct ProviderMovie { pub(crate) struct ProviderMovie {
pub imdb_id: ExternalMetadataId, pub imdb_id: ExternalMetadataId,
@@ -31,6 +32,12 @@ impl MetadataClientImpl {
provider: Box::new(omdb::OmdbProvider::new(api_key)), provider: Box::new(omdb::OmdbProvider::new(api_key)),
} }
} }
pub fn new_tmdb(api_key: String) -> Self {
Self {
provider: Box::new(tmdb::TmdbProvider::new(api_key)),
}
}
} }
#[async_trait] #[async_trait]

View File

@@ -0,0 +1,179 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::MetadataSearchCriteria,
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
};
use serde::Deserialize;
use crate::{MetadataProvider, ProviderMovie};
pub(crate) struct TmdbProvider {
client: reqwest::Client,
api_key: String,
}
impl TmdbProvider {
pub(crate) fn new(api_key: String) -> Self {
Self {
client: reqwest::Client::new(),
api_key,
}
}
fn base(&self, path: &str) -> String {
format!("https://api.themoviedb.org/3{}", path)
}
fn poster_url(&self, path: &str) -> Option<PosterUrl> {
if path.is_empty() || path == "null" {
return None;
}
PosterUrl::new(format!("https://image.tmdb.org/t/p/w500{}", path)).ok()
}
async fn get<T: for<'de> Deserialize<'de>>(
&self,
url: &str,
extra: &[(&str, &str)],
) -> Result<T, DomainError> {
let mut req = self
.client
.get(url)
.query(&[("api_key", self.api_key.as_str())]);
for (k, v) in extra {
req = req.query(&[(k, v)]);
}
req.send()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.error_for_status()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.json::<T>()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn fetch_details(&self, tmdb_id: u64) -> Result<ProviderMovie, DomainError> {
#[derive(Deserialize)]
struct CrewMember {
job: String,
name: String,
}
#[derive(Deserialize)]
struct Credits {
crew: Vec<CrewMember>,
}
#[derive(Deserialize)]
struct Details {
imdb_id: Option<String>,
title: String,
release_date: String, // "YYYY-MM-DD"
poster_path: Option<String>,
credits: Credits,
}
let url = self.base(&format!("/movie/{}", tmdb_id));
let d: Details = self
.get(&url, &[("append_to_response", "credits")])
.await?;
let year: u16 = d
.release_date
.split('-')
.next()
.and_then(|y| y.parse().ok())
.ok_or_else(|| {
DomainError::InfrastructureError(format!(
"Unparseable release_date: {}",
d.release_date
))
})?;
// Prefer IMDB ID; fall back to "tmdb:{id}" so the record is still usable.
let raw_id = d
.imdb_id
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format!("tmdb:{}", tmdb_id));
let imdb_id = ExternalMetadataId::new(raw_id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let title =
MovieTitle::new(d.title).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let release_year =
ReleaseYear::new(year).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let director = d
.credits
.crew
.into_iter()
.find(|c| c.job == "Director")
.map(|c| c.name);
let poster_url = d
.poster_path
.as_deref()
.and_then(|p| self.poster_url(p));
Ok(ProviderMovie {
imdb_id,
title,
release_year,
director,
poster_url,
})
}
}
#[async_trait]
impl MetadataProvider for TmdbProvider {
async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError> {
let tmdb_id: u64 = match criteria {
MetadataSearchCriteria::ImdbId(id) => {
#[derive(Deserialize)]
struct FindResult {
id: u64,
}
#[derive(Deserialize)]
struct FindResponse {
movie_results: Vec<FindResult>,
}
let url = self.base(&format!("/find/{}", id.value()));
let resp: FindResponse =
self.get(&url, &[("external_source", "imdb_id")]).await?;
resp.movie_results
.into_iter()
.next()
.ok_or_else(|| DomainError::NotFound(format!("TMDB: no movie for {}", id.value())))?
.id
}
MetadataSearchCriteria::Title { title, year } => {
#[derive(Deserialize)]
struct SearchResult {
id: u64,
}
#[derive(Deserialize)]
struct SearchResponse {
results: Vec<SearchResult>,
}
let url = self.base("/search/movie");
let mut extra = vec![("query", title.value())];
let year_str;
if let Some(y) = year {
year_str = y.value().to_string();
extra.push(("year", year_str.as_str()));
}
let resp: SearchResponse = self.get(&url, &extra).await?;
resp.results
.into_iter()
.next()
.ok_or_else(|| {
DomainError::NotFound(format!("TMDB: no results for '{}'", title.value()))
})?
.id
}
};
self.fetch_details(tmdb_id).await
}
}

View File

@@ -3,6 +3,15 @@ name = "presentation"
version = "0.1.0" version = "0.1.0"
edition = "2024" 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] [dependencies]
tower-http = { version = "0.6.8", features = ["fs", "trace", "tracing"] } tower-http = { version = "0.6.8", features = ["fs", "trace", "tracing"] }
infer = "0.19.0" infer = "0.19.0"
@@ -28,19 +37,23 @@ auth = { workspace = true }
metadata = { workspace = true } metadata = { workspace = true }
poster-fetcher = { workspace = true } poster-fetcher = { workspace = true }
poster-storage = { 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 } template-askama = { workspace = true }
event-publisher = { workspace = true } event-publisher = { workspace = true }
rss = { workspace = true } rss = { workspace = true }
export = { workspace = true } export = { workspace = true }
doc = { workspace = true } doc = { workspace = true }
sqlx = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } 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] [dev-dependencies]
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1" http-body-util = "0.1"

View File

@@ -220,6 +220,7 @@ mod tests {
panic!() panic!()
} }
} }
#[cfg(feature = "federation")]
#[async_trait::async_trait] #[async_trait::async_trait]
impl domain::ports::SocialQueryPort for Panic { impl domain::ports::SocialQueryPort for Panic {
async fn get_accepted_following_urls( async fn get_accepted_following_urls(
@@ -435,7 +436,9 @@ mod tests {
}, },
html_renderer: Arc::new(Panic), html_renderer: Arc::new(Panic),
rss_renderer: Arc::new(Panic), rss_renderer: Arc::new(Panic),
#[cfg(feature = "federation")]
ap_service: Arc::new(activitypub::NoopActivityPubService), ap_service: Arc::new(activitypub::NoopActivityPubService),
#[cfg(feature = "federation")]
social_query: Arc::new(Panic), social_query: Arc::new(Panic),
} }
} }

View File

@@ -15,27 +15,25 @@ pub mod html {
use application::{ use application::{
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand}, commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
ports::{ ports::{HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView},
FollowersPageData, FollowingPageData, HtmlPageContext, LoginPageData,
NewReviewPageData, RegisterPageData, RemoteActorView,
},
use_cases::{ use_cases::{
delete_review, export_diary as export_diary_uc, log_review, login as login_uc, delete_review, export_diary as export_diary_uc, log_review, login as login_uc,
register as register_uc, register as register_uc,
}, },
}; };
#[cfg(feature = "federation")]
use application::ports::{FollowersPageData, FollowingPageData};
use domain::models::ExportFormat; use domain::models::ExportFormat;
use domain::{errors::DomainError, value_objects::UserId}; use domain::{errors::DomainError, value_objects::UserId};
use crate::{ use crate::{
csrf::CsrfToken, csrf::CsrfToken,
dtos::{ dtos::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm},
ErrorQuery, FeedQueryParams, FollowForm, FollowerActionForm, LogReviewData,
LogReviewForm, LoginForm, RegisterForm, UnfollowForm,
},
extractors::{OptionalCookieUser, RequiredCookieUser}, extractors::{OptionalCookieUser, RequiredCookieUser},
state::AppState, state::AppState,
}; };
#[cfg(feature = "federation")]
use crate::dtos::{FollowForm, FollowerActionForm, UnfollowForm};
async fn build_page_context( async fn build_page_context(
state: &AppState, state: &AppState,
@@ -349,11 +347,15 @@ pub mod html {
let limit = params.limit.unwrap_or(20); let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0); let offset = params.offset.unwrap_or(0);
#[cfg(feature = "federation")]
let filter_str = if params.filter == "following" && user_id.is_some() { let filter_str = if params.filter == "following" && user_id.is_some() {
"following" "following"
} else { } else {
"all" "all"
}; };
#[cfg(not(feature = "federation"))]
let filter_str = "all";
let sort_by_str = match params.sort_by.as_str() { let sort_by_str = match params.sort_by.as_str() {
"date_asc" => "date_asc", "date_asc" => "date_asc",
"rating" => "rating", "rating" => "rating",
@@ -361,6 +363,7 @@ pub mod html {
_ => "date", _ => "date",
}; };
#[cfg(feature = "federation")]
let following = if filter_str == "following" { let following = if filter_str == "following" {
if let Some(uid) = user_id { if let Some(uid) = user_id {
let urls = state.social_query let urls = state.social_query
@@ -389,6 +392,8 @@ pub mod html {
} else { } else {
None None
}; };
#[cfg(not(feature = "federation"))]
let following: Option<domain::ports::FollowingFilter> = None;
let search_opt = if params.search.is_empty() { let search_opt = if params.search.is_empty() {
None None
@@ -438,6 +443,7 @@ pub mod html {
ctx.page_title = "Members — Movies Diary".to_string(); ctx.page_title = "Members — Movies Diary".to_string();
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
#[cfg(feature = "federation")]
let (users_result, actors_result) = tokio::join!( let (users_result, actors_result) = tokio::join!(
application::use_cases::get_users::execute( application::use_cases::get_users::execute(
&state.app_ctx, &state.app_ctx,
@@ -445,6 +451,15 @@ pub mod html {
), ),
state.social_query.list_all_followed_remote_actors() 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) { match (users_result, actors_result) {
(Ok(users), Ok(remote_actors)) => { (Ok(users), Ok(remote_actors)) => {
@@ -480,6 +495,8 @@ pub mod html {
Extension(csrf): Extension<CsrfToken>, Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Content negotiation: AP clients request application/activity+json // Content negotiation: AP clients request application/activity+json
#[cfg(feature = "federation")]
{
let accept = headers let accept = headers
.get(axum::http::header::ACCEPT) .get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
@@ -501,6 +518,7 @@ pub mod html {
Err(_) => StatusCode::NOT_FOUND.into_response(), Err(_) => StatusCode::NOT_FOUND.into_response(),
}; };
} }
}
let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await; let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
let view_str = params.view.as_deref().unwrap_or("recent"); let view_str = params.view.as_deref().unwrap_or("recent");
@@ -545,6 +563,7 @@ pub mod html {
.map(|u| u.value() == profile_user_uuid) .map(|u| u.value() == profile_user_uuid)
.unwrap_or(false); .unwrap_or(false);
#[cfg(feature = "federation")]
let following_count = if is_own_profile { let following_count = if is_own_profile {
if let Some(ref uid) = user_id { if let Some(ref uid) = user_id {
state state
@@ -558,7 +577,10 @@ pub mod html {
} else { } else {
0 0
}; };
#[cfg(not(feature = "federation"))]
let following_count = 0usize;
#[cfg(feature = "federation")]
let followers_count = if is_own_profile { let followers_count = if is_own_profile {
state state
.ap_service .ap_service
@@ -568,8 +590,11 @@ pub mod html {
} else { } else {
0 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 state
.ap_service .ap_service
.get_pending_followers(profile_user_uuid) .get_pending_followers(profile_user_uuid)
@@ -585,6 +610,8 @@ pub mod html {
} else { } else {
vec![] vec![]
}; };
#[cfg(not(feature = "federation"))]
let pending_followers: Vec<application::ports::RemoteActorView> = vec![];
let query = application::queries::GetUserProfileQuery { let query = application::queries::GetUserProfileQuery {
user_id: profile_user_uuid, user_id: profile_user_uuid,
@@ -639,6 +666,7 @@ pub mod html {
} }
} }
#[cfg(feature = "federation")]
pub async fn follow_remote_user( pub async fn follow_remote_user(
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -662,6 +690,7 @@ pub mod html {
} }
} }
#[cfg(feature = "federation")]
pub async fn unfollow_remote_user( pub async fn unfollow_remote_user(
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -693,6 +722,7 @@ pub mod html {
} }
} }
#[cfg(feature = "federation")]
pub async fn accept_follower( pub async fn accept_follower(
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -719,6 +749,7 @@ pub mod html {
} }
} }
#[cfg(feature = "federation")]
pub async fn reject_follower( pub async fn reject_follower(
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -745,6 +776,7 @@ pub mod html {
} }
} }
#[cfg(feature = "federation")]
pub async fn get_following_page( pub async fn get_following_page(
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -793,6 +825,7 @@ pub mod html {
} }
} }
#[cfg(feature = "federation")]
pub async fn get_followers_page( pub async fn get_followers_page(
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -845,6 +878,7 @@ pub mod html {
} }
} }
#[cfg(feature = "federation")]
pub async fn remove_follower( pub async fn remove_follower(
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -1010,17 +1044,19 @@ pub mod api {
use crate::{ use crate::{
dtos::{ dtos::{
ActivityFeedQueryParams, ActivityFeedResponse, ActorListResponse, ActorUrlRequest, ActivityFeedQueryParams, ActivityFeedResponse,
DiaryEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, DiaryEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams,
FeedEntryDto, FollowRequest, LogReviewData, LogReviewRequest, LoginRequest, FeedEntryDto, LogReviewData, LogReviewRequest, LoginRequest,
LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest,
RemoteActorDto, ReviewDto, ReviewHistoryResponse, UserProfileQueryParams, ReviewDto, ReviewHistoryResponse, UserProfileQueryParams,
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
}, },
errors::ApiError, errors::ApiError,
extractors::AuthenticatedUser, extractors::AuthenticatedUser,
state::AppState, state::AppState,
}; };
#[cfg(feature = "federation")]
use crate::dtos::{ActorListResponse, ActorUrlRequest, FollowRequest, RemoteActorDto};
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/diary", get, path = "/api/v1/diary",
@@ -1246,11 +1282,13 @@ pub mod api {
} }
} }
#[cfg(feature = "federation")]
fn ap_err(e: anyhow::Error) -> impl IntoResponse { fn ap_err(e: anyhow::Error) -> impl IntoResponse {
tracing::error!("ActivityPub error: {:?}", e); tracing::error!("ActivityPub error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
} }
#[cfg(feature = "federation")]
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/social/following", get, path = "/api/v1/social/following",
responses( responses(
@@ -1279,6 +1317,7 @@ pub mod api {
} }
} }
#[cfg(feature = "federation")]
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/social/followers", get, path = "/api/v1/social/followers",
responses( responses(
@@ -1307,6 +1346,7 @@ pub mod api {
} }
} }
#[cfg(feature = "federation")]
#[utoipa::path( #[utoipa::path(
post, path = "/api/v1/social/follow", post, path = "/api/v1/social/follow",
request_body = FollowRequest, request_body = FollowRequest,
@@ -1327,6 +1367,7 @@ pub mod api {
} }
} }
#[cfg(feature = "federation")]
#[utoipa::path( #[utoipa::path(
post, path = "/api/v1/social/unfollow", post, path = "/api/v1/social/unfollow",
request_body = ActorUrlRequest, request_body = ActorUrlRequest,
@@ -1347,6 +1388,7 @@ pub mod api {
} }
} }
#[cfg(feature = "federation")]
#[utoipa::path( #[utoipa::path(
post, path = "/api/v1/social/followers/accept", post, path = "/api/v1/social/followers/accept",
request_body = ActorUrlRequest, request_body = ActorUrlRequest,
@@ -1367,6 +1409,7 @@ pub mod api {
} }
} }
#[cfg(feature = "federation")]
#[utoipa::path( #[utoipa::path(
post, path = "/api/v1/social/followers/reject", post, path = "/api/v1/social/followers/reject",
request_body = ActorUrlRequest, request_body = ActorUrlRequest,
@@ -1387,6 +1430,7 @@ pub mod api {
} }
} }
#[cfg(feature = "federation")]
#[utoipa::path( #[utoipa::path(
post, path = "/api/v1/social/followers/remove", post, path = "/api/v1/social/followers/remove",
request_body = ActorUrlRequest, request_body = ActorUrlRequest,
@@ -1407,6 +1451,7 @@ pub mod api {
} }
} }
#[cfg(feature = "federation")]
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/social/followers/pending", get, path = "/api/v1/social/followers/pending",
responses( 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); 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 let followers_count = state
.ap_service .ap_service
.count_accepted_followers(user_id) .count_accepted_followers(user_id)
.await .await
.unwrap_or(0); .unwrap_or(0);
#[cfg(not(feature = "federation"))]
let followers_count = 0usize;
let entries = profile.entries.map(|p| DiaryResponse { let entries = profile.entries.map(|p| DiaryResponse {
items: p.items.iter().map(entry_to_dto).collect(), 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 presentation::event_handlers::PosterSyncHandler;
use std::str::FromStr; use std::str::FromStr;
use sqlx::SqlitePool;
use sqlx::sqlite::SqliteConnectOptions;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 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::{ use activitypub::{
ActivityPubEventHandler, ActivityPubPort, ActivityPubService, DomainUserRepoAdapter, ActivityPubEventHandler, ActivityPubPort, ActivityPubService, DomainUserRepoAdapter,
ReviewObjectHandler, ReviewObjectHandler,
}; };
use activitypub::FederationRepository;
use application::{config::AppConfig, context::AppContext}; use application::{config::AppConfig, context::AppContext};
use auth::{Argon2PasswordHasher, AuthConfig, JwtAuthService}; use auth::{Argon2PasswordHasher, AuthConfig, JwtAuthService};
use export::ExportAdapter; use export::ExportAdapter;
@@ -22,10 +31,6 @@ use metadata::MetadataClientImpl;
use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher}; use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher};
use poster_storage::{PosterStorageAdapter, StorageConfig}; use poster_storage::{PosterStorageAdapter, StorageConfig};
use rss::RssAdapter; use rss::RssAdapter;
use sqlite::{SqliteMovieRepository, SqliteUserRepository};
use sqlite_federation::SqliteFederationRepository;
use postgres::{PostgresRepository, PostgresUserRepository};
use postgres_federation::PostgresFederationRepository;
use template_askama::AskamaHtmlRenderer; use template_askama::AskamaHtmlRenderer;
use doc::ApiDocExt; use doc::ApiDocExt;
@@ -38,6 +43,9 @@ use domain::ports::{
UserRepository, 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] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
@@ -63,13 +71,17 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let auth_config = AuthConfig::from_env()?; let auth_config = AuthConfig::from_env()?;
let storage_config = StorageConfig::from_env()?; let storage_config = StorageConfig::from_env()?;
let app_config = AppConfig::from_env(); let app_config = AppConfig::from_env();
let omdb_api_key = std::env::var("OMDB_API_KEY").context("OMDB_API_KEY must be set")?;
let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string()); let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string());
let metadata_client: Arc<dyn MetadataClient> = let metadata_client: Arc<dyn MetadataClient> =
Arc::new(MetadataClientImpl::new_omdb(omdb_api_key)); if let Ok(tmdb_key) = std::env::var("TMDB_API_KEY") {
Arc::new(MetadataClientImpl::new_tmdb(tmdb_key))
} else {
let omdb_key = std::env::var("OMDB_API_KEY")
.context("Either TMDB_API_KEY or OMDB_API_KEY must be set")?;
Arc::new(MetadataClientImpl::new_omdb(omdb_key))
};
let poster_fetcher: Arc<dyn PosterFetcherClient> = let poster_fetcher: Arc<dyn PosterFetcherClient> =
Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?); Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?);
let poster_storage: Arc<dyn PosterStorage> = let poster_storage: Arc<dyn PosterStorage> =
@@ -77,14 +89,35 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let auth_service: Arc<dyn AuthService> = Arc::new(JwtAuthService::new(auth_config)); let auth_service: Arc<dyn AuthService> = Arc::new(JwtAuthService::new(auth_config));
let password_hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher); let password_hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher);
let (movie_repository, review_repository, diary_repository, stats_repository, // Only track pools when the federation feature for that backend needs them
user_repository, federation_repo_dyn, review_store, social_query) = #[cfg(feature = "sqlite-federation")]
if backend == "postgres" { let mut sqlite_pool: Option<sqlx::SqlitePool> = None;
wire_postgres(&database_url).await? #[cfg(feature = "postgres-federation")]
} else { let mut pg_pool: Option<sqlx::PgPool> = None;
wire_sqlite(&database_url).await?
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 { let handler_ctx = AppContext {
movie_repository: Arc::clone(&movie_repository), movie_repository: Arc::clone(&movie_repository),
review_repository: Arc::clone(&review_repository), review_repository: Arc::clone(&review_repository),
@@ -101,6 +134,30 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
config: app_config.clone(), config: app_config.clone(),
}; };
// 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 user_repo_adapter = Arc::new(DomainUserRepoAdapter(Arc::clone(&user_repository))); let user_repo_adapter = Arc::new(DomainUserRepoAdapter(Arc::clone(&user_repository)));
let review_handler = Arc::new(ReviewObjectHandler { let review_handler = Arc::new(ReviewObjectHandler {
movie_repository: Arc::clone(&movie_repository), movie_repository: Arc::clone(&movie_repository),
@@ -110,7 +167,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
}); });
let concrete_ap_service = Arc::new( let concrete_ap_service = Arc::new(
ActivityPubService::new( ActivityPubService::new(
federation_repo_dyn, federation_repo,
user_repo_adapter, user_repo_adapter,
review_handler, review_handler,
app_config.base_url.clone(), app_config.base_url.clone(),
@@ -125,7 +182,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
Arc::clone(&review_repository), Arc::clone(&review_repository),
app_config.base_url.clone(), app_config.base_url.clone(),
); );
let ap_service: Arc<dyn ActivityPubPort> = concrete_ap_service; let ap_service_arc: Arc<dyn ActivityPubPort> = concrete_ap_service;
let poster_handler = PosterSyncHandler::new(handler_ctx, 3); let poster_handler = PosterSyncHandler::new(handler_ctx, 3);
let (event_publisher, event_worker) = create_event_channel( let (event_publisher, event_worker) = create_event_channel(
@@ -134,6 +191,21 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
); );
tokio::spawn(event_worker.run()); 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 { let app_ctx = AppContext {
movie_repository, movie_repository,
review_repository, review_repository,
@@ -143,7 +215,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
metadata_client, metadata_client,
poster_fetcher, poster_fetcher,
poster_storage, poster_storage,
event_publisher: Arc::new(event_publisher), event_publisher: event_publisher_arc,
auth_service, auth_service,
password_hasher, password_hasher,
user_repository, user_repository,
@@ -156,30 +228,31 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
rss_renderer: Arc::new(RssAdapter::new( rss_renderer: Arc::new(RssAdapter::new(
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()), std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
)), )),
#[cfg(feature = "federation")]
ap_service, ap_service,
#[cfg(feature = "federation")]
social_query, social_query,
}; };
Ok((state, ap_router)) 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 MovieRepository>,
Arc<dyn ReviewRepository>, Arc<dyn ReviewRepository>,
Arc<dyn DiaryRepository>, Arc<dyn DiaryRepository>,
Arc<dyn StatsRepository>, Arc<dyn StatsRepository>,
Arc<dyn UserRepository>, Arc<dyn UserRepository>,
Arc<dyn FederationRepository>, )> {
Arc<dyn activitypub::RemoteReviewRepository>, use sqlx::sqlite::SqliteConnectOptions;
Arc<dyn domain::ports::SocialQueryPort>,
)>;
async fn wire_sqlite(database_url: &str) -> WireResult {
let opts = SqliteConnectOptions::from_str(database_url) let opts = SqliteConnectOptions::from_str(database_url)
.context("Invalid DATABASE_URL")? .context("Invalid DATABASE_URL")?
.create_if_missing(true) .create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(std::time::Duration::from_secs(5)); .busy_timeout(std::time::Duration::from_secs(5));
let pool = SqlitePool::connect_with(opts) let pool = sqlx::SqlitePool::connect_with(opts)
.await .await
.context("Failed to connect to SQLite database")?; .context("Failed to connect to SQLite database")?;
@@ -197,16 +270,18 @@ async fn wire_sqlite(database_url: &str) -> WireResult {
let user_repository: Arc<dyn UserRepository> = let user_repository: Arc<dyn UserRepository> =
Arc::new(SqliteUserRepository::new(pool.clone())); Arc::new(SqliteUserRepository::new(pool.clone()));
let fed = Arc::new(SqliteFederationRepository::new(pool)); Ok((pool, movie_repository, review_repository, diary_repository, stats_repository, user_repository))
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))
} }
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) let pool = sqlx::PgPool::connect(database_url)
.await .await
.context("Failed to connect to PostgreSQL database")?; .context("Failed to connect to PostgreSQL database")?;
@@ -225,13 +300,7 @@ async fn wire_postgres(database_url: &str) -> WireResult {
let user_repository: Arc<dyn UserRepository> = let user_repository: Arc<dyn UserRepository> =
Arc::new(PostgresUserRepository::new(pool.clone())); Arc::new(PostgresUserRepository::new(pool.clone()));
let fed = Arc::new(PostgresFederationRepository::new(pool)); Ok((pool, movie_repository, review_repository, diary_repository, stats_repository, user_repository))
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))
} }
fn init_tracing() { fn init_tracing() {

View File

@@ -4,12 +4,14 @@ use utoipa::{
}; };
use crate::dtos::{ use crate::dtos::{
ActivityFeedResponse, ActorListResponse, ActorUrlRequest, DiaryEntryDto, DiaryResponse, ActivityFeedResponse, DiaryEntryDto, DiaryResponse,
DirectorStatDto, FeedEntryDto, FollowRequest, LoginRequest, LoginResponse, LogReviewRequest, DirectorStatDto, FeedEntryDto, LoginRequest, LoginResponse, LogReviewRequest,
MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, RemoteActorDto, ReviewDto, MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, ReviewDto,
ReviewHistoryResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, ReviewHistoryResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
UsersResponse, UsersResponse,
}; };
#[cfg(feature = "federation")]
use crate::dtos::{ActorListResponse, ActorUrlRequest, FollowRequest, RemoteActorDto};
struct SecurityAddon; 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)] #[derive(OpenApi)]
#[openapi( #[openapi(
info( info(

View File

@@ -43,13 +43,44 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
GovernorLayer::new(cfg) GovernorLayer::new(cfg)
}); });
Router::new() let base = Router::new()
.route("/", routing::get(handlers::html::get_activity_feed)) .route("/", routing::get(handlers::html::get_activity_feed))
.route("/users", routing::get(handlers::html::get_users_list)) .route("/users", routing::get(handlers::html::get_users_list))
.route( .route(
"/users/{id}", "/users/{id}",
routing::get(handlers::html::get_user_profile), 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( .route(
"/users/{id}/follow", "/users/{id}/follow",
routing::post(handlers::html::follow_remote_user), routing::post(handlers::html::follow_remote_user),
@@ -78,27 +109,6 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
"/users/{id}/followers/remove", "/users/{id}/followers/remove",
routing::post(handlers::html::remove_follower), 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> { fn api_routes(rate_limit: u64) -> Router<AppState> {
@@ -109,9 +119,7 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.finish() .finish()
.unwrap(); .unwrap();
Router::new().nest( let base = Router::new()
"/api/v1",
Router::new()
.route("/diary", routing::get(handlers::api::get_diary)) .route("/diary", routing::get(handlers::api::get_diary))
.route( .route(
"/movies/{id}/history", "/movies/{id}/history",
@@ -134,7 +142,17 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
routing::get(handlers::api::get_activity_feed), routing::get(handlers::api::get_activity_feed),
) )
.route("/users", routing::get(handlers::api::list_users)) .route("/users", routing::get(handlers::api::list_users))
.route("/users/{id}", routing::get(handlers::api::get_user_profile)) .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( .route(
"/social/following", "/social/following",
routing::get(handlers::api::get_following), routing::get(handlers::api::get_following),
@@ -161,6 +179,4 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
"/social/followers/remove", "/social/followers/remove",
routing::post(handlers::api::remove_follower), routing::post(handlers::api::remove_follower),
) )
.layer(GovernorLayer::new(cfg)),
)
} }

View File

@@ -1,6 +1,5 @@
use std::sync::Arc; use std::sync::Arc;
use activitypub::ActivityPubPort;
use application::context::AppContext; use application::context::AppContext;
use crate::ports::{HtmlRenderer, RssFeedRenderer}; use crate::ports::{HtmlRenderer, RssFeedRenderer};
@@ -10,6 +9,8 @@ pub struct AppState {
pub app_ctx: AppContext, pub app_ctx: AppContext,
pub html_renderer: Arc<dyn HtmlRenderer>, pub html_renderer: Arc<dyn HtmlRenderer>,
pub rss_renderer: Arc<dyn RssFeedRenderer>, 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>, 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; struct PanicSocialQuery;
#[cfg(feature = "federation")]
#[async_trait::async_trait] #[async_trait::async_trait]
impl domain::ports::SocialQueryPort for PanicSocialQuery { impl domain::ports::SocialQueryPort for PanicSocialQuery {
async fn get_accepted_following_urls( async fn get_accepted_following_urls(
@@ -171,7 +173,9 @@ async fn test_app() -> Router {
}, },
html_renderer: Arc::new(AskamaHtmlRenderer::new()), html_renderer: Arc::new(AskamaHtmlRenderer::new()),
rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())), rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
#[cfg(feature = "federation")]
ap_service: Arc::new(activitypub::NoopActivityPubService), ap_service: Arc::new(activitypub::NoopActivityPubService),
#[cfg(feature = "federation")]
social_query: Arc::new(PanicSocialQuery), social_query: Arc::new(PanicSocialQuery),
}; };