diff --git a/Cargo.lock b/Cargo.lock index 0e405dc1..463da0f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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)", ] diff --git a/migrations/postgres/2019-08-03-131154_default_app/down.sql b/migrations/postgres/2019-08-03-131154_default_app/down.sql new file mode 100644 index 00000000..d6a03f00 --- /dev/null +++ b/migrations/postgres/2019-08-03-131154_default_app/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DELETE FROM apps WHERE name = 'Plume web interface'; diff --git a/migrations/postgres/2019-08-03-131154_default_app/up.sql b/migrations/postgres/2019-08-03-131154_default_app/up.sql new file mode 100644 index 00000000..f94e4db7 --- /dev/null +++ b/migrations/postgres/2019-08-03-131154_default_app/up.sql @@ -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(()) +--#!} diff --git a/migrations/sqlite/2019-08-03-210305_default_app/down.sql b/migrations/sqlite/2019-08-03-210305_default_app/down.sql new file mode 100644 index 00000000..d6a03f00 --- /dev/null +++ b/migrations/sqlite/2019-08-03-210305_default_app/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DELETE FROM apps WHERE name = 'Plume web interface'; diff --git a/migrations/sqlite/2019-08-03-210305_default_app/up.sql b/migrations/sqlite/2019-08-03-210305_default_app/up.sql new file mode 100644 index 00000000..f94e4db7 --- /dev/null +++ b/migrations/sqlite/2019-08-03-210305_default_app/up.sql @@ -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(()) +--#!} diff --git a/plume-api/src/posts.rs b/plume-api/src/posts.rs index 57b7cf29..edd9da06 100644 --- a/plume-api/src/posts.rs +++ b/plume-api/src/posts.rs @@ -28,4 +28,5 @@ pub struct PostData { pub license: String, pub tags: Vec, pub cover_id: Option, + pub url: String, } diff --git a/plume-front/Cargo.toml b/plume-front/Cargo.toml index 61cb26d0..42d53cf9 100644 --- a/plume-front/Cargo.toml +++ b/plume-front/Cargo.toml @@ -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" diff --git a/plume-front/src/editor.rs b/plume-front/src/editor.rs index 2cbbf30e..dbf90d29 100644 --- a/plume-front/src/editor.rs +++ b/plume-front/src/editor.rs @@ -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 = elt.clone().try_into(); + let select: Result = elt.clone().try_into(); let textarea: Result = elt.try_into(); - inp.map(|i| i.raw_value()) - .unwrap_or_else(|_| textarea.unwrap().value()) -} - -fn set_value>(id: &'static str, val: S) { - let elt = document().get_element_by_id(id).unwrap(); - let inp: Result = elt.clone().try_into(); - let textarea: Result = 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 { - 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::>(); - 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 { - 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 { 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 { _ => 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(); - } -} diff --git a/plume-front/src/main.rs b/plume-front/src/main.rs index a34e2ac1..32067f44 100644 --- a/plume-front/src/main.rs +++ b/plume-front/src/main.rs @@ -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::*, *}; diff --git a/plume-models/src/api_tokens.rs b/plume-models/src/api_tokens.rs index cc876fbc..aaa4da4c 100644 --- a/plume-models/src/api_tokens.rs +++ b/plume-models/src/api_tokens.rs @@ -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 { + 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; diff --git a/plume-models/src/apps.rs b/plume-models/src/apps.rs index 4b24a88e..bb104c5f 100644 --- a/plume-models/src/apps.rs +++ b/plume-models/src/apps.rs @@ -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); } diff --git a/plume-models/src/lib.rs b/plume-models/src/lib.rs index 2c30cb8b..7f20314b 100644 --- a/plume-models/src/lib.rs +++ b/plume-models/src/lib.rs @@ -64,6 +64,7 @@ pub enum Error { Signature, Unauthorized, Url, + Validation(String), Webfinger, Expired, } diff --git a/src/api/mod.rs b/src/api/mod.rs index c320e081..8f37ec54 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -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" })) diff --git a/src/api/posts.rs b/src/api/posts.rs index 2399cf52..3cc34759 100644 --- a/src/api/posts.rs +++ b/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>, 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::>() + .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/", data = "")] +pub fn update( + id: i32, + auth: Authorization, + payload: Json, + rockets: PlumeRocket, +) -> Api { + 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::>() + .into_iter() + .filter_map(|t| Tag::build_activity(t).ok()) + .collect::>(); + post.update_tags(&conn, tags)?; + + let hashtags = hashtags + .into_iter() + .map(|h| h.to_camel_case()) + .collect::>() + .into_iter() + .filter_map(|t| Tag::build_activity(t).ok()) + .collect::>(); + 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/")] pub fn delete(auth: Authorization, rockets: PlumeRocket, id: i32) -> Api<()> { let author = User::get(&*rockets.conn, auth.0.user_id)?; diff --git a/src/main.rs b/src/main.rs index 0bdfaddc..27e11f0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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=".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,]); diff --git a/src/routes/posts.rs b/src/routes/posts.rs index c78fe43c..4872dca0 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -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, errors: ValidationErrors, medias: Vec, content_len: u64) +@(ctx: BaseContext, title: String, blog: Blog, editing: bool, form: &NewPostForm, is_draft: bool, article: Option, errors: ValidationErrors, medias: Vec, content_len: u64, api_token: String) @:base(ctx, title.clone(), {}, {}, { +