diff --git a/projects/tentacles/config.toml b/projects/tentacles/config.toml
index 5313bbb..cbd6019 100644
--- a/projects/tentacles/config.toml
+++ b/projects/tentacles/config.toml
@@ -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
diff --git a/projects/tentacles/src/python/tentacles/__main__.py b/projects/tentacles/src/python/tentacles/__main__.py
index 6a69e0c..926dc8a 100644
--- a/projects/tentacles/src/python/tentacles/__main__.py
+++ b/projects/tentacles/src/python/tentacles/__main__.py
@@ -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)
diff --git a/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py b/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py
new file mode 100644
index 0000000..27e6da0
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py
@@ -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")
diff --git a/projects/tentacles/src/python/tentacles/blueprints/ui.py b/projects/tentacles/src/python/tentacles/blueprints/ui.py
deleted file mode 100644
index 25d078b..0000000
--- a/projects/tentacles/src/python/tentacles/blueprints/ui.py
+++ /dev/null
@@ -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
diff --git a/projects/tentacles/src/python/tentacles/blueprints/user_ui.py b/projects/tentacles/src/python/tentacles/blueprints/user_ui.py
new file mode 100644
index 0000000..49633ee
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/blueprints/user_ui.py
@@ -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")
diff --git a/projects/tentacles/src/python/tentacles/blueprints/util.py b/projects/tentacles/src/python/tentacles/blueprints/util.py
new file mode 100644
index 0000000..25b2725
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/blueprints/util.py
@@ -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
diff --git a/projects/tentacles/src/python/tentacles/schema.sql b/projects/tentacles/src/python/tentacles/schema.sql
index d077e9f..3d7aa3a 100644
--- a/projects/tentacles/src/python/tentacles/schema.sql
+++ b/projects/tentacles/src/python/tentacles/schema.sql
@@ -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)
);
----------------------------------------------------------------------------------------------------
diff --git a/projects/tentacles/src/python/tentacles/static/css/style.scss b/projects/tentacles/src/python/tentacles/static/css/style.scss
index 0fea28a..70af198 100644
--- a/projects/tentacles/src/python/tentacles/static/css/style.scss
+++ b/projects/tentacles/src/python/tentacles/static/css/style.scss
@@ -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;
+ }
+}
diff --git a/projects/tentacles/src/python/tentacles/store.py b/projects/tentacles/src/python/tentacles/store.py
index 17b0b41..197fcd6 100644
--- a/projects/tentacles/src/python/tentacles/store.py
+++ b/projects/tentacles/src/python/tentacles/store.py
@@ -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,14 +155,22 @@ 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]
- return self._create_session(uid, ttl)
+
+ 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]:
@@ -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
################################################################################
diff --git a/projects/tentacles/src/python/tentacles/templates/add_printer.html.j2 b/projects/tentacles/src/python/tentacles/templates/add_printer.html.j2
new file mode 100644
index 0000000..479d7ba
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/templates/add_printer.html.j2
@@ -0,0 +1,31 @@
+{% extends "base.html.j2" %}
+{% block content %}
+
Add printer
+
+
+
+{% endblock %}
diff --git a/projects/tentacles/src/python/tentacles/templates/base.html.j2 b/projects/tentacles/src/python/tentacles/templates/base.html.j2
index 1f77969..d5a5524 100644
--- a/projects/tentacles/src/python/tentacles/templates/base.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/base.html.j2
@@ -24,28 +24,31 @@
- {% with messages = get_flashed_messages(with_categories=True) %}
- {% if messages %}
+
+ {% with messages = get_flashed_messages(with_categories=True) %}
+ {% if messages %}
{% for category, message in messages %}
-
{{ message }}
+
{% endfor %}
- {% endif %}
- {% endwith %}
-
+ {% endif %}
+ {% endwith %}
+
{% block content %}Oops, an empty page :/{% endblock %}