about summary refs log tree commit diff
path: root/util
diff options
context:
space:
mode:
Diffstat (limited to 'util')
-rw-r--r--util/Cargo.toml29
-rw-r--r--util/src/error.rs95
-rw-r--r--util/src/lib.rs123
-rw-r--r--util/src/queue.rs66
4 files changed, 313 insertions, 0 deletions
diff --git a/util/Cargo.toml b/util/Cargo.toml
new file mode 100644
index 0000000..0425849
--- /dev/null
+++ b/util/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "kittybox-util"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[features]
+fs = ["rand", "tokio", "tokio/fs"]
+
+[dependencies]
+serde = { version = "^1.0.170", features = ["derive"] }
+serde_json = "^1.0.64"
+axum-core = "^0.3.4"
+http = "^0.2.7"
+async-trait = "^0.1.50"
+futures-util = "^0.3.14"
+uuid = "^1.3.3"
+[dependencies.rand]
+version = "^0.8.5"
+optional = true
+[dependencies.tokio]
+version = "^1.16.1"
+features = ["tracing"]
+optional = true
+[dependencies.sqlx]
+version = "0.7"
+features = ["json"]
+optional = true
\ No newline at end of file
diff --git a/util/src/error.rs b/util/src/error.rs
new file mode 100644
index 0000000..1c95020
--- /dev/null
+++ b/util/src/error.rs
@@ -0,0 +1,95 @@
+use serde::{Deserialize, Serialize};
+use http::StatusCode;
+use axum_core::response::{Response, IntoResponse};
+
+#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)]
+#[serde(rename_all = "snake_case")]
+/// Kinds of errors that can happen within a Micropub operation.
+pub enum ErrorType {
+    /// An erroneous attempt to create something that already exists.
+    AlreadyExists,
+    /// Current user is expressly forbidden from performing this action.
+    Forbidden,
+    /// The Micropub server experienced an internal error.
+    InternalServerError,
+    /// The request was invalid or malformed.
+    InvalidRequest,
+    /// The provided OAuth2 scopes were insufficient to allow performing this action.
+    InvalidScope,
+    /// There was no token or other means of authorization in the request.
+    NotAuthorized,
+    /// Whatever was requested was not found.
+    NotFound,
+    /// The request payload was of a type unsupported by the Micropub endpoint.
+    UnsupportedMediaType,
+}
+
+/// Representation of the Micropub API error.
+#[derive(Serialize, Deserialize, Debug)]
+pub struct MicropubError {
+    /// General kind of an error that occured.
+    pub error: ErrorType,
+    /// A human-readable error description intended for application developers.
+    // TODO use Cow<'static, str> to save on heap allocations
+    pub error_description: String,
+}
+
+impl std::error::Error for MicropubError {}
+
+impl std::fmt::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<serde_json::Error> for MicropubError {
+    fn from(err: serde_json::Error) -> Self {
+        use ErrorType::*;
+        Self {
+            error: InvalidRequest,
+            error_description: err.to_string(),
+        }
+    }
+}
+
+impl MicropubError {
+    /// Create a new Micropub error.
+    pub fn new(error: ErrorType, error_description: &str) -> Self {
+        Self {
+            error,
+            error_description: error_description.to_owned(),
+        }
+    }
+}
+
+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<MicropubError> for StatusCode {
+    fn from(err: MicropubError) -> Self {
+        (&err).into()
+    }
+}
+
+impl IntoResponse for MicropubError {
+    fn into_response(self) -> Response {
+        IntoResponse::into_response((
+            StatusCode::from(&self),
+            [("Content-Type", "application/json")],
+            serde_json::to_string(&self).unwrap(),
+        ))
+    }
+}
diff --git a/util/src/lib.rs b/util/src/lib.rs
new file mode 100644
index 0000000..c49bdf5
--- /dev/null
+++ b/util/src/lib.rs
@@ -0,0 +1,123 @@
+#![warn(missing_docs)]
+//! Small things that couldn't fit elsewhere in Kittybox, yet may be
+//! useful on their own or in multiple Kittybox crates.
+//!
+//! Some things are gated behind features, namely:
+//!  - `fs` - enables use of filesystem-related utilities
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct IndiewebEndpoints {
+    pub authorization_endpoint: String,
+    pub token_endpoint: String,
+    pub webmention: Option<String>,
+    pub microsub: Option<String>,
+}
+
+/// Data structure representing a Micropub channel in the ?q=channels output.
+#[derive(Serialize, Deserialize, PartialEq, Debug)]
+#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))]
+pub struct MicropubChannel {
+    /// The channel's UID. It is usually also a publically accessible permalink URL.
+    pub uid: String,
+    /// The channel's user-friendly name used to recognize it in lists.
+    pub name: String,
+}
+
+#[derive(Debug, Default)]
+/// Common types of webmentions.
+pub enum MentionType {
+    /// Corresponds to a `u-in-reply-to` link.
+    Reply,
+    /// Corresponds to a `u-like-of` link.
+    Like,
+    /// Corresponds to a `u-repost-of` link.
+    Repost,
+    /// Corresponds to a `u-bookmark-of` link.
+    Bookmark,
+    /// A plain link without MF2 annotations.
+    #[default]
+    Mention
+}
+
+/// Common errors from the IndieWeb protocols that can be reused between modules.
+pub mod error;
+pub use error::{ErrorType, MicropubError};
+
+/// Common data-types useful in creating smart authentication systems.
+pub mod auth {
+    #[derive(PartialEq, Eq, Hash, Clone, Copy)]
+    pub enum EnrolledCredential {
+        /// An indicator that a password is enrolled. Passwords can be
+        /// used to recover from a lost token.
+        Password,
+        /// An indicator that one or more WebAuthn credentials were
+        /// enrolled.
+        WebAuthn
+    }
+}
+
+/// A collection of traits for implementing a robust job queue.
+pub mod queue;
+
+#[cfg(feature = "fs")]
+/// Commonly-used operations with the file system in Kittybox's
+/// underlying storage mechanisms.
+pub mod fs {
+    use std::io::{self, Result};
+    use std::path::{Path, PathBuf};
+    use rand::{Rng, distributions::Alphanumeric};
+    use tokio::fs;
+
+    /// Create a temporary file named `temp.[a-zA-Z0-9]{length}` in
+    /// the given location and immediately open it. Returns the
+    /// filename and the corresponding file handle. It is the caller's
+    /// responsibility to clean up the temporary file when it is no
+    /// longer needed.
+    ///
+    /// Uses [`OpenOptions::create_new`][fs::OpenOptions::create_new]
+    /// to detect filename collisions, in which case it will
+    /// automatically retry until the operation succeeds.
+    ///
+    /// # Errors
+    ///
+    /// Returns the underlying [`io::Error`] if the operation fails
+    /// due to reasons other than filename collision.
+    pub async fn mktemp<T, B>(dir: T, basename: B, length: usize) -> Result<(PathBuf, fs::File)>
+    where
+        T: AsRef<Path>,
+        B: Into<Option<&'static str>>
+    {
+        let dir = dir.as_ref();
+        let basename = basename.into().unwrap_or("");
+        fs::create_dir_all(dir).await?;
+
+        loop {
+            let filename = dir.join(format!(
+                "{}{}{}",
+                basename,
+                if basename.is_empty() { "" } else { "." },
+                {
+                    let string = rand::thread_rng()
+                        .sample_iter(&Alphanumeric)
+                        .take(length)
+                        .collect::<Vec<u8>>();
+                    String::from_utf8(string).unwrap()
+                }
+            ));
+
+            match fs::OpenOptions::new()
+                .create_new(true)
+                .write(true)
+                .open(&filename)
+                .await
+            {
+                Ok(file) => return Ok((filename, file)),
+                Err(err) => match err.kind() {
+                    io::ErrorKind::AlreadyExists => continue,
+                    _ => return Err(err)
+                }
+            }
+        }
+    }
+}
diff --git a/util/src/queue.rs b/util/src/queue.rs
new file mode 100644
index 0000000..c880597
--- /dev/null
+++ b/util/src/queue.rs
@@ -0,0 +1,66 @@
+use futures_util::Stream;
+use std::pin::Pin;
+use uuid::Uuid;
+
+#[async_trait::async_trait]
+/// A job queue that can store and return jobs.
+pub trait JobQueue<T: JobItem>: Send + Sync + Sized + Clone + 'static {
+    /// A type of job object that will be returned by the queue.
+    type Job: Job<T, Self>;
+    /// Error type that the queue can produce in its work.
+    type Error: std::error::Error + Send + Sync + Sized;
+
+    /// Get one item from the job queue, if the job queue has pending
+    /// items available.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if a job queue failed in some way. Having no
+    /// items is not a failure, in which case `Ok(None)` is returned.
+    async fn get_one(&self) -> Result<Option<Self::Job>, Self::Error>;
+    /// Put an item into a job queue, returning its UUID.
+    async fn put(&self, item: &T) -> Result<Uuid, Self::Error>;
+
+    /*
+    /// Check the amount of pending and stuck items in the job queue.
+    async fn len(&self) -> Result<(usize, usize), Self::Error>;
+    /// Returns whether the job queue has some pending items.
+    async fn is_empty(&self) -> Result<bool, Self::Error> {
+        Ok(self.len().await?.0 == 0)
+    }
+    /// Returns whether the job queue has some stuck items that
+    /// require manual cleanup.
+    async fn has_stuck(&self) -> Result<bool, Self::Error> {
+        Ok(self.len().await?.1 > 0)
+    }
+    */
+
+    /// Consume the job queue object and return a stream of unstuck
+    /// items from the job queue.
+    ///
+    /// Note that one item may be returned several times if it is not
+    /// marked as done.
+    async fn into_stream(self) -> Result<Pin<Box<dyn Stream<Item = Result<Self::Job, Self::Error>> + Send>>, Self::Error>;
+}
+
+#[async_trait::async_trait]
+/// A job description yielded from a job queue.
+///
+/// # Implementors
+///
+/// On [`Drop`], the job should be returned to a job queue. If your
+/// job queue tracks attempts, the counter should be incremented by
+/// one.
+///
+/// Figuring how to do this asynchronously from a synchronous trait
+/// is left as an exercise to the reader.
+pub trait Job<T: JobItem, Q: JobQueue<T>>: Send + Sync + Sized {
+    /// Get the object describing the task itself.
+    fn job(&self) -> &T;
+    /// Mark the job as done and remove it from the job queue.
+    async fn done(self) -> Result<(), Q::Error>;
+}
+
+/// An object describing the job itself, returned as part of a
+/// [`Job`].
+pub trait JobItem: Send + Sync + Sized + std::fmt::Debug {}