diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 0842e62..cfae86e 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -267,3 +267,6 @@ version = "1.0.0" "journals:action.publish" = "Publish" "journals:action.unpublish" = "Unpublish" "journals:action.view" = "View" + +"littleweb:label.create_new" = "Create new site" +"littleweb:label.my_services" = "My services" diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp new file mode 100644 index 0000000..e4525ca --- /dev/null +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -0,0 +1,92 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My stacks - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{{ macros::timelines_nav(selected=\"littleweb\") }} {% if user -%}") + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (str (text "littleweb:label.create_new")))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "create_service_from_form(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "name") + (text "{{ text \"communities:label.name\" }}")) + (input + ("type" "text") + ("name" "name") + ("id" "name") + ("placeholder" "name") + ("required" "") + ("minlength" "2") + ("maxlength" "32"))) + (button + ("class" "primary") + (text "{{ text \"communities:action.create\" }}")))) + (text "{%- endif %}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "panel-top")) + (span + (str (text "littleweb:label.my_services"))))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for item in list %}") + (a + ("href" "/services/{{ item.id }}") + ("class" "card secondary flex flex-col gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "globe")) + (b + (text "{{ item.name }}"))) + (span + (text "Created ") + (span + ("class" "date") + (text "{{ item.created }}")) + (text "; {{ item.files|length }} files"))) + (text "{% endfor %}")))) + +(script + (text "async function create_service_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"services::create\"]); + + fetch(\"/api/v1/services\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + name: e.target.name.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = `/services/${res.payload}`; + }, 100); + } + }); + }")) + +(text "{% endblock %}") diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs new file mode 100644 index 0000000..aec1a01 --- /dev/null +++ b/crates/app/src/routes/api/v1/domains.rs @@ -0,0 +1,164 @@ +use crate::{ + get_user_from_token, + routes::api::v1::{CreateDomain, UpdateDomainData}, + State, +}; +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + http::StatusCode, + Extension, Json, +}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{littleweb::Domain, oauth, ApiReturn, Error}; +use serde::Deserialize; + +pub async fn get_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + match data.get_domain_by_id(id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_domains_by_user(user.id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_domain(Domain::new(req.name, req.tld, user.id)) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Domain created".to_string(), + payload: x.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_data_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_domain_data(id, &user, req.data).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Domain updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_domain(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Domain deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +#[derive(Deserialize)] +pub struct GetFileQuery { + pub addr: String, +} + +pub async fn get_file_request( + Extension(data): Extension, + Query(props): Query, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let (subdomain, domain, tld, path) = Domain::from_str(&props.addr); + + // resolve domain + let domain = match data.get_domain_by_name_tld(&domain, &tld).await { + Ok(x) => x, + Err(e) => { + return Err((StatusCode::BAD_REQUEST, e.to_string())); + } + }; + + // resolve service + let service = match domain.service(&subdomain) { + Some(id) => match data.get_service_by_id(id).await { + Ok(x) => x, + Err(e) => { + return Err((StatusCode::BAD_REQUEST, e.to_string())); + } + }, + None => { + return Err(( + StatusCode::NOT_FOUND, + Error::GeneralNotFound("service".to_string()).to_string(), + )); + } + }; + + // resolve file + match service.file(&path) { + Some(f) => Ok(( + [("Content-Type".to_string(), f.mime.to_string())], + f.content, + )), + None => { + return Err(( + StatusCode::NOT_FOUND, + Error::GeneralNotFound("file".to_string()).to_string(), + )); + } + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 42bcd97..59f4353 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -2,6 +2,7 @@ pub mod apps; pub mod auth; pub mod channels; pub mod communities; +pub mod domains; pub mod journals; pub mod layouts; pub mod notes; @@ -9,6 +10,7 @@ pub mod notifications; pub mod reactions; pub mod reports; pub mod requests; +pub mod services; pub mod stacks; pub mod uploads; pub mod util; @@ -28,6 +30,7 @@ use tetratto_core::model::{ communities_permissions::CommunityPermission, journals::JournalPrivacyPermission, layouts::{CustomizablePage, LayoutPage, LayoutPrivacy}, + littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, reactions::AssetType, @@ -633,10 +636,22 @@ pub fn routes() -> Router { post(layouts::update_privacy_request), ) .route("/layouts/{id}/pages", post(layouts::update_pages_request)) + // services + .route("/services", get(services::list_request)) + .route("/services", post(services::create_request)) + .route("/services/{id}", get(services::get_request)) + .route("/services/{id}", delete(services::delete_request)) + .route("/services/{id}/files", post(services::update_files_request)) + // domains + .route("/domains", get(domains::list_request)) + .route("/domains", post(domains::create_request)) + .route("/domains/{id}", get(domains::get_request)) + .route("/domains/{id}", delete(domains::delete_request)) + .route("/domains/{id}/data", post(domains::update_data_request)) } pub fn lw_routes() -> Router { - Router::new() + Router::new().route("/file", get(domains::get_file_request)) } #[derive(Deserialize)] @@ -1055,3 +1070,24 @@ pub struct UpdateLayoutPrivacy { pub struct UpdateLayoutPages { pub pages: Vec, } + +#[derive(Deserialize)] +pub struct CreateService { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateServiceFiles { + pub files: Vec, +} + +#[derive(Deserialize)] +pub struct CreateDomain { + pub name: String, + pub tld: DomainTld, +} + +#[derive(Deserialize)] +pub struct UpdateDomainData { + pub data: Vec<(String, DomainData)>, +} diff --git a/crates/app/src/routes/api/v1/services.rs b/crates/app/src/routes/api/v1/services.rs new file mode 100644 index 0000000..36895d6 --- /dev/null +++ b/crates/app/src/routes/api/v1/services.rs @@ -0,0 +1,104 @@ +use crate::{ + get_user_from_token, + routes::api::v1::{UpdateServiceFiles, CreateService}, + State, +}; +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{littleweb::Service, oauth, ApiReturn, Error}; + +pub async fn get_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + match data.get_service_by_id(id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_services_by_user(user.id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.create_service(Service::new(req.name, user.id)).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Service created".to_string(), + payload: x.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_files_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_service_files(id, &user, req.files).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_service(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 5e3cd5b..1009797 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -26,6 +26,7 @@ mod questions; mod reactions; mod reports; mod requests; +mod services; mod stackblocks; mod stacks; mod uploads; diff --git a/crates/core/src/database/services.rs b/crates/core/src/database/services.rs new file mode 100644 index 0000000..de67f74 --- /dev/null +++ b/crates/core/src/database/services.rs @@ -0,0 +1,130 @@ +use crate::model::{ + auth::User, + littleweb::{Service, ServiceFsEntry}, + permissions::{FinePermission, SecondaryPermission}, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Service`] from an SQL row. + pub(crate) fn get_service_from_row(x: &PostgresRow) -> Service { + Service { + 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)), + files: serde_json::from_str(&get!(x->4(String))).unwrap(), + } + } + + auto_method!(get_service_by_id(usize as i64)@get_service_from_row -> "SELECT * FROM services WHERE id = $1" --name="service" --returns=Service --cache-key-tmpl="atto.service:{}"); + + /// Get all services by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch services for + pub async fn get_services_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 services WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_service_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("service".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_SERVICES: usize = 5; + + /// Create a new service in the database. + /// + /// # Arguments + /// * `data` - a mock [`Service`] object to insert + pub async fn create_service(&self, data: Service) -> 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 number of services + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let services = self.get_services_by_user(data.owner).await?; + + if services.len() >= Self::MAXIMUM_FREE_SERVICES { + return Err(Error::MiscError( + "You already have the maximum number of services you can have".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 services VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.name, + &serde_json::to_string(&data.files).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_service(&self, id: usize, user: &User) -> Result<()> { + let service = self.get_service_by_id(id).await?; + + // check user permission + if user.id != service.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_SERVICES) + { + 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 services WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.service:{}", id)).await; + Ok(()) + } + + auto_method!(update_service_files(Vec)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET data = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}"); +} diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs index 474bec4..79cffb1 100644 --- a/crates/core/src/model/littleweb.rs +++ b/crates/core/src/model/littleweb.rs @@ -1,5 +1,6 @@ use std::fmt::Display; use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Service { @@ -10,6 +11,42 @@ pub struct Service { pub files: Vec, } +impl Service { + /// Create a new [`Service`]. + pub fn new(name: String, owner: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + name, + files: Vec::new(), + } + } + + /// Resolve a file from the virtual file system. + pub fn file(&self, path: &str) -> Option { + let segments = path.chars().filter(|x| x == &'/').count(); + + let mut path = path.split("/"); + let mut path_segment = path.next().unwrap(); + 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()); + } + + f = &nf.children; + path_segment = path.next().unwrap(); + i += 1; + } + + None + } +} + /// A file type for [`ServiceFsEntry`] structs. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ServiceFsMime { @@ -84,14 +121,28 @@ pub struct Domain { } impl Domain { + /// Create a new [`Domain`]. + pub fn new(name: String, tld: DomainTld, owner: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().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) -> (&str, &str, DomainTld, Vec) { + 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> = value.split(".").collect(); + let mut s: Vec<&str> = no_protocol.split(".").collect(); s.reverse(); let mut s = s.into_iter(); @@ -100,7 +151,6 @@ impl Domain { let subdomain = s.next().unwrap_or("@"); // get path - let no_protocol = value.replace("atto://", ""); let mut chars = no_protocol.chars(); let mut char = '.'; @@ -113,12 +163,7 @@ impl Domain { let path: String = chars.collect(); // return - ( - subdomain, - domain, - tld, - path.split("/").map(|x| x.to_owned()).collect(), - ) + (subdomain.to_owned(), domain.to_owned(), tld, path) } /// Update an HTML/JS/CSS string with the correct URL for all "atto://" protocol requests. @@ -131,7 +176,7 @@ impl Domain { // 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://") + input.replace("\"atto://", "/api/v1/file?addr=atto://") } /// Get the domain's service ID. diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 7d5ebb6..07a23c3 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -70,6 +70,10 @@ pub enum AppScope { UserReadNotes, /// Read the user's layouts. UserReadLayouts, + /// Read the user's domains. + UserReadDomains, + /// Read the user's services. + UserReadServices, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -90,6 +94,10 @@ pub enum AppScope { UserCreateNotes, /// Create layouts on behalf of the user. UserCreateLayouts, + /// Create domains on behalf of the user. + UserCreateDomains, + /// Create services on behalf of the user. + UserCreateServices, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -126,6 +134,10 @@ pub enum AppScope { UserManageNotes, /// Manage the user's layouts. UserManageLayouts, + /// Manage the user's domains. + UserManageDomains, + /// Manage the user's services. + UserManageServices, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user.