From c4de17058b6b5a81b1fff7a620048abd44ed63d8 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 7 Jul 2025 14:45:30 -0400 Subject: [PATCH] add: littleweb base --- README.md | 2 + crates/app/src/main.rs | 22 ++- crates/app/src/public/js/atto.js | 15 +- .../routes/api/v1/auth/connections/stripe.rs | 52 ++++++ crates/app/src/routes/api/v1/mod.rs | 4 + crates/app/src/routes/mod.rs | 11 ++ crates/app/src/routes/pages/mod.rs | 4 + crates/core/src/config.rs | 9 + crates/core/src/database/common.rs | 3 + crates/core/src/database/domains.rs | 153 +++++++++++++++++ crates/core/src/database/drivers/common.rs | 3 + .../database/drivers/sql/create_domains.sql | 8 + .../database/drivers/sql/create_layouts.sql | 9 + .../database/drivers/sql/create_services.sql | 7 + crates/core/src/database/mod.rs | 1 + crates/core/src/model/littleweb.rs | 154 ++++++++++++++++++ crates/core/src/model/mod.rs | 1 + crates/core/src/model/permissions.rs | 2 + example/tetratto.toml | 1 + justfile | 4 + 20 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 crates/core/src/database/domains.rs create mode 100644 crates/core/src/database/drivers/sql/create_domains.sql create mode 100644 crates/core/src/database/drivers/sql/create_layouts.sql create mode 100644 crates/core/src/database/drivers/sql/create_services.sql create mode 100644 crates/core/src/model/littleweb.rs diff --git a/README.md b/README.md index e1ac999..d1a3d80 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ A `docs` directory will be generated in the same directory that you ran the `tet You can configure your port through the `port` key of the configuration file. You can also run the server with the `PORT` environment variable, which will override whatever is set in the configuration file. You can also use the `CACHE_BREAKER` environment variable to specify a version number to be used in static asset links in order to break cache entries. +You can launch with the `LITTLEWEB=true` environment variable to start the littleweb viewer/fake DNS server. This should be used in combination with `PORT`, as well as set as the `lw_host` in your configuration file. This secondary server is required to allow users to view their littleweb projects. + ## Usage (as a user) Tetratto is very simple once you get the hang of it! At the top of the page (or bottom if you're on mobile), you'll see the navigation bar. Once logged in, you'll be able to access "Home", "Popular", and "Communities" from there! You can also press your profile picture (on the right) to view your own profile, settings, or log out! diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 75a9c02..baad195 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -113,9 +113,22 @@ async fn main() { tera.register_filter("emojis", render_emojis); let client = Client::new(); + let mut app = Router::new(); - let app = Router::new() - .merge(routes::routes(&config)) + // add correct routes + if var("LITTLEWEB").is_ok() { + app = app.merge(routes::lw_routes()); + } else { + app = app + .merge(routes::routes(&config)) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("content-security-policy"), + HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors 'self'"), + )); + } + + // add junk + app = app .layer(Extension(Arc::new(RwLock::new((database, tera, client))))) .layer(axum::extract::DefaultBodyLimit::max( var("BODY_LIMIT") @@ -128,12 +141,9 @@ async fn main() { .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), ) - .layer(SetResponseHeaderLayer::if_not_present( - HeaderName::from_static("content-security-policy"), - HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors 'self'"), - )) .layer(CatchPanicLayer::new()); + // ... let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port)) .await .unwrap(); diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index be40e7f..43a46b8 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1277,11 +1277,22 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} } if ( - text.includes(`!`) + text.includes( + `!`, + ) || + document.documentElement.innerHTML.includes("observer_disconnect") ) { console.log("io_data_end; disconnect"); self.IO_DATA_OBSERVER.disconnect(); - self.IO_DATA_ELEMENT.innerHTML += text; + + if ( + !document.documentElement.innerHTML.includes( + "observer_disconnect", + ) + ) { + self.IO_DATA_ELEMENT.innerHTML += text; + } + self.IO_DATA_DISCONNECTED = true; return; } diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index b5f746c..e62a0e8 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -207,6 +207,58 @@ pub async fn stripe_webhook( return Json(e.into()); } } + EventType::InvoicePaymentFailed => { + // payment failed + let subscription = match req.data.object { + EventObject::Subscription(c) => c, + _ => unreachable!("cannot be this"), + }; + + let customer_id = subscription.customer.id(); + + let user = match data.get_user_by_stripe_id(customer_id.as_str()).await { + Ok(ua) => ua, + Err(e) => return Json(e.into()), + }; + + tracing::info!( + "unsubscribe (pay fail) {} (stripe: {})", + user.id, + customer_id + ); + let new_user_permissions = user.permissions - FinePermission::SUPPORTER; + + if let Err(e) = data + .update_user_role(user.id, new_user_permissions, user.clone(), true) + .await + { + return Json(e.into()); + } + + if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0 + { + // user doesn't come from an invite code, and is a purchased account + // this means their account must be locked if they stop paying + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .await + { + return Json(e.into()); + } + } + + if let Err(e) = data + .create_notification(Notification::new( + "It seems your recent payment has failed :(".to_string(), + "No worries! Your subscription is still active and will be retried. Your supporter status will resume when you have a successful payment." + .to_string(), + user.id, + )) + .await + { + return Json(e.into()); + } + } _ => return Json(Error::Unknown.into()), } diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 19a17ca..42bcd97 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -635,6 +635,10 @@ pub fn routes() -> Router { .route("/layouts/{id}/pages", post(layouts::update_pages_request)) } +pub fn lw_routes() -> Router { + Router::new() +} + #[derive(Deserialize)] pub struct LoginProps { pub username: String, diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index 80f5cbe..81746d9 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -46,3 +46,14 @@ pub fn routes(config: &Config) -> Router { // pages .merge(pages::routes()) } + +/// These routes are only used when you provide the `LITTLEWEB` environment variable. +/// +/// These routes are NOT for editing. These routes are only for viewing littleweb sites. +pub fn lw_routes() -> Router { + Router::new() + // api + .nest("/api/v1", api::v1::lw_routes()) + // pages + .merge(pages::lw_routes()) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 6cf5431..07bd5a7 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -141,6 +141,10 @@ pub fn routes() -> Router { .route("/x/{note}", get(journals::global_view_request)) } +pub fn lw_routes() -> Router { + Router::new() +} + pub async fn render_error( e: Error, jar: &CookieJar, diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 3a3e7d6..85ff839 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -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(), diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index aabb0d3..969b014 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -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 diff --git a/crates/core/src/database/domains.rs b/crates/core/src/database/domains.rs new file mode 100644 index 0000000..0249f6f --- /dev/null +++ b/crates/core/src/database/domains.rs @@ -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 { + 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> { + 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 { + // 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:{}"); +} diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index e1cfad7..efa3eae 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -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"); diff --git a/crates/core/src/database/drivers/sql/create_domains.sql b/crates/core/src/database/drivers/sql/create_domains.sql new file mode 100644 index 0000000..fc0f190 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_domains.sql @@ -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 +) diff --git a/crates/core/src/database/drivers/sql/create_layouts.sql b/crates/core/src/database/drivers/sql/create_layouts.sql new file mode 100644 index 0000000..3f28c0a --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_layouts.sql @@ -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 +) diff --git a/crates/core/src/database/drivers/sql/create_services.sql b/crates/core/src/database/drivers/sql/create_services.sql new file mode 100644 index 0000000..78277b5 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_services.sql @@ -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 +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 6877100..5e3cd5b 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -5,6 +5,7 @@ mod channels; mod common; mod communities; pub mod connections; +mod domains; mod drafts; mod drivers; mod emojis; diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs new file mode 100644 index 0000000..474bec4 --- /dev/null +++ b/crates/core/src/model/littleweb.rs @@ -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, +} + +/// 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, + 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) { + // 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 { + 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), +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 3ff8379..e825340 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -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; diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 1584083..55cf9cc 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -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; } diff --git a/example/tetratto.toml b/example/tetratto.toml index 0f36100..bc7ff59 100644 --- a/example/tetratto.toml +++ b/example/tetratto.toml @@ -4,6 +4,7 @@ color = "#c9b1bc" port = 4118 banned_hosts = [] host = "http://localhost:4118" +lw_host = "http://localhost:4119" no_track = [] banned_usernames = [ "admin", diff --git a/justfile b/justfile index ad945c9..a83d0c4 100644 --- a/justfile +++ b/justfile @@ -8,3 +8,7 @@ fix: doc: cargo doc --document-private-items --no-deps + +test: + cd example && LITTLEWEB=true PORT=4119 cargo run & + cd example && cargo run