federation refinement

This commit is contained in:
2026-05-09 13:53:45 +02:00
parent df71748897
commit 470b29c9e1
56 changed files with 1513 additions and 544 deletions

View File

@@ -51,6 +51,7 @@ pub struct LoginForm {
#[derive(Deserialize)]
pub struct RegisterForm {
pub email: String,
pub username: String,
pub password: String,
}
@@ -131,6 +132,7 @@ pub struct LoginResponse {
#[derive(Deserialize)]
pub struct RegisterRequest {
pub email: String,
pub username: String,
pub password: String,
}
@@ -244,6 +246,11 @@ pub struct UnfollowForm {
pub actor_url: String,
}
#[derive(Deserialize)]
pub struct FollowerActionForm {
pub actor_url: String,
}
#[derive(serde::Deserialize, Default)]
pub struct ProfileQueryParams {
pub view: Option<String>,

View File

@@ -142,6 +142,7 @@ mod tests {
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { panic!("unexpected") }
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!("unexpected") }
async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<User>, DomainError> { panic!("unexpected") }
async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<User>, DomainError> { panic!("unexpected") }
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { panic!("unexpected") }
}

View File

@@ -164,7 +164,7 @@ mod tests {
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> { panic!() } }
let state = crate::state::AppState {
app_ctx: AppContext {
@@ -247,10 +247,28 @@ mod tests {
async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { Ok(None) }
async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { Ok(()) }
async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { Ok(None) }
async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<domain::models::User>, domain::errors::DomainError> { Ok(None) }
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> { Ok(vec![]) }
}
struct DummyMovieRepo;
#[async_trait::async_trait] impl domain::ports::MovieRepository for DummyMovieRepo {
async fn get_movie_by_external_id(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { Ok(None) }
async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { Ok(None) }
async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result<Vec<domain::models::Movie>, domain::errors::DomainError> { Ok(vec![]) }
async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { Ok(()) }
async fn save_review(&self, _: &domain::models::Review) -> Result<domain::events::DomainEvent, domain::errors::DomainError> { panic!() }
async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result<domain::models::collections::Paginated<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result<domain::models::ReviewHistory, domain::errors::DomainError> { panic!() }
async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result<Option<domain::models::Review>, domain::errors::DomainError> { Ok(None) }
async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { Ok(()) }
async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { Ok(()) }
async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::FeedEntry>, domain::errors::DomainError> { panic!() }
async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserStats, domain::errors::DomainError> { panic!() }
async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result<Vec<domain::models::DiaryEntry>, domain::errors::DomainError> { Ok(vec![]) }
async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserTrends, domain::errors::DomainError> { panic!() }
}
Arc::new(
activitypub::ActivityPubService::new(fed_repo, Arc::new(DummyUserRepo), "http://localhost:3000".to_string(), true)
activitypub::ActivityPubService::new(fed_repo, Arc::new(DummyUserRepo), Arc::new(DummyMovieRepo), "http://localhost:3000".to_string(), true)
.await
.unwrap(),
)
@@ -284,7 +302,7 @@ mod tests {
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent2 { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher2 { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth2 { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo2 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo2 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> { panic!() } }
struct PanicRenderer2;
impl crate::ports::HtmlRenderer for PanicRenderer2 {
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
@@ -346,7 +364,7 @@ mod tests {
#[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage3 { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent3 { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher3 { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo3 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo3 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> { panic!() } }
struct PanicRenderer3;
impl crate::ports::HtmlRenderer for PanicRenderer3 {
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }

View File

@@ -2,6 +2,8 @@ const DEFAULT_PAGE_LIMIT: u32 = 5;
const RSS_FEED_LIMIT: u32 = 50;
pub mod html {
use std::str::FromStr;
use axum::{
extract::{Path, Query, State},
http::{HeaderValue, StatusCode, header::SET_COOKIE},
@@ -19,7 +21,7 @@ pub mod html {
use domain::{errors::DomainError, value_objects::UserId};
use crate::{
dtos::{DiaryQueryParams, ErrorQuery, FollowForm, LoginForm, LogReviewData, LogReviewForm, RegisterForm, UnfollowForm},
dtos::{DiaryQueryParams, ErrorQuery, FollowForm, FollowerActionForm, LoginForm, LogReviewData, LogReviewForm, RegisterForm, UnfollowForm},
extractors::{OptionalCookieUser, RequiredCookieUser},
state::AppState,
};
@@ -153,6 +155,7 @@ pub mod html {
&state.app_ctx,
RegisterCommand {
email: form.email,
username: form.username,
password: form.password,
},
)
@@ -294,10 +297,29 @@ pub mod html {
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
headers: axum::http::HeaderMap,
Query(params): Query<crate::dtos::ProfileQueryParams>,
) -> 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(),
};
}
let mut ctx = build_page_context(&state, user_id.clone()).await;
let view = params.view.unwrap_or_else(|| "recent".to_string());
let view_str = params.view.as_deref().unwrap_or("recent");
let profile_view = match application::queries::ProfileView::from_str(view_str) {
Ok(v) => v,
Err(_) => return (axum::http::StatusCode::BAD_REQUEST, "invalid view parameter").into_response(),
};
let profile_user = match state.app_ctx.user_repository
.find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid))
@@ -308,8 +330,7 @@ pub mod html {
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let display_name = profile_user.email().value()
.split('@').next().unwrap_or("User");
let display_name = profile_user.username().value();
ctx.page_title = format!("{}'s Diary — Movies Diary", display_name);
ctx.canonical_url = format!("{}/users/{}", state.app_ctx.config.base_url, profile_user_uuid);
@@ -328,9 +349,25 @@ pub mod html {
0
};
let pending_followers = if is_own_profile {
state.ap_service
.get_pending_followers(domain::value_objects::UserId::from_uuid(profile_user_uuid))
.await
.unwrap_or_default()
.into_iter()
.map(|a| application::ports::RemoteActorView {
handle: a.handle,
url: a.url,
display_name: a.display_name,
})
.collect()
} else {
vec![]
};
let query = application::queries::GetUserProfileQuery {
user_id: profile_user_uuid,
view: view.clone(),
view: profile_view,
limit: params.limit,
offset: params.offset,
};
@@ -349,7 +386,7 @@ pub mod html {
profile_user_id: profile_user_uuid,
profile_user_email: profile_user.email().value().to_string(),
stats: profile.stats,
view,
view: profile_view.as_str().to_string(),
entries: profile.entries,
current_offset: offset,
has_more,
@@ -359,6 +396,7 @@ pub mod html {
is_own_profile,
error: params.error,
following_count,
pending_followers,
};
match state.html_renderer.render_profile_page(data) {
Ok(html) => Html(html).into_response(),
@@ -406,6 +444,42 @@ pub mod html {
}
}
pub async fn accept_follower(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Form(form): Form<FollowerActionForm>,
) -> impl IntoResponse {
if user_id.value() != profile_user_uuid {
return StatusCode::FORBIDDEN.into_response();
}
match state.ap_service.accept_follower(user_id, &form.actor_url).await {
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
Err(e) => {
let msg = encode_error(&e.to_string());
Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response()
}
}
}
pub async fn reject_follower(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Form(form): Form<FollowerActionForm>,
) -> impl IntoResponse {
if user_id.value() != profile_user_uuid {
return StatusCode::FORBIDDEN.into_response();
}
match state.ap_service.reject_follower(user_id, &form.actor_url).await {
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
Err(e) => {
let msg = encode_error(&e.to_string());
Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response()
}
}
}
pub async fn get_following_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
@@ -459,6 +533,11 @@ pub mod posters {
State(state): State<AppState>,
Path(path): Path<String>,
) -> impl IntoResponse {
// If path is a remote URL, redirect directly instead of serving from local storage.
if path.starts_with("http://") || path.starts_with("https://") {
return axum::response::Redirect::temporary(&path).into_response();
}
let poster_path = match PosterPath::new(path) {
Ok(p) => p,
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
@@ -672,6 +751,7 @@ pub mod api {
) -> Result<StatusCode, ApiError> {
register_uc::execute(&state.app_ctx, RegisterCommand {
email: req.email,
username: req.username,
password: req.password,
})
.await?;

View File

@@ -98,6 +98,7 @@ async fn wire_dependencies() -> anyhow::Result<AppState> {
ActivityPubService::new(
federation_repo,
Arc::clone(&user_repository),
Arc::clone(&repository),
app_config.base_url.clone(),
cfg!(debug_assertions),
)

View File

@@ -112,6 +112,14 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
"/users/{id}/unfollow",
routing::post(handlers::html::unfollow_remote_user),
)
.route(
"/users/{id}/followers/accept",
routing::post(handlers::html::accept_follower),
)
.route(
"/users/{id}/followers/reject",
routing::post(handlers::html::reject_follower),
)
.route(
"/users/{id}/following-list",
routing::get(handlers::html::get_following_page),
@@ -127,7 +135,7 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
routing::post(handlers::html::post_delete_review),
)
.route(
"/posters/{path}",
"/posters/{*path}",
routing::get(handlers::posters::get_poster),
)
.route("/feed.rss", routing::get(handlers::rss::get_feed))

View File

@@ -83,6 +83,7 @@ struct NobodyUserRepo;
#[async_trait]
impl UserRepository for NobodyUserRepo {
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { Ok(None) }
async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<User>, DomainError> { Ok(None) }
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() }
async fn find_by_id(&self, _: &UserId) -> Result<Option<User>, DomainError> { panic!() }
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { panic!() }
@@ -103,12 +104,15 @@ async fn test_ap_service() -> Arc<activitypub::ActivityPubService> {
#[async_trait]
impl UserRepository for DummyUserRepo {
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { Ok(None) }
async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<User>, DomainError> { Ok(None) }
async fn save(&self, _: &User) -> Result<(), DomainError> { Ok(()) }
async fn find_by_id(&self, _: &UserId) -> Result<Option<User>, DomainError> { Ok(None) }
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { Ok(vec![]) }
}
let movie_pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
let movie_repo = Arc::new(sqlite::SqliteMovieRepository::new(movie_pool));
Arc::new(
activitypub::ActivityPubService::new(fed_repo, Arc::new(DummyUserRepo), "http://localhost:3000".to_string(), true)
activitypub::ActivityPubService::new(fed_repo, Arc::new(DummyUserRepo), movie_repo, "http://localhost:3000".to_string(), true)
.await
.unwrap(),
)