//! ActivityPub actor fetching and keypair generation.
//!
//! Keypairs are generated lazily on first actor fetch, inside a transaction.
//! A unique constraint on user_id (or repo_id) makes the race loser re-read.
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use crate::activitypub::ApUrls;
use crate::entities::prelude::{ApActors, Repositories, Users};
use crate::entities::{ap_actors, repositories, users};
use crate::repositories::RESERVED_SLUGS;
/// Fetch or create an actor for the given user, generating a keypair if needed.
pub async fn get_or_create(
db: &DatabaseConnection,
user_id: i64,
) -> Result<ap_actors::Model, sea_orm::DbErr> {
// Try to fetch an existing actor first.
if let Some(actor) = ApActors::find()
.filter(ap_actors::Column::UserId.eq(user_id))
.one(db)
.await?
{
return Ok(actor);
}
// No actor yet: generate a keypair and insert.
let (private_pem, public_pem) = crate::activitypub::keys::generate()
.map_err(|e| sea_orm::DbErr::Custom(format!("key generation failed: {e}")))?;
let actor = ap_actors::ActiveModel {
user_id: Set(Some(user_id)),
repo_id: Set(None),
private_key_pem: Set(private_pem),
public_key_pem: Set(public_pem),
..Default::default()
};
let inserted = actor.insert(db).await?;
Ok(inserted)
}
/// Fetch or create an actor for the given repository, generating a keypair if
/// needed.
pub async fn get_or_create_for_repo(
db: &DatabaseConnection,
repo_id: i64,
) -> Result<ap_actors::Model, sea_orm::DbErr> {
// Try to fetch an existing actor first.
if let Some(actor) = ApActors::find()
.filter(ap_actors::Column::RepoId.eq(repo_id))
.one(db)
.await?
{
return Ok(actor);
}
// No actor yet: generate a keypair and insert.
let (private_pem, public_pem) = crate::activitypub::keys::generate()
.map_err(|e| sea_orm::DbErr::Custom(format!("key generation failed: {e}")))?;
let actor = ap_actors::ActiveModel {
user_id: Set(None),
repo_id: Set(Some(repo_id)),
private_key_pem: Set(private_pem),
public_key_pem: Set(public_pem),
..Default::default()
};
let inserted = actor.insert(db).await?;
Ok(inserted)
}
/// Get the actor URL for a given actor model.
///
/// Returns the appropriate URL based on whether this is a user actor or
/// repository actor.
pub async fn actor_url_for(
db: &DatabaseConnection,
public_url: &str,
actor: &ap_actors::Model,
) -> Result<Option<String>, sea_orm::DbErr> {
let urls = ApUrls::new(public_url);
if let Some(user_id) = actor.user_id {
// User actor: look up the user to get their name
let user = Users::find_by_id(user_id).one(db).await?;
if let Some(user) = user {
return Ok(Some(urls.actor(&user.name)));
}
} else if let Some(repo_id) = actor.repo_id {
// Repository actor: look up the repository to get owner name and slug
let repo = Repositories::find_by_id(repo_id).one(db).await?;
if let Some(repo) = repo {
// Look up the owner user to get their name
let owner = Users::find_by_id(repo.owner_id).one(db).await?;
if let Some(owner) = owner {
return Ok(Some(urls.repo_actor(&owner.name, &repo.slug)));
}
}
}
Ok(None)
}
/// Fetch a local actor by their ActivityPub URL.
///
/// Returns None if the URL doesn't match a local actor pattern or if the user
/// doesn't exist. Matches both user actors at `{base}/~{user}` and repository
/// actors at `{base}/~{owner}/{slug}`.
pub async fn get_by_url(
db: &DatabaseConnection,
actor_url: &str,
public_url: &str,
) -> Result<Option<ap_actors::Model>, sea_orm::DbErr> {
let urls = ApUrls::new(public_url);
// Check if this URL matches our actor pattern: {base}/~{...}
let prefix = format!("{}/~", urls.base());
if !actor_url.starts_with(&prefix) {
return Ok(None);
}
// Extract the part after the prefix
let remainder = &actor_url[prefix.len()..];
// Split by '/' to get segments
let segments: Vec<&str> = remainder.split('/').collect();
match segments.len() {
1 => {
// User actor: {base}/~{user}
let username = segments[0];
let user = Users::find()
.filter(users::Column::Name.eq(username))
.one(db)
.await?;
if let Some(user) = user {
get_or_create(db, user.id).await.map(Some)
} else {
Ok(None)
}
}
2 => {
// Repository actor: {base}/~{owner}/{slug}
let owner_name = segments[0];
let slug = segments[1];
// Check that the slug is not reserved
if RESERVED_SLUGS.contains(&slug) {
return Ok(None);
}
// Look up the owner user
let owner = Users::find()
.filter(users::Column::Name.eq(owner_name))
.one(db)
.await?;
if let Some(owner) = owner {
// Look up the repository (must be public and not deleted)
let repo = Repositories::find()
.filter(repositories::Column::OwnerId.eq(owner.id))
.filter(repositories::Column::Slug.eq(slug))
.filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
.filter(repositories::Column::DeletedAt.is_null())
.one(db)
.await?;
if let Some(repo) = repo {
get_or_create_for_repo(db, repo.id).await.map(Some)
} else {
Ok(None)
}
} else {
Ok(None)
}
}
_ => {
// More than 2 segments (e.g., /~user/inbox) - not an actor URL
Ok(None)
}
}
}