From 4995de8372095732375bd8d87e8d35d1b9d568be Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Wed, 8 Apr 2020 16:51:39 +0800 Subject: [PATCH] implement publishing / updating --- src/blog.rs | 55 +++++++++++++++++++++++++++++++ src/sn.rs | 92 ++++++++++++++++++++++++++++++++++++++++++++++++---- src/utils.rs | 20 ++++++++++++ 3 files changed, 161 insertions(+), 6 deletions(-) diff --git a/src/blog.rs b/src/blog.rs index 9b846f3..2287dc1 100644 --- a/src/blog.rs +++ b/src/blog.rs @@ -39,7 +39,62 @@ impl PostsList { // Also consumes self, as this should normally be the last action // in an API call pub async fn add_post(mut self, uuid: &str) -> MyResult<()> { + if self.has_post(uuid) { + return Ok(()); + } + self.0.insert(0, uuid.into()); store::put_obj_pretty("posts_list", self.0).await } +} + +#[derive(Serialize, Deserialize)] +pub struct Post { + // The UUID of the post (a Standard Notes UUID) + pub uuid: String, + // The UNIX timestamp (in seconds) for the post + pub timestamp: u64, + // URL of the post (relative to the root of the site) + pub url: String, + // Title of the post + pub title: String, + // The Markdown content of the post + // We keep the original content here + // so that we could make changes to the Markdown parser + // in the future; we won't be stuck with a parsed version + pub content: String +} + +impl Post { + fn uuid_to_post_key(uuid: &str) -> String { + format!("post_by_uuid_{}", uuid) + } + + fn url_to_mapping_key(url: &str) -> String { + format!("url_mapping_{}", url) + } + + async fn create_url_mapping(url: &str, uuid: &str) -> MyResult<()> { + store::put_str(&Self::url_to_mapping_key(url), uuid).await + } + + // Returns Err(InternalError) if the post is not found + // Note that the existence status of a post here must + // be synchronized with the PostsList; that is, if a + // post is not found in PostsList, it must not be found + // here either; if a post is found in PostsList, then + // this method should not return any error. + pub async fn find_by_uuid(uuid: &str) -> MyResult { + store::get_obj(&Self::uuid_to_post_key(uuid)).await + } + + // Write the Post to KV storage; this can be a new post or + // update to an existing post; either way, the CALLER is + // responsible for making sure PostsList is updated with the + // latest set of posts sorted in order. + // This function will also create a mapping from URL to UUID in the KV + pub async fn write_to_kv(self) -> MyResult<()> { + Self::create_url_mapping(&self.url, &self.uuid).await?; + store::put_obj(&Self::uuid_to_post_key(&self.uuid), self).await + } } \ No newline at end of file diff --git a/src/sn.rs b/src/sn.rs index 5ea3271..643050b 100644 --- a/src/sn.rs +++ b/src/sn.rs @@ -2,12 +2,15 @@ use crate::{CONFIG, blog}; use crate::router::Router; use crate::utils::*; -use serde::{Serialize, Serializer}; +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 { @@ -29,13 +32,16 @@ async fn get_actions(_req: Request, url: Url) -> MyResult { let mut actions = vec![]; // Show different options depending on whether the post already exists - let post_exists = match params.get("item_uuid") { - Some(uuid) => { - let posts = blog::PostsList::load().await; - posts.has_post(&uuid) + // 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 => false + None => None }; + let post_exists = post.is_some(); actions.push(Action { label: if post_exists { "Update".into() } else { "Publish".into() }, @@ -66,6 +72,62 @@ async fn get_actions(_req: Request, url: Url) -> MyResult { ).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, @@ -149,4 +211,22 @@ pub struct ActionsExtension { 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 } \ No newline at end of file diff --git a/src/utils.rs b/src/utils.rs index 28241f0..bd5dc43 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -41,6 +41,26 @@ macro_rules! headers( () => { ::web_sys::Headers::new().unwrap() }; ); +// Remove all non-ascii characters from string +pub fn filter_non_ascii(s: &str) -> String { + s.chars().into_iter() + .filter(|c| c.is_ascii()) + .collect() +} + +// A URL is "/" +// The UUID involvement is to reduce the chance that two +// articles have the same URL by having the same title when +// all non-ASCII characters are removed +pub fn title_to_url(uuid: &str, title: &str) -> String { + let title_part = filter_non_ascii(title) + .split_whitespace() + .collect::>() + .join("-") + .to_lowercase(); + format!("{}/{}", &uuid[0..4], title_part) +} + pub trait HeadersExt { fn add_cors(self) -> Self; }