add: apps api
This commit is contained in:
parent
2a99d49c8a
commit
ebded00fd3
33 changed files with 698 additions and 31 deletions
10
README.md
10
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 <https://tetratto.com/doc/credits.md>. Most credits on that page are for tetratto.com specifically, however it does include a saved dependency tree.
|
||||
|
|
|
@ -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<RwLock<String>> =
|
||||
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<String, Box<dyn FnMut(Element) -> 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);
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -94,6 +94,7 @@
|
|||
]);
|
||||
|
||||
if (res.ok) {
|
||||
e.target.reset();
|
||||
setTimeout(() => {
|
||||
window.location.href = `/community/${res.payload}`;
|
||||
}, 100);
|
||||
|
|
|
@ -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\" }}")))
|
||||
; <a href="https://trisuaso.github.io/tetratto">
|
||||
; {{ icon "book" }}
|
||||
; <span>{{ text "general:link.reference" }}</span>
|
||||
; </a>
|
||||
(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\" }}")
|
||||
|
|
121
crates/app/src/public/html/developer/home.lisp
Normal file
121
crates/app/src/public/html/developer/home.lisp
Normal 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 %}")
|
|
@ -71,6 +71,7 @@
|
|||
]);
|
||||
|
||||
if (res.ok) {
|
||||
e.target.reset();
|
||||
setTimeout(() => {
|
||||
window.location.href = `/forge/${res.payload}`;
|
||||
}, 100);
|
||||
|
|
|
@ -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 %}")
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
BIN
crates/app/src/public/images/tetratto_bunny.webp
Normal file
BIN
crates/app/src/public/images/tetratto_bunny.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.6 KiB |
151
crates/app/src/routes/api/v1/apps.rs
Normal file
151
crates/app/src/routes/api/v1/apps.rs
Normal 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()),
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
35
crates/app/src/routes/pages/developer.rs
Normal file
35
crates/app/src/routes/pages/developer.rs
Normal 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(),
|
||||
))
|
||||
}
|
|
@ -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))
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
150
crates/core/src/database/apps.rs
Normal file
150
crates/core/src/database/apps.rs
Normal 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);
|
||||
}
|
|
@ -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?;
|
||||
|
|
|
@ -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");
|
||||
|
|
11
crates/core/src/database/drivers/sql/create_apps.sql
Normal file
11
crates/core/src/database/drivers/sql/create_apps.sql
Normal 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
|
||||
)
|
|
@ -1,3 +1,4 @@
|
|||
mod apps;
|
||||
mod audit_log;
|
||||
mod auth;
|
||||
mod common;
|
||||
|
|
|
@ -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};
|
||||
|
||||
|
|
60
crates/core/src/model/apps.rs
Normal file
60
crates/core/src/model/apps.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)]
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
pub mod addr;
|
||||
pub mod apps;
|
||||
pub mod auth;
|
||||
pub mod communities;
|
||||
pub mod communities_permissions;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
4
example/.gitignore
vendored
4
example/.gitignore
vendored
|
@ -1,7 +1,9 @@
|
|||
atto.db*
|
||||
|
||||
html/*
|
||||
# public/*
|
||||
public/*
|
||||
!public/footer.html
|
||||
!public/robots.txt
|
||||
media/*
|
||||
icons/*
|
||||
langs/*
|
||||
|
|
1
example/reference
Symbolic link
1
example/reference
Symbolic link
|
@ -0,0 +1 @@
|
|||
../target/doc
|
3
justfile
3
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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue