Register/Login/Logout somewhat working

This commit is contained in:
Reid 'arrdem' McKenzie 2023-05-19 00:52:07 -06:00
parent 63b2c9042b
commit 5a284aa980
22 changed files with 581 additions and 109 deletions

View file

@ -7,5 +7,8 @@ py_project(
py_requirement("click"), py_requirement("click"),
py_requirement("flask"), py_requirement("flask"),
py_requirement("jinja2"), py_requirement("jinja2"),
] ],
main_data = [
"//projects/tentacles/src/python/tentacles/static/css",
],
) )

View file

@ -6,7 +6,7 @@ A simple queue system for OctoPrint, designed to receive jobs and forward them t
Username+Password users Username+Password users
API keys mapped to users API keys mapped to users (same thing as sessions actually)
Users mapped to job priority Users mapped to job priority
@ -34,7 +34,8 @@ Checking bed status on a printer
-X POST \ -X POST \
-H 'Accept: application/json' \ -H 'Accept: application/json' \
-H 'Content-Type: application/json; charset=UTF-8' \ -H 'Content-Type: application/json; charset=UTF-8' \
-H 'Authorization: Bearer ...' \ -H 'Authorization: Bearer ...No creds
' \
--data-raw '{"command":"check_bed","reference":"reference_2023-05-11T05:22:40.212Z.jpg"}' \ --data-raw '{"command":"check_bed","reference":"reference_2023-05-11T05:22:40.212Z.jpg"}' \
| jq . | jq .

View file

@ -0,0 +1,2 @@
[db]
uri = "tentacles.sqlite3"

View file

@ -4,8 +4,13 @@
""" """
from pathlib import Path
import click import click
import flask from flask import Flask, request, session, current_app
import tomllib
from tentacles.blueprints import ui, api
from tentacles.store import Store
@click.group() @click.group()
@ -13,11 +18,49 @@ def cli():
pass pass
def open_db():
current_app.db = Store(current_app.config.get("db", {}).get("uri"))
current_app.db.connect()
def commit_db(resp):
current_app.db.commit()
return resp
def user_session():
if (session_id := request.cookies.get("sid", "")) and (
uid := current_app.db.try_key(session_id)
):
request.sid = session_id
request.uid = uid
_, gid, name, _ = current_app.db.fetch_user(uid)
request.gid = gid
request.username = name
else:
request.sid = request.uid = request.gid = request.username = None
@cli.command() @cli.command()
def serve(): @click.option("--hostname", "hostname", type=str, default="0.0.0.0")
app = flask.Flask() @click.option("--port", "port", type=int, default=8080)
app.register_blueprint() @click.option("--config", type=Path)
app.run() def serve(hostname: str, port: int, config: Path):
app = Flask(__name__)
if config:
with open(config, "rb") as fp:
app.config.update(tomllib.load(fp))
print(app.config)
app.before_first_request(open_db)
app.before_request(user_session)
app.after_request(commit_db)
app.register_blueprint(ui.BLUEPRINT)
app.register_blueprint(api.BLUEPRINT)
app.run(host=hostname, port=port)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,7 +0,0 @@
#!/usr/bin/env python3
"""Blueprints for the Tentacles app."""
from flask import Blueprint
BLUEPRINT = Blueprint(__name__, __qualname__, template_folder=__package__)

View file

@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""API endpoints supporting the 'ui'."""
from importlib.resources import files
from sys import meta_path, prefix
from flask import Blueprint, request, Request, redirect
BLUEPRINT = Blueprint("api", __name__, url_prefix="/api")
####################################################################################################
# Printers
#
# The trick here is handling multipart uploads.
@BLUEPRINT.route("/printer", methods=["POST"])
def create_printer():
pass
@BLUEPRINT.route("/printer", methods=["GET"])
def list_printers():
pass
@BLUEPRINT.route("/printer", methods=["DELETE"])
def delete_printer():
pass
####################################################################################################
# Files
#
# The trick here is handling multipart uploads.
@BLUEPRINT.route("/file", methods=["POST"])
def create_file():
pass
@BLUEPRINT.route("/file", methods=["GET"])
def get_files():
pass
@BLUEPRINT.route("/file", methods=["DELETE"])
def delete_file():
pass
####################################################################################################
# Jobs
@BLUEPRINT.route("/job", methods=["POST"])
def create_job():
pass
@BLUEPRINT.route("/job", methods=["GET"])
def get_jobs():
pass
@BLUEPRINT.route("/job", methods=["DELETE"])
def delete_job():
pass
####################################################################################################
# API tokens
@BLUEPRINT.route("/token", methods=["GET"])
def get_tokens():
pass

View file

@ -0,0 +1,86 @@
#!/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,
)
BLUEPRINT = Blueprint("ui", __name__)
def is_logged_in(authorization):
return False
@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.authorization):
return redirect("/")
elif request.method == "POST":
if sid := current_app.db.try_login(
request.form["username"], request.form["password"], timedelta(days=1)
):
resp = redirect("/")
resp.set_cookie("sid", sid)
return resp
else:
return render_template("login.html.j2", error="Incorrect username/password")
else:
return render_template("login.html.j2")
@BLUEPRINT.route("/register", methods=["GET", "POST"])
def register():
if is_logged_in(request.authorization):
return redirect("/")
elif request.method == "POST":
try:
if uid := current_app.db.try_create_user(
request.form["username"], request.form["password"]
):
return render_template(
"login.html.j2", message="Success, please log in"
)
except:
pass
return render_template(
"login.html.j2", error="Unable to register that username"
)
else:
return render_template("login.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,76 @@
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT
, name TEXT
, priority INTEGER CHECK(priority IS NOT NULL AND priority > 0)
);
INSERT OR IGNORE INTO groups (id, name, priority) VALUES (0, 'root', 20);
INSERT OR IGNORE INTO groups (id, name, priority) VALUES (0, 'users', 10);
INSERT OR IGNORE INTO groups (id, name, priority) VALUES (0, 'guests', 0);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT
, group_id INTEGER
, name TEXT
, hash TEXT
, FOREIGN KEY(group_id) REFERENCES groups(id)
, UNIQUE(name)
);
----------------------------------------------------------------------------------------------------
-- Keys represent API keys and auth sessions
CREATE TABLE IF NOT EXISTS user_keys (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(48))))
, user_id INTEGER
, expiration TEXT
);
----------------------------------------------------------------------------------------------------
-- Printers represent physical devices (really octoprint API targets) that jobs can go to
CREATE TABLE IF NOT EXISTs printer_statuses (
id INTEGER PRIMARY KEY AUTOINCREMENT
, name TEXT
, UNIQUE(name)
);
INSERT OR IGNORE INTO printer_statuses (id, name) values (-1, 'error');
INSERT OR IGNORE INTO printer_statuses (id, name) values (0, 'disconnected');
INSERT OR IGNORE INTO printer_statuses (id, name) values (1, 'connected');
INSERT OR IGNORE INTO printer_statuses (id, name) values (2, 'idle');
INSERT OR IGNORE INTO printer_statuses (id, name) values (3, 'running');
CREATE TABLE IF NOT EXISTS printers (
id INTEGER PRIMARY KEY AUTOINCREMENT
, url TEXT
, api_key TEXT
, status_id INTEGER
, last_poll_date TEXT
, FOREIGN KEY(status_id) REFERENCES printer_statuses(id)
);
----------------------------------------------------------------------------------------------------
-- Files are printables
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT
, user_id INTEGER
, filename TEXT
, upload_date TEXT
, FOREIGN KEY(user_id) REFERENCES user(id)
);
----------------------------------------------------------------------------------------------------
-- 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 jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT
, user_id INTEGER NOT NULL
, file_id INTEGER NOT NULL
, priority INTEGER CHECK(priority IS NOT NULL AND 0 <= priority)
, started_at TEXT
, finished_at TEXT
, state 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)
);

View file

@ -0,0 +1 @@
#!/usr/bin/env python3

View file

@ -0,0 +1,20 @@
sass_library(
name = "lib",
srcs = glob(["**/_*.scss"]),
)
sass_binary(
name = "style",
src = "style.scss",
deps = [
":lib"
],
)
filegroup(
name = "css",
srcs = [
":style",
],
visibility = ["//projects/tentacles:__pkg__"],
)

View file

@ -0,0 +1,9 @@
$black: #171426;
$beige: #F4F8EE;
$red: #BB2D2E;
$orange: #CA4F1F;
$yellow: #EDB822;
$secondary_blue: #288BC2;
$secondary_green: #A5C426;
$secondary_light_grey: #CACBCA;
$secondary_dark_grey: #9A9A9A;

View file

@ -0,0 +1,89 @@
// @use 'tirefire' as tfi;
$black: #171426;
$beige: #F4F8EE;
$red: #BB2D2E;
$orange: #CA4F1F;
$yellow: #EDB822;
$secondary_blue: #288BC2;
$secondary_green: #A5C426;
$secondary_light_grey: #CACBCA;
$secondary_dark_grey: #9A9A9A;
@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');
}
@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');
}
@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');
}
@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');
}
html {
font-family: 'Aaux Next', sans-serif;
background-color: $beige;
color: $black;
display: flex;
}
body {
width: 100vw;
}
*, *::before, *::after {
box-sizing: inherit;
margin: 0;
padding: 0;
}
nav {
width: 100%;
border-bottom: $orange 10px solid;
padding-left: 30px;
padding-right: 10px;
}
.logo {
display: inline-block;
}
.nav-links {
list-style: none;
}
.nav-item a {
display: inline-block;
padding: 10px 15px;
text-decoration: none;
color: $secondary_green;
}
.nav-item:hover {
background-color: white;
}
.nav-item:hover a {
color: $secondary_blue;
}
.logo img {
width: 175px;
vertical-align: middle;
}

View file

@ -5,80 +5,13 @@ from hashlib import sha3_256
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 collections import namedtuple
from typing import Optional from typing import Optional
PRELUDE = """ with files(__package__).joinpath("schema.sql").open("r") as fp:
CREATE TABLE IF NOT EXISTS groups ( PRELUDE = fp.read()
id INTEGER PRIMARY KEY AUTOINCREMENT
, name TEXT
, priority INTEGER CHECK(priority IS NOT NULL AND priority > 0)
DEFAULT 100
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT
, group_id INTEGER
, name TEXT
, hash TEXT
, FOREIGN KEY(group_id) REFERENCES groups(id)
, UNIQUE(name)
);
CREATE TABLE IF NOT EXISTS user_keys (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(48))))
, user_id INTEGER
, expiration TEXT
);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT
, user_id INTEGER
, filename TEXT
, upload_date TEXT
, FOREIGN KEY(user_id) REFERENCES user(id)
);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT
, user_id INTEGER
, FOREIGN KEY(user_id) REFERENCES user(id)
);
CREATE TABLE IF NOT EXISTs printer_statuses (
id INTEGER PRIMARY KEY AUTOINCREMENT
, name TEXT
, UNIQUE(name)
);
INSERT OR IGNORE INTO printer_statuses (name) values ('disconnected');
INSERT OR IGNORE INTO printer_statuses (name) values ('connected');
INSERT OR IGNORE INTO printer_statuses (name) values ('idle');
INSERT OR IGNORE INTO printer_statuses (name) values ('running');
INSERT OR IGNORE INTO printer_statuses (name) values ('error');
CREATE TABLE IF NOT EXISTS printers (
id INTEGER PRIMARY KEY AUTOINCREMENT
, url TEXT
, api_key TEXT
, status_id INTEGER
, last_poll_date TEXT
, FOREIGN KEY(status_id) REFERENCES printer_statuses(id)
);
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT
, job_id INTEGER
, started_at TEXT
, finished_at TEXT
, state TEXT
, printer_id INTEGER
, FOREIGN KEY(job_id) REFERENCES job(id)
, FOREIGN KEY(printer_id) REFERENCES printer(id)
);
"""
def requires_conn(f): def requires_conn(f):
@ -90,6 +23,28 @@ def requires_conn(f):
return _helper return _helper
def fmap(ctor):
"""Fmap a constructor over the records returned from a query function."""
def _h1(f):
def _h2(*args, **kwargs):
res = f(*args, **kwargs)
if isinstance(res, list):
return [ctor(*it) for it in res]
elif isinstance(res, tuple):
return ctor(*res)
return _h2
return _h1
def one(it, *args, **kwargs):
assert not args
assert not kwargs
return it
class Store(object): class Store(object):
def __init__(self, path): def __init__(self, path):
self._path = path self._path = path
@ -106,12 +61,17 @@ class Store(object):
f"Unable to execute startup script:\n{indent(hunk, ' > ')}" f"Unable to execute startup script:\n{indent(hunk, ' > ')}"
) from e ) from e
@requires_conn
def commit(self):
self._conn.commit()
def close(self): def close(self):
if self._conn: if self._conn:
self._conn.commit() self.commit()
self._conn.close() self._conn.close()
self._conn = None self._conn = None
@fmap(one)
@requires_conn @requires_conn
def try_create_user(self, username, password): def try_create_user(self, username, password):
"""Attempt to create a new user.""" """Attempt to create a new user."""
@ -121,7 +81,19 @@ class Store(object):
return self._conn.execute( return self._conn.execute(
"INSERT INTO users (name, hash) VALUES (?, ?) RETURNING (id)", "INSERT INTO users (name, hash) VALUES (?, ?) RETURNING (id)",
[username, digest.hexdigest()], [username, digest.hexdigest()],
).fetchone()[0] ).fetchone()
@requires_conn
def fetch_user(self, uid: int):
return self._conn.execute("SELECT * FROM users WHERE id = ?", [uid]).fetchone()
@fmap(one)
@requires_conn
def fetch_user_priority(self, uid: int):
return self._conn.execute(
"SELECT priority FROM groups g INNER JOIN users u ON g.id = u.group_id WHERE u.id = ?",
[uid],
).fetchone()
@requires_conn @requires_conn
def list_users(self): def list_users(self):
@ -130,12 +102,13 @@ class Store(object):
################################################################################ ################################################################################
# Sessions / 'keys' # Sessions / 'keys'
@fmap(one)
@requires_conn @requires_conn
def _create_session(self, uid: int, ttl: Optional[timedelta]): def _create_session(self, uid: int, ttl: Optional[timedelta]):
return self._conn.execute( return self._conn.execute(
"INSERT INTO user_keys (user_id, expiration) VALUES (?, ?) RETURNING (id)", "INSERT INTO user_keys (user_id, expiration) VALUES (?, ?) RETURNING (id)",
[uid, (datetime.now() + ttl).isoformat() if ttl else None], [uid, (datetime.now() + ttl).isoformat() if ttl else None],
).fetchone()[0] ).fetchone()
@requires_conn @requires_conn
def try_login(self, username: str, password: str, ttl: timedelta) -> Optional[str]: def try_login(self, username: str, password: str, ttl: timedelta) -> Optional[str]:
@ -177,16 +150,15 @@ class Store(object):
"SELECT * FROM user_keys WHERE id = ?", [kid] "SELECT * FROM user_keys WHERE id = ?", [kid]
).fetchone() ).fetchone()
@fmap(one)
@requires_conn @requires_conn
def try_key(self, kid: str): def try_key(self, kid: str):
"""Try to find the mapped user for a session.""" """Try to find the mapped user for a session."""
res = self._conn.execute( return self._conn.execute(
"SELECT user_id FROM user_keys WHERE expiration IS NULL OR unixepoch(expiration) > unixepoch('now') and id = ?", "SELECT user_id FROM user_keys WHERE expiration IS NULL OR unixepoch(expiration) > unixepoch('now') and id = ?",
[kid], [kid],
).fetchone() ).fetchone()
if res:
return res[0]
@requires_conn @requires_conn
def refresh_key(self, kid: str, ttl: timedelta): def refresh_key(self, kid: str, ttl: timedelta):
@ -227,39 +199,79 @@ class Store(object):
# Files # Files
# #
# A record of local files on disk, and the users who own then. # A record of local files on disk, and the users who own then.
@fmap(one)
@requires_conn @requires_conn
def create_file(self, uid: int, path: Path): def create_file(self, uid: int, name: str, path: Path) -> int:
pass return self._conn.execute(
"INSERT INTO files (user_id, filename, upload_date) VALUES (?, ?, datetime('now')) RETURNING (id)",
[uid, name],
).fetchone()
@requires_conn @requires_conn
def list_files(self, uid: int): def list_files(self, uid: int):
pass return self._conn.execute(
"SELECT * FROM files WHERE user_id = ?", [uid]
).fetchall()
@requires_conn @requires_conn
def delete_file(self, uid: int, fid: int): def delete_file(self, uid: int, fid: int):
pass self._conn.execute("DELETE FROM files WHERE user_id = ? AND id = ?", [uid, fid])
################################################################################ ################################################################################
# Job # Job
# #
# A request by a user for a given file to be printed. # A request by a user for a given file to be printed.
@fmap(one)
@requires_conn @requires_conn
def create_job(self, uid: int): def create_job(self, uid: int, fid: int, relative_priority: int = 0):
pass """Create a job mapped to a user with a file to print and a priority.
Note that the user may provide a sub-priority within their group queue. This allows users to create jobs with
higher priority than existing jobs as a means of controlling the queue order.
May want a different (eg. more explicit) queue ordering mechanism here. Emulating the Netflix queue behavior of
being able to drag some jobs ahead of others? How to model that?
"""
assert 0 <= relative_priority <= 9
return self._conn.execute(
"INSERT INTO jobs (user_id, file_id, priority) VALUES (?, ?, ?) RETURNING (id)",
[uid, fid, self.fetch_user_priority(uid) + relative_priority],
).fetchone()
@requires_conn @requires_conn
def list_jobs(self, uid: int): def list_jobs(self, uid: Optional[int] = None):
pass """Enumerate jobs in priority order."""
cond = f"AND user_id = {uid}" if uid else ""
return self._conn.execute(
f"SELECT * FROM jobs WHERE started_at IS NULL {cond} ORDER BY priority DESC",
[],
).fetchall()
@requires_conn
def fetch_job(self, uid: int, jid: int) -> Optional[tuple]:
return self._conn.execute(
"SELECT * FROM jobs WHERE user_id = ? AND id = ?", [uid, jid]
).fetchone()
@requires_conn
def alter_job(self, job: tuple):
fields = [
"id",
"user_id",
"file_id",
"priority",
"started_at",
"finished_at",
"state",
"printer_id",
]
assert len(job) == len(fields)
return self._conn.execute(
f"INSERT OR REPLACE INTO jobs ({', '.join(fields)}) VALUES ({', '.join(['?'] * len(fields))})",
job,
)
@requires_conn @requires_conn
def delete_job(self, uid: int, jid: int): def delete_job(self, uid: int, jid: int):
pass self._conn.execute("DELETE FROM jobs WHERE user_id = ? and id = ?", [uid, jid])
################################################################################
# Run
#
# A record tha that a Job has been assigned to a Printer and is running.
# Could perhaps be eliminated in favor of rolling state into the Job.
@requires_conn
def list_runs(self, uid: int):
pass

View file

@ -0,0 +1 @@
#!/usr/bin/env python3

View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% block head %}
<link rel="stylesheet" href="/static/css/style.css" />
<title>Tentacles{% block title %}{% endblock %}</title>
{% endblock %}
</head>
<body>
<nav class="navbar">
<a href="https://freecodecamp.org" class="logo">
<img src="https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg" alt="freeCodeCamp logo">
</a>
<ul class="nav-links">
<li class="nav-item"><a href="#">Curriculum</a></li>
<li class="nav-item"><a href="#">Forum</a></li>
<li class="nav-item"><a href="#">News</a></li>
<li class="nav-item"><a href="#">Sign in</a></li>
</ul>
</nav>
</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div id="flashes">
<ul class=flashes>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endwith %}
<div id="content">
{% block content %}Oops, an empty page :/{% endblock %}
</div>
<div id="footer">
{% block footer %}
&copy; Copyright 2023 by <a href="https://arrdem.com/">@arrdem</a>.
{% endblock %}
</div>
</body>
</html>

View file

@ -0,0 +1,4 @@
{% extends "base.html.j2" %}
{% block content %}
<p>Hello, {% if request.uid %}{{ request.username }}{% else %}world{% endif %}!</p>
{% endblock %}

View file

@ -0,0 +1,9 @@
{% extends "base.html.j2" %}
{% block content %}
<h1>Log in</h1>
<form method="post">
<p>Username: <input type="text" name="username">
<p>Password: <input type="password" name="password">
<p><input type="submit" value=Login>
</form>
{% endblock %}

View file

@ -0,0 +1,9 @@
{% extends "base.html.j2" %}
{% block content %}
<h1>Register</h1>
<form method="post">
<p>Username: <input type="text" name="username">
<p>Password: <input type="password" name="password">
<p><input type="submit" value=Login>
</form>
{% endblock %}