🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! 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::ap_actors;
use crate::entities::prelude::{ApActors, Repositories, Users};
use crate::entities::{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)
        }
    }
}