Compare commits

...

13 commits

39 changed files with 861 additions and 186 deletions

56
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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\",

View file

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

View file

@ -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\"],
[
[

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"] }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View 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:{}");
}

View file

@ -14,6 +14,7 @@ mod invite_codes;
mod ipbans;
mod ipblocks;
mod journals;
mod letters;
mod memberships;
mod message_reactions;
mod messages;

View file

@ -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
View 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
}

View file

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

View file

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

View file

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

View 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,
}
}
}

View file

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

View file

@ -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;
}

View file

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

View file

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

View file

@ -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("--&gt;", "<align class=\"right\">")
.replace("-&gt;", "<align class=\"center\">")
.replace("&lt;-", "</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()
}