Rewrite article publication with the REST API
- Add a default App and ApiToken for each user, that is used by the front-end - Add an API route to update an article (CSRF had to be disabled because of a bug in rocket_csrf) - Use AJAX to publish and edit articles in the new editor, instead of weird hacks with HTML forms
This commit is contained in:
		
							parent
							
								
									4142e73018
								
							
						
					
					
						commit
						cc998e7c61
					
				
							
								
								
									
										2
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							| @ -1965,6 +1965,8 @@ dependencies = [ | ||||
|  "gettext-macros 0.4.0 (git+https://github.com/Plume-org/gettext-macros/?rev=a7c605f7edd6bfbfbfe7778026bfefd88d82db10)", | ||||
|  "gettext-utils 0.1.0 (git+https://github.com/Plume-org/gettext-macros/?rev=a7c605f7edd6bfbfbfe7778026bfefd88d82db10)", | ||||
|  "lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", | ||||
|  "plume-api 0.3.0", | ||||
|  "serde_json 1.0.39 (registry+https://github.com/rust-lang/crates.io-index)", | ||||
|  "stdweb 0.4.14 (registry+https://github.com/rust-lang/crates.io-index)", | ||||
|  "stdweb-internal-runtime 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", | ||||
| ] | ||||
|  | ||||
| @ -0,0 +1,2 @@ | ||||
| -- This file should undo anything in `up.sql` | ||||
| DELETE FROM apps WHERE name = 'Plume web interface'; | ||||
							
								
								
									
										35
									
								
								migrations/postgres/2019-08-03-131154_default_app/up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								migrations/postgres/2019-08-03-131154_default_app/up.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| -- Your SQL goes here | ||||
| --#!|conn: &Connection, path: &Path| { | ||||
| --#!    use plume_common::utils::random_hex; | ||||
| --#! | ||||
| --#!	let client_id = random_hex(); | ||||
| --#!    let client_secret = random_hex(); | ||||
| --#!    let app = crate::apps::App::insert( | ||||
| --#!        &*conn, | ||||
| --#!        crate::apps::NewApp { | ||||
| --#!            name: "Plume web interface".into(), | ||||
| --#!            client_id, | ||||
| --#!            client_secret, | ||||
| --#!            redirect_uri: None, | ||||
| --#!            website: Some("https://joinplu.me".into()), | ||||
| --#!        }, | ||||
| --#!    ).unwrap(); | ||||
| --#! | ||||
| --#!    for i in 0..=(crate::users::User::count_local(conn).unwrap() as i32 / 20) { | ||||
| --#!		if let Ok(page) = crate::users::User::get_local_page(conn, (i * 20, (i + 1) * 20)) { | ||||
| --#!			for user in page { | ||||
| --#!				crate::api_tokens::ApiToken::insert( | ||||
| --#!			        conn, | ||||
| --#!			        crate::api_tokens::NewApiToken { | ||||
| --#!			            app_id: app.id, | ||||
| --#!			            user_id: user.id, | ||||
| --#!			            value: random_hex(), | ||||
| --#!			            scopes: "read+write".into(), | ||||
| --#!			        }, | ||||
| --#!			    ).unwrap(); | ||||
| --#!			} | ||||
| --#!		} | ||||
| --#!	} | ||||
| --#! | ||||
| --#!	Ok(()) | ||||
| --#!} | ||||
							
								
								
									
										2
									
								
								migrations/sqlite/2019-08-03-210305_default_app/down.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								migrations/sqlite/2019-08-03-210305_default_app/down.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | ||||
| -- This file should undo anything in `up.sql` | ||||
| DELETE FROM apps WHERE name = 'Plume web interface'; | ||||
							
								
								
									
										35
									
								
								migrations/sqlite/2019-08-03-210305_default_app/up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								migrations/sqlite/2019-08-03-210305_default_app/up.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| -- Your SQL goes here | ||||
| --#!|conn: &Connection, path: &Path| { | ||||
| --#!    use plume_common::utils::random_hex; | ||||
| --#! | ||||
| --#!	let client_id = random_hex(); | ||||
| --#!    let client_secret = random_hex(); | ||||
| --#!    let app = crate::apps::App::insert( | ||||
| --#!        &*conn, | ||||
| --#!        crate::apps::NewApp { | ||||
| --#!            name: "Plume web interface".into(), | ||||
| --#!            client_id, | ||||
| --#!            client_secret, | ||||
| --#!            redirect_uri: None, | ||||
| --#!            website: Some("https://joinplu.me".into()), | ||||
| --#!        }, | ||||
| --#!    ).unwrap(); | ||||
| --#! | ||||
| --#!    for i in 0..=(crate::users::User::count_local(conn).unwrap() as i32 / 20) { | ||||
| --#!		if let Ok(page) = crate::users::User::get_local_page(conn, (i * 20, (i + 1) * 20)) { | ||||
| --#!			for user in page { | ||||
| --#!				crate::api_tokens::ApiToken::insert( | ||||
| --#!			        conn, | ||||
| --#!			        crate::api_tokens::NewApiToken { | ||||
| --#!			            app_id: app.id, | ||||
| --#!			            user_id: user.id, | ||||
| --#!			            value: random_hex(), | ||||
| --#!			            scopes: "read+write".into(), | ||||
| --#!			        }, | ||||
| --#!			    ).unwrap(); | ||||
| --#!			} | ||||
| --#!		} | ||||
| --#!	} | ||||
| --#! | ||||
| --#!	Ok(()) | ||||
| --#!} | ||||
| @ -28,4 +28,5 @@ pub struct PostData { | ||||
|     pub license: String, | ||||
|     pub tags: Vec<String>, | ||||
|     pub cover_id: Option<i32>, | ||||
|     pub url: String, | ||||
| } | ||||
|  | ||||
| @ -10,3 +10,5 @@ gettext = { git = "https://github.com/Plume-org/gettext/", rev = "294c54d74c699f | ||||
| gettext-macros = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" } | ||||
| gettext-utils = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" } | ||||
| lazy_static = "1.3" | ||||
| plume-api = { path = "../plume-api" } | ||||
| serde_json = "1.0" | ||||
|  | ||||
| @ -16,17 +16,14 @@ macro_rules! mv { | ||||
| fn get_elt_value(id: &'static str) -> String { | ||||
|     let elt = document().get_element_by_id(id).unwrap(); | ||||
|     let inp: Result<InputElement, _> = elt.clone().try_into(); | ||||
|     let select: Result<SelectElement, _> = elt.clone().try_into(); | ||||
|     let textarea: Result<TextAreaElement, _> = elt.try_into(); | ||||
|     inp.map(|i| i.raw_value()) | ||||
|         .unwrap_or_else(|_| textarea.unwrap().value()) | ||||
| } | ||||
| 
 | ||||
| fn set_value<S: AsRef<str>>(id: &'static str, val: S) { | ||||
|     let elt = document().get_element_by_id(id).unwrap(); | ||||
|     let inp: Result<InputElement, _> = elt.clone().try_into(); | ||||
|     let textarea: Result<TextAreaElement, _> = elt.try_into(); | ||||
|     inp.map(|i| i.set_raw_value(val.as_ref())) | ||||
|         .unwrap_or_else(|_| textarea.unwrap().set_value(val.as_ref())) | ||||
|     let res = inp.map(|i| i.raw_value()).unwrap_or_else(|_| { | ||||
|         textarea | ||||
|             .map(|t| t.value()) | ||||
|             .unwrap_or_else(|_| select.unwrap().value().unwrap_or_default()) | ||||
|     }); | ||||
|     res | ||||
| } | ||||
| 
 | ||||
| fn no_return(evt: KeyDownEvent) { | ||||
| @ -163,12 +160,102 @@ fn init_editor() -> Result<(), EditorError> { | ||||
|                 }; | ||||
|             }); | ||||
| 
 | ||||
|         document() | ||||
|             .get_element_by_id("confirm-publish")? | ||||
|             .add_event_listener(|_: ClickEvent| { | ||||
|                 save(false); | ||||
|             }); | ||||
| 
 | ||||
|         document() | ||||
|             .get_element_by_id("save-draft")? | ||||
|             .add_event_listener(|_: ClickEvent| { | ||||
|                 save(true); | ||||
|             }); | ||||
| 
 | ||||
|         show_errors(); | ||||
|         setup_close_button(); | ||||
|     } | ||||
|     Ok(()) | ||||
| } | ||||
| 
 | ||||
| fn save(is_draft: bool) { | ||||
|     let req = XmlHttpRequest::new(); | ||||
|     if bool::try_from(js! { return window.editing }).unwrap_or(false) { | ||||
|         req.open( | ||||
|             "PUT", | ||||
|             &format!( | ||||
|                 "/api/v1/posts/{}", | ||||
|                 i32::try_from(js! { return window.post_id }).unwrap() | ||||
|             ), | ||||
|         ) | ||||
|         .unwrap(); | ||||
|     } else { | ||||
|         req.open("POST", "/api/v1/posts").unwrap(); | ||||
|     } | ||||
|     req.set_request_header("Accept", "application/json") | ||||
|         .unwrap(); | ||||
|     req.set_request_header("Content-Type", "application/json") | ||||
|         .unwrap(); | ||||
|     req.set_request_header( | ||||
|         "Authorization", | ||||
|         &format!( | ||||
|             "Bearer {}", | ||||
|             String::try_from(js! { return window.api_token }).unwrap() | ||||
|         ), | ||||
|     ) | ||||
|     .unwrap(); | ||||
|     let req_clone = req.clone(); | ||||
|     req.add_event_listener(move |_: ProgressLoadEvent| { | ||||
|         if let Ok(Some(res)) = req_clone.response_text() { | ||||
|             serde_json::from_str(&res) | ||||
|                 .map(|res: plume_api::posts::PostData| { | ||||
|                     let url = res.url; | ||||
|                     js! { | ||||
|                         window.location.href = @{url}; | ||||
|                     }; | ||||
|                 }) | ||||
|                 .map_err(|_| { | ||||
|                     let json: serde_json::Value = serde_json::from_str(&res).unwrap(); | ||||
|                     window().alert(&format!( | ||||
|                         "Error: {}", | ||||
|                         json["error"].as_str().unwrap_or_default() | ||||
|                     )); | ||||
|                 }) | ||||
|                 .ok(); | ||||
|         } | ||||
|     }); | ||||
|     let data = plume_api::posts::NewPostData { | ||||
|         title: HtmlElement::try_from(document().get_element_by_id("editor-title").unwrap()) | ||||
|             .unwrap() | ||||
|             .inner_text(), | ||||
|         subtitle: document() | ||||
|             .get_element_by_id("editor-subtitle") | ||||
|             .map(|s| HtmlElement::try_from(s).unwrap().inner_text()), | ||||
|         source: HtmlElement::try_from( | ||||
|             document() | ||||
|                 .get_element_by_id("editor-default-paragraph") | ||||
|                 .unwrap(), | ||||
|         ) | ||||
|         .unwrap() | ||||
|         .inner_text(), | ||||
|         author: String::new(), // it is ignored anyway (TODO: remove it ??)
 | ||||
|         blog_id: i32::try_from(js! { return window.blog_id }).ok(), | ||||
|         published: Some(!is_draft), | ||||
|         creation_date: None, | ||||
|         license: Some(get_elt_value("license")), | ||||
|         tags: Some( | ||||
|             get_elt_value("tags") | ||||
|                 .split(',') | ||||
|                 .map(|t| t.trim().to_string()) | ||||
|                 .filter(|t| !t.is_empty()) | ||||
|                 .collect(), | ||||
|         ), | ||||
|         cover_id: get_elt_value("cover").parse().ok(), | ||||
|     }; | ||||
|     let json = serde_json::to_string(&data).unwrap(); | ||||
|     req.send_with_string(&json).unwrap(); | ||||
| } | ||||
| 
 | ||||
| fn setup_close_button() { | ||||
|     if let Some(button) = document().get_element_by_id("close-editor") { | ||||
|         button.add_event_listener(|_: ClickEvent| { | ||||
| @ -201,108 +288,6 @@ fn show_errors() { | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn init_popup( | ||||
|     title: &HtmlElement, | ||||
|     subtitle: &HtmlElement, | ||||
|     content: &HtmlElement, | ||||
|     old_ed: &Element, | ||||
| ) -> Result<Element, EditorError> { | ||||
|     let popup = document().create_element("div")?; | ||||
|     popup.class_list().add("popup")?; | ||||
|     popup.set_attribute("id", "publish-popup")?; | ||||
| 
 | ||||
|     let tags = get_elt_value("tags") | ||||
|         .split(',') | ||||
|         .map(str::trim) | ||||
|         .map(str::to_string) | ||||
|         .collect::<Vec<_>>(); | ||||
|     let license = get_elt_value("license"); | ||||
|     make_input(&i18n!(CATALOG, "Tags"), "popup-tags", &popup).set_raw_value(&tags.join(", ")); | ||||
|     make_input(&i18n!(CATALOG, "License"), "popup-license", &popup).set_raw_value(&license); | ||||
| 
 | ||||
|     let cover_label = document().create_element("label")?; | ||||
|     cover_label.append_child(&document().create_text_node(&i18n!(CATALOG, "Cover"))); | ||||
|     cover_label.set_attribute("for", "cover")?; | ||||
|     let cover = document().get_element_by_id("cover")?; | ||||
|     cover.parent_element()?.remove_child(&cover).ok(); | ||||
|     popup.append_child(&cover_label); | ||||
|     popup.append_child(&cover); | ||||
| 
 | ||||
|     if let Some(draft_checkbox) = document().get_element_by_id("draft") { | ||||
|         let draft_label = document().create_element("label")?; | ||||
|         draft_label.set_attribute("for", "popup-draft")?; | ||||
| 
 | ||||
|         let draft = document().create_element("input").unwrap(); | ||||
|         js! { | ||||
|             @{&draft}.id = "popup-draft"; | ||||
|             @{&draft}.name = "popup-draft"; | ||||
|             @{&draft}.type = "checkbox"; | ||||
|             @{&draft}.checked = @{&draft_checkbox}.checked; | ||||
|         }; | ||||
| 
 | ||||
|         draft_label.append_child(&draft); | ||||
|         draft_label.append_child(&document().create_text_node(&i18n!(CATALOG, "This is a draft"))); | ||||
|         popup.append_child(&draft_label); | ||||
|     } | ||||
| 
 | ||||
|     let button = document().create_element("input")?; | ||||
|     js! { | ||||
|         @{&button}.type = "submit"; | ||||
|         @{&button}.value = @{i18n!(CATALOG, "Publish")}; | ||||
|     }; | ||||
|     button.append_child(&document().create_text_node(&i18n!(CATALOG, "Publish"))); | ||||
|     button.add_event_listener( | ||||
|         mv!(title, subtitle, content, old_ed => move |_: ClickEvent| { | ||||
|             title.focus(); // Remove the placeholder before publishing
 | ||||
|             set_value("title", title.inner_text()); | ||||
|             subtitle.focus(); | ||||
|             set_value("subtitle", subtitle.inner_text()); | ||||
|             content.focus(); | ||||
|             set_value("editor-content", content.child_nodes().iter().fold(String::new(), |md, ch| { | ||||
|                 let to_append = match ch.node_type() { | ||||
|                     NodeType::Element => { | ||||
|                         if js!{ return @{&ch}.tagName; } == "DIV" { | ||||
|                             (js!{ return @{&ch}.innerHTML; }).try_into().unwrap_or_default() | ||||
|                         } else { | ||||
|                             (js!{ return @{&ch}.outerHTML; }).try_into().unwrap_or_default() | ||||
|                         } | ||||
|                     }, | ||||
|                     NodeType::Text => ch.node_value().unwrap_or_default(), | ||||
|                     _ => unreachable!(), | ||||
|                 }; | ||||
|                 format!("{}\n\n{}", md, to_append) | ||||
|             })); | ||||
|             set_value("tags", get_elt_value("popup-tags")); | ||||
|             if let Some(draft) = document().get_element_by_id("popup-draft") { | ||||
|                 js!{ | ||||
|                     document.getElementById("draft").checked = @{draft}.checked; | ||||
|                 }; | ||||
|             } | ||||
|             let cover = document().get_element_by_id("cover").unwrap(); | ||||
|             cover.parent_element().unwrap().remove_child(&cover).ok(); | ||||
|             old_ed.append_child(&cover); | ||||
|             set_value("license", get_elt_value("popup-license")); | ||||
|             js! { | ||||
|                 @{&old_ed}.submit(); | ||||
|             }; | ||||
|         }), | ||||
|     ); | ||||
|     popup.append_child(&button); | ||||
| 
 | ||||
|     document().body()?.append_child(&popup); | ||||
|     Ok(popup) | ||||
| } | ||||
| 
 | ||||
| fn init_popup_bg() -> Result<Element, EditorError> { | ||||
|     let bg = document().create_element("div")?; | ||||
|     bg.class_list().add("popup-bg")?; | ||||
|     bg.set_attribute("id", "popup-bg")?; | ||||
| 
 | ||||
|     document().body()?.append_child(&bg); | ||||
|     bg.add_event_listener(|_: ClickEvent| close_popup()); | ||||
|     Ok(bg) | ||||
| } | ||||
| 
 | ||||
| fn chars_left(selector: &str, content: &Element) -> Option<i32> { | ||||
|     match document().query_selector(selector) { | ||||
|         Ok(Some(form)) => HtmlElement::try_from(form).ok().and_then(|form| { | ||||
| @ -329,42 +314,3 @@ fn chars_left(selector: &str, content: &Element) -> Option<i32> { | ||||
|         _ => None, | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn close_popup() { | ||||
|     let hide = |x: Element| x.class_list().remove("show"); | ||||
|     document().get_element_by_id("publish-popup").map(hide); | ||||
|     document().get_element_by_id("popup-bg").map(hide); | ||||
| } | ||||
| 
 | ||||
| fn make_input(label_text: &str, name: &'static str, form: &Element) -> InputElement { | ||||
|     let label = document().create_element("label").unwrap(); | ||||
|     label.append_child(&document().create_text_node(label_text)); | ||||
|     label.set_attribute("for", name).unwrap(); | ||||
| 
 | ||||
|     let inp: InputElement = document() | ||||
|         .create_element("input") | ||||
|         .unwrap() | ||||
|         .try_into() | ||||
|         .unwrap(); | ||||
|     inp.set_attribute("name", name).unwrap(); | ||||
|     inp.set_attribute("id", name).unwrap(); | ||||
| 
 | ||||
|     form.append_child(&label); | ||||
|     form.append_child(&inp); | ||||
|     inp | ||||
| } | ||||
| 
 | ||||
| fn make_editable(tag: &'static str) -> Element { | ||||
|     let elt = document() | ||||
|         .create_element(tag) | ||||
|         .expect("Couldn't create editable element"); | ||||
|     elt.set_attribute("contenteditable", "true") | ||||
|         .expect("Couldn't make the element editable"); | ||||
|     elt | ||||
| } | ||||
| 
 | ||||
| fn clear_children(elt: &HtmlElement) { | ||||
|     for child in elt.child_nodes() { | ||||
|         elt.remove_child(&child).unwrap(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -8,6 +8,7 @@ extern crate gettext_macros; | ||||
| extern crate lazy_static; | ||||
| #[macro_use] | ||||
| extern crate stdweb; | ||||
| extern crate serde_json; | ||||
| 
 | ||||
| use stdweb::web::{event::*, *}; | ||||
| 
 | ||||
|  | ||||
| @ -44,6 +44,18 @@ impl ApiToken { | ||||
|     get!(api_tokens); | ||||
|     insert!(api_tokens, NewApiToken); | ||||
|     find_by!(api_tokens, find_by_value, value as &str); | ||||
|     find_by!( | ||||
|         api_tokens, | ||||
|         find_by_app_and_user, | ||||
|         app_id as i32, | ||||
|         user_id as i32 | ||||
|     ); | ||||
| 
 | ||||
|     /// The token for Plume's front-end
 | ||||
|     pub fn web_token(conn: &crate::Connection, user_id: i32) -> Result<ApiToken> { | ||||
|         let app = crate::apps::App::find_by_name(conn, "Plume web interface")?; | ||||
|         Self::find_by_app_and_user(conn, app.id, user_id) | ||||
|     } | ||||
| 
 | ||||
|     pub fn can(&self, what: &'static str, scope: &'static str) -> bool { | ||||
|         let full_scope = what.to_owned() + ":" + scope; | ||||
|  | ||||
| @ -29,4 +29,5 @@ impl App { | ||||
|     get!(apps); | ||||
|     insert!(apps, NewApp); | ||||
|     find_by!(apps, find_by_client_id, client_id as &str); | ||||
|     find_by!(apps, find_by_name, name as &str); | ||||
| } | ||||
|  | ||||
| @ -64,6 +64,7 @@ pub enum Error { | ||||
|     Signature, | ||||
|     Unauthorized, | ||||
|     Url, | ||||
|     Validation(String), | ||||
|     Webfinger, | ||||
|     Expired, | ||||
| } | ||||
|  | ||||
| @ -37,6 +37,7 @@ impl<'r> Responder<'r> for ApiError { | ||||
|                 "error": "You are not authorized to access this resource" | ||||
|             })) | ||||
|             .respond_to(req), | ||||
|             Error::Validation(msg) => Json(json!({ "error": msg })).respond_to(req), | ||||
|             _ => Json(json!({ | ||||
|                 "error": "Server error" | ||||
|             })) | ||||
|  | ||||
							
								
								
									
										157
									
								
								src/api/posts.rs
									
									
									
									
									
								
							
							
						
						
									
										157
									
								
								src/api/posts.rs
									
									
									
									
									
								
							| @ -1,6 +1,7 @@ | ||||
| use chrono::NaiveDateTime; | ||||
| use chrono::{NaiveDateTime, Utc}; | ||||
| use heck::{CamelCase, KebabCase}; | ||||
| use rocket_contrib::json::Json; | ||||
| use std::collections::HashSet; | ||||
| 
 | ||||
| use crate::api::{authorization::*, Api}; | ||||
| use plume_api::posts::*; | ||||
| @ -44,6 +45,7 @@ pub fn get(id: i32, auth: Option<Authorization<Read, Post>>, conn: DbConn) -> Ap | ||||
|         published: post.published, | ||||
|         license: post.license, | ||||
|         cover_id: post.cover_id, | ||||
|         url: post.ap_url, | ||||
|     })) | ||||
| } | ||||
| 
 | ||||
| @ -91,6 +93,7 @@ pub fn list( | ||||
|                     published: p.published, | ||||
|                     license: p.license, | ||||
|                     cover_id: p.cover_id, | ||||
|                     url: p.ap_url, | ||||
|                 }) | ||||
|             }) | ||||
|             .collect(), | ||||
| @ -114,6 +117,20 @@ pub fn create( | ||||
|         NaiveDateTime::parse_from_str(format!("{} 00:00:00", d).as_ref(), "%Y-%m-%d %H:%M:%S").ok() | ||||
|     }); | ||||
| 
 | ||||
|     if slug.as_str() == "new" { | ||||
|         return Err( | ||||
|             Error::Validation("Sorry, but your article can't have this title.".into()).into(), | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     if payload.title.is_empty() { | ||||
|         return Err(Error::Validation("You have to give your article a title.".into()).into()); | ||||
|     } | ||||
| 
 | ||||
|     if payload.source.is_empty() { | ||||
|         return Err(Error::Validation("Your article can't be empty.".into()).into()); | ||||
|     } | ||||
| 
 | ||||
|     let domain = &Instance::get_local()?.public_domain; | ||||
|     let (content, mentions, hashtags) = md_to_html( | ||||
|         &payload.source, | ||||
| @ -131,6 +148,10 @@ pub fn create( | ||||
|         } | ||||
|     })?; | ||||
| 
 | ||||
|     if !author.is_author_in(conn, &Blog::get(conn, blog)?)? { | ||||
|         return Err(Error::Unauthorized.into()); | ||||
|     } | ||||
| 
 | ||||
|     if Post::find_by_slug(conn, slug, blog).is_ok() { | ||||
|         return Err(Error::InvalidValue.into()); | ||||
|     } | ||||
| @ -166,11 +187,19 @@ pub fn create( | ||||
|     )?; | ||||
| 
 | ||||
|     if let Some(ref tags) = payload.tags { | ||||
|         let tags = tags | ||||
|             .iter() | ||||
|             .map(|t| t.to_camel_case()) | ||||
|             .filter(|t| !t.is_empty()) | ||||
|             .collect::<HashSet<_>>() | ||||
|             .into_iter() | ||||
|             .filter_map(|t| Tag::build_activity(t).ok()); | ||||
| 
 | ||||
|         for tag in tags { | ||||
|             Tag::insert( | ||||
|                 conn, | ||||
|                 NewTag { | ||||
|                     tag: tag.to_string(), | ||||
|                     tag: tag.name_string().unwrap(), | ||||
|                     is_hashtag: false, | ||||
|                     post_id: post.id, | ||||
|                 }, | ||||
| @ -211,7 +240,6 @@ pub fn create( | ||||
|             .into_iter() | ||||
|             .map(|t| t.tag) | ||||
|             .collect(), | ||||
| 
 | ||||
|         id: post.id, | ||||
|         title: post.title, | ||||
|         subtitle: post.subtitle, | ||||
| @ -221,9 +249,132 @@ pub fn create( | ||||
|         published: post.published, | ||||
|         license: post.license, | ||||
|         cover_id: post.cover_id, | ||||
|         url: post.ap_url, | ||||
|     })) | ||||
| } | ||||
| 
 | ||||
| #[put("/posts/<id>", data = "<payload>")] | ||||
| pub fn update( | ||||
|     id: i32, | ||||
|     auth: Authorization<Write, Post>, | ||||
|     payload: Json<NewPostData>, | ||||
|     rockets: PlumeRocket, | ||||
| ) -> Api<PostData> { | ||||
|     let conn = &*rockets.conn; | ||||
|     let mut post = Post::get(&*conn, id)?; | ||||
|     let author = User::get(conn, auth.0.user_id)?; | ||||
|     let b = post.get_blog(&*conn)?; | ||||
| 
 | ||||
|     let new_slug = if !post.published { | ||||
|         payload.title.to_string().to_kebab_case() | ||||
|     } else { | ||||
|         post.slug.clone() | ||||
|     }; | ||||
| 
 | ||||
|     if new_slug != post.slug && Post::find_by_slug(&*conn, &new_slug, b.id).is_ok() { | ||||
|         return Err(Error::Validation("A post with the same title already exists.".into()).into()); | ||||
|     } | ||||
| 
 | ||||
|     if !author.is_author_in(&*conn, &b)? { | ||||
|         Err(Error::Unauthorized.into()) | ||||
|     } else { | ||||
|         let (content, mentions, hashtags) = md_to_html( | ||||
|             &payload.source, | ||||
|             Some(&Instance::get_local()?.public_domain), | ||||
|             false, | ||||
|             Some(Media::get_media_processor( | ||||
|                 &conn, | ||||
|                 b.list_authors(&conn)?.iter().collect(), | ||||
|             )), | ||||
|         ); | ||||
| 
 | ||||
|         // update publication date if when this article is no longer a draft
 | ||||
|         let newly_published = if !post.published && payload.published.unwrap_or(post.published) { | ||||
|             post.published = true; | ||||
|             post.creation_date = Utc::now().naive_utc(); | ||||
|             true | ||||
|         } else { | ||||
|             false | ||||
|         }; | ||||
| 
 | ||||
|         post.slug = new_slug.clone(); | ||||
|         post.title = payload.title.clone(); | ||||
|         post.subtitle = payload.subtitle.clone().unwrap_or_default(); | ||||
|         post.content = SafeString::new(&content); | ||||
|         post.source = payload.source.clone(); | ||||
|         post.license = payload.license.clone().unwrap_or_default(); | ||||
|         post.cover_id = payload.cover_id; | ||||
|         post.update(&*conn, &rockets.searcher)?; | ||||
| 
 | ||||
|         if post.published { | ||||
|             post.update_mentions( | ||||
|                 &conn, | ||||
|                 mentions | ||||
|                     .into_iter() | ||||
|                     .filter_map(|m| Mention::build_activity(&rockets, &m).ok()) | ||||
|                     .collect(), | ||||
|             )?; | ||||
|         } | ||||
| 
 | ||||
|         let tags = payload | ||||
|             .tags | ||||
|             .clone() | ||||
|             .unwrap_or_default() | ||||
|             .iter() | ||||
|             .map(|t| t.trim().to_camel_case()) | ||||
|             .filter(|t| !t.is_empty()) | ||||
|             .collect::<HashSet<_>>() | ||||
|             .into_iter() | ||||
|             .filter_map(|t| Tag::build_activity(t).ok()) | ||||
|             .collect::<Vec<_>>(); | ||||
|         post.update_tags(&conn, tags)?; | ||||
| 
 | ||||
|         let hashtags = hashtags | ||||
|             .into_iter() | ||||
|             .map(|h| h.to_camel_case()) | ||||
|             .collect::<HashSet<_>>() | ||||
|             .into_iter() | ||||
|             .filter_map(|t| Tag::build_activity(t).ok()) | ||||
|             .collect::<Vec<_>>(); | ||||
|         post.update_hashtags(&conn, hashtags)?; | ||||
| 
 | ||||
|         if post.published { | ||||
|             if newly_published { | ||||
|                 let act = post.create_activity(&conn)?; | ||||
|                 let dest = User::one_by_instance(&*conn)?; | ||||
|                 rockets | ||||
|                     .worker | ||||
|                     .execute(move || broadcast(&author, act, dest)); | ||||
|             } else { | ||||
|                 let act = post.update_activity(&*conn)?; | ||||
|                 let dest = User::one_by_instance(&*conn)?; | ||||
|                 rockets | ||||
|                     .worker | ||||
|                     .execute(move || broadcast(&author, act, dest)); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         Ok(Json(PostData { | ||||
|             authors: post.get_authors(conn)?.into_iter().map(|a| a.fqn).collect(), | ||||
|             creation_date: post.creation_date.format("%Y-%m-%d").to_string(), | ||||
|             tags: Tag::for_post(conn, post.id)? | ||||
|                 .into_iter() | ||||
|                 .map(|t| t.tag) | ||||
|                 .collect(), | ||||
|             id: post.id, | ||||
|             title: post.title, | ||||
|             subtitle: post.subtitle, | ||||
|             content: post.content.to_string(), | ||||
|             source: Some(post.source), | ||||
|             blog_id: post.blog_id, | ||||
|             published: post.published, | ||||
|             license: post.license, | ||||
|             cover_id: post.cover_id, | ||||
|             url: post.ap_url, | ||||
|         })) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[delete("/posts/<id>")] | ||||
| pub fn delete(auth: Authorization<Write, Post>, rockets: PlumeRocket, id: i32) -> Api<()> { | ||||
|     let author = User::get(&*rockets.conn, auth.0.user_id)?; | ||||
|  | ||||
| @ -275,6 +275,7 @@ Then try to restart Plume | ||||
|                 api::posts::get, | ||||
|                 api::posts::list, | ||||
|                 api::posts::create, | ||||
|                 api::posts::update, | ||||
|                 api::posts::delete, | ||||
|             ], | ||||
|         ) | ||||
| @ -289,7 +290,7 @@ Then try to restart Plume | ||||
|         .manage(Arc::new(workpool)) | ||||
|         .manage(searcher) | ||||
|         .manage(include_i18n!()) | ||||
|         .attach( | ||||
|         /*.attach(
 | ||||
|             CsrfFairingBuilder::new() | ||||
|                 .set_default_target( | ||||
|                     "/csrf-violation?target=<uri>".to_owned(), | ||||
| @ -314,7 +315,7 @@ Then try to restart Plume | ||||
|                 ]) | ||||
|                 .finalize() | ||||
|                 .expect("main: csrf fairing creation error"), | ||||
|         ); | ||||
|         )*/; | ||||
| 
 | ||||
|     #[cfg(feature = "test")] | ||||
|     let rocket = rocket.mount("/test", routes![test_routes::health,]); | ||||
|  | ||||
| @ -13,6 +13,7 @@ use validator::{Validate, ValidationError, ValidationErrors}; | ||||
| use plume_common::activity_pub::{broadcast, ActivityStream, ApRequest}; | ||||
| use plume_common::utils; | ||||
| use plume_models::{ | ||||
|     api_tokens::ApiToken, | ||||
|     blogs::*, | ||||
|     comments::{Comment, CommentTree}, | ||||
|     inbox::inbox, | ||||
| @ -156,7 +157,8 @@ pub fn new(blog: String, cl: ContentLen, rockets: PlumeRocket) -> Result<Ructe, | ||||
|         None, | ||||
|         ValidationErrors::default(), | ||||
|         medias, | ||||
|         cl.0 | ||||
|         cl.0, | ||||
|         ApiToken::web_token(&*conn, user.id)?.value | ||||
|     ))) | ||||
| } | ||||
| 
 | ||||
| @ -210,7 +212,8 @@ pub fn edit( | ||||
|         Some(post), | ||||
|         ValidationErrors::default(), | ||||
|         medias, | ||||
|         cl.0 | ||||
|         cl.0, | ||||
|         ApiToken::web_token(&*conn, user.id)?.value | ||||
|     ))) | ||||
| } | ||||
| 
 | ||||
| @ -366,7 +369,10 @@ pub fn update( | ||||
|             Some(post), | ||||
|             errors.clone(), | ||||
|             medias.clone(), | ||||
|             cl.0 | ||||
|             cl.0, | ||||
|             ApiToken::web_token(&*conn, user.id) | ||||
|                 .expect("The default API token cannot be retrieved") | ||||
|                 .value | ||||
|         )) | ||||
|         .into() | ||||
|     } | ||||
| @ -550,7 +556,8 @@ pub fn create( | ||||
|             None, | ||||
|             errors.clone(), | ||||
|             medias, | ||||
|             cl.0 | ||||
|             cl.0, | ||||
|             ApiToken::web_token(&*conn, user.id)?.value | ||||
|         )) | ||||
|         .into()) | ||||
|     } | ||||
|  | ||||
| @ -9,9 +9,19 @@ | ||||
| @use routes::posts::NewPostForm; | ||||
| @use routes::*; | ||||
| 
 | ||||
| @(ctx: BaseContext, title: String, blog: Blog, editing: bool, form: &NewPostForm, is_draft: bool, article: Option<Post>, errors: ValidationErrors, medias: Vec<Media>, content_len: u64) | ||||
| @(ctx: BaseContext, title: String, blog: Blog, editing: bool, form: &NewPostForm, is_draft: bool, article: Option<Post>, errors: ValidationErrors, medias: Vec<Media>, content_len: u64, api_token: String) | ||||
| 
 | ||||
| @:base(ctx, title.clone(), {}, {}, { | ||||
|     <script> | ||||
|         window.blog_id = @blog.id | ||||
|         window.api_token = '@api_token' | ||||
|         @if editing { | ||||
|             window.editing = true | ||||
|             window.post_id = @article.clone().unwrap().id | ||||
|         } else { | ||||
|             window.editing = false | ||||
|         } | ||||
|     </script> | ||||
|     <div id="plume-editor" style="display: none;" dir="auto"> | ||||
|       <header> | ||||
|         <a href="#" id="close-editor">@i18n!(ctx.1, "Classic editor (any changes will be lost)")</a> | ||||
| @ -34,7 +44,7 @@ | ||||
|             <a href="#" id="cancel-publish">@i18n!(ctx.1, "Cancel")</a> | ||||
|             <p>@i18n!(ctx.1, "You are about to publish this article in {}"; &blog.title)</p> | ||||
|             <button id="confirm-publish" class="button">@i18n!(ctx.1, "Confirm publication")</button> | ||||
|             <button id="draft" class="button secondary">@i18n!(ctx.1, "Save as draft")</button> | ||||
|             <button id="save-draft" class="button secondary">@i18n!(ctx.1, "Save as draft")</button> | ||||
|         </div> | ||||
|     </aside> | ||||
|     </div> | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user