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

+
+

Hostname:

+

API key:

+

+

+ +
+ + +{% 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 }}
+
+

{{ message }}

+
{% endfor %}
- {% endif %} - {% endwith %} -
+ {% endif %} + {% endwith %} + {% block content %}Oops, an empty page :/{% endblock %}