export feature
This commit is contained in:
@@ -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(())
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<()>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
14
crates/adapters/export/Cargo.toml
Normal file
14
crates/adapters/export/Cargo.toml
Normal 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 }
|
||||
225
crates/adapters/export/src/lib.rs
Normal file
225
crates/adapters/export/src/lib.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user