use actix_web::{ dev::ConnectionInfo, get, patch, post, web, HttpRequest, HttpResponse, Scope, }; use actix_web_httpauth::extractors::bearer::BearerAuth; use uuid::Uuid; use mitra_config::{Config, DefaultRole, RegistrationType}; use mitra_models::{ database::{get_database_client, DatabaseError, DbPool}, posts::queries::get_posts_by_author, profiles::helpers::find_verified_aliases, profiles::queries::{ get_profile_by_acct, get_profile_by_id, search_profiles_by_did, update_profile, }, profiles::types::{ IdentityProof, IdentityProofType, ProfileUpdateData, }, relationships::queries::{ get_followers_paginated, get_following_paginated, hide_replies, hide_reposts, show_replies, show_reposts, unfollow, }, subscriptions::queries::get_incoming_subscriptions, users::queries::{ create_user, get_user_by_did, is_valid_invite_code, }, users::types::{Role, UserCreateData}, }; use mitra_utils::{ caip2::ChainId, canonicalization::canonicalize_object, crypto_rsa::{ generate_rsa_key, serialize_private_key, }, currencies::Currency, did::Did, did_pkh::DidPkh, id::generate_ulid, passwords::hash_password, }; use crate::activitypub::{ builders::{ follow::follow_or_create_request, undo_follow::prepare_undo_follow, update_person::{ build_update_person, prepare_update_person, }, }, identifiers::local_actor_id, }; use crate::errors::ValidationError; use crate::http::get_request_base_url; use crate::identity::{ claims::create_identity_claim, minisign::{ minisign_key_to_did, parse_minisign_signature, verify_minisign_signature, }, }; use crate::json_signatures::{ create::{add_integrity_proof, IntegrityProof}, verify::{ verify_ed25519_json_signature, verify_eip191_json_signature, }, }; use crate::mastodon_api::{ errors::MastodonError, oauth::auth::get_current_user, pagination::get_paginated_response, search::helpers::search_profiles_only, statuses::helpers::build_status_list, statuses::types::Status, }; use crate::validators::{ profiles::clean_profile_update_data, users::validate_local_username, }; use super::helpers::{ get_aliases, get_relationship, }; use super::types::{ Account, AccountCreateData, AccountUpdateData, ActivityParams, ApiSubscription, FollowData, FollowListQueryParams, IdentityClaim, IdentityClaimQueryParams, IdentityProofData, LookupAcctQueryParams, RelationshipQueryParams, SearchAcctQueryParams, SearchDidQueryParams, SignedActivity, StatusListQueryParams, UnsignedActivity, }; #[post("")] pub async fn create_account( connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, account_data: web::Json, ) -> Result { let db_client = &mut **get_database_client(&db_pool).await?; // Validate if config.registration.registration_type == RegistrationType::Invite { let invite_code = account_data.invite_code.as_ref() .ok_or(ValidationError("invite code is required"))?; if !is_valid_invite_code(db_client, invite_code).await? { return Err(ValidationError("invalid invite code").into()); }; }; validate_local_username(&account_data.username)?; if account_data.password.is_none() && account_data.message.is_none() { return Err(ValidationError("password or EIP-4361 message is required").into()); }; let maybe_password_hash = if let Some(password) = account_data.password.as_ref() { let password_hash = hash_password(password) .map_err(|_| MastodonError::InternalError)?; Some(password_hash) } else { None }; // Generate RSA private key for actor let private_key = match web::block(generate_rsa_key).await { Ok(Ok(private_key)) => private_key, _ => return Err(MastodonError::InternalError), }; let private_key_pem = serialize_private_key(&private_key) .map_err(|_| MastodonError::InternalError)?; let AccountCreateData { username, invite_code, .. } = account_data.into_inner(); let role = match config.registration.default_role { DefaultRole::NormalUser => Role::NormalUser, DefaultRole::ReadOnlyUser => Role::ReadOnlyUser, }; let user_data = UserCreateData { username, password_hash: maybe_password_hash, private_key_pem, wallet_address: None, invite_code, role, }; let user = match create_user(db_client, user_data).await { Ok(user) => user, Err(DatabaseError::AlreadyExists(_)) => return Err(ValidationError("user already exists").into()), Err(other_error) => return Err(other_error.into()), }; log::warn!("created user {}", user.id); let account = Account::from_user( &get_request_base_url(connection_info), &config.instance_url(), user, ); Ok(HttpResponse::Created().json(account)) } #[get("/verify_credentials")] async fn verify_credentials( auth: BearerAuth, connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; let user = get_current_user(db_client, auth.token()).await?; let account = Account::from_user( &get_request_base_url(connection_info), &config.instance_url(), user, ); Ok(HttpResponse::Ok().json(account)) } #[patch("/update_credentials")] async fn update_credentials( auth: BearerAuth, connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, account_data: web::Json, ) -> Result { let db_client = &mut **get_database_client(&db_pool).await?; let mut current_user = get_current_user(db_client, auth.token()).await?; let mut profile_data = account_data.into_inner() .into_profile_data( ¤t_user.profile, &config.media_dir(), )?; clean_profile_update_data(&mut profile_data)?; current_user.profile = update_profile( db_client, ¤t_user.id, profile_data, ).await?; // Federate prepare_update_person( db_client, &config.instance(), ¤t_user, None, ).await?.enqueue(db_client).await?; let account = Account::from_user( &get_request_base_url(connection_info), &config.instance_url(), current_user, ); Ok(HttpResponse::Ok().json(account)) } #[get("/signed_update")] async fn get_unsigned_update( auth: BearerAuth, config: web::Data, db_pool: web::Data, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; let current_user = get_current_user(db_client, auth.token()).await?; let internal_activity_id = generate_ulid(); let activity = build_update_person( &config.instance_url(), ¤t_user, Some(internal_activity_id), ).map_err(|_| MastodonError::InternalError)?; let canonical_json = canonicalize_object(&activity) .map_err(|_| MastodonError::InternalError)?; let data = UnsignedActivity { params: ActivityParams::Update { internal_activity_id }, message: canonical_json, }; Ok(HttpResponse::Ok().json(data)) } #[post("/send_activity")] async fn send_signed_activity( auth: BearerAuth, connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, data: web::Json, ) -> Result { let db_client = &mut **get_database_client(&db_pool).await?; let current_user = get_current_user(db_client, auth.token()).await?; let signer = data.signer.parse::() .map_err(|_| ValidationError("invalid DID"))?; if !current_user.profile.identity_proofs.any(&signer) { return Err(ValidationError("unknown signer").into()); }; let mut outgoing_activity = match &data.params { ActivityParams::Update { internal_activity_id } => { prepare_update_person( db_client, &config.instance(), ¤t_user, Some(*internal_activity_id), ).await.map_err(|_| MastodonError::InternalError)? }, }; let canonical_json = canonicalize_object(&outgoing_activity.activity) .map_err(|_| MastodonError::InternalError)?; let proof = match signer { Did::Key(signer) => { let signature_bin = parse_minisign_signature(&data.signature) .map_err(|_| ValidationError("invalid encoding"))?; verify_ed25519_json_signature(&signer, &canonical_json, &signature_bin) .map_err(|_| ValidationError("invalid signature"))?; IntegrityProof::jcs_ed25519(&signer, &signature_bin) }, Did::Pkh(signer) => { let signature_bin = hex::decode(&data.signature) .map_err(|_| ValidationError("invalid encoding"))?; verify_eip191_json_signature(&signer, &canonical_json, &signature_bin) .map_err(|_| ValidationError("invalid signature"))?; IntegrityProof::jcs_eip191(&signer, &signature_bin) }, }; add_integrity_proof(&mut outgoing_activity.activity, proof) .map_err(|_| MastodonError::InternalError)?; outgoing_activity.enqueue(db_client).await?; let account = Account::from_user( &get_request_base_url(connection_info), &config.instance_url(), current_user, ); Ok(HttpResponse::Ok().json(account)) } #[get("/identity_proof")] async fn get_identity_claim( auth: BearerAuth, config: web::Data, db_pool: web::Data, query_params: web::Query, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; let current_user = get_current_user(db_client, auth.token()).await?; let did = match query_params.proof_type.as_str() { "ethereum" => { let did_pkh = DidPkh::from_address( &Currency::Ethereum, &query_params.signer, ); Did::Pkh(did_pkh) }, "minisign" => { let did_key = minisign_key_to_did(&query_params.signer) .map_err(|_| ValidationError("invalid key"))?; Did::Key(did_key) }, _ => return Err(ValidationError("unknown proof type").into()), }; let actor_id = local_actor_id( &config.instance_url(), ¤t_user.profile.username, ); let claim = create_identity_claim(&actor_id, &did) .map_err(|_| MastodonError::InternalError)?; let response = IdentityClaim { did, claim }; Ok(HttpResponse::Ok().json(response)) } #[post("/identity_proof")] async fn create_identity_proof( auth: BearerAuth, connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, proof_data: web::Json, ) -> Result { let db_client = &mut **get_database_client(&db_pool).await?; let mut current_user = get_current_user(db_client, auth.token()).await?; let did = proof_data.did.parse::() .map_err(|_| ValidationError("invalid DID"))?; // Reject proof if there's another local user with the same DID. // This is needed for matching ethereum subscriptions match get_user_by_did(db_client, &did).await { Ok(user) => { if user.id != current_user.id { return Err(ValidationError("DID already associated with another user").into()); }; }, Err(DatabaseError::NotFound(_)) => (), Err(other_error) => return Err(other_error.into()), }; let actor_id = local_actor_id( &config.instance_url(), ¤t_user.profile.username, ); let message = create_identity_claim(&actor_id, &did) .map_err(|_| ValidationError("invalid claim"))?; // Verify proof let proof_type = match did { Did::Key(ref did_key) => { let signature_bin = parse_minisign_signature(&proof_data.signature) .map_err(|_| ValidationError("invalid signature encoding"))?; verify_minisign_signature( did_key, &message, &signature_bin, ).map_err(|_| ValidationError("invalid signature"))?; IdentityProofType::LegacyMinisignIdentityProof }, Did::Pkh(ref did_pkh) => { if did_pkh.chain_id != ChainId::ethereum_mainnet() { // DID must point to Ethereum Mainnet because it is a valid // identifier on any Ethereum chain return Err(ValidationError("unsupported chain ID").into()); }; let maybe_public_address = current_user.public_wallet_address(&Currency::Ethereum); if let Some(address) = maybe_public_address { // Do not allow to add more than one address proof if did_pkh.address != address { return Err(ValidationError("DID doesn't match current identity").into()); }; }; return Err(ValidationError("invalid signature").into()); }, }; let proof = IdentityProof { issuer: did, proof_type: proof_type, value: proof_data.signature.clone(), }; let mut profile_data = ProfileUpdateData::from(¤t_user.profile); profile_data.add_identity_proof(proof); current_user.profile = update_profile( db_client, ¤t_user.id, profile_data, ).await?; // Federate prepare_update_person( db_client, &config.instance(), ¤t_user, None, ).await?.enqueue(db_client).await?; let account = Account::from_user( &get_request_base_url(connection_info), &config.instance_url(), current_user, ); Ok(HttpResponse::Ok().json(account)) } #[get("/relationships")] async fn get_relationships_view( auth: BearerAuth, db_pool: web::Data, query_params: web::Query, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; let current_user = get_current_user(db_client, auth.token()).await?; let relationship = get_relationship( db_client, ¤t_user.id, &query_params.id, ).await?; Ok(HttpResponse::Ok().json(vec![relationship])) } #[get("/lookup")] async fn lookup_acct( connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, query_params: web::Query, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; let profile = get_profile_by_acct(db_client, &query_params.acct).await?; let account = Account::from_profile( &get_request_base_url(connection_info), &config.instance_url(), profile, ); Ok(HttpResponse::Ok().json(account)) } #[get("/search")] async fn search_by_acct( auth: Option, connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, query_params: web::Query, ) -> Result { let db_client = &mut **get_database_client(&db_pool).await?; match auth { Some(auth) => { get_current_user(db_client, auth.token()).await?; }, None => { // Only authorized users can make webfinger queries if query_params.resolve { return Err(MastodonError::PermissionError); }; }, }; let profiles = search_profiles_only( &config, db_client, &query_params.q, query_params.resolve, query_params.limit.inner(), ).await?; let base_url = get_request_base_url(connection_info); let instance_url = config.instance().url(); let accounts: Vec = profiles.into_iter() .map(|profile| Account::from_profile( &base_url, &instance_url, profile, )) .collect(); Ok(HttpResponse::Ok().json(accounts)) } #[get("/search_did")] async fn search_by_did( connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, query_params: web::Query, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; let did: Did = query_params.did.parse() .map_err(|_| ValidationError("invalid DID"))?; let profiles = search_profiles_by_did(db_client, &did, false).await?; let base_url = get_request_base_url(connection_info); let instance_url = config.instance().url(); let accounts: Vec = profiles.into_iter() .map(|profile| Account::from_profile( &base_url, &instance_url, profile, )) .collect(); Ok(HttpResponse::Ok().json(accounts)) } #[get("/{account_id}")] async fn get_account( connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, account_id: web::Path, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; let profile = get_profile_by_id(db_client, &account_id).await?; let account = Account::from_profile( &get_request_base_url(connection_info), &config.instance_url(), profile, ); Ok(HttpResponse::Ok().json(account)) } #[post("/{account_id}/follow")] async fn follow_account( auth: BearerAuth, config: web::Data, db_pool: web::Data, account_id: web::Path, follow_data: web::Json, ) -> Result { let db_client = &mut **get_database_client(&db_pool).await?; let current_user = get_current_user(db_client, auth.token()).await?; let target = get_profile_by_id(db_client, &account_id).await?; follow_or_create_request( db_client, &config.instance(), ¤t_user, &target, ).await?; if follow_data.reblogs { show_reposts(db_client, ¤t_user.id, &target.id).await?; } else { hide_reposts(db_client, ¤t_user.id, &target.id).await?; }; if follow_data.replies { show_replies(db_client, ¤t_user.id, &target.id).await?; } else { hide_replies(db_client, ¤t_user.id, &target.id).await?; }; let relationship = get_relationship( db_client, ¤t_user.id, &target.id, ).await?; Ok(HttpResponse::Ok().json(relationship)) } #[post("/{account_id}/unfollow")] async fn unfollow_account( auth: BearerAuth, config: web::Data, db_pool: web::Data, account_id: web::Path, ) -> Result { let db_client = &mut **get_database_client(&db_pool).await?; let current_user = get_current_user(db_client, auth.token()).await?; let target = get_profile_by_id(db_client, &account_id).await?; match unfollow(db_client, ¤t_user.id, &target.id).await { Ok(Some(follow_request_id)) => { // Remote follow let remote_actor = target.actor_json .ok_or(MastodonError::InternalError)?; prepare_undo_follow( &config.instance(), ¤t_user, &remote_actor, &follow_request_id, ).enqueue(db_client).await?; }, Ok(None) => (), // local follow Err(DatabaseError::NotFound(_)) => (), // not following Err(other_error) => return Err(other_error.into()), }; let relationship = get_relationship( db_client, ¤t_user.id, &target.id, ).await?; Ok(HttpResponse::Ok().json(relationship)) } #[get("/{account_id}/statuses")] async fn get_account_statuses( auth: Option, connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, account_id: web::Path, query_params: web::Query, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; let maybe_current_user = match auth { Some(auth) => Some(get_current_user(db_client, auth.token()).await?), None => None, }; if query_params.pinned { // Pinned posts are not supported let statuses: Vec = vec![]; return Ok(HttpResponse::Ok().json(statuses)); }; let profile = get_profile_by_id(db_client, &account_id).await?; // Include reposts but not replies let posts = get_posts_by_author( db_client, &profile.id, maybe_current_user.as_ref().map(|user| &user.id), !query_params.exclude_replies, true, query_params.max_id, query_params.limit.inner(), ).await?; let statuses = build_status_list( db_client, &get_request_base_url(connection_info), &config.instance_url(), maybe_current_user.as_ref(), posts, ).await?; Ok(HttpResponse::Ok().json(statuses)) } #[get("/{account_id}/followers")] async fn get_account_followers( auth: BearerAuth, connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, account_id: web::Path, query_params: web::Query, request: HttpRequest, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; let current_user = get_current_user(db_client, auth.token()).await?; let profile = get_profile_by_id(db_client, &account_id).await?; if profile.id != current_user.id { // Social graph is hidden let accounts: Vec = vec![]; return Ok(HttpResponse::Ok().json(accounts)); }; let followers = get_followers_paginated( db_client, &profile.id, query_params.max_id, query_params.limit.inner(), ).await?; let max_index = usize::from(query_params.limit.inner().saturating_sub(1)); let maybe_last_id = followers.get(max_index).map(|item| item.relationship_id); let base_url = get_request_base_url(connection_info); let instance_url = config.instance().url(); let accounts: Vec = followers.into_iter() .map(|item| Account::from_profile( &base_url, &instance_url, item.profile, )) .collect(); let response = get_paginated_response( &instance_url, request.uri().path(), accounts, maybe_last_id, ); Ok(response) } #[get("/{account_id}/following")] async fn get_account_following( auth: BearerAuth, connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, account_id: web::Path, query_params: web::Query, request: HttpRequest, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; let current_user = get_current_user(db_client, auth.token()).await?; let profile = get_profile_by_id(db_client, &account_id).await?; if profile.id != current_user.id { // Social graph is hidden let accounts: Vec = vec![]; return Ok(HttpResponse::Ok().json(accounts)); }; let following = get_following_paginated( db_client, &profile.id, query_params.max_id, query_params.limit.inner(), ).await?; let max_index = usize::from(query_params.limit.inner().saturating_sub(1)); let maybe_last_id = following.get(max_index).map(|item| item.relationship_id); let base_url = get_request_base_url(connection_info); let instance_url = config.instance().url(); let accounts: Vec = following.into_iter() .map(|item| Account::from_profile( &base_url, &instance_url, item.profile, )) .collect(); let response = get_paginated_response( &instance_url, request.uri().path(), accounts, maybe_last_id, ); Ok(response) } #[get("/{account_id}/subscribers")] async fn get_account_subscribers( auth: BearerAuth, connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, account_id: web::Path, query_params: web::Query, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; let current_user = get_current_user(db_client, auth.token()).await?; let profile = get_profile_by_id(db_client, &account_id).await?; if profile.id != current_user.id { // Social graph is hidden let subscriptions: Vec = vec![]; return Ok(HttpResponse::Ok().json(subscriptions)); }; let base_url = get_request_base_url(connection_info); let instance_url = config.instance_url(); let subscriptions: Vec = get_incoming_subscriptions( db_client, &profile.id, query_params.max_id, query_params.limit.inner(), ) .await? .into_iter() .map(|subscription| ApiSubscription::from_subscription( &base_url, &instance_url, subscription, )) .collect(); Ok(HttpResponse::Ok().json(subscriptions)) } #[get("/{account_id}/aliases")] async fn get_account_aliases( connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, account_id: web::Path, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; let profile = get_profile_by_id(db_client, &account_id).await?; let aliases = find_verified_aliases(db_client, &profile).await?; let base_url = get_request_base_url(connection_info); let instance_url = config.instance_url(); let accounts: Vec = aliases.into_iter() .map(|profile| Account::from_profile( &base_url, &instance_url, profile, )) .collect(); Ok(HttpResponse::Ok().json(accounts)) } #[get("/{account_id}/aliases/all")] async fn get_all_account_aliases( connection_info: ConnectionInfo, config: web::Data, db_pool: web::Data, account_id: web::Path, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; let profile = get_profile_by_id(db_client, &account_id).await?; let base_url = get_request_base_url(connection_info); let instance_url = config.instance_url(); let aliases = get_aliases( db_client, &base_url, &instance_url, &profile, ).await?; Ok(HttpResponse::Ok().json(aliases)) } pub fn account_api_scope() -> Scope { web::scope("/api/v1/accounts") // Routes without account ID .service(create_account) .service(verify_credentials) .service(update_credentials) .service(get_unsigned_update) .service(send_signed_activity) .service(get_identity_claim) .service(create_identity_proof) .service(get_relationships_view) .service(lookup_acct) .service(search_by_acct) .service(search_by_did) // Routes with account ID .service(get_account) .service(follow_account) .service(unfollow_account) .service(get_account_statuses) .service(get_account_followers) .service(get_account_following) .service(get_account_subscribers) .service(get_account_aliases) .service(get_all_account_aliases) }