tetratto_core/model/
littleweb.rs

1use std::fmt::Display;
2use serde::{Serialize, Deserialize};
3use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
4use paste::paste;
5use std::sync::LazyLock;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Service {
9    pub id: usize,
10    pub created: usize,
11    pub owner: usize,
12    pub name: String,
13    pub files: Vec<ServiceFsEntry>,
14    pub revision: usize,
15}
16
17impl Service {
18    /// Create a new [`Service`].
19    pub fn new(name: String, owner: usize) -> Self {
20        Self {
21            id: Snowflake::new().to_string().parse::<usize>().unwrap(),
22            created: unix_epoch_timestamp(),
23            owner,
24            name,
25            files: Vec::new(),
26            revision: unix_epoch_timestamp(),
27        }
28    }
29
30    /// Resolve a file from the virtual file system.
31    ///
32    /// # Returns
33    /// `(file, id path)`
34    pub fn file(&self, path: &str) -> Option<(ServiceFsEntry, Vec<String>)> {
35        let segments = path.chars().filter(|x| x == &'/').count();
36
37        let mut path = path.split("/");
38        let mut path_segment = path.next().unwrap();
39        let mut ids = Vec::new();
40        let mut i = 0;
41
42        let mut f = &self.files;
43
44        while let Some(nf) = f.iter().find(|x| x.name == path_segment) {
45            ids.push(nf.id.clone());
46
47            if i == segments {
48                return Some((nf.to_owned(), ids));
49            }
50
51            f = &nf.children;
52            path_segment = path.next().unwrap();
53            i += 1;
54        }
55
56        None
57    }
58
59    /// Resolve a file from the virtual file system (mutable).
60    ///
61    /// # Returns
62    /// `&mut file`
63    pub fn file_mut(&mut self, id_path: Vec<String>) -> Option<&mut ServiceFsEntry> {
64        let total_segments = id_path.len();
65        let mut i = 0;
66
67        let mut f = &mut self.files;
68        for segment in id_path {
69            if let Some(nf) = f.iter_mut().find(|x| (**x).id == segment) {
70                if i == total_segments - 1 {
71                    return Some(nf);
72                }
73
74                f = &mut nf.children;
75                i += 1;
76            } else {
77                break;
78            }
79        }
80
81        None
82    }
83}
84
85/// A file type for [`ServiceFsEntry`] structs.
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
87pub enum ServiceFsMime {
88    #[serde(alias = "text/html")]
89    Html,
90    #[serde(alias = "text/css")]
91    Css,
92    #[serde(alias = "text/javascript")]
93    Js,
94    #[serde(alias = "application/json")]
95    Json,
96    #[serde(alias = "text/plain")]
97    Plain,
98}
99
100impl Display for ServiceFsMime {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.write_str(match self {
103            Self::Html => "text/html",
104            Self::Css => "text/css",
105            Self::Js => "text/javascript",
106            Self::Json => "application/json",
107            Self::Plain => "text/plain",
108        })
109    }
110}
111
112/// A single entry in the file system of [`Service`].
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct ServiceFsEntry {
115    /// Files use a UUID since they're generated on the client.
116    pub id: String,
117    pub name: String,
118    pub mime: ServiceFsMime,
119    pub children: Vec<ServiceFsEntry>,
120    pub content: String,
121}
122
123macro_rules! domain_tld_display_match {
124    ($self:ident, $($tld:ident),+ $(,)?) => {
125        match $self {
126            $(
127                Self::$tld => stringify!($tld).to_lowercase(),
128            )+
129        }
130    }
131}
132
133macro_rules! domain_tld_strings {
134    ($($tld:ident),+ $(,)?) => {
135        $(
136            paste! {
137                /// Constant from macro.
138                const [<TLD_ $tld:snake:upper>]: LazyLock<String> = LazyLock::new(|| stringify!($tld).to_lowercase());
139            }
140        )+
141    }
142}
143
144macro_rules! domain_tld_from_match {
145    ($value:ident, $($tld:ident),+ $(,)?) => {
146        {
147            $(
148                paste! {
149                    let [<$tld:snake:lower>] = &*[<TLD_ $tld:snake:upper>];
150                }
151            )+;
152
153            // can't use match here, the expansion is going to look really ugly
154            $(
155                if $value == paste!{ [<$tld:snake:lower>] } {
156                    return Self::$tld;
157                }
158            )+
159
160            return Self::Bunny;
161        }
162    }
163}
164
165macro_rules! define_domain_tlds {
166    ($($tld:ident),+ $(,)?) => {
167        #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
168        pub enum DomainTld {
169            $($tld),+
170        }
171
172        domain_tld_strings!($($tld),+);
173
174        impl From<&str> for DomainTld {
175            fn from(value: &str) -> Self {
176                domain_tld_from_match!(
177                    value, $($tld),+
178                )
179            }
180        }
181
182        impl Display for DomainTld {
183            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184                // using this macro allows us to just copy and paste the enum variants
185                f.write_str(&domain_tld_display_match!(
186                    self, $($tld),+
187                ))
188            }
189        }
190
191        /// This is VERY important so that I don't have to manually type them all for the UI dropdown.
192        pub const TLDS_VEC: LazyLock<Vec<&str>> = LazyLock::new(|| vec![$(stringify!($tld)),+]);
193    }
194}
195
196define_domain_tlds!(
197    Bunny, Tet, Cool, Qwerty, Boy, Girl, Them, Quack, Bark, Meow, Silly, Wow, Neko, Yay, Lol, Love,
198    Fun, Gay, City, Woah, Clown, Apple, Yaoi, Yuri, World, Wav, Zero, Evil, Dragon, Yum, Site, All,
199    Me, Bug, Slop, Retro, Eye, Neo, Spring, Nurse, Pony
200);
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct Domain {
204    pub id: usize,
205    pub created: usize,
206    pub owner: usize,
207    pub name: String,
208    pub tld: DomainTld,
209    /// Data about the domain. This can only be configured by the domain's owner.
210    ///
211    /// Maximum of 4 entries. Stored in a structure of `(subdomain string, data)`.
212    pub data: Vec<(String, DomainData)>,
213}
214
215impl Domain {
216    /// Create a new [`Domain`].
217    pub fn new(name: String, tld: DomainTld, owner: usize) -> Self {
218        Self {
219            id: Snowflake::new().to_string().parse::<usize>().unwrap(),
220            created: unix_epoch_timestamp(),
221            owner,
222            name,
223            tld,
224            data: Vec::new(),
225        }
226    }
227
228    /// Get the domain's subdomain, name, TLD, and path segments from a string.
229    ///
230    /// If no subdomain is provided, the subdomain will be "@". This means that
231    /// domain data entries should use "@" as the root service.
232    pub fn from_str(value: &str) -> (String, String, DomainTld, String) {
233        let no_protocol = value.replace("atto://", "");
234
235        // we're reversing this so it's predictable, as there might not always be a subdomain
236        // (we shouldn't have the variable entry be first, there is always going to be a tld)
237        let mut s: Vec<&str> = no_protocol.split("/").next().unwrap().split(".").collect();
238        s.reverse();
239        let mut s = s.into_iter();
240
241        let tld = DomainTld::from(s.next().unwrap());
242        let domain = s.next().unwrap_or("default.bunny");
243        let subdomain = s.next().unwrap_or("@");
244
245        // get path
246        let mut chars = no_protocol.chars();
247        let mut char = '.';
248
249        while char != '/' {
250            // we need to keep eating characters until we reach the first /
251            // (marking the start of the path)
252            char = chars.next().unwrap_or('/');
253        }
254
255        let path: String = chars.collect();
256
257        // return
258        (subdomain.to_owned(), domain.to_owned(), tld, path)
259    }
260
261    /// Update an HTML/JS/CSS string with the correct URL for all "atto://" protocol requests.
262    ///
263    /// This would not be needed if the JS custom protocol API wasn't awful.
264    pub fn http_assets(input: String) -> String {
265        // this is served over the littleweb api NOT the main api!
266        //
267        // littleweb requests MUST be on another subdomain so cookies are
268        // not shared with custom user HTML (since users can embed JS which can make POST requests)
269        //
270        // the littleweb routes are used by providing the "LITTLEWEB" env var
271        input.replace("\"atto://", "/api/v1/file?addr=atto://")
272    }
273
274    /// Get the domain's service ID.
275    pub fn service(&self, subdomain: &str) -> Option<usize> {
276        let s = self.data.iter().find(|x| x.0 == subdomain)?;
277        match s.1 {
278            DomainData::Service(ref id) => Some(match id.parse::<usize>() {
279                Ok(id) => id,
280                Err(_) => return None,
281            }),
282            _ => None,
283        }
284    }
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
288pub enum DomainData {
289    /// The ID of the service this domain points to. The first service found will
290    /// always be used. This means having multiple service entires will be useless.
291    Service(String),
292    /// A text entry with a maximum of 512 characters.
293    Text(String),
294}