add: icon resolver
add: config "no_track" file list option add: rainbeam-shared -> tetratto-shared add: l10n
This commit is contained in:
parent
b6fe2fba37
commit
d2ca9e23d3
40 changed files with 1107 additions and 583 deletions
983
Cargo.lock
generated
983
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "2"
|
||||||
members = ["crates/app", "crates/tetratto_core"]
|
members = ["crates/app", "crates/shared", "crates/core", "crates/l10n"]
|
||||||
package.authors = ["trisuaso"]
|
package.authors = ["trisuaso"]
|
||||||
package.repository = "https://github.com/trisuaso/tetratto"
|
package.repository = "https://github.com/trisuaso/tetratto"
|
||||||
package.license = "AGPL-3.0-or-later"
|
package.license = "AGPL-3.0-or-later"
|
||||||
|
|
|
@ -4,23 +4,24 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
postgres = ["tetratto_core/postgres"]
|
postgres = ["tetratto-core/postgres"]
|
||||||
sqlite = ["tetratto_core/sqlite"]
|
sqlite = ["tetratto-core/sqlite"]
|
||||||
default = ["sqlite"]
|
default = ["sqlite"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pathbufd = "0.1.4"
|
pathbufd = "0.1.4"
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
tera = "1.20.0"
|
tera = "1.20.0"
|
||||||
toml = "0.8.20"
|
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
tower-http = { version = "0.6.2", features = ["trace", "fs"] }
|
tower-http = { version = "0.6.2", features = ["trace", "fs"] }
|
||||||
axum = { version = "0.8.1", features = ["macros"] }
|
axum = { version = "0.8.1", features = ["macros"] }
|
||||||
tokio = { version = "1.44.0", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] }
|
||||||
rainbeam-shared = "1.0.1"
|
|
||||||
serde_json = "1.0.140"
|
|
||||||
axum-extra = { version = "0.10.0", features = ["cookie", "multipart"] }
|
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"
|
image = "0.25.5"
|
||||||
mime_guess = "2.0.5"
|
reqwest = "0.12.15"
|
||||||
|
regex = "1.11.1"
|
||||||
|
|
|
@ -1,12 +1,21 @@
|
||||||
use pathbufd::PathBufD;
|
use pathbufd::PathBufD;
|
||||||
|
use regex::Regex;
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fs::{exists, read_to_string, write},
|
||||||
|
sync::LazyLock,
|
||||||
|
};
|
||||||
use tera::Context;
|
use tera::Context;
|
||||||
use tetratto_core::{config::Config, model::auth::User};
|
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
|
// images
|
||||||
pub const DEFAULT_AVATAR: &str = include_str!("./public/images/default-avatar.svg");
|
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 DEFAULT_BANNER: &str = include_str!("./public/images/default-banner.svg");
|
||||||
|
pub const FAVICON: &str = include_str!("./public/images/favicon.svg");
|
||||||
|
|
||||||
// css
|
// css
|
||||||
pub const STYLE_CSS: &str = include_str!("./public/css/style.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_LOGIN: &str = include_str!("./public/html/auth/login.html");
|
||||||
pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.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.
|
/// Set up public directories.
|
||||||
pub(crate) fn write_assets(html_path: &PathBufD) {
|
pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
||||||
write_template!(html_path->"root.html"(crate::assets::ROOT));
|
let html_path = PathBufD::current().join(&config.dirs.templates);
|
||||||
write_template!(html_path->"macros.html"(crate::assets::MACROS));
|
|
||||||
|
|
||||||
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->"misc/index.html"(crate::assets::MISC_INDEX) -d "misc" --config=config);
|
||||||
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->"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.
|
/// 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();
|
let mut ctx = Context::new();
|
||||||
ctx.insert("config", &config);
|
ctx.insert("config", &config);
|
||||||
ctx.insert("user", &user);
|
ctx.insert("user", &user);
|
||||||
|
ctx.insert("lang", &lang);
|
||||||
ctx
|
ctx
|
||||||
}
|
}
|
||||||
|
|
6
crates/app/src/langs/en-US.toml
Normal file
6
crates/app/src/langs/en-US.toml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
name = "com.tetratto.langs:en-US"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
[data]
|
||||||
|
"general:action.login" = "Login"
|
||||||
|
"general:action.register" = "Register"
|
|
@ -1,16 +1,46 @@
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! write_template {
|
macro_rules! write_template {
|
||||||
($html_path:ident->$path:literal($as:expr)) => {
|
($into:ident->$path:literal($as:expr)) => {
|
||||||
std::fs::write($html_path.join($path), $as).unwrap();
|
std::fs::write($into.join($path), $as).unwrap();
|
||||||
};
|
};
|
||||||
|
|
||||||
($html_path:ident->$path:literal($as:expr) -d $dir_path:literal) => {
|
($into:ident->$path:literal($as:expr) --config=$config:ident) => {
|
||||||
let dir = $html_path.join($dir_path);
|
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() {
|
if !std::fs::exists(&dir).unwrap() {
|
||||||
std::fs::create_dir(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>) => {{
|
(($jar:ident, $db:expr) <optional>) => {{
|
||||||
if let Some(token) = $jar.get("__Secure-atto-token") {
|
if let Some(token) = $jar.get("__Secure-atto-token") {
|
||||||
match $db
|
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=", ""),
|
token.to_string().replace("__Secure-atto-token=", ""),
|
||||||
))
|
))
|
||||||
.await
|
.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()
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
|
@ -3,11 +3,10 @@ mod avif;
|
||||||
mod macros;
|
mod macros;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
use assets::write_assets;
|
use assets::{init_dirs, write_assets};
|
||||||
pub use tetratto_core::*;
|
pub use tetratto_core::*;
|
||||||
|
|
||||||
use axum::{Extension, Router};
|
use axum::{Extension, Router};
|
||||||
use pathbufd::PathBufD;
|
|
||||||
use tera::Tera;
|
use tera::Tera;
|
||||||
use tower_http::trace::{self, TraceLayer};
|
use tower_http::trace::{self, TraceLayer};
|
||||||
use tracing::{Level, info};
|
use tracing::{Level, info};
|
||||||
|
@ -26,24 +25,9 @@ async fn main() {
|
||||||
|
|
||||||
let config = config::Config::get_config();
|
let config = config::Config::get_config();
|
||||||
|
|
||||||
// ...
|
// init
|
||||||
create_dir_if_not_exists!(&config.dirs.media);
|
init_dirs(&config).await;
|
||||||
let images_path =
|
let html_path = write_assets(&config).await;
|
||||||
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);
|
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
|
|
@ -132,9 +132,17 @@ footer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* typo */
|
/* typo */
|
||||||
.icon {
|
svg.icon {
|
||||||
color: inherit;
|
|
||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.icon.filled {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
button svg {
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
hr {
|
hr {
|
||||||
|
@ -666,9 +674,7 @@ dialog[open] {
|
||||||
.dropdown .inner button {
|
.dropdown .inner button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.25rem var(--horizontal-padding);
|
padding: 0.25rem var(--horizontal-padding);
|
||||||
/* transition:
|
transition: none !important;
|
||||||
background 0.1s,
|
|
||||||
transform 0.15s; */
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends "auth/base.html" %} {% block head %}
|
{% extends "auth/base.html" %} {% block head %}
|
||||||
<title>🐐 Login</title>
|
<title>Login</title>
|
||||||
{% endblock %} {% block title %}Login{% endblock %} {% block content %}
|
{% endblock %} {% block title %}Login{% endblock %} {% block content %}
|
||||||
<form class="w-full flex flex-col gap-4">
|
<form class="w-full flex flex-col gap-4">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends "auth/base.html" %} {% block head %}
|
{% extends "auth/base.html" %} {% block head %}
|
||||||
<title>🐐 Register</title>
|
<title>Register</title>
|
||||||
{% endblock %} {% block title %}Register{% endblock %} {% block content %}
|
{% endblock %} {% block title %}Register{% endblock %} {% block content %}
|
||||||
<form class="w-full flex flex-col gap-4" onsubmit="register(event)">
|
<form class="w-full flex flex-col gap-4" onsubmit="register(event)">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
|
|
|
@ -10,14 +10,17 @@
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="button {% if selected == 'home' %}active{% endif %}"
|
class="button {% if selected == 'home' %}active{% endif %}"
|
||||||
>Home</a
|
|
||||||
>
|
>
|
||||||
|
{{ icon "house" }}
|
||||||
|
<span class="desktop">Home</span>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex nav_side">
|
<div class="flex nav_side">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
|
<!-- prettier-ignore -->
|
||||||
<button
|
<button
|
||||||
class="flex-row title"
|
class="flex-row title"
|
||||||
onclick="trigger('atto::hooks::dropdown', [event])"
|
onclick="trigger('atto::hooks::dropdown', [event])"
|
||||||
|
@ -25,11 +28,30 @@
|
||||||
style="gap: 0.25rem !important"
|
style="gap: 0.25rem !important"
|
||||||
>
|
>
|
||||||
{{ macros::avatar(username=user.username, size="24px") }}
|
{{ macros::avatar(username=user.username, size="24px") }}
|
||||||
|
{{ icon "chevron-down" c(dropdown-arrow) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/auth/login" class="button">Login</a>
|
<div class="dropdown">
|
||||||
<a href="/auth/register" class="button">Register</a>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,6 +5,13 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
<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" />
|
<link rel="stylesheet" href="/css/style.css" />
|
||||||
|
|
||||||
<script src="/js/loader.js"></script>
|
<script src="/js/loader.js"></script>
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
<svg width="460" height="460" viewBox="0 0 460 460" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<rect width="460" height="460" fill="#C9B1BC"/>
|
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>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 159 B After Width: | Height: | Size: 181 B |
|
@ -1,3 +1,9 @@
|
||||||
<svg width="460" height="460" viewBox="0 0 460 460" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<rect width="460" height="460" fill="#C9B1BC"/>
|
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>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 159 B After Width: | Height: | Size: 184 B |
53
crates/app/src/public/images/favicon.svg
Normal file
53
crates/app/src/public/images/favicon.svg
Normal 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 |
|
@ -1,5 +1,10 @@
|
||||||
use axum::response::IntoResponse;
|
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`
|
/// `/css/style.css`
|
||||||
pub async fn style_css_request() -> impl IntoResponse {
|
pub async fn style_css_request() -> impl IntoResponse {
|
||||||
([("Content-Type", "text/css")], crate::assets::STYLE_CSS)
|
([("Content-Type", "text/css")], crate::assets::STYLE_CSS)
|
||||||
|
|
|
@ -15,9 +15,10 @@ pub fn routes(config: &Config) -> Router {
|
||||||
.route("/js/atto.js", get(assets::atto_js_request))
|
.route("/js/atto.js", get(assets::atto_js_request))
|
||||||
.route("/js/loader.js", get(assets::loader_js_request))
|
.route("/js/loader.js", get(assets::loader_js_request))
|
||||||
.nest_service(
|
.nest_service(
|
||||||
"/static",
|
"/public",
|
||||||
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
|
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
|
||||||
)
|
)
|
||||||
|
.route("/public/favicon.svg", get(assets::favicon_request))
|
||||||
// api
|
// api
|
||||||
.nest("/api/v1", api::v1::routes())
|
.nest("/api/v1", api::v1::routes())
|
||||||
// pages
|
// pages
|
||||||
|
|
|
@ -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::{
|
use axum::{
|
||||||
Extension,
|
Extension,
|
||||||
response::{Html, IntoResponse, Redirect},
|
response::{Html, IntoResponse, Redirect},
|
||||||
|
@ -14,7 +14,9 @@ pub async fn login_request(jar: CookieJar, Extension(data): Extension<State>) ->
|
||||||
return Err(Redirect::to("/"));
|
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(
|
Ok(Html(
|
||||||
data.1.render("auth/login.html", &mut context).unwrap(),
|
data.1.render("auth/login.html", &mut context).unwrap(),
|
||||||
))
|
))
|
||||||
|
@ -32,7 +34,9 @@ pub async fn register_request(
|
||||||
return Err(Redirect::to("/"));
|
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(
|
Ok(Html(
|
||||||
data.1.render("auth/register.html", &mut context).unwrap(),
|
data.1.render("auth/register.html", &mut context).unwrap(),
|
||||||
))
|
))
|
||||||
|
|
|
@ -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::{
|
use axum::{
|
||||||
Extension,
|
Extension,
|
||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse},
|
||||||
|
@ -10,6 +10,8 @@ pub async fn index_request(jar: CookieJar, Extension(data): Extension<State>) ->
|
||||||
let data = data.read().await;
|
let data = data.read().await;
|
||||||
let user = get_user_from_token!((jar, data.0) <optional>);
|
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())
|
Html(data.1.render("misc/index.html", &mut context).unwrap())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
[package]
|
[package]
|
||||||
name = "tetratto_core"
|
name = "tetratto-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
@ -11,16 +11,10 @@ default = ["sqlite"]
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pathbufd = "0.1.4"
|
pathbufd = "0.1.4"
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
tera = "1.20.0"
|
|
||||||
toml = "0.8.20"
|
toml = "0.8.20"
|
||||||
tracing = "0.1.41"
|
tetratto-shared = { path = "../shared" }
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
tetratto-l10n = { path = "../l10n" }
|
||||||
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"
|
serde_json = "1.0.140"
|
||||||
axum-extra = { version = "0.10.0", features = ["cookie"] }
|
|
||||||
|
|
||||||
rusqlite = { version = "0.34.0", optional = true }
|
rusqlite = { version = "0.34.0", optional = true }
|
||||||
|
|
|
@ -51,6 +51,9 @@ pub struct DirsConfig {
|
||||||
/// Media (user avatars/banners) files directory.
|
/// Media (user avatars/banners) files directory.
|
||||||
#[serde(default = "default_dir_media")]
|
#[serde(default = "default_dir_media")]
|
||||||
pub media: String,
|
pub media: String,
|
||||||
|
/// The icons files directory.
|
||||||
|
#[serde(default = "default_dir_icons")]
|
||||||
|
pub icons: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_dir_templates() -> String {
|
fn default_dir_templates() -> String {
|
||||||
|
@ -65,12 +68,17 @@ fn default_dir_media() -> String {
|
||||||
"media".to_string()
|
"media".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_dir_icons() -> String {
|
||||||
|
"icons".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for DirsConfig {
|
impl Default for DirsConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
templates: default_dir_templates(),
|
templates: default_dir_templates(),
|
||||||
assets: default_dir_assets(),
|
assets: default_dir_assets(),
|
||||||
media: default_dir_media(),
|
media: default_dir_media(),
|
||||||
|
icons: default_dir_icons(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -122,8 +130,13 @@ pub struct Config {
|
||||||
/// The locations where different files should be matched.
|
/// The locations where different files should be matched.
|
||||||
#[serde(default = "default_dirs")]
|
#[serde(default = "default_dirs")]
|
||||||
pub dirs: DirsConfig,
|
pub dirs: DirsConfig,
|
||||||
|
/// Database configuration.
|
||||||
#[serde(default = "default_database")]
|
#[serde(default = "default_database")]
|
||||||
pub database: DatabaseConfig,
|
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 {
|
fn default_name() -> String {
|
||||||
|
@ -153,6 +166,10 @@ fn default_database() -> DatabaseConfig {
|
||||||
DatabaseConfig::default()
|
DatabaseConfig::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_no_track() -> Vec<String> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
@ -163,6 +180,7 @@ impl Default for Config {
|
||||||
database: default_database(),
|
database: default_database(),
|
||||||
security: default_security(),
|
security: default_security(),
|
||||||
dirs: default_dirs(),
|
dirs: default_dirs(),
|
||||||
|
no_track: default_no_track(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,7 +2,7 @@ use super::*;
|
||||||
use crate::model::{Error, Result};
|
use crate::model::{Error, Result};
|
||||||
use crate::{execute, get, query_row};
|
use crate::{execute, get, query_row};
|
||||||
|
|
||||||
use rainbeam_shared::hash::hash_salted;
|
use tetratto_shared::hash::hash_salted;
|
||||||
|
|
||||||
#[cfg(feature = "sqlite")]
|
#[cfg(feature = "sqlite")]
|
||||||
use rusqlite::Row;
|
use rusqlite::Row;
|
|
@ -3,18 +3,23 @@ use bb8_postgres::{
|
||||||
PostgresConnectionManager,
|
PostgresConnectionManager,
|
||||||
bb8::{Pool, PooledConnection},
|
bb8::{Pool, PooledConnection},
|
||||||
};
|
};
|
||||||
|
use tetratto_l10n::{LangFile, read_langs};
|
||||||
use tokio_postgres::{Config as PgConfig, NoTls, Row, types::ToSql};
|
use tokio_postgres::{Config as PgConfig, NoTls, Row, types::ToSql};
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, tokio_postgres::Error>;
|
pub type Result<T> = std::result::Result<T, tokio_postgres::Error>;
|
||||||
pub type Connection<'a> = PooledConnection<'a, PostgresConnectionManager<NoTls>>;
|
pub type Connection<'a> = PooledConnection<'a, PostgresConnectionManager<NoTls>>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[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 {
|
impl DataManager {
|
||||||
/// Obtain a connection to the staging database.
|
/// Obtain a connection to the staging database.
|
||||||
pub(crate) async fn connect(&self) -> Result<Connection> {
|
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).
|
/// 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 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 c = this.clone();
|
||||||
let conn = c.connect().await?;
|
let conn = c.connect().await?;
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use rusqlite::{Connection, Result};
|
use rusqlite::{Connection, Result};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tetratto_l10n::{LangFile, read_langs};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DataManager(pub Config);
|
pub struct DataManager(pub Config, pub HashMap<String, LangFile>);
|
||||||
|
|
||||||
impl DataManager {
|
impl DataManager {
|
||||||
/// Obtain a connection to the staging database.
|
/// Obtain a connection to the staging database.
|
||||||
|
@ -12,7 +14,7 @@ impl DataManager {
|
||||||
|
|
||||||
/// Create a new [`DataManager`] (and init database).
|
/// Create a new [`DataManager`] (and init database).
|
||||||
pub async fn new(config: Config) -> Result<Self> {
|
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?;
|
let conn = this.connect().await?;
|
||||||
|
|
||||||
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
|
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
|
|
@ -1,9 +1,9 @@
|
||||||
use rainbeam_shared::{
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tetratto_shared::{
|
||||||
hash::{hash_salted, salt},
|
hash::{hash_salted, salt},
|
||||||
snow::AlmostSnowflake,
|
snow::AlmostSnowflake,
|
||||||
unix_epoch_timestamp,
|
unix_epoch_timestamp,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// `(ip, token, creation timestamp)`
|
/// `(ip, token, creation timestamp)`
|
||||||
pub type Token = (String, String, usize);
|
pub type Token = (String, String, usize);
|
||||||
|
@ -53,12 +53,12 @@ impl User {
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// `(unhashed id, token)`
|
/// `(unhashed id, token)`
|
||||||
pub fn create_token(ip: &str) -> (String, Token) {
|
pub fn create_token(ip: &str) -> (String, Token) {
|
||||||
let unhashed = rainbeam_shared::hash::uuid();
|
let unhashed = tetratto_shared::hash::uuid();
|
||||||
(
|
(
|
||||||
unhashed.clone(),
|
unhashed.clone(),
|
||||||
(
|
(
|
||||||
ip.to_string(),
|
ip.to_string(),
|
||||||
rainbeam_shared::hash::hash(unhashed),
|
tetratto_shared::hash::hash(unhashed),
|
||||||
unix_epoch_timestamp() as usize,
|
unix_epoch_timestamp() as usize,
|
||||||
),
|
),
|
||||||
)
|
)
|
12
crates/l10n/Cargo.toml
Normal file
12
crates/l10n/Cargo.toml
Normal 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
95
crates/l10n/src/lib.rs
Normal 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
16
crates/shared/Cargo.toml
Normal 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
38
crates/shared/src/hash.rs
Normal 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
5
crates/shared/src/lib.rs
Normal 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
52
crates/shared/src/snow.rs
Normal 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
23
crates/shared/src/time.rs
Normal 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
2
example/.gitignore
vendored
|
@ -7,3 +7,5 @@ public/*
|
||||||
!public/.gitkeep
|
!public/.gitkeep
|
||||||
|
|
||||||
media/*
|
media/*
|
||||||
|
icons/*
|
||||||
|
langs/*
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue