use crate::{ comments::{Cache, CommentNode}, error::{Error, OptionExt}, extensions::SubmissionExt, images::{largest_image, FullImage, ImageType}, middleware::{CurrentSubmission, UserProfile}, nav::NavState, views::{OwnedProfileView, OwnedSubmissionView}, ActixLoader, State, }; use actix_web::{client::Client, web, HttpRequest, HttpResponse, Scope}; use hyaenidae_profiles::{ apub::actions::{ CreateComment, CreateReport, CreateSubmission, DeleteSubmission, UpdateSubmission, }, store::{File, Profile, Submission, Visibility}, }; use hyaenidae_toolkit::{Button, FileInput, Select, TextInput, Tile}; use i18n_embed_fl::fl; use std::collections::HashMap; use uuid::Uuid; pub(super) fn scope() -> Scope { web::scope("/submissions") .service( web::resource("/create") .route(web::get().to(files_page)) .route(web::post().to(upload_files)), ) .service( web::scope("/{submission_id}") .route("", web::get().to(submission_page)) .service( web::resource("/report") .route(web::get().to(report_page)) .route(web::post().to(report)), ) .route("/report-success", web::get().to(report_success_page)) .service( web::resource("/comment") .route(web::get().to(route_to_update_page)) .route(web::post().to(create_comment)), ) .service( web::resource("/update") .route(web::get().to(update_page)) .route(web::post().to(update_submission)), ) .service( web::resource("/visibility") .route(web::get().to(route_to_update_page)) .route(web::post().to(update_visibility)), ) .service( web::resource("/sensitive") .route(web::get().to(route_to_update_page)) .route(web::post().to(update_sensitive)), ) .service( web::resource("/add-file") .route(web::get().to(route_to_update_page)) .route(web::post().to(add_file)), ) .service( web::resource("/remove-file") .route(web::get().to(route_to_update_page)) .route(web::post().to(remove_file)), ) .service( web::resource("/publish") .route(web::get().to(route_to_update_page)) .route(web::post().to(publish_submission)), ) .service( web::resource("/delete") .route(web::get().to(route_to_update_page)) .route(web::post().to(delete_submission)), ), ) } pub struct ReportView { pub(crate) files: HashMap, pub(crate) submission: Submission, pub(crate) author: Profile, error: Option, value: Option, } impl ReportView { async fn build(submission_id: Uuid, state: &State) -> Result { let store = state.profiles.clone(); let view = web::block(move || { let mut files = HashMap::new(); let submission = store.store.submissions.by_id(submission_id)?.req()?; let author = store.store.profiles.by_id(submission.profile_id())?.req()?; let file_ids = submission .files() .iter() .copied() .chain(author.icon()) .chain(author.banner()); for file_id in file_ids { if !files.contains_key(&file_id) { let file = store.store.files.by_id(file_id)?.req()?; files.insert(file.id(), file); } } Ok(ReportView { files, submission, author, error: None, value: None, }) }) .await?; Ok(view) } pub(crate) fn submission(&self) -> OwnedSubmissionView { OwnedSubmissionView { submission: self.submission.clone(), files: self .submission .files() .iter() .filter_map(move |file_id| self.files.get(file_id)) .map(|file| file.clone()) .collect(), current_file: None, } } pub(crate) fn author(&self) -> OwnedProfileView { OwnedProfileView { profile: self.author.clone(), icon: self .author .icon() .and_then(|i| self.files.get(&i)) .map(|i| i.clone()), banner: self .author .banner() .and_then(|b| self.files.get(&b)) .map(|b| b.clone()), } } pub(crate) fn input(&self, loader: &ActixLoader) -> TextInput { let input = TextInput::new("body") .title(&fl!(loader, "report-input")) .placeholder(&fl!(loader, "report-placeholder")) .textarea(); let input = if let Some(error) = &self.error { input.error_opt(Some(error.to_owned())) } else { input }; if let Some(value) = &self.value { input.value(value) } else { input } } fn error_opt(&mut self, opt: Option) -> &mut Self { self.error = opt; self } fn value(&mut self, text: String) -> &mut Self { self.value = Some(text); self } } async fn report_page( loader: ActixLoader, submission_id: web::Path, profile: UserProfile, nav_state: NavState, state: web::Data, ) -> Result { let profile = profile.0; let view = ReportView::build(submission_id.into_inner(), &state).await?; if let Some(res) = can_view(Some(profile.id()), &view.author, &view.submission, &state).await? { return Ok(res); } crate::rendered(HttpResponse::Ok(), |cursor| { crate::templates::submissions::report(cursor, &loader, &view, &nav_state) }) } #[derive(Clone, Debug, serde::Deserialize)] struct ReportForm { body: String, } const MAX_REPORT_LEN: usize = 1000; async fn report( loader: ActixLoader, form: web::Form, submission_id: web::Path, profile: UserProfile, nav_state: NavState, state: web::Data, ) -> Result { let profile = profile.0; let mut view = ReportView::build(submission_id.into_inner(), &state).await?; if let Some(res) = can_view(Some(profile.id()), &view.author, &view.submission, &state).await? { return Ok(res); } 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_submission( view.submission.id(), profile.id(), Some(form.body.clone()), )) .await; match res { Ok(_) => return Ok(to_report_success_page(view.submission.id())), Err(e) => e.to_string(), } }; view.error_opt(Some(error)).value(form.body); crate::rendered(HttpResponse::BadRequest(), |cursor| { crate::templates::submissions::report(cursor, &loader, &view, &nav_state) }) } async fn report_success_page( loader: ActixLoader, submission_id: web::Path, profile: UserProfile, nav_state: NavState, state: web::Data, ) -> Result { let profile = profile.0; let view = ReportView::build(submission_id.into_inner(), &state).await?; if let Some(res) = can_view(Some(profile.id()), &view.author, &view.submission, &state).await? { return Ok(res); } crate::rendered(HttpResponse::Ok(), |cursor| { crate::templates::submissions::report_success(cursor, &loader, &view, &nav_state) }) } fn to_report_success_page(submission_id: Uuid) -> HttpResponse { crate::redirect(&format!("/submissions/{}/report-success", submission_id)) } pub struct SubmissionState { pub(crate) files: HashMap, pub(crate) submission: Submission, title_value: Option, title_error: Option, description_value: Option, description_error: Option, file_error: Option, pub(crate) remove_file_error: HashMap, pub(crate) publish_error: Option, pub(crate) delete_error: Option, pub(crate) visibility_error: Option, pub(crate) sensitive_error: Option, } impl SubmissionState { async fn new(submission: Submission, state: &State) -> Result { let file_ids: Vec = submission.files().iter().copied().collect(); let store = state.profiles.clone(); let files = web::block(move || { let mut files = HashMap::new(); for file_id in file_ids { if !files.contains_key(&file_id) { let file = store.store.files.by_id(file_id)?.req()?; files.insert(file.id(), file); } } Ok(files) as Result, Error> }) .await?; Ok(SubmissionState { files, submission, title_value: None, title_error: None, description_value: None, description_error: None, file_error: None, remove_file_error: HashMap::new(), publish_error: None, delete_error: None, visibility_error: None, sensitive_error: None, }) } pub(crate) fn visibility(&self, loader: &ActixLoader) -> Select { Select::new("visibility") .title(&fl!(loader, "submission-visibility-select")) .options(&[ (&fl!(loader, "submission-visibility-followers"), "Followers"), (&fl!(loader, "submission-visibility-unlisted"), "Unlisted"), (&fl!(loader, "submission-visibility-public"), "Public"), ]) .default_option(&self.submission.visibility().to_string()) } pub(crate) fn title_input(&self, loader: &ActixLoader) -> TextInput { TextInput::new("title") .title(&fl!(loader, "update-submission-title-input")) .placeholder(&fl!(loader, "update-submission-title-placeholder")) .value( self.title_value.as_deref().unwrap_or( self.submission .title_source() .unwrap_or(self.submission.title()), ), ) .error_opt(self.title_error.clone()) } pub(crate) fn description_input(&self, loader: &ActixLoader) -> TextInput { let input = TextInput::new("description") .title(&fl!(loader, "update-submission-description-input")) .placeholder(&fl!(loader, "update-submission-description-placeholder")) .textarea() .error_opt(self.description_error.clone()); if let Some(text) = self .description_value .as_deref() .or_else(|| self.submission.description_source()) { input.value(text) } else { input } } pub(crate) fn file_input(&self, loader: &ActixLoader) -> FileInput { file_input(loader) } pub(crate) fn is_published(&self) -> bool { self.submission.published().is_some() } pub(crate) fn view_path(&self) -> String { self.submission.view_path() } pub(crate) fn visibility_path(&self) -> String { format!("/submissions/{}/visibility", self.submission.id()) } pub(crate) fn sensitive_path(&self) -> String { format!("/submissions/{}/sensitive", self.submission.id()) } pub(crate) fn update_path(&self) -> String { format!("/submissions/{}/update", self.submission.id()) } pub(crate) fn add_file_path(&self) -> String { format!("/submissions/{}/add-file", self.submission.id()) } pub(crate) fn remove_file_path(&self) -> String { format!("/submissions/{}/remove-file", self.submission.id()) } pub(crate) fn publish_path(&self) -> String { format!("/submissions/{}/publish", self.submission.id()) } pub(crate) fn delete_path(&self) -> String { format!("/submissions/{}/delete", self.submission.id()) } pub(crate) fn images<'a>(&'a self) -> impl Iterator + 'a { self.submission .files() .iter() .filter_map(move |file_id| self.files.get(file_id)) .filter_map(|file| Some((file.id(), file.pictrs_key()?))) .enumerate() .map(move |(index, (id, key))| { let title = format!("{} file {}", self.submission.title_text(), index + 1); (id, FullImage::new(key, &title)) }) } fn title(mut self, text: &str) -> Self { self.title_value = Some(text.to_owned()); self } fn title_error(mut self, error: String) -> Self { self.title_error = Some(error); self } fn description(mut self, text: &str) -> Self { self.description_value = Some(text.to_owned()); self } fn description_error(mut self, error: Option) -> Self { self.description_error = error; self } fn file_error(mut self, error: String) -> Self { self.file_error = Some(error); self } fn remove_file_error(mut self, file_id: Uuid, error: String) -> Self { self.remove_file_error.insert(file_id, error); self } fn publish_error(mut self, error: String) -> Self { self.publish_error = Some(error); self } fn delete_error(mut self, error: String) -> Self { self.delete_error = Some(error); self } fn visibility_error(mut self, error: String) -> Self { self.visibility_error = Some(error); self } fn sensitive_error(mut self, error: String) -> Self { self.sensitive_error = Some(error); self } } async fn files_page( loader: ActixLoader, _: UserProfile, nav_state: NavState, ) -> Result { let file_input = file_input(&loader); crate::rendered(HttpResponse::Ok(), |cursor| { crate::templates::submissions::create(cursor, &loader, file_input, None, &nav_state) }) } async fn upload_files( loader: ActixLoader, profile: UserProfile, req: HttpRequest, pl: web::Payload, nav_state: NavState, client: web::Data, state: web::Data, ) -> Result { let profile = profile.0; let res = state .profiles .upload_image(req, pl.into_inner(), &client) .await; let error = match res { Ok(file_ids) if file_ids.len() == 1 => { let res = state .profiles .run(CreateSubmission::from_file(profile.id(), file_ids[0])) .await; match res { Ok(Some(submission_id)) => return Ok(to_update_page(submission_id)), Ok(None) => "Error creating submission".to_string(), Err(e) => e.to_string(), } } Ok(_) => "Incorrect number of files".to_owned(), Err(e) => e.to_string(), }; let file_input = file_input(&loader); crate::rendered(HttpResponse::BadRequest(), |cursor| { crate::templates::submissions::create(cursor, &loader, file_input, Some(error), &nav_state) }) } #[derive(Clone, Debug, serde::Deserialize)] struct FileQuery { file_page: usize, } pub struct ViewSubmissionState { pub(crate) cache: Cache, pub(crate) profile: Option, pub(crate) submission: Submission, pub(crate) poster: Profile, pub(crate) current_file: Uuid, pub(crate) nav: Vec