diff --git a/projects/tentacles/src/python/tentacles/__main__.py b/projects/tentacles/src/python/tentacles/__main__.py index 091b3fc..00a220b 100644 --- a/projects/tentacles/src/python/tentacles/__main__.py +++ b/projects/tentacles/src/python/tentacles/__main__.py @@ -11,10 +11,10 @@ import cherrypy import click from flask import Flask, request from tentacles.blueprints import ( + admin_ui, api, file_ui, job_ui, - printer_ui, user_ui, ) from tentacles.db import Db @@ -39,8 +39,7 @@ def custom_ctx(app, wsgi_app): store = db_factory(app) token = _ctx.set(Ctx(store)) try: - with store.savepoint(): - return wsgi_app(environ, start_response) + return wsgi_app(environ, start_response) finally: store.close() _ctx.reset(token) @@ -88,11 +87,11 @@ def make_app(): app.before_request(user_session) # Blueprints - app.register_blueprint(user_ui.BLUEPRINT) - app.register_blueprint(printer_ui.BLUEPRINT) - app.register_blueprint(job_ui.BLUEPRINT) - app.register_blueprint(file_ui.BLUEPRINT) + app.register_blueprint(admin_ui.BLUEPRINT) app.register_blueprint(api.BLUEPRINT) + app.register_blueprint(file_ui.BLUEPRINT) + app.register_blueprint(job_ui.BLUEPRINT) + app.register_blueprint(user_ui.BLUEPRINT) # Shove our middleware in there app.wsgi_app = custom_ctx(app, app.wsgi_app) @@ -106,6 +105,8 @@ def make_app(): @click.option("--trace/--no-trace", "trace", default=False) @click.option("--config", type=Path) def serve(hostname: str, port: int, config: Path, trace: bool): + logging.addLevelName(logging.DEBUG - 5, "TRACE") + logging.TRACE = logging.DEBUG - 5 logging.basicConfig( format="%(asctime)s %(threadName)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO, @@ -113,7 +114,7 @@ def serve(hostname: str, port: int, config: Path, trace: bool): logging.getLogger("tentacles").setLevel(logging.DEBUG) if trace: - logging.getLogger("tentacles.db").setLevel(logging.DEBUG - 1) + logging.getLogger("tentacles.db").setLevel(logging.TRACE) app = make_app() diff --git a/projects/tentacles/src/python/tentacles/blueprints/admin_ui.py b/projects/tentacles/src/python/tentacles/blueprints/admin_ui.py new file mode 100644 index 0000000..2a2727f --- /dev/null +++ b/projects/tentacles/src/python/tentacles/blueprints/admin_ui.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +import logging + +from .api import requires_admin + +from flask import ( + Blueprint, + flash, + redirect, + render_template, + request, +) +from tentacles.globals import ctx + + +log = logging.getLogger(__name__) +BLUEPRINT = Blueprint("admin", __name__) + + +@BLUEPRINT.route("/admin", methods=["GET"]) +@requires_admin +def list_files(): + if request.method == "POST": + flash("Not supported yet", category="warning") + + return render_template("admin.html.j2") + + +@BLUEPRINT.route("/admin/users", methods=["POST"]) +@requires_admin +def manipulate_users(): + uid = int(request.form.get("user_id", "-1")) + + match request.form.get("action"): + case "approve": + row = ctx.db.approve_user(uid=uid) + ctx.db.enable_user(uid=uid) + ctx.db.create_email( + uid=uid, + subject="Tentacles account approved!", + body=render_template( + "approval_email.html.j2", + username=row.name, + base_url=request.root_url, + ), + ) + if row: + flash(f"Approved {row.name}", category="success") + + case "enable": + row = ctx.db.enable_user(uid=uid) + if row: + flash(f"Enabled {row.name}", category="success") + + case "disable": + row = ctx.db.disable_user(uid=uid) + if row: + flash(f"Disabled {row.name}", category="success") + + case "passwdchng": + ctx.db.set_user_status(uid=uid, status="passwdchng") + + case _: + print(request.form) + flash("Not supported yet", category="warning") + return render_template("admin.html.j2"), 400 + + return redirect("/admin") + + +@BLUEPRINT.route("/admin/users", methods=["GET"]) +def get_users(): + return redirect("/admin") + + +@BLUEPRINT.route("/admin/printers") +@requires_admin +def printers(): + return render_template("printers.html.j2") + + +@BLUEPRINT.route("/admin/printers/add", methods=["GET"]) +@requires_admin +def add_printer(): + return render_template("add_printer.html.j2") + + +@BLUEPRINT.route("/admin/printers", methods=["POST"]) +@requires_admin +def handle_add_printer(): + try: + assert request.form["name"] + assert request.form["url"] + assert request.form["api_key"] + ctx.db.try_create_printer( + name=request.form["name"], + url=request.form["url"], + api_key=request.form["api_key"], + sid=0, # Disconnected + ) + flash("Printer created") + return redirect("/admin/printers") + + except Exception as e: + log.exception("Failed to create printer") + flash(f"Unable to create printer", category="error") + + return render_template("printers.html.j2") + + +@BLUEPRINT.route("/admin/files", methods=["POST"]) +@requires_admin +def manipulate_files(): + fid = int(request.form.get("file_id", "-1")) + + match request.form.get("action"): + case _: + print(request.form) + flash("Not supported yet", category="warning") + return render_template("admin.html.j2"), 400 + + return redirect("/admin") diff --git a/projects/tentacles/src/python/tentacles/blueprints/file_ui.py b/projects/tentacles/src/python/tentacles/blueprints/file_ui.py index 3fa087a..660e9e8 100644 --- a/projects/tentacles/src/python/tentacles/blueprints/file_ui.py +++ b/projects/tentacles/src/python/tentacles/blueprints/file_ui.py @@ -12,6 +12,7 @@ from flask import ( redirect, render_template, request, + send_file, ) from tentacles.globals import ctx @@ -41,6 +42,15 @@ def manipulate_files(): flash(resp.get("error"), category="error") return render_template("files.html.j2"), code + case "download": + file = ctx.db.fetch_file(uid=ctx.uid, fid=int(request.form.get("file_id"))) + if file: + return send_file( + file.path, as_attachment=True, download_name=file.filename + ) + else: + flash("File not found", category="error") + case "delete": file = ctx.db.fetch_file(uid=ctx.uid, fid=int(request.form.get("file_id"))) if any( diff --git a/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py b/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py deleted file mode 100644 index f005f67..0000000 --- a/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 - -import logging - -from .util import is_logged_in, requires_admin - -from flask import ( - Blueprint, - flash, - redirect, - render_template, - request, -) -from tentacles.globals import ctx - - -log = logging.getLogger(__name__) -BLUEPRINT = Blueprint("printer", __name__) - - -@BLUEPRINT.route("/printers") -@requires_admin -def printers(): - return render_template("printers.html.j2") - - -@BLUEPRINT.route("/printers/add", methods=["get", "post"]) -@requires_admin -def add_printer(): - if not is_logged_in(): - return redirect("/") - - elif request.method == "POST": - try: - assert request.form["name"] - assert request.form["url"] - assert request.form["api_key"] - ctx.db.try_create_printer( - name=request.form["name"], - url=request.form["url"], - api_key=request.form["api_key"], - sid=0, # Disconnected - ) - flash("Printer created") - return redirect("/printers") - - except Exception as e: - log.exception("Failed to create printer") - flash(f"Unable to create printer", category="error") - - return render_template("add_printer.html.j2") - - -@BLUEPRINT.route("/printers/delete") -@requires_admin -def delete_printer(): - return render_template("delete_printer.html.j2") diff --git a/projects/tentacles/src/python/tentacles/blueprints/user_ui.py b/projects/tentacles/src/python/tentacles/blueprints/user_ui.py index 2a7ffb3..f2fcd6e 100644 --- a/projects/tentacles/src/python/tentacles/blueprints/user_ui.py +++ b/projects/tentacles/src/python/tentacles/blueprints/user_ui.py @@ -120,6 +120,18 @@ def post_register(): return render_template("register.html.j2") +@BLUEPRINT.route("/user/verify", methods=["GET", "POST"]) +def verify(): + token = request.args.get("token", "") + row = ctx.db.try_verify_user(token=token) + if row: + flash( + f"Thanks for verifying your email {row.name}! You'll receive another email when your account is ready for use.", + category="success", + ) + return redirect("/user/login") + + @BLUEPRINT.route("/user/logout") def logout(): # Invalidate the user's authorization diff --git a/projects/tentacles/src/python/tentacles/db.py b/projects/tentacles/src/python/tentacles/db.py index dbbdbc7..d19ad1e 100644 --- a/projects/tentacles/src/python/tentacles/db.py +++ b/projects/tentacles/src/python/tentacles/db.py @@ -28,7 +28,7 @@ def qfn(name, f): # Force lazy values for convenience if isinstance(res, GeneratorType): res = list(res) - log.log(logging.DEBUG - 1, "%s (%r) -> %r", name, kwargs, res) + log.log(logging.TRACE, "%s (%r) -> %r", name, kwargs, res) return res _helper.__name__ = f.__name__ @@ -158,15 +158,10 @@ class Db(Queries): digest.update(password.encode("utf-8")) res = super().try_login(username=username, hash=digest.hexdigest()) if not res: - return + res = self.fetch_user_status(sid=res.status_id) + raise LoginError(res.name) - uid, status = res - if status > 0: - return self.create_key(uid=uid, name="web session", ttl=ttl) - - else: - _, status = self.fetch_user_status(status) - raise LoginError(status) + return self.create_key(uid=res.id, name="web session", ttl=ttl) def try_create_user( self, *, username: str, email: str, password: str, gid: int = 1, sid: int = -3 diff --git a/projects/tentacles/src/python/tentacles/schema.sql b/projects/tentacles/src/python/tentacles/schema.sql index 373aaa3..7c8cfc0 100644 --- a/projects/tentacles/src/python/tentacles/schema.sql +++ b/projects/tentacles/src/python/tentacles/schema.sql @@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS user_statuses ( , UNIQUE(name) ); +INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-4, 'passwdchng'); INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-3, 'unverified'); INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-2, 'unapproved'); INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-1, 'disabled'); @@ -30,6 +31,7 @@ CREATE TABLE IF NOT EXISTS users ( , email TEXT , hash TEXT , status_id INTEGER + , created_at TEXT DEFAULT (datetime('now')) , verification_token TEXT DEFAULT (lower(hex(randomblob(32)))) , verified_at TEXT , approved_at TEXT @@ -89,16 +91,29 @@ CREATE TABLE IF NOT EXISTS files ( ---------------------------------------------------------------------------------------------------- -- A job is a request for a copy of a file to be run off -- For simplicity, jobs also serve as scheduling records + +CREATE TABLE IF NOT EXISTS job_statuses ( + id INTEGER PRIMARY KEY AUTOINCREMENT + , name TEXT + , UNIQUE(name) +); + +INSERT OR IGNORE INTO job_statuses (id, name) VALUES (2, 'success'); +INSERT OR IGNORE INTO job_statuses (id, name) VALUES (1, 'running'); +INSERT OR IGNORE INTO job_statuses (id, name) VALUES (0, 'queued'); +INSERT OR IGNORE INTO job_statuses (id, name) VALUES (-1, 'cancelled'); +INSERT OR IGNORE INTO job_statuses (id, name) VALUES (-2, 'failed'); + CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT , user_id INTEGER NOT NULL , file_id INTEGER NOT NULL + , printer_id INTEGER , started_at TEXT , cancelled_at TEXT , finished_at TEXT - , state TEXT + , status_id INTEGER DEFAULT (0) , message TEXT - , printer_id INTEGER , FOREIGN KEY(user_id) REFERENCES users(id) , FOREIGN KEY(file_id) REFERENCES files(id) , FOREIGN KEY(printer_id) REFERENCES printer(id) @@ -141,8 +156,7 @@ WHERE -- name: list-users SELECT - id - , email + * FROM users ; @@ -155,21 +169,60 @@ WHERE AND verified_at IS NULL ; --- name: verify-user! +-- name: try-verify-user^ UPDATE users SET verified_at = datetime('now') + , verification_token = lower(hex(randomblob(32))) WHERE - id = :uid + verification_token = :token +RETURNING + id + , name ; - --- name: set-user-status! +-- name: enable-user^ UPDATE users SET - status_id = :sid + enabled_at = datetime('now') WHERE id = :uid +RETURNING + id + , name +; + +-- name: disable-user^ +UPDATE users +SET + enabled_at = NULL +WHERE + id = :uid +RETURNING + id + , name +; + +-- name: approve-user^ +UPDATE users +SET + approved_at = datetime('now') +WHERE + id = :uid +RETURNING + id + , name +; + +-- name: set-user-status^ +UPDATE users +SET + status_id = (SELECT id FROM user_statuses WHERE id = :status OR name = :status) +WHERE + id = :uid +RETURNING + id + , name ; ---------------------------------------------------------------------------------------------------- @@ -207,8 +260,12 @@ SELECT , status_id FROM users WHERE - (name = :username AND hash = :hash) - OR (email = :username AND hash = :hash) + ((name = :username AND hash = :hash) + OR (email = :username AND hash = :hash)) + AND ((verified_at IS NOT NULL + AND approved_at IS NOT NULL + AND enabled_at IS NOT NULL) + OR group_id = 0) LIMIT 1 ; @@ -222,6 +279,17 @@ WHERE user_id = :uid ; +-- name: list-nonweb-keys +SELECT + id + , name + , expiration +FROM user_keys +WHERE + user_id = :uid + AND name NOT LIKE '%web session%' +; + -- name: fetch-key^ SELECT * @@ -232,12 +300,15 @@ WHERE -- name: try-key^ SELECT - id + k.id , user_id -FROM user_keys +FROM user_keys k +INNER JOIN users u + ON k.user_id = u.id WHERE (expiration IS NULL OR unixepoch(expiration) > unixepoch('now')) - AND id = :kid + AND k.id = :kid + AND u.enabled_at IS NOT NULL -- and the user is not disabled! ; -- name: refresh-key @@ -336,7 +407,9 @@ RETURNING -- name: list-files SELECT * -FROM files + , (SELECT COUNT(*) FROM jobs WHERE file_id = f.id AND status_id > 1) AS `print_successes` + , (SELECT COUNT(*) FROM jobs WHERE file_id = f.id AND status_id < 0) AS `print_failures` +FROM files f WHERE user_id = :uid ; @@ -385,6 +458,7 @@ WHERE -- name: list-jobs SELECT * + , (SELECT name FROM job_statuses WHERE id = j.status_id) AS `status` FROM jobs WHERE (:uid IS NULL OR user_id = :uid) @@ -393,6 +467,7 @@ WHERE -- name: list-jobs-by-file SELECT * + , (SELECT name FROM job_statuses WHERE id = j.status_id) AS `status` FROM jobs WHERE file_id = :fid @@ -422,7 +497,8 @@ LIMIT 1 -- name: list-job-history SELECT * -FROM jobs + , (SELECT name FROM job_statuses WHERE id = j.status_id) AS `status` +FROM jobs j WHERE finished_at IS NOT NULL AND (:uid IS NULL OR user_id = :uid) @@ -496,7 +572,7 @@ WHERE UPDATE jobs SET finished_at = datetime('now') - , state = :state + , status_id = (SELECT id FROM job_statuses WHERE name = :state) , message = :message WHERE id = :jid diff --git a/projects/tentacles/src/python/tentacles/templates/admin.html.j2 b/projects/tentacles/src/python/tentacles/templates/admin.html.j2 new file mode 100644 index 0000000..7e893c0 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/admin.html.j2 @@ -0,0 +1,12 @@ +{% extends "base.html.j2" %} +{% block content %} +
+ {% include "users_list.html.j2" %} +
+
+ {% include "files_admin_list.html.j2" %} +
+
+ {% include "printers_list.html.j2" %} +
+{% endblock %} diff --git a/projects/tentacles/src/python/tentacles/templates/approval_email.html.j2 b/projects/tentacles/src/python/tentacles/templates/approval_email.html.j2 new file mode 100644 index 0000000..b3f7979 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/approval_email.html.j2 @@ -0,0 +1,32 @@ + + + + + + + + + + +
+
+

+ Hello {{ username }}, +

+

+ Your account on Tentacles has been approved! You may now log in and start printing! +

+
+
+ + + diff --git a/projects/tentacles/src/python/tentacles/templates/base.html.j2 b/projects/tentacles/src/python/tentacles/templates/base.html.j2 index f5ddd14..4b293a7 100644 --- a/projects/tentacles/src/python/tentacles/templates/base.html.j2 +++ b/projects/tentacles/src/python/tentacles/templates/base.html.j2 @@ -33,7 +33,7 @@
  • Jobs
  • Files
  • {% if ctx.is_admin %} -
  • Printers
  • +
  • Administration
  • {% endif %}
  • Settings
  • Log out
  • diff --git a/projects/tentacles/src/python/tentacles/templates/files_admin_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/files_admin_list.html.j2 new file mode 100644 index 0000000..72dc8fb --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/files_admin_list.html.j2 @@ -0,0 +1,21 @@ +{% import "macros.html.j2" as macros %} +

    Files

    +{% with files = ctx.db.list_files(uid=ctx.uid) %} +{% if files %} +{% for file in files %} +
    +
    + {{ file.filename }} + {{ ctx.db.fetch_user(uid=file.user_id).name }} + {{ file.print_successes }} successes + {{ file.print_failures }} errors +
    +
    + {{ macros.delete_file(file.id, endpoint="/admin/files") }} +
    +
    +{% endfor %} +{% else %} + You don't have any files. Upload something! +{% endif %} +{% endwith %} diff --git a/projects/tentacles/src/python/tentacles/templates/files_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/files_list.html.j2 index daed823..6861143 100644 --- a/projects/tentacles/src/python/tentacles/templates/files_list.html.j2 +++ b/projects/tentacles/src/python/tentacles/templates/files_list.html.j2 @@ -6,9 +6,12 @@
    {{ file.filename }} + {{ file.print_successes }} successes + {{ file.print_failures }} errors
    {{ macros.start_job(file.id) }} + {{ macros.download_file(file.id) }} {{ macros.delete_file(file.id) }}
    diff --git a/projects/tentacles/src/python/tentacles/templates/macros.html.j2 b/projects/tentacles/src/python/tentacles/templates/macros.html.j2 index 0b6794a..8d238f8 100644 --- a/projects/tentacles/src/python/tentacles/templates/macros.html.j2 +++ b/projects/tentacles/src/python/tentacles/templates/macros.html.j2 @@ -36,15 +36,58 @@ {{ 'queued' if (not job.finished_at and not job.printer_id and not job.cancelled_at) else 'running' if (not job.finished_at and job.printer_id and not job.cancelled_at) else 'cancelling' if (not job.finished_at and job.cancelled_at) else - job.state }} + job.status }} {% endmacro %} {# #################################################################################################### #} {# File CRUD #} -{% macro delete_file(id) %} -
    +{% macro delete_file(id, endpoint="/files") %} +
    {% endmacro %} + +{% macro download_file(id, endpoint="/files") %} +
    + + + +
    +{% endmacro %} + +{# #################################################################################################### #} +{# User CRUD #} + +{% macro approve_user(id) %} +
    + + + +
    +{% endmacro %} + +{% macro enable_user(id) %} +
    + + + +
    +{% endmacro %} + +{% macro lock_user(id) %} +
    + + + +
    +{% endmacro %} + +{% macro passwdchng_user(id) %} +
    + + + +
    +{% endmacro %} diff --git a/projects/tentacles/src/python/tentacles/templates/user.html.j2 b/projects/tentacles/src/python/tentacles/templates/user.html.j2 index 709dbce..8f7aab3 100644 --- a/projects/tentacles/src/python/tentacles/templates/user.html.j2 +++ b/projects/tentacles/src/python/tentacles/templates/user.html.j2 @@ -3,14 +3,14 @@

    User settings

    API keys

    - {% with keys = ctx.db.list_keys(ctx.uid) %} + {% with keys = ctx.db.list_nonweb_keys(ctx.uid) %} {% if keys %} {% for id, name, exp in keys %}
    {{ name or 'anonymous' }} {{ id[:10] }}... - {{ 'Expires in ' if exp else ''}}{{ exp - datetime.now() if exp else 'Never expires' }} + {{ 'Expires in ' if exp else ''}}{{ datetime.fromisoformat(exp) - datetime.now() if exp else 'Never expires' }}
    diff --git a/projects/tentacles/src/python/tentacles/templates/users_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/users_list.html.j2 new file mode 100644 index 0000000..644b345 --- /dev/null +++ b/projects/tentacles/src/python/tentacles/templates/users_list.html.j2 @@ -0,0 +1,23 @@ +{% import "macros.html.j2" as macros %} +

    Users

    +{% with users = ctx.db.list_users(uid=ctx.uid) %} +{% for user in users %} +
    +
    + {{ user.name }} + {{ user.email }} + {{ user.group }} + {{ user.status }} +
    +
    + {% if not user.approved_at %}{{ macros.approve_user(user.id) }}{% endif %} + {% if not user.enabled_at %} + {{ macros.enable_user(user.id) }} + {% else %} + {{ macros.lock_user(user.id) }} + {% endif %} + {{ macros.passwdchng_user(user.id) }} +
    +
    +{% endfor %} +{% endwith %} diff --git a/projects/tentacles/src/python/tentacles/workers.py b/projects/tentacles/src/python/tentacles/workers.py index cdf0da4..13344e9 100644 --- a/projects/tentacles/src/python/tentacles/workers.py +++ b/projects/tentacles/src/python/tentacles/workers.py @@ -10,7 +10,6 @@ Mostly related to monitoring and managing Printer state. from contextlib import closing import logging from pathlib import Path -from threading import Event from typing import Callable from urllib import parse as urlparse @@ -42,8 +41,6 @@ class OctoRest(_OR): log = logging.getLogger(__name__) -SHUTDOWN = Event() - def poll_printers(app: App, db: Db) -> None: """Poll printers for their status.""" @@ -56,13 +53,14 @@ def poll_printers(app: App, db: Db) -> None: log.info(f"Printer {printer.id} {printer.status} -> {status}") db.update_printer_status(pid=printer.id, status=status) + printer_job = {} try: client = OctoRest(url=printer.url, apikey=printer.api_key) - printer_job = client.job_info() + printer_job: dict = client.job_info() try: - printer_state = client.printer().get("state").get("flags", {}) + printer_state: dict = client.printer().get("state").get("flags", {}) except HTTPError: - printer_state = {"disconnected": True} + printer_state: dict = {"disconnected": True} if printer_state.get("error"): # If there's a mapped job, we manually fail it so that @@ -251,19 +249,7 @@ def send_emails(app, db: Db): subject=message.subject, msg=message.body, ) - db.send_email(message.id) - - -def once(f): - val = uninitialized = object() - - def _helper(*args, **kwargs): - nonlocal val - if val is uninitialized: - val = f(*args, **kwargs) - return val - - return _helper + db.send_email(eid=message.id) def toil(*fs):