From 5610a5f0bf1a9df02bd3d5b55e2cdebef2440360 Mon Sep 17 00:00:00 2001 From: Vika Date: Tue, 24 May 2022 17:18:30 +0300 Subject: flake.nix: reorganize - Kittybox's source code is moved to a subfolder - This improves build caching by Nix since it doesn't take changes to other files into account - Package and test definitions were spun into separate files - This makes my flake.nix much easier to navigate - This also makes it somewhat possible to use without flakes (but it is still not easy, so use flakes!) - Some attributes were moved in compliance with Nix 2.8's changes to flake schema --- kittybox-rs/src/bin/kittybox_bulk_import.rs | 66 ++ kittybox-rs/src/bin/kittybox_database_converter.rs | 106 +++ kittybox-rs/src/bin/pyindieblog_to_kittybox.rs | 68 ++ kittybox-rs/src/database/file/mod.rs | 619 +++++++++++++ kittybox-rs/src/database/memory.rs | 200 +++++ kittybox-rs/src/database/mod.rs | 539 ++++++++++++ kittybox-rs/src/database/redis/edit_post.lua | 93 ++ kittybox-rs/src/database/redis/mod.rs | 392 +++++++++ kittybox-rs/src/frontend/login.rs | 333 +++++++ kittybox-rs/src/frontend/mod.rs | 459 ++++++++++ kittybox-rs/src/frontend/onboarding.css | 33 + kittybox-rs/src/frontend/onboarding.js | 87 ++ kittybox-rs/src/frontend/style.css | 194 +++++ kittybox-rs/src/index.html | 182 ++++ kittybox-rs/src/indieauth.rs | 291 +++++++ kittybox-rs/src/lib.rs | 103 +++ kittybox-rs/src/main.rs | 256 ++++++ kittybox-rs/src/media/mod.rs | 46 + kittybox-rs/src/metrics.rs | 21 + kittybox-rs/src/micropub/get.rs | 82 ++ kittybox-rs/src/micropub/mod.rs | 964 +++++++++++++++++++++ kittybox-rs/src/micropub/post.rs | 944 ++++++++++++++++++++ 22 files changed, 6078 insertions(+) create mode 100644 kittybox-rs/src/bin/kittybox_bulk_import.rs create mode 100644 kittybox-rs/src/bin/kittybox_database_converter.rs create mode 100644 kittybox-rs/src/bin/pyindieblog_to_kittybox.rs create mode 100644 kittybox-rs/src/database/file/mod.rs create mode 100644 kittybox-rs/src/database/memory.rs create mode 100644 kittybox-rs/src/database/mod.rs create mode 100644 kittybox-rs/src/database/redis/edit_post.lua create mode 100644 kittybox-rs/src/database/redis/mod.rs create mode 100644 kittybox-rs/src/frontend/login.rs create mode 100644 kittybox-rs/src/frontend/mod.rs create mode 100644 kittybox-rs/src/frontend/onboarding.css create mode 100644 kittybox-rs/src/frontend/onboarding.js create mode 100644 kittybox-rs/src/frontend/style.css create mode 100644 kittybox-rs/src/index.html create mode 100644 kittybox-rs/src/indieauth.rs create mode 100644 kittybox-rs/src/lib.rs create mode 100644 kittybox-rs/src/main.rs create mode 100644 kittybox-rs/src/media/mod.rs create mode 100644 kittybox-rs/src/metrics.rs create mode 100644 kittybox-rs/src/micropub/get.rs create mode 100644 kittybox-rs/src/micropub/mod.rs create mode 100644 kittybox-rs/src/micropub/post.rs (limited to 'kittybox-rs/src') diff --git a/kittybox-rs/src/bin/kittybox_bulk_import.rs b/kittybox-rs/src/bin/kittybox_bulk_import.rs new file mode 100644 index 0000000..7e1f6af --- /dev/null +++ b/kittybox-rs/src/bin/kittybox_bulk_import.rs @@ -0,0 +1,66 @@ +use anyhow::{anyhow, bail, Context, Result}; +use std::fs::File; +use std::io; + +#[async_std::main] +async fn main() -> Result<()> { + let args = std::env::args().collect::>(); + if args.iter().skip(1).any(|s| s == "--help") { + println!("Usage: {} [file]", args[0]); + println!("\nIf launched with no arguments, reads from stdin."); + println!( + "\nUse KITTYBOX_AUTH_TOKEN environment variable to authorize to the Micropub endpoint." + ); + std::process::exit(0); + } + + let token = std::env::var("KITTYBOX_AUTH_TOKEN") + .map_err(|_| anyhow!("No auth token found! Use KITTYBOX_AUTH_TOKEN env variable."))?; + let data: Vec = (if args.len() == 2 || (args.len() == 3 && args[2] == "-") { + serde_json::from_reader(io::stdin()) + } else if args.len() == 3 { + serde_json::from_reader(File::open(&args[2]).with_context(|| "Error opening input file")?) + } else { + bail!("See `{} --help` for usage.", args[0]); + }) + .with_context(|| "Error while loading the input file")?; + + let url = surf::Url::parse(&args[1])?; + let client = surf::Client::new(); + + let iter = data.into_iter(); + + for post in iter { + println!( + "Processing {}...", + post["properties"]["url"][0] + .as_str() + .or_else(|| post["properties"]["published"][0] + .as_str() + .or_else(|| post["properties"]["name"][0] + .as_str() + .or(Some("")))) + .unwrap() + ); + match client + .post(&url) + .body(surf::http::Body::from_string(serde_json::to_string(&post)?)) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", &token)) + .send() + .await + { + Ok(mut response) => { + if response.status() == 201 || response.status() == 202 { + println!("Posted at {}", response.header("location").unwrap().last()); + } else { + println!("Error: {:?}", response.body_string().await); + } + } + Err(err) => { + println!("{}", err); + } + } + } + Ok(()) +} diff --git a/kittybox-rs/src/bin/kittybox_database_converter.rs b/kittybox-rs/src/bin/kittybox_database_converter.rs new file mode 100644 index 0000000..bc355c9 --- /dev/null +++ b/kittybox-rs/src/bin/kittybox_database_converter.rs @@ -0,0 +1,106 @@ +use anyhow::{anyhow, Context}; +use kittybox::database::FileStorage; +use kittybox::database::Storage; +use redis::{self, AsyncCommands}; +use std::collections::HashMap; + +/// Convert from a Redis storage to a new storage new_storage. +async fn convert_from_redis(from: String, new_storage: S) -> anyhow::Result<()> { + let db = redis::Client::open(from).context("Failed to open the Redis connection")?; + + let mut conn = db + .get_async_std_connection() + .await + .context("Failed to connect to Redis")?; + + // Rebinding to convince the borrow checker we're not smuggling stuff outta scope + let storage = &new_storage; + + let mut stream = conn.hscan::<_, String>("posts").await?; + + while let Some(key) = stream.next_item().await { + let value = serde_json::from_str::( + &stream + .next_item() + .await + .ok_or(anyhow!("Failed to find a corresponding value for the key"))?, + )?; + + println!("{}, {:?}", key, value); + + if value["see_other"].is_string() { + continue; + } + + let user = &(url::Url::parse(value["properties"]["uid"][0].as_str().unwrap()) + .unwrap() + .origin() + .ascii_serialization() + .clone() + + "/"); + if let Err(err) = storage.clone().put_post(&value, user).await { + eprintln!("Error saving post: {}", err); + } + } + + let mut stream: redis::AsyncIter = conn.scan_match("settings_*").await?; + while let Some(key) = stream.next_item().await { + let mut conn = db + .get_async_std_connection() + .await + .context("Failed to connect to Redis")?; + let user = key.strip_prefix("settings_").unwrap(); + match conn + .hgetall::<&str, HashMap>(&key) + .await + .context(format!("Failed getting settings from key {}", key)) + { + Ok(settings) => { + for (k, v) in settings.iter() { + if let Err(e) = storage + .set_setting(k, user, v) + .await + .with_context(|| format!("Failed setting {} for {}", k, user)) + { + eprintln!("{}", e); + } + } + } + Err(e) => { + eprintln!("{}", e); + } + } + } + + Ok(()) +} + +#[async_std::main] +async fn main() -> anyhow::Result<()> { + let mut args = std::env::args(); + args.next(); // skip argv[0] + let old_uri = args + .next() + .ok_or_else(|| anyhow!("No import source is provided."))?; + let new_uri = args + .next() + .ok_or_else(|| anyhow!("No import destination is provided."))?; + + let storage = if new_uri.starts_with("file:") { + let folder = new_uri.strip_prefix("file://").unwrap(); + let path = std::path::PathBuf::from(folder); + Box::new( + FileStorage::new(path) + .await + .context("Failed to construct the file storage")?, + ) + } else { + anyhow::bail!("Cannot construct the storage abstraction for destination storage. Check the storage type?"); + }; + + if old_uri.starts_with("redis") { + convert_from_redis(old_uri, *storage).await? + } + + Ok(()) +} diff --git a/kittybox-rs/src/bin/pyindieblog_to_kittybox.rs b/kittybox-rs/src/bin/pyindieblog_to_kittybox.rs new file mode 100644 index 0000000..38590c3 --- /dev/null +++ b/kittybox-rs/src/bin/pyindieblog_to_kittybox.rs @@ -0,0 +1,68 @@ +use anyhow::{anyhow, Context, Result}; + +use redis::AsyncCommands; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs::File; + +#[derive(Default, Serialize, Deserialize)] +struct PyindieblogData { + posts: Vec, + cards: Vec, +} + +#[async_std::main] +async fn main() -> Result<()> { + let mut args = std::env::args(); + args.next(); // skip argv[0] which is the name + let redis_uri = args + .next() + .ok_or_else(|| anyhow!("No Redis URI provided"))?; + let client = redis::Client::open(redis_uri.as_str()) + .with_context(|| format!("Failed to construct Redis client on {}", redis_uri))?; + + let filename = args + .next() + .ok_or_else(|| anyhow!("No filename provided for export"))?; + + let mut data: Vec; + + let file = File::create(filename)?; + + let mut conn = client + .get_async_std_connection() + .await + .with_context(|| "Failed to connect to the Redis server")?; + + data = conn + .hgetall::<&str, HashMap>("posts") + .await? + .values() + .map(|s| { + serde_json::from_str::(s) + .with_context(|| format!("Failed to parse the following entry: {:?}", s)) + }) + .collect::, anyhow::Error>>() + .with_context(|| "Failed to export h-entries from pyindieblog")?; + data.extend( + conn.hgetall::<&str, HashMap>("hcards") + .await? + .values() + .map(|s| { + serde_json::from_str::(s) + .with_context(|| format!("Failed to parse the following card: {:?}", s)) + }) + .collect::, anyhow::Error>>() + .with_context(|| "Failed to export h-cards from pyindieblog")?, + ); + + data.sort_by_key(|v| { + v["properties"]["published"][0] + .as_str() + .map(|s| s.to_string()) + }); + + serde_json::to_writer(file, &data)?; + + Ok(()) +} diff --git a/kittybox-rs/src/database/file/mod.rs b/kittybox-rs/src/database/file/mod.rs new file mode 100644 index 0000000..1e7aa96 --- /dev/null +++ b/kittybox-rs/src/database/file/mod.rs @@ -0,0 +1,619 @@ +//#![warn(clippy::unwrap_used)] +use crate::database::{filter_post, ErrorKind, Result, Storage, StorageError, Settings}; +use std::io::ErrorKind as IOErrorKind; +use tokio::fs::{File, OpenOptions}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::task::spawn_blocking; +use async_trait::async_trait; +use futures::{stream, StreamExt, TryStreamExt}; +use log::debug; +use serde_json::json; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +impl From for StorageError { + fn from(source: std::io::Error) -> Self { + Self::with_source( + match source.kind() { + IOErrorKind::NotFound => ErrorKind::NotFound, + IOErrorKind::AlreadyExists => ErrorKind::Conflict, + _ => ErrorKind::Backend, + }, + "file I/O error", + Box::new(source), + ) + } +} + +impl From for StorageError { + fn from(source: tokio::time::error::Elapsed) -> Self { + Self::with_source( + ErrorKind::Backend, + "timeout on I/O operation", + Box::new(source) + ) + } +} + +// Copied from https://stackoverflow.com/questions/39340924 +// This routine is adapted from the *old* Path's `path_relative_from` +// function, which works differently from the new `relative_from` function. +// In particular, this handles the case on unix where both paths are +// absolute but with only the root as the common directory. +fn path_relative_from(path: &Path, base: &Path) -> Option { + use std::path::Component; + + if path.is_absolute() != base.is_absolute() { + if path.is_absolute() { + Some(PathBuf::from(path)) + } else { + None + } + } else { + let mut ita = path.components(); + let mut itb = base.components(); + let mut comps: Vec = vec![]; + loop { + match (ita.next(), itb.next()) { + (None, None) => break, + (Some(a), None) => { + comps.push(a); + comps.extend(ita.by_ref()); + break; + } + (None, _) => comps.push(Component::ParentDir), + (Some(a), Some(b)) if comps.is_empty() && a == b => (), + (Some(a), Some(b)) if b == Component::CurDir => comps.push(a), + (Some(_), Some(b)) if b == Component::ParentDir => return None, + (Some(a), Some(_)) => { + comps.push(Component::ParentDir); + for _ in itb { + comps.push(Component::ParentDir); + } + comps.push(a); + comps.extend(ita.by_ref()); + break; + } + } + } + Some(comps.iter().map(|c| c.as_os_str()).collect()) + } +} + +#[allow(clippy::unwrap_used, clippy::expect_used)] +#[cfg(test)] +mod tests { + #[test] + fn test_relative_path_resolving() { + let path1 = std::path::Path::new("/home/vika/Projects/kittybox"); + let path2 = std::path::Path::new("/home/vika/Projects/nixpkgs"); + let relative_path = super::path_relative_from(path2, path1).unwrap(); + + assert_eq!(relative_path, std::path::Path::new("../nixpkgs")) + } +} + +// TODO: Check that the path ACTUALLY IS INSIDE THE ROOT FOLDER +// This could be checked by completely resolving the path +// and checking if it has a common prefix +fn url_to_path(root: &Path, url: &str) -> PathBuf { + let path = url_to_relative_path(url).to_logical_path(root); + if !path.starts_with(root) { + // TODO: handle more gracefully + panic!("Security error: {:?} is not a prefix of {:?}", path, root) + } else { + path + } +} + +fn url_to_relative_path(url: &str) -> relative_path::RelativePathBuf { + let url = warp::http::Uri::try_from(url).expect("Couldn't parse a URL"); + let mut path = relative_path::RelativePathBuf::new(); + path.push(url.authority().unwrap().to_string() + url.path() + ".json"); + + path +} + +fn modify_post(post: &serde_json::Value, update: &serde_json::Value) -> Result { + let mut add_keys: HashMap> = HashMap::new(); + let mut remove_keys: Vec = vec![]; + let mut remove_values: HashMap> = HashMap::new(); + let mut post = post.clone(); + + if let Some(delete) = update["delete"].as_array() { + remove_keys.extend( + delete + .iter() + .filter_map(|v| v.as_str()) + .map(|v| v.to_string()), + ); + } else if let Some(delete) = update["delete"].as_object() { + for (k, v) in delete { + if let Some(v) = v.as_array() { + remove_values + .entry(k.to_string()) + .or_default() + .extend(v.clone()); + } else { + return Err(StorageError::new( + ErrorKind::BadRequest, + "Malformed update object", + )); + } + } + } + if let Some(add) = update["add"].as_object() { + for (k, v) in add { + if let Some(v) = v.as_array() { + add_keys.insert(k.to_string(), v.clone()); + } else { + return Err(StorageError::new( + ErrorKind::BadRequest, + "Malformed update object", + )); + } + } + } + if let Some(replace) = update["replace"].as_object() { + for (k, v) in replace { + remove_keys.push(k.to_string()); + if let Some(v) = v.as_array() { + add_keys.insert(k.to_string(), v.clone()); + } else { + return Err(StorageError::new(ErrorKind::BadRequest, "Malformed update object")); + } + } + } + + if let Some(props) = post["properties"].as_object_mut() { + for k in remove_keys { + props.remove(&k); + } + } + for (k, v) in remove_values { + let k = &k; + let props = if k == "children" { + &mut post + } else { + &mut post["properties"] + }; + v.iter().for_each(|v| { + if let Some(vec) = props[k].as_array_mut() { + if let Some(index) = vec.iter().position(|w| w == v) { + vec.remove(index); + } + } + }); + } + for (k, v) in add_keys { + let props = if k == "children" { + &mut post + } else { + &mut post["properties"] + }; + let k = &k; + if let Some(prop) = props[k].as_array_mut() { + if k == "children" { + v.into_iter() + .rev() + .for_each(|v| prop.insert(0, v)); + } else { + prop.extend(v.into_iter()); + } + } else { + post["properties"][k] = serde_json::Value::Array(v) + } + } + Ok(post) +} + +#[derive(Clone, Debug)] +/// A backend using a folder with JSON files as a backing store. +/// Uses symbolic links to represent a many-to-one mapping of URLs to a post. +pub struct FileStorage { + root_dir: PathBuf, +} + +impl FileStorage { + /// Create a new storage wrapping a folder specified by root_dir. + pub async fn new(root_dir: PathBuf) -> Result { + // TODO check if the dir is writable + Ok(Self { root_dir }) + } +} + +async fn hydrate_author( + feed: &mut serde_json::Value, + user: &'_ Option, + storage: &S, +) { + let url = feed["properties"]["uid"][0] + .as_str() + .expect("MF2 value should have a UID set! Check if you used normalize_mf2 before recording the post!"); + if let Some(author) = feed["properties"]["author"].as_array().cloned() { + if !feed["type"] + .as_array() + .expect("MF2 value should have a type set!") + .iter() + .any(|i| i == "h-card") + { + let author_list: Vec = stream::iter(author.iter()) + .then(|i| async move { + if let Some(i) = i.as_str() { + match storage.get_post(i).await { + Ok(post) => match post { + Some(post) => match filter_post(post, user) { + Some(author) => author, + None => json!(i), + }, + None => json!(i), + }, + Err(e) => { + log::error!("Error while hydrating post {}: {}", url, e); + json!(i) + } + } + } else { + i.clone() + } + }) + .collect::>() + .await; + if let Some(props) = feed["properties"].as_object_mut() { + props["author"] = json!(author_list); + } else { + feed["properties"] = json!({"author": author_list}); + } + } + } +} + +#[async_trait] +impl Storage for FileStorage { + async fn post_exists(&self, url: &str) -> Result { + let path = url_to_path(&self.root_dir, url); + debug!("Checking if {:?} exists...", path); + /*let result = match tokio::fs::metadata(path).await { + Ok(metadata) => { + Ok(true) + }, + Err(err) => { + if err.kind() == IOErrorKind::NotFound { + Ok(false) + } else { + Err(err.into()) + } + } + };*/ + #[allow(clippy::unwrap_used)] // JoinHandle captures panics, this closure shouldn't panic + Ok(spawn_blocking(move || path.is_file()).await.unwrap()) + } + + async fn get_post(&self, url: &str) -> Result> { + let path = url_to_path(&self.root_dir, url); + // TODO: check that the path actually belongs to the dir of user who requested it + // it's not like you CAN access someone else's private posts with it + // so it's not exactly a security issue, but it's still not good + debug!("Opening {:?}", path); + + match File::open(&path).await { + Ok(mut file) => { + let mut content = String::new(); + // Typechecks because OS magic acts on references + // to FDs as if they were behind a mutex + AsyncReadExt::read_to_string(&mut file, &mut content).await?; + debug!("Read {} bytes successfully from {:?}", content.as_bytes().len(), &path); + Ok(Some(serde_json::from_str(&content)?)) + }, + Err(err) => { + if err.kind() == IOErrorKind::NotFound { + Ok(None) + } else { + Err(err.into()) + } + } + } + } + + async fn put_post(&self, post: &'_ serde_json::Value, user: &'_ str) -> Result<()> { + let key = post["properties"]["uid"][0] + .as_str() + .expect("Tried to save a post without UID"); + let path = url_to_path(&self.root_dir, key); + let tempfile = (&path).with_extension("tmp"); + debug!("Creating {:?}", path); + + let parent = path.parent().expect("Parent for this directory should always exist").to_owned(); + if !parent.is_dir() { + tokio::fs::create_dir_all(parent).await?; + } + + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tempfile).await?; + + file.write_all(post.to_string().as_bytes()).await?; + file.flush().await?; + drop(file); + tokio::fs::rename(&tempfile, &path).await?; + + if let Some(urls) = post["properties"]["url"].as_array() { + for url in urls + .iter() + .map(|i| i.as_str().unwrap()) + { + if url != key && url.starts_with(user) { + let link = url_to_path(&self.root_dir, url); + debug!("Creating a symlink at {:?}", link); + let orig = path.clone(); + // We're supposed to have a parent here. + let basedir = link.parent().ok_or_else(|| { + StorageError::new( + ErrorKind::Backend, + "Failed to calculate parent directory when creating a symlink", + ) + })?; + let relative = path_relative_from(&orig, basedir).unwrap(); + println!("{:?} - {:?} = {:?}", &orig, &basedir, &relative); + tokio::fs::symlink(relative, link).await?; + } + } + } + + if post["type"] + .as_array() + .unwrap() + .iter() + .any(|s| s.as_str() == Some("h-feed")) + { + println!("Adding to channel list..."); + // Add the h-feed to the channel list + let mut path = relative_path::RelativePathBuf::new(); + path.push(warp::http::Uri::try_from(user.to_string()).unwrap().authority().unwrap().to_string()); + path.push("channels"); + + let path = path.to_path(&self.root_dir); + let tempfilename = (&path).with_extension("tmp"); + let channel_name = post["properties"]["name"][0] + .as_str() + .map(|s| s.to_string()) + .unwrap_or_else(String::default); + let key = key.to_string(); + + let mut tempfile = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tempfilename).await?; + let mut file = OpenOptions::new() + .read(true) + .write(true) + .truncate(false) + .create(true) + .open(&path).await?; + + let mut content = String::new(); + file.read_to_string(&mut content).await?; + drop(file); + let mut channels: Vec = if !content.is_empty() { + serde_json::from_str(&content)? + } else { + Vec::default() + }; + + channels.push(super::MicropubChannel { + uid: key.to_string(), + name: channel_name, + }); + + tempfile.write_all(serde_json::to_string(&channels)?.as_bytes()).await?; + tempfile.flush().await?; + drop(tempfile); + tokio::fs::rename(tempfilename, path).await?; + } + Ok(()) + } + + async fn update_post(&self, url: &'_ str, update: serde_json::Value) -> Result<()> { + let path = url_to_path(&self.root_dir, url); + let tempfilename = path.with_extension("tmp"); + #[allow(unused_variables)] + let (old_json, new_json) = { + let mut temp = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tempfilename) + .await?; + let mut file = OpenOptions::new() + .read(true) + .open(&path) + .await?; + + let mut content = String::new(); + file.read_to_string(&mut content).await?; + let json: serde_json::Value = serde_json::from_str(&content)?; + drop(file); + // Apply the editing algorithms + let new_json = modify_post(&json, &update)?; + + temp.write_all(new_json.to_string().as_bytes()).await?; + temp.flush().await?; + drop(temp); + tokio::fs::rename(tempfilename, path).await?; + + (json, new_json) + }; + // TODO check if URLs changed between old and new JSON + Ok(()) + } + + async fn get_channels(&self, user: &'_ str) -> Result> { + let mut path = relative_path::RelativePathBuf::new(); + path.push(warp::http::Uri::try_from(user.to_string()).unwrap().authority().unwrap().to_string()); + path.push("channels"); + + let path = path.to_path(&self.root_dir); + match File::open(&path).await { + Ok(mut f) => { + let mut content = String::new(); + f.read_to_string(&mut content).await?; + // This should not happen, but if it does, handle it gracefully + if content.is_empty() { + return Ok(vec![]); + } + let channels: Vec = serde_json::from_str(&content)?; + Ok(channels) + } + Err(e) => { + if e.kind() == IOErrorKind::NotFound { + Ok(vec![]) + } else { + Err(e.into()) + } + } + } + } + + async fn read_feed_with_limit( + &self, + url: &'_ str, + after: &'_ Option, + limit: usize, + user: &'_ Option, + ) -> Result> { + if let Some(feed) = self.get_post(url).await? { + if let Some(mut feed) = filter_post(feed, user) { + if feed["children"].is_array() { + // This code contains several clones. It looks + // like the borrow checker thinks it is preventing + // me from doing something incredibly stupid. The + // borrow checker may or may not be right. + let children = feed["children"].as_array().unwrap().clone(); + let mut posts_iter = children + .into_iter() + .map(|s: serde_json::Value| s.as_str().unwrap().to_string()); + // Note: we can't actually use skip_while here because we end up emitting `after`. + // This imperative snippet consumes after instead of emitting it, allowing the + // stream of posts to return only those items that truly come *after* + if let Some(after) = after { + for s in posts_iter.by_ref() { + if &s == after { + break + } + } + }; + let posts = stream::iter(posts_iter) + .map(|url: String| async move { self.get_post(&url).await }) + .buffered(std::cmp::min(3, limit)) + // Hack to unwrap the Option and sieve out broken links + // Broken links return None, and Stream::filter_map skips Nones. + .try_filter_map(|post: Option| async move { Ok(post) }) + .try_filter_map(|post| async move { Ok(filter_post(post, user)) }) + .and_then(|mut post| async move { + hydrate_author(&mut post, user, self).await; + Ok(post) + }) + .take(limit); + + match posts.try_collect::>().await { + Ok(posts) => feed["children"] = serde_json::json!(posts), + Err(err) => { + return Err(StorageError::with_source( + ErrorKind::Other, + "Feed assembly error", + Box::new(err), + )); + } + } + } + hydrate_author(&mut feed, user, self).await; + Ok(Some(feed)) + } else { + Err(StorageError::new( + ErrorKind::PermissionDenied, + "specified user cannot access this post", + )) + } + } else { + Ok(None) + } + } + + async fn delete_post(&self, url: &'_ str) -> Result<()> { + let path = url_to_path(&self.root_dir, url); + if let Err(e) = tokio::fs::remove_file(path).await { + Err(e.into()) + } else { + // TODO check for dangling references in the channel list + Ok(()) + } + } + + async fn get_setting(&self, setting: Settings, user: &'_ str) -> Result { + log::debug!("User for getting settings: {}", user); + let url = warp::http::Uri::try_from(user).expect("Couldn't parse a URL"); + let mut path = relative_path::RelativePathBuf::new(); + path.push(url.authority().unwrap().to_string()); + path.push("settings"); + + let path = path.to_path(&self.root_dir); + log::debug!("Getting settings from {:?}", &path); + let setting = setting.to_string(); + let mut file = File::open(path).await?; + let mut content = String::new(); + file.read_to_string(&mut content).await?; + + let settings: HashMap = serde_json::from_str(&content)?; + // XXX consider returning string slices instead of cloning a string every time + // it might come with a performance hit and/or memory usage inflation + settings + .get(&setting) + .cloned() + .ok_or_else(|| StorageError::new(ErrorKind::Backend, "Setting not set")) + } + + async fn set_setting(&self, setting: Settings, user: &'_ str, value: &'_ str) -> Result<()> { + let url = warp::http::Uri::try_from(user).expect("Couldn't parse a URL"); + let mut path = relative_path::RelativePathBuf::new(); + path.push(url.authority().unwrap().to_string()); + path.push("settings"); + + let path = path.to_path(&self.root_dir); + let temppath = path.with_extension("tmp"); + + let parent = path.parent().unwrap().to_owned(); + if !spawn_blocking(move || parent.is_dir()).await.unwrap() { + tokio::fs::create_dir_all(path.parent().unwrap()).await?; + } + + let (setting, value) = (setting.to_string(), value.to_string()); + + let mut tempfile = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temppath) + .await?; + + let mut settings: HashMap = match File::open(&path).await { + Ok(mut f) => { + let mut content = String::new(); + f.read_to_string(&mut content).await?; + if content.is_empty() { + HashMap::default() + } else { + serde_json::from_str(&content)? + } + } + Err(err) => if err.kind() == IOErrorKind::NotFound { + HashMap::default() + } else { + return Err(err.into()) + } + }; + settings.insert(setting, value); + tempfile.write_all(serde_json::to_string(&settings)?.as_bytes()).await?; + drop(tempfile); + tokio::fs::rename(temppath, path).await?; + Ok(()) + } +} diff --git a/kittybox-rs/src/database/memory.rs b/kittybox-rs/src/database/memory.rs new file mode 100644 index 0000000..786466c --- /dev/null +++ b/kittybox-rs/src/database/memory.rs @@ -0,0 +1,200 @@ +#![allow(clippy::todo)] +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use futures_util::FutureExt; +use serde_json::json; + +use crate::database::{Storage, Result, StorageError, ErrorKind, MicropubChannel, Settings}; + +#[derive(Clone, Debug)] +pub struct MemoryStorage { + pub mapping: Arc>>, + pub channels: Arc>>> +} + +#[async_trait] +impl Storage for MemoryStorage { + async fn post_exists(&self, url: &str) -> Result { + return Ok(self.mapping.read().await.contains_key(url)) + } + + async fn get_post(&self, url: &str) ->Result> { + let mapping = self.mapping.read().await; + match mapping.get(url) { + Some(val) => { + if let Some(new_url) = val["see_other"].as_str() { + match mapping.get(new_url) { + Some(val) => Ok(Some(val.clone())), + None => { + drop(mapping); + self.mapping.write().await.remove(url); + Ok(None) + } + } + } else { + Ok(Some(val.clone())) + } + }, + _ => Ok(None) + } + } + + async fn put_post(&self, post: &'_ serde_json::Value, _user: &'_ str) -> Result<()> { + let mapping = &mut self.mapping.write().await; + let key: &str = match post["properties"]["uid"][0].as_str() { + Some(uid) => uid, + None => return Err(StorageError::new(ErrorKind::Other, "post doesn't have a UID")) + }; + mapping.insert(key.to_string(), post.clone()); + if post["properties"]["url"].is_array() { + for url in post["properties"]["url"].as_array().unwrap().iter().map(|i| i.as_str().unwrap().to_string()) { + if url != key { + mapping.insert(url, json!({"see_other": key})); + } + } + } + if post["type"].as_array().unwrap().iter().any(|i| i == "h-feed") { + // This is a feed. Add it to the channels array if it's not already there. + println!("{:#}", post); + self.channels.write().await.entry(post["properties"]["author"][0].as_str().unwrap().to_string()).or_insert_with(Vec::new).push(key.to_string()) + } + Ok(()) + } + + async fn update_post(&self, url: &'_ str, update: serde_json::Value) -> Result<()> { + let mut add_keys: HashMap = HashMap::new(); + let mut remove_keys: Vec = vec![]; + let mut remove_values: HashMap> = HashMap::new(); + + if let Some(delete) = update["delete"].as_array() { + remove_keys.extend(delete.iter().filter_map(|v| v.as_str()).map(|v| v.to_string())); + } else if let Some(delete) = update["delete"].as_object() { + for (k, v) in delete { + if let Some(v) = v.as_array() { + remove_values.entry(k.to_string()).or_default().extend(v.clone()); + } else { + return Err(StorageError::new(ErrorKind::BadRequest, "Malformed update object")); + } + } + } + if let Some(add) = update["add"].as_object() { + for (k, v) in add { + if v.is_array() { + add_keys.insert(k.to_string(), v.clone()); + } else { + return Err(StorageError::new(ErrorKind::BadRequest, "Malformed update object")); + } + } + } + if let Some(replace) = update["replace"].as_object() { + for (k, v) in replace { + remove_keys.push(k.to_string()); + add_keys.insert(k.to_string(), v.clone()); + } + } + let mut mapping = self.mapping.write().await; + if let Some(mut post) = mapping.get(url) { + if let Some(url) = post["see_other"].as_str() { + if let Some(new_post) = mapping.get(url) { + post = new_post + } else { + return Err(StorageError::new(ErrorKind::NotFound, "The post you have requested is not found in the database.")); + } + } + let mut post = post.clone(); + for k in remove_keys { + post["properties"].as_object_mut().unwrap().remove(&k); + } + for (k, v) in remove_values { + let k = &k; + let props = if k == "children" { + &mut post + } else { + &mut post["properties"] + }; + v.iter().for_each(|v| { + if let Some(vec) = props[k].as_array_mut() { + if let Some(index) = vec.iter().position(|w| w == v) { + vec.remove(index); + } + } + }); + } + for (k, v) in add_keys { + let props = if k == "children" { + &mut post + } else { + &mut post["properties"] + }; + let k = &k; + if let Some(prop) = props[k].as_array_mut() { + if k == "children" { + v.as_array().unwrap().iter().cloned().rev().for_each(|v| prop.insert(0, v)); + } else { + prop.extend(v.as_array().unwrap().iter().cloned()); + } + } else { + post["properties"][k] = v + } + } + mapping.insert(post["properties"]["uid"][0].as_str().unwrap().to_string(), post); + } else { + return Err(StorageError::new(ErrorKind::NotFound, "The designated post wasn't found in the database.")); + } + Ok(()) + } + + async fn get_channels(&self, user: &'_ str) -> Result> { + match self.channels.read().await.get(user) { + Some(channels) => Ok(futures_util::future::join_all(channels.iter() + .map(|channel| self.get_post(channel) + .map(|result| result.unwrap()) + .map(|post: Option| { + post.map(|post| MicropubChannel { + uid: post["properties"]["uid"][0].as_str().unwrap().to_string(), + name: post["properties"]["name"][0].as_str().unwrap().to_string() + }) + }) + ).collect::>()).await.into_iter().flatten().collect::>()), + None => Ok(vec![]) + } + + } + + #[allow(unused_variables)] + async fn read_feed_with_limit(&self, url: &'_ str, after: &'_ Option, limit: usize, user: &'_ Option) -> Result> { + todo!() + } + + async fn delete_post(&self, url: &'_ str) -> Result<()> { + self.mapping.write().await.remove(url); + Ok(()) + } + + #[allow(unused_variables)] + async fn get_setting(&self, setting: Settings, user: &'_ str) -> Result { + todo!() + } + + #[allow(unused_variables)] + async fn set_setting(&self, setting: Settings, user: &'_ str, value: &'_ str) -> Result<()> { + todo!() + } +} + +impl Default for MemoryStorage { + fn default() -> Self { + Self::new() + } +} + +impl MemoryStorage { + pub fn new() -> Self { + Self { + mapping: Arc::new(RwLock::new(HashMap::new())), + channels: Arc::new(RwLock::new(HashMap::new())) + } + } +} diff --git a/kittybox-rs/src/database/mod.rs b/kittybox-rs/src/database/mod.rs new file mode 100644 index 0000000..6bf5409 --- /dev/null +++ b/kittybox-rs/src/database/mod.rs @@ -0,0 +1,539 @@ +#![warn(missing_docs)] +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +mod file; +pub use crate::database::file::FileStorage; +#[cfg(test)] +mod memory; +#[cfg(test)] +pub use crate::database::memory::MemoryStorage; + +pub use kittybox_util::MicropubChannel; + +/// Enum representing different errors that might occur during the database query. +#[derive(Debug, Clone, Copy)] +pub enum ErrorKind { + /// Backend error (e.g. database connection error) + Backend, + /// Error due to insufficient contextual permissions for the query + PermissionDenied, + /// Error due to the database being unable to parse JSON returned from the backing storage. + /// Usually indicative of someone fiddling with the database manually instead of using proper tools. + JsonParsing, + /// - ErrorKind::NotFound - equivalent to a 404 error. Note, some requests return an Option, + /// in which case None is also equivalent to a 404. + NotFound, + /// The user's query or request to the database was malformed. Used whenever the database processes + /// the user's query directly, such as when editing posts inside of the database (e.g. Redis backend) + BadRequest, + /// the user's query collided with an in-flight request and needs to be retried + Conflict, + /// - ErrorKind::Other - when something so weird happens that it becomes undescribable. + Other, +} + +/// Enum representing settings that might be stored in the site's database. +#[derive(Deserialize, Serialize, Debug, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub enum Settings { + /// The name of the website -- displayed in the header and the browser titlebar. + SiteName, +} + +impl std::string::ToString for Settings { + fn to_string(&self) -> String { + serde_variant::to_variant_name(self).unwrap().to_string() + } +} + +/// Error signalled from the database. +#[derive(Debug)] +pub struct StorageError { + msg: String, + source: Option>, + kind: ErrorKind, +} + +impl warp::reject::Reject for StorageError {} + +impl std::error::Error for StorageError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source + .as_ref() + .map(|e| e.as_ref() as &dyn std::error::Error) + } +} +impl From for StorageError { + fn from(err: serde_json::Error) -> Self { + Self { + msg: format!("{}", err), + source: Some(Box::new(err)), + kind: ErrorKind::JsonParsing, + } + } +} +impl std::fmt::Display for StorageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match match self.kind { + ErrorKind::Backend => write!(f, "backend error: "), + ErrorKind::JsonParsing => write!(f, "error while parsing JSON: "), + ErrorKind::PermissionDenied => write!(f, "permission denied: "), + ErrorKind::NotFound => write!(f, "not found: "), + ErrorKind::BadRequest => write!(f, "bad request: "), + ErrorKind::Conflict => write!(f, "conflict with an in-flight request or existing data: "), + ErrorKind::Other => write!(f, "generic storage layer error: "), + } { + Ok(_) => write!(f, "{}", self.msg), + Err(err) => Err(err), + } + } +} +impl serde::Serialize for StorageError { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + serializer.serialize_str(&self.to_string()) + } +} +impl StorageError { + /// Create a new StorageError of an ErrorKind with a message. + fn new(kind: ErrorKind, msg: &str) -> Self { + Self { + msg: msg.to_string(), + source: None, + kind, + } + } + /// Create a StorageError using another arbitrary Error as a source. + fn with_source( + kind: ErrorKind, + msg: &str, + source: Box, + ) -> Self { + Self { + msg: msg.to_string(), + source: Some(source), + kind, + } + } + /// Get the kind of an error. + pub fn kind(&self) -> ErrorKind { + self.kind + } + /// Get the message as a string slice. + pub fn msg(&self) -> &str { + &self.msg + } +} + +/// A special Result type for the Micropub backing storage. +pub type Result = std::result::Result; + +/// Filter the post according to the value of `user`. +/// +/// Anonymous users cannot view private posts and protected locations; +/// Logged-in users can only view private posts targeted at them; +/// Logged-in users can't view private location data +pub fn filter_post( + mut post: serde_json::Value, + user: &'_ Option, +) -> Option { + if post["properties"]["deleted"][0].is_string() { + return Some(serde_json::json!({ + "type": post["type"], + "properties": { + "deleted": post["properties"]["deleted"] + } + })); + } + let empty_vec: Vec = vec![]; + let author = post["properties"]["author"] + .as_array() + .unwrap_or(&empty_vec) + .iter() + .map(|i| i.as_str().unwrap().to_string()); + let visibility = post["properties"]["visibility"][0] + .as_str() + .unwrap_or("public"); + let mut audience = author.chain( + post["properties"]["audience"] + .as_array() + .unwrap_or(&empty_vec) + .iter() + .map(|i| i.as_str().unwrap().to_string()), + ); + if (visibility == "private" && !audience.any(|i| Some(i) == *user)) + || (visibility == "protected" && user.is_none()) + { + return None; + } + if post["properties"]["location"].is_array() { + let location_visibility = post["properties"]["location-visibility"][0] + .as_str() + .unwrap_or("private"); + let mut author = post["properties"]["author"] + .as_array() + .unwrap_or(&empty_vec) + .iter() + .map(|i| i.as_str().unwrap().to_string()); + if (location_visibility == "private" && !author.any(|i| Some(i) == *user)) + || (location_visibility == "protected" && user.is_none()) + { + post["properties"] + .as_object_mut() + .unwrap() + .remove("location"); + } + } + Some(post) +} + +/// A storage backend for the Micropub server. +/// +/// Implementations should note that all methods listed on this trait MUST be fully atomic +/// or lock the database so that write conflicts or reading half-written data should not occur. +#[async_trait] +pub trait Storage: std::fmt::Debug + Clone + Send + Sync { + /// Check if a post exists in the database. + async fn post_exists(&self, url: &str) -> Result; + + /// Load a post from the database in MF2-JSON format, deserialized from JSON. + async fn get_post(&self, url: &str) -> Result>; + + /// Save a post to the database as an MF2-JSON structure. + /// + /// Note that the `post` object MUST have `post["properties"]["uid"][0]` defined. + async fn put_post(&self, post: &'_ serde_json::Value, user: &'_ str) -> Result<()>; + + /// Modify a post using an update object as defined in the Micropub spec. + /// + /// Note to implementors: the update operation MUST be atomic and + /// SHOULD lock the database to prevent two clients overwriting + /// each other's changes or simply corrupting something. Rejecting + /// is allowed in case of concurrent updates if waiting for a lock + /// cannot be done. + async fn update_post(&self, url: &'_ str, update: serde_json::Value) -> Result<()>; + + /// Get a list of channels available for the user represented by the URL `user` to write to. + async fn get_channels(&self, user: &'_ str) -> Result>; + + /// Fetch a feed at `url` and return a an h-feed object containing + /// `limit` posts after a post by url `after`, filtering the content + /// in context of a user specified by `user` (or an anonymous user). + /// + /// Specifically, private posts that don't include the user in the audience + /// will be elided from the feed, and the posts containing location and not + /// specifying post["properties"]["location-visibility"][0] == "public" + /// will have their location data (but not check-in data) stripped. + /// + /// This function is used as an optimization so the client, whatever it is, + /// doesn't have to fetch posts, then realize some of them are private, and + /// fetch more posts. + /// + /// Note for implementors: if you use streams to fetch posts in parallel + /// from the database, preferably make this method use a connection pool + /// to reduce overhead of creating a database connection per post for + /// parallel fetching. + async fn read_feed_with_limit( + &self, + url: &'_ str, + after: &'_ Option, + limit: usize, + user: &'_ Option, + ) -> Result>; + + /// Deletes a post from the database irreversibly. 'nuff said. Must be idempotent. + async fn delete_post(&self, url: &'_ str) -> Result<()>; + + /// Gets a setting from the setting store and passes the result. + async fn get_setting(&self, setting: Settings, user: &'_ str) -> Result; + + /// Commits a setting to the setting store. + async fn set_setting(&self, setting: Settings, user: &'_ str, value: &'_ str) -> Result<()>; +} + +#[cfg(test)] +mod tests { + use super::{MicropubChannel, Storage}; + use serde_json::json; + + async fn test_basic_operations(backend: Backend) { + let post: serde_json::Value = json!({ + "type": ["h-entry"], + "properties": { + "content": ["Test content"], + "author": ["https://fireburn.ru/"], + "uid": ["https://fireburn.ru/posts/hello"], + "url": ["https://fireburn.ru/posts/hello", "https://fireburn.ru/posts/test"] + } + }); + let key = post["properties"]["uid"][0].as_str().unwrap().to_string(); + let alt_url = post["properties"]["url"][1].as_str().unwrap().to_string(); + + // Reading and writing + backend + .put_post(&post, "https://fireburn.ru/") + .await + .unwrap(); + if let Some(returned_post) = backend.get_post(&key).await.unwrap() { + assert!(returned_post.is_object()); + assert_eq!( + returned_post["type"].as_array().unwrap().len(), + post["type"].as_array().unwrap().len() + ); + assert_eq!( + returned_post["type"].as_array().unwrap(), + post["type"].as_array().unwrap() + ); + let props: &serde_json::Map = + post["properties"].as_object().unwrap(); + for key in props.keys() { + assert_eq!( + returned_post["properties"][key].as_array().unwrap(), + post["properties"][key].as_array().unwrap() + ) + } + } else { + panic!("For some reason the backend did not return the post.") + } + // Check the alternative URL - it should return the same post + if let Ok(Some(returned_post)) = backend.get_post(&alt_url).await { + assert!(returned_post.is_object()); + assert_eq!( + returned_post["type"].as_array().unwrap().len(), + post["type"].as_array().unwrap().len() + ); + assert_eq!( + returned_post["type"].as_array().unwrap(), + post["type"].as_array().unwrap() + ); + let props: &serde_json::Map = + post["properties"].as_object().unwrap(); + for key in props.keys() { + assert_eq!( + returned_post["properties"][key].as_array().unwrap(), + post["properties"][key].as_array().unwrap() + ) + } + } else { + panic!("For some reason the backend did not return the post.") + } + } + + /// Note: this is merely a smoke check and is in no way comprehensive. + // TODO updates for feeds must update children using special logic + async fn test_update(backend: Backend) { + let post: serde_json::Value = json!({ + "type": ["h-entry"], + "properties": { + "content": ["Test content"], + "author": ["https://fireburn.ru/"], + "uid": ["https://fireburn.ru/posts/hello"], + "url": ["https://fireburn.ru/posts/hello", "https://fireburn.ru/posts/test"] + } + }); + let key = post["properties"]["uid"][0].as_str().unwrap().to_string(); + + // Reading and writing + backend + .put_post(&post, "https://fireburn.ru/") + .await + .unwrap(); + + backend + .update_post( + &key, + json!({ + "url": &key, + "add": { + "category": ["testing"], + }, + "replace": { + "content": ["Different test content"] + } + }), + ) + .await + .unwrap(); + + match backend.get_post(&key).await { + Ok(Some(returned_post)) => { + assert!(returned_post.is_object()); + assert_eq!( + returned_post["type"].as_array().unwrap().len(), + post["type"].as_array().unwrap().len() + ); + assert_eq!( + returned_post["type"].as_array().unwrap(), + post["type"].as_array().unwrap() + ); + assert_eq!( + returned_post["properties"]["content"][0].as_str().unwrap(), + "Different test content" + ); + assert_eq!( + returned_post["properties"]["category"].as_array().unwrap(), + &vec![json!("testing")] + ); + }, + something_else => { + something_else.expect("Shouldn't error").expect("Should have the post"); + } + } + } + + async fn test_get_channel_list(backend: Backend) { + let feed = json!({ + "type": ["h-feed"], + "properties": { + "name": ["Main Page"], + "author": ["https://fireburn.ru/"], + "uid": ["https://fireburn.ru/feeds/main"] + }, + "children": [] + }); + backend + .put_post(&feed, "https://fireburn.ru/") + .await + .unwrap(); + let chans = backend.get_channels("https://fireburn.ru/").await.unwrap(); + assert_eq!(chans.len(), 1); + assert_eq!( + chans[0], + MicropubChannel { + uid: "https://fireburn.ru/feeds/main".to_string(), + name: "Main Page".to_string() + } + ); + } + + async fn test_settings(backend: Backend) { + backend + .set_setting(crate::database::Settings::SiteName, "https://fireburn.ru/", "Vika's Hideout") + .await + .unwrap(); + assert_eq!( + backend + .get_setting(crate::database::Settings::SiteName, "https://fireburn.ru/") + .await + .unwrap(), + "Vika's Hideout" + ); + } + + fn gen_random_post(domain: &str) -> serde_json::Value { + use faker_rand::lorem::{Paragraphs, Word}; + + let uid = format!( + "https://{domain}/posts/{}-{}-{}", + rand::random::(), rand::random::(), rand::random::() + ); + + let post = json!({ + "type": ["h-entry"], + "properties": { + "content": [rand::random::().to_string()], + "uid": [&uid], + "url": [&uid] + } + }); + + post + } + + async fn test_feed_pagination(backend: Backend) { + let posts = std::iter::from_fn(|| Some(gen_random_post("fireburn.ru"))) + .take(20) + .collect::>(); + + let feed = json!({ + "type": ["h-feed"], + "properties": { + "name": ["Main Page"], + "author": ["https://fireburn.ru/"], + "uid": ["https://fireburn.ru/feeds/main"] + }, + "children": posts.iter() + .filter_map(|json| json["properties"]["uid"][0].as_str()) + .collect::>() + }); + let key = feed["properties"]["uid"][0].as_str().unwrap(); + + backend + .put_post(&feed, "https://fireburn.ru/") + .await + .unwrap(); + println!("---"); + for (i, post) in posts.iter().enumerate() { + backend.put_post(post, "https://fireburn.ru/").await.unwrap(); + println!("posts[{}] = {}", i, post["properties"]["uid"][0]); + } + println!("---"); + let limit: usize = 10; + let result = backend.read_feed_with_limit(key, &None, limit, &None) + .await + .unwrap() + .unwrap(); + for (i, post) in result["children"].as_array().unwrap().iter().enumerate() { + println!("feed[0][{}] = {}", i, post["properties"]["uid"][0]); + } + println!("---"); + assert_eq!(result["children"].as_array().unwrap()[0..10], posts[0..10]); + + let result2 = backend.read_feed_with_limit( + key, + &result["children"] + .as_array() + .unwrap() + .last() + .unwrap() + ["properties"]["uid"][0] + .as_str() + .map(|i| i.to_owned()), + limit, &None + ).await.unwrap().unwrap(); + for (i, post) in result2["children"].as_array().unwrap().iter().enumerate() { + println!("feed[1][{}] = {}", i, post["properties"]["uid"][0]); + } + println!("---"); + assert_eq!(result2["children"].as_array().unwrap()[0..10], posts[10..20]); + + // Regression test for #4 + let nonsense_after = Some("1010101010".to_owned()); + let result3 = tokio::time::timeout(tokio::time::Duration::from_secs(10), async move { + backend.read_feed_with_limit( + key, &nonsense_after, limit, &None + ).await.unwrap().unwrap() + }).await.expect("Operation should not hang: see https://gitlab.com/kittybox/kittybox/-/issues/4"); + assert!(result3["children"].as_array().unwrap().is_empty()); + } + + /// Automatically generates a test suite for + macro_rules! test_all { + ($func_name:ident, $mod_name:ident) => { + mod $mod_name { + $func_name!(test_basic_operations); + $func_name!(test_get_channel_list); + $func_name!(test_settings); + $func_name!(test_update); + $func_name!(test_feed_pagination); + } + } + } + macro_rules! file_test { + ($func_name:ident) => { + #[tokio::test] + async fn $func_name () { + test_logger::ensure_env_logger_initialized(); + let tempdir = tempdir::TempDir::new("file").expect("Failed to create tempdir"); + let backend = super::super::FileStorage::new(tempdir.into_path()).await.unwrap(); + super::$func_name(backend).await + } + }; + } + + test_all!(file_test, file); + +} diff --git a/kittybox-rs/src/database/redis/edit_post.lua b/kittybox-rs/src/database/redis/edit_post.lua new file mode 100644 index 0000000..a398f8d --- /dev/null +++ b/kittybox-rs/src/database/redis/edit_post.lua @@ -0,0 +1,93 @@ +local posts = KEYS[1] +local update_desc = cjson.decode(ARGV[2]) +local post = cjson.decode(redis.call("HGET", posts, ARGV[1])) + +local delete_keys = {} +local delete_kvs = {} +local add_keys = {} + +if update_desc.replace ~= nil then + for k, v in pairs(update_desc.replace) do + table.insert(delete_keys, k) + add_keys[k] = v + end +end +if update_desc.delete ~= nil then + if update_desc.delete[0] == nil then + -- Table has string keys. Probably! + for k, v in pairs(update_desc.delete) do + delete_kvs[k] = v + end + else + -- Table has numeric keys. Probably! + for i, v in ipairs(update_desc.delete) do + table.insert(delete_keys, v) + end + end +end +if update_desc.add ~= nil then + for k, v in pairs(update_desc.add) do + add_keys[k] = v + end +end + +for i, v in ipairs(delete_keys) do + post["properties"][v] = nil + -- TODO delete URL links +end + +for k, v in pairs(delete_kvs) do + local index = -1 + if k == "children" then + for j, w in ipairs(post[k]) do + if w == v then + index = j + break + end + end + if index > -1 then + table.remove(post[k], index) + end + else + for j, w in ipairs(post["properties"][k]) do + if w == v then + index = j + break + end + end + if index > -1 then + table.remove(post["properties"][k], index) + -- TODO delete URL links + end + end +end + +for k, v in pairs(add_keys) do + if k == "children" then + if post["children"] == nil then + post["children"] = {} + end + for i, w in ipairs(v) do + table.insert(post["children"], 1, w) + end + else + if post["properties"][k] == nil then + post["properties"][k] = {} + end + for i, w in ipairs(v) do + table.insert(post["properties"][k], w) + end + if k == "url" then + redis.call("HSET", posts, v, cjson.encode({ see_other = post["properties"]["uid"][1] })) + elseif k == "channel" then + local feed = cjson.decode(redis.call("HGET", posts, v)) + table.insert(feed["children"], 1, post["properties"]["uid"][1]) + redis.call("HSET", posts, v, cjson.encode(feed)) + end + end +end + +local encoded = cjson.encode(post) +redis.call("SET", "debug", encoded) +redis.call("HSET", posts, post["properties"]["uid"][1], encoded) +return \ No newline at end of file diff --git a/kittybox-rs/src/database/redis/mod.rs b/kittybox-rs/src/database/redis/mod.rs new file mode 100644 index 0000000..eeaa3f2 --- /dev/null +++ b/kittybox-rs/src/database/redis/mod.rs @@ -0,0 +1,392 @@ +use async_trait::async_trait; +use futures::stream; +use futures_util::FutureExt; +use futures_util::StreamExt; +use futures_util::TryStream; +use futures_util::TryStreamExt; +use lazy_static::lazy_static; +use log::error; +use mobc::Pool; +use mobc_redis::redis; +use mobc_redis::redis::AsyncCommands; +use mobc_redis::RedisConnectionManager; +use serde_json::json; +use std::time::Duration; + +use crate::database::{ErrorKind, MicropubChannel, Result, Storage, StorageError, filter_post}; +use crate::indieauth::User; + +struct RedisScripts { + edit_post: redis::Script, +} + +impl From for StorageError { + fn from(err: mobc_redis::redis::RedisError) -> Self { + Self { + msg: format!("{}", err), + source: Some(Box::new(err)), + kind: ErrorKind::Backend, + } + } +} +impl From> for StorageError { + fn from(err: mobc::Error) -> Self { + Self { + msg: format!("{}", err), + source: Some(Box::new(err)), + kind: ErrorKind::Backend, + } + } +} + +lazy_static! { + static ref SCRIPTS: RedisScripts = RedisScripts { + edit_post: redis::Script::new(include_str!("./edit_post.lua")) + }; +} + +#[derive(Clone)] +pub struct RedisStorage { + // note to future Vika: + // mobc::Pool is actually a fancy name for an Arc + // around a shared connection pool with a manager + // which makes it safe to implement [`Clone`] and + // not worry about new pools being suddenly made + // + // stop worrying and start coding, you dum-dum + redis: mobc::Pool, +} + +#[async_trait] +impl Storage for RedisStorage { + async fn get_setting<'a>(&self, setting: &'a str, user: &'a str) -> Result { + let mut conn = self.redis.get().await.map_err(|e| StorageError::with_source(ErrorKind::Backend, "Error getting a connection from the pool", Box::new(e)))?; + Ok(conn + .hget::(format!("settings_{}", user), setting) + .await?) + } + + async fn set_setting<'a>(&self, setting: &'a str, user: &'a str, value: &'a str) -> Result<()> { + let mut conn = self.redis.get().await.map_err(|e| StorageError::with_source(ErrorKind::Backend, "Error getting a connection from the pool", Box::new(e)))?; + Ok(conn + .hset::(format!("settings_{}", user), setting, value) + .await?) + } + + async fn delete_post<'a>(&self, url: &'a str) -> Result<()> { + let mut conn = self.redis.get().await.map_err(|e| StorageError::with_source(ErrorKind::Backend, "Error getting a connection from the pool", Box::new(e)))?; + Ok(conn.hdel::<&str, &str, ()>("posts", url).await?) + } + + async fn post_exists(&self, url: &str) -> Result { + let mut conn = self.redis.get().await.map_err(|e| StorageError::with_source(ErrorKind::Backend, "Error getting a connection from the pool", Box::new(e)))?; + Ok(conn.hexists::<&str, &str, bool>("posts", url).await?) + } + + async fn get_post(&self, url: &str) -> Result> { + let mut conn = self.redis.get().await.map_err(|e| StorageError::with_source(ErrorKind::Backend, "Error getting a connection from the pool", Box::new(e)))?; + match conn + .hget::<&str, &str, Option>("posts", url) + .await? + { + Some(val) => { + let parsed = serde_json::from_str::(&val)?; + if let Some(new_url) = parsed["see_other"].as_str() { + match conn + .hget::<&str, &str, Option>("posts", new_url) + .await? + { + Some(val) => Ok(Some(serde_json::from_str::(&val)?)), + None => Ok(None), + } + } else { + Ok(Some(parsed)) + } + } + None => Ok(None), + } + } + + async fn get_channels(&self, user: &User) -> Result> { + let mut conn = self.redis.get().await.map_err(|e| StorageError::with_source(ErrorKind::Backend, "Error getting a connection from the pool", Box::new(e)))?; + let channels = conn + .smembers::>("channels_".to_string() + user.me.as_str()) + .await?; + // TODO: use streams here instead of this weird thing... how did I even write this?! + Ok(futures_util::future::join_all( + channels + .iter() + .map(|channel| { + self.get_post(channel).map(|result| result.unwrap()).map( + |post: Option| { + post.map(|post| MicropubChannel { + uid: post["properties"]["uid"][0].as_str().unwrap().to_string(), + name: post["properties"]["name"][0].as_str().unwrap().to_string(), + }) + }, + ) + }) + .collect::>(), + ) + .await + .into_iter() + .flatten() + .collect::>()) + } + + async fn put_post<'a>(&self, post: &'a serde_json::Value, user: &'a str) -> Result<()> { + let mut conn = self.redis.get().await.map_err(|e| StorageError::with_source(ErrorKind::Backend, "Error getting a connection from the pool", Box::new(e)))?; + let key: &str; + match post["properties"]["uid"][0].as_str() { + Some(uid) => key = uid, + None => { + return Err(StorageError::new( + ErrorKind::BadRequest, + "post doesn't have a UID", + )) + } + } + conn.hset::<&str, &str, String, ()>("posts", key, post.to_string()) + .await?; + if post["properties"]["url"].is_array() { + for url in post["properties"]["url"] + .as_array() + .unwrap() + .iter() + .map(|i| i.as_str().unwrap().to_string()) + { + if url != key && url.starts_with(user) { + conn.hset::<&str, &str, String, ()>( + "posts", + &url, + json!({ "see_other": key }).to_string(), + ) + .await?; + } + } + } + if post["type"] + .as_array() + .unwrap() + .iter() + .any(|i| i == "h-feed") + { + // This is a feed. Add it to the channels array if it's not already there. + conn.sadd::( + "channels_".to_string() + post["properties"]["author"][0].as_str().unwrap(), + key, + ) + .await? + } + Ok(()) + } + + async fn read_feed_with_limit<'a>( + &self, + url: &'a str, + after: &'a Option, + limit: usize, + user: &'a Option, + ) -> Result> { + let mut conn = self.redis.get().await?; + let mut feed; + match conn + .hget::<&str, &str, Option>("posts", url) + .await + .map_err(|e| StorageError::with_source(ErrorKind::Backend, "Error getting a connection from the pool", Box::new(e)))? + { + Some(post) => feed = serde_json::from_str::(&post)?, + None => return Ok(None), + } + if feed["see_other"].is_string() { + match conn + .hget::<&str, &str, Option>("posts", feed["see_other"].as_str().unwrap()) + .await? + { + Some(post) => feed = serde_json::from_str::(&post)?, + None => return Ok(None), + } + } + if let Some(post) = filter_post(feed, user) { + feed = post + } else { + return Err(StorageError::new( + ErrorKind::PermissionDenied, + "specified user cannot access this post", + )); + } + if feed["children"].is_array() { + let children = feed["children"].as_array().unwrap(); + let mut posts_iter = children.iter().map(|i| i.as_str().unwrap().to_string()); + if after.is_some() { + loop { + let i = posts_iter.next(); + if &i == after { + break; + } + } + } + async fn fetch_post_for_feed(url: String) -> Option { + return Some(serde_json::json!({})); + } + let posts = stream::iter(posts_iter) + .map(|url: String| async move { + return Ok(fetch_post_for_feed(url).await); + /*match self.redis.get().await { + Ok(mut conn) => { + match conn.hget::<&str, &str, Option>("posts", &url).await { + Ok(post) => match post { + Some(post) => { + Ok(Some(serde_json::from_str(&post)?)) + } + // Happens because of a broken link (result of an improper deletion?) + None => Ok(None), + }, + Err(err) => Err(StorageError::with_source(ErrorKind::Backend, "Error executing a Redis command", Box::new(err))) + } + } + Err(err) => Err(StorageError::with_source(ErrorKind::Backend, "Error getting a connection from the pool", Box::new(err))) + }*/ + }) + // TODO: determine the optimal value for this buffer + // It will probably depend on how often can you encounter a private post on the page + // It shouldn't be too large, or we'll start fetching too many posts from the database + // It MUST NOT be larger than the typical page size + // It MUST NOT be a significant amount of the connection pool size + //.buffered(std::cmp::min(3, limit)) + // Hack to unwrap the Option and sieve out broken links + // Broken links return None, and Stream::filter_map skips all Nones. + // I wonder if one can use try_flatten() here somehow akin to iters + .try_filter_map(|post| async move { Ok(post) }) + .try_filter_map(|post| async move { + Ok(filter_post(post, user)) + }) + .take(limit); + match posts.try_collect::>().await { + Ok(posts) => feed["children"] = json!(posts), + Err(err) => { + let e = StorageError::with_source( + ErrorKind::Other, + "An error was encountered while processing the feed", + Box::new(err) + ); + error!("Error while assembling feed: {}", e); + return Err(e); + } + } + } + return Ok(Some(feed)); + } + + async fn update_post<'a>(&self, mut url: &'a str, update: serde_json::Value) -> Result<()> { + let mut conn = self.redis.get().await.map_err(|e| StorageError::with_source(ErrorKind::Backend, "Error getting a connection from the pool", Box::new(e)))?; + if !conn + .hexists::<&str, &str, bool>("posts", url) + .await + .unwrap() + { + return Err(StorageError::new( + ErrorKind::NotFound, + "can't edit a non-existent post", + )); + } + let post: serde_json::Value = + serde_json::from_str(&conn.hget::<&str, &str, String>("posts", url).await?)?; + if let Some(new_url) = post["see_other"].as_str() { + url = new_url + } + Ok(SCRIPTS + .edit_post + .key("posts") + .arg(url) + .arg(update.to_string()) + .invoke_async::<_, ()>(&mut conn as &mut redis::aio::Connection) + .await?) + } +} + +impl RedisStorage { + /// Create a new RedisDatabase that will connect to Redis at `redis_uri` to store data. + pub async fn new(redis_uri: String) -> Result { + match redis::Client::open(redis_uri) { + Ok(client) => Ok(Self { + redis: Pool::builder() + .max_open(20) + .max_idle(5) + .get_timeout(Some(Duration::from_secs(3))) + .max_lifetime(Some(Duration::from_secs(120))) + .build(RedisConnectionManager::new(client)), + }), + Err(e) => Err(e.into()), + } + } + + pub async fn conn(&self) -> Result> { + self.redis.get().await.map_err(|e| StorageError::with_source( + ErrorKind::Backend, "Error getting a connection from the pool", Box::new(e) + )) + } +} + +#[cfg(test)] +pub mod tests { + use mobc_redis::redis; + use std::process; + use std::time::Duration; + + pub struct RedisInstance { + // We just need to hold on to it so it won't get dropped and remove the socket + _tempdir: tempdir::TempDir, + uri: String, + child: std::process::Child, + } + impl Drop for RedisInstance { + fn drop(&mut self) { + self.child.kill().expect("Failed to kill the child!"); + } + } + impl RedisInstance { + pub fn uri(&self) -> &str { + &self.uri + } + } + + pub async fn get_redis_instance() -> RedisInstance { + let tempdir = tempdir::TempDir::new("redis").expect("failed to create tempdir"); + let socket = tempdir.path().join("redis.sock"); + let redis_child = process::Command::new("redis-server") + .current_dir(&tempdir) + .arg("--port") + .arg("0") + .arg("--unixsocket") + .arg(&socket) + .stdout(process::Stdio::null()) + .stderr(process::Stdio::null()) + .spawn() + .expect("Failed to spawn Redis"); + println!("redis+unix:///{}", socket.to_str().unwrap()); + let uri = format!("redis+unix:///{}", socket.to_str().unwrap()); + // There should be a slight delay, we need to wait for Redis to spin up + let client = redis::Client::open(uri.clone()).unwrap(); + let millisecond = Duration::from_millis(1); + let mut retries: usize = 0; + const MAX_RETRIES: usize = 60 * 1000/*ms*/; + while let Err(err) = client.get_connection() { + if err.is_connection_refusal() { + async_std::task::sleep(millisecond).await; + retries += 1; + if retries > MAX_RETRIES { + panic!("Timeout waiting for Redis, last error: {}", err); + } + } else { + panic!("Could not connect: {}", err); + } + } + + RedisInstance { + uri, + child: redis_child, + _tempdir: tempdir, + } + } +} diff --git a/kittybox-rs/src/frontend/login.rs b/kittybox-rs/src/frontend/login.rs new file mode 100644 index 0000000..9665ce7 --- /dev/null +++ b/kittybox-rs/src/frontend/login.rs @@ -0,0 +1,333 @@ +use http_types::Mime; +use log::{debug, error}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::convert::TryInto; +use std::str::FromStr; + +use crate::frontend::templates::Template; +use crate::frontend::{FrontendError, IndiewebEndpoints}; +use crate::{database::Storage, ApplicationState}; +use kittybox_templates::LoginPage; + +pub async fn form(req: Request>) -> Result { + let owner = req.url().origin().ascii_serialization() + "/"; + let storage = &req.state().storage; + let authorization_endpoint = req.state().authorization_endpoint.to_string(); + let token_endpoint = req.state().token_endpoint.to_string(); + let blog_name = storage + .get_setting("site_name", &owner) + .await + .unwrap_or_else(|_| "Kitty Box!".to_string()); + let feeds = storage.get_channels(&owner).await.unwrap_or_default(); + + Ok(Response::builder(200) + .body( + Template { + title: "Sign in with IndieAuth", + blog_name: &blog_name, + endpoints: IndiewebEndpoints { + authorization_endpoint, + token_endpoint, + webmention: None, + microsub: None, + }, + feeds, + user: req.session().get("user"), + content: LoginPage {}.to_string(), + } + .to_string(), + ) + .content_type("text/html; charset=utf-8") + .build()) +} + +#[derive(Serialize, Deserialize)] +struct LoginForm { + url: String, +} + +#[derive(Serialize, Deserialize)] +struct IndieAuthClientState { + /// A random value to protect from CSRF attacks. + nonce: String, + /// The user's initial "me" value. + me: String, + /// Authorization endpoint used. + authorization_endpoint: String, +} + +#[derive(Serialize, Deserialize)] +struct IndieAuthRequestParams { + response_type: String, // can only have "code". TODO make an enum + client_id: String, // always a URL. TODO consider making a URL + redirect_uri: surf::Url, // callback URI for IndieAuth + state: String, // CSRF protection, should include randomness and be passed through + code_challenge: String, // base64-encoded PKCE challenge + code_challenge_method: String, // usually "S256". TODO make an enum + scope: Option, // oAuth2 scopes to grant, + me: surf::Url, // User's entered profile URL +} + +/// Handle login requests. Find the IndieAuth authorization endpoint and redirect to it. +pub async fn handler(mut req: Request>) -> Result { + let content_type = req.content_type(); + if content_type.is_none() { + return Err(FrontendError::with_code(400, "Use the login form, Luke.").into()); + } + if content_type.unwrap() != Mime::from_str("application/x-www-form-urlencoded").unwrap() { + return Err( + FrontendError::with_code(400, "Login form results must be a urlencoded form").into(), + ); + } + + let form = req.body_form::().await?; // FIXME check if it returns 400 or 500 on error + let homepage_uri = surf::Url::parse(&form.url)?; + let http = &req.state().http_client; + + let mut fetch_response = http.get(&homepage_uri).send().await?; + if fetch_response.status() != 200 { + return Err(FrontendError::with_code( + 500, + "Error fetching your authorization endpoint. Check if your website's okay.", + ) + .into()); + } + + let mut authorization_endpoint: Option = None; + if let Some(links) = fetch_response.header("Link") { + // NOTE: this is the same Link header parser used in src/micropub/post.rs:459. + // One should refactor it to a function to use independently and improve later + for link in links.iter().flat_map(|i| i.as_str().split(',')) { + debug!("Trying to match {} as authorization_endpoint", link); + let mut split_link = link.split(';'); + + match split_link.next() { + Some(uri) => { + if let Some(uri) = uri.strip_prefix('<').and_then(|uri| uri.strip_suffix('>')) { + debug!("uri: {}", uri); + for prop in split_link { + debug!("prop: {}", prop); + let lowercased = prop.to_ascii_lowercase(); + let trimmed = lowercased.trim(); + if trimmed == "rel=\"authorization_endpoint\"" + || trimmed == "rel=authorization_endpoint" + { + if let Ok(endpoint) = homepage_uri.join(uri) { + debug!( + "Found authorization endpoint {} for user {}", + endpoint, + homepage_uri.as_str() + ); + authorization_endpoint = Some(endpoint); + break; + } + } + } + } + } + None => continue, + } + } + } + // If the authorization_endpoint is still not found after the Link parsing gauntlet, + // bring out the big guns and parse HTML to find it. + if authorization_endpoint.is_none() { + let body = fetch_response.body_string().await?; + let pattern = + easy_scraper::Pattern::new(r#""#) + .expect("Cannot parse the pattern for authorization_endpoint"); + let matches = pattern.matches(&body); + debug!("Matches for authorization_endpoint in HTML: {:?}", matches); + if !matches.is_empty() { + if let Ok(endpoint) = homepage_uri.join(&matches[0]["url"]) { + debug!( + "Found authorization endpoint {} for user {}", + endpoint, + homepage_uri.as_str() + ); + authorization_endpoint = Some(endpoint) + } + } + }; + // If even after this the authorization endpoint is still not found, bail out. + if authorization_endpoint.is_none() { + error!( + "Couldn't find authorization_endpoint for {}", + homepage_uri.as_str() + ); + return Err(FrontendError::with_code( + 400, + "Your website doesn't support the IndieAuth protocol.", + ) + .into()); + } + let mut authorization_endpoint: surf::Url = authorization_endpoint.unwrap(); + let mut rng = rand::thread_rng(); + let state: String = data_encoding::BASE64URL.encode( + serde_urlencoded::to_string(IndieAuthClientState { + nonce: (0..8) + .map(|_| { + let idx = rng.gen_range(0..INDIEAUTH_PKCE_CHARSET.len()); + INDIEAUTH_PKCE_CHARSET[idx] as char + }) + .collect(), + me: homepage_uri.to_string(), + authorization_endpoint: authorization_endpoint.to_string(), + })? + .as_bytes(), + ); + // PKCE code generation + let code_verifier: String = (0..128) + .map(|_| { + let idx = rng.gen_range(0..INDIEAUTH_PKCE_CHARSET.len()); + INDIEAUTH_PKCE_CHARSET[idx] as char + }) + .collect(); + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let code_challenge: String = data_encoding::BASE64URL.encode(&hasher.finalize()); + + authorization_endpoint.set_query(Some(&serde_urlencoded::to_string( + IndieAuthRequestParams { + response_type: "code".to_string(), + client_id: req.url().origin().ascii_serialization(), + redirect_uri: req.url().join("login/callback")?, + state: state.clone(), + code_challenge, + code_challenge_method: "S256".to_string(), + scope: Some("profile".to_string()), + me: homepage_uri, + }, + )?)); + + let cookies = vec![ + format!( + r#"indieauth_state="{}"; Same-Site: None; Secure; Max-Age: 600"#, + state + ), + format!( + r#"indieauth_code_verifier="{}"; Same-Site: None; Secure; Max-Age: 600"#, + code_verifier + ), + ]; + + let cookie_header = cookies + .iter() + .map(|i| -> http_types::headers::HeaderValue { (i as &str).try_into().unwrap() }) + .collect::>(); + + Ok(Response::builder(302) + .header("Location", authorization_endpoint.to_string()) + .header("Set-Cookie", &*cookie_header) + .build()) +} + +const INDIEAUTH_PKCE_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ + abcdefghijklmnopqrstuvwxyz\ + 1234567890-._~"; + +#[derive(Deserialize)] +struct IndieAuthCallbackResponse { + code: Option, + error: Option, + error_description: Option, + #[allow(dead_code)] + error_uri: Option, + // This needs to be further decoded to receive state back and will always be present + state: String, +} + +impl IndieAuthCallbackResponse { + fn is_successful(&self) -> bool { + self.code.is_some() + } +} + +#[derive(Serialize, Deserialize)] +struct IndieAuthCodeRedeem { + grant_type: String, + code: String, + client_id: String, + redirect_uri: String, + code_verifier: String, +} + +#[derive(Serialize, Deserialize)] +struct IndieWebProfile { + name: Option, + url: Option, + email: Option, + photo: Option, +} + +#[derive(Serialize, Deserialize)] +struct IndieAuthResponse { + me: String, + scope: Option, + access_token: Option, + token_type: Option, + profile: Option, +} + +/// Handle IndieAuth parameters, fetch the final h-card and redirect the user to the homepage. +pub async fn callback(mut req: Request>) -> Result { + let params: IndieAuthCallbackResponse = req.query()?; + let http: &surf::Client = &req.state().http_client; + let origin = req.url().origin().ascii_serialization(); + + if req.cookie("indieauth_state").unwrap().value() != params.state { + return Err(FrontendError::with_code(400, "The state doesn't match. A possible CSRF attack was prevented. Please try again later.").into()); + } + let state: IndieAuthClientState = + serde_urlencoded::from_bytes(&data_encoding::BASE64URL.decode(params.state.as_bytes())?)?; + + if !params.is_successful() { + return Err(FrontendError::with_code( + 400, + &format!( + "The authorization endpoint indicated a following error: {:?}: {:?}", + ¶ms.error, ¶ms.error_description + ), + ) + .into()); + } + + let authorization_endpoint = surf::Url::parse(&state.authorization_endpoint).unwrap(); + let mut code_response = http + .post(authorization_endpoint) + .body_string(serde_urlencoded::to_string(IndieAuthCodeRedeem { + grant_type: "authorization_code".to_string(), + code: params.code.unwrap().to_string(), + client_id: origin.to_string(), + redirect_uri: origin + "/login/callback", + code_verifier: req + .cookie("indieauth_code_verifier") + .unwrap() + .value() + .to_string(), + })?) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .send() + .await?; + + if code_response.status() != 200 { + return Err(FrontendError::with_code( + code_response.status(), + &format!( + "Authorization endpoint returned an error when redeeming the code: {}", + code_response.body_string().await? + ), + ) + .into()); + } + + let json: IndieAuthResponse = code_response.body_json().await?; + let session = req.session_mut(); + session.insert("user", &json.me)?; + + // TODO redirect to the page user came from + Ok(Response::builder(302).header("Location", "/").build()) +} diff --git a/kittybox-rs/src/frontend/mod.rs b/kittybox-rs/src/frontend/mod.rs new file mode 100644 index 0000000..b87f9c6 --- /dev/null +++ b/kittybox-rs/src/frontend/mod.rs @@ -0,0 +1,459 @@ +use std::convert::TryInto; +use crate::database::Storage; +use serde::Deserialize; +use futures_util::TryFutureExt; +use warp::{http::StatusCode, Filter, host::Authority, path::FullPath}; + +//pub mod login; + +#[allow(unused_imports)] +use kittybox_templates::{ErrorPage, MainPage, OnboardingPage, Template, POSTS_PER_PAGE}; + +pub use kittybox_util::IndiewebEndpoints; + +#[derive(Deserialize)] +struct QueryParams { + after: Option, +} + +#[derive(Debug)] +struct FrontendError { + msg: String, + source: Option>, + code: StatusCode, +} + +impl FrontendError { + pub fn with_code(code: C, msg: &str) -> Self + where + C: TryInto, + { + Self { + msg: msg.to_string(), + source: None, + code: code.try_into().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + } + } + pub fn msg(&self) -> &str { + &self.msg + } + pub fn code(&self) -> StatusCode { + self.code + } +} + +impl From for FrontendError { + fn from(err: crate::database::StorageError) -> Self { + Self { + msg: "Database error".to_string(), + source: Some(Box::new(err)), + code: StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl std::error::Error for FrontendError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source + .as_ref() + .map(|e| e.as_ref() as &(dyn std::error::Error + 'static)) + } +} + +impl std::fmt::Display for FrontendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.msg) + } +} + +impl warp::reject::Reject for FrontendError {} + +async fn get_post_from_database( + db: &S, + url: &str, + after: Option, + user: &Option, +) -> std::result::Result { + match db + .read_feed_with_limit(url, &after, POSTS_PER_PAGE, user) + .await + { + Ok(result) => match result { + Some(post) => Ok(post), + None => Err(FrontendError::with_code( + StatusCode::NOT_FOUND, + "Post not found in the database", + )), + }, + Err(err) => match err.kind() { + crate::database::ErrorKind::PermissionDenied => { + // TODO: Authentication + if user.is_some() { + Err(FrontendError::with_code( + StatusCode::FORBIDDEN, + "User authenticated AND forbidden to access this resource", + )) + } else { + Err(FrontendError::with_code( + StatusCode::UNAUTHORIZED, + "User needs to authenticate themselves", + )) + } + } + _ => Err(err.into()), + }, + } +} + +#[allow(dead_code)] +#[derive(Deserialize)] +struct OnboardingFeed { + slug: String, + name: String, +} + +#[allow(dead_code)] +#[derive(Deserialize)] +struct OnboardingData { + user: serde_json::Value, + first_post: serde_json::Value, + #[serde(default = "OnboardingData::default_blog_name")] + blog_name: String, + feeds: Vec, +} + +impl OnboardingData { + fn default_blog_name() -> String { + "Kitty Box!".to_owned() + } +} + +/*pub async fn onboarding_receiver(mut req: Request>) -> Result { + use serde_json::json; + + log::debug!("Entering onboarding receiver..."); + + // This cannot error out as the URL must be valid. Or there is something horribly wrong + // and we shouldn't serve this request anyway. + >::as_mut(&mut req) + .url_mut() + .set_scheme("https") + .unwrap(); + + log::debug!("Parsing the body..."); + let body = req.body_json::().await?; + log::debug!("Body parsed!"); + let backend = &req.state().storage; + + #[cfg(any(not(debug_assertions), test))] + let me = req.url(); + #[cfg(all(debug_assertions, not(test)))] + let me = url::Url::parse("https://localhost:8080/").unwrap(); + + log::debug!("me value: {:?}", me); + + if get_post_from_database(backend, me.as_str(), None, &None) + .await + .is_ok() + { + return Err(FrontendError::with_code( + StatusCode::Forbidden, + "Onboarding is over. Are you trying to take over somebody's website?!", + ) + .into()); + } + info!("Onboarding new user: {}", me); + + let user = crate::indieauth::User::new(me.as_str(), "https://kittybox.fireburn.ru/", "create"); + + log::debug!("Setting the site name to {}", &body.blog_name); + backend + .set_setting("site_name", user.me.as_str(), &body.blog_name) + .await?; + + if body.user["type"][0] != "h-card" || body.first_post["type"][0] != "h-entry" { + return Err(FrontendError::with_code( + StatusCode::BadRequest, + "user and first_post should be h-card and h-entry", + ) + .into()); + } + info!("Validated body.user and body.first_post as microformats2"); + + let mut hcard = body.user; + let hentry = body.first_post; + + // Ensure the h-card's UID is set to the main page, so it will be fetchable. + hcard["properties"]["uid"] = json!([me.as_str()]); + // Normalize the h-card - note that it should preserve the UID we set here. + let (_, hcard) = crate::micropub::normalize_mf2(hcard, &user); + // The h-card is written directly - all the stuff in the Micropub's + // post function is just to ensure that the posts will be syndicated + // and inserted into proper feeds. Here, we don't have a need for this, + // since the h-card is DIRECTLY accessible via its own URL. + log::debug!("Saving the h-card..."); + backend.put_post(&hcard, me.as_str()).await?; + + log::debug!("Creating feeds..."); + for feed in body.feeds { + if feed.name.is_empty() || feed.slug.is_empty() { + continue; + }; + log::debug!("Creating feed {} with slug {}", &feed.name, &feed.slug); + let (_, feed) = crate::micropub::normalize_mf2( + json!({ + "type": ["h-feed"], + "properties": {"name": [feed.name], "mp-slug": [feed.slug]} + }), + &user, + ); + + backend.put_post(&feed, me.as_str()).await?; + } + log::debug!("Saving the h-entry..."); + // This basically puts the h-entry post through the normal creation process. + // We need to insert it into feeds and optionally send a notification to everywhere. + req.set_ext(user); + crate::micropub::post::new_post(req, hentry).await?; + + Ok(Response::builder(201).header("Location", "/").build()) +} +*/ + +fn request_uri() -> impl Filter + Copy { + crate::util::require_host() + .and(warp::path::full()) + .map(|host: Authority, path: FullPath| "https://".to_owned() + host.as_str() + path.as_str()) +} + +#[forbid(clippy::unwrap_used)] +pub fn homepage(db: D, endpoints: IndiewebEndpoints) -> impl Filter + Clone { + let inject_db = move || db.clone(); + warp::any() + .map(inject_db.clone()) + .and(crate::util::require_host()) + .and(warp::query()) + .and_then(|db: D, host: Authority, q: QueryParams| async move { + let path = format!("https://{}/", host); + let feed_path = format!("https://{}/feeds/main", host); + + match tokio::try_join!( + get_post_from_database(&db, &path, None, &None), + get_post_from_database(&db, &feed_path, q.after, &None) + ) { + Ok((hcard, hfeed)) => Ok(( + Some(hcard), + Some(hfeed), + StatusCode::OK + )), + Err(err) => { + if err.code == StatusCode::NOT_FOUND { + // signal for onboarding flow + Ok((None, None, err.code)) + } else { + Err(warp::reject::custom(err)) + } + } + } + }) + .and(warp::any().map(move || endpoints.clone())) + .and(crate::util::require_host()) + .and(warp::any().map(inject_db)) + .then(|content: (Option, Option, StatusCode), endpoints: IndiewebEndpoints, host: Authority, db: D| async move { + let owner = format!("https://{}/", host.as_str()); + let blog_name = db.get_setting(crate::database::Settings::SiteName, &owner).await + .unwrap_or_else(|_| "Kitty Box!".to_string()); + let feeds = db.get_channels(&owner).await.unwrap_or_default(); + match content { + (Some(card), Some(feed), StatusCode::OK) => { + Box::new(warp::reply::html(Template { + title: &blog_name, + blog_name: &blog_name, + endpoints: Some(endpoints), + feeds, + user: None, // TODO + content: MainPage { feed: &feed, card: &card }.to_string() + }.to_string())) as Box + }, + (None, None, StatusCode::NOT_FOUND) => { + // TODO Onboarding + Box::new(warp::redirect::found( + hyper::Uri::from_static("/onboarding") + )) as Box + } + _ => unreachable!() + } + }) +} + +pub fn onboarding( + db: D, + endpoints: IndiewebEndpoints, + http: reqwest::Client +) -> impl Filter + Clone { + let inject_db = move || db.clone(); + warp::get() + .map(move || warp::reply::html(Template { + title: "Kittybox - Onboarding", + blog_name: "Kittybox", + endpoints: Some(endpoints.clone()), + feeds: vec![], + user: None, + content: OnboardingPage {}.to_string() + }.to_string())) + .or(warp::post() + .and(crate::util::require_host()) + .and(warp::any().map(inject_db)) + .and(warp::body::json::()) + .and(warp::any().map(move || http.clone())) + .and_then(|host: warp::host::Authority, db: D, body: OnboardingData, http: reqwest::Client| async move { + let user_uid = format!("https://{}/", host.as_str()); + if db.post_exists(&user_uid).await.map_err(FrontendError::from)? { + + return Ok(warp::redirect(hyper::Uri::from_static("/"))); + } + let user = crate::indieauth::User::new(&user_uid, "https://kittybox.fireburn.ru/", "create"); + if body.user["type"][0] != "h-card" || body.first_post["type"][0] != "h-entry" { + return Err(FrontendError::with_code(StatusCode::BAD_REQUEST, "user and first_post should be an h-card and an h-entry").into()); + } + db.set_setting(crate::database::Settings::SiteName, user.me.as_str(), &body.blog_name) + .await + .map_err(FrontendError::from)?; + + let (_, hcard) = { + let mut hcard = body.user; + hcard["properties"]["uid"] = serde_json::json!([&user_uid]); + crate::micropub::normalize_mf2(hcard, &user) + }; + db.put_post(&hcard, &user_uid).await.map_err(FrontendError::from)?; + let (uid, post) = crate::micropub::normalize_mf2(body.first_post, &user); + crate::micropub::_post(user, uid, post, db, http).await.map_err(|e| { + FrontendError { + msg: "Error while posting the first post".to_string(), + source: Some(Box::new(e)), + code: StatusCode::INTERNAL_SERVER_ERROR + } + })?; + Ok::<_, warp::Rejection>(warp::redirect(hyper::Uri::from_static("/"))) + })) + +} + +#[forbid(clippy::unwrap_used)] +pub fn catchall(db: D, endpoints: IndiewebEndpoints) -> impl Filter + Clone { + let inject_db = move || db.clone(); + warp::any() + .map(inject_db.clone()) + .and(request_uri()) + .and(warp::query()) + .and_then(|db: D, path: String, query: QueryParams| async move { + get_post_from_database(&db, &path, query.after, &None).map_err(warp::reject::custom).await + }) + // Rendering pipeline + .and_then(|post: serde_json::Value| async move { + let post_name = &post["properties"]["name"][0].as_str().to_owned(); + match post["type"][0] + .as_str() + { + Some("h-entry") => Ok(( + post_name.unwrap_or("Note").to_string(), + kittybox_templates::Entry { post: &post }.to_string(), + StatusCode::OK + )), + Some("h-card") => Ok(( + post_name.unwrap_or("Contact card").to_string(), + kittybox_templates::VCard { card: &post }.to_string(), + StatusCode::OK + )), + Some("h-feed") => Ok(( + post_name.unwrap_or("Feed").to_string(), + kittybox_templates::Feed { feed: &post }.to_string(), + StatusCode::OK + )), + _ => Err(warp::reject::custom(FrontendError::with_code( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Couldn't render an unknown type: {}", post["type"][0]), + ))) + } + }) + .recover(|err: warp::Rejection| { + use warp::Rejection; + use futures_util::future; + if let Some(err) = err.find::() { + return future::ok::<(String, String, StatusCode), Rejection>(( + format!("Error: HTTP {}", err.code().as_u16()), + ErrorPage { code: err.code(), msg: Some(err.msg().to_string()) }.to_string(), + err.code() + )); + } + future::err::<(String, String, StatusCode), Rejection>(err) + }) + .unify() + .and(warp::any().map(move || endpoints.clone())) + .and(crate::util::require_host()) + .and(warp::any().map(inject_db)) + .then(|content: (String, String, StatusCode), endpoints: IndiewebEndpoints, host: Authority, db: D| async move { + let owner = format!("https://{}/", host.as_str()); + let blog_name = db.get_setting(crate::database::Settings::SiteName, &owner).await + .unwrap_or_else(|_| "Kitty Box!".to_string()); + let feeds = db.get_channels(&owner).await.unwrap_or_default(); + let (title, content, code) = content; + warp::reply::with_status(warp::reply::html(Template { + title: &title, + blog_name: &blog_name, + endpoints: Some(endpoints), + feeds, + user: None, // TODO + content, + }.to_string()), code) + }) + +} + +static STYLE_CSS: &[u8] = include_bytes!("./style.css"); +static ONBOARDING_JS: &[u8] = include_bytes!("./onboarding.js"); +static ONBOARDING_CSS: &[u8] = include_bytes!("./onboarding.css"); + +static MIME_JS: &str = "application/javascript"; +static MIME_CSS: &str = "text/css"; + +fn _dispatch_static(name: &str) -> Option<(&'static [u8], &'static str)> { + match name { + "style.css" => Some((STYLE_CSS, MIME_CSS)), + "onboarding.js" => Some((ONBOARDING_JS, MIME_JS)), + "onboarding.css" => Some((ONBOARDING_CSS, MIME_CSS)), + _ => None + } +} + +pub fn static_files() -> impl Filter + Copy { + use futures_util::future; + + warp::get() + .and(warp::path::param() + .and_then(|filename: String| { + match _dispatch_static(&filename) { + Some((buf, content_type)) => future::ok( + warp::reply::with_header( + buf, "Content-Type", content_type + ) + ), + None => future::err(warp::reject()) + } + })) + .or(warp::head() + .and(warp::path::param() + .and_then(|filename: String| { + match _dispatch_static(&filename) { + Some((buf, content_type)) => future::ok( + warp::reply::with_header( + warp::reply::with_header( + warp::reply(), "Content-Type", content_type + ), + "Content-Length", buf.len() + ) + ), + None => future::err(warp::reject()) + } + }))) +} diff --git a/kittybox-rs/src/frontend/onboarding.css b/kittybox-rs/src/frontend/onboarding.css new file mode 100644 index 0000000..6f191b9 --- /dev/null +++ b/kittybox-rs/src/frontend/onboarding.css @@ -0,0 +1,33 @@ +form.onboarding > ul#progressbar > li.active { + font-weight: bold; +} +form.onboarding > ul#progressbar { + display: flex; list-style: none; justify-content: space-around; +} + +form.onboarding > fieldset > div.switch_card_buttons { + display: flex; + justify-content: space-between; + width: 100%; +} +form.onboarding > fieldset > div.switch_card_buttons button:last-child { + margin-left: auto; +} +.form_group, .multi_input { + display: flex; + flex-direction: column; +} +.multi_input { + align-items: start; +} +.multi_input > input { + width: 100%; + align-self: stretch; +} +form.onboarding > fieldset > .form_group + * { + margin-top: .75rem; +} +form.onboarding textarea { + width: 100%; + resize: vertical; +} diff --git a/kittybox-rs/src/frontend/onboarding.js b/kittybox-rs/src/frontend/onboarding.js new file mode 100644 index 0000000..7f9aa32 --- /dev/null +++ b/kittybox-rs/src/frontend/onboarding.js @@ -0,0 +1,87 @@ +const firstOnboardingCard = "intro"; + +function switchOnboardingCard(card) { + Array.from(document.querySelectorAll("form.onboarding > fieldset")).map(node => { + if (node.id == card) { + node.style.display = "block"; + } else { + node.style.display = "none"; + } + }); + + Array.from(document.querySelectorAll("form.onboarding > ul#progressbar > li")).map(node => { + if (node.id == card) { + node.classList.add("active") + } else { + node.classList.remove("active") + } + }) +}; + +window.kittybox_onboarding = { + switchOnboardingCard +}; + +document.querySelector("form.onboarding > ul#progressbar").style.display = ""; +switchOnboardingCard(firstOnboardingCard); + +function switchCardOnClick(event) { + switchOnboardingCard(event.target.dataset.card) +} + +function multiInputAddMore(event) { + let parent = event.target.parentElement; + let template = event.target.parentElement.querySelector("template").content.cloneNode(true); + parent.prepend(template); +} + +Array.from(document.querySelectorAll("form.onboarding > fieldset button.switch_card")).map(button => { + button.addEventListener("click", switchCardOnClick) +}) + +Array.from(document.querySelectorAll("form.onboarding > fieldset div.multi_input > button.add_more")).map(button => { + button.addEventListener("click", multiInputAddMore) + multiInputAddMore({ target: button }); +}) + +const form = document.querySelector("form.onboarding"); +console.log(form); +form.onsubmit = async (event) => { + console.log(event); + event.preventDefault(); + const form = event.target; + const json = { + user: { + type: ["h-card"], + properties: { + name: [form.querySelector("#hcard_name").value], + pronoun: Array.from(form.querySelectorAll("#hcard_pronouns")).map(input => input.value).filter(i => i != ""), + url: Array.from(form.querySelectorAll("#hcard_url")).map(input => input.value).filter(i => i != ""), + note: [form.querySelector("#hcard_note").value] + } + }, + first_post: { + type: ["h-entry"], + properties: { + content: [form.querySelector("#first_post_content").value] + } + }, + blog_name: form.querySelector("#blog_name").value, + feeds: Array.from(form.querySelectorAll(".multi_input#custom_feeds > fieldset.feed")).map(form => { + return { + name: form.querySelector("#feed_name").value, + slug: form.querySelector("#feed_slug").value + } + }).filter(feed => feed.name == "" || feed.slug == "") + }; + + await fetch("/", { + method: "POST", + body: JSON.stringify(json), + headers: { "Content-Type": "application/json" } + }).then(response => { + if (response.status == 201) { + window.location.href = window.location.href; + } + }) +} \ No newline at end of file diff --git a/kittybox-rs/src/frontend/style.css b/kittybox-rs/src/frontend/style.css new file mode 100644 index 0000000..109bba0 --- /dev/null +++ b/kittybox-rs/src/frontend/style.css @@ -0,0 +1,194 @@ +@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@500&family=Lato&display=swap'); + +:root { + font-family: var(--font-normal); + --font-normal: 'Lato', sans-serif; + --font-accent: 'Caveat', cursive; + --type-scale: 1.250; + + --primary-accent: purple; + --secondary-accent: gold; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-accent); +} +.titanic { + font-size: 3.815rem +} +h1, .xxxlarge { + margin-top: 0; + margin-bottom: 0; + font-size: 3.052rem; +} +h2, .xxlarge {font-size: 2.441rem;} +h3, .xlarge {font-size: 1.953rem;} +h4, .larger {font-size: 1.563rem;} +h5, .large {font-size: 1.25rem;} +h6, .normal {font-size: 1rem;} +small, .small { font-size: 0.8em; } + +nav#headerbar { + background: var(--primary-accent); + color: whitesmoke; + border-bottom: .75rem solid var(--secondary-accent); + padding: .3rem; + vertical-align: center; + position: sticky; + top: 0; +} +nav#headerbar a#homepage { + font-weight: bolder; + font-family: var(--font-accent); + font-size: 2rem; +} +nav#headerbar > ul { + display: flex; + padding: inherit; + margin: inherit; + gap: .75em; +} +nav#headerbar > ul > li { + display: inline-flex; + flex-direction: column; + marker: none; + padding: inherit; + margin: inherit; + justify-content: center; +} +nav#headerbar > ul > li.shiftright { + margin-left: auto; +} +nav#headerbar a { + color: white; +} +body > main { + max-width: 60rem; + margin: auto; + padding: .75rem; +} +body > footer { + text-align: center; +} +.sidebyside { + display: flex; + flex-wrap: wrap; + gap: .75rem; + margin-top: .75rem; + margin-bottom: .75rem; +} +.sidebyside > * { + width: 100%; + margin-top: 0; + margin-bottom: 0; + border: .125rem solid black; + border-radius: .75rem; + padding: .75rem; + margin-top: 0 !important; + margin-bottom: 0 !important; + flex-basis: 28rem; + flex-grow: 1; +} +article > * + * { + margin-top: .75rem; +} +article > header { + padding-bottom: .75rem; + border-bottom: 1px solid gray; +} +article > footer { + border-top: 1px solid gray; +} +article.h-entry, article.h-feed, article.h-card, article.h-event { + border: 2px solid black; + border-radius: .75rem; + padding: .75rem; + margin-top: .75rem; + margin-bottom: .75rem; +} +.webinteractions > ul.counters { + display: inline-flex; + padding: inherit; + margin: inherit; + gap: .75em; + flex-wrap: wrap; +} +.webinteractions > ul.counters > li > .icon { + font-size: 1.5em; +} +.webinteractions > ul.counters > li { + display: inline-flex; + align-items: center; + gap: .5em; +} +article.h-entry > header.metadata ul { + padding-inline-start: unset; + margin: unset; +} +article.h-entry > header.metadata ul.categories { + flex-wrap: wrap; + display: inline-flex; + list-style-type: none; +} +article.h-entry > header.metadata ul.categories li { + display: inline; + margin-inline-start: unset; +} +article.h-entry > header.metadata ul li { + margin-inline-start: 2.5em; +} +article.h-entry .e-content pre { + border: 1px solid gray; + border-radius: 0.5em; + overflow-y: auto; + padding: 0.5em; +} +article.h-entry img.u-photo { + max-width: 80%; + max-height: 90vh; + display: block; + margin: auto; +} +article.h-entry img.u-photo + * { + margin-top: .75rem; +} +article.h-entry > header.metadata span + span::before { + content: " | " +} +li.p-category::before { + content: " #"; +} + +article.h-entry ul.categories { + gap: .2em; +} +article.h-card img.u-photo { + border-radius: 100%; + float: left; + height: 8rem; + border: 1px solid gray; + margin-right: .75em; + object-fit: cover; + aspect-ratio: 1; +} + +.mini-h-card img { + height: 2em; + display: inline-block; + border: 2px solid gray; + border-radius: 100%; + margin-right: 0.5rem; +} + +.mini-h-card * { + vertical-align: middle; +} + +.mini-h-card a { + text-decoration: none; +} diff --git a/kittybox-rs/src/index.html b/kittybox-rs/src/index.html new file mode 100644 index 0000000..1fc2a96 --- /dev/null +++ b/kittybox-rs/src/index.html @@ -0,0 +1,182 @@ + + + + Kittybox-Micropub debug client + + + + +

Kittybox-Micropub debug client

+ +
+

+ In a pinch? Lost your Micropub client, but need to make a quick announcement? + Worry not, the debug client has your back. I just hope you have a spare Micropub token stored somewhere like I do... +

+ +
+
+ Authorization details +
+ + + +

Get an access token (will open in a new tab)

+
+
+
+ Post details: +
+ + +
+
+ + +
+
+ + +
+
+ Channels +
+ + +
+ +
+ + +
+ + +
+
+ +
+
+ + \ No newline at end of file diff --git a/kittybox-rs/src/indieauth.rs b/kittybox-rs/src/indieauth.rs new file mode 100644 index 0000000..57c0301 --- /dev/null +++ b/kittybox-rs/src/indieauth.rs @@ -0,0 +1,291 @@ +use url::Url; +use serde::{Serialize, Deserialize}; +use warp::{Filter, Rejection, reject::MissingHeader}; + +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +pub struct User { + pub me: Url, + pub client_id: Url, + scope: String, +} + +#[derive(Debug, Clone, PartialEq, Copy)] +pub enum ErrorKind { + PermissionDenied, + NotAuthorized, + TokenEndpointError, + JsonParsing, + Other +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct TokenEndpointError { + error: String, + error_description: String +} + +#[derive(Debug)] +pub struct IndieAuthError { + source: Option>, + kind: ErrorKind, + msg: String +} + +impl std::error::Error for IndieAuthError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source.as_ref().map(|e| e.as_ref() as &dyn std::error::Error) + } +} + +impl std::fmt::Display for IndieAuthError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match match self.kind { + ErrorKind::TokenEndpointError => write!(f, "token endpoint returned an error: "), + ErrorKind::JsonParsing => write!(f, "error while parsing token endpoint response: "), + ErrorKind::NotAuthorized => write!(f, "token endpoint did not recognize the token: "), + ErrorKind::PermissionDenied => write!(f, "token endpoint rejected the token: "), + ErrorKind::Other => write!(f, "token endpoint communication error: "), + } { + Ok(_) => write!(f, "{}", self.msg), + Err(err) => Err(err) + } + } +} + +impl From for IndieAuthError { + fn from(err: serde_json::Error) -> Self { + Self { + msg: format!("{}", err), + source: Some(Box::new(err)), + kind: ErrorKind::JsonParsing, + } + } +} + +impl From for IndieAuthError { + fn from(err: reqwest::Error) -> Self { + Self { + msg: format!("{}", err), + source: Some(Box::new(err)), + kind: ErrorKind::Other, + } + } +} + +impl warp::reject::Reject for IndieAuthError {} + +impl User { + pub fn check_scope(&self, scope: &str) -> bool { + self.scopes().any(|i| i == scope) + } + pub fn scopes(&self) -> std::str::SplitAsciiWhitespace<'_> { + self.scope.split_ascii_whitespace() + } + pub fn new(me: &str, client_id: &str, scope: &str) -> Self { + Self { + me: Url::parse(me).unwrap(), + client_id: Url::parse(client_id).unwrap(), + scope: scope.to_string(), + } + } +} + +pub fn require_token(token_endpoint: String, http: reqwest::Client) -> impl Filter + Clone { + // It might be OK to panic here, because we're still inside the initialisation sequence for now. + // Proper error handling on the top of this should be used though. + let token_endpoint_uri = url::Url::parse(&token_endpoint) + .expect("Couldn't parse the token endpoint URI!"); + warp::any() + .map(move || token_endpoint_uri.clone()) + .and(warp::any().map(move || http.clone())) + .and(warp::header::("Authorization").recover(|err: Rejection| async move { + if err.find::().is_some() { + Err(IndieAuthError { + source: None, + msg: "No Authorization header provided.".to_string(), + kind: ErrorKind::NotAuthorized + }.into()) + } else { + Err(err) + } + }).unify()) + .and_then(|token_endpoint, http: reqwest::Client, token| async move { + use hyper::StatusCode; + + match http + .get(token_endpoint) + .header("Authorization", token) + .header("Accept", "application/json") + .send() + .await + { + Ok(res) => match res.status() { + StatusCode::OK => match res.json::().await { + Ok(json) => match serde_json::from_value::(json.clone()) { + Ok(user) => Ok(user), + Err(err) => { + if let Some(false) = json["active"].as_bool() { + Err(IndieAuthError { + source: None, + kind: ErrorKind::NotAuthorized, + msg: "The token is not active for this user.".to_owned() + }.into()) + } else { + Err(IndieAuthError::from(err).into()) + } + } + } + Err(err) => Err(IndieAuthError::from(err).into()) + }, + StatusCode::BAD_REQUEST => { + match res.json::().await { + Ok(err) => { + if err.error == "unauthorized" { + Err(IndieAuthError { + source: None, + kind: ErrorKind::NotAuthorized, + msg: err.error_description + }.into()) + } else { + Err(IndieAuthError { + source: None, + kind: ErrorKind::TokenEndpointError, + msg: err.error_description + }.into()) + } + }, + Err(err) => Err(IndieAuthError::from(err).into()) + } + }, + _ => Err(IndieAuthError { + source: None, + msg: format!("Token endpoint returned {}", res.status()), + kind: ErrorKind::TokenEndpointError + }.into()) + }, + Err(err) => Err(warp::reject::custom(IndieAuthError::from(err))) + } + }) +} + +#[cfg(test)] +mod tests { + use super::{User, IndieAuthError, require_token}; + use httpmock::prelude::*; + + #[test] + fn user_scopes_are_checkable() { + let user = User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + ); + + assert!(user.check_scope("create")); + assert!(!user.check_scope("delete")); + } + + #[inline] + fn get_http_client() -> reqwest::Client { + reqwest::Client::new() + } + + #[tokio::test] + async fn test_require_token_with_token() { + let server = MockServer::start_async().await; + server.mock_async(|when, then| { + when.path("/token") + .header("Authorization", "Bearer token"); + + then.status(200) + .header("Content-Type", "application/json") + .json_body(serde_json::to_value(User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + )).unwrap()); + }).await; + + let filter = require_token(server.url("/token"), get_http_client()); + + let res: User = warp::test::request() + .path("/") + .header("Authorization", "Bearer token") + .filter(&filter) + .await + .unwrap(); + + assert_eq!(res.me.as_str(), "https://fireburn.ru/") + } + + #[tokio::test] + async fn test_require_token_fake_token() { + let server = MockServer::start_async().await; + server.mock_async(|when, then| { + when.path("/refuse_token"); + + then.status(200) + .json_body(serde_json::json!({"active": false})); + }).await; + + let filter = require_token(server.url("/refuse_token"), get_http_client()); + + let res = warp::test::request() + .path("/") + .header("Authorization", "Bearer token") + .filter(&filter) + .await + .unwrap_err(); + + let err: &IndieAuthError = res.find().unwrap(); + assert_eq!(err.kind, super::ErrorKind::NotAuthorized); + } + + #[tokio::test] + async fn test_require_token_no_token() { + let server = MockServer::start_async().await; + let mock = server.mock_async(|when, then| { + when.path("/should_never_be_called"); + + then.status(500); + }).await; + let filter = require_token(server.url("/should_never_be_called"), get_http_client()); + + let res = warp::test::request() + .path("/") + .filter(&filter) + .await + .unwrap_err(); + + let err: &IndieAuthError = res.find().unwrap(); + assert_eq!(err.kind, super::ErrorKind::NotAuthorized); + + mock.assert_hits_async(0).await; + } + + #[tokio::test] + async fn test_require_token_400_error_unauthorized() { + let server = MockServer::start_async().await; + server.mock_async(|when, then| { + when.path("/refuse_token_with_400"); + + then.status(400) + .json_body(serde_json::json!({ + "error": "unauthorized", + "error_description": "The token provided was malformed" + })); + }).await; + + let filter = require_token(server.url("/refuse_token_with_400"), get_http_client()); + + let res = warp::test::request() + .path("/") + .header("Authorization", "Bearer token") + .filter(&filter) + .await + .unwrap_err(); + + let err: &IndieAuthError = res.find().unwrap(); + assert_eq!(err.kind, super::ErrorKind::NotAuthorized); + } +} diff --git a/kittybox-rs/src/lib.rs b/kittybox-rs/src/lib.rs new file mode 100644 index 0000000..1800b5b --- /dev/null +++ b/kittybox-rs/src/lib.rs @@ -0,0 +1,103 @@ +#![forbid(unsafe_code)] +#![warn(clippy::todo)] + +pub mod metrics; +/// Database abstraction layer for Kittybox, allowing the CMS to work with any kind of database. +pub mod database; +pub mod micropub; +pub mod media; +pub mod indieauth; +pub mod frontend; + +pub(crate) mod rejections { + #[derive(Debug)] + pub(crate) struct UnacceptableContentType; + impl warp::reject::Reject for UnacceptableContentType {} + + #[derive(Debug)] + pub(crate) struct HostHeaderUnset; + impl warp::reject::Reject for HostHeaderUnset {} +} + +pub static MICROPUB_CLIENT: &[u8] = include_bytes!("./index.html"); + +pub mod util { + use warp::{Filter, host::Authority}; + use super::rejections; + + pub fn require_host() -> impl Filter + Copy { + warp::host::optional() + .and_then(|authority: Option| async move { + authority.ok_or_else(|| warp::reject::custom(rejections::HostHeaderUnset)) + }) + } + + pub fn parse_accept() -> impl Filter + Copy { + warp::header::value("Accept").and_then(|accept: warp::http::HeaderValue| async move { + let mut accept: http_types::content::Accept = { + // This is unneccesarily complicated because I want to reuse some http-types parsing + // and http-types has constructor for Headers private so I need to construct + // a mock Request to reason about headers... this is so dumb wtf + // so much for zero-cost abstractions, huh + let bytes: &[u8] = accept.as_bytes(); + let value = http_types::headers::HeaderValue::from_bytes(bytes.to_vec()).unwrap(); + let values: http_types::headers::HeaderValues = vec![value].into(); + let mut request = http_types::Request::new(http_types::Method::Get, "http://example.com/"); + request.append_header("Accept".parse::().unwrap(), &values); + http_types::content::Accept::from_headers(&request).unwrap().unwrap() + }; + + // This code is INCREDIBLY dumb, honestly... + // why did I even try to use it? + // TODO vendor this stuff in so I can customize it + match accept.negotiate(&[ + "text/html; encoding=\"utf-8\"".into(), + "application/json; encoding=\"utf-8\"".into(), + "text/html".into(), + "application/json".into(), + + ]) { + Ok(mime) => { + Ok(http_types::Mime::from(mime.value().as_str())) + }, + Err(err) => { + log::error!("Content-Type negotiation error: {:?}, accepting: {:?}", err, accept); + Err(warp::reject::custom(rejections::UnacceptableContentType)) + } + } + }) + } + + mod tests { + #[tokio::test] + async fn test_require_host_with_host() { + use super::require_host; + + let filter = require_host(); + + let res = warp::test::request() + .path("/") + .header("Host", "localhost:8080") + .filter(&filter) + .await + .unwrap(); + + assert_eq!(res, "localhost:8080"); + + } + + #[tokio::test] + async fn test_require_host_no_host() { + use super::require_host; + + let filter = require_host(); + + let res = warp::test::request() + .path("/") + .filter(&filter) + .await; + + assert!(res.is_err()); + } + } +} diff --git a/kittybox-rs/src/main.rs b/kittybox-rs/src/main.rs new file mode 100644 index 0000000..eb70885 --- /dev/null +++ b/kittybox-rs/src/main.rs @@ -0,0 +1,256 @@ +use log::{debug, error, info}; +use std::{convert::Infallible, env, time::Duration}; +use url::Url; +use warp::{Filter, host::Authority}; + +#[tokio::main] +async fn main() { + // TODO turn into a feature so I can enable and disable it + #[cfg(debug_assertions)] + console_subscriber::init(); + + // TODO use tracing instead of log + let logger_env = env_logger::Env::new().filter_or("RUST_LOG", "info"); + env_logger::init_from_env(logger_env); + + info!("Starting the kittybox server..."); + + let backend_uri: String = match env::var("BACKEND_URI") { + Ok(val) => { + debug!("Backend URI: {}", val); + val + } + Err(_) => { + error!("BACKEND_URI is not set, cannot find a database"); + std::process::exit(1); + } + }; + let token_endpoint: Url = match env::var("TOKEN_ENDPOINT") { + Ok(val) => { + debug!("Token endpoint: {}", val); + match Url::parse(&val) { + Ok(val) => val, + _ => { + error!("Token endpoint URL cannot be parsed, aborting."); + std::process::exit(1) + } + } + } + Err(_) => { + error!("TOKEN_ENDPOINT is not set, will not be able to authorize users!"); + std::process::exit(1) + } + }; + let authorization_endpoint: Url = match env::var("AUTHORIZATION_ENDPOINT") { + Ok(val) => { + debug!("Auth endpoint: {}", val); + match Url::parse(&val) { + Ok(val) => val, + _ => { + error!("Authorization endpoint URL cannot be parsed, aborting."); + std::process::exit(1) + } + } + } + Err(_) => { + error!("AUTHORIZATION_ENDPOINT is not set, will not be able to confirm token and ID requests using IndieAuth!"); + std::process::exit(1) + } + }; + + //let internal_token: Option = env::var("KITTYBOX_INTERNAL_TOKEN").ok(); + + /*let cookie_secret: String = match env::var("COOKIE_SECRET").ok() { + Some(value) => value, + None => { + if let Ok(filename) = env::var("COOKIE_SECRET_FILE") { + use tokio::io::AsyncReadExt; + + let mut file = tokio::fs::File::open(filename).await.map_err(|e| { + error!("Couldn't open the cookie secret file: {}", e); + std::process::exit(1); + }).unwrap(); + let mut temp_string = String::new(); + file.read_to_string(&mut temp_string).await.map_err(|e| { + error!("Couldn't read the cookie secret from file: {}", e); + std::process::exit(1); + }).unwrap(); + + temp_string + } else { + error!("COOKIE_SECRET or COOKIE_SECRET_FILE is not set, will not be able to log in users securely!"); + std::process::exit(1); + } + } + };*/ + + let listen_at = match env::var("SERVE_AT") + .ok() + .unwrap_or_else(|| "[::]:8080".to_string()) + .parse::() { + Ok(addr) => addr, + Err(e) => { + error!("Cannot parse SERVE_AT: {}", e); + std::process::exit(1); + } + }; + + // This thing handles redirects automatically but is type-incompatible with hyper::Client + // Bonus: less generics to be aware of, this thing hides its complexity + let http: reqwest::Client = { + #[allow(unused_mut)] + let mut builder = reqwest::Client::builder() + .user_agent(concat!( + env!("CARGO_PKG_NAME"), + "/", + env!("CARGO_PKG_VERSION") + )); + // TODO: add a root certificate if there's an environment variable pointing at it + //builder = builder.add_root_certificate(reqwest::Certificate::from_pem(todo!())); + + builder.build().unwrap() + }; + + if backend_uri.starts_with("redis") { + println!("The Redis backend is deprecated."); + std::process::exit(1); + } else if backend_uri.starts_with("file") { + + let database = { + let folder = backend_uri.strip_prefix("file://").unwrap(); + let path = std::path::PathBuf::from(folder); + match kittybox::database::FileStorage::new(path).await { + Ok(db) => db, + Err(err) => { + error!("Error creating database: {:?}", err); + std::process::exit(1); + } + } + }; + + let endpoints = kittybox::frontend::IndiewebEndpoints { + authorization_endpoint: authorization_endpoint.to_string(), + token_endpoint: token_endpoint.to_string(), + webmention: None, + microsub: None, + }; + + let homepage = warp::get() + .and(warp::path::end()) + .and(kittybox::frontend::homepage(database.clone(), endpoints.clone())); + + let onboarding = warp::path("onboarding") + .and(warp::path::end()) + .and(kittybox::frontend::onboarding( + database.clone(), + endpoints.clone(), + http.clone() + )); + + let micropub = warp::path("micropub") + .and(warp::path::end() + .and(kittybox::micropub::micropub( + database.clone(), + token_endpoint.to_string(), + http.clone() + )) + .or(warp::get() + .and(warp::path("client")) + .and(warp::path::end()) + .map(|| warp::reply::html(kittybox::MICROPUB_CLIENT)))); + + let media = warp::path("media") + .and(warp::path::end() + .and(kittybox::media::media()) + .or(kittybox::util::require_host() + .and(warp::path::param()) + .map(|_host: Authority, path: String| format!("media file {}", path)))); + + // TODO remember how login logic works because I forgor + let login = warp::path("login") + .and(warp::path("callback") + .map(|| "callback!") + // TODO form on GET and handler on POST + .or(warp::path::end().map(|| "login page!"))); + + // TODO prettier error response + let coffee = warp::path("coffee") + .map(|| warp::reply::with_status("I'm a teapot!", warp::http::StatusCode::IM_A_TEAPOT)); + + let static_files = warp::path("static") + .and(kittybox::frontend::static_files()); + + let catchall = kittybox::frontend::catchall( + database.clone(), + endpoints.clone() + ); + + let health = warp::path("health").and(warp::path::end()).map(|| "OK"); + let metrics = warp::path("metrics").and(warp::path::end()).map(kittybox::metrics::gather); + + let app = homepage + .or(onboarding) + .or(metrics + .or(health)) + .or(static_files) + .or(login) + .or(coffee) + .or(micropub) + .or(media) + .or(catchall) + .with(warp::log("kittybox")) + .with(kittybox::metrics::metrics(vec![ + "health".to_string(), + "micropub".to_string(), + "static".to_string(), + "media".to_string(), + "metrics".to_string() + ])) + ; + + let svc = warp::service(app); + + let mut listenfd = listenfd::ListenFd::from_env(); + let tcp_listener: std::net::TcpListener = if let Ok(Some(listener)) = listenfd.take_tcp_listener(0) { + listener + } else { + std::net::TcpListener::bind(listen_at).unwrap() + }; + tcp_listener.set_nonblocking(true).unwrap(); + + info!("Listening on {}", tcp_listener.local_addr().unwrap()); + let server = hyper::server::Server::from_tcp(tcp_listener) + .unwrap() + .tcp_keepalive(Some(Duration::from_secs(30 * 60))) + .serve(hyper::service::make_service_fn(move |_| { + let service = svc.clone(); + async move { + Ok::<_, Infallible>(service) + } + })) + .with_graceful_shutdown(async move { + // Defer to C-c handler whenever we're not on Unix + // TODO consider using a diverging future here + #[cfg(not(unix))] + return tokio::signal::ctrl_c().await.unwrap(); + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + + signal(SignalKind::terminate()) + .unwrap() + .recv() + .await + .unwrap() + } + }); + + if let Err(err) = server.await { + error!("Error serving requests: {}", err); + std::process::exit(1); + } + } else { + println!("Unknown backend, not starting."); + std::process::exit(1); + } +} diff --git a/kittybox-rs/src/media/mod.rs b/kittybox-rs/src/media/mod.rs new file mode 100644 index 0000000..0d46e0c --- /dev/null +++ b/kittybox-rs/src/media/mod.rs @@ -0,0 +1,46 @@ +use futures_util::StreamExt; +use bytes::buf::Buf; +use warp::{Filter, Rejection, Reply, multipart::{FormData, Part}}; + +pub fn query() -> impl Filter + Clone { + warp::get() + .and(crate::util::require_host()) + .map(|host| "media endpoint query...") +} + +pub fn options() -> impl Filter + Clone { + warp::options() + .map(|| warp::reply::json::>(&None)) + .with(warp::reply::with::header("Allow", "GET, POST")) +} + +pub fn upload() -> impl Filter + Clone { + warp::post() + .and(crate::util::require_host()) + .and(warp::multipart::form().max_length(1024*1024*150/*mb*/)) + .and_then(|host, mut form: FormData| async move { + // TODO get rid of the double unwrap() here + let file: Part = form.next().await.unwrap().unwrap(); + log::debug!("Uploaded: {:?}, type: {:?}", file.filename(), file.content_type()); + + let mut data = file.stream(); + while let Some(buf) = data.next().await { + // TODO save it into a file + log::debug!("buffer length: {:?}", buf.map(|b| b.remaining())); + } + Ok::<_, warp::Rejection>(warp::reply::with_header( + warp::reply::with_status( + "", + warp::http::StatusCode::CREATED + ), + "Location", + "./awoo.png" + )) + }) +} + +pub fn media() -> impl Filter + Clone { + upload() + .or(query()) + .or(options()) +} diff --git a/kittybox-rs/src/metrics.rs b/kittybox-rs/src/metrics.rs new file mode 100644 index 0000000..48f5d9b --- /dev/null +++ b/kittybox-rs/src/metrics.rs @@ -0,0 +1,21 @@ +#![allow(unused_imports, dead_code)] +use async_trait::async_trait; +use lazy_static::lazy_static; +use std::time::{Duration, Instant}; +use prometheus::Encoder; + +// TODO: Vendor in the Metrics struct from warp_prometheus and rework the path matching algorithm + +pub fn metrics(path_includes: Vec) -> warp::log::Log { + let metrics = warp_prometheus::Metrics::new(prometheus::default_registry(), &path_includes); + warp::log::custom(move |info| metrics.http_metrics(info)) +} + +pub fn gather() -> Vec { + let mut buffer: Vec = vec![]; + let encoder = prometheus::TextEncoder::new(); + let metric_families = prometheus::gather(); + encoder.encode(&metric_families, &mut buffer).unwrap(); + + buffer +} diff --git a/kittybox-rs/src/micropub/get.rs b/kittybox-rs/src/micropub/get.rs new file mode 100644 index 0000000..718714a --- /dev/null +++ b/kittybox-rs/src/micropub/get.rs @@ -0,0 +1,82 @@ +use crate::database::{MicropubChannel, Storage}; +use crate::indieauth::User; +use crate::ApplicationState; +use tide::prelude::{json, Deserialize}; +use tide::{Request, Response, Result}; + +#[derive(Deserialize)] +struct QueryOptions { + q: String, + url: Option, +} + +pub async fn get_handler(req: Request>) -> Result +where + Backend: Storage + Send + Sync, +{ + let user = req.ext::().unwrap(); + let backend = &req.state().storage; + let media_endpoint = &req.state().media_endpoint; + let query = req.query::().unwrap_or(QueryOptions { + q: "".to_string(), + url: None, + }); + match &*query.q { + "config" => { + let channels: Vec; + match backend.get_channels(user.me.as_str()).await { + Ok(chans) => channels = chans, + Err(err) => return Ok(err.into()) + } + Ok(Response::builder(200).body(json!({ + "q": ["source", "config", "channel"], + "channels": channels, + "media-endpoint": media_endpoint + })).build()) + }, + "channel" => { + let channels: Vec; + match backend.get_channels(user.me.as_str()).await { + Ok(chans) => channels = chans, + Err(err) => return Ok(err.into()) + } + Ok(Response::builder(200).body(json!(channels)).build()) + } + "source" => { + if user.check_scope("create") || user.check_scope("update") || user.check_scope("delete") || user.check_scope("undelete") { + if let Some(url) = query.url { + match backend.get_post(&url).await { + Ok(post) => if let Some(post) = post { + Ok(Response::builder(200).body(post).build()) + } else { + Ok(Response::builder(404).build()) + }, + Err(err) => Ok(err.into()) + } + } else { + Ok(Response::builder(400).body(json!({ + "error": "invalid_request", + "error_description": "Please provide `url`." + })).build()) + } + } else { + Ok(Response::builder(401).body(json!({ + "error": "insufficient_scope", + "error_description": "You don't have the required scopes to proceed.", + "scope": "update" + })).build()) + } + }, + // TODO: ?q=food, ?q=geo, ?q=contacts + // Depends on indexing posts + // Errors + "" => Ok(Response::builder(400).body(json!({ + "error": "invalid_request", + "error_description": "No ?q= parameter specified. Try ?q=config maybe?" + })).build()), + _ => Ok(Response::builder(400).body(json!({ + "error": "invalid_request", + "error_description": "Unsupported ?q= query. Try ?q=config and see the q array for supported values." + })).build()) + } +} diff --git a/kittybox-rs/src/micropub/mod.rs b/kittybox-rs/src/micropub/mod.rs new file mode 100644 index 0000000..f426c77 --- /dev/null +++ b/kittybox-rs/src/micropub/mod.rs @@ -0,0 +1,964 @@ +use std::convert::Infallible; +use std::fmt::Display; +use either::Either; +use log::{info, warn, error}; +use warp::http::StatusCode; +use warp::{Filter, Rejection, reject::InvalidQuery}; +use serde_json::json; +use serde::{Serialize, Deserialize}; +use crate::database::{MicropubChannel, Storage, StorageError}; +use crate::indieauth::User; +use crate::micropub::util::form_to_mf2_json; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +#[serde(rename_all = "kebab-case")] +enum QueryType { + Source, + Config, + Channel, + SyndicateTo +} + +#[derive(Serialize, Deserialize, Debug)] +struct MicropubQuery { + q: QueryType, + url: Option +} + +#[derive(Serialize, Deserialize, PartialEq, Debug)] +#[serde(rename_all = "snake_case")] +enum ErrorType { + AlreadyExists, + Forbidden, + InternalServerError, + InvalidRequest, + InvalidScope, + NotAuthorized, + NotFound, + UnsupportedMediaType +} + +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct MicropubError { + error: ErrorType, + error_description: String +} + +impl From for MicropubError { + fn from(err: StorageError) -> Self { + Self { + error: match err.kind() { + crate::database::ErrorKind::NotFound => ErrorType::NotFound, + _ => ErrorType::InternalServerError + }, + error_description: format!("Backend error: {}", err) + } + } +} + +impl std::error::Error for MicropubError {} + +impl Display for MicropubError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Micropub error: ")?; + f.write_str(&self.error_description) + } +} + +impl From<&MicropubError> for StatusCode { + fn from(err: &MicropubError) -> Self { + use ErrorType::*; + match err.error { + AlreadyExists => StatusCode::CONFLICT, + Forbidden => StatusCode::FORBIDDEN, + InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, + InvalidRequest => StatusCode::BAD_REQUEST, + InvalidScope => StatusCode::UNAUTHORIZED, + NotAuthorized => StatusCode::UNAUTHORIZED, + NotFound => StatusCode::NOT_FOUND, + UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE, + } + } +} +impl From for StatusCode { + fn from(err: MicropubError) -> Self { + (&err).into() + } +} + +impl From for MicropubError { + fn from(err: serde_json::Error) -> Self { + use ErrorType::*; + Self { + error: InvalidRequest, + error_description: err.to_string() + } + } +} + +impl MicropubError { + fn new(error: ErrorType, error_description: &str) -> Self { + Self { + error, + error_description: error_description.to_owned() + } + } +} + +impl warp::reject::Reject for MicropubError {} + +mod post; +pub(crate) use post::normalize_mf2; + +mod util { + use serde_json::json; + + pub(crate) fn form_to_mf2_json(form: Vec<(String, String)>) -> serde_json::Value { + let mut mf2 = json!({"type": [], "properties": {}}); + for (k, v) in form { + if k == "h" { + mf2["type"] + .as_array_mut() + .unwrap() + .push(json!("h-".to_string() + &v)); + } else if k != "access_token" { + let key = k.strip_suffix("[]").unwrap_or(&k); + match mf2["properties"][key].as_array_mut() { + Some(prop) => prop.push(json!(v)), + None => mf2["properties"][key] = json!([v]), + } + } + } + if mf2["type"].as_array().unwrap().is_empty() { + mf2["type"].as_array_mut().unwrap().push(json!("h-entry")); + } + mf2 + } + + #[cfg(test)] + mod tests { + use serde_json::json; + #[test] + fn test_form_to_mf2() { + assert_eq!( + super::form_to_mf2_json( + serde_urlencoded::from_str( + "h=entry&content=something%20interesting" + ).unwrap() + ), + json!({ + "type": ["h-entry"], + "properties": { + "content": ["something interesting"] + } + }) + ) + } + } +} + +#[derive(Debug)] +struct FetchedPostContext { + url: url::Url, + mf2: serde_json::Value, + webmention: Option +} + +fn populate_reply_context(mf2: &serde_json::Value, prop: &str, ctxs: &[FetchedPostContext]) -> Option { + if mf2["properties"][prop].is_array() { + Some(json!( + mf2["properties"][prop] + .as_array() + // Safe to unwrap because we checked its existence and type + // And it's not like we can make it disappear without unsafe code + .unwrap() + .iter() + // This seems to be O(n^2) and I don't like it. + // Nevertheless, I lack required knowledge to optimize it. Also, it works, so... + .map(|i| ctxs.iter() + .find(|ctx| Some(ctx.url.as_str()) == i.as_str()) + .and_then(|ctx| ctx.mf2["items"].get(0)) + .or(Some(i)) + .unwrap()) + .collect::>() + )) + } else { + None + } +} + +// TODO actually save the post to the database and schedule post-processing +pub(crate) async fn _post( + user: crate::indieauth::User, + uid: String, + mf2: serde_json::Value, + db: D, + http: reqwest::Client +) -> Result { + // Here, we have the following guarantees: + // - The user is the same user for this host (guaranteed by ensure_same_user) + // - The MF2-JSON document is normalized (guaranteed by normalize_mf2)\ + // - The MF2-JSON document contains a UID + // - The MF2-JSON document's URL list contains its UID + // - The MF2-JSON document's "content" field contains an HTML blob, if present + // - The MF2-JSON document's publishing datetime is present + // - The MF2-JSON document's target channels are set + // - The MF2-JSON document's author is set + + // Security check! Do we have an oAuth2 scope to proceed? + if !user.check_scope("create") { + return Err(MicropubError { + error: ErrorType::InvalidScope, + error_description: "Not enough privileges - try acquiring the \"create\" scope.".to_owned() + }); + } + + // Security check #2! Are we posting to our own website? + if !uid.starts_with(user.me.as_str()) || mf2["properties"]["channel"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .any(|url| !url.as_str().unwrap().starts_with(user.me.as_str())) + { + return Err(MicropubError { + error: ErrorType::Forbidden, + error_description: "You're posting to a website that's not yours.".to_owned() + }); + } + + // Security check #3! Are we overwriting an existing document? + if db.post_exists(&uid).await? { + return Err(MicropubError { + error: ErrorType::AlreadyExists, + error_description: "UID clash was detected, operation aborted.".to_owned() + }); + } + + // Save the post + db.put_post(&mf2, user.me.as_str()).await?; + + let mut channels = mf2["properties"]["channel"] + .as_array() + .unwrap() + .iter() + .map(|i| i.as_str().unwrap_or("")) + .filter(|i| !i.is_empty()); + + let default_channel = user.me.join(post::DEFAULT_CHANNEL_PATH).unwrap().to_string(); + let vcards_channel = user.me.join(post::CONTACTS_CHANNEL_PATH).unwrap().to_string(); + let food_channel = user.me.join(post::FOOD_CHANNEL_PATH).unwrap().to_string(); + let default_channels = vec![default_channel, vcards_channel, food_channel]; + + for chan in &mut channels { + if db.post_exists(chan).await? { + db.update_post(chan, json!({"add": {"children": [uid]}})).await?; + } else if default_channels.iter().any(|i| chan == i) { + post::create_feed(&db, &uid, chan, &user).await?; + } else { + warn!("Ignoring non-existent channel: {}", chan); + } + } + + let reply = warp::reply::with_status( + warp::reply::with_header( + warp::reply::json(&json!({"location": &uid})), + "Location", &uid + ), + StatusCode::ACCEPTED + ); + + // TODO: Post-processing the post (aka second write pass) + // - [x] Download rich reply contexts + // - [ ] Syndicate the post if requested, add links to the syndicated copies + // - [ ] Send WebSub notifications to the hub (if we happen to have one) + // - [x] Send webmentions + tokio::task::spawn(async move { + use futures_util::StreamExt; + + let uid: &str = mf2["properties"]["uid"][0].as_str().unwrap(); + + let context_props = ["in-reply-to", "like-of", "repost-of", "bookmark-of"]; + let mut context_urls: Vec = vec![]; + for prop in &context_props { + if let Some(array) = mf2["properties"][prop].as_array() { + context_urls.extend( + array + .iter() + .filter_map(|v| v.as_str()) + .filter_map(|v| v.parse::().ok()), + ); + } + } + // TODO parse HTML in e-content and add links found here + context_urls.sort_unstable_by_key(|u| u.to_string()); + context_urls.dedup(); + + // TODO: Make a stream to fetch all these posts and convert them to MF2 + let post_contexts = { + let http = &http; + tokio_stream::iter(context_urls.into_iter()) + .then(move |url: url::Url| http.get(url).send()) + .filter_map(|response| futures::future::ready(response.ok())) + .filter(|response| futures::future::ready(response.status() == 200)) + .filter_map(|response: reqwest::Response| async move { + // 1. We need to preserve the URL + // 2. We need to get the HTML for MF2 processing + // 3. We need to get the webmention endpoint address + // All of that can be done in one go. + let url = response.url().clone(); + // TODO parse link headers + let links = response + .headers() + .get_all(hyper::http::header::LINK) + .iter() + .cloned() + .collect::>(); + let html = response.text().await; + if html.is_err() { + return None; + } + let html = html.unwrap(); + let mf2 = microformats::from_html(&html, url.clone()).unwrap(); + // TODO use first Link: header if available + let webmention: Option = mf2.rels.by_rels().get("webmention") + .and_then(|i| i.first().cloned()); + + dbg!(Some(FetchedPostContext { + url, mf2: serde_json::to_value(mf2).unwrap(), webmention + })) + }) + .collect::>() + .await + }; + + let mut update = json!({ "replace": {} }); + for prop in &context_props { + if let Some(json) = populate_reply_context(&mf2, prop, &post_contexts) { + update["replace"][prop] = json; + } + } + if !update["replace"].as_object().unwrap().is_empty() { + if let Err(err) = db.update_post(uid, update).await { + error!("Failed to update post with rich reply contexts: {}", err); + } + } + + // At this point we can start syndicating the post. + // Currently we don't really support any syndication endpoints, but still! + /*if let Some(syndicate_to) = mf2["properties"]["mp-syndicate-to"].as_array() { + let http = &http; + tokio_stream::iter(syndicate_to) + .filter_map(|i| futures::future::ready(i.as_str())) + .for_each_concurrent(3, |s: &str| async move { + #[allow(clippy::match_single_binding)] + match s { + _ => { + todo!("Syndicate to generic webmention-aware service {}", s); + } + // TODO special handling for non-webmention-aware services like the birdsite + } + }) + .await; + }*/ + + { + let http = &http; + tokio_stream::iter( + post_contexts.into_iter() + .filter(|ctx| ctx.webmention.is_some())) + .for_each_concurrent(2, |ctx| async move { + let mut map = std::collections::HashMap::new(); + map.insert("source", uid); + map.insert("target", ctx.url.as_str()); + + match http.post(ctx.webmention.unwrap().clone()) + .form(&map) + .send() + .await + { + Ok(res) => { + if !res.status().is_success() { + warn!( + "Failed to send a webmention for {}: got HTTP {}", + ctx.url, res.status() + ); + } else { + info!("Sent a webmention to {}, got HTTP {}", ctx.url, res.status()) + } + }, + Err(err) => warn!("Failed to send a webmention for {}: {}", ctx.url, err) + } + }) + .await; + } + }); + + Ok(reply) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ActionType { + Delete, + Update +} + +#[derive(Serialize, Deserialize)] +struct MicropubFormAction { + action: ActionType, + url: String +} + +#[derive(Serialize, Deserialize)] +struct MicropubAction { + action: ActionType, + url: String, + #[serde(skip_serializing_if = "Option::is_none")] + replace: Option, + #[serde(skip_serializing_if = "Option::is_none")] + add: Option, + #[serde(skip_serializing_if = "Option::is_none")] + delete: Option +} + +impl From for MicropubAction { + fn from(a: MicropubFormAction) -> Self { + Self { + action: a.action, + url: a.url, + replace: None, add: None, delete: None + } + } +} + +// TODO perform the requested actions synchronously +async fn post_action( + action: MicropubAction, + db: D, + user: User +) -> Result { + + let uri = if let Ok(uri) = action.url.parse::() { + uri + } else { + return Err(MicropubError { + error: ErrorType::InvalidRequest, + error_description: "Your URL doesn't parse properly.".to_owned() + }); + }; + + if uri.authority().unwrap() != user.me.as_str().parse::().unwrap().authority().unwrap() { + return Err(MicropubError { + error: ErrorType::Forbidden, + error_description: "Don't tamper with others' posts!".to_owned() + }); + } + + match action.action { + ActionType::Delete => { + if !user.check_scope("delete") { + return Err(MicropubError { + error: ErrorType::InvalidScope, + error_description: "You need a \"delete\" scope for this.".to_owned() + }); + } + + db.delete_post(&action.url).await? + }, + ActionType::Update => { + if !user.check_scope("update") { + return Err(MicropubError { + error: ErrorType::InvalidScope, + error_description: "You need an \"update\" scope for this.".to_owned() + }); + } + + db.update_post( + &action.url, + // Here, unwrapping is safe, because this value + // was recently deserialized from JSON already. + serde_json::to_value(&action).unwrap() + ).await? + }, + } + + Ok(warp::reply::reply()) +} + +async fn check_auth(host: warp::host::Authority, user: User) -> Result { + let user_authority = warp::http::Uri::try_from(user.me.as_str()) + .unwrap() + .authority() + .unwrap() + .clone(); + // TODO compare with potential list of allowed websites + // to allow one user to edit several websites with one token + if host != user_authority { + Err(warp::reject::custom(MicropubError::new( + ErrorType::NotAuthorized, + "This user is not authorized to use Micropub on this website." + ))) + } else { + Ok(user) + } +} + +#[cfg(any(not(debug_assertions), test))] +fn ensure_same_user_as_host( + token_endpoint: String, + http: reqwest::Client +) -> impl Filter + Clone { + crate::util::require_host() + .and(crate::indieauth::require_token(token_endpoint, http)) + .and_then(check_auth) +} + +async fn dispatch_post_body( + mut body: impl bytes::Buf, + mimetype: http_types::Mime +) -> Result, warp::Rejection> { + // Since hyper::common::buf::BufList doesn't implement Clone, we can't use Clone in here + // We have to copy the body. Ugh!!! + // so much for zero-copy buffers + let body = { + let mut _body: Vec = Vec::default(); + while body.has_remaining() { + _body.extend(body.chunk()); + body.advance(body.chunk().len()); + } + _body + }; + match mimetype.essence() { + "application/json" => { + if let Ok(body) = serde_json::from_slice::(&body) { + Ok(Either::Left(body)) + } else if let Ok(body) = serde_json::from_slice::(&body) { + // quick sanity check + if !body.is_object() || !body["type"].is_array() { + return Err(MicropubError { + error: ErrorType::InvalidRequest, + error_description: "Invalid MF2-JSON detected: `.` should be an object, `.type` should be an array of MF2 types".to_owned() + }.into()) + } + Ok(Either::Right(body)) + } else { + Err(MicropubError { + error: ErrorType::InvalidRequest, + error_description: "Invalid JSON object passed.".to_owned() + }.into()) + } + }, + "application/x-www-form-urlencoded" => { + if let Ok(body) = serde_urlencoded::from_bytes::(&body) { + Ok(Either::Left(body.into())) + } else if let Ok(body) = serde_urlencoded::from_bytes::>(&body) { + Ok(Either::Right(form_to_mf2_json(body))) + } else { + Err(MicropubError { + error: ErrorType::InvalidRequest, + error_description: "Invalid form-encoded data. Try h=entry&content=Hello!".to_owned() + }.into()) + } + }, + other => Err(MicropubError { + error: ErrorType::UnsupportedMediaType, + error_description: format!("Unsupported media type: {}. Try application/json?", other) + }.into()) + } +} + +#[cfg_attr(all(debug_assertions, not(test)), allow(unused_variables))] +pub fn post( + db: D, + token_endpoint: String, + http: reqwest::Client +) -> impl Filter + Clone { + let inject_db = warp::any().map(move || db.clone()); + #[cfg(all(debug_assertions, not(test)))] + let ensure_same_user = warp::any().map(|| crate::indieauth::User::new( + "http://localhost:8080/", + "https://quill.p3k.io/", + "create update delete media" + )); + #[cfg(any(not(debug_assertions), test))] + let ensure_same_user = ensure_same_user_as_host(token_endpoint, http.clone()); + + warp::post() + .and(warp::body::content_length_limit(1024 * 512) + .and(warp::body::aggregate()) + .and(warp::header::("Content-Type")) + .and_then(dispatch_post_body)) + .and(inject_db) + .and(warp::any().map(move || http.clone())) + .and(ensure_same_user) + .and_then(|body: Either, db: D, http: reqwest::Client, user: crate::indieauth::User| async move { + (match body { + Either::Left(action) => { + post_action(action, db, user).await.map(|p| Box::new(p) as Box) + }, + Either::Right(post) => { + let (uid, mf2) = post::normalize_mf2(post, &user); + _post(user, uid, mf2, db, http).await.map(|p| Box::new(p) as Box) + } + }).map_err(warp::reject::custom) + }) +} + +pub fn options() -> impl Filter + Clone { + warp::options() + // TODO make it reply with a basic description of Micropub spec + .map(|| warp::reply::json::>(&None)) + .with(warp::reply::with::header("Allow", "GET, POST")) +} + +async fn _query( + db: D, + query: MicropubQuery, + user: crate::indieauth::User +) -> Box { + let user_authority = warp::http::Uri::try_from(user.me.as_str()) + .unwrap() + .authority() + .unwrap() + .clone(); + + match query.q { + QueryType::Config => { + let channels: Vec = match db.get_channels(user_authority.as_str()).await { + Ok(chans) => chans, + Err(err) => return Box::new(warp::reply::with_status( + warp::reply::json(&MicropubError::new( + ErrorType::InternalServerError, + &format!("Error fetching channels: {}", err) + )), + StatusCode::INTERNAL_SERVER_ERROR + )) + }; + + Box::new(warp::reply::json(json!({ + "q": [ + QueryType::Source, + QueryType::Config, + QueryType::Channel, + QueryType::SyndicateTo + ], + "channels": channels, + "_kittybox_authority": user_authority.as_str(), + "syndicate-to": [] + }).as_object().unwrap())) + }, + QueryType::Source => { + match query.url { + Some(url) => { + if warp::http::Uri::try_from(&url).unwrap().authority().unwrap() != &user_authority { + return Box::new(warp::reply::with_status( + warp::reply::json(&MicropubError::new( + ErrorType::NotAuthorized, + "You are requesting a post from a website that doesn't belong to you." + )), + StatusCode::UNAUTHORIZED + )) + } + match db.get_post(&url).await { + Ok(some) => match some { + Some(post) => Box::new(warp::reply::json(&post)), + None => Box::new(warp::reply::with_status( + warp::reply::json(&MicropubError::new( + ErrorType::NotFound, + "The specified MF2 object was not found in database." + )), + StatusCode::NOT_FOUND + )) + }, + Err(err) => { + Box::new(warp::reply::json(&MicropubError::new( + ErrorType::InternalServerError, + &format!("Backend error: {}", err) + ))) + } + } + }, + None => { + // Here, one should probably attempt to query at least the main feed and collect posts + // Using a pre-made query function can't be done because it does unneeded filtering + // Don't implement for now, this is optional + Box::new(warp::reply::with_status( + warp::reply::json(&MicropubError::new( + ErrorType::InvalidRequest, + "Querying for post list is not implemented yet." + )), + StatusCode::BAD_REQUEST + )) + } + } + }, + QueryType::Channel => { + let channels: Vec = match db.get_channels(user_authority.as_str()).await { + Ok(chans) => chans, + Err(err) => return Box::new(warp::reply::with_status( + warp::reply::json(&MicropubError::new( + ErrorType::InternalServerError, + &format!("Error fetching channels: {}", err) + )), + StatusCode::INTERNAL_SERVER_ERROR + )) + }; + + Box::new(warp::reply::json(&json!({ "channels": channels }))) + }, + QueryType::SyndicateTo => { + Box::new(warp::reply::json(&json!({ "syndicate-to": [] }))) + } + } +} + +pub fn query( + db: D, + token_endpoint: String, + http: reqwest::Client +) -> impl Filter + Clone { + warp::get() + .map(move || db.clone()) + .and(warp::query::()) + .and(crate::util::require_host() + .and(crate::indieauth::require_token(token_endpoint, http)) + .and_then(check_auth)) + .then(_query) + .recover(|e: warp::Rejection| async move { + if let Some(err) = e.find::() { + Ok(warp::reply::json(err)) + } else { + Err(e) + } + }) +} + +pub async fn recover(err: Rejection) -> Result { + if let Some(error) = err.find::() { + return Ok(warp::reply::with_status(warp::reply::json(&error), error.into())) + } + let error = if err.find::().is_some() { + MicropubError::new( + ErrorType::InvalidRequest, + "Invalid query parameters sent. Try ?q=config to see what you can do." + ) + } else { + log::error!("Unhandled rejection: {:?}", err); + MicropubError::new( + ErrorType::InternalServerError, + &format!("Unknown error: {:?}", err) + ) + }; + + Ok(warp::reply::with_status(warp::reply::json(&error), error.into())) +} + +pub fn micropub( + db: D, + token_endpoint: String, + http: reqwest::Client +) -> impl Filter + Clone { + query(db.clone(), token_endpoint.clone(), http.clone()) + .or(post(db, token_endpoint, http)) + .or(options()) + .recover(recover) +} +#[cfg(test)] +#[allow(dead_code)] +impl MicropubQuery { + fn config() -> Self { + Self { + q: QueryType::Config, + url: None + } + } + + fn source(url: &str) -> Self { + Self { + q: QueryType::Source, + url: Some(url.to_owned()) + } + } +} + +#[cfg(test)] +mod tests { + use hyper::body::HttpBody; + use crate::{database::Storage, micropub::MicropubError}; + use warp::{Filter, Reply}; + use serde_json::json; + + use super::FetchedPostContext; + + #[test] + fn test_populate_reply_context() { + let already_expanded_reply_ctx = json!({ + "type": ["h-entry"], + "properties": { + "content": ["Hello world!"] + } + }); + let mf2 = json!({ + "type": ["h-entry"], + "properties": { + "like-of": [ + "https://fireburn.ru/posts/example", + already_expanded_reply_ctx, + "https://fireburn.ru/posts/non-existent" + ] + } + }); + let test_ctx = json!({ + "type": ["h-entry"], + "properties": { + "content": ["This is a post which was reacted to."] + } + }); + let reply_contexts = vec![ + FetchedPostContext { + url: "https://fireburn.ru/posts/example".parse().unwrap(), + mf2: json!({ + "items": [ + test_ctx + ] + }), + webmention: None + } + ]; + + let like_of = super::populate_reply_context(&mf2, "like-of", &reply_contexts).unwrap(); + + assert_eq!(like_of[0], test_ctx); + assert_eq!(like_of[1], already_expanded_reply_ctx); + assert_eq!(like_of[2], "https://fireburn.ru/posts/non-existent"); + } + + #[tokio::test] + async fn check_post_reject_scope() { + let inject_db = { + let db = crate::database::MemoryStorage::new(); + + move || db.clone() + }; + let db = inject_db(); + + let res = warp::test::request() + .filter(&warp::any() + .map(inject_db) + .and_then(move |db| async move { + let post = json!({ + "type": ["h-entry"], + "properties": { + "content": ["Hello world!"] + } + }); + let user = crate::indieauth::User::new( + "https://localhost:8080/", + "https://kittybox.fireburn.ru/", + "profile" + ); + let (uid, mf2) = super::post::normalize_mf2(post, &user); + + super::_post( + user, uid, mf2, db, reqwest::Client::new() + ).await.map_err(warp::reject::custom) + }) + ) + .await + .map(|_| panic!("Tried to do something with a reply!")) + .unwrap_err(); + + if let Some(err) = res.find::() { + assert_eq!(err.error, super::ErrorType::InvalidScope); + } else { + panic!("Did not return MicropubError"); + } + + let hashmap = db.mapping.read().await; + assert!(hashmap.is_empty()); + } + + #[tokio::test] + async fn check_post_mf2() { + let inject_db = { + let db = crate::database::MemoryStorage::new(); + + move || db.clone() + }; + let db = inject_db(); + + let res = warp::test::request() + .filter(&warp::any() + .map(inject_db) + .and_then(move |db| async move { + let post = json!({ + "type": ["h-entry"], + "properties": { + "content": ["Hello world!"] + } + }); + let user = crate::indieauth::User::new( + "https://localhost:8080/", + "https://kittybox.fireburn.ru/", + "create" + ); + let (uid, mf2) = super::post::normalize_mf2(post, &user); + + super::_post( + user, uid, mf2, db, reqwest::Client::new() + ).await.map_err(warp::reject::custom) + }) + ) + .await + .unwrap() + .into_response(); + + assert!(res.headers().contains_key("Location")); + let location = res.headers().get("Location").unwrap(); + assert!(db.post_exists(location.to_str().unwrap()).await.unwrap()); + assert!(db.post_exists("https://localhost:8080/feeds/main").await.unwrap()); + } + + #[tokio::test] + async fn test_check_auth() { + let err = warp::test::request() + .filter(&warp::any() + .map(|| ( + warp::host::Authority::from_static("aaronparecki.com"), + crate::indieauth::User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media" + ))) + .untuple_one() + .and_then(super::check_auth)) + .await + .unwrap_err(); + + let json: &MicropubError = err.find::().unwrap(); + assert_eq!(json.error, super::ErrorType::NotAuthorized); + } + + #[tokio::test] + async fn test_query_foreign_url() { + let mut res = warp::test::request() + .filter(&warp::any().then(|| super::_query( + crate::database::MemoryStorage::new(), + super::MicropubQuery::source("https://aaronparecki.com/feeds/main"), + crate::indieauth::User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media" + ) + ))) + .await + .unwrap() + .into_response(); + + assert_eq!(res.status(), 401); + let body = res.body_mut().data().await.unwrap().unwrap(); + let json: MicropubError = serde_json::from_slice(&body as &[u8]).unwrap(); + assert_eq!(json.error, super::ErrorType::NotAuthorized); + } +} + diff --git a/kittybox-rs/src/micropub/post.rs b/kittybox-rs/src/micropub/post.rs new file mode 100644 index 0000000..cf9f3d9 --- /dev/null +++ b/kittybox-rs/src/micropub/post.rs @@ -0,0 +1,944 @@ +use crate::database::Storage; +use crate::indieauth::User; +use chrono::prelude::*; +use core::iter::Iterator; +use newbase60::num_to_sxg; +use std::convert::TryInto; +use serde_json::json; + +pub(crate) static DEFAULT_CHANNEL_PATH: &str = "/feeds/main"; +static DEFAULT_CHANNEL_NAME: &str = "Main feed"; +pub(crate) static CONTACTS_CHANNEL_PATH: &str = "/feeds/vcards"; +static CONTACTS_CHANNEL_NAME: &str = "My address book"; +pub(crate) static FOOD_CHANNEL_PATH: &str = "/feeds/food"; +static FOOD_CHANNEL_NAME: &str = "My recipe book"; + +fn get_folder_from_type(post_type: &str) -> String { + (match post_type { + "h-feed" => "feeds/", + "h-card" => "vcards/", + "h-event" => "events/", + "h-food" => "food/", + _ => "posts/", + }) + .to_string() +} + +/// Reset the datetime to a proper datetime. +/// Do not attempt to recover the information. +/// Do not pass GO. Do not collect $200. +fn reset_dt(post: &mut serde_json::Value) -> DateTime { + let curtime: DateTime = Local::now(); + post["properties"]["published"] = json!([curtime.to_rfc3339()]); + chrono::DateTime::from(curtime) +} + +pub fn normalize_mf2(mut body: serde_json::Value, user: &User) -> (String, serde_json::Value) { + // Normalize the MF2 object here. + let me = &user.me; + let folder = get_folder_from_type(body["type"][0].as_str().unwrap()); + let published: DateTime = if let Some(dt) = body["properties"]["published"][0].as_str() { + // Check if the datetime is parsable. + match DateTime::parse_from_rfc3339(dt) { + Ok(dt) => dt, + Err(_) => reset_dt(&mut body) + } + } else { + // Set the datetime. + // Note: this code block duplicates functionality with the above failsafe. + // Consider refactoring it to a helper function? + reset_dt(&mut body) + }; + match body["properties"]["uid"][0].as_str() { + None => { + let uid = serde_json::Value::String( + me.join( + &(folder.clone() + + &num_to_sxg(published.timestamp_millis().try_into().unwrap())), + ) + .unwrap() + .to_string(), + ); + body["properties"]["uid"] = serde_json::Value::Array(vec![uid.clone()]); + match body["properties"]["url"].as_array_mut() { + Some(array) => array.push(uid), + None => body["properties"]["url"] = body["properties"]["uid"].clone(), + } + } + Some(uid_str) => { + let uid = uid_str.to_string(); + match body["properties"]["url"].as_array_mut() { + Some(array) => { + if !array.iter().any(|i| i.as_str().unwrap_or("") == uid) { + array.push(serde_json::Value::String(uid)) + } + } + None => body["properties"]["url"] = body["properties"]["uid"].clone(), + } + } + } + if let Some(slugs) = body["properties"]["mp-slug"].as_array() { + let new_urls = slugs + .iter() + .map(|i| i.as_str().unwrap_or("")) + .filter(|i| i != &"") + .map(|i| me.join(&((&folder).clone() + i)).unwrap().to_string()) + .collect::>(); + let urls = body["properties"]["url"].as_array_mut().unwrap(); + new_urls.iter().for_each(|i| urls.push(json!(i))); + } + let props = body["properties"].as_object_mut().unwrap(); + props.remove("mp-slug"); + + if body["properties"]["content"][0].is_string() { + // Convert the content to HTML using the `markdown` crate + body["properties"]["content"] = json!([{ + "html": markdown::to_html(body["properties"]["content"][0].as_str().unwrap()), + "value": body["properties"]["content"][0] + }]) + } + // TODO: apply this normalization to editing too + if body["properties"]["mp-channel"].is_array() { + let mut additional_channels = body["properties"]["mp-channel"].as_array().unwrap().clone(); + if let Some(array) = body["properties"]["channel"].as_array_mut() { + array.append(&mut additional_channels); + } else { + body["properties"]["channel"] = json!(additional_channels) + } + body["properties"].as_object_mut().unwrap().remove("mp-channel"); + } else if body["properties"]["mp-channel"].is_string() { + let chan = body["properties"]["mp-channel"].as_str().unwrap().to_owned(); + if let Some(array) = body["properties"]["channel"].as_array_mut() { + array.push(json!(chan)) + } else { + body["properties"]["channel"] = json!([chan]); + } + body["properties"].as_object_mut().unwrap().remove("mp-channel"); + } + if body["properties"]["channel"][0].as_str().is_none() { + match body["type"][0].as_str() { + Some("h-entry") => { + // Set the channel to the main channel... + let default_channel = me.join(DEFAULT_CHANNEL_PATH).unwrap().to_string(); + + body["properties"]["channel"] = json!([default_channel]); + } + Some("h-card") => { + let default_channel = me.join(CONTACTS_CHANNEL_PATH).unwrap().to_string(); + + body["properties"]["channel"] = json!([default_channel]); + } + Some("h-food") => { + let default_channel = me.join(FOOD_CHANNEL_PATH).unwrap().to_string(); + + body["properties"]["channel"] = json!([default_channel]); + } + // TODO h-event + /*"h-event" => { + let default_channel + },*/ + _ => { + body["properties"]["channel"] = json!([]); + } + } + } + body["properties"]["posted-with"] = json!([user.client_id]); + if body["properties"]["author"][0].as_str().is_none() { + body["properties"]["author"] = json!([me.as_str()]) + } + // TODO: maybe highlight #hashtags? + // Find other processing to do and insert it here + return ( + body["properties"]["uid"][0].as_str().unwrap().to_string(), + body, + ); +} + +/*pub async fn new_post( + req: Request>, + body: serde_json::Value, +) -> Result { + // First, check for rights. + let user = req.ext::().unwrap(); + let storage = &req.state().storage; + if !user.check_scope("create") { + return error_json!( + 401, + "invalid_scope", + "Not enough privileges to post. Try a token with a \"create\" scope instead." + ); + } + let (uid, post) = normalize_mf2(body, user); + + // Security check! + // This software might also be used in a multi-user setting + // where several users or identities share one Micropub server + // (maybe a family website or a shitpost sideblog?) + if !post["properties"]["uid"][0] + .as_str() + .unwrap() + .starts_with(user.me.as_str()) + || post["properties"]["channel"] + .as_array() + .unwrap() + .iter() + .any(|url| !url.as_str().unwrap().starts_with(user.me.as_str())) + { + return error_json!( + 403, + "forbidden", + "You're trying to post to someone else's website..." + ); + } + + match storage.post_exists(&uid).await { + Ok(exists) => { + if exists { + return error_json!( + 409, + "already_exists", + format!( + "A post with the exact same UID already exists in the database: {}", + uid + ) + ); + } + } + Err(err) => return Ok(err.into()), + } + + if let Err(err) = storage.put_post(&post, user.me.as_str()).await { + return error_json!(500, "database_error", format!("{}", err)); + } + + // It makes sense to use a loop here, because you wouldn't post to a hundred channels at once + // Mostly one or two, and even those ones will be the ones picked for you by software + for channel in post["properties"]["channel"] + .as_array() + .unwrap() + .iter() + .map(|i| i.as_str().unwrap_or("").to_string()) + .filter(|i| !i.is_empty()) + .collect::>() + { + let default_channel = user.me.join(DEFAULT_CHANNEL_PATH).unwrap().to_string(); + let vcards_channel = user.me.join(CONTACTS_CHANNEL_PATH).unwrap().to_string(); + let food_channel = user.me.join(FOOD_CHANNEL_PATH).unwrap().to_string(); + match storage.post_exists(&channel).await { + Ok(exists) => { + if exists { + if let Err(err) = storage + .update_post(&channel, json!({"add": {"children": [uid]}})) + .await + { + return error_json!( + 500, + "database_error", + format!( + "Couldn't insert post into the channel due to a database error: {}", + err + ) + ); + } + } else if channel == default_channel + || channel == vcards_channel + || channel == food_channel + { + if let Err(err) = create_feed(storage, &uid, &channel, user).await { + return error_json!( + 500, + "database_error", + format!("Couldn't save feed: {}", err) + ); + } + } else { + warn!( + "Ignoring request to post to a non-existent feed: {}", + channel + ); + } + } + Err(err) => return error_json!(500, "database_error", err), + } + } + // END WRITE BOUNDARY + + // do background processing on the post + async_std::task::spawn(post_process_new_post(req, post)); + + Ok(Response::builder(202) + .header("Location", &uid) + .body(json!({"status": "accepted", "location": &uid})) + .build()) +}*/ + +pub(crate) async fn create_feed( + storage: &impl Storage, + uid: &str, + channel: &str, + user: &User, +) -> crate::database::Result<()> { + let path = url::Url::parse(channel).unwrap().path().to_string(); + + // Note to Future Vika: DO NOT CONVERT THIS TO A MATCH BLOCK + // It will get treated as a binding instead of a const + // See `rustc --explain E0530` for more info + let name = if path == DEFAULT_CHANNEL_PATH { + DEFAULT_CHANNEL_NAME + } else if path == CONTACTS_CHANNEL_PATH { + CONTACTS_CHANNEL_NAME + } else if path == FOOD_CHANNEL_PATH { + FOOD_CHANNEL_NAME + } else { + panic!("Tried to create an unknown default feed!") + }; + + let (_, feed) = normalize_mf2( + json!({ + "type": ["h-feed"], + "properties": { + "name": [name], + "uid": [channel] + }, + "children": [uid] + }), + user, + ); + storage.put_post(&feed, user.me.as_str()).await +} + +/*async fn post_process_new_post( + req: Request>, + post: serde_json::Value, +) { + // TODO: Post-processing the post (aka second write pass) + // - [-] Download rich reply contexts + // - [-] Syndicate the post if requested, add links to the syndicated copies + // - [ ] Send WebSub notifications to the hub (if we happen to have one) + // - [x] Send webmentions + let http = &req.state().http_client; + let uid = post["properties"]["uid"][0].as_str().unwrap().to_string(); + // 1. Download rich reply contexts + // This needs to be done first, because at this step we can also determine webmention endpoints + // and save them for later use. Additionally, the richer our content is, the better. + // This needs to be done asynchronously, so the posting experience for the author will be as fast + // as possible without making them wait for potentially slow downstream websites to load + // 1.1. Collect the list of contextually-significant post to load context from. + // This will include reply-tos, liked, reposted and bookmarked content + // + // TODO: Fetch links mentioned in a post, since we need to send webmentions to those as mentions + let mut contextually_significant_posts: Vec = vec![]; + for prop in &["in-reply-to", "like-of", "repost-of", "bookmark-of"] { + if let Some(array) = post["properties"][prop].as_array() { + contextually_significant_posts.extend( + array + .iter() + .filter_map(|v| v.as_str().and_then(|v| surf::Url::parse(v).ok())), + ); + } + } + // 1.2. Deduplicate the list + contextually_significant_posts.sort_unstable(); + contextually_significant_posts.dedup(); + + // 1.3. Fetch the posts with their bodies and save them in a new Vec<(surf::Url, String)> + let posts_with_bodies: Vec<(surf::Url, surf::Response, String)> = + stream::iter(contextually_significant_posts.into_iter()) + .filter_map(|v: surf::Url| async move { + if let Ok(res) = http.get(&v).send().await { + if res.status() != 200 { + None + } else { + Some((v, res)) + } + } else { + None + } + }) + .filter_map(|(v, mut res): (surf::Url, surf::Response)| async move { + if let Ok(body) = res.body_string().await { + Some((v, res, body)) + } else { + None + } + }) + .collect() + .await; + // 1.4. Parse the bodies and include them in relevant places on the MF2 struct + // This requires an MF2 parser, and there are none for Rust at the moment. + // + // TODO: integrate https://gitlab.com/vikanezrimaya/mf2-parser when it's ready + + // 2. Syndicate the post + let syndicated_copies: Vec; + if let Some(syndication_targets) = post["properties"]["syndicate-to"].as_array() { + syndicated_copies = stream::iter( + syndication_targets + .iter() + .filter_map(|v| v.as_str()) + .filter_map(|t| surf::Url::parse(t).ok()) + .collect::>() + .into_iter() + .map(|_t: surf::Url| async move { + // TODO: Define supported syndication methods + // and syndicate the endpoint there + // Possible ideas: + // - indieweb.xyz (might need a lot of space for the buttons though, investigate proposing grouping syndication targets) + // - news.indieweb.org (IndieNews - needs a category linking to #indienews) + // - Twitter via brid.gy (do I really need Twitter syndication tho?) + if false { + Some("") + } else { + None + } + }), + ) + .buffer_unordered(3) + .filter_map(|v| async move { v }) + .map(|v| serde_json::Value::String(v.to_string())) + .collect::>() + .await; + } else { + syndicated_copies = vec![] + } + // Save the post a second time here after syndication + // We use update_post here to prevent race conditions since its required to be atomic + let mut update = json!({ + "action": "update", + "url": &uid + }); + if !syndicated_copies.is_empty() { + update["add"] = json!({}); + update["add"]["syndication"] = serde_json::Value::Array(syndicated_copies); + } + if !posts_with_bodies.is_empty() { + error!("Replacing context links with parsed MF2-JSON data is not yet implemented (but it's ok! it'll just be less pretty)") + /* TODO: Replace context links with parsed MF2-JSON data * / + update["replace"] = {} + update["replace"]["like-of"] = [] + update["replace"]["in-reply-to"] = [] + update["replace"]["bookmark-of"] = [] + update["replace"]["repost-of"] = [] + // */ + } + // We don't need the original copy of the post anymore... I hope! + // This will act as a safeguard so I can't read stale data by accident anymore... + drop(post); + if let Err(err) = req.state().storage.update_post(&uid, update).await { + error!("Encountered error while post-processing a post: {}", err) + // At this point, we can still continue, we just won't have rich data for the post + // I wonder why could it even happen except in case of a database disconnection? + } + // 3. Send WebSub notifications + // TODO WebSub support + + // 4. Send webmentions + // We'll need the bodies here to get their endpoints + let source = &uid; + stream::iter(posts_with_bodies.into_iter()) + .filter_map( + |(url, response, body): (surf::Url, surf::Response, String)| async move { + // Check Link headers first + // the first webmention endpoint will be returned + if let Some(values) = response.header("Link") { + let iter = values.iter().flat_map(|i| i.as_str().split(',')); + + // Honestly I don't like this parser. It's very crude. + // But it should do the job. But I don't like it. + for link in iter { + let mut split = link.split(';'); + + match split.next() { + Some(uri) => { + if let Some(uri) = uri.strip_prefix('<') { + if let Some(uri) = uri.strip_suffix('>') { + for prop in split { + let lowercased = prop.to_ascii_lowercase(); + if &lowercased == "rel=\"webmention\"" + || &lowercased == "rel=webmention" + { + if let Ok(endpoint) = url.join(uri) { + return Some((url, endpoint)); + } + } + } + } + } + } + None => continue, + } + } + } + // TODO: Replace this function once the MF2 parser is ready + // A compliant parser's output format includes rels, + // we could just find a Webmention one in there + let pattern = + easy_scraper::Pattern::new(r#""#) + .expect("Pattern for webmentions couldn't be parsed"); + let matches = pattern.matches(&body); + if matches.is_empty() { + return None; + } + let endpoint = &matches[0]["url"]; + if let Ok(endpoint) = url.join(endpoint) { + Some((url, endpoint)) + } else { + None + } + }, + ) + .map(|(target, endpoint)| async move { + info!( + "Sending webmention to {} about {}", + source, + &target.to_string() + ); + let response = http + .post(&endpoint) + .content_type("application/x-www-form-urlencoded") + .body( + serde_urlencoded::to_string(vec![ + ("source", source), + ("target", &target.to_string()), + ]) + .expect("Couldn't construct webmention form"), + ) + .send() + .await; + match response { + Ok(response) => { + if response.status() == 200 + || response.status() == 201 + || response.status() == 202 + { + info!("Sent webmention for {} to {}", target, endpoint); + Ok(()) + } else { + error!( + "Sending webmention for {} to {} failed: Endpoint replied with HTTP {}", + target, + endpoint, + response.status() + ); + Err(()) + } + } + Err(err) => { + error!( + "Sending webmention for {} to {} failed: {}", + target, endpoint, err + ); + Err(()) + } + } + }) + .buffer_unordered(3) + .collect::>() + .await; +}*/ + +/*async fn process_json( + req: Request>, + body: serde_json::Value, +) -> Result { + let is_action = body["action"].is_string() && body["url"].is_string(); + if is_action { + // This could be an update, a deletion or an undeletion request. + // Process it separately. + let action = body["action"].as_str().unwrap(); + let url = body["url"].as_str().unwrap(); + let user = req.ext::().unwrap(); + match action { + "delete" => { + if !user.check_scope("delete") { + return error_json!( + 401, + "insufficient_scope", + "You need a `delete` scope to delete posts." + ); + } + // This special scope is not available through a token endpoint, since the + // authorization endpoint is supposed to reject any auth request trying to get this + // scope. It is intended for TRUSTED external services that need to modify the + // database while ignoring any access controls + if (url::Url::parse(url)?.origin().ascii_serialization() + "/") != user.me.as_str() + && !user.check_scope("kittybox_internal:do_what_thou_wilt") + { + return error_json!( + 403, + "forbidden", + "You're not allowed to delete someone else's posts." + ); + } + if let Err(error) = req.state().storage.delete_post(url).await { + return Ok(error.into()); + } + Ok(Response::builder(200).build()) + } + "update" => { + if !user.check_scope("update") { + return error_json!( + 401, + "insufficient_scope", + "You need an `update` scope to update posts." + ); + } + if (url::Url::parse(url)?.origin().ascii_serialization() + "/") != user.me.as_str() + && !user.check_scope("kittybox_internal:do_what_thou_wilt") + { + return error_json!( + 403, + "forbidden", + "You're not allowed to delete someone else's posts." + ); + } + if let Err(error) = req.state().storage.update_post(url, body.clone()).await { + Ok(error.into()) + } else { + Ok(Response::builder(204).build()) + } + } + _ => return error_json!(400, "invalid_request", "This action is not supported."), + } + } else if body["type"][0].is_string() { + // This is definitely an h-entry or something similar. Check if it has properties? + if body["properties"].is_object() { + // Ok, this is definitely a new h-entry. Let's save it. + return new_post(req, body).await; + } else { + return error_json!( + 400, + "invalid_request", + "This MF2-JSON object has a type, but not properties. This makes no sense to post." + ); + } + } else { + return error_json!( + 400, + "invalid_request", + "Try sending MF2-structured data or an object with an \"action\" and \"url\" keys." + ); + } +}*/ + +/*async fn process_form( + req: Request>, + form: Vec<(String, String)>, +) -> Result { + if let Some((_, v)) = form.iter().find(|(k, _)| k == "action") { + if v == "delete" { + let user = req.ext::().unwrap(); + if !user.check_scope("delete") { + return error_json!( + 401, + "insufficient_scope", + "You cannot delete posts without a `delete` scope." + ); + } + match form.iter().find(|(k, _)| k == "url") { + Some((_, url)) => { + if (url::Url::parse(url)?.origin().ascii_serialization() + "/") + != user.me.as_str() + && !user.check_scope("kittybox_internal:do_what_thou_wilt") + { + return error_json!( + 403, + "forbidden", + "You're not allowed to delete someone else's posts." + ); + } + if let Err(error) = req.state().storage.delete_post(url).await { + return error_json!(500, "database_error", error); + } + return Ok(Response::builder(200).build()); + } + None => { + return error_json!( + 400, + "invalid_request", + "Please provide an `url` to delete." + ) + } + } + } else { + return error_json!(400, "invalid_request", "This action is not supported in form-encoded mode. (JSON requests support more actions, use JSON!)"); + } + } + + let mf2 = convert_form_to_mf2_json(form); + + if mf2["properties"].as_object().unwrap().keys().len() > 0 { + return new_post(req, mf2).await; + } + return error_json!( + 400, + "invalid_request", + "Try sending h=entry&content=something%20interesting" + ); +}*/ + +/*pub async fn post_handler(mut req: Request>) -> Result { + match req.content_type() { + Some(value) => { + if value == Mime::from_str("application/json").unwrap() { + match req.body_json::().await { + Ok(parsed) => return process_json(req, parsed).await, + Err(err) => { + return error_json!( + 400, + "invalid_request", + format!("Parsing JSON failed: {:?}", err) + ) + } + } + } else if value == Mime::from_str("application/x-www-form-urlencoded").unwrap() { + match req.body_form::>().await { + Ok(parsed) => return process_form(req, parsed).await, + Err(err) => { + return error_json!( + 400, + "invalid_request", + format!("Parsing form failed: {:?}", err) + ) + } + } + } else { + return error_json!( + 415, "unsupported_media_type", + "What's this? Try sending JSON instead. (urlencoded form also works but is less cute)" + ); + } + } + _ => { + return error_json!( + 415, "unsupported_media_type", + "You didn't send a Content-Type header, so we don't know how to parse your request." + ); + } + } +}*/ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_no_replace_uid() { + let mf2 = json!({ + "type": ["h-card"], + "properties": { + "uid": ["https://fireburn.ru/"], + "name": ["Vika Nezrimaya"], + "note": ["A crazy programmer girl who wants some hugs"] + } + }); + + let (uid, normalized) = normalize_mf2( + mf2.clone(), + &User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + ), + ); + assert_eq!( + normalized["properties"]["uid"][0], mf2["properties"]["uid"][0], + "UID was replaced" + ); + assert_eq!( + normalized["properties"]["uid"][0], uid, + "Returned post location doesn't match UID" + ); + } + + #[test] + fn test_mp_channel() { + let mf2 = json!({ + "type": ["h-entry"], + "properties": { + "uid": ["https://fireburn.ru/posts/test"], + "content": [{"html": "

Hello world!

"}], + "mp-channel": ["https://fireburn.ru/feeds/test"] + } + }); + + let (_, normalized) = normalize_mf2( + mf2.clone(), + &User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + ) + ); + + assert_eq!( + normalized["properties"]["channel"], + mf2["properties"]["mp-channel"] + ); + } + + #[test] + fn test_mp_channel_as_string() { + let mf2 = json!({ + "type": ["h-entry"], + "properties": { + "uid": ["https://fireburn.ru/posts/test"], + "content": [{"html": "

Hello world!

"}], + "mp-channel": "https://fireburn.ru/feeds/test" + } + }); + + let (_, normalized) = normalize_mf2( + mf2.clone(), + &User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + ) + ); + + assert_eq!( + normalized["properties"]["channel"][0], + mf2["properties"]["mp-channel"] + ); + } + + #[test] + fn test_normalize_mf2() { + let mf2 = json!({ + "type": ["h-entry"], + "properties": { + "content": ["This is content!"] + } + }); + + let (uid, post) = normalize_mf2( + mf2, + &User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + ), + ); + assert_eq!( + post["properties"]["published"] + .as_array() + .expect("post['published'] is undefined") + .len(), + 1, + "Post doesn't have a published time" + ); + DateTime::parse_from_rfc3339(post["properties"]["published"][0].as_str().unwrap()) + .expect("Couldn't parse date from rfc3339"); + assert!( + !post["properties"]["url"] + .as_array() + .expect("post['url'] is undefined") + .is_empty(), + "Post doesn't have any URLs" + ); + assert_eq!( + post["properties"]["uid"] + .as_array() + .expect("post['uid'] is undefined") + .len(), + 1, + "Post doesn't have a single UID" + ); + assert_eq!( + post["properties"]["uid"][0], uid, + "UID of a post and its supposed location don't match" + ); + assert!( + uid.starts_with("https://fireburn.ru/posts/"), + "The post namespace is incorrect" + ); + assert_eq!( + post["properties"]["content"][0]["html"] + .as_str() + .expect("Post doesn't have a rich content object") + .trim(), + "

This is content!

", + "Parsed Markdown content doesn't match expected HTML" + ); + assert_eq!( + post["properties"]["channel"][0], "https://fireburn.ru/feeds/main", + "Post isn't posted to the main channel" + ); + assert_eq!( + post["properties"]["author"][0], "https://fireburn.ru/", + "Post author is unknown" + ); + } + + #[test] + fn test_mp_slug() { + let mf2 = json!({ + "type": ["h-entry"], + "properties": { + "content": ["This is content!"], + "mp-slug": ["hello-post"] + }, + }); + + let (_, post) = normalize_mf2( + mf2, + &User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + ), + ); + assert!( + post["properties"]["url"] + .as_array() + .unwrap() + .iter() + .map(|i| i.as_str().unwrap()) + .any(|i| i == "https://fireburn.ru/posts/hello-post"), + "Didn't found an URL pointing to the location expected by the mp-slug semantics" + ); + assert!( + post["properties"]["mp-slug"].as_array().is_none(), + "mp-slug wasn't deleted from the array!" + ) + } + + #[test] + fn test_normalize_feed() { + let mf2 = json!({ + "type": ["h-feed"], + "properties": { + "name": "Main feed", + "mp-slug": ["main"] + } + }); + + let (uid, post) = normalize_mf2( + mf2, + &User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + ), + ); + assert_eq!( + post["properties"]["uid"][0], uid, + "UID of a post and its supposed location don't match" + ); + assert_eq!(post["properties"]["author"][0], "https://fireburn.ru/"); + assert!( + post["properties"]["url"] + .as_array() + .unwrap() + .iter() + .map(|i| i.as_str().unwrap()) + .any(|i| i == "https://fireburn.ru/feeds/main"), + "Didn't found an URL pointing to the location expected by the mp-slug semantics" + ); + assert!( + post["properties"]["mp-slug"].as_array().is_none(), + "mp-slug wasn't deleted from the array!" + ) + } +} -- cgit 1.4.1