Something went wrong. Try again.
Paper Mario atproto mod hosting site starhaven.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212//! 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<AppState> { Router::new().route("/{handle}", get(handle_get).patch(handle_patch))}
async fn handle_get( Path(handle): Path<String>, State(state): State<AppState>, session: Option<SessionCookie>, page: Page,) -> Result<Html<String>, 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<String>, #[serde(default)] avatar: Option<AvatarPatch>,}
#[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<String>, State(state): State<AppState>, session: SessionCookie, headers: HeaderMap, body: Bytes,) -> Result<Response, AppError> { 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()) }}