//! Actor profile page. use atproto_record::lexicon::TypedBlob; use axum::body::Bytes; use axum::extract::{Path, State}; use axum::http::header::CONTENT_TYPE; use axum::http::{HeaderMap, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::get; use axum::Router; use maud::{html, Markup}; use serde::Deserialize; use crate::atproto::id::{Did, Handle, Rkey}; use crate::atproto::lexicon::{ActorProfile, ModListing}; use crate::error::AppError; use crate::oauth::session::SessionCookie; use crate::page::Page; use crate::search; use crate::state::AppState; pub fn router() -> Router { Router::new().route("/{handle}", get(handle_get).patch(handle_patch)) } async fn handle_get( Path(handle): Path, State(state): State, session: Option, page: Page, ) -> Result, AppError> { let handle = Handle::new(&handle).map_err(|e| AppError::BadRequest(e.to_string()))?; let did = handle .resolve_did(&state) .await? .ok_or(AppError::NotFound)?; let profile = ActorProfile::load(&state.db, &did) .await .map_err(AppError::Internal)?; let listings = ModListing::find_by_author(&state.db, &did) .await .map_err(AppError::Internal)?; let is_owner = session.is_some_and(|s| s.did == did); Ok(page .body(actor_page(&handle, &did, &profile, &listings, is_owner)) .title(handle.to_string()) .render()) } fn actor_page( handle: &Handle, did: &Did, profile: &ActorProfile, listings: &[(Rkey, ModListing)], is_owner: bool, ) -> Markup { html! { main.actor-page { div.actor-header { @if is_owner { avatar-form action=(format!("/{handle}")) src=(profile.avatar_url(did)) {} } @else { (actor_avatar(did, profile)) } div.actor-header-info { h1 { (handle) } @if is_owner { form.actor-bio-form id="profile-form" hx-patch=(format!("/{handle}")) { label.visually-hidden for="description" { "Bio" } textarea.actor-bio-input name="description" id="description" placeholder="Write something about yourself" { (profile.description) } div.form-actions { button.button type="submit" { "Save" } } } } @else if !profile.description.is_empty() { p.actor-bio { (profile.description) } } } } h2 { "Mods" } @if listings.is_empty() { p.hint { "No mods yet." } } @else { ul.search-results { @for (_, listing) in listings { li.search-result { a href={ "/" (handle) "/" (listing.slug) } { (search::hit_card(&listing_hit(handle, did, listing))) } } } } } } } } fn actor_avatar(did: &Did, profile: &ActorProfile) -> Markup { html! { img.actor-avatar.actor-avatar-img id="actor-avatar-img" src=(profile.avatar_url(did)) alt=""; } } fn listing_hit(handle: &Handle, did: &Did, listing: &ModListing) -> search::Hit { search::Hit { slug: listing.slug.clone(), handle: Some(handle.clone()), title: listing.title.clone(), description: listing.description.clone(), game: Some(listing.game), thumb_url: listing .media .first() .map(|item| format!("/blob/{did}/{}", item.file().inner.ref_.link)), } } /// A PATCH body can update the description, the avatar, or both. #[derive(Deserialize)] struct ProfilePatch { #[serde(default)] description: Option, #[serde(default)] avatar: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AvatarPatch { cid: String, mime_type: String, size: u64, } impl AvatarPatch { fn into_typed_blob(self) -> TypedBlob { use atproto_record::lexicon::{Blob, Link}; TypedBlob::new(Blob { ref_: Link { link: self.cid }, mime_type: self.mime_type, size: self.size, }) } } async fn handle_patch( Path(handle): Path, State(state): State, session: SessionCookie, headers: HeaderMap, body: Bytes, ) -> Result { let content_type = headers .get(CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); let is_json = content_type.starts_with("application/json"); let patch: ProfilePatch = if is_json { serde_json::from_slice(&body).map_err(|e| AppError::BadRequest(e.to_string()))? } else { serde_urlencoded::from_bytes(&body).map_err(|e| AppError::BadRequest(e.to_string()))? }; let handle = Handle::new(&handle).map_err(|e| AppError::BadRequest(e.to_string()))?; let did = handle .resolve_did(&state) .await? .ok_or(AppError::NotFound)?; if did != session.did { return Err(AppError::Forbidden); } let mut profile = ActorProfile::load(&state.db, &did) .await .map_err(AppError::Internal)?; if let Some(description) = patch.description { let description = description.trim().to_string(); if description.chars().count() > 1000 { return Err(AppError::BadRequest( "Bio must be 1000 characters or less".to_string(), )); } profile.description = description; } if let Some(avatar) = patch.avatar { profile.avatar = Some(avatar.into_typed_blob()); } profile.save(&state, &session).await?; if is_json { Ok(StatusCode::NO_CONTENT.into_response()) } else { Ok(( [("HX-Redirect", format!("/{handle}"))], StatusCode::NO_CONTENT, ) .into_response()) } }