add: postgres support

chore: restructure
This commit is contained in:
trisua 2025-03-22 22:17:47 -04:00
parent cda879f6df
commit b6fe2fba37
58 changed files with 3403 additions and 603 deletions

803
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,18 @@
[package] [workspace]
name = "tetratto" resolver = "3"
version = "0.1.0" members = ["crates/app", "crates/tetratto_core"]
edition = "2024" package.authors = ["trisuaso"]
package.repository = "https://github.com/trisuaso/tetratto"
package.license = "AGPL-3.0-or-later"
[dependencies] [profile.dev]
pathbufd = "0.1.4" incremental = true
rusqlite = "0.34.0"
serde = { version = "1.0.219", features = ["derive"] } [profile.release]
tera = "1.20.0" opt-level = "z"
toml = "0.8.20" lto = true
tracing = "0.1.41" codegen-units = 1
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } # panic = "abort"
tower-http = { version = "0.6.2", features = ["trace", "fs"] } panic = "unwind"
axum = { version = "0.8.1", features = ["macros"] } strip = true
tokio = { version = "1.44.0", features = ["macros", "rt-multi-thread"] } incremental = true
rainbeam-shared = "1.0.1"
serde_json = "1.0.140"
axum-extra = { version = "0.10.0", features = ["cookie"] }

View file

@ -4,15 +4,11 @@ This is the year of the personal website.
Tetratto (`4 * 10^-18`) is a _super_ simple **dynamic** site server which takes in a conglomeration of HTML files (which are actually Jinja templates) and static files like CSS and JS, then serves them! Tetratto (`4 * 10^-18`) is a _super_ simple **dynamic** site server which takes in a conglomeration of HTML files (which are actually Jinja templates) and static files like CSS and JS, then serves them!
You _might_ by wondering: "why dynamic and not just generate a static site then?" Well the answer is simple! I needed something to manage my server remotely through my browser, and most things were just overly complicated for this simple feat.
## Features ## Features
- Templated HTML files (`html/` directory) - Templated HTML files (`html/` directory)
- Markdown posts (`posts/` directory, served with `html/post.html` template) - Markdown posts (`posts/` directory, served with `html/post.html` template)
- Super simple SQLite database for authentication (and other stuff) - Super simple SQLite database for authentication (and other stuff)
- Web terminal for managing your server
- Must be enabled in `tetratto.toml` in project root
## Usage ## Usage

26
crates/app/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "tetratto"
version = "0.1.0"
edition = "2024"
[features]
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"
axum-extra = { version = "0.10.0", features = ["cookie", "multipart"] }
tetratto_core = { path = "../tetratto_core", default-features = false }
image = "0.25.5"
mime_guess = "2.0.5"

1
crates/app/LICENSE Symbolic link
View file

@ -0,0 +1 @@
../../LICENSE

48
crates/app/src/assets.rs Normal file
View file

@ -0,0 +1,48 @@
use pathbufd::PathBufD;
use tera::Context;
use tetratto_core::{config::Config, model::auth::User};
use crate::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");
// css
pub const STYLE_CSS: &str = include_str!("./public/css/style.css");
// js
pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
pub const LOADER_JS: &str = include_str!("./public/js/loader.js");
// html
pub const ROOT: &str = include_str!("./public/html/root.html");
pub const MACROS: &str = include_str!("./public/html/macros.html");
pub const MISC_INDEX: &str = include_str!("./public/html/misc/index.html");
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");
// ...
/// 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));
write_template!(html_path->"misc/index.html"(crate::assets::MISC_INDEX) -d "misc");
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));
}
/// Create the initial template context.
pub(crate) fn initial_context(config: &Config, user: &Option<User>) -> Context {
let mut ctx = Context::new();
ctx.insert("config", &config);
ctx.insert("user", &user);
ctx
}

82
crates/app/src/avif.rs Normal file
View file

@ -0,0 +1,82 @@
use axum::{
body::Bytes,
extract::{FromRequest, Request},
http::{StatusCode, header::CONTENT_TYPE},
};
use axum_extra::extract::Multipart;
use std::{fs::File, io::BufWriter};
/// An image extractor accepting:
/// * `multipart/form-data`
/// * `image/png`
/// * `image/jpeg`
/// * `image/avif`
/// * `image/webp`
pub struct Image(pub Bytes);
impl<S> FromRequest<S> for Image
where
Bytes: FromRequest<S>,
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let Some(content_type) = req.headers().get(CONTENT_TYPE) else {
return Err(StatusCode::BAD_REQUEST);
};
let body = if content_type
.to_str()
.unwrap()
.starts_with("multipart/form-data")
{
let mut multipart = Multipart::from_request(req, state)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
let Ok(Some(field)) = multipart.next_field().await else {
return Err(StatusCode::BAD_REQUEST);
};
field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?
} else if (content_type == "image/avif")
| (content_type == "image/jpeg")
| (content_type == "image/png")
| (content_type == "image/webp")
{
Bytes::from_request(req, state)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?
} else {
return Err(StatusCode::BAD_REQUEST);
};
Ok(Self(body))
}
}
/// Create an AVIF buffer given an input of `bytes`
pub fn save_avif_buffer(path: &str, bytes: Vec<u8>) -> std::io::Result<()> {
let pre_img_buffer = match image::load_from_memory(&bytes) {
Ok(i) => i,
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Image failed",
));
}
};
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
if let Err(_) = pre_img_buffer.write_to(&mut writer, image::ImageFormat::Avif) {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
"Image conversion failed",
));
};
Ok(())
}

54
crates/app/src/macros.rs Normal file
View file

@ -0,0 +1,54 @@
#[macro_export]
macro_rules! write_template {
($html_path:ident->$path:literal($as:expr)) => {
std::fs::write($html_path.join($path), $as).unwrap();
};
($html_path:ident->$path:literal($as:expr) -d $dir_path:literal) => {
let dir = $html_path.join($dir_path);
if !std::fs::exists(&dir).unwrap() {
std::fs::create_dir(dir).unwrap();
}
std::fs::write($html_path.join($path), $as).unwrap();
};
}
#[macro_export]
macro_rules! create_dir_if_not_exists {
($dir_path:expr) => {
if !std::fs::exists(&$dir_path).unwrap() {
std::fs::create_dir($dir_path).unwrap();
}
};
}
#[macro_export]
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(
token.to_string().replace("__Secure-atto-token=", ""),
))
.await
{
Ok(ua) => Some(ua),
Err(_) => None,
}
} else {
None
}
}};
($jar:ident, $db:ident) => {{
if let Some(token) = $jar.get("__Secure-Atto-Token") {
match $db.get_user_by_token(token) {
Ok(ua) => ua,
Err(_) => return axum::response::Html(crate::data::assets::REDIRECT_TO_AUTH),
}
} else {
None
}
}};
}

68
crates/app/src/main.rs Normal file
View file

@ -0,0 +1,68 @@
mod assets;
mod avif;
mod macros;
mod routes;
use assets::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};
use std::sync::Arc;
use tokio::sync::RwLock;
pub(crate) type State = Arc<RwLock<(DataManager, Tera)>>;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_target(false)
.compact()
.init();
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);
// ...
let app = Router::new()
.merge(routes::routes(&config))
.layer(Extension(Arc::new(RwLock::new((
DataManager::new(config.clone()).await.unwrap(),
Tera::new(&format!("{html_path}/**/*")).unwrap(),
)))))
.layer(
TraceLayer::new_for_http()
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
.on_response(trace::DefaultOnResponse::new().level(Level::INFO)),
);
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port))
.await
.unwrap();
info!("🐐 tetratto.");
info!("listening on http://0.0.0.0:{}", config.port);
axum::serve(listener, app).await.unwrap();
}

View file

@ -132,6 +132,11 @@ footer {
} }
/* typo */ /* typo */
.icon {
color: inherit;
stroke: currentColor;
}
hr { hr {
border-top: 1px var(--color-super-lowered); border-top: 1px var(--color-super-lowered);
} }
@ -566,6 +571,358 @@ nav .button:not(.title):not(.active):hover {
} }
} }
/* dialog */
dialog {
padding: 0;
position: fixed;
bottom: 0;
top: 0;
display: flex;
background: var(--color-surface);
border: solid 1px var(--color-super-lowered) !important;
border-radius: var(--radius);
max-width: 100%;
border-style: none;
display: none;
margin: auto;
color: var(--color-text);
animation: popin ease-in-out 1 0.1s forwards running;
}
dialog .inner {
padding: 1rem;
width: 25rem;
max-width: 100%;
}
dialog .inner hr:not(.flipped):last-of-type {
/* options separator */
margin-top: 2rem;
}
dialog .inner hr.flipped:last-of-type {
margin-bottom: 2rem;
}
dialog[open] {
display: block;
}
/* dropdown */
.dropdown {
position: relative;
}
.dropdown .inner {
--horizontal-padding: 1.25rem;
display: none;
position: absolute;
background: var(--color-raised);
z-index: 2;
border-radius: var(--radius);
top: calc(100% + 5px);
right: 0;
width: max-content;
min-width: 10rem;
max-width: 100dvw;
max-height: 80dvh;
overflow: auto;
padding: 0.5rem 0;
box-shadow: 0 0 8px 2px var(--color-shadow);
}
.dropdown .inner.top {
top: unset;
bottom: calc(100% + 5px);
}
.dropdown .inner.left {
left: 0;
right: unset;
}
.dropdown .inner.open {
display: flex;
flex-direction: column;
}
.dropdown .inner .title {
padding: 0.25rem var(--horizontal-padding);
font-size: 13px;
opacity: 50%;
color: var(--color-text-raised);
text-align: left;
}
.dropdown .inner b.title {
font-weight: 600;
}
.dropdown .inner .title:not(:first-of-type) {
padding-top: 0.5rem;
}
.dropdown .inner a,
.dropdown .inner button {
width: 100%;
padding: 0.25rem var(--horizontal-padding);
/* transition:
background 0.1s,
transform 0.15s; */
text-decoration: none;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
color: var(--color-text-raised);
box-shadow: none !important;
background: transparent;
border-radius: 0 !important;
font-size: 13px;
min-height: 30px !important;
height: 30px !important;
font-weight: 500 !important;
position: relative;
opacity: 100% !important;
& svg {
width: 16px;
height: 16px;
aspect-ratio: 1 / 1;
}
}
.dropdown .inner a:hover,
.dropdown .inner button:hover {
background-color: var(--color-lowered);
}
.dropdown .inner a:focus,
.dropdown .inner button:focus {
outline: none;
}
.dropdown:not(nav *):has(.inner.open) button:not(.inner button) {
color: var(--color-text) !important;
background: var(--color-lowered) !important;
}
.dropdown:not(nav *):has(.inner.open) button.primary:not(.inner button) {
color: var(--color-text-primary) !important;
background: var(--color-primary-lowered) !important;
}
.dropdown button .icon {
transition: transform 0.15s;
}
.dropdown:has(.inner.open) .dropdown-arrow {
transform: rotateZ(180deg);
}
/* toasts */
#toast_zone {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
position: fixed;
bottom: 0.5rem;
right: 0.5rem;
z-index: 6880;
width: calc(100% - 1rem);
pointer-events: none;
}
.toast {
box-shadow: 0 0 8px var(--color-shadow);
width: max-content;
border-radius: var(--radius);
padding: 0.75rem 1rem;
animation: popin ease-in-out 1 0.15s running;
display: flex;
justify-content: space-between;
gap: 1rem;
}
.toast.success {
background: rgb(41, 81, 56);
color: rgb(134, 239, 172);
}
.toast.error {
background: rgb(81, 41, 41);
color: rgb(239, 134, 134);
}
.toast .timer {
font-family: monospace;
display: flex;
align-items: center;
justify-content: center;
min-width: max-content;
}
@keyframes popin {
from {
opacity: 0%;
transform: scale(0);
}
to {
opacity: 100%;
transform: scale(1);
}
}
@keyframes fadeout {
from {
opacity: 100%;
transform: scale(1);
}
to {
opacity: 0%;
transform: scale(0);
}
}
/* tag */
.tag {
font-size: 0.825rem;
font-family: monospace;
opacity: 75%;
color: inherit;
}
/* hook:long */
.hook\:long\.hidden_text {
position: relative;
cursor: pointer;
}
.hook\:long\.hidden_text::before {
content: "";
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
background: linear-gradient(transparent 50%, var(--color-raised));
}
.hook\:long\.hidden_text\+lowered::before {
background: linear-gradient(transparent 50%, var(--color-lowered));
}
.hook\:long\.hidden_text::after {
position: absolute;
content: "Show full content";
border-radius: calc(var(--radius) * 4);
padding: 0.25rem 0.75rem;
background: var(--color-primary);
font-weight: 600;
bottom: 20px;
opacity: 0%;
left: calc(50% - (180px / 2));
width: 156px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
transform: scale(0);
transition:
transform 0.15s,
opacity 0.25s;
box-shadow: 0 8px 16px var(--color-shadow);
color: var(--color-text-primary);
}
.hook\:long\.hidden_text:hover::after {
transform: scale(1);
opacity: 100%;
}
@media screen and (max-width: 900px) {
.hook\:long\.hidden_text::after {
transform: scale(1);
opacity: 100%;
}
}
/* turbo */
.turbo-progress-bar {
background: var(--color-primary);
}
/* details */
details summary {
display: flex;
align-items: center;
gap: 0.25rem;
transition: background 0.15s;
cursor: pointer;
width: max-content;
padding: 0.25rem 0.75rem;
border-radius: var(--radius);
background: var(--color-lowered);
}
details summary:hover {
background: var(--color-super-lowered);
}
details summary::-webkit-details-marker {
display: none;
}
details[open] summary {
background: hsla(var(--color-primary-hsl), 25%);
margin-bottom: 0.25rem;
}
details .card {
background: var(--color-super-raised);
}
details.accordion {
--background: var(--color-surface);
width: 100%;
}
details.accordion summary {
background: var(--background);
border: solid 1px var(--color-super-lowered);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin: 0;
width: 100%;
user-select: none;
}
details.accordion summary .icon {
transition: transform 0.15s;
}
details.accordion[open] summary .icon {
transform: rotateZ(180deg);
}
details.accordion[open] summary {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
details.accordion .inner {
background: var(--background);
padding: 0.75rem 1rem;
border-radius: var(--radius);
border-top-left-radius: 0;
border-top-right-radius: 0;
border: solid 1px var(--color-super-lowered);
border-top: none;
}
/* utility */ /* utility */
.flex { .flex {
display: flex; display: flex;

View file

@ -1,4 +1,4 @@
{% extends "_atto/root.html" %} {% block body %} {% extends "root.html" %} {% block body %}
<main class="flex flex-col gap-2" style="max-width: 25rem"> <main class="flex flex-col gap-2" style="max-width: 25rem">
<h2 class="w-full text-center">{% block title %}{% endblock %}</h2> <h2 class="w-full text-center">{% block title %}{% endblock %}</h2>
<div class="card w-full flex flex-col gap-4 justify-center align-center"> <div class="card w-full flex flex-col gap-4 justify-center align-center">

View file

@ -1,4 +1,4 @@
{% extends "_atto/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">
@ -28,6 +28,6 @@
</form> </form>
{% endblock %} {% block footer %} {% endblock %} {% block footer %}
<span class="small w-full text-center" <span class="small w-full text-center"
>Or, <a href="/_atto/register">register</a></span >Or, <a href="/auth/register">register</a></span
> >
{% endblock %} {% endblock %}

View file

@ -0,0 +1,62 @@
{% extends "auth/base.html" %} {% block head %}
<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">
<label for="username"><b>Username</b></label>
<input
type="text"
placeholder="username"
required
name="username"
id="username"
/>
</div>
<div class="flex flex-col gap-1">
<label for="username"><b>Password</b></label>
<input
type="password"
placeholder="password"
required
name="password"
id="password"
/>
</div>
<button>Submit</button>
</form>
<script>
function register(e) {
e.preventDefault();
fetch("/api/v1/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: e.target.username.value,
password: e.target.password.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "sucesss" : "error",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = "/";
}, 150);
}
});
}
</script>
{% endblock %} {% block footer %}
<span class="small w-full text-center"
>Or, <a href="/auth/login">login</a></span
>
{% endblock %}

View file

@ -0,0 +1,45 @@
{% macro nav(selected="", show_lhs=true) -%}
<nav>
<div class="content_container">
<div class="flex nav_side">
<a href="/" class="button desktop title">
<b>{{ config.name }}</b>
</a>
{% if show_lhs %}
<a
href="/"
class="button {% if selected == 'home' %}active{% endif %}"
>Home</a
>
{% endif %}
</div>
<div class="flex nav_side">
{% if user %}
<div class="dropdown">
<button
class="flex-row title"
onclick="trigger('atto::hooks::dropdown', [event])"
exclude="dropdown"
style="gap: 0.25rem !important"
>
{{ macros::avatar(username=user.username, size="24px") }}
</button>
</div>
{% else %}
<a href="/auth/login" class="button">Login</a>
<a href="/auth/register" class="button">Register</a>
{% endif %}
</div>
</div>
</nav>
{%- endmacro %} {% macro avatar(username, size="24px") -%}
<img
title="{{ username }}'s avatar"
src="/api/v1/auth/profile/{{ username }}/avatar"
alt="@{{ username }}"
class="avatar shadow"
style="--size: {{ size }}"
/>
{%- endmacro %}

View file

@ -1,20 +1,5 @@
{% extends "_atto/root.html" %} {% block body %} {% import "macros.html" as macros %} {% extends "root.html" %} {% block body %}
<nav> {{ macros::nav(selected="home") }}
<div class="content_container">
<div class="flex nav_side">
<a href="/" class="button desktop title">
<b>{{ name }}</b>
</a>
<a href="/" class="button active">Home</a>
</div>
<div class="flex nav_side">
<a href="/_atto/login" class="button">Login</a>
<a href="/_atto/register" class="button">Register</a>
</div>
</div>
</nav>
<main class="flex flex-col gap-2"> <main class="flex flex-col gap-2">
<h1>Hello, world!</h1> <h1>Hello, world!</h1>

View file

@ -0,0 +1,73 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<link rel="stylesheet" href="/css/style.css" />
<script src="/js/loader.js"></script>
<script defer async src="/js/atto.js"></script>
<script>
globalThis.ns_verbose = false;
globalThis.ns_config = {
root: "/js/",
verbose: globalThis.ns_verbose,
};
globalThis._app_base = {
name: "tetratto",
ns_store: {},
classes: {},
};
globalThis.no_policy = false;
</script>
<meta name="theme-color" content="{{ config.color }}" />
<meta name="description" content="{{ config.description }}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="{{ config.name }}" />
<meta name="turbo-prefetch" content="false" />
<meta name="turbo-refresh-method" content="morph" />
<meta name="turbo-refresh-scroll" content="preserve" />
<script
src="https://unpkg.com/@hotwired/turbo@8.0.5/dist/turbo.es2017-esm.js"
type="module"
async
defer
></script>
{% block head %}{% endblock %}
</head>
<body>
<div id="toast_zone"></div>
{% block body %}{% endblock %}
<script data-turbo-permanent="true" id="init-script">
document.documentElement.addEventListener("turbo:load", () => {
const atto = ns("atto");
atto.disconnect_observers();
atto.clean_date_codes();
atto.link_filter();
atto["hooks::scroll"](document.body, document.documentElement);
atto["hooks::dropdown.init"](window);
atto["hooks::character_counter.init"]();
atto["hooks::long_text.init"]();
atto["hooks::alt"]();
// atto["hooks::ips"]();
atto["hooks::check_reactions"]();
atto["hooks::tabs"]();
atto["hooks::partial_embeds"]();
});
</script>
</body>
</html>

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 159 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 159 B

View file

@ -0,0 +1,618 @@
console.log("🐐 tetratto - https://github.com/trisuaso/tetratto");
// theme preference
function media_theme_pref() {
document.documentElement.removeAttribute("class");
if (
window.matchMedia("(prefers-color-scheme: dark)").matches &&
!window.localStorage.getItem("tetratto:theme")
) {
document.documentElement.classList.add("dark");
// window.localStorage.setItem("theme", "dark");
} else if (
window.matchMedia("(prefers-color-scheme: light)").matches &&
!window.localStorage.getItem("tetratto:theme")
) {
document.documentElement.classList.remove("dark");
// window.localStorage.setItem("theme", "light");
} else if (window.localStorage.getItem("tetratto:theme")) {
/* restore theme */
const current = window.localStorage.getItem("tetratto:theme");
document.documentElement.className = current;
}
}
function set_theme(theme) {
window.localStorage.setItem("tetratto:theme", theme);
document.documentElement.className = theme;
}
media_theme_pref();
// atto ns
(() => {
const self = reg_ns("atto");
// env
self.DEBOUNCE = [];
self.OBSERVERS = [];
// ...
self.define("try_use", (_, ns_name, callback) => {
// attempt to get existing namespace
if (globalThis._app_base.ns_store[`$${ns_name}`]) {
return callback(globalThis._app_base.ns_store[`$${ns_name}`]);
}
// otherwise, call normal use
use(ns_name, callback);
});
self.define("debounce", ({ $ }, name) => {
return new Promise((resolve, reject) => {
if ($.DEBOUNCE.includes(name)) {
return reject();
}
$.DEBOUNCE.push(name);
setTimeout(() => {
delete $.DEBOUNCE[$.DEBOUNCE.indexOf(name)];
}, 1000);
return resolve();
});
});
self.define("rel_date", (_, date) => {
// stolen and slightly modified because js dates suck
const diff = (new Date().getTime() - date.getTime()) / 1000;
const day_diff = Math.floor(diff / 86400);
if (Number.isNaN(day_diff) || day_diff < 0 || day_diff >= 31) {
return;
}
return (
(day_diff === 0 &&
((diff < 60 && "just now") ||
(diff < 120 && "1 minute ago") ||
// biome-ignore lint/style/useTemplate: ok
(diff < 3600 && Math.floor(diff / 60) + " minutes ago") ||
(diff < 7200 && "1 hour ago") ||
(diff < 86400 &&
// biome-ignore lint/style/useTemplate: ok
Math.floor(diff / 3600) + " hours ago"))) ||
(day_diff === 1 && "Yesterday") ||
// biome-ignore lint/style/useTemplate: ok
(day_diff < 7 && day_diff + " days ago") ||
// biome-ignore lint/style/useTemplate: ok
(day_diff < 31 && Math.ceil(day_diff / 7) + " weeks ago")
);
});
self.define("clean_date_codes", ({ $ }) => {
for (const element of Array.from(document.querySelectorAll(".date"))) {
if (element.getAttribute("data-unix")) {
// this allows us to run the function twice on the same page
// without errors from already rendered dates
element.innerText = element.getAttribute("data-unix");
}
element.setAttribute("data-unix", element.innerText);
const then = new Date(Number.parseInt(element.innerText));
if (Number.isNaN(element.innerText)) {
continue;
}
element.setAttribute("title", then.toLocaleString());
let pretty = $.rel_date(then);
if (screen.width < 900 && pretty !== undefined) {
// shorten dates even more for mobile
pretty = pretty
.replaceAll(" minutes ago", "m")
.replaceAll(" minute ago", "m")
.replaceAll(" hours ago", "h")
.replaceAll(" hour ago", "h")
.replaceAll(" days ago", "d")
.replaceAll(" day ago", "d")
.replaceAll(" weeks ago", "w")
.replaceAll(" week ago", "w")
.replaceAll(" months ago", "m")
.replaceAll(" month ago", "m")
.replaceAll(" years ago", "y")
.replaceAll(" year ago", "y");
}
element.innerText =
pretty === undefined ? then.toLocaleDateString() : pretty;
element.style.display = "inline-block";
}
});
self.define("copy_text", ({ $ }, text) => {
navigator.clipboard.writeText(text);
$.toast("success", "Copied!");
});
self.define("smooth_remove", (_, element, ms) => {
// run animation
element.style.animation = `fadeout ease-in-out 1 ${ms}ms forwards running`;
// remove
setTimeout(() => {
element.remove();
}, ms);
});
self.define("disconnect_observers", ({ $ }) => {
for (const observer of $.OBSERVERS) {
observer.disconnect();
}
$.OBSERVERS = [];
});
self.define("offload_work_to_client_when_in_view", (_, entry_callback) => {
// instead of spending the time on the server loading everything before
// returning the page, we can instead of just create an IntersectionObserver
// and send individual requests as we see the element it's needed for
const seen = [];
return new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const element = entry.target;
if (!entry.isIntersecting || seen.includes(element)) {
continue;
}
seen.push(element);
entry_callback(element);
}
},
{
root: document.body,
rootMargin: "0px",
threshold: 1.0,
},
);
});
self.define("toggle_flex", (_, element) => {
if (element.style.display === "none") {
element.style.display = "flex";
} else {
element.style.display = "none";
}
});
// hooks
self.define("hooks::scroll", (_, scroll_element, track_element) => {
const goals = [150, 250, 500, 1000];
track_element.setAttribute("data-scroll", "0");
scroll_element.addEventListener("scroll", (e) => {
track_element.setAttribute("data-scroll", scroll_element.scrollTop);
for (const goal of goals) {
const name = `data-scroll-${goal}`;
if (scroll_element.scrollTop >= goal) {
track_element.setAttribute(name, "true");
} else {
track_element.removeAttribute(name);
}
}
});
});
self.define("hooks::dropdown.close", (_) => {
for (const dropdown of Array.from(
document.querySelectorAll(".inner.open"),
)) {
dropdown.classList.remove("open");
}
});
self.define("hooks::dropdown", ({ $ }, event) => {
event.stopImmediatePropagation();
let target = event.target;
while (!target.matches(".dropdown")) {
target = target.parentElement;
}
// close all others
$["hooks::dropdown.close"]();
// open
setTimeout(() => {
for (const dropdown of Array.from(
target.querySelectorAll(".inner"),
)) {
// check y
const box = target.getBoundingClientRect();
let parent = dropdown.parentElement;
while (!parent.matches("html, .window")) {
parent = parent.parentElement;
}
let parent_height = parent.getBoundingClientRect().y;
if (parent.nodeName === "HTML") {
parent_height = window.screen.height;
}
const scroll = window.scrollY;
const height = parent_height;
const y = box.y + scroll;
if (y > height - scroll - 300) {
dropdown.classList.add("top");
} else {
dropdown.classList.remove("top");
}
// open
dropdown.classList.add("open");
if (dropdown.classList.contains("open")) {
dropdown.removeAttribute("aria-hidden");
} else {
dropdown.setAttribute("aria-hidden", "true");
}
}
}, 5);
});
self.define("hooks::dropdown.init", (_, bind_to) => {
for (const dropdown of Array.from(
document.querySelectorAll(".inner"),
)) {
dropdown.setAttribute("aria-hidden", "true");
}
bind_to.addEventListener("click", (event) => {
if (
event.target.matches(".dropdown") ||
event.target.matches("[exclude=dropdown]")
) {
return;
}
for (const dropdown of Array.from(
document.querySelectorAll(".inner.open"),
)) {
dropdown.classList.remove("open");
}
});
});
self.define("hooks::character_counter", (_, event) => {
let target = event.target;
while (!target.matches("textarea, input")) {
target = target.parentElement;
}
const counter = document.getElementById(`${target.id}:counter`);
counter.innerText = `${target.value.length}/${target.getAttribute("maxlength")}`;
});
self.define("hooks::character_counter.init", (_, event) => {
for (const element of Array.from(
document.querySelectorAll("[hook=counter]") || [],
)) {
const counter = document.getElementById(`${element.id}:counter`);
counter.innerText = `0/${element.getAttribute("maxlength")}`;
element.addEventListener("keyup", (e) =>
app["hooks::character_counter"](e),
);
}
});
self.define("hooks::long", (_, element, full_text) => {
element.classList.remove("hook:long.hidden_text");
element.innerHTML = full_text;
});
self.define("hooks::long_text.init", (_, event) => {
for (const element of Array.from(
document.querySelectorAll("[hook=long]") || [],
)) {
const is_long = element.innerText.length >= 64 * 16;
if (!is_long) {
continue;
}
element.classList.add("hook:long.hidden_text");
if (element.getAttribute("hook-arg") === "lowered") {
element.classList.add("hook:long.hidden_text+lowered");
}
const html = element.innerHTML;
const short = html.slice(0, 64 * 16);
element.innerHTML = `${short}...`;
// event
const listener = () => {
app["hooks::long"](element, html);
element.removeEventListener("click", listener);
};
element.addEventListener("click", listener);
}
});
self.define("hooks::alt", (_) => {
for (const element of Array.from(
document.querySelectorAll("img") || [],
)) {
if (element.getAttribute("alt") && !element.getAttribute("title")) {
element.setAttribute("title", element.getAttribute("alt"));
}
}
});
self.define(
"hooks::attach_to_partial",
({ $ }, partial, full, attach, wrapper, page, run_on_load) => {
return new Promise((resolve, reject) => {
async function load_partial() {
const url = `${partial}${partial.includes("?") ? "&" : "?"}page=${page}`;
history.replaceState(
history.state,
"",
url.replace(partial, full),
);
fetch(url)
.then(async (res) => {
const text = await res.text();
if (
text.length < 100 ||
text.includes('data-marker="no-results"')
) {
// pretty much blank content, no more pages
wrapper.removeEventListener("scroll", event);
return resolve();
}
attach.innerHTML += text;
$.clean_date_codes();
$.link_filter();
$["hooks::alt"]();
})
.catch(() => {
// done scrolling, no more pages (http error)
wrapper.removeEventListener("scroll", event);
resolve();
});
}
const event = async () => {
if (
wrapper.scrollTop + wrapper.offsetHeight + 100 >
attach.offsetHeight
) {
self.debounce("app::partials")
.then(async () => {
if (document.getElementById("initial_loader")) {
console.log("partial blocked");
return;
}
// biome-ignore lint/style/noParameterAssign: no it isn't
page += 1;
await load_partial();
await $["hooks::partial_embeds"]();
})
.catch(() => {
console.log("partial stuck");
});
}
};
wrapper.addEventListener("scroll", event);
});
},
);
self.define("hooks::partial_embeds", (_) => {
for (const paragraph of Array.from(
document.querySelectorAll("span[class] p"),
)) {
const groups = /(\/\+r\/)([\w]+)/.exec(paragraph.innerText);
if (groups === null) {
continue;
}
// add embed
paragraph.innerText = paragraph.innerText.replace(groups[0], "");
paragraph.parentElement.innerHTML += `<include-partial
src="/_app/components/response.html?id=${groups[2]}&do_render_nested=false"
uses="app::clean_date_codes,app::link_filter,app::hooks::alt"
></include-partial>`;
}
});
self.define("hooks::check_reactions", async ({ $ }) => {
const observer = $.offload_work_to_client_when_in_view(
async (element) => {
const reaction = await (
await fetch(
`/api/v1/reactions/${element.getAttribute("hook-arg:id")}`,
)
).json();
if (reaction.success) {
element.classList.add("green");
element.querySelector("svg").classList.add("filled");
}
},
);
for (const element of Array.from(
document.querySelectorAll("[hook=check_reaction]") || [],
)) {
observer.observe(element);
}
$.OBSERVERS.push(observer);
});
self.define("hooks::tabs:switch", (_, tab) => {
// tab
for (const element of Array.from(
document.querySelectorAll("[data-tab]"),
)) {
element.classList.add("hidden");
}
document
.querySelector(`[data-tab="${tab}"]`)
.classList.remove("hidden");
// button
if (document.querySelector(`[data-tab-button="${tab}"]`)) {
for (const element of Array.from(
document.querySelectorAll("[data-tab-button]"),
)) {
element.classList.remove("active");
}
document
.querySelector(`[data-tab-button="${tab}"]`)
.classList.add("active");
}
});
self.define("hooks::tabs:check", ({ $ }, hash) => {
if (!hash || !hash.startsWith("#/")) {
return;
}
$["hooks::tabs:switch"](hash.replace("#/", ""));
});
self.define("hooks::tabs", ({ $ }) => {
$["hooks::tabs:check"](window.location.hash); // initial check
window.addEventListener("hashchange", (event) =>
$["hooks::tabs:check"](new URL(event.newURL).hash),
);
});
// web api replacements
self.define("prompt", (_, msg) => {
const dialog = document.getElementById("web_api_prompt");
document.getElementById("web_api_prompt:msg").innerText = msg;
return new Promise((resolve, _) => {
globalThis.web_api_prompt_submit = (value) => {
dialog.close();
return resolve(value);
};
dialog.showModal();
});
});
self.define("prompt_long", (_, msg) => {
const dialog = document.getElementById("web_api_prompt_long");
document.getElementById("web_api_prompt_long:msg").innerText = msg;
return new Promise((resolve, _) => {
globalThis.web_api_prompt_long_submit = (value) => {
dialog.close();
return resolve(value);
};
dialog.showModal();
});
});
self.define("confirm", (_, msg) => {
const dialog = document.getElementById("web_api_confirm");
document.getElementById("web_api_confirm:msg").innerText = msg;
return new Promise((resolve, _) => {
globalThis.web_api_confirm_submit = (value) => {
dialog.close();
return resolve(value);
};
dialog.showModal();
});
});
// toast
self.define("toast", ({ $ }, type, content, time_until_remove = 5) => {
const element = document.createElement("div");
element.id = "toast";
element.classList.add(type);
element.classList.add("toast");
element.innerHTML = `<span>${content
.replaceAll("<", "&lt")
.replaceAll(">", "&gt;")}</span>`;
document.getElementById("toast_zone").prepend(element);
const timer = document.createElement("span");
element.appendChild(timer);
timer.innerText = time_until_remove;
timer.classList.add("timer");
// start timer
setTimeout(() => {
clearInterval(count_interval);
$.smooth_remove(element, 500);
}, time_until_remove * 1000);
const count_interval = setInterval(() => {
// biome-ignore lint/style/noParameterAssign: no it isn't
time_until_remove -= 1;
timer.innerText = time_until_remove;
}, 1000);
});
// link filter
self.define("link_filter", (_) => {
for (const anchor of Array.from(document.querySelectorAll("a"))) {
if (anchor.href.length === 0) {
continue;
}
const url = new URL(anchor.href);
if (
anchor.href.startsWith("/") ||
anchor.href.startsWith("javascript:") ||
url.origin === window.location.origin
) {
continue;
}
anchor.addEventListener("click", (e) => {
e.preventDefault();
document.getElementById("link_filter_url").innerText =
anchor.href;
document.getElementById("link_filter_continue").href =
anchor.href;
document.getElementById("link_filter").showModal();
});
}
});
})();

View file

@ -0,0 +1,205 @@
//! https://github.com/trisuaso/tetratto
globalThis.ns_config = globalThis.ns_config || {
root: "/static/js/",
version: 0,
verbose: true,
};
globalThis._app_base = globalThis._app_base || { ns_store: {}, classes: {} };
function regns_log(level, ...args) {
if (globalThis.ns_config.verbose) {
console[level](...args);
} else {
return;
}
}
/// Query an existing namespace
globalThis.ns = (ns) => {
regns_log("info", "namespace query:", ns);
// get namespace from app base
const res = globalThis._app_base.ns_store[`$${ns}`];
if (!res) {
return console.error(
"namespace does not exist, please use one of the following:",
Object.keys(globalThis._app_base.ns_store),
);
}
return res;
};
/// Register a new namespace
globalThis.reg_ns = (ns, deps) => {
if (typeof ns !== "string") {
return console.error("type check failed on namespace:", ns);
}
if (!ns) {
return console.error("cannot register invalid namespace!");
}
if (globalThis._app_base.ns_store[`$${ns}`]) {
regns_log("warn", "overwriting existing namespace:", ns);
}
// register new blank namespace
globalThis._app_base.ns_store[`$${ns}`] = {
_ident: ns,
_deps: deps || [],
/// Pull dependencies (other namespaces) as listed in the given `deps` argument
_get_deps: () => {
const self = globalThis._app_base.ns_store[`$${ns}`];
const deps = {};
for (const dep of self._deps) {
const res = globalThis.ns(dep);
if (!res) {
regns_log("warn", "failed to pull dependency:", dep);
continue;
}
deps[dep] = res;
}
deps.$ = self; // give access to self through $
return deps;
},
/// Store the real versions of functions
_fn_store: {},
/// Call a function in a namespace and load namespace dependencies
define: (name, func, types) => {
const self = globalThis.ns(ns);
self._fn_store[name] = func; // store real function
self[name] = function (...args) {
regns_log("info", "namespace call:", ns, name);
// js doesn't provide type checking, we do
if (types) {
for (const i in args) {
// biome-ignore lint: this is incorrect, you do not need a string literal to use typeof
if (types[i] && typeof args[i] !== types[i]) {
return console.error(
"argument does not pass type check:",
i,
args[i],
);
}
}
}
// ...
// we MUST return here, otherwise nothing will work in workers
return self._fn_store[name](self._get_deps(), ...args); // call with deps and arguments
};
},
};
regns_log("log", "registered namespace:", ns);
return globalThis._app_base.ns_store[`$${ns}`];
};
/// Call a namespace function quickly
globalThis.trigger = (id, args) => {
// get namespace
const s = id.split("::");
const [namespace, func] = [s[0], s.slice(1, s.length).join("::")];
const self = ns(namespace);
if (!self) {
return console.error("namespace does not exist:", namespace);
}
if (!self[func]) {
return console.error("namespace function does not exist:", id);
}
return self[func](...(args || []));
};
/// Import a namespace from path (relative to ns_config.root)
globalThis.use = (id, callback) => {
let file = id;
if (id.includes(".h.")) {
const split = id.split(".h.");
file = split[1];
}
// check if namespace already exists
const res = globalThis._app_base.ns_store[`$${file}`];
if (res) {
return callback(res);
}
// create script to load
const script = document.createElement("script");
script.src = `${globalThis.ns_config.root}${id}.js?v=${globalThis.ns_config.version}`;
script.id = `${globalThis.ns_config.version}-${file}.js`;
document.head.appendChild(script);
script.setAttribute("data-turbo-permanent", "true");
script.setAttribute("data-registered", new Date().toISOString());
script.setAttribute("data-version", globalThis.ns_config.version);
// run callback once the script loads
script.addEventListener("load", () => {
const res = globalThis._app_base.ns_store[`$${file}`];
if (!res) {
return console.error("imported namespace failed to register:", id);
}
callback(res);
});
};
// classes
/// Import a class from path (relative to ns_config.root/classes)
globalThis.require = (id, callback) => {
let file = id;
if (id.includes(".h.")) {
const split = id.split(".h.");
file = split[1];
}
// check if class already exists
const res = globalThis._app_base.classes[file];
if (res) {
return callback(res);
}
// create script to load
const script = document.createElement("script");
script.src = `${globalThis.ns_config.root}classes/${id}.js?v=${globalThis.ns_config.version}`;
script.id = `${globalThis.ns_config.version}-${file}.class.js`;
document.head.appendChild(script);
script.setAttribute("data-turbo-permanent", "true");
script.setAttribute("data-registered", new Date().toISOString());
script.setAttribute("data-version", globalThis.ns_config.version);
// run callback once the script loads
script.addEventListener("load", () => {
const res = globalThis._app_base.classes[file];
if (!res) {
return console.error("imported class failed to register:", id);
}
callback(res);
});
};
globalThis.define = (class_name, class_) => {
globalThis._app_base.classes[class_name] = class_;
regns_log("log", "registered class:", class_name);
};

View file

@ -0,0 +1,102 @@
use axum::{Extension, body::Body, extract::Path, response::IntoResponse};
use pathbufd::PathBufD;
use std::{
fs::{File, exists},
io::Read,
};
use crate::State;
pub fn read_image(path: PathBufD) -> Vec<u8> {
let mut bytes = Vec::new();
for byte in File::open(path).unwrap().bytes() {
bytes.push(byte.unwrap())
}
bytes
}
/// Get a profile's avatar image
/// `/api/v1/auth/profile/{id}/avatar`
pub async fn avatar_request(
Path(username): Path<String>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match data.get_user_by_username(&username).await {
Ok(ua) => ua,
Err(_) => {
return (
[("Content-Type", "image/svg+xml")],
Body::from(read_image(PathBufD::current().extend(&[
data.0.dirs.media.as_str(),
"images",
"default-avatar.svg",
]))),
);
}
};
let path =
PathBufD::current().extend(&["avatars", &data.0.dirs.media, &format!("{}.avif", &user.id)]);
if !exists(&path).unwrap() {
return (
[("Content-Type", "image/svg+xml")],
Body::from(read_image(PathBufD::current().extend(&[
data.0.dirs.media.as_str(),
"images",
"default-avatar.svg",
]))),
);
}
(
[("Content-Type", "image/avif")],
Body::from(read_image(path)),
)
}
/// Get a profile's banner image
/// `/api/v1/auth/profile/{id}/banner`
pub async fn banner_request(
Path(username): Path<String>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match data.get_user_by_username(&username).await {
Ok(ua) => ua,
Err(_) => {
return (
[("Content-Type", "image/svg+xml")],
Body::from(read_image(PathBufD::current().extend(&[
data.0.dirs.media.as_str(),
"images",
"default-banner.svg",
]))),
);
}
};
let path =
PathBufD::current().extend(&["avatars", &data.0.dirs.media, &format!("{}.avif", &user.id)]);
if !exists(&path).unwrap() {
return (
[("Content-Type", "image/svg+xml")],
Body::from(read_image(PathBufD::current().extend(&[
data.0.dirs.media.as_str(),
"images",
"default-banner.svg",
]))),
);
}
(
[("Content-Type", "image/avif")],
Body::from(read_image(path)),
)
}

View file

@ -0,0 +1,127 @@
pub mod images;
use super::AuthProps;
use crate::{
State, get_user_from_token,
model::{ApiReturn, Error, auth::User},
};
use axum::{
Extension, Json,
http::{HeaderMap, HeaderValue},
response::IntoResponse,
};
use axum_extra::extract::CookieJar;
/// `/api/v1/auth/register`
pub async fn register_request(
headers: HeaderMap,
jar: CookieJar,
Extension(data): Extension<State>,
Json(props): Json<AuthProps>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = get_user_from_token!((jar, data) <optional>);
if user.is_some() {
return (
None,
Json(ApiReturn {
ok: false,
message: Error::AlreadyAuthenticated.to_string(),
payload: (),
}),
);
}
// get real ip
let real_ip = headers
.get(data.0.security.real_ip_header.to_owned())
.unwrap_or(&HeaderValue::from_static(""))
.to_str()
.unwrap_or("")
.to_string();
// ...
let mut user = User::new(props.username, props.password);
let (initial_token, t) = User::create_token(&real_ip);
user.tokens.push(t);
// return
match data.create_user(user).await {
Ok(_) => (
Some([(
"Set-Cookie",
format!(
"__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}",
initial_token,
60 * 60 * 24 * 365
),
)]),
Json(ApiReturn {
ok: true,
message: "User created".to_string(),
payload: (),
}),
),
Err(e) => (None, Json(e.into())),
}
}
/// `/api/v1/auth/login`
pub async fn login_request(
headers: HeaderMap,
jar: CookieJar,
Extension(data): Extension<State>,
Json(props): Json<AuthProps>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = get_user_from_token!((jar, data) <optional>);
if user.is_some() {
return (None, Json(Error::AlreadyAuthenticated.into()));
}
// get real ip
let real_ip = headers
.get(data.0.security.real_ip_header.to_owned())
.unwrap_or(&HeaderValue::from_static(""))
.to_str()
.unwrap_or("")
.to_string();
// verify password
let user = match data.get_user_by_username(&props.username).await {
Ok(ua) => ua,
Err(_) => return (None, Json(Error::IncorrectPassword.into())),
};
if !user.check_password(props.password) {
return (None, Json(Error::IncorrectPassword.into()));
}
// update tokens
let mut new_tokens = user.tokens.clone();
let (unhashed_token_id, token) = User::create_token(&real_ip);
new_tokens.push(token);
if let Err(e) = data.update_user_tokens(user.id, new_tokens).await {
return (None, Json(e.into()));
}
// ...
(
Some([(
"Set-Cookie",
format!(
"__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}",
unhashed_token_id,
60 * 60 * 24 * 365
),
)]),
Json(ApiReturn {
ok: true,
message: unhashed_token_id,
payload: (),
}),
)
}

View file

@ -0,0 +1,28 @@
pub mod auth;
use axum::{
Router,
routing::{get, post},
};
use serde::Deserialize;
pub fn routes() -> Router {
Router::new()
// global
.route("/auth/register", post(auth::register_request))
.route("/auth/login", post(auth::login_request))
// profile
.route(
"/auth/profile/{id}/avatar",
get(auth::images::avatar_request),
)
.route(
"/auth/profile/{id}/banner",
get(auth::images::banner_request),
)
}
#[derive(Deserialize)]
pub struct AuthProps {
pub username: String,
pub password: String,
}

View file

@ -2,16 +2,21 @@ use axum::response::IntoResponse;
/// `/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::data::assets::STYLE_CSS,
)
} }
/// `/js/atto.js` /// `/js/atto.js`
pub async fn atto_js_request() -> impl IntoResponse { pub async fn atto_js_request() -> impl IntoResponse {
( (
[("Content-Type", "text/javascript")], [("Content-Type", "text/javascript")],
crate::data::assets::ATTO_JS, crate::assets::ATTO_JS,
)
}
/// `/js/atto.js`
pub async fn loader_js_request() -> impl IntoResponse {
(
[("Content-Type", "text/javascript")],
crate::assets::LOADER_JS,
) )
} }

View file

@ -0,0 +1,25 @@
pub mod api;
pub mod assets;
pub mod pages;
use crate::config::Config;
use axum::{
Router,
routing::{get, get_service},
};
pub fn routes(config: &Config) -> Router {
Router::new()
// assets
.route("/css/style.css", get(assets::style_css_request))
.route("/js/atto.js", get(assets::atto_js_request))
.route("/js/loader.js", get(assets::loader_js_request))
.nest_service(
"/static",
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
)
// api
.nest("/api/v1", api::v1::routes())
// pages
.merge(pages::routes())
}

View file

@ -0,0 +1,39 @@
use crate::{State, assets::initial_context, get_user_from_token};
use axum::{
Extension,
response::{Html, IntoResponse, Redirect},
};
use axum_extra::extract::CookieJar;
/// `/auth/login`
pub async fn login_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!((jar, data.0) <optional>);
if user.is_some() {
return Err(Redirect::to("/"));
}
let mut context = initial_context(&data.0.0, &user);
Ok(Html(
data.1.render("auth/login.html", &mut context).unwrap(),
))
}
/// `/auth/register`
pub async fn register_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!((jar, data.0) <optional>);
if user.is_some() {
return Err(Redirect::to("/"));
}
let mut context = initial_context(&data.0.0, &user);
Ok(Html(
data.1.render("auth/register.html", &mut context).unwrap(),
))
}

View file

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

View file

@ -0,0 +1,13 @@
pub mod auth;
pub mod misc;
use axum::{Router, routing::get};
pub fn routes() -> Router {
Router::new()
// misc
.route("/", get(misc::index_request))
// auth
.route("/auth/register", get(auth::register_request))
.route("/auth/login", get(auth::login_request))
}

View file

@ -0,0 +1,28 @@
[package]
name = "tetratto_core"
version = "0.1.0"
edition = "2024"
[features]
postgres = ["dep:tokio-postgres", "dep:bb8-postgres"]
sqlite = ["dep:rusqlite"]
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"
axum-extra = { version = "0.10.0", features = ["cookie"] }
rusqlite = { version = "0.34.0", optional = true }
tokio-postgres = { version = "0.7.13", optional = true }
bb8-postgres = { version = "0.9.0", optional = true }

View file

@ -0,0 +1 @@
../../LICENSE

View file

@ -12,6 +12,9 @@ pub struct SecurityConfig {
/// If registrations are enabled. /// If registrations are enabled.
#[serde(default = "default_security_admin_user")] #[serde(default = "default_security_admin_user")]
pub admin_user: String, pub admin_user: String,
/// If registrations are enabled.
#[serde(default = "default_real_ip_header")]
pub real_ip_header: String,
} }
fn default_security_registration_enabled() -> bool { fn default_security_registration_enabled() -> bool {
@ -22,11 +25,16 @@ fn default_security_admin_user() -> String {
"admin".to_string() "admin".to_string()
} }
fn default_real_ip_header() -> String {
"CF-Connecting-IP".to_string()
}
impl Default for SecurityConfig { impl Default for SecurityConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
registration_enabled: default_security_registration_enabled(), registration_enabled: default_security_registration_enabled(),
admin_user: default_security_admin_user(), admin_user: default_security_admin_user(),
real_ip_header: default_real_ip_header(),
} }
} }
} }
@ -40,6 +48,9 @@ pub struct DirsConfig {
/// Static files directory. /// Static files directory.
#[serde(default = "default_dir_assets")] #[serde(default = "default_dir_assets")]
pub assets: String, pub assets: String,
/// Media (user avatars/banners) files directory.
#[serde(default = "default_dir_media")]
pub media: String,
} }
fn default_dir_templates() -> String { fn default_dir_templates() -> String {
@ -50,11 +61,42 @@ fn default_dir_assets() -> String {
"public".to_string() "public".to_string()
} }
fn default_dir_media() -> String {
"media".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(),
}
}
}
/// Database configuration.
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct DatabaseConfig {
pub name: String,
#[cfg(feature = "postgres")]
pub url: String,
#[cfg(feature = "postgres")]
pub user: String,
#[cfg(feature = "postgres")]
pub password: String,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
name: "atto.db".to_string(),
#[cfg(feature = "postgres")]
url: "localhost:5432".to_string(),
#[cfg(feature = "postgres")]
user: "postgres".to_string(),
#[cfg(feature = "postgres")]
password: "postgres".to_string(),
} }
} }
} }
@ -62,33 +104,41 @@ impl Default for DirsConfig {
/// Configuration file /// Configuration file
#[derive(Clone, Serialize, Deserialize, Debug)] #[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Config { pub struct Config {
/// The name of the app for templates. /// The name of the app.
#[serde(default = "default_name")] #[serde(default = "default_name")]
pub name: String, pub name: String,
/// The description of the app.
#[serde(default = "default_description")]
pub description: String,
/// The theme color of the app.
#[serde(default = "default_color")]
pub color: String,
/// The port to serve the server on. /// The port to serve the server on.
#[serde(default = "default_port")] #[serde(default = "default_port")]
pub port: u16, pub port: u16,
/// The name of the file to store the SQLite database in.
#[serde(default = "default_database")]
pub database: String,
/// Database security. /// Database security.
#[serde(default = "default_security")] #[serde(default = "default_security")]
pub security: SecurityConfig, pub security: SecurityConfig,
/// 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,
#[serde(default = "default_database")]
pub database: DatabaseConfig,
} }
fn default_name() -> String { fn default_name() -> String {
"Tetratto".to_string() "Tetratto".to_string()
} }
fn default_port() -> u16 { fn default_description() -> String {
4118 "🐐 tetratto!".to_string()
} }
fn default_database() -> String { fn default_color() -> String {
"atto.db".to_string() "#c9b1bc".to_string()
}
fn default_port() -> u16 {
4118
} }
fn default_security() -> SecurityConfig { fn default_security() -> SecurityConfig {
@ -99,10 +149,16 @@ fn default_dirs() -> DirsConfig {
DirsConfig::default() DirsConfig::default()
} }
fn default_database() -> DatabaseConfig {
DatabaseConfig::default()
}
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
name: default_name(), name: default_name(),
description: default_description(),
color: default_color(),
port: default_port(), port: default_port(),
database: default_database(), database: default_database(),
security: default_security(), security: default_security(),

View file

@ -0,0 +1,196 @@
use super::*;
use crate::model::{Error, Result};
use crate::{execute, get, query_row};
use rainbeam_shared::hash::hash_salted;
#[cfg(feature = "sqlite")]
use rusqlite::Row;
#[cfg(feature = "postgres")]
use tokio_postgres::Row;
impl DataManager {
/// Get a [`User`] from an SQL row.
pub(crate) fn get_user_from_row(
#[cfg(feature = "sqlite")] x: &Row<'_>,
#[cfg(feature = "postgres")] x: &Row,
) -> User {
User {
id: get!(x->0(u64)) as usize,
created: get!(x->1(u64)) as usize as usize,
username: get!(x->2(String)),
password: get!(x->3(String)),
salt: get!(x->4(String)),
settings: serde_json::from_str(&get!(x->5(String)).to_string()).unwrap(),
tokens: serde_json::from_str(&get!(x->6(String)).to_string()).unwrap(),
}
}
/// Get a user given just their `id`.
///
/// # Arguments
/// * `id` - the ID of the user
pub async fn get_user_by_id(&self, id: &str) -> Result<User> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_row!(&conn, "SELECT * FROM users WHERE id = $1", &[&id], |x| {
Ok(Self::get_user_from_row(x))
});
if res.is_err() {
return Err(Error::UserNotFound);
}
Ok(res.unwrap())
}
/// Get a user given just their `username`.
///
/// # Arguments
/// * `username` - the username of the user
pub async fn get_user_by_username(&self, username: &str) -> Result<User> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_row!(
&conn,
"SELECT * FROM users WHERE username = $1",
&[&username],
|x| Ok(Self::get_user_from_row(x))
);
if res.is_err() {
return Err(Error::UserNotFound);
}
Ok(res.unwrap())
}
/// Get a user given just their auth token.
///
/// # Arguments
/// * `token` - the token of the user
pub async fn get_user_by_token(&self, token: &str) -> Result<User> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_row!(
&conn,
"SELECT * FROM users WHERE tokens LIKE $1",
&[&format!("%\"{token}\"%")],
|x| Ok(Self::get_user_from_row(x))
);
if res.is_err() {
return Err(Error::UserNotFound);
}
Ok(res.unwrap())
}
/// Create a new user in the database.
///
/// # Arguments
/// * `data` - a mock [`User`] object to insert
pub async fn create_user(&self, data: User) -> Result<()> {
if self.0.security.registration_enabled == false {
return Err(Error::RegistrationDisabled);
}
// check values
if data.username.len() < 2 {
return Err(Error::DataTooShort("username".to_string()));
} else if data.username.len() > 32 {
return Err(Error::DataTooLong("username".to_string()));
}
if data.password.len() < 6 {
return Err(Error::DataTooShort("password".to_string()));
}
// ...
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7)",
&[
&data.id.to_string(),
&data.created.to_string(),
&data.username,
&data.password,
&data.salt,
&serde_json::to_string(&data.settings).unwrap(),
&serde_json::to_string(&data.tokens).unwrap(),
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(())
}
/// Create a new user in the database.
///
/// # Arguments
/// * `id` - the ID of the user
/// * `password` - the current password of the user
/// * `force` - if we should delete even if the given password is incorrect
pub async fn delete_user(&self, id: &str, password: &str, force: bool) -> Result<()> {
let user = self.get_user_by_id(id).await?;
if (hash_salted(password.to_string(), user.salt) != user.password) && !force {
return Err(Error::IncorrectPassword);
}
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(&conn, "DELETE FROM users WHERE id = $1", &[&id]);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(())
}
/// Update the tokens of the the specified account (by `id`).
///
/// # Arguments
/// * `id` - the ID of the user
/// * `tokens` - the **new** tokens vector for the user
pub async fn update_user_tokens(&self, id: usize, tokens: Vec<Token>) -> Result<()> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"UPDATE users SET tokens = $1 WHERE id = $2",
&[&serde_json::to_string(&tokens).unwrap(), &id.to_string()]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(())
}
}

View file

@ -0,0 +1 @@
pub const CREATE_TABLE_USERS: &str = include_str!("./sql/create_users.sql");

View file

@ -0,0 +1,7 @@
pub mod common;
#[cfg(feature = "sqlite")]
pub mod sqlite;
#[cfg(feature = "postgres")]
pub mod postgres;

View file

@ -0,0 +1,95 @@
use crate::config::Config;
use bb8_postgres::{
PostgresConnectionManager,
bb8::{Pool, PooledConnection},
};
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>>);
impl DataManager {
/// Obtain a connection to the staging database.
pub(crate) async fn connect(&self) -> Result<Connection> {
Ok(self.1.get().await.unwrap())
}
/// Create a new [`DataManager`] (and init database).
pub async fn new(config: Config) -> Result<Self> {
let manager = PostgresConnectionManager::new(
{
let mut c = PgConfig::new();
c.user(&config.database.user);
c.password(&config.database.password);
c.dbname(&config.database.name);
c
},
NoTls,
);
let pool = Pool::builder().max_size(15).build(manager).await.unwrap();
let this = Self(config.clone(), pool);
let c = this.clone();
let conn = c.connect().await?;
conn.execute(super::common::CREATE_TABLE_USERS, &[])
.await
.unwrap();
Ok(this)
}
}
#[cfg(feature = "postgres")]
#[macro_export]
macro_rules! get {
($row:ident->$idx:literal($t:tt)) => {
$row.get::<usize, Option<$t>>($idx).unwrap()
};
}
pub async fn query_row_helper<T, F>(
conn: &Connection<'_>,
sql: &str,
params: &[&(dyn ToSql + Sync)],
f: F,
) -> Result<T>
where
F: FnOnce(&Row) -> Result<T>,
{
let query = conn.prepare(sql).await.unwrap();
let res = conn.query_one(&query, params).await;
if let Ok(row) = res {
Ok(f(&row).unwrap())
} else {
Err(res.unwrap_err())
}
}
#[macro_export]
macro_rules! query_row {
($conn:expr, $sql:expr, $params:expr, $f:expr) => {
crate::database::query_row_helper($conn, $sql, $params, $f).await
};
}
pub async fn execute_helper(
conn: &Connection<'_>,
sql: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<()> {
let query = conn.prepare(sql).await.unwrap();
conn.execute(&query, params).await?;
Ok(())
}
#[macro_export]
macro_rules! execute {
($conn:expr, $sql:expr, $params:expr) => {
crate::database::execute_helper($conn, $sql, $params).await
};
}

View file

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY,
created INTEGER NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
salt TEXT NOT NULL,
settings TEXT NOT NULL,
tokens TEXT NOT NULL
)

View file

@ -0,0 +1,46 @@
use crate::config::Config;
use rusqlite::{Connection, Result};
#[derive(Clone)]
pub struct DataManager(pub Config);
impl DataManager {
/// Obtain a connection to the staging database.
pub(crate) async fn connect(&self) -> Result<Connection> {
Ok(Connection::open(&self.0.database.name)?)
}
/// Create a new [`DataManager`] (and init database).
pub async fn new(config: Config) -> Result<Self> {
let this = Self(config.clone());
let conn = this.connect().await?;
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn.execute(super::common::CREATE_TABLE_USERS, ()).unwrap();
Ok(this)
}
}
#[cfg(feature = "sqlite")]
#[macro_export]
macro_rules! get {
($row:ident->$idx:literal($t:tt)) => {
$row.get::<usize, $t>($idx).unwrap()
};
}
#[macro_export]
macro_rules! query_row {
($conn:expr, $sql:expr, $params:expr, $f:expr) => {{
let mut query = $conn.prepare($sql).unwrap();
query.query_row($params, $f)
}};
}
#[macro_export]
macro_rules! execute {
($conn:expr, $sql:expr, $params:expr) => {
$conn.prepare($sql).unwrap().execute($params)
};
}

View file

@ -0,0 +1,10 @@
mod auth;
mod drivers;
use super::model::auth::{Token, User};
#[cfg(feature = "sqlite")]
pub use drivers::sqlite::*;
#[cfg(feature = "postgres")]
pub use drivers::postgres::*;

View file

@ -0,0 +1,5 @@
pub mod config;
pub mod database;
pub mod model;
pub use database::DataManager;

View file

@ -5,36 +5,10 @@ use rainbeam_shared::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug)] /// `(ip, token, creation timestamp)`
pub enum Error { pub type Token = (String, String, usize);
DatabaseConnection(String),
UserNotFound,
RegistrationDisabled,
DatabaseError,
IncorrectPassword,
AlreadyAuthenticated,
Unknown,
}
impl ToString for Error { #[derive(Debug, Serialize)]
fn to_string(&self) -> String {
match self {
Error::DatabaseConnection(msg) => msg.to_owned(),
Error::UserNotFound => "Unable to find user with given parameters".to_string(),
Error::RegistrationDisabled => "Registration is disabled".to_string(),
Error::IncorrectPassword => "The given password is invalid".to_string(),
Error::AlreadyAuthenticated => "Already authenticated".to_string(),
_ => format!("An unknown error as occurred ({:?})", self),
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
/// `(ip, token)`
pub type Token = (String, String);
#[derive(Debug)]
pub struct User { pub struct User {
pub id: usize, pub id: usize,
pub created: usize, pub created: usize,
@ -70,7 +44,28 @@ impl User {
password, password,
salt, salt,
settings: UserSettings::default(), settings: UserSettings::default(),
tokens: vec![(String::new(), AlmostSnowflake::new(1234567890).to_string())], tokens: Vec::new(),
} }
} }
/// Create a new token
///
/// # Returns
/// `(unhashed id, token)`
pub fn create_token(ip: &str) -> (String, Token) {
let unhashed = rainbeam_shared::hash::uuid();
(
unhashed.clone(),
(
ip.to_string(),
rainbeam_shared::hash::hash(unhashed),
unix_epoch_timestamp() as usize,
),
)
}
/// Check if the given password is correct for the user.
pub fn check_password(&self, against: String) -> bool {
self.password == hash_salted(against, self.salt.clone())
}
} }

View file

@ -0,0 +1,56 @@
pub mod auth;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct ApiReturn<T>
where
T: Serialize,
{
pub ok: bool,
pub message: String,
pub payload: T,
}
#[derive(Debug)]
pub enum Error {
DatabaseConnection(String),
UserNotFound,
RegistrationDisabled,
DatabaseError(String),
IncorrectPassword,
AlreadyAuthenticated,
DataTooLong(String),
DataTooShort(String),
Unknown,
}
impl ToString for Error {
fn to_string(&self) -> String {
match self {
Error::DatabaseConnection(msg) => msg.to_owned(),
Error::DatabaseError(msg) => format!("Database error: {msg}"),
Error::UserNotFound => "Unable to find user with given parameters".to_string(),
Error::RegistrationDisabled => "Registration is disabled".to_string(),
Error::IncorrectPassword => "The given password is invalid".to_string(),
Error::AlreadyAuthenticated => "Already authenticated".to_string(),
Error::DataTooLong(name) => format!("Given {name} is too long!"),
Error::DataTooShort(name) => format!("Given {name} is too short!"),
_ => format!("An unknown error as occurred: ({:?})", self),
}
}
}
impl<T> Into<ApiReturn<T>> for Error
where
T: Default + Serialize,
{
fn into(self) -> ApiReturn<T> {
ApiReturn {
ok: false,
message: self.to_string(),
payload: T::default(),
}
}
}
pub type Result<T> = std::result::Result<T, Error>;

12
example/.gitignore vendored
View file

@ -1,3 +1,9 @@
atto.db atto.db*
html/_atto/
public/_atto/ html/*
!html/.gitkeep
public/*
!public/.gitkeep
media/*

0
example/html/.gitkeep Normal file
View file

View file

@ -3,7 +3,6 @@ port = 4118
[security] [security]
registration_enabled = true registration_enabled = true
admin_user = "admin"
[dirs] [dirs]
templates = "html" templates = "html"

7
justfile Normal file
View file

@ -0,0 +1,7 @@
clean-deps:
cargo upgrade -i
cargo machete
fix:
cargo fix --allow-dirty
cargo clippy --fix --allow-dirty

View file

@ -1,14 +0,0 @@
// css
pub const STYLE_CSS: &str = include_str!("../public/css/style.css");
// js
pub const ATTO_JS: &str = include_str!("../public/js/atto.js");
// html
pub const ROOT: &str = include_str!("../public/html/root.html");
pub const REDIRECT_TO_AUTH: &str =
"<head><meta http-equiv=\"refresh\" content=\"0; url=/_atto/login\" /></head>";
pub const AUTH_BASE: &str = include_str!("../public/html/auth/base.html");
pub const LOGIN: &str = include_str!("../public/html/auth/login.html");
pub const REGISTER: &str = include_str!("../public/html/auth/register.html");

View file

@ -1,187 +0,0 @@
use super::model::{Error, Result, User};
use crate::config::Config;
use crate::write_template;
use pathbufd::PathBufD as PathBuf;
use rainbeam_shared::hash::hash_salted;
use rusqlite::{Connection, Result as SqlResult, Row};
use std::fs::{create_dir, exists};
use tera::{Context, Tera};
pub struct DataManager(pub(crate) Config, pub Tera);
impl DataManager {
/// Obtain a connection to the staging database.
pub(crate) fn connect(name: &str) -> SqlResult<Connection> {
Ok(Connection::open(name)?)
}
/// Create a new [`DataManager`] (and init database).
pub async fn new(config: Config) -> SqlResult<Self> {
let conn = Self::connect(&config.database)?;
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn.execute(
"CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY,
created INTEGER NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
salt TEXT NOT NULL,
settings TEXT NOT NULL,
tokens TEXT NOT NULL
)",
(),
)
.unwrap();
// create system templates
let html_path = PathBuf::current().join(&config.dirs.templates);
let atto_dir = html_path.join("_atto");
if !exists(&atto_dir).unwrap() {
create_dir(&atto_dir).unwrap();
}
write_template!(atto_dir->"root.html"(super::assets::ROOT));
write_template!(atto_dir->"auth/base.html"(super::assets::AUTH_BASE) -d "auth");
write_template!(atto_dir->"auth/login.html"(super::assets::LOGIN));
write_template!(atto_dir->"auth/register.html"(super::assets::REGISTER));
// return
Ok(Self(
config.clone(),
Tera::new(&format!("{html_path}/**/*")).unwrap(),
))
}
/// Create the initial template context.
pub(crate) fn initial_context(&self) -> Context {
let mut ctx = Context::new();
ctx.insert("name", &self.0.name);
ctx
}
// users
/// Get a [`User`] from an SQL row.
pub(crate) fn get_user_from_row(x: &Row<'_>) -> User {
User {
id: x.get(0).unwrap(),
created: x.get(1).unwrap(),
username: x.get(2).unwrap(),
password: x.get(3).unwrap(),
salt: x.get(4).unwrap(),
settings: serde_json::from_str(&x.get::<usize, String>(5).unwrap().to_string())
.unwrap(),
tokens: serde_json::from_str(&x.get::<usize, String>(6).unwrap().to_string()).unwrap(),
}
}
/// Get a user given just their `id`.
///
/// # Arguments
/// * `id` - the ID of the user
pub async fn get_user_by_id(&self, id: &str) -> Result<User> {
let conn = match Self::connect(&self.0.name) {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let mut query = conn.prepare("SELECT * FROM users WHERE id = ?").unwrap();
let res = query.query_row([id], |x| Ok(Self::get_user_from_row(x)));
if res.is_err() {
return Err(Error::UserNotFound);
}
Ok(res.unwrap())
}
/// Get a user given just their auth token.
///
/// # Arguments
/// * `token` - the token of the user
pub async fn get_user_by_token(&self, token: &str) -> Result<User> {
let conn = match Self::connect(&self.0.name) {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let mut query = conn
.prepare("SELECT * FROM users WHERE tokens LIKE ?")
.unwrap();
let res = query.query_row([format!("%,\"{token}\"%")], |x| {
Ok(Self::get_user_from_row(x))
});
if res.is_err() {
return Err(Error::UserNotFound);
}
Ok(res.unwrap())
}
/// Create a new user in the database.
///
/// # Arguments
/// * `data` - a mock [`User`] object to insert
pub async fn create_user(&self, data: User) -> Result<()> {
if self.0.security.registration_enabled == false {
return Err(Error::RegistrationDisabled);
}
let conn = match Self::connect(&self.0.name) {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = conn.execute(
"INSERT INTO users VALUES (?, ?, ?, ?, ?, ?, ?)",
(
data.id,
data.created,
data.username,
data.password,
data.salt,
serde_json::to_string(&data.settings).unwrap(),
serde_json::to_string(&data.tokens).unwrap(),
),
);
if res.is_err() {
return Err(Error::DatabaseError);
}
Ok(())
}
/// Create a new user in the database.
///
/// # Arguments
/// * `id` - the ID of the user
/// * `password` - the current password of the user
/// * `force` - if we should delete even if the given password is incorrect
pub async fn delete_user(&self, id: &str, password: &str, force: bool) -> Result<()> {
let user = self.get_user_by_id(id).await?;
if (hash_salted(password.to_string(), user.salt) != user.password) && !force {
return Err(Error::IncorrectPassword);
}
let conn = match Self::connect(&self.0.name) {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = conn.execute("DELETE FROM users WHERE id = ?", [id]);
if res.is_err() {
return Err(Error::DatabaseError);
}
Ok(())
}
}

View file

@ -1,5 +0,0 @@
pub mod assets;
pub mod manager;
pub mod model;
pub use manager::DataManager;

View file

@ -1,40 +0,0 @@
#[macro_export]
macro_rules! write_template {
($atto_dir:ident->$path:literal($as:expr)) => {
std::fs::write($atto_dir.join($path), $as).unwrap();
};
($atto_dir:ident->$path:literal($as:expr) -d $dir_path:literal) => {
let dir = $atto_dir.join($dir_path);
if !std::fs::exists(&dir).unwrap() {
std::fs::create_dir(dir).unwrap();
}
std::fs::write($atto_dir.join($path), $as).unwrap();
};
}
#[macro_export]
macro_rules! get_user_from_token {
(($jar:ident, $db:ident) <optional>) => {{
if let Some(token) = $jar.get("__Secure-Atto-Token") {
match $db.get_user_by_token(&token.to_string()).await {
Ok(ua) => Some(ua),
Err(_) => None,
}
} else {
None
}
}};
($jar:ident, $db:ident) => {{
if let Some(token) = $jar.get("__Secure-Atto-Token") {
match $db.get_user_by_token(token) {
Ok(ua) => ua,
Err(_) => return axum::response::Html(crate::data::assets::REDIRECT_TO_AUTH),
}
} else {
None
}
}};
}

View file

@ -1,44 +0,0 @@
mod config;
mod data;
mod macros;
mod routes;
use data::DataManager;
use axum::{Extension, Router};
use tower_http::trace::{self, TraceLayer};
use tracing::{Level, info};
use std::sync::Arc;
use tokio::sync::RwLock;
pub(crate) type State = Arc<RwLock<DataManager>>;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_target(false)
.compact()
.init();
let config = config::Config::get_config();
let app = Router::new()
.merge(routes::routes())
.layer(Extension(Arc::new(RwLock::new(
DataManager::new(config.clone()).await.unwrap(),
))))
.layer(
TraceLayer::new_for_http()
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
.on_response(trace::DefaultOnResponse::new().level(Level::INFO)),
);
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port))
.await
.unwrap();
info!("🐐 tetratto.");
info!("listening on http://0.0.0.0:{}", config.port);
axum::serve(listener, app).await.unwrap();
}

View file

@ -1,33 +0,0 @@
{% extends "_atto/auth/base.html" %} {% block head %}
<title>🐐 Register</title>
{% endblock %} {% block title %}Register{% endblock %} {% block content %}
<form class="w-full flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="username"><b>Username</b></label>
<input
type="text"
placeholder="username"
required
name="username"
id="username"
/>
</div>
<div class="flex flex-col gap-1">
<label for="username"><b>Password</b></label>
<input
type="password"
placeholder="password"
required
name="password"
id="password"
/>
</div>
<button>Submit</button>
</form>
{% endblock %} {% block footer %}
<span class="small w-full text-center"
>Or, <a href="/_atto/login">login</a></span
>
{% endblock %}

View file

@ -1,17 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<link rel="stylesheet" href="/css/style.css" />
<script defer async src="/js/atto.js"></script>
{% block head %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>

View file

@ -1,31 +0,0 @@
console.log("🐐 tetratto - https://github.com/trisuaso/tetratto");
// theme preference
function media_theme_pref() {
document.documentElement.removeAttribute("class");
if (
window.matchMedia("(prefers-color-scheme: dark)").matches &&
!window.localStorage.getItem("tetratto:theme")
) {
document.documentElement.classList.add("dark");
// window.localStorage.setItem("theme", "dark");
} else if (
window.matchMedia("(prefers-color-scheme: light)").matches &&
!window.localStorage.getItem("tetratto:theme")
) {
document.documentElement.classList.remove("dark");
// window.localStorage.setItem("theme", "light");
} else if (window.localStorage.getItem("tetratto:theme")) {
/* restore theme */
const current = window.localStorage.getItem("tetratto:theme");
document.documentElement.className = current;
}
}
function set_theme(theme) {
window.localStorage.setItem("tetratto:theme", theme);
document.documentElement.className = theme;
}
media_theme_pref();

View file

@ -1,41 +0,0 @@
use super::{ApiReturn, AuthProps};
use crate::{
State,
data::model::{Error, User},
get_user_from_token,
};
use axum::{Extension, Json, response::IntoResponse};
use axum_extra::extract::CookieJar;
pub async fn register_request(
jar: CookieJar,
Extension(data): Extension<State>,
Json(props): Json<AuthProps>,
) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!((jar, data) <optional>);
if user.is_some() {
return Json(ApiReturn {
ok: false,
message: Error::AlreadyAuthenticated.to_string(),
payload: (),
});
}
match data
.create_user(User::new(props.username, props.password))
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "User created".to_string(),
payload: (),
}),
Err(_) => Json(ApiReturn {
ok: false,
message: Error::Unknown.to_string(),
payload: (),
}),
}
}

View file

@ -1,32 +0,0 @@
pub mod auth;
use axum::{Router, routing::post};
use serde::{Deserialize, Serialize};
pub fn routes() -> Router {
Router::new().route("/auth/register", post(auth::register_request))
}
#[derive(Serialize, Deserialize)]
pub struct ApiReturn<T>
where
T: Serialize,
{
pub ok: bool,
pub message: String,
pub payload: T,
}
impl<T> ApiReturn<T>
where
T: Serialize,
{
pub fn to_json(&self) -> String {
serde_json::to_string(&self).unwrap()
}
}
#[derive(Deserialize)]
pub struct AuthProps {
pub username: String,
pub password: String,
}

View file

@ -1,69 +0,0 @@
pub mod api;
pub mod assets;
use crate::{State, get_user_from_token};
use axum::{
Extension, Router,
response::{Html, IntoResponse, Redirect},
routing::get,
};
use axum_extra::extract::CookieJar;
/// `/`
pub async fn index_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!((jar, data) <optional>);
let mut context = data.initial_context();
Html(data.1.render("index.html", &mut context).unwrap())
}
/// `/_atto/login`
pub async fn login_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!((jar, data) <optional>);
if user.is_some() {
return Err(Redirect::to("/"));
}
let mut context = data.initial_context();
Ok(Html(
data.1
.render("_atto/auth/login.html", &mut context)
.unwrap(),
))
}
/// `/_atto/register`
pub async fn register_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!((jar, data) <optional>);
if user.is_some() {
return Err(Redirect::to("/"));
}
let mut context = data.initial_context();
Ok(Html(
data.1
.render("_atto/auth/register.html", &mut context)
.unwrap(),
))
}
pub fn routes() -> Router {
Router::new()
// assets
.route("/css/style.css", get(assets::style_css_request))
.route("/js/atto.js", get(assets::atto_js_request))
// api
.nest("/api/v1", api::v1::routes())
// pages
.route("/", get(index_request))
.route("/_atto/login", get(login_request))
.route("/_atto/register", get(register_request))
}

16
tetratto.toml Normal file
View file

@ -0,0 +1,16 @@
name = "Tetratto"
description = "🐐 tetratto!"
color = "#c9b1bc"
port = 4118
[security]
registration_enabled = true
admin_user = "admin"
real_ip_header = "CF-Connecting-IP"
[dirs]
templates = "html"
assets = "public"
[database]
name = "atto.db"