Compare commits
13 commits
Author | SHA1 | Date | |
---|---|---|---|
46e38042ce | |||
29155ddb0c | |||
a337e0c7c1 | |||
e78c43ab62 | |||
8786cb4781 | |||
9aed5de097 | |||
c757ddb77a | |||
46849ba66c | |||
fe2e61118a | |||
3f70a8f465 | |||
55460fc60a | |||
d58e47cbbe | |||
270d7550d6 |
39 changed files with 861 additions and 186 deletions
56
Cargo.lock
generated
56
Cargo.lock
generated
|
@ -955,6 +955,15 @@ dependencies = [
|
|||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.16"
|
||||
|
@ -1743,15 +1752,6 @@ version = "1.0.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb"
|
||||
dependencies = [
|
||||
"unicode-id",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.35.0"
|
||||
|
@ -2355,6 +2355,25 @@ dependencies = [
|
|||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"getopts",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
version = "0.4.1"
|
||||
|
@ -3301,7 +3320,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-core"
|
||||
version = "12.0.0"
|
||||
version = "12.0.2"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"base16ct",
|
||||
|
@ -3334,13 +3353,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-shared"
|
||||
version = "12.0.0"
|
||||
version = "12.0.6"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"chrono",
|
||||
"hex_fmt",
|
||||
"markdown",
|
||||
"pulldown-cmark",
|
||||
"rand 0.9.1",
|
||||
"regex",
|
||||
"serde",
|
||||
"sha2",
|
||||
"snowflaked",
|
||||
|
@ -3871,12 +3891,6 @@ version = "0.3.18"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-id"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10103c57044730945224467c09f71a4db0071c123a0648cc3e818913bde6b561"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
|
@ -3898,6 +3912,12 @@ version = "0.1.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
|
|
@ -4,15 +4,11 @@ use nanoneo::{
|
|||
};
|
||||
use pathbufd::PathBufD;
|
||||
use regex::Regex;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{exists, read_to_string, write},
|
||||
sync::LazyLock,
|
||||
time::SystemTime,
|
||||
};
|
||||
use std::{collections::HashMap, fs::read_to_string, sync::LazyLock, time::SystemTime};
|
||||
use tera::Context;
|
||||
use tetratto_core::{
|
||||
config::Config,
|
||||
html::{pull_icons, ICONS},
|
||||
model::{
|
||||
auth::{DefaultTimelineChoice, User},
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
|
@ -157,38 +153,6 @@ pub const TETRATTO_BUNNY: &[u8] = include_bytes!("./public/images/tetratto_bunny
|
|||
pub(crate) static HTML_FOOTER: LazyLock<RwLock<String>> =
|
||||
LazyLock::new(|| RwLock::new(String::new()));
|
||||
|
||||
/// A container for all loaded icons.
|
||||
pub(crate) static ICONS: LazyLock<RwLock<HashMap<String, String>>> =
|
||||
LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||
|
||||
/// Pull an icon given its name and insert it into [`ICONS`].
|
||||
pub(crate) async fn pull_icon(icon: &str, icons_dir: &str) {
|
||||
let writer = &mut ICONS.write().await;
|
||||
|
||||
let icon_url = format!(
|
||||
"https://raw.githubusercontent.com/lucide-icons/lucide/refs/heads/main/icons/{icon}.svg"
|
||||
);
|
||||
|
||||
let file_path = PathBufD::current().extend(&[icons_dir, &format!("{icon}.svg")]);
|
||||
|
||||
if exists(&file_path).unwrap() {
|
||||
writer.insert(icon.to_string(), read_to_string(&file_path).unwrap());
|
||||
return;
|
||||
}
|
||||
|
||||
println!("download icon: {icon}");
|
||||
let svg = reqwest::get(icon_url)
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
.replace("\n", "");
|
||||
|
||||
write(&file_path, &svg).unwrap();
|
||||
writer.insert(icon.to_string(), svg);
|
||||
}
|
||||
|
||||
macro_rules! vendor_icon {
|
||||
($name:literal, $icon:ident, $icons_dir:expr) => {{
|
||||
let writer = &mut ICONS.write().await;
|
||||
|
@ -261,56 +225,8 @@ pub(crate) async fn replace_in_html(
|
|||
input = input.replace(cap.get(0).unwrap().as_str(), &replace_with);
|
||||
}
|
||||
|
||||
// icon (with class)
|
||||
let icon_with_class =
|
||||
Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*c\\((.*?)\\)\\s*(\\}\\})").unwrap();
|
||||
|
||||
for cap in icon_with_class.captures_iter(&input.clone()) {
|
||||
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
|
||||
let icon = &(if cap_str.contains(" }}") {
|
||||
cap_str.split(" }}").next().unwrap().to_string()
|
||||
} else {
|
||||
cap_str.to_string()
|
||||
});
|
||||
|
||||
pull_icon(icon, &config.dirs.icons).await;
|
||||
|
||||
let reader = ICONS.read().await;
|
||||
let icon_text = reader.get(icon).unwrap().replace(
|
||||
"<svg",
|
||||
&format!("<svg class=\"icon {}\"", cap.get(4).unwrap().as_str()),
|
||||
);
|
||||
|
||||
input = input.replace(
|
||||
&format!(
|
||||
"{{{{ icon \"{cap_str}\" c({}) }}}}",
|
||||
cap.get(4).unwrap().as_str()
|
||||
),
|
||||
&icon_text,
|
||||
);
|
||||
}
|
||||
|
||||
// icon (without class)
|
||||
let icon_without_class = Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*(\\}\\})").unwrap();
|
||||
|
||||
for cap in icon_without_class.captures_iter(&input.clone()) {
|
||||
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
|
||||
let icon = &(if cap_str.contains(" }}") {
|
||||
cap_str.split(" }}").next().unwrap().to_string()
|
||||
} else {
|
||||
cap_str.to_string()
|
||||
});
|
||||
|
||||
pull_icon(icon, &config.dirs.icons).await;
|
||||
|
||||
let reader = ICONS.read().await;
|
||||
let icon_text = reader
|
||||
.get(icon)
|
||||
.unwrap()
|
||||
.replace("<svg", "<svg class=\"icon\"");
|
||||
|
||||
input = input.replace(&format!("{{{{ icon \"{cap_str}\" }}}}",), &icon_text);
|
||||
}
|
||||
// icons
|
||||
input = pull_icons(input, &config.dirs.icons).await;
|
||||
|
||||
// return
|
||||
input = input.replacen("<!-- html_footer_goes_here -->", &format!("{reader}"), 1);
|
||||
|
|
|
@ -258,6 +258,7 @@ version = "1.0.0"
|
|||
"developer:label.change_homepage" = "Change homepage"
|
||||
"developer:label.change_redirect" = "Change redirect URL"
|
||||
"developer:label.change_quota_status" = "Change quota status"
|
||||
"developer:label.change_storage_capacity" = "Change storage capacity"
|
||||
"developer:label.manage_scopes" = "Manage scopes"
|
||||
"developer:label.scopes" = "Scopes"
|
||||
"developer:label.guides_and_help" = "Guides & help"
|
||||
|
|
|
@ -36,12 +36,13 @@ pub(crate) type InnerState = (DataManager, Tera, Client, Option<StripeClient>);
|
|||
pub(crate) type State = Arc<RwLock<InnerState>>;
|
||||
|
||||
fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||
Ok(
|
||||
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(value.as_str().unwrap()))
|
||||
.replace("\\@", "@")
|
||||
.replace("%5C@", "@")
|
||||
.into(),
|
||||
Ok(tetratto_shared::markdown::render_markdown(
|
||||
&CustomEmoji::replace(value.as_str().unwrap()),
|
||||
true,
|
||||
)
|
||||
.replace("\\@", "@")
|
||||
.replace("%5C@", "@")
|
||||
.into())
|
||||
}
|
||||
|
||||
fn render_emojis(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||
|
|
|
@ -2415,7 +2415,7 @@
|
|||
(ul
|
||||
("style" "margin-bottom: var(--pad-4)")
|
||||
(li
|
||||
(text "Increased app storage limit (500 KB->5 MB)"))
|
||||
(text "Increased app storage limit (500 KB->25 MB)"))
|
||||
(li
|
||||
(text "Ability to create forges"))
|
||||
(li
|
||||
|
|
|
@ -44,6 +44,28 @@
|
|||
("value" "Unlimited")
|
||||
("selected" "{% if app.quota_status == 'Unlimited' -%}true{% else %}false{%- endif %}")
|
||||
(text "Unlimited")))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "database-zap"))
|
||||
(b (str (text "developer:label.change_storage_capacity"))))
|
||||
(div
|
||||
("class" "card")
|
||||
(select
|
||||
("onchange" "save_storage_capacity(event)")
|
||||
(option
|
||||
("value" "Tier1")
|
||||
("selected" "{% if app.storage_capacity == 'Tier1' -%}true{% else %}false{%- endif %}")
|
||||
(text "Tier 1 (25 MB)"))
|
||||
(option
|
||||
("value" "Tier2")
|
||||
("selected" "{% if app.storage_capacity == 'Tier2' -%}true{% else %}false{%- endif %}")
|
||||
(text "Tier 2 (50 MB)"))
|
||||
(option
|
||||
("value" "Tier3")
|
||||
("selected" "{% if app.storage_capacity == 'Tier3' -%}true{% else %}false{%- endif %}")
|
||||
(text "Tier 3 (100 MB)")))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
|
@ -232,6 +254,26 @@
|
|||
});
|
||||
};
|
||||
|
||||
globalThis.save_storage_capacity = (event) => {
|
||||
const selected = event.target.selectedOptions[0];
|
||||
fetch(\"/api/v1/apps/{{ app.id }}/storage_capacity\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
storage_capacity: selected.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.change_title = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
|
@ -406,6 +406,7 @@
|
|||
MANAGE_SERVICES: 1 << 3,
|
||||
MANAGE_PRODUCTS: 1 << 4,
|
||||
DEVELOPER_PASS: 1 << 5,
|
||||
MANAGE_LETTERS: 1 << 6,
|
||||
},
|
||||
\"secondary_role\",
|
||||
\"add_permission_to_secondary_role\",
|
||||
|
|
|
@ -107,6 +107,7 @@
|
|||
(p
|
||||
(text "{{ profile.settings.status }}"))
|
||||
(text "{%- endif %}")
|
||||
(text "{% if not profile.settings.hide_social_follows or (user and user.id == profile.id) -%}")
|
||||
(div
|
||||
("class" "w-full flex")
|
||||
(a
|
||||
|
@ -123,6 +124,7 @@
|
|||
(text "{{ profile.following_count }}"))
|
||||
(span
|
||||
(text "{{ text \"auth:label.following\" }}"))))
|
||||
(text "{%- endif %}")
|
||||
(text "{% if is_following_you -%}")
|
||||
(b
|
||||
("class" "notification chip w-content flex items-center gap-2")
|
||||
|
|
|
@ -1889,6 +1889,14 @@
|
|||
\"{{ profile.settings.hide_from_social_lists }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[
|
||||
\"hide_social_follows\",
|
||||
\"Hide followers/following links on my profile\",
|
||||
],
|
||||
\"{{ profile.settings.hide_social_follows }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[[], \"Questions\", \"title\"],
|
||||
[
|
||||
[
|
||||
|
|
|
@ -72,6 +72,25 @@ export default function tetratto({
|
|||
);
|
||||
}
|
||||
|
||||
async function check_ip(ip) {
|
||||
if (!api_key) {
|
||||
throw Error("No API key provided.");
|
||||
}
|
||||
|
||||
return api_promise(
|
||||
json_parse(
|
||||
await (
|
||||
await fetch(`${host}/api/v1/bans/${ip}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Atto-Secret-Key": api_key,
|
||||
},
|
||||
})
|
||||
).text(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function query(body) {
|
||||
if (!api_key) {
|
||||
throw Error("No API key provided.");
|
||||
|
@ -285,6 +304,7 @@ export default function tetratto({
|
|||
api_key,
|
||||
// app data
|
||||
app,
|
||||
check_ip,
|
||||
query,
|
||||
insert,
|
||||
update,
|
||||
|
|
|
@ -72,7 +72,7 @@ pub async fn create_request(
|
|||
|
||||
// check size
|
||||
let new_size = app.data_used + req.value.len();
|
||||
if new_size > AppData::user_limit(&owner) {
|
||||
if new_size > AppData::user_limit(&owner, &app) {
|
||||
return Json(Error::AppHitStorageLimit.into());
|
||||
}
|
||||
|
||||
|
@ -155,12 +155,19 @@ pub async fn update_value_request(
|
|||
let size_without = app.data_used - app_data.value.len();
|
||||
let new_size = size_without + req.value.len();
|
||||
|
||||
if new_size > AppData::user_limit(&owner) {
|
||||
if new_size > AppData::user_limit(&owner, &app) {
|
||||
return Json(Error::AppHitStorageLimit.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
if let Err(e) = data.add_app_data_used(app.id, req.value.len() as i32).await {
|
||||
// we only need to add the delta size (the next size - the old size)
|
||||
if let Err(e) = data
|
||||
.add_app_data_used(
|
||||
app.id,
|
||||
(req.value.len() as i32) - (app_data.value.len() as i32),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ use tetratto_core::model::{
|
|||
ApiReturn, Error,
|
||||
};
|
||||
use tetratto_shared::{hash::random_id, unix_epoch_timestamp};
|
||||
use super::CreateApp;
|
||||
use super::{CreateApp, UpdateAppStorageCapacity};
|
||||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
|
@ -138,6 +138,35 @@ pub async fn update_quota_status_request(
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn update_storage_capacity_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateAppStorageCapacity>,
|
||||
) -> 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_storage_capacity(id, req.storage_capacity)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "App updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_scopes_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
|
|
|
@ -1,12 +1,34 @@
|
|||
use crate::{
|
||||
State, get_user_from_token,
|
||||
get_app_from_key, get_user_from_token,
|
||||
model::{ApiReturn, Error},
|
||||
routes::api::v1::CreateIpBan,
|
||||
State,
|
||||
};
|
||||
use axum::{Extension, Json, extract::Path, response::IntoResponse};
|
||||
use axum::{extract::Path, http::HeaderMap, response::IntoResponse, Extension, Json};
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{addr::RemoteAddr, auth::IpBan, permissions::FinePermission};
|
||||
|
||||
/// Check if the given IP is banned.
|
||||
pub async fn check_request(
|
||||
headers: HeaderMap,
|
||||
Path(ip): Path<String>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
if get_app_from_key!(data, headers).is_none() {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: data
|
||||
.get_ipban_by_addr(&RemoteAddr::from(ip.as_str()))
|
||||
.await
|
||||
.is_ok(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new IP ban.
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
|
|
|
@ -20,9 +20,9 @@ use axum::{
|
|||
routing::{any, delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize};
|
||||
use serde::Deserialize;
|
||||
use tetratto_core::model::{
|
||||
apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota},
|
||||
apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota},
|
||||
auth::AchievementName,
|
||||
communities::{
|
||||
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
|
||||
|
@ -432,6 +432,10 @@ pub fn routes() -> Router {
|
|||
"/apps/{id}/quota_status",
|
||||
post(apps::update_quota_status_request),
|
||||
)
|
||||
.route(
|
||||
"/apps/{id}/storage_capacity",
|
||||
post(apps::update_storage_capacity_request),
|
||||
)
|
||||
.route("/apps/{id}/scopes", post(apps::update_scopes_request))
|
||||
.route("/apps/{id}/grant", post(apps::grant_request))
|
||||
.route("/apps/{id}/roll", post(apps::roll_api_key_request))
|
||||
|
@ -491,6 +495,7 @@ pub fn routes() -> Router {
|
|||
post(communities::communities::update_membership_role),
|
||||
)
|
||||
// ipbans
|
||||
.route("/bans/{ip}", get(auth::ipbans::check_request))
|
||||
.route("/bans/{ip}", post(auth::ipbans::create_request))
|
||||
.route("/bans/{ip}", delete(auth::ipbans::delete_request))
|
||||
// reports
|
||||
|
@ -1031,6 +1036,11 @@ pub struct UpdateAppQuotaStatus {
|
|||
pub quota_status: AppQuota,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAppStorageCapacity {
|
||||
pub storage_capacity: DeveloperPassStorageQuota,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAppScopes {
|
||||
pub scopes: Vec<AppScope>,
|
||||
|
|
|
@ -267,7 +267,7 @@ pub async fn delete_by_dir_request(
|
|||
}
|
||||
|
||||
pub async fn render_markdown_request(Json(req): Json<RenderMarkdown>) -> impl IntoResponse {
|
||||
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content))
|
||||
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content), true)
|
||||
.replace("\\@", "@")
|
||||
.replace("%5C@", "@")
|
||||
}
|
||||
|
|
|
@ -62,7 +62,7 @@ pub async fn app_request(
|
|||
));
|
||||
}
|
||||
|
||||
let data_limit = AppData::user_limit(&user);
|
||||
let data_limit = AppData::user_limit(&user, &app);
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
|
|
@ -731,6 +731,21 @@ pub async fn following_request(
|
|||
|
||||
check_user_blocked_or_private!(user, other_user, data, jar);
|
||||
|
||||
// check hide_social_follows
|
||||
if other_user.settings.hide_social_follows {
|
||||
if let Some(ref ua) = user {
|
||||
if ua.id != other_user.id {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// fetch data
|
||||
let list = match data
|
||||
.0
|
||||
|
@ -826,6 +841,21 @@ pub async fn followers_request(
|
|||
|
||||
check_user_blocked_or_private!(user, other_user, data, jar);
|
||||
|
||||
// check hide_social_follows
|
||||
if other_user.settings.hide_social_follows {
|
||||
if let Some(ref ua) = user {
|
||||
if ua.id != other_user.id {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// fetch data
|
||||
let list = match data
|
||||
.0
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
[package]
|
||||
name = "tetratto-core"
|
||||
description = "The core behind Tetratto"
|
||||
version = "12.0.0"
|
||||
version = "12.0.2"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
@ -18,7 +18,7 @@ default = ["database", "types", "sdk"]
|
|||
pathbufd = "0.1.4"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
toml = "0.9.2"
|
||||
tetratto-shared = { version = "12.0.0", path = "../shared" }
|
||||
tetratto-shared = { version = "12.0.6", path = "../shared" }
|
||||
tetratto-l10n = { version = "12.0.0", path = "../l10n" }
|
||||
serde_json = "1.0.141"
|
||||
totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true }
|
||||
|
@ -35,6 +35,4 @@ oiseau = { version = "0.1.2", default-features = false, features = [
|
|||
"redis",
|
||||
], optional = true }
|
||||
paste = { version = "1.0.15", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] }
|
||||
|
|
|
@ -5,7 +5,7 @@ use crate::{auto_method, DataManager};
|
|||
use oiseau::{PostgresRow, execute, get, query_row, query_rows, params};
|
||||
|
||||
pub const FREE_DATA_LIMIT: usize = 512_000;
|
||||
pub const PASS_DATA_LIMIT: usize = 5_242_880;
|
||||
pub const PASS_DATA_LIMIT: usize = 26_214_400;
|
||||
|
||||
impl DataManager {
|
||||
/// Get a [`AppData`] from an SQL row.
|
||||
|
@ -117,13 +117,13 @@ impl DataManager {
|
|||
let app = self.get_app_by_id(data.app).await?;
|
||||
|
||||
// check values
|
||||
if data.key.len() < 2 {
|
||||
if data.key.len() < 1 {
|
||||
return Err(Error::DataTooShort("key".to_string()));
|
||||
} else if data.key.len() > 32 {
|
||||
} else if data.key.len() > 128 {
|
||||
return Err(Error::DataTooLong("key".to_string()));
|
||||
}
|
||||
|
||||
if data.value.len() < 2 {
|
||||
if data.value.len() < 1 {
|
||||
return Err(Error::DataTooShort("value".to_string()));
|
||||
} else if data.value.len() > Self::MAXIMUM_DATA_SIZE {
|
||||
return Err(Error::DataTooLong("value".to_string()));
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use oiseau::cache::Cache;
|
||||
use crate::model::{
|
||||
apps::{AppQuota, ThirdPartyApp},
|
||||
apps::{AppQuota, ThirdPartyApp, DeveloperPassStorageQuota},
|
||||
auth::User,
|
||||
oauth::AppScope,
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
|
@ -25,6 +25,7 @@ impl DataManager {
|
|||
scopes: serde_json::from_str(&get!(x->9(String))).unwrap(),
|
||||
api_key: get!(x->10(String)),
|
||||
data_used: get!(x->11(i32)) as usize,
|
||||
storage_capacity: serde_json::from_str(&get!(x->12(String))).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -95,7 +96,7 @@ impl DataManager {
|
|||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
|
||||
"INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
|
@ -108,7 +109,8 @@ impl DataManager {
|
|||
&(data.grants as i32),
|
||||
&serde_json::to_string(&data.scopes).unwrap(),
|
||||
&data.api_key,
|
||||
&(data.data_used as i32)
|
||||
&(data.data_used as i32),
|
||||
&serde_json::to_string(&data.storage_capacity).unwrap(),
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -167,6 +169,7 @@ impl DataManager {
|
|||
auto_method!(update_app_quota_status(AppQuota)@get_app_by_id -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_app);
|
||||
auto_method!(update_app_scopes(Vec<AppScope>)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_app);
|
||||
auto_method!(update_app_api_key(&str)@get_app_by_id -> "UPDATE apps SET api_key = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app);
|
||||
auto_method!(update_app_storage_capacity(DeveloperPassStorageQuota)@get_app_by_id -> "UPDATE apps SET storage_capacity = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_app);
|
||||
|
||||
auto_method!(update_app_data_used(i32)@get_app_by_id -> "UPDATE apps SET data_used = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app);
|
||||
auto_method!(add_app_data_used(i32)@get_app_by_id -> "UPDATE apps SET data_used = data_used + $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app);
|
||||
|
|
|
@ -100,14 +100,21 @@ impl DataManager {
|
|||
tokens: serde_json::from_str(&get!(x->6(String)).to_string()).unwrap(),
|
||||
permissions: FinePermission::from_bits(get!(x->7(i32)) as u32).unwrap(),
|
||||
is_verified: get!(x->8(i32)) as i8 == 1,
|
||||
notification_count: get!(x->9(i32)) as usize,
|
||||
notification_count: {
|
||||
let x = get!(x->9(i32)) as usize;
|
||||
// we're a little too close to the maximum count, clearly something's gone wrong
|
||||
if x > usize::MAX - 1000 { 0 } else { x }
|
||||
},
|
||||
follower_count: get!(x->10(i32)) as usize,
|
||||
following_count: get!(x->11(i32)) as usize,
|
||||
last_seen: get!(x->12(i64)) as usize,
|
||||
totp: get!(x->13(String)),
|
||||
recovery_codes: serde_json::from_str(&get!(x->14(String)).to_string()).unwrap(),
|
||||
post_count: get!(x->15(i32)) as usize,
|
||||
request_count: get!(x->16(i32)) as usize,
|
||||
request_count: {
|
||||
let x = get!(x->16(i32)) as usize;
|
||||
if x > usize::MAX - 1000 { 0 } else { x }
|
||||
},
|
||||
connections: serde_json::from_str(&get!(x->17(String)).to_string()).unwrap(),
|
||||
stripe_id: get!(x->18(String)),
|
||||
grants: serde_json::from_str(&get!(x->19(String)).to_string()).unwrap(),
|
||||
|
|
|
@ -44,6 +44,7 @@ impl DataManager {
|
|||
execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_LETTERS).unwrap();
|
||||
|
||||
for x in common::VERSION_MIGRATIONS.split(";") {
|
||||
execute!(&conn, x).unwrap();
|
||||
|
|
|
@ -32,3 +32,4 @@ pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql");
|
|||
pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql");
|
||||
pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql");
|
||||
pub const CREATE_TABLE_APP_DATA: &str = include_str!("./sql/create_app_data.sql");
|
||||
pub const CREATE_TABLE_LETTERS: &str = include_str!("./sql/create_letters.sql");
|
||||
|
|
|
@ -9,5 +9,6 @@ CREATE TABLE IF NOT EXISTS apps (
|
|||
banned INT NOT NULL,
|
||||
grants INT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
data_used INT NOT NULL CHECK (data_used >= 0)
|
||||
data_used INT NOT NULL CHECK (data_used >= 0),
|
||||
storage_capacity TEXT NOT NULL
|
||||
)
|
||||
|
|
9
crates/core/src/database/drivers/sql/create_letters.sql
Normal file
9
crates/core/src/database/drivers/sql/create_letters.sql
Normal file
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE IF NOT EXISTS letters (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
created BIGINT NOT NULL,
|
||||
owner BIGINT NOT NULL,
|
||||
receivers TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
read_by TEXT NOT NULL
|
||||
)
|
|
@ -5,3 +5,11 @@ ADD COLUMN IF NOT EXISTS channel_mutes TEXT DEFAULT '[]';
|
|||
-- users is_deactivated
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS is_deactivated INT DEFAULT 0;
|
||||
|
||||
-- apps storage_capacity
|
||||
ALTER TABLE apps
|
||||
ADD COLUMN IF NOT EXISTS storage_capacity TEXT DEFAULT '"Tier1"';
|
||||
|
||||
-- letters replying_to
|
||||
ALTER TABLE letters
|
||||
ADD COLUMN IF NOT EXISTS replying_to TEXT DEFAULT 0;
|
||||
|
|
170
crates/core/src/database/letters.rs
Normal file
170
crates/core/src/database/letters.rs
Normal file
|
@ -0,0 +1,170 @@
|
|||
use crate::model::{auth::User, mail::Letter, permissions::SecondaryPermission, Error, Result};
|
||||
use crate::{auto_method, DataManager};
|
||||
use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow};
|
||||
|
||||
impl DataManager {
|
||||
/// Get a [`Letter`] from an SQL row.
|
||||
pub(crate) fn get_letter_from_row(x: &PostgresRow) -> Letter {
|
||||
Letter {
|
||||
id: get!(x->0(i64)) as usize,
|
||||
created: get!(x->1(i64)) as usize,
|
||||
owner: get!(x->2(i64)) as usize,
|
||||
receivers: serde_json::from_str(&get!(x->3(String))).unwrap(),
|
||||
subject: get!(x->4(String)),
|
||||
content: get!(x->5(String)),
|
||||
read_by: serde_json::from_str(&get!(x->6(String))).unwrap(),
|
||||
replying_to: get!(x->7(i32)) as usize,
|
||||
}
|
||||
}
|
||||
|
||||
auto_method!(get_letter_by_id(usize as i64)@get_letter_from_row -> "SELECT * FROM letters WHERE id = $1" --name="letter" --returns=Letter --cache-key-tmpl="atto.letter:{}");
|
||||
|
||||
/// Get all letters by user.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the user to fetch letters for
|
||||
pub async fn get_letters_by_user(&self, id: usize) -> Result<Vec<Letter>> {
|
||||
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 letters WHERE owner = $1 ORDER BY created DESC",
|
||||
&[&(id as i64)],
|
||||
|x| { Self::get_letter_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("letter".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get all letters by user (where user is a receiver).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the user to fetch letters for
|
||||
pub async fn get_received_letters_by_user(&self, id: usize) -> Result<Vec<Letter>> {
|
||||
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 letters WHERE receivers LIKE $1 ORDER BY created DESC",
|
||||
&[&format!("%\"{id}\"%")],
|
||||
|x| { Self::get_letter_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("letter".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get all letters which are replying to the given letter.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the letter to fetch letters for
|
||||
pub async fn get_letters_by_replying_to(&self, id: usize) -> Result<Vec<Letter>> {
|
||||
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 letters WHERE replying_to = $1 ORDER BY created DESC",
|
||||
&[&(id as i64)],
|
||||
|x| { Self::get_letter_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("letter".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Create a new letter in the database.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - a mock [`Letter`] object to insert
|
||||
pub async fn create_letter(&self, data: Letter) -> Result<Letter> {
|
||||
// check values
|
||||
if data.subject.len() < 2 {
|
||||
return Err(Error::DataTooShort("subject".to_string()));
|
||||
} else if data.subject.len() > 256 {
|
||||
return Err(Error::DataTooLong("subject".to_string()));
|
||||
}
|
||||
|
||||
if data.content.len() < 2 {
|
||||
return Err(Error::DataTooShort("content".to_string()));
|
||||
} else if data.content.len() > 16384 {
|
||||
return Err(Error::DataTooLong("content".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 letters VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
&(data.owner as i64),
|
||||
&serde_json::to_string(&data.receivers).unwrap(),
|
||||
&data.subject,
|
||||
&data.content,
|
||||
&serde_json::to_string(&data.read_by).unwrap(),
|
||||
&(data.replying_to as i64)
|
||||
]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub async fn delete_letter(&self, id: usize, user: &User) -> Result<()> {
|
||||
let letter = self.get_letter_by_id(id).await?;
|
||||
|
||||
// check user permission
|
||||
if user.id != letter.owner
|
||||
&& !user
|
||||
.secondary_permissions
|
||||
.check(SecondaryPermission::MANAGE_LETTERS)
|
||||
{
|
||||
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 letters WHERE id = $1", &[&(id as i64)]);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// ...
|
||||
self.0.1.remove(format!("atto.letter:{}", id)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
auto_method!(update_letter_read_by(Vec<usize>) -> "UPDATE letters SET read_by = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.letter:{}");
|
||||
}
|
|
@ -14,6 +14,7 @@ mod invite_codes;
|
|||
mod ipbans;
|
||||
mod ipblocks;
|
||||
mod journals;
|
||||
mod letters;
|
||||
mod memberships;
|
||||
mod message_reactions;
|
||||
mod messages;
|
||||
|
|
|
@ -408,6 +408,10 @@ impl DataManager {
|
|||
|
||||
// check muted phrases
|
||||
for phrase in receiver.settings.muted {
|
||||
if phrase.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if data.content.contains(&phrase) {
|
||||
// act like the question was created so theyre less likely to try and send it again or bypass
|
||||
return Ok(0);
|
||||
|
|
97
crates/core/src/html.rs
Normal file
97
crates/core/src/html.rs
Normal file
|
@ -0,0 +1,97 @@
|
|||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{exists, read_to_string, write},
|
||||
sync::LazyLock,
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use pathbufd::PathBufD;
|
||||
|
||||
/// A container for all loaded icons.
|
||||
pub static ICONS: LazyLock<RwLock<HashMap<String, String>>> =
|
||||
LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||
|
||||
/// Pull an icon given its name and insert it into [`ICONS`].
|
||||
pub async fn pull_icon(icon: &str, icons_dir: &str) {
|
||||
let writer = &mut ICONS.write().await;
|
||||
|
||||
let icon_url = format!(
|
||||
"https://raw.githubusercontent.com/lucide-icons/lucide/refs/heads/main/icons/{icon}.svg"
|
||||
);
|
||||
|
||||
let file_path = PathBufD::current().extend(&[icons_dir, &format!("{icon}.svg")]);
|
||||
|
||||
if exists(&file_path).unwrap_or(false) {
|
||||
writer.insert(icon.to_string(), read_to_string(&file_path).unwrap());
|
||||
return;
|
||||
}
|
||||
|
||||
println!("download icon: {icon}");
|
||||
let svg = reqwest::get(icon_url)
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
.replace("\n", "");
|
||||
|
||||
write(&file_path, &svg).unwrap();
|
||||
writer.insert(icon.to_string(), svg);
|
||||
}
|
||||
|
||||
/// Read a string and pull all icons found within it.
|
||||
pub async fn pull_icons(mut input: String, icon_dir: &str) -> String {
|
||||
// icon (with class)
|
||||
let icon_with_class =
|
||||
regex::Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*c\\((.*?)\\)\\s*(\\}\\})").unwrap();
|
||||
|
||||
for cap in icon_with_class.captures_iter(&input.clone()) {
|
||||
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
|
||||
let icon = &(if cap_str.contains(" }}") {
|
||||
cap_str.split(" }}").next().unwrap().to_string()
|
||||
} else {
|
||||
cap_str.to_string()
|
||||
});
|
||||
|
||||
pull_icon(icon, icon_dir).await;
|
||||
|
||||
let reader = ICONS.read().await;
|
||||
let icon_text = reader.get(icon).unwrap().replace(
|
||||
"<svg",
|
||||
&format!("<svg class=\"icon {}\"", cap.get(4).unwrap().as_str()),
|
||||
);
|
||||
|
||||
input = input.replace(
|
||||
&format!(
|
||||
"{{{{ icon \"{cap_str}\" c({}) }}}}",
|
||||
cap.get(4).unwrap().as_str()
|
||||
),
|
||||
&icon_text,
|
||||
);
|
||||
}
|
||||
|
||||
// icon (without class)
|
||||
let icon_without_class = regex::Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*(\\}\\})").unwrap();
|
||||
|
||||
for cap in icon_without_class.captures_iter(&input.clone()) {
|
||||
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
|
||||
let icon = &(if cap_str.contains(" }}") {
|
||||
cap_str.split(" }}").next().unwrap().to_string()
|
||||
} else {
|
||||
cap_str.to_string()
|
||||
});
|
||||
|
||||
pull_icon(icon, icon_dir).await;
|
||||
|
||||
let reader = ICONS.read().await;
|
||||
let icon_text = reader
|
||||
.get(icon)
|
||||
.unwrap()
|
||||
.replace("<svg", "<svg class=\"icon\"");
|
||||
|
||||
input = input.replace(&format!("{{{{ icon \"{cap_str}\" }}}}",), &icon_text);
|
||||
}
|
||||
|
||||
// ...
|
||||
input
|
||||
}
|
|
@ -3,6 +3,8 @@ pub mod config;
|
|||
#[cfg(feature = "database")]
|
||||
pub mod database;
|
||||
#[cfg(feature = "types")]
|
||||
pub mod html;
|
||||
#[cfg(feature = "types")]
|
||||
pub mod model;
|
||||
#[cfg(feature = "sdk")]
|
||||
pub mod sdk;
|
||||
|
|
|
@ -21,6 +21,35 @@ impl Default for AppQuota {
|
|||
}
|
||||
}
|
||||
|
||||
/// The storage limit for apps where the owner has a developer pass.
|
||||
///
|
||||
/// Free users are always limited to 500 KB.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DeveloperPassStorageQuota {
|
||||
/// The app is limited to 25 MB.
|
||||
Tier1,
|
||||
/// The app is limited to 50 MB.
|
||||
Tier2,
|
||||
/// The app is limited to 100 MB.
|
||||
Tier3,
|
||||
}
|
||||
|
||||
impl Default for DeveloperPassStorageQuota {
|
||||
fn default() -> Self {
|
||||
Self::Tier1
|
||||
}
|
||||
}
|
||||
|
||||
impl DeveloperPassStorageQuota {
|
||||
pub fn limit(&self) -> usize {
|
||||
match self {
|
||||
DeveloperPassStorageQuota::Tier1 => 26214400,
|
||||
DeveloperPassStorageQuota::Tier2 => 52428800,
|
||||
DeveloperPassStorageQuota::Tier3 => 104857600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An app is required to request grants on user accounts.
|
||||
///
|
||||
/// Users must approve grants through a web portal.
|
||||
|
@ -90,6 +119,8 @@ pub struct ThirdPartyApp {
|
|||
pub api_key: String,
|
||||
/// The number of bytes the app's app_data rows are using.
|
||||
pub data_used: usize,
|
||||
/// The app's storage capacity.
|
||||
pub storage_capacity: DeveloperPassStorageQuota,
|
||||
}
|
||||
|
||||
impl ThirdPartyApp {
|
||||
|
@ -102,12 +133,13 @@ impl ThirdPartyApp {
|
|||
title,
|
||||
homepage,
|
||||
redirect,
|
||||
quota_status: AppQuota::Limited,
|
||||
quota_status: AppQuota::default(),
|
||||
banned: false,
|
||||
grants: 0,
|
||||
scopes: Vec::new(),
|
||||
api_key: String::new(),
|
||||
data_used: 0,
|
||||
storage_capacity: DeveloperPassStorageQuota::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -132,12 +164,16 @@ impl AppData {
|
|||
}
|
||||
|
||||
/// Get the data limit of a given user.
|
||||
pub fn user_limit(user: &User) -> usize {
|
||||
pub fn user_limit(user: &User, app: &ThirdPartyApp) -> usize {
|
||||
if user
|
||||
.secondary_permissions
|
||||
.check(SecondaryPermission::DEVELOPER_PASS)
|
||||
{
|
||||
PASS_DATA_LIMIT
|
||||
if app.storage_capacity != DeveloperPassStorageQuota::Tier1 {
|
||||
app.storage_capacity.limit()
|
||||
} else {
|
||||
PASS_DATA_LIMIT
|
||||
}
|
||||
} else {
|
||||
FREE_DATA_LIMIT
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use super::{
|
||||
oauth::AuthGrant,
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
|
@ -338,6 +337,10 @@ pub struct UserSettings {
|
|||
/// Biography shown on `profile/private.lisp` page.
|
||||
#[serde(default)]
|
||||
pub private_biography: String,
|
||||
/// If the followers/following links are hidden from the user's profile.
|
||||
/// Will also revoke access to their respective pages.
|
||||
#[serde(default)]
|
||||
pub hide_social_follows: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
|
|
44
crates/core/src/model/mail.rs
Normal file
44
crates/core/src/model/mail.rs
Normal file
|
@ -0,0 +1,44 @@
|
|||
use serde::{Serialize, Deserialize};
|
||||
use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
|
||||
|
||||
/// A letter is the most basic structure of the mail system. Letters are sent
|
||||
/// and received by users.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Letter {
|
||||
pub id: usize,
|
||||
pub created: usize,
|
||||
pub owner: usize,
|
||||
pub receivers: Vec<usize>,
|
||||
pub subject: String,
|
||||
pub content: String,
|
||||
/// The ID of every use who has read the letter. Can be checked in the UI
|
||||
/// with `user.id in letter.read_by`.
|
||||
///
|
||||
/// This field can be updated by anyone in the letter's `receivers` field.
|
||||
/// Other fields in the letter can only be updated by the letter's `owner`.
|
||||
pub read_by: Vec<usize>,
|
||||
/// The ID of the letter this letter is replying to.
|
||||
pub replying_to: usize,
|
||||
}
|
||||
|
||||
impl Letter {
|
||||
/// Create a new [`Letter`].
|
||||
pub fn new(
|
||||
owner: usize,
|
||||
receivers: Vec<usize>,
|
||||
subject: String,
|
||||
content: String,
|
||||
replying_to: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
created: unix_epoch_timestamp(),
|
||||
owner,
|
||||
receivers,
|
||||
subject,
|
||||
content,
|
||||
read_by: Vec::new(),
|
||||
replying_to,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ pub mod communities;
|
|||
pub mod communities_permissions;
|
||||
pub mod journals;
|
||||
pub mod littleweb;
|
||||
pub mod mail;
|
||||
pub mod moderation;
|
||||
pub mod oauth;
|
||||
pub mod permissions;
|
||||
|
|
|
@ -178,6 +178,7 @@ bitflags! {
|
|||
const MANAGE_SERVICES = 1 << 3;
|
||||
const MANAGE_PRODUCTS = 1 << 4;
|
||||
const DEVELOPER_PASS = 1 << 5;
|
||||
const MANAGE_LETTERS = 1 << 6;
|
||||
|
||||
const _ = !0;
|
||||
}
|
||||
|
|
|
@ -75,6 +75,22 @@ impl DataClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// Check if the given IP is IP banned from the Tetratto host. You will only know
|
||||
/// if the IP is banned or not, meaning you will not be shown the reason if it
|
||||
/// is banned.
|
||||
pub async fn check_ip(&self, ip: &str) -> Result<bool> {
|
||||
match self
|
||||
.http
|
||||
.get(format!("{}/api/v1/bans/{}", self.host, ip))
|
||||
.header("Atto-Secret-Key", &self.api_key)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(x) => api_return_ok!(bool, x),
|
||||
Err(e) => Err(Error::MiscError(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query the app's data.
|
||||
pub async fn query(&self, query: &SimplifiedQuery) -> Result<AppDataQueryResult> {
|
||||
match self
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
[package]
|
||||
name = "tetratto-shared"
|
||||
description = "Shared stuff for Tetratto"
|
||||
version = "12.0.0"
|
||||
version = "12.0.6"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
@ -10,9 +10,10 @@ license.workspace = true
|
|||
[dependencies]
|
||||
ammonia = "4.1.1"
|
||||
chrono = "0.4.41"
|
||||
markdown = "1.0.0"
|
||||
hex_fmt = "0.3.0"
|
||||
pulldown-cmark = "0.13.0"
|
||||
rand = "0.9.1"
|
||||
regex = "1.11.1"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
sha2 = "0.10.9"
|
||||
snowflaked = "1.0.3"
|
||||
|
|
|
@ -1,36 +1,42 @@
|
|||
use ammonia::Builder;
|
||||
use markdown::{to_html_with_options, Options, CompileOptions, ParseOptions, Constructs};
|
||||
use pulldown_cmark::{Parser, Options, html::push_html};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Render markdown input into HTML
|
||||
pub fn render_markdown(input: &str) -> String {
|
||||
let options = Options {
|
||||
compile: CompileOptions {
|
||||
allow_any_img_src: false,
|
||||
allow_dangerous_html: true,
|
||||
allow_dangerous_protocol: true,
|
||||
gfm_task_list_item_checkable: false,
|
||||
gfm_tagfilter: false,
|
||||
..Default::default()
|
||||
},
|
||||
parse: ParseOptions {
|
||||
constructs: Constructs {
|
||||
math_flow: true,
|
||||
math_text: true,
|
||||
..Constructs::gfm()
|
||||
},
|
||||
gfm_strikethrough_single_tilde: false,
|
||||
math_text_single_dollar: false,
|
||||
mdx_expression_parse: None,
|
||||
mdx_esm_parse: None,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
pub fn render_markdown_dirty(input: &str) -> String {
|
||||
let input = &autolinks(&parse_alignment(&parse_backslash_breaks(input)));
|
||||
|
||||
let html = match to_html_with_options(input, &options) {
|
||||
Ok(h) => h,
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
options.insert(Options::ENABLE_GFM);
|
||||
options.insert(Options::ENABLE_FOOTNOTES);
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
|
||||
options.insert(Options::ENABLE_SUBSCRIPT);
|
||||
|
||||
let parser = Parser::new_ext(input, options);
|
||||
|
||||
let mut html = String::new();
|
||||
push_html(&mut html, parser);
|
||||
|
||||
html
|
||||
}
|
||||
|
||||
pub fn clean_html(html: String, allowed_attributes: HashSet<&str>) -> String {
|
||||
Builder::default()
|
||||
.generic_attributes(allowed_attributes)
|
||||
.add_tags(&[
|
||||
"video", "source", "img", "b", "span", "p", "i", "strong", "em", "a", "align",
|
||||
])
|
||||
.rm_tags(&["script", "style", "link", "canvas"])
|
||||
.add_tag_attributes("a", &["href", "target"])
|
||||
.add_url_schemes(&["atto"])
|
||||
.clean(&html.replace("<video ", "<video controls "))
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Render markdown input into HTML
|
||||
pub fn render_markdown(input: &str, proxy_images: bool) -> String {
|
||||
let html = render_markdown_dirty(input);
|
||||
|
||||
let mut allowed_attributes = HashSet::new();
|
||||
allowed_attributes.insert("id");
|
||||
|
@ -42,22 +48,178 @@ pub fn render_markdown(input: &str) -> String {
|
|||
allowed_attributes.insert("align");
|
||||
allowed_attributes.insert("src");
|
||||
|
||||
Builder::default()
|
||||
.generic_attributes(allowed_attributes)
|
||||
.add_tags(&[
|
||||
"video", "source", "img", "b", "span", "p", "i", "strong", "em", "a",
|
||||
])
|
||||
.rm_tags(&["script", "style", "link", "canvas"])
|
||||
.add_tag_attributes("a", &["href", "target"])
|
||||
.add_url_schemes(&["atto"])
|
||||
.clean(&html)
|
||||
.to_string()
|
||||
.replace(
|
||||
let output = clean_html(html, allowed_attributes);
|
||||
|
||||
if proxy_images {
|
||||
output.replace(
|
||||
"src=\"http",
|
||||
"loading=\"lazy\" src=\"/api/v1/util/proxy?url=http",
|
||||
)
|
||||
.replace("<video loading=", "<video controls loading=")
|
||||
.replace("-->", "<align class=\"right\">")
|
||||
.replace("->", "<align class=\"center\">")
|
||||
.replace("<-", "</align>")
|
||||
} else {
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_alignment_line(line: &str, output: &mut String, buffer: &mut String, is_in_pre: bool) {
|
||||
if is_in_pre {
|
||||
output.push_str(&format!("{line}\n"));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut is_alignment_waiting: bool = false;
|
||||
let mut alignment_center: bool = false;
|
||||
let mut has_dash: bool = false;
|
||||
let mut escape: bool = false;
|
||||
|
||||
for char in line.chars() {
|
||||
if alignment_center && char != '-' {
|
||||
// last char was <, but we didn't receive a hyphen directly after
|
||||
alignment_center = false;
|
||||
buffer.push('<');
|
||||
}
|
||||
|
||||
if has_dash && char != '>' {
|
||||
// the last char was -, meaning we need to flip has_dash and push the char since we haven't used it
|
||||
has_dash = false;
|
||||
buffer.push('-');
|
||||
}
|
||||
|
||||
match char {
|
||||
'\\' => {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
'-' => {
|
||||
if escape {
|
||||
buffer.push(char);
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if alignment_center && is_alignment_waiting {
|
||||
// this means the previous element was <, so we're wrapping up alignment now
|
||||
alignment_center = false;
|
||||
is_alignment_waiting = false;
|
||||
output.push_str(&format!("<align class=\"center\">{buffer}</align>"));
|
||||
buffer.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
has_dash = true;
|
||||
|
||||
if !is_alignment_waiting {
|
||||
// we need to go ahead and push/clear the buffer so we don't capture the stuff that came before this
|
||||
// this only needs to be done on the first of these for a single alignment block
|
||||
output.push_str(&buffer);
|
||||
buffer.clear();
|
||||
}
|
||||
}
|
||||
'<' => {
|
||||
if escape {
|
||||
buffer.push(char);
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
alignment_center = true;
|
||||
continue;
|
||||
}
|
||||
'>' => {
|
||||
if escape {
|
||||
buffer.push(char);
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if has_dash {
|
||||
has_dash = false;
|
||||
|
||||
// if we're already waiting for aligmment, this means this is the SECOND aligner arrow
|
||||
if is_alignment_waiting {
|
||||
is_alignment_waiting = false;
|
||||
output.push_str(&format!("<align class=\"right\">{buffer}</align>"));
|
||||
buffer.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
// we're now waiting for the next aligner
|
||||
is_alignment_waiting = true;
|
||||
continue;
|
||||
} else {
|
||||
buffer.push('>');
|
||||
}
|
||||
}
|
||||
_ => buffer.push(char),
|
||||
}
|
||||
|
||||
escape = false;
|
||||
}
|
||||
|
||||
output.push_str(&format!("{buffer}\n"));
|
||||
buffer.clear();
|
||||
}
|
||||
|
||||
pub fn parse_alignment(input: &str) -> String {
|
||||
let lines = input.split("\n");
|
||||
|
||||
let mut is_in_pre: bool = false;
|
||||
let mut output = String::new();
|
||||
let mut buffer = String::new();
|
||||
|
||||
for line in lines {
|
||||
if line.starts_with("```") {
|
||||
is_in_pre = !is_in_pre;
|
||||
output.push_str(&format!("{line}\n"));
|
||||
} else {
|
||||
parse_alignment_line(line, &mut output, &mut buffer, is_in_pre)
|
||||
}
|
||||
}
|
||||
|
||||
output.push_str(&buffer);
|
||||
output
|
||||
}
|
||||
|
||||
pub fn parse_backslash_breaks(input: &str) -> String {
|
||||
let mut in_pre_block = false;
|
||||
let mut output = String::new();
|
||||
|
||||
for line in input.split("\n") {
|
||||
if line.starts_with("```") {
|
||||
in_pre_block = !in_pre_block;
|
||||
output.push_str(&format!("{line}\n"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_pre_block {
|
||||
output.push_str(&format!("{line}\n"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.trim_end().ends_with("\\") {
|
||||
output.push_str(&format!("{line}<br />\n"));
|
||||
} else {
|
||||
output.push_str(&format!("{line}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Adapted from <https://git.cypr.io/oz/autolink-rust>.
|
||||
///
|
||||
/// The only real change here is that autolinks require a whitespace OR end the
|
||||
/// end of the pattern to match here.
|
||||
pub fn autolinks(input: &str) -> String {
|
||||
if input.len() == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let pattern = regex::Regex::new(
|
||||
r"(?ix)\b(([\w-]+://?|www[.])[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|/)))(\s|$)",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
pattern
|
||||
.replace_all(input, "<a href=\"$0\">$0</a> ")
|
||||
.to_string()
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue