add: littleweb full

This commit is contained in:
trisua 2025-07-08 13:35:23 -04:00
parent 3fc0872867
commit d67e7c9c33
32 changed files with 1699 additions and 71 deletions

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-core"
version = "10.0.0"
version = "11.0.0"
edition = "2024"
[dependencies]
@ -19,4 +19,8 @@ base16ct = { version = "0.2.0", features = ["alloc"] }
base64 = "0.22.1"
emojis = "0.7.0"
regex = "1.11.1"
oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis"] }
oiseau = { version = "0.1.2", default-features = false, features = [
"postgres",
"redis",
] }
paste = "1.0.15"

View file

@ -361,6 +361,9 @@ fn default_banned_usernames() -> Vec<String> {
"search".to_string(),
"journals".to_string(),
"links".to_string(),
"app".to_string(),
"services".to_string(),
"domains".to_string(),
]
}

View file

@ -1,8 +1,11 @@
use crate::model::{
auth::User,
littleweb::{Domain, DomainData, DomainTld},
permissions::{FinePermission, SecondaryPermission},
Error, Result,
use crate::{
database::NAME_REGEX,
model::{
auth::User,
littleweb::{Domain, DomainData, DomainTld},
permissions::{FinePermission, SecondaryPermission},
Error, Result,
},
};
use crate::{auto_method, DataManager};
use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow};
@ -71,6 +74,8 @@ impl DataManager {
Ok(res.unwrap())
}
const MAXIMUM_FREE_DOMAINS: usize = 5;
/// Create a new domain in the database.
///
/// # Arguments
@ -83,6 +88,31 @@ impl DataManager {
return Err(Error::DataTooLong("name".to_string()));
}
// check number of domains
let owner = self.get_user_by_id(data.owner).await?;
if !owner.permissions.check(FinePermission::SUPPORTER) {
let domains = self.get_domains_by_user(data.owner).await?;
if domains.len() >= Self::MAXIMUM_FREE_DOMAINS {
return Err(Error::MiscError(
"You already have the maximum number of domains you can have".to_string(),
));
}
}
// check name
let regex = regex::RegexBuilder::new(NAME_REGEX)
.multi_line(true)
.build()
.unwrap();
if regex.captures(&data.name).is_some() {
return Err(Error::MiscError(
"Domain name contains invalid characters".to_string(),
));
}
// check for existing
if self
.get_domain_by_name_tld(&data.name, &data.tld)

View file

@ -126,5 +126,6 @@ impl DataManager {
Ok(())
}
auto_method!(update_service_files(Vec<ServiceFsEntry>)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET data = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}");
auto_method!(update_service_name(&str)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.service:{}");
auto_method!(update_service_files(Vec<ServiceFsEntry>)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET files = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}");
}

View file

@ -1,6 +1,8 @@
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 {
@ -24,18 +26,24 @@ impl Service {
}
/// Resolve a file from the virtual file system.
pub fn file(&self, path: &str) -> Option<ServiceFsEntry> {
///
/// # 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) {
if i == segments - 1 {
return Some(nf.to_owned());
ids.push(nf.id.clone());
if i == segments {
return Some((nf.to_owned(), ids));
}
f = &nf.children;
@ -45,6 +53,31 @@ impl Service {
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.
@ -77,36 +110,92 @@ impl Display for ServiceFsMime {
/// 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,
/// SHA-256 checksum of the entry's content.
pub checksum: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum DomainTld {
Bunny,
}
impl Display for DomainTld {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Bunny => "bunny",
})
}
}
impl From<&str> for DomainTld {
fn from(value: &str) -> Self {
match value {
"bunny" => Self::Bunny,
_ => Self::Bunny,
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
);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Domain {
pub id: usize,
@ -142,12 +231,12 @@ impl Domain {
// 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(".").collect();
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();
let domain = s.next().unwrap_or("default.bunny");
let subdomain = s.next().unwrap_or("@");
// get path
@ -157,7 +246,7 @@ impl Domain {
while char != '/' {
// we need to keep eating characters until we reach the first /
// (marking the start of the path)
char = chars.next().unwrap();
char = chars.next().unwrap_or('/');
}
let path: String = chars.collect();
@ -183,7 +272,10 @@ impl Domain {
pub fn service(&self, subdomain: &str) -> Option<usize> {
let s = self.data.iter().find(|x| x.0 == subdomain)?;
match s.1 {
DomainData::Service(id) => Some(id),
DomainData::Service(ref id) => Some(match id.parse::<usize>() {
Ok(id) => id,
Err(_) => return None,
}),
_ => None,
}
}
@ -193,7 +285,7 @@ impl Domain {
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(usize),
Service(String),
/// A text entry with a maximum of 512 characters.
Text(String),
}