add: postgres support
chore: restructure
This commit is contained in:
parent
cda879f6df
commit
b6fe2fba37
58 changed files with 3403 additions and 603 deletions
803
Cargo.lock
generated
803
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
35
Cargo.toml
35
Cargo.toml
|
@ -1,19 +1,18 @@
|
|||
[package]
|
||||
name = "tetratto"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/app", "crates/tetratto_core"]
|
||||
package.authors = ["trisuaso"]
|
||||
package.repository = "https://github.com/trisuaso/tetratto"
|
||||
package.license = "AGPL-3.0-or-later"
|
||||
|
||||
[dependencies]
|
||||
pathbufd = "0.1.4"
|
||||
rusqlite = "0.34.0"
|
||||
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"] }
|
||||
[profile.dev]
|
||||
incremental = true
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
# panic = "abort"
|
||||
panic = "unwind"
|
||||
strip = true
|
||||
incremental = true
|
||||
|
|
|
@ -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!
|
||||
|
||||
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
|
||||
|
||||
- Templated HTML files (`html/` directory)
|
||||
- Markdown posts (`posts/` directory, served with `html/post.html` template)
|
||||
- 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
|
||||
|
||||
|
|
26
crates/app/Cargo.toml
Normal file
26
crates/app/Cargo.toml
Normal 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
1
crates/app/LICENSE
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../LICENSE
|
48
crates/app/src/assets.rs
Normal file
48
crates/app/src/assets.rs
Normal 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
82
crates/app/src/avif.rs
Normal 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
54
crates/app/src/macros.rs
Normal 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
68
crates/app/src/main.rs
Normal 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();
|
||||
}
|
|
@ -132,6 +132,11 @@ footer {
|
|||
}
|
||||
|
||||
/* typo */
|
||||
.icon {
|
||||
color: inherit;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
hr {
|
||||
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 */
|
||||
.flex {
|
||||
display: flex;
|
|
@ -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">
|
||||
<h2 class="w-full text-center">{% block title %}{% endblock %}</h2>
|
||||
<div class="card w-full flex flex-col gap-4 justify-center align-center">
|
|
@ -1,4 +1,4 @@
|
|||
{% extends "_atto/auth/base.html" %} {% block head %}
|
||||
{% extends "auth/base.html" %} {% block head %}
|
||||
<title>🐐 Login</title>
|
||||
{% endblock %} {% block title %}Login{% endblock %} {% block content %}
|
||||
<form class="w-full flex flex-col gap-4">
|
||||
|
@ -28,6 +28,6 @@
|
|||
</form>
|
||||
{% endblock %} {% block footer %}
|
||||
<span class="small w-full text-center"
|
||||
>Or, <a href="/_atto/register">register</a></span
|
||||
>Or, <a href="/auth/register">register</a></span
|
||||
>
|
||||
{% endblock %}
|
62
crates/app/src/public/html/auth/register.html
Normal file
62
crates/app/src/public/html/auth/register.html
Normal 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 %}
|
45
crates/app/src/public/html/macros.html
Normal file
45
crates/app/src/public/html/macros.html
Normal 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 %}
|
|
@ -1,20 +1,5 @@
|
|||
{% extends "_atto/root.html" %} {% block body %}
|
||||
<nav>
|
||||
<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>
|
||||
{% import "macros.html" as macros %} {% extends "root.html" %} {% block body %}
|
||||
{{ macros::nav(selected="home") }}
|
||||
|
||||
<main class="flex flex-col gap-2">
|
||||
<h1>Hello, world!</h1>
|
73
crates/app/src/public/html/root.html
Normal file
73
crates/app/src/public/html/root.html
Normal 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>
|
3
crates/app/src/public/images/default-avatar.svg
Normal file
3
crates/app/src/public/images/default-avatar.svg
Normal 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 |
3
crates/app/src/public/images/default-banner.svg
Normal file
3
crates/app/src/public/images/default-banner.svg
Normal 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 |
618
crates/app/src/public/js/atto.js
Normal file
618
crates/app/src/public/js/atto.js
Normal 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("<", "<")
|
||||
.replaceAll(">", ">")}</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();
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
205
crates/app/src/public/js/loader.js
Normal file
205
crates/app/src/public/js/loader.js
Normal 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);
|
||||
};
|
102
crates/app/src/routes/api/v1/auth/images.rs
Normal file
102
crates/app/src/routes/api/v1/auth/images.rs
Normal 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)),
|
||||
)
|
||||
}
|
127
crates/app/src/routes/api/v1/auth/mod.rs
Normal file
127
crates/app/src/routes/api/v1/auth/mod.rs
Normal 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: (),
|
||||
}),
|
||||
)
|
||||
}
|
28
crates/app/src/routes/api/v1/mod.rs
Normal file
28
crates/app/src/routes/api/v1/mod.rs
Normal 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,
|
||||
}
|
|
@ -2,16 +2,21 @@ use axum::response::IntoResponse;
|
|||
|
||||
/// `/css/style.css`
|
||||
pub async fn style_css_request() -> impl IntoResponse {
|
||||
(
|
||||
[("Content-Type", "text/css")],
|
||||
crate::data::assets::STYLE_CSS,
|
||||
)
|
||||
([("Content-Type", "text/css")], crate::assets::STYLE_CSS)
|
||||
}
|
||||
|
||||
/// `/js/atto.js`
|
||||
pub async fn atto_js_request() -> impl IntoResponse {
|
||||
(
|
||||
[("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,
|
||||
)
|
||||
}
|
25
crates/app/src/routes/mod.rs
Normal file
25
crates/app/src/routes/mod.rs
Normal 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())
|
||||
}
|
39
crates/app/src/routes/pages/auth.rs
Normal file
39
crates/app/src/routes/pages/auth.rs
Normal 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(),
|
||||
))
|
||||
}
|
15
crates/app/src/routes/pages/misc.rs
Normal file
15
crates/app/src/routes/pages/misc.rs
Normal 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())
|
||||
}
|
13
crates/app/src/routes/pages/mod.rs
Normal file
13
crates/app/src/routes/pages/mod.rs
Normal 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))
|
||||
}
|
28
crates/tetratto_core/Cargo.toml
Normal file
28
crates/tetratto_core/Cargo.toml
Normal 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 }
|
1
crates/tetratto_core/LICENSE
Symbolic link
1
crates/tetratto_core/LICENSE
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../LICENSE
|
|
@ -12,6 +12,9 @@ pub struct SecurityConfig {
|
|||
/// If registrations are enabled.
|
||||
#[serde(default = "default_security_admin_user")]
|
||||
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 {
|
||||
|
@ -22,11 +25,16 @@ fn default_security_admin_user() -> String {
|
|||
"admin".to_string()
|
||||
}
|
||||
|
||||
fn default_real_ip_header() -> String {
|
||||
"CF-Connecting-IP".to_string()
|
||||
}
|
||||
|
||||
impl Default for SecurityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
registration_enabled: default_security_registration_enabled(),
|
||||
admin_user: default_security_admin_user(),
|
||||
real_ip_header: default_real_ip_header(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -40,6 +48,9 @@ pub struct DirsConfig {
|
|||
/// Static files directory.
|
||||
#[serde(default = "default_dir_assets")]
|
||||
pub assets: String,
|
||||
/// Media (user avatars/banners) files directory.
|
||||
#[serde(default = "default_dir_media")]
|
||||
pub media: String,
|
||||
}
|
||||
|
||||
fn default_dir_templates() -> String {
|
||||
|
@ -50,11 +61,42 @@ fn default_dir_assets() -> String {
|
|||
"public".to_string()
|
||||
}
|
||||
|
||||
fn default_dir_media() -> String {
|
||||
"media".to_string()
|
||||
}
|
||||
|
||||
impl Default for DirsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
templates: default_dir_templates(),
|
||||
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
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
pub struct Config {
|
||||
/// The name of the app for templates.
|
||||
/// The name of the app.
|
||||
#[serde(default = "default_name")]
|
||||
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.
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
/// The name of the file to store the SQLite database in.
|
||||
#[serde(default = "default_database")]
|
||||
pub database: String,
|
||||
/// Database security.
|
||||
#[serde(default = "default_security")]
|
||||
pub security: SecurityConfig,
|
||||
/// The locations where different files should be matched.
|
||||
#[serde(default = "default_dirs")]
|
||||
pub dirs: DirsConfig,
|
||||
#[serde(default = "default_database")]
|
||||
pub database: DatabaseConfig,
|
||||
}
|
||||
|
||||
fn default_name() -> String {
|
||||
"Tetratto".to_string()
|
||||
}
|
||||
|
||||
fn default_port() -> u16 {
|
||||
4118
|
||||
fn default_description() -> String {
|
||||
"🐐 tetratto!".to_string()
|
||||
}
|
||||
|
||||
fn default_database() -> String {
|
||||
"atto.db".to_string()
|
||||
fn default_color() -> String {
|
||||
"#c9b1bc".to_string()
|
||||
}
|
||||
fn default_port() -> u16 {
|
||||
4118
|
||||
}
|
||||
|
||||
fn default_security() -> SecurityConfig {
|
||||
|
@ -99,10 +149,16 @@ fn default_dirs() -> DirsConfig {
|
|||
DirsConfig::default()
|
||||
}
|
||||
|
||||
fn default_database() -> DatabaseConfig {
|
||||
DatabaseConfig::default()
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: default_name(),
|
||||
description: default_description(),
|
||||
color: default_color(),
|
||||
port: default_port(),
|
||||
database: default_database(),
|
||||
security: default_security(),
|
196
crates/tetratto_core/src/database/auth.rs
Normal file
196
crates/tetratto_core/src/database/auth.rs
Normal 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(())
|
||||
}
|
||||
}
|
1
crates/tetratto_core/src/database/drivers/common.rs
Normal file
1
crates/tetratto_core/src/database/drivers/common.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub const CREATE_TABLE_USERS: &str = include_str!("./sql/create_users.sql");
|
7
crates/tetratto_core/src/database/drivers/mod.rs
Normal file
7
crates/tetratto_core/src/database/drivers/mod.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
pub mod common;
|
||||
|
||||
#[cfg(feature = "sqlite")]
|
||||
pub mod sqlite;
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
pub mod postgres;
|
95
crates/tetratto_core/src/database/drivers/postgres.rs
Normal file
95
crates/tetratto_core/src/database/drivers/postgres.rs
Normal 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
|
||||
};
|
||||
}
|
|
@ -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
|
||||
)
|
46
crates/tetratto_core/src/database/drivers/sqlite.rs
Normal file
46
crates/tetratto_core/src/database/drivers/sqlite.rs
Normal 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)
|
||||
};
|
||||
}
|
10
crates/tetratto_core/src/database/mod.rs
Normal file
10
crates/tetratto_core/src/database/mod.rs
Normal 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::*;
|
5
crates/tetratto_core/src/lib.rs
Normal file
5
crates/tetratto_core/src/lib.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
pub mod config;
|
||||
pub mod database;
|
||||
pub mod model;
|
||||
|
||||
pub use database::DataManager;
|
|
@ -5,36 +5,10 @@ use rainbeam_shared::{
|
|||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
DatabaseConnection(String),
|
||||
UserNotFound,
|
||||
RegistrationDisabled,
|
||||
DatabaseError,
|
||||
IncorrectPassword,
|
||||
AlreadyAuthenticated,
|
||||
Unknown,
|
||||
}
|
||||
/// `(ip, token, creation timestamp)`
|
||||
pub type Token = (String, String, usize);
|
||||
|
||||
impl ToString for Error {
|
||||
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)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct User {
|
||||
pub id: usize,
|
||||
pub created: usize,
|
||||
|
@ -70,7 +44,28 @@ impl User {
|
|||
password,
|
||||
salt,
|
||||
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())
|
||||
}
|
||||
}
|
56
crates/tetratto_core/src/model/mod.rs
Normal file
56
crates/tetratto_core/src/model/mod.rs
Normal 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
12
example/.gitignore
vendored
|
@ -1,3 +1,9 @@
|
|||
atto.db
|
||||
html/_atto/
|
||||
public/_atto/
|
||||
atto.db*
|
||||
|
||||
html/*
|
||||
!html/.gitkeep
|
||||
|
||||
public/*
|
||||
!public/.gitkeep
|
||||
|
||||
media/*
|
||||
|
|
0
example/html/.gitkeep
Normal file
0
example/html/.gitkeep
Normal file
|
@ -3,7 +3,6 @@ port = 4118
|
|||
|
||||
[security]
|
||||
registration_enabled = true
|
||||
admin_user = "admin"
|
||||
|
||||
[dirs]
|
||||
templates = "html"
|
||||
|
|
7
justfile
Normal file
7
justfile
Normal file
|
@ -0,0 +1,7 @@
|
|||
clean-deps:
|
||||
cargo upgrade -i
|
||||
cargo machete
|
||||
|
||||
fix:
|
||||
cargo fix --allow-dirty
|
||||
cargo clippy --fix --allow-dirty
|
|
@ -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");
|
|
@ -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(())
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
pub mod assets;
|
||||
pub mod manager;
|
||||
pub mod model;
|
||||
|
||||
pub use manager::DataManager;
|
|
@ -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
|
||||
}
|
||||
}};
|
||||
}
|
44
src/main.rs
44
src/main.rs
|
@ -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();
|
||||
}
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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();
|
|
@ -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: (),
|
||||
}),
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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
16
tetratto.toml
Normal 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"
|
Loading…
Add table
Add a link
Reference in a new issue