From 6afab2a15ca605855e1eeedd5ff20f51be14ecd3 Mon Sep 17 00:00:00 2001 From: evilchili Date: Wed, 22 Oct 2025 19:20:47 -0700 Subject: [PATCH] refactor page loader --- src/ttfrog/app.py | 108 +++++++++++- src/ttfrog/exceptions.py | 18 ++ src/ttfrog/forms.py | 17 +- .../themes/default/static/editor/editor.js | 31 ++++ src/ttfrog/web.py | 166 ++++++++---------- 5 files changed, 239 insertions(+), 101 deletions(-) diff --git a/src/ttfrog/app.py b/src/ttfrog/app.py index ff967c6..0b7fbaf 100644 --- a/src/ttfrog/app.py +++ b/src/ttfrog/app.py @@ -11,7 +11,12 @@ from tinydb import where from tinydb.storages import MemoryStorage from ttfrog import schema -from ttfrog.exceptions import ApplicationNotInitializedError +from ttfrog.exceptions import ( + ApplicationNotInitializedError, + MalformedRequestError, + RecordNotFoundError, + UnauthorizedError, +) class ApplicationContext: @@ -46,6 +51,7 @@ ADMIN_EMAIL=admin@telisar THEME=default VIEW_URI=/ +API_URI=/_/v1/ """ @@ -108,6 +114,8 @@ VIEW_URI=/ Session(self.web) + self.log = self.web.logger + self._initialized = True def check_state(self) -> None: @@ -119,16 +127,16 @@ VIEW_URI=/ Returns the User record matching the given username and password """ if not (username and password): - self.web.logger.debug("Need both username and password to login") + self.log.debug("Need both username and password to login") return None user = self.db.User.get(where("name") == username) if not user: - self.web.logger.debug(f"No user matching {username}") + self.log.debug(f"No user matching {username}") return None if not user.check_credentials(username, password): - self.web.logger.debug(f"Invalid credentials for {username}") + self.log.debug(f"Invalid credentials for {username}") return None return user @@ -136,5 +144,97 @@ VIEW_URI=/ def authorize(self, user, record, requested): return user.has_permission(record, requested) + def _get_or_create_page_by_uri(self, user, table, uri): + """ + Get a page by URI. If it doesn't exist, create a new one if and only if the user has permission + to write on its parent. + """ + uri = uri.replace(" ", "").strip("/") + if uri.startswith(self.config.VIEW_URI): + uri = uri.replace(self.config.VIEW_URI, "", 1) + + parent_uri = '' + search_uri = '/' + page_name = '/' + + if "/" in uri: + (parent_uri, page_name) = uri.rsplit("/", 1) + if parent_uri == 'Page': + parent_uri = '/' + search_uri = page_name + else: + search_uri = uri + elif uri: + parent_uri = "/" + search_uri = uri + page_name = uri + + self.log.debug(f"Searching for page in {table = } with {search_uri = }; its parent is {parent_uri=}") + # self.log.debug("\n".join([f"{p.doc_id}: {p.uri}" for p in table.all()])) + page = table.get(where("uri") == search_uri, recurse=False) + if not page: + + # load the parent to check for write permissions + self.log.debug(f"Page at {search_uri} does not exist, looking for parent at {parent_uri=}") + parent_table = table if "/" in parent_uri else self.db.Page + parent = None + try: + self.log.debug(f"Loading parent with {parent_uri}") + parent = self.get_page(user, parent_table.name, uri=parent_uri) + except Exception as e: + self.log.debug(f"Error loading parent: {e}") + + if not parent: + raise MalformedRequestError("Page does not exist and neither does its parent.") + if not self.authorize(user, parent, schema.Permissions.WRITE): + raise UnauthorizedError(f"User {user.doc_id} does not have permission to create under {parent_uri}.") + page = getattr(schema, table.name)( + name=page_name, body=f"# {page_name}\nThis page does not exist", parent=parent + ) + self.log.debug(f"Returning {page.doc_id}: {page.uri}") + return page + + def get_page(self, user, table_name, doc_id=None, uri=None): + """ + Get a page by doc_id or by URI, if and only if the user is allowed to read it. A new Record + instance will be returned if the requested page does not exist but the user has permission + to create it. + """ + + if not user.doc_id: + self.log.error(f"Invalid user: {user}") + raise MalformedRequestError("User does not exist.") + + try: + table = self.db.table(table_name) + except RuntimeError: + table = self.db.Page + self.log.error(f"Invalid table_name: {table_name}, will use Page") + # raise MalformedRequestError(f"{table_name} table does not exist.") + + if doc_id: + page = table.get(doc_id=doc_id) + if not page: + raise RecordNotFoundError(f"No record with {doc_id=} was found.") + elif uri: + page = self._get_or_create_page_by_uri(user, table, uri) + else: + self.log.error("No doc_id or uri.") + raise MalformedRequestError("Either a doc_id or a uri must be specified.") + + if not self.authorize(user, page, schema.Permissions.READ): + self.log.error(f"No permission for {user.name} on {page}") + raise UnauthorizedError(f"User {user.doc_id} does not have permission to read {table_name} {page.doc_id}.") + + # resolve the pointers to subpages so we can render things like nav elements. + if hasattr(page, "members"): + subpages = [] + for pointer in page.members: + table, pkey, pval = pointer.split("::") + subpages += self.db.table(table).search(where(pkey) == pval, recurse=False) + page.members = subpages + + return page + sys.modules[__name__] = ApplicationContext() diff --git a/src/ttfrog/exceptions.py b/src/ttfrog/exceptions.py index 5a07048..33c7c29 100644 --- a/src/ttfrog/exceptions.py +++ b/src/ttfrog/exceptions.py @@ -3,3 +3,21 @@ class ApplicationNotInitializedError(Exception): Thrown when attempting to access methods on the ApplicationContext before it has been initialized. """ + + +class MalformedRequestError(Exception): + """ + Thrown when a request cannnot be completed due to bad arguments. + """ + + +class RecordNotFoundError(Exception): + """ + Thrown when the specified record could not be loaded by doc_id. + """ + + +class UnauthorizedError(Exception): + """ + Thrown when a user does not have permissino to do the requested action. + """ diff --git a/src/ttfrog/forms.py b/src/ttfrog/forms.py index 1dd8a29..62e492a 100644 --- a/src/ttfrog/forms.py +++ b/src/ttfrog/forms.py @@ -2,11 +2,11 @@ from dataclasses import dataclass, field from functools import cached_property from flask import g -from grung.types import BackReference, Collection, Pointer, Record +from grung.types import BackReference, Collection, Pointer, Record, Timestamp from ttfrog import schema -READ_ONLY_FIELD_TYPES = [Collection, Pointer, BackReference] +READ_ONLY_FIELD_TYPES = [Collection, Pointer, BackReference, Timestamp] @dataclass @@ -22,7 +22,7 @@ class Form: def read_only(self) -> set: return [ name for (name, attr) in self.record._metadata.fields.items() if type(attr) in READ_ONLY_FIELD_TYPES - ] + ["uid"] + ] + ["uid", "acl"] def prepare(self): for key, value in self.data.items(): @@ -43,9 +43,14 @@ class Page(Form): record: schema.Page - @cached_property - def read_only(self) -> set: - return set(list(super().read_only) + ["stub"]) + +@dataclass +class Wiki(Form): + """ + A form for creating and updating Wiki records. + """ + + record: schema.Page @dataclass diff --git a/src/ttfrog/themes/default/static/editor/editor.js b/src/ttfrog/themes/default/static/editor/editor.js index 14e2dfe..21b3d1f 100644 --- a/src/ttfrog/themes/default/static/editor/editor.js +++ b/src/ttfrog/themes/default/static/editor/editor.js @@ -6,6 +6,27 @@ var pageContent = null; var saveButton = null; var editorUI = null; +APIv1 = { + put: function(data, callback) { + (async () => { + const raw = await fetch('/_/v1/put/' + window.location.pathname, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + 'body': data + }), + }); + const res = await raw.json(); + if (res['code'] != 200) { + console.error("APIv1 error: ", res) + } + callback(res); + })(); + }, +}; isReadOnly = function() { if (editor) { @@ -53,7 +74,17 @@ makeSaveButton = function() { button.className = 'actions'; button.innerHTML = 'save'; button.id = 'saveButton'; + button.style.border = "1px solid black"; button.addEventListener('click', () => { + APIv1.put({ + 'body': editorUI.getMarkdown() + }, (res) => { + if (res['code'] == 200) { + button.style.border = "1px solid green"; + } else { + button.style.border = "1px solid red"; + } + }); }); saveButton = button; return button; diff --git a/src/ttfrog/web.py b/src/ttfrog/web.py index f16698f..9f3a32c 100644 --- a/src/ttfrog/web.py +++ b/src/ttfrog/web.py @@ -1,67 +1,49 @@ -from flask import Response, g, redirect, render_template, request, session, url_for -from tinydb import where +import json + +from flask import Response, g, jsonify, redirect, render_template, request, session, url_for from ttfrog import app, forms, schema +from ttfrog.exceptions import MalformedRequestError, RecordNotFoundError, UnauthorizedError -def relative_uri(path: str = ""): - """ - The request's URI relative to the VIEW_URI without the leading '/'. - """ - - return (path or request.path).replace(app.config.VIEW_URI, "", 1).strip("/") or "/" - - -def get_parent(table: str, uri: str): - try: - parent_uri = uri.strip("/").rsplit("/", 1)[0] - except IndexError: - return None - - return get_page(parent_uri, table=table if "/" in parent_uri else "Page", create_okay=False) - - -def get_page(path: str, table: str = "Page", create_okay: bool = False): +def get_page( + path: str, table: str = "Page", doc_id: int = None, create_okay: bool = False +) -> (schema.Record | None, Exception | None): """ Get one page, including its members, but not recursively. """ - uri = relative_uri(path) - - if table not in app.db.tables(): - app.web.logger.debug(f"Table {table} does not exist in {app.db.tables()}.") - return None - - page = app.db.table(table).get(where("uri") == uri, recurse=False) - - if not page: - app.web.logger.debug("Page does not exist.") + try: + page = app.get_page(g.user, table, doc_id=doc_id, uri=path) + except (UnauthorizedError, MalformedRequestError) as e: + return None, e + except RecordNotFoundError as e: if not create_okay: - app.web.logger.debug("Page does not exist and creating is not okay.") - return None - parent = get_parent(table, uri) - if not app.authorize(g.user, parent, schema.Permissions.WRITE): - app.web.logger.debug(f"User {g.user} is not authorized to write {parent}") - return None - return getattr(schema, table)(name=uri.split("/")[-1], body="This page does not exist", parent=parent) + return None, e + return page, None - if not app.authorize(g.user, page, schema.Permissions.READ): - return None - if hasattr(page, "members"): - subpages = [] - for pointer in page.members: - table, pkey, pval = pointer.split("::") - subpages += app.db.table(table).search(where(pkey) == pval, recurse=False) - page.members = subpages +def api_response(response={}, messages=[], error=None): + response_code = 200 - return page + if error: + response_code = 500 + response = {} + if isinstance(error, UnauthorizedError): + response_code = 403 + elif isinstance(error, MalformedRequestError): + response_code = 4000 + elif isinstance(error, RecordNotFoundError): + response_code = 404 + + return jsonify({"messages": messages, "response": response, "code": response_code}), response_code def rendered(page: schema.Record, template: str = "page.html"): if not page: return Response("Page not found", status=404) - return render_template(template, page=page, app=app, breadcrumbs=breadcrumbs(), root=g.root, user=g.user, g=g) + root = page if page.uri == app.config.VIEW_URI else get_page(app.config.VIEW_URI)[0] + return render_template(template, page=page, app=app, breadcrumbs=breadcrumbs(), root=root, user=g.user, g=g) def breadcrumbs(): @@ -69,14 +51,36 @@ def breadcrumbs(): Return (uri, name) pairs for the parents leading from the VIEW_URI to the current request. """ uri = "" - for name in relative_uri().split("/"): + names = (request.path.replace(app.config.VIEW_URI, "", 1).strip("/") or "/").split("/") + for name in names: uri = "/".join([uri, name]) yield (uri, name) +@app.web.before_request +def before_request(): + g.messages = [] + if not request.path.startswith("/static"): + user_id = session.get("user_id", 1) + g.user = app.db.User.get(doc_id=user_id) + session["user_id"] = user_id + session["user"] = dict(g.user.serialize()) + + +@app.web.after_request +def add_header(r): + r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, public, max-age=0" + r.headers["Pragma"] = "no-cache" + r.headers["Expires"] = "0" + return r + + @app.web.route(app.config.VIEW_URI) def index(): - return rendered(get_page(app.config.VIEW_URI, create_okay=False)) + page, error = get_page(app.config.VIEW_URI) + if error: + g.messages.append(str(error)) + return rendered(page) @app.web.route("/login", methods=["GET", "POST"]) @@ -92,7 +96,7 @@ def login(): session["user"] = dict(g.user.serialize()) return redirect(url_for("index")) g.messages.append(f"Invalid login for {username}") - return rendered(schema.Page(name="Login", title="Please enter your login details"), "login.html") + return rendered(schema.Page(name="Login"), "login.html") @app.web.route("/logout") @@ -106,51 +110,31 @@ def logout(): @app.web.route(f"{app.config.VIEW_URI}//", methods=["GET"]) @app.web.route(f"{app.config.VIEW_URI}/", methods=["GET"], defaults={"table": "Page"}) def view(table, path): - parent = get_parent(table, relative_uri()) - if table not in app.db.tables(): - table = parent.__class__.__name__ if parent else "Page" - page = get_page(request.path, table=table, create_okay=(parent and parent.doc_id is not None)) + page, error = get_page(request.path, table=table, create_okay=True) + if error: + g.messages.append(str(error)) return rendered(page) -@app.web.route(f"{app.config.VIEW_URI}//", methods=["POST"]) -@app.web.route(f"{app.config.VIEW_URI}/", methods=["POST"], defaults={"table": "Page"}) -def edit(table, path): - uri = relative_uri() - parent = get_parent(table, uri) - if not parent: - return Response("You cannot create a page at this location.", status=403) +@app.web.route(f"{app.config.API_URI}/put//", methods=["POST"]) +@app.web.route(f"{app.config.API_URI}/put/", methods=["POST"], defaults={"table": "Page"}) +def put(table, path): + app.log.debug(f"Checking for page at {table}/{path} in {table} space") + page, error = get_page("/".join([table, path]), table=table, create_okay=True) + app.log.debug(f"Found {page.doc_id}") + if error: + return api_response(error=error) - # get or create the docoument at this uri - page = get_page(uri, table=table, create_okay=True) - if not app.authorize(g.user, page, schema.Permissions.WRITE): - return Response("Permission denied.", status=403) - save_data = getattr(forms, table)(page, request.form).prepare() + params = json.loads(request.data.decode())["body"] + save_data = getattr(forms, table)(page, params).prepare() - # editing existing document - if page.doc_id: - if page.uid != request.form["uid"]: - return Response("Invalid UID.", status=403) - return rendered(app.db.save(save_data)) - - # saving a new document - return rendered(parent.add_member(save_data)) + doc = app.db.save(save_data) if page.doc_id else page.parent.add_member(save_data) + app.log.debug(f"Saved: {dict(doc)=}") + return api_response(response=dict(doc)) -@app.web.before_request -def before_request(): - g.messages = [] - if not request.path.startswith("/static"): - user_id = session.get("user_id", 1) - g.user = app.db.User.get(doc_id=user_id) - session["user_id"] = user_id - session["user"] = dict(g.user.serialize()) - g.root = get_page(app.config.VIEW_URI) - - -@app.web.after_request -def add_header(r): - r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, public, max-age=0" - r.headers["Pragma"] = "no-cache" - r.headers["Expires"] = "0" - return r +@app.web.route(f"{app.config.API_URI}/get//", methods=["GET"]) +def get(table, doc_id): + app.log.debug(f"API: getting {table}({doc_id})") + page, error = get_page(g.user, table=table, doc_id=doc_id) + return api_response(response=dict(page), error=error)