add(ui): ability to log out

This commit is contained in:
trisua 2025-03-23 16:37:43 -04:00
parent d2ca9e23d3
commit b3cac5f97a
29 changed files with 499 additions and 124 deletions

1
Cargo.lock generated
View file

@ -2569,6 +2569,7 @@ name = "tetratto-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bb8-postgres", "bb8-postgres",
"bitflags 2.9.0",
"pathbufd", "pathbufd",
"rusqlite", "rusqlite",
"serde", "serde",

View file

@ -1,31 +1,10 @@
# 🐐 tetratto! # 🐐 tetratto!
This is the year of the personal website. Tetratto is your personal journal!
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!
## Features ## Features
- Templated HTML files (`html/` directory) - Create new pages in your journal (essentially just posts)
- Markdown posts (`posts/` directory, served with `html/post.html` template) - Create new pages in your journal where people can post messages (essentially message boards that you control)
- Super simple SQLite database for authentication (and other stuff) - Follow other people and see their (public) journal entries
- Journal entries can either be public, unlisted (only accessible via link), and fully private (only accessible to moderators and the owner)
## Usage
Install Tetratto CLI:
```bash
cargo install tetratto
```
Clone the `./example` directory to get started.
You can run a project by running `tetratto` in the directory. The entry file for CSS is assumed to be `public/css/style.css`. Note that your `index.html` file should _not_ include boilerplate stuff, and should instead just include a `{% block body %}` for beginning your content in the body. `{% block head %}` can be used to place data in the page head element. Templates should all extend the `_atto/root.html` template.
### Config
You can configure Tetratto by editing the project's `tetratto.toml` file.
- `name`: the `{{ name }}` variable in templates (default: `Tetratto`)
- `port`: the port the server is served on (default: `4118`)
- `database`: the name of the file to store the SQLite database in (default: `./atto.db`)

View file

@ -8,6 +8,7 @@ use std::{
use tera::Context; use tera::Context;
use tetratto_core::{config::Config, model::auth::User}; use tetratto_core::{config::Config, model::auth::User};
use tetratto_l10n::LangFile; use tetratto_l10n::LangFile;
use tetratto_shared::hash::salt;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::{create_dir_if_not_exists, write_if_track, write_template}; use crate::{create_dir_if_not_exists, write_if_track, write_template};
@ -21,8 +22,9 @@ pub const FAVICON: &str = include_str!("./public/images/favicon.svg");
pub const STYLE_CSS: &str = include_str!("./public/css/style.css"); pub const STYLE_CSS: &str = include_str!("./public/css/style.css");
// js // js
pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
pub const LOADER_JS: &str = include_str!("./public/js/loader.js"); pub const LOADER_JS: &str = include_str!("./public/js/loader.js");
pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
pub const ME_JS: &str = include_str!("./public/js/me.js");
// html // html
pub const ROOT: &str = include_str!("./public/html/root.html"); pub const ROOT: &str = include_str!("./public/html/root.html");
@ -165,11 +167,15 @@ pub(crate) async fn init_dirs(config: &Config) {
write_template!(langs_path->"en-US.toml"(LANG_EN_US)); write_template!(langs_path->"en-US.toml"(LANG_EN_US));
} }
/// A random ASCII value inserted into the URL of static assets to "break" the cache. Essentially just for cache busting.
pub(crate) static CACHE_BREAKER: LazyLock<String> = LazyLock::new(|| salt());
/// Create the initial template context. /// Create the initial template context.
pub(crate) fn initial_context(config: &Config, lang: &LangFile, user: &Option<User>) -> Context { pub(crate) fn initial_context(config: &Config, lang: &LangFile, user: &Option<User>) -> Context {
let mut ctx = Context::new(); let mut ctx = Context::new();
ctx.insert("config", &config); ctx.insert("config", &config);
ctx.insert("user", &user); ctx.insert("user", &user);
ctx.insert("lang", &lang); ctx.insert("lang", &lang.data);
ctx.insert("random_cache_breaker", &CACHE_BREAKER.clone());
ctx ctx
} }

View file

@ -2,5 +2,15 @@ name = "com.tetratto.langs:en-US"
version = "1.0.0" version = "1.0.0"
[data] [data]
"general:action.login" = "Login" "general:link.home" = "Home"
"general:action.register" = "Register"
"dialog:action.okay" = "Ok"
"dialog:action.continue" = "Continue"
"dialog:action.cancel" = "Cancel"
"dialog:action.yes" = "Yes"
"dialog:action.no" = "No"
"auth:action.login" = "Login"
"auth:action.register" = "Register"
"auth:action.logout" = "Logout"
"auth:link.my_profile" = "My profile"

View file

@ -55,7 +55,7 @@ macro_rules! create_dir_if_not_exists {
#[macro_export] #[macro_export]
macro_rules! get_user_from_token { macro_rules! get_user_from_token {
(($jar:ident, $db:expr) <optional>) => {{ ($jar:ident, $db:expr) => {{
if let Some(token) = $jar.get("__Secure-atto-token") { if let Some(token) = $jar.get("__Secure-atto-token") {
match $db match $db
.get_user_by_token(&tetratto_shared::hash::hash( .get_user_by_token(&tetratto_shared::hash::hash(
@ -70,17 +70,6 @@ macro_rules! get_user_from_token {
None 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
}
}};
} }
#[macro_export] #[macro_export]

View file

@ -318,6 +318,27 @@ table ol {
background: var(--color-surface); background: var(--color-surface);
} }
.card-nest {
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
var(--color-shadow);
border-radius: var(--radius);
}
.card-nest .card {
box-shadow: 0;
}
.card-nest > .card:first-child {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
background: var(--color-super-raised);
}
.card-nest > .card:last-child {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
/* buttons */ /* buttons */
button, button,
.button { .button {
@ -616,6 +637,11 @@ dialog[open] {
display: block; display: block;
} }
dialog::backdrop {
background: hsla(0, 0%, 0%, 50%);
backdrop-filter: blur(5px);
}
/* dropdown */ /* dropdown */
.dropdown { .dropdown {
position: relative; position: relative;

View file

@ -1,7 +1,7 @@
{% extends "auth/base.html" %} {% block head %} {% extends "auth/base.html" %} {% block head %}
<title>Login</title> <title>Login</title>
{% endblock %} {% block title %}Login{% endblock %} {% block content %} {% endblock %} {% block title %}Login{% endblock %} {% block content %}
<form class="w-full flex flex-col gap-4"> <form class="w-full flex flex-col gap-4" onsubmit="login(event)">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="username"><b>Username</b></label> <label for="username"><b>Username</b></label>
<input <input
@ -26,6 +26,35 @@
<button>Submit</button> <button>Submit</button>
</form> </form>
<script>
function login(e) {
e.preventDefault();
fetch("/api/v1/auth/login", {
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 %} {% endblock %} {% block footer %}
<span class="small w-full text-center" <span class="small w-full text-center"
>Or, <a href="/auth/register">register</a></span >Or, <a href="/auth/register">register</a></span

View file

@ -12,7 +12,7 @@
class="button {% if selected == 'home' %}active{% endif %}" class="button {% if selected == 'home' %}active{% endif %}"
> >
{{ icon "house" }} {{ icon "house" }}
<span class="desktop">Home</span> <span class="desktop">{{ text "general:link.home" }}</span>
</a> </a>
{% endif %} {% endif %}
</div> </div>
@ -30,6 +30,20 @@
{{ macros::avatar(username=user.username, size="24px") }} {{ macros::avatar(username=user.username, size="24px") }}
{{ icon "chevron-down" c(dropdown-arrow) }} {{ icon "chevron-down" c(dropdown-arrow) }}
</button> </button>
<div class="inner">
<b class="title">{{ user.username }}</b>
<a href="/@{{ user.username }}">
{{ icon "book-heart" }}
<span>{{ text "auth:link.my_profile" }}</span>
</a>
<div class="title"></div>
<button class="red" onclick="trigger('me::logout')">
{{ icon "log-out" }}
<span>{{ text "auth:action.logout" }}</span>
</button>
</div>
</div> </div>
{% else %} {% else %}
<div class="dropdown"> <div class="dropdown">
@ -44,11 +58,11 @@
<div class="inner"> <div class="inner">
<a href="/auth/login" class="button"> <a href="/auth/login" class="button">
{{ icon "log-in" }} {{ icon "log-in" }}
<span>Login</span> <span>{{ text "auth:action.login" }}</span>
</a> </a>
<a href="/auth/register" class="button"> <a href="/auth/register" class="button">
{{ icon "user-plus" }} {{ icon "user-plus" }}
<span>Register</span> <span>{{ text "auth:action.register" }}</span>
</a> </a>
</div> </div>
</div> </div>

View file

@ -2,22 +2,12 @@
{{ macros::nav(selected="home") }} {{ macros::nav(selected="home") }}
<main class="flex flex-col gap-2"> <main class="flex flex-col gap-2">
<h1>Hello, world!</h1> <div class="card-nest">
<div class="card">
<div class="pillmenu"> <b>✨ Welcome to <i>{{ config.name }}</i>!</b>
<a class="active" href="#">A</a>
<a href="#">B</a>
<a href="#">C</a>
</div>
<div class="card w-full flex flex-col gap-2">
<div class="flex gap-2 flex-wrap">
<button>Hello, world!</button>
<button class="secondary">Hello, world!</button>
<button class="camo">Hello, world!</button>
</div> </div>
<input type="text" placeholder="abcd" /> <div class="card">We're still working on your feed...</div>
</div> </div>
</main> </main>
{% endblock %} {% endblock %}

View file

@ -22,6 +22,7 @@
globalThis.ns_config = { globalThis.ns_config = {
root: "/js/", root: "/js/",
verbose: globalThis.ns_verbose, verbose: globalThis.ns_verbose,
version: "cache-breaker-{{ random_cache_breaker }}",
}; };
globalThis._app_base = { globalThis._app_base = {
@ -76,5 +77,141 @@
atto["hooks::partial_embeds"](); atto["hooks::partial_embeds"]();
}); });
</script> </script>
<!-- dialogs -->
<dialog id="link_filter">
<div class="inner">
<p>Pressing continue will bring you to the following URL:</p>
<pre><code id="link_filter_url"></code></pre>
<p>Are sure you want to go there?</p>
<hr />
<div class="flex gap-2">
<a
class="button primary bold"
id="link_filter_continue"
rel="noopener noreferrer"
target="_blank"
onclick="document.getElementById('link_filter').close()"
>
{{ text "dialog:action.continue" }}
</a>
<button
class="bold"
type="button"
onclick="document.getElementById('link_filter').close()"
>
{{ text "dialog:action.cancel" }}
</button>
</div>
</div>
</dialog>
<dialog id="web_api_prompt">
<div class="inner flex flex-col gap-2">
<form
class="flex gap-2 flex-col"
onsubmit="event.preventDefault()"
>
<label for="prompt" id="web_api_prompt:msg"></label>
<input id="prompt" name="prompt" />
<div class="flex justify-between">
<div></div>
<div class="flex gap-2">
<button
class="primary bold circle"
onclick="globalThis.web_api_prompt_submit(document.getElementById('prompt').value); document.getElementById('prompt').value = ''"
type="button"
>
{{ icon "check" }} {{ text "dialog:action.okay"
}}
</button>
<button
class="bold red camo"
onclick="globalThis.web_api_prompt_submit('')"
type="button"
>
{{ icon "x" }} {{ text "dialog:action.cancel" }}
</button>
</div>
</div>
</form>
</div>
</dialog>
<dialog id="web_api_prompt_long">
<div class="inner flex flex-col gap-2">
<form
class="flex gap-2 flex-col"
onsubmit="event.preventDefault()"
>
<label
for="prompt_long"
id="web_api_prompt_long:msg"
></label>
<textarea id="prompt_long" name="prompt_long"></textarea>
<div class="flex justify-between">
<div></div>
<div class="flex gap-2">
<button
class="primary bold circle"
onclick="globalThis.web_api_prompt_long_submit(document.getElementById('prompt_long').value); document.getElementById('prompt_long').value = ''"
type="button"
>
{{ icon "check" }} {{ text "dialog:action.okay"
}}
</button>
<button
class="bold red camo"
onclick="globalThis.web_api_prompt_long_submit('')"
type="button"
>
{{ icon "x" }} {{ text "dialog:action.cancel" }}
</button>
</div>
</div>
</form>
</div>
</dialog>
<dialog id="web_api_confirm">
<div class="inner flex flex-col gap-2">
<form
class="flex gap-2 flex-col"
onsubmit="event.preventDefault()"
>
<label id="web_api_confirm:msg"></label>
<div class="flex justify-between">
<div></div>
<div class="flex gap-2">
<button
class="primary bold circle"
onclick="globalThis.web_api_confirm_submit(true)"
type="button"
>
{{ icon "check" }} {{ text "dialog:action.yes"
}}
</button>
<button
class="bold red camo"
onclick="globalThis.web_api_confirm_submit(false)"
type="button"
>
{{ icon "x" }} {{ text "dialog:action.no" }}
</button>
</div>
</div>
</form>
</div>
</dialog>
</body> </body>
</html> </html>

View file

@ -5,5 +5,5 @@
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<rect width="460" height="460" fill="#C9B1BC" /> <rect width="460" height="460" fill="#E793B9" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 181 B

After

Width:  |  Height:  |  Size: 181 B

Before After
Before After

View file

@ -5,5 +5,5 @@
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<rect width="1500" height="350" fill="#C9B1BC" /> <rect width="1500" height="350" fill="#E793B9" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 184 B

After

Width:  |  Height:  |  Size: 184 B

Before After
Before After

View file

@ -34,6 +34,9 @@ media_theme_pref();
(() => { (() => {
const self = reg_ns("atto"); const self = reg_ns("atto");
// init
use("me", () => {});
// env // env
self.DEBOUNCE = []; self.DEBOUNCE = [];
self.OBSERVERS = []; self.OBSERVERS = [];

View file

@ -0,0 +1,30 @@
(() => {
const self = reg_ns("me");
self.define("logout", async () => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch("/api/v1/auth/logout", {
method: "POST",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "sucesss" : "error",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = "/";
}, 150);
}
});
});
})();

View file

@ -11,6 +11,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
}; };
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use tetratto_shared::hash::hash;
/// `/api/v1/auth/register` /// `/api/v1/auth/register`
pub async fn register_request( pub async fn register_request(
@ -20,7 +21,7 @@ pub async fn register_request(
Json(props): Json<AuthProps>, Json(props): Json<AuthProps>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = &(data.read().await).0; let data = &(data.read().await).0;
let user = get_user_from_token!((jar, data) <optional>); let user = get_user_from_token!(jar, data);
if user.is_some() { if user.is_some() {
return ( return (
@ -75,7 +76,7 @@ pub async fn login_request(
Json(props): Json<AuthProps>, Json(props): Json<AuthProps>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = &(data.read().await).0; let data = &(data.read().await).0;
let user = get_user_from_token!((jar, data) <optional>); let user = get_user_from_token!(jar, data);
if user.is_some() { if user.is_some() {
return (None, Json(Error::AlreadyAuthenticated.into())); return (None, Json(Error::AlreadyAuthenticated.into()));
@ -125,3 +126,50 @@ pub async fn login_request(
}), }),
) )
} }
/// `/api/v1/auth/logout`
pub async fn logout_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return (None, Json(Error::NotAllowed.into())),
};
// update tokens
let token = jar
.get("__Secure-atto-token")
.unwrap()
.to_string()
.replace("__Secure-atto-token=", "");
let mut new_tokens = user.tokens.clone();
new_tokens.remove(
new_tokens
.iter()
.position(|t| t.1 == hash(token.to_string()))
.unwrap(),
);
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=0",
"refresh",
),
)]),
Json(ApiReturn {
ok: true,
message: "Goodbye!".to_string(),
payload: (),
}),
)
}

View file

@ -7,9 +7,11 @@ use serde::Deserialize;
pub fn routes() -> Router { pub fn routes() -> Router {
Router::new() Router::new()
// auth
// global // global
.route("/auth/register", post(auth::register_request)) .route("/auth/register", post(auth::register_request))
.route("/auth/login", post(auth::login_request)) .route("/auth/login", post(auth::login_request))
.route("/auth/logout", post(auth::logout_request))
// profile // profile
.route( .route(
"/auth/profile/{id}/avatar", "/auth/profile/{id}/avatar",

View file

@ -1,27 +1,16 @@
use axum::response::IntoResponse; use axum::response::IntoResponse;
/// `/public/favicon.svg` macro_rules! serve_asset {
pub async fn favicon_request() -> impl IntoResponse { ($fn_name:ident: $name:ident($type:literal)) => {
([("Content-Type", "image/svg+xml")], crate::assets::FAVICON) pub async fn $fn_name() -> impl IntoResponse {
([("Content-Type", $type)], crate::assets::$name)
}
};
} }
/// `/css/style.css` serve_asset!(favicon_request: FAVICON("image/svg+xml"));
pub async fn style_css_request() -> impl IntoResponse { serve_asset!(style_css_request: STYLE_CSS("text/css"));
([("Content-Type", "text/css")], crate::assets::STYLE_CSS)
}
/// `/js/atto.js` serve_asset!(loader_js_request: LOADER_JS("text/javascript"));
pub async fn atto_js_request() -> impl IntoResponse { serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
( serve_asset!(me_js_request: ME_JS("text/javascript"));
[("Content-Type", "text/javascript")],
crate::assets::ATTO_JS,
)
}
/// `/js/atto.js`
pub async fn loader_js_request() -> impl IntoResponse {
(
[("Content-Type", "text/javascript")],
crate::assets::LOADER_JS,
)
}

View file

@ -12,8 +12,9 @@ pub fn routes(config: &Config) -> Router {
Router::new() Router::new()
// assets // assets
.route("/css/style.css", get(assets::style_css_request)) .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)) .route("/js/loader.js", get(assets::loader_js_request))
.route("/js/atto.js", get(assets::atto_js_request))
.route("/js/me.js", get(assets::me_js_request))
.nest_service( .nest_service(
"/public", "/public",
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),

View file

@ -8,7 +8,7 @@ use axum_extra::extract::CookieJar;
/// `/auth/login` /// `/auth/login`
pub async fn login_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse { pub async fn login_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = data.read().await; let data = data.read().await;
let user = get_user_from_token!((jar, data.0) <optional>); let user = get_user_from_token!(jar, data.0);
if user.is_some() { if user.is_some() {
return Err(Redirect::to("/")); return Err(Redirect::to("/"));
@ -28,7 +28,7 @@ pub async fn register_request(
Extension(data): Extension<State>, Extension(data): Extension<State>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = data.read().await; let data = data.read().await;
let user = get_user_from_token!((jar, data.0) <optional>); let user = get_user_from_token!(jar, data.0);
if user.is_some() { if user.is_some() {
return Err(Redirect::to("/")); return Err(Redirect::to("/"));

View file

@ -8,7 +8,7 @@ use axum_extra::extract::CookieJar;
/// `/` /// `/`
pub async fn index_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse { pub async fn index_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = data.read().await; let data = data.read().await;
let user = get_user_from_token!((jar, data.0) <optional>); let user = get_user_from_token!(jar, data.0);
let lang = get_lang!(jar, data.0); let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &user); let mut context = initial_context(&data.0.0, lang, &user);

View file

@ -20,3 +20,4 @@ rusqlite = { version = "0.34.0", optional = true }
tokio-postgres = { version = "0.7.13", optional = true } tokio-postgres = { version = "0.7.13", optional = true }
bb8-postgres = { version = "0.9.0", optional = true } bb8-postgres = { version = "0.9.0", optional = true }
bitflags = "2.9.0"

View file

@ -1,4 +1,5 @@
use super::*; use super::*;
use crate::model::permissions::FinePermission;
use crate::model::{Error, Result}; use crate::model::{Error, Result};
use crate::{execute, get, query_row}; use crate::{execute, get, query_row};
@ -18,12 +19,13 @@ impl DataManager {
) -> User { ) -> User {
User { User {
id: get!(x->0(u64)) as usize, id: get!(x->0(u64)) as usize,
created: get!(x->1(u64)) as usize as usize, created: get!(x->1(u64)) as usize,
username: get!(x->2(String)), username: get!(x->2(String)),
password: get!(x->3(String)), password: get!(x->3(String)),
salt: get!(x->4(String)), salt: get!(x->4(String)),
settings: serde_json::from_str(&get!(x->5(String)).to_string()).unwrap(), settings: serde_json::from_str(&get!(x->5(String)).to_string()).unwrap(),
tokens: serde_json::from_str(&get!(x->6(String)).to_string()).unwrap(), tokens: serde_json::from_str(&get!(x->6(String)).to_string()).unwrap(),
permissions: FinePermission::from_bits(get!(x->7(u32))).unwrap(),
} }
} }
@ -124,15 +126,16 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7)", "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
&[ &[
&data.id.to_string(), &data.id.to_string().as_str(),
&data.created.to_string(), &data.created.to_string().as_str(),
&data.username, &data.username.as_str(),
&data.password, &data.password.as_str(),
&data.salt, &data.salt.as_str(),
&serde_json::to_string(&data.settings).unwrap(), &serde_json::to_string(&data.settings).unwrap().as_str(),
&serde_json::to_string(&data.tokens).unwrap(), &serde_json::to_string(&data.tokens).unwrap().as_str(),
&(FinePermission::DEFAULT.bits()).to_string().as_str()
] ]
); );

View file

@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS users (
password TEXT NOT NULL, password TEXT NOT NULL,
salt TEXT NOT NULL, salt TEXT NOT NULL,
settings TEXT NOT NULL, settings TEXT NOT NULL,
tokens TEXT NOT NULL tokens TEXT NOT NULL,
permissions INTEGER NOT NULL
) )

View file

@ -5,6 +5,8 @@ use tetratto_shared::{
unix_epoch_timestamp, unix_epoch_timestamp,
}; };
use super::permissions::FinePermission;
/// `(ip, token, creation timestamp)` /// `(ip, token, creation timestamp)`
pub type Token = (String, String, usize); pub type Token = (String, String, usize);
@ -17,6 +19,7 @@ pub struct User {
pub salt: String, pub salt: String,
pub settings: UserSettings, pub settings: UserSettings,
pub tokens: Vec<Token>, pub tokens: Vec<Token>,
pub permissions: FinePermission,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -45,6 +48,7 @@ impl User {
salt, salt,
settings: UserSettings::default(), settings: UserSettings::default(),
tokens: Vec::new(), tokens: Vec::new(),
permissions: FinePermission::DEFAULT,
} }
} }

View file

@ -1,4 +1,5 @@
pub mod auth; pub mod auth;
pub mod permissions;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -18,6 +19,7 @@ pub enum Error {
RegistrationDisabled, RegistrationDisabled,
DatabaseError(String), DatabaseError(String),
IncorrectPassword, IncorrectPassword,
NotAllowed,
AlreadyAuthenticated, AlreadyAuthenticated,
DataTooLong(String), DataTooLong(String),
DataTooShort(String), DataTooShort(String),
@ -27,14 +29,15 @@ pub enum Error {
impl ToString for Error { impl ToString for Error {
fn to_string(&self) -> String { fn to_string(&self) -> String {
match self { match self {
Error::DatabaseConnection(msg) => msg.to_owned(), Self::DatabaseConnection(msg) => msg.to_owned(),
Error::DatabaseError(msg) => format!("Database error: {msg}"), Self::DatabaseError(msg) => format!("Database error: {msg}"),
Error::UserNotFound => "Unable to find user with given parameters".to_string(), Self::UserNotFound => "Unable to find user with given parameters".to_string(),
Error::RegistrationDisabled => "Registration is disabled".to_string(), Self::RegistrationDisabled => "Registration is disabled".to_string(),
Error::IncorrectPassword => "The given password is invalid".to_string(), Self::IncorrectPassword => "The given password is invalid".to_string(),
Error::AlreadyAuthenticated => "Already authenticated".to_string(), Self::NotAllowed => "You are not allowed to do this".to_string(),
Error::DataTooLong(name) => format!("Given {name} is too long!"), Self::AlreadyAuthenticated => "Already authenticated".to_string(),
Error::DataTooShort(name) => format!("Given {name} is too short!"), Self::DataTooLong(name) => format!("Given {name} is too long!"),
Self::DataTooShort(name) => format!("Given {name} is too short!"),
_ => format!("An unknown error as occurred: ({:?})", self), _ => format!("An unknown error as occurred: ({:?})", self),
} }
} }

View file

@ -0,0 +1,123 @@
use bitflags::bitflags;
use serde::{
Deserialize, Deserializer, Serialize,
de::{Error as DeError, Visitor},
};
bitflags! {
/// Fine-grained permissions built using bitwise operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FinePermission: u32 {
const DEFAULT = 1 << 0;
const ADMINISTRATOR = 1 << 1;
const MANAGE_JOURNAL_PAGES = 1 << 2;
const MANAGE_JOURNAL_ENTRIES = 1 << 3;
const MANAGE_JOURNAL_ENTRY_COMMENTS = 1 << 4;
const MANAGE_USERS = 1 << 5;
const MANAGE_BANS = 1 << 6; // includes managing IP bans
const MANAGE_WARNINGS = 1 << 7;
const MANAGE_NOTIFICATIONS = 1 << 8;
const VIEW_REPORTS = 1 << 9;
const VIEW_AUDIT_LOG = 1 << 10;
const _ = !0;
}
}
impl Serialize for FinePermission {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_u32(self.bits())
}
}
struct FinePermissionVisitor;
impl<'de> Visitor<'de> for FinePermissionVisitor {
type Value = FinePermission;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("u32")
}
fn visit_u32<E>(self, value: u32) -> Result<Self::Value, E>
where
E: DeError,
{
if let Some(permission) = FinePermission::from_bits(value) {
Ok(permission)
} else {
Ok(FinePermission::from_bits_retain(value))
}
}
fn visit_i32<E>(self, value: i32) -> Result<Self::Value, E>
where
E: DeError,
{
if let Some(permission) = FinePermission::from_bits(value as u32) {
Ok(permission)
} else {
Ok(FinePermission::from_bits_retain(value as u32))
}
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: DeError,
{
if let Some(permission) = FinePermission::from_bits(value as u32) {
Ok(permission)
} else {
Ok(FinePermission::from_bits_retain(value as u32))
}
}
}
impl<'de> Deserialize<'de> for FinePermission {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(FinePermissionVisitor)
}
}
impl FinePermission {
/// Join two [`FinePermission`]s into a single `u32`.
pub fn join(lhs: FinePermission, rhs: FinePermission) -> FinePermission {
lhs | rhs
}
/// Check if the given `input` contains the given [`FinePermission`].
pub fn check(self, permission: FinePermission) -> bool {
if (self & FinePermission::ADMINISTRATOR) == FinePermission::ADMINISTRATOR {
// has administrator permission, meaning everything else is automatically true
return true;
}
(self & permission) == permission
}
/// Check if thhe given [`FinePermission`] is qualifies as "Helper" status.
pub fn check_helper(self) -> bool {
self.check(FinePermission::MANAGE_JOURNAL_ENTRIES)
&& self.check(FinePermission::MANAGE_JOURNAL_PAGES)
&& self.check(FinePermission::MANAGE_JOURNAL_ENTRY_COMMENTS)
&& self.check(FinePermission::MANAGE_WARNINGS)
&& self.check(FinePermission::VIEW_REPORTS)
&& self.check(FinePermission::VIEW_AUDIT_LOG)
}
/// Check if thhe given [`FinePermission`] is qualifies as "Manager" status.
pub fn check_manager(self) -> bool {
self.check_helper() && self.check(FinePermission::ADMINISTRATOR)
}
}
impl Default for FinePermission {
fn default() -> Self {
Self::DEFAULT
}
}

1
crates/l10n/LICENSE Symbolic link
View file

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

1
crates/shared/LICENSE Symbolic link
View file

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

View file

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