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" SECRET_KEY = "SgvzxsO5oPBGInmqsyyGQWAJXkS9"
[db] [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 from pathlib import Path
import click import click
from flask import Flask, request, session, current_app, Authorization from flask import Flask, request, session, current_app
import tomllib import tomllib
from tentacles.blueprints import ui, api from tentacles.blueprints import user_ui, printer_ui, api
from tentacles.store import Store from tentacles.store import Store
@ -19,22 +19,28 @@ def cli():
def open_db(): def open_db():
current_app.db = Store(current_app.config.get("db", {}).get("uri")) request.db = Store(current_app.config.get("db", {}).get("uri"))
current_app.db.connect() request.db.connect()
def commit_db(resp): def commit_db(resp):
current_app.db.commit() request.db.close()
return resp return resp
def create_j2_request_global():
current_app.jinja_env.globals["request"] = request
def user_session(): def user_session():
if (session_id := request.cookies.get("sid", "")) and ( 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.sid = session_id
request.uid = uid 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.gid = gid
request.username = name request.username = name
request.is_admin = gid == 0 request.is_admin = gid == 0
@ -58,11 +64,19 @@ def serve(hostname: str, port: int, config: Path):
print(app.config) 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) app.before_request(user_session)
# After request
app.after_request(commit_db) 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.register_blueprint(api.BLUEPRINT)
app.run(host=hostname, port=port) 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(group_id) REFERENCES groups(id)
, FOREIGN KEY(status_id) REFERENCES user_statuses(id) , FOREIGN KEY(status_id) REFERENCES user_statuses(id)
, UNIQUE(name) , UNIQUE(name)
, UNIQUE(email)
); );
---------------------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------

View file

@ -10,31 +10,35 @@ $secondary_blue: #288BC2;
$secondary_green: #A5C426; $secondary_green: #A5C426;
$secondary_light_grey: #CACBCA; $secondary_light_grey: #CACBCA;
$secondary_dark_grey: #9A9A9A; $secondary_dark_grey: #9A9A9A;
$secondary_red: red;
$clear: rgba(255, 255, 255, 255); $clear: rgba(255, 255, 255, 255);
@font-face { @font-face {
font-family: 'Aaux Next'; font-family: 'Aaux Next';
font-style: normal; font-style: normal;
font-weight: 400; 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-face {
font-family: 'Aaux Next'; font-family: 'Aaux Next';
font-style: normal; font-style: normal;
font-weight: 400; 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-face {
font-family: 'Aaux Next'; font-family: 'Aaux Next';
font-style: normal; font-style: normal;
font-weight: 400; 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-face {
font-family: 'Aaux Next'; font-family: 'Aaux Next';
font-style: normal; font-style: normal;
font-weight: 400; 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); @import url(https://fonts.googleapis.com/css?family=Raleway);
@ -55,6 +59,20 @@ html, body {
height: 100%; height: 100%;
width: 100%; width: 100%;
min-width: 400px; 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 { a {
@ -228,3 +246,29 @@ $navbar_padding: 10px;
padding-left: 10%; padding-left: 10%;
padding-right: 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 #!/usr/bin/env python3
from datetime import timedelta, datetime from collections import namedtuple
from datetime import datetime, timedelta
from hashlib import sha3_256 from hashlib import sha3_256
from importlib.resources import files
from pathlib import Path from pathlib import Path
import sqlite3 import sqlite3
from textwrap import indent from textwrap import indent
from importlib.resources import files
from typing import Optional from typing import Optional
@ -45,14 +45,28 @@ def one(it, *args, **kwargs):
return it return it
class StoreError(Exception):
pass
class LoginError(StoreError):
pass
class Store(object): class Store(object):
def __init__(self, path): def __init__(self, path):
self._path = path self._path = path
self._conn: sqlite3.Connection = None 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): def connect(self):
if not self._conn: if not self._conn:
self._conn = sqlite3.connect(self._path, isolation_level="IMMEDIATE") self._conn = sqlite3.connect(self._path, isolation_level="IMMEDIATE")
self._conn.row_factory = self._factory
for hunk in PRELUDE.split("\n\n"): for hunk in PRELUDE.split("\n\n"):
try: try:
self._conn.executescript(hunk).fetchall() self._conn.executescript(hunk).fetchall()
@ -71,16 +85,28 @@ class Store(object):
self._conn.close() self._conn.close()
self._conn = None self._conn = None
@fmap(one) ################################################################################
# Users
@requires_conn @requires_conn
def try_create_user(self, username, email, password): def try_create_user(self, username, email, password, group_id=10, status_id=-2):
"""Attempt to create a new user.""" """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 = sha3_256()
digest.update(password.encode("utf-8")) digest.update(password.encode("utf-8"))
digest = digest.hexdigest()
print(f"{username}: {digest!r}")
return self._conn.execute( return self._conn.execute(
"INSERT INTO users (name, email, hash) VALUES (?, ?, ?) RETURNING (id)", "INSERT INTO users (name, email, hash, group_id, status_id) VALUES (?, ?, ?, ?, ?) RETURNING id, status_id",
[username, email, digest.hexdigest()], [username, email, digest, group_id, status_id],
).fetchone() ).fetchone()
@requires_conn @requires_conn
@ -99,6 +125,15 @@ class Store(object):
def list_users(self): def list_users(self):
return self._conn.execute("SELECT id, name FROM users").fetchall() 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' # Sessions / 'keys'
@ -120,15 +155,23 @@ class Store(object):
digest = sha3_256() digest = sha3_256()
digest.update(password.encode("utf-8")) digest.update(password.encode("utf-8"))
digest = digest.hexdigest()
print(f"{username}: {digest!r}")
res = self._conn.execute( res = self._conn.execute(
"SELECT id FROM users WHERE name=? AND hash=? LIMIT 1", "SELECT id, status_id FROM users WHERE (name=?1 AND hash=?2) OR (email=?1 AND hash=?2) LIMIT 1",
[username, digest.hexdigest()], [username, digest],
).fetchone() ).fetchone()
if not res: if not res:
return return
uid = res[0]
uid, status = res
if status > 0:
return self._create_session(uid, ttl) return self._create_session(uid, ttl)
else:
_, status = self.fetch_user_status(status)
raise LoginError(status)
@requires_conn @requires_conn
def create_key(self, kid: str, ttl) -> Optional[str]: def create_key(self, kid: str, ttl) -> Optional[str]:
"""Given an _existing_ login session, create a new key. """Given an _existing_ login session, create a new key.
@ -183,16 +226,23 @@ class Store(object):
# Printers # Printers
# #
# Printers represent connections to OctoPrint instances controlling physical machines. # Printers represent connections to OctoPrint instances controlling physical machines.
@fmap(one)
@requires_conn @requires_conn
def create_printer(self): def try_create_printer(self, url, api_key):
pass self._conn.execute(
"INSERT INTO printers (url, api_key, status_id) VALUES (?, ?, 0) RETURNING id",
[url, api_key],
).fetchone()
@requires_conn @requires_conn
def list_printers(self): 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 @requires_conn
def update_printer_status(self): def update_printer_status(self, printer_id, status_id):
pass 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"> <ul class="menu">
{% if not request.uid %} {% if not request.uid %}
<li><a href="/login">Log in</a></li> <li><a href="/user/login">Log in</a></li>
<li><a href="/register">Register</a></li> <li><a href="/user/register">Register</a></li>
{% else %} {% else %}
{% if request.is_admin %} {% if request.is_admin %}
<li><a href="/printers">Settings</a></li> <li><a href="/printers">Printers</a></li>
{% endif %} {% endif %}
<li><a href="/settings">Settings</a></li> <li><a href="/user">Settings</a></li>
<li><a href="/logout">Log out</a></li> <li><a href="/user/logout">Log out</a></li>
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>
</div> </div>
<div class="content">
{% with messages = get_flashed_messages(with_categories=True) %} {% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %} {% if messages %}
<div class="flashes"> <div class="flashes">
{% for category, message in messages %} {% for category, message in messages %}
<div class="flash-{{ category }}">{{ message }}</div> <div class="flash {{ category }}">
<center><p>{{ message }}</p></center>
</div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<div class="content">
{% block content %}Oops, an empty page :/{% endblock %} {% block content %}Oops, an empty page :/{% endblock %}
</div> </div>
<div class="footer"> <div class="footer">

View file

@ -1,4 +1,49 @@
{% extends "base.html.j2" %} {% extends "base.html.j2" %}
{% block content %} {% 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 %} {% endblock %}

View file

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

View file

@ -26,7 +26,13 @@ def password_testy():
@pytest.fixture @pytest.fixture
def uid_testy(store, username_testy, password_testy): 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 @pytest.fixture