use std::sync::Arc;
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, DatabaseConnection, DbErr, EntityTrait, PaginatorTrait};
use sea_orm::{ColumnTrait, QueryFilter};
use serde::Deserialize;
use tokio::sync::RwLock;
use tracing::error;
use crate::AppState;
use crate::auth::{CurrentUser, viewer_name};
use crate::entities::prelude::*;
use crate::entities::users;
use crate::state_read;
use crate::views;
use crate::views::profile::ActorProfile;
use crate::webui::repositories;
/// What the profile editing form posts.
#[derive(Debug, Deserialize)]
pub struct EditProfile {
description: String,
}
#[tracing::instrument(level = "debug", skip(db))]
async fn get_user(db: &DatabaseConnection, name: &str) -> Result<Option<users::Model>, DbErr> {
Users::find()
.filter(users::Column::Name.eq(name))
.one(db)
.await
}
/// Profiles live under a `~`, the way `example.org/~user` has worked since
/// before the web was a platform. It also keeps `/~name` from colliding with
/// whatever single-segment routes come later - repositories, settings, sign
/// in - without every one of them having to dodge `/{name}`.
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn get_profile(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
user_or_team: web::Path<String>,
req: HttpRequest,
) -> HttpResponse {
// Read the user out of the database if there is one.
let state = state_read(&data).await;
let user = match get_user(&state.db, user_or_team.as_str()).await {
Ok(user) => user,
Err(error) => {
error!(%error, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
let Some(user) = user else {
drop(state);
return HttpResponse::NotFound().body(format!("User `{user_or_team}` could not be found"));
};
let repositories = match repositories::summaries(
&state.db,
user.id,
&user.name,
viewer.as_ref().map(|viewer| viewer.id),
)
.await
{
Ok(repositories) => repositories,
Err(error) => {
error!(%error, user = %user.name, "listing repositories failed");
return HttpResponse::InternalServerError().finish();
}
};
// Load notes if federation is enabled
let note_views = if crate::activitypub::enabled(&state) {
// Get the actor for this user
let actor = match crate::activitypub::actor::get_or_create(&state.db, user.id).await {
Ok(actor) => actor,
Err(error) => {
error!(%error, user = %user.name, "getting actor failed");
return HttpResponse::InternalServerError().finish();
}
};
// Load notes for this actor
let notes = match crate::activitypub::notes::list(&state.db, actor.id).await {
Ok(notes) => notes,
Err(error) => {
error!(%error, user = %user.name, "listing notes failed");
return HttpResponse::InternalServerError().finish();
}
};
// Load reactions for each note
let mut note_view_list = Vec::new();
// Query viewer's reactions to these notes (if viewer exists)
let viewer_reacted_note_ids = if let Some(viewer_user) = &viewer {
let note_ids: Vec<i64> = notes.iter().map(|n| n.id).collect();
if !note_ids.is_empty() {
match crate::entities::prelude::ApReactions::find()
.filter(crate::entities::ap_reactions::Column::NoteId.is_in(note_ids))
.filter(
crate::entities::ap_reactions::Column::LocalUserId.eq(Some(viewer_user.id)),
)
.filter(crate::entities::ap_reactions::Column::Kind.eq("like"))
.all(&state.db)
.await
{
Ok(reactions) => reactions.iter().filter_map(|r| r.note_id).collect(),
Err(error) => {
error!(%error, "querying viewer reactions failed");
Vec::new()
}
}
} else {
Vec::new()
}
} else {
Vec::new()
};
// Query viewer's boosted notes
let viewer_boosted_note_ids = if let Some(viewer_user) = &viewer {
let note_ids: Vec<i64> = notes.iter().map(|n| n.id).collect();
if !note_ids.is_empty() {
match crate::entities::prelude::ApReactions::find()
.filter(crate::entities::ap_reactions::Column::NoteId.is_in(note_ids))
.filter(
crate::entities::ap_reactions::Column::LocalUserId.eq(Some(viewer_user.id)),
)
.filter(crate::entities::ap_reactions::Column::Kind.eq("announce"))
.all(&state.db)
.await
{
Ok(reactions) => reactions.iter().filter_map(|r| r.note_id).collect(),
Err(error) => {
error!(%error, "querying viewer boosts failed");
Vec::new()
}
}
} else {
Vec::new()
}
} else {
Vec::new()
};
let public_url = state.public_url().to_string();
let viewer_username = viewer.as_ref().map(|v| v.name.clone());
for note in notes {
match crate::activitypub::notes::get_reactions(&state.db, note.id).await {
Ok(reactions) => {
let viewer_reacted = viewer_reacted_note_ids.contains(¬e.id);
let viewer_boosted = viewer_boosted_note_ids.contains(¬e.id);
note_view_list.push(views::NoteView {
actor_profile: ActorProfile {
display_name: user.name.clone(),
handle: user.name.clone(),
host: None,
profile_url: None,
icon_url: None, // Local users don't have avatars yet
actor_url: format!("/~{}", user.name),
},
note,
reactions,
viewer_reacted,
viewer_boosted,
public_url: public_url.clone(),
viewer_username: viewer_username.clone(),
});
}
Err(error) => {
error!(%error, note_id = note.id, "loading reactions failed");
// Continue without reactions for this note
}
}
}
note_view_list
} else {
vec![]
};
// Load follower count if federation is enabled
let follower_count = if crate::activitypub::enabled(&state) {
match crate::activitypub::actor::get_or_create(&state.db, user.id).await {
Ok(actor) => {
match crate::entities::prelude::ApFollowers::find()
.filter(crate::entities::ap_followers::Column::LocalActorId.eq(actor.id))
.count(&state.db)
.await
{
Ok(count) => count,
Err(error) => {
error!(%error, user = %user.name, "counting followers failed");
0
}
}
}
Err(error) => {
error!(%error, user = %user.name, "getting actor failed");
0
}
}
} else {
0
};
// Build fediverse handle and actor URL if federation is enabled
let (handle, actor_url) = if crate::activitypub::enabled(&state) {
let urls = crate::activitypub::ApUrls::new(state.public_url());
let handle = Some(format!("@{}@{}", user.name, urls.host()));
let actor_url = Some(urls.actor(&user.name));
(handle, actor_url)
} else {
(None, None)
};
// Load following list if viewer is the owner and federation is enabled
let is_owner = viewer.as_ref().map(|v| v.name.as_str()) == Some(user.name.as_str());
let federation_enabled = crate::activitypub::enabled(&state);
let following = if is_owner && federation_enabled {
match crate::activitypub::actor::get_or_create(&state.db, user.id).await {
Ok(actor) => {
use crate::entities::ap_following;
match crate::entities::prelude::ApFollowing::find()
.filter(ap_following::Column::LocalActorId.eq(actor.id))
.find_also_related(ApRemoteActors)
.all(&state.db)
.await
{
Ok(rows) => {
rows.into_iter()
.filter_map(|(following, remote)| {
remote.map(|r| {
let display_name =
r.preferred_username.clone().unwrap_or_else(|| {
// Extract username from actor_url
r.actor_url
.split('/')
.next_back()
.unwrap_or("unknown")
.to_string()
});
views::FollowingEntry {
actor_url: r.actor_url.clone(),
display_name,
state: following.state.clone(),
}
})
})
.collect()
}
Err(error) => {
error!(%error, user = %user.name, "loading following failed");
vec![]
}
}
}
Err(error) => {
error!(%error, user = %user.name, "getting actor failed");
vec![]
}
}
} else {
vec![]
};
// Extract error/notice messages from query parameters
let query_string = req.query_string();
let error_message = query_string
.split('&')
.find(|pair| pair.starts_with("error="))
.and_then(|pair| pair.strip_prefix("error="))
.map(|s| {
url::form_urlencoded::parse(s.as_bytes())
.map(|(k, _)| k)
.collect::<String>()
});
let notice_message = query_string
.split('&')
.find(|pair| pair.starts_with("notice="))
.and_then(|pair| pair.strip_prefix("notice="))
.map(|s| {
url::form_urlencoded::parse(s.as_bytes())
.map(|(k, _)| k)
.collect::<String>()
});
// Let go of the read lock before rendering.
drop(state);
views::profile(
viewer_name(viewer.as_ref()),
&user.name,
user.description.as_deref(),
handle,
actor_url,
follower_count,
federation_enabled,
following,
error_message,
notice_message,
&repositories,
¬e_views,
)
}
/// The form for editing one's own profile. [`CurrentUser`] turns an anonymous
/// request into a `401` before this runs; a session for somebody else is a
/// `403`, since there is no reason a signed-in user should be able to probe
/// another one's editor.
#[get("/~{user_or_team}/edit")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
async fn get_edit_profile(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
user_or_team: web::Path<String>,
) -> HttpResponse {
let state = state_read(&data).await;
let user = match get_user(&state.db, user_or_team.as_str()).await {
Ok(user) => user,
Err(error) => {
error!(%error, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
drop(state);
let Some(user) = user else {
return HttpResponse::NotFound().body(format!("User `{user_or_team}` could not be found"));
};
if user.id != current.id {
return HttpResponse::Forbidden().finish();
}
views::edit_profile(Some(¤t.name), &user.name, user.description.as_deref())
}
#[post("/~{user_or_team}/edit")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
async fn post_edit_profile(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
user_or_team: web::Path<String>,
form: web::Form<EditProfile>,
) -> HttpResponse {
let state = state_read(&data).await;
let user = match get_user(&state.db, user_or_team.as_str()).await {
Ok(user) => user,
Err(error) => {
error!(%error, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
drop(state);
let Some(user) = user else {
return HttpResponse::NotFound().body(format!("User `{user_or_team}` could not be found"));
};
if user.id != current.id {
return HttpResponse::Forbidden().finish();
}
let name = user.name.clone();
let mut edited: users::ActiveModel = user.into();
edited.description = Set(Some(form.into_inner().description));
let state = state_read(&data).await;
if let Err(error) = edited.update(&state.db).await {
error!(%error, user = %name, "updating the profile failed");
return HttpResponse::InternalServerError().finish();
}
drop(state);
HttpResponse::SeeOther()
.append_header(("Location", format!("/~{name}")))
.finish()
}