hyaenidae/server/src/profiles/mod.rs

1965 lines
57 KiB
Rust

use crate::{
error::{Error, OptionExt},
nav::NavState,
State,
};
use actix_session::Session;
use actix_web::{web, HttpRequest, HttpResponse, Scope};
use hyaenidae_accounts::User;
use hyaenidae_profiles::store::{File, FileStore, ProfileStore, Submission};
use hyaenidae_toolkit::{Button, TextInput};
use std::collections::HashMap;
use uuid::Uuid;
mod middleware;
pub(crate) use middleware::CurrentProfile;
pub(super) fn scope() -> Scope {
web::scope("/profiles")
.service(web::resource("").route(web::get().to(profiles)))
.service(web::resource("/current").route(web::get().to(current_profile)))
.service(
web::resource("/delete")
.route(web::get().to(delete_page))
.route(web::post().to(delete_profile)),
)
.service(
web::scope("/update")
.service(
web::resource("/bio")
.route(web::post().to(update_bio))
.route(web::get().to(to_current_profile)),
)
.service(
web::resource("/icon")
.route(web::post().to(update_icon))
.route(web::get().to(to_current_profile)),
)
.service(
web::resource("/banner")
.route(web::post().to(update_banner))
.route(web::get().to(to_current_profile)),
)
.service(
web::resource("/require-login")
.route(web::post().to(update_require_login))
.route(web::get().to(to_current_profile)),
),
)
.service(
web::resource("/change")
.route(web::get().to(change_profile_page))
.route(web::post().to(change_profile)),
)
.service(
web::scope("/create")
.service(web::resource("").route(web::get().to(to_create)))
.service(
web::resource("/handle")
.route(web::get().to(new_handle))
.route(web::post().to(create_handle)),
)
.service(
web::resource("/bio")
.route(web::get().to(new_bio))
.route(web::post().to(create_bio)),
)
.service(
web::resource("/icon")
.route(web::get().to(new_icon))
.route(web::post().to(create_icon)),
)
.service(
web::resource("/banner")
.route(web::get().to(new_banner))
.route(web::post().to(create_banner)),
)
.service(
web::resource("/require-login")
.route(web::get().to(new_require_login))
.route(web::post().to(create_require_login)),
)
.service(web::resource("/done").route(web::get().to(done))),
)
.route("/drafts", web::get().to(drafts_page))
.service(
web::scope("/@{handle}@{domain}")
.route("", web::get().to(handle_view))
.service(
web::resource("/follow")
.route(web::get().to(route_to_profile_page))
.route(web::post().to(follow)),
)
.service(
web::resource("/unfollow")
.route(web::get().to(route_to_profile_page))
.route(web::post().to(unfollow)),
)
.service(
web::resource("/block")
.route(web::get().to(route_to_profile_page))
.route(web::post().to(block)),
)
.service(
web::resource("/unblock")
.route(web::get().to(route_to_profile_page))
.route(web::post().to(unblock)),
)
.service(
web::resource("/report")
.route(web::get().to(report_page))
.route(web::post().to(report)),
)
.route("/report-success", web::get().to(report_success_page)),
)
.route("/id/{id}", web::get().to(id_view))
}
fn to_profile_page(handle: &str, domain: &str) -> HttpResponse {
crate::redirect(&format!("/profiles/@{}@{}", handle, domain))
}
async fn route_to_profile_page(handle: web::Path<(String, String)>) -> HttpResponse {
let (handle, domain) = handle.into_inner();
to_profile_page(&handle, &domain)
}
async fn follow(
handle: web::Path<(String, String)>,
self_profile: Profile,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::CreateFollowRequest;
let (handle, domain) = handle.into_inner();
let profile = profile_from_handle(handle.clone(), domain.clone(), &state).await?;
let self_id = self_profile.id();
let profile_id = profile.id();
let blocks = state.profiles.store.view.blocks.clone();
let is_blocked =
web::block(move || Ok(blocks.by_forward(self_id, profile_id)?.is_some())).await?;
if is_blocked {
return Ok(crate::to_404());
}
let follow_requests = state.profiles.store.view.follow_requests.clone();
let follows = state.profiles.store.view.follows.clone();
let follow_exists = web::block(move || {
let exists = follow_requests.by_forward(profile_id, self_id)?.is_some()
|| follows.by_forward(profile_id, self_id)?.is_some();
Ok(exists)
})
.await?;
if follow_exists {
return Ok(to_profile_page(&handle, &domain));
}
state
.profiles
.run(CreateFollowRequest::from_profiles(
profile.id(),
self_profile.id(),
))
.await?;
Ok(to_profile_page(&handle, &domain))
}
async fn unfollow(
handle: web::Path<(String, String)>,
self_profile: Profile,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::{UndoFollow, UndoFollowRequest};
let (handle, domain) = handle.into_inner();
let profile = profile_from_handle(handle.clone(), domain.clone(), &state).await?;
let self_id = self_profile.id();
let profile_id = profile.id();
let blocks = state.profiles.store.view.blocks.clone();
let is_blocked =
web::block(move || Ok(blocks.by_forward(self_id, profile_id)?.is_some())).await?;
if is_blocked {
return Ok(crate::to_404());
}
let follow_requests = state.profiles.store.view.follow_requests.clone();
let follows = state.profiles.store.view.follows.clone();
let (follow_request, follow) = web::block(move || {
let follow_request = follow_requests.by_forward(profile_id, self_id)?;
let follow = follows.by_forward(profile_id, self_id)?;
Ok((follow_request, follow))
})
.await?;
if let Some(follow_request) = follow_request {
state
.profiles
.run(UndoFollowRequest::from_id(follow_request))
.await?;
}
if let Some(follow) = follow {
state.profiles.run(UndoFollow::from_id(follow)).await?;
}
Ok(to_profile_page(&handle, &domain))
}
async fn block(
handle: web::Path<(String, String)>,
self_profile: Profile,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::CreateBlock;
let (handle, domain) = handle.into_inner();
let profile = profile_from_handle(handle.clone(), domain.clone(), &state).await?;
let self_id = self_profile.id();
let profile_id = profile.id();
let blocks = state.profiles.store.view.blocks.clone();
let is_blocked =
web::block(move || Ok(blocks.by_forward(self_id, profile_id)?.is_some())).await?;
if is_blocked {
return Ok(crate::to_404());
}
let blocks = state.profiles.store.view.blocks.clone();
let block_exists =
web::block(move || Ok(blocks.by_forward(profile_id, self_id)?.is_some())).await?;
if block_exists {
return Ok(to_profile_page(&handle, &domain));
}
state
.profiles
.run(CreateBlock::from_profiles(profile.id(), self_profile.id()))
.await?;
Ok(to_profile_page(&handle, &domain))
}
async fn unblock(
handle: web::Path<(String, String)>,
self_profile: Profile,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::DeleteBlock;
let (handle, domain) = handle.into_inner();
let profile = profile_from_handle(handle.clone(), domain.clone(), &state).await?;
let self_id = self_profile.id();
let profile_id = profile.id();
let blocks = state.profiles.store.view.blocks.clone();
let block = web::block(move || Ok(blocks.by_forward(profile_id, self_id)?)).await?;
if let Some(block) = block {
state.profiles.run(DeleteBlock::from_id(block)).await?;
}
Ok(to_profile_page(&handle, &domain))
}
pub struct ReportView {
pub(crate) profile: Profile,
pub(crate) input: TextInput,
}
impl ReportView {
fn new(profile: Profile, dark: bool) -> Self {
let mut input = TextInput::new("body");
input
.title("Report")
.placeholder("Type your report info here")
.textarea()
.dark(dark);
ReportView { profile, input }
}
}
async fn profile_from_id(id: Uuid, state: &State) -> Result<Profile, Error> {
let profile_store = state.profiles.store.profiles.clone();
let file_store = state.profiles.store.files.clone();
let profile = web::block(move || {
let profile = Profile::from_stores(id, &profile_store, &file_store)?;
Ok(profile)
})
.await?;
Ok(profile)
}
async fn profile_from_handle(
handle: String,
domain: String,
state: &State,
) -> Result<Profile, Error> {
let profile_store = state.profiles.store.profiles.clone();
let file_store = state.profiles.store.files.clone();
let profile = web::block(move || {
let id = profile_store.by_handle(&handle, &domain)?.req()?;
let profile = Profile::from_stores(id, &profile_store, &file_store)?;
Ok(profile)
})
.await?;
Ok(profile)
}
async fn report_page(
self_profile: Profile,
handle: web::Path<(String, String)>,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let (handle, domain) = handle.into_inner();
let profile = profile_from_handle(handle, domain, &state).await?;
let blocks = state.profiles.store.view.blocks.clone();
let self_id = self_profile.id();
let profile_id = profile.id();
let is_blocked =
web::block(move || Ok(blocks.by_forward(self_id, profile_id)?.is_some())).await?;
if is_blocked {
return Ok(crate::to_404());
}
let view = ReportView::new(profile, nav_state.dark());
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::report(cursor, &view, &nav_state)
})
}
const MAX_REPORT_LEN: usize = 1000;
#[derive(Clone, Debug, serde::Deserialize)]
struct ReportForm {
body: String,
}
async fn report(
self_profile: Profile,
handle: web::Path<(String, String)>,
form: web::Form<ReportForm>,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::CreateReport;
let (handle, domain) = handle.into_inner();
let profile = profile_from_handle(handle, domain, &state).await?;
let form = form.into_inner();
let error = if form.body.len() > MAX_REPORT_LEN {
format!("Must be shorter than {} characters", MAX_REPORT_LEN)
} else if form.body.trim().is_empty() {
format!("Must be present")
} else {
let res = state
.profiles
.run(CreateReport::from_profile(
profile.id(),
self_profile.id(),
Some(form.body.clone()),
))
.await;
match res {
Ok(_) => return Ok(to_report_success_page(profile.full_handle())),
Err(e) => e.to_string(),
}
};
let mut view = ReportView::new(profile, nav_state.dark());
view.input.error_opt(Some(error)).value(&form.body);
crate::rendered(HttpResponse::BadRequest(), |cursor| {
crate::templates::profiles::report(cursor, &view, &nav_state)
})
}
async fn report_success_page(
self_profile: Profile,
handle: web::Path<(String, String)>,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let (handle, domain) = handle.into_inner();
let profile = profile_from_handle(handle, domain, &state).await?;
let blocks = state.profiles.store.view.blocks.clone();
let self_id = self_profile.id();
let profile_id = profile.id();
let is_blocked =
web::block(move || Ok(blocks.by_forward(self_id, profile_id)?.is_some())).await?;
if is_blocked {
return Ok(crate::to_404());
}
let view = ReportView::new(profile, nav_state.dark());
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::report_success(cursor, &view, &nav_state)
})
}
fn to_report_success_page(full_handle: String) -> HttpResponse {
redirect(&format!("/profiles/{}/report-success", full_handle))
}
fn to_create() -> HttpResponse {
redirect("/profiles/create/handle")
}
fn to_bio() -> HttpResponse {
redirect("/profiles/create/bio")
}
fn to_icon() -> HttpResponse {
redirect("/profiles/create/icon")
}
fn to_banner() -> HttpResponse {
redirect("/profiles/create/banner")
}
fn to_require_login() -> HttpResponse {
redirect("/profiles/create/require-login")
}
fn to_done() -> HttpResponse {
redirect("/profiles/create/done")
}
fn to_current_profile() -> HttpResponse {
redirect("/profiles/current")
}
pub(super) fn to_change_profile_page() -> HttpResponse {
redirect("/profiles/change")
}
fn redirect(path: &str) -> HttpResponse {
HttpResponse::SeeOther().header("Location", path).finish()
}
const ACCEPT_TYPES: &str = "image/png,image/webp,image/jpeg,image/gif,.png,.webp,.jpg,.jpeg,.gif";
pub use state::ProfileState;
mod state {
use super::Profile;
use super::ACCEPT_TYPES;
use hyaenidae_toolkit::{FileInput, TextInput};
pub struct ProfileState {
pub(crate) profile: Profile,
pub(crate) display_name: TextInput,
pub(crate) description: TextInput,
pub(crate) icon: FileInput,
pub(crate) icon_error: Option<String>,
pub(crate) banner: FileInput,
pub(crate) banner_error: Option<String>,
pub(crate) login_required_error: Option<String>,
}
impl ProfileState {
pub(super) fn new(profile: Profile, dark: bool) -> Self {
let mut display_name = TextInput::new("display_name");
display_name
.title("Display Name")
.placeholder("Display Name")
.dark(dark);
if let Some(text) = profile.display_name() {
display_name.value(text);
}
let mut description = TextInput::new("description");
description
.title("Description")
.placeholder("Description")
.textarea()
.dark(dark);
if let Some(text) = profile.description() {
description.value(text);
}
let mut icon = FileInput::secondary("images[]", "Select Icon");
icon.accept(ACCEPT_TYPES).no_group().dark(dark);
let mut banner = FileInput::secondary("images[]", "Select Banner");
banner.accept(ACCEPT_TYPES).no_group().dark(dark);
ProfileState {
profile,
display_name,
description,
icon,
icon_error: None,
banner,
banner_error: None,
login_required_error: None,
}
}
pub(crate) fn view_path(&self) -> String {
format!(
"/profiles/@{}@{}",
self.profile.inner.handle(),
self.profile.inner.domain()
)
}
pub(super) fn display_name_error(&mut self, err: String) -> &mut Self {
self.display_name.error_opt(Some(err));
self
}
pub(super) fn description_error(&mut self, err: String) -> &mut Self {
self.description.error_opt(Some(err));
self
}
pub(super) fn icon_error(&mut self, err: &str) -> &mut Self {
self.icon_error = Some(err.to_owned());
self
}
pub(super) fn banner_error(&mut self, err: &str) -> &mut Self {
self.banner_error = Some(err.to_owned());
self
}
pub(super) fn login_required_error(&mut self, err: &str) -> &mut Self {
self.login_required_error = Some(err.to_owned());
self
}
}
}
#[derive(Clone, Debug)]
pub struct Profile {
inner: hyaenidae_profiles::store::Profile,
banner: Option<String>,
icon: Option<String>,
}
impl Profile {
pub(crate) fn from_stores(
profile_id: Uuid,
profiles: &ProfileStore,
files: &FileStore,
) -> Result<Self, Error> {
let inner = profiles.by_id(profile_id)?.req()?;
let banner = match inner.banner() {
Some(banner_id) => {
let file = files.by_id(banner_id)?.req()?;
if let Some(key) = file.pictrs_key() {
Some(key.to_owned())
} else {
None
}
}
None => None,
};
let icon = match inner.icon() {
Some(icon_id) => {
let file = files.by_id(icon_id)?.req()?;
if let Some(key) = file.pictrs_key() {
Some(key.to_owned())
} else {
None
}
}
None => None,
};
Ok(Profile {
inner,
banner,
icon,
})
}
pub(crate) fn from_id(profile_id: Uuid, state: &State) -> Result<Self, Error> {
Self::from_stores(
profile_id,
&state.profiles.store.profiles,
&state.profiles.store.files,
)
}
pub(crate) fn view_path(&self) -> String {
format!("/profiles/{}", self.full_handle())
}
pub(crate) fn report_path(&self) -> String {
format!("/profiles/{}/report", self.full_handle())
}
pub(crate) fn follow_path(&self) -> String {
format!("/profiles/{}/follow", self.full_handle())
}
pub(crate) fn unfollow_path(&self) -> String {
format!("/profiles/{}/unfollow", self.full_handle())
}
pub(crate) fn block_path(&self) -> String {
format!("/profiles/{}/block", self.full_handle())
}
pub(crate) fn unblock_path(&self) -> String {
format!("/profiles/{}/unblock", self.full_handle())
}
fn refresh(self, state: &State) -> Result<Self, Error> {
Self::from_id(self.inner.id(), state)
}
fn is_suspended(&self) -> bool {
self.inner.is_suspended()
}
pub(crate) fn id(&self) -> Uuid {
self.inner.id()
}
pub(crate) fn login_required(&self) -> bool {
self.inner.login_required()
}
pub(crate) fn full_handle(&self) -> String {
format!("@{}@{}", self.inner.handle(), self.inner.domain())
}
pub(crate) fn display_name(&self) -> Option<&str> {
self.inner.display_name()
}
pub(crate) fn description(&self) -> Option<&str> {
if self.is_suspended() {
Some("Profile Suspended")
} else {
self.inner.description()
}
}
pub(crate) fn name(&self) -> String {
self.inner
.display_name()
.map(|d| d.to_owned())
.unwrap_or(self.full_handle())
}
pub(crate) fn icon_key(&self) -> Option<&str> {
self.icon.as_deref()
}
pub(crate) fn banner_key(&self) -> Option<&str> {
self.banner.as_deref()
}
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(untagged)]
pub(crate) enum SubmissionPage {
Max { max: Uuid },
Min { min: Uuid },
}
pub(crate) struct SubmissionAggregation {
pub(crate) submissions: Vec<SubmissionView>,
pub(crate) nav: Vec<Button>,
}
pub(crate) fn build_submissions(
base_url: &str,
drafts: bool,
profile: Option<Profile>,
viewed_by: Option<Uuid>,
page: Option<SubmissionPage>,
per_page: usize,
dark: bool,
state: &State,
) -> SubmissionAggregation {
let mut nav = vec![];
let mut submissions: Vec<SubmissionView>;
let mut view_state = ViewState::new();
match page {
Some(SubmissionPage::Max { max }) => {
submissions = if profile.is_some() {
if drafts {
state
.profiles
.store
.submissions
.drafted_older_than_for_profile(max)
.filter_map(|submission_id| {
SubmissionView::from_id(
submission_id,
viewed_by,
&mut view_state,
&state,
)
})
.take(per_page + 1)
.collect()
} else {
state
.profiles
.store
.submissions
.published_older_than_for_profile(max)
.filter_map(|submission_id| {
SubmissionView::from_id(
submission_id,
viewed_by,
&mut view_state,
&state,
)
})
.take(per_page + 1)
.collect()
}
} else {
state
.profiles
.store
.submissions
.published_older_than(max)
.filter_map(|submission_id| {
SubmissionView::from_id(submission_id, viewed_by, &mut view_state, &state)
})
.take(per_page + 1)
.collect()
};
if let Some(sub) = submissions.get(0) {
let prev = Button::secondary("Previous");
prev.href(&format!("{}?min={}", base_url, sub.id()))
.dark(dark);
nav.push(prev);
} else {
let reset = Button::secondary("Reset");
reset.href(base_url).dark(dark);
nav.push(reset);
}
if submissions.len() == per_page + 1 {
submissions.pop();
let next = Button::secondary("Next");
next.href(&format!(
"{}?max={}",
base_url,
submissions[per_page.saturating_sub(1)].id()
))
.dark(dark);
nav.push(next);
}
}
Some(SubmissionPage::Min { min }) => {
let mut tmp_submissions: Vec<_> = if profile.is_some() {
if drafts {
state
.profiles
.store
.submissions
.drafted_newer_than_for_profile(min)
.filter_map(|submission_id| {
SubmissionView::from_id(
submission_id,
viewed_by,
&mut view_state,
&state,
)
})
.take(per_page + 2)
.collect()
} else {
state
.profiles
.store
.submissions
.published_newer_than_for_profile(min)
.filter_map(|submission_id| {
SubmissionView::from_id(
submission_id,
viewed_by,
&mut view_state,
&state,
)
})
.take(per_page + 2)
.collect()
}
} else {
state
.profiles
.store
.submissions
.published_newer_than(min)
.filter_map(|submission_id| {
SubmissionView::from_id(submission_id, viewed_by, &mut view_state, &state)
})
.take(per_page + 2)
.collect()
};
if tmp_submissions.len() == per_page + 2 {
tmp_submissions.pop();
let prev = Button::secondary("Previous");
prev.href(&format!(
"{}?min={}",
base_url,
tmp_submissions[per_page.saturating_sub(1)].id()
))
.dark(dark);
nav.push(prev);
}
submissions = tmp_submissions.into_iter().rev().collect();
submissions.pop();
if let Some(sub) = submissions.get(submissions.len().saturating_sub(1)) {
let next = Button::secondary("Next");
next.href(&format!("{}?max={}", base_url, sub.id()))
.dark(dark);
nav.push(next);
} else {
let reset = Button::secondary("Reset");
reset.href(base_url).dark(dark);
nav.push(reset);
}
}
None => {
submissions = if let Some(profile) = profile {
if drafts {
state
.profiles
.store
.submissions
.drafted_for_profile(profile.id())
.filter_map(|submission_id| {
SubmissionView::from_id(
submission_id,
viewed_by,
&mut view_state,
&state,
)
})
.take(per_page + 1)
.collect()
} else {
state
.profiles
.store
.submissions
.published_for_profile(profile.id())
.filter_map(|submission_id| {
SubmissionView::from_id(
submission_id,
viewed_by,
&mut view_state,
&state,
)
})
.take(per_page + 1)
.collect()
}
} else {
state
.profiles
.store
.submissions
.published()
.filter_map(|submission_id| {
SubmissionView::from_id(submission_id, viewed_by, &mut view_state, &state)
})
.take(per_page + 1)
.collect()
};
if submissions.len() == per_page + 1 {
submissions.pop();
let next = Button::secondary("Next");
next.href(&format!(
"{}?max={}",
base_url,
submissions[per_page.saturating_sub(1)].id()
))
.dark(dark);
nav.push(next);
}
}
};
SubmissionAggregation { submissions, nav }
}
async fn id_view(
req: HttpRequest,
user: Option<User>,
self_profile: Option<Profile>,
id: web::Path<Uuid>,
page: Option<web::Query<SubmissionPage>>,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let profile = profile_from_id(id.into_inner(), &state).await?;
do_public_view(
req.uri().path(),
user,
self_profile,
profile,
page.map(|q| q.into_inner()),
nav_state,
&state,
)
.await
}
async fn handle_view(
req: HttpRequest,
user: Option<User>,
self_profile: Option<Profile>,
handle: web::Path<(String, String)>,
page: Option<web::Query<SubmissionPage>>,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let (handle, domain) = handle.into_inner();
let profile = profile_from_handle(handle, domain, &state).await?;
do_public_view(
req.uri().path(),
user,
self_profile,
profile,
page.map(|q| q.into_inner()),
nav_state,
&state,
)
.await
}
pub struct ProfileView {
pub(crate) profile: Profile,
pub(crate) submissions: Vec<SubmissionView>,
nav: Vec<Button>,
pub(crate) viewer: Option<Uuid>,
buttons: Vec<Button>,
}
impl ProfileView {
pub(crate) fn nav(&self) -> Vec<&Button> {
self.nav.iter().collect()
}
pub(crate) fn buttons(&self) -> Vec<&Button> {
self.buttons.iter().collect()
}
}
pub struct SubmissionView {
submission: Submission,
poster: Profile,
first_file: File,
}
struct ViewState {
profiles: HashMap<Uuid, Profile>,
blocks: HashMap<Uuid, bool>,
follows: HashMap<Uuid, bool>,
}
impl ViewState {
fn new() -> Self {
ViewState {
profiles: HashMap::new(),
blocks: HashMap::new(),
follows: HashMap::new(),
}
}
}
impl SubmissionView {
fn id(&self) -> Uuid {
self.submission.id()
}
pub(crate) fn from_parts(submission: &Submission, poster: &Profile, first_file: &File) -> Self {
SubmissionView {
submission: submission.clone(),
poster: poster.clone(),
first_file: first_file.clone(),
}
}
pub(crate) fn posted_by(&self, profile: Option<Uuid>) -> bool {
profile.map(|p| p == self.poster.id()).unwrap_or(false)
}
pub(crate) fn is_published(&self) -> bool {
self.submission.published().is_some()
}
pub(crate) fn name(&self) -> String {
self.poster.name()
}
pub(crate) fn pictrs_key(&self) -> Option<&str> {
self.first_file.pictrs().map(|f| f.key())
}
pub(crate) fn title(&self) -> String {
if !self.is_published() {
if self.submission.title().len() == 0 {
"Not Published".to_owned()
} else {
format!("Not Published: {}", self.submission.title())
}
} else {
self.submission.title().to_owned()
}
}
pub(crate) fn view_path(&self) -> String {
format!("/submissions/{}", self.submission.id())
}
fn from_id(
submission_id: Uuid,
viewed_by: Option<Uuid>,
view_state: &mut ViewState,
state: &State,
) -> Option<Self> {
let submission = state
.profiles
.store
.submissions
.by_id(submission_id)
.ok()??;
let is_self = viewed_by
.map(|pid| pid == submission.profile_id())
.unwrap_or(false);
if !is_self && submission.published().is_none() {
return None;
}
let poster = if let Some(profile) = view_state.profiles.get(&submission.profile_id()) {
profile.clone()
} else {
let profile = Profile::from_id(submission.profile_id(), state).ok()?;
view_state.profiles.insert(profile.id(), profile.clone());
profile
};
if viewed_by.is_none() && poster.login_required() {
return None;
}
if let Some(block) = view_state.blocks.get(&poster.id()) {
if *block {
return None;
}
} else {
if let Some(profile_id) = viewed_by {
let blocking = state
.profiles
.store
.view
.blocks
.by_forward(submission.profile_id(), profile_id)
.ok()?
.is_some();
if blocking {
view_state.blocks.insert(poster.id(), true);
return None;
}
let blocked = state
.profiles
.store
.view
.blocks
.by_forward(profile_id, submission.profile_id())
.ok()?
.is_some();
if blocked {
view_state.blocks.insert(poster.id(), true);
return None;
}
view_state.blocks.insert(poster.id(), false);
}
}
if submission.is_followers_only() {
if let Some(follow) = view_state.follows.get(&poster.id()) {
if !follow {
return None;
}
} else {
let profile_id = viewed_by?;
if !is_self
&& state
.profiles
.store
.view
.follows
.by_forward(submission.profile_id(), profile_id)
.ok()?
.is_none()
{
view_state.follows.insert(poster.id(), false);
return None;
}
view_state.follows.insert(poster.id(), true);
}
}
let file_id = submission.files().get(0)?;
let first_file = state.profiles.store.files.by_id(*file_id).ok()??;
Some(SubmissionView {
submission,
poster,
first_file,
})
}
}
async fn profile_buttons(
profile: &Profile,
self_profile: Option<&Profile>,
is_drafts: bool,
dark: bool,
state: &State,
) -> Result<Vec<Button>, Error> {
let mut buttons = vec![];
if profile.is_suspended() {
return Ok(buttons);
}
let is_follow_requested = if let Some(self_profile) = self_profile {
let follow_requests = state.profiles.store.view.follow_requests.clone();
let self_id = self_profile.id();
let profile_id = profile.id();
web::block(move || Ok(follow_requests.by_forward(profile_id, self_id)?.is_some())).await?
} else {
false
};
let is_follower = if let Some(self_profile) = self_profile {
let follows = state.profiles.store.view.follows.clone();
let self_id = self_profile.id();
let profile_id = profile.id();
web::block(move || Ok(follows.by_forward(profile_id, self_id)?.is_some())).await?
} else {
false
};
let is_blocking = if let Some(self_profile) = self_profile {
let blocks = state.profiles.store.view.blocks.clone();
let self_id = self_profile.id();
let profile_id = profile.id();
web::block(move || Ok(blocks.by_forward(profile_id, self_id)?.is_some())).await?
} else {
false
};
if self_profile
.map(|p| p.id() == profile.id())
.unwrap_or(false)
{
let drafts = if is_drafts {
let view = Button::secondary("View Profile");
view.href(&profile.view_path()).dark(dark);
view
} else {
let drafts = Button::secondary("View Drafts");
drafts.href("/profiles/drafts").dark(dark);
drafts
};
let edit = Button::secondary("Edit Profile");
let switch = Button::secondary("Switch Profile");
edit.href("/profiles/current").dark(dark);
switch.href("/profiles/change").dark(dark);
buttons.push(drafts);
buttons.push(edit);
buttons.push(switch);
} else if is_follower || is_follow_requested {
let unfollow = if is_follower {
Button::secondary("Unfollow")
} else {
Button::secondary("Remove Request")
};
let block = Button::secondary("Block");
let report = Button::primary_outline("Report");
unfollow.form(&profile.unfollow_path()).dark(dark);
block.form(&profile.block_path()).dark(dark);
report.href(&profile.report_path()).dark(dark);
buttons.push(unfollow);
buttons.push(block);
buttons.push(report);
} else if is_blocking {
let unblock = Button::secondary("Unblock");
let report = Button::primary_outline("Report");
unblock.form(&profile.unblock_path()).dark(dark);
report.href(&profile.report_path()).dark(dark);
buttons.push(unblock);
buttons.push(report);
} else if self_profile.is_some() {
let follow = Button::secondary("Follow");
let block = Button::secondary("Block");
let report = Button::primary_outline("Report");
follow.form(&profile.follow_path()).dark(dark);
block.form(&profile.block_path()).dark(dark);
report.href(&profile.report_path()).dark(dark);
buttons.push(follow);
buttons.push(block);
buttons.push(report);
}
Ok(buttons)
}
async fn drafts_page(
req: HttpRequest,
profile: Profile,
page: Option<web::Query<SubmissionPage>>,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let viewed_by = Some(profile.id());
let agg = build_submissions(
req.uri().path(),
true,
Some(profile.clone()),
viewed_by,
page.map(|q| q.into_inner()),
6,
nav_state.dark(),
&state,
);
let buttons = profile_buttons(&profile, Some(&profile), true, nav_state.dark(), &state).await?;
let view = ProfileView {
profile,
submissions: agg.submissions,
nav: agg.nav,
viewer: viewed_by,
buttons,
};
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::drafts(cursor, &view, &nav_state)
})
}
async fn do_public_view(
base_url: &str,
user: Option<User>,
self_profile: Option<Profile>,
profile: Profile,
page: Option<SubmissionPage>,
nav_state: NavState,
state: &State,
) -> Result<HttpResponse, Error> {
if profile.login_required() && user.is_none() {
return Ok(crate::to_404());
}
let is_blocked = if let Some(self_profile) = &self_profile {
let blocks = state.profiles.store.view.blocks.clone();
let self_id = self_profile.id();
let profile_id = profile.id();
web::block(move || Ok(blocks.by_forward(self_id, profile_id)?.is_some())).await?
} else {
false
};
if is_blocked {
return Ok(crate::to_404());
}
let viewed_by = self_profile.as_ref().map(|p| p.id());
let agg = build_submissions(
base_url,
false,
Some(profile.clone()),
viewed_by,
page,
6,
nav_state.dark(),
state,
);
let buttons = profile_buttons(
&profile,
self_profile.as_ref(),
false,
nav_state.dark(),
state,
)
.await?;
let view = ProfileView {
profile,
submissions: agg.submissions,
nav: agg.nav,
viewer: viewed_by,
buttons,
};
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::public(cursor, &view, &nav_state)
})
}
async fn profiles() -> HttpResponse {
to_current_profile()
}
async fn delete_page(profile: Profile, nav_state: NavState) -> Result<HttpResponse, Error> {
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::delete(cursor, &profile, &nav_state)
})
}
async fn delete_profile(profile: Profile, state: web::Data<State>) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::DeleteProfile;
state
.profiles
.run(DeleteProfile::from_id(profile.id()))
.await?;
Ok(to_change_profile_page())
}
async fn current_profile(profile: Profile, nav_state: NavState) -> Result<HttpResponse, Error> {
let profile_state = ProfileState::new(profile, nav_state.dark());
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::current(cursor, &profile_state, &nav_state)
})
}
const MAX_HANDLE_LEN: usize = 20;
const MAX_DISPLAY_NAME_LEN: usize = 20;
const MAX_DESCRIPTION_LEN: usize = 500;
fn validate_handle(handle: &str) -> Option<String> {
if handle.chars().any(|c| !c.is_alphanumeric()) {
return Some("Must contain only alphanumeric characters".to_owned());
}
if handle.len() > MAX_HANDLE_LEN {
return Some(format!(
"Must be shorter than {} characters",
MAX_HANDLE_LEN
));
}
None
}
fn validate_display_name(display_name: &str) -> Option<String> {
if display_name.len() > MAX_DISPLAY_NAME_LEN {
return Some(format!(
"Must be shorter than {} characters",
MAX_DISPLAY_NAME_LEN
));
}
if display_name.len() == 0 {
return Some(format!("Must be present"));
}
None
}
fn validate_description(description: &str) -> Option<String> {
if description.len() > MAX_DESCRIPTION_LEN {
return Some(format!(
"Must be shorter than {} characters",
MAX_DESCRIPTION_LEN
));
}
None
}
async fn update_bio(
form: web::Form<BioForm>,
profile: Profile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let display_name = form.display_name.clone();
let description = form.description.clone();
let display_name_error = validate_display_name(&display_name);
let description_error = validate_description(&description);
let res = if display_name_error.is_none() && description_error.is_none() {
use hyaenidae_profiles::apub::actions::UpdateProfile;
let res = state
.profiles
.run(UpdateProfile::from_text(
profile.id(),
display_name.clone(),
description.clone(),
))
.await;
Some(res)
} else {
None
};
let mut profile_state = ProfileState::new(profile.refresh(&state)?, nav_state.dark());
profile_state.display_name.value(&display_name);
profile_state.description.value(&description);
match res {
Some(Ok(_)) => return Ok(to_current_profile()),
Some(Err(e)) => {
profile_state.display_name_error(e.to_string());
}
None => {
if let Some(e) = display_name_error {
profile_state.display_name_error(e);
}
if let Some(e) = description_error {
profile_state.description_error(e);
}
}
};
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::current(cursor, &profile_state, &nav_state)
})
}
async fn update_icon(
request: HttpRequest,
payload: web::Payload,
profile: Profile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let res = state
.profiles
.upload_image(request, payload.into_inner())
.await;
let error = match res {
Ok(file_ids) if file_ids.len() == 1 => {
use hyaenidae_profiles::apub::actions::UpdateProfile;
let res = state
.profiles
.run(UpdateProfile::from_icon(profile.id(), file_ids[0]))
.await;
match res {
Ok(_) => return Ok(to_current_profile()),
Err(e) => e.to_string(),
}
}
Ok(_) => "Incorrect number of files".to_owned(),
Err(e) => e.to_string(),
};
let mut profile_state = ProfileState::new(profile.refresh(&state)?, nav_state.dark());
profile_state.icon_error(&error);
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::current(cursor, &profile_state, &nav_state)
})
}
async fn update_banner(
request: HttpRequest,
payload: web::Payload,
profile: Profile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let res = state
.profiles
.upload_image(request, payload.into_inner())
.await;
let error = match res {
Ok(file_ids) if file_ids.len() == 1 => {
use hyaenidae_profiles::apub::actions::UpdateProfile;
let res = state
.profiles
.run(UpdateProfile::from_banner(profile.id(), file_ids[0]))
.await;
match res {
Ok(_) => return Ok(to_current_profile()),
Err(e) => e.to_string(),
}
}
Ok(_) => "Incorrect number of files".to_owned(),
Err(e) => e.to_string(),
};
let mut profile_state = ProfileState::new(profile.refresh(&state)?, nav_state.dark());
profile_state.banner_error(&error);
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::current(cursor, &profile_state, &nav_state)
})
}
async fn update_require_login(
form: web::Form<RequireLoginForm>,
profile: Profile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let login_required = form.require_login.is_some();
use hyaenidae_profiles::apub::actions::UpdateProfile;
let res = state
.profiles
.run(UpdateProfile::from_login_required(
profile.id(),
login_required,
))
.await;
let mut profile_state = ProfileState::new(profile.refresh(&state)?, nav_state.dark());
match res {
Ok(_) => return Ok(to_current_profile()),
Err(e) => {
profile_state.login_required_error(&e.to_string());
}
};
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::current(cursor, &profile_state, &nav_state)
})
}
#[derive(Clone, Debug, serde::Deserialize)]
struct ChangeProfileForm {
profile_id: Uuid,
}
async fn change_profile_page(
user: User,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let profiles = state
.profiles
.store
.profiles
.for_local(user.id())
.filter_map(|profile_id| Profile::from_id(profile_id, &state).ok())
.collect::<Vec<_>>();
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::list(cursor, &profiles, &nav_state)
})
}
async fn change_profile(
user: User,
session: Session,
form: web::Form<ChangeProfileForm>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let profile = Profile::from_id(form.profile_id, &state)?;
if profile.inner.local_owner() != Some(user.id()) {
return Ok(to_change_profile_page());
}
middleware::ProfileData::set_data(form.profile_id, &session)
.ok()
.req()?;
Ok(crate::to_home())
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct HandleForm {
pub(crate) handle: String,
}
async fn new_handle(_: User, nav_state: NavState) -> Result<HttpResponse, Error> {
let mut handle_input = TextInput::new("handle");
handle_input
.placeholder("Handle")
.title("Handle")
.dark(nav_state.dark());
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::create::handle(cursor, &handle_input, &nav_state)
})
}
async fn create_handle(
user: User,
form: web::Form<HandleForm>,
session: Session,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let domain = state.domain.clone();
let handle = form.handle.clone();
let error = match validate_handle(&handle) {
Some(error) => Some(error),
None => {
let exists = state
.profiles
.store
.profiles
.by_handle(&handle, &domain)?
.is_some();
if !exists {
use hyaenidae_profiles::apub::actions::CreateProfile;
let res = state
.profiles
.run(CreateProfile::from_local(user.id(), handle, domain))
.await;
match res {
Ok(Some(id)) => {
middleware::ProfileData::set_data(id, &session).ok().req()?;
return Ok(to_bio());
}
Ok(None) => None,
Err(e) => Some(e.to_string()),
}
} else {
Some("Handle already in use".to_owned())
}
}
};
let mut handle_input = TextInput::new("handle");
handle_input
.placeholder("Handle")
.title("Handle")
.value(&form.handle)
.error_opt(error)
.dark(nav_state.dark());
crate::rendered(HttpResponse::BadRequest(), |cursor| {
crate::templates::profiles::create::handle(cursor, &handle_input, &nav_state)
})
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct BioForm {
pub(crate) display_name: String,
pub(crate) description: String,
}
async fn new_bio(profile: Profile, nav_state: NavState) -> Result<HttpResponse, Error> {
let mut display_name = TextInput::new("display_name");
display_name
.title("Display Name")
.placeholder("Display Name")
.dark(nav_state.dark());
if let Some(text) = profile.display_name() {
display_name.value(text);
}
let mut description = TextInput::new("description");
description
.title("Description")
.placeholder("Description")
.textarea()
.dark(nav_state.dark());
if let Some(text) = profile.description() {
description.value(text);
}
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::create::bio(
cursor,
&display_name,
&description,
&profile,
&nav_state,
)
})
}
async fn create_bio(
form: web::Form<BioForm>,
profile: Profile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let display_name = form.display_name.clone();
let description = form.description.clone();
let mut display_name_error = validate_display_name(&display_name);
let description_error = validate_description(&description);
use hyaenidae_profiles::apub::actions::UpdateProfile;
if display_name_error.is_none() && description_error.is_none() {
let res = state
.profiles
.run(UpdateProfile::from_text(
profile.id(),
display_name,
description,
))
.await;
match res {
Ok(_) => return Ok(to_icon()),
Err(e) => {
display_name_error = Some(e.to_string());
}
}
}
let mut display_name = TextInput::new("display_name");
display_name
.title("Display Name")
.placeholder("Display Name")
.value(&form.display_name)
.error_opt(display_name_error)
.dark(nav_state.dark());
let mut description = TextInput::new("description");
description
.title("Description")
.placeholder("Description")
.value(&form.description)
.error_opt(description_error)
.dark(nav_state.dark());
let profile = profile.refresh(&state)?;
crate::rendered(HttpResponse::BadRequest(), |cursor| {
crate::templates::profiles::create::bio(
cursor,
&display_name,
&description,
&profile,
&nav_state,
)
})
}
async fn new_icon(profile: Profile, nav_state: NavState) -> Result<HttpResponse, Error> {
let mut icon_input = hyaenidae_toolkit::FileInput::secondary("images[]", "Select Icon");
icon_input.accept(ACCEPT_TYPES).dark(nav_state.dark());
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::create::icon(cursor, &icon_input, None, &profile, &nav_state)
})
}
async fn create_icon(
request: HttpRequest,
payload: web::Payload,
profile: Profile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let res = state
.profiles
.upload_image(request, payload.into_inner())
.await;
let error = match res {
Ok(file_ids) if file_ids.len() == 1 => {
use hyaenidae_profiles::apub::actions::UpdateProfile;
let res = state
.profiles
.run(UpdateProfile::from_icon(profile.id(), file_ids[0]))
.await;
match res {
Ok(_) => return Ok(to_banner()),
Err(e) => Some(e.to_string()),
}
}
Ok(_) => Some("Incorrect number of files".to_owned()),
Err(e) => Some(e.to_string()),
};
let mut icon_input = hyaenidae_toolkit::FileInput::secondary("images[]", "Select Icon");
icon_input.accept(ACCEPT_TYPES).dark(nav_state.dark());
let profile = profile.refresh(&state)?;
crate::rendered(HttpResponse::BadRequest(), |cursor| {
crate::templates::profiles::create::icon(cursor, &icon_input, error, &profile, &nav_state)
})
}
async fn new_banner(profile: Profile, nav_state: NavState) -> Result<HttpResponse, Error> {
let mut banner_input = hyaenidae_toolkit::FileInput::secondary("images[]", "Select Banner");
banner_input.accept(ACCEPT_TYPES).dark(nav_state.dark());
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::create::banner(
cursor,
&banner_input,
None,
&profile,
&nav_state,
)
})
}
async fn create_banner(
request: HttpRequest,
payload: web::Payload,
profile: Profile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let res = state
.profiles
.upload_image(request, payload.into_inner())
.await;
let error = match res {
Ok(file_ids) if file_ids.len() == 1 => {
use hyaenidae_profiles::apub::actions::UpdateProfile;
let res = state
.profiles
.run(UpdateProfile::from_banner(profile.id(), file_ids[0]))
.await;
match res {
Ok(_) => return Ok(to_require_login()),
Err(e) => Some(e.to_string()),
}
}
Ok(_) => Some("Incorrect number of files".to_owned()),
Err(e) => Some(e.to_string()),
};
let mut banner_input = hyaenidae_toolkit::FileInput::secondary("images[]", "Select Banner");
banner_input.accept(ACCEPT_TYPES).dark(nav_state.dark());
let profile = profile.refresh(&state)?;
crate::rendered(HttpResponse::BadRequest(), |cursor| {
crate::templates::profiles::create::banner(
cursor,
&banner_input,
error,
&profile,
&nav_state,
)
})
}
#[derive(Clone, Debug, serde::Deserialize)]
struct RequireLoginForm {
require_login: Option<String>,
}
async fn new_require_login(profile: Profile, nav_state: NavState) -> Result<HttpResponse, Error> {
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::create::require_login(
cursor,
profile.inner.login_required(),
None,
&profile,
&nav_state,
)
})
}
async fn create_require_login(
form: web::Form<RequireLoginForm>,
profile: Profile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::UpdateProfile;
let res = state
.profiles
.run(UpdateProfile::from_login_required(
profile.id(),
form.require_login.is_some(),
))
.await;
let error = match res {
Ok(_) => return Ok(to_done()),
Err(e) => Some(e.to_string()),
};
let profile = profile.refresh(&state)?;
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::create::require_login(
cursor,
form.require_login.is_some(),
error,
&profile,
&nav_state,
)
})
}
async fn done(profile: Profile, nav_state: NavState) -> Result<HttpResponse, Error> {
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::profiles::create::done(cursor, &profile, &nav_state)
})
}