// Interface for Standard Notes (Actions) use crate::{CONFIG, blog}; use crate::router::Router; use crate::utils::*; use js_sys::Date; use serde::{Deserialize, Serialize, Serializer}; use std::vec::Vec; use wasm_bindgen_futures::JsFuture; use web_sys::*; pub fn build_routes(router: &mut Router) { router.add_route("/actions", &get_actions); router.add_route("/post", &create_or_update_post); } macro_rules! verify_secret { ($url:expr, $params:ident) => { let $params = UrlSearchParams::new_with_str(&$url.search()) .map_err(|_| Error::BadRequest("Failed to parse query string".into()))?; if !$params.has("secret") { return Err(Error::BadRequest("Secret needed".into())); } else if $params.get("secret").unwrap() != crate::CONFIG.secret { return Err(Error::Unauthorized("Secret mismatch".into())); } }; } async fn get_actions(_req: Request, url: Url) -> MyResult { verify_secret!(url, params); let origin = url.origin(); let mut actions = vec![]; // Show different options depending on whether the post already exists // Use Post here because PostsList is larger to read into memory // also slower to check one-by-one let post = match params.get("item_uuid") { Some(uuid) => match blog::Post::find_by_uuid(&uuid).await { Ok(post) => Some(post), Err(_) => None }, None => None }; let post_exists = post.is_some(); actions.push(Action { label: if post_exists { "Update".into() } else { "Publish".into() }, url: format!("{}/post?secret={}", origin, CONFIG.secret.clone()), verb: Verb::Post, context: Context::Item, content_types: vec![ContentType::Note], access_type: Some(AccessType::Decrypted) }); let info = ActionsExtension { identifier: CONFIG.plugin_identifier.clone(), name: CONFIG.title.clone(), description: format!("Standard Notes plugin for {}", CONFIG.title.clone()), url: format!("{}/actions?secret={}", origin, CONFIG.secret.clone()), content_type: ContentType::Extension, supported_types: vec![ContentType::Note], actions }; Response::new_with_opt_str_and_init( Some(&serde_json::to_string(&info).internal_err()?), ResponseInit::new() .status(200) .headers(headers!{ "Content-Type" => "application/json" }.add_cors().as_ref()) ).internal_err() } async fn create_or_update_post(req: Request, url: Url) -> MyResult { verify_secret!(url, params); if req.method() != "POST" { return Err(Error::BadRequest("Unsupported method".into())); } // Load the information sent as POST body let data: ActionsPostData = serde_json::from_str( &JsFuture::from(req.text().internal_err()?) .await.internal_err()? .as_string().ok_or(Error::BadRequest("Unable to parse POST body".into()))? ).internal_err()?; if data.items.len() == 0 { return Err(Error::BadRequest("At least one item must be supplied".into())); } // TODO: we should support customizing timestamp and URL from text // and if there are option detected in text, it always overrides // whatever was stored before // If the URL is changed via this way, we should make sure the old // URL actually 301's to the new URL let uuid = data.items[0].uuid.clone(); let text = data.items[0].content.text.clone(); let title = data.items[0].content.title.clone(); let post = match blog::Post::find_by_uuid(&uuid).await { Ok(mut post) => { post.content = text; post.title = title; post }, Err(_) => { blog::Post { url: title_to_url(&uuid, &title), uuid: uuid, title: title, content: text, timestamp: Date::now() as u64 / 1000 // Seconds } } }; // Write the new post to storage // As you may have seen by now, the process is far from atomic // This is fine because we don't expect users to update posts from // multiple endpoints simultaneously all the time blog::PostsList::load().await.add_post(&post.uuid).await?; post.write_to_kv().await?; Response::new_with_opt_str_and_init( None, ResponseInit::new() .status(200) .headers(headers!().add_cors().as_ref()) ).internal_err() } pub enum Verb { Show, Post, Render } impl Serialize for Verb { fn serialize(&self, serializer: S) -> Result where S: Serializer { serializer.serialize_str(match *self { Verb::Show => "show", Verb::Post => "post", Verb::Render => "render" }) } } pub enum Context { Item } impl Serialize for Context { fn serialize(&self, serializer: S) -> Result where S: Serializer { serializer.serialize_str(match *self { Context::Item => "Item" }) } } pub enum ContentType { Note, Extension } impl Serialize for ContentType { fn serialize(&self, serializer: S) -> Result where S: Serializer { serializer.serialize_str(match *self { ContentType::Note => "Note", ContentType::Extension => "Extension" }) } } pub enum AccessType { Decrypted, Encrypted } impl Serialize for AccessType { fn serialize(&self, serializer: S) -> Result where S: Serializer { serializer.serialize_str(match *self { AccessType::Decrypted => "decrypted", AccessType::Encrypted => "encrypted" }) } } #[derive(Serialize)] pub struct Action { label: String, url: String, verb: Verb, context: Context, content_types: Vec, access_type: Option } #[derive(Serialize)] pub struct ActionsExtension { identifier: String, name: String, description: String, url: String, content_type: ContentType, supported_types: Vec, actions: Vec } // Many fields are omitted here since we don't use them for now #[derive(Deserialize)] pub struct ActionsPostItem { uuid: String, content: ActionsPostContent } #[derive(Deserialize)] pub struct ActionsPostContent { title: String, text: String } #[derive(Deserialize)] pub struct ActionsPostData { items: Vec }