mirror of
https://git.joinplu.me/Plume/Plume.git
synced 2024-11-26 05:21:00 +00:00
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
17 changed files with 372 additions and 162 deletions
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-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)",
|
"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)",
|
"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 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)",
|
"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 license: String,
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
pub cover_id: Option<i32>,
|
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-macros = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
|
||||||
gettext-utils = { 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"
|
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 {
|
fn get_elt_value(id: &'static str) -> String {
|
||||||
let elt = document().get_element_by_id(id).unwrap();
|
let elt = document().get_element_by_id(id).unwrap();
|
||||||
let inp: Result<InputElement, _> = elt.clone().try_into();
|
let inp: Result<InputElement, _> = elt.clone().try_into();
|
||||||
|
let select: Result<SelectElement, _> = elt.clone().try_into();
|
||||||
let textarea: Result<TextAreaElement, _> = elt.try_into();
|
let textarea: Result<TextAreaElement, _> = elt.try_into();
|
||||||
inp.map(|i| i.raw_value())
|
let res = inp.map(|i| i.raw_value()).unwrap_or_else(|_| {
|
||||||
.unwrap_or_else(|_| textarea.unwrap().value())
|
textarea
|
||||||
}
|
.map(|t| t.value())
|
||||||
|
.unwrap_or_else(|_| select.unwrap().value().unwrap_or_default())
|
||||||
fn set_value<S: AsRef<str>>(id: &'static str, val: S) {
|
});
|
||||||
let elt = document().get_element_by_id(id).unwrap();
|
res
|
||||||
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()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn no_return(evt: KeyDownEvent) {
|
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();
|
show_errors();
|
||||||
setup_close_button();
|
setup_close_button();
|
||||||
}
|
}
|
||||||
Ok(())
|
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() {
|
fn setup_close_button() {
|
||||||
if let Some(button) = document().get_element_by_id("close-editor") {
|
if let Some(button) = document().get_element_by_id("close-editor") {
|
||||||
button.add_event_listener(|_: ClickEvent| {
|
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> {
|
fn chars_left(selector: &str, content: &Element) -> Option<i32> {
|
||||||
match document().query_selector(selector) {
|
match document().query_selector(selector) {
|
||||||
Ok(Some(form)) => HtmlElement::try_from(form).ok().and_then(|form| {
|
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,
|
_ => 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;
|
extern crate lazy_static;
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate stdweb;
|
extern crate stdweb;
|
||||||
|
extern crate serde_json;
|
||||||
|
|
||||||
use stdweb::web::{event::*, *};
|
use stdweb::web::{event::*, *};
|
||||||
|
|
||||||
|
|
|
@ -44,6 +44,18 @@ impl ApiToken {
|
||||||
get!(api_tokens);
|
get!(api_tokens);
|
||||||
insert!(api_tokens, NewApiToken);
|
insert!(api_tokens, NewApiToken);
|
||||||
find_by!(api_tokens, find_by_value, value as &str);
|
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 {
|
pub fn can(&self, what: &'static str, scope: &'static str) -> bool {
|
||||||
let full_scope = what.to_owned() + ":" + scope;
|
let full_scope = what.to_owned() + ":" + scope;
|
||||||
|
|
|
@ -29,4 +29,5 @@ impl App {
|
||||||
get!(apps);
|
get!(apps);
|
||||||
insert!(apps, NewApp);
|
insert!(apps, NewApp);
|
||||||
find_by!(apps, find_by_client_id, client_id as &str);
|
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,
|
Signature,
|
||||||
Unauthorized,
|
Unauthorized,
|
||||||
Url,
|
Url,
|
||||||
|
Validation(String),
|
||||||
Webfinger,
|
Webfinger,
|
||||||
Expired,
|
Expired,
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,6 +37,7 @@ impl<'r> Responder<'r> for ApiError {
|
||||||
"error": "You are not authorized to access this resource"
|
"error": "You are not authorized to access this resource"
|
||||||
}))
|
}))
|
||||||
.respond_to(req),
|
.respond_to(req),
|
||||||
|
Error::Validation(msg) => Json(json!({ "error": msg })).respond_to(req),
|
||||||
_ => Json(json!({
|
_ => Json(json!({
|
||||||
"error": "Server error"
|
"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 heck::{CamelCase, KebabCase};
|
||||||
use rocket_contrib::json::Json;
|
use rocket_contrib::json::Json;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::api::{authorization::*, Api};
|
use crate::api::{authorization::*, Api};
|
||||||
use plume_api::posts::*;
|
use plume_api::posts::*;
|
||||||
|
@ -44,6 +45,7 @@ pub fn get(id: i32, auth: Option<Authorization<Read, Post>>, conn: DbConn) -> Ap
|
||||||
published: post.published,
|
published: post.published,
|
||||||
license: post.license,
|
license: post.license,
|
||||||
cover_id: post.cover_id,
|
cover_id: post.cover_id,
|
||||||
|
url: post.ap_url,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,6 +93,7 @@ pub fn list(
|
||||||
published: p.published,
|
published: p.published,
|
||||||
license: p.license,
|
license: p.license,
|
||||||
cover_id: p.cover_id,
|
cover_id: p.cover_id,
|
||||||
|
url: p.ap_url,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect(),
|
.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()
|
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 domain = &Instance::get_local()?.public_domain;
|
||||||
let (content, mentions, hashtags) = md_to_html(
|
let (content, mentions, hashtags) = md_to_html(
|
||||||
&payload.source,
|
&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() {
|
if Post::find_by_slug(conn, slug, blog).is_ok() {
|
||||||
return Err(Error::InvalidValue.into());
|
return Err(Error::InvalidValue.into());
|
||||||
}
|
}
|
||||||
|
@ -166,11 +187,19 @@ pub fn create(
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if let Some(ref tags) = payload.tags {
|
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 {
|
for tag in tags {
|
||||||
Tag::insert(
|
Tag::insert(
|
||||||
conn,
|
conn,
|
||||||
NewTag {
|
NewTag {
|
||||||
tag: tag.to_string(),
|
tag: tag.name_string().unwrap(),
|
||||||
is_hashtag: false,
|
is_hashtag: false,
|
||||||
post_id: post.id,
|
post_id: post.id,
|
||||||
},
|
},
|
||||||
|
@ -211,7 +240,6 @@ pub fn create(
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|t| t.tag)
|
.map(|t| t.tag)
|
||||||
.collect(),
|
.collect(),
|
||||||
|
|
||||||
id: post.id,
|
id: post.id,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
subtitle: post.subtitle,
|
subtitle: post.subtitle,
|
||||||
|
@ -221,9 +249,132 @@ pub fn create(
|
||||||
published: post.published,
|
published: post.published,
|
||||||
license: post.license,
|
license: post.license,
|
||||||
cover_id: post.cover_id,
|
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>")]
|
#[delete("/posts/<id>")]
|
||||||
pub fn delete(auth: Authorization<Write, Post>, rockets: PlumeRocket, id: i32) -> Api<()> {
|
pub fn delete(auth: Authorization<Write, Post>, rockets: PlumeRocket, id: i32) -> Api<()> {
|
||||||
let author = User::get(&*rockets.conn, auth.0.user_id)?;
|
let author = User::get(&*rockets.conn, auth.0.user_id)?;
|
||||||
|
|
|
@ -275,6 +275,7 @@ Then try to restart Plume
|
||||||
api::posts::get,
|
api::posts::get,
|
||||||
api::posts::list,
|
api::posts::list,
|
||||||
api::posts::create,
|
api::posts::create,
|
||||||
|
api::posts::update,
|
||||||
api::posts::delete,
|
api::posts::delete,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -289,7 +290,7 @@ Then try to restart Plume
|
||||||
.manage(Arc::new(workpool))
|
.manage(Arc::new(workpool))
|
||||||
.manage(searcher)
|
.manage(searcher)
|
||||||
.manage(include_i18n!())
|
.manage(include_i18n!())
|
||||||
.attach(
|
/*.attach(
|
||||||
CsrfFairingBuilder::new()
|
CsrfFairingBuilder::new()
|
||||||
.set_default_target(
|
.set_default_target(
|
||||||
"/csrf-violation?target=<uri>".to_owned(),
|
"/csrf-violation?target=<uri>".to_owned(),
|
||||||
|
@ -314,7 +315,7 @@ Then try to restart Plume
|
||||||
])
|
])
|
||||||
.finalize()
|
.finalize()
|
||||||
.expect("main: csrf fairing creation error"),
|
.expect("main: csrf fairing creation error"),
|
||||||
);
|
)*/;
|
||||||
|
|
||||||
#[cfg(feature = "test")]
|
#[cfg(feature = "test")]
|
||||||
let rocket = rocket.mount("/test", routes![test_routes::health,]);
|
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::activity_pub::{broadcast, ActivityStream, ApRequest};
|
||||||
use plume_common::utils;
|
use plume_common::utils;
|
||||||
use plume_models::{
|
use plume_models::{
|
||||||
|
api_tokens::ApiToken,
|
||||||
blogs::*,
|
blogs::*,
|
||||||
comments::{Comment, CommentTree},
|
comments::{Comment, CommentTree},
|
||||||
inbox::inbox,
|
inbox::inbox,
|
||||||
|
@ -156,7 +157,8 @@ pub fn new(blog: String, cl: ContentLen, rockets: PlumeRocket) -> Result<Ructe,
|
||||||
None,
|
None,
|
||||||
ValidationErrors::default(),
|
ValidationErrors::default(),
|
||||||
medias,
|
medias,
|
||||||
cl.0
|
cl.0,
|
||||||
|
ApiToken::web_token(&*conn, user.id)?.value
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -210,7 +212,8 @@ pub fn edit(
|
||||||
Some(post),
|
Some(post),
|
||||||
ValidationErrors::default(),
|
ValidationErrors::default(),
|
||||||
medias,
|
medias,
|
||||||
cl.0
|
cl.0,
|
||||||
|
ApiToken::web_token(&*conn, user.id)?.value
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -366,7 +369,10 @@ pub fn update(
|
||||||
Some(post),
|
Some(post),
|
||||||
errors.clone(),
|
errors.clone(),
|
||||||
medias.clone(),
|
medias.clone(),
|
||||||
cl.0
|
cl.0,
|
||||||
|
ApiToken::web_token(&*conn, user.id)
|
||||||
|
.expect("The default API token cannot be retrieved")
|
||||||
|
.value
|
||||||
))
|
))
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
@ -550,7 +556,8 @@ pub fn create(
|
||||||
None,
|
None,
|
||||||
errors.clone(),
|
errors.clone(),
|
||||||
medias,
|
medias,
|
||||||
cl.0
|
cl.0,
|
||||||
|
ApiToken::web_token(&*conn, user.id)?.value
|
||||||
))
|
))
|
||||||
.into())
|
.into())
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,9 +9,19 @@
|
||||||
@use routes::posts::NewPostForm;
|
@use routes::posts::NewPostForm;
|
||||||
@use routes::*;
|
@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(), {}, {}, {
|
@: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">
|
<div id="plume-editor" style="display: none;" dir="auto">
|
||||||
<header>
|
<header>
|
||||||
<a href="#" id="close-editor">@i18n!(ctx.1, "Classic editor (any changes will be lost)")</a>
|
<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>
|
<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>
|
<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="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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in a new issue