add: developer panel
This commit is contained in:
parent
ebded00fd3
commit
39574df691
44 changed files with 982 additions and 84 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
@ -3288,7 +3288,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto"
|
||||
version = "6.0.0"
|
||||
version = "7.0.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"async-stripe",
|
||||
|
@ -3319,7 +3319,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-core"
|
||||
version = "6.0.0"
|
||||
version = "7.0.0"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"base16ct",
|
||||
|
@ -3341,7 +3341,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-l10n"
|
||||
version = "6.0.0"
|
||||
version = "7.0.0"
|
||||
dependencies = [
|
||||
"pathbufd",
|
||||
"serde",
|
||||
|
@ -3350,7 +3350,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-shared"
|
||||
version = "6.0.0"
|
||||
version = "7.0.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"chrono",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto"
|
||||
version = "6.0.0"
|
||||
version = "7.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
|
|
|
@ -122,6 +122,8 @@ 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");
|
||||
pub const DEVELOPER_APP: &str = include_str!("./public/html/developer/app.lisp");
|
||||
pub const DEVELOPER_LINK: &str = include_str!("./public/html/developer/link.lisp");
|
||||
|
||||
// langs
|
||||
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
|
||||
|
@ -408,6 +410,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
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);
|
||||
write_template!(html_path->"developer/app.html"(crate::assets::DEVELOPER_APP) --config=config --lisp plugins);
|
||||
write_template!(html_path->"developer/link.html"(crate::assets::DEVELOPER_LINK) --config=config --lisp plugins);
|
||||
|
||||
html_path
|
||||
}
|
||||
|
|
|
@ -212,7 +212,17 @@ version = "1.0.0"
|
|||
"forge:action.reopen" = "Reopen"
|
||||
"forge:action.close" = "Close"
|
||||
|
||||
"developer:label.for_developers" = "for Developers"
|
||||
"developer:label.my_apps" = "My apps"
|
||||
"developer:label.create_new" = "Create new app"
|
||||
"developer:label.homepage" = "Homepage"
|
||||
"developer:label.redirect" = "Redirect URL"
|
||||
"developer:label.change_title" = "Change title"
|
||||
"developer:label.change_homepage" = "Change homepage"
|
||||
"developer:label.change_redirect" = "Change redirect URL"
|
||||
"developer:label.change_quota_status" = "Change quota status"
|
||||
"developer:label.manage_scopes" = "Manage scopes"
|
||||
"developer:label.scopes" = "Scopes"
|
||||
"developer:label.guides_and_help" = "Guides & help"
|
||||
"developer:action.delete" = "Delete app"
|
||||
"developer:action.authorize" = "Authorize"
|
||||
|
|
|
@ -100,19 +100,14 @@ macro_rules! get_user_from_token {
|
|||
}};
|
||||
|
||||
($jar:ident, $db:expr, $grant_scope:expr) => {{
|
||||
if let Some(token) = $jar.get("Atto-Grant")
|
||||
&& let Some(verifier) = $jar.get("Atto-Grant-Verifier")
|
||||
{
|
||||
if let Some(token) = $jar.get("Atto-Grant") {
|
||||
// grant token
|
||||
let verifier = verifier.to_string().replace("Atto-Grant-Verifier=", "");
|
||||
match $db
|
||||
.get_user_by_grant_token(&token.to_string().replace("Atto-Grant=", ""))
|
||||
.get_user_by_grant_token(&token.to_string().replace("Atto-Grant=", ""), true)
|
||||
.await
|
||||
{
|
||||
Ok((grant, ua)) => {
|
||||
if grant.scopes.contains(&$grant_scope)
|
||||
&& grant.check_verifier(&verifier).is_ok()
|
||||
{
|
||||
if grant.scopes.contains(&$grant_scope) {
|
||||
if ua.permissions.check_banned() {
|
||||
Some(tetratto_core::model::auth::User::banned())
|
||||
} else {
|
||||
|
|
|
@ -191,6 +191,7 @@ table ol {
|
|||
|
||||
.card.lowered {
|
||||
background: var(--color-lowered);
|
||||
color: var(--color-text-lowered);
|
||||
}
|
||||
|
||||
.card-nest {
|
||||
|
|
348
crates/app/src/public/html/developer/app.lisp
Normal file
348
crates/app/src/public/html/developer/app.lisp
Normal file
|
@ -0,0 +1,348 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "{{ app.title }} - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(div
|
||||
("class" "w-full flex flex-col gap-2")
|
||||
(div
|
||||
("id" "manage_fields")
|
||||
("class" "card lowered flex flex-col gap-2")
|
||||
(text "{% if is_helper -%}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b (str (text "developer:label.change_quota_status"))))
|
||||
(div
|
||||
("class" "card")
|
||||
(select
|
||||
("onchange" "save_quota_status(event)")
|
||||
(option
|
||||
("value" "Limited")
|
||||
("selected" "{% if app.quota_status == 'Limited' -%}true{% else %}false{%- endif %}")
|
||||
(text "Limited"))
|
||||
(option
|
||||
("value" "Unlimited")
|
||||
("selected" "{% if app.quota_status == 'Unlimited' -%}true{% else %}false{%- endif %}")
|
||||
(text "Unlimited")))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b (str (text "developer:label.change_title"))))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "change_title(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "title")
|
||||
(text "{{ text \"communities:label.new_title\" }}"))
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "title")
|
||||
("id" "title")
|
||||
("placeholder" "new title")
|
||||
("required" "")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}")))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b (str (text "developer:label.change_homepage"))))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "change_homepage(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "homepage")
|
||||
(text "{{ text \"developer:label.homepage\" }}"))
|
||||
(input
|
||||
("type" "url")
|
||||
("name" "homepage")
|
||||
("id" "homepage")
|
||||
("placeholder" "new homepage")
|
||||
("required" "")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}")))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b (str (text "developer:label.change_redirect"))))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "change_redirect(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "redirect")
|
||||
(text "{{ text \"developer:label.redirect\" }}"))
|
||||
(input
|
||||
("type" "url")
|
||||
("name" "redirect")
|
||||
("id" "redirect")
|
||||
("placeholder" "new redirect URL")
|
||||
("required" "")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}")))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b (str (text "developer:label.manage_scopes"))))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "change_scopes(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "scopes")
|
||||
(text "{{ text \"developer:label.scopes\" }}"))
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "scopes")
|
||||
("id" "scopes")
|
||||
("placeholder" "new scopes")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("value" "{% for scope in app.scopes -%} {{ scope }} {% endfor %}")))
|
||||
|
||||
(pre ("class" "hidden red w-full") (code ("id" "scope_error_message") ("style" "white-space: pre-wrap")))
|
||||
|
||||
(details
|
||||
(summary ("class" "button lowered small") (icon (text "circle-help")) (text "Help"))
|
||||
(div
|
||||
("class" "card flex flex-col gap-1")
|
||||
(span ("class" "fade") (text "Scopes should be separated by a single space."))
|
||||
(a
|
||||
("class" "button")
|
||||
("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html#variants")
|
||||
("target" "_blank")
|
||||
(icon (text "external-link")) (text "Docs"))))
|
||||
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}"))))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(ul
|
||||
(li (b (text "Homepage: ")) (text "{{ app.homepage }}"))
|
||||
(li (b (text "Redirect URL: ")) (text "{{ app.redirect }}"))
|
||||
(li (b (text "Quota status: ")) (text "{{ app.quota_status }}"))
|
||||
(li (b (text "User grants: ")) (text "{{ app.grants }}"))
|
||||
(li (b (text "Grant URL: ")) (text "{{ config.host }}/auth/connections_link/app/{{ app.id }}")))
|
||||
|
||||
(a
|
||||
("class" "button")
|
||||
("href" "https://tetratto.com/reference/tetratto/model/apps/struct.ThirdPartyApp.html#structfield.redirect")
|
||||
("target" "_blank")
|
||||
(icon (text "external-link")) (text "Docs")))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex gap-1 items-center red")
|
||||
(text "{{ icon \"skull\" }}")
|
||||
(b
|
||||
(text "{{ text \"communities:label.danger_zone\" }}")))
|
||||
(div
|
||||
("class" "card flex flex-wrap gap-2")
|
||||
(button
|
||||
("class" "red lowered")
|
||||
("onclick" "delete_app()")
|
||||
(text "{{ icon \"trash\" }}")
|
||||
(span (str (text "developer:action.delete"))))))
|
||||
(div
|
||||
("class" "flex gap-2 flex-wrap")
|
||||
(a
|
||||
("href" "/developer")
|
||||
("class" "button secondary")
|
||||
(text "{{ icon \"arrow-left\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.back\" }}"))))))
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
globalThis.save_quota_status = (event) => {
|
||||
const selected = event.target.selectedOptions[0];
|
||||
fetch(\"/api/v1/apps/{{ app.id }}/quota_status\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
quota_status: selected.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.change_title = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/apps/{{ app.id }}/title\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: e.target.title.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.change_homepage = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/apps/{{ app.id }}/homepage\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
homepage: e.target.homepage.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.change_redirect = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/apps/{{ app.id }}/redirect\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
redirect: e.target.redirect.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.change_scopes = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this? This will only impact new grants.\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/apps/{{ app.id }}/scopes\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
scopes: e.target.scopes.value.trim().split(\" \"),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.text())
|
||||
.then((res) => {
|
||||
if (res.startsWith(\"{\")) {
|
||||
const r = JSON.parse(res);
|
||||
trigger(\"atto::toast\", [r.ok ? \"success\" : \"error\", r.message]);
|
||||
document.getElementById(\"scope_error_message\").parentElement.classList.add(\"hidden\");
|
||||
} else {
|
||||
document.getElementById(\"scope_error_message\").innerText = res;
|
||||
document.getElementById(\"scope_error_message\").parentElement.classList.remove(\"hidden\");
|
||||
document.getElementById(\"scope_error_message\").parentElement.parentElement.querySelector(\"details\").setAttribute(\"open\", \"\");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.delete_app = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this? This action is permanent.\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/apps/{{ app.id }}`, {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
}, 250);"))
|
||||
|
||||
(text "{% endblock %}")
|
|
@ -85,7 +85,33 @@
|
|||
("class" "date")
|
||||
(text "{{ item.created }}"))
|
||||
(text "; {{ item.quota_status }} mode; {{ item.grants }} users")))
|
||||
(text "{% endfor %}"))))
|
||||
(text "{% endfor %}")))
|
||||
|
||||
; useful links
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "circle-help"))
|
||||
(str (text "developer:label.guides_and_help")))
|
||||
|
||||
(div
|
||||
("class" "card")
|
||||
(ul
|
||||
(li
|
||||
(a ("href" "https://trisua.com/t/tetratto") (text "Source code")))
|
||||
(li
|
||||
(a ("href" "https://tetratto.com/reference/tetratto/index.html") (text "Source code reference")))
|
||||
(li
|
||||
(a ("href" "https://tetratto.com/reference/tetratto/model/struct.ApiReturn.html") (text "API response structure")))
|
||||
(li
|
||||
(a ("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html") (text "App scopes")))
|
||||
(li
|
||||
(a ("href" "https://tetratto.com/reference/tetratto/model/permissions/struct.FinePermission.html") (text "User permissions")))
|
||||
(li
|
||||
(a ("href" "https://tetratto.com/reference/tetratto/model/communities_permissions/struct.CommunityPermission.html") (text "Community member permissions")))
|
||||
(li
|
||||
(a ("href" "https://tetratto.com/forge/tetratto") (text "Report issues")))))))
|
||||
|
||||
(script
|
||||
(text "async function create_app_from_form(e) {
|
||||
|
|
86
crates/app/src/public/html/developer/link.lisp
Normal file
86
crates/app/src/public/html/developer/link.lisp
Normal file
|
@ -0,0 +1,86 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "{{ app.title }} - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(div
|
||||
("class" "w-full flex flex-col gap-2")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(h4 (text "Would you like to allow \"{{ app.title }}\" to access your account?"))
|
||||
(p (text "This app is requesting the following permissions on your account:"))
|
||||
|
||||
(div
|
||||
("class" "card")
|
||||
(ul
|
||||
(text "{% for scope in app.scopes -%}")
|
||||
(li
|
||||
(a
|
||||
("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html#variant.{{ scope }}")
|
||||
("target" "_blank")
|
||||
(text "{{ scope }}")))
|
||||
(text "{%- endfor %}")))
|
||||
|
||||
(p (text "You can revoke this app's permissions at any time through your connection settings.")))
|
||||
|
||||
(div
|
||||
("class" "card flex gap-2")
|
||||
(button
|
||||
("onclick" "authorize()")
|
||||
(str (text "developer:action.authorize")))
|
||||
|
||||
(a
|
||||
("class" "button secondary")
|
||||
("href" "javascript:window.close()")
|
||||
(str (text "dialog:action.cancel")))))))
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
globalThis.authorize = async (event) => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const verifier = await trigger(\"connections::pkce_verifier\", [
|
||||
128,
|
||||
]);
|
||||
|
||||
const challenge = await trigger(\"connections::pkce_challenge\", [
|
||||
verifier,
|
||||
]);
|
||||
|
||||
fetch(\"/api/v1/apps/{{ app.id }}/grant\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
challenge
|
||||
})
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
search.append(\"verifier\", verifier);
|
||||
search.append(\"token\", res.payload);
|
||||
|
||||
window.location.href = `{{ app.redirect|remove_script_tags|safe }}?${search.toString()}`;
|
||||
}
|
||||
});
|
||||
};
|
||||
}, 250);"))
|
||||
|
||||
(text "{% endblock %}")
|
|
@ -255,6 +255,7 @@
|
|||
MANAGE_EMOJIS: 1 << 25,
|
||||
MANAGE_STACKS: 1 << 26,
|
||||
STAFF_BADGE: 1 << 27,
|
||||
MANAGE_APPS: 1 << 28,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
|
|
@ -845,7 +845,57 @@
|
|||
("class" "w-content"))
|
||||
(span
|
||||
(text "Shown on profile")))))
|
||||
(text "{% endfor %}"))
|
||||
(text "{% endfor %}")
|
||||
(text "{% for grant in profile_grants %}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-4")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "bot"))
|
||||
(a
|
||||
("class" "flush")
|
||||
("href" "{{ grant[0].homepage }}")
|
||||
("target" "_blank")
|
||||
(b
|
||||
("class" "flex items-center gap-2")
|
||||
(text "{{ grant[0].title }}"))))
|
||||
|
||||
(span
|
||||
("class" "fade flex items-center gap-2")
|
||||
(icon (text "clock"))
|
||||
(span ("class" "date") (text "{{ grant[1].last_updated }}"))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(details
|
||||
(summary (icon (text "scan-eye")) (text "{{ grant[1].scopes|length }} scope{{ grant[1].scopes|length|pluralize }}"))
|
||||
|
||||
(div
|
||||
("class" "card lowered w-full")
|
||||
(ul
|
||||
(text "{% for scope in grant[1].scopes -%}")
|
||||
(li
|
||||
(a
|
||||
("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html#variant.{{ scope }}")
|
||||
("target" "_blank")
|
||||
(text "{{ scope }}")))
|
||||
(text "{%- endfor %}"))))
|
||||
|
||||
(button
|
||||
("class" "lowered red small")
|
||||
("onclick" "remove_grant('{{ grant[0].id }}')")
|
||||
(text "{{ text \"general:action.delete\" }}"))))
|
||||
(text "{% endfor %}")
|
||||
|
||||
(hr)
|
||||
(a
|
||||
("class" "button")
|
||||
("href" "/developer")
|
||||
(icon (text "code"))
|
||||
(span
|
||||
(text "{{ config.name }} ")
|
||||
(str (text "developer:label.for_developers")))))
|
||||
(script
|
||||
("type" "application/json")
|
||||
("id" "settings_json")
|
||||
|
@ -897,6 +947,27 @@
|
|||
});
|
||||
};
|
||||
|
||||
globalThis.remove_grant = async (id) => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/auth/user/{{ profile.id }}/grants/${id}`, {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.save_settings = () => {
|
||||
fetch(\"/api/v1/auth/user/{{ profile.id }}/settings\", {
|
||||
method: \"POST\",
|
||||
|
|
|
@ -818,6 +818,7 @@ media_theme_pref();
|
|||
anchor.href.length === 0 ||
|
||||
anchor.href.startsWith("https://github.com") ||
|
||||
anchor.href.startsWith("https://trisua.com") ||
|
||||
anchor.href.startsWith("https://tetratto.com") ||
|
||||
anchor.href.startsWith("https://buy.stripe.com") ||
|
||||
anchor.href.startsWith("https://billing.stripe.com") ||
|
||||
anchor.href.startsWith("https://last.fm")
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::api::v1::{UpdateAppHomepage, UpdateAppQuotaStatus, UpdateAppRedirect, UpdateAppTitle},
|
||||
routes::api::v1::{
|
||||
CreateGrant, UpdateAppHomepage, UpdateAppQuotaStatus, UpdateAppRedirect, UpdateAppScopes,
|
||||
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 tetratto_core::model::{
|
||||
apps::ThirdPartyApp,
|
||||
oauth::{AuthGrant, PkceChallengeMethod},
|
||||
permissions::FinePermission,
|
||||
ApiReturn, Error,
|
||||
};
|
||||
use tetratto_shared::{hash::random_id, unix_epoch_timestamp};
|
||||
use super::CreateApp;
|
||||
|
||||
pub async fn create_request(
|
||||
|
@ -129,6 +138,28 @@ pub async fn update_quota_status_request(
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn update_scopes_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateAppScopes>,
|
||||
) -> 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_scopes(id, &user, req.scopes).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>,
|
||||
|
@ -149,3 +180,50 @@ pub async fn delete_request(
|
|||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn grant_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<CreateGrant>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let app = match data.get_app_by_id(id).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if user.get_grant_by_app_id(id).is_some() {
|
||||
return Json(Error::MiscError("This app already has a grant".to_string()).into());
|
||||
}
|
||||
|
||||
let grant = AuthGrant {
|
||||
app: app.id,
|
||||
challenge: req.challenge,
|
||||
method: PkceChallengeMethod::S256,
|
||||
token: random_id(),
|
||||
last_updated: unix_epoch_timestamp(),
|
||||
scopes: app.scopes.clone(),
|
||||
};
|
||||
|
||||
user.grants.push(grant.clone());
|
||||
match data.update_user_grants(user.id, user.grants).await {
|
||||
Ok(_) => {
|
||||
if let Err(e) = data.incr_app_grants(id).await {
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User updated".to_string(),
|
||||
payload: Some(grant.token),
|
||||
})
|
||||
}
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,8 @@ use crate::{
|
|||
get_user_from_token,
|
||||
model::{ApiReturn, Error},
|
||||
routes::api::v1::{
|
||||
AppendAssociations, DeleteUser, DisableTotp, UpdateUserIsVerified, UpdateUserPassword,
|
||||
UpdateUserRole, UpdateUserUsername,
|
||||
AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateUserIsVerified,
|
||||
UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
|
||||
},
|
||||
State,
|
||||
};
|
||||
|
@ -31,7 +31,10 @@ use tetratto_core::{
|
|||
|
||||
#[cfg(feature = "redis")]
|
||||
use tetratto_core::cache::redis::Commands;
|
||||
use tetratto_shared::hash;
|
||||
use tetratto_shared::{
|
||||
hash::{self, random_id},
|
||||
unix_epoch_timestamp,
|
||||
};
|
||||
|
||||
pub async fn redirect_from_id(
|
||||
Extension(data): Extension<State>,
|
||||
|
@ -717,3 +720,104 @@ pub async fn get_user_gpa_request(
|
|||
payload: Some(gpa),
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove a grant token.
|
||||
pub async fn remove_grant_request(
|
||||
jar: CookieJar,
|
||||
Path((user_id, app_id)): Path<(usize, usize)>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if user_id != user.id && !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
if user.get_grant_by_app_id(app_id).is_none() {
|
||||
return Json(Error::GeneralNotFound("grant".to_string()).into());
|
||||
}
|
||||
|
||||
// remove grant
|
||||
user.grants
|
||||
.remove(user.grants.iter().position(|x| x.app == app_id).unwrap());
|
||||
|
||||
if let Err(e) = data.decr_app_grants(app_id).await {
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// update grants
|
||||
match data.update_user_grants(user_id, user.grants).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh a grant token.
|
||||
pub async fn refresh_grant_request(
|
||||
jar: CookieJar,
|
||||
Path((user_id, app_id)): Path<(usize, usize)>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<RefreshGrantToken>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = if let Some(token) = jar.get("Atto-Grant") {
|
||||
match data
|
||||
.get_user_by_grant_token(&token.to_string().replace("Atto-Grant=", ""), false)
|
||||
.await
|
||||
{
|
||||
Ok((grant, ua)) => {
|
||||
if grant.check_verifier(&req.verifier).is_err() {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
if ua.permissions.check_banned() {
|
||||
tetratto_core::model::auth::User::banned()
|
||||
} else {
|
||||
ua
|
||||
}
|
||||
}
|
||||
Err(_) => return Json(Error::NotAllowed.into()),
|
||||
}
|
||||
} else {
|
||||
return Json(Error::NotAllowed.into());
|
||||
};
|
||||
|
||||
if user_id != user.id && !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
let mut grant = match user.get_grant_by_app_id(app_id) {
|
||||
Some(g) => g.to_owned(),
|
||||
None => return Json(Error::GeneralNotFound("grant".to_string()).into()),
|
||||
};
|
||||
|
||||
// remove grant
|
||||
user.grants
|
||||
.remove(user.grants.iter().position(|x| x.app == app_id).unwrap());
|
||||
|
||||
// refresh token
|
||||
let token = random_id();
|
||||
grant.token = token.clone();
|
||||
grant.last_updated = unix_epoch_timestamp();
|
||||
|
||||
// add grant
|
||||
user.grants.push(grant);
|
||||
|
||||
// update grants
|
||||
match data.update_user_grants(user_id, user.grants).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User updated".to_string(),
|
||||
payload: Some(token),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ use tetratto_core::model::{
|
|||
PollOption, PostContext,
|
||||
},
|
||||
communities_permissions::CommunityPermission,
|
||||
oauth::AppScope,
|
||||
permissions::FinePermission,
|
||||
reactions::AssetType,
|
||||
stacks::{StackMode, StackPrivacy, StackSort},
|
||||
|
@ -348,6 +349,14 @@ pub fn routes() -> Router {
|
|||
"/auth/user/{id}/followers",
|
||||
get(auth::social::followers_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/grants/{app}",
|
||||
delete(auth::profile::remove_grant_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/grants/{app}/refresh",
|
||||
post(auth::profile::refresh_grant_request),
|
||||
)
|
||||
// apps
|
||||
.route("/apps", post(apps::create_request))
|
||||
.route("/apps/{id}/title", post(apps::update_title_request))
|
||||
|
@ -357,7 +366,9 @@ pub fn routes() -> Router {
|
|||
"/apps/{id}/quota_status",
|
||||
post(apps::update_quota_status_request),
|
||||
)
|
||||
.route("/apps/{id}/scopes", post(apps::update_scopes_request))
|
||||
.route("/apps/{id}", delete(apps::delete_request))
|
||||
.route("/apps/{id}/grant", post(apps::grant_request))
|
||||
// warnings
|
||||
.route("/warnings/{id}", get(auth::user_warnings::get_request))
|
||||
.route("/warnings/{id}", post(auth::user_warnings::create_request))
|
||||
|
@ -816,3 +827,18 @@ pub struct UpdateAppRedirect {
|
|||
pub struct UpdateAppQuotaStatus {
|
||||
pub quota_status: AppQuota,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAppScopes {
|
||||
pub scopes: Vec<AppScope>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateGrant {
|
||||
pub challenge: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RefreshGrantToken {
|
||||
pub verifier: String,
|
||||
}
|
||||
|
|
|
@ -2,10 +2,11 @@ use super::render_error;
|
|||
use crate::{assets::initial_context, get_lang, get_user_from_token, State};
|
||||
use axum::{
|
||||
response::{Html, IntoResponse},
|
||||
extract::Path,
|
||||
Extension,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_core::model::Error;
|
||||
use tetratto_core::model::{permissions::FinePermission, Error};
|
||||
|
||||
/// `/developer`
|
||||
pub async fn home_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
|
||||
|
@ -33,3 +34,62 @@ pub async fn home_request(jar: CookieJar, Extension(data): Extension<State>) ->
|
|||
data.1.render("developer/home.html", &context).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `/developer/app/{id}`
|
||||
pub async fn app_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> 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 app = match data.0.get_app_by_id(id).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
if user.id != app.owner && !user.permissions.check(FinePermission::MANAGE_APPS) {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &None).await,
|
||||
));
|
||||
}
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
context.insert("app", &app);
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("developer/app.html", &context).unwrap()))
|
||||
}
|
||||
|
||||
/// `/auth/connections_link/app/{id}`
|
||||
pub async fn connection_callback_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => ua,
|
||||
None => return Html(render_error(Error::NotAllowed, &jar, &data, &None).await),
|
||||
};
|
||||
|
||||
let app = match data.0.get_app_by_id(id).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => return 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("app", &app);
|
||||
|
||||
Html(data.1.render("developer/link.html", &context).unwrap())
|
||||
}
|
||||
|
|
|
@ -68,6 +68,10 @@ pub fn routes() -> Router {
|
|||
"/auth/connections_link/{service}",
|
||||
get(auth::connection_callback_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/connections_link/app/{id}",
|
||||
get(developer::connection_callback_request),
|
||||
)
|
||||
// profile
|
||||
.route("/settings", get(profile::settings_request))
|
||||
.route("/@{username}", get(profile::posts_request))
|
||||
|
@ -119,6 +123,7 @@ pub fn routes() -> Router {
|
|||
.route("/forge/{title}/members", get(communities::members_request))
|
||||
// developer
|
||||
.route("/developer", get(developer::home_request))
|
||||
.route("/developer/app/{id}", get(developer::app_request))
|
||||
// stacks
|
||||
.route("/stacks", get(stacks::list_request))
|
||||
.route("/stacks/{id}", get(stacks::posts_request))
|
||||
|
|
|
@ -104,6 +104,22 @@ pub async fn settings_request(
|
|||
.unwrap()
|
||||
.replace("\"", "\\\""),
|
||||
);
|
||||
context.insert("profile_grants", &{
|
||||
let mut out = Vec::new();
|
||||
|
||||
for grant in profile.grants {
|
||||
out.push((
|
||||
match data.0.get_app_by_id(grant.app).await {
|
||||
Ok(a) => a,
|
||||
// TODO: remove grant from user (app deleted)
|
||||
Err(_) => continue,
|
||||
},
|
||||
grant,
|
||||
));
|
||||
}
|
||||
|
||||
out
|
||||
});
|
||||
|
||||
// check color contrasts
|
||||
let mut failing_color_keys: Vec<(&str, f64)> = Vec::new();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto-core"
|
||||
version = "6.0.0"
|
||||
version = "7.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
use oiseau::cache::Cache;
|
||||
use crate::model::{
|
||||
Error, Result,
|
||||
auth::User,
|
||||
permissions::FinePermission,
|
||||
apps::{AppQuota, ThirdPartyApp},
|
||||
auth::User,
|
||||
oauth::AppScope,
|
||||
permissions::FinePermission,
|
||||
Error, Result,
|
||||
};
|
||||
use crate::{auto_method, DataManager};
|
||||
|
||||
|
@ -31,6 +32,7 @@ impl DataManager {
|
|||
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,
|
||||
scopes: serde_json::from_str(&get!(x->9(String))).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -95,7 +97,7 @@ impl DataManager {
|
|||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
"INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
|
@ -105,7 +107,8 @@ impl DataManager {
|
|||
&data.redirect,
|
||||
&serde_json::to_string(&data.quota_status).unwrap(),
|
||||
&{ if data.banned { 1 } else { 0 } },
|
||||
&(data.grants as i32)
|
||||
&(data.grants as i32),
|
||||
&serde_json::to_string(&data.scopes).unwrap(),
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -144,7 +147,8 @@ impl DataManager {
|
|||
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!(update_app_scopes(Vec<AppScope>)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET scopes = $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);
|
||||
auto_method!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --incr);
|
||||
auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --decr=grants);
|
||||
}
|
||||
|
|
|
@ -114,7 +114,11 @@ impl DataManager {
|
|||
///
|
||||
/// # Arguments
|
||||
/// * `token` - the token of the user
|
||||
pub async fn get_user_by_grant_token(&self, token: &str) -> Result<(AuthGrant, User)> {
|
||||
pub async fn get_user_by_grant_token(
|
||||
&self,
|
||||
token: &str,
|
||||
check_expiration: bool,
|
||||
) -> Result<(AuthGrant, User)> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
|
@ -122,7 +126,7 @@ impl DataManager {
|
|||
|
||||
let res = query_row!(
|
||||
&conn,
|
||||
"SELECT * FROM users WHERE grants::jsonb @> ('{\"token\":' || $1 || '}')::jsonb",
|
||||
"SELECT * FROM users WHERE (SELECT jsonb_array_elements(grants::jsonb) @> ('{\"token\":\"' || $1 || '\"}')::jsonb)",
|
||||
&[&token],
|
||||
|x| Ok(Self::get_user_from_row(x))
|
||||
);
|
||||
|
@ -132,14 +136,25 @@ impl DataManager {
|
|||
}
|
||||
|
||||
let user = res.unwrap();
|
||||
Ok((
|
||||
user.grants
|
||||
let grant = user
|
||||
.grants
|
||||
.iter()
|
||||
.find(|x| x.token == token)
|
||||
.unwrap()
|
||||
.clone(),
|
||||
user,
|
||||
))
|
||||
.clone();
|
||||
|
||||
// check token expiry
|
||||
if check_expiration {
|
||||
let now = unix_epoch_timestamp();
|
||||
let delta = now - grant.last_updated;
|
||||
|
||||
if delta > 604_800_000 {
|
||||
return Err(Error::MiscError("Token expired".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
Ok((grant, user))
|
||||
}
|
||||
|
||||
/// Create a new user in the database.
|
||||
|
|
|
@ -312,7 +312,7 @@ impl DataManager {
|
|||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// add journal page owner as admin
|
||||
// add community owner as admin
|
||||
self.create_membership(CommunityMembership::new(
|
||||
data.owner,
|
||||
data.id,
|
||||
|
|
|
@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS apps (
|
|||
redirect TEXT NOT NULL,
|
||||
quota_status TEXT NOT NULL,
|
||||
banned INT NOT NULL,
|
||||
grants INT NOT NULL
|
||||
grants INT NOT NULL,
|
||||
scopes TEXT NOT NULL
|
||||
)
|
||||
|
|
|
@ -11,7 +11,7 @@ use oiseau::PostgresRow;
|
|||
use oiseau::{execute, get, query_row, params};
|
||||
|
||||
impl DataManager {
|
||||
/// Get a [`UserBlock`] from an SQL row.
|
||||
/// Get an [`IpBlock`] from an SQL row.
|
||||
pub(crate) fn get_ipblock_from_row(
|
||||
#[cfg(feature = "sqlite")] x: &SqliteRow<'_>,
|
||||
#[cfg(feature = "postgres")] x: &PostgresRow,
|
||||
|
@ -79,7 +79,7 @@ impl DataManager {
|
|||
/// Create a new user block in the database.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - a mock [`UserBlock`] object to insert
|
||||
/// * `data` - a mock [`IpBlock`] object to insert
|
||||
pub async fn create_ipblock(&self, data: IpBlock) -> Result<()> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
|
|
|
@ -19,7 +19,7 @@ use oiseau::PostgresRow;
|
|||
use oiseau::{execute, get, query_row, query_rows, params};
|
||||
|
||||
impl DataManager {
|
||||
/// Get a [`JournalEntry`] from an SQL row.
|
||||
/// Get a [`CommunityMembership`] from an SQL row.
|
||||
pub(crate) fn get_membership_from_row(
|
||||
#[cfg(feature = "sqlite")] x: &SqliteRow<'_>,
|
||||
#[cfg(feature = "postgres")] x: &PostgresRow,
|
||||
|
|
|
@ -76,7 +76,7 @@ impl DataManager {
|
|||
// get poll and check permission
|
||||
let poll = self.get_poll_by_id(data.poll_id).await?;
|
||||
|
||||
let now = unix_epoch_timestamp() as usize;
|
||||
let now = unix_epoch_timestamp();
|
||||
let diff = now - poll.created;
|
||||
|
||||
if diff > poll.expires {
|
||||
|
|
|
@ -269,7 +269,7 @@ impl DataManager {
|
|||
if post.poll_id != 0 {
|
||||
Ok(Some(match self.get_poll_by_id(post.poll_id).await {
|
||||
Ok(p) => {
|
||||
let expired = (unix_epoch_timestamp() as usize) - p.created > p.expires;
|
||||
let expired = unix_epoch_timestamp() - p.created > p.expires;
|
||||
(
|
||||
p,
|
||||
self.get_pollvote_by_owner_poll(user.id, post.poll_id)
|
||||
|
@ -2022,7 +2022,7 @@ impl DataManager {
|
|||
}
|
||||
|
||||
// update context
|
||||
y.context.edited = unix_epoch_timestamp() as usize;
|
||||
y.context.edited = unix_epoch_timestamp();
|
||||
self.update_post_context(id, user, y.context).await?;
|
||||
|
||||
// return
|
||||
|
@ -2075,7 +2075,7 @@ impl DataManager {
|
|||
}
|
||||
|
||||
// update context
|
||||
y.context.edited = unix_epoch_timestamp() as usize;
|
||||
y.context.edited = unix_epoch_timestamp();
|
||||
self.update_post_context(id, user, y.context).await?;
|
||||
|
||||
// return
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
|
||||
use crate::model::oauth::AppScope;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppQuota {
|
||||
|
@ -33,6 +34,37 @@ pub struct ThirdPartyApp {
|
|||
/// 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.
|
||||
///
|
||||
/// The developer dashboard lists the URL you should send users to in order to
|
||||
/// create a grant on their account in the information section under the label
|
||||
/// "Grant URL".
|
||||
///
|
||||
/// Any search parameters sent with your grant URL (such as an internal user ID)
|
||||
/// will also be sent back when the user is redirected to your redirect URL.
|
||||
///
|
||||
/// You can use this behaviour to keep track of what user you should save the grant
|
||||
/// token under.
|
||||
///
|
||||
/// 1. Redirect user to grant URL with their ID: `{grant_url}?my_app_user_id={id}`
|
||||
/// 2. In your redirect endpoint, read that ID and the added `token` parameter to
|
||||
/// store the `token` under the given `my_app_user_id`
|
||||
///
|
||||
/// The redirect URL will also have a `verifier` search parameter appended.
|
||||
/// This verifier is required to refresh the grant's token (which is what is
|
||||
/// used in the `Atto-Grant` cookie).
|
||||
///
|
||||
/// Tokens only last a week after they were generated (with the verifier),
|
||||
/// but you can refresh them by sending a request to:
|
||||
/// `{tetratto}/api/v1/auth/user/{user_id}/grants/{app_id}/refresh`.
|
||||
///
|
||||
/// Tetratto will generate the verifier and challenge for you. The challenge
|
||||
/// is an SHA-256 hashed + base64 url encoded version of the verifier. This means
|
||||
/// if the verifier doesn't match, it won't pass the challenge.
|
||||
///
|
||||
/// Requests to API endpoints using your grant token should be sent with a
|
||||
/// cookie (in the `Cookie` header) named `Atto-Grant`. This cookie should
|
||||
/// contain the token you received from either the initial connection,
|
||||
/// or a token refresh.
|
||||
pub redirect: String,
|
||||
/// The app's quota status, which determines how many grants the app is allowed to maintain.
|
||||
pub quota_status: AppQuota,
|
||||
|
@ -40,6 +72,15 @@ pub struct ThirdPartyApp {
|
|||
pub banned: bool,
|
||||
/// The number of accepted grants the app maintains.
|
||||
pub grants: usize,
|
||||
/// The scopes used for every grant the app maintains.
|
||||
///
|
||||
/// These scopes are only cloned into **new** grants created for the app.
|
||||
/// An app *cannot* change scopes and have them affect users who already have the
|
||||
/// app connected. Users must delete the app's grant and authenticate it again
|
||||
/// to update their scopes.
|
||||
///
|
||||
/// Your app should handle informing users when scopes change.
|
||||
pub scopes: Vec<AppScope>,
|
||||
}
|
||||
|
||||
impl ThirdPartyApp {
|
||||
|
@ -47,7 +88,7 @@ impl 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,
|
||||
created: unix_epoch_timestamp(),
|
||||
owner,
|
||||
title,
|
||||
homepage,
|
||||
|
@ -55,6 +96,7 @@ impl ThirdPartyApp {
|
|||
quota_status: AppQuota::Limited,
|
||||
banned: false,
|
||||
grants: 0,
|
||||
scopes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -251,7 +251,7 @@ impl User {
|
|||
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
username,
|
||||
password,
|
||||
salt,
|
||||
|
@ -262,7 +262,7 @@ impl User {
|
|||
notification_count: 0,
|
||||
follower_count: 0,
|
||||
following_count: 0,
|
||||
last_seen: unix_epoch_timestamp() as usize,
|
||||
last_seen: unix_epoch_timestamp(),
|
||||
totp: String::new(),
|
||||
recovery_codes: Vec::new(),
|
||||
post_count: 0,
|
||||
|
@ -312,7 +312,7 @@ impl User {
|
|||
(
|
||||
ip.to_string(),
|
||||
tetratto_shared::hash::hash(unhashed),
|
||||
unix_epoch_timestamp() as usize,
|
||||
unix_epoch_timestamp(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
@ -460,7 +460,7 @@ impl Notification {
|
|||
pub fn new(title: String, content: String, owner: usize) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
title,
|
||||
content,
|
||||
owner,
|
||||
|
@ -483,7 +483,7 @@ impl UserFollow {
|
|||
pub fn new(initiator: usize, receiver: usize) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
initiator,
|
||||
receiver,
|
||||
}
|
||||
|
@ -511,7 +511,7 @@ impl UserBlock {
|
|||
pub fn new(initiator: usize, receiver: usize) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
initiator,
|
||||
receiver,
|
||||
}
|
||||
|
@ -531,7 +531,7 @@ impl IpBlock {
|
|||
pub fn new(initiator: usize, receiver: String) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
initiator,
|
||||
receiver,
|
||||
}
|
||||
|
@ -551,7 +551,7 @@ impl IpBan {
|
|||
pub fn new(ip: String, moderator: usize, reason: String) -> Self {
|
||||
Self {
|
||||
ip,
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
reason,
|
||||
moderator,
|
||||
}
|
||||
|
@ -572,7 +572,7 @@ impl UserWarning {
|
|||
pub fn new(user: usize, moderator: usize, content: String) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
receiver: user,
|
||||
moderator,
|
||||
content,
|
||||
|
|
|
@ -33,7 +33,7 @@ impl Channel {
|
|||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
community,
|
||||
owner,
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
minimum_role_read: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(),
|
||||
minimum_role_write: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(),
|
||||
position,
|
||||
|
@ -78,7 +78,7 @@ pub struct Message {
|
|||
|
||||
impl Message {
|
||||
pub fn new(channel: usize, owner: usize, content: String) -> Self {
|
||||
let now = unix_epoch_timestamp() as usize;
|
||||
let now = unix_epoch_timestamp();
|
||||
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
|
|
|
@ -33,7 +33,7 @@ impl Community {
|
|||
pub fn new(title: String, owner: usize) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
title: title.clone(),
|
||||
context: CommunityContext {
|
||||
display_name: title,
|
||||
|
@ -157,7 +157,7 @@ impl CommunityMembership {
|
|||
pub fn new(owner: usize, community: usize, role: CommunityPermission) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
owner,
|
||||
community,
|
||||
role,
|
||||
|
@ -273,7 +273,7 @@ impl Post {
|
|||
) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
content,
|
||||
owner,
|
||||
community,
|
||||
|
@ -353,7 +353,7 @@ impl Question {
|
|||
) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
owner,
|
||||
receiver,
|
||||
content,
|
||||
|
@ -387,7 +387,7 @@ impl PostDraft {
|
|||
pub fn new(content: String, owner: usize) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
content,
|
||||
owner,
|
||||
}
|
||||
|
@ -426,7 +426,7 @@ impl Poll {
|
|||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
owner,
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
expires,
|
||||
// options
|
||||
option_a,
|
||||
|
@ -491,7 +491,7 @@ impl PollVote {
|
|||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
owner,
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
poll_id,
|
||||
vote,
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ use serde::{
|
|||
};
|
||||
|
||||
bitflags! {
|
||||
/// Fine-grained journal permissions built using bitwise operations.
|
||||
/// Fine-grained community permissions built using bitwise operations.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct CommunityPermission: u32 {
|
||||
const DEFAULT = 1 << 0;
|
||||
|
|
|
@ -16,7 +16,7 @@ impl AuditLogEntry {
|
|||
pub fn new(moderator: usize, content: String) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
moderator,
|
||||
content,
|
||||
}
|
||||
|
@ -38,7 +38,7 @@ impl Report {
|
|||
pub fn new(owner: usize, content: String, asset: usize, asset_type: AssetType) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
owner,
|
||||
content,
|
||||
asset,
|
||||
|
|
|
@ -5,7 +5,6 @@ use super::{Result, Error};
|
|||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AuthGrant {
|
||||
pub id: usize,
|
||||
/// The ID of the application associated with this grant.
|
||||
pub app: usize,
|
||||
/// The code challenge for PKCE verifiers associated with this grant.
|
||||
|
@ -22,6 +21,9 @@ pub struct AuthGrant {
|
|||
/// The access token associated with the account. This is **not** the same as
|
||||
/// regular account access tokens, as the token can only be used with the requested `scopes`.
|
||||
pub token: String,
|
||||
/// The time in which the token was last refreshed. Tokens should stop being
|
||||
/// accepted after a week has passed since this time.
|
||||
pub last_updated: usize,
|
||||
/// Scopes define what the grant's token is actually allowed to do.
|
||||
///
|
||||
/// No scope shall ever be allowed to change scopes or manage grants on behalf of the user.
|
||||
|
|
|
@ -29,7 +29,7 @@ impl Reaction {
|
|||
pub fn new(owner: usize, asset: usize, asset_type: AssetType, is_like: bool) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
owner,
|
||||
asset,
|
||||
asset_type,
|
||||
|
|
|
@ -33,7 +33,7 @@ impl ActionRequest {
|
|||
pub fn new(owner: usize, action_type: ActionType, linked_asset: usize) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
owner,
|
||||
action_type,
|
||||
linked_asset,
|
||||
|
@ -44,7 +44,7 @@ impl ActionRequest {
|
|||
pub fn with_id(id: usize, owner: usize, action_type: ActionType, linked_asset: usize) -> Self {
|
||||
Self {
|
||||
id,
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
owner,
|
||||
action_type,
|
||||
linked_asset,
|
||||
|
|
|
@ -60,7 +60,7 @@ impl UserStack {
|
|||
pub fn new(name: String, owner: usize, users: Vec<usize>) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
owner,
|
||||
name,
|
||||
users,
|
||||
|
|
|
@ -48,7 +48,7 @@ impl MediaUpload {
|
|||
pub fn new(what: MediaType, owner: usize) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
owner,
|
||||
what,
|
||||
}
|
||||
|
@ -108,7 +108,7 @@ impl CustomEmoji {
|
|||
) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp() as usize,
|
||||
created: unix_epoch_timestamp(),
|
||||
owner,
|
||||
community,
|
||||
upload_id,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto-l10n"
|
||||
version = "6.0.0"
|
||||
version = "7.0.0"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
|
|
@ -59,7 +59,7 @@ impl LangFile {
|
|||
}
|
||||
}
|
||||
|
||||
/// Read the `langs` directory and return a [`Hashmap`] containing all files
|
||||
/// Read the `langs` directory and return a Hashmap containing all files
|
||||
pub fn read_langs() -> HashMap<String, LangFile> {
|
||||
let mut out = HashMap::new();
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto-shared"
|
||||
version = "6.0.0"
|
||||
version = "7.0.0"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
|
|
@ -18,7 +18,7 @@ impl Snowflake {
|
|||
Builder::new().epoch(UNIX_EPOCH + Duration::from_millis(EPOCH_2024))
|
||||
}
|
||||
|
||||
/// Create a new [`AlmostSnowflake`]
|
||||
/// Create a new [`Snowflake`]
|
||||
pub fn new() -> Self {
|
||||
Self(
|
||||
Self::builder()
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
use chrono::{TimeZone, Utc};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Get a [`u128`] timestamp
|
||||
pub fn unix_epoch_timestamp() -> u128 {
|
||||
/// Get a [`usize`] timestamp
|
||||
pub fn unix_epoch_timestamp() -> usize {
|
||||
let right_now = SystemTime::now();
|
||||
let time_since = right_now
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time travel is not allowed");
|
||||
|
||||
time_since.as_millis()
|
||||
time_since.as_millis() as usize
|
||||
}
|
||||
|
||||
/// Get a [`i64`] timestamp from the given `year` epoch
|
||||
|
|
2
sql_changes/apps_scopes.sql
Normal file
2
sql_changes/apps_scopes.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE apps
|
||||
ADD COLUMN scopes TEXT NOT NULL DEFAULT '[]';
|
Loading…
Add table
Add a link
Reference in a new issue