export feature

This commit is contained in:
2026-05-09 20:51:29 +02:00
parent 1eaa3ca8a6
commit dcfc17f542
57 changed files with 2245 additions and 624 deletions

View File

@@ -1,7 +1,9 @@
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
kinds::activity::{AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType},
kinds::activity::{
AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType,
},
traits::Activity,
};
use serde::{Deserialize, Serialize};
@@ -42,10 +44,16 @@ impl Activity for FollowActivity {
let target_domain = match (target_url.host_str(), target_url.port()) {
(Some(host), Some(port)) => format!("{}:{}", host, port),
(Some(host), None) => host.to_string(),
_ => return Err(Error::bad_request(anyhow::anyhow!("invalid follow target URL"))),
_ => {
return Err(Error::bad_request(anyhow::anyhow!(
"invalid follow target URL"
)));
}
};
if target_domain != data.domain {
return Err(Error::bad_request(anyhow::anyhow!("follow target is not a local actor")));
return Err(Error::bad_request(anyhow::anyhow!(
"follow target is not a local actor"
)));
}
Ok(())
}
@@ -105,7 +113,11 @@ impl Activity for AcceptActivity {
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner())
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?;
data.federation_repo
.update_following_status(local_user_id, self.actor.inner().as_str(), FollowingStatus::Accepted)
.update_following_status(
local_user_id,
self.actor.inner().as_str(),
FollowingStatus::Accepted,
)
.await?;
tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote");
Ok(())

View File

@@ -3,7 +3,7 @@ use activitypub_federation::{
};
use axum::extract::Path;
use crate::actors::{get_local_actor, Person};
use crate::actors::{Person, get_local_actor};
use crate::data::FederationData;
use crate::error::Error;

View File

@@ -63,11 +63,7 @@ pub async fn get_local_actor(
None => {
let kp = generate_actor_keypair()?;
data.federation_repo
.save_local_actor_keypair(
user_id,
kp.public_key.clone(),
kp.private_key.clone(),
)
.save_local_actor_keypair(user_id, kp.public_key.clone(), kp.private_key.clone())
.await?;
(kp.public_key, kp.private_key)
}
@@ -179,10 +175,7 @@ impl Object for DbActor {
Ok(())
}
async fn from_json(
json: Self::Kind,
data: &Data<Self::DataType>,
) -> Result<Self, Self::Error> {
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
let actor = RemoteActor {
url: json.id.inner().to_string(),
handle: json.preferred_username.clone(),

View File

@@ -1,5 +1,5 @@
use activitypub_federation::{
axum::inbox::{receive_activity, ActivityData},
axum::inbox::{ActivityData, receive_activity},
config::Data,
protocol::context::WithContext,
};
@@ -13,8 +13,6 @@ pub async fn inbox_handler(
data: Data<FederationData>,
activity_data: ActivityData,
) -> Result<(), Error> {
receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(
activity_data, &data,
)
.await
receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(activity_data, &data)
.await
}

View File

@@ -10,14 +10,16 @@ pub mod inbox;
pub mod outbox;
pub mod repository;
pub mod service;
pub(crate) mod urls;
pub mod user;
pub mod webfinger;
pub(crate) mod urls;
pub use content::ApObjectHandler;
pub use data::FederationData;
pub use error::Error;
pub use federation::ApFederationConfig;
pub use repository::{FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor};
pub use repository::{
FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
pub use service::ActivityPubService;
pub use user::{ApUser, ApUserRepository};

View File

@@ -31,20 +31,61 @@ pub struct Follower {
#[async_trait]
pub trait FederationRepository: Send + Sync {
async fn add_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowerStatus, follow_activity_id: &str) -> Result<()>;
async fn get_follower_follow_activity_id(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<Option<String>>;
async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()>;
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follower_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<()>;
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
async fn update_follower_status(&self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowerStatus) -> Result<()>;
async fn add_following(&self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str) -> Result<()>;
async fn get_follow_activity_id(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<Option<String>>;
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()>;
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>;
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<(String, String)>>;
async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, public_key: String, private_key: String) -> Result<()>;
async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>>;
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()>;
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn update_following_status(&self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowingStatus) -> Result<()>;
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()>;
}

View File

@@ -6,12 +6,12 @@ use activitypub_federation::{
protocol::context::WithContext,
traits::Actor,
};
use axum::{routing::get, routing::post, Router};
use axum::{Router, routing::get, routing::post};
use url::Url;
use crate::{
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity},
actors::{get_local_actor, DbActor},
actors::{DbActor, get_local_actor},
content::ApObjectHandler,
data::FederationData,
federation::ApFederationConfig,
@@ -19,8 +19,8 @@ use crate::{
inbox::inbox_handler,
outbox::outbox_handler,
repository::{FederationRepository, FollowerStatus, FollowingStatus, RemoteActor},
user::ApUserRepository,
urls::activity_url,
user::ApUserRepository,
webfinger::webfinger_handler,
};
@@ -64,7 +64,10 @@ impl ActivityPubService {
) -> anyhow::Result<Self> {
let data = FederationData::new(repo, user_repo, object_handler, base_url.clone());
let federation_config = ApFederationConfig::new(data, debug).await?;
Ok(Self { federation_config, base_url })
Ok(Self {
federation_config,
base_url,
})
}
pub fn federation_config(&self) -> &ApFederationConfig {
@@ -82,7 +85,9 @@ impl ActivityPubService {
let actor = get_local_actor(uuid, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person = actor.into_json(&data).await
let person = actor
.into_json(&data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(serde_json::to_string(&WithContext::new_default(person))?)
}
@@ -133,7 +138,10 @@ impl ActivityPubService {
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some activity deliveries failed permanently");
tracing::warn!(
count = failures.len(),
"some activity deliveries failed permanently"
);
}
let remote = RemoteActor {
@@ -150,11 +158,17 @@ impl ActivityPubService {
Ok(())
}
pub async fn unfollow(&self, local_user_id: uuid::Uuid, actor_url_str: &str) -> anyhow::Result<()> {
pub async fn unfollow(
&self,
local_user_id: uuid::Uuid,
actor_url_str: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
if actor_url_str.starts_with(&self.base_url) {
return self.unfollow_local(local_user_id, actor_url_str, &data).await;
return self
.unfollow_local(local_user_id, actor_url_str, &data)
.await;
}
let remote = data
@@ -202,7 +216,10 @@ impl ActivityPubService {
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some activity deliveries failed permanently");
tracing::warn!(
count = failures.len(),
"some activity deliveries failed permanently"
);
}
data.federation_repo
@@ -236,7 +253,9 @@ impl ActivityPubService {
.federation_repo
.get_follower_follow_activity_id(local_user_id, remote_actor_url)
.await?
.ok_or_else(|| anyhow::anyhow!("follow activity id not found for {}", remote_actor_url))?;
.ok_or_else(|| {
anyhow::anyhow!("follow activity id not found for {}", remote_actor_url)
})?;
let follow_id = Url::parse(&follow_id_str)?;
let follow = FollowActivity {
id: follow_id,
@@ -265,7 +284,9 @@ impl ActivityPubService {
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!("failed to deliver Accept activity, but follower is marked accepted locally");
tracing::warn!(
"failed to deliver Accept activity, but follower is marked accepted locally"
);
}
self.spawn_backfill(local_user_id, remote_actor.inbox_url.clone());
@@ -313,7 +334,10 @@ impl ActivityPubService {
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some activity deliveries failed permanently");
tracing::warn!(
count = failures.len(),
"some activity deliveries failed permanently"
);
}
data.federation_repo
@@ -323,12 +347,20 @@ impl ActivityPubService {
Ok(())
}
pub async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
pub async fn get_pending_followers(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
data.federation_repo.get_pending_followers(local_user_id).await
data.federation_repo
.get_pending_followers(local_user_id)
.await
}
pub async fn get_accepted_followers(&self, local_user_id: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
pub async fn get_accepted_followers(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
let followers = data.federation_repo.get_followers(local_user_id).await?;
Ok(followers
@@ -338,13 +370,22 @@ impl ActivityPubService {
.collect())
}
pub async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> anyhow::Result<usize> {
pub async fn count_accepted_followers(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<usize> {
let data = self.federation_config.to_request_data();
let followers = data.federation_repo.get_followers(local_user_id).await?;
Ok(followers.into_iter().filter(|f| f.status == FollowerStatus::Accepted).count())
Ok(followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.count())
}
pub async fn get_following(&self, local_user_id: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
pub async fn get_following(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
data.federation_repo.get_following(local_user_id).await
}
@@ -354,9 +395,15 @@ impl ActivityPubService {
data.federation_repo.count_following(local_user_id).await
}
pub async fn remove_follower(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> {
pub async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.federation_repo.remove_follower(local_user_id, actor_url).await
data.federation_repo
.remove_follower(local_user_id, actor_url)
.await
}
/// Broadcast a single object to all accepted followers as a Create activity.
@@ -395,10 +442,14 @@ impl ActivityPubService {
.filter_map(|f| Url::parse(&f.actor.inbox_url).ok())
.collect();
let sends = SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?;
let sends =
SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some activity deliveries failed permanently");
tracing::warn!(
count = failures.len(),
"some activity deliveries failed permanently"
);
}
Ok(())
@@ -423,10 +474,17 @@ impl ActivityPubService {
let follower_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
let target_actor_url = crate::urls::actor_url(&self.base_url, target.id);
let target_inbox_url = format!("{}/inbox", target_actor_url);
let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?.to_string();
let follow_id = activity_url(&self.base_url)
.map_err(|e| anyhow::anyhow!("{e}"))?
.to_string();
data.federation_repo
.add_follower(target.id, &follower_actor_url, FollowerStatus::Accepted, &follow_id)
.add_follower(
target.id,
&follower_actor_url,
FollowerStatus::Accepted,
&follow_id,
)
.await?;
let target_as_remote = RemoteActor {
@@ -441,7 +499,11 @@ impl ActivityPubService {
.await?;
data.federation_repo
.update_following_status(local_user_id, &target_actor_url.to_string(), FollowingStatus::Accepted)
.update_following_status(
local_user_id,
&target_actor_url.to_string(),
FollowingStatus::Accepted,
)
.await?;
tracing::info!(follower = %local_user_id, followee = %target.id, "local follow");
@@ -460,8 +522,12 @@ impl ActivityPubService {
let local_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
data.federation_repo.remove_follower(target_user_id, &local_actor_url).await?;
data.federation_repo.remove_following(local_user_id, target_actor_url).await?;
data.federation_repo
.remove_follower(target_user_id, &local_actor_url)
.await?;
data.federation_repo
.remove_following(local_user_id, target_actor_url)
.await?;
tracing::info!(follower = %local_user_id, followee = %target_user_id, "local unfollow");
Ok(())
@@ -471,7 +537,14 @@ impl ActivityPubService {
let config = self.federation_config.clone();
let base_url = self.base_url.clone();
tokio::spawn(async move {
if let Err(e) = ActivityPubService::run_backfill(config, base_url, owner_user_id, follower_inbox_url).await {
if let Err(e) = ActivityPubService::run_backfill(
config,
base_url,
owner_user_id,
follower_inbox_url,
)
.await
{
tracing::warn!(error = %e, "backfill: task failed");
}
});
@@ -491,7 +564,10 @@ impl ActivityPubService {
.map_err(|e| anyhow::anyhow!("{e}"))?;
let inbox = Url::parse(&follower_inbox_url)?;
let mut objects = data.object_handler.get_local_objects_for_user(owner_user_id).await?;
let mut objects = data
.object_handler
.get_local_objects_for_user(owner_user_id)
.await?;
objects.reverse(); // oldest first → chronological feed
let total = objects.len();
@@ -501,7 +577,9 @@ impl ActivityPubService {
for chunk in objects.chunks(BATCH_SIZE) {
for (ap_id, object_json) in chunk {
// Use a stable Create activity ID derived from the object's ap_id
let create_id = Url::parse(&format!("{}/activities/create/{}", base_url,
let create_id = Url::parse(&format!(
"{}/activities/create/{}",
base_url,
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, ap_id.as_str().as_bytes())
))?;
@@ -517,7 +595,8 @@ impl ActivityPubService {
&local_actor,
vec![inbox.clone()],
&data,
).await?;
)
.await?;
let failures = send_with_retry(sends, &data).await;
if failures.is_empty() {
success_count += 1;

View File

@@ -1,6 +1,6 @@
use activitypub_federation::{
config::Data,
fetch::webfinger::{build_webfinger_response, extract_webfinger_name, Webfinger},
fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name},
};
use axum::{
extract::Query,
@@ -33,10 +33,6 @@ pub async fn webfinger_handler(
let ap_id = crate::urls::actor_url(&data.base_url, user.id);
let wf: Webfinger = build_webfinger_response(query.resource, ap_id);
let body = serde_json::to_string(&wf)
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
Ok((
[(header::CONTENT_TYPE, "application/jrd+json")],
body,
).into_response())
let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
}

View File

@@ -1,11 +1,11 @@
use async_trait::async_trait;
use domain::ports::EventHandler;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{MovieRepository, ReviewRepository},
value_objects::{ReviewId, UserId},
};
use domain::ports::EventHandler;
use std::sync::Arc;
use activitypub_base::ActivityPubService;
@@ -27,7 +27,12 @@ impl ActivityPubEventHandler {
review_repository: Arc<dyn ReviewRepository>,
base_url: String,
) -> Self {
Self { ap_service, movie_repository, review_repository, base_url }
Self {
ap_service,
movie_repository,
review_repository,
base_url,
}
}
}
@@ -35,7 +40,9 @@ impl ActivityPubEventHandler {
impl EventHandler for ActivityPubEventHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
match event {
DomainEvent::ReviewLogged { review_id, user_id, .. } => self
DomainEvent::ReviewLogged {
review_id, user_id, ..
} => self
.on_review_logged(user_id, review_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
@@ -45,11 +52,7 @@ impl EventHandler for ActivityPubEventHandler {
}
impl ActivityPubEventHandler {
async fn on_review_logged(
&self,
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
let review = match self.review_repository.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
@@ -58,16 +61,33 @@ impl ActivityPubEventHandler {
let ap_id = review_url(&self.base_url, review_id);
let actor = actor_url(&self.base_url, user_id.value());
let movie = self.movie_repository.get_movie_by_id(review.movie_id()).await.ok().flatten();
let movie_title = movie.as_ref()
let movie = self
.movie_repository
.get_movie_by_id(review.movie_id())
.await
.ok()
.flatten();
let movie_title = movie
.as_ref()
.map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0);
let poster_url = movie.as_ref()
let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let poster_url = movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/posters/{}", self.base_url, p.value()));
let obj = review_to_ap_object(&review, ap_id.clone(), actor, movie_title, release_year, poster_url);
let obj = review_to_ap_object(
&review,
ap_id.clone(),
actor,
movie_title,
release_year,
poster_url,
);
let json = serde_json::to_value(obj)?;
self.ap_service

View File

@@ -3,8 +3,8 @@ pub mod objects;
pub mod port;
pub mod remote_review_repository;
pub mod review_handler;
pub mod user_adapter;
pub(crate) mod urls;
pub mod user_adapter;
// Re-export the generic base types that callers need
pub use activitypub_base::{

View File

@@ -36,10 +36,17 @@ pub fn review_to_ap_object(
) -> ReviewObject {
let stars: String = "\u{2B50}".repeat(review.rating().value() as usize);
let comment_text = review.comment().map(|c| c.value().to_string());
let year_str = if release_year > 0 { format!(" ({})", release_year) } else { String::new() };
let year_str = if release_year > 0 {
format!(" ({})", release_year)
} else {
String::new()
};
let watched_str = format!("Watched: {}", review.watched_at().format("%b %-d, %Y"));
let content = match &comment_text {
Some(c) => format!("{} {}{}\n{}\n{}", stars, movie_title, year_str, c, watched_str),
Some(c) => format!(
"{} {}{}\n{}\n{}",
stars, movie_title, year_str, c, watched_str
),
None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str),
};

View File

@@ -11,10 +11,19 @@ pub trait ActivityPubPort: Send + Sync {
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>;
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn accept_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()>;
async fn reject_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()>;
async fn accept_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()>;
async fn reject_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()>;
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn get_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn get_accepted_followers(&self, local_user_id: Uuid)
-> anyhow::Result<Vec<RemoteActor>>;
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
}
@@ -38,16 +47,27 @@ impl ActivityPubPort for ActivityPubService {
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.unfollow(local_user_id, actor_url).await
}
async fn accept_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()> {
async fn accept_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
self.accept_follower(local_user_id, remote_actor_url).await
}
async fn reject_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()> {
async fn reject_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
self.reject_follower(local_user_id, remote_actor_url).await
}
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
self.get_following(local_user_id).await
}
async fn get_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
async fn get_accepted_followers(
&self,
local_user_id: Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
self.get_accepted_followers(local_user_id).await
}
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
@@ -59,15 +79,37 @@ pub struct NoopActivityPubService;
#[async_trait]
impl ActivityPubPort for NoopActivityPubService {
async fn actor_json(&self, _: &str) -> anyhow::Result<String> { Ok(String::new()) }
async fn count_following(&self, _: Uuid) -> anyhow::Result<usize> { Ok(0) }
async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result<usize> { Ok(0) }
async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> { Ok(vec![]) }
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) }
async fn unfollow(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) }
async fn accept_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) }
async fn reject_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) }
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> { Ok(vec![]) }
async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> { Ok(vec![]) }
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) }
async fn actor_json(&self, _: &str) -> anyhow::Result<String> {
Ok(String::new())
}
async fn count_following(&self, _: Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn unfollow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn accept_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn reject_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
}

View File

@@ -9,7 +9,7 @@ use domain::{
};
use url::Url;
use crate::objects::{review_to_ap_object, ReviewObject};
use crate::objects::{ReviewObject, review_to_ap_object};
use crate::remote_review_repository::RemoteReviewRepository;
use crate::urls::{actor_url, review_url};
@@ -27,7 +27,10 @@ impl ApObjectHandler for ReviewObjectHandler {
user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
let domain_user_id = UserId::from_uuid(user_id);
let history = self.diary_repository.get_user_history(&domain_user_id).await?;
let history = self
.diary_repository
.get_user_history(&domain_user_id)
.await?;
let mut results = Vec::new();
for entry in history {
@@ -39,18 +42,33 @@ impl ApObjectHandler for ReviewObjectHandler {
let ap_id = review_url(&self.base_url, review.id());
let actor_url = actor_url(&self.base_url, user_id);
let movie = self.movie_repository.get_movie_by_id(review.movie_id()).await.ok().flatten();
let movie_title = movie.as_ref()
let movie = self
.movie_repository
.get_movie_by_id(review.movie_id())
.await
.ok()
.flatten();
let movie_title = movie
.as_ref()
.map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let release_year = movie.as_ref()
let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let poster_url = movie.as_ref()
let poster_url = movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/posters/{}", self.base_url, p.value()));
let obj = review_to_ap_object(review, ap_id.clone(), actor_url, movie_title, release_year, poster_url);
let obj = review_to_ap_object(
review,
ap_id.clone(),
actor_url,
movie_title,
release_year,
poster_url,
);
let json = serde_json::to_value(obj)?;
results.push((ap_id, json));
}
@@ -73,8 +91,14 @@ impl ApObjectHandler for ReviewObjectHandler {
let actor_url_str = obj.attributed_to.to_string();
let review_id = ReviewId::generate();
let movie_id = MovieId::from_uuid(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, obj.movie_title.as_bytes()));
let user_id = UserId::from_uuid(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, actor_url_str.as_bytes()));
let movie_id = MovieId::from_uuid(uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
obj.movie_title.as_bytes(),
));
let user_id = UserId::from_uuid(uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
actor_url_str.as_bytes(),
));
let rating = Rating::new(obj.rating.min(5))?;
let comment = obj.comment.map(Comment::new).transpose()?;
@@ -86,11 +110,19 @@ impl ApObjectHandler for ReviewObjectHandler {
comment,
obj.watched_at.naive_utc(),
obj.published.naive_utc(),
ReviewSource::Remote { actor_url: actor_url_str },
ReviewSource::Remote {
actor_url: actor_url_str,
},
);
self.review_store
.save_remote_review(&review, obj.id.as_str(), &obj.movie_title, obj.release_year, obj.poster_url.as_deref())
.save_remote_review(
&review,
obj.id.as_str(),
&obj.movie_title,
obj.release_year,
obj.poster_url.as_deref(),
)
.await?;
Ok(())

View File

@@ -1,5 +1,5 @@
use url::Url;
use domain::value_objects::ReviewId;
use url::Url;
/// Builds the canonical actor URL: `{base_url}/users/{user_id}`
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {

View File

@@ -18,8 +18,8 @@ impl ApUserRepository for DomainUserRepoAdapter {
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
use domain::value_objects::Username;
let uname = Username::new(username.to_string())
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let uname =
Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(self.0.find_by_username(&uname).await?.map(|u| ApUser {
id: u.id().value(),
username: u.username().value().to_string(),

View File

@@ -1,8 +1,8 @@
use async_trait::async_trait;
use argon2::{
Argon2,
password_hash::{PasswordHasher as _, PasswordVerifier, SaltString},
};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use rand_core::OsRng;
@@ -31,7 +31,10 @@ impl AuthConfig {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(86400u64);
Ok(Self { secret, ttl_seconds })
Ok(Self {
secret,
ttl_seconds,
})
}
}

View File

@@ -120,12 +120,12 @@ pub fn create_event_channel(
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId},
};
use std::sync::{Arc, Mutex};
struct RecordingHandler {
calls: Arc<Mutex<Vec<String>>>,
@@ -147,7 +147,9 @@ mod tests {
#[tokio::test]
async fn single_handler_receives_event() {
let calls = Arc::new(Mutex::new(vec![]));
let handler = RecordingHandler { calls: Arc::clone(&calls) };
let handler = RecordingHandler {
calls: Arc::clone(&calls),
};
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, worker) = create_event_channel(config, vec![Box::new(handler)]);
@@ -168,13 +170,15 @@ mod tests {
async fn multiple_handlers_all_receive_event() {
let calls1 = Arc::new(Mutex::new(vec![]));
let calls2 = Arc::new(Mutex::new(vec![]));
let handler1 = RecordingHandler { calls: Arc::clone(&calls1) };
let handler2 = RecordingHandler { calls: Arc::clone(&calls2) };
let handler1 = RecordingHandler {
calls: Arc::clone(&calls1),
};
let handler2 = RecordingHandler {
calls: Arc::clone(&calls2),
};
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, worker) = create_event_channel(
config,
vec![Box::new(handler1), Box::new(handler2)],
);
let (publisher, worker) =
create_event_channel(config, vec![Box::new(handler1), Box::new(handler2)]);
let handle = tokio::spawn(worker.run());
@@ -201,12 +205,12 @@ mod tests {
}
let calls = Arc::new(Mutex::new(vec![]));
let good = RecordingHandler { calls: Arc::clone(&calls) };
let good = RecordingHandler {
calls: Arc::clone(&calls),
};
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, worker) = create_event_channel(
config,
vec![Box::new(FailingHandler), Box::new(good)],
);
let (publisher, worker) =
create_event_channel(config, vec![Box::new(FailingHandler), Box::new(good)]);
let handle = tokio::spawn(worker.run());

View File

@@ -0,0 +1,14 @@
[package]
name = "export"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
uuid = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,225 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{DiaryEntry, ExportFormat},
ports::DiaryExporter,
};
pub struct ExportAdapter;
#[async_trait]
impl DiaryExporter for ExportAdapter {
async fn serialize_entries(
&self,
entries: &[DiaryEntry],
format: ExportFormat,
) -> Result<Vec<u8>, DomainError> {
match format {
ExportFormat::Csv => serialize_csv(entries),
ExportFormat::Json => serialize_json(entries),
}
}
}
fn serialize_csv(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
let mut out =
String::from("title,year,director,rating,comment,watched_at,external_metadata_id\n");
for e in entries {
let title = csv_escape(e.movie().title().value());
let year = e.movie().release_year().value();
let director = e.movie().director().map(csv_escape).unwrap_or_default();
let rating = e.review().rating().value();
let comment = e
.review()
.comment()
.map(|c| csv_escape(c.value()))
.unwrap_or_default();
let watched_at = e.review().watched_at().format("%Y-%m-%d");
let ext_id = e
.movie()
.external_metadata_id()
.map(|id| id.value().to_string())
.unwrap_or_default();
out.push_str(&format!(
"{},{},{},{},{},{},{}\n",
title, year, director, rating, comment, watched_at, ext_id
));
}
Ok(out.into_bytes())
}
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
fn serialize_json(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
let arr: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
serde_json::json!({
"title": e.movie().title().value(),
"year": e.movie().release_year().value(),
"director": e.movie().director(),
"rating": e.review().rating().value(),
"comment": e.review().comment().map(|c| c.value()),
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value()),
})
})
.collect();
serde_json::to_vec_pretty(&arr).map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::ExportAdapter;
use domain::{
models::{DiaryEntry, ExportFormat, Movie, Review},
ports::DiaryExporter,
value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear},
};
fn make_entry(
title: &str,
year: u16,
director: Option<&str>,
rating: u8,
comment: Option<&str>,
) -> DiaryEntry {
make_entry_full(title, year, director, rating, comment, None)
}
fn make_entry_full(
title: &str,
year: u16,
director: Option<&str>,
rating: u8,
comment: Option<&str>,
external_id: Option<&str>,
) -> DiaryEntry {
let movie = Movie::new(
external_id.map(|id| ExternalMetadataId::new(id.to_string()).unwrap()),
MovieTitle::new(title.to_string()).unwrap(),
ReleaseYear::new(year).unwrap(),
director.map(str::to_string),
None,
);
let user_id = domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4());
let review = Review::new(
movie.id().clone(),
user_id,
Rating::new(rating).unwrap(),
comment.map(|c| domain::value_objects::Comment::new(c.to_string()).unwrap()),
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
)
.unwrap();
DiaryEntry::new(movie, review)
}
#[tokio::test]
async fn csv_has_header_and_one_row() {
let adapter = ExportAdapter;
let entry = make_entry(
"Inception",
2010,
Some("Christopher Nolan"),
5,
Some("great"),
);
let bytes = adapter
.serialize_entries(&[entry], ExportFormat::Csv)
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap();
assert!(
text.starts_with(
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
)
);
assert!(text.contains("Inception"));
assert!(text.contains("2010"));
assert!(text.contains("Christopher Nolan"));
assert!(text.contains("5"));
assert!(text.contains("great"));
assert!(text.contains("2024-03-15"));
}
#[tokio::test]
async fn csv_escapes_commas_in_title() {
let adapter = ExportAdapter;
let entry = make_entry("Tár, A Film", 2022, None, 4, None);
let bytes = adapter
.serialize_entries(&[entry], ExportFormat::Csv)
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap();
assert!(text.contains("\"Tár, A Film\""));
}
#[tokio::test]
async fn json_is_valid_array() {
let adapter = ExportAdapter;
let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None);
let bytes = adapter
.serialize_entries(&[entry], ExportFormat::Json)
.await
.unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["title"], "Dune");
assert_eq!(arr[0]["year"], 2021);
assert_eq!(arr[0]["rating"], 5);
assert_eq!(arr[0]["comment"], serde_json::Value::Null);
assert_eq!(arr[0]["external_metadata_id"], serde_json::Value::Null);
}
#[tokio::test]
async fn external_metadata_id_included_when_present() {
let adapter = ExportAdapter;
let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748"));
let bytes = adapter
.serialize_entries(&[entry], ExportFormat::Json)
.await
.unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr[0]["external_metadata_id"], "tt0078748");
let bytes = adapter
.serialize_entries(
&[make_entry_full(
"Alien",
1979,
None,
5,
None,
Some("tt0078748"),
)],
ExportFormat::Csv,
)
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap();
assert!(text.contains("tt0078748"));
}
#[tokio::test]
async fn empty_entries_returns_csv_header_only() {
let adapter = ExportAdapter;
let bytes = adapter
.serialize_entries(&[], ExportFormat::Csv)
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap();
assert_eq!(
text,
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
);
}
}

View File

@@ -40,7 +40,13 @@ impl MetadataClient for MetadataClientImpl {
criteria: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
let pm = self.provider.fetch(criteria).await?;
Ok(Movie::new(Some(pm.imdb_id), pm.title, pm.release_year, pm.director, None))
Ok(Movie::new(
Some(pm.imdb_id),
pm.title,
pm.release_year,
pm.director,
None,
))
}
async fn get_poster_url(

View File

@@ -101,8 +101,8 @@ impl MetadataProvider for OmdbProvider {
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let title = MovieTitle::new(resp.title)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let release_year = ReleaseYear::new(year)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let release_year =
ReleaseYear::new(year).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let director = match resp.director.as_str() {
"N/A" | "" => None,
@@ -114,6 +114,12 @@ impl MetadataProvider for OmdbProvider {
url => PosterUrl::new(url.to_string()).ok(),
};
Ok(ProviderMovie { imdb_id, title, release_year, director, poster_url })
Ok(ProviderMovie {
imdb_id,
title,
release_year,
director,
poster_url,
})
}
}

View File

@@ -22,9 +22,9 @@ impl StorageConfig {
&std::env::var("POSTER_STORAGE_PATH")
.context("POSTER_STORAGE_PATH required when POSTER_STORAGE_BACKEND=local")?,
)?,
other => anyhow::bail!(
"Unknown POSTER_STORAGE_BACKEND: {other:?}. Valid values: s3, local"
),
other => {
anyhow::bail!("Unknown POSTER_STORAGE_BACKEND: {other:?}. Valid values: s3, local")
}
};
Ok(Self(store))
@@ -55,8 +55,7 @@ fn build_s3_store(
}
fn build_local_store(path: &str) -> anyhow::Result<Arc<dyn ObjectStore>> {
std::fs::create_dir_all(path)
.context("Failed to create poster storage directory")?;
std::fs::create_dir_all(path).context("Failed to create poster storage directory")?;
let store = LocalFileSystem::new_with_prefix(path)
.context("Failed to initialise local file system store")?;
Ok(Arc::new(store))
@@ -68,8 +67,7 @@ mod tests {
#[test]
fn local_store_creates_dir_and_succeeds() {
let dir = std::env::temp_dir()
.join(format!("poster_test_{}", uuid::Uuid::new_v4()));
let dir = std::env::temp_dir().join(format!("poster_test_{}", uuid::Uuid::new_v4()));
let result = build_local_store(dir.to_str().unwrap());
assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
assert!(dir.exists(), "directory should have been created");
@@ -77,8 +75,7 @@ mod tests {
#[test]
fn local_store_succeeds_if_dir_already_exists() {
let dir = std::env::temp_dir()
.join(format!("poster_test_{}", uuid::Uuid::new_v4()));
let dir = std::env::temp_dir().join(format!("poster_test_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let result = build_local_store(dir.to_str().unwrap());
assert!(result.is_ok());

View File

@@ -7,7 +7,7 @@ use domain::{
ports::PosterStorage,
value_objects::{MovieId, PosterPath},
};
use object_store::{Attribute, Attributes, PutOptions, path::Path, ObjectStore};
use object_store::{Attribute, Attributes, ObjectStore, PutOptions, path::Path};
use std::sync::Arc;
fn detect_mime(bytes: &[u8]) -> &'static str {
@@ -41,7 +41,10 @@ impl PosterStorage for PosterStorageAdapter {
let mime = detect_mime(image_bytes);
let mut attributes = Attributes::new();
attributes.insert(Attribute::ContentType, mime.into());
let opts = PutOptions { attributes, ..Default::default() };
let opts = PutOptions {
attributes,
..Default::default()
};
self.store
.put_opts(&path, image_bytes.to_vec().into(), opts)
.await
@@ -52,7 +55,9 @@ impl PosterStorage for PosterStorageAdapter {
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError> {
let path = Path::from(poster_path.value().to_string());
let result = self.store.get(&path).await.map_err(|e| match e {
object_store::Error::NotFound { .. } => DomainError::NotFound("Poster not found".into()),
object_store::Error::NotFound { .. } => {
DomainError::NotFound("Poster not found".into())
}
_ => DomainError::InfrastructureError(e.to_string()),
})?;
result

View File

@@ -1,10 +1,12 @@
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use chrono::{NaiveDateTime, Utc};
use sqlx::{Row, SqlitePool};
use activitypub_base::{FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor};
use activitypub::RemoteReviewRepository;
use activitypub_base::{
FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use domain::models::{Review, ReviewSource};
fn datetime_to_str(dt: &NaiveDateTime) -> String {
@@ -85,7 +87,11 @@ impl FederationRepository for SqliteFederationRepository {
Ok(row.flatten())
}
async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()> {
async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?")
.bind(&uid)
@@ -116,11 +122,18 @@ impl FederationRepository for SqliteFederationRepository {
let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
let shared_inbox_url: Option<String> =
row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
Follower {
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name },
actor: RemoteActor {
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
},
status: str_to_status(&status_str),
}
})
@@ -154,7 +167,12 @@ impl FederationRepository for SqliteFederationRepository {
Ok(())
}
async fn add_following(&self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str) -> Result<()> {
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()> {
let uid = local_user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
@@ -175,7 +193,11 @@ impl FederationRepository for SqliteFederationRepository {
Ok(())
}
async fn get_follow_activity_id(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<Option<String>> {
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"SELECT follow_activity_id FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?",
@@ -210,13 +232,16 @@ impl FederationRepository for SqliteFederationRepository {
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|row| RemoteActor {
url: row.get("url"),
handle: row.get("handle"),
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
}).collect())
Ok(rows
.into_iter()
.map(|row| RemoteActor {
url: row.get("url"),
handle: row.get("handle"),
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
})
.collect())
}
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
@@ -274,16 +299,25 @@ impl FederationRepository for SqliteFederationRepository {
}))
}
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<(String, String)>> {
async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>> {
let uid = user_id.to_string();
let row = sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?")
.bind(&uid)
.fetch_optional(&self.pool)
.await?;
let row =
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?")
.bind(&uid)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| (r.get("public_key"), r.get("private_key"))))
}
async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, public_key: String, private_key: String) -> Result<()> {
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()> {
let uid = user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
@@ -319,13 +353,16 @@ impl FederationRepository for SqliteFederationRepository {
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|row| RemoteActor {
url: row.get("remote_actor_url"),
handle: row.try_get("handle").unwrap_or_default(),
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
}).collect())
Ok(rows
.into_iter()
.map(|row| RemoteActor {
url: row.get("remote_actor_url"),
handle: row.try_get("handle").unwrap_or_default(),
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
})
.collect())
}
async fn update_following_status(

View File

@@ -3,8 +3,8 @@ use domain::{
errors::DomainError,
events::DomainEvent,
models::{
DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, Movie, MonthlyRating,
Review, ReviewHistory, ReviewSource, SortDirection, UserStats, UserTrends,
DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, MonthlyRating, Movie, Review,
ReviewHistory, ReviewSource, SortDirection, UserStats, UserTrends,
collections::{PageParams, Paginated},
},
ports::{DiaryRepository, MovieRepository, ReviewRepository, StatsRepository},
@@ -17,20 +17,31 @@ mod models;
mod users;
use models::{
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, ReviewRow,
UserTotalsRow, datetime_to_str,
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, ReviewRow, UserTotalsRow,
datetime_to_str,
};
pub use users::SqliteUserRepository;
fn format_year_month(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 { return ym.to_string(); }
if parts.len() != 2 {
return ym.to_string();
}
let year = parts[0].get(2..).unwrap_or(parts[0]);
let month = match parts[1] {
"01" => "Jan", "02" => "Feb", "03" => "Mar", "04" => "Apr",
"05" => "May", "06" => "Jun", "07" => "Jul", "08" => "Aug",
"09" => "Sep", "10" => "Oct", "11" => "Nov", "12" => "Dec",
"01" => "Jan",
"02" => "Feb",
"03" => "Mar",
"04" => "Apr",
"05" => "May",
"06" => "Jun",
"07" => "Jul",
"08" => "Aug",
"09" => "Sep",
"10" => "Oct",
"11" => "Nov",
"12" => "Dec",
_ => parts[1],
};
format!("{} '{}", month, year)
@@ -60,12 +71,10 @@ impl SqliteMovieRepository {
.fetch_one(&self.pool)
.await
.map_err(Self::map_err),
Some(id) => {
sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE movie_id = ?", id)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
}
Some(id) => sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE movie_id = ?", id)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err),
}
}
@@ -155,13 +164,10 @@ impl SqliteMovieRepository {
}
async fn count_user_diary_entries(&self, user_id: &str) -> Result<i64, DomainError> {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM reviews WHERE user_id = ?",
user_id
)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE user_id = ?", user_id)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
}
async fn fetch_user_diary_rows_by_watched(
@@ -215,11 +221,7 @@ impl SqliteMovieRepository {
.map_err(Self::map_err)
}
async fn fetch_feed_rows(
&self,
limit: i64,
offset: i64,
) -> Result<Vec<FeedRow>, DomainError> {
async fn fetch_feed_rows(&self, limit: i64, offset: i64) -> Result<Vec<FeedRow>, DomainError> {
sqlx::query_as!(
FeedRow,
r#"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
@@ -451,11 +453,21 @@ impl ReviewRepository for SqliteMovieRepository {
.map_err(Self::map_err)?;
Ok(())
}
async fn get_all_reviews_for_user(
&self,
_user_id: &UserId,
) -> Result<Vec<Review>, DomainError> {
todo!()
}
}
#[async_trait]
impl DiaryRepository for SqliteMovieRepository {
async fn query_diary(&self, filter: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
async fn query_diary(
&self,
filter: &DiaryFilter,
) -> Result<Paginated<DiaryEntry>, DomainError> {
let limit = filter.page.limit as i64;
let offset = filter.page.offset as i64;
@@ -647,9 +659,16 @@ impl StatsRepository for SqliteMovieRepository {
let top_directors = director_rows
.into_iter()
.map(|d| DirectorStat { director: d.director, count: d.count })
.map(|d| DirectorStat {
director: d.director,
count: d.count,
})
.collect();
Ok(UserTrends { monthly_ratings, top_directors, max_director_count })
Ok(UserTrends {
monthly_ratings,
top_directors,
max_director_count,
})
}
}

View File

@@ -2,20 +2,22 @@ use async_trait::async_trait;
use chrono::Utc;
use sqlx::SqlitePool;
use super::models::UserSummaryRow;
use domain::{
errors::DomainError,
models::User,
ports::UserRepository,
value_objects::{Email, PasswordHash, UserId, Username},
};
use super::models::UserSummaryRow;
pub struct SqliteUserRepository {
pool: SqlitePool,
}
impl SqliteUserRepository {
pub fn new(pool: SqlitePool) -> Self { Self { pool } }
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
@@ -30,13 +32,18 @@ impl SqliteUserRepository {
) -> Result<User, DomainError> {
let id = uuid::Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let email = Email::new(email_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let email =
Email::new(email_str).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let username = Username::new(username_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let hash = PasswordHash::new(hash_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(User::from_persistence(UserId::from_uuid(id), email, username, hash))
Ok(User::from_persistence(
UserId::from_uuid(id),
email,
username,
hash,
))
}
}
@@ -52,8 +59,15 @@ impl UserRepository for SqliteUserRepository {
.await
.map_err(Self::map_err)?;
row.map(|r| Self::row_to_user(r.id.unwrap_or_default(), r.email, r.username, r.password_hash))
.transpose()
row.map(|r| {
Self::row_to_user(
r.id.unwrap_or_default(),
r.email,
r.username,
r.password_hash,
)
})
.transpose()
}
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
@@ -66,18 +80,29 @@ impl UserRepository for SqliteUserRepository {
.await
.map_err(Self::map_err)?;
row.map(|r| Self::row_to_user(r.id.unwrap_or_default(), r.email, r.username, r.password_hash))
.transpose()
row.map(|r| {
Self::row_to_user(
r.id.unwrap_or_default(),
r.email,
r.username,
r.password_hash,
)
})
.transpose()
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
// Check email uniqueness first (clearer error than INSERT OR IGNORE)
if self.find_by_email(user.email()).await?.is_some() {
return Err(DomainError::ValidationError("Email already registered".into()));
return Err(DomainError::ValidationError(
"Email already registered".into(),
));
}
// Check username uniqueness
if self.find_by_username(user.username()).await?.is_some() {
return Err(DomainError::ValidationError("Username already taken".into()));
return Err(DomainError::ValidationError(
"Username already taken".into(),
));
}
let id = user.id().value().to_string();
@@ -107,8 +132,15 @@ impl UserRepository for SqliteUserRepository {
.await
.map_err(Self::map_err)?;
row.map(|r| Self::row_to_user(r.id.unwrap_or_default(), r.email, r.username, r.password_hash))
.transpose()
row.map(|r| {
Self::row_to_user(
r.id.unwrap_or_default(),
r.email,
r.username,
r.password_hash,
)
})
.transpose()
}
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
@@ -175,10 +207,7 @@ mod tests {
.await
.unwrap();
let result = repo
.find_by_id(&UserId::from_uuid(id))
.await
.unwrap();
let result = repo.find_by_id(&UserId::from_uuid(id)).await.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().email().value(), "test@example.com");
}

View File

@@ -1,12 +1,12 @@
use askama::Template;
use chrono::Datelike;
use application::ports::{
ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
LoginPageData, NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData,
};
use askama::Template;
use chrono::Datelike;
use domain::models::{
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats,
UserTrends, collections::Paginated,
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends,
collections::Paginated,
};
struct PageItem {
@@ -31,9 +31,17 @@ fn build_page_items(total_pages: u32, current_page: u32) -> Vec<PageItem> {
let mut items = Vec::new();
for (i, &p) in pages.iter().enumerate() {
if i > 0 && p > pages[i - 1] + 1 {
items.push(PageItem { number: 0, is_current: false, is_ellipsis: true });
items.push(PageItem {
number: 0,
is_current: false,
is_ellipsis: true,
});
}
items.push(PageItem { number: p, is_current: p == current_page, is_ellipsis: false });
items.push(PageItem {
number: p,
is_current: p == current_page,
is_ellipsis: false,
});
}
items
}
@@ -162,40 +170,71 @@ struct HeatmapCell {
fn relative_time(dt: chrono::NaiveDateTime) -> String {
let now = chrono::Utc::now().naive_utc();
let diff = now.signed_duration_since(dt);
if diff.num_seconds() <= 0 { return "just now".to_string(); }
if diff.num_seconds() <= 0 {
return "just now".to_string();
}
let minutes = diff.num_minutes();
let hours = diff.num_hours();
let days = diff.num_days();
if minutes < 1 { return "just now".to_string(); }
if minutes < 60 { return format!("{} min ago", minutes); }
if hours < 24 { return format!("{} h ago", hours); }
if days == 1 { return "yesterday".to_string(); }
if days < 30 { return format!("{} days ago", days); }
if minutes < 1 {
return "just now".to_string();
}
if minutes < 60 {
return format!("{} min ago", minutes);
}
if hours < 24 {
return format!("{} h ago", hours);
}
if days == 1 {
return "yesterday".to_string();
}
if days < 30 {
return format!("{} days ago", days);
}
dt.format("%b %-d, %Y").to_string()
}
fn build_heatmap(history: &[MonthActivity]) -> Vec<HeatmapCell> {
let current_year = chrono::Utc::now().year();
let count_for = |m: &str| -> i64 {
history.iter().find(|a| a.year_month == format!("{}-{}", current_year, m))
history
.iter()
.find(|a| a.year_month == format!("{}-{}", current_year, m))
.map(|a| a.count)
.unwrap_or(0)
};
let months = [
("01", "Jan"), ("02", "Feb"), ("03", "Mar"), ("04", "Apr"),
("05", "May"), ("06", "Jun"), ("07", "Jul"), ("08", "Aug"),
("09", "Sep"), ("10", "Oct"), ("11", "Nov"), ("12", "Dec"),
("01", "Jan"),
("02", "Feb"),
("03", "Mar"),
("04", "Apr"),
("05", "May"),
("06", "Jun"),
("07", "Jul"),
("08", "Aug"),
("09", "Sep"),
("10", "Oct"),
("11", "Nov"),
("12", "Dec"),
];
let counts: Vec<i64> = months.iter().map(|(m, _)| count_for(m)).collect();
let max = counts.iter().copied().max().unwrap_or(0).max(1);
months.iter().zip(counts.iter()).map(|((_, label), &count)| {
let alpha = if count == 0 { 0.05 } else { 0.15 + 0.75 * (count as f64 / max as f64) };
HeatmapCell {
month_label: label.to_string(),
count,
alpha,
}
}).collect()
months
.iter()
.zip(counts.iter())
.map(|((_, label), &count)| {
let alpha = if count == 0 {
0.05
} else {
0.15 + 0.75 * (count as f64 / max as f64)
};
HeatmapCell {
month_label: label.to_string(),
count,
alpha,
}
})
.collect()
}
fn bar_height_px(avg_rating: f64) -> i64 {
@@ -211,7 +250,11 @@ impl AskamaHtmlRenderer {
}
impl HtmlRenderer for AskamaHtmlRenderer {
fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String> {
fn render_diary_page(
&self,
data: &Paginated<DiaryEntry>,
ctx: HtmlPageContext,
) -> Result<String, String> {
let has_more = (data.offset + data.limit) < data.total_count as u32;
let (total_pages, current_page) = if data.limit > 0 {
let tp = ((data.total_count + data.limit as u64 - 1) / data.limit as u64) as u32;
@@ -262,8 +305,14 @@ impl HtmlRenderer for AskamaHtmlRenderer {
let limit = data.limit;
let total_pages = if limit > 0 {
((data.entries.total_count + limit as u64 - 1) / limit as u64) as u32
} else { 0 };
let current_page = if limit > 0 { data.current_offset / limit } else { 0 };
} else {
0
};
let current_page = if limit > 0 {
data.current_offset / limit
} else {
0
};
ActivityFeedTemplate {
entries: &data.entries.items,
current_offset: data.current_offset,
@@ -277,21 +326,30 @@ impl HtmlRenderer for AskamaHtmlRenderer {
}
fn render_users_page(&self, data: UsersPageData) -> Result<String, String> {
let users: Vec<UserSummaryView> = data.users.iter().map(|u| {
let email = u.email();
let display_name = email.split('@').next().unwrap_or(email).to_string();
let initial = display_name.chars().next().unwrap_or('?').to_ascii_uppercase();
let avg_rating_display = u.avg_rating
.map(|r| format!("{:.1}", r))
.unwrap_or_else(|| "".to_string());
UserSummaryView {
user_id: u.user_id.value(),
display_name,
initial,
avg_rating_display,
total_movies: u.total_movies,
}
}).collect();
let users: Vec<UserSummaryView> = data
.users
.iter()
.map(|u| {
let email = u.email();
let display_name = email.split('@').next().unwrap_or(email).to_string();
let initial = display_name
.chars()
.next()
.unwrap_or('?')
.to_ascii_uppercase();
let avg_rating_display = u
.avg_rating
.map(|r| format!("{:.1}", r))
.unwrap_or_else(|| "".to_string());
UserSummaryView {
user_id: u.user_id.value(),
display_name,
initial,
avg_rating_display,
total_movies: u.total_movies,
}
})
.collect();
UsersTemplate {
users,
ctx: &data.ctx,
@@ -301,29 +359,60 @@ impl HtmlRenderer for AskamaHtmlRenderer {
}
fn render_profile_page(&self, data: ProfilePageData) -> Result<String, String> {
let heatmap = data.history.as_deref()
let heatmap = data
.history
.as_deref()
.map(|h| build_heatmap(h))
.unwrap_or_default();
let profile_display_name = data.profile_user_email
.split('@').next().unwrap_or(&data.profile_user_email).to_string();
let monthly_rating_rows: Vec<MonthlyRatingRow<'_>> = data.trends.as_ref()
.map(|t| t.monthly_ratings.iter().map(|r| MonthlyRatingRow {
bar_height_px: bar_height_px(r.avg_rating),
rating: r,
}).collect())
let profile_display_name = data
.profile_user_email
.split('@')
.next()
.unwrap_or(&data.profile_user_email)
.to_string();
let monthly_rating_rows: Vec<MonthlyRatingRow<'_>> = data
.trends
.as_ref()
.map(|t| {
t.monthly_ratings
.iter()
.map(|r| MonthlyRatingRow {
bar_height_px: bar_height_px(r.avg_rating),
rating: r,
})
.collect()
})
.unwrap_or_default();
let total_pages = data.entries.as_ref()
.map(|e| if e.limit > 0 { ((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32 } else { 0 })
let total_pages = data
.entries
.as_ref()
.map(|e| {
if e.limit > 0 {
((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32
} else {
0
}
})
.unwrap_or(0);
let current_page = if data.limit > 0 { data.current_offset / data.limit } else { 0 };
let avg_rating_display = data.stats.avg_rating
let current_page = if data.limit > 0 {
data.current_offset / data.limit
} else {
0
};
let avg_rating_display = data
.stats
.avg_rating
.map(|r| format!("{:.1}", r))
.unwrap_or_else(|| "".to_string());
let favorite_director_display = data.stats.favorite_director
let favorite_director_display = data
.stats
.favorite_director
.as_deref()
.unwrap_or("")
.to_string();
let most_active_month_display = data.stats.most_active_month
let most_active_month_display = data
.stats
.most_active_month
.as_deref()
.unwrap_or("")
.to_string();
@@ -349,11 +438,15 @@ impl HtmlRenderer for AskamaHtmlRenderer {
error: data.error,
following_count: data.following_count,
followers_count: data.followers_count,
pending_followers: data.pending_followers.into_iter().map(|a| RemoteActorData {
handle: a.handle,
url: a.url,
display_name: a.display_name,
}).collect(),
pending_followers: data
.pending_followers
.into_iter()
.map(|a| RemoteActorData {
handle: a.handle,
url: a.url,
display_name: a.display_name,
})
.collect(),
}
.render()
.map_err(|e| e.to_string())
@@ -363,11 +456,15 @@ impl HtmlRenderer for AskamaHtmlRenderer {
FollowingTemplate {
ctx: data.ctx,
user_id: data.user_id,
actors: data.actors.into_iter().map(|a| RemoteActorData {
handle: a.handle,
display_name: a.display_name,
url: a.url,
}).collect(),
actors: data
.actors
.into_iter()
.map(|a| RemoteActorData {
handle: a.handle,
display_name: a.display_name,
url: a.url,
})
.collect(),
error: data.error,
}
.render()
@@ -378,11 +475,15 @@ impl HtmlRenderer for AskamaHtmlRenderer {
FollowersTemplate {
ctx: data.ctx,
user_id: data.user_id,
actors: data.actors.into_iter().map(|a| RemoteActorData {
handle: a.handle,
display_name: a.display_name,
url: a.url,
}).collect(),
actors: data
.actors
.into_iter()
.map(|a| RemoteActorData {
handle: a.handle,
display_name: a.display_name,
url: a.url,
})
.collect(),
error: data.error,
}
.render()

View File

@@ -58,6 +58,11 @@
</ul>
</section>
{% endif %}
<section class="export-section">
<h3>Export diary</h3>
<a href="/diary/export?format=csv">Download CSV</a>
<a href="/diary/export?format=json">Download JSON</a>
</section>
{% endif %}
<div class="view-tabs">

View File

@@ -10,12 +10,16 @@ impl AppConfig {
let allow_registration = std::env::var("ALLOW_REGISTRATION")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let base_url = std::env::var("BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000".to_string());
let base_url =
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
let rate_limit = std::env::var("RATE_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
Self { allow_registration, base_url, rate_limit }
Self {
allow_registration,
base_url,
rate_limit,
}
}
}

View File

@@ -1,7 +1,7 @@
use std::sync::Arc;
use domain::ports::{
AuthService, DiaryRepository, EventPublisher, MetadataClient, MovieRepository,
AuthService, DiaryExporter, DiaryRepository, EventPublisher, MetadataClient, MovieRepository,
PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository,
UserRepository,
};
@@ -13,6 +13,7 @@ pub struct AppContext {
pub movie_repository: Arc<dyn MovieRepository>,
pub review_repository: Arc<dyn ReviewRepository>,
pub diary_repository: Arc<dyn DiaryRepository>,
pub diary_exporter: Arc<dyn DiaryExporter>,
pub stats_repository: Arc<dyn StatsRepository>,
pub metadata_client: Arc<dyn MetadataClient>,
pub poster_fetcher: Arc<dyn PosterFetcherClient>,

View File

@@ -207,29 +207,80 @@ mod tests {
#[async_trait]
impl MovieRepository for RepoWithExternalMovie {
async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result<Option<Movie>, DomainError> { Ok(Some(self.0.clone())) }
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> { panic!("unexpected") }
async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result<Vec<Movie>, DomainError> { panic!("unexpected") }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn get_movie_by_external_id(
&self,
_: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
Ok(Some(self.0.clone()))
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
panic!("unexpected")
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
}
#[async_trait]
impl MovieRepository for RepoEmpty {
async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result<Option<Movie>, DomainError> { Ok(None) }
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> { panic!("unexpected") }
async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result<Vec<Movie>, DomainError> { Ok(vec![]) }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn get_movie_by_external_id(
&self,
_: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
Ok(None)
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
Ok(vec![])
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
}
#[async_trait]
impl MovieRepository for RepoWithTitleMatch {
async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result<Option<Movie>, DomainError> { panic!("unexpected") }
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> { panic!("unexpected") }
async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result<Vec<Movie>, DomainError> { Ok(vec![self.0.clone()]) }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn get_movie_by_external_id(
&self,
_: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
Ok(vec![self.0.clone()])
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
}
struct MetaReturnsMovie(Movie);
@@ -257,7 +308,9 @@ mod tests {
&self,
_: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
Err(DomainError::InfrastructureError("metadata unavailable".into()))
Err(DomainError::InfrastructureError(
"metadata unavailable".into(),
))
}
async fn get_poster_url(
&self,

View File

@@ -1,6 +1,9 @@
use uuid::Uuid;
use domain::models::{DiaryEntry, FeedEntry, MonthActivity, UserStats, UserSummary, UserTrends, collections::Paginated};
use domain::models::{
DiaryEntry, FeedEntry, MonthActivity, UserStats, UserSummary, UserTrends,
collections::Paginated,
};
pub struct RemoteActorView {
pub handle: String,
@@ -85,7 +88,11 @@ pub struct FollowersPageData {
}
pub trait HtmlRenderer: Send + Sync {
fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String>;
fn render_diary_page(
&self,
data: &Paginated<DiaryEntry>,
ctx: HtmlPageContext,
) -> Result<String, String>;
fn render_login_page(&self, data: LoginPageData<'_>) -> Result<String, String>;
fn render_register_page(&self, data: RegisterPageData<'_>) -> Result<String, String>;
fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result<String, String>;

View File

@@ -1,5 +1,8 @@
use domain::{errors::DomainError, value_objects::{ReviewId, UserId}};
use crate::{commands::DeleteReviewCommand, context::AppContext};
use domain::{
errors::DomainError,
value_objects::{ReviewId, UserId},
};
pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), DomainError> {
let review_id = ReviewId::from_uuid(cmd.review_id);

View File

@@ -1,23 +1,13 @@
use std::sync::Arc;
use domain::{errors::DomainError, value_objects::UserId};
use domain::{
errors::DomainError,
ports::{DiaryExporter, DiaryRepository},
};
use crate::{commands::ExportCommand, context::AppContext};
use crate::commands::ExportCommand;
pub struct ExportDiary {
repository: Arc<dyn DiaryRepository>,
exporter: Arc<dyn DiaryExporter>,
}
impl ExportDiary {
pub async fn execute(&self, req: ExportCommand) -> Result<Vec<u8>, DomainError> {
// 1. fetch all diary entries for the user
// 2. delegate serialization to the port (exporter)
// Return bytes of the exported diary, which can be written to a file or returned in an HTTP response
Ok(vec![])
}
pub async fn execute(ctx: &AppContext, cmd: ExportCommand) -> Result<Vec<u8>, DomainError> {
let entries = ctx
.diary_repository
.get_user_history(&UserId::from_uuid(cmd.user_id))
.await?;
ctx.diary_exporter
.serialize_entries(&entries, cmd.format)
.await
}

View File

@@ -1,8 +1,11 @@
use crate::{context::AppContext, queries::GetActivityFeedQuery};
use domain::{
errors::DomainError,
models::{FeedEntry, collections::{PageParams, Paginated}},
models::{
FeedEntry,
collections::{PageParams, Paginated},
},
};
use crate::{context::AppContext, queries::GetActivityFeedQuery};
pub async fn execute(
ctx: &AppContext,

View File

@@ -1,3 +1,7 @@
use crate::{
context::AppContext,
queries::{GetUserProfileQuery, ProfileView},
};
use chrono::Datelike;
use domain::{
errors::DomainError,
@@ -7,7 +11,6 @@ use domain::{
},
value_objects::UserId,
};
use crate::{context::AppContext, queries::{GetUserProfileQuery, ProfileView}};
pub struct UserProfileData {
pub stats: UserStats,
@@ -27,26 +30,61 @@ pub async fn execute(
ProfileView::History => {
let all_entries = ctx.diary_repository.get_user_history(&user_id).await?;
let history = group_by_month(all_entries);
Ok(UserProfileData { stats, entries: None, history: Some(history), trends: None })
Ok(UserProfileData {
stats,
entries: None,
history: Some(history),
trends: None,
})
}
ProfileView::Trends => {
let trends = ctx.stats_repository.get_user_trends(&user_id).await?;
Ok(UserProfileData { stats, entries: None, history: None, trends: Some(trends) })
Ok(UserProfileData {
stats,
entries: None,
history: None,
trends: Some(trends),
})
}
ProfileView::Ratings => {
let filter = paged_user_filter(user_id, SortDirection::ByRatingDesc, query.limit, query.offset)?;
let filter = paged_user_filter(
user_id,
SortDirection::ByRatingDesc,
query.limit,
query.offset,
)?;
let entries = ctx.diary_repository.query_diary(&filter).await?;
Ok(UserProfileData { stats, entries: Some(entries), history: None, trends: None })
Ok(UserProfileData {
stats,
entries: Some(entries),
history: None,
trends: None,
})
}
ProfileView::Recent => {
let filter = paged_user_filter(user_id, SortDirection::Descending, query.limit, query.offset)?;
let filter = paged_user_filter(
user_id,
SortDirection::Descending,
query.limit,
query.offset,
)?;
let entries = ctx.diary_repository.query_diary(&filter).await?;
Ok(UserProfileData { stats, entries: Some(entries), history: None, trends: None })
Ok(UserProfileData {
stats,
entries: Some(entries),
history: None,
trends: None,
})
}
}
}
fn paged_user_filter(user_id: UserId, sort_by: SortDirection, limit: Option<u32>, offset: Option<u32>) -> Result<DiaryFilter, DomainError> {
fn paged_user_filter(
user_id: UserId,
sort_by: SortDirection,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<DiaryFilter, DomainError> {
let page = PageParams::new(limit, offset)?;
Ok(DiaryFilter {
sort_by,
@@ -81,11 +119,22 @@ fn group_by_month(entries: Vec<DiaryEntry>) -> Vec<MonthActivity> {
fn format_year_month_long(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 { return ym.to_string(); }
if parts.len() != 2 {
return ym.to_string();
}
let month = match parts[1] {
"01" => "January", "02" => "February", "03" => "March", "04" => "April",
"05" => "May", "06" => "June", "07" => "July", "08" => "August",
"09" => "September", "10" => "October", "11" => "November", "12" => "December",
"01" => "January",
"02" => "February",
"03" => "March",
"04" => "April",
"05" => "May",
"06" => "June",
"07" => "July",
"08" => "August",
"09" => "September",
"10" => "October",
"11" => "November",
"12" => "December",
_ => parts[1],
};
format!("{} {}", month, parts[0])

View File

@@ -1,5 +1,5 @@
use domain::{errors::DomainError, models::UserSummary};
use crate::{context::AppContext, queries::GetUsersQuery};
use domain::{errors::DomainError, models::UserSummary};
pub async fn execute(
ctx: &AppContext,

View File

@@ -20,7 +20,9 @@ pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), Doma
repository: ctx.movie_repository.as_ref(),
metadata_client: ctx.metadata_client.as_ref(),
};
let (movie, is_new_movie) = MovieResolver::default_pipeline().resolve(&cmd, &deps).await?;
let (movie, is_new_movie) = MovieResolver::default_pipeline()
.resolve(&cmd, &deps)
.await?;
ctx.movie_repository.upsert_movie(&movie).await?;

View File

@@ -1,4 +1,8 @@
use domain::{errors::DomainError, models::User, value_objects::{Email, Username}};
use domain::{
errors::DomainError,
models::User,
value_objects::{Email, Username},
};
use crate::{commands::RegisterCommand, context::AppContext};
@@ -19,13 +23,24 @@ pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), Domai
let username = Username::new(cmd.username)?;
if ctx.user_repository.find_by_email(&email).await?.is_some() {
return Err(DomainError::ValidationError("Email already registered".into()));
return Err(DomainError::ValidationError(
"Email already registered".into(),
));
}
if ctx.user_repository.find_by_username(&username).await?.is_some() {
return Err(DomainError::ValidationError("Username already taken".into()));
if ctx
.user_repository
.find_by_username(&username)
.await?
.is_some()
{
return Err(DomainError::ValidationError(
"Username already taken".into(),
));
}
let hash = ctx.password_hasher.hash(&cmd.password).await?;
ctx.user_repository.save(&User::new(email, username, hash)).await
ctx.user_repository
.save(&User::new(email, username, hash))
.await
}

View File

@@ -5,8 +5,8 @@ use crate::{
errors::DomainError,
events::DomainEvent,
models::{
DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, User, UserStats,
UserSummary, UserTrends,
DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, Movie, Review, ReviewHistory, User,
UserStats, UserSummary, UserTrends,
collections::{PageParams, Paginated},
},
value_objects::{
@@ -36,12 +36,17 @@ pub trait ReviewRepository: Send + Sync {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError>;
}
#[async_trait]
pub trait DiaryRepository: Send + Sync {
async fn query_diary(&self, filter: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError>;
async fn query_activity_feed(&self, page: &PageParams) -> Result<Paginated<FeedEntry>, DomainError>;
async fn query_diary(&self, filter: &DiaryFilter)
-> Result<Paginated<DiaryEntry>, DomainError>;
async fn query_activity_feed(
&self,
page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError>;
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError>;
}
@@ -122,7 +127,11 @@ pub trait PasswordHasher: Send + Sync {
#[async_trait]
pub trait DiaryExporter: Send + Sync {
async fn serialize_reviews(&self, reviews: &[Review]) -> Result<Vec<u8>, DomainError>;
async fn serialize_entries(
&self,
entries: &[DiaryEntry],
format: ExportFormat,
) -> Result<Vec<u8>, DomainError>;
}
#[async_trait]

View File

@@ -184,10 +184,18 @@ impl Username {
let s = raw.trim().to_lowercase();
if s.len() < Self::MIN_LENGTH || s.len() > Self::MAX_LENGTH {
return Err(DomainError::ValidationError(
format!("Username must be {}{} characters", Self::MIN_LENGTH, Self::MAX_LENGTH).into(),
format!(
"Username must be {}{} characters",
Self::MIN_LENGTH,
Self::MAX_LENGTH
)
.into(),
));
}
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(DomainError::ValidationError(
"Username may only contain letters, digits, underscores, and hyphens".into(),
));

View File

@@ -32,6 +32,7 @@ sqlx = { workspace = true }
template-askama = { workspace = true }
event-publisher = { workspace = true }
rss = { workspace = true }
export = { workspace = true }
infer = "0.19.0"
percent-encoding = "2"

View File

@@ -259,6 +259,16 @@ pub struct ProfileQueryParams {
pub error: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct ExportQueryParams {
#[serde(default = "default_export_format")]
pub format: String,
}
fn default_export_format() -> String {
"csv".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -345,7 +355,10 @@ mod tests {
movie_id: None,
};
let query = GetDiaryQuery::from(params);
assert!(matches!(query.sort_by, Some(domain::models::SortDirection::Ascending)));
assert!(matches!(
query.sort_by,
Some(domain::models::SortDirection::Ascending)
));
}
#[test]
@@ -357,7 +370,10 @@ mod tests {
movie_id: None,
};
let query = GetDiaryQuery::from(params);
assert!(matches!(query.sort_by, Some(domain::models::SortDirection::Descending)));
assert!(matches!(
query.sort_by,
Some(domain::models::SortDirection::Descending)
));
}
#[test]

View File

@@ -2,8 +2,8 @@ use std::time::Duration;
use application::{commands::SyncPosterCommand, context::AppContext, use_cases::sync_poster};
use async_trait::async_trait;
use domain::{errors::DomainError, events::DomainEvent};
use domain::ports::EventHandler;
use domain::{errors::DomainError, events::DomainEvent};
pub struct PosterSyncHandler {
ctx: AppContext,

View File

@@ -28,11 +28,7 @@ where
"Missing or invalid auth token".into(),
))
})?;
let user_id = app_state
.app_ctx
.auth_service
.validate_token(token)
.await?;
let user_id = app_state.app_ctx.auth_service.validate_token(token).await?;
Ok(AuthenticatedUser(user_id))
}
}
@@ -98,28 +94,32 @@ where
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use application::{config::AppConfig, context::AppContext};
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::get,
Router,
};
use application::{config::AppConfig, context::AppContext};
use domain::{
errors::DomainError,
events::DomainEvent,
models::{DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats, UserTrends, collections::{PageParams, Paginated}},
models::{
DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats,
UserTrends,
collections::{PageParams, Paginated},
},
ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient,
MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage,
ReviewRepository, StatsRepository, UserRepository,
MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository,
StatsRepository, UserRepository,
},
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
ReleaseYear, ReviewId, UserId,
},
};
use std::sync::Arc;
use tower::ServiceExt;
// --- Panic stubs (defined once) ---
@@ -128,82 +128,232 @@ mod tests {
#[async_trait::async_trait]
impl MovieRepository for Panic {
async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result<Option<Movie>, DomainError> { panic!() }
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> { panic!() }
async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result<Vec<Movie>, DomainError> { panic!() }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!() }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!() }
async fn get_movie_by_external_id(
&self,
_: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
panic!()
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!()
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
panic!()
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!()
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl ReviewRepository for Panic {
async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> { panic!() }
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> { panic!() }
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { panic!() }
async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> {
panic!()
}
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> {
panic!()
}
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> {
panic!()
}
async fn get_all_reviews_for_user(&self, _: &UserId) -> Result<Vec<Review>, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl DiaryRepository for Panic {
async fn query_diary(&self, _: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> { panic!() }
async fn query_activity_feed(&self, _: &PageParams) -> Result<Paginated<FeedEntry>, DomainError> { panic!() }
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> { panic!() }
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> { panic!() }
async fn query_diary(&self, _: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
panic!()
}
async fn query_activity_feed(
&self,
_: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!()
}
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> {
panic!()
}
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl StatsRepository for Panic {
async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> { panic!() }
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> { panic!() }
async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> {
panic!()
}
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl MetadataClient for Panic {
async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<Movie, DomainError> { panic!() }
async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> { panic!() }
async fn fetch_movie_metadata(
&self,
_: &domain::ports::MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
panic!()
}
async fn get_poster_url(
&self,
_: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl PosterFetcherClient for Panic { async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> { panic!() } }
impl PosterFetcherClient for Panic {
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl PosterStorage for Panic {
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> { panic!() }
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> { panic!() }
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> {
panic!()
}
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl AuthService for Panic {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() }
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> { panic!() }
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
panic!()
}
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl PasswordHasher for Panic {
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> { panic!() }
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> { panic!() }
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> {
panic!()
}
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl UserRepository for Panic {
async fn find_by_email(&self, _: &Email) -> Result<Option<domain::models::User>, DomainError> { panic!() }
async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> { panic!() }
async fn find_by_id(&self, _: &UserId) -> Result<Option<domain::models::User>, DomainError> { panic!() }
async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<domain::models::User>, DomainError> { panic!() }
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { panic!() }
async fn find_by_email(
&self,
_: &Email,
) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> {
panic!()
}
async fn find_by_id(
&self,
_: &UserId,
) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn find_by_username(
&self,
_: &domain::value_objects::Username,
) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl EventPublisher for Panic { async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { panic!() } }
impl EventPublisher for Panic {
async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl domain::ports::DiaryExporter for Panic {
async fn serialize_entries(
&self,
_: &[domain::models::DiaryEntry],
_: domain::models::ExportFormat,
) -> Result<Vec<u8>, domain::errors::DomainError> {
panic!()
}
}
impl crate::ports::HtmlRenderer for Panic {
fn render_diary_page(&self, _: &Paginated<DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
fn render_activity_feed_page(&self, _: application::ports::ActivityFeedPageData) -> Result<String, String> { panic!() }
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result<String, String> { panic!() }
fn render_followers_page(&self, _: application::ports::FollowersPageData) -> Result<String, String> { panic!() }
fn render_diary_page(
&self,
_: &Paginated<DiaryEntry>,
_: application::ports::HtmlPageContext,
) -> Result<String, String> {
panic!()
}
fn render_login_page(
&self,
_: application::ports::LoginPageData<'_>,
) -> Result<String, String> {
panic!()
}
fn render_register_page(
&self,
_: application::ports::RegisterPageData<'_>,
) -> Result<String, String> {
panic!()
}
fn render_new_review_page(
&self,
_: application::ports::NewReviewPageData<'_>,
) -> Result<String, String> {
panic!()
}
fn render_activity_feed_page(
&self,
_: application::ports::ActivityFeedPageData,
) -> Result<String, String> {
panic!()
}
fn render_users_page(
&self,
_: application::ports::UsersPageData,
) -> Result<String, String> {
panic!()
}
fn render_profile_page(
&self,
_: application::ports::ProfilePageData,
) -> Result<String, String> {
panic!()
}
fn render_following_page(
&self,
_: application::ports::FollowingPageData,
) -> Result<String, String> {
panic!()
}
fn render_followers_page(
&self,
_: application::ports::FollowersPageData,
) -> Result<String, String> {
panic!()
}
}
impl crate::ports::RssFeedRenderer for Panic {
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> { panic!() }
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
panic!()
}
}
struct RejectingAuth;
#[async_trait::async_trait]
impl AuthService for RejectingAuth {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() }
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
panic!()
}
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
Err(DomainError::Unauthorized("bad token".into()))
}
@@ -218,6 +368,7 @@ mod tests {
movie_repository: Arc::clone(&repo) as _,
review_repository: Arc::clone(&repo) as _,
diary_repository: Arc::clone(&repo) as _,
diary_exporter: Arc::clone(&repo) as _,
stats_repository: Arc::clone(&repo) as _,
metadata_client: Arc::clone(&repo) as _,
poster_fetcher: Arc::clone(&repo) as _,
@@ -226,7 +377,11 @@ mod tests {
password_hasher: Arc::clone(&repo) as _,
user_repository: Arc::clone(&repo) as _,
auth_service,
config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20 },
config: AppConfig {
allow_registration: false,
base_url: "http://localhost:3000".to_string(),
rate_limit: 20,
},
},
html_renderer: Arc::new(Panic),
rss_renderer: Arc::new(Panic),
@@ -236,47 +391,103 @@ mod tests {
// --- Routers ---
async fn protected_handler(user: AuthenticatedUser) -> String { user.0.value().to_string() }
async fn optional_cookie_handler(user: OptionalCookieUser) -> String {
match user.0 { Some(id) => id.value().to_string(), None => "none".to_string() }
async fn protected_handler(user: AuthenticatedUser) -> String {
user.0.value().to_string()
}
async fn optional_cookie_handler(user: OptionalCookieUser) -> String {
match user.0 {
Some(id) => id.value().to_string(),
None => "none".to_string(),
}
}
async fn required_cookie_handler(user: RequiredCookieUser) -> String {
user.0.value().to_string()
}
async fn required_cookie_handler(user: RequiredCookieUser) -> String { user.0.value().to_string() }
fn router_protected(state: crate::state::AppState) -> Router { Router::new().route("/protected", get(protected_handler)).with_state(state) }
fn router_optional(state: crate::state::AppState) -> Router { Router::new().route("/optional", get(optional_cookie_handler)).with_state(state) }
fn router_required(state: crate::state::AppState) -> Router { Router::new().route("/required", get(required_cookie_handler)).with_state(state) }
fn router_protected(state: crate::state::AppState) -> Router {
Router::new()
.route("/protected", get(protected_handler))
.with_state(state)
}
fn router_optional(state: crate::state::AppState) -> Router {
Router::new()
.route("/optional", get(optional_cookie_handler))
.with_state(state)
}
fn router_required(state: crate::state::AppState) -> Router {
Router::new()
.route("/required", get(required_cookie_handler))
.with_state(state)
}
// --- Tests ---
#[tokio::test]
async fn missing_auth_header_returns_401() {
let app = router_protected(make_test_state(Arc::new(Panic)));
let resp = app.oneshot(Request::builder().uri("/protected").body(Body::empty()).unwrap()).await.unwrap();
let resp = app
.oneshot(
Request::builder()
.uri("/protected")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn optional_cookie_user_returns_none_without_cookie() {
let app = router_optional(make_test_state(Arc::new(Panic)));
let resp = app.oneshot(Request::builder().uri("/optional").body(Body::empty()).unwrap()).await.unwrap();
let resp = app
.oneshot(
Request::builder()
.uri("/optional")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"none");
}
#[tokio::test]
async fn optional_cookie_user_returns_none_with_invalid_token() {
let app = router_optional(make_test_state(Arc::new(RejectingAuth)));
let resp = app.oneshot(Request::builder().uri("/optional").header("cookie", "token=bad.token.here").body(Body::empty()).unwrap()).await.unwrap();
let resp = app
.oneshot(
Request::builder()
.uri("/optional")
.header("cookie", "token=bad.token.here")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"none");
}
#[tokio::test]
async fn required_cookie_user_redirects_without_cookie() {
let app = router_required(make_test_state(Arc::new(Panic)));
let resp = app.oneshot(Request::builder().uri("/required").body(Body::empty()).unwrap()).await.unwrap();
let resp = app
.oneshot(
Request::builder()
.uri("/required")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get("location").unwrap(), "/login");
}
@@ -284,7 +495,16 @@ mod tests {
#[tokio::test]
async fn required_cookie_user_redirects_with_invalid_token() {
let app = router_required(make_test_state(Arc::new(RejectingAuth)));
let resp = app.oneshot(Request::builder().uri("/required").header("cookie", "token=bad.token.here").body(Body::empty()).unwrap()).await.unwrap();
let resp = app
.oneshot(
Request::builder()
.uri("/required")
.header("cookie", "token=bad.token.here")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get("location").unwrap(), "/login");
}

View File

@@ -14,13 +14,17 @@ pub mod html {
use uuid::Uuid;
use application::{
commands::{DeleteReviewCommand, LoginCommand, RegisterCommand},
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
ports::{
FollowersPageData, FollowingPageData, HtmlPageContext, LoginPageData,
NewReviewPageData, RegisterPageData, RemoteActorView,
},
use_cases::{delete_review, log_review, login as login_uc, register as register_uc},
use_cases::{
delete_review, export_diary as export_diary_uc, log_review, login as login_uc,
register as register_uc,
},
};
use domain::models::ExportFormat;
use domain::{errors::DomainError, value_objects::UserId};
use crate::{
@@ -265,6 +269,45 @@ pub mod html {
}
}
pub async fn get_export(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Query(params): Query<crate::dtos::ExportQueryParams>,
) -> impl IntoResponse {
let format = match params.format.as_str() {
"csv" => ExportFormat::Csv,
"json" => ExportFormat::Json,
_ => return StatusCode::BAD_REQUEST.into_response(),
};
let (content_type, filename) = match &format {
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
ExportFormat::Json => ("application/json", "diary.json"),
};
let cmd = ExportCommand {
user_id: user_id.value(),
format,
};
match export_diary_uc::execute(&state.app_ctx, cmd).await {
Ok(bytes) => (
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
bytes,
)
.into_response(),
Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(),
Err(e) => {
tracing::error!("export error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn get_activity_feed(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,

View File

@@ -1,6 +1,6 @@
pub mod event_handlers;
pub mod dtos;
pub mod errors;
pub mod event_handlers;
pub mod extractors;
pub mod handlers;
pub mod ports;

View File

@@ -10,15 +10,19 @@ use sqlx::sqlite::SqliteConnectOptions;
use tokio::net::TcpListener;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use activitypub::{
ActivityPubEventHandler, ActivityPubPort, ActivityPubService, DomainUserRepoAdapter,
ReviewObjectHandler,
};
use application::{config::AppConfig, context::AppContext};
use auth::{AuthConfig, Argon2PasswordHasher, JwtAuthService};
use auth::{Argon2PasswordHasher, AuthConfig, JwtAuthService};
use export::ExportAdapter;
use metadata::MetadataClientImpl;
use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher};
use poster_storage::{PosterStorageAdapter, StorageConfig};
use activitypub::{ActivityPubEventHandler, ActivityPubPort, ActivityPubService, DomainUserRepoAdapter, ReviewObjectHandler};
use rss::RssAdapter;
use sqlite::{SqliteMovieRepository, SqliteUserRepository};
use sqlite_federation::SqliteFederationRepository;
use rss::RssAdapter;
use template_askama::AskamaHtmlRenderer;
use presentation::{routes, state::AppState};
@@ -68,18 +72,23 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
.context("Database migration failed")?;
use domain::ports::{
AuthService, DiaryRepository, MetadataClient, MovieRepository, PasswordHasher,
PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository, UserRepository,
AuthService, DiaryExporter, DiaryRepository, MetadataClient, MovieRepository,
PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository,
UserRepository,
};
let movie_repository: Arc<dyn MovieRepository> = Arc::clone(&sqlite_repo) as _;
let movie_repository: Arc<dyn MovieRepository> = Arc::clone(&sqlite_repo) as _;
let review_repository: Arc<dyn ReviewRepository> = Arc::clone(&sqlite_repo) as _;
let diary_repository: Arc<dyn DiaryRepository> = Arc::clone(&sqlite_repo) as _;
let stats_repository: Arc<dyn StatsRepository> = Arc::clone(&sqlite_repo) as _;
let diary_repository: Arc<dyn DiaryRepository> = Arc::clone(&sqlite_repo) as _;
let stats_repository: Arc<dyn StatsRepository> = Arc::clone(&sqlite_repo) as _;
let user_repository: Arc<dyn UserRepository> = Arc::new(SqliteUserRepository::new(pool.clone()));
let metadata_client: Arc<dyn MetadataClient> = Arc::new(MetadataClientImpl::new_omdb(omdb_api_key));
let poster_fetcher: Arc<dyn PosterFetcherClient> = Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?);
let poster_storage: Arc<dyn PosterStorage> = Arc::new(PosterStorageAdapter::from_config(storage_config));
let user_repository: Arc<dyn UserRepository> =
Arc::new(SqliteUserRepository::new(pool.clone()));
let metadata_client: Arc<dyn MetadataClient> =
Arc::new(MetadataClientImpl::new_omdb(omdb_api_key));
let poster_fetcher: Arc<dyn PosterFetcherClient> =
Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?);
let poster_storage: Arc<dyn PosterStorage> =
Arc::new(PosterStorageAdapter::from_config(storage_config));
let auth_service: Arc<dyn AuthService> = Arc::new(JwtAuthService::new(auth_config));
let password_hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher);
@@ -89,6 +98,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
movie_repository: Arc::clone(&movie_repository),
review_repository: Arc::clone(&review_repository),
diary_repository: Arc::clone(&diary_repository),
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
stats_repository: Arc::clone(&stats_repository),
metadata_client: Arc::clone(&metadata_client),
poster_fetcher: Arc::clone(&poster_fetcher),
@@ -139,6 +149,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
movie_repository,
review_repository,
diary_repository,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
stats_repository,
metadata_client,
poster_fetcher,

View File

@@ -36,7 +36,11 @@ impl RateLimiter {
let prev = self.window.load(Ordering::Acquire);
if now != prev {
// compare_exchange ensures only one thread wins the window reset
if self.window.compare_exchange(prev, now, Ordering::AcqRel, Ordering::Relaxed).is_ok() {
if self
.window
.compare_exchange(prev, now, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
{
self.count.store(1, Ordering::Release);
return true;
}
@@ -130,6 +134,7 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
"/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",

View File

@@ -21,9 +21,9 @@ use domain::{
};
use http_body_util::BodyExt;
use presentation::{routes, state::AppState};
use rss::RssAdapter;
use sqlite::SqliteMovieRepository;
use sqlx::SqlitePool;
use rss::RssAdapter;
use template_askama::AskamaHtmlRenderer;
use tower::ServiceExt;
@@ -41,7 +41,10 @@ impl MetadataClient for PanicMeta {
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
panic!("metadata not wired in tests")
}
async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> {
async fn get_poster_url(
&self,
_: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
panic!()
}
}
@@ -68,25 +71,58 @@ impl PosterStorage for PanicStorage {
struct PanicHasher;
#[async_trait]
impl PasswordHasher for PanicHasher {
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> { panic!() }
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> { panic!() }
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> {
panic!()
}
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> {
panic!()
}
}
struct PanicAuth;
#[async_trait]
impl AuthService for PanicAuth {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() }
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> { panic!() }
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
panic!()
}
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
panic!()
}
}
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!() }
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!()
}
}
struct PanicExporter;
#[async_trait]
impl domain::ports::DiaryExporter for PanicExporter {
async fn serialize_entries(
&self,
_: &[domain::models::DiaryEntry],
_: domain::models::ExportFormat,
) -> Result<Vec<u8>, DomainError> {
panic!()
}
}
async fn test_app() -> Router {
@@ -102,6 +138,7 @@ async fn test_app() -> Router {
movie_repository: Arc::clone(&repo) as _,
review_repository: Arc::clone(&repo) as _,
diary_repository: Arc::clone(&repo) as _,
diary_exporter: Arc::new(PanicExporter),
stats_repository: Arc::clone(&repo) as _,
metadata_client: Arc::new(PanicMeta),
poster_fetcher: Arc::new(PanicFetcher),
@@ -110,7 +147,11 @@ async fn test_app() -> Router {
auth_service: Arc::new(PanicAuth),
password_hasher: Arc::new(PanicHasher),
user_repository: Arc::new(NobodyUserRepo),
config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20 },
config: AppConfig {
allow_registration: false,
base_url: "http://localhost:3000".to_string(),
rate_limit: 20,
},
},
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
@@ -124,7 +165,12 @@ async fn test_app() -> Router {
async fn get_api_diary_returns_empty_list() {
let app = test_app().await;
let response = app
.oneshot(Request::builder().uri("/api/diary").body(Body::empty()).unwrap())
.oneshot(
Request::builder()
.uri("/api/diary")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

File diff suppressed because it is too large Load Diff

View File

@@ -123,7 +123,10 @@ impl ApiClient {
let resp = self
.http
.post(format!("{}/api/auth/login", self.url()))
.json(&LoginRequest { email: email.into(), password: password.into() })
.json(&LoginRequest {
email: email.into(),
password: password.into(),
})
.send()
.await?;
Ok(check_status(resp).await?.json().await?)
@@ -159,11 +162,7 @@ impl ApiClient {
Ok(check_status(resp).await?.json().await?)
}
pub async fn create_review(
&self,
token: &str,
req: &LogReviewRequest,
) -> Result<(), ApiError> {
pub async fn create_review(&self, token: &str, req: &LogReviewRequest) -> Result<(), ApiError> {
let resp = self
.http
.post(format!("{}/api/reviews", self.url()))

View File

@@ -83,7 +83,9 @@ mod tests {
#[test]
fn config_roundtrip() {
let config = Config { api_url: "http://localhost:3000".into() };
let config = Config {
api_url: "http://localhost:3000".into(),
};
let json = serde_json::to_string(&config).unwrap();
let decoded: Config = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.api_url, "http://localhost:3000");

View File

@@ -4,9 +4,7 @@ use tokio::sync::mpsc;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers};
use tui::app::{
self, Action, App, BulkImportStage, Command, Screen, SettingsField, Tab,
};
use tui::app::{self, Action, App, BulkImportStage, Command, Screen, SettingsField, Tab};
use tui::client::ApiClient;
use tui::config::Config;
@@ -29,9 +27,14 @@ async fn run() -> anyhow::Result<()> {
}
}
let initial_url = config.as_ref().map(|c| c.api_url.as_str()).unwrap_or("http://localhost:3000");
let initial_url = config
.as_ref()
.map(|c| c.api_url.as_str())
.unwrap_or("http://localhost:3000");
let client = Arc::new(ApiClient::new(initial_url));
let saved_token = tokio::task::spawn_blocking(Config::load_token).await.unwrap_or(None);
let saved_token = tokio::task::spawn_blocking(Config::load_token)
.await
.unwrap_or(None);
let mut app = App::new(config, saved_token.clone());
let (tx, mut rx) = mpsc::channel::<Action>(64);
@@ -45,7 +48,10 @@ async fn run() -> anyhow::Result<()> {
let tx2 = tx.clone();
tokio::spawn(async move {
let action = match c.get_diary(&t, 0, 20).await {
Ok(r) => Action::DiaryLoaded { entries: r.items, total: r.total_count },
Ok(r) => Action::DiaryLoaded {
entries: r.items,
total: r.total_count,
},
Err(e) => Action::DiaryLoadFailed(e.to_string()),
};
let _ = tx2.send(action).await;
@@ -84,7 +90,8 @@ async fn run() -> anyhow::Result<()> {
}
}
Ok::<(), anyhow::Error>(())
}.await;
}
.await;
ratatui::restore();
result
@@ -95,11 +102,15 @@ async fn run() -> anyhow::Result<()> {
fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::Sender<Action>) {
match cmd {
Command::SaveConfig(url) => {
let config = Config { api_url: url.clone() };
let config = Config {
api_url: url.clone(),
};
if let Err(e) = config.save() {
let tx2 = tx.clone();
let msg = format!("Failed to save config: {e}");
tokio::spawn(async move { let _ = tx2.send(Action::DiaryLoadFailed(msg)).await; });
tokio::spawn(async move {
let _ = tx2.send(Action::DiaryLoadFailed(msg)).await;
});
}
client.update_url(&url);
}
@@ -136,12 +147,17 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
}
Command::LoadDiary { offset } => {
let Some(token) = app.token.clone() else { return };
let Some(token) = app.token.clone() else {
return;
};
let c = client.clone();
let tx = tx.clone();
tokio::spawn(async move {
let action = match c.get_diary(&token, offset, 20).await {
Ok(r) => Action::DiaryLoaded { entries: r.items, total: r.total_count },
Ok(r) => Action::DiaryLoaded {
entries: r.items,
total: r.total_count,
},
Err(e) => Action::DiaryLoadFailed(e.to_string()),
};
let _ = tx.send(action).await;
@@ -149,7 +165,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
}
Command::LoadHistory { movie_id } => {
let Some(token) = app.token.clone() else { return };
let Some(token) = app.token.clone() else {
return;
};
let c = client.clone();
let tx = tx.clone();
tokio::spawn(async move {
@@ -162,7 +180,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
}
Command::CreateReview(req) => {
let Some(token) = app.token.clone() else { return };
let Some(token) = app.token.clone() else {
return;
};
let c = client.clone();
let tx = tx.clone();
tokio::spawn(async move {
@@ -175,7 +195,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
}
Command::DeleteReview(id) => {
let Some(token) = app.token.clone() else { return };
let Some(token) = app.token.clone() else {
return;
};
let c = client.clone();
let tx = tx.clone();
tokio::spawn(async move {
@@ -188,7 +210,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
}
Command::ImportNext(index) => {
let Some(token) = app.token.clone() else { return };
let Some(token) = app.token.clone() else {
return;
};
let req = match &app.screen {
Screen::Main(m) => match m.bulk_import.valid_requests.get(index) {
Some(r) => r.clone(),
@@ -199,7 +223,11 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
let c = client.clone();
let tx = tx.clone();
tokio::spawn(async move {
let error = c.create_review(&token, &req).await.err().map(|e| e.to_string());
let error = c
.create_review(&token, &req)
.await
.err()
.map(|e| e.to_string());
let _ = tx.send(Action::BulkItemDone { index, error }).await;
});
}
@@ -248,8 +276,12 @@ fn key_to_action(app: &App, key: ratatui::crossterm::event::KeyEvent) -> Option<
KeyCode::Down | KeyCode::Char('j') => Some(Action::ScrollDown),
KeyCode::Enter => Some(Action::OpenHistory),
KeyCode::Char('d') => Some(Action::DeleteInit),
KeyCode::Char('y') if m.diary.delete_pending.is_some() => Some(Action::DeleteConfirm),
KeyCode::Char('n') if m.diary.delete_pending.is_some() => Some(Action::DeleteCancel),
KeyCode::Char('y') if m.diary.delete_pending.is_some() => {
Some(Action::DeleteConfirm)
}
KeyCode::Char('n') if m.diary.delete_pending.is_some() => {
Some(Action::DeleteCancel)
}
KeyCode::Esc => Some(Action::Escape),
KeyCode::Char('q') => Some(Action::Quit),
KeyCode::Tab => Some(Action::TabNext),

View File

@@ -99,8 +99,20 @@ fn draw_login(frame: &mut Frame, area: Rect, state: &LoginState) {
.split(popup);
let pass_masked = "*".repeat(state.password.len());
render_input(frame, rows[1], "Email", &state.email, state.focused == LoginField::Email);
render_input(frame, rows[3], "Password", &pass_masked, state.focused == LoginField::Password);
render_input(
frame,
rows[1],
"Email",
&state.email,
state.focused == LoginField::Email,
);
render_input(
frame,
rows[3],
"Password",
&pass_masked,
state.focused == LoginField::Password,
);
frame.render_widget(
Paragraph::new("Tab: next field Enter: login").alignment(Alignment::Center),
rows[4],
@@ -175,9 +187,16 @@ fn draw_diary(frame: &mut Frame, area: Rect, state: &DiaryState) {
let can_load_prev = state.offset > 0;
let page = state.offset / 20 + 1;
let total_pages = state.total.div_ceil(20).max(1);
let mut title = format!(" Diary ({} entries, page {}/{}) ", state.total, page, total_pages);
if can_load_prev { title.push_str("[b: prev] "); }
if can_load_more { title.push_str("[m: next] "); }
let mut title = format!(
" Diary ({} entries, page {}/{}) ",
state.total, page, total_pages
);
if can_load_prev {
title.push_str("[b: prev] ");
}
if can_load_more {
title.push_str("[m: next] ");
}
let mut list_state = ListState::default();
list_state.select(Some(state.selected));
let list = List::new(items).block(Block::default().title(title).borders(Borders::ALL));
@@ -273,23 +292,61 @@ fn draw_add_review(frame: &mut Frame, area: Rect, state: &AddReviewState) {
])
.split(inner);
render_input(frame, rows[0], "External ID (TMDB/OMDB)", &state.external_id, state.focused == AddReviewField::ExternalId);
render_input(frame, rows[1], "Title", &state.title, state.focused == AddReviewField::Title);
render_input(frame, rows[2], "Year", &state.year, state.focused == AddReviewField::Year);
render_input(
frame,
rows[0],
"External ID (TMDB/OMDB)",
&state.external_id,
state.focused == AddReviewField::ExternalId,
);
render_input(
frame,
rows[1],
"Title",
&state.title,
state.focused == AddReviewField::Title,
);
render_input(
frame,
rows[2],
"Year",
&state.year,
state.focused == AddReviewField::Year,
);
let rating_active = state.focused == AddReviewField::Rating;
frame.render_widget(
Paragraph::new(format!("{} \u{2190} \u{2192} to adjust", stars(state.rating))).block(
Paragraph::new(format!(
"{} \u{2190} \u{2192} to adjust",
stars(state.rating)
))
.block(
Block::default()
.title("Rating (0-5)")
.borders(Borders::ALL)
.border_style(if rating_active { Style::default().fg(Color::Yellow) } else { Style::default() }),
.border_style(if rating_active {
Style::default().fg(Color::Yellow)
} else {
Style::default()
}),
),
rows[3],
);
render_input(frame, rows[4], "Watched at (YYYY-MM-DDTHH:MM:SS)", &state.watched_at, state.focused == AddReviewField::WatchedAt);
render_input(frame, rows[5], "Comment (optional)", &state.comment, state.focused == AddReviewField::Comment);
render_input(
frame,
rows[4],
"Watched at (YYYY-MM-DDTHH:MM:SS)",
&state.watched_at,
state.focused == AddReviewField::WatchedAt,
);
render_input(
frame,
rows[5],
"Comment (optional)",
&state.comment,
state.focused == AddReviewField::Comment,
);
let submit_style = if state.focused == AddReviewField::Submit {
Style::default()
@@ -507,7 +564,13 @@ fn draw_settings(frame: &mut Frame, area: Rect, state: &SettingsState) {
])
.split(inner);
render_input(frame, rows[0], "API URL", &state.api_url, state.focused == SettingsField::ApiUrl);
render_input(
frame,
rows[0],
"API URL",
&state.api_url,
state.focused == SettingsField::ApiUrl,
);
let save_style = if state.focused == SettingsField::Save {
Style::default()
@@ -555,11 +618,22 @@ fn draw_status_bar(frame: &mut Frame, area: Rect, status: Option<&StatusMsg>, lo
// ── Helpers ───────────────────────────────────────────────────────────────────
fn render_input(frame: &mut Frame, area: Rect, title: &str, value: &str, active: bool) {
let text = if active { format!("{value}_") } else { value.to_string() };
let border_style = if active { Style::default().fg(Color::Yellow) } else { Style::default() };
let text = if active {
format!("{value}_")
} else {
value.to_string()
};
let border_style = if active {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
frame.render_widget(
Paragraph::new(text).block(
Block::default().title(title).borders(Borders::ALL).border_style(border_style),
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style),
),
area,
);