22054c427463519346462843ea625a242a2e8124 / frogue / src / activitypub / notes.rs
Raw
//! ActivityPub note publishing.
//!
//! Handles creating notes from markdown input, rendering them to HTML,
//! and creating the corresponding Create activities.
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::activitypub::types::{CONTEXT, Create, Note};
use crate::entities::prelude::{ApActors, ApNotes};
use crate::entities::{ap_activities, ap_actors, ap_notes};
/// Publishes a new note from markdown content.
///
/// Renders the markdown to HTML, stores the note, and creates a Create activity.
pub async fn publish(
db: &DatabaseConnection,
actor_id: i64,
actor_url: &str,
_public_url: &str,
content_md: &str,
) -> Result<ap_notes::Model, sea_orm::DbErr> {
// Render markdown to HTML
let content_html = crate::markdown::render(content_md);
// Generate UUID for the note
let note_uuid = Uuid::new_v4();
let note_url = format!("{}/notes/{}", actor_url, note_uuid);
// Insert the note
let note = ap_notes::ActiveModel {
uuid: Set(note_uuid),
actor_id: Set(actor_id),
content_md: Set(content_md.to_string()),
content_html: Set(content_html.clone()),
..Default::default()
};
let note = note.insert(db).await?;
// Build the Note object
let note_obj = Note {
context: CONTEXT.to_vec(),
id: note_url.clone(),
kind: "Note",
attributed_to: actor_url.to_string(),
content: content_html,
media_type: None,
url: note_url.clone(),
in_reply_to: None,
source: None,
published: chrono::Utc::now().to_rfc3339(),
to: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
cc: vec![],
};
// Build the Create activity
let activity_uuid = Uuid::new_v4();
let activity_url = format!("{}/activities/{}", actor_url, activity_uuid);
let create_activity = Create {
context: CONTEXT.to_vec(),
id: activity_url,
kind: "Create",
actor: actor_url.to_string(),
object: note_obj,
published: chrono::Utc::now().to_rfc3339(),
to: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
cc: vec![],
};
// Serialize the activity
let activity_json = serde_json::to_string(&create_activity)
.map_err(|e| sea_orm::DbErr::Custom(e.to_string()))?;
// Insert the activity
let activity = ap_activities::ActiveModel {
uuid: Set(activity_uuid),
actor_id: Set(actor_id),
activity_json: Set(activity_json),
object_url: Set(note_url),
..Default::default()
};
let activity = activity.insert(db).await?;
// Fan out to followers
deliver_to_followers(db, actor_id, activity.id).await?;
Ok(note)
}
/// Retrieves a note by its URL.
///
/// Returns the note as an ActivityPub Note object, or None if not found.
pub async fn get_note(
db: &DatabaseConnection,
note_url: &str,
actor_url: &str,
) -> Result<Option<serde_json::Value>, sea_orm::DbErr> {
// Extract the UUID from the URL
// URL format: {base}/~{user}/notes/{uuid}
let uuid_str = note_url.rsplit('/').next().unwrap_or("");
let uuid = match Uuid::parse_str(uuid_str) {
Ok(uuid) => uuid,
Err(_) => return Ok(None),
};
// Find the note
let note = ApNotes::find()
.filter(ap_notes::Column::Uuid.eq(uuid))
.one(db)
.await?;
let Some(note) = note else {
return Ok(None);
};
// Find the actor to get the user name
let actor = ApActors::find()
.filter(ap_actors::Column::Id.eq(note.actor_id))
.one(db)
.await?;
let Some(actor) = actor else {
return Ok(None);
};
// Get the user to build the actor URL
let user = match actor.user_id {
Some(user_id) => {
use crate::entities::prelude::Users;
use crate::entities::users;
use sea_orm::QueryFilter;
Users::find()
.filter(users::Column::Id.eq(user_id))
.one(db)
.await?
}
None => None,
};
let Some(_user) = user else {
return Ok(None);
};
// Build the Note object
let note_obj = Note {
context: CONTEXT.to_vec(),
id: note_url.to_string(),
kind: "Note",
attributed_to: actor_url.to_string(),
content: note.content_html,
media_type: None,
url: note_url.to_string(),
in_reply_to: None,
source: None,
published: note.created_at.to_rfc3339(),
to: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
cc: vec![],
};
Ok(Some(serde_json::to_value(note_obj).unwrap()))
}
/// Lists notes for an actor, newest first.
pub async fn list(
db: &DatabaseConnection,
actor_id: i64,
) -> Result<Vec<ap_notes::Model>, sea_orm::DbErr> {
use sea_orm::QueryOrder;
ApNotes::find()
.filter(ap_notes::Column::ActorId.eq(actor_id))
.order_by_desc(ap_notes::Column::CreatedAt)
.all(db)
.await
}
/// Reaction counts for a note.
pub struct NoteReactions {
pub likes: u64,
pub announces: u64,
pub emojis: Vec<(String, u64)>,
}
impl NoteReactions {
pub fn is_empty(&self) -> bool {
self.likes == 0 && self.announces == 0 && self.emojis.is_empty()
}
}
/// Loads reaction counts for a note.
pub async fn get_reactions(
db: &DatabaseConnection,
note_id: i64,
) -> Result<NoteReactions, sea_orm::DbErr> {
use crate::entities::ap_reactions;
use crate::entities::prelude::ApReactions;
let reactions = ApReactions::find()
.filter(ap_reactions::Column::NoteId.eq(note_id))
.all(db)
.await?;
let mut likes = 0u64;
let mut announces = 0u64;
let mut emoji_counts = std::collections::HashMap::new();
for reaction in reactions {
match reaction.kind.as_str() {
"like" => likes += 1,
"announce" => announces += 1,
"emoji_react" => {
if let Some(emoji) = reaction.emoji {
*emoji_counts.entry(emoji).or_insert(0u64) += 1;
}
}
_ => {}
}
}
let mut emojis: Vec<(String, u64)> = emoji_counts.into_iter().collect();
emojis.sort_by(|a, b| b.1.cmp(&a.1)); // Sort by count descending
Ok(NoteReactions {
likes,
announces,
emojis,
})
}
/// Deliver an activity to all followers of an actor.
///
/// Groups followers by sharedInbox when available to avoid duplicate deliveries.
async fn deliver_to_followers(
db: &DatabaseConnection,
actor_id: i64,
activity_id: i64,
) -> Result<(), sea_orm::DbErr> {
use crate::entities::prelude::{ApFollowers, ApRemoteActors};
use crate::entities::{ap_deliveries, ap_followers};
use sea_orm::QueryFilter;
use std::collections::HashSet;
// Find all followers of this actor
let followers = ApFollowers::find()
.filter(ap_followers::Column::LocalActorId.eq(actor_id))
.all(db)
.await?;
// Collect unique inbox URLs, preferring sharedInbox when available
let mut inbox_urls = HashSet::new();
for follower in followers {
let remote_actor = ApRemoteActors::find_by_id(follower.remote_actor_id)
.one(db)
.await?;
if let Some(remote_actor) = remote_actor {
// Use sharedInbox if available, otherwise use individual inbox
let inbox_url = remote_actor
.shared_inbox_url
.unwrap_or(remote_actor.inbox_url);
inbox_urls.insert(inbox_url);
}
}
// Create a delivery row for each unique inbox
for inbox_url in inbox_urls {
let delivery = ap_deliveries::ActiveModel {
activity_id: Set(activity_id),
signer_actor_id: Set(actor_id),
inbox_url: Set(inbox_url),
status: Set("pending".to_string()),
attempts: Set(0),
next_attempt_at: Set(chrono::Utc::now().into()),
last_error: Set(None),
..Default::default()
};
delivery.insert(db).await?;
}
Ok(())
}