add(ui): ability to log out
This commit is contained in:
parent
d2ca9e23d3
commit
b3cac5f97a
29 changed files with 499 additions and 124 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -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",
|
||||||
|
|
31
README.md
31
README.md
|
@ -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`)
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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 = [];
|
||||||
|
|
30
crates/app/src/public/js/me.js
Normal file
30
crates/app/src/public/js/me.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
|
@ -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: (),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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)),
|
||||||
|
|
|
@ -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("/"));
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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()
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
123
crates/core/src/model/permissions.rs
Normal file
123
crates/core/src/model/permissions.rs
Normal 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
1
crates/l10n/LICENSE
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../../LICENSE
|
1
crates/shared/LICENSE
Symbolic link
1
crates/shared/LICENSE
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../../LICENSE
|
|
@ -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"
|
|
Loading…
Add table
Add a link
Reference in a new issue