tetratto-core 12.0.2

The core behind Tetratto
Documentation
use std::fmt::Display;
use serde::{Serialize, Deserialize};
use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
use paste::paste;
use std::sync::LazyLock;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Service {
    pub id: usize,
    pub created: usize,
    pub owner: usize,
    pub name: String,
    pub files: Vec<ServiceFsEntry>,
    pub revision: usize,
}

impl Service {
    /// Create a new [`Service`].
    pub fn new(name: String, owner: usize) -> Self {
        Self {
            id: Snowflake::new().to_string().parse::<usize>().unwrap(),
            created: unix_epoch_timestamp(),
            owner,
            name,
            files: Vec::new(),
            revision: unix_epoch_timestamp(),
        }
    }

    /// Resolve a file from the virtual file system.
    ///
    /// # Returns
    /// `(file, id path)`
    pub fn file(&self, path: &str) -> Option<(ServiceFsEntry, Vec<String>)> {
        let segments = path.chars().filter(|x| x == &'/').count();

        let mut path = path.split("/");
        let mut path_segment = path.next().unwrap();
        let mut ids = Vec::new();
        let mut i = 0;

        let mut f = &self.files;

        while let Some(nf) = f.iter().find(|x| x.name == path_segment) {
            ids.push(nf.id.clone());

            if i == segments {
                return Some((nf.to_owned(), ids));
            }

            f = &nf.children;
            path_segment = path.next().unwrap();
            i += 1;
        }

        None
    }

    /// Resolve a file from the virtual file system (mutable).
    ///
    /// # Returns
    /// `&mut file`
    pub fn file_mut(&mut self, id_path: Vec<String>) -> Option<&mut ServiceFsEntry> {
        let total_segments = id_path.len();
        let mut i = 0;

        let mut f = &mut self.files;
        for segment in id_path {
            if let Some(nf) = f.iter_mut().find(|x| (**x).id == segment) {
                if i == total_segments - 1 {
                    return Some(nf);
                }

                f = &mut nf.children;
                i += 1;
            } else {
                break;
            }
        }

        None
    }
}

/// A file type for [`ServiceFsEntry`] structs.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ServiceFsMime {
    #[serde(alias = "text/html")]
    Html,
    #[serde(alias = "text/css")]
    Css,
    #[serde(alias = "text/javascript")]
    Js,
    #[serde(alias = "application/json")]
    Json,
    #[serde(alias = "text/plain")]
    Plain,
}

impl Display for ServiceFsMime {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            Self::Html => "text/html",
            Self::Css => "text/css",
            Self::Js => "text/javascript",
            Self::Json => "application/json",
            Self::Plain => "text/plain",
        })
    }
}

/// A single entry in the file system of [`Service`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceFsEntry {
    /// Files use a UUID since they're generated on the client.
    pub id: String,
    pub name: String,
    pub mime: ServiceFsMime,
    pub children: Vec<ServiceFsEntry>,
    pub content: String,
}

macro_rules! domain_tld_display_match {
    ($self:ident, $($tld:ident),+ $(,)?) => {
        match $self {
            $(
                Self::$tld => stringify!($tld).to_lowercase(),
            )+
        }
    }
}

macro_rules! domain_tld_strings {
    ($($tld:ident),+ $(,)?) => {
        $(
            paste! {
                /// Constant from macro.
                const [<TLD_ $tld:snake:upper>]: LazyLock<String> = LazyLock::new(|| stringify!($tld).to_lowercase());
            }
        )+
    }
}

macro_rules! domain_tld_from_match {
    ($value:ident, $($tld:ident),+ $(,)?) => {
        {
            $(
                paste! {
                    let [<$tld:snake:lower>] = &*[<TLD_ $tld:snake:upper>];
                }
            )+;

            // can't use match here, the expansion is going to look really ugly
            $(
                if $value == paste!{ [<$tld:snake:lower>] } {
                    return Self::$tld;
                }
            )+

            return Self::Bunny;
        }
    }
}

macro_rules! define_domain_tlds {
    ($($tld:ident),+ $(,)?) => {
        #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
        pub enum DomainTld {
            $($tld),+
        }

        domain_tld_strings!($($tld),+);

        impl From<&str> for DomainTld {
            fn from(value: &str) -> Self {
                domain_tld_from_match!(
                    value, $($tld),+
                )
            }
        }

        impl Display for DomainTld {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                // using this macro allows us to just copy and paste the enum variants
                f.write_str(&domain_tld_display_match!(
                    self, $($tld),+
                ))
            }
        }

        /// This is VERY important so that I don't have to manually type them all for the UI dropdown.
        pub const TLDS_VEC: LazyLock<Vec<&str>> = LazyLock::new(|| vec![$(stringify!($tld)),+]);
    }
}

define_domain_tlds!(
    Bunny, Tet, Cool, Qwerty, Boy, Girl, Them, Quack, Bark, Meow, Silly, Wow, Neko, Yay, Lol, Love,
    Fun, Gay, City, Woah, Clown, Apple, Yaoi, Yuri, World, Wav, Zero, Evil, Dragon, Yum, Site, All,
    Me, Bug, Slop, Retro, Eye, Neo, Spring, Nurse, Pony
);

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Domain {
    pub id: usize,
    pub created: usize,
    pub owner: usize,
    pub name: String,
    pub tld: DomainTld,
    /// Data about the domain. This can only be configured by the domain's owner.
    ///
    /// Maximum of 4 entries. Stored in a structure of `(subdomain string, data)`.
    pub data: Vec<(String, DomainData)>,
}

impl Domain {
    /// Create a new [`Domain`].
    pub fn new(name: String, tld: DomainTld, owner: usize) -> Self {
        Self {
            id: Snowflake::new().to_string().parse::<usize>().unwrap(),
            created: unix_epoch_timestamp(),
            owner,
            name,
            tld,
            data: Vec::new(),
        }
    }

    /// Get the domain's subdomain, name, TLD, and path segments from a string.
    ///
    /// If no subdomain is provided, the subdomain will be "@". This means that
    /// domain data entries should use "@" as the root service.
    pub fn from_str(value: &str) -> (String, String, DomainTld, String) {
        let no_protocol = value.replace("atto://", "");

        // we're reversing this so it's predictable, as there might not always be a subdomain
        // (we shouldn't have the variable entry be first, there is always going to be a tld)
        let mut s: Vec<&str> = no_protocol.split("/").next().unwrap().split(".").collect();
        s.reverse();
        let mut s = s.into_iter();

        let tld = DomainTld::from(s.next().unwrap());
        let domain = s.next().unwrap_or("default.bunny");
        let subdomain = s.next().unwrap_or("@");

        // get path
        let mut chars = no_protocol.chars();
        let mut char = '.';

        while char != '/' {
            // we need to keep eating characters until we reach the first /
            // (marking the start of the path)
            char = chars.next().unwrap_or('/');
        }

        let path: String = chars.collect();

        // return
        (subdomain.to_owned(), domain.to_owned(), tld, path)
    }

    /// Update an HTML/JS/CSS string with the correct URL for all "atto://" protocol requests.
    ///
    /// This would not be needed if the JS custom protocol API wasn't awful.
    pub fn http_assets(input: String) -> String {
        // this is served over the littleweb api NOT the main api!
        //
        // littleweb requests MUST be on another subdomain so cookies are
        // not shared with custom user HTML (since users can embed JS which can make POST requests)
        //
        // the littleweb routes are used by providing the "LITTLEWEB" env var
        input.replace("\"atto://", "/api/v1/file?addr=atto://")
    }

    /// Get the domain's service ID.
    pub fn service(&self, subdomain: &str) -> Option<usize> {
        let s = self.data.iter().find(|x| x.0 == subdomain)?;
        match s.1 {
            DomainData::Service(ref id) => Some(match id.parse::<usize>() {
                Ok(id) => id,
                Err(_) => return None,
            }),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum DomainData {
    /// The ID of the service this domain points to. The first service found will
    /// always be used. This means having multiple service entires will be useless.
    Service(String),
    /// A text entry with a maximum of 512 characters.
    Text(String),
}