More tentacles progress

This commit is contained in:
Reid 'arrdem' McKenzie 2023-05-22 22:21:53 -06:00
parent 1f7c21f26e
commit e05cf362df
14 changed files with 442 additions and 139 deletions

View file

@ -1,4 +1,9 @@
SECRET_KEY = "SgvzxsO5oPBGInmqsyyGQWAJXkS9"
[db]
uri = "tentacles.sqlite3"
uri = "/home/arrdem/Documents/hobby/programming/source/projects/tentacles/tentacles.sqlite3"
[[users]]
email = "root@tirefireind.us"
group_id = 0
status_id = 1

View file

@ -6,10 +6,10 @@
from pathlib import Path
import click
from flask import Flask, request, session, current_app, Authorization
from flask import Flask, request, session, current_app
import tomllib
from tentacles.blueprints import ui, api
from tentacles.blueprints import user_ui, printer_ui, api
from tentacles.store import Store
@ -19,22 +19,28 @@ def cli():
def open_db():
current_app.db = Store(current_app.config.get("db", {}).get("uri"))
current_app.db.connect()
request.db = Store(current_app.config.get("db", {}).get("uri"))
request.db.connect()
def commit_db(resp):
current_app.db.commit()
request.db.close()
return resp
def create_j2_request_global():
current_app.jinja_env.globals["request"] = request
def user_session():
if (session_id := request.cookies.get("sid", "")) and (
uid := current_app.db.try_key(session_id)
uid := request.db.try_key(session_id)
):
request.sid = session_id
request.uid = uid
_, gid, name, _ = current_app.db.fetch_user(uid)
_id, gid, name, _email, _hash, _status, _verification = request.db.fetch_user(
uid
)
request.gid = gid
request.username = name
request.is_admin = gid == 0
@ -58,11 +64,19 @@ def serve(hostname: str, port: int, config: Path):
print(app.config)
app.before_first_request(open_db)
# Before first request
app.before_first_request(create_j2_request_global)
# Before request
app.before_request(open_db)
app.before_request(user_session)
# After request
app.after_request(commit_db)
app.register_blueprint(ui.BLUEPRINT)
# Blueprints
app.register_blueprint(user_ui.BLUEPRINT)
app.register_blueprint(printer_ui.BLUEPRINT)
app.register_blueprint(api.BLUEPRINT)
app.run(host=hostname, port=port)

View file

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Blueprints for HTML serving 'ui'."""
import logging
from datetime import timedelta
from importlib.resources import files
from click import group
from flask import (
Blueprint,
current_app,
request,
Request,
redirect,
render_template,
session,
url_for,
flash,
)
from .util import is_logged_in
log = logging.getLogger(__name__)
BLUEPRINT = Blueprint("printer", __name__)
@BLUEPRINT.route("/printers")
def printers():
return render_template("printers.html.j2")
@BLUEPRINT.route("/printers/add", methods=["get", "post"])
def add_printer():
if not is_logged_in(request):
return redirect("/")
elif request.method == "POST":
pass
return render_template("add_printer.html.j2")
@BLUEPRINT.route("/printers/delete")
def delete_printer():
return render_template("delete_printer.html.j2")

View file

@ -1,95 +0,0 @@
#!/usr/bin/env python3
"""Blueprints for HTML serving 'ui'."""
from datetime import timedelta
from importlib.resources import files
from flask import (
Blueprint,
current_app,
request,
Request,
redirect,
render_template,
session,
url_for,
flash,
)
BLUEPRINT = Blueprint("ui", __name__)
def is_logged_in(request: Request) -> bool:
return request.uid is not None
@BLUEPRINT.route("/")
def root():
return (
render_template(
"index.html.j2",
request=request,
),
200,
)
@BLUEPRINT.route("/login", methods=["GET", "POST"])
def login():
if is_logged_in(request):
return redirect("/")
elif request.method == "POST":
if sid := current_app.db.try_login(
username := request.form["username"],
request.form["password"],
timedelta(days=1),
):
resp = redirect("/")
resp.set_cookie("sid", sid)
flash(f"Welcome, {username}", category="success")
return resp
else:
flash("Incorrect username/password", category="error")
return render_template("login.html.j2")
else:
return render_template("login.html.j2")
@BLUEPRINT.route("/register", methods=["GET", "POST"])
def register():
if is_logged_in(request):
return redirect("/")
elif request.method == "POST":
try:
if uid := current_app.db.try_create_user(
request.form["username"],
request.form["email"],
request.form["password"],
):
flash(
"Please check your email for a verification request",
category="success",
)
return render_template("register.html.j2")
except:
pass
flash("Unable to register that username", category="error")
return render_template("register.html.j2")
else:
return render_template("register.html.j2")
@BLUEPRINT.route("/logout")
def logout():
# Invalidate the user's authorization
current_app.db.delete_key(request.sid)
resp = redirect("/")
resp.set_cookie("sid", "")
return resp

View file

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Blueprints for HTML serving 'ui'."""
import logging
from datetime import timedelta
from importlib.resources import files
from click import group
from flask import (
Blueprint,
current_app,
request,
Request,
redirect,
render_template,
session,
url_for,
flash,
)
from .util import salt, is_logged_in
log = logging.getLogger(__name__)
BLUEPRINT = Blueprint("user", __name__)
@BLUEPRINT.route("/")
def root():
return (
render_template(
"index.html.j2",
),
200,
)
@BLUEPRINT.route("/user/login", methods=["GET", "POST"])
def login():
if is_logged_in(request):
return redirect("/")
elif request.method == "POST":
if sid := request.db.try_login(
username := request.form["username"],
salt(request.form["password"]),
timedelta(days=1),
):
resp = redirect("/")
resp.set_cookie("sid", sid)
flash(f"Welcome, {username}", category="success")
return resp
else:
flash("Incorrect username/password", category="error")
return render_template("login.html.j2")
else:
return render_template("login.html.j2")
@BLUEPRINT.route("/user/register", methods=["GET", "POST"])
def register():
if is_logged_in(request):
return redirect("/")
elif request.method == "POST":
try:
username = request.form["username"]
email = request.form["email"]
group_id = None
status_id = None
for user_config in current_app.config.get("users", []):
if user_config["email"] == email:
if "group_id" in user_config:
group_id = user_config["group_id"]
if "status_id" in user_config:
status_id = user_config["status_id"]
break
if res := request.db.try_create_user(
username, email, salt(request.form["password"]), group_id, status_id
):
id, status = res
if status == -1:
flash(
"Please check your email for a verification request",
category="success",
)
return render_template("register.html.j2")
except Exception as e:
log.exception("Error encountered while registering a user...")
flash("Unable to register that username", category="error")
return render_template("register.html.j2")
else:
return render_template("register.html.j2")
@BLUEPRINT.route("/user/logout")
def logout():
# Invalidate the user's authorization
request.db.delete_key(request.sid)
resp = redirect("/")
resp.set_cookie("sid", "")
return resp
@BLUEPRINT.route("/user", methods=["GET", "POST"])
def settings():
if is_logged_in(request):
return redirect("/")
elif request.method == "POST":
pass
else:
return render_template("user.html.j2")

View file

@ -0,0 +1,29 @@
#!/usr/bin/env python3
import logging
from datetime import timedelta
from importlib.resources import files
from click import group
from flask import (
Blueprint,
current_app,
request,
Request,
redirect,
render_template,
session,
url_for,
flash,
)
log = logging.getLogger(__name__)
def is_logged_in(request: Request) -> bool:
return request.uid is not None
def salt(password: str) -> str:
return "$SALT$" + current_app.config["SECRET_KEY"] + password

View file

@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS users (
, FOREIGN KEY(group_id) REFERENCES groups(id)
, FOREIGN KEY(status_id) REFERENCES user_statuses(id)
, UNIQUE(name)
, UNIQUE(email)
);
----------------------------------------------------------------------------------------------------

View file

@ -10,31 +10,35 @@ $secondary_blue: #288BC2;
$secondary_green: #A5C426;
$secondary_light_grey: #CACBCA;
$secondary_dark_grey: #9A9A9A;
$secondary_red: red;
$clear: rgba(255, 255, 255, 255);
@font-face {
font-family: 'Aaux Next';
font-style: normal;
font-weight: 400;
src: local('Aaux Next'), url('https://fonts.cdnfonts.com/s/60597/AauxNextBlk.woff') format('woff');
src: local('Aaux Next'), url('/static/font/AauxNextBlk.otf') format('otf');
}
@font-face {
font-family: 'Aaux Next';
font-style: normal;
font-weight: 400;
src: local('Aaux Next'), url('https://fonts.cdnfonts.com/s/60597/aauxnextbdwebfont.woff') format('woff');
src: local('Aaux Next'), url('/static/font/aauxnextbdwebfont.otf') format('otf');
}
@font-face {
font-family: 'Aaux Next';
font-style: normal;
font-weight: 400;
src: local('Aaux Next'), url('https://fonts.cdnfonts.com/s/60597/aauxnextltwebfont.woff') format('woff');
src: local('Aaux Next'), url('/static/font/aauxnextltwebfont.otf') format('otf');
}
@font-face {
font-family: 'Aaux Next';
font-style: normal;
font-weight: 400;
src: local('Aaux Next'), url('https://fonts.cdnfonts.com/s/60597/aauxnextmdwebfont.woff') format('woff');
src: local('Aaux Next'), url('/static/font/aauxnextmdwebfont.otf') format('otf');
}
@import url(https://fonts.googleapis.com/css?family=Raleway);
@ -55,6 +59,20 @@ html, body {
height: 100%;
width: 100%;
min-width: 400px;
display: flex;
flex-grow: 1;
flex-direction: column;
}
.content, .footer {
padding-left: 10%;
padding-right: 10%;
}
.content {
.flash, .panel {
margin-bottom: 40px;
}
}
a {
@ -228,3 +246,29 @@ $navbar_padding: 10px;
padding-left: 10%;
padding-right: 10%;
}
.footer {
margin-top: auto;
width: 100%;
}
.flashes {
.flash {
border: 10px solid $secondary_blue;
border-radius: 20px;
min-height: 40px;
p {
font-size: 20px;
margin-top: 10px;
margin-left: 10px;
}
}
.success {
border-color: $secondary_green;
}
.error {
border-color: $secondary_red;
}
}

View file

@ -1,12 +1,12 @@
#!/usr/bin/env python3
from datetime import timedelta, datetime
from collections import namedtuple
from datetime import datetime, timedelta
from hashlib import sha3_256
from importlib.resources import files
from pathlib import Path
import sqlite3
from textwrap import indent
from importlib.resources import files
from typing import Optional
@ -45,14 +45,28 @@ def one(it, *args, **kwargs):
return it
class StoreError(Exception):
pass
class LoginError(StoreError):
pass
class Store(object):
def __init__(self, path):
self._path = path
self._conn: sqlite3.Connection = None
def _factory(self, cursor, row):
fields = [column[0] for column in cursor.description]
cls = namedtuple("Row", fields)
return cls._make(row)
def connect(self):
if not self._conn:
self._conn = sqlite3.connect(self._path, isolation_level="IMMEDIATE")
self._conn.row_factory = self._factory
for hunk in PRELUDE.split("\n\n"):
try:
self._conn.executescript(hunk).fetchall()
@ -71,16 +85,28 @@ class Store(object):
self._conn.close()
self._conn = None
@fmap(one)
################################################################################
# Users
@requires_conn
def try_create_user(self, username, email, password):
"""Attempt to create a new user."""
def try_create_user(self, username, email, password, group_id=10, status_id=-2):
"""Attempt to create a new user.
:param username: The name of the user to be created.
:param email: The email of the user to be created.
:param password: The (hopefully salted!) plain text password for the user. Will be hashed before storage.
:param group_id: The numeric ID of a group to assign the user to. Default 10 AKA normal user.
:param status_id: The numeric ID of the status to assign the user to. Default -2 AKA email verification required.
"""
digest = sha3_256()
digest.update(password.encode("utf-8"))
digest = digest.hexdigest()
print(f"{username}: {digest!r}")
return self._conn.execute(
"INSERT INTO users (name, email, hash) VALUES (?, ?, ?) RETURNING (id)",
[username, email, digest.hexdigest()],
"INSERT INTO users (name, email, hash, group_id, status_id) VALUES (?, ?, ?, ?, ?) RETURNING id, status_id",
[username, email, digest, group_id, status_id],
).fetchone()
@requires_conn
@ -99,6 +125,15 @@ class Store(object):
def list_users(self):
return self._conn.execute("SELECT id, name FROM users").fetchall()
@fmap(one)
@requires_conn
def fetch_user_status(self, user_status_id: int):
"""Fetch a user status by ID"""
return self._conn.execute(
"SELECT id, name FROM user_statuses WHERE id = ?", [user_status_id]
).fetchone()
################################################################################
# Sessions / 'keys'
@ -120,15 +155,23 @@ class Store(object):
digest = sha3_256()
digest.update(password.encode("utf-8"))
digest = digest.hexdigest()
print(f"{username}: {digest!r}")
res = self._conn.execute(
"SELECT id FROM users WHERE name=? AND hash=? LIMIT 1",
[username, digest.hexdigest()],
"SELECT id, status_id FROM users WHERE (name=?1 AND hash=?2) OR (email=?1 AND hash=?2) LIMIT 1",
[username, digest],
).fetchone()
if not res:
return
uid = res[0]
uid, status = res
if status > 0:
return self._create_session(uid, ttl)
else:
_, status = self.fetch_user_status(status)
raise LoginError(status)
@requires_conn
def create_key(self, kid: str, ttl) -> Optional[str]:
"""Given an _existing_ login session, create a new key.
@ -183,16 +226,23 @@ class Store(object):
# Printers
#
# Printers represent connections to OctoPrint instances controlling physical machines.
@fmap(one)
@requires_conn
def create_printer(self):
pass
def try_create_printer(self, url, api_key):
self._conn.execute(
"INSERT INTO printers (url, api_key, status_id) VALUES (?, ?, 0) RETURNING id",
[url, api_key],
).fetchone()
@requires_conn
def list_printers(self):
pass
return self._conn.execute(
"SELECT id, url, last_poll_date, s.name as status FROM printers p INNER JOIN printer_stauses s ON p.status_id = s.id"
).fetchall()
@requires_conn
def update_printer_status(self):
def update_printer_status(self, printer_id, status_id):
pass
################################################################################

View file

@ -0,0 +1,31 @@
{% extends "base.html.j2" %}
{% block content %}
<h1>Add printer</h1>
<form method="post" id="form">
<p>Hostname: <input type="text" name="hostname" /></p>
<p>API key: <input type="text" name="key" /></p>
<p><input id="test" type="button" value="Test" enabled="false" /></p>
<p><input id="submit" type="submit" value="Add" onclick="maybeSubmit();" /></p>
<input type="hidden" name="tested" value="false" />
</form>
<script type="text/javascript">
document.getElementById("input").disabled = True;
function testSettings() {
var formData = new FormData(document.getElementById("form"))
var req = new XMLHttpRequest();
req.open("POST", "/printer/test");
req.send(formData);
};
function maybeSubmit() {
if (document.getElementById("tested").value == "true") {
document.getElementById("form").submit();
} else {
console.error("Form values have not been tested!");
}
};
</script>
{% endblock %}

View file

@ -24,28 +24,31 @@
<ul class="menu">
{% if not request.uid %}
<li><a href="/login">Log in</a></li>
<li><a href="/register">Register</a></li>
<li><a href="/user/login">Log in</a></li>
<li><a href="/user/register">Register</a></li>
{% else %}
{% if request.is_admin %}
<li><a href="/printers">Settings</a></li>
<li><a href="/printers">Printers</a></li>
{% endif %}
<li><a href="/settings">Settings</a></li>
<li><a href="/logout">Log out</a></li>
<li><a href="/user">Settings</a></li>
<li><a href="/user/logout">Log out</a></li>
{% endif %}
</ul>
</nav>
</div>
<div class="content">
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
<div class="flashes">
{% for category, message in messages %}
<div class="flash-{{ category }}">{{ message }}</div>
<div class="flash {{ category }}">
<center><p>{{ message }}</p></center>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="content">
{% block content %}Oops, an empty page :/{% endblock %}
</div>
<div class="footer">

View file

@ -1,4 +1,49 @@
{% extends "base.html.j2" %}
{% block content %}
<p>Hello, {% if request.uid %}{{ request.username }}{% else %}world{% endif %}!</p>
<div class="panel printers">
<h2>Printers</h2>
{% with printers = request.db.list_printers() %}
{% if printers %}
<ul>
{% for printer in printers %}
<li></li>
{% endfor %}
</ul>
{% else %}
No printers available. {% if request.is_admin %}<a href="/printers/add">Configure one!</a>{% else %}Ask the admin to configure one!{% endif %}
{% endif %}
{% endwith %}
</div>
<div class="panel queue">
<h2>Queue</h2>
{% with jobs = request.db.list_jobs(uid=request.uid) %}
{% if jobs %}
<ul>
{% for job in jobs %}
<li></li>
{% endfor %}
</ul>
{% else %}
No pending tasks. {% if request.uid %}Start something!{% endif %}
{% endif %}
{% endwith %}
</div>
{% if request.uid %}
<div class="panel files">
<h2>Files</h2>
{% with files = request.db.list_files(uid=request.uid) %}
{% if files %}
<ul>
{% for file in files %}
<li></li>
{% endfor %}
</ul>
{% else %}
You don't have any files. Upload something!
{% endif %}
{% endwith %}
</div>
{% endif %}
{% endblock %}

View file

@ -5,6 +5,6 @@
<p>Username: <input type="text" name="username">
<p>Email address: <input type="text" name="email">
<p>Password: <input type="password" name="password">
<p><input type="submit" value=Register>
<p><input type="submit" value="Register">
</form>
{% endblock %}

View file

@ -26,7 +26,13 @@ def password_testy():
@pytest.fixture
def uid_testy(store, username_testy, password_testy):
return store.try_create_user(username_testy, password_testy)
uid, status = store.try_create_user(
username_testy,
username_testy,
password_testy,
status_id=1,
)
return uid
@pytest.fixture