diff --git a/README.md b/README.md index 9afd5bc..77155e8 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,13 @@ All Tetratto instances support reports for communities and posts through the UI. ## Debugging -All panics should be reported as a bug report (or on tetratto.com). Panics which cannot be avoided will have an error tag attached to their error message, so you can easily search through your service logs to understand more. All error tags will begin with `ERROR_TETRATTO_`, and will have a more detailed tag suffix. +All panics should be reported as a bug report (on [tetratto.com](https://tetratto.com/forge/tetratto)). Panics which cannot be avoided will have an error tag attached to their error message, so you can easily search through your service logs to understand more. All error tags will begin with `ERROR_TETRATTO_` or `ERROR_OISEAU_`, and will have a more detailed tag suffix. All current panic tags are listed below: -- `ERROR_TETRATTO_PSQL_CON` - errors in connection to the postgres database -- `ERROR_TETRATTO_REDIS_CON` - an error in creating the redis connection pool -- `ERROR_TETRATTO_REDIS_CON_ACQUIRE` - an error in acquiring a redis connection +- `ERROR_OISEAU_PSQL_CON` - errors in connection to the postgres database +- `ERROR_OISEAU_REDIS_CON` - an error in creating the redis connection pool +- `ERROR_OISEAU_REDIS_CON_ACQUIRE` - an error in acquiring a redis connection # Updating @@ -68,3 +68,5 @@ Read the ["Contribution Guidelines"](./.github/CONTRIBUTING.md) before contribut # License Tetratto is licensed under the [AGPL-3.0](./LICENSE). + +General credits can be found at . Most credits on that page are for tetratto.com specifically, however it does include a saved dependency tree. diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 6b9e3d0..3d4752e 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -121,6 +121,8 @@ pub const FORGE_BASE: &str = include_str!("./public/html/forge/base.lisp"); pub const FORGE_INFO: &str = include_str!("./public/html/forge/info.lisp"); pub const FORGE_TICKETS: &str = include_str!("./public/html/forge/tickets.lisp"); +pub const DEVELOPER_HOME: &str = include_str!("./public/html/developer/home.lisp"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -129,6 +131,8 @@ pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); pub const VENDOR_SPOTIFY_ICON: &str = include_str!("./public/images/vendor/spotify.svg"); pub const VENDOR_LAST_FM_ICON: &str = include_str!("./public/images/vendor/last-fm.svg"); +pub const TETRATTO_BUNNY: &[u8] = include_bytes!("./public/images/tetratto_bunny.webp"); + pub(crate) static HTML_FOOTER: LazyLock> = LazyLock::new(|| RwLock::new(String::new())); @@ -174,6 +178,13 @@ macro_rules! vendor_icon { }}; } +macro_rules! bin_icon { + ($name:literal, $icon:ident, $icons_dir:expr) => {{ + let file_path = PathBufD::current().extend(&[$icons_dir.clone(), $name.to_string()]); + std::fs::write(file_path, $icon).unwrap(); + }}; +} + /// Read a string and replace all custom blocks with the corresponding correct HTML. /// /// # Replaces @@ -315,6 +326,7 @@ pub(crate) fn lisp_plugins() -> HashMap Elemen pub(crate) async fn write_assets(config: &Config) -> PathBufD { vendor_icon!("spotify", VENDOR_SPOTIFY_ICON, config.dirs.icons); vendor_icon!("last_fm", VENDOR_LAST_FM_ICON, config.dirs.icons); + bin_icon!("tetratto_bunny.webp", TETRATTO_BUNNY, config.dirs.assets); // ... let mut plugins = lisp_plugins(); @@ -395,6 +407,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"forge/info.html"(crate::assets::FORGE_INFO) --config=config --lisp plugins); write_template!(html_path->"forge/tickets.html"(crate::assets::FORGE_TICKETS) --config=config --lisp plugins); + write_template!(html_path->"developer/home.html"(crate::assets::DEVELOPER_HOME) -d "developer" --config=config --lisp plugins); + html_path } @@ -402,6 +416,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { pub(crate) async fn init_dirs(config: &Config) { create_dir_if_not_exists!(&config.dirs.templates); create_dir_if_not_exists!(&config.dirs.docs); + create_dir_if_not_exists!(&config.dirs.rustdoc); + create_dir_if_not_exists!(&config.dirs.assets); // images create_dir_if_not_exists!(&config.dirs.media); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 6c0d911..6e68f4f 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -28,6 +28,7 @@ version = "1.0.0" "general:action.view" = "View" "general:action.copy_link" = "Copy link" "general:action.post" = "Post" +"general:label.account" = "Account" "general:label.safety" = "Safety" "general:label.share" = "Share" "general:action.add_account" = "Add account" @@ -210,3 +211,8 @@ version = "1.0.0" "forge:tab.tickets" = "Tickets" "forge:action.reopen" = "Reopen" "forge:action.close" = "Close" + +"developer:label.my_apps" = "My apps" +"developer:label.create_new" = "Create new app" +"developer:label.homepage" = "Homepage" +"developer:label.redirect" = "Redirect URL" diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 0377385..b4f188c 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -1,3 +1,6 @@ +#![doc = include_str!("../../../README.md")] +#![doc(html_favicon_url = "/public/favicon.svg")] +#![doc(html_logo_url = "/public/tetratto_bunny.webp")] mod assets; mod image; mod macros; diff --git a/crates/app/src/public/html/communities/list.lisp b/crates/app/src/public/html/communities/list.lisp index 58a50e2..186d4f9 100644 --- a/crates/app/src/public/html/communities/list.lisp +++ b/crates/app/src/public/html/communities/list.lisp @@ -94,6 +94,7 @@ ]); if (res.ok) { + e.target.reset(); setTimeout(() => { window.location.href = `/community/${res.payload}`; }, 100); diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index d4f4cf1..8a4c953 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -595,7 +595,7 @@ ("style" "display: none;") (text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }} {% if user.permissions|has_supporter -%}") (style - (text "{{ user.settings.theme_custom_css|safe|remove_script_tags }}")) + (text "{{ user.settings.theme_custom_css|remove_script_tags|safe }}")) (text "{%- endif %}")) (text "{%- endif %} {%- endmacro %} {% macro theme_color(color, css) -%} {% if color -%}") @@ -1001,20 +1001,20 @@ (span (text "{{ text \"general:link.stats\" }}"))) (text "{%- endif %}") - (b - ("class" "title") - (text "{{ config.name }}")) + (b ("class" "title") (text "{{ config.name }}")) (a ("href" "https://trisua.com/t/tetratto") - (text "{{ icon \"code\" }}") - (span - (text "{{ text \"general:link.source_code\" }}"))) - ; - ; {{ icon "book" }} - ; {{ text "general:link.reference" }} - ; - (div - ("class" "title")) + ("class" "button") + (icon (text "code")) + (str (text "general:link.source_code"))) + + (a + ("href" "/reference/tetratto/index.html") + ("class" "button") + ("data-turbo" "false") + (icon (text "rabbit")) + (str (text "general:link.reference"))) + (b ("class" "title") (str (text "general:label.account"))) (button ("onclick" "trigger('me::switch_account')") (text "{{ icon \"ellipsis\" }}") diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp new file mode 100644 index 0000000..c0a63f2 --- /dev/null +++ b/crates/app/src/public/html/developer/home.lisp @@ -0,0 +1,121 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Developer panel - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}") +(main + ("class" "flex flex-col gap-2") + ; create new + (text "{{ components::supporter_ad(body=\"Become a supporter to create multiple apps!\") }}") + + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "{{ text \"developer:label.create_new\" }}"))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "create_app_from_form(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "title") + (text "{{ text \"communities:label.name\" }}")) + (input + ("type" "text") + ("name" "title") + ("id" "title") + ("placeholder" "name") + ("required" "") + ("minlength" "2") + ("maxlength" "32"))) + (div + ("class" "flex flex-col gap-1") + (label + ("for" "title") + (text "{{ text \"developer:label.homepage\" }}")) + (input + ("type" "url") + ("name" "homepage") + ("id" "homepage") + ("placeholder" "homepage") + ("required" "") + ("minlength" "2") + ("maxlength" "32"))) + (div + ("class" "flex flex-col gap-1") + (label + ("for" "title") + (text "{{ text \"developer:label.redirect\" }}")) + (input + ("type" "url") + ("name" "redirect") + ("id" "redirect") + ("placeholder" "redirect URL") + ("required" "") + ("minlength" "2") + ("maxlength" "32"))) + (button + ("class" "primary") + (text "{{ text \"communities:action.create\" }}")))) + + ; app listing + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2") + (icon (text "bot")) + (str (text "developer:label.my_apps"))) + + (div + ("class" "card flex flex-col gap-2") + (text "{% for item in list %}") + (a + ("href" "/developer/app/{{ item.id }}") + ("class" "card secondary flex flex-col gap-2") + (div + ("class" "flex items-center gap-2") + (text "{{ icon \"code\" }}") + (b + (text "{{ item.title }}"))) + (span + (text "Created ") + (span + ("class" "date") + (text "{{ item.created }}")) + (text "; {{ item.quota_status }} mode; {{ item.grants }} users"))) + (text "{% endfor %}")))) + +(script + (text "async function create_app_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"apps::create\"]); + + fetch(\"/api/v1/apps\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title: e.target.title.value, + homepage: e.target.homepage.value, + redirect: e.target.redirect.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + e.target.reset(); + setTimeout(() => { + window.location.href = `/developer/app/${res.payload}`; + }, 100); + } + }); + }")) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/forge/home.lisp b/crates/app/src/public/html/forge/home.lisp index 3132000..a83c545 100644 --- a/crates/app/src/public/html/forge/home.lisp +++ b/crates/app/src/public/html/forge/home.lisp @@ -71,6 +71,7 @@ ]); if (res.ok) { + e.target.reset(); setTimeout(() => { window.location.href = `/forge/${res.payload}`; }, 100); diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index 18a4e2a..f544f5e 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -100,12 +100,19 @@ (icon (text "user-plus")) (str (text "auth:action.register"))) - (div ("class" "title")) + (b ("class" "title") (text "{{ config.name }}")) (a ("href" "https://trisua.com/t/tetratto") ("class" "button") (icon (text "code")) - (text "View source"))))) + (str (text "general:link.source_code"))) + + (a + ("href" "/reference/tetratto/index.html") + ("class" "button") + ("data-turbo" "false") + (icon (text "rabbit")) + (str (text "general:link.reference")))))) (text "{%- endif %}"))) (text "{%- endmacro %}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 5d96e29..7c44c50 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -561,7 +561,9 @@ (li (text "Ability to search through all posts")) (li - (text "Ability to create forges"))) + (text "Ability to create forges")) + (li + (text "Ability to create more than 1 app"))) (a ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") ("class" "button") diff --git a/crates/app/src/public/html/stacks/list.lisp b/crates/app/src/public/html/stacks/list.lisp index 6c63921..6ef2cab 100644 --- a/crates/app/src/public/html/stacks/list.lisp +++ b/crates/app/src/public/html/stacks/list.lisp @@ -57,8 +57,7 @@ (span ("class" "date") (text "{{ item.created }}")) - (text "; {{ - item.privacy }}; {{ item.users|length }} users"))) + (text "; {{ item.privacy }}; {{ item.users|length }} users"))) (text "{% endfor %}")))) (script diff --git a/crates/app/src/public/images/tetratto_bunny.webp b/crates/app/src/public/images/tetratto_bunny.webp new file mode 100644 index 0000000..9ff9cb6 Binary files /dev/null and b/crates/app/src/public/images/tetratto_bunny.webp differ diff --git a/crates/app/src/routes/api/v1/apps.rs b/crates/app/src/routes/api/v1/apps.rs new file mode 100644 index 0000000..1a33518 --- /dev/null +++ b/crates/app/src/routes/api/v1/apps.rs @@ -0,0 +1,151 @@ +use crate::{ + get_user_from_token, + routes::api::v1::{UpdateAppHomepage, UpdateAppQuotaStatus, UpdateAppRedirect, UpdateAppTitle}, + State, +}; +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{apps::ThirdPartyApp, permissions::FinePermission, ApiReturn, Error}; +use super::CreateApp; + +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) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_app(ThirdPartyApp::new( + req.title, + user.id, + req.homepage, + req.redirect, + )) + .await + { + Ok(s) => Json(ApiReturn { + ok: true, + message: "App created".to_string(), + payload: s.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_title_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) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_app_title(id, &user, &req.title).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "App updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_homepage_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) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_app_homepage(id, &user, &req.homepage).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "App updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_redirect_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) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_app_redirect(id, &user, &req.redirect).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "App updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_quota_status_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) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if !user.permissions.check(FinePermission::MANAGE_APPS) { + return Json(Error::NotAllowed.into()); + } + + match data.update_app_quota_status(id, req.quota_status).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "App 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) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_app(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "App deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs index 192907a..9a67da8 100644 --- a/crates/app/src/routes/api/v1/auth/images.rs +++ b/crates/app/src/routes/api/v1/auth/images.rs @@ -164,8 +164,8 @@ pub async fn banner_request( )) } -pub static MAXIMUM_FILE_SIZE: usize = 8388608; -pub static MAXIMUM_GIF_FILE_SIZE: usize = 2097152; +pub const MAXIMUM_FILE_SIZE: usize = 8388608; +pub const MAXIMUM_GIF_FILE_SIZE: usize = 2097152; /// Upload avatar pub async fn upload_avatar_request( diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 0b5128b..5bdc72f 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -1,3 +1,4 @@ +pub mod apps; pub mod auth; pub mod communities; pub mod notifications; @@ -17,6 +18,7 @@ use axum::{ }; use serde::Deserialize; use tetratto_core::model::{ + apps::AppQuota, communities::{ CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, PollOption, PostContext, @@ -321,6 +323,10 @@ pub fn routes() -> Router { "/auth/user/find_by_ip/{ip}", get(auth::profile::redirect_from_ip), ) + .route( + "/auth/user/find_by_stripe_id/{id}", + get(auth::profile::redirect_from_stripe_id), + ) .route("/auth/ip/{ip}/block", post(auth::social::ip_block_request)) .route( "/auth/user/{id}/gpa", @@ -342,6 +348,16 @@ pub fn routes() -> Router { "/auth/user/{id}/followers", get(auth::social::followers_request), ) + // apps + .route("/apps", post(apps::create_request)) + .route("/apps/{id}/title", post(apps::update_title_request)) + .route("/apps/{id}/homepage", post(apps::update_homepage_request)) + .route("/apps/{id}/redirect", post(apps::update_redirect_request)) + .route( + "/apps/{id}/quota_status", + post(apps::update_quota_status_request), + ) + .route("/apps/{id}", delete(apps::delete_request)) // warnings .route("/warnings/{id}", get(auth::user_warnings::get_request)) .route("/warnings/{id}", post(auth::user_warnings::create_request)) @@ -773,3 +789,30 @@ pub struct AppendAssociations { pub struct UpdatePostIsOpen { pub open: bool, } + +#[derive(Deserialize)] +pub struct CreateApp { + pub title: String, + pub homepage: String, + pub redirect: String, +} + +#[derive(Deserialize)] +pub struct UpdateAppTitle { + pub title: String, +} + +#[derive(Deserialize)] +pub struct UpdateAppHomepage { + pub homepage: String, +} + +#[derive(Deserialize)] +pub struct UpdateAppRedirect { + pub redirect: String, +} + +#[derive(Deserialize)] +pub struct UpdateAppQuotaStatus { + pub quota_status: AppQuota, +} diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index 43e75b9..9538133 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -22,6 +22,10 @@ pub fn routes(config: &Config) -> Router { "/public", get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), ) + .nest_service( + "/reference", + get_service(tower_http::services::ServeDir::new(&config.dirs.rustdoc)), + ) .route("/public/favicon.svg", get(assets::favicon_request)) .route_service( "/robots.txt", diff --git a/crates/app/src/routes/pages/developer.rs b/crates/app/src/routes/pages/developer.rs new file mode 100644 index 0000000..3bf14f7 --- /dev/null +++ b/crates/app/src/routes/pages/developer.rs @@ -0,0 +1,35 @@ +use super::render_error; +use crate::{assets::initial_context, get_lang, get_user_from_token, State}; +use axum::{ + response::{Html, IntoResponse}, + Extension, +}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::Error; + +/// `/developer` +pub async fn home_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let list = match data.0.get_apps_by_owner(user.id).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("list", &list); + + // return + Ok(Html( + data.1.render("developer/home.html", &context).unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index c5e0b8f..1497638 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod communities; +pub mod developer; pub mod forge; pub mod misc; pub mod mod_panel; @@ -116,6 +117,8 @@ pub fn routes() -> Router { .route("/forge/{title}", get(forge::info_request)) .route("/forge/{title}/tickets", get(forge::tickets_request)) .route("/forge/{title}/members", get(communities::members_request)) + // developer + .route("/developer", get(developer::home_request)) // stacks .route("/stacks", get(stacks::list_request)) .route("/stacks/{id}", get(stacks::posts_request)) diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 7db5c8e..7e2713b 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -50,6 +50,10 @@ pub struct DirsConfig { /// The markdown document files directory. #[serde(default = "default_dir_docs")] pub docs: String, + /// The directory which holds your `rustdoc` (`cargo doc`) output. The directory should + /// exist, but it isn't required to actually have anything in it. + #[serde(default = "default_dir_rustdoc")] + pub rustdoc: String, } fn default_dir_templates() -> String { @@ -72,6 +76,10 @@ fn default_dir_docs() -> String { "docs".to_string() } +fn default_dir_rustdoc() -> String { + "reference".to_string() +} + impl Default for DirsConfig { fn default() -> Self { Self { @@ -80,6 +88,7 @@ impl Default for DirsConfig { media: default_dir_media(), icons: default_dir_icons(), docs: default_dir_docs(), + rustdoc: default_dir_rustdoc(), } } } diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs new file mode 100644 index 0000000..e6e6f95 --- /dev/null +++ b/crates/core/src/database/apps.rs @@ -0,0 +1,150 @@ +use oiseau::cache::Cache; +use crate::model::{ + Error, Result, + auth::User, + permissions::FinePermission, + apps::{AppQuota, ThirdPartyApp}, +}; +use crate::{auto_method, DataManager}; + +#[cfg(feature = "sqlite")] +use oiseau::SqliteRow; + +#[cfg(feature = "postgres")] +use oiseau::PostgresRow; + +use oiseau::{execute, get, query_rows, params}; + +impl DataManager { + /// Get a [`ThirdPartyApp`] from an SQL row. + pub(crate) fn get_app_from_row( + #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, + #[cfg(feature = "postgres")] x: &PostgresRow, + ) -> ThirdPartyApp { + ThirdPartyApp { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + title: get!(x->3(String)), + homepage: get!(x->4(String)), + redirect: get!(x->5(String)), + quota_status: serde_json::from_str(&get!(x->6(String))).unwrap(), + banned: get!(x->7(i32)) as i8 == 1, + grants: get!(x->8(i32)) as usize, + } + } + + auto_method!(get_app_by_id(usize as i64)@get_app_from_row -> "SELECT * FROM apps WHERE id = $1" --name="app" --returns=ThirdPartyApp --cache-key-tmpl="atto.app:{}"); + + /// Get all apps by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch apps for + pub async fn get_apps_by_owner(&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 apps WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_app_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("app".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_APPS: usize = 1; + + /// Create a new app in the database. + /// + /// # Arguments + /// * `data` - a mock [`ThirdPartyApp`] object to insert + pub async fn create_app(&self, data: ThirdPartyApp) -> Result { + // check values + if data.title.len() < 2 { + return Err(Error::DataTooShort("title".to_string())); + } else if data.title.len() > 32 { + return Err(Error::DataTooLong("title".to_string())); + } + + // check number of apps + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let apps = self.get_apps_by_owner(data.owner).await?; + + if apps.len() >= Self::MAXIMUM_FREE_APPS { + return Err(Error::MiscError( + "You already have the maximum number of apps 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 apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.title, + &data.homepage, + &data.redirect, + &serde_json::to_string(&data.quota_status).unwrap(), + &{ if data.banned { 1 } else { 0 } }, + &(data.grants as i32) + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_app(&self, id: usize, user: &User) -> Result<()> { + let app = self.get_app_by_id(id).await?; + + // check user permission + if user.id != app.owner && !user.permissions.check(FinePermission::MANAGE_APPS) { + 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 apps WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.app:{}", id)).await; + Ok(()) + } + + auto_method!(update_app_title(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_homepage(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_redirect(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_quota_status(AppQuota) -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); + + auto_method!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $2" --cache-key-tmpl="atto.app:{}" --incr); + auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $2" --cache-key-tmpl="atto.app:{}" --decr=grants); +} diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index aab6c22..cc62849 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -34,6 +34,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_DRAFTS).unwrap(); execute!(&conn, common::CREATE_TABLE_POLLS).unwrap(); execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap(); + execute!(&conn, common::CREATE_TABLE_APPS).unwrap(); self.0 .1 @@ -456,7 +457,7 @@ macro_rules! auto_method { let res = execute!( &conn, $query, - &[&serde_json::to_string(&x).unwrap(), &(id as i64)] + params![&serde_json::to_string(&x).unwrap(), &(id as i64)] ); if let Err(e) = res { @@ -507,6 +508,31 @@ macro_rules! auto_method { } }; + ($name:ident()@$select_fn:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal --decr=$field:ident) => { + pub async fn $name(&self, id: usize) -> Result<()> { + let y = self.$select_fn(id).await?; + + if (y.$field as isize) - 1 < 0 { + return Ok(()); + } + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, $query, &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!($cache_key_tmpl, id)).await; + + Ok(()) + } + }; + ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { pub async fn $name(&self, id: usize, user: &User) -> Result<()> { let y = self.$select_fn(id).await?; diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 0647d43..9d8df41 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -21,3 +21,4 @@ pub const CREATE_TABLE_STACKS: &str = include_str!("./sql/create_stacks.sql"); pub const CREATE_TABLE_DRAFTS: &str = include_str!("./sql/create_drafts.sql"); pub const CREATE_TABLE_POLLS: &str = include_str!("./sql/create_polls.sql"); pub const CREATE_TABLE_POLLVOTES: &str = include_str!("./sql/create_pollvotes.sql"); +pub const CREATE_TABLE_APPS: &str = include_str!("./sql/create_apps.sql"); diff --git a/crates/core/src/database/drivers/sql/create_apps.sql b/crates/core/src/database/drivers/sql/create_apps.sql new file mode 100644 index 0000000..f70e399 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_apps.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS apps ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + title TEXT NOT NULL, + homepage TEXT NOT NULL, + redirect TEXT NOT NULL, + quota_status TEXT NOT NULL, + banned INT NOT NULL, + grants INT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 9c8438e..c7290bf 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -1,3 +1,4 @@ +mod apps; mod audit_log; mod auth; mod common; diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index 0ecef14..2407807 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -1,11 +1,10 @@ use oiseau::cache::Cache; -use crate::model::communities::{Community, Poll, Post, Question}; -use crate::model::stacks::{StackMode, StackSort}; use crate::model::{ Error, Result, auth::User, permissions::FinePermission, - stacks::{StackPrivacy, UserStack}, + stacks::{StackPrivacy, UserStack, StackMode, StackSort}, + communities::{Community, Poll, Post, Question}, }; use crate::{auto_method, DataManager}; diff --git a/crates/core/src/model/apps.rs b/crates/core/src/model/apps.rs new file mode 100644 index 0000000..e6fd614 --- /dev/null +++ b/crates/core/src/model/apps.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub enum AppQuota { + /// The app is limited to 5 grants. + Limited, + /// The app is allowed to maintain an unlimited number of grants. + Unlimited, +} + +impl Default for AppQuota { + fn default() -> Self { + Self::Limited + } +} + +/// An app is required to request grants on user accounts. +/// +/// Users must approve grants through a web portal. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ThirdPartyApp { + pub id: usize, + pub created: usize, + /// The ID of the owner of the app. + pub owner: usize, + /// The name of the app. + pub title: String, + /// The URL of the app's homepage. + pub homepage: String, + /// The redirect URL for the app. + /// + /// Upon accepting a grant request, the user will be redirected to this URL + /// with a query parameter named `token`, which should be saved by the app + /// for future authentication. + pub redirect: String, + /// The app's quota status, which determines how many grants the app is allowed to maintain. + pub quota_status: AppQuota, + /// If the app is banned. A banned app cannot use any of its grants. + pub banned: bool, + /// The number of accepted grants the app maintains. + pub grants: usize, +} + +impl ThirdPartyApp { + /// Create a new [`ThirdPartyApp`]. + pub fn new(title: String, owner: usize, homepage: String, redirect: String) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp() as usize, + owner, + title, + homepage, + redirect, + quota_status: AppQuota::Limited, + banned: false, + grants: 0, + } + } +} diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index af4d529..f02c8ce 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -403,6 +403,14 @@ impl User { self.stripe_id = String::new(); self.connections = HashMap::new(); } + + /// Get a grant from the user given the grant's `app` ID. + /// + /// Should be used **before** adding another grant (to ensure the app doesn't + /// already have a grant for this user). + pub fn get_grant_by_app_id(&self, id: usize) -> Option<&AuthGrant> { + self.grants.iter().find(|x| x.app == id) + } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index abd044a..1693fa3 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -1,4 +1,5 @@ pub mod addr; +pub mod apps; pub mod auth; pub mod communities; pub mod communities_permissions; diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index a4e361f..9985dca 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -6,8 +6,8 @@ use super::{Result, Error}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct AuthGrant { pub id: usize, - /// The name of the application associated with this grant. - pub name: String, + /// The ID of the application associated with this grant. + pub app: usize, /// The code challenge for PKCE verifiers associated with this grant. /// /// This challenge is *all* that is required to refresh this grant's auth token. diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 9be028c..c0c3542 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -36,6 +36,7 @@ bitflags! { const MANAGE_EMOJIS = 1 << 25; const MANAGE_STACKS = 1 << 26; const STAFF_BADGE = 1 << 27; + const MANAGE_APPS = 1 << 28; const _ = !0; } diff --git a/example/.gitignore b/example/.gitignore index ba0c3d0..004f366 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -1,7 +1,9 @@ atto.db* html/* -# public/* +public/* +!public/footer.html +!public/robots.txt media/* icons/* langs/* diff --git a/example/reference b/example/reference new file mode 120000 index 0000000..c257394 --- /dev/null +++ b/example/reference @@ -0,0 +1 @@ +../target/doc \ No newline at end of file diff --git a/justfile b/justfile index 023dd4e..ad945c9 100644 --- a/justfile +++ b/justfile @@ -5,3 +5,6 @@ clean-deps: fix: cargo fix --allow-dirty cargo clippy --fix --allow-dirty + +doc: + cargo doc --document-private-items --no-deps