add: littleweb base

This commit is contained in:
trisua 2025-07-07 14:45:30 -04:00
parent 07a23f505b
commit c4de17058b
20 changed files with 457 additions and 8 deletions

View file

@ -252,6 +252,10 @@ pub struct Config {
/// so this host should be included in there as well.
#[serde(default = "default_host")]
pub host: String,
/// The main public host of the littleweb server. **Not** used to check against banned hosts,
/// so this host should be included in there as well.
#[serde(default = "default_lw_host")]
pub lw_host: String,
/// Database security.
#[serde(default = "default_security")]
pub security: SecurityConfig,
@ -319,6 +323,10 @@ fn default_host() -> String {
String::new()
}
fn default_lw_host() -> String {
String::new()
}
fn default_security() -> SecurityConfig {
SecurityConfig::default()
}
@ -385,6 +393,7 @@ impl Default for Config {
port: default_port(),
banned_hosts: default_banned_hosts(),
host: default_host(),
lw_host: default_lw_host(),
database: default_database(),
security: default_security(),
dirs: default_dirs(),

View file

@ -40,6 +40,9 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_NOTES).unwrap();
execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap();
execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap();
execute!(&conn, common::CREATE_TABLE_LAYOUTS).unwrap();
execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap();
execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap();
self.0
.1

View file

@ -0,0 +1,153 @@
use crate::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};
impl DataManager {
/// Get a [`Domain`] from an SQL row.
pub(crate) fn get_domain_from_row(x: &PostgresRow) -> Domain {
Domain {
id: get!(x->0(i64)) as usize,
created: get!(x->1(i64)) as usize,
owner: get!(x->2(i64)) as usize,
name: get!(x->3(String)),
tld: (get!(x->4(String)).as_str()).into(),
data: serde_json::from_str(&get!(x->5(String))).unwrap(),
}
}
auto_method!(get_domain_by_id(usize as i64)@get_domain_from_row -> "SELECT * FROM domains WHERE id = $1" --name="domain" --returns=Domain --cache-key-tmpl="atto.domain:{}");
/// Get a domain given its name and TLD.
///
/// # Arguments
/// * `name`
/// * `tld`
pub async fn get_domain_by_name_tld(&self, name: &str, tld: &DomainTld) -> Result<Domain> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_row!(
&conn,
"SELECT * FROM domains WHERE name = $1 AND tld = $2",
&[&name, &tld.to_string()],
|x| { Ok(Self::get_domain_from_row(x)) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("domain".to_string()));
}
Ok(res.unwrap())
}
/// Get all domains by user.
///
/// # Arguments
/// * `id` - the ID of the user to fetch domains for
pub async fn get_domains_by_user(&self, id: usize) -> Result<Vec<Domain>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM domains WHERE owner = $1 ORDER BY created DESC",
&[&(id as i64)],
|x| { Self::get_domain_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("domain".to_string()));
}
Ok(res.unwrap())
}
/// Create a new domain in the database.
///
/// # Arguments
/// * `data` - a mock [`Domain`] object to insert
pub async fn create_domain(&self, data: Domain) -> Result<Domain> {
// check values
if data.name.len() < 2 {
return Err(Error::DataTooShort("name".to_string()));
} else if data.name.len() > 128 {
return Err(Error::DataTooLong("name".to_string()));
}
// check for existing
if self
.get_domain_by_name_tld(&data.name, &data.tld)
.await
.is_ok()
{
return Err(Error::MiscError(
"Domain + TLD already in use. Maybe try another TLD!".to_string(),
));
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"INSERT INTO domains VALUES ($1, $2, $3, $4, $5, $6)",
params![
&(data.id as i64),
&(data.created as i64),
&(data.owner as i64),
&data.name,
&data.tld.to_string(),
&serde_json::to_string(&data.data).unwrap(),
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(data)
}
pub async fn delete_domain(&self, id: usize, user: &User) -> Result<()> {
let domain = self.get_domain_by_id(id).await?;
// check user permission
if user.id != domain.owner
&& !user
.secondary_permissions
.check(SecondaryPermission::MANAGE_DOMAINS)
{
return Err(Error::NotAllowed);
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(&conn, "DELETE FROM domains WHERE id = $1", &[&(id as i64)]);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// ...
self.0.1.remove(format!("atto.domain:{}", id)).await;
Ok(())
}
auto_method!(update_domain_data(Vec<(String, DomainData)>)@get_domain_by_id:FinePermission::MANAGE_USERS; -> "UPDATE domains SET data = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.domain:{}");
}

View file

@ -27,3 +27,6 @@ pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql"
pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql");
pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql");
pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql");
pub const CREATE_TABLE_LAYOUTS: &str = include_str!("./sql/create_layouts.sql");
pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql");
pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql");

View file

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS domains (
id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
name TEXT NOT NULL,
tld TEXT NOT NULL,
data TEXT NOT NULL
)

View file

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS layouts (
id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
title TEXT NOT NULL,
privacy TEXT NOT NULL,
pages TEXT NOT NULL,
replaces TEXT NOT NULL
)

View file

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS services (
id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
name TEXT NOT NULL,
files TEXT NOT NULL
)

View file

@ -5,6 +5,7 @@ mod channels;
mod common;
mod communities;
pub mod connections;
mod domains;
mod drafts;
mod drivers;
mod emojis;

View file

@ -0,0 +1,154 @@
use std::fmt::Display;
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Service {
pub id: usize,
pub created: usize,
pub owner: usize,
pub name: String,
pub files: Vec<ServiceFsEntry>,
}
/// 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 {
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,
}
}
}
#[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 {
/// 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) -> (&str, &str, DomainTld, Vec<String>) {
// 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> = value.split(".").collect();
s.reverse();
let mut s = s.into_iter();
let tld = DomainTld::from(s.next().unwrap());
let domain = s.next().unwrap();
let subdomain = s.next().unwrap_or("@");
// get path
let no_protocol = value.replace("atto://", "");
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();
}
let path: String = chars.collect();
// return
(
subdomain,
domain,
tld,
path.split("/").map(|x| x.to_owned()).collect(),
)
}
/// 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/over_http?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(id) => Some(id),
_ => 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(usize),
/// A text entry with a maximum of 512 characters.
Text(String),
}

View file

@ -7,6 +7,7 @@ pub mod communities;
pub mod communities_permissions;
pub mod journals;
pub mod layouts;
pub mod littleweb;
pub mod moderation;
pub mod oauth;
pub mod permissions;

View file

@ -174,6 +174,8 @@ bitflags! {
pub struct SecondaryPermission: u32 {
const DEFAULT = 1 << 0;
const ADMINISTRATOR = 1 << 1;
const MANAGE_DOMAINS = 1 << 2;
const MANAGE_SERVICES = 1 << 3;
const _ = !0;
}