add: apps api

This commit is contained in:
trisua 2025-06-14 14:45:52 -04:00
parent 2a99d49c8a
commit ebded00fd3
33 changed files with 698 additions and 31 deletions

View file

@ -41,13 +41,13 @@ All Tetratto instances support reports for communities and posts through the UI.
## Debugging ## 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: All current panic tags are listed below:
- `ERROR_TETRATTO_PSQL_CON` - errors in connection to the postgres database - `ERROR_OISEAU_PSQL_CON` - errors in connection to the postgres database
- `ERROR_TETRATTO_REDIS_CON` - an error in creating the redis connection pool - `ERROR_OISEAU_REDIS_CON` - an error in creating the redis connection pool
- `ERROR_TETRATTO_REDIS_CON_ACQUIRE` - an error in acquiring a redis connection - `ERROR_OISEAU_REDIS_CON_ACQUIRE` - an error in acquiring a redis connection
# Updating # Updating
@ -68,3 +68,5 @@ Read the ["Contribution Guidelines"](./.github/CONTRIBUTING.md) before contribut
# License # License
Tetratto is licensed under the [AGPL-3.0](./LICENSE). Tetratto is licensed under the [AGPL-3.0](./LICENSE).
General credits can be found at <https://tetratto.com/doc/credits.md>. Most credits on that page are for tetratto.com specifically, however it does include a saved dependency tree.

View file

@ -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_INFO: &str = include_str!("./public/html/forge/info.lisp");
pub const FORGE_TICKETS: &str = include_str!("./public/html/forge/tickets.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 // langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); 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_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 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<RwLock<String>> = pub(crate) static HTML_FOOTER: LazyLock<RwLock<String>> =
LazyLock::new(|| RwLock::new(String::new())); 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. /// Read a string and replace all custom blocks with the corresponding correct HTML.
/// ///
/// # Replaces /// # Replaces
@ -315,6 +326,7 @@ pub(crate) fn lisp_plugins() -> HashMap<String, Box<dyn FnMut(Element) -> Elemen
pub(crate) async fn write_assets(config: &Config) -> PathBufD { pub(crate) async fn write_assets(config: &Config) -> PathBufD {
vendor_icon!("spotify", VENDOR_SPOTIFY_ICON, config.dirs.icons); vendor_icon!("spotify", VENDOR_SPOTIFY_ICON, config.dirs.icons);
vendor_icon!("last_fm", VENDOR_LAST_FM_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(); 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/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->"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 html_path
} }
@ -402,6 +416,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
pub(crate) async fn init_dirs(config: &Config) { pub(crate) async fn init_dirs(config: &Config) {
create_dir_if_not_exists!(&config.dirs.templates); create_dir_if_not_exists!(&config.dirs.templates);
create_dir_if_not_exists!(&config.dirs.docs); 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 // images
create_dir_if_not_exists!(&config.dirs.media); create_dir_if_not_exists!(&config.dirs.media);

View file

@ -28,6 +28,7 @@ version = "1.0.0"
"general:action.view" = "View" "general:action.view" = "View"
"general:action.copy_link" = "Copy link" "general:action.copy_link" = "Copy link"
"general:action.post" = "Post" "general:action.post" = "Post"
"general:label.account" = "Account"
"general:label.safety" = "Safety" "general:label.safety" = "Safety"
"general:label.share" = "Share" "general:label.share" = "Share"
"general:action.add_account" = "Add account" "general:action.add_account" = "Add account"
@ -210,3 +211,8 @@ version = "1.0.0"
"forge:tab.tickets" = "Tickets" "forge:tab.tickets" = "Tickets"
"forge:action.reopen" = "Reopen" "forge:action.reopen" = "Reopen"
"forge:action.close" = "Close" "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"

View file

@ -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 assets;
mod image; mod image;
mod macros; mod macros;

View file

@ -94,6 +94,7 @@
]); ]);
if (res.ok) { if (res.ok) {
e.target.reset();
setTimeout(() => { setTimeout(() => {
window.location.href = `/community/${res.payload}`; window.location.href = `/community/${res.payload}`;
}, 100); }, 100);

View file

@ -595,7 +595,7 @@
("style" "display: none;") ("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 -%}") (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 (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 %}"))
(text "{%- endif %} {%- endmacro %} {% macro theme_color(color, css) -%} {% if color -%}") (text "{%- endif %} {%- endmacro %} {% macro theme_color(color, css) -%} {% if color -%}")
@ -1001,20 +1001,20 @@
(span (span
(text "{{ text \"general:link.stats\" }}"))) (text "{{ text \"general:link.stats\" }}")))
(text "{%- endif %}") (text "{%- endif %}")
(b (b ("class" "title") (text "{{ config.name }}"))
("class" "title")
(text "{{ config.name }}"))
(a (a
("href" "https://trisua.com/t/tetratto") ("href" "https://trisua.com/t/tetratto")
(text "{{ icon \"code\" }}") ("class" "button")
(span (icon (text "code"))
(text "{{ text \"general:link.source_code\" }}"))) (str (text "general:link.source_code")))
; <a href="https://trisuaso.github.io/tetratto">
; {{ icon "book" }} (a
; <span>{{ text "general:link.reference" }}</span> ("href" "/reference/tetratto/index.html")
; </a> ("class" "button")
(div ("data-turbo" "false")
("class" "title")) (icon (text "rabbit"))
(str (text "general:link.reference")))
(b ("class" "title") (str (text "general:label.account")))
(button (button
("onclick" "trigger('me::switch_account')") ("onclick" "trigger('me::switch_account')")
(text "{{ icon \"ellipsis\" }}") (text "{{ icon \"ellipsis\" }}")

View file

@ -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 %}")

View file

@ -71,6 +71,7 @@
]); ]);
if (res.ok) { if (res.ok) {
e.target.reset();
setTimeout(() => { setTimeout(() => {
window.location.href = `/forge/${res.payload}`; window.location.href = `/forge/${res.payload}`;
}, 100); }, 100);

View file

@ -100,12 +100,19 @@
(icon (text "user-plus")) (icon (text "user-plus"))
(str (text "auth:action.register"))) (str (text "auth:action.register")))
(div ("class" "title")) (b ("class" "title") (text "{{ config.name }}"))
(a (a
("href" "https://trisua.com/t/tetratto") ("href" "https://trisua.com/t/tetratto")
("class" "button") ("class" "button")
(icon (text "code")) (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 "{%- endif %}")))
(text "{%- endmacro %}") (text "{%- endmacro %}")

View file

@ -561,7 +561,9 @@
(li (li
(text "Ability to search through all posts")) (text "Ability to search through all posts"))
(li (li
(text "Ability to create forges"))) (text "Ability to create forges"))
(li
(text "Ability to create more than 1 app")))
(a (a
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
("class" "button") ("class" "button")

View file

@ -57,8 +57,7 @@
(span (span
("class" "date") ("class" "date")
(text "{{ item.created }}")) (text "{{ item.created }}"))
(text "; {{ (text "; {{ item.privacy }}; {{ item.users|length }} users")))
item.privacy }}; {{ item.users|length }} users")))
(text "{% endfor %}")))) (text "{% endfor %}"))))
(script (script

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View file

@ -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<State>,
Json(req): Json<CreateApp>,
) -> 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<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateAppTitle>,
) -> 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<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateAppHomepage>,
) -> 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<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateAppRedirect>,
) -> 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<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateAppQuotaStatus>,
) -> 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<State>,
Path(id): Path<usize>,
) -> 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()),
}
}

View file

@ -164,8 +164,8 @@ pub async fn banner_request(
)) ))
} }
pub static MAXIMUM_FILE_SIZE: usize = 8388608; pub const MAXIMUM_FILE_SIZE: usize = 8388608;
pub static MAXIMUM_GIF_FILE_SIZE: usize = 2097152; pub const MAXIMUM_GIF_FILE_SIZE: usize = 2097152;
/// Upload avatar /// Upload avatar
pub async fn upload_avatar_request( pub async fn upload_avatar_request(

View file

@ -1,3 +1,4 @@
pub mod apps;
pub mod auth; pub mod auth;
pub mod communities; pub mod communities;
pub mod notifications; pub mod notifications;
@ -17,6 +18,7 @@ use axum::{
}; };
use serde::Deserialize; use serde::Deserialize;
use tetratto_core::model::{ use tetratto_core::model::{
apps::AppQuota,
communities::{ communities::{
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
PollOption, PostContext, PollOption, PostContext,
@ -321,6 +323,10 @@ pub fn routes() -> Router {
"/auth/user/find_by_ip/{ip}", "/auth/user/find_by_ip/{ip}",
get(auth::profile::redirect_from_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/ip/{ip}/block", post(auth::social::ip_block_request))
.route( .route(
"/auth/user/{id}/gpa", "/auth/user/{id}/gpa",
@ -342,6 +348,16 @@ pub fn routes() -> Router {
"/auth/user/{id}/followers", "/auth/user/{id}/followers",
get(auth::social::followers_request), 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 // warnings
.route("/warnings/{id}", get(auth::user_warnings::get_request)) .route("/warnings/{id}", get(auth::user_warnings::get_request))
.route("/warnings/{id}", post(auth::user_warnings::create_request)) .route("/warnings/{id}", post(auth::user_warnings::create_request))
@ -773,3 +789,30 @@ pub struct AppendAssociations {
pub struct UpdatePostIsOpen { pub struct UpdatePostIsOpen {
pub open: bool, 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,
}

View file

@ -22,6 +22,10 @@ pub fn routes(config: &Config) -> Router {
"/public", "/public",
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), 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("/public/favicon.svg", get(assets::favicon_request))
.route_service( .route_service(
"/robots.txt", "/robots.txt",

View file

@ -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<State>) -> 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(),
))
}

View file

@ -1,5 +1,6 @@
pub mod auth; pub mod auth;
pub mod communities; pub mod communities;
pub mod developer;
pub mod forge; pub mod forge;
pub mod misc; pub mod misc;
pub mod mod_panel; pub mod mod_panel;
@ -116,6 +117,8 @@ pub fn routes() -> Router {
.route("/forge/{title}", get(forge::info_request)) .route("/forge/{title}", get(forge::info_request))
.route("/forge/{title}/tickets", get(forge::tickets_request)) .route("/forge/{title}/tickets", get(forge::tickets_request))
.route("/forge/{title}/members", get(communities::members_request)) .route("/forge/{title}/members", get(communities::members_request))
// developer
.route("/developer", get(developer::home_request))
// stacks // stacks
.route("/stacks", get(stacks::list_request)) .route("/stacks", get(stacks::list_request))
.route("/stacks/{id}", get(stacks::posts_request)) .route("/stacks/{id}", get(stacks::posts_request))

View file

@ -50,6 +50,10 @@ pub struct DirsConfig {
/// The markdown document files directory. /// The markdown document files directory.
#[serde(default = "default_dir_docs")] #[serde(default = "default_dir_docs")]
pub docs: String, 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 { fn default_dir_templates() -> String {
@ -72,6 +76,10 @@ fn default_dir_docs() -> String {
"docs".to_string() "docs".to_string()
} }
fn default_dir_rustdoc() -> String {
"reference".to_string()
}
impl Default for DirsConfig { impl Default for DirsConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -80,6 +88,7 @@ impl Default for DirsConfig {
media: default_dir_media(), media: default_dir_media(),
icons: default_dir_icons(), icons: default_dir_icons(),
docs: default_dir_docs(), docs: default_dir_docs(),
rustdoc: default_dir_rustdoc(),
} }
} }
} }

View file

@ -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<Vec<ThirdPartyApp>> {
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<ThirdPartyApp> {
// 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);
}

View file

@ -34,6 +34,7 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_DRAFTS).unwrap(); execute!(&conn, common::CREATE_TABLE_DRAFTS).unwrap();
execute!(&conn, common::CREATE_TABLE_POLLS).unwrap(); execute!(&conn, common::CREATE_TABLE_POLLS).unwrap();
execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap();
execute!(&conn, common::CREATE_TABLE_APPS).unwrap();
self.0 self.0
.1 .1
@ -456,7 +457,7 @@ macro_rules! auto_method {
let res = execute!( let res = execute!(
&conn, &conn,
$query, $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 { 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) => { ($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<()> { pub async fn $name(&self, id: usize, user: &User) -> Result<()> {
let y = self.$select_fn(id).await?; let y = self.$select_fn(id).await?;

View file

@ -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_DRAFTS: &str = include_str!("./sql/create_drafts.sql");
pub const CREATE_TABLE_POLLS: &str = include_str!("./sql/create_polls.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_POLLVOTES: &str = include_str!("./sql/create_pollvotes.sql");
pub const CREATE_TABLE_APPS: &str = include_str!("./sql/create_apps.sql");

View file

@ -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
)

View file

@ -1,3 +1,4 @@
mod apps;
mod audit_log; mod audit_log;
mod auth; mod auth;
mod common; mod common;

View file

@ -1,11 +1,10 @@
use oiseau::cache::Cache; use oiseau::cache::Cache;
use crate::model::communities::{Community, Poll, Post, Question};
use crate::model::stacks::{StackMode, StackSort};
use crate::model::{ use crate::model::{
Error, Result, Error, Result,
auth::User, auth::User,
permissions::FinePermission, permissions::FinePermission,
stacks::{StackPrivacy, UserStack}, stacks::{StackPrivacy, UserStack, StackMode, StackSort},
communities::{Community, Poll, Post, Question},
}; };
use crate::{auto_method, DataManager}; use crate::{auto_method, DataManager};

View file

@ -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::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
owner,
title,
homepage,
redirect,
quota_status: AppQuota::Limited,
banned: false,
grants: 0,
}
}
}

View file

@ -403,6 +403,14 @@ impl User {
self.stripe_id = String::new(); self.stripe_id = String::new();
self.connections = HashMap::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)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]

View file

@ -1,4 +1,5 @@
pub mod addr; pub mod addr;
pub mod apps;
pub mod auth; pub mod auth;
pub mod communities; pub mod communities;
pub mod communities_permissions; pub mod communities_permissions;

View file

@ -6,8 +6,8 @@ use super::{Result, Error};
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthGrant { pub struct AuthGrant {
pub id: usize, pub id: usize,
/// The name of the application associated with this grant. /// The ID of the application associated with this grant.
pub name: String, pub app: usize,
/// The code challenge for PKCE verifiers associated with this grant. /// The code challenge for PKCE verifiers associated with this grant.
/// ///
/// This challenge is *all* that is required to refresh this grant's auth token. /// This challenge is *all* that is required to refresh this grant's auth token.

View file

@ -36,6 +36,7 @@ bitflags! {
const MANAGE_EMOJIS = 1 << 25; const MANAGE_EMOJIS = 1 << 25;
const MANAGE_STACKS = 1 << 26; const MANAGE_STACKS = 1 << 26;
const STAFF_BADGE = 1 << 27; const STAFF_BADGE = 1 << 27;
const MANAGE_APPS = 1 << 28;
const _ = !0; const _ = !0;
} }

4
example/.gitignore vendored
View file

@ -1,7 +1,9 @@
atto.db* atto.db*
html/* html/*
# public/* public/*
!public/footer.html
!public/robots.txt
media/* media/*
icons/* icons/*
langs/* langs/*

1
example/reference Symbolic link
View file

@ -0,0 +1 @@
../target/doc

View file

@ -5,3 +5,6 @@ clean-deps:
fix: fix:
cargo fix --allow-dirty cargo fix --allow-dirty
cargo clippy --fix --allow-dirty cargo clippy --fix --allow-dirty
doc:
cargo doc --document-private-items --no-deps