add: icon resolver

add: config "no_track" file list option
add: rainbeam-shared -> tetratto-shared
add: l10n
This commit is contained in:
trisua 2025-03-23 12:31:48 -04:00
parent b6fe2fba37
commit d2ca9e23d3
40 changed files with 1107 additions and 583 deletions

983
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[workspace]
resolver = "3"
members = ["crates/app", "crates/tetratto_core"]
resolver = "2"
members = ["crates/app", "crates/shared", "crates/core", "crates/l10n"]
package.authors = ["trisuaso"]
package.repository = "https://github.com/trisuaso/tetratto"
package.license = "AGPL-3.0-or-later"

View file

@ -4,23 +4,24 @@ version = "0.1.0"
edition = "2024"
[features]
postgres = ["tetratto_core/postgres"]
sqlite = ["tetratto_core/sqlite"]
postgres = ["tetratto-core/postgres"]
sqlite = ["tetratto-core/sqlite"]
default = ["sqlite"]
[dependencies]
pathbufd = "0.1.4"
serde = { version = "1.0.219", features = ["derive"] }
tera = "1.20.0"
toml = "0.8.20"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tower-http = { version = "0.6.2", features = ["trace", "fs"] }
axum = { version = "0.8.1", features = ["macros"] }
tokio = { version = "1.44.0", features = ["macros", "rt-multi-thread"] }
rainbeam-shared = "1.0.1"
serde_json = "1.0.140"
tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] }
axum-extra = { version = "0.10.0", features = ["cookie", "multipart"] }
tetratto_core = { path = "../tetratto_core", default-features = false }
tetratto-shared = { path = "../shared" }
tetratto-core = { path = "../core", default-features = false }
tetratto-l10n = { path = "../l10n" }
image = "0.25.5"
mime_guess = "2.0.5"
reqwest = "0.12.15"
regex = "1.11.1"

View file

@ -1,12 +1,21 @@
use pathbufd::PathBufD;
use regex::Regex;
use std::{
collections::HashMap,
fs::{exists, read_to_string, write},
sync::LazyLock,
};
use tera::Context;
use tetratto_core::{config::Config, model::auth::User};
use tetratto_l10n::LangFile;
use tokio::sync::RwLock;
use crate::write_template;
use crate::{create_dir_if_not_exists, write_if_track, write_template};
// images
pub const DEFAULT_AVATAR: &str = include_str!("./public/images/default-avatar.svg");
pub const DEFAULT_BANNER: &str = include_str!("./public/images/default-banner.svg");
pub const FAVICON: &str = include_str!("./public/images/favicon.svg");
// css
pub const STYLE_CSS: &str = include_str!("./public/css/style.css");
@ -25,24 +34,142 @@ pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.html");
pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.html");
pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.html");
// langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
// ...
/// 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, icon]);
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();
write(&file_path, &svg).unwrap();
writer.insert(icon.to_string(), svg);
}
/// Read a string and replace all custom blocks with the corresponding correct HTML.
///
/// # Replaces
/// * icons
/// * icons (with class specifier)
/// * l10n text
pub(crate) async fn replace_in_html(input: &str, config: &Config) -> String {
let mut input = input.to_string();
input = input.replace("<!-- prettier-ignore -->", "");
// l10n text
let text = Regex::new("(\\{\\{)\\s*(text)\\s*\"(.*?)\"\\s*(\\}\\})").unwrap();
for cap in text.captures_iter(&input.clone()) {
let replace_with = format!("{{{{ lang[\"{}\"] }}}}", cap.get(3).unwrap().as_str());
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 icon = &cap.get(3).unwrap().as_str().replace("\"", "");
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(cap.get(0).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 icon = &cap.get(3).unwrap().as_str().replace("\"", "");
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(cap.get(0).unwrap().as_str(), &icon_text);
}
// return
input
}
/// Set up public directories.
pub(crate) fn write_assets(html_path: &PathBufD) {
write_template!(html_path->"root.html"(crate::assets::ROOT));
write_template!(html_path->"macros.html"(crate::assets::MACROS));
pub(crate) async fn write_assets(config: &Config) -> PathBufD {
let html_path = PathBufD::current().join(&config.dirs.templates);
write_template!(html_path->"misc/index.html"(crate::assets::MISC_INDEX) -d "misc");
write_template!(html_path->"root.html"(crate::assets::ROOT) --config=config);
write_template!(html_path->"macros.html"(crate::assets::MACROS) --config=config);
write_template!(html_path->"auth/base.html"(crate::assets::AUTH_BASE) -d "auth");
write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN));
write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER));
write_template!(html_path->"misc/index.html"(crate::assets::MISC_INDEX) -d "misc" --config=config);
write_template!(html_path->"auth/base.html"(crate::assets::AUTH_BASE) -d "auth" --config=config);
write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config);
write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config);
html_path
}
/// Set up extra directories.
pub(crate) async fn init_dirs(config: &Config) {
// images
create_dir_if_not_exists!(&config.dirs.media);
let images_path = PathBufD::current().extend(&[config.dirs.media.as_str(), "images"]);
create_dir_if_not_exists!(&images_path);
create_dir_if_not_exists!(
&PathBufD::current().extend(&[config.dirs.media.as_str(), "avatars"])
);
create_dir_if_not_exists!(
&PathBufD::current().extend(&[config.dirs.media.as_str(), "banners"])
);
write_if_track!(images_path->"default-avatar.svg"(DEFAULT_AVATAR) --config=config);
write_if_track!(images_path->"default-banner.svg"(DEFAULT_BANNER) --config=config);
write_if_track!(images_path->"favicon.svg"(FAVICON) --config=config);
// icons
create_dir_if_not_exists!(&PathBufD::current().join(config.dirs.icons.as_str()));
// langs
let langs_path = PathBufD::current().join("langs");
create_dir_if_not_exists!(&langs_path);
write_template!(langs_path->"en-US.toml"(LANG_EN_US));
}
/// Create the initial template context.
pub(crate) fn initial_context(config: &Config, user: &Option<User>) -> Context {
pub(crate) fn initial_context(config: &Config, lang: &LangFile, user: &Option<User>) -> Context {
let mut ctx = Context::new();
ctx.insert("config", &config);
ctx.insert("user", &user);
ctx.insert("lang", &lang);
ctx
}

View file

@ -0,0 +1,6 @@
name = "com.tetratto.langs:en-US"
version = "1.0.0"
[data]
"general:action.login" = "Login"
"general:action.register" = "Register"

View file

@ -1,16 +1,46 @@
#[macro_export]
macro_rules! write_template {
($html_path:ident->$path:literal($as:expr)) => {
std::fs::write($html_path.join($path), $as).unwrap();
($into:ident->$path:literal($as:expr)) => {
std::fs::write($into.join($path), $as).unwrap();
};
($html_path:ident->$path:literal($as:expr) -d $dir_path:literal) => {
let dir = $html_path.join($dir_path);
($into:ident->$path:literal($as:expr) --config=$config:ident) => {
std::fs::write(
$into.join($path),
crate::assets::replace_in_html($as, &$config).await,
)
.unwrap();
};
($into:ident->$path:literal($as:expr) -d $dir_path:literal) => {
let dir = $into.join($dir_path);
if !std::fs::exists(&dir).unwrap() {
std::fs::create_dir(dir).unwrap();
}
std::fs::write($html_path.join($path), $as).unwrap();
std::fs::write($into.join($path), $as).unwrap();
};
($into:ident->$path:literal($as:expr) -d $dir_path:literal --config=$config:ident) => {
let dir = $into.join($dir_path);
if !std::fs::exists(&dir).unwrap() {
std::fs::create_dir(dir).unwrap();
}
std::fs::write(
$into.join($path),
crate::assets::replace_in_html($as, &$config).await,
)
.unwrap();
};
}
#[macro_export]
macro_rules! write_if_track {
($into:ident->$path:literal($as:expr) --config=$config:ident) => {
if !$config.no_track.contains(&$path.to_string()) {
write_template!($into->$path($as));
}
};
}
@ -28,7 +58,7 @@ macro_rules! get_user_from_token {
(($jar:ident, $db:expr) <optional>) => {{
if let Some(token) = $jar.get("__Secure-atto-token") {
match $db
.get_user_by_token(&rainbeam_shared::hash::hash(
.get_user_by_token(&tetratto_shared::hash::hash(
token.to_string().replace("__Secure-atto-token=", ""),
))
.await
@ -52,3 +82,20 @@ macro_rules! get_user_from_token {
}
}};
}
#[macro_export]
macro_rules! get_lang {
($jar:ident, $db:expr) => {{
if let Some(lang) = $jar.get("__Secure-atto-lang") {
match $db
.1
.get(&lang.to_string().replace("__Secure-atto-lang=", ""))
{
Some(lang) => lang,
None => $db.1.get("com.tetratto.langs:en-US").unwrap(),
}
} else {
$db.1.get("com.tetratto.langs:en-US").unwrap()
}
}};
}

View file

@ -3,11 +3,10 @@ mod avif;
mod macros;
mod routes;
use assets::write_assets;
use assets::{init_dirs, write_assets};
pub use tetratto_core::*;
use axum::{Extension, Router};
use pathbufd::PathBufD;
use tera::Tera;
use tower_http::trace::{self, TraceLayer};
use tracing::{Level, info};
@ -26,24 +25,9 @@ async fn main() {
let config = config::Config::get_config();
// ...
create_dir_if_not_exists!(&config.dirs.media);
let images_path =
PathBufD::current().extend(&[config.dirs.media.clone(), "images".to_string()]);
create_dir_if_not_exists!(&images_path);
create_dir_if_not_exists!(
&PathBufD::current().extend(&[config.dirs.media.clone(), "avatars".to_string()])
);
create_dir_if_not_exists!(
&PathBufD::current().extend(&[config.dirs.media.clone(), "banners".to_string()])
);
write_template!(images_path->"default-avatar.svg"(assets::DEFAULT_AVATAR));
write_template!(images_path->"default-banner.svg"(assets::DEFAULT_BANNER));
// create templates
let html_path = PathBufD::current().join(&config.dirs.templates);
write_assets(&html_path);
// init
init_dirs(&config).await;
let html_path = write_assets(&config).await;
// ...
let app = Router::new()

View file

@ -132,9 +132,17 @@ footer {
}
/* typo */
.icon {
color: inherit;
svg.icon {
stroke: currentColor;
width: 18px;
}
svg.icon.filled {
fill: currentColor;
}
button svg {
pointer-events: none;
}
hr {
@ -666,9 +674,7 @@ dialog[open] {
.dropdown .inner button {
width: 100%;
padding: 0.25rem var(--horizontal-padding);
/* transition:
background 0.1s,
transform 0.15s; */
transition: none !important;
text-decoration: none;
display: flex;
align-items: center;

View file

@ -1,5 +1,5 @@
{% extends "auth/base.html" %} {% block head %}
<title>🐐 Login</title>
<title>Login</title>
{% endblock %} {% block title %}Login{% endblock %} {% block content %}
<form class="w-full flex flex-col gap-4">
<div class="flex flex-col gap-1">

View file

@ -1,5 +1,5 @@
{% extends "auth/base.html" %} {% block head %}
<title>🐐 Register</title>
<title>Register</title>
{% endblock %} {% block title %}Register{% endblock %} {% block content %}
<form class="w-full flex flex-col gap-4" onsubmit="register(event)">
<div class="flex flex-col gap-1">

View file

@ -10,14 +10,17 @@
<a
href="/"
class="button {% if selected == 'home' %}active{% endif %}"
>Home</a
>
{{ icon "house" }}
<span class="desktop">Home</span>
</a>
{% endif %}
</div>
<div class="flex nav_side">
{% if user %}
<div class="dropdown">
<!-- prettier-ignore -->
<button
class="flex-row title"
onclick="trigger('atto::hooks::dropdown', [event])"
@ -25,11 +28,30 @@
style="gap: 0.25rem !important"
>
{{ macros::avatar(username=user.username, size="24px") }}
{{ icon "chevron-down" c(dropdown-arrow) }}
</button>
</div>
{% else %}
<a href="/auth/login" class="button">Login</a>
<a href="/auth/register" class="button">Register</a>
<div class="dropdown">
<button
class="title"
onclick="trigger('atto::hooks::dropdown', [event])"
exclude="dropdown"
>
{{ icon "chevron-down" c(dropdown-arrow) }}
</button>
<div class="inner">
<a href="/auth/login" class="button">
{{ icon "log-in" }}
<span>Login</span>
</a>
<a href="/auth/register" class="button">
{{ icon "user-plus" }}
<span>Register</span>
</a>
</div>
</div>
{% endif %}
</div>
</div>

View file

@ -5,6 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta
http-equiv="content-security-policy"
content="default-src 'self' blob:; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' blob: *; script-src 'self' 'unsafe-inline' blob: *; object-src 'self' blob: *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: data: *"
/>
<link rel="icon" href="/public/favicon.svg" />
<link rel="stylesheet" href="/css/style.css" />
<script src="/js/loader.js"></script>

View file

@ -1,3 +1,9 @@
<svg width="460" height="460" viewBox="0 0 460 460" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="460"
height="460"
viewBox="0 0 460 460"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="460" height="460" fill="#C9B1BC" />
</svg>

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 181 B

Before After
Before After

View file

@ -1,3 +1,9 @@
<svg width="460" height="460" viewBox="0 0 460 460" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="460" height="460" fill="#C9B1BC"/>
<svg
width="1500"
height="350"
viewBox="0 0 1500 350"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="1500" height="350" fill="#C9B1BC" />
</svg>

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 184 B

Before After
Before After

View file

@ -0,0 +1,53 @@
<svg
width="128"
height="128"
viewBox="0 0 128 128"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="0.5" y="0.5" width="127" height="127" rx="31.5" fill="#E793B9" />
<rect x="0.5" y="0.5" width="127" height="127" rx="31.5" stroke="white" />
<g filter="url(#filter0_d_1073_2)">
<path
d="M54.8535 59.0918V85.1797C53.7155 86.1693 52.194 86.998 50.2891 87.666C48.3841 88.334 46.071 88.668 43.3496 88.668C40.6283 88.668 38.3151 88.0618 36.4102 86.8496C34.2578 85.5384 33.1816 83.6829 33.1816 81.2832V57.3848H24.5352C22.7539 54.8366 21.8633 52.0534 21.8633 49.0352C21.8633 46.9818 22.1849 45.2005 22.8281 43.6914C23.4961 42.1576 24.3125 40.9701 25.2773 40.1289H63.6484C64.3659 41.2422 64.9596 42.5905 65.4297 44.1738C65.8997 45.7572 66.1348 47.3281 66.1348 48.8867C66.1348 51.806 65.4915 53.9583 64.2051 55.3438C62.9186 56.7044 61.1868 57.3848 59.0098 57.3848H48.1367C48.0625 57.5579 48.0254 57.7435 48.0254 57.9414C48.0254 58.3867 48.2852 58.6836 48.8047 58.832C49.349 59.0052 50.5117 59.0918 52.293 59.0918H54.8535ZM102.91 69.6309C102.91 71.4121 103.108 72.847 103.504 73.9355C103.9 75.0241 104.592 75.8776 105.582 76.4961C105.607 76.6198 105.619 76.8548 105.619 77.2012V77.8691C105.619 81.3822 104.555 84.1159 102.428 86.0703C100.548 87.8021 98.222 88.668 95.4512 88.668C93.571 88.668 91.8516 88.2227 90.293 87.332C88.7344 86.4414 87.6211 85.1549 86.9531 83.4727C86.112 83.4727 85.6914 83.8809 85.6914 84.6973C85.6914 85.39 86.1243 86.1693 86.9902 87.0352C85.3822 88.1237 82.9577 88.668 79.7168 88.668C75.14 88.668 71.4785 87.431 68.7324 84.957C66.0111 82.4583 64.6504 78.9824 64.6504 74.5293C64.6504 70.0514 66.0977 66.5879 68.9922 64.1387C71.6393 61.9368 75.0658 60.8359 79.2715 60.8359C80.7064 60.8359 82.0918 60.9349 83.4277 61.1328C84.3184 61.306 84.8008 61.4049 84.875 61.4297C84.9492 61.1823 84.9863 60.972 84.9863 60.7988C84.9863 60.1556 84.4421 59.7227 83.3535 59.5C82.265 59.2773 80.8424 59.166 79.0859 59.166C75.3503 59.166 72.1589 59.9206 69.5117 61.4297C68.2995 59.3763 67.6934 56.7786 67.6934 53.6367C67.6934 51.8555 67.916 50.5319 68.3613 49.666C70.1426 48.8496 72.4062 48.1322 75.1523 47.5137C77.8984 46.8704 80.793 46.5488 83.8359 46.5488C90.1198 46.5488 94.8698 47.9219 98.0859 50.668C101.302 53.4141 102.91 57.3105 102.91 62.3574V69.6309ZM83.6504 71.4492C82.6113 71.4492 82.0918 71.9193 82.0918 72.8594C82.0918 73.75 82.6979 74.1953 83.9102 74.1953C84.7513 74.1953 85.3698 73.9974 85.7656 73.6016C86.1615 73.181 86.3594 72.4635 86.3594 71.4492C86.3594 71.4492 85.4564 71.4492 83.6504 71.4492Z"
fill="white"
/>
</g>
<defs>
<filter
id="filter0_d_1073_2"
x="17.8633"
y="36.1289"
width="91.7559"
height="56.5391"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="2" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_1073_2"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_1073_2"
result="shape"
/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -1,5 +1,10 @@
use axum::response::IntoResponse;
/// `/public/favicon.svg`
pub async fn favicon_request() -> impl IntoResponse {
([("Content-Type", "image/svg+xml")], crate::assets::FAVICON)
}
/// `/css/style.css`
pub async fn style_css_request() -> impl IntoResponse {
([("Content-Type", "text/css")], crate::assets::STYLE_CSS)

View file

@ -15,9 +15,10 @@ pub fn routes(config: &Config) -> Router {
.route("/js/atto.js", get(assets::atto_js_request))
.route("/js/loader.js", get(assets::loader_js_request))
.nest_service(
"/static",
"/public",
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
)
.route("/public/favicon.svg", get(assets::favicon_request))
// api
.nest("/api/v1", api::v1::routes())
// pages

View file

@ -1,4 +1,4 @@
use crate::{State, assets::initial_context, get_user_from_token};
use crate::{State, assets::initial_context, get_lang, get_user_from_token};
use axum::{
Extension,
response::{Html, IntoResponse, Redirect},
@ -14,7 +14,9 @@ pub async fn login_request(jar: CookieJar, Extension(data): Extension<State>) ->
return Err(Redirect::to("/"));
}
let mut context = initial_context(&data.0.0, &user);
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &user);
Ok(Html(
data.1.render("auth/login.html", &mut context).unwrap(),
))
@ -32,7 +34,9 @@ pub async fn register_request(
return Err(Redirect::to("/"));
}
let mut context = initial_context(&data.0.0, &user);
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &user);
Ok(Html(
data.1.render("auth/register.html", &mut context).unwrap(),
))

View file

@ -1,4 +1,4 @@
use crate::{State, assets::initial_context, get_user_from_token};
use crate::{State, assets::initial_context, get_lang, get_user_from_token};
use axum::{
Extension,
response::{Html, IntoResponse},
@ -10,6 +10,8 @@ pub async fn index_request(jar: CookieJar, Extension(data): Extension<State>) ->
let data = data.read().await;
let user = get_user_from_token!((jar, data.0) <optional>);
let mut context = initial_context(&data.0.0, &user);
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &user);
Html(data.1.render("misc/index.html", &mut context).unwrap())
}

View file

@ -1,5 +1,5 @@
[package]
name = "tetratto_core"
name = "tetratto-core"
version = "0.1.0"
edition = "2024"
@ -11,16 +11,10 @@ default = ["sqlite"]
[dependencies]
pathbufd = "0.1.4"
serde = { version = "1.0.219", features = ["derive"] }
tera = "1.20.0"
toml = "0.8.20"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tower-http = { version = "0.6.2", features = ["trace", "fs"] }
axum = { version = "0.8.1", features = ["macros"] }
tokio = { version = "1.44.0", features = ["macros", "rt-multi-thread"] }
rainbeam-shared = "1.0.1"
tetratto-shared = { path = "../shared" }
tetratto-l10n = { path = "../l10n" }
serde_json = "1.0.140"
axum-extra = { version = "0.10.0", features = ["cookie"] }
rusqlite = { version = "0.34.0", optional = true }

View file

@ -51,6 +51,9 @@ pub struct DirsConfig {
/// Media (user avatars/banners) files directory.
#[serde(default = "default_dir_media")]
pub media: String,
/// The icons files directory.
#[serde(default = "default_dir_icons")]
pub icons: String,
}
fn default_dir_templates() -> String {
@ -65,12 +68,17 @@ fn default_dir_media() -> String {
"media".to_string()
}
fn default_dir_icons() -> String {
"icons".to_string()
}
impl Default for DirsConfig {
fn default() -> Self {
Self {
templates: default_dir_templates(),
assets: default_dir_assets(),
media: default_dir_media(),
icons: default_dir_icons(),
}
}
}
@ -122,8 +130,13 @@ pub struct Config {
/// The locations where different files should be matched.
#[serde(default = "default_dirs")]
pub dirs: DirsConfig,
/// Database configuration.
#[serde(default = "default_database")]
pub database: DatabaseConfig,
/// A list of files (just their name, no full path) which are NOT updated to match the
/// version built with the server binary.
#[serde(default = "default_no_track")]
pub no_track: Vec<String>,
}
fn default_name() -> String {
@ -153,6 +166,10 @@ fn default_database() -> DatabaseConfig {
DatabaseConfig::default()
}
fn default_no_track() -> Vec<String> {
Vec::new()
}
impl Default for Config {
fn default() -> Self {
Self {
@ -163,6 +180,7 @@ impl Default for Config {
database: default_database(),
security: default_security(),
dirs: default_dirs(),
no_track: default_no_track(),
}
}
}

View file

@ -2,7 +2,7 @@ use super::*;
use crate::model::{Error, Result};
use crate::{execute, get, query_row};
use rainbeam_shared::hash::hash_salted;
use tetratto_shared::hash::hash_salted;
#[cfg(feature = "sqlite")]
use rusqlite::Row;

View file

@ -3,18 +3,23 @@ use bb8_postgres::{
PostgresConnectionManager,
bb8::{Pool, PooledConnection},
};
use tetratto_l10n::{LangFile, read_langs};
use tokio_postgres::{Config as PgConfig, NoTls, Row, types::ToSql};
pub type Result<T> = std::result::Result<T, tokio_postgres::Error>;
pub type Connection<'a> = PooledConnection<'a, PostgresConnectionManager<NoTls>>;
#[derive(Clone)]
pub struct DataManager(pub Config, pub Pool<PostgresConnectionManager<NoTls>>);
pub struct DataManager(
pub Config,
pub HashMap<String, LangFile>,
pub Pool<PostgresConnectionManager<NoTls>>,
);
impl DataManager {
/// Obtain a connection to the staging database.
pub(crate) async fn connect(&self) -> Result<Connection> {
Ok(self.1.get().await.unwrap())
Ok(self.2.get().await.unwrap())
}
/// Create a new [`DataManager`] (and init database).
@ -31,7 +36,7 @@ impl DataManager {
);
let pool = Pool::builder().max_size(15).build(manager).await.unwrap();
let this = Self(config.clone(), pool);
let this = Self(config.clone(), read_langs(), pool);
let c = this.clone();
let conn = c.connect().await?;

View file

@ -1,8 +1,10 @@
use crate::config::Config;
use rusqlite::{Connection, Result};
use std::collections::HashMap;
use tetratto_l10n::{LangFile, read_langs};
#[derive(Clone)]
pub struct DataManager(pub Config);
pub struct DataManager(pub Config, pub HashMap<String, LangFile>);
impl DataManager {
/// Obtain a connection to the staging database.
@ -12,7 +14,7 @@ impl DataManager {
/// Create a new [`DataManager`] (and init database).
pub async fn new(config: Config) -> Result<Self> {
let this = Self(config.clone());
let this = Self(config.clone(), read_langs());
let conn = this.connect().await?;
conn.pragma_update(None, "journal_mode", "WAL").unwrap();

View file

@ -1,9 +1,9 @@
use rainbeam_shared::{
use serde::{Deserialize, Serialize};
use tetratto_shared::{
hash::{hash_salted, salt},
snow::AlmostSnowflake,
unix_epoch_timestamp,
};
use serde::{Deserialize, Serialize};
/// `(ip, token, creation timestamp)`
pub type Token = (String, String, usize);
@ -53,12 +53,12 @@ impl User {
/// # Returns
/// `(unhashed id, token)`
pub fn create_token(ip: &str) -> (String, Token) {
let unhashed = rainbeam_shared::hash::uuid();
let unhashed = tetratto_shared::hash::uuid();
(
unhashed.clone(),
(
ip.to_string(),
rainbeam_shared::hash::hash(unhashed),
tetratto_shared::hash::hash(unhashed),
unix_epoch_timestamp() as usize,
),
)

12
crates/l10n/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "tetratto-l10n"
version = "0.1.0"
edition = "2024"
authors.workspace = true
repository.workspace = true
license.workspace = true
[dependencies]
pathbufd = "0.1.4"
serde = { version = "1.0.219", features = ["derive"] }
toml = "0.8.20"

95
crates/l10n/src/lib.rs Normal file
View file

@ -0,0 +1,95 @@
use pathbufd::PathBufD;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
fs::{read_dir, read_to_string},
sync::{LazyLock, RwLock},
};
pub static ENGLISH_US: LazyLock<RwLock<LangFile>> = LazyLock::new(RwLock::default);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LangFile {
pub name: String,
pub version: String,
pub data: HashMap<String, String>,
}
impl Default for LangFile {
fn default() -> Self {
Self {
name: "com.tettrato.langs.testing:aa-BB".to_string(),
version: "0.0.0".to_string(),
data: HashMap::new(),
}
}
}
impl LangFile {
/// Check if a value exists in `data` (and isn't empty)
pub fn exists(&self, key: &str) -> bool {
if let Some(value) = self.data.get(key) {
if value.is_empty() {
return false;
}
return true;
}
false
}
/// Get a value from `data`, returns an empty string if it doesn't exist
pub fn get(&self, key: &str) -> String {
if !self.exists(key) {
if (self.name == "com.tettrato.langs.testing:aa-BB")
| (self.name == "com.tettrato.langs.testing:en-US")
{
return key.to_string();
} else {
// load english instead
let reader = ENGLISH_US
.read()
.expect("failed to pull reader for ENGLISH_US");
return reader.get(key);
}
}
self.data.get(key).unwrap().to_owned()
}
}
/// Read the `langs` directory and return a [`Hashmap`] containing all files
pub fn read_langs() -> HashMap<String, LangFile> {
let mut out = HashMap::new();
let langs_dir = PathBufD::current().join("langs");
if let Ok(files) = read_dir(langs_dir) {
for file in files.into_iter() {
if file.is_err() {
continue;
}
let de: LangFile = match toml::from_str(&match read_to_string(file.unwrap().path()) {
Ok(f) => f,
Err(_) => continue,
}) {
Ok(de) => de,
Err(_) => continue,
};
if de.name.ends_with("en-US") {
let mut writer = ENGLISH_US
.write()
.expect("failed to pull writer for ENGLISH_US");
*writer = de.clone();
drop(writer);
}
out.insert(de.name.clone(), de);
}
}
// return
out
}

16
crates/shared/Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "tetratto-shared"
version = "0.1.0"
edition = "2024"
authors.workspace = true
repository.workspace = true
license.workspace = true
[dependencies]
chrono = "0.4.40"
hex_fmt = "0.3.0"
num-bigint = "0.4.6"
rand = "0.9.0"
serde = "1.0.219"
sha2 = "0.10.8"
uuid = { version = "1.16.0", features = ["v4"] }

38
crates/shared/src/hash.rs Normal file
View file

@ -0,0 +1,38 @@
use hex_fmt::HexFmt;
use rand::{Rng, distr::Alphanumeric, rng};
use sha2::{Digest, Sha256};
use uuid::Uuid;
// ids
pub fn uuid() -> String {
let uuid = Uuid::new_v4();
uuid.to_string()
}
pub fn hash(input: String) -> String {
let mut hasher = <Sha256 as Digest>::new();
hasher.update(input.into_bytes());
let res = hasher.finalize();
HexFmt(res).to_string()
}
pub fn hash_salted(input: String, salt: String) -> String {
let mut hasher = <Sha256 as Digest>::new();
hasher.update(format!("{salt}{input}").into_bytes());
let res = hasher.finalize();
HexFmt(res).to_string()
}
pub fn salt() -> String {
rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect()
}
pub fn random_id() -> String {
hash(uuid())
}

5
crates/shared/src/lib.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod hash;
pub mod snow;
pub mod time;
pub use time::{epoch_timestamp, unix_epoch_timestamp};

52
crates/shared/src/snow.rs Normal file
View file

@ -0,0 +1,52 @@
//! Almost Snowflake
//!
//! Random IDs which include timestamp information (like Twitter Snowflakes)
//!
//! IDs are generated with 41 bits of an epoch timestamp, 10 bits of a machine/server ID, and 12 bits of randomly generated numbers.
//!
//! ```
//! tttttttttttttttttttttttttttttttttttttttttiiiiiiiiiirrrrrrrrrrrr...
//! Timestamp ID Seed
//! ```
use crate::epoch_timestamp;
use serde::{Deserialize, Serialize};
use num_bigint::BigInt;
use rand::Rng;
static SEED_LEN: usize = 12;
// static ID_LEN: usize = 10;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct AlmostSnowflake(String);
pub fn bigint(input: usize) -> BigInt {
BigInt::from(input)
}
impl AlmostSnowflake {
/// Create a new [`AlmostSnowflake`]
pub fn new(server_id: usize) -> Self {
// generate random bytes
let mut bytes = String::new();
let mut rng = rand::rng();
for _ in 1..=SEED_LEN {
bytes.push_str(&rng.random_range(0..10).to_string())
}
// build id
let mut id = bigint(epoch_timestamp(2024) as usize) << 22_u128;
id |= bigint((server_id % 1024) << 12);
id |= bigint((bytes.parse::<usize>().unwrap() + 1) % 4096);
// return
Self(id.to_string())
}
}
impl std::fmt::Display for AlmostSnowflake {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}

23
crates/shared/src/time.rs Normal file
View file

@ -0,0 +1,23 @@
use chrono::{TimeZone, Utc};
use std::time::{SystemTime, UNIX_EPOCH};
/// Get a [`u128`] timestamp
pub fn unix_epoch_timestamp() -> u128 {
let right_now = SystemTime::now();
let time_since = right_now
.duration_since(UNIX_EPOCH)
.expect("Time travel is not allowed");
time_since.as_millis()
}
/// Get a [`i64`] timestamp from the given `year` epoch
pub fn epoch_timestamp(year: i32) -> i64 {
let now = Utc::now().timestamp_millis();
let then = Utc
.with_ymd_and_hms(year, 1, 1, 0, 0, 0)
.unwrap()
.timestamp_millis();
now - then
}

2
example/.gitignore vendored
View file

@ -7,3 +7,5 @@ public/*
!public/.gitkeep
media/*
icons/*
langs/*